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