xref: /plugin/struct/meta/SchemaBuilder.php (revision ea5ad12ac615a67b2c935ce7472ece1c00189ba7)
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        $this->table = $table;
59        $this->data = $data;
60        $this->oldschema = new Schema($table, 0, $data['islookup']);
61
62        $this->helper = plugin_load('helper', 'struct_db');
63        $this->sqlite = $this->helper->getDB();
64        $this->user = $_SERVER['REMOTE_USER'];
65    }
66
67    /**
68     * Create the new schema
69     *
70     * @param int $time when to create this schema 0 for now
71     * @return bool|int the new schema id on success
72     */
73    public function build($time=0) {
74        $this->time = $time;
75        $this->fixLabelUniqueness();
76
77        $this->sqlite->query('BEGIN TRANSACTION');
78        $ok = true;
79        // create the data table if new schema
80        if(!$this->oldschema->getId()) {
81            if($this->oldschema->isLookup()) {
82                $ok = $this->newLookupTable();
83            } else {
84                $ok = $this->newDataTable();
85            }
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 $this->newschemaid;
102    }
103
104    /**
105     * Makes sure all labels in the schema to save are unique
106     */
107    protected function fixLabelUniqueness() {
108        $labels = array();
109
110        if(isset($this->data['cols'])) foreach($this->data['cols'] as $idx => $column) {
111            $this->data['cols'][$idx]['label'] = $this->fixLabel($column['label'], $labels);
112        }
113
114        if(isset($this->data['new'])) foreach($this->data['new'] as $idx => $column) {
115            $this->data['new'][$idx]['label'] = $this->fixLabel($column['label'], $labels);
116        }
117    }
118
119    /**
120     * Creates a unique label from the given one
121     *
122     * @param string $wantedlabel
123     * @param array $labels list of already assigned labels (will be filled)
124     * @return string
125     */
126    protected function fixLabel($wantedlabel, &$labels) {
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        if(!$this->time) $this->time = time();
147
148        /** @noinspection SqlResolve */
149        $sql = "INSERT INTO schemas (tbl, ts, islookup, user, editors) VALUES (?, ?, ?, ?, ?)";
150        $this->sqlite->query($sql, $this->table, $this->time, (int) $this->oldschema->isLookup(), $this->user, $this->data['editors']);
151        $res = $this->sqlite->query('SELECT last_insert_rowid()');
152        $this->newschemaid = $this->sqlite->res2single($res);
153        $this->sqlite->res_close($res);
154        if(!$this->newschemaid) return false;
155        return true;
156    }
157
158    /**
159     * Updates all the existing column infos and adds them to the new schema
160     */
161    protected function updateColumns() {
162        foreach($this->oldschema->getColumns() as $column) {
163            $oldEntry = $column->getType()->getAsEntry();
164            $oldTid   = $column->getTid();
165            $newEntry = $oldEntry;
166            $newTid   = $oldTid;
167            $sort = $column->getSort();
168            if(isset($this->data['cols'][$column->getColref()])){
169                // todo I'm not too happy with this hardcoded here - we should probably have a list of fields at one place
170                $newEntry['config'] = $this->data['cols'][$column->getColref()]['config'];
171                $newEntry['label'] = $this->data['cols'][$column->getColref()]['label'];
172                $newEntry['ismulti'] = $this->data['cols'][$column->getColref()]['ismulti'];
173                $newEntry['class'] = $this->data['cols'][$column->getColref()]['class'];
174                $sort = $this->data['cols'][$column->getColref()]['sort'];
175                $enabled = (bool) $this->data['cols'][$column->getColref()]['isenabled'];
176
177                // when the type definition has changed, we create a new one
178                if(array_diff_assoc($oldEntry, $newEntry)) {
179                    $ok = $this->sqlite->storeEntry('types', $newEntry);
180                    if(!$ok) return false;
181                    $res = $this->sqlite->query('SELECT last_insert_rowid()');
182                    if(!$res) return false;
183                    $newTid = $this->sqlite->res2single($res);
184                    $this->sqlite->res_close($res);
185                    if ($oldEntry['ismulti'] == false && $newEntry['ismulti'] == '1') {
186                        $this->migrateSingleToMulti($this->oldschema->getTable(), $column->getColref());
187                    }
188                }
189            } else {
190                $enabled = false; // no longer there for some reason
191            }
192
193            // add this type to the schema columns
194            $schemaEntry = array(
195                'sid' => $this->newschemaid,
196                'colref' => $column->getColref(),
197                'enabled' => $enabled,
198                'tid' => $newTid,
199                'sort' => $sort
200            );
201            $ok = $this->sqlite->storeEntry('schema_cols', $schemaEntry);
202            if(!$ok) return false;
203        }
204        return true;
205    }
206
207    /**
208     * Write the latest value from an entry in a data_ table to the corresponding multi_table
209     *
210     * @param string $table
211     * @param int    $colref
212     */
213    protected function migrateSingleToMulti($table, $colref) {
214        /** @noinspection SqlResolve */
215        $sqlSelect = "SELECT pid, rev, col$colref AS value FROM data_$table WHERE latest = 1";
216        $res = $this->sqlite->query($sqlSelect);
217        $valueSet = $this->sqlite->res2arr($res);
218        $this->sqlite->res_close($res);
219        $valueString = array();
220        $arguments = array();
221        foreach ($valueSet as $values) {
222            if (blank($values['value']) || trim($values['value']) == '') {
223                continue;
224            }
225            $valueString[] = "(?, ?, ?, ?, ?)";
226            $arguments = array_merge($arguments, array($colref, $values['pid'], $values['rev'], 1, $values['value']));
227        }
228        if (empty($valueString)) {
229            return;
230        }
231        $valueString = join(',', $valueString);
232        /** @noinspection SqlResolve */
233        $sqlInsert = "INSERT OR REPLACE INTO multi_$table (colref, pid, rev, row, value) VALUES $valueString";
234        $this->sqlite->query($sqlInsert, $arguments);
235    }
236
237    /**
238     * Adds new columns to the new schema
239     *
240     * @return bool
241     */
242    protected function addColumns() {
243        if(!isset($this->data['new'])) return true;
244
245        $colref = count($this->oldschema->getColumns())+1;
246
247        foreach($this->data['new'] as $column) {
248            if(!$column['isenabled']) continue; // we do not add a disabled column
249
250            // todo this duplicates the hardcoding as in  the function above
251            $newEntry = array();
252            $newEntry['config'] = $column['config'];
253            $newEntry['label'] = $column['label'];
254            $newEntry['ismulti'] = $column['ismulti'];
255            $newEntry['class'] = $column['class'];
256            $sort = $column['sort'];
257
258
259            // only save if the column got a name
260            if(!$newEntry['label']) continue;
261
262            // add new column to the data table
263            if(!$this->addDataTableColumn($colref)) {
264                return false;
265            }
266
267            // save the type
268            $ok = $this->sqlite->storeEntry('types', $newEntry);
269            if(!$ok) return false;
270            $res = $this->sqlite->query('SELECT last_insert_rowid()');
271            if(!$res) return false;
272            $newTid = $this->sqlite->res2single($res);
273            $this->sqlite->res_close($res);
274
275
276            // add this type to the schema columns
277            $schemaEntry = array(
278                'sid' => $this->newschemaid,
279                'colref' => $colref,
280                'enabled' => true,
281                'tid' => $newTid,
282                'sort' => $sort
283            );
284            $ok = $this->sqlite->storeEntry('schema_cols', $schemaEntry);
285            if(!$ok) return false;
286            $colref++;
287        }
288
289        return true;
290    }
291
292    /**
293     * Create a completely new data table with no columns yet also create the appropriate
294     * multi value table for the schema
295     *
296     * @todo how do we want to handle indexes?
297     * @return bool
298     */
299    protected function newDataTable() {
300        $ok = true;
301
302        $tbl = 'data_' . $this->table;
303        $sql = "CREATE TABLE $tbl (
304                    pid NOT NULL,
305                    rev INTEGER NOT NULL,
306                    latest BOOLEAN NOT NULL DEFAULT 0,
307                    PRIMARY KEY(pid, rev)
308                )";
309        $ok = $ok && (bool) $this->sqlite->query($sql);
310
311        $tbl = 'multi_' . $this->table;
312        $sql = "CREATE TABLE $tbl (
313                    colref INTEGER NOT NULL,
314                    pid NOT NULL,
315                    rev INTEGER NOT NULL,
316                    latest INTEGER NOT NULL DEFAULT 0,
317                    row INTEGER NOT NULL,
318                    value,
319                    PRIMARY KEY(colref, pid, rev, row)
320                );";
321        $ok = $ok && (bool) $this->sqlite->query($sql);
322
323        return $ok;
324    }
325
326    /**
327     * Creates a new lookup table with no columns
328     *
329     * This is basically the same as @see newDataTable() but sets
330     * different primary keys and types
331     *
332     * @return bool
333     */
334    protected function newLookupTable() {
335        $ok = true;
336
337        $tbl = 'data_' . $this->table;
338        $sql = "CREATE TABLE $tbl (
339                    pid INTEGER PRIMARY KEY,
340                    rev INTEGER NOT NULL DEFAULT 0,
341                    latest BOOLEAN NOT NULL DEFAULT 1
342                )";
343        $ok = $ok && (bool) $this->sqlite->query($sql);
344
345        $tbl = 'multi_' . $this->table;
346        $sql = "CREATE TABLE $tbl (
347                    colref INTEGER NOT NULL,
348                    pid INTEGER NOT NULL,
349                    rev INTEGER NOT NULL DEFAULT 0,
350                    latest INTEGER NOT NULL DEFAULT 0,
351                    row INTEGER NOT NULL,
352                    value,
353                    PRIMARY KEY(colref, pid, row)
354                );";
355        $ok = $ok && (bool) $this->sqlite->query($sql);
356
357        return $ok;
358    }
359
360    /**
361     * Add an additional column to the existing data table
362     *
363     * @param int $index the new column index to add
364     * @return bool
365     */
366    protected function addDataTableColumn($index) {
367        $tbl = 'data_' . $this->table;
368        $sql = " ALTER TABLE $tbl ADD COLUMN col$index DEFAULT ''";
369        if(! $this->sqlite->query($sql)) {
370            return false;
371        }
372        return true;
373    }
374
375    /**
376     * @param string $user
377     * @return SchemaBuilder
378     */
379    public function setUser($user) {
380        $this->user = $user;
381        return $this;
382    }
383
384
385}
386