11c502704SAndreas Gohr<?php 21c502704SAndreas Gohr 3ba766201SAndreas Gohrnamespace dokuwiki\plugin\struct\meta; 41c502704SAndreas Gohr 5*7234bfb1Ssplitbrainuse dokuwiki\plugin\sqlite\SQLiteDB; 6a91bbca2SAndreas Gohruse dokuwiki\Utf8\PhpString; 7a91bbca2SAndreas Gohr 87182938bSAndreas Gohr/** 97182938bSAndreas Gohr * Class SchemaBuilder 107182938bSAndreas Gohr * 117182938bSAndreas Gohr * This class builds and updates the schema definitions for our tables. This includes CREATEing and ALTERing 127182938bSAndreas Gohr * the actual data tables as well as updating the meta information in our meta data tables. 137182938bSAndreas Gohr * 147182938bSAndreas Gohr * To use, simply instantiate a new object of the Builder and run the build() method on it. 157182938bSAndreas Gohr * 167182938bSAndreas Gohr * Note: even though data tables use a data_ prefix in the database, this prefix is internal only and should 177182938bSAndreas Gohr * never be passed as $table anywhere! 187182938bSAndreas Gohr * 19ba766201SAndreas Gohr * @package dokuwiki\plugin\struct\meta 207182938bSAndreas Gohr */ 21d6d97f60SAnna Dabrowskaclass SchemaBuilder 22d6d97f60SAnna Dabrowska{ 231c502704SAndreas Gohr /** 241c502704SAndreas Gohr * @var array The posted new data for the schema 257182938bSAndreas Gohr * @see Schema::AdminEditor() 261c502704SAndreas Gohr */ 27*7234bfb1Ssplitbrain protected $data = []; 281c502704SAndreas Gohr 29fa7b96aaSMichael Grosse protected $user; 30fa7b96aaSMichael Grosse 311c502704SAndreas Gohr /** 321c502704SAndreas Gohr * @var string The table name associated with the schema 331c502704SAndreas Gohr */ 341c502704SAndreas Gohr protected $table = ''; 351c502704SAndreas Gohr 361c502704SAndreas Gohr /** 371c502704SAndreas Gohr * @var Schema the previously valid schema for this table 381c502704SAndreas Gohr */ 391c502704SAndreas Gohr protected $oldschema; 401c502704SAndreas Gohr 411c502704SAndreas Gohr /** @var int the ID of the newly created schema */ 421c502704SAndreas Gohr protected $newschemaid = 0; 431c502704SAndreas Gohr 44a1603abdSAndreas Gohr /** @var \helper_plugin_struct_db */ 45a1603abdSAndreas Gohr protected $helper; 46a1603abdSAndreas Gohr 47*7234bfb1Ssplitbrain /** @var SQLiteDB|null */ 481c502704SAndreas Gohr protected $sqlite; 491c502704SAndreas Gohr 509d580426SAndreas Gohr /** @var int the time for which this schema should be created - default to time() can be overriden for tests */ 519d580426SAndreas Gohr protected $time = 0; 529d580426SAndreas Gohr 531c502704SAndreas Gohr /** 541c502704SAndreas Gohr * SchemaBuilder constructor. 557182938bSAndreas Gohr * 567182938bSAndreas Gohr * @param string $table The table's name 577182938bSAndreas Gohr * @param array $data The defining of the table (basically what get's posted in the schema editor form) 587182938bSAndreas Gohr * @see Schema::AdminEditor() 591c502704SAndreas Gohr */ 60d6d97f60SAnna Dabrowska public function __construct($table, $data) 61d6d97f60SAnna Dabrowska { 62ecf2cba2SAndreas Gohr global $INPUT; 63ecf2cba2SAndreas Gohr 641c502704SAndreas Gohr $this->table = $table; 651c502704SAndreas Gohr $this->data = $data; 660ceefd5cSAnna Dabrowska $this->oldschema = new Schema($table, 0); 671c502704SAndreas Gohr 68a1603abdSAndreas Gohr $this->helper = plugin_load('helper', 'struct_db'); 69a1603abdSAndreas Gohr $this->sqlite = $this->helper->getDB(); 701ca21e17SAnna Dabrowska $this->user = $_SERVER['REMOTE_USER'] ?? ''; 711c502704SAndreas Gohr } 721c502704SAndreas Gohr 731c502704SAndreas Gohr /** 741c502704SAndreas Gohr * Create the new schema 751c502704SAndreas Gohr * 769d580426SAndreas Gohr * @param int $time when to create this schema 0 for now 771ca21e17SAnna Dabrowska * @return int the new schema id on success 781c502704SAndreas Gohr */ 79d6d97f60SAnna Dabrowska public function build($time = 0) 80d6d97f60SAnna Dabrowska { 819d580426SAndreas Gohr $this->time = $time; 82a1603abdSAndreas Gohr $this->fixLabelUniqueness(); 83a1603abdSAndreas Gohr 841c502704SAndreas Gohr $this->sqlite->query('BEGIN TRANSACTION'); 856390a534SMichael Große $ok = true; 866684d78dSAndreas Gohr // create the data table if new schema 876684d78dSAndreas Gohr if (!$this->oldschema->getId()) { 881c502704SAndreas Gohr $ok = $this->newDataTable(); 896684d78dSAndreas Gohr } 901c502704SAndreas Gohr 911c502704SAndreas Gohr // create a new schema 926390a534SMichael Große $ok = $ok && $this->newSchema(); 931c502704SAndreas Gohr 941c502704SAndreas Gohr // update column info 956390a534SMichael Große $ok = $ok && $this->updateColumns(); 966390a534SMichael Große $ok = $ok && $this->addColumns(); 971c502704SAndreas Gohr 986390a534SMichael Große if (!$ok) { 996390a534SMichael Große $this->sqlite->query('ROLLBACK TRANSACTION'); 1006390a534SMichael Große return false; 1016390a534SMichael Große } 1021c502704SAndreas Gohr $this->sqlite->query('COMMIT TRANSACTION'); 1031c502704SAndreas Gohr 1045cc23c44SAndreas Gohr return (int)$this->newschemaid; 1051c502704SAndreas Gohr } 1061c502704SAndreas Gohr 1071c502704SAndreas Gohr /** 108a1603abdSAndreas Gohr * Makes sure all labels in the schema to save are unique 109a1603abdSAndreas Gohr */ 110d6d97f60SAnna Dabrowska protected function fixLabelUniqueness() 111d6d97f60SAnna Dabrowska { 112*7234bfb1Ssplitbrain $labels = []; 113a1603abdSAndreas Gohr 114cf25d54bSAndreas Gohr if (isset($this->data['cols'])) foreach ($this->data['cols'] as $idx => $column) { 115a1603abdSAndreas Gohr $this->data['cols'][$idx]['label'] = $this->fixLabel($column['label'], $labels); 116a1603abdSAndreas Gohr } 117a1603abdSAndreas Gohr 118cf25d54bSAndreas Gohr if (isset($this->data['new'])) foreach ($this->data['new'] as $idx => $column) { 119a1603abdSAndreas Gohr $this->data['new'][$idx]['label'] = $this->fixLabel($column['label'], $labels); 120a1603abdSAndreas Gohr } 121a1603abdSAndreas Gohr } 122a1603abdSAndreas Gohr 123a1603abdSAndreas Gohr /** 124a1603abdSAndreas Gohr * Creates a unique label from the given one 125a1603abdSAndreas Gohr * 126a1603abdSAndreas Gohr * @param string $wantedlabel 127a1603abdSAndreas Gohr * @param array $labels list of already assigned labels (will be filled) 128a1603abdSAndreas Gohr * @return string 129a1603abdSAndreas Gohr */ 130d6d97f60SAnna Dabrowska protected function fixLabel($wantedlabel, &$labels) 131d6d97f60SAnna Dabrowska { 132a1603abdSAndreas Gohr $wantedlabel = trim($wantedlabel); 133a1603abdSAndreas Gohr $fixedlabel = $wantedlabel; 134a1603abdSAndreas Gohr $idx = 1; 135a91bbca2SAndreas Gohr while (isset($labels[PhpString::strtolower($fixedlabel)])) { 136a1603abdSAndreas Gohr $fixedlabel = $wantedlabel . $idx++; 137a1603abdSAndreas Gohr } 138a1603abdSAndreas Gohr // did we actually do a rename? apply it. 139*7234bfb1Ssplitbrain if ($fixedlabel !== $wantedlabel) { 140a1603abdSAndreas Gohr msg(sprintf($this->helper->getLang('duplicate_label'), $wantedlabel, $fixedlabel), -1); 141a1603abdSAndreas Gohr $this->data['cols']['label'] = $fixedlabel; 142a1603abdSAndreas Gohr } 143a91bbca2SAndreas Gohr $labels[PhpString::strtolower($fixedlabel)] = 1; 144a1603abdSAndreas Gohr return $fixedlabel; 145a1603abdSAndreas Gohr } 146a1603abdSAndreas Gohr 147a1603abdSAndreas Gohr /** 1481c502704SAndreas Gohr * Creates a new schema 1491c502704SAndreas Gohr */ 150d6d97f60SAnna Dabrowska protected function newSchema() 151d6d97f60SAnna Dabrowska { 1529d580426SAndreas Gohr if (!$this->time) $this->time = time(); 1539d580426SAndreas Gohr 1548f171097SAndreas Gohr $config = $this->data['config'] ?? '{}'; 155127d6bacSMichael Große 15676f0676dSAndreas Gohr /** @noinspection SqlResolve */ 1575b808f9fSAnna Dabrowska $sql = "INSERT INTO schemas (tbl, ts, user, config) VALUES (?, ?, ?, ?)"; 15879b29326SAnna Dabrowska $this->sqlite->query($sql, [$this->table, $this->time, $this->user, $config]); 15979b29326SAnna Dabrowska $this->newschemaid = $this->sqlite->queryValue('SELECT last_insert_rowid()'); 16079b29326SAnna Dabrowska 1611c502704SAndreas Gohr if (!$this->newschemaid) return false; 1621c502704SAndreas Gohr return true; 1631c502704SAndreas Gohr } 1641c502704SAndreas Gohr 1651c502704SAndreas Gohr /** 1661c502704SAndreas Gohr * Updates all the existing column infos and adds them to the new schema 1671c502704SAndreas Gohr */ 168d6d97f60SAnna Dabrowska protected function updateColumns() 169d6d97f60SAnna Dabrowska { 1701c502704SAndreas Gohr foreach ($this->oldschema->getColumns() as $column) { 1711c502704SAndreas Gohr $oldEntry = $column->getType()->getAsEntry(); 1721c502704SAndreas Gohr $oldTid = $column->getTid(); 1731c502704SAndreas Gohr $newEntry = $oldEntry; 1741c502704SAndreas Gohr $newTid = $oldTid; 1751c502704SAndreas Gohr $sort = $column->getSort(); 1761c502704SAndreas Gohr if (isset($this->data['cols'][$column->getColref()])) { 17717a3a578SAndreas Gohr // todo I'm not too happy with this hardcoded here - 17817a3a578SAndreas Gohr // we should probably have a list of fields at one place 1791c502704SAndreas Gohr $newEntry['config'] = $this->data['cols'][$column->getColref()]['config']; 1801c502704SAndreas Gohr $newEntry['label'] = $this->data['cols'][$column->getColref()]['label']; 1813a41f427SAnna Dabrowska $newEntry['ismulti'] = $this->data['cols'][$column->getColref()]['ismulti'] ?? 0; 1821c502704SAndreas Gohr $newEntry['class'] = $this->data['cols'][$column->getColref()]['class']; 1831c502704SAndreas Gohr $sort = $this->data['cols'][$column->getColref()]['sort']; 1843a41f427SAnna Dabrowska $enabled = (bool)($this->data['cols'][$column->getColref()]['isenabled'] ?? 0); 1851c502704SAndreas Gohr 1861c502704SAndreas Gohr // when the type definition has changed, we create a new one 1871c502704SAndreas Gohr if (array_diff_assoc($oldEntry, $newEntry)) { 18879b29326SAnna Dabrowska $ok = $this->sqlite->saveRecord('types', $newEntry); 1891c502704SAndreas Gohr if (!$ok) return false; 19079b29326SAnna Dabrowska $newTid = $this->sqlite->queryValue('SELECT last_insert_rowid()'); 19179b29326SAnna Dabrowska if (!$newTid) return false; 192b3e0ad6dSMichael Grosse if ($oldEntry['ismulti'] == false && $newEntry['ismulti'] == '1') { 193b3e0ad6dSMichael Grosse $this->migrateSingleToMulti($this->oldschema->getTable(), $column->getColref()); 194b3e0ad6dSMichael Grosse } 1951c502704SAndreas Gohr } 1961c502704SAndreas Gohr } else { 19726147f8cSAndreas Gohr $enabled = false; // no longer there for some reason 1981c502704SAndreas Gohr } 1991c502704SAndreas Gohr 2001c502704SAndreas Gohr // add this type to the schema columns 201*7234bfb1Ssplitbrain $schemaEntry = ['sid' => $this->newschemaid, 'colref' => $column->getColref(), 'enabled' => $enabled, 'tid' => $newTid, 'sort' => $sort]; 20279b29326SAnna Dabrowska $ok = $this->sqlite->saveRecord('schema_cols', $schemaEntry); 2031c502704SAndreas Gohr if (!$ok) return false; 2041c502704SAndreas Gohr } 2051c502704SAndreas Gohr return true; 2061c502704SAndreas Gohr } 2071c502704SAndreas Gohr 2084050be94SMichael Grosse /** 2094050be94SMichael Grosse * Write the latest value from an entry in a data_ table to the corresponding multi_table 2104050be94SMichael Grosse * 2114050be94SMichael Grosse * @param string $table 2124050be94SMichael Grosse * @param int $colref 2134050be94SMichael Grosse */ 214d6d97f60SAnna Dabrowska protected function migrateSingleToMulti($table, $colref) 215d6d97f60SAnna Dabrowska { 21676f0676dSAndreas Gohr /** @noinspection SqlResolve */ 217fc6ac2e5SAnna Dabrowska $sqlSelect = "SELECT pid, rev, published, col$colref AS value FROM data_$table WHERE latest = 1"; 21879b29326SAnna Dabrowska $valueSet = $this->sqlite->queryAll($sqlSelect); 219*7234bfb1Ssplitbrain $valueString = []; 220*7234bfb1Ssplitbrain $arguments = []; 221b3e0ad6dSMichael Grosse foreach ($valueSet as $values) { 222b3e0ad6dSMichael Grosse if (blank($values['value']) || trim($values['value']) == '') { 223b3e0ad6dSMichael Grosse continue; 224b3e0ad6dSMichael Grosse } 225fc6ac2e5SAnna Dabrowska $valueString[] = "(?, ?, ?, ?, ?, ?)"; 22612d2f24fSAnna Dabrowska $arguments = array_merge( 22712d2f24fSAnna Dabrowska $arguments, 22812d2f24fSAnna Dabrowska [$colref, $values['pid'], $values['rev'], $values['published'], 1, $values['value']] 22912d2f24fSAnna Dabrowska ); 230b3e0ad6dSMichael Grosse } 231*7234bfb1Ssplitbrain if ($valueString === []) { 232b3e0ad6dSMichael Grosse return; 233b3e0ad6dSMichael Grosse } 234*7234bfb1Ssplitbrain $valueString = implode(',', $valueString); 23576f0676dSAndreas Gohr /** @noinspection SqlResolve */ 23612d2f24fSAnna Dabrowska $sqlInsert = "INSERT OR REPLACE INTO multi_$table (colref, pid, rev, published, row, value) VALUES $valueString"; // phpcs:ignore 2374050be94SMichael Grosse $this->sqlite->query($sqlInsert, $arguments); 238b3e0ad6dSMichael Grosse } 239b3e0ad6dSMichael Grosse 2401c502704SAndreas Gohr /** 2411c502704SAndreas Gohr * Adds new columns to the new schema 2421c502704SAndreas Gohr * 2431c502704SAndreas Gohr * @return bool 2441c502704SAndreas Gohr */ 245d6d97f60SAnna Dabrowska protected function addColumns() 246d6d97f60SAnna Dabrowska { 2471c502704SAndreas Gohr if (!isset($this->data['new'])) return true; 2481c502704SAndreas Gohr 2491c502704SAndreas Gohr $colref = count($this->oldschema->getColumns()) + 1; 2501c502704SAndreas Gohr 2511c502704SAndreas Gohr foreach ($this->data['new'] as $column) { 25226147f8cSAndreas Gohr if (!$column['isenabled']) continue; // we do not add a disabled column 25326147f8cSAndreas Gohr 2541c502704SAndreas Gohr // todo this duplicates the hardcoding as in the function above 255*7234bfb1Ssplitbrain $newEntry = []; 2561ca21e17SAnna Dabrowska $newEntry['config'] = $column['config'] ?? '{}'; 2571c502704SAndreas Gohr $newEntry['label'] = $column['label']; 2583a41f427SAnna Dabrowska $newEntry['ismulti'] = $column['ismulti'] ?? 0; 2591c502704SAndreas Gohr $newEntry['class'] = $column['class']; 2601c502704SAndreas Gohr $sort = $column['sort']; 26126147f8cSAndreas Gohr 2621c502704SAndreas Gohr 2636684d78dSAndreas Gohr // only save if the column got a name 2646684d78dSAndreas Gohr if (!$newEntry['label']) continue; 2656684d78dSAndreas Gohr 2666684d78dSAndreas Gohr // add new column to the data table 2676684d78dSAndreas Gohr if (!$this->addDataTableColumn($colref)) { 2686684d78dSAndreas Gohr return false; 2696684d78dSAndreas Gohr } 2706684d78dSAndreas Gohr 2711c502704SAndreas Gohr // save the type 27279b29326SAnna Dabrowska $ok = $this->sqlite->saveRecord('types', $newEntry); 2731c502704SAndreas Gohr if (!$ok) return false; 27479b29326SAnna Dabrowska $newTid = $this->sqlite->queryValue('SELECT last_insert_rowid()'); 27579b29326SAnna Dabrowska 27679b29326SAnna Dabrowska if (!$newTid) return false; 2771c502704SAndreas Gohr 2781c502704SAndreas Gohr 2791c502704SAndreas Gohr // add this type to the schema columns 280*7234bfb1Ssplitbrain $schemaEntry = ['sid' => $this->newschemaid, 'colref' => $colref, 'enabled' => true, 'tid' => $newTid, 'sort' => $sort]; 28179b29326SAnna Dabrowska $ok = $this->sqlite->saveRecord('schema_cols', $schemaEntry); 2821c502704SAndreas Gohr if (!$ok) return false; 2831c502704SAndreas Gohr $colref++; 2841c502704SAndreas Gohr } 2851c502704SAndreas Gohr 2861c502704SAndreas Gohr return true; 2871c502704SAndreas Gohr } 2881c502704SAndreas Gohr 2891c502704SAndreas Gohr /** 2900fe33e72SAndreas Gohr * Create a completely new data table with no columns yet also create the appropriate 2910fe33e72SAndreas Gohr * multi value table for the schema 2921c502704SAndreas Gohr * 2931c502704SAndreas Gohr * @return bool 2940549dcc5SAndreas Gohr * @todo how do we want to handle indexes? 2951c502704SAndreas Gohr */ 296d6d97f60SAnna Dabrowska protected function newDataTable() 297d6d97f60SAnna Dabrowska { 2980fe33e72SAndreas Gohr $ok = true; 2991c502704SAndreas Gohr 3000fe33e72SAndreas Gohr $tbl = 'data_' . $this->table; 3011c502704SAndreas Gohr $sql = "CREATE TABLE $tbl ( 30286a40c1eSAnna Dabrowska pid TEXT DEFAULT '', 3030ceefd5cSAnna Dabrowska rid INTEGER, 3040ceefd5cSAnna Dabrowska rev INTEGER, 3057059e7e1SAndreas Gohr latest BOOLEAN NOT NULL DEFAULT 0, 306fc6ac2e5SAnna Dabrowska published BOOLEAN DEFAULT NULL, 3070ceefd5cSAnna Dabrowska PRIMARY KEY(pid, rid, rev) 3086684d78dSAndreas Gohr )"; 3090fe33e72SAndreas Gohr $ok = $ok && (bool)$this->sqlite->query($sql); 3101c502704SAndreas Gohr 3110fe33e72SAndreas Gohr $tbl = 'multi_' . $this->table; 3120fe33e72SAndreas Gohr $sql = "CREATE TABLE $tbl ( 3130fe33e72SAndreas Gohr colref INTEGER NOT NULL, 31486a40c1eSAnna Dabrowska pid TEXT DEFAULT '', 3150ceefd5cSAnna Dabrowska rid INTEGER, 3160ceefd5cSAnna Dabrowska rev INTEGER, 3178f259467SAndreas Gohr latest INTEGER NOT NULL DEFAULT 0, 318fc6ac2e5SAnna Dabrowska published BOOLEAN DEFAULT NULL, 3190fe33e72SAndreas Gohr row INTEGER NOT NULL, 3200fe33e72SAndreas Gohr value, 3210ceefd5cSAnna Dabrowska PRIMARY KEY(colref, pid, rid, rev, row) 3227c080d69SAndreas Gohr );"; 3237c080d69SAndreas Gohr $ok = $ok && (bool)$this->sqlite->query($sql); 3247c080d69SAndreas Gohr 3257c080d69SAndreas Gohr return $ok; 3267c080d69SAndreas Gohr } 3277c080d69SAndreas Gohr 3287c080d69SAndreas Gohr /** 3296684d78dSAndreas Gohr * Add an additional column to the existing data table 3301c502704SAndreas Gohr * 3316684d78dSAndreas Gohr * @param int $index the new column index to add 3321c502704SAndreas Gohr * @return bool 3331c502704SAndreas Gohr */ 334d6d97f60SAnna Dabrowska protected function addDataTableColumn($index) 335d6d97f60SAnna Dabrowska { 3361c502704SAndreas Gohr $tbl = 'data_' . $this->table; 3376684d78dSAndreas Gohr $sql = " ALTER TABLE $tbl ADD COLUMN col$index DEFAULT ''"; 3381c502704SAndreas Gohr if (!$this->sqlite->query($sql)) { 3391c502704SAndreas Gohr return false; 3401c502704SAndreas Gohr } 3411c502704SAndreas Gohr return true; 3421c502704SAndreas Gohr } 3431c502704SAndreas Gohr 34478bff02fSMichael Grosse /** 34578bff02fSMichael Grosse * @param string $user 34678bff02fSMichael Grosse * @return SchemaBuilder 34778bff02fSMichael Grosse */ 348d6d97f60SAnna Dabrowska public function setUser($user) 349d6d97f60SAnna Dabrowska { 35078bff02fSMichael Grosse $this->user = $user; 35178bff02fSMichael Grosse return $this; 35278bff02fSMichael Grosse } 3531c502704SAndreas Gohr} 354