xref: /plugin/struct/meta/SchemaBuilder.php (revision 3a41f42756979739f26dc963a28cd0b588fe4b6f)
11c502704SAndreas Gohr<?php
21c502704SAndreas Gohr
3ba766201SAndreas Gohrnamespace dokuwiki\plugin\struct\meta;
41c502704SAndreas Gohr
5a91bbca2SAndreas Gohruse dokuwiki\Utf8\PhpString;
6a91bbca2SAndreas Gohr
77182938bSAndreas Gohr/**
87182938bSAndreas Gohr * Class SchemaBuilder
97182938bSAndreas Gohr *
107182938bSAndreas Gohr * This class builds and updates the schema definitions for our tables. This includes CREATEing and ALTERing
117182938bSAndreas Gohr * the actual data tables as well as updating the meta information in our meta data tables.
127182938bSAndreas Gohr *
137182938bSAndreas Gohr * To use, simply instantiate a new object of the Builder and run the build() method on it.
147182938bSAndreas Gohr *
157182938bSAndreas Gohr * Note: even though data tables use a data_ prefix in the database, this prefix is internal only and should
167182938bSAndreas Gohr *       never be passed as $table anywhere!
177182938bSAndreas Gohr *
18ba766201SAndreas Gohr * @package dokuwiki\plugin\struct\meta
197182938bSAndreas Gohr */
20d6d97f60SAnna Dabrowskaclass SchemaBuilder
21d6d97f60SAnna Dabrowska{
221c502704SAndreas Gohr    /**
231c502704SAndreas Gohr     * @var array The posted new data for the schema
247182938bSAndreas Gohr     * @see Schema::AdminEditor()
251c502704SAndreas Gohr     */
261c502704SAndreas Gohr    protected $data = array();
271c502704SAndreas Gohr
28fa7b96aaSMichael Grosse    protected $user;
29fa7b96aaSMichael Grosse
301c502704SAndreas Gohr    /**
311c502704SAndreas Gohr     * @var string The table name associated with the schema
321c502704SAndreas Gohr     */
331c502704SAndreas Gohr    protected $table = '';
341c502704SAndreas Gohr
351c502704SAndreas Gohr    /**
361c502704SAndreas Gohr     * @var Schema the previously valid schema for this table
371c502704SAndreas Gohr     */
381c502704SAndreas Gohr    protected $oldschema;
391c502704SAndreas Gohr
401c502704SAndreas Gohr    /** @var int the ID of the newly created schema */
411c502704SAndreas Gohr    protected $newschemaid = 0;
421c502704SAndreas Gohr
43a1603abdSAndreas Gohr    /** @var \helper_plugin_struct_db */
44a1603abdSAndreas Gohr    protected $helper;
45a1603abdSAndreas Gohr
461c502704SAndreas Gohr    /** @var \helper_plugin_sqlite|null */
471c502704SAndreas Gohr    protected $sqlite;
481c502704SAndreas Gohr
499d580426SAndreas Gohr    /** @var int the time for which this schema should be created - default to time() can be overriden for tests */
509d580426SAndreas Gohr    protected $time = 0;
519d580426SAndreas Gohr
521c502704SAndreas Gohr    /**
531c502704SAndreas Gohr     * SchemaBuilder constructor.
547182938bSAndreas Gohr     *
557182938bSAndreas Gohr     * @param string $table The table's name
567182938bSAndreas Gohr     * @param array $data The defining of the table (basically what get's posted in the schema editor form)
577182938bSAndreas Gohr     * @see Schema::AdminEditor()
581c502704SAndreas Gohr     */
59d6d97f60SAnna Dabrowska    public function __construct($table, $data)
60d6d97f60SAnna Dabrowska    {
61ecf2cba2SAndreas Gohr        global $INPUT;
62ecf2cba2SAndreas Gohr
631c502704SAndreas Gohr        $this->table = $table;
641c502704SAndreas Gohr        $this->data = $data;
650ceefd5cSAnna Dabrowska        $this->oldschema = new Schema($table, 0);
661c502704SAndreas Gohr
67a1603abdSAndreas Gohr        $this->helper = plugin_load('helper', 'struct_db');
68a1603abdSAndreas Gohr        $this->sqlite = $this->helper->getDB();
691ca21e17SAnna Dabrowska        $this->user = $_SERVER['REMOTE_USER'] ?? '';
701c502704SAndreas Gohr    }
711c502704SAndreas Gohr
721c502704SAndreas Gohr    /**
731c502704SAndreas Gohr     * Create the new schema
741c502704SAndreas Gohr     *
759d580426SAndreas Gohr     * @param int $time when to create this schema 0 for now
761ca21e17SAnna Dabrowska     * @return int the new schema id on success
771c502704SAndreas Gohr     */
78d6d97f60SAnna Dabrowska    public function build($time = 0)
79d6d97f60SAnna Dabrowska    {
809d580426SAndreas Gohr        $this->time = $time;
81a1603abdSAndreas Gohr        $this->fixLabelUniqueness();
82a1603abdSAndreas Gohr
831c502704SAndreas Gohr        $this->sqlite->query('BEGIN TRANSACTION');
846390a534SMichael Große        $ok = true;
856684d78dSAndreas Gohr        // create the data table if new schema
866684d78dSAndreas Gohr        if (!$this->oldschema->getId()) {
871c502704SAndreas Gohr            $ok = $this->newDataTable();
886684d78dSAndreas Gohr        }
891c502704SAndreas Gohr
901c502704SAndreas Gohr        // create a new schema
916390a534SMichael Große        $ok = $ok && $this->newSchema();
921c502704SAndreas Gohr
931c502704SAndreas Gohr        // update column info
946390a534SMichael Große        $ok = $ok && $this->updateColumns();
956390a534SMichael Große        $ok = $ok && $this->addColumns();
961c502704SAndreas Gohr
976390a534SMichael Große        if (!$ok) {
986390a534SMichael Große            $this->sqlite->query('ROLLBACK TRANSACTION');
996390a534SMichael Große            return false;
1006390a534SMichael Große        }
1011c502704SAndreas Gohr        $this->sqlite->query('COMMIT TRANSACTION');
1021c502704SAndreas Gohr
1035cc23c44SAndreas Gohr        return (int)$this->newschemaid;
1041c502704SAndreas Gohr    }
1051c502704SAndreas Gohr
1061c502704SAndreas Gohr    /**
107a1603abdSAndreas Gohr     * Makes sure all labels in the schema to save are unique
108a1603abdSAndreas Gohr     */
109d6d97f60SAnna Dabrowska    protected function fixLabelUniqueness()
110d6d97f60SAnna Dabrowska    {
111a1603abdSAndreas Gohr        $labels = array();
112a1603abdSAndreas Gohr
113cf25d54bSAndreas Gohr        if (isset($this->data['cols'])) foreach ($this->data['cols'] as $idx => $column) {
114a1603abdSAndreas Gohr            $this->data['cols'][$idx]['label'] = $this->fixLabel($column['label'], $labels);
115a1603abdSAndreas Gohr        }
116a1603abdSAndreas Gohr
117cf25d54bSAndreas Gohr        if (isset($this->data['new'])) foreach ($this->data['new'] as $idx => $column) {
118a1603abdSAndreas Gohr            $this->data['new'][$idx]['label'] = $this->fixLabel($column['label'], $labels);
119a1603abdSAndreas Gohr        }
120a1603abdSAndreas Gohr    }
121a1603abdSAndreas Gohr
122a1603abdSAndreas Gohr    /**
123a1603abdSAndreas Gohr     * Creates a unique label from the given one
124a1603abdSAndreas Gohr     *
125a1603abdSAndreas Gohr     * @param string $wantedlabel
126a1603abdSAndreas Gohr     * @param array $labels list of already assigned labels (will be filled)
127a1603abdSAndreas Gohr     * @return string
128a1603abdSAndreas Gohr     */
129d6d97f60SAnna Dabrowska    protected function fixLabel($wantedlabel, &$labels)
130d6d97f60SAnna Dabrowska    {
131a1603abdSAndreas Gohr        $wantedlabel = trim($wantedlabel);
132a1603abdSAndreas Gohr        $fixedlabel = $wantedlabel;
133a1603abdSAndreas Gohr        $idx = 1;
134a91bbca2SAndreas Gohr        while (isset($labels[PhpString::strtolower($fixedlabel)])) {
135a1603abdSAndreas Gohr            $fixedlabel = $wantedlabel . $idx++;
136a1603abdSAndreas Gohr        }
137a1603abdSAndreas Gohr        // did we actually do a rename? apply it.
138a1603abdSAndreas Gohr        if ($fixedlabel != $wantedlabel) {
139a1603abdSAndreas Gohr            msg(sprintf($this->helper->getLang('duplicate_label'), $wantedlabel, $fixedlabel), -1);
140a1603abdSAndreas Gohr            $this->data['cols']['label'] = $fixedlabel;
141a1603abdSAndreas Gohr        }
142a91bbca2SAndreas Gohr        $labels[PhpString::strtolower($fixedlabel)] = 1;
143a1603abdSAndreas Gohr        return $fixedlabel;
144a1603abdSAndreas Gohr    }
145a1603abdSAndreas Gohr
146a1603abdSAndreas Gohr    /**
1471c502704SAndreas Gohr     * Creates a new schema
1481c502704SAndreas Gohr     */
149d6d97f60SAnna Dabrowska    protected function newSchema()
150d6d97f60SAnna Dabrowska    {
1519d580426SAndreas Gohr        if (!$this->time) $this->time = time();
1529d580426SAndreas Gohr
1538f171097SAndreas Gohr        $config = $this->data['config'] ?? '{}';
154127d6bacSMichael Große
15576f0676dSAndreas Gohr        /** @noinspection SqlResolve */
1565b808f9fSAnna Dabrowska        $sql = "INSERT INTO schemas (tbl, ts, user, config) VALUES (?, ?, ?, ?)";
1575b808f9fSAnna Dabrowska        $this->sqlite->query($sql, $this->table, $this->time, $this->user, $config);
1581c502704SAndreas Gohr        $res = $this->sqlite->query('SELECT last_insert_rowid()');
1591c502704SAndreas Gohr        $this->newschemaid = $this->sqlite->res2single($res);
1601c502704SAndreas Gohr        $this->sqlite->res_close($res);
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'];
181*3a41f427SAnna 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'];
184*3a41f427SAnna 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)) {
1881c502704SAndreas Gohr                    $ok = $this->sqlite->storeEntry('types', $newEntry);
1891c502704SAndreas Gohr                    if (!$ok) return false;
1901c502704SAndreas Gohr                    $res = $this->sqlite->query('SELECT last_insert_rowid()');
1911c502704SAndreas Gohr                    if (!$res) return false;
1921c502704SAndreas Gohr                    $newTid = $this->sqlite->res2single($res);
1931c502704SAndreas Gohr                    $this->sqlite->res_close($res);
194b3e0ad6dSMichael Grosse                    if ($oldEntry['ismulti'] == false && $newEntry['ismulti'] == '1') {
195b3e0ad6dSMichael Grosse                        $this->migrateSingleToMulti($this->oldschema->getTable(), $column->getColref());
196b3e0ad6dSMichael Grosse                    }
1971c502704SAndreas Gohr                }
1981c502704SAndreas Gohr            } else {
19926147f8cSAndreas Gohr                $enabled = false; // no longer there for some reason
2001c502704SAndreas Gohr            }
2011c502704SAndreas Gohr
2021c502704SAndreas Gohr            // add this type to the schema columns
2031c502704SAndreas Gohr            $schemaEntry = array(
2041c502704SAndreas Gohr                'sid' => $this->newschemaid,
2051c502704SAndreas Gohr                'colref' => $column->getColref(),
2061c502704SAndreas Gohr                'enabled' => $enabled,
2071c502704SAndreas Gohr                'tid' => $newTid,
2081c502704SAndreas Gohr                'sort' => $sort
2091c502704SAndreas Gohr            );
2101c502704SAndreas Gohr            $ok = $this->sqlite->storeEntry('schema_cols', $schemaEntry);
2111c502704SAndreas Gohr            if (!$ok) return false;
2121c502704SAndreas Gohr        }
2131c502704SAndreas Gohr        return true;
2141c502704SAndreas Gohr    }
2151c502704SAndreas Gohr
2164050be94SMichael Grosse    /**
2174050be94SMichael Grosse     * Write the latest value from an entry in a data_ table to the corresponding multi_table
2184050be94SMichael Grosse     *
2194050be94SMichael Grosse     * @param string $table
2204050be94SMichael Grosse     * @param int $colref
2214050be94SMichael Grosse     */
222d6d97f60SAnna Dabrowska    protected function migrateSingleToMulti($table, $colref)
223d6d97f60SAnna Dabrowska    {
22476f0676dSAndreas Gohr        /** @noinspection SqlResolve */
225fc6ac2e5SAnna Dabrowska        $sqlSelect = "SELECT pid, rev, published, col$colref AS value FROM data_$table WHERE latest = 1";
226b3e0ad6dSMichael Grosse        $res = $this->sqlite->query($sqlSelect);
227b3e0ad6dSMichael Grosse        $valueSet = $this->sqlite->res2arr($res);
228b3e0ad6dSMichael Grosse        $this->sqlite->res_close($res);
229b3e0ad6dSMichael Grosse        $valueString = array();
2304050be94SMichael Grosse        $arguments = array();
231b3e0ad6dSMichael Grosse        foreach ($valueSet as $values) {
232b3e0ad6dSMichael Grosse            if (blank($values['value']) || trim($values['value']) == '') {
233b3e0ad6dSMichael Grosse                continue;
234b3e0ad6dSMichael Grosse            }
235fc6ac2e5SAnna Dabrowska            $valueString[] = "(?, ?, ?, ?, ?, ?)";
23612d2f24fSAnna Dabrowska            $arguments = array_merge(
23712d2f24fSAnna Dabrowska                $arguments,
23812d2f24fSAnna Dabrowska                [$colref, $values['pid'], $values['rev'], $values['published'], 1, $values['value']]
23912d2f24fSAnna Dabrowska            );
240b3e0ad6dSMichael Grosse        }
241b3e0ad6dSMichael Grosse        if (empty($valueString)) {
242b3e0ad6dSMichael Grosse            return;
243b3e0ad6dSMichael Grosse        }
244b3e0ad6dSMichael Grosse        $valueString = join(',', $valueString);
24576f0676dSAndreas Gohr        /** @noinspection SqlResolve */
24612d2f24fSAnna Dabrowska        $sqlInsert = "INSERT OR REPLACE INTO multi_$table (colref, pid, rev, published, row, value) VALUES $valueString"; // phpcs:ignore
2474050be94SMichael Grosse        $this->sqlite->query($sqlInsert, $arguments);
248b3e0ad6dSMichael Grosse    }
249b3e0ad6dSMichael Grosse
2501c502704SAndreas Gohr    /**
2511c502704SAndreas Gohr     * Adds new columns to the new schema
2521c502704SAndreas Gohr     *
2531c502704SAndreas Gohr     * @return bool
2541c502704SAndreas Gohr     */
255d6d97f60SAnna Dabrowska    protected function addColumns()
256d6d97f60SAnna Dabrowska    {
2571c502704SAndreas Gohr        if (!isset($this->data['new'])) return true;
2581c502704SAndreas Gohr
2591c502704SAndreas Gohr        $colref = count($this->oldschema->getColumns()) + 1;
2601c502704SAndreas Gohr
2611c502704SAndreas Gohr        foreach ($this->data['new'] as $column) {
26226147f8cSAndreas Gohr            if (!$column['isenabled']) continue; // we do not add a disabled column
26326147f8cSAndreas Gohr
2641c502704SAndreas Gohr            // todo this duplicates the hardcoding as in  the function above
2651c502704SAndreas Gohr            $newEntry = array();
2661ca21e17SAnna Dabrowska            $newEntry['config'] = $column['config'] ?? '{}';
2671c502704SAndreas Gohr            $newEntry['label'] = $column['label'];
268*3a41f427SAnna Dabrowska            $newEntry['ismulti'] = $column['ismulti'] ?? 0;
2691c502704SAndreas Gohr            $newEntry['class'] = $column['class'];
2701c502704SAndreas Gohr            $sort = $column['sort'];
27126147f8cSAndreas Gohr
2721c502704SAndreas Gohr
2736684d78dSAndreas Gohr            // only save if the column got a name
2746684d78dSAndreas Gohr            if (!$newEntry['label']) continue;
2756684d78dSAndreas Gohr
2766684d78dSAndreas Gohr            // add new column to the data table
2776684d78dSAndreas Gohr            if (!$this->addDataTableColumn($colref)) {
2786684d78dSAndreas Gohr                return false;
2796684d78dSAndreas Gohr            }
2806684d78dSAndreas Gohr
2811c502704SAndreas Gohr            // save the type
2821c502704SAndreas Gohr            $ok = $this->sqlite->storeEntry('types', $newEntry);
2831c502704SAndreas Gohr            if (!$ok) return false;
2841c502704SAndreas Gohr            $res = $this->sqlite->query('SELECT last_insert_rowid()');
2851c502704SAndreas Gohr            if (!$res) return false;
2861c502704SAndreas Gohr            $newTid = $this->sqlite->res2single($res);
2871c502704SAndreas Gohr            $this->sqlite->res_close($res);
2881c502704SAndreas Gohr
2891c502704SAndreas Gohr
2901c502704SAndreas Gohr            // add this type to the schema columns
2911c502704SAndreas Gohr            $schemaEntry = array(
2921c502704SAndreas Gohr                'sid' => $this->newschemaid,
2931c502704SAndreas Gohr                'colref' => $colref,
29426147f8cSAndreas Gohr                'enabled' => true,
2951c502704SAndreas Gohr                'tid' => $newTid,
2961c502704SAndreas Gohr                'sort' => $sort
2971c502704SAndreas Gohr            );
2981c502704SAndreas Gohr            $ok = $this->sqlite->storeEntry('schema_cols', $schemaEntry);
2991c502704SAndreas Gohr            if (!$ok) return false;
3001c502704SAndreas Gohr            $colref++;
3011c502704SAndreas Gohr        }
3021c502704SAndreas Gohr
3031c502704SAndreas Gohr        return true;
3041c502704SAndreas Gohr    }
3051c502704SAndreas Gohr
3061c502704SAndreas Gohr    /**
3070fe33e72SAndreas Gohr     * Create a completely new data table with no columns yet also create the appropriate
3080fe33e72SAndreas Gohr     * multi value table for the schema
3091c502704SAndreas Gohr     *
3101c502704SAndreas Gohr     * @return bool
3110549dcc5SAndreas Gohr     * @todo how do we want to handle indexes?
3121c502704SAndreas Gohr     */
313d6d97f60SAnna Dabrowska    protected function newDataTable()
314d6d97f60SAnna Dabrowska    {
3150fe33e72SAndreas Gohr        $ok = true;
3161c502704SAndreas Gohr
3170fe33e72SAndreas Gohr        $tbl = 'data_' . $this->table;
3181c502704SAndreas Gohr        $sql = "CREATE TABLE $tbl (
31986a40c1eSAnna Dabrowska                    pid TEXT DEFAULT '',
3200ceefd5cSAnna Dabrowska                    rid INTEGER,
3210ceefd5cSAnna Dabrowska                    rev INTEGER,
3227059e7e1SAndreas Gohr                    latest BOOLEAN NOT NULL DEFAULT 0,
323fc6ac2e5SAnna Dabrowska                    published BOOLEAN DEFAULT NULL,
3240ceefd5cSAnna Dabrowska                    PRIMARY KEY(pid, rid, rev)
3256684d78dSAndreas Gohr                )";
3260fe33e72SAndreas Gohr        $ok = $ok && (bool)$this->sqlite->query($sql);
3271c502704SAndreas Gohr
3280fe33e72SAndreas Gohr        $tbl = 'multi_' . $this->table;
3290fe33e72SAndreas Gohr        $sql = "CREATE TABLE $tbl (
3300fe33e72SAndreas Gohr                    colref INTEGER NOT NULL,
33186a40c1eSAnna Dabrowska                    pid TEXT DEFAULT '',
3320ceefd5cSAnna Dabrowska                    rid INTEGER,
3330ceefd5cSAnna Dabrowska                    rev INTEGER,
3348f259467SAndreas Gohr                    latest INTEGER NOT NULL DEFAULT 0,
335fc6ac2e5SAnna Dabrowska                    published BOOLEAN DEFAULT NULL,
3360fe33e72SAndreas Gohr                    row INTEGER NOT NULL,
3370fe33e72SAndreas Gohr                    value,
3380ceefd5cSAnna Dabrowska                    PRIMARY KEY(colref, pid, rid, rev, row)
3397c080d69SAndreas Gohr                );";
3407c080d69SAndreas Gohr        $ok = $ok && (bool)$this->sqlite->query($sql);
3417c080d69SAndreas Gohr
3427c080d69SAndreas Gohr        return $ok;
3437c080d69SAndreas Gohr    }
3447c080d69SAndreas Gohr
3457c080d69SAndreas Gohr    /**
3466684d78dSAndreas Gohr     * Add an additional column to the existing data table
3471c502704SAndreas Gohr     *
3486684d78dSAndreas Gohr     * @param int $index the new column index to add
3491c502704SAndreas Gohr     * @return bool
3501c502704SAndreas Gohr     */
351d6d97f60SAnna Dabrowska    protected function addDataTableColumn($index)
352d6d97f60SAnna Dabrowska    {
3531c502704SAndreas Gohr        $tbl = 'data_' . $this->table;
3546684d78dSAndreas Gohr        $sql = " ALTER TABLE $tbl ADD COLUMN col$index DEFAULT ''";
3551c502704SAndreas Gohr        if (!$this->sqlite->query($sql)) {
3561c502704SAndreas Gohr            return false;
3571c502704SAndreas Gohr        }
3581c502704SAndreas Gohr        return true;
3591c502704SAndreas Gohr    }
3601c502704SAndreas Gohr
36178bff02fSMichael Grosse    /**
36278bff02fSMichael Grosse     * @param string $user
36378bff02fSMichael Grosse     * @return SchemaBuilder
36478bff02fSMichael Grosse     */
365d6d97f60SAnna Dabrowska    public function setUser($user)
366d6d97f60SAnna Dabrowska    {
36778bff02fSMichael Grosse        $this->user = $user;
36878bff02fSMichael Grosse        return $this;
36978bff02fSMichael Grosse    }
3701c502704SAndreas Gohr}
371