xref: /plugin/struct/meta/SchemaBuilder.php (revision 0549dcc5bc88d4f9d923acdd09931d8d51be7097)
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        $this->table = $table;
60        $this->data = $data;
61        $this->oldschema = new Schema($table, 0);
62
63        $this->helper = plugin_load('helper', 'struct_db');
64        $this->sqlite = $this->helper->getDB();
65        $this->user = $_SERVER['REMOTE_USER'];
66    }
67
68    /**
69     * Create the new schema
70     *
71     * @param int $time when to create this schema 0 for now
72     * @return bool|int the new schema id on success
73     */
74    public function build($time = 0)
75    {
76        $this->time = $time;
77        $this->fixLabelUniqueness();
78
79        $this->sqlite->query('BEGIN TRANSACTION');
80        $ok = true;
81        // create the data table if new schema
82        if (!$this->oldschema->getId()) {
83            $ok = $this->newDataTable();
84        }
85
86        // create a new schema
87        $ok = $ok && $this->newSchema();
88
89        // update column info
90        $ok = $ok && $this->updateColumns();
91        $ok = $ok && $this->addColumns();
92
93        if (!$ok) {
94            $this->sqlite->query('ROLLBACK TRANSACTION');
95            return false;
96        }
97        $this->sqlite->query('COMMIT TRANSACTION');
98
99        return $this->newschemaid;
100    }
101
102    /**
103     * Makes sure all labels in the schema to save are unique
104     */
105    protected function fixLabelUniqueness()
106    {
107        $labels = array();
108
109        if (isset($this->data['cols'])) foreach ($this->data['cols'] as $idx => $column) {
110            $this->data['cols'][$idx]['label'] = $this->fixLabel($column['label'], $labels);
111        }
112
113        if (isset($this->data['new'])) foreach ($this->data['new'] as $idx => $column) {
114            $this->data['new'][$idx]['label'] = $this->fixLabel($column['label'], $labels);
115        }
116    }
117
118    /**
119     * Creates a unique label from the given one
120     *
121     * @param string $wantedlabel
122     * @param array $labels list of already assigned labels (will be filled)
123     * @return string
124     */
125    protected function fixLabel($wantedlabel, &$labels)
126    {
127        $wantedlabel = trim($wantedlabel);
128        $fixedlabel = $wantedlabel;
129        $idx = 1;
130        while (isset($labels[utf8_strtolower($fixedlabel)])) {
131            $fixedlabel = $wantedlabel . $idx++;
132        }
133        // did we actually do a rename? apply it.
134        if ($fixedlabel != $wantedlabel) {
135            msg(sprintf($this->helper->getLang('duplicate_label'), $wantedlabel, $fixedlabel), -1);
136            $this->data['cols']['label'] = $fixedlabel;
137        }
138        $labels[utf8_strtolower($fixedlabel)] = 1;
139        return $fixedlabel;
140    }
141
142    /**
143     * Creates a new schema
144     */
145    protected function newSchema()
146    {
147        if (!$this->time) $this->time = time();
148
149        $config = $this->data['config'] ?: '{}';
150
151        /** @noinspection SqlResolve */
152        $sql = "INSERT INTO schemas (tbl, ts, user, config) VALUES (?, ?, ?, ?)";
153        $this->sqlite->query($sql, $this->table, $this->time, $this->user, $config);
154        $res = $this->sqlite->query('SELECT last_insert_rowid()');
155        $this->newschemaid = $this->sqlite->res2single($res);
156        $this->sqlite->res_close($res);
157        if (!$this->newschemaid) return false;
158        return true;
159    }
160
161    /**
162     * Updates all the existing column infos and adds them to the new schema
163     */
164    protected function updateColumns()
165    {
166        foreach ($this->oldschema->getColumns() as $column) {
167            $oldEntry = $column->getType()->getAsEntry();
168            $oldTid = $column->getTid();
169            $newEntry = $oldEntry;
170            $newTid = $oldTid;
171            $sort = $column->getSort();
172            if (isset($this->data['cols'][$column->getColref()])) {
173                // todo I'm not too happy with this hardcoded here - we should probably have a list of fields at one place
174                $newEntry['config'] = $this->data['cols'][$column->getColref()]['config'];
175                $newEntry['label'] = $this->data['cols'][$column->getColref()]['label'];
176                $newEntry['ismulti'] = $this->data['cols'][$column->getColref()]['ismulti'];
177                $newEntry['class'] = $this->data['cols'][$column->getColref()]['class'];
178                $sort = $this->data['cols'][$column->getColref()]['sort'];
179                $enabled = (bool)$this->data['cols'][$column->getColref()]['isenabled'];
180
181                // when the type definition has changed, we create a new one
182                if (array_diff_assoc($oldEntry, $newEntry)) {
183                    $ok = $this->sqlite->storeEntry('types', $newEntry);
184                    if (!$ok) return false;
185                    $res = $this->sqlite->query('SELECT last_insert_rowid()');
186                    if (!$res) return false;
187                    $newTid = $this->sqlite->res2single($res);
188                    $this->sqlite->res_close($res);
189                    if ($oldEntry['ismulti'] == false && $newEntry['ismulti'] == '1') {
190                        $this->migrateSingleToMulti($this->oldschema->getTable(), $column->getColref());
191                    }
192                }
193            } else {
194                $enabled = false; // no longer there for some reason
195            }
196
197            // add this type to the schema columns
198            $schemaEntry = array(
199                'sid' => $this->newschemaid,
200                'colref' => $column->getColref(),
201                'enabled' => $enabled,
202                'tid' => $newTid,
203                'sort' => $sort
204            );
205            $ok = $this->sqlite->storeEntry('schema_cols', $schemaEntry);
206            if (!$ok) return false;
207        }
208        return true;
209    }
210
211    /**
212     * Write the latest value from an entry in a data_ table to the corresponding multi_table
213     *
214     * @param string $table
215     * @param int $colref
216     */
217    protected function migrateSingleToMulti($table, $colref)
218    {
219        /** @noinspection SqlResolve */
220        $sqlSelect = "SELECT pid, rev, col$colref AS value FROM data_$table WHERE latest = 1";
221        $res = $this->sqlite->query($sqlSelect);
222        $valueSet = $this->sqlite->res2arr($res);
223        $this->sqlite->res_close($res);
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($arguments, array($colref, $values['pid'], $values['rev'], 1, $values['value']));
232        }
233        if (empty($valueString)) {
234            return;
235        }
236        $valueString = join(',', $valueString);
237        /** @noinspection SqlResolve */
238        $sqlInsert = "INSERT OR REPLACE INTO multi_$table (colref, pid, rev, row, value) VALUES $valueString";
239        $this->sqlite->query($sqlInsert, $arguments);
240    }
241
242    /**
243     * Adds new columns to the new schema
244     *
245     * @return bool
246     */
247    protected function addColumns()
248    {
249        if (!isset($this->data['new'])) return true;
250
251        $colref = count($this->oldschema->getColumns()) + 1;
252
253        foreach ($this->data['new'] as $column) {
254            if (!$column['isenabled']) continue; // we do not add a disabled column
255
256            // todo this duplicates the hardcoding as in  the function above
257            $newEntry = array();
258            $newEntry['config'] = $column['config'];
259            $newEntry['label'] = $column['label'];
260            $newEntry['ismulti'] = $column['ismulti'];
261            $newEntry['class'] = $column['class'];
262            $sort = $column['sort'];
263
264
265            // only save if the column got a name
266            if (!$newEntry['label']) continue;
267
268            // add new column to the data table
269            if (!$this->addDataTableColumn($colref)) {
270                return false;
271            }
272
273            // save the type
274            $ok = $this->sqlite->storeEntry('types', $newEntry);
275            if (!$ok) return false;
276            $res = $this->sqlite->query('SELECT last_insert_rowid()');
277            if (!$res) return false;
278            $newTid = $this->sqlite->res2single($res);
279            $this->sqlite->res_close($res);
280
281
282            // add this type to the schema columns
283            $schemaEntry = array(
284                'sid' => $this->newschemaid,
285                'colref' => $colref,
286                'enabled' => true,
287                'tid' => $newTid,
288                'sort' => $sort
289            );
290            $ok = $this->sqlite->storeEntry('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                    PRIMARY KEY(pid, rid, rev)
316                )";
317        $ok = $ok && (bool)$this->sqlite->query($sql);
318
319        $tbl = 'multi_' . $this->table;
320        $sql = "CREATE TABLE $tbl (
321                    colref INTEGER NOT NULL,
322                    pid TEXT DEFAULT '',
323                    rid INTEGER,
324                    rev INTEGER,
325                    latest INTEGER NOT NULL DEFAULT 0,
326                    row INTEGER NOT NULL,
327                    value,
328                    PRIMARY KEY(colref, pid, rid, rev, row)
329                );";
330        $ok = $ok && (bool)$this->sqlite->query($sql);
331
332        return $ok;
333    }
334
335    /**
336     * Add an additional column to the existing data table
337     *
338     * @param int $index the new column index to add
339     * @return bool
340     */
341    protected function addDataTableColumn($index)
342    {
343        $tbl = 'data_' . $this->table;
344        $sql = " ALTER TABLE $tbl ADD COLUMN col$index DEFAULT ''";
345        if (!$this->sqlite->query($sql)) {
346            return false;
347        }
348        return true;
349    }
350
351    /**
352     * @param string $user
353     * @return SchemaBuilder
354     */
355    public function setUser($user)
356    {
357        $this->user = $user;
358        return $this;
359    }
360}
361