1<?php
2
3use dokuwiki\Extension\Plugin;
4
5/**
6 * Tagging Plugin (helper component)
7 */
8class helper_plugin_tagging_querybuilder extends Plugin
9{
10    /** @var string */
11    protected $field;
12    /** @var bool */
13    protected $logicalAnd = false;
14    /** @var array */
15    protected $tags = [];
16    /** @var array  */
17    protected $ns = [];
18    /** @var array */
19    protected $notns = [];
20    /** @var string */
21    protected $pid;
22
23    /**
24     * FIXME consolidate pid (current page query) and pids (global search query)
25     * @var array
26     */
27    protected $pids;
28
29    /** @var string */
30    protected $tagger = '';
31    /** @var int */
32    protected $limit;
33    /** @var string */
34    protected $orderby;
35    /** @var string */
36    protected $groupby;
37    /** @var string */
38    protected $having = '';
39    /** @var array */
40    protected $values = [];
41
42    /**
43     * Shorthand method: calls the appropriate getter deduced from $this->field
44     *
45     * @return array
46     */
47    public function getQuery()
48    {
49        if (!$this->field) {
50            throw new \RuntimeException('Failed to build a query, no field specified');
51        }
52        return ($this->field === 'pid') ? $this->getPages() : $this->getTags();
53    }
54
55    /**
56     * Processes all parts of the query for fetching tagged pages
57     *
58     * Returns SQL and query parameter values
59     *
60     * @return array
61     */
62    public function getPages()
63    {
64        $this->groupby = 'pid';
65        $this->orderby = "cnt DESC, pid";
66        if ($this->tags && $this->logicalAnd) $this->having = ' HAVING cnt = ' . count($this->tags);
67
68        return [$this->getSql(), $this->values];
69    }
70
71    /**
72     * Processes all parts of the query for fetching tags
73     *
74     * Returns SQL and query parameter values
75     *
76     * @return array
77     */
78    public function getTags()
79    {
80        $this->groupby = 'CLEANTAG(tag)';
81        $this->orderby = 'CLEANTAG(tag)';
82
83        return [$this->getSql(), $this->values];
84    }
85
86    /**
87     * Tags to search for
88     * @param array $tags
89     */
90    public function setTags($tags)
91    {
92        $this->tags = $tags;
93    }
94
95    /**
96     * Namespaces to limit search to
97     * @param array $ns
98     */
99    public function includeNS($ns)
100    {
101        $this->ns = $this->globNS($ns);
102    }
103
104    /**
105     * Namespaces to exclude from search
106     * @param array $ns
107     */
108    public function excludeNS($ns)
109    {
110        $this->notns = $this->globNS($ns);
111    }
112
113    /**
114     * Sets the logical operator used in tag search to AND
115     * @param bool $and
116     */
117    public function setLogicalAnd($and)
118    {
119        $this->logicalAnd = (bool)$and;
120    }
121
122    /**
123     * Result limit
124     * @param int $limit
125     */
126    public function setLimit($limit)
127    {
128        $this->limit = $limit;
129    }
130
131    /**
132     * Database field to select
133     * @param string $field
134     */
135    public function setField($field)
136    {
137        $this->field = $field;
138    }
139
140    /**
141     * Limit search to this page id
142     * @param string $pid
143     */
144    public function setPid($pid)
145    {
146        $this->pid = $pid;
147    }
148
149    /**
150     * Limit search to certain pages
151     *
152     * @param array $pids
153     */
154    public function setPids($pids)
155    {
156        $this->pids = $pids;
157    }
158
159    /**
160     * Limit results to this tagger
161     * @param string $tagger
162     */
163    public function setTagger($tagger)
164    {
165        $this->tagger = $tagger;
166    }
167
168    /**
169     * Returns full query SQL
170     * @return string
171     */
172    protected function getSql()
173    {
174        $sql = "SELECT $this->field AS item, COUNT(*) AS cnt
175                FROM taggings
176                WHERE " . $this->getWhere() .
177                " GROUP BY $this->groupby
178                $this->having
179                ORDER BY $this->orderby
180                ";
181
182        if ($this->limit) {
183            $sql .= ' LIMIT ?';
184            $this->values[] = $this->limit;
185        }
186
187        return $sql;
188    }
189
190    /**
191     * Builds the WHERE part of query string
192     * @return string
193     */
194    protected function getWhere()
195    {
196        $where = '1=1';
197
198        if ($this->pid) {
199            $where .= ' AND pid';
200            $where .= $this->useLike($this->pid) ? ' GLOB' : ' =';
201            $where .= '  ?';
202            $this->values[] = $this->pid;
203        }
204
205        if ($this->pids) {
206            $where .= ' AND pid';
207            $where .=  ' IN(';
208            foreach ($this->pids as $pid) {
209                $where .= '  ?,';
210                $this->values[] = $pid;
211            }
212            $where = rtrim($where, ',') . ')';
213        }
214
215        if ($this->tagger) {
216            $where .= ' AND tagger = ?';
217            $this->values[] = $this->tagger;
218        }
219
220        if ($this->ns) {
221            $where .= ' AND ';
222
223            $nsCnt = count($this->ns);
224            $i = 0;
225            foreach ($this->ns as $ns) {
226                $where .= ' pid';
227                $where .= ' GLOB';
228                $where .= ' ?';
229                if (++$i < $nsCnt) $where .= ' OR';
230                $this->values[] = $ns;
231            }
232        }
233
234        if ($this->notns) {
235            $where .= ' AND ';
236
237            $nsCnt = count($this->notns);
238            $i = 0;
239            foreach ($this->notns as $notns) {
240                $where .= ' pid';
241                $where .= ' NOT GLOB';
242                $where .= ' ?';
243                if (++$i < $nsCnt) $where .= ' AND';
244                $this->values[] = $notns;
245            }
246        }
247
248        if ($this->tags) {
249            $where .= ' AND ';
250
251            $tagCnt = count($this->tags);
252            $i = 0;
253            foreach ($this->tags as $tag) {
254                $where .= ' CLEANTAG(tag)';
255                $where .= $this->useLike($tag) ? ' GLOB' : ' =';
256                $where .= ' CLEANTAG(?)';
257                if (++$i < $tagCnt) $where .= ' OR';
258                $this->values[] = $tag;
259            }
260        }
261
262        // bypass page access check when called by a command line tool
263        if (PHP_SAPI !== 'cli' || defined('DOKU_UNITTEST')) {
264            $where .= ' AND GETACCESSLEVEL(pid) >= ' . AUTH_READ;
265        }
266
267        return $where;
268    }
269
270    /**
271     * Check if the given string is a LIKE statement
272     *
273     * @param string $value
274     * @return bool
275     */
276    protected function useLike($value)
277    {
278        return str_starts_with($value, '*') || strrpos($value, '*') === strlen($value) - 1;
279    }
280
281    /**
282     * Converts namespaces into a wildcard form suitable for SQL queries
283     *
284     * @param array $item
285     * @return array
286     */
287    protected function globNS(array $item)
288    {
289        return array_map(fn($ns) => cleanId($ns) . '*', $item);
290    }
291}
292