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