Initial commit after working 5 evenings on it
[roundcube-nextcloud_sql_addressbook.git] / nextcloud_sql_addressbook_backend.php
1 <?php
2 /**
3  * Address book backend accessing the NextCloud database directly.
4  *
5  * Read-only.
6  *
7  * Only returns two fields: email and name (Full name).
8  *
9  * @author  Christian Weiske <cweiske@cweiske.de>
10  * @license AGPLv3+ http://www.gnu.org/licenses/agpl.html
11  */
12 class nextcloud_sql_addressbook_backend extends rcube_addressbook
13 {
14     /**
15      * Nextcloud address book ID
16      *
17      * @var intger
18      */
19     protected $abId;
20
21     /**
22      * Database connection to the NextCloud database
23      *
24      * @var rcube_db
25      */
26     protected $db;
27
28     /**
29      * Database table prefix
30      *
31      * @var string
32      */
33     protected $prefix = 'oc_';
34
35     /**
36      * Result of the last operation
37      *
38      * @var rcube_result_set
39      */
40     protected $result;
41
42     /**
43      * Stored SQL filter to limit record list
44      *
45      * @var string
46      */
47     protected $filter  = null;
48
49     /**
50      * Set required parameters
51      *
52      * @param int      $abId   Addressbook ID (oc_addressbooks.id)
53      * @param rcube_db $db     Connection to the NextCloud database
54      * @param string   $prefix Database table prefix
55      */
56     public function __construct($abId, $db, $prefix)
57     {
58         $this->abId   = $abId;
59         $this->db     = $db;
60         $this->prefix = $prefix;
61     }
62
63     /**
64      * Get the title of this address book
65      *
66      * Used in contact details view.
67      *
68      * @return string Address book name
69      */
70     public function get_name()
71     {
72         $sql = 'SELECT displayname'
73              . ' FROM ' . $this->prefix . 'addressbooks'
74              . ' WHERE id = ?';
75         $stmt = $this->db->query($sql, $this->abId);
76         $row = $this->db->fetch_assoc($stmt);
77
78         return $row['displayname'] . ' (Nextcloud)';
79     }
80
81     /**
82      * Save a search string for future listings.
83      *
84      * Needed to share the filter between search(), list_records() and count().
85      *
86      * @param string $filter Part of the SQL statement used to filter contacts
87      *
88      * @return void
89      */
90     public function set_search_set($filter)
91     {
92         $this->filter = $filter;
93     }
94
95     /**
96      * Getter for saved search properties
97      *
98      * @return string Filtering part of the contact-fetching SQL statement
99      */
100     public function get_search_set()
101     {
102         return $this->filter;
103     }
104
105     /**
106      * Reset saved results and search parameters
107      *
108      * @return void
109      */
110     public function reset()
111     {
112         $this->result = null;
113         $this->filter = null;
114         $this->cache  = null;
115     }
116
117     /**
118      * List the current set of contact records
119      *
120      * @param array   $cols    List of cols to show, NULL means all
121      *                         Known values:
122      *                         - name
123      *                         - firstname
124      *                         - surname
125      *                         - email
126      * @param int     $subset  Only return this number of records,
127      *                         use negative values for tail
128      * @param boolean $nocount Do not calculate the number of all records
129      *
130      * @return rcube_result_set
131      *
132      * @internal Paging information is stored in $this->list_page
133      *           and starts with 1
134      */
135     public function list_records($cols = null, $subset = 0, $nocount = false)
136     {
137         $this->result = new rcube_result_set();
138
139         $sql = <<<SQL
140 SELECT
141     p_email.cardid AS id,
142     p_email.value AS email,
143     p_name.value AS name
144 FROM
145     %PREFIX%cards_properties AS p_email
146     JOIN %PREFIX%cards_properties AS p_name
147         ON p_name.cardid = p_email.cardid
148             AND p_name.name = "FN"
149 WHERE
150     p_email.addressbookid = ?
151     AND p_email.name = "EMAIL"
152     %FILTER%
153 ORDER BY name, email
154 SQL;
155         
156         $sql = str_replace(
157             '%FILTER%',
158             $this->filter ? ' AND ' . $this->filter : '',
159             $this->replaceTablePrefix($sql)
160         );
161
162         $firstRecord = $this->list_page * $this->page_size - $this->page_size;
163         $stmt = $this->db->limitquery(
164             $sql,
165             $firstRecord, $this->page_size,
166             $this->abId
167         );
168         foreach ($stmt as $row) {
169             $this->result->add(
170                 [
171                     'ID'    => $row['id'],
172                     'name'  => $row['name'],
173                     'email' => $row['email'],
174                 ]
175             );
176         }
177
178         if ($nocount) {
179             //do not fetch the numer of all records
180             $this->result->count = count($this->result->records);
181         } else {
182             $this->result->count = $this->count()->count;
183         }
184         
185         return $this->result;
186     }
187
188     /**
189      * Search records
190      *
191      * @param array|string $fields   One or more field names to search in. Examples:
192      *                               - '*'
193      *                               - 'ID'
194      * @param array|string $value    Search value
195      * @param int          $mode     Search mode. Sum of self::SEARCH_*.
196      * @param boolean      $select   False: only count records; do not select them
197      * @param boolean      $nocount  True to not calculate the total record count
198      * @param array        $required List of fields that cannot be empty
199      *
200      * @return rcube_result_set List of contact records and 'count' value
201      */
202     public function search(
203         $fields, $value, $mode = 0, $select = true,
204         $nocount = false, $required = []
205     ) {
206         $where = $this->buildSearchQuery($fields, $value, $mode);
207         if (empty($where)) {
208             return new rcube_result_set();
209         }
210         
211         $this->set_search_set($where);
212         if ($select) {
213             return $this->list_records(null, 0, $nocount);
214         } else {
215             return $this->count();
216         }
217     }
218
219     /**
220      * Build an SQL WHERE clause to search for $value
221      *
222      * TODO: We do not support space-separated search words yet
223      *
224      * @param array|string $fields One or more field names to search in.
225      *                             Examples:
226      *                             - '*'
227      *                             - 'ID'
228      * @param array|string $value  Search value
229      * @param int          $mode   Search mode. Sum of self::SEARCH_*.
230      *
231      * @return string Part of an SQL query, but without the prefixed " AND "
232      */
233     protected function buildSearchQuery($fields, $value, $mode)
234     {
235         if ($fields === 'ID') {
236             return 'p_email.cardid = ' . intval($value);
237
238         } else if ($fields === '*') {
239             return '('
240                 . $this->buildSearchQueryField('name', $value, $mode)
241                 . ' OR '
242                 . $this->buildSearchQueryField('email', $value, $mode)
243                 . ')';
244         }
245
246         $fields = (array) $fields;
247         $sqlParts = [];
248         foreach ($fields as $field) {
249             if ($field != 'name' && $field != 'email') {
250                 continue;
251             }
252
253             $sqlParts[] = $this->buildSearchQueryField($field, $value, $mode);
254         }
255         return '(' . implode(' OR ', $sqlParts) . ')';
256     }
257
258     /**
259      * Build a search SQL for a single field
260      *
261      * @param string       $field Field name. Examples:
262      *                            - '*'
263      *                            - 'ID'
264      * @param array|string $value Search value
265      * @param int          $mode  Search mode. Sum of self::SEARCH_*.
266      *
267      * @return string Part of an SQL query
268      */
269     protected function buildSearchQueryField($field, $value, $mode)
270     {
271         $sqlField = 'p_' . $field . '.value';
272         
273         if ($mode & self::SEARCH_STRICT) {
274             //exact match
275             return $sqlField . ' = ' . $this->db->quote($value);
276
277         } else if ($mode & self::SEARCH_PREFIX) {
278             return $this->db->ilike($sqlField, $value . '%');            
279         }
280
281         return $this->db->ilike($sqlField, '%' . $value . '%');
282     }
283
284     /**
285      * Count number of available contacts in database
286      *
287      * @return rcube_result_set Result set with values for 'count' and 'first'
288      */
289     public function count()
290     {
291         $count = isset($this->cache['count'])
292             ? $this->cache['count']
293             : $this->_count();
294
295         return new rcube_result_set(
296             $count, ($this->list_page - 1) * $this->page_size
297         );
298     }
299
300     /**
301      * Count number of available contacts in database
302      *
303      * @return int Contacts count
304      */
305     protected function _count()
306     {
307         $sql = <<<SQL
308 SELECT COUNT(*) AS cnt
309 FROM
310     %PREFIX%cards_properties AS p_email
311     JOIN %PREFIX%cards_properties AS p_name
312         ON p_name.cardid = p_email.cardid
313             AND p_name.name = "FN"
314 WHERE
315     p_email.addressbookid = ?
316     AND p_email.name = "EMAIL"
317     %FILTER%
318 SQL;
319         
320         $sql = str_replace(
321             '%FILTER%',
322             $this->filter ? ' AND ' . $this->filter : '',
323             $this->replaceTablePrefix($sql)
324         );
325
326         $stmt = $this->db->query($sql, $this->abId);
327         $row = $this->db->fetch_assoc($stmt);
328
329         $this->cache['count'] = (int) $row['cnt'];
330         return $this->cache['count'];
331     }
332     
333     /**
334      * Return the last result set
335      *
336      * @return rcube_result_set Current result set or NULL if nothing selected yet
337      */
338     public function get_result()
339     {
340         return $this->result;
341     }
342
343     /**
344      * Get a specific contact record
345      *
346      * @param mixed   $id    Record identifier
347      * @param boolean $assoc True to return record as associative array.
348      *                       False: a result set is returned
349      *
350      * @return rcube_result_set|array|null Result object with all record fields
351      *                                     NULL when it does not exist/
352      *                                     is not accessible
353      */
354     public function get_record($id, $assoc = false)
355     {
356         $sql = <<<SQL
357 SELECT
358     p_email.cardid AS ID,
359     p_email.value AS email,
360     p_name.value AS name
361 FROM
362     %PREFIX%cards_properties AS p_email
363     JOIN %PREFIX%cards_properties AS p_name
364         ON p_name.cardid = p_email.cardid
365             AND p_name.name = "FN"
366 WHERE
367     p_email.addressbookid = ?
368     AND p_email.cardid = ?
369     AND p_email.name = "EMAIL"
370 ORDER BY name, email
371 SQL;
372
373         $stmt = $this->db->query(
374             $this->replaceTablePrefix($sql),
375             $this->abId, $id
376         );
377         $row = $this->db->fetch_assoc($stmt);
378
379         if ($row === false) {
380             return null;
381         }
382
383         $this->result = new rcube_result_set(1);
384         $this->result->add(
385             [
386                 'ID' => $row['id'],
387                 'name'  => $row['name'],
388                 'email' => $row['email'],
389             ]
390         );
391         
392         return $assoc ? $this->result->first() : $this->result;
393     }
394
395     /**
396      * Replace the %PREFIX% variable in SQL queries with the configured
397      * NextCloud table prefix
398      *
399      * @param string $sql SQL query with %PREFIX% variables
400      *
401      * @return string Working SQL query
402      */
403     protected function replaceTablePrefix($sql)
404     {
405         return str_replace('%PREFIX%', $this->prefix, $sql);
406     }
407 }
408 ?>