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, $data['islookup']); 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 if($this->oldschema->isLookup()) { 82 $ok = $this->newLookupTable(); 83 } else { 84 $ok = $this->newDataTable(); 85 } 86 } 87 88 // create a new schema 89 $ok = $ok && $this->newSchema(); 90 91 // update column info 92 $ok = $ok && $this->updateColumns(); 93 $ok = $ok && $this->addColumns(); 94 95 if (!$ok) { 96 $this->sqlite->query('ROLLBACK TRANSACTION'); 97 return false; 98 } 99 $this->sqlite->query('COMMIT TRANSACTION'); 100 101 return $this->newschemaid; 102 } 103 104 /** 105 * Makes sure all labels in the schema to save are unique 106 */ 107 protected function fixLabelUniqueness() { 108 $labels = array(); 109 110 if(isset($this->data['cols'])) foreach($this->data['cols'] as $idx => $column) { 111 $this->data['cols'][$idx]['label'] = $this->fixLabel($column['label'], $labels); 112 } 113 114 if(isset($this->data['new'])) foreach($this->data['new'] as $idx => $column) { 115 $this->data['new'][$idx]['label'] = $this->fixLabel($column['label'], $labels); 116 } 117 } 118 119 /** 120 * Creates a unique label from the given one 121 * 122 * @param string $wantedlabel 123 * @param array $labels list of already assigned labels (will be filled) 124 * @return string 125 */ 126 protected function fixLabel($wantedlabel, &$labels) { 127 $wantedlabel = trim($wantedlabel); 128 $fixedlabel = $wantedlabel; 129 $idx = 1; 130 while(isset($labels[utf8_strtolower($fixedlabel)])) { 131 $fixedlabel = $wantedlabel.$idx++; 132 } 133 // did we actually do a rename? apply it. 134 if($fixedlabel != $wantedlabel) { 135 msg(sprintf($this->helper->getLang('duplicate_label'), $wantedlabel, $fixedlabel), -1); 136 $this->data['cols']['label'] = $fixedlabel; 137 } 138 $labels[utf8_strtolower($fixedlabel)] = 1; 139 return $fixedlabel; 140 } 141 142 /** 143 * Creates a new schema 144 */ 145 protected function newSchema() { 146 if(!$this->time) $this->time = time(); 147 148 /** @noinspection SqlResolve */ 149 $sql = "INSERT INTO schemas (tbl, ts, islookup, user, editors) VALUES (?, ?, ?, ?, ?)"; 150 $this->sqlite->query($sql, $this->table, $this->time, (int) $this->oldschema->isLookup(), $this->user, $this->data['editors']); 151 $res = $this->sqlite->query('SELECT last_insert_rowid()'); 152 $this->newschemaid = $this->sqlite->res2single($res); 153 $this->sqlite->res_close($res); 154 if(!$this->newschemaid) return false; 155 return true; 156 } 157 158 /** 159 * Updates all the existing column infos and adds them to the new schema 160 */ 161 protected function updateColumns() { 162 foreach($this->oldschema->getColumns() as $column) { 163 $oldEntry = $column->getType()->getAsEntry(); 164 $oldTid = $column->getTid(); 165 $newEntry = $oldEntry; 166 $newTid = $oldTid; 167 $sort = $column->getSort(); 168 if(isset($this->data['cols'][$column->getColref()])){ 169 // todo I'm not too happy with this hardcoded here - we should probably have a list of fields at one place 170 $newEntry['config'] = $this->data['cols'][$column->getColref()]['config']; 171 $newEntry['label'] = $this->data['cols'][$column->getColref()]['label']; 172 $newEntry['ismulti'] = $this->data['cols'][$column->getColref()]['ismulti']; 173 $newEntry['class'] = $this->data['cols'][$column->getColref()]['class']; 174 $sort = $this->data['cols'][$column->getColref()]['sort']; 175 $enabled = (bool) $this->data['cols'][$column->getColref()]['isenabled']; 176 177 // when the type definition has changed, we create a new one 178 if(array_diff_assoc($oldEntry, $newEntry)) { 179 $ok = $this->sqlite->storeEntry('types', $newEntry); 180 if(!$ok) return false; 181 $res = $this->sqlite->query('SELECT last_insert_rowid()'); 182 if(!$res) return false; 183 $newTid = $this->sqlite->res2single($res); 184 $this->sqlite->res_close($res); 185 if ($oldEntry['ismulti'] == false && $newEntry['ismulti'] == '1') { 186 $this->migrateSingleToMulti($this->oldschema->getTable(), $column->getColref()); 187 } 188 } 189 } else { 190 $enabled = false; // no longer there for some reason 191 } 192 193 // add this type to the schema columns 194 $schemaEntry = array( 195 'sid' => $this->newschemaid, 196 'colref' => $column->getColref(), 197 'enabled' => $enabled, 198 'tid' => $newTid, 199 'sort' => $sort 200 ); 201 $ok = $this->sqlite->storeEntry('schema_cols', $schemaEntry); 202 if(!$ok) return false; 203 } 204 return true; 205 } 206 207 /** 208 * Write the latest value from an entry in a data_ table to the corresponding multi_table 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 $sql = "CREATE TABLE $tbl ( 304 pid NOT NULL, 305 rev INTEGER NOT NULL, 306 latest BOOLEAN NOT NULL DEFAULT 0, 307 PRIMARY KEY(pid, rev) 308 )"; 309 $ok = $ok && (bool) $this->sqlite->query($sql); 310 311 $tbl = 'multi_' . $this->table; 312 $sql = "CREATE TABLE $tbl ( 313 colref INTEGER NOT NULL, 314 pid NOT NULL, 315 rev INTEGER NOT NULL, 316 latest INTEGER NOT NULL DEFAULT 0, 317 row INTEGER NOT NULL, 318 value, 319 PRIMARY KEY(colref, pid, rev, row) 320 );"; 321 $ok = $ok && (bool) $this->sqlite->query($sql); 322 323 return $ok; 324 } 325 326 /** 327 * Creates a new lookup table with no columns 328 * 329 * This is basically the same as @see newDataTable() but sets 330 * different primary keys and types 331 * 332 * @return bool 333 */ 334 protected function newLookupTable() { 335 $ok = true; 336 337 $tbl = 'data_' . $this->table; 338 $sql = "CREATE TABLE $tbl ( 339 pid INTEGER PRIMARY KEY, 340 rev INTEGER NOT NULL DEFAULT 0, 341 latest BOOLEAN NOT NULL DEFAULT 1 342 )"; 343 $ok = $ok && (bool) $this->sqlite->query($sql); 344 345 $tbl = 'multi_' . $this->table; 346 $sql = "CREATE TABLE $tbl ( 347 colref INTEGER NOT NULL, 348 pid INTEGER NOT NULL, 349 rev INTEGER NOT NULL DEFAULT 0, 350 latest INTEGER NOT NULL DEFAULT 0, 351 row INTEGER NOT NULL, 352 value, 353 PRIMARY KEY(colref, pid, row) 354 );"; 355 $ok = $ok && (bool) $this->sqlite->query($sql); 356 357 return $ok; 358 } 359 360 /** 361 * Add an additional column to the existing data table 362 * 363 * @param int $index the new column index to add 364 * @return bool 365 */ 366 protected function addDataTableColumn($index) { 367 $tbl = 'data_' . $this->table; 368 $sql = " ALTER TABLE $tbl ADD COLUMN col$index DEFAULT ''"; 369 if(! $this->sqlite->query($sql)) { 370 return false; 371 } 372 return true; 373 } 374 375 /** 376 * @param string $user 377 * @return SchemaBuilder 378 */ 379 public function setUser($user) { 380 $this->user = $user; 381 return $this; 382 } 383 384 385} 386