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