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