xref: /plugin/struct/meta/SchemaBuilder.php (revision 7fe2cdf28c472c686961bf42f0123eb33d2f3e60)
11c502704SAndreas Gohr<?php
21c502704SAndreas Gohr
3ba766201SAndreas Gohrnamespace dokuwiki\plugin\struct\meta;
41c502704SAndreas Gohr
57234bfb1Ssplitbrainuse 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     */
277234bfb1Ssplitbrain    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
477234bfb1Ssplitbrain    /** @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    {
1127234bfb1Ssplitbrain        $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.
1397234bfb1Ssplitbrain        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*7fe2cdf2SAndreas Gohr            $schemaEntry = [
202*7fe2cdf2SAndreas Gohr                'sid' => $this->newschemaid,
203*7fe2cdf2SAndreas Gohr                'colref' => $column->getColref(),
204*7fe2cdf2SAndreas Gohr                'enabled' => $enabled,
205*7fe2cdf2SAndreas Gohr                'tid' => $newTid,
206*7fe2cdf2SAndreas Gohr                'sort' => $sort
207*7fe2cdf2SAndreas Gohr            ];
20879b29326SAnna Dabrowska            $ok = $this->sqlite->saveRecord('schema_cols', $schemaEntry);
2091c502704SAndreas Gohr            if (!$ok) return false;
2101c502704SAndreas Gohr        }
2111c502704SAndreas Gohr        return true;
2121c502704SAndreas Gohr    }
2131c502704SAndreas Gohr
2144050be94SMichael Grosse    /**
2154050be94SMichael Grosse     * Write the latest value from an entry in a data_ table to the corresponding multi_table
2164050be94SMichael Grosse     *
2174050be94SMichael Grosse     * @param string $table
2184050be94SMichael Grosse     * @param int $colref
2194050be94SMichael Grosse     */
220d6d97f60SAnna Dabrowska    protected function migrateSingleToMulti($table, $colref)
221d6d97f60SAnna Dabrowska    {
22276f0676dSAndreas Gohr        /** @noinspection SqlResolve */
223fc6ac2e5SAnna Dabrowska        $sqlSelect = "SELECT pid, rev, published, col$colref AS value FROM data_$table WHERE latest = 1";
22479b29326SAnna Dabrowska        $valueSet = $this->sqlite->queryAll($sqlSelect);
2257234bfb1Ssplitbrain        $valueString = [];
2267234bfb1Ssplitbrain        $arguments = [];
227b3e0ad6dSMichael Grosse        foreach ($valueSet as $values) {
228b3e0ad6dSMichael Grosse            if (blank($values['value']) || trim($values['value']) == '') {
229b3e0ad6dSMichael Grosse                continue;
230b3e0ad6dSMichael Grosse            }
231fc6ac2e5SAnna Dabrowska            $valueString[] = "(?, ?, ?, ?, ?, ?)";
23212d2f24fSAnna Dabrowska            $arguments = array_merge(
23312d2f24fSAnna Dabrowska                $arguments,
23412d2f24fSAnna Dabrowska                [$colref, $values['pid'], $values['rev'], $values['published'], 1, $values['value']]
23512d2f24fSAnna Dabrowska            );
236b3e0ad6dSMichael Grosse        }
2377234bfb1Ssplitbrain        if ($valueString === []) {
238b3e0ad6dSMichael Grosse            return;
239b3e0ad6dSMichael Grosse        }
2407234bfb1Ssplitbrain        $valueString = implode(',', $valueString);
24176f0676dSAndreas Gohr        /** @noinspection SqlResolve */
24212d2f24fSAnna Dabrowska        $sqlInsert = "INSERT OR REPLACE INTO multi_$table (colref, pid, rev, published, row, value) VALUES $valueString"; // phpcs:ignore
2434050be94SMichael Grosse        $this->sqlite->query($sqlInsert, $arguments);
244b3e0ad6dSMichael Grosse    }
245b3e0ad6dSMichael Grosse
2461c502704SAndreas Gohr    /**
2471c502704SAndreas Gohr     * Adds new columns to the new schema
2481c502704SAndreas Gohr     *
2491c502704SAndreas Gohr     * @return bool
2501c502704SAndreas Gohr     */
251d6d97f60SAnna Dabrowska    protected function addColumns()
252d6d97f60SAnna Dabrowska    {
2531c502704SAndreas Gohr        if (!isset($this->data['new'])) return true;
2541c502704SAndreas Gohr
2551c502704SAndreas Gohr        $colref = count($this->oldschema->getColumns()) + 1;
2561c502704SAndreas Gohr
2571c502704SAndreas Gohr        foreach ($this->data['new'] as $column) {
25826147f8cSAndreas Gohr            if (!$column['isenabled']) continue; // we do not add a disabled column
25926147f8cSAndreas Gohr
2601c502704SAndreas Gohr            // todo this duplicates the hardcoding as in  the function above
2617234bfb1Ssplitbrain            $newEntry = [];
2621ca21e17SAnna Dabrowska            $newEntry['config'] = $column['config'] ?? '{}';
2631c502704SAndreas Gohr            $newEntry['label'] = $column['label'];
2643a41f427SAnna Dabrowska            $newEntry['ismulti'] = $column['ismulti'] ?? 0;
2651c502704SAndreas Gohr            $newEntry['class'] = $column['class'];
2661c502704SAndreas Gohr            $sort = $column['sort'];
26726147f8cSAndreas Gohr
2681c502704SAndreas Gohr
2696684d78dSAndreas Gohr            // only save if the column got a name
2706684d78dSAndreas Gohr            if (!$newEntry['label']) continue;
2716684d78dSAndreas Gohr
2726684d78dSAndreas Gohr            // add new column to the data table
2736684d78dSAndreas Gohr            if (!$this->addDataTableColumn($colref)) {
2746684d78dSAndreas Gohr                return false;
2756684d78dSAndreas Gohr            }
2766684d78dSAndreas Gohr
2771c502704SAndreas Gohr            // save the type
27879b29326SAnna Dabrowska            $ok = $this->sqlite->saveRecord('types', $newEntry);
2791c502704SAndreas Gohr            if (!$ok) return false;
28079b29326SAnna Dabrowska            $newTid = $this->sqlite->queryValue('SELECT last_insert_rowid()');
28179b29326SAnna Dabrowska
28279b29326SAnna Dabrowska            if (!$newTid) return false;
2831c502704SAndreas Gohr
2841c502704SAndreas Gohr
2851c502704SAndreas Gohr            // add this type to the schema columns
286*7fe2cdf2SAndreas Gohr            $schemaEntry = [
287*7fe2cdf2SAndreas Gohr                'sid' => $this->newschemaid,
288*7fe2cdf2SAndreas Gohr                'colref' => $colref,
289*7fe2cdf2SAndreas Gohr                'enabled' => true,
290*7fe2cdf2SAndreas Gohr                'tid' => $newTid,
291*7fe2cdf2SAndreas Gohr                'sort' => $sort
292*7fe2cdf2SAndreas Gohr            ];
29379b29326SAnna Dabrowska            $ok = $this->sqlite->saveRecord('schema_cols', $schemaEntry);
2941c502704SAndreas Gohr            if (!$ok) return false;
2951c502704SAndreas Gohr            $colref++;
2961c502704SAndreas Gohr        }
2971c502704SAndreas Gohr
2981c502704SAndreas Gohr        return true;
2991c502704SAndreas Gohr    }
3001c502704SAndreas Gohr
3011c502704SAndreas Gohr    /**
3020fe33e72SAndreas Gohr     * Create a completely new data table with no columns yet also create the appropriate
3030fe33e72SAndreas Gohr     * multi value table for the schema
3041c502704SAndreas Gohr     *
3051c502704SAndreas Gohr     * @return bool
3060549dcc5SAndreas Gohr     * @todo how do we want to handle indexes?
3071c502704SAndreas Gohr     */
308d6d97f60SAnna Dabrowska    protected function newDataTable()
309d6d97f60SAnna Dabrowska    {
3100fe33e72SAndreas Gohr        $ok = true;
3111c502704SAndreas Gohr
3120fe33e72SAndreas Gohr        $tbl = 'data_' . $this->table;
3131c502704SAndreas Gohr        $sql = "CREATE TABLE $tbl (
31486a40c1eSAnna Dabrowska                    pid TEXT DEFAULT '',
3150ceefd5cSAnna Dabrowska                    rid INTEGER,
3160ceefd5cSAnna Dabrowska                    rev INTEGER,
3177059e7e1SAndreas Gohr                    latest BOOLEAN NOT NULL DEFAULT 0,
318fc6ac2e5SAnna Dabrowska                    published BOOLEAN DEFAULT NULL,
3190ceefd5cSAnna Dabrowska                    PRIMARY KEY(pid, rid, rev)
3206684d78dSAndreas Gohr                )";
3210fe33e72SAndreas Gohr        $ok = $ok && (bool)$this->sqlite->query($sql);
3221c502704SAndreas Gohr
3230fe33e72SAndreas Gohr        $tbl = 'multi_' . $this->table;
3240fe33e72SAndreas Gohr        $sql = "CREATE TABLE $tbl (
3250fe33e72SAndreas Gohr                    colref INTEGER NOT NULL,
32686a40c1eSAnna Dabrowska                    pid TEXT DEFAULT '',
3270ceefd5cSAnna Dabrowska                    rid INTEGER,
3280ceefd5cSAnna Dabrowska                    rev INTEGER,
3298f259467SAndreas Gohr                    latest INTEGER NOT NULL DEFAULT 0,
330fc6ac2e5SAnna Dabrowska                    published BOOLEAN DEFAULT NULL,
3310fe33e72SAndreas Gohr                    row INTEGER NOT NULL,
3320fe33e72SAndreas Gohr                    value,
3330ceefd5cSAnna Dabrowska                    PRIMARY KEY(colref, pid, rid, rev, row)
3347c080d69SAndreas Gohr                );";
3357c080d69SAndreas Gohr        $ok = $ok && (bool)$this->sqlite->query($sql);
3367c080d69SAndreas Gohr
3377c080d69SAndreas Gohr        return $ok;
3387c080d69SAndreas Gohr    }
3397c080d69SAndreas Gohr
3407c080d69SAndreas Gohr    /**
3416684d78dSAndreas Gohr     * Add an additional column to the existing data table
3421c502704SAndreas Gohr     *
3436684d78dSAndreas Gohr     * @param int $index the new column index to add
3441c502704SAndreas Gohr     * @return bool
3451c502704SAndreas Gohr     */
346d6d97f60SAnna Dabrowska    protected function addDataTableColumn($index)
347d6d97f60SAnna Dabrowska    {
3481c502704SAndreas Gohr        $tbl = 'data_' . $this->table;
3496684d78dSAndreas Gohr        $sql = " ALTER TABLE $tbl ADD COLUMN col$index DEFAULT ''";
3501c502704SAndreas Gohr        if (!$this->sqlite->query($sql)) {
3511c502704SAndreas Gohr            return false;
3521c502704SAndreas Gohr        }
3531c502704SAndreas Gohr        return true;
3541c502704SAndreas Gohr    }
3551c502704SAndreas Gohr
35678bff02fSMichael Grosse    /**
35778bff02fSMichael Grosse     * @param string $user
35878bff02fSMichael Grosse     * @return SchemaBuilder
35978bff02fSMichael Grosse     */
360d6d97f60SAnna Dabrowska    public function setUser($user)
361d6d97f60SAnna Dabrowska    {
36278bff02fSMichael Grosse        $this->user = $user;
36378bff02fSMichael Grosse        return $this;
36478bff02fSMichael Grosse    }
3651c502704SAndreas Gohr}
366