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 \helper_plugin_sqlite|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 $res = $this->sqlite->query('SELECT last_insert_rowid()'); 159 $this->newschemaid = $this->sqlite->res2single($res); 160 $this->sqlite->res_close($res); 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']; 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']; 185 186 // when the type definition has changed, we create a new one 187 if (array_diff_assoc($oldEntry, $newEntry)) { 188 $ok = $this->sqlite->storeEntry('types', $newEntry); 189 if (!$ok) return false; 190 $res = $this->sqlite->query('SELECT last_insert_rowid()'); 191 if (!$res) return false; 192 $newTid = $this->sqlite->res2single($res); 193 $this->sqlite->res_close($res); 194 if ($oldEntry['ismulti'] == false && $newEntry['ismulti'] == '1') { 195 $this->migrateSingleToMulti($this->oldschema->getTable(), $column->getColref()); 196 } 197 } 198 } else { 199 $enabled = false; // no longer there for some reason 200 } 201 202 // add this type to the schema columns 203 $schemaEntry = array( 204 'sid' => $this->newschemaid, 205 'colref' => $column->getColref(), 206 'enabled' => $enabled, 207 'tid' => $newTid, 208 'sort' => $sort 209 ); 210 $ok = $this->sqlite->storeEntry('schema_cols', $schemaEntry); 211 if (!$ok) return false; 212 } 213 return true; 214 } 215 216 /** 217 * Write the latest value from an entry in a data_ table to the corresponding multi_table 218 * 219 * @param string $table 220 * @param int $colref 221 */ 222 protected function migrateSingleToMulti($table, $colref) 223 { 224 /** @noinspection SqlResolve */ 225 $sqlSelect = "SELECT pid, rev, col$colref AS value FROM data_$table WHERE latest = 1"; 226 $res = $this->sqlite->query($sqlSelect); 227 $valueSet = $this->sqlite->res2arr($res); 228 $this->sqlite->res_close($res); 229 $valueString = array(); 230 $arguments = array(); 231 foreach ($valueSet as $values) { 232 if (blank($values['value']) || trim($values['value']) == '') { 233 continue; 234 } 235 $valueString[] = "(?, ?, ?, ?, ?)"; 236 $arguments = array_merge($arguments, array($colref, $values['pid'], $values['rev'], 1, $values['value'])); 237 } 238 if (empty($valueString)) { 239 return; 240 } 241 $valueString = join(',', $valueString); 242 /** @noinspection SqlResolve */ 243 $sqlInsert = "INSERT OR REPLACE INTO multi_$table (colref, pid, rev, row, value) VALUES $valueString"; 244 $this->sqlite->query($sqlInsert, $arguments); 245 } 246 247 /** 248 * Adds new columns to the new schema 249 * 250 * @return bool 251 */ 252 protected function addColumns() 253 { 254 if (!isset($this->data['new'])) return true; 255 256 $colref = count($this->oldschema->getColumns()) + 1; 257 258 foreach ($this->data['new'] as $column) { 259 if (!$column['isenabled']) continue; // we do not add a disabled column 260 261 // todo this duplicates the hardcoding as in the function above 262 $newEntry = array(); 263 $newEntry['config'] = $column['config'] ?? '{}'; 264 $newEntry['label'] = $column['label']; 265 $newEntry['ismulti'] = $column['ismulti']; 266 $newEntry['class'] = $column['class']; 267 $sort = $column['sort']; 268 269 270 // only save if the column got a name 271 if (!$newEntry['label']) continue; 272 273 // add new column to the data table 274 if (!$this->addDataTableColumn($colref)) { 275 return false; 276 } 277 278 // save the type 279 $ok = $this->sqlite->storeEntry('types', $newEntry); 280 if (!$ok) return false; 281 $res = $this->sqlite->query('SELECT last_insert_rowid()'); 282 if (!$res) return false; 283 $newTid = $this->sqlite->res2single($res); 284 $this->sqlite->res_close($res); 285 286 287 // add this type to the schema columns 288 $schemaEntry = array( 289 'sid' => $this->newschemaid, 290 'colref' => $colref, 291 'enabled' => true, 292 'tid' => $newTid, 293 'sort' => $sort 294 ); 295 $ok = $this->sqlite->storeEntry('schema_cols', $schemaEntry); 296 if (!$ok) return false; 297 $colref++; 298 } 299 300 return true; 301 } 302 303 /** 304 * Create a completely new data table with no columns yet also create the appropriate 305 * multi value table for the schema 306 * 307 * @return bool 308 * @todo how do we want to handle indexes? 309 */ 310 protected function newDataTable() 311 { 312 $ok = true; 313 314 $tbl = 'data_' . $this->table; 315 $sql = "CREATE TABLE $tbl ( 316 pid TEXT DEFAULT '', 317 rid INTEGER, 318 rev INTEGER, 319 latest BOOLEAN NOT NULL DEFAULT 0, 320 PRIMARY KEY(pid, rid, rev) 321 )"; 322 $ok = $ok && (bool)$this->sqlite->query($sql); 323 324 $tbl = 'multi_' . $this->table; 325 $sql = "CREATE TABLE $tbl ( 326 colref INTEGER NOT NULL, 327 pid TEXT DEFAULT '', 328 rid INTEGER, 329 rev INTEGER, 330 latest INTEGER NOT NULL DEFAULT 0, 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