3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
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 +--------------------------------------------------------------------+
12 namespace Civi\Search
;
14 use CRM_Search_ExtensionUtil
as E
;
18 * @package Civi\Search
25 public static function getAdminSettings():array {
26 $schema = self
::getSchema();
27 $extensions = \CRM_Extension_System
::singleton()->getMapper();
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 'defaultPagerSize' => \Civi
::settings()->get('default_pager_size'),
36 'afformEnabled' => $extensions->isActiveModule('afform'),
37 'afformAdminEnabled' => $extensions->isActiveModule('afform_admin'),
44 public static function getOperators():array {
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'),
67 public static function getStyles():array {
69 'default' => E
::ts('Default'),
70 'primary' => E
::ts('Primary'),
71 'success' => E
::ts('Success'),
72 'info' => E
::ts('Info'),
73 'warning' => E
::ts('Warning'),
74 'danger' => E
::ts('Danger'),
79 * Fetch all entities the current user has permission to `get`
82 public static function getSchema() {
84 $entities = \Civi\Api4\Entity
::get()
85 ->addSelect('name', 'title', 'type', 'title_plural', 'description', 'label_field', 'icon', 'paths', 'dao', 'bridge', 'ui_join_filters', 'searchable')
86 ->addWhere('searchable', '!=', 'none')
87 ->addOrderBy('title_plural')
89 'get' => ['$name', 'getActions', ['where' => [['name', '=', 'get']]], ['params']],
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
94 // Add paths (but only RUD actions) with translated titles
95 foreach ($entity['paths'] as $action => $path) {
96 unset($entity['paths'][$action]);
97 if (in_array($action, ['view', 'update', 'delete'], TRUE)) {
98 $entity['paths'][] = [
104 $getFields = civicrm_api4($entity['name'], 'getFields', [
105 'select' => ['name', 'title', 'label', 'description', 'options', 'input_type', 'input_attrs', 'data_type', 'serialize', 'entity', 'fk_entity', 'readonly'],
106 'where' => [['name', 'NOT IN', ['api_key', 'hash']]],
107 'orderBy' => ['label'],
109 foreach ($getFields as $field) {
110 $field['fieldName'] = $field['name'];
111 $entity['fields'][] = $field;
113 $params = $entity['get'][0];
114 // Entity must support at least these params or it is too weird for search kit
115 if (!array_diff(['select', 'where', 'orderBy', 'limit', 'offset'], array_keys($params))) {
116 \CRM_Utils_Array
::remove($params, 'checkPermissions', 'debug', 'chain', 'language', 'select', 'where', 'orderBy', 'limit', 'offset');
117 unset($entity['get']);
118 $schema[$entity['name']] = ['params' => array_keys($params)] +
array_filter($entity);
122 // Add in FK fields for implicit joins
123 // For example, add a `campaign_id.title` field to the Contribution entity
124 foreach ($schema as &$entity) {
125 if (in_array('DAOEntity', $entity['type'], TRUE) && !in_array('EntityBridge', $entity['type'], TRUE)) {
126 foreach (array_reverse($entity['fields'], TRUE) as $index => $field) {
127 if (!empty($field['fk_entity']) && !$field['options'] && empty($field['serialize']) && !empty($schema[$field['fk_entity']]['label_field'])) {
128 $isCustom = strpos($field['name'], '.');
129 // Custom fields: append "Contact ID" to original field label
131 $entity['fields'][$index]['label'] .= ' ' . E
::ts('Contact ID');
133 // DAO fields: use title instead of label since it represents the id (title usually ends in ID but label does not)
135 $entity['fields'][$index]['label'] = $field['title'];
137 // Add the label field from the other entity to this entity's list of fields
138 $newField = \CRM_Utils_Array
::findAll($schema[$field['fk_entity']]['fields'], ['name' => $schema[$field['fk_entity']]['label_field']])[0];
139 $newField['name'] = $field['name'] . '.' . $schema[$field['fk_entity']]['label_field'];
140 $newField['label'] = $field['label'] . ' ' . $newField['label'];
141 array_splice($entity['fields'], $index, 0, [$newField]);
146 return array_values($schema);
150 * @param array $allowedEntities
153 public static function getJoins(array $allowedEntities) {
155 foreach ($allowedEntities as $entity) {
156 // Multi-record custom field groups (to-date only the contact entity supports these)
157 if (in_array('CustomValue', $entity['type'])) {
158 $targetEntity = $allowedEntities['Contact'];
159 // Join from Custom group to Contact (n-1)
160 $alias = $entity['name'] . '_Contact_entity_id';
161 $joins[$entity['name']][] = [
162 'label' => $entity['title'] . ' ' . $targetEntity['title'],
164 'entity' => 'Contact',
165 'conditions' => self
::getJoinConditions('entity_id', $alias . '.id'),
166 'defaults' => self
::getJoinDefaults($alias, $targetEntity),
170 // Join from Contact to Custom group (n-n)
171 $alias = 'Contact_' . $entity['name'] . '_entity_id';
172 $joins['Contact'][] = [
173 'label' => $entity['title_plural'],
175 'entity' => $entity['name'],
176 'conditions' => self
::getJoinConditions('id', $alias . '.entity_id'),
177 'defaults' => self
::getJoinDefaults($alias, $entity),
182 // Non-custom DAO entities
183 elseif (!empty($entity['dao'])) {
184 /* @var \CRM_Core_DAO $daoClass */
185 $daoClass = $entity['dao'];
186 $references = $daoClass::getReferenceColumns();
187 // Only the first bridge reference gets processed, so if it's dynamic we want to be sure it's first in the list
188 usort($references, function($reference) {
189 return is_a($reference, 'CRM_Core_Reference_Dynamic') ?
-1 : 1;
191 $fields = array_column($entity['fields'], NULL, 'name');
192 $bridge = in_array('EntityBridge', $entity['type']) ?
$entity['name'] : NULL;
193 $bridgeFields = array_keys($entity['bridge'] ??
[]);
194 foreach ($references as $reference) {
195 $keyField = $fields[$reference->getReferenceKey()] ??
NULL;
197 // Sanity check - keyField must exist
199 // Exclude any joins that are better represented by pseudoconstants
200 is_a($reference, 'CRM_Core_Reference_OptionValue') ||
(!$bridge && !empty($keyField['options'])) ||
201 // Limit bridge joins to just the first
202 ($bridge && array_search($keyField['name'], $bridgeFields) !== 0) ||
203 // Sanity check - table should match
204 $daoClass::getTableName() !== $reference->getReferenceTable()
208 // Dynamic references use a column like "entity_table" (for normal joins this value will be null)
209 $dynamicCol = $reference->getTypeColumn();
210 // For dynamic references getTargetEntities will return multiple targets; for normal joins this loop will only run once
211 foreach ($reference->getTargetEntities() as $targetTable => $targetEntityName) {
212 if (!isset($allowedEntities[$targetEntityName]) ||
$targetEntityName === $entity['name']) {
215 $targetEntity = $allowedEntities[$targetEntityName];
216 // Non-bridge joins directly between 2 entities
218 // Add the straight 1-1 join
219 $alias = $entity['name'] . '_' . $targetEntityName . '_' . $keyField['name'];
220 $joins[$entity['name']][] = [
221 'label' => $entity['title'] . ' ' . $targetEntity['title'],
222 'description' => $dynamicCol ?
'' : $keyField['label'],
223 'entity' => $targetEntityName,
224 'conditions' => self
::getJoinConditions($keyField['name'], $alias . '.' . $reference->getTargetKey(), $targetTable, $dynamicCol),
225 'defaults' => self
::getJoinDefaults($alias, $targetEntity),
229 // Flip the conditions & add the reverse (1-n) join
230 $alias = $targetEntityName . '_' . $entity['name'] . '_' . $keyField['name'];
231 $joins[$targetEntityName][] = [
232 'label' => $targetEntity['title'] . ' ' . $entity['title_plural'],
233 'description' => $dynamicCol ?
'' : $keyField['label'],
234 'entity' => $entity['name'],
235 'conditions' => self
::getJoinConditions($reference->getTargetKey(), $alias . '.' . $keyField['name'], $targetTable, $dynamicCol ?
$alias . '.' . $dynamicCol : NULL),
236 'defaults' => self
::getJoinDefaults($alias, $entity),
241 // Bridge joins (sanity check - bridge must specify exactly 2 FK fields)
242 elseif (count($entity['bridge']) === 2) {
243 // Get the other entity being linked through this bridge
244 $baseKey = array_search($reference->getReferenceKey(), $bridgeFields) ?
$bridgeFields[0] : $bridgeFields[1];
245 $baseEntity = $allowedEntities[$fields[$baseKey]['fk_entity']] ??
NULL;
249 // Add joins for the two entities that connect through this bridge (n-n)
250 $symmetric = $baseEntity['name'] === $targetEntityName;
251 $targetsTitle = $symmetric ?
$allowedEntities[$bridge]['title_plural'] : $targetEntity['title_plural'];
252 $alias = $baseEntity['name'] . "_{$bridge}_" . $targetEntityName;
253 $joins[$baseEntity['name']][] = [
254 'label' => $baseEntity['title'] . ' ' . $targetsTitle,
255 'description' => $entity['bridge'][$baseKey]['description'] ?? E
::ts('Multiple %1 per %2', [1 => $targetsTitle, 2 => $baseEntity['title']]),
256 'entity' => $targetEntityName,
257 'conditions' => array_merge(
259 self
::getJoinConditions('id', $alias . '.' . $baseKey, NULL, NULL)
261 'defaults' => self
::getJoinDefaults($alias, $targetEntity, $entity),
267 $alias = $targetEntityName . "_{$bridge}_" . $baseEntity['name'];
268 $joins[$targetEntityName][] = [
269 'label' => $targetEntity['title'] . ' ' . $baseEntity['title_plural'],
270 'description' => $entity['bridge'][$reference->getReferenceKey()]['description'] ?? E
::ts('Multiple %1 per %2', [1 => $baseEntity['title_plural'], 2 => $targetEntity['title']]),
271 'entity' => $baseEntity['name'],
272 'conditions' => array_merge(
274 self
::getJoinConditions($reference->getTargetKey(), $alias . '.' . $keyField['name'], $targetTable, $dynamicCol ?
$alias . '.' . $dynamicCol : NULL)
276 'defaults' => self
::getJoinDefaults($alias, $baseEntity, $entity),
291 * Boilerplate join clause
293 * @param string $nearCol
294 * @param string $farCol
295 * @param string $targetTable
296 * @param string|null $dynamicCol
299 private static function getJoinConditions($nearCol, $farCol, $targetTable = NULL, $dynamicCol = NULL) {
319 * @param array ...$entities
322 private static function getJoinDefaults($alias, ...$entities):array {
324 foreach ($entities as $entity) {
325 foreach ($entity['ui_join_filters'] ??
[] as $fieldName) {
326 $field = civicrm_api4($entity['name'], 'getFields', [
327 'select' => ['options'],
328 'where' => [['name', '=', $fieldName]],
329 'loadOptions' => ['name'],
331 $value = isset($field['options'][0]) ?
json_encode($field['options'][0]['name']) : '';
333 $alias . '.' . $fieldName . ($value ?
':name' : ''),