xref: /plugin/struct/meta/SchemaBuilder.php (revision 322ec97bd5f382df0c695c1ac56e1af10442ab66)
1<?php
2
3namespace dokuwiki\plugin\struct\meta;
4
5/**
6 * Class SchemaBuilder
7 *
8 * This class builds and updates the schema definitions for our tables. This includes CREATEing and ALTERing
9 * the actual data tables as well as updating the meta information in our meta data tables.
10 *
11 * To use, simply instantiate a new object of the Builder and run the build() method on it.
12 *
13 * Note: even though data tables use a data_ prefix in the database, this prefix is internal only and should
14 *       never be passed as $table anywhere!
15 *
16 * @package dokuwiki\plugin\struct\meta
17 */
18class SchemaBuilder
19{
20    /**
21     * @var array The posted new data for the schema
22     * @see Schema::AdminEditor()
23     */
24    protected $data = array();
25
26    protected $user;
27
28    /**
29     * @var string The table name associated with the schema
30     */
31    protected $table = '';
32
33    /**
34     * @var Schema the previously valid schema for this table
35     */
36    protected $oldschema;
37
38    /** @var int the ID of the newly created schema */
39    protected $newschemaid = 0;
40
41    /** @var \helper_plugin_struct_db */
42    protected $helper;
43
44    /** @var \helper_plugin_sqlite|null */
45    protected $sqlite;
46
47    /** @var int the time for which this schema should be created - default to time() can be overriden for tests */
48    protected $time = 0;
49
50    /**
51     * SchemaBuilder constructor.
52     *
53     * @param string $table The table's name
54     * @param array $data The defining of the table (basically what get's posted in the schema editor form)
55     * @see Schema::AdminEditor()
56     */
57    public function __construct($table, $data)
58    {
59        global $INPUT;
60
61        $this->table = $table;
62        $this->data = $data;
63        $this->oldschema = new Schema($table, 0);
64
65        $this->helper = plugin_load('helper', 'struct_db');
66        $this->sqlite = $this->helper->getDB();
67        $this->user = $_SERVER['REMOTE_USER'] ?? '';
68    }
69
70    /**
71     * Create the new schema
72     *
73     * @param int $time when to create this schema 0 for now
74     * @return int the new schema id on success
75     */
76    public function build($time = 0)
77    {
78        $this->time = $time;
79        $this->fixLabelUniqueness();
80
81        $this->sqlite->query('BEGIN TRANSACTION');
82        $ok = true;
83        // create the data table if new schema
84        if (!$this->oldschema->getId()) {
85            $ok = $this->newDataTable();
86        }
87
88        // create a new schema
89        $ok = $ok && $this->newSchema();
90
91        // update column info
92        $ok = $ok && $this->updateColumns();
93        $ok = $ok && $this->addColumns();
94
95        if (!$ok) {
96            $this->sqlite->query('ROLLBACK TRANSACTION');
97            return false;
98        }
99        $this->sqlite->query('COMMIT TRANSACTION');
100
101        return (int)$this->newschemaid;
102    }
103
104    /**
105     * Makes sure all labels in the schema to save are unique
106     */
107    protected function fixLabelUniqueness()
108    {
109        $labels = array();
110
111        if (isset($this->data['cols'])) foreach ($this->data['cols'] as $idx => $column) {
112            $this->data['cols'][$idx]['label'] = $this->fixLabel($column['label'], $labels);
113        }
114
115        if (isset($this->data['new'])) foreach ($this->data['new'] as $idx => $column) {
116            $this->data['new'][$idx]['label'] = $this->fixLabel($column['label'], $labels);
117        }
118    }
119
120    /**
121     * Creates a unique label from the given one
122     *
123     * @param string $wantedlabel
124     * @param array $labels list of already assigned labels (will be filled)
125     * @return string
126     */
127    protected function fixLabel($wantedlabel, &$labels)
128    {
129        $wantedlabel = trim($wantedlabel);
130        $fixedlabel = $wantedlabel;
131        $idx = 1;
132        while (isset($labels[utf8_strtolower($fixedlabel)])) {
133            $fixedlabel = $wantedlabel . $idx++;
134        }
135        // did we actually do a rename? apply it.
136        if ($fixedlabel != $wantedlabel) {
137            msg(sprintf($this->helper->getLang('duplicate_label'), $wantedlabel, $fixedlabel), -1);
138            $this->data['cols']['label'] = $fixedlabel;
139        }
140        $labels[utf8_strtolower($fixedlabel)] = 1;
141        return $fixedlabel;
142    }
143
144    /**
145     * Creates a new schema
146     */
147    protected function newSchema()
148    {
149        if (!$this->time) $this->time = time();
150
151        $config = $this->data['config'] ?? '{}';
152
153        /** @noinspection SqlResolve */
154        $sql = "INSERT INTO schemas (tbl, ts, user, config) VALUES (?, ?, ?, ?)";
155        $this->sqlite->query($sql, $this->table, $this->time, $this->user, $config);
156        $res = $this->sqlite->query('SELECT last_insert_rowid()');
157        $this->newschemaid = $this->sqlite->res2single($res);
158        $this->sqlite->res_close($res);
159        if (!$this->newschemaid) return false;
160        return true;
161    }
162
163    /**
164     * Updates all the existing column infos and adds them to the new schema
165     */
166    protected function updateColumns()
167    {
168        foreach ($this->oldschema->getColumns() as $column) {
169            $oldEntry = $column->getType()->getAsEntry();
170            $oldTid = $column->getTid();
171            $newEntry = $oldEntry;
172            $newTid = $oldTid;
173            $sort = $column->getSort();
174            if (isset($this->data['cols'][$column->getColref()])) {
175                // todo I'm not too happy with this hardcoded here -
176                // we should probably have a list of fields at one place
177                $newEntry['config'] = $this->data['cols'][$column->getColref()]['config'];
178                $newEntry['label'] = $this->data['cols'][$column->getColref()]['label'];
179                $newEntry['ismulti'] = $this->data['cols'][$column->getColref()]['ismulti'];
180                $newEntry['class'] = $this->data['cols'][$column->getColref()]['class'];
181                $sort = $this->data['cols'][$column->getColref()]['sort'];
182                $enabled = (bool)$this->data['cols'][$column->getColref()]['isenabled'];
183
184                // when the type definition has changed, we create a new one
185                if (array_diff_assoc($oldEntry, $newEntry)) {
186                    $ok = $this->sqlite->storeEntry('types', $newEntry);
187                    if (!$ok) return false;
188                    $res = $this->sqlite->query('SELECT last_insert_rowid()');
189                    if (!$res) return false;
190                    $newTid = $this->sqlite->res2single($res);
191                    $this->sqlite->res_close($res);
192                    if ($oldEntry['ismulti'] == false && $newEntry['ismulti'] == '1') {
193                        $this->migrateSingleToMulti($this->oldschema->getTable(), $column->getColref());
194                    }
195                }
196            } else {
197                $enabled = false; // no longer there for some reason
198            }
199
200            // add this type to the schema columns
201            $schemaEntry = array(
202                'sid' => $this->newschemaid,
203                'colref' => $column->getColref(),
204                'enabled' => $enabled,
205                'tid' => $newTid,
206                'sort' => $sort
207            );
208            $ok = $this->sqlite->storeEntry('schema_cols', $schemaEntry);
209            if (!$ok) return false;
210        }
211        return true;
212    }
213
214    /**
215     * Write the latest value from an entry in a data_ table to the corresponding multi_table
216     *
217     * @param string $table
218     * @param int $colref
219     */
220    protected function migrateSingleToMulti($table, $colref)
221    {
222        /** @noinspection SqlResolve */
223        $sqlSelect = "SELECT pid, rev, published, col$colref AS value FROM data_$table WHERE latest = 1";
224        $res = $this->sqlite->query($sqlSelect);
225        $valueSet = $this->sqlite->res2arr($res);
226        $this->sqlite->res_close($res);
227        $valueString = array();
228        $arguments = array();
229        foreach ($valueSet as $values) {
230            if (blank($values['value']) || trim($values['value']) == '') {
231                continue;
232            }
233            $valueString[] = "(?, ?, ?, ?, ?, ?)";
234            $arguments = array_merge(
235                $arguments,
236                [$colref, $values['pid'], $values['rev'], $values['published'], 1, $values['value']]
237            );
238        }
239        if (empty($valueString)) {
240            return;
241        }
242        $valueString = join(',', $valueString);
243        /** @noinspection SqlResolve */
244        $sqlInsert = "INSERT OR REPLACE INTO multi_$table (colref, pid, rev, published, row, value) VALUES $valueString"; // phpcs:ignore
245        $this->sqlite->query($sqlInsert, $arguments);
246    }
247
248    /**
249     * Adds new columns to the new schema
250     *
251     * @return bool
252     */
253    protected function addColumns()
254    {
255        if (!isset($this->data['new'])) return true;
256
257        $colref = count($this->oldschema->getColumns()) + 1;
258
259        foreach ($this->data['new'] as $column) {
260            if (!$column['isenabled']) continue; // we do not add a disabled column
261
262            // todo this duplicates the hardcoding as in  the function above
263            $newEntry = array();
264            $newEntry['config'] = $column['config'] ?? '{}';
265            $newEntry['label'] = $column['label'];
266            $newEntry['ismulti'] = $column['ismulti'];
267            $newEntry['class'] = $column['class'];
268            $sort = $column['sort'];
269
270
271            // only save if the column got a name
272            if (!$newEntry['label']) continue;
273
274            // add new column to the data table
275            if (!$this->addDataTableColumn($colref)) {
276                return false;
277            }
278
279            // save the type
280            $ok = $this->sqlite->storeEntry('types', $newEntry);
281            if (!$ok) return false;
282            $res = $this->sqlite->query('SELECT last_insert_rowid()');
283            if (!$res) return false;
284            $newTid = $this->sqlite->res2single($res);
285            $this->sqlite->res_close($res);
286
287
288            // add this type to the schema columns
289            $schemaEntry = array(
290                'sid' => $this->newschemaid,
291                'colref' => $colref,
292                'enabled' => true,
293                'tid' => $newTid,
294                'sort' => $sort
295            );
296            $ok = $this->sqlite->storeEntry('schema_cols', $schemaEntry);
297            if (!$ok) return false;
298            $colref++;
299        }
300
301        return true;
302    }
303
304    /**
305     * Create a completely new data table with no columns yet also create the appropriate
306     * multi value table for the schema
307     *
308     * @return bool
309     * @todo how do we want to handle indexes?
310     */
311    protected function newDataTable()
312    {
313        $ok = true;
314
315        $tbl = 'data_' . $this->table;
316        $sql = "CREATE TABLE $tbl (
317                    pid TEXT DEFAULT '',
318                    rid INTEGER,
319                    rev INTEGER,
320                    latest BOOLEAN NOT NULL DEFAULT 0,
321                    published BOOLEAN DEFAULT NULL,
322                    PRIMARY KEY(pid, rid, rev)
323                )";
324        $ok = $ok && (bool)$this->sqlite->query($sql);
325
326        $tbl = 'multi_' . $this->table;
327        $sql = "CREATE TABLE $tbl (
328                    colref INTEGER NOT NULL,
329                    pid TEXT DEFAULT '',
330                    rid INTEGER,
331                    rev INTEGER,
332                    latest INTEGER NOT NULL DEFAULT 0,
333                    published BOOLEAN DEFAULT NULL,
334                    row INTEGER NOT NULL,
335                    value,
336                    PRIMARY KEY(colref, pid, rid, rev, row)
337                );";
338        $ok = $ok && (bool)$this->sqlite->query($sql);
339
340        return $ok;
341    }
342
343    /**
344     * Add an additional column to the existing data table
345     *
346     * @param int $index the new column index to add
347     * @return bool
348     */
349    protected function addDataTableColumn($index)
350    {
351        $tbl = 'data_' . $this->table;
352        $sql = " ALTER TABLE $tbl ADD COLUMN col$index DEFAULT ''";
353        if (!$this->sqlite->query($sql)) {
354            return false;
355        }
356        return true;
357    }
358
359    /**
360     * @param string $user
361     * @return SchemaBuilder
362     */
363    public function setUser($user)
364    {
365        $this->user = $user;
366        return $this;
367    }
368}
369