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