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