xref: /plugin/struct/meta/SchemaBuilder.php (revision d6d97f6064c3b0f90310be8341edc9585520ee54)
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 */
18*d6d97f60SAnna Dabrowskaclass SchemaBuilder
19*d6d97f60SAnna Dabrowska{
201c502704SAndreas Gohr
211c502704SAndreas Gohr    /**
221c502704SAndreas Gohr     * @var array The posted new data for the schema
237182938bSAndreas Gohr     * @see Schema::AdminEditor()
241c502704SAndreas Gohr     */
251c502704SAndreas Gohr    protected $data = array();
261c502704SAndreas Gohr
27fa7b96aaSMichael Grosse    protected $user;
28fa7b96aaSMichael Grosse
291c502704SAndreas Gohr    /**
301c502704SAndreas Gohr     * @var string The table name associated with the schema
311c502704SAndreas Gohr     */
321c502704SAndreas Gohr    protected $table = '';
331c502704SAndreas Gohr
341c502704SAndreas Gohr    /**
351c502704SAndreas Gohr     * @var Schema the previously valid schema for this table
361c502704SAndreas Gohr     */
371c502704SAndreas Gohr    protected $oldschema;
381c502704SAndreas Gohr
391c502704SAndreas Gohr    /** @var int the ID of the newly created schema */
401c502704SAndreas Gohr    protected $newschemaid = 0;
411c502704SAndreas Gohr
42a1603abdSAndreas Gohr    /** @var \helper_plugin_struct_db */
43a1603abdSAndreas Gohr    protected $helper;
44a1603abdSAndreas Gohr
451c502704SAndreas Gohr    /** @var \helper_plugin_sqlite|null  */
461c502704SAndreas Gohr    protected $sqlite;
471c502704SAndreas Gohr
489d580426SAndreas Gohr    /** @var int the time for which this schema should be created - default to time() can be overriden for tests */
499d580426SAndreas Gohr    protected $time = 0;
509d580426SAndreas Gohr
511c502704SAndreas Gohr    /**
521c502704SAndreas Gohr     * SchemaBuilder constructor.
537182938bSAndreas Gohr     *
547182938bSAndreas Gohr     * @param string $table The table's name
557182938bSAndreas Gohr     * @param array $data The defining of the table (basically what get's posted in the schema editor form)
567182938bSAndreas Gohr     * @see Schema::AdminEditor()
571c502704SAndreas Gohr     */
58*d6d97f60SAnna Dabrowska    public function __construct($table, $data)
59*d6d97f60SAnna Dabrowska    {
601c502704SAndreas Gohr        $this->table = $table;
611c502704SAndreas Gohr        $this->data = $data;
620ceefd5cSAnna Dabrowska        $this->oldschema = new Schema($table, 0);
631c502704SAndreas Gohr
64a1603abdSAndreas Gohr        $this->helper = plugin_load('helper', 'struct_db');
65a1603abdSAndreas Gohr        $this->sqlite = $this->helper->getDB();
6612fe9b2cSMichael Grosse        $this->user = $_SERVER['REMOTE_USER'];
671c502704SAndreas Gohr    }
681c502704SAndreas Gohr
691c502704SAndreas Gohr    /**
701c502704SAndreas Gohr     * Create the new schema
711c502704SAndreas Gohr     *
729d580426SAndreas Gohr     * @param int $time when to create this schema 0 for now
731c502704SAndreas Gohr     * @return bool|int the new schema id on success
741c502704SAndreas Gohr     */
75*d6d97f60SAnna Dabrowska    public function build($time = 0)
76*d6d97f60SAnna Dabrowska    {
779d580426SAndreas Gohr        $this->time = $time;
78a1603abdSAndreas Gohr        $this->fixLabelUniqueness();
79a1603abdSAndreas Gohr
801c502704SAndreas Gohr        $this->sqlite->query('BEGIN TRANSACTION');
816390a534SMichael Große        $ok = true;
826684d78dSAndreas Gohr        // create the data table if new schema
836684d78dSAndreas Gohr        if (!$this->oldschema->getId()) {
841c502704SAndreas Gohr            $ok = $this->newDataTable();
856684d78dSAndreas Gohr        }
861c502704SAndreas Gohr
871c502704SAndreas Gohr        // create a new schema
886390a534SMichael Große        $ok = $ok && $this->newSchema();
891c502704SAndreas Gohr
901c502704SAndreas Gohr        // update column info
916390a534SMichael Große        $ok = $ok && $this->updateColumns();
926390a534SMichael Große        $ok = $ok && $this->addColumns();
931c502704SAndreas Gohr
946390a534SMichael Große        if (!$ok) {
956390a534SMichael Große            $this->sqlite->query('ROLLBACK TRANSACTION');
966390a534SMichael Große            return false;
976390a534SMichael Große        }
981c502704SAndreas Gohr        $this->sqlite->query('COMMIT TRANSACTION');
991c502704SAndreas Gohr
1001c502704SAndreas Gohr        return $this->newschemaid;
1011c502704SAndreas Gohr    }
1021c502704SAndreas Gohr
1031c502704SAndreas Gohr    /**
104a1603abdSAndreas Gohr     * Makes sure all labels in the schema to save are unique
105a1603abdSAndreas Gohr     */
106*d6d97f60SAnna Dabrowska    protected function fixLabelUniqueness()
107*d6d97f60SAnna Dabrowska    {
108a1603abdSAndreas Gohr        $labels = array();
109a1603abdSAndreas Gohr
110cf25d54bSAndreas Gohr        if (isset($this->data['cols'])) foreach ($this->data['cols'] as $idx => $column) {
111a1603abdSAndreas Gohr            $this->data['cols'][$idx]['label'] = $this->fixLabel($column['label'], $labels);
112a1603abdSAndreas Gohr        }
113a1603abdSAndreas Gohr
114cf25d54bSAndreas Gohr        if (isset($this->data['new'])) foreach ($this->data['new'] as $idx => $column) {
115a1603abdSAndreas Gohr            $this->data['new'][$idx]['label'] = $this->fixLabel($column['label'], $labels);
116a1603abdSAndreas Gohr        }
117a1603abdSAndreas Gohr    }
118a1603abdSAndreas Gohr
119a1603abdSAndreas Gohr    /**
120a1603abdSAndreas Gohr     * Creates a unique label from the given one
121a1603abdSAndreas Gohr     *
122a1603abdSAndreas Gohr     * @param string $wantedlabel
123a1603abdSAndreas Gohr     * @param array $labels list of already assigned labels (will be filled)
124a1603abdSAndreas Gohr     * @return string
125a1603abdSAndreas Gohr     */
126*d6d97f60SAnna Dabrowska    protected function fixLabel($wantedlabel, &$labels)
127*d6d97f60SAnna Dabrowska    {
128a1603abdSAndreas Gohr        $wantedlabel = trim($wantedlabel);
129a1603abdSAndreas Gohr        $fixedlabel = $wantedlabel;
130a1603abdSAndreas Gohr        $idx = 1;
1312d8ff844SAndreas Gohr        while (isset($labels[utf8_strtolower($fixedlabel)])) {
132a1603abdSAndreas Gohr            $fixedlabel = $wantedlabel . $idx++;
133a1603abdSAndreas Gohr        }
134a1603abdSAndreas Gohr        // did we actually do a rename? apply it.
135a1603abdSAndreas Gohr        if ($fixedlabel != $wantedlabel) {
136a1603abdSAndreas Gohr            msg(sprintf($this->helper->getLang('duplicate_label'), $wantedlabel, $fixedlabel), -1);
137a1603abdSAndreas Gohr            $this->data['cols']['label'] = $fixedlabel;
138a1603abdSAndreas Gohr        }
1392d8ff844SAndreas Gohr        $labels[utf8_strtolower($fixedlabel)] = 1;
140a1603abdSAndreas Gohr        return $fixedlabel;
141a1603abdSAndreas Gohr    }
142a1603abdSAndreas Gohr
143a1603abdSAndreas Gohr    /**
1441c502704SAndreas Gohr     * Creates a new schema
1451c502704SAndreas Gohr     */
146*d6d97f60SAnna Dabrowska    protected function newSchema()
147*d6d97f60SAnna Dabrowska    {
1489d580426SAndreas Gohr        if (!$this->time) $this->time = time();
1499d580426SAndreas Gohr
150127d6bacSMichael Große        $config = $this->data['config'] ?: '{}';
151127d6bacSMichael Große
15276f0676dSAndreas Gohr        /** @noinspection SqlResolve */
153127d6bacSMichael Große        $sql = "INSERT INTO schemas (tbl, ts, islookup, user, config) VALUES (?, ?, ?, ?, ?)";
1540ceefd5cSAnna Dabrowska        // FIXME magic 0 for islookup
1550ceefd5cSAnna Dabrowska        $this->sqlite->query($sql, $this->table, $this->time, 0, $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     */
166*d6d97f60SAnna Dabrowska    protected function updateColumns()
167*d6d97f60SAnna 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()])) {
1751c502704SAndreas Gohr                // todo I'm not too happy with this hardcoded here - we should probably have a list of fields at one place
1761c502704SAndreas Gohr                $newEntry['config'] = $this->data['cols'][$column->getColref()]['config'];
1771c502704SAndreas Gohr                $newEntry['label'] = $this->data['cols'][$column->getColref()]['label'];
178b7a5ee93SMichael Große                $newEntry['ismulti'] = $this->data['cols'][$column->getColref()]['ismulti'];
1791c502704SAndreas Gohr                $newEntry['class'] = $this->data['cols'][$column->getColref()]['class'];
1801c502704SAndreas Gohr                $sort = $this->data['cols'][$column->getColref()]['sort'];
18126147f8cSAndreas Gohr                $enabled = (bool) $this->data['cols'][$column->getColref()]['isenabled'];
1821c502704SAndreas Gohr
1831c502704SAndreas Gohr                // when the type definition has changed, we create a new one
1841c502704SAndreas Gohr                if (array_diff_assoc($oldEntry, $newEntry)) {
1851c502704SAndreas Gohr                    $ok = $this->sqlite->storeEntry('types', $newEntry);
1861c502704SAndreas Gohr                    if (!$ok) return false;
1871c502704SAndreas Gohr                    $res = $this->sqlite->query('SELECT last_insert_rowid()');
1881c502704SAndreas Gohr                    if (!$res) return false;
1891c502704SAndreas Gohr                    $newTid = $this->sqlite->res2single($res);
1901c502704SAndreas Gohr                    $this->sqlite->res_close($res);
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            );
2071c502704SAndreas Gohr            $ok = $this->sqlite->storeEntry('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     */
219*d6d97f60SAnna Dabrowska    protected function migrateSingleToMulti($table, $colref)
220*d6d97f60SAnna Dabrowska    {
22176f0676dSAndreas Gohr        /** @noinspection SqlResolve */
222b3e0ad6dSMichael Grosse        $sqlSelect = "SELECT pid, rev, col$colref AS value FROM data_$table WHERE latest = 1";
223b3e0ad6dSMichael Grosse        $res = $this->sqlite->query($sqlSelect);
224b3e0ad6dSMichael Grosse        $valueSet = $this->sqlite->res2arr($res);
225b3e0ad6dSMichael Grosse        $this->sqlite->res_close($res);
226b3e0ad6dSMichael Grosse        $valueString = array();
2274050be94SMichael Grosse        $arguments = array();
228b3e0ad6dSMichael Grosse        foreach ($valueSet as $values) {
229b3e0ad6dSMichael Grosse            if (blank($values['value']) || trim($values['value']) == '') {
230b3e0ad6dSMichael Grosse                continue;
231b3e0ad6dSMichael Grosse            }
2324050be94SMichael Grosse            $valueString[] = "(?, ?, ?, ?, ?)";
2334050be94SMichael Grosse            $arguments = array_merge($arguments, array($colref, $values['pid'], $values['rev'], 1, $values['value']));
234b3e0ad6dSMichael Grosse        }
235b3e0ad6dSMichael Grosse        if (empty($valueString)) {
236b3e0ad6dSMichael Grosse            return;
237b3e0ad6dSMichael Grosse        }
238b3e0ad6dSMichael Grosse        $valueString = join(',', $valueString);
23976f0676dSAndreas Gohr        /** @noinspection SqlResolve */
240b3e0ad6dSMichael Grosse        $sqlInsert = "INSERT OR REPLACE INTO multi_$table (colref, pid, rev, row, value) VALUES $valueString";
2414050be94SMichael Grosse        $this->sqlite->query($sqlInsert, $arguments);
242b3e0ad6dSMichael Grosse    }
243b3e0ad6dSMichael Grosse
2441c502704SAndreas Gohr    /**
2451c502704SAndreas Gohr     * Adds new columns to the new schema
2461c502704SAndreas Gohr     *
2471c502704SAndreas Gohr     * @return bool
2481c502704SAndreas Gohr     */
249*d6d97f60SAnna Dabrowska    protected function addColumns()
250*d6d97f60SAnna Dabrowska    {
2511c502704SAndreas Gohr        if (!isset($this->data['new'])) return true;
2521c502704SAndreas Gohr
2531c502704SAndreas Gohr        $colref = count($this->oldschema->getColumns()) + 1;
2541c502704SAndreas Gohr
2551c502704SAndreas Gohr        foreach ($this->data['new'] as $column) {
25626147f8cSAndreas Gohr            if (!$column['isenabled']) continue; // we do not add a disabled column
25726147f8cSAndreas Gohr
2581c502704SAndreas Gohr            // todo this duplicates the hardcoding as in  the function above
2591c502704SAndreas Gohr            $newEntry = array();
2601c502704SAndreas Gohr            $newEntry['config'] = $column['config'];
2611c502704SAndreas Gohr            $newEntry['label'] = $column['label'];
262b7a5ee93SMichael Große            $newEntry['ismulti'] = $column['ismulti'];
2631c502704SAndreas Gohr            $newEntry['class'] = $column['class'];
2641c502704SAndreas Gohr            $sort = $column['sort'];
26526147f8cSAndreas Gohr
2661c502704SAndreas Gohr
2676684d78dSAndreas Gohr            // only save if the column got a name
2686684d78dSAndreas Gohr            if (!$newEntry['label']) continue;
2696684d78dSAndreas Gohr
2706684d78dSAndreas Gohr            // add new column to the data table
2716684d78dSAndreas Gohr            if (!$this->addDataTableColumn($colref)) {
2726684d78dSAndreas Gohr                return false;
2736684d78dSAndreas Gohr            }
2746684d78dSAndreas Gohr
2751c502704SAndreas Gohr            // save the type
2761c502704SAndreas Gohr            $ok = $this->sqlite->storeEntry('types', $newEntry);
2771c502704SAndreas Gohr            if (!$ok) return false;
2781c502704SAndreas Gohr            $res = $this->sqlite->query('SELECT last_insert_rowid()');
2791c502704SAndreas Gohr            if (!$res) return false;
2801c502704SAndreas Gohr            $newTid = $this->sqlite->res2single($res);
2811c502704SAndreas Gohr            $this->sqlite->res_close($res);
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            );
2921c502704SAndreas Gohr            $ok = $this->sqlite->storeEntry('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     * @todo how do we want to handle indexes?
3051c502704SAndreas Gohr     * @return bool
3061c502704SAndreas Gohr     */
307*d6d97f60SAnna Dabrowska    protected function newDataTable()
308*d6d97f60SAnna 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,
3170ceefd5cSAnna Dabrowska                    PRIMARY KEY(pid, rid, rev)
3186684d78dSAndreas Gohr                )";
3190fe33e72SAndreas Gohr        $ok = $ok && (bool) $this->sqlite->query($sql);
3201c502704SAndreas Gohr
3210fe33e72SAndreas Gohr        $tbl = 'multi_' . $this->table;
3220fe33e72SAndreas Gohr        $sql = "CREATE TABLE $tbl (
3230fe33e72SAndreas Gohr                    colref INTEGER NOT NULL,
32486a40c1eSAnna Dabrowska                    pid TEXT DEFAULT '',
3250ceefd5cSAnna Dabrowska                    rid INTEGER,
3260ceefd5cSAnna Dabrowska                    rev INTEGER,
3278f259467SAndreas Gohr                    latest INTEGER NOT NULL DEFAULT 0,
3280fe33e72SAndreas Gohr                    row INTEGER NOT NULL,
3290fe33e72SAndreas Gohr                    value,
3300ceefd5cSAnna Dabrowska                    PRIMARY KEY(colref, pid, rid, rev, row)
3317c080d69SAndreas Gohr                );";
3327c080d69SAndreas Gohr        $ok = $ok && (bool) $this->sqlite->query($sql);
3337c080d69SAndreas Gohr
3347c080d69SAndreas Gohr        return $ok;
3357c080d69SAndreas Gohr    }
3367c080d69SAndreas Gohr
3377c080d69SAndreas Gohr    /**
3386684d78dSAndreas Gohr     * Add an additional column to the existing data table
3391c502704SAndreas Gohr     *
3406684d78dSAndreas Gohr     * @param int $index the new column index to add
3411c502704SAndreas Gohr     * @return bool
3421c502704SAndreas Gohr     */
343*d6d97f60SAnna Dabrowska    protected function addDataTableColumn($index)
344*d6d97f60SAnna Dabrowska    {
3451c502704SAndreas Gohr        $tbl = 'data_' . $this->table;
3466684d78dSAndreas Gohr        $sql = " ALTER TABLE $tbl ADD COLUMN col$index DEFAULT ''";
3471c502704SAndreas Gohr        if (! $this->sqlite->query($sql)) {
3481c502704SAndreas Gohr            return false;
3491c502704SAndreas Gohr        }
3501c502704SAndreas Gohr        return true;
3511c502704SAndreas Gohr    }
3521c502704SAndreas Gohr
35378bff02fSMichael Grosse    /**
35478bff02fSMichael Grosse     * @param string $user
35578bff02fSMichael Grosse     * @return SchemaBuilder
35678bff02fSMichael Grosse     */
357*d6d97f60SAnna Dabrowska    public function setUser($user)
358*d6d97f60SAnna Dabrowska    {
35978bff02fSMichael Grosse        $this->user = $user;
36078bff02fSMichael Grosse        return $this;
36178bff02fSMichael Grosse    }
3621c502704SAndreas Gohr}
363