xref: /plugin/struct/meta/SchemaBuilder.php (revision 76f0676d357fa0fdc4ae19716c100df9716c8684)
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) VALUES (?, ?, ?, ?)";
150        $this->sqlite->query($sql, $this->table, $this->time, (int) $this->oldschema->isLookup(), $this->user);
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                    row INTEGER NOT NULL,
317                    value,
318                    PRIMARY KEY(colref, pid, rev, row)
319                );";
320        $ok = $ok && (bool) $this->sqlite->query($sql);
321
322        return $ok;
323    }
324
325    /**
326     * Creates a new lookup table with no columns
327     *
328     * This is basically the same as @see newDataTable() but sets
329     * different primary keys and types
330     *
331     * @return bool
332     */
333    protected function newLookupTable() {
334        $ok = true;
335
336        $tbl = 'data_' . $this->table;
337        $sql = "CREATE TABLE $tbl (
338                    pid INTEGER PRIMARY KEY,
339                    rev INTEGER NOT NULL DEFAULT 0,
340                    latest BOOLEAN NOT NULL DEFAULT 1
341                )";
342        $ok = $ok && (bool) $this->sqlite->query($sql);
343
344        $tbl = 'multi_' . $this->table;
345        $sql = "CREATE TABLE $tbl (
346                    colref INTEGER NOT NULL,
347                    pid INTEGER NOT NULL,
348                    rev INTEGER NOT NULL DEFAULT 0,
349                    row INTEGER NOT NULL,
350                    value,
351                    PRIMARY KEY(colref, pid, row)
352                );";
353        $ok = $ok && (bool) $this->sqlite->query($sql);
354
355        return $ok;
356    }
357
358    /**
359     * Add an additional column to the existing data table
360     *
361     * @param int $index the new column index to add
362     * @return bool
363     */
364    protected function addDataTableColumn($index) {
365        $tbl = 'data_' . $this->table;
366        $sql = " ALTER TABLE $tbl ADD COLUMN col$index DEFAULT ''";
367        if(! $this->sqlite->query($sql)) {
368            return false;
369        }
370        return true;
371    }
372
373    /**
374     * @param string $user
375     * @return SchemaBuilder
376     */
377    public function setUser($user) {
378        $this->user = $user;
379        return $this;
380    }
381
382
383}
384