xref: /plugin/struct/meta/SchemaBuilder.php (revision fc6ac2e560a8d00924bd642434731ce9aa18206d)
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 */
223*fc6ac2e5SAnna 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            }
233*fc6ac2e5SAnna Dabrowska            $valueString[] = "(?, ?, ?, ?, ?, ?)";
234*fc6ac2e5SAnna Dabrowska            $arguments = array_merge($arguments, array($colref, $values['pid'], $values['rev'], $values['published'], 1, $values['value']));
235b3e0ad6dSMichael Grosse        }
236b3e0ad6dSMichael Grosse        if (empty($valueString)) {
237b3e0ad6dSMichael Grosse            return;
238b3e0ad6dSMichael Grosse        }
239b3e0ad6dSMichael Grosse        $valueString = join(',', $valueString);
24076f0676dSAndreas Gohr        /** @noinspection SqlResolve */
241*fc6ac2e5SAnna Dabrowska        $sqlInsert = "INSERT OR REPLACE INTO multi_$table (colref, pid, rev, published, row, value) VALUES $valueString";
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'];
263b7a5ee93SMichael Große            $newEntry['ismulti'] = $column['ismulti'];
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
2771c502704SAndreas Gohr            $ok = $this->sqlite->storeEntry('types', $newEntry);
2781c502704SAndreas Gohr            if (!$ok) return false;
2791c502704SAndreas Gohr            $res = $this->sqlite->query('SELECT last_insert_rowid()');
2801c502704SAndreas Gohr            if (!$res) return false;
2811c502704SAndreas Gohr            $newTid = $this->sqlite->res2single($res);
2821c502704SAndreas Gohr            $this->sqlite->res_close($res);
2831c502704SAndreas Gohr
2841c502704SAndreas Gohr
2851c502704SAndreas Gohr            // add this type to the schema columns
2861c502704SAndreas Gohr            $schemaEntry = array(
2871c502704SAndreas Gohr                'sid' => $this->newschemaid,
2881c502704SAndreas Gohr                'colref' => $colref,
28926147f8cSAndreas Gohr                'enabled' => true,
2901c502704SAndreas Gohr                'tid' => $newTid,
2911c502704SAndreas Gohr                'sort' => $sort
2921c502704SAndreas Gohr            );
2931c502704SAndreas Gohr            $ok = $this->sqlite->storeEntry('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,
318*fc6ac2e5SAnna 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,
330*fc6ac2e5SAnna 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