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