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
;
16 * @package Civi\Search
23 public static function getAdminSettings():array {
24 $schema = self
::getSchema();
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']
35 'afformAdminEnabled' => (bool) \CRM_Utils_Array
::findAll(
36 \CRM_Extension_System
::singleton()->getMapper()->getActiveModuleFiles(),
37 ['fullName' => 'org.civicrm.afform_admin']
45 public static function getOperators():array {
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'),
66 * Fetch all entities the current user has permission to `get`
69 public static function getSchema() {
71 $entities = \Civi\Api4\Entity
::get()
72 ->addSelect('name', 'title', 'type', 'title_plural', 'description', 'icon', 'paths', 'dao', 'bridge', 'ui_join_filters')
73 ->addWhere('searchable', '=', TRUE)
74 ->addOrderBy('title_plural')
76 'get' => ['$name', 'getActions', ['where' => [['name', '=', 'get']]], ['params']],
78 $getFields = ['name', 'label', 'description', 'options', 'input_type', 'input_attrs', 'data_type', 'serialize', '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
82 // Add paths (but only RUD actions) with translated titles
83 foreach ($entity['paths'] as $action => $path) {
84 unset($entity['paths'][$action]);
87 $title = ts('View %1', [1 => $entity['title']]);
91 $title = ts('Edit %1', [1 => $entity['title']]);
95 $title = ts('Delete %1', [1 => $entity['title']]);
101 $entity['paths'][] = [
107 $entity['fields'] = (array) civicrm_api4($entity['name'], 'getFields', [
108 'select' => $getFields,
109 'where' => [['name', 'NOT IN', ['api_key', 'hash']]],
110 'orderBy' => ['label'],
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[] = ['params' => array_keys($params)] +
array_filter($entity);
125 * @param array $allowedEntities
128 public static function getJoins(array $allowedEntities) {
130 foreach ($allowedEntities as $entity) {
131 // Multi-record custom field groups (to-date only the contact entity supports these)
132 if (in_array('CustomValue', $entity['type'])) {
133 $targetEntity = $allowedEntities['Contact'];
134 // Join from Custom group to Contact (n-1)
135 $alias = $entity['name'] . '_Contact_entity_id';
136 $joins[$entity['name']][] = [
137 'label' => $entity['title'] . ' ' . $targetEntity['title'],
139 'entity' => 'Contact',
140 'conditions' => self
::getJoinConditions('entity_id', $alias . '.id'),
141 'defaults' => self
::getJoinDefaults($alias, $targetEntity),
145 // Join from Contact to Custom group (n-n)
146 $alias = 'Contact_' . $entity['name'] . '_entity_id';
147 $joins['Contact'][] = [
148 'label' => $entity['title_plural'],
150 'entity' => $entity['name'],
151 'conditions' => self
::getJoinConditions('id', $alias . '.entity_id'),
152 'defaults' => self
::getJoinDefaults($alias, $entity),
157 // Non-custom DAO entities
158 elseif (!empty($entity['dao'])) {
159 /* @var \CRM_Core_DAO $daoClass */
160 $daoClass = $entity['dao'];
161 $references = $daoClass::getReferenceColumns();
162 // Only the first bridge reference gets processed, so if it's dynamic we want to be sure it's first in the list
163 usort($references, function($reference) {
164 return is_a($reference, 'CRM_Core_Reference_Dynamic') ?
-1 : 1;
166 $fields = array_column($entity['fields'], NULL, 'name');
167 $bridge = in_array('EntityBridge', $entity['type']) ?
$entity['name'] : NULL;
168 foreach ($references as $reference) {
169 $keyField = $fields[$reference->getReferenceKey()] ??
NULL;
170 // Exclude any joins that are better represented by pseudoconstants
171 if (is_a($reference, 'CRM_Core_Reference_OptionValue')
172 ||
!$keyField ||
!empty($keyField['options'])
173 // Limit bridge joins to just the first
174 ||
$bridge && array_search($keyField['name'], $entity['bridge']) !== 0
175 // Sanity check - table should match
176 ||
$daoClass::getTableName() !== $reference->getReferenceTable()
180 // Dynamic references use a column like "entity_table" (for normal joins this value will be null)
181 $dynamicCol = $reference->getTypeColumn();
182 // For dynamic references getTargetEntities will return multiple targets; for normal joins this loop will only run once
183 foreach ($reference->getTargetEntities() as $targetTable => $targetEntityName) {
184 if (!isset($allowedEntities[$targetEntityName]) ||
$targetEntityName === $entity['name']) {
187 $targetEntity = $allowedEntities[$targetEntityName];
188 // Non-bridge joins directly between 2 entities
190 // Add the straight 1-1 join
191 $alias = $entity['name'] . '_' . $targetEntityName . '_' . $keyField['name'];
192 $joins[$entity['name']][] = [
193 'label' => $entity['title'] . ' ' . $targetEntity['title'],
194 'description' => $dynamicCol ?
'' : $keyField['label'],
195 'entity' => $targetEntityName,
196 'conditions' => self
::getJoinConditions($keyField['name'], $alias . '.' . $reference->getTargetKey(), $targetTable, $dynamicCol),
197 'defaults' => self
::getJoinDefaults($alias, $targetEntity),
201 // Flip the conditions & add the reverse (1-n) join
202 $alias = $targetEntityName . '_' . $entity['name'] . '_' . $keyField['name'];
203 $joins[$targetEntityName][] = [
204 'label' => $targetEntity['title'] . ' ' . $entity['title_plural'],
205 'description' => $dynamicCol ?
'' : $keyField['label'],
206 'entity' => $entity['name'],
207 'conditions' => self
::getJoinConditions($reference->getTargetKey(), $alias . '.' . $keyField['name'], $targetTable, $dynamicCol ?
$alias . '.' . $dynamicCol : NULL),
208 'defaults' => self
::getJoinDefaults($alias, $entity),
213 // Bridge joins (sanity check - bridge must specify exactly 2 FK fields)
214 elseif (count($entity['bridge']) === 2) {
215 // Get the other entity being linked through this bridge
216 $baseKey = array_search($reference->getReferenceKey(), $entity['bridge']) ?
$entity['bridge'][0] : $entity['bridge'][1];
217 $baseEntity = $allowedEntities[$fields[$baseKey]['fk_entity']] ??
NULL;
221 // Add joins for the two entities that connect through this bridge (n-n)
222 $symmetric = $baseEntity['name'] === $targetEntityName;
223 $targetsTitle = $symmetric ?
$allowedEntities[$bridge]['title_plural'] : $targetEntity['title_plural'];
224 $alias = $baseEntity['name'] . "_{$bridge}_" . $targetEntityName;
225 $joins[$baseEntity['name']][] = [
226 'label' => $baseEntity['title'] . ' ' . $targetsTitle,
227 'description' => ts('Multiple %1 per %2', [1 => $targetsTitle, 2 => $baseEntity['title']]),
228 'entity' => $targetEntityName,
229 'conditions' => array_merge(
231 self
::getJoinConditions('id', $alias . '.' . $baseKey, NULL, NULL)
233 'defaults' => self
::getJoinDefaults($alias, $targetEntity, $entity),
239 $alias = $targetEntityName . "_{$bridge}_" . $baseEntity['name'];
240 $joins[$targetEntityName][] = [
241 'label' => $targetEntity['title'] . ' ' . $baseEntity['title_plural'],
242 'description' => ts('Multiple %1 per %2', [1 => $baseEntity['title_plural'], 2 => $targetEntity['title']]),
243 'entity' => $baseEntity['name'],
244 'conditions' => array_merge(
246 self
::getJoinConditions($reference->getTargetKey(), $alias . '.' . $keyField['name'], $targetTable, $dynamicCol ?
$alias . '.' . $dynamicCol : NULL)
248 'defaults' => self
::getJoinDefaults($alias, $baseEntity, $entity),
263 * Boilerplate join clause
265 * @param string $nearCol
266 * @param string $farCol
267 * @param string $targetTable
268 * @param string|null $dynamicCol
271 private static function getJoinConditions($nearCol, $farCol, $targetTable = NULL, $dynamicCol = NULL) {
291 * @param array ...$entities
294 private static function getJoinDefaults($alias, ...$entities):array {
296 foreach ($entities as $entity) {
297 foreach ($entity['ui_join_filters'] ??
[] as $fieldName) {
298 $field = civicrm_api4($entity['name'], 'getFields', [
299 'select' => ['options'],
300 'where' => [['name', '=', $fieldName]],
301 'loadOptions' => ['name'],
303 $value = isset($field['options'][0]) ?
json_encode($field['options'][0]['name']) : '';
305 $alias . '.' . $fieldName . ($value ?
':name' : ''),