Commit | Line | Data |
---|---|---|
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 | ||
12 | namespace Civi\Search; | |
13 | ||
daa4e55a CW |
14 | use CRM_Search_ExtensionUtil as E; |
15 | ||
22601c92 CW |
16 | /** |
17 | * Class Admin | |
18 | * @package Civi\Search | |
19 | */ | |
20 | class 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 | } |