| 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', '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 | } |