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