xref: /plugin/struct/meta/SchemaBuilder.php (revision 0549dcc5bc88d4f9d923acdd09931d8d51be7097)
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    {
591c502704SAndreas Gohr        $this->table = $table;
601c502704SAndreas Gohr        $this->data = $data;
610ceefd5cSAnna Dabrowska        $this->oldschema = new Schema($table, 0);
621c502704SAndreas Gohr
63a1603abdSAndreas Gohr        $this->helper = plugin_load('helper', 'struct_db');
64a1603abdSAndreas Gohr        $this->sqlite = $this->helper->getDB();
6512fe9b2cSMichael Grosse        $this->user = $_SERVER['REMOTE_USER'];
661c502704SAndreas Gohr    }
671c502704SAndreas Gohr
681c502704SAndreas Gohr    /**
691c502704SAndreas Gohr     * Create the new schema
701c502704SAndreas Gohr     *
719d580426SAndreas Gohr     * @param int $time when to create this schema 0 for now
721c502704SAndreas Gohr     * @return bool|int the new schema id on success
731c502704SAndreas Gohr     */
74d6d97f60SAnna Dabrowska    public function build($time = 0)
75d6d97f60SAnna Dabrowska    {
769d580426SAndreas Gohr        $this->time = $time;
77a1603abdSAndreas Gohr        $this->fixLabelUniqueness();
78a1603abdSAndreas Gohr
791c502704SAndreas Gohr        $this->sqlite->query('BEGIN TRANSACTION');
806390a534SMichael Große        $ok = true;
816684d78dSAndreas Gohr        // create the data table if new schema
826684d78dSAndreas Gohr        if (!$this->oldschema->getId()) {
831c502704SAndreas Gohr            $ok = $this->newDataTable();
846684d78dSAndreas Gohr        }
851c502704SAndreas Gohr
861c502704SAndreas Gohr        // create a new schema
876390a534SMichael Große        $ok = $ok && $this->newSchema();
881c502704SAndreas Gohr
891c502704SAndreas Gohr        // update column info
906390a534SMichael Große        $ok = $ok && $this->updateColumns();
916390a534SMichael Große        $ok = $ok && $this->addColumns();
921c502704SAndreas Gohr
936390a534SMichael Große        if (!$ok) {
946390a534SMichael Große            $this->sqlite->query('ROLLBACK TRANSACTION');
956390a534SMichael Große            return false;
966390a534SMichael Große        }
971c502704SAndreas Gohr        $this->sqlite->query('COMMIT TRANSACTION');
981c502704SAndreas Gohr
991c502704SAndreas Gohr        return $this->newschemaid;
1001c502704SAndreas Gohr    }
1011c502704SAndreas Gohr
1021c502704SAndreas Gohr    /**
103a1603abdSAndreas Gohr     * Makes sure all labels in the schema to save are unique
104a1603abdSAndreas Gohr     */
105d6d97f60SAnna Dabrowska    protected function fixLabelUniqueness()
106d6d97f60SAnna Dabrowska    {
107a1603abdSAndreas Gohr        $labels = array();
108a1603abdSAndreas Gohr
109cf25d54bSAndreas Gohr        if (isset($this->data['cols'])) foreach ($this->data['cols'] as $idx => $column) {
110a1603abdSAndreas Gohr            $this->data['cols'][$idx]['label'] = $this->fixLabel($column['label'], $labels);
111a1603abdSAndreas Gohr        }
112a1603abdSAndreas Gohr
113cf25d54bSAndreas Gohr        if (isset($this->data['new'])) foreach ($this->data['new'] as $idx => $column) {
114a1603abdSAndreas Gohr            $this->data['new'][$idx]['label'] = $this->fixLabel($column['label'], $labels);
115a1603abdSAndreas Gohr        }
116a1603abdSAndreas Gohr    }
117a1603abdSAndreas Gohr
118a1603abdSAndreas Gohr    /**
119a1603abdSAndreas Gohr     * Creates a unique label from the given one
120a1603abdSAndreas Gohr     *
121a1603abdSAndreas Gohr     * @param string $wantedlabel
122a1603abdSAndreas Gohr     * @param array $labels list of already assigned labels (will be filled)
123a1603abdSAndreas Gohr     * @return string
124a1603abdSAndreas Gohr     */
125d6d97f60SAnna Dabrowska    protected function fixLabel($wantedlabel, &$labels)
126d6d97f60SAnna Dabrowska    {
127a1603abdSAndreas Gohr        $wantedlabel = trim($wantedlabel);
128a1603abdSAndreas Gohr        $fixedlabel = $wantedlabel;
129a1603abdSAndreas Gohr        $idx = 1;
1302d8ff844SAndreas Gohr        while (isset($labels[utf8_strtolower($fixedlabel)])) {
131a1603abdSAndreas Gohr            $fixedlabel = $wantedlabel . $idx++;
132a1603abdSAndreas Gohr        }
133a1603abdSAndreas Gohr        // did we actually do a rename? apply it.
134a1603abdSAndreas Gohr        if ($fixedlabel != $wantedlabel) {
135a1603abdSAndreas Gohr            msg(sprintf($this->helper->getLang('duplicate_label'), $wantedlabel, $fixedlabel), -1);
136a1603abdSAndreas Gohr            $this->data['cols']['label'] = $fixedlabel;
137a1603abdSAndreas Gohr        }
1382d8ff844SAndreas Gohr        $labels[utf8_strtolower($fixedlabel)] = 1;
139a1603abdSAndreas Gohr        return $fixedlabel;
140a1603abdSAndreas Gohr    }
141a1603abdSAndreas Gohr
142a1603abdSAndreas Gohr    /**
1431c502704SAndreas Gohr     * Creates a new schema
1441c502704SAndreas Gohr     */
145d6d97f60SAnna Dabrowska    protected function newSchema()
146d6d97f60SAnna Dabrowska    {
1479d580426SAndreas Gohr        if (!$this->time) $this->time = time();
1489d580426SAndreas Gohr
149127d6bacSMichael Große        $config = $this->data['config'] ?: '{}';
150127d6bacSMichael Große
15176f0676dSAndreas Gohr        /** @noinspection SqlResolve */
1525b808f9fSAnna Dabrowska        $sql = "INSERT INTO schemas (tbl, ts, user, config) VALUES (?, ?, ?, ?)";
1535b808f9fSAnna Dabrowska        $this->sqlite->query($sql, $this->table, $this->time, $this->user, $config);
1541c502704SAndreas Gohr        $res = $this->sqlite->query('SELECT last_insert_rowid()');
1551c502704SAndreas Gohr        $this->newschemaid = $this->sqlite->res2single($res);
1561c502704SAndreas Gohr        $this->sqlite->res_close($res);
1571c502704SAndreas Gohr        if (!$this->newschemaid) return false;
1581c502704SAndreas Gohr        return true;
1591c502704SAndreas Gohr    }
1601c502704SAndreas Gohr
1611c502704SAndreas Gohr    /**
1621c502704SAndreas Gohr     * Updates all the existing column infos and adds them to the new schema
1631c502704SAndreas Gohr     */
164d6d97f60SAnna Dabrowska    protected function updateColumns()
165d6d97f60SAnna Dabrowska    {
1661c502704SAndreas Gohr        foreach ($this->oldschema->getColumns() as $column) {
1671c502704SAndreas Gohr            $oldEntry = $column->getType()->getAsEntry();
1681c502704SAndreas Gohr            $oldTid = $column->getTid();
1691c502704SAndreas Gohr            $newEntry = $oldEntry;
1701c502704SAndreas Gohr            $newTid = $oldTid;
1711c502704SAndreas Gohr            $sort = $column->getSort();
1721c502704SAndreas Gohr            if (isset($this->data['cols'][$column->getColref()])) {
1731c502704SAndreas Gohr                // todo I'm not too happy with this hardcoded here - we should probably have a list of fields at one place
1741c502704SAndreas Gohr                $newEntry['config'] = $this->data['cols'][$column->getColref()]['config'];
1751c502704SAndreas Gohr                $newEntry['label'] = $this->data['cols'][$column->getColref()]['label'];
176b7a5ee93SMichael Große                $newEntry['ismulti'] = $this->data['cols'][$column->getColref()]['ismulti'];
1771c502704SAndreas Gohr                $newEntry['class'] = $this->data['cols'][$column->getColref()]['class'];
1781c502704SAndreas Gohr                $sort = $this->data['cols'][$column->getColref()]['sort'];
17926147f8cSAndreas Gohr                $enabled = (bool)$this->data['cols'][$column->getColref()]['isenabled'];
1801c502704SAndreas Gohr
1811c502704SAndreas Gohr                // when the type definition has changed, we create a new one
1821c502704SAndreas Gohr                if (array_diff_assoc($oldEntry, $newEntry)) {
1831c502704SAndreas Gohr                    $ok = $this->sqlite->storeEntry('types', $newEntry);
1841c502704SAndreas Gohr                    if (!$ok) return false;
1851c502704SAndreas Gohr                    $res = $this->sqlite->query('SELECT last_insert_rowid()');
1861c502704SAndreas Gohr                    if (!$res) return false;
1871c502704SAndreas Gohr                    $newTid = $this->sqlite->res2single($res);
1881c502704SAndreas Gohr                    $this->sqlite->res_close($res);
189b3e0ad6dSMichael Grosse                    if ($oldEntry['ismulti'] == false && $newEntry['ismulti'] == '1') {
190b3e0ad6dSMichael Grosse                        $this->migrateSingleToMulti($this->oldschema->getTable(), $column->getColref());
191b3e0ad6dSMichael Grosse                    }
1921c502704SAndreas Gohr                }
1931c502704SAndreas Gohr            } else {
19426147f8cSAndreas Gohr                $enabled = false; // no longer there for some reason
1951c502704SAndreas Gohr            }
1961c502704SAndreas Gohr
1971c502704SAndreas Gohr            // add this type to the schema columns
1981c502704SAndreas Gohr            $schemaEntry = array(
1991c502704SAndreas Gohr                'sid' => $this->newschemaid,
2001c502704SAndreas Gohr                'colref' => $column->getColref(),
2011c502704SAndreas Gohr                'enabled' => $enabled,
2021c502704SAndreas Gohr                'tid' => $newTid,
2031c502704SAndreas Gohr                'sort' => $sort
2041c502704SAndreas Gohr            );
2051c502704SAndreas Gohr            $ok = $this->sqlite->storeEntry('schema_cols', $schemaEntry);
2061c502704SAndreas Gohr            if (!$ok) return false;
2071c502704SAndreas Gohr        }
2081c502704SAndreas Gohr        return true;
2091c502704SAndreas Gohr    }
2101c502704SAndreas Gohr
2114050be94SMichael Grosse    /**
2124050be94SMichael Grosse     * Write the latest value from an entry in a data_ table to the corresponding multi_table
2134050be94SMichael Grosse     *
2144050be94SMichael Grosse     * @param string $table
2154050be94SMichael Grosse     * @param int $colref
2164050be94SMichael Grosse     */
217d6d97f60SAnna Dabrowska    protected function migrateSingleToMulti($table, $colref)
218d6d97f60SAnna Dabrowska    {
21976f0676dSAndreas Gohr        /** @noinspection SqlResolve */
220b3e0ad6dSMichael Grosse        $sqlSelect = "SELECT pid, rev, col$colref AS value FROM data_$table WHERE latest = 1";
221b3e0ad6dSMichael Grosse        $res = $this->sqlite->query($sqlSelect);
222b3e0ad6dSMichael Grosse        $valueSet = $this->sqlite->res2arr($res);
223b3e0ad6dSMichael Grosse        $this->sqlite->res_close($res);
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            }
2304050be94SMichael Grosse            $valueString[] = "(?, ?, ?, ?, ?)";
2314050be94SMichael Grosse            $arguments = array_merge($arguments, array($colref, $values['pid'], $values['rev'], 1, $values['value']));
232b3e0ad6dSMichael Grosse        }
233b3e0ad6dSMichael Grosse        if (empty($valueString)) {
234b3e0ad6dSMichael Grosse            return;
235b3e0ad6dSMichael Grosse        }
236b3e0ad6dSMichael Grosse        $valueString = join(',', $valueString);
23776f0676dSAndreas Gohr        /** @noinspection SqlResolve */
238b3e0ad6dSMichael Grosse        $sqlInsert = "INSERT OR REPLACE INTO multi_$table (colref, pid, rev, row, value) VALUES $valueString";
2394050be94SMichael Grosse        $this->sqlite->query($sqlInsert, $arguments);
240b3e0ad6dSMichael Grosse    }
241b3e0ad6dSMichael Grosse
2421c502704SAndreas Gohr    /**
2431c502704SAndreas Gohr     * Adds new columns to the new schema
2441c502704SAndreas Gohr     *
2451c502704SAndreas Gohr     * @return bool
2461c502704SAndreas Gohr     */
247d6d97f60SAnna Dabrowska    protected function addColumns()
248d6d97f60SAnna Dabrowska    {
2491c502704SAndreas Gohr        if (!isset($this->data['new'])) return true;
2501c502704SAndreas Gohr
2511c502704SAndreas Gohr        $colref = count($this->oldschema->getColumns()) + 1;
2521c502704SAndreas Gohr
2531c502704SAndreas Gohr        foreach ($this->data['new'] as $column) {
25426147f8cSAndreas Gohr            if (!$column['isenabled']) continue; // we do not add a disabled column
25526147f8cSAndreas Gohr
2561c502704SAndreas Gohr            // todo this duplicates the hardcoding as in  the function above
2571c502704SAndreas Gohr            $newEntry = array();
2581c502704SAndreas Gohr            $newEntry['config'] = $column['config'];
2591c502704SAndreas Gohr            $newEntry['label'] = $column['label'];
260b7a5ee93SMichael Große            $newEntry['ismulti'] = $column['ismulti'];
2611c502704SAndreas Gohr            $newEntry['class'] = $column['class'];
2621c502704SAndreas Gohr            $sort = $column['sort'];
26326147f8cSAndreas Gohr
2641c502704SAndreas Gohr
2656684d78dSAndreas Gohr            // only save if the column got a name
2666684d78dSAndreas Gohr            if (!$newEntry['label']) continue;
2676684d78dSAndreas Gohr
2686684d78dSAndreas Gohr            // add new column to the data table
2696684d78dSAndreas Gohr            if (!$this->addDataTableColumn($colref)) {
2706684d78dSAndreas Gohr                return false;
2716684d78dSAndreas Gohr            }
2726684d78dSAndreas Gohr
2731c502704SAndreas Gohr            // save the type
2741c502704SAndreas Gohr            $ok = $this->sqlite->storeEntry('types', $newEntry);
2751c502704SAndreas Gohr            if (!$ok) return false;
2761c502704SAndreas Gohr            $res = $this->sqlite->query('SELECT last_insert_rowid()');
2771c502704SAndreas Gohr            if (!$res) return false;
2781c502704SAndreas Gohr            $newTid = $this->sqlite->res2single($res);
2791c502704SAndreas Gohr            $this->sqlite->res_close($res);
2801c502704SAndreas Gohr
2811c502704SAndreas Gohr
2821c502704SAndreas Gohr            // add this type to the schema columns
2831c502704SAndreas Gohr            $schemaEntry = array(
2841c502704SAndreas Gohr                'sid' => $this->newschemaid,
2851c502704SAndreas Gohr                'colref' => $colref,
28626147f8cSAndreas Gohr                'enabled' => true,
2871c502704SAndreas Gohr                'tid' => $newTid,
2881c502704SAndreas Gohr                'sort' => $sort
2891c502704SAndreas Gohr            );
2901c502704SAndreas Gohr            $ok = $this->sqlite->storeEntry('schema_cols', $schemaEntry);
2911c502704SAndreas Gohr            if (!$ok) return false;
2921c502704SAndreas Gohr            $colref++;
2931c502704SAndreas Gohr        }
2941c502704SAndreas Gohr
2951c502704SAndreas Gohr        return true;
2961c502704SAndreas Gohr    }
2971c502704SAndreas Gohr
2981c502704SAndreas Gohr    /**
2990fe33e72SAndreas Gohr     * Create a completely new data table with no columns yet also create the appropriate
3000fe33e72SAndreas Gohr     * multi value table for the schema
3011c502704SAndreas Gohr     *
3021c502704SAndreas Gohr     * @return bool
303*0549dcc5SAndreas Gohr     * @todo how do we want to handle indexes?
3041c502704SAndreas Gohr     */
305d6d97f60SAnna Dabrowska    protected function newDataTable()
306d6d97f60SAnna Dabrowska    {
3070fe33e72SAndreas Gohr        $ok = true;
3081c502704SAndreas Gohr
3090fe33e72SAndreas Gohr        $tbl = 'data_' . $this->table;
3101c502704SAndreas Gohr        $sql = "CREATE TABLE $tbl (
31186a40c1eSAnna Dabrowska                    pid TEXT DEFAULT '',
3120ceefd5cSAnna Dabrowska                    rid INTEGER,
3130ceefd5cSAnna Dabrowska                    rev INTEGER,
3147059e7e1SAndreas Gohr                    latest BOOLEAN NOT NULL DEFAULT 0,
3150ceefd5cSAnna Dabrowska                    PRIMARY KEY(pid, rid, rev)
3166684d78dSAndreas Gohr                )";
3170fe33e72SAndreas Gohr        $ok = $ok && (bool)$this->sqlite->query($sql);
3181c502704SAndreas Gohr
3190fe33e72SAndreas Gohr        $tbl = 'multi_' . $this->table;
3200fe33e72SAndreas Gohr        $sql = "CREATE TABLE $tbl (
3210fe33e72SAndreas Gohr                    colref INTEGER NOT NULL,
32286a40c1eSAnna Dabrowska                    pid TEXT DEFAULT '',
3230ceefd5cSAnna Dabrowska                    rid INTEGER,
3240ceefd5cSAnna Dabrowska                    rev INTEGER,
3258f259467SAndreas Gohr                    latest INTEGER NOT NULL DEFAULT 0,
3260fe33e72SAndreas Gohr                    row INTEGER NOT NULL,
3270fe33e72SAndreas Gohr                    value,
3280ceefd5cSAnna Dabrowska                    PRIMARY KEY(colref, pid, rid, rev, row)
3297c080d69SAndreas Gohr                );";
3307c080d69SAndreas Gohr        $ok = $ok && (bool)$this->sqlite->query($sql);
3317c080d69SAndreas Gohr
3327c080d69SAndreas Gohr        return $ok;
3337c080d69SAndreas Gohr    }
3347c080d69SAndreas Gohr
3357c080d69SAndreas Gohr    /**
3366684d78dSAndreas Gohr     * Add an additional column to the existing data table
3371c502704SAndreas Gohr     *
3386684d78dSAndreas Gohr     * @param int $index the new column index to add
3391c502704SAndreas Gohr     * @return bool
3401c502704SAndreas Gohr     */
341d6d97f60SAnna Dabrowska    protected function addDataTableColumn($index)
342d6d97f60SAnna Dabrowska    {
3431c502704SAndreas Gohr        $tbl = 'data_' . $this->table;
3446684d78dSAndreas Gohr        $sql = " ALTER TABLE $tbl ADD COLUMN col$index DEFAULT ''";
3451c502704SAndreas Gohr        if (!$this->sqlite->query($sql)) {
3461c502704SAndreas Gohr            return false;
3471c502704SAndreas Gohr        }
3481c502704SAndreas Gohr        return true;
3491c502704SAndreas Gohr    }
3501c502704SAndreas Gohr
35178bff02fSMichael Grosse    /**
35278bff02fSMichael Grosse     * @param string $user
35378bff02fSMichael Grosse     * @return SchemaBuilder
35478bff02fSMichael Grosse     */
355d6d97f60SAnna Dabrowska    public function setUser($user)
356d6d97f60SAnna Dabrowska    {
35778bff02fSMichael Grosse        $this->user = $user;
35878bff02fSMichael Grosse        return $this;
35978bff02fSMichael Grosse    }
3601c502704SAndreas Gohr}
361