xref: /plugin/struct/meta/AccessTable.php (revision 96b31936eb401ddce9adf0e8672bd8ecdf3ce067)
1<?php
2
3namespace dokuwiki\plugin\struct\meta;
4
5/**
6 * Class AccessTable
7 *
8 * Base class for data accessors
9 *
10 * @package dokuwiki\plugin\struct\meta
11 */
12abstract class AccessTable
13{
14
15    const DEFAULT_REV = 0;
16    const DEFAULT_LATEST = 1;
17
18    /** @var  Schema */
19    protected $schema;
20    protected $pid;
21    protected $rid;
22    protected $labels = [];
23    protected $ts     = 0;
24    /** @var \helper_plugin_sqlite */
25    protected $sqlite;
26
27    // options on how to retrieve data
28    protected $opt_skipempty = false;
29
30    protected $optQueries = [];
31
32    /**
33     * @var string Name of single-value table
34     */
35    protected $stable;
36
37    /**
38     * @var string Name of multi-value table
39     */
40    protected $mtable;
41
42    /**
43     * @var array Column names for the single-value insert/update
44     */
45    protected $singleCols;
46
47    /**
48     * @var array Input values for the single-value insert/update
49     */
50    protected $singleValues;
51
52    /**
53     * @var array Input values for the multi-value inserts/updates
54     */
55    protected $multiValues;
56
57    public static function getPageAccess($tablename, $pid, $ts = 0)
58    {
59        $schema = new Schema($tablename, $ts);
60        return new AccessTablePage($schema, $pid, $ts, 0);
61    }
62
63    public static function getSerialAccess($tablename, $pid, $rid = 0)
64    {
65        $schema = new Schema($tablename, 0);
66        return new AccessTableSerial($schema, $pid, 0, $rid);
67    }
68
69    public static function getGlobalAccess($tablename, $rid = 0)
70    {
71        $schema = new Schema($tablename, 0);
72        return new AccessTableGlobal($schema, '', 0, $rid);
73    }
74
75    /**
76     * Factory method returning the appropriate data accessor (page, global or serial)
77     *
78     * @param Schema $schema schema to load
79     * @param string $pid Page id to access
80     * @param int $ts Time at which the data should be read or written
81     * @param int $rid Row id, 0 for page type data, otherwise autoincrement
82     * @return AccessTablePage|AccessTableGlobal
83     * @deprecated
84     */
85    public static function bySchema(Schema $schema, $pid, $ts = 0, $rid = 0)
86    {
87        if (self::isTypePage($pid, $ts)) {
88            return new AccessTablePage($schema, $pid, $ts, $rid);
89        }
90        return new AccessTableGlobal($schema, $pid, $ts, $rid);
91    }
92
93    /**
94     * Factory Method to access data
95     *
96     * @param string $tablename schema to load
97     * @param string $pid Page id to access
98     * @param int $ts Time at which the data should be read or written
99     * @param int $rid Row id, 0 for page type data, otherwise autoincrement
100     * @return AccessTablePage|AccessTableGlobal
101     *@deprecated  Use specific methods since we can no longer
102     *              guarantee instantiating the required descendant class
103     */
104    public static function byTableName($tablename, $pid, $ts = 0, $rid = 0)
105    {
106        // force loading the latest schema for anything other than page data,
107        // for which we might actually need the history
108        if (!self::isTypePage($pid, $ts)) {
109            $schema = new Schema($tablename, time());
110        } else {
111            $schema = new Schema($tablename, $ts);
112        }
113        return self::bySchema($schema, $pid, $ts, $rid);
114    }
115
116    /**
117     * AccessTable constructor
118     *
119     * @param Schema $schema The schema valid at $ts
120     * @param string $pid Page id
121     * @param int $ts Time at which the data should be read or written, 0 for now
122     * @param int $rid Row id: 0 for pages, autoincremented for other types
123     */
124    public function __construct($schema, $pid, $ts = 0, $rid = 0)
125    {
126        /** @var \helper_plugin_struct_db $helper */
127        $helper = plugin_load('helper', 'struct_db');
128        $this->sqlite = $helper->getDB();
129
130        if (!$schema->getId()) {
131            throw new StructException('Schema does not exist. Only data of existing schemas can be accessed');
132        }
133
134        $this->schema = $schema;
135        $this->pid = $pid;
136        $this->rid = $rid;
137        $this->setTimestamp($ts);
138        foreach ($this->schema->getColumns() as $col) {
139            $this->labels[$col->getColref()] = $col->getType()->getLabel();
140        }
141    }
142
143    /**
144     * gives access to the schema
145     *
146     * @return Schema
147     */
148    public function getSchema()
149    {
150        return $this->schema;
151    }
152
153    /**
154     * The current pid
155     *
156     * @return string
157     */
158    public function getPid()
159    {
160        return $this->pid;
161    }
162
163    /**
164     * The current rid
165     *
166     * @return int
167     */
168    public function getRid()
169    {
170        return $this->rid;
171    }
172
173    /**
174     * Should remove the current data, by either deleting or ovewriting it
175     *
176     * @return bool if the delete succeeded
177     */
178    abstract public function clearData();
179
180    /**
181     * Save the data to the database.
182     *
183     * We differentiate between single-value-column and multi-value-column by the value to the respective column-name,
184     * i.e. depending on if that is a string or an array, respectively.
185     *
186     * @param array $data typelabel => value for single fields or typelabel => array(value, value, ...) for multi fields
187     * @return bool success of saving the data to the database
188     */
189    public function saveData($data)
190    {
191        if (!$this->validateTypeData($data)) {
192            return false;
193        }
194
195        $this->stable = 'data_' . $this->schema->getTable();
196        $this->mtable = 'multi_' . $this->schema->getTable();
197
198        $colrefs = array_flip($this->labels);
199
200        foreach ($data as $colname => $value) {
201            if (!isset($colrefs[$colname])) {
202                throw new StructException("Unknown column %s in schema.", hsc($colname));
203            }
204
205            $this->singleCols[] = 'col' . $colrefs[$colname];
206            if (is_array($value)) {
207                foreach ($value as $index => $multivalue) {
208                    $this->multiValues[] = [$colrefs[$colname], $index + 1, $multivalue];
209                }
210                // copy first value to the single column
211                if (isset($value[0])) {
212                    $this->singleValues[] = $value[0];
213                    if ($value[0] === '') {
214                        $this->handleEmptyMulti($this->pid, $this->rid, $colrefs[$colname]);
215                    }
216                } else {
217                    $this->singleValues[] = null;
218                }
219            } else {
220                $this->singleValues[] = $value;
221            }
222        }
223
224        $this->sqlite->query('BEGIN TRANSACTION');
225
226        $ok = $this->beforeSave();
227
228        // insert single values
229        $ok = $ok && $this->sqlite->query(
230            $this->getSingleSql(),
231            array_merge($this->getSingleNoninputValues(), $this->singleValues)
232        );
233
234        $ok = $ok && $this->afterSingleSave();
235
236        // insert multi values
237        if ($ok && $this->multiValues) {
238            $multisql = $this->getMultiSql();
239            $multiNoninputValues = $this->getMultiNoninputValues();
240            foreach ($this->multiValues as $value) {
241                $ok = $ok && $this->sqlite->query(
242                    $multisql,
243                    array_merge($multiNoninputValues, $value)
244                );
245            }
246        }
247
248        $ok = $ok && $this->afterSave();
249
250        if (!$ok) {
251            $this->sqlite->query('ROLLBACK TRANSACTION');
252            return false;
253        }
254        $this->sqlite->query('COMMIT TRANSACTION');
255        return true;
256    }
257
258    /**
259     * Check whether all required data is present
260     *
261     * @param array $data
262     * @return bool
263     */
264    abstract protected function validateTypeData($data);
265
266    /**
267     * Names of non-input columns to be inserted into SQL query
268     *
269     * @return array
270     */
271    abstract protected function getSingleNoninputCols();
272
273    /**
274     * Values for non-input columns to be inserted into SQL query
275     * for single-value tables
276     *
277     * @return array
278     */
279    abstract protected function getSingleNoninputValues();
280
281    /**
282     * String template for single-value table
283     *
284     * @return string
285     */
286    protected function getSingleSql()
287    {
288        $cols = array_merge($this->getSingleNoninputCols(), $this->singleCols);
289        $cols = join(',', $cols);
290        $vals = array_merge($this->getSingleNoninputValues(), $this->singleValues);
291
292        return "INSERT INTO $this->stable ($cols) VALUES (" . trim(str_repeat('?,', count($vals)), ',') . ');';
293    }
294
295    /**
296     * Optional operations to be executed before saving data
297     *
298     * @return bool False if any of the operations failed and transaction should be rolled back
299     */
300    protected function beforeSave()
301    {
302        return true;
303    }
304
305    /**
306     * Optional operations to be executed after saving data to single-value table,
307     * before saving multivalues
308     *
309     * @return bool False if anything goes wrong and transaction should be rolled back
310     */
311    protected function afterSingleSave()
312    {
313        return true;
314    }
315
316    /**
317     * Executes final optional queries.
318     *
319     * @return bool False if anything goes wrong and transaction should be rolled back
320     */
321    protected function afterSave()
322    {
323        $ok = true;
324        foreach ($this->optQueries as $query) {
325            $ok = $ok && $this->sqlite->query(array_shift($query), $query);
326        }
327        return $ok;
328    }
329
330    /**
331     * String template for multi-value table
332     *
333     * @return string
334     */
335    abstract protected function getMultiSql();
336
337    /**
338     * Values for non-input columns to be inserted into SQL query
339     * for multi-value tables
340     * @return array
341     */
342    abstract protected function getMultiNoninputValues();
343
344
345    /**
346     * Should empty or invisible (inpage) fields be returned?
347     *
348     * Defaults to false
349     *
350     * @param null|bool $set new value, null to read only
351     * @return bool current value (after set)
352     */
353    public function optionSkipEmpty($set = null)
354    {
355        if (!is_null($set)) {
356            $this->opt_skipempty = $set;
357        }
358        return $this->opt_skipempty;
359    }
360
361    /**
362     * Get the value of a single column
363     *
364     * @param Column $column
365     * @return Value|null
366     */
367    public function getDataColumn($column)
368    {
369        $data = $this->getData();
370        foreach ($data as $value) {
371            if ($value->getColumn() == $column) {
372                return $value;
373            }
374        }
375        return null;
376    }
377
378    /**
379     * returns the data saved for the page
380     *
381     * @return Value[] a list of values saved for the current page
382     */
383    public function getData()
384    {
385        $data = $this->getDataFromDB();
386        $data = $this->consolidateData($data, false);
387        return $data;
388    }
389
390    /**
391     * returns the data saved for the page as associative array
392     *
393     * The array returned is in the same format as used in @see saveData()
394     *
395     * It always returns raw Values!
396     *
397     * @return array
398     */
399    public function getDataArray()
400    {
401        $data = $this->getDataFromDB();
402        $data = $this->consolidateData($data, true);
403        return $data;
404    }
405
406    /**
407     * Return the data in pseudo syntax
408     */
409    public function getDataPseudoSyntax()
410    {
411        $result = '';
412        $data = $this->getData();
413
414        foreach ($data as $value) {
415            $key = $value->getColumn()->getFullQualifiedLabel();
416            $value = $value->getDisplayValue();
417            if (is_array($value)) $value = join(', ', $value);
418            $result .= sprintf("% -20s : %s\n", $key, $value);
419        }
420        return $result;
421    }
422
423    /**
424     * retrieve the data saved for the page from the database. Usually there is no need to call this function.
425     * Call @see SchemaData::getData instead.
426     */
427    protected function getDataFromDB()
428    {
429        $idColumn = self::isTypePage($this->pid, $this->ts) ? 'pid' : 'rid';
430        list($sql, $opt) = $this->buildGetDataSQL($idColumn);
431
432        $res = $this->sqlite->query($sql, $opt);
433        $data = $this->sqlite->res2arr($res);
434        $this->sqlite->res_close($res);
435        return $data;
436    }
437
438    /**
439     * Creates a proper result array from the database data
440     *
441     * @param array $DBdata the data as it is retrieved from the database, i.e. by SchemaData::getDataFromDB
442     * @param bool $asarray return data as associative array (true) or as array of Values (false)
443     * @return array|Value[]
444     */
445    protected function consolidateData($DBdata, $asarray = false)
446    {
447        $data = array();
448
449        $sep = Search::CONCAT_SEPARATOR;
450
451        foreach ($this->schema->getColumns(false) as $col) {
452            // if no data saved yet, return empty strings
453            if ($DBdata) {
454                $val = $DBdata[0]['out' . $col->getColref()];
455            } else {
456                $val = '';
457            }
458
459            // multi val data is concatenated
460            if ($col->isMulti()) {
461                $val = explode($sep, $val);
462                $val = array_filter($val);
463            }
464
465            $value = new Value($col, $val);
466
467            if ($this->opt_skipempty && $value->isEmpty()) continue;
468            if ($this->opt_skipempty && !$col->isVisibleInPage()) continue; //FIXME is this a correct assumption?
469
470            // for arrays, we return the raw value only
471            if ($asarray) {
472                $data[$col->getLabel()] = $value->getRawValue();
473            } else {
474                $data[$col->getLabel()] = $value;
475            }
476        }
477
478        return $data;
479    }
480
481    /**
482     * Builds the SQL statement to select the data for this page and schema
483     *
484     * @return array Two fields: the SQL string and the parameters array
485     */
486    protected function buildGetDataSQL($idColumn = 'pid')
487    {
488        $sep = Search::CONCAT_SEPARATOR;
489        $stable = 'data_' . $this->schema->getTable();
490        $mtable = 'multi_' . $this->schema->getTable();
491
492        $QB = new QueryBuilder();
493        $QB->addTable($stable, 'DATA');
494        $QB->addSelectColumn('DATA', $idColumn, strtoupper($idColumn));
495        $QB->addGroupByStatement("DATA.$idColumn");
496
497        foreach ($this->schema->getColumns(false) as $col) {
498            $colref = $col->getColref();
499            $colname = 'col' . $colref;
500            $outname = 'out' . $colref;
501
502            if ($col->getType()->isMulti()) {
503                $tn = 'M' . $colref;
504                $QB->addLeftJoin(
505                    'DATA',
506                    $mtable,
507                    $tn,
508                    "DATA.$idColumn = $tn.$idColumn AND DATA.rev = $tn.rev AND $tn.colref = $colref"
509                );
510                $col->getType()->select($QB, $tn, 'value', $outname);
511                $sel = $QB->getSelectStatement($outname);
512                $QB->addSelectStatement("GROUP_CONCAT($sel, '$sep')", $outname);
513            } else {
514                $col->getType()->select($QB, 'DATA', $colname, $outname);
515                $QB->addGroupByStatement($outname);
516            }
517        }
518
519        $pl = $QB->addValue($this->{$idColumn});
520        $QB->filters()->whereAnd("DATA.$idColumn = $pl");
521        $pl = $QB->addValue($this->getLastRevisionTimestamp());
522        $QB->filters()->whereAnd("DATA.rev = $pl");
523
524        return $QB->getSQL();
525    }
526
527    /**
528     * @param int $ts
529     */
530    public function setTimestamp($ts)
531    {
532        if ($ts && $ts < $this->schema->getTimeStamp()) {
533            throw new StructException('Given timestamp is not valid for current Schema');
534        }
535
536        $this->ts = $ts;
537    }
538
539    /**
540     * Returns the timestamp from the current data
541     * @return int
542     */
543    public function getTimestamp()
544    {
545        return $this->ts;
546    }
547
548    /**
549     * Return the last time an edit happened for this table for the currently set
550     * time and pid. Used in @see buildGetDataSQL()
551     *
552     * @return int
553     */
554    abstract protected function getLastRevisionTimestamp();
555
556    /**
557     * Check if the given data validates against the current types.
558     *
559     * @param array $data
560     * @return AccessDataValidator
561     */
562    public function getValidator($data)
563    {
564        return new AccessDataValidator($this, $data);
565    }
566
567    /**
568     * Returns true if data is of type "page"
569     *
570     * @param string $pid
571     * @param int $rev
572     * @param int $rid
573     * @return bool
574     */
575    public static function isTypePage($pid, $rev)
576    {
577        return $rev > 0;
578    }
579
580    /**
581     * Returns true if data is of type "global"
582     *
583     * @param string $pid
584     * @param int $rev
585     * @param int $rid
586     * @return bool
587     */
588    public static function isTypeGlobal($pid, $rev)
589    {
590        return $pid === '';
591    }
592
593    /**
594     * Returns true if data is of type "serial"
595     *
596     * @param string $pid
597     * @param int $rev
598     * @param int $rid
599     * @return bool
600     */
601    public static function isTypeSerial($pid, $rev)
602    {
603        return $pid !== '' && $rev === 0;
604    }
605
606    /**
607     * Global and serial data require additional queries. They are put into query queue
608     * in descendants of this method.
609     *
610     * @param string $pid
611     * @param int $rid
612     * @param int $colref
613     */
614    protected function handleEmptyMulti($pid, $rid, $colref)
615    {
616    }
617
618    /**
619     * Clears all multi_ values for the current row.
620     * Executed when updating global and serial data. Otherwise removed (deselected) values linger in database.
621     *
622     * @return bool|\SQLiteResult
623     */
624    protected function clearMulti()
625    {
626        $colrefs = array_unique(array_map(function ($val) {
627            return $val[0];
628        }, $this->multiValues));
629        return $this->sqlite->query(
630            "DELETE FROM $this->mtable WHERE pid = ? AND rid = $this->rid AND rev = 0 AND colref IN (" .
631                implode(',', $colrefs) . ")",
632            $this->pid
633        );
634    }
635}
636