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