xref: /plugin/struct/meta/AccessTable.php (revision 4fc1424af829d05c69a00a0c2ae33facc7342115)
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    public const DEFAULT_REV = 0;
15    public const DEFAULT_LATEST = 1;
16
17    /** @var  Schema */
18    protected $schema;
19    protected $pid;
20    protected $rid;
21    protected $labels = [];
22    protected $ts = 0;
23    protected $published;
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     * Published status
175     *
176     * @return int|null
177     */
178    public function getPublished()
179    {
180        return $this->published;
181    }
182
183    /**
184     * Should remove the current data, by either deleting or ovewriting it
185     *
186     * @return bool if the delete succeeded
187     */
188    abstract public function clearData();
189
190    /**
191     * Save the data to the database.
192     *
193     * We differentiate between single-value-column and multi-value-column by the value to the respective column-name,
194     * i.e. depending on if that is a string or an array, respectively.
195     *
196     * @param array $data typelabel => value for single fields or typelabel => array(value, value, ...) for multi fields
197     * @return bool success of saving the data to the database
198     */
199    public function saveData($data)
200    {
201        if (!$this->validateTypeData($data)) {
202            return false;
203        }
204
205        $this->stable = 'data_' . $this->schema->getTable();
206        $this->mtable = 'multi_' . $this->schema->getTable();
207
208        $colrefs = array_flip($this->labels);
209
210        foreach ($data as $colname => $value) {
211            if (!isset($colrefs[$colname])) {
212                throw new StructException("Unknown column %s in schema.", hsc($colname));
213            }
214
215            $this->singleCols[] = 'col' . $colrefs[$colname];
216            if (is_array($value)) {
217                foreach ($value as $index => $multivalue) {
218                    $this->multiValues[] = [$colrefs[$colname], $index + 1, $multivalue];
219                }
220                // copy first value to the single column
221                if (isset($value[0])) {
222                    $this->singleValues[] = $value[0];
223                    if ($value[0] === '') {
224                        $this->handleEmptyMulti($this->pid, $this->rid, $colrefs[$colname]);
225                    }
226                } else {
227                    $this->singleValues[] = null;
228                }
229            } else {
230                $this->singleValues[] = $value;
231            }
232        }
233
234        $this->sqlite->query('BEGIN TRANSACTION');
235
236        $ok = $this->beforeSave();
237
238        // insert single values
239        $ok = $ok && $this->sqlite->query(
240            $this->getSingleSql(),
241            array_merge($this->getSingleNoninputValues(), $this->singleValues)
242        );
243
244        $ok = $ok && $this->afterSingleSave();
245
246        // insert multi values
247        if ($ok && $this->multiValues) {
248            $multisql = $this->getMultiSql();
249            $multiNoninputValues = $this->getMultiNoninputValues();
250            foreach ($this->multiValues as $value) {
251                $ok = $ok && $this->sqlite->query(
252                    $multisql,
253                    array_merge($multiNoninputValues, $value)
254                );
255            }
256        }
257
258        $ok = $ok && $this->afterSave();
259
260        if (!$ok) {
261            $this->sqlite->query('ROLLBACK TRANSACTION');
262            return false;
263        }
264        $this->sqlite->query('COMMIT TRANSACTION');
265        return true;
266    }
267
268    /**
269     * Check whether all required data is present
270     *
271     * @param array $data
272     * @return bool
273     */
274    abstract protected function validateTypeData($data);
275
276    /**
277     * Names of non-input columns to be inserted into SQL query
278     *
279     * @return array
280     */
281    abstract protected function getSingleNoninputCols();
282
283    /**
284     * Values for non-input columns to be inserted into SQL query
285     * for single-value tables
286     *
287     * @return array
288     */
289    abstract protected function getSingleNoninputValues();
290
291    /**
292     * String template for single-value table
293     *
294     * @return string
295     */
296    protected function getSingleSql()
297    {
298        $cols = array_merge($this->getSingleNoninputCols(), $this->singleCols);
299        $cols = join(',', $cols);
300        $vals = array_merge($this->getSingleNoninputValues(), $this->singleValues);
301
302        return "INSERT INTO $this->stable ($cols) VALUES (" . trim(str_repeat('?,', count($vals)), ',') . ');';
303    }
304
305    /**
306     * Optional operations to be executed before saving data
307     *
308     * @return bool False if any of the operations failed and transaction should be rolled back
309     */
310    protected function beforeSave()
311    {
312        return true;
313    }
314
315    /**
316     * Optional operations to be executed after saving data to single-value table,
317     * before saving multivalues
318     *
319     * @return bool False if anything goes wrong and transaction should be rolled back
320     */
321    protected function afterSingleSave()
322    {
323        return true;
324    }
325
326    /**
327     * Executes final optional queries.
328     *
329     * @return bool False if anything goes wrong and transaction should be rolled back
330     */
331    protected function afterSave()
332    {
333        $ok = true;
334        foreach ($this->optQueries as $query) {
335            $sql = array_shift($query);
336            $ok = $ok && $this->sqlite->query($sql, $query);
337        }
338        return $ok;
339    }
340
341    /**
342     * String template for multi-value table
343     *
344     * @return string
345     */
346    abstract protected function getMultiSql();
347
348    /**
349     * Values for non-input columns to be inserted into SQL query
350     * for multi-value tables
351     * @return array
352     */
353    abstract protected function getMultiNoninputValues();
354
355
356    /**
357     * Should empty or invisible (inpage) fields be returned?
358     *
359     * Defaults to false
360     *
361     * @param null|bool $set new value, null to read only
362     * @return bool current value (after set)
363     */
364    public function optionSkipEmpty($set = null)
365    {
366        if (!is_null($set)) {
367            $this->opt_skipempty = $set;
368        }
369        return $this->opt_skipempty;
370    }
371
372    /**
373     * Get the value of a single column
374     *
375     * @param Column $column
376     * @return Value|null
377     */
378    public function getDataColumn($column)
379    {
380        $data = $this->getData();
381        foreach ($data as $value) {
382            if ($value->getColumn() == $column) {
383                return $value;
384            }
385        }
386        return null;
387    }
388
389    /**
390     * returns the data saved for the page
391     *
392     * @return Value[] a list of values saved for the current page
393     */
394    public function getData()
395    {
396        $data = $this->getDataFromDB();
397        $data = $this->consolidateData($data, false);
398        return $data;
399    }
400
401    /**
402     * returns the data saved for the page as associative array
403     *
404     * The array returned is in the same format as used in @return array
405     * @see saveData()
406     *
407     * It always returns raw Values!
408     *
409     * @return array
410     */
411    public function getDataArray()
412    {
413        $data = $this->getDataFromDB();
414        $data = $this->consolidateData($data, true);
415        return $data;
416    }
417
418    /**
419     * Return the data in pseudo syntax
420     */
421    public function getDataPseudoSyntax()
422    {
423        $result = '';
424        $data = $this->getData();
425
426        foreach ($data as $value) {
427            $key = $value->getColumn()->getFullQualifiedLabel();
428            $value = $value->getDisplayValue();
429            if (is_array($value)) $value = join(', ', $value);
430            $result .= sprintf("% -20s : %s\n", $key, $value);
431        }
432        return $result;
433    }
434
435    /**
436     * retrieve the data saved for the page from the database. Usually there is no need to call this function.
437     * Call @see SchemaData::getData instead.
438     */
439    protected function getDataFromDB()
440    {
441        $idColumn = self::isTypePage($this->pid, $this->ts) ? 'pid' : 'rid';
442        list($sql, $opt) = $this->buildGetDataSQL($idColumn);
443
444        return $this->sqlite->queryAll($sql, $opt);
445    }
446
447    /**
448     * Creates a proper result array from the database data
449     *
450     * @param array $DBdata the data as it is retrieved from the database, i.e. by SchemaData::getDataFromDB
451     * @param bool $asarray return data as associative array (true) or as array of Values (false)
452     * @return array|Value[]
453     */
454    protected function consolidateData($DBdata, $asarray = false)
455    {
456        $data = array();
457
458        $sep = Search::CONCAT_SEPARATOR;
459
460        foreach ($this->schema->getColumns(false) as $col) {
461            // if no data saved yet, return empty strings
462            if ($DBdata) {
463                $val = (string) $DBdata[0]['out' . $col->getColref()];
464            } else {
465                $val = '';
466            }
467
468            // multi val data is concatenated
469            if ($col->isMulti()) {
470                $val = explode($sep, $val);
471                $val = array_filter($val);
472            }
473
474            $value = new Value($col, $val);
475
476            if ($this->opt_skipempty && $value->isEmpty()) continue;
477            if ($this->opt_skipempty && !$col->isVisibleInPage()) continue; //FIXME is this a correct assumption?
478
479            // for arrays, we return the raw value only
480            if ($asarray) {
481                $data[$col->getLabel()] = $value->getRawValue();
482            } else {
483                $data[$col->getLabel()] = $value;
484            }
485        }
486
487        return $data;
488    }
489
490    /**
491     * Builds the SQL statement to select the data for this page and schema
492     *
493     * @return array Two fields: the SQL string and the parameters array
494     */
495    protected function buildGetDataSQL($idColumn = 'pid')
496    {
497        $sep = Search::CONCAT_SEPARATOR;
498        $stable = 'data_' . $this->schema->getTable();
499        $mtable = 'multi_' . $this->schema->getTable();
500
501        $QB = new QueryBuilder();
502        $QB->addTable($stable, 'DATA');
503        $QB->addSelectColumn('DATA', $idColumn, strtoupper($idColumn));
504        $QB->addGroupByStatement("DATA.$idColumn");
505
506        foreach ($this->schema->getColumns(false) as $col) {
507            $colref = $col->getColref();
508            $colname = 'col' . $colref;
509            $outname = 'out' . $colref;
510
511            if ($col->getType()->isMulti()) {
512                $tn = 'M' . $colref;
513                $QB->addLeftJoin(
514                    'DATA',
515                    $mtable,
516                    $tn,
517                    "DATA.$idColumn = $tn.$idColumn AND DATA.rev = $tn.rev AND $tn.colref = $colref"
518                );
519                $col->getType()->select($QB, $tn, 'value', $outname);
520                $sel = $QB->getSelectStatement($outname);
521                $QB->addSelectStatement("GROUP_CONCAT_DISTINCT($sel, '$sep')", $outname);
522            } else {
523                $col->getType()->select($QB, 'DATA', $colname, $outname);
524                $QB->addGroupByStatement($outname);
525            }
526        }
527
528        $pl = $QB->addValue($this->{$idColumn});
529        $QB->filters()->whereAnd("DATA.$idColumn = $pl");
530        $pl = $QB->addValue($this->getLastRevisionTimestamp());
531        $QB->filters()->whereAnd("DATA.rev = $pl");
532
533        return $QB->getSQL();
534    }
535
536    /**
537     * @param int $ts
538     */
539    public function setTimestamp($ts)
540    {
541        if ($ts && $ts < $this->schema->getTimeStamp()) {
542            throw new StructException('Given timestamp is not valid for current Schema');
543        }
544
545        $this->ts = $ts;
546    }
547
548    /**
549     * Returns the timestamp from the current data
550     * @return int
551     */
552    public function getTimestamp()
553    {
554        return $this->ts;
555    }
556
557    /**
558     * Return the last time an edit happened for this table for the currently set
559     * time and pid. Used in
560     * @see buildGetDataSQL()
561     *
562     * @return int
563     */
564    abstract protected function getLastRevisionTimestamp();
565
566    /**
567     * Check if the given data validates against the current types.
568     *
569     * @param array $data
570     * @return AccessDataValidator
571     */
572    public function getValidator($data)
573    {
574        return new AccessDataValidator($this, $data);
575    }
576
577    /**
578     * Returns true if data is of type "page"
579     *
580     * @param string $pid
581     * @param int $rev
582     * @param int $rid
583     * @return bool
584     */
585    public static function isTypePage($pid, $rev)
586    {
587        return $rev > 0;
588    }
589
590    /**
591     * Returns true if data is of type "global"
592     *
593     * @param string $pid
594     * @param int $rev
595     * @param int $rid
596     * @return bool
597     */
598    public static function isTypeGlobal($pid, $rev)
599    {
600        return $pid === '';
601    }
602
603    /**
604     * Returns true if data is of type "serial"
605     *
606     * @param string $pid
607     * @param int $rev
608     * @param int $rid
609     * @return bool
610     */
611    public static function isTypeSerial($pid, $rev)
612    {
613        return $pid !== '' && $rev === 0;
614    }
615
616    /**
617     * Global and serial data require additional queries. They are put into query queue
618     * in descendants of this method.
619     *
620     * @param string $pid
621     * @param int $rid
622     * @param int $colref
623     */
624    protected function handleEmptyMulti($pid, $rid, $colref)
625    {
626    }
627
628    /**
629     * Clears all multi_ values for the current row.
630     * Executed when updating global and serial data. Otherwise removed (deselected) values linger in database.
631     *
632     * @return bool|\SQLiteResult
633     */
634    protected function clearMulti()
635    {
636        $colrefs = array_unique(array_map(function ($val) {
637            return $val[0];
638        }, $this->multiValues));
639        return $this->sqlite->query(
640            "DELETE FROM $this->mtable WHERE pid = ? AND rid = $this->rid AND rev = 0 AND colref IN (" .
641            implode(',', $colrefs) . ")",
642            [$this->pid]
643        );
644    }
645}
646