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