1<?php 2 3namespace dokuwiki\plugin\struct\meta; 4 5/** 6 * Class SchemaBuilder 7 * 8 * This class builds and updates the schema definitions for our tables. This includes CREATEing and ALTERing 9 * the actual data tables as well as updating the meta information in our meta data tables. 10 * 11 * To use, simply instantiate a new object of the Builder and run the build() method on it. 12 * 13 * Note: even though data tables use a data_ prefix in the database, this prefix is internal only and should 14 * never be passed as $table anywhere! 15 * 16 * @package dokuwiki\plugin\struct\meta 17 */ 18class SchemaBuilder { 19 20 /** 21 * @var array The posted new data for the schema 22 * @see Schema::AdminEditor() 23 */ 24 protected $data = array(); 25 26 protected $user; 27 28 /** 29 * @var string The table name associated with the schema 30 */ 31 protected $table = ''; 32 33 /** 34 * @var Schema the previously valid schema for this table 35 */ 36 protected $oldschema; 37 38 /** @var int the ID of the newly created schema */ 39 protected $newschemaid = 0; 40 41 /** @var \helper_plugin_struct_db */ 42 protected $helper; 43 44 /** @var \helper_plugin_sqlite|null */ 45 protected $sqlite; 46 47 /** @var int the time for which this schema should be created - default to time() can be overriden for tests */ 48 protected $time = 0; 49 50 /** 51 * SchemaBuilder constructor. 52 * 53 * @param string $table The table's name 54 * @param array $data The defining of the table (basically what get's posted in the schema editor form) 55 * @see Schema::AdminEditor() 56 */ 57 public function __construct($table, $data) { 58 $this->table = $table; 59 $this->data = $data; 60 $this->oldschema = new Schema($table, 0); 61 62 $this->helper = plugin_load('helper', 'struct_db'); 63 $this->sqlite = $this->helper->getDB(); 64 $this->user = $_SERVER['REMOTE_USER']; 65 } 66 67 /** 68 * Create the new schema 69 * 70 * @param int $time when to create this schema 0 for now 71 * @return bool|int the new schema id on success 72 */ 73 public function build($time=0) { 74 $this->time = $time; 75 $this->fixLabelUniqueness(); 76 77 $this->sqlite->query('BEGIN TRANSACTION'); 78 $ok = true; 79 // create the data table if new schema 80 if(!$this->oldschema->getId()) { 81 $ok = $this->newDataTable(); 82 } 83 84 // create a new schema 85 $ok = $ok && $this->newSchema(); 86 87 // update column info 88 $ok = $ok && $this->updateColumns(); 89 $ok = $ok && $this->addColumns(); 90 91 if (!$ok) { 92 $this->sqlite->query('ROLLBACK TRANSACTION'); 93 return false; 94 } 95 $this->sqlite->query('COMMIT TRANSACTION'); 96 97 return $this->newschemaid; 98 } 99 100 /** 101 * Makes sure all labels in the schema to save are unique 102 */ 103 protected function fixLabelUniqueness() { 104 $labels = array(); 105 106 if(isset($this->data['cols'])) foreach($this->data['cols'] as $idx => $column) { 107 $this->data['cols'][$idx]['label'] = $this->fixLabel($column['label'], $labels); 108 } 109 110 if(isset($this->data['new'])) foreach($this->data['new'] as $idx => $column) { 111 $this->data['new'][$idx]['label'] = $this->fixLabel($column['label'], $labels); 112 } 113 } 114 115 /** 116 * Creates a unique label from the given one 117 * 118 * @param string $wantedlabel 119 * @param array $labels list of already assigned labels (will be filled) 120 * @return string 121 */ 122 protected function fixLabel($wantedlabel, &$labels) { 123 $wantedlabel = trim($wantedlabel); 124 $fixedlabel = $wantedlabel; 125 $idx = 1; 126 while(isset($labels[utf8_strtolower($fixedlabel)])) { 127 $fixedlabel = $wantedlabel.$idx++; 128 } 129 // did we actually do a rename? apply it. 130 if($fixedlabel != $wantedlabel) { 131 msg(sprintf($this->helper->getLang('duplicate_label'), $wantedlabel, $fixedlabel), -1); 132 $this->data['cols']['label'] = $fixedlabel; 133 } 134 $labels[utf8_strtolower($fixedlabel)] = 1; 135 return $fixedlabel; 136 } 137 138 /** 139 * Creates a new schema 140 */ 141 protected function newSchema() { 142 if(!$this->time) $this->time = time(); 143 144 $config = $this->data['config'] ?: '{}'; 145 146 /** @noinspection SqlResolve */ 147 $sql = "INSERT INTO schemas (tbl, ts, islookup, user, config) VALUES (?, ?, ?, ?, ?)"; 148 // FIXME magic 0 for islookup 149 $this->sqlite->query($sql, $this->table, $this->time, 0, $this->user, $config); 150 $res = $this->sqlite->query('SELECT last_insert_rowid()'); 151 $this->newschemaid = $this->sqlite->res2single($res); 152 $this->sqlite->res_close($res); 153 if(!$this->newschemaid) return false; 154 return true; 155 } 156 157 /** 158 * Updates all the existing column infos and adds them to the new schema 159 */ 160 protected function updateColumns() { 161 foreach($this->oldschema->getColumns() as $column) { 162 $oldEntry = $column->getType()->getAsEntry(); 163 $oldTid = $column->getTid(); 164 $newEntry = $oldEntry; 165 $newTid = $oldTid; 166 $sort = $column->getSort(); 167 if(isset($this->data['cols'][$column->getColref()])){ 168 // todo I'm not too happy with this hardcoded here - we should probably have a list of fields at one place 169 $newEntry['config'] = $this->data['cols'][$column->getColref()]['config']; 170 $newEntry['label'] = $this->data['cols'][$column->getColref()]['label']; 171 $newEntry['ismulti'] = $this->data['cols'][$column->getColref()]['ismulti']; 172 $newEntry['class'] = $this->data['cols'][$column->getColref()]['class']; 173 $sort = $this->data['cols'][$column->getColref()]['sort']; 174 $enabled = (bool) $this->data['cols'][$column->getColref()]['isenabled']; 175 176 // when the type definition has changed, we create a new one 177 if(array_diff_assoc($oldEntry, $newEntry)) { 178 $ok = $this->sqlite->storeEntry('types', $newEntry); 179 if(!$ok) return false; 180 $res = $this->sqlite->query('SELECT last_insert_rowid()'); 181 if(!$res) return false; 182 $newTid = $this->sqlite->res2single($res); 183 $this->sqlite->res_close($res); 184 if ($oldEntry['ismulti'] == false && $newEntry['ismulti'] == '1') { 185 $this->migrateSingleToMulti($this->oldschema->getTable(), $column->getColref()); 186 } 187 } 188 } else { 189 $enabled = false; // no longer there for some reason 190 } 191 192 // add this type to the schema columns 193 $schemaEntry = array( 194 'sid' => $this->newschemaid, 195 'colref' => $column->getColref(), 196 'enabled' => $enabled, 197 'tid' => $newTid, 198 'sort' => $sort 199 ); 200 $ok = $this->sqlite->storeEntry('schema_cols', $schemaEntry); 201 if(!$ok) return false; 202 } 203 return true; 204 } 205 206 /** 207 * Write the latest value from an entry in a data_ table to the corresponding multi_table 208 * FIXME handle rid 209 * 210 * @param string $table 211 * @param int $colref 212 */ 213 protected function migrateSingleToMulti($table, $colref) { 214 /** @noinspection SqlResolve */ 215 $sqlSelect = "SELECT pid, rev, col$colref AS value FROM data_$table WHERE latest = 1"; 216 $res = $this->sqlite->query($sqlSelect); 217 $valueSet = $this->sqlite->res2arr($res); 218 $this->sqlite->res_close($res); 219 $valueString = array(); 220 $arguments = array(); 221 foreach ($valueSet as $values) { 222 if (blank($values['value']) || trim($values['value']) == '') { 223 continue; 224 } 225 $valueString[] = "(?, ?, ?, ?, ?)"; 226 $arguments = array_merge($arguments, array($colref, $values['pid'], $values['rev'], 1, $values['value'])); 227 } 228 if (empty($valueString)) { 229 return; 230 } 231 $valueString = join(',', $valueString); 232 /** @noinspection SqlResolve */ 233 $sqlInsert = "INSERT OR REPLACE INTO multi_$table (colref, pid, rev, row, value) VALUES $valueString"; 234 $this->sqlite->query($sqlInsert, $arguments); 235 } 236 237 /** 238 * Adds new columns to the new schema 239 * 240 * @return bool 241 */ 242 protected function addColumns() { 243 if(!isset($this->data['new'])) return true; 244 245 $colref = count($this->oldschema->getColumns())+1; 246 247 foreach($this->data['new'] as $column) { 248 if(!$column['isenabled']) continue; // we do not add a disabled column 249 250 // todo this duplicates the hardcoding as in the function above 251 $newEntry = array(); 252 $newEntry['config'] = $column['config']; 253 $newEntry['label'] = $column['label']; 254 $newEntry['ismulti'] = $column['ismulti']; 255 $newEntry['class'] = $column['class']; 256 $sort = $column['sort']; 257 258 259 // only save if the column got a name 260 if(!$newEntry['label']) continue; 261 262 // add new column to the data table 263 if(!$this->addDataTableColumn($colref)) { 264 return false; 265 } 266 267 // save the type 268 $ok = $this->sqlite->storeEntry('types', $newEntry); 269 if(!$ok) return false; 270 $res = $this->sqlite->query('SELECT last_insert_rowid()'); 271 if(!$res) return false; 272 $newTid = $this->sqlite->res2single($res); 273 $this->sqlite->res_close($res); 274 275 276 // add this type to the schema columns 277 $schemaEntry = array( 278 'sid' => $this->newschemaid, 279 'colref' => $colref, 280 'enabled' => true, 281 'tid' => $newTid, 282 'sort' => $sort 283 ); 284 $ok = $this->sqlite->storeEntry('schema_cols', $schemaEntry); 285 if(!$ok) return false; 286 $colref++; 287 } 288 289 return true; 290 } 291 292 /** 293 * Create a completely new data table with no columns yet also create the appropriate 294 * multi value table for the schema 295 * 296 * @todo how do we want to handle indexes? 297 * @return bool 298 */ 299 protected function newDataTable() { 300 $ok = true; 301 302 $tbl = 'data_' . $this->table; 303 // FIXME default latest in data table is 1 for lookups, 0 for pages 304 // FIXME because lookup data is never versioned? 305 $sql = "CREATE TABLE $tbl ( 306 pid TEXT DEFAULT NULL, 307 rid INTEGER, 308 rev INTEGER, 309 latest BOOLEAN NOT NULL DEFAULT 0, 310 PRIMARY KEY(pid, rid, rev) 311 )"; 312 $ok = $ok && (bool) $this->sqlite->query($sql); 313 314 $tbl = 'multi_' . $this->table; 315 $sql = "CREATE TABLE $tbl ( 316 colref INTEGER NOT NULL, 317 pid TEXT, 318 rid INTEGER, 319 rev INTEGER, 320 latest INTEGER NOT NULL DEFAULT 0, 321 row INTEGER NOT NULL, 322 value, 323 PRIMARY KEY(colref, pid, rid, rev, row) 324 );"; 325 $ok = $ok && (bool) $this->sqlite->query($sql); 326 327 return $ok; 328 } 329 330 /** 331 * Add an additional column to the existing data table 332 * 333 * @param int $index the new column index to add 334 * @return bool 335 */ 336 protected function addDataTableColumn($index) { 337 $tbl = 'data_' . $this->table; 338 $sql = " ALTER TABLE $tbl ADD COLUMN col$index DEFAULT ''"; 339 if(! $this->sqlite->query($sql)) { 340 return false; 341 } 342 return true; 343 } 344 345 /** 346 * @param string $user 347 * @return SchemaBuilder 348 */ 349 public function setUser($user) { 350 $this->user = $user; 351 return $this; 352 } 353 354 355} 356