Add is empty filter to search / api
[civicrm-core.git] / ext / search / Civi / Search / Admin.php
CommitLineData
22601c92
CW
1<?php
2/*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
10 */
11
12namespace Civi\Search;
13
daa4e55a
CW
14use CRM_Search_ExtensionUtil as E;
15
22601c92
CW
16/**
17 * Class Admin
18 * @package Civi\Search
19 */
20class Admin {
21
22 /**
23 * @return array
24 */
25 public static function getAdminSettings():array {
4f0729ed 26 $schema = self::getSchema();
22601c92 27 return [
4f0729ed
CW
28 'schema' => $schema,
29 'joins' => self::getJoins(array_column($schema, NULL, 'name')),
22601c92
CW
30 'operators' => \CRM_Utils_Array::makeNonAssociative(self::getOperators()),
31 'functions' => \CRM_Api4_Page_Api4Explorer::getSqlFunctions(),
ecb9d1eb 32 'displayTypes' => Display::getDisplayTypes(['id', 'name', 'label', 'description', 'icon']),
daa4e55a 33 'styles' => \CRM_Utils_Array::makeNonAssociative(self::getStyles()),
2ef64700
CW
34 'afformEnabled' => (bool) \CRM_Utils_Array::findAll(
35 \CRM_Extension_System::singleton()->getMapper()->getActiveModuleFiles(),
36 ['fullName' => 'org.civicrm.afform']
37 ),
38 'afformAdminEnabled' => (bool) \CRM_Utils_Array::findAll(
39 \CRM_Extension_System::singleton()->getMapper()->getActiveModuleFiles(),
40 ['fullName' => 'org.civicrm.afform_admin']
41 ),
22601c92
CW
42 ];
43 }
44
45 /**
46 * @return string[]
47 */
48 public static function getOperators():array {
49 return [
50 '=' => '=',
51 '!=' => '≠',
52 '>' => '>',
53 '<' => '<',
54 '>=' => '≥',
55 '<=' => '≤',
daa4e55a
CW
56 'CONTAINS' => E::ts('Contains'),
57 'IN' => E::ts('Is One Of'),
58 'NOT IN' => E::ts('Not One Of'),
59 'LIKE' => E::ts('Is Like'),
60 'NOT LIKE' => E::ts('Not Like'),
61 'BETWEEN' => E::ts('Is Between'),
62 'NOT BETWEEN' => E::ts('Not Between'),
c0e68893 63 'IS EMPTY' => E::ts('Is Empty'),
64 'IS NOT EMPTY' => E::ts('Not Empty'),
daa4e55a
CW
65 ];
66 }
67
68 /**
69 * @return string[]
70 */
71 public static function getStyles():array {
72 return [
73 'default' => E::ts('Default'),
74 'primary' => E::ts('Primary'),
75 'success' => E::ts('Success'),
76 'info' => E::ts('Info'),
77 'warning' => E::ts('Warning'),
78 'danger' => E::ts('Danger'),
22601c92
CW
79 ];
80 }
81
82 /**
83 * Fetch all entities the current user has permission to `get`
590c0e3f 84 * @return array
22601c92
CW
85 */
86 public static function getSchema() {
87 $schema = [];
88 $entities = \Civi\Api4\Entity::get()
861688db 89 ->addSelect('name', 'title', 'type', 'title_plural', 'description', 'label_field', 'icon', 'paths', 'dao', 'bridge', 'ui_join_filters')
09815e9c 90 ->addWhere('searchable', '=', TRUE)
44111498 91 ->addOrderBy('title_plural')
22601c92
CW
92 ->setChain([
93 'get' => ['$name', 'getActions', ['where' => [['name', '=', 'get']]], ['params']],
94 ])->execute();
30d895a9 95 $getFields = ['name', 'title', 'label', 'description', 'options', 'input_type', 'input_attrs', 'data_type', 'serialize', 'entity', 'fk_entity'];
22601c92
CW
96 foreach ($entities as $entity) {
97 // Skip if entity doesn't have a 'get' action or the user doesn't have permission to use get
98 if ($entity['get']) {
f9cf8797
CW
99 // Add paths (but only RUD actions) with translated titles
100 foreach ($entity['paths'] as $action => $path) {
101 unset($entity['paths'][$action]);
102 switch ($action) {
103 case 'view':
daa4e55a 104 $title = E::ts('View %1', [1 => $entity['title']]);
f9cf8797
CW
105 break;
106
5c385299 107 case 'update':
daa4e55a 108 $title = E::ts('Edit %1', [1 => $entity['title']]);
f9cf8797
CW
109 break;
110
111 case 'delete':
daa4e55a 112 $title = E::ts('Delete %1', [1 => $entity['title']]);
f9cf8797
CW
113 break;
114
115 default:
116 continue 2;
117 }
118 $entity['paths'][] = [
119 'path' => $path,
120 'title' => $title,
121 'action' => $action,
122 ];
123 }
4f0729ed 124 $entity['fields'] = (array) civicrm_api4($entity['name'], 'getFields', [
22601c92 125 'select' => $getFields,
393840b9 126 'where' => [['name', 'NOT IN', ['api_key', 'hash']]],
22601c92
CW
127 'orderBy' => ['label'],
128 ]);
129 $params = $entity['get'][0];
130 // Entity must support at least these params or it is too weird for search kit
131 if (!array_diff(['select', 'where', 'orderBy', 'limit', 'offset'], array_keys($params))) {
132 \CRM_Utils_Array::remove($params, 'checkPermissions', 'debug', 'chain', 'language', 'select', 'where', 'orderBy', 'limit', 'offset');
133 unset($entity['get']);
861688db 134 $schema[$entity['name']] = ['params' => array_keys($params)] + array_filter($entity);
22601c92
CW
135 }
136 }
137 }
861688db
CW
138 // Add in FK fields for implicit joins
139 // For example, add a `campaign.title` field to the Contribution entity
140 foreach ($schema as &$entity) {
141 foreach (array_reverse($entity['fields'], TRUE) as $index => $field) {
142 if (!empty($field['fk_entity']) && !$field['options'] && !empty($schema[$field['fk_entity']]['label_field'])) {
143 // The original field will get title instead of label since it represents the id (title usually ends in ID but label does not)
144 $entity['fields'][$index]['label'] = $field['title'];
145 // Add the label field from the other entity to this entity's list of fields
146 $newField = \CRM_Utils_Array::findAll($schema[$field['fk_entity']]['fields'], ['name' => $schema[$field['fk_entity']]['label_field']])[0];
147 $newField['name'] = str_replace('_id', '', $field['name']) . '.' . $schema[$field['fk_entity']]['label_field'];
148 $newField['label'] = $field['label'] . ' ' . $newField['label'];
149 array_splice($entity['fields'], $index, 0, [$newField]);
150 }
151 }
152 }
153 return array_values($schema);
22601c92
CW
154 }
155
156 /**
590c0e3f 157 * @param array $allowedEntities
22601c92
CW
158 * @return array
159 */
4f0729ed
CW
160 public static function getJoins(array $allowedEntities) {
161 $joins = [];
162 foreach ($allowedEntities as $entity) {
5828ae54
CW
163 // Multi-record custom field groups (to-date only the contact entity supports these)
164 if (in_array('CustomValue', $entity['type'])) {
165 $targetEntity = $allowedEntities['Contact'];
166 // Join from Custom group to Contact (n-1)
167 $alias = $entity['name'] . '_Contact_entity_id';
168 $joins[$entity['name']][] = [
169 'label' => $entity['title'] . ' ' . $targetEntity['title'],
170 'description' => '',
171 'entity' => 'Contact',
172 'conditions' => self::getJoinConditions('entity_id', $alias . '.id'),
173 'defaults' => self::getJoinDefaults($alias, $targetEntity),
174 'alias' => $alias,
175 'multi' => FALSE,
176 ];
177 // Join from Contact to Custom group (n-n)
178 $alias = 'Contact_' . $entity['name'] . '_entity_id';
179 $joins['Contact'][] = [
180 'label' => $entity['title_plural'],
181 'description' => '',
182 'entity' => $entity['name'],
183 'conditions' => self::getJoinConditions('id', $alias . '.entity_id'),
184 'defaults' => self::getJoinDefaults($alias, $entity),
185 'alias' => $alias,
186 'multi' => TRUE,
187 ];
188 }
189 // Non-custom DAO entities
190 elseif (!empty($entity['dao'])) {
4f0729ed
CW
191 /* @var \CRM_Core_DAO $daoClass */
192 $daoClass = $entity['dao'];
193 $references = $daoClass::getReferenceColumns();
194 // Only the first bridge reference gets processed, so if it's dynamic we want to be sure it's first in the list
195 usort($references, function($reference) {
196 return is_a($reference, 'CRM_Core_Reference_Dynamic') ? -1 : 1;
197 });
198 $fields = array_column($entity['fields'], NULL, 'name');
199 $bridge = in_array('EntityBridge', $entity['type']) ? $entity['name'] : NULL;
4f0729ed
CW
200 foreach ($references as $reference) {
201 $keyField = $fields[$reference->getReferenceKey()] ?? NULL;
202 // Exclude any joins that are better represented by pseudoconstants
203 if (is_a($reference, 'CRM_Core_Reference_OptionValue')
204 || !$keyField || !empty($keyField['options'])
205 // Limit bridge joins to just the first
206 || $bridge && array_search($keyField['name'], $entity['bridge']) !== 0
207 // Sanity check - table should match
208 || $daoClass::getTableName() !== $reference->getReferenceTable()
209 ) {
210 continue;
211 }
5828ae54
CW
212 // Dynamic references use a column like "entity_table" (for normal joins this value will be null)
213 $dynamicCol = $reference->getTypeColumn();
2f616560 214 // For dynamic references getTargetEntities will return multiple targets; for normal joins this loop will only run once
17019d49 215 foreach ($reference->getTargetEntities() as $targetTable => $targetEntityName) {
4f0729ed
CW
216 if (!isset($allowedEntities[$targetEntityName]) || $targetEntityName === $entity['name']) {
217 continue;
218 }
219 $targetEntity = $allowedEntities[$targetEntityName];
552bb940 220 // Non-bridge joins directly between 2 entities
4f0729ed
CW
221 if (!$bridge) {
222 // Add the straight 1-1 join
223 $alias = $entity['name'] . '_' . $targetEntityName . '_' . $keyField['name'];
224 $joins[$entity['name']][] = [
225 'label' => $entity['title'] . ' ' . $targetEntity['title'],
226 'description' => $dynamicCol ? '' : $keyField['label'],
227 'entity' => $targetEntityName,
228 'conditions' => self::getJoinConditions($keyField['name'], $alias . '.' . $reference->getTargetKey(), $targetTable, $dynamicCol),
2f616560 229 'defaults' => self::getJoinDefaults($alias, $targetEntity),
4f0729ed
CW
230 'alias' => $alias,
231 'multi' => FALSE,
232 ];
233 // Flip the conditions & add the reverse (1-n) join
234 $alias = $targetEntityName . '_' . $entity['name'] . '_' . $keyField['name'];
235 $joins[$targetEntityName][] = [
236 'label' => $targetEntity['title'] . ' ' . $entity['title_plural'],
237 'description' => $dynamicCol ? '' : $keyField['label'],
238 'entity' => $entity['name'],
239 'conditions' => self::getJoinConditions($reference->getTargetKey(), $alias . '.' . $keyField['name'], $targetTable, $dynamicCol ? $alias . '.' . $dynamicCol : NULL),
2f616560 240 'defaults' => self::getJoinDefaults($alias, $entity),
4f0729ed
CW
241 'alias' => $alias,
242 'multi' => TRUE,
243 ];
244 }
552bb940
CW
245 // Bridge joins (sanity check - bridge must specify exactly 2 FK fields)
246 elseif (count($entity['bridge']) === 2) {
247 // Get the other entity being linked through this bridge
248 $baseKey = array_search($reference->getReferenceKey(), $entity['bridge']) ? $entity['bridge'][0] : $entity['bridge'][1];
249 $baseEntity = $allowedEntities[$fields[$baseKey]['fk_entity']] ?? NULL;
250 if (!$baseEntity) {
251 continue;
252 }
4f0729ed
CW
253 // Add joins for the two entities that connect through this bridge (n-n)
254 $symmetric = $baseEntity['name'] === $targetEntityName;
255 $targetsTitle = $symmetric ? $allowedEntities[$bridge]['title_plural'] : $targetEntity['title_plural'];
552bb940 256 $alias = $baseEntity['name'] . "_{$bridge}_" . $targetEntityName;
4f0729ed
CW
257 $joins[$baseEntity['name']][] = [
258 'label' => $baseEntity['title'] . ' ' . $targetsTitle,
daa4e55a 259 'description' => E::ts('Multiple %1 per %2', [1 => $targetsTitle, 2 => $baseEntity['title']]),
4f0729ed 260 'entity' => $targetEntityName,
552bb940
CW
261 'conditions' => array_merge(
262 [$bridge],
263 self::getJoinConditions('id', $alias . '.' . $baseKey, NULL, NULL)
264 ),
2f616560 265 'defaults' => self::getJoinDefaults($alias, $targetEntity, $entity),
4f0729ed 266 'bridge' => $bridge,
552bb940 267 'alias' => $alias,
4f0729ed
CW
268 'multi' => TRUE,
269 ];
270 if (!$symmetric) {
552bb940 271 $alias = $targetEntityName . "_{$bridge}_" . $baseEntity['name'];
4f0729ed
CW
272 $joins[$targetEntityName][] = [
273 'label' => $targetEntity['title'] . ' ' . $baseEntity['title_plural'],
daa4e55a 274 'description' => E::ts('Multiple %1 per %2', [1 => $baseEntity['title_plural'], 2 => $targetEntity['title']]),
4f0729ed 275 'entity' => $baseEntity['name'],
552bb940
CW
276 'conditions' => array_merge(
277 [$bridge],
278 self::getJoinConditions($reference->getTargetKey(), $alias . '.' . $keyField['name'], $targetTable, $dynamicCol ? $alias . '.' . $dynamicCol : NULL)
279 ),
2f616560 280 'defaults' => self::getJoinDefaults($alias, $baseEntity, $entity),
4f0729ed 281 'bridge' => $bridge,
552bb940 282 'alias' => $alias,
4f0729ed
CW
283 'multi' => TRUE,
284 ];
285 }
286 }
287 }
22601c92
CW
288 }
289 }
22601c92 290 }
4f0729ed
CW
291 return $joins;
292 }
293
294 /**
295 * Boilerplate join clause
296 *
297 * @param string $nearCol
298 * @param string $farCol
299 * @param string $targetTable
300 * @param string|null $dynamicCol
301 * @return array[]
302 */
5828ae54 303 private static function getJoinConditions($nearCol, $farCol, $targetTable = NULL, $dynamicCol = NULL) {
4f0729ed
CW
304 $conditions = [
305 [
306 $nearCol,
307 '=',
308 $farCol,
309 ],
310 ];
311 if ($dynamicCol) {
312 $conditions[] = [
313 $dynamicCol,
314 '=',
315 "'$targetTable'",
316 ];
317 }
318 return $conditions;
22601c92
CW
319 }
320
2f616560
CW
321 /**
322 * @param $alias
323 * @param array ...$entities
324 * @return array
325 */
326 private static function getJoinDefaults($alias, ...$entities):array {
327 $conditions = [];
328 foreach ($entities as $entity) {
329 foreach ($entity['ui_join_filters'] ?? [] as $fieldName) {
330 $field = civicrm_api4($entity['name'], 'getFields', [
331 'select' => ['options'],
332 'where' => [['name', '=', $fieldName]],
333 'loadOptions' => ['name'],
334 ])->first();
335 $value = isset($field['options'][0]) ? json_encode($field['options'][0]['name']) : '';
336 $conditions[] = [
337 $alias . '.' . $fieldName . ($value ? ':name' : ''),
338 '=',
339 $value,
340 ];
341 }
342 }
343 return $conditions;
344 }
345
22601c92 346}