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