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