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