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