xref: /plugin/struct/meta/SchemaBuilder.php (revision 61356325e2c5dbdcb8405fa2eb4c34732d79b65f)
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    /**
22     * @var array The posted new data for the schema
23     * @see Schema::AdminEditor()
24     */
25    protected $data = array();
26
27    protected $user;
28
29    /**
30     * @var string The table name associated with the schema
31     */
32    protected $table = '';
33
34    /**
35     * @var Schema the previously valid schema for this table
36     */
37    protected $oldschema;
38
39    /** @var int the ID of the newly created schema */
40    protected $newschemaid = 0;
41
42    /** @var \helper_plugin_struct_db */
43    protected $helper;
44
45    /** @var \helper_plugin_sqlite|null  */
46    protected $sqlite;
47
48    /** @var int the time for which this schema should be created - default to time() can be overriden for tests */
49    protected $time = 0;
50
51    /**
52     * SchemaBuilder constructor.
53     *
54     * @param string $table The table's name
55     * @param array $data The defining of the table (basically what get's posted in the schema editor form)
56     * @see Schema::AdminEditor()
57     */
58    public function __construct($table, $data)
59    {
60        $this->table = $table;
61        $this->data = $data;
62        $this->oldschema = new Schema($table, 0);
63
64        $this->helper = plugin_load('helper', 'struct_db');
65        $this->sqlite = $this->helper->getDB();
66        $this->user = $_SERVER['REMOTE_USER'];
67    }
68
69    /**
70     * Create the new schema
71     *
72     * @param int $time when to create this schema 0 for now
73     * @return bool|int the new schema id on success
74     */
75    public function build($time = 0)
76    {
77        $this->time = $time;
78        $this->fixLabelUniqueness();
79
80        $this->sqlite->query('BEGIN TRANSACTION');
81        $ok = true;
82        // create the data table if new schema
83        if (!$this->oldschema->getId()) {
84            $ok = $this->newDataTable();
85        }
86
87        // create a new schema
88        $ok = $ok && $this->newSchema();
89
90        // update column info
91        $ok = $ok && $this->updateColumns();
92        $ok = $ok && $this->addColumns();
93
94        if (!$ok) {
95            $this->sqlite->query('ROLLBACK TRANSACTION');
96            return false;
97        }
98        $this->sqlite->query('COMMIT TRANSACTION');
99
100        return $this->newschemaid;
101    }
102
103    /**
104     * Makes sure all labels in the schema to save are unique
105     */
106    protected function fixLabelUniqueness()
107    {
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    {
128        $wantedlabel = trim($wantedlabel);
129        $fixedlabel = $wantedlabel;
130        $idx = 1;
131        while (isset($labels[utf8_strtolower($fixedlabel)])) {
132            $fixedlabel = $wantedlabel . $idx++;
133        }
134        // did we actually do a rename? apply it.
135        if ($fixedlabel != $wantedlabel) {
136            msg(sprintf($this->helper->getLang('duplicate_label'), $wantedlabel, $fixedlabel), -1);
137            $this->data['cols']['label'] = $fixedlabel;
138        }
139        $labels[utf8_strtolower($fixedlabel)] = 1;
140        return $fixedlabel;
141    }
142
143    /**
144     * Creates a new schema
145     */
146    protected function newSchema()
147    {
148        if (!$this->time) $this->time = time();
149
150        $config = $this->data['config'] ?: '{}';
151
152        /** @noinspection SqlResolve */
153        $sql = "INSERT INTO schemas (tbl, ts, islookup, user, config) VALUES (?, ?, ?, ?, ?)";
154        // FIXME magic 0 for islookup
155        $this->sqlite->query($sql, $this->table, $this->time, 0, $this->user, $config);
156        $res = $this->sqlite->query('SELECT last_insert_rowid()');
157        $this->newschemaid = $this->sqlite->res2single($res);
158        $this->sqlite->res_close($res);
159        if (!$this->newschemaid) return false;
160        return true;
161    }
162
163    /**
164     * Updates all the existing column infos and adds them to the new schema
165     */
166    protected function updateColumns()
167    {
168        foreach ($this->oldschema->getColumns() as $column) {
169            $oldEntry = $column->getType()->getAsEntry();
170            $oldTid   = $column->getTid();
171            $newEntry = $oldEntry;
172            $newTid   = $oldTid;
173            $sort = $column->getSort();
174            if (isset($this->data['cols'][$column->getColref()])) {
175                // todo I'm not too happy with this hardcoded here - we should probably have a list of fields at one place
176                $newEntry['config'] = $this->data['cols'][$column->getColref()]['config'];
177                $newEntry['label'] = $this->data['cols'][$column->getColref()]['label'];
178                $newEntry['ismulti'] = $this->data['cols'][$column->getColref()]['ismulti'];
179                $newEntry['class'] = $this->data['cols'][$column->getColref()]['class'];
180                $sort = $this->data['cols'][$column->getColref()]['sort'];
181                $enabled = (bool) $this->data['cols'][$column->getColref()]['isenabled'];
182
183                // when the type definition has changed, we create a new one
184                if (array_diff_assoc($oldEntry, $newEntry)) {
185                    $ok = $this->sqlite->storeEntry('types', $newEntry);
186                    if (!$ok) return false;
187                    $res = $this->sqlite->query('SELECT last_insert_rowid()');
188                    if (!$res) return false;
189                    $newTid = $this->sqlite->res2single($res);
190                    $this->sqlite->res_close($res);
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->storeEntry('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, col$colref AS value FROM data_$table WHERE latest = 1";
223        $res = $this->sqlite->query($sqlSelect);
224        $valueSet = $this->sqlite->res2arr($res);
225        $this->sqlite->res_close($res);
226        $valueString = array();
227        $arguments = array();
228        foreach ($valueSet as $values) {
229            if (blank($values['value']) || trim($values['value']) == '') {
230                continue;
231            }
232            $valueString[] = "(?, ?, ?, ?, ?)";
233            $arguments = array_merge($arguments, array($colref, $values['pid'], $values['rev'], 1, $values['value']));
234        }
235        if (empty($valueString)) {
236            return;
237        }
238        $valueString = join(',', $valueString);
239        /** @noinspection SqlResolve */
240        $sqlInsert = "INSERT OR REPLACE INTO multi_$table (colref, pid, rev, row, value) VALUES $valueString";
241        $this->sqlite->query($sqlInsert, $arguments);
242    }
243
244    /**
245     * Adds new columns to the new schema
246     *
247     * @return bool
248     */
249    protected function addColumns()
250    {
251        if (!isset($this->data['new'])) return true;
252
253        $colref = count($this->oldschema->getColumns()) + 1;
254
255        foreach ($this->data['new'] as $column) {
256            if (!$column['isenabled']) continue; // we do not add a disabled column
257
258            // todo this duplicates the hardcoding as in  the function above
259            $newEntry = array();
260            $newEntry['config'] = $column['config'];
261            $newEntry['label'] = $column['label'];
262            $newEntry['ismulti'] = $column['ismulti'];
263            $newEntry['class'] = $column['class'];
264            $sort = $column['sort'];
265
266
267            // only save if the column got a name
268            if (!$newEntry['label']) continue;
269
270            // add new column to the data table
271            if (!$this->addDataTableColumn($colref)) {
272                return false;
273            }
274
275            // save the type
276            $ok = $this->sqlite->storeEntry('types', $newEntry);
277            if (!$ok) return false;
278            $res = $this->sqlite->query('SELECT last_insert_rowid()');
279            if (!$res) return false;
280            $newTid = $this->sqlite->res2single($res);
281            $this->sqlite->res_close($res);
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->storeEntry('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     * @todo how do we want to handle indexes?
305     * @return bool
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                    PRIMARY KEY(pid, rid, rev)
318                )";
319        $ok = $ok && (bool) $this->sqlite->query($sql);
320
321        $tbl = 'multi_' . $this->table;
322        $sql = "CREATE TABLE $tbl (
323                    colref INTEGER NOT NULL,
324                    pid TEXT DEFAULT '',
325                    rid INTEGER,
326                    rev INTEGER,
327                    latest INTEGER NOT NULL DEFAULT 0,
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