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