1<?php
2
3namespace dokuwiki\plugin\struct\meta;
4
5/**
6 * Class Assignments
7 *
8 * Manages the assignment of schemas (table names) to pages and namespaces.
9 * An assignment is created when actual struct data is attached to the page.
10 * Assignment are never deleted, only their "assigned" status is changed.
11 *
12 * This is a singleton. Assignment data is only loaded once per request.
13 *
14 * @package dokuwiki\plugin\struct\meta
15 */
16class Assignments
17{
18    /** @var \helper_plugin_sqlite|null */
19    protected $sqlite;
20
21    /** @var  array All the assignments patterns */
22    protected $patterns;
23
24    /** @var Assignments */
25    protected static $instance;
26
27    /**
28     * Get the singleton instance of the Assignments
29     *
30     * @param bool $forcereload create a new instace to reload the assignment data
31     * @return Assignments
32     */
33    public static function getInstance($forcereload = false)
34    {
35        if (is_null(self::$instance) || $forcereload) {
36            $class = static::class;
37            self::$instance = new $class();
38        }
39        return self::$instance;
40    }
41
42    /**
43     * Assignments constructor.
44     *
45     * Not public. Use Assignments::getInstance() instead
46     */
47    protected function __construct()
48    {
49        /** @var \helper_plugin_struct_db $helper */
50        $helper = plugin_load('helper', 'struct_db');
51        $this->sqlite = $helper->getDB();
52
53        $this->loadPatterns();
54    }
55
56
57    /**
58     * Load existing assignment patterns
59     */
60    protected function loadPatterns()
61    {
62        $sql = 'SELECT * FROM schema_assignments_patterns ORDER BY pattern';
63        $this->patterns = $this->sqlite->queryAll($sql);
64    }
65
66    /**
67     * Add a new assignment pattern to the pattern table
68     *
69     * @param string $pattern
70     * @param string $table
71     * @return bool
72     */
73    public function addPattern($pattern, $table)
74    {
75        // add the pattern
76        $sql = 'REPLACE INTO schema_assignments_patterns (pattern, tbl) VALUES (?,?)';
77        $ok = (bool)$this->sqlite->query($sql, [$pattern, $table]);
78
79        // reload patterns
80        $this->loadPatterns();
81        $this->propagatePageAssignments($table);
82
83
84        return $ok;
85    }
86
87    /**
88     * Remove an existing assignment pattern from the pattern table
89     *
90     * @param string $pattern
91     * @param string $table
92     * @return bool
93     */
94    public function removePattern($pattern, $table)
95    {
96        // remove the pattern
97        $sql = 'DELETE FROM schema_assignments_patterns WHERE pattern = ? AND tbl = ?';
98        $ok = (bool)$this->sqlite->query($sql, [$pattern, $table]);
99
100        // reload patterns
101        $this->loadPatterns();
102
103        // fetch possibly affected pages
104        $sql = 'SELECT pid FROM schema_assignments WHERE tbl = ?';
105        $pagerows = $this->sqlite->queryAll($sql, [$table]);
106
107        // reevalute the pages and unassign when needed
108        foreach ($pagerows as $row) {
109            $tables = $this->getPageAssignments($row['pid'], true);
110            if (!in_array($table, $tables)) {
111                $this->deassignPageSchema($row['pid'], $table);
112            }
113        }
114
115        return $ok;
116    }
117
118    /**
119     * Rechecks all assignments of a given page against the current patterns
120     *
121     * @param string $pid
122     */
123    public function reevaluatePageAssignments($pid)
124    {
125        // reload patterns
126        $this->loadPatterns();
127        $tables = $this->getPageAssignments($pid, true);
128
129        // fetch possibly affected tables
130        $sql = 'SELECT tbl FROM schema_assignments WHERE pid = ?';
131        $tablerows = $this->sqlite->queryAll($sql, [$pid]);
132
133        // reevalute the tables and apply assignments
134        foreach ($tablerows as $row) {
135            if (in_array($row['tbl'], $tables)) {
136                $this->assignPageSchema($pid, $row['tbl']);
137            } else {
138                $this->deassignPageSchema($pid, $row['tbl']);
139            }
140        }
141    }
142
143    /**
144     * Clear all patterns - deassigns all pages
145     *
146     * This is mostly useful for testing and not used in the interface currently
147     *
148     * @param bool $full fully delete all previous assignments
149     * @return bool
150     */
151    public function clear($full = false)
152    {
153        $sql = 'DELETE FROM schema_assignments_patterns';
154        $ok = (bool)$this->sqlite->query($sql);
155
156        if ($full) {
157            $sql = 'DELETE FROM schema_assignments';
158        } else {
159            $sql = 'UPDATE schema_assignments SET assigned = 0';
160        }
161        $ok = $ok && (bool)$this->sqlite->query($sql);
162
163        // reload patterns
164        $this->loadPatterns();
165
166        return $ok;
167    }
168
169    /**
170     * Add page to assignments
171     *
172     * @param string $page
173     * @param string $table
174     * @return bool
175     */
176    public function assignPageSchema($page, $table)
177    {
178        $sql = 'REPLACE INTO schema_assignments (pid, tbl, assigned) VALUES (?, ?, 1)';
179        return (bool)$this->sqlite->query($sql, [$page, $table]);
180    }
181
182    /**
183     * Remove page from assignments
184     *
185     * @param string $page
186     * @param string $table
187     * @return bool
188     */
189    public function deassignPageSchema($page, $table)
190    {
191        $sql = 'REPLACE INTO schema_assignments (pid, tbl, assigned) VALUES (?, ?, 0)';
192        return (bool)$this->sqlite->query($sql, [$page, $table]);
193    }
194
195    /**
196     * Get the whole pattern table
197     *
198     * @return array
199     */
200    public function getAllPatterns()
201    {
202        return $this->patterns;
203    }
204
205    /**
206     * Returns a list of table names assigned to the given page
207     *
208     * @param string $page
209     * @param bool $checkpatterns Should the current patterns be re-evaluated?
210     * @return \string[] tables assigned
211     */
212    public function getPageAssignments($page, $checkpatterns = true)
213    {
214        $tables = [];
215        $page = cleanID($page);
216
217        if ($checkpatterns) {
218            // evaluate patterns
219            $pns = ':' . getNS($page) . ':';
220            foreach ($this->patterns as $row) {
221                if ($this->matchPagePattern($row['pattern'], $page, $pns)) {
222                    $tables[] = $row['tbl'];
223                }
224            }
225        } else {
226            // just select
227            $sql = 'SELECT tbl FROM schema_assignments WHERE pid = ? AND assigned = 1';
228            $list = $this->sqlite->queryAll($sql, [$page]);
229            foreach ($list as $row) {
230                $tables[] = $row['tbl'];
231            }
232        }
233
234        return array_unique($tables);
235    }
236
237    /**
238     * Get the pages known to struct and their assignment state
239     *
240     * @param null|string $schema limit results to the given schema
241     * @param bool $assignedonly limit results to currently assigned only
242     * @return array
243     */
244    public function getPages($schema = null, $assignedonly = false)
245    {
246        $sql = 'SELECT pid, tbl, assigned FROM schema_assignments WHERE 1=1';
247
248        $opts = [];
249        if ($schema) {
250            $sql .= ' AND tbl = ?';
251            $opts[] = $schema;
252        }
253        if ($assignedonly) {
254            $sql .= ' AND assigned = 1';
255        }
256
257        $sql .= ' ORDER BY pid, tbl';
258
259        $list = $this->sqlite->queryAll($sql, $opts);
260
261        $result = [];
262        foreach ($list as $row) {
263            $pid = $row['pid'];
264            $tbl = $row['tbl'];
265            if (!isset($result[$pid])) $result[$pid] = [];
266            $result[$pid][$tbl] = (bool)$row['assigned'];
267        }
268
269        return $result;
270    }
271
272    /**
273     * Check if the given pattern matches the given page
274     *
275     * @param string $pattern the pattern to check against
276     * @param string $page the cleaned pageid to check
277     * @param string|null $pns optimization, the colon wrapped namespace of the page, set null for automatic
278     * @return bool
279     */
280    protected function matchPagePattern($pattern, $page, $pns = null)
281    {
282        if (trim($pattern, ':') == '**') return true; // match all
283
284        // regex patterns
285        if ($pattern[0] == '/') {
286            return (bool)preg_match($pattern, ":$page");
287        }
288
289        if (is_null($pns)) {
290            $pns = ':' . getNS($page) . ':';
291        }
292
293        $ans = ':' . cleanID($pattern) . ':';
294        if (substr($pattern, -2) == '**') {
295            // upper namespaces match
296            if (strpos($pns, $ans) === 0) {
297                return true;
298            }
299        } elseif (substr($pattern, -1) == '*') {
300            // namespaces match exact
301            if ($ans == $pns) {
302                return true;
303            }
304        } elseif (cleanID($pattern) == $page) {
305            // exact match
306            return true;
307        }
308
309        return false;
310    }
311
312    /**
313     * Returns all tables of schemas that existed and stored data for the page back then
314     *
315     * @deprecated because we're always only interested in the current state of affairs, even when restoring.
316     *
317     * @param string $page
318     * @param string $ts
319     * @return array
320     */
321    public function getHistoricAssignments($page, $ts)
322    {
323        $sql = "SELECT DISTINCT tbl FROM schemas WHERE ts <= ? ORDER BY ts DESC";
324        $tables = $this->sqlite->queryAll($sql, [$ts]);
325
326        $assigned = [];
327        foreach ($tables as $row) {
328            $table = $row['tbl'];
329            /** @noinspection SqlResolve */
330            $sql = "SELECT pid FROM data_$table WHERE pid = ? AND rev <= ? LIMIT 1";
331            $found = $this->sqlite->queryAll($sql, [$page, $ts]);
332
333            if ($found) $assigned[] = $table;
334        }
335
336        return $assigned;
337    }
338
339    /**
340     * fetch all pages where the schema isn't assigned, yet
341     * and reevaluate the page assignments for those pages and assign when needed
342     *
343     * @param $table
344     */
345    public function propagatePageAssignments($table)
346    {
347        $sql = 'SELECT pid FROM schema_assignments WHERE tbl != ? OR assigned != 1';
348        $pagerows = $this->sqlite->queryAll($sql, [$table]);
349
350        foreach ($pagerows as $row) {
351            $tables = $this->getPageAssignments($row['pid'], true);
352            if (in_array($table, $tables)) {
353                $this->assignPageSchema($row['pid'], $table);
354            }
355        }
356    }
357}
358