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