xref: /plugin/struct/meta/SchemaBuilder.php (revision 12d2f24f56e51f623113e6c5ac6486e27c39b35c)
11c502704SAndreas Gohr<?php
21c502704SAndreas Gohr
3ba766201SAndreas Gohrnamespace dokuwiki\plugin\struct\meta;
41c502704SAndreas Gohr
57182938bSAndreas Gohr/**
67182938bSAndreas Gohr * Class SchemaBuilder
77182938bSAndreas Gohr *
87182938bSAndreas Gohr * This class builds and updates the schema definitions for our tables. This includes CREATEing and ALTERing
97182938bSAndreas Gohr * the actual data tables as well as updating the meta information in our meta data tables.
107182938bSAndreas Gohr *
117182938bSAndreas Gohr * To use, simply instantiate a new object of the Builder and run the build() method on it.
127182938bSAndreas Gohr *
137182938bSAndreas Gohr * Note: even though data tables use a data_ prefix in the database, this prefix is internal only and should
147182938bSAndreas Gohr *       never be passed as $table anywhere!
157182938bSAndreas Gohr *
16ba766201SAndreas Gohr * @package dokuwiki\plugin\struct\meta
177182938bSAndreas Gohr */
18d6d97f60SAnna Dabrowskaclass SchemaBuilder
19d6d97f60SAnna Dabrowska{
201c502704SAndreas Gohr    /**
211c502704SAndreas Gohr     * @var array The posted new data for the schema
227182938bSAndreas Gohr     * @see Schema::AdminEditor()
231c502704SAndreas Gohr     */
241c502704SAndreas Gohr    protected $data = array();
251c502704SAndreas Gohr
26fa7b96aaSMichael Grosse    protected $user;
27fa7b96aaSMichael Grosse
281c502704SAndreas Gohr    /**
291c502704SAndreas Gohr     * @var string The table name associated with the schema
301c502704SAndreas Gohr     */
311c502704SAndreas Gohr    protected $table = '';
321c502704SAndreas Gohr
331c502704SAndreas Gohr    /**
341c502704SAndreas Gohr     * @var Schema the previously valid schema for this table
351c502704SAndreas Gohr     */
361c502704SAndreas Gohr    protected $oldschema;
371c502704SAndreas Gohr
381c502704SAndreas Gohr    /** @var int the ID of the newly created schema */
391c502704SAndreas Gohr    protected $newschemaid = 0;
401c502704SAndreas Gohr
41a1603abdSAndreas Gohr    /** @var \helper_plugin_struct_db */
42a1603abdSAndreas Gohr    protected $helper;
43a1603abdSAndreas Gohr
441c502704SAndreas Gohr    /** @var \helper_plugin_sqlite|null */
451c502704SAndreas Gohr    protected $sqlite;
461c502704SAndreas Gohr
479d580426SAndreas Gohr    /** @var int the time for which this schema should be created - default to time() can be overriden for tests */
489d580426SAndreas Gohr    protected $time = 0;
499d580426SAndreas Gohr
501c502704SAndreas Gohr    /**
511c502704SAndreas Gohr     * SchemaBuilder constructor.
527182938bSAndreas Gohr     *
537182938bSAndreas Gohr     * @param string $table The table's name
547182938bSAndreas Gohr     * @param array $data The defining of the table (basically what get's posted in the schema editor form)
557182938bSAndreas Gohr     * @see Schema::AdminEditor()
561c502704SAndreas Gohr     */
57d6d97f60SAnna Dabrowska    public function __construct($table, $data)
58d6d97f60SAnna Dabrowska    {
59ecf2cba2SAndreas Gohr        global $INPUT;
60ecf2cba2SAndreas Gohr
611c502704SAndreas Gohr        $this->table = $table;
621c502704SAndreas Gohr        $this->data = $data;
630ceefd5cSAnna Dabrowska        $this->oldschema = new Schema($table, 0);
641c502704SAndreas Gohr
65a1603abdSAndreas Gohr        $this->helper = plugin_load('helper', 'struct_db');
66a1603abdSAndreas Gohr        $this->sqlite = $this->helper->getDB();
671ca21e17SAnna Dabrowska        $this->user = $_SERVER['REMOTE_USER'] ?? '';
681c502704SAndreas Gohr    }
691c502704SAndreas Gohr
701c502704SAndreas Gohr    /**
711c502704SAndreas Gohr     * Create the new schema
721c502704SAndreas Gohr     *
739d580426SAndreas Gohr     * @param int $time when to create this schema 0 for now
741ca21e17SAnna Dabrowska     * @return int the new schema id on success
751c502704SAndreas Gohr     */
76d6d97f60SAnna Dabrowska    public function build($time = 0)
77d6d97f60SAnna Dabrowska    {
789d580426SAndreas Gohr        $this->time = $time;
79a1603abdSAndreas Gohr        $this->fixLabelUniqueness();
80a1603abdSAndreas Gohr
811c502704SAndreas Gohr        $this->sqlite->query('BEGIN TRANSACTION');
826390a534SMichael Große        $ok = true;
836684d78dSAndreas Gohr        // create the data table if new schema
846684d78dSAndreas Gohr        if (!$this->oldschema->getId()) {
851c502704SAndreas Gohr            $ok = $this->newDataTable();
866684d78dSAndreas Gohr        }
871c502704SAndreas Gohr
881c502704SAndreas Gohr        // create a new schema
896390a534SMichael Große        $ok = $ok && $this->newSchema();
901c502704SAndreas Gohr
911c502704SAndreas Gohr        // update column info
926390a534SMichael Große        $ok = $ok && $this->updateColumns();
936390a534SMichael Große        $ok = $ok && $this->addColumns();
941c502704SAndreas Gohr
956390a534SMichael Große        if (!$ok) {
966390a534SMichael Große            $this->sqlite->query('ROLLBACK TRANSACTION');
976390a534SMichael Große            return false;
986390a534SMichael Große        }
991c502704SAndreas Gohr        $this->sqlite->query('COMMIT TRANSACTION');
1001c502704SAndreas Gohr
1015cc23c44SAndreas Gohr        return (int)$this->newschemaid;
1021c502704SAndreas Gohr    }
1031c502704SAndreas Gohr
1041c502704SAndreas Gohr    /**
105a1603abdSAndreas Gohr     * Makes sure all labels in the schema to save are unique
106a1603abdSAndreas Gohr     */
107d6d97f60SAnna Dabrowska    protected function fixLabelUniqueness()
108d6d97f60SAnna Dabrowska    {
109a1603abdSAndreas Gohr        $labels = array();
110a1603abdSAndreas Gohr
111cf25d54bSAndreas Gohr        if (isset($this->data['cols'])) foreach ($this->data['cols'] as $idx => $column) {
112a1603abdSAndreas Gohr            $this->data['cols'][$idx]['label'] = $this->fixLabel($column['label'], $labels);
113a1603abdSAndreas Gohr        }
114a1603abdSAndreas Gohr
115cf25d54bSAndreas Gohr        if (isset($this->data['new'])) foreach ($this->data['new'] as $idx => $column) {
116a1603abdSAndreas Gohr            $this->data['new'][$idx]['label'] = $this->fixLabel($column['label'], $labels);
117a1603abdSAndreas Gohr        }
118a1603abdSAndreas Gohr    }
119a1603abdSAndreas Gohr
120a1603abdSAndreas Gohr    /**
121a1603abdSAndreas Gohr     * Creates a unique label from the given one
122a1603abdSAndreas Gohr     *
123a1603abdSAndreas Gohr     * @param string $wantedlabel
124a1603abdSAndreas Gohr     * @param array $labels list of already assigned labels (will be filled)
125a1603abdSAndreas Gohr     * @return string
126a1603abdSAndreas Gohr     */
127d6d97f60SAnna Dabrowska    protected function fixLabel($wantedlabel, &$labels)
128d6d97f60SAnna Dabrowska    {
129a1603abdSAndreas Gohr        $wantedlabel = trim($wantedlabel);
130a1603abdSAndreas Gohr        $fixedlabel = $wantedlabel;
131a1603abdSAndreas Gohr        $idx = 1;
1322d8ff844SAndreas Gohr        while (isset($labels[utf8_strtolower($fixedlabel)])) {
133a1603abdSAndreas Gohr            $fixedlabel = $wantedlabel . $idx++;
134a1603abdSAndreas Gohr        }
135a1603abdSAndreas Gohr        // did we actually do a rename? apply it.
136a1603abdSAndreas Gohr        if ($fixedlabel != $wantedlabel) {
137a1603abdSAndreas Gohr            msg(sprintf($this->helper->getLang('duplicate_label'), $wantedlabel, $fixedlabel), -1);
138a1603abdSAndreas Gohr            $this->data['cols']['label'] = $fixedlabel;
139a1603abdSAndreas Gohr        }
1402d8ff844SAndreas Gohr        $labels[utf8_strtolower($fixedlabel)] = 1;
141a1603abdSAndreas Gohr        return $fixedlabel;
142a1603abdSAndreas Gohr    }
143a1603abdSAndreas Gohr
144a1603abdSAndreas Gohr    /**
1451c502704SAndreas Gohr     * Creates a new schema
1461c502704SAndreas Gohr     */
147d6d97f60SAnna Dabrowska    protected function newSchema()
148d6d97f60SAnna Dabrowska    {
1499d580426SAndreas Gohr        if (!$this->time) $this->time = time();
1509d580426SAndreas Gohr
1518f171097SAndreas Gohr        $config = $this->data['config'] ?? '{}';
152127d6bacSMichael Große
15376f0676dSAndreas Gohr        /** @noinspection SqlResolve */
1545b808f9fSAnna Dabrowska        $sql = "INSERT INTO schemas (tbl, ts, user, config) VALUES (?, ?, ?, ?)";
1555b808f9fSAnna Dabrowska        $this->sqlite->query($sql, $this->table, $this->time, $this->user, $config);
1561c502704SAndreas Gohr        $res = $this->sqlite->query('SELECT last_insert_rowid()');
1571c502704SAndreas Gohr        $this->newschemaid = $this->sqlite->res2single($res);
1581c502704SAndreas Gohr        $this->sqlite->res_close($res);
1591c502704SAndreas Gohr        if (!$this->newschemaid) return false;
1601c502704SAndreas Gohr        return true;
1611c502704SAndreas Gohr    }
1621c502704SAndreas Gohr
1631c502704SAndreas Gohr    /**
1641c502704SAndreas Gohr     * Updates all the existing column infos and adds them to the new schema
1651c502704SAndreas Gohr     */
166d6d97f60SAnna Dabrowska    protected function updateColumns()
167d6d97f60SAnna Dabrowska    {
1681c502704SAndreas Gohr        foreach ($this->oldschema->getColumns() as $column) {
1691c502704SAndreas Gohr            $oldEntry = $column->getType()->getAsEntry();
1701c502704SAndreas Gohr            $oldTid = $column->getTid();
1711c502704SAndreas Gohr            $newEntry = $oldEntry;
1721c502704SAndreas Gohr            $newTid = $oldTid;
1731c502704SAndreas Gohr            $sort = $column->getSort();
1741c502704SAndreas Gohr            if (isset($this->data['cols'][$column->getColref()])) {
17517a3a578SAndreas Gohr                // todo I'm not too happy with this hardcoded here -
17617a3a578SAndreas Gohr                // we should probably have a list of fields at one place
1771c502704SAndreas Gohr                $newEntry['config'] = $this->data['cols'][$column->getColref()]['config'];
1781c502704SAndreas Gohr                $newEntry['label'] = $this->data['cols'][$column->getColref()]['label'];
179b7a5ee93SMichael Große                $newEntry['ismulti'] = $this->data['cols'][$column->getColref()]['ismulti'];
1801c502704SAndreas Gohr                $newEntry['class'] = $this->data['cols'][$column->getColref()]['class'];
1811c502704SAndreas Gohr                $sort = $this->data['cols'][$column->getColref()]['sort'];
18226147f8cSAndreas Gohr                $enabled = (bool)$this->data['cols'][$column->getColref()]['isenabled'];
1831c502704SAndreas Gohr
1841c502704SAndreas Gohr                // when the type definition has changed, we create a new one
1851c502704SAndreas Gohr                if (array_diff_assoc($oldEntry, $newEntry)) {
1861c502704SAndreas Gohr                    $ok = $this->sqlite->storeEntry('types', $newEntry);
1871c502704SAndreas Gohr                    if (!$ok) return false;
1881c502704SAndreas Gohr                    $res = $this->sqlite->query('SELECT last_insert_rowid()');
1891c502704SAndreas Gohr                    if (!$res) return false;
1901c502704SAndreas Gohr                    $newTid = $this->sqlite->res2single($res);
1911c502704SAndreas Gohr                    $this->sqlite->res_close($res);
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
2011c502704SAndreas Gohr            $schemaEntry = array(
2021c502704SAndreas Gohr                'sid' => $this->newschemaid,
2031c502704SAndreas Gohr                'colref' => $column->getColref(),
2041c502704SAndreas Gohr                'enabled' => $enabled,
2051c502704SAndreas Gohr                'tid' => $newTid,
2061c502704SAndreas Gohr                'sort' => $sort
2071c502704SAndreas Gohr            );
2081c502704SAndreas Gohr            $ok = $this->sqlite->storeEntry('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";
224b3e0ad6dSMichael Grosse        $res = $this->sqlite->query($sqlSelect);
225b3e0ad6dSMichael Grosse        $valueSet = $this->sqlite->res2arr($res);
226b3e0ad6dSMichael Grosse        $this->sqlite->res_close($res);
227b3e0ad6dSMichael Grosse        $valueString = array();
2284050be94SMichael Grosse        $arguments = array();
229b3e0ad6dSMichael Grosse        foreach ($valueSet as $values) {
230b3e0ad6dSMichael Grosse            if (blank($values['value']) || trim($values['value']) == '') {
231b3e0ad6dSMichael Grosse                continue;
232b3e0ad6dSMichael Grosse            }
233fc6ac2e5SAnna Dabrowska            $valueString[] = "(?, ?, ?, ?, ?, ?)";
234*12d2f24fSAnna Dabrowska            $arguments = array_merge(
235*12d2f24fSAnna Dabrowska                $arguments,
236*12d2f24fSAnna Dabrowska                [$colref, $values['pid'], $values['rev'], $values['published'], 1, $values['value']]
237*12d2f24fSAnna Dabrowska            );
238b3e0ad6dSMichael Grosse        }
239b3e0ad6dSMichael Grosse        if (empty($valueString)) {
240b3e0ad6dSMichael Grosse            return;
241b3e0ad6dSMichael Grosse        }
242b3e0ad6dSMichael Grosse        $valueString = join(',', $valueString);
24376f0676dSAndreas Gohr        /** @noinspection SqlResolve */
244*12d2f24fSAnna Dabrowska        $sqlInsert = "INSERT OR REPLACE INTO multi_$table (colref, pid, rev, published, row, value) VALUES $valueString"; // phpcs:ignore
2454050be94SMichael Grosse        $this->sqlite->query($sqlInsert, $arguments);
246b3e0ad6dSMichael Grosse    }
247b3e0ad6dSMichael Grosse
2481c502704SAndreas Gohr    /**
2491c502704SAndreas Gohr     * Adds new columns to the new schema
2501c502704SAndreas Gohr     *
2511c502704SAndreas Gohr     * @return bool
2521c502704SAndreas Gohr     */
253d6d97f60SAnna Dabrowska    protected function addColumns()
254d6d97f60SAnna Dabrowska    {
2551c502704SAndreas Gohr        if (!isset($this->data['new'])) return true;
2561c502704SAndreas Gohr
2571c502704SAndreas Gohr        $colref = count($this->oldschema->getColumns()) + 1;
2581c502704SAndreas Gohr
2591c502704SAndreas Gohr        foreach ($this->data['new'] as $column) {
26026147f8cSAndreas Gohr            if (!$column['isenabled']) continue; // we do not add a disabled column
26126147f8cSAndreas Gohr
2621c502704SAndreas Gohr            // todo this duplicates the hardcoding as in  the function above
2631c502704SAndreas Gohr            $newEntry = array();
2641ca21e17SAnna Dabrowska            $newEntry['config'] = $column['config'] ?? '{}';
2651c502704SAndreas Gohr            $newEntry['label'] = $column['label'];
266b7a5ee93SMichael Große            $newEntry['ismulti'] = $column['ismulti'];
2671c502704SAndreas Gohr            $newEntry['class'] = $column['class'];
2681c502704SAndreas Gohr            $sort = $column['sort'];
26926147f8cSAndreas Gohr
2701c502704SAndreas Gohr
2716684d78dSAndreas Gohr            // only save if the column got a name
2726684d78dSAndreas Gohr            if (!$newEntry['label']) continue;
2736684d78dSAndreas Gohr
2746684d78dSAndreas Gohr            // add new column to the data table
2756684d78dSAndreas Gohr            if (!$this->addDataTableColumn($colref)) {
2766684d78dSAndreas Gohr                return false;
2776684d78dSAndreas Gohr            }
2786684d78dSAndreas Gohr
2791c502704SAndreas Gohr            // save the type
2801c502704SAndreas Gohr            $ok = $this->sqlite->storeEntry('types', $newEntry);
2811c502704SAndreas Gohr            if (!$ok) return false;
2821c502704SAndreas Gohr            $res = $this->sqlite->query('SELECT last_insert_rowid()');
2831c502704SAndreas Gohr            if (!$res) return false;
2841c502704SAndreas Gohr            $newTid = $this->sqlite->res2single($res);
2851c502704SAndreas Gohr            $this->sqlite->res_close($res);
2861c502704SAndreas Gohr
2871c502704SAndreas Gohr
2881c502704SAndreas Gohr            // add this type to the schema columns
2891c502704SAndreas Gohr            $schemaEntry = array(
2901c502704SAndreas Gohr                'sid' => $this->newschemaid,
2911c502704SAndreas Gohr                'colref' => $colref,
29226147f8cSAndreas Gohr                'enabled' => true,
2931c502704SAndreas Gohr                'tid' => $newTid,
2941c502704SAndreas Gohr                'sort' => $sort
2951c502704SAndreas Gohr            );
2961c502704SAndreas Gohr            $ok = $this->sqlite->storeEntry('schema_cols', $schemaEntry);
2971c502704SAndreas Gohr            if (!$ok) return false;
2981c502704SAndreas Gohr            $colref++;
2991c502704SAndreas Gohr        }
3001c502704SAndreas Gohr
3011c502704SAndreas Gohr        return true;
3021c502704SAndreas Gohr    }
3031c502704SAndreas Gohr
3041c502704SAndreas Gohr    /**
3050fe33e72SAndreas Gohr     * Create a completely new data table with no columns yet also create the appropriate
3060fe33e72SAndreas Gohr     * multi value table for the schema
3071c502704SAndreas Gohr     *
3081c502704SAndreas Gohr     * @return bool
3090549dcc5SAndreas Gohr     * @todo how do we want to handle indexes?
3101c502704SAndreas Gohr     */
311d6d97f60SAnna Dabrowska    protected function newDataTable()
312d6d97f60SAnna Dabrowska    {
3130fe33e72SAndreas Gohr        $ok = true;
3141c502704SAndreas Gohr
3150fe33e72SAndreas Gohr        $tbl = 'data_' . $this->table;
3161c502704SAndreas Gohr        $sql = "CREATE TABLE $tbl (
31786a40c1eSAnna Dabrowska                    pid TEXT DEFAULT '',
3180ceefd5cSAnna Dabrowska                    rid INTEGER,
3190ceefd5cSAnna Dabrowska                    rev INTEGER,
3207059e7e1SAndreas Gohr                    latest BOOLEAN NOT NULL DEFAULT 0,
321fc6ac2e5SAnna Dabrowska                    published BOOLEAN DEFAULT NULL,
3220ceefd5cSAnna Dabrowska                    PRIMARY KEY(pid, rid, rev)
3236684d78dSAndreas Gohr                )";
3240fe33e72SAndreas Gohr        $ok = $ok && (bool)$this->sqlite->query($sql);
3251c502704SAndreas Gohr
3260fe33e72SAndreas Gohr        $tbl = 'multi_' . $this->table;
3270fe33e72SAndreas Gohr        $sql = "CREATE TABLE $tbl (
3280fe33e72SAndreas Gohr                    colref INTEGER NOT NULL,
32986a40c1eSAnna Dabrowska                    pid TEXT DEFAULT '',
3300ceefd5cSAnna Dabrowska                    rid INTEGER,
3310ceefd5cSAnna Dabrowska                    rev INTEGER,
3328f259467SAndreas Gohr                    latest INTEGER NOT NULL DEFAULT 0,
333fc6ac2e5SAnna Dabrowska                    published BOOLEAN DEFAULT NULL,
3340fe33e72SAndreas Gohr                    row INTEGER NOT NULL,
3350fe33e72SAndreas Gohr                    value,
3360ceefd5cSAnna Dabrowska                    PRIMARY KEY(colref, pid, rid, rev, row)
3377c080d69SAndreas Gohr                );";
3387c080d69SAndreas Gohr        $ok = $ok && (bool)$this->sqlite->query($sql);
3397c080d69SAndreas Gohr
3407c080d69SAndreas Gohr        return $ok;
3417c080d69SAndreas Gohr    }
3427c080d69SAndreas Gohr
3437c080d69SAndreas Gohr    /**
3446684d78dSAndreas Gohr     * Add an additional column to the existing data table
3451c502704SAndreas Gohr     *
3466684d78dSAndreas Gohr     * @param int $index the new column index to add
3471c502704SAndreas Gohr     * @return bool
3481c502704SAndreas Gohr     */
349d6d97f60SAnna Dabrowska    protected function addDataTableColumn($index)
350d6d97f60SAnna Dabrowska    {
3511c502704SAndreas Gohr        $tbl = 'data_' . $this->table;
3526684d78dSAndreas Gohr        $sql = " ALTER TABLE $tbl ADD COLUMN col$index DEFAULT ''";
3531c502704SAndreas Gohr        if (!$this->sqlite->query($sql)) {
3541c502704SAndreas Gohr            return false;
3551c502704SAndreas Gohr        }
3561c502704SAndreas Gohr        return true;
3571c502704SAndreas Gohr    }
3581c502704SAndreas Gohr
35978bff02fSMichael Grosse    /**
36078bff02fSMichael Grosse     * @param string $user
36178bff02fSMichael Grosse     * @return SchemaBuilder
36278bff02fSMichael Grosse     */
363d6d97f60SAnna Dabrowska    public function setUser($user)
364d6d97f60SAnna Dabrowska    {
36578bff02fSMichael Grosse        $this->user = $user;
36678bff02fSMichael Grosse        return $this;
36778bff02fSMichael Grosse    }
3681c502704SAndreas Gohr}
369