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 = implode(',', $cols);
300
301        $vals = array_merge($this->getSingleNoninputValues(), $this->singleValues);
302
303        return "INSERT INTO $this->stable ($cols) VALUES (" . trim(str_repeat('?,', count($vals)), ',') . ');';
304    }
305
306    /**
307     * Optional operations to be executed before saving data
308     *
309     * @return bool False if any of the operations failed and transaction should be rolled back
310     */
311    protected function beforeSave()
312    {
313        return true;
314    }
315
316    /**
317     * Optional operations to be executed after saving data to single-value table,
318     * before saving multivalues
319     *
320     * @return bool False if anything goes wrong and transaction should be rolled back
321     */
322    protected function afterSingleSave()
323    {
324        return true;
325    }
326
327    /**
328     * Executes final optional queries.
329     *
330     * @return bool False if anything goes wrong and transaction should be rolled back
331     */
332    protected function afterSave()
333    {
334        $ok = true;
335        foreach ($this->optQueries as $query) {
336            $sql = array_shift($query);
337            $ok = $ok && $this->sqlite->query($sql, $query);
338        }
339        return $ok;
340    }
341
342    /**
343     * String template for multi-value table
344     *
345     * @return string
346     */
347    abstract protected function getMultiSql();
348
349    /**
350     * Values for non-input columns to be inserted into SQL query
351     * for multi-value tables
352     * @return array
353     */
354    abstract protected function getMultiNoninputValues();
355
356
357    /**
358     * Should empty or invisible (inpage) fields be returned?
359     *
360     * Defaults to false
361     *
362     * @param null|bool $set new value, null to read only
363     * @return bool current value (after set)
364     */
365    public function optionSkipEmpty($set = null)
366    {
367        if (!is_null($set)) {
368            $this->opt_skipempty = $set;
369        }
370        return $this->opt_skipempty;
371    }
372
373    /**
374     * Get the value of a single column
375     *
376     * @param Column $column
377     * @return Value|null
378     */
379    public function getDataColumn($column)
380    {
381        $data = $this->getData();
382        foreach ($data as $value) {
383            if ($value->getColumn() == $column) {
384                return $value;
385            }
386        }
387        return null;
388    }
389
390    /**
391     * returns the data saved for the page
392     *
393     * @return Value[] a list of values saved for the current page
394     */
395    public function getData()
396    {
397        $data = $this->getDataFromDB();
398        $data = $this->consolidateData($data, false);
399        return $data;
400    }
401
402    /**
403     * returns the data saved for the page as associative array
404     *
405     * The array returned is in the same format as used in @return array
406     * @see saveData()
407     *
408     * It always returns raw Values!
409     *
410     * @return array
411     */
412    public function getDataArray()
413    {
414        $data = $this->getDataFromDB();
415        $data = $this->consolidateData($data, true);
416        return $data;
417    }
418
419    /**
420     * Return the data in pseudo syntax
421     */
422    public function getDataPseudoSyntax()
423    {
424        $result = '';
425        $data = $this->getData();
426
427        foreach ($data as $value) {
428            $key = $value->getColumn()->getFullQualifiedLabel();
429            $value = $value->getDisplayValue();
430            if (is_array($value)) $value = implode(', ', $value);
431            $result .= sprintf("% -20s : %s\n", $key, $value);
432        }
433        return $result;
434    }
435
436    /**
437     * retrieve the data saved for the page from the database. Usually there is no need to call this function.
438     * Call @see SchemaData::getData instead.
439     */
440    protected function getDataFromDB()
441    {
442        $idColumn = self::isTypePage($this->pid, $this->ts) ? 'pid' : 'rid';
443        [$sql, $opt] = $this->buildGetDataSQL($idColumn);
444
445        return $this->sqlite->queryAll($sql, $opt);
446    }
447
448    /**
449     * Creates a proper result array from the database data
450     *
451     * @param array $DBdata the data as it is retrieved from the database, i.e. by SchemaData::getDataFromDB
452     * @param bool $asarray return data as associative array (true) or as array of Values (false)
453     * @return array|Value[]
454     */
455    protected function consolidateData($DBdata, $asarray = false)
456    {
457        $data = [];
458
459        $sep = Search::CONCAT_SEPARATOR;
460
461        foreach ($this->schema->getColumns(false) as $col) {
462            // if no data saved yet, return empty strings
463            if ($DBdata) {
464                $val = (string) $DBdata[0]['out' . $col->getColref()];
465            } else {
466                $val = '';
467            }
468
469            // multi val data is concatenated
470            if ($col->isMulti()) {
471                $val = explode($sep, $val);
472                $val = array_filter($val);
473            }
474
475            $value = new Value($col, $val);
476
477            if ($this->opt_skipempty && $value->isEmpty()) continue;
478            if ($this->opt_skipempty && !$col->isVisibleInPage()) continue; //FIXME is this a correct assumption?
479
480            // for arrays, we return the raw value only
481            if ($asarray) {
482                $data[$col->getLabel()] = $value->getRawValue();
483            } else {
484                $data[$col->getLabel()] = $value;
485            }
486        }
487
488        return $data;
489    }
490
491    /**
492     * Builds the SQL statement to select the data for this page and schema
493     *
494     * @return array Two fields: the SQL string and the parameters array
495     */
496    protected function buildGetDataSQL($idColumn = 'pid')
497    {
498        $sep = Search::CONCAT_SEPARATOR;
499        $stable = 'data_' . $this->schema->getTable();
500        $mtable = 'multi_' . $this->schema->getTable();
501
502        $QB = new QueryBuilder();
503        $QB->addTable($stable, 'DATA');
504        $QB->addSelectColumn('DATA', $idColumn, strtoupper($idColumn));
505        $QB->addGroupByStatement("DATA.$idColumn");
506
507        foreach ($this->schema->getColumns(false) as $col) {
508            $colref = $col->getColref();
509            $colname = 'col' . $colref;
510            $outname = 'out' . $colref;
511
512            if ($col->getType()->isMulti()) {
513                $tn = 'M' . $colref;
514                $QB->addLeftJoin(
515                    'DATA',
516                    $mtable,
517                    $tn,
518                    "DATA.$idColumn = $tn.$idColumn AND DATA.rev = $tn.rev AND $tn.colref = $colref"
519                );
520                $col->getType()->select($QB, $tn, 'value', $outname);
521                $sel = $QB->getSelectStatement($outname);
522                $QB->addSelectStatement("GROUP_CONCAT_DISTINCT($sel, '$sep')", $outname);
523            } else {
524                $col->getType()->select($QB, 'DATA', $colname, $outname);
525                $QB->addGroupByStatement($outname);
526            }
527        }
528
529        $pl = $QB->addValue($this->{$idColumn});
530        $QB->filters()->whereAnd("DATA.$idColumn = $pl");
531        $pl = $QB->addValue($this->getLastRevisionTimestamp());
532        $QB->filters()->whereAnd("DATA.rev = $pl");
533
534        return $QB->getSQL();
535    }
536
537    /**
538     * @param int $ts
539     */
540    public function setTimestamp($ts)
541    {
542        if ($ts && $ts < $this->schema->getTimeStamp()) {
543            throw new StructException('Given timestamp is not valid for current Schema');
544        }
545
546        $this->ts = $ts;
547    }
548
549    /**
550     * Returns the timestamp from the current data
551     * @return int
552     */
553    public function getTimestamp()
554    {
555        return $this->ts;
556    }
557
558    /**
559     * Return the last time an edit happened for this table for the currently set
560     * time and pid. Used in
561     * @see buildGetDataSQL()
562     *
563     * @return int
564     */
565    abstract protected function getLastRevisionTimestamp();
566
567    /**
568     * Check if the given data validates against the current types.
569     *
570     * @param array $data
571     * @return AccessDataValidator
572     */
573    public function getValidator($data)
574    {
575        return new AccessDataValidator($this, $data);
576    }
577
578    /**
579     * Returns true if data is of type "page"
580     *
581     * @param string $pid
582     * @param int $rev
583     * @param int $rid
584     * @return bool
585     */
586    public static function isTypePage($pid, $rev)
587    {
588        return $rev > 0;
589    }
590
591    /**
592     * Returns true if data is of type "global"
593     *
594     * @param string $pid
595     * @param int $rev
596     * @param int $rid
597     * @return bool
598     */
599    public static function isTypeGlobal($pid, $rev)
600    {
601        return $pid === '';
602    }
603
604    /**
605     * Returns true if data is of type "serial"
606     *
607     * @param string $pid
608     * @param int $rev
609     * @param int $rid
610     * @return bool
611     */
612    public static function isTypeSerial($pid, $rev)
613    {
614        return $pid !== '' && $rev === 0;
615    }
616
617    /**
618     * Global and serial data require additional queries. They are put into query queue
619     * in descendants of this method.
620     *
621     * @param string $pid
622     * @param int $rid
623     * @param int $colref
624     */
625    protected function handleEmptyMulti($pid, $rid, $colref)
626    {
627    }
628
629    /**
630     * Clears all multi_ values for the current row.
631     * Executed when updating global and serial data. Otherwise removed (deselected) values linger in database.
632     *
633     * @return bool|\SQLiteResult
634     */
635    protected function clearMulti()
636    {
637        $colrefs = array_unique(array_map(static fn($val) => $val[0], $this->multiValues));
638        return $this->sqlite->query(
639            "DELETE FROM $this->mtable WHERE pid = ? AND rid = $this->rid AND rev = 0 AND colref IN (" .
640            implode(',', $colrefs) . ")",
641            [$this->pid]
642        );
643    }
644}
645