11c502704SAndreas Gohr<?php 21c502704SAndreas Gohr 3ba766201SAndreas Gohrnamespace dokuwiki\plugin\struct\meta; 41c502704SAndreas Gohr 5a91bbca2SAndreas Gohruse dokuwiki\Utf8\PhpString; 6a91bbca2SAndreas Gohr 77182938bSAndreas Gohr/** 87182938bSAndreas Gohr * Class SchemaBuilder 97182938bSAndreas Gohr * 107182938bSAndreas Gohr * This class builds and updates the schema definitions for our tables. This includes CREATEing and ALTERing 117182938bSAndreas Gohr * the actual data tables as well as updating the meta information in our meta data tables. 127182938bSAndreas Gohr * 137182938bSAndreas Gohr * To use, simply instantiate a new object of the Builder and run the build() method on it. 147182938bSAndreas Gohr * 157182938bSAndreas Gohr * Note: even though data tables use a data_ prefix in the database, this prefix is internal only and should 167182938bSAndreas Gohr * never be passed as $table anywhere! 177182938bSAndreas Gohr * 18ba766201SAndreas Gohr * @package dokuwiki\plugin\struct\meta 197182938bSAndreas Gohr */ 20d6d97f60SAnna Dabrowskaclass SchemaBuilder 21d6d97f60SAnna Dabrowska{ 221c502704SAndreas Gohr /** 231c502704SAndreas Gohr * @var array The posted new data for the schema 247182938bSAndreas Gohr * @see Schema::AdminEditor() 251c502704SAndreas Gohr */ 261c502704SAndreas Gohr protected $data = array(); 271c502704SAndreas Gohr 28fa7b96aaSMichael Grosse protected $user; 29fa7b96aaSMichael Grosse 301c502704SAndreas Gohr /** 311c502704SAndreas Gohr * @var string The table name associated with the schema 321c502704SAndreas Gohr */ 331c502704SAndreas Gohr protected $table = ''; 341c502704SAndreas Gohr 351c502704SAndreas Gohr /** 361c502704SAndreas Gohr * @var Schema the previously valid schema for this table 371c502704SAndreas Gohr */ 381c502704SAndreas Gohr protected $oldschema; 391c502704SAndreas Gohr 401c502704SAndreas Gohr /** @var int the ID of the newly created schema */ 411c502704SAndreas Gohr protected $newschemaid = 0; 421c502704SAndreas Gohr 43a1603abdSAndreas Gohr /** @var \helper_plugin_struct_db */ 44a1603abdSAndreas Gohr protected $helper; 45a1603abdSAndreas Gohr 46*79b29326SAnna Dabrowska /** @var \dokuwiki\plugin\sqlite\SQLiteDB|null */ 471c502704SAndreas Gohr protected $sqlite; 481c502704SAndreas Gohr 499d580426SAndreas Gohr /** @var int the time for which this schema should be created - default to time() can be overriden for tests */ 509d580426SAndreas Gohr protected $time = 0; 519d580426SAndreas Gohr 521c502704SAndreas Gohr /** 531c502704SAndreas Gohr * SchemaBuilder constructor. 547182938bSAndreas Gohr * 557182938bSAndreas Gohr * @param string $table The table's name 567182938bSAndreas Gohr * @param array $data The defining of the table (basically what get's posted in the schema editor form) 577182938bSAndreas Gohr * @see Schema::AdminEditor() 581c502704SAndreas Gohr */ 59d6d97f60SAnna Dabrowska public function __construct($table, $data) 60d6d97f60SAnna Dabrowska { 61ecf2cba2SAndreas Gohr global $INPUT; 62ecf2cba2SAndreas Gohr 631c502704SAndreas Gohr $this->table = $table; 641c502704SAndreas Gohr $this->data = $data; 650ceefd5cSAnna Dabrowska $this->oldschema = new Schema($table, 0); 661c502704SAndreas Gohr 67a1603abdSAndreas Gohr $this->helper = plugin_load('helper', 'struct_db'); 68a1603abdSAndreas Gohr $this->sqlite = $this->helper->getDB(); 691ca21e17SAnna Dabrowska $this->user = $_SERVER['REMOTE_USER'] ?? ''; 701c502704SAndreas Gohr } 711c502704SAndreas Gohr 721c502704SAndreas Gohr /** 731c502704SAndreas Gohr * Create the new schema 741c502704SAndreas Gohr * 759d580426SAndreas Gohr * @param int $time when to create this schema 0 for now 761ca21e17SAnna Dabrowska * @return int the new schema id on success 771c502704SAndreas Gohr */ 78d6d97f60SAnna Dabrowska public function build($time = 0) 79d6d97f60SAnna Dabrowska { 809d580426SAndreas Gohr $this->time = $time; 81a1603abdSAndreas Gohr $this->fixLabelUniqueness(); 82a1603abdSAndreas Gohr 831c502704SAndreas Gohr $this->sqlite->query('BEGIN TRANSACTION'); 846390a534SMichael Große $ok = true; 856684d78dSAndreas Gohr // create the data table if new schema 866684d78dSAndreas Gohr if (!$this->oldschema->getId()) { 871c502704SAndreas Gohr $ok = $this->newDataTable(); 886684d78dSAndreas Gohr } 891c502704SAndreas Gohr 901c502704SAndreas Gohr // create a new schema 916390a534SMichael Große $ok = $ok && $this->newSchema(); 921c502704SAndreas Gohr 931c502704SAndreas Gohr // update column info 946390a534SMichael Große $ok = $ok && $this->updateColumns(); 956390a534SMichael Große $ok = $ok && $this->addColumns(); 961c502704SAndreas Gohr 976390a534SMichael Große if (!$ok) { 986390a534SMichael Große $this->sqlite->query('ROLLBACK TRANSACTION'); 996390a534SMichael Große return false; 1006390a534SMichael Große } 1011c502704SAndreas Gohr $this->sqlite->query('COMMIT TRANSACTION'); 1021c502704SAndreas Gohr 1035cc23c44SAndreas Gohr return (int)$this->newschemaid; 1041c502704SAndreas Gohr } 1051c502704SAndreas Gohr 1061c502704SAndreas Gohr /** 107a1603abdSAndreas Gohr * Makes sure all labels in the schema to save are unique 108a1603abdSAndreas Gohr */ 109d6d97f60SAnna Dabrowska protected function fixLabelUniqueness() 110d6d97f60SAnna Dabrowska { 111a1603abdSAndreas Gohr $labels = array(); 112a1603abdSAndreas Gohr 113cf25d54bSAndreas Gohr if (isset($this->data['cols'])) foreach ($this->data['cols'] as $idx => $column) { 114a1603abdSAndreas Gohr $this->data['cols'][$idx]['label'] = $this->fixLabel($column['label'], $labels); 115a1603abdSAndreas Gohr } 116a1603abdSAndreas Gohr 117cf25d54bSAndreas Gohr if (isset($this->data['new'])) foreach ($this->data['new'] as $idx => $column) { 118a1603abdSAndreas Gohr $this->data['new'][$idx]['label'] = $this->fixLabel($column['label'], $labels); 119a1603abdSAndreas Gohr } 120a1603abdSAndreas Gohr } 121a1603abdSAndreas Gohr 122a1603abdSAndreas Gohr /** 123a1603abdSAndreas Gohr * Creates a unique label from the given one 124a1603abdSAndreas Gohr * 125a1603abdSAndreas Gohr * @param string $wantedlabel 126a1603abdSAndreas Gohr * @param array $labels list of already assigned labels (will be filled) 127a1603abdSAndreas Gohr * @return string 128a1603abdSAndreas Gohr */ 129d6d97f60SAnna Dabrowska protected function fixLabel($wantedlabel, &$labels) 130d6d97f60SAnna Dabrowska { 131a1603abdSAndreas Gohr $wantedlabel = trim($wantedlabel); 132a1603abdSAndreas Gohr $fixedlabel = $wantedlabel; 133a1603abdSAndreas Gohr $idx = 1; 134a91bbca2SAndreas Gohr while (isset($labels[PhpString::strtolower($fixedlabel)])) { 135a1603abdSAndreas Gohr $fixedlabel = $wantedlabel . $idx++; 136a1603abdSAndreas Gohr } 137a1603abdSAndreas Gohr // did we actually do a rename? apply it. 138a1603abdSAndreas Gohr if ($fixedlabel != $wantedlabel) { 139a1603abdSAndreas Gohr msg(sprintf($this->helper->getLang('duplicate_label'), $wantedlabel, $fixedlabel), -1); 140a1603abdSAndreas Gohr $this->data['cols']['label'] = $fixedlabel; 141a1603abdSAndreas Gohr } 142a91bbca2SAndreas Gohr $labels[PhpString::strtolower($fixedlabel)] = 1; 143a1603abdSAndreas Gohr return $fixedlabel; 144a1603abdSAndreas Gohr } 145a1603abdSAndreas Gohr 146a1603abdSAndreas Gohr /** 1471c502704SAndreas Gohr * Creates a new schema 1481c502704SAndreas Gohr */ 149d6d97f60SAnna Dabrowska protected function newSchema() 150d6d97f60SAnna Dabrowska { 1519d580426SAndreas Gohr if (!$this->time) $this->time = time(); 1529d580426SAndreas Gohr 1538f171097SAndreas Gohr $config = $this->data['config'] ?? '{}'; 154127d6bacSMichael Große 15576f0676dSAndreas Gohr /** @noinspection SqlResolve */ 1565b808f9fSAnna Dabrowska $sql = "INSERT INTO schemas (tbl, ts, user, config) VALUES (?, ?, ?, ?)"; 157*79b29326SAnna Dabrowska $this->sqlite->query($sql, [$this->table, $this->time, $this->user, $config]); 158*79b29326SAnna Dabrowska $this->newschemaid = $this->sqlite->queryValue('SELECT last_insert_rowid()'); 159*79b29326SAnna Dabrowska 1601c502704SAndreas Gohr if (!$this->newschemaid) return false; 1611c502704SAndreas Gohr return true; 1621c502704SAndreas Gohr } 1631c502704SAndreas Gohr 1641c502704SAndreas Gohr /** 1651c502704SAndreas Gohr * Updates all the existing column infos and adds them to the new schema 1661c502704SAndreas Gohr */ 167d6d97f60SAnna Dabrowska protected function updateColumns() 168d6d97f60SAnna Dabrowska { 1691c502704SAndreas Gohr foreach ($this->oldschema->getColumns() as $column) { 1701c502704SAndreas Gohr $oldEntry = $column->getType()->getAsEntry(); 1711c502704SAndreas Gohr $oldTid = $column->getTid(); 1721c502704SAndreas Gohr $newEntry = $oldEntry; 1731c502704SAndreas Gohr $newTid = $oldTid; 1741c502704SAndreas Gohr $sort = $column->getSort(); 1751c502704SAndreas Gohr if (isset($this->data['cols'][$column->getColref()])) { 17617a3a578SAndreas Gohr // todo I'm not too happy with this hardcoded here - 17717a3a578SAndreas Gohr // we should probably have a list of fields at one place 1781c502704SAndreas Gohr $newEntry['config'] = $this->data['cols'][$column->getColref()]['config']; 1791c502704SAndreas Gohr $newEntry['label'] = $this->data['cols'][$column->getColref()]['label']; 1803a41f427SAnna Dabrowska $newEntry['ismulti'] = $this->data['cols'][$column->getColref()]['ismulti'] ?? 0; 1811c502704SAndreas Gohr $newEntry['class'] = $this->data['cols'][$column->getColref()]['class']; 1821c502704SAndreas Gohr $sort = $this->data['cols'][$column->getColref()]['sort']; 1833a41f427SAnna Dabrowska $enabled = (bool)($this->data['cols'][$column->getColref()]['isenabled'] ?? 0); 1841c502704SAndreas Gohr 1851c502704SAndreas Gohr // when the type definition has changed, we create a new one 1861c502704SAndreas Gohr if (array_diff_assoc($oldEntry, $newEntry)) { 187*79b29326SAnna Dabrowska $ok = $this->sqlite->saveRecord('types', $newEntry); 1881c502704SAndreas Gohr if (!$ok) return false; 189*79b29326SAnna Dabrowska $newTid = $this->sqlite->queryValue('SELECT last_insert_rowid()'); 190*79b29326SAnna Dabrowska if (!$newTid) return false; 191b3e0ad6dSMichael Grosse if ($oldEntry['ismulti'] == false && $newEntry['ismulti'] == '1') { 192b3e0ad6dSMichael Grosse $this->migrateSingleToMulti($this->oldschema->getTable(), $column->getColref()); 193b3e0ad6dSMichael Grosse } 1941c502704SAndreas Gohr } 1951c502704SAndreas Gohr } else { 19626147f8cSAndreas Gohr $enabled = false; // no longer there for some reason 1971c502704SAndreas Gohr } 1981c502704SAndreas Gohr 1991c502704SAndreas Gohr // add this type to the schema columns 2001c502704SAndreas Gohr $schemaEntry = array( 2011c502704SAndreas Gohr 'sid' => $this->newschemaid, 2021c502704SAndreas Gohr 'colref' => $column->getColref(), 2031c502704SAndreas Gohr 'enabled' => $enabled, 2041c502704SAndreas Gohr 'tid' => $newTid, 2051c502704SAndreas Gohr 'sort' => $sort 2061c502704SAndreas Gohr ); 207*79b29326SAnna Dabrowska $ok = $this->sqlite->saveRecord('schema_cols', $schemaEntry); 2081c502704SAndreas Gohr if (!$ok) return false; 2091c502704SAndreas Gohr } 2101c502704SAndreas Gohr return true; 2111c502704SAndreas Gohr } 2121c502704SAndreas Gohr 2134050be94SMichael Grosse /** 2144050be94SMichael Grosse * Write the latest value from an entry in a data_ table to the corresponding multi_table 2154050be94SMichael Grosse * 2164050be94SMichael Grosse * @param string $table 2174050be94SMichael Grosse * @param int $colref 2184050be94SMichael Grosse */ 219d6d97f60SAnna Dabrowska protected function migrateSingleToMulti($table, $colref) 220d6d97f60SAnna Dabrowska { 22176f0676dSAndreas Gohr /** @noinspection SqlResolve */ 222fc6ac2e5SAnna Dabrowska $sqlSelect = "SELECT pid, rev, published, col$colref AS value FROM data_$table WHERE latest = 1"; 223*79b29326SAnna Dabrowska $valueSet = $this->sqlite->queryAll($sqlSelect); 224b3e0ad6dSMichael Grosse $valueString = array(); 2254050be94SMichael Grosse $arguments = array(); 226b3e0ad6dSMichael Grosse foreach ($valueSet as $values) { 227b3e0ad6dSMichael Grosse if (blank($values['value']) || trim($values['value']) == '') { 228b3e0ad6dSMichael Grosse continue; 229b3e0ad6dSMichael Grosse } 230fc6ac2e5SAnna Dabrowska $valueString[] = "(?, ?, ?, ?, ?, ?)"; 23112d2f24fSAnna Dabrowska $arguments = array_merge( 23212d2f24fSAnna Dabrowska $arguments, 23312d2f24fSAnna Dabrowska [$colref, $values['pid'], $values['rev'], $values['published'], 1, $values['value']] 23412d2f24fSAnna Dabrowska ); 235b3e0ad6dSMichael Grosse } 236b3e0ad6dSMichael Grosse if (empty($valueString)) { 237b3e0ad6dSMichael Grosse return; 238b3e0ad6dSMichael Grosse } 239b3e0ad6dSMichael Grosse $valueString = join(',', $valueString); 24076f0676dSAndreas Gohr /** @noinspection SqlResolve */ 24112d2f24fSAnna Dabrowska $sqlInsert = "INSERT OR REPLACE INTO multi_$table (colref, pid, rev, published, row, value) VALUES $valueString"; // phpcs:ignore 2424050be94SMichael Grosse $this->sqlite->query($sqlInsert, $arguments); 243b3e0ad6dSMichael Grosse } 244b3e0ad6dSMichael Grosse 2451c502704SAndreas Gohr /** 2461c502704SAndreas Gohr * Adds new columns to the new schema 2471c502704SAndreas Gohr * 2481c502704SAndreas Gohr * @return bool 2491c502704SAndreas Gohr */ 250d6d97f60SAnna Dabrowska protected function addColumns() 251d6d97f60SAnna Dabrowska { 2521c502704SAndreas Gohr if (!isset($this->data['new'])) return true; 2531c502704SAndreas Gohr 2541c502704SAndreas Gohr $colref = count($this->oldschema->getColumns()) + 1; 2551c502704SAndreas Gohr 2561c502704SAndreas Gohr foreach ($this->data['new'] as $column) { 25726147f8cSAndreas Gohr if (!$column['isenabled']) continue; // we do not add a disabled column 25826147f8cSAndreas Gohr 2591c502704SAndreas Gohr // todo this duplicates the hardcoding as in the function above 2601c502704SAndreas Gohr $newEntry = array(); 2611ca21e17SAnna Dabrowska $newEntry['config'] = $column['config'] ?? '{}'; 2621c502704SAndreas Gohr $newEntry['label'] = $column['label']; 2633a41f427SAnna Dabrowska $newEntry['ismulti'] = $column['ismulti'] ?? 0; 2641c502704SAndreas Gohr $newEntry['class'] = $column['class']; 2651c502704SAndreas Gohr $sort = $column['sort']; 26626147f8cSAndreas Gohr 2671c502704SAndreas Gohr 2686684d78dSAndreas Gohr // only save if the column got a name 2696684d78dSAndreas Gohr if (!$newEntry['label']) continue; 2706684d78dSAndreas Gohr 2716684d78dSAndreas Gohr // add new column to the data table 2726684d78dSAndreas Gohr if (!$this->addDataTableColumn($colref)) { 2736684d78dSAndreas Gohr return false; 2746684d78dSAndreas Gohr } 2756684d78dSAndreas Gohr 2761c502704SAndreas Gohr // save the type 277*79b29326SAnna Dabrowska $ok = $this->sqlite->saveRecord('types', $newEntry); 2781c502704SAndreas Gohr if (!$ok) return false; 279*79b29326SAnna Dabrowska $newTid = $this->sqlite->queryValue('SELECT last_insert_rowid()'); 280*79b29326SAnna Dabrowska 281*79b29326SAnna Dabrowska if (!$newTid) return false; 2821c502704SAndreas Gohr 2831c502704SAndreas Gohr 2841c502704SAndreas Gohr // add this type to the schema columns 2851c502704SAndreas Gohr $schemaEntry = array( 2861c502704SAndreas Gohr 'sid' => $this->newschemaid, 2871c502704SAndreas Gohr 'colref' => $colref, 28826147f8cSAndreas Gohr 'enabled' => true, 2891c502704SAndreas Gohr 'tid' => $newTid, 2901c502704SAndreas Gohr 'sort' => $sort 2911c502704SAndreas Gohr ); 292*79b29326SAnna Dabrowska $ok = $this->sqlite->saveRecord('schema_cols', $schemaEntry); 2931c502704SAndreas Gohr if (!$ok) return false; 2941c502704SAndreas Gohr $colref++; 2951c502704SAndreas Gohr } 2961c502704SAndreas Gohr 2971c502704SAndreas Gohr return true; 2981c502704SAndreas Gohr } 2991c502704SAndreas Gohr 3001c502704SAndreas Gohr /** 3010fe33e72SAndreas Gohr * Create a completely new data table with no columns yet also create the appropriate 3020fe33e72SAndreas Gohr * multi value table for the schema 3031c502704SAndreas Gohr * 3041c502704SAndreas Gohr * @return bool 3050549dcc5SAndreas Gohr * @todo how do we want to handle indexes? 3061c502704SAndreas Gohr */ 307d6d97f60SAnna Dabrowska protected function newDataTable() 308d6d97f60SAnna Dabrowska { 3090fe33e72SAndreas Gohr $ok = true; 3101c502704SAndreas Gohr 3110fe33e72SAndreas Gohr $tbl = 'data_' . $this->table; 3121c502704SAndreas Gohr $sql = "CREATE TABLE $tbl ( 31386a40c1eSAnna Dabrowska pid TEXT DEFAULT '', 3140ceefd5cSAnna Dabrowska rid INTEGER, 3150ceefd5cSAnna Dabrowska rev INTEGER, 3167059e7e1SAndreas Gohr latest BOOLEAN NOT NULL DEFAULT 0, 317fc6ac2e5SAnna Dabrowska published BOOLEAN DEFAULT NULL, 3180ceefd5cSAnna Dabrowska PRIMARY KEY(pid, rid, rev) 3196684d78dSAndreas Gohr )"; 3200fe33e72SAndreas Gohr $ok = $ok && (bool)$this->sqlite->query($sql); 3211c502704SAndreas Gohr 3220fe33e72SAndreas Gohr $tbl = 'multi_' . $this->table; 3230fe33e72SAndreas Gohr $sql = "CREATE TABLE $tbl ( 3240fe33e72SAndreas Gohr colref INTEGER NOT NULL, 32586a40c1eSAnna Dabrowska pid TEXT DEFAULT '', 3260ceefd5cSAnna Dabrowska rid INTEGER, 3270ceefd5cSAnna Dabrowska rev INTEGER, 3288f259467SAndreas Gohr latest INTEGER NOT NULL DEFAULT 0, 329fc6ac2e5SAnna Dabrowska published BOOLEAN DEFAULT NULL, 3300fe33e72SAndreas Gohr row INTEGER NOT NULL, 3310fe33e72SAndreas Gohr value, 3320ceefd5cSAnna Dabrowska PRIMARY KEY(colref, pid, rid, rev, row) 3337c080d69SAndreas Gohr );"; 3347c080d69SAndreas Gohr $ok = $ok && (bool)$this->sqlite->query($sql); 3357c080d69SAndreas Gohr 3367c080d69SAndreas Gohr return $ok; 3377c080d69SAndreas Gohr } 3387c080d69SAndreas Gohr 3397c080d69SAndreas Gohr /** 3406684d78dSAndreas Gohr * Add an additional column to the existing data table 3411c502704SAndreas Gohr * 3426684d78dSAndreas Gohr * @param int $index the new column index to add 3431c502704SAndreas Gohr * @return bool 3441c502704SAndreas Gohr */ 345d6d97f60SAnna Dabrowska protected function addDataTableColumn($index) 346d6d97f60SAnna Dabrowska { 3471c502704SAndreas Gohr $tbl = 'data_' . $this->table; 3486684d78dSAndreas Gohr $sql = " ALTER TABLE $tbl ADD COLUMN col$index DEFAULT ''"; 3491c502704SAndreas Gohr if (!$this->sqlite->query($sql)) { 3501c502704SAndreas Gohr return false; 3511c502704SAndreas Gohr } 3521c502704SAndreas Gohr return true; 3531c502704SAndreas Gohr } 3541c502704SAndreas Gohr 35578bff02fSMichael Grosse /** 35678bff02fSMichael Grosse * @param string $user 35778bff02fSMichael Grosse * @return SchemaBuilder 35878bff02fSMichael Grosse */ 359d6d97f60SAnna Dabrowska public function setUser($user) 360d6d97f60SAnna Dabrowska { 36178bff02fSMichael Grosse $this->user = $user; 36278bff02fSMichael Grosse return $this; 36378bff02fSMichael Grosse } 3641c502704SAndreas Gohr} 365