Skip to content

Commit

Permalink
SearchKit - Add display of type entity
Browse files Browse the repository at this point in the history
An entity display does not produce user-facing output, instead it writes to a SQL table
which can then be queried from SearchKit, the API, or other SQL-based tools like Drupal Views.
  • Loading branch information
colemanw committed Mar 20, 2023
1 parent 5894635 commit de0cac9
Show file tree
Hide file tree
Showing 13 changed files with 610 additions and 5 deletions.
11 changes: 7 additions & 4 deletions Civi/Api4/Generic/Traits/SavedSearchInspectorTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,14 @@ trait SavedSearchInspectorTrait {

/**
* If SavedSearch is supplied as a string, this will load it as an array
* @param int|null $id
* @throws UnauthorizedException
* @throws \CRM_Core_Exception
* @throws \Civi\API\Exception\UnauthorizedException
*/
protected function loadSavedSearch() {
if (is_string($this->savedSearch)) {
protected function loadSavedSearch(int $id = NULL) {
if ($id || is_string($this->savedSearch)) {
$this->savedSearch = SavedSearch::get(FALSE)
->addWhere('name', '=', $this->savedSearch)
->addWhere($id ? 'id' : 'name', '=', $id ?: $this->savedSearch)
->execute()->single();
}
if (is_array($this->savedSearch)) {
Expand All @@ -64,6 +65,8 @@ protected function loadSavedSearch() {
];
$this->savedSearch['api_params'] += ['version' => 4, 'select' => [], 'where' => []];
}
// Reset internal cached metadata
$this->_selectQuery = $this->_selectClause = $this->_searchEntityFields = NULL;
$this->_apiParams = ($this->savedSearch['api_params'] ?? []) + ['select' => [], 'where' => []];
}

Expand Down
28 changes: 28 additions & 0 deletions ext/search_kit/Civi/Api4/Action/SearchDisplay/Run.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ protected function processResult(\Civi\Api4\Result\SearchDisplayRunResult $resul
// Pager can operate in "page" mode for traditional pager, or "scroll" mode for infinite scrolling
$pagerMode = NULL;

// "entity" displays have no output, they write to a db table
if ($this->display['type'] === 'entity') {
$this->writeToEntityTable();
return;
}

$this->augmentSelectClause($apiParams);
$this->applyFilters();

Expand Down Expand Up @@ -124,4 +130,26 @@ protected function processResult(\Civi\Api4\Result\SearchDisplayRunResult $resul

}

/**
* Special RUN function for displays of type `entity` which save to a DB table
* rather than outputting anything to the user.
*
* @throws \CRM_Core_Exception
* @throws \Civi\API\Exception\NotImplementedException
* @throws \Civi\Core\Exception\DBQueryException
*/
private function writeToEntityTable() {
$apiParams = $this->_apiParams;
$apiParams['orderBy'] = $this->getOrderByFromSort();
$api = Request::create($this->savedSearch['api_entity'], 'get', $apiParams);
$query = new Api4SelectQuery($api);
$query->forceSelectId = FALSE;
$select = $query->getSql();
$tableName = $this->display['settings']['tableName'];
$columnSpecs = array_column($this->display['settings']['columns'], 'spec');
$columns = implode(', ', array_column($columnSpecs, 'name'));
$sql = "INSERT INTO `$tableName` ($columns) $select";
\CRM_Core_DAO::executeQuery($sql);
}

}
211 changes: 211 additions & 0 deletions ext/search_kit/Civi/Api4/Event/Subscriber/SKEntitySubscriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
<?php
/*
+--------------------------------------------------------------------+
| Copyright CiviCRM LLC. All rights reserved. |
| |
| This work is published under the GNU AGPLv3 license with some |
| permitted exceptions and without any warranty. For full license |
| and copyright information, see https://civicrm.org/licensing |
+--------------------------------------------------------------------+
*/

namespace Civi\Api4\Event\Subscriber;

use Civi\Api4\Generic\Traits\SavedSearchInspectorTrait;
use Civi\Api4\SKEntity;
use Civi\Api4\Utils\CoreUtil;
use Civi\Core\Event\GenericHookEvent;
use Civi\Core\Event\PostEvent;
use Civi\Core\Event\PreEvent;
use Civi\Core\Service\AutoService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
* Manages tables and API entities created from search displays of type "entity"
* @service
* @internal
*/
class SKEntitySubscriber extends AutoService implements EventSubscriberInterface {

use SavedSearchInspectorTrait;

/**
* @return array
*/
public static function getSubscribedEvents(): array {
return [
'civi.api4.entityTypes' => 'on_civi_api4_entityTypes',
'hook_civicrm_pre' => 'onPreSaveDisplay',
'hook_civicrm_post' => 'onPostSaveDisplay',
];
}

/**
* Register SearchDisplays of type 'entity'
*
* @param \Civi\Core\Event\GenericHookEvent $event
*/
public static function on_civi_api4_entityTypes(GenericHookEvent $event): void {
// Can't use the API to fetch search displays because this hook is called when the API boots
foreach (_search_kit_get_entity_displays() as $display) {
$event->entities[$display['entityName']] = [
'name' => $display['entityName'],
'title' => $display['label'],
'title_plural' => $display['label'],
'description' => $display['description'],
'primary_key' => ['_row'],
'type' => ['SavedSearch'],
'table_name' => $display['tableName'],
'class_args' => [$display['apiName']],
'label_field' => NULL,
'searchable' => 'secondary',
'class' => SKEntity::class,
'icon' => 'fa-search-plus',
];
}
}

/**
* @param \Civi\Core\Event\PreEvent $event
*/
public function onPreSaveDisplay(PreEvent $event): void {
if (!$this->applies($event)) {
return;
}
$newSettings = $event->params['settings'] ?? NULL;
// No changes made, nothing to do
if (!$newSettings && $event->action !== 'delete') {
return;
}
$oldSettings = !$event->id ? NULL :
\CRM_Core_DAO::unSerializeField(\CRM_Core_DAO::getFieldValue('CRM_Search_DAO_SearchDisplay', $event->id, 'settings'), \CRM_Core_DAO::SERIALIZE_JSON);
if (!empty($oldSettings['tableName'])) {
\CRM_Core_BAO_SchemaHandler::dropTable($oldSettings['tableName']);
}
if ($event->action === 'delete') {
return;
}
$ssId = $event->params['saved_search_id'] ?? \CRM_Core_DAO::getFieldValue('CRM_Search_DAO_SearchDisplay', $event->id, 'saved_search_id');
$this->loadSavedSearch($ssId);
$table = [
'name' => $newSettings['tableName'],
'is_multiple' => FALSE,
'attributes' => 'ENGINE=InnoDB',
'fields' => [],
];
// Primary key field
$table['fields'][] = [
'name' => '_row',
'type' => 'int unsigned',
'primary' => TRUE,
'required' => TRUE,
'attributes' => 'AUTO_INCREMENT',
'comment' => 'Row number',
];
foreach ($newSettings['columns'] as &$column) {
$expr = $this->getSelectExpression($column['key']);
if (!$expr) {
continue;
}
$column['spec'] = $this->formatFieldSpec($column, $expr);
$table['fields'][] = $this->formatSQLSpec($column, $expr);
}
$event->params['settings'] = $newSettings;
$sql = \CRM_Core_BAO_SchemaHandler::buildTableSQL($table);
// do not i18n-rewrite
\CRM_Core_DAO::executeQuery($sql, [], TRUE, NULL, FALSE, FALSE);
}

/**
* @param array $column
* @param array{fields: array, expr: SqlExpression, dataType: string} $expr
* @return array
*/
private function formatFieldSpec(array $column, array $expr): array {
// Strip the pseuoconstant suffix
[$name, $suffix] = array_pad(explode(':', $column['key']), 2, NULL);
// Sanitize the name
$name = \CRM_Utils_String::munge($name, '_', 255);
$spec = [
'name' => $name,
'data_type' => $expr['dataType'],
'suffixes' => $suffix ? ['id', $suffix] : NULL,
'options' => FALSE,
];
if ($expr['expr']->getType() === 'SqlField') {
$field = \CRM_Utils_Array::first($expr['fields']);
$spec['fk_entity'] = $field['fk_entity'] ?? NULL;
if ($suffix) {
$spec['options'] = civicrm_api4($field['entity'], 'getFields', [
'where' => [['name', '=', $field['name']]],
'loadOptions' => $spec['suffixes'],
])->first()['options'];
}
}
elseif ($expr['expr']->getType() === 'SqlFunction') {
if ($suffix) {
$spec['options'] = CoreUtil::formatOptionList($expr['expr']::getOptions(), $spec['suffixes']);
}
}
return $spec;
}

/**
* @param array $column
* @param array{fields: array, expr: SqlExpression, dataType: string} $expr
* @return array
*/
private function formatSQLSpec(array $column, array $expr): array {
// Try to use the exact sql column type as the original field
$field = \CRM_Utils_Array::first($expr['fields']);
if (!empty($field['column_name']) && !empty($field['table_name'])) {
$columns = \CRM_Core_DAO::executeQuery("DESCRIBE `{$field['table_name']}`")
->fetchMap('Field', 'Type');
$type = $columns[$field['column_name']] ?? NULL;
}
// If we can't get the exact data type from the column, take an educated guess
if (empty($type) ||
($expr['expr']->getType() !== 'SqlField' && $field['data_type'] !== $expr['dataType'])
) {
$map = [
'Array' => 'text',
'Boolean' => 'tinyint',
'Date' => 'date',
'Float' => 'double',
'Integer' => 'int',
'String' => 'text',
'Text' => 'text',
'Timestamp' => 'datetime',
];
$type = $map[$expr['dataType']] ?? $type;
}
return [
'name' => $column['spec']['name'],
'type' => $type,
];
}

/**
* @param \Civi\Core\Event\PostEvent $event
*/
public function onPostSaveDisplay(PostEvent $event): void {
if ($this->applies($event)) {
\Civi::cache('metadata')->clear();
}
}

/**
* Check if pre/post hook applies to a SearchDisplay type 'entity'
*
* @param \Civi\Core\Event\PreEvent|\Civi\Core\Event\PostEvent $event
* @return bool
*/
private function applies(GenericHookEvent $event): bool {
if ($event->entity !== 'SearchDisplay') {
return FALSE;
}
$type = $event->params['type'] ?? $event->object->type ?? \CRM_Core_DAO::getFieldValue('CRM_Search_DAO_SearchDisplay', $event->id, 'type');
return $type === 'entity';
}

}
59 changes: 59 additions & 0 deletions ext/search_kit/Civi/Api4/SKEntity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

namespace Civi\Api4;

/**
* Virtual API entities provided by SearchDisplays of type "entity"
* @package Civi\Api4
*/
class SKEntity {

/**
* @param string $displayEntity
* @param bool $checkPermissions
*
* @return \Civi\Api4\Generic\DAOGetFieldsAction
*/
public static function getFields(string $displayEntity, bool $checkPermissions = TRUE): Generic\DAOGetFieldsAction {
return (new Generic\DAOGetFieldsAction('SK_' . $displayEntity, __FUNCTION__))
->setCheckPermissions($checkPermissions);
}

/**
* @param string $displayEntity
* @param bool $checkPermissions
* @return \Civi\Api4\Generic\DAOGetAction
* @throws \CRM_Core_Exception
*/
public static function get(string $displayEntity, bool $checkPermissions = TRUE): Generic\DAOGetAction {
return (new Generic\DAOGetAction('SK_' . $displayEntity, __FUNCTION__))
->setCheckPermissions($checkPermissions);
}

/**
* @param string $displayEntity
* @param bool $checkPermissions
* @return \Civi\Api4\Action\GetActions
*/
public static function getActions(string $displayEntity, bool $checkPermissions = TRUE): Action\GetActions {
return (new Action\GetActions('SK_' . $displayEntity, __FUNCTION__))
->setCheckPermissions($checkPermissions);
}

/**
* @param string $displayEntity
* @return \Civi\Api4\Generic\CheckAccessAction
* @throws \CRM_Core_Exception
*/
public static function checkAccess(string $displayEntity): Generic\CheckAccessAction {
return new Generic\CheckAccessAction('SK_' . $displayEntity, __FUNCTION__);
}

/**
* @return array
*/
public static function permissions(): array {
return [];
}

}
Loading

0 comments on commit de0cac9

Please sign in to comment.