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) VALUES (?, ?, ?, ?)"; 150 $this->sqlite->query($sql, $this->table, $this->time, (int) $this->oldschema->isLookup(), $this->user); 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 row INTEGER NOT NULL, 317 value, 318 PRIMARY KEY(colref, pid, rev, row) 319 );"; 320 $ok = $ok && (bool) $this->sqlite->query($sql); 321 322 return $ok; 323 } 324 325 /** 326 * Creates a new lookup table with no columns 327 * 328 * This is basically the same as @see newDataTable() but sets 329 * different primary keys and types 330 * 331 * @return bool 332 */ 333 protected function newLookupTable() { 334 $ok = true; 335 336 $tbl = 'data_' . $this->table; 337 $sql = "CREATE TABLE $tbl ( 338 pid INTEGER PRIMARY KEY, 339 rev INTEGER NOT NULL DEFAULT 0, 340 latest BOOLEAN NOT NULL DEFAULT 1 341 )"; 342 $ok = $ok && (bool) $this->sqlite->query($sql); 343 344 $tbl = 'multi_' . $this->table; 345 $sql = "CREATE TABLE $tbl ( 346 colref INTEGER NOT NULL, 347 pid INTEGER NOT NULL, 348 rev INTEGER NOT NULL DEFAULT 0, 349 row INTEGER NOT NULL, 350 value, 351 PRIMARY KEY(colref, pid, row) 352 );"; 353 $ok = $ok && (bool) $this->sqlite->query($sql); 354 355 return $ok; 356 } 357 358 /** 359 * Add an additional column to the existing data table 360 * 361 * @param int $index the new column index to add 362 * @return bool 363 */ 364 protected function addDataTableColumn($index) { 365 $tbl = 'data_' . $this->table; 366 $sql = " ALTER TABLE $tbl ADD COLUMN col$index DEFAULT ''"; 367 if(! $this->sqlite->query($sql)) { 368 return false; 369 } 370 return true; 371 } 372 373 /** 374 * @param string $user 375 * @return SchemaBuilder 376 */ 377 public function setUser($user) { 378 $this->user = $user; 379 return $this; 380 } 381 382 383} 384