1<?php 2 3namespace dokuwiki\plugin\struct\meta; 4 5use dokuwiki\plugin\sqlite\SQLiteDB; 6use dokuwiki\Utf8\PhpString; 7 8/** 9 * Class SchemaBuilder 10 * 11 * This class builds and updates the schema definitions for our tables. This includes CREATEing and ALTERing 12 * the actual data tables as well as updating the meta information in our meta data tables. 13 * 14 * To use, simply instantiate a new object of the Builder and run the build() method on it. 15 * 16 * Note: even though data tables use a data_ prefix in the database, this prefix is internal only and should 17 * never be passed as $table anywhere! 18 * 19 * @package dokuwiki\plugin\struct\meta 20 */ 21class SchemaBuilder 22{ 23 /** 24 * @var array The posted new data for the schema 25 * @see Schema::AdminEditor() 26 */ 27 protected $data = []; 28 29 protected $user; 30 31 /** 32 * @var string The table name associated with the schema 33 */ 34 protected $table = ''; 35 36 /** 37 * @var Schema the previously valid schema for this table 38 */ 39 protected $oldschema; 40 41 /** @var int the ID of the newly created schema */ 42 protected $newschemaid = 0; 43 44 /** @var \helper_plugin_struct_db */ 45 protected $helper; 46 47 /** @var SQLiteDB|null */ 48 protected $sqlite; 49 50 /** @var int the time for which this schema should be created - default to time() can be overriden for tests */ 51 protected $time = 0; 52 53 /** 54 * SchemaBuilder constructor. 55 * 56 * @param string $table The table's name 57 * @param array $data The defining of the table (basically what get's posted in the schema editor form) 58 * @see Schema::AdminEditor() 59 */ 60 public function __construct($table, $data) 61 { 62 global $INPUT; 63 64 $this->table = $table; 65 $this->data = $data; 66 $this->oldschema = new Schema($table, 0); 67 68 $this->helper = plugin_load('helper', 'struct_db'); 69 $this->sqlite = $this->helper->getDB(); 70 $this->user = $_SERVER['REMOTE_USER'] ?? ''; 71 } 72 73 /** 74 * Create the new schema 75 * 76 * @param int $time when to create this schema 0 for now 77 * @return int the new schema id on success 78 */ 79 public function build($time = 0) 80 { 81 $this->time = $time; 82 $this->fixLabelUniqueness(); 83 84 $this->sqlite->query('BEGIN TRANSACTION'); 85 $ok = true; 86 // create the data table if new schema 87 if (!$this->oldschema->getId()) { 88 $ok = $this->newDataTable(); 89 } 90 91 // create a new schema 92 $ok = $ok && $this->newSchema(); 93 94 // update column info 95 $ok = $ok && $this->updateColumns(); 96 $ok = $ok && $this->addColumns(); 97 98 if (!$ok) { 99 $this->sqlite->query('ROLLBACK TRANSACTION'); 100 return false; 101 } 102 $this->sqlite->query('COMMIT TRANSACTION'); 103 104 return (int)$this->newschemaid; 105 } 106 107 /** 108 * Makes sure all labels in the schema to save are unique 109 */ 110 protected function fixLabelUniqueness() 111 { 112 $labels = []; 113 114 if (isset($this->data['cols'])) foreach ($this->data['cols'] as $idx => $column) { 115 $this->data['cols'][$idx]['label'] = $this->fixLabel($column['label'], $labels); 116 } 117 118 if (isset($this->data['new'])) foreach ($this->data['new'] as $idx => $column) { 119 $this->data['new'][$idx]['label'] = $this->fixLabel($column['label'], $labels); 120 } 121 } 122 123 /** 124 * Creates a unique label from the given one 125 * 126 * @param string $wantedlabel 127 * @param array $labels list of already assigned labels (will be filled) 128 * @return string 129 */ 130 protected function fixLabel($wantedlabel, &$labels) 131 { 132 $wantedlabel = trim($wantedlabel); 133 $fixedlabel = $wantedlabel; 134 $idx = 1; 135 while (isset($labels[PhpString::strtolower($fixedlabel)])) { 136 $fixedlabel = $wantedlabel . $idx++; 137 } 138 // did we actually do a rename? apply it. 139 if ($fixedlabel !== $wantedlabel) { 140 msg(sprintf($this->helper->getLang('duplicate_label'), $wantedlabel, $fixedlabel), -1); 141 $this->data['cols']['label'] = $fixedlabel; 142 } 143 $labels[PhpString::strtolower($fixedlabel)] = 1; 144 return $fixedlabel; 145 } 146 147 /** 148 * Creates a new schema 149 */ 150 protected function newSchema() 151 { 152 if (!$this->time) $this->time = time(); 153 154 $config = $this->data['config'] ?? '{}'; 155 156 /** @noinspection SqlResolve */ 157 $sql = "INSERT INTO schemas (tbl, ts, user, config) VALUES (?, ?, ?, ?)"; 158 $this->sqlite->query($sql, [$this->table, $this->time, $this->user, $config]); 159 $this->newschemaid = $this->sqlite->queryValue('SELECT last_insert_rowid()'); 160 161 if (!$this->newschemaid) return false; 162 return true; 163 } 164 165 /** 166 * Updates all the existing column infos and adds them to the new schema 167 */ 168 protected function updateColumns() 169 { 170 foreach ($this->oldschema->getColumns() as $column) { 171 $oldEntry = $column->getType()->getAsEntry(); 172 $oldTid = $column->getTid(); 173 $newEntry = $oldEntry; 174 $newTid = $oldTid; 175 $sort = $column->getSort(); 176 if (isset($this->data['cols'][$column->getColref()])) { 177 // todo I'm not too happy with this hardcoded here - 178 // we should probably have a list of fields at one place 179 $newEntry['config'] = $this->data['cols'][$column->getColref()]['config']; 180 $newEntry['label'] = $this->data['cols'][$column->getColref()]['label']; 181 $newEntry['ismulti'] = $this->data['cols'][$column->getColref()]['ismulti'] ?? 0; 182 $newEntry['class'] = $this->data['cols'][$column->getColref()]['class']; 183 $sort = $this->data['cols'][$column->getColref()]['sort']; 184 $enabled = (bool)($this->data['cols'][$column->getColref()]['isenabled'] ?? 0); 185 186 // when the type definition has changed, we create a new one 187 if (array_diff_assoc($oldEntry, $newEntry)) { 188 $ok = $this->sqlite->saveRecord('types', $newEntry); 189 if (!$ok) return false; 190 $newTid = $this->sqlite->queryValue('SELECT last_insert_rowid()'); 191 if (!$newTid) return false; 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 = [ 202 'sid' => $this->newschemaid, 203 'colref' => $column->getColref(), 204 'enabled' => $enabled, 205 'tid' => $newTid, 206 'sort' => $sort 207 ]; 208 $ok = $this->sqlite->saveRecord('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 $valueSet = $this->sqlite->queryAll($sqlSelect); 225 $valueString = []; 226 $arguments = []; 227 foreach ($valueSet as $values) { 228 if (blank($values['value']) || trim($values['value']) == '') { 229 continue; 230 } 231 $valueString[] = "(?, ?, ?, ?, ?, ?)"; 232 $arguments = array_merge( 233 $arguments, 234 [$colref, $values['pid'], $values['rev'], $values['published'], 1, $values['value']] 235 ); 236 } 237 if ($valueString === []) { 238 return; 239 } 240 $valueString = implode(',', $valueString); 241 /** @noinspection SqlResolve */ 242 $sqlInsert = "INSERT OR REPLACE INTO multi_$table (colref, pid, rev, published, row, value) VALUES $valueString"; // phpcs:ignore 243 $this->sqlite->query($sqlInsert, $arguments); 244 } 245 246 /** 247 * Adds new columns to the new schema 248 * 249 * @return bool 250 */ 251 protected function addColumns() 252 { 253 if (!isset($this->data['new'])) return true; 254 255 $colref = count($this->oldschema->getColumns()) + 1; 256 257 foreach ($this->data['new'] as $column) { 258 if (!$column['isenabled']) continue; // we do not add a disabled column 259 260 // todo this duplicates the hardcoding as in the function above 261 $newEntry = []; 262 $newEntry['config'] = $column['config'] ?? '{}'; 263 $newEntry['label'] = $column['label']; 264 $newEntry['ismulti'] = $column['ismulti'] ?? 0; 265 $newEntry['class'] = $column['class']; 266 $sort = $column['sort']; 267 268 269 // only save if the column got a name 270 if (!$newEntry['label']) continue; 271 272 // add new column to the data table 273 if (!$this->addDataTableColumn($colref)) { 274 return false; 275 } 276 277 // save the type 278 $ok = $this->sqlite->saveRecord('types', $newEntry); 279 if (!$ok) return false; 280 $newTid = $this->sqlite->queryValue('SELECT last_insert_rowid()'); 281 282 if (!$newTid) return false; 283 284 285 // add this type to the schema columns 286 $schemaEntry = [ 287 'sid' => $this->newschemaid, 288 'colref' => $colref, 289 'enabled' => true, 290 'tid' => $newTid, 291 'sort' => $sort 292 ]; 293 $ok = $this->sqlite->saveRecord('schema_cols', $schemaEntry); 294 if (!$ok) return false; 295 $colref++; 296 } 297 298 return true; 299 } 300 301 /** 302 * Create a completely new data table with no columns yet also create the appropriate 303 * multi value table for the schema 304 * 305 * @return bool 306 * @todo how do we want to handle indexes? 307 */ 308 protected function newDataTable() 309 { 310 $ok = true; 311 312 $tbl = 'data_' . $this->table; 313 $sql = "CREATE TABLE $tbl ( 314 pid TEXT DEFAULT '', 315 rid INTEGER, 316 rev INTEGER, 317 latest BOOLEAN NOT NULL DEFAULT 0, 318 published BOOLEAN DEFAULT NULL, 319 PRIMARY KEY(pid, rid, rev) 320 )"; 321 $ok = $ok && (bool)$this->sqlite->query($sql); 322 323 $tbl = 'multi_' . $this->table; 324 $sql = "CREATE TABLE $tbl ( 325 colref INTEGER NOT NULL, 326 pid TEXT DEFAULT '', 327 rid INTEGER, 328 rev INTEGER, 329 latest INTEGER NOT NULL DEFAULT 0, 330 published BOOLEAN DEFAULT NULL, 331 row INTEGER NOT NULL, 332 value, 333 PRIMARY KEY(colref, pid, rid, rev, row) 334 );"; 335 $ok = $ok && (bool)$this->sqlite->query($sql); 336 337 return $ok; 338 } 339 340 /** 341 * Add an additional column to the existing data table 342 * 343 * @param int $index the new column index to add 344 * @return bool 345 */ 346 protected function addDataTableColumn($index) 347 { 348 $tbl = 'data_' . $this->table; 349 $sql = " ALTER TABLE $tbl ADD COLUMN col$index DEFAULT ''"; 350 if (!$this->sqlite->query($sql)) { 351 return false; 352 } 353 return true; 354 } 355 356 /** 357 * @param string $user 358 * @return SchemaBuilder 359 */ 360 public function setUser($user) 361 { 362 $this->user = $user; 363 return $this; 364 } 365} 366