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