From 2dbc1a2aa6b5703e2e844dfc9ff53062b52c28e3 Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Sun, 10 Mar 2024 13:02:17 +0200 Subject: [PATCH] Improve filtering.Dynamically construct where clause using QueryExpressions. --- app/src/Controller/StandardController.php | 35 +-- app/src/Lib/Traits/QueryModificationTrait.php | 11 - app/src/Lib/Traits/SearchFilterTrait.php | 234 +++++++++--------- 3 files changed, 129 insertions(+), 151 deletions(-) diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index d5fd11821..0fbc1dc00 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -29,6 +29,7 @@ namespace App\Controller; +use Cake\Database\Expression\QueryExpression; use InvalidArgumentException; use \Cake\Http\Exception\BadRequestException; use \App\Lib\Enum\ProvisioningContextEnum; @@ -564,22 +565,22 @@ public function index() { $table = $this->$modelsName; // $tableName = models $tableName = $table->getTable(); - - $query = null; - // PrimaryLinkTrait $link = $this->getPrimaryLink(true); // AutoViewVarsTrait $this->populateAutoViewVars(); - + // Initialize the Query Object $query = $table->find($this->paginate['finder'] ?? 'all'); + // Get a pointer to my expressions list + $newexp = $query->newExpr(); + if(!empty($link->attr)) { // If a link attribute is defined but no value is provided, then query // where the link attribute is NULL // "all" is the default finder. But since we are utilizing the paginator here, we will check the configuration // for any custom finder. - $query = $query->where([$table->getAlias().'.'.$link->attr => $link->value]); + $newexp->add([$table->getAlias().'.'.$link->attr => $link->value]); } // QueryModificationTrait @@ -590,43 +591,33 @@ public function index() { // SearchFilterTrait if(method_exists($table, 'getSearchableAttributes')) { - $searchableAttributes = $table->getSearchableAttributes($this->name, $this->viewBuilder()->getVar('vv_tz')); + $searchableAttributes = $table->getSearchableAttributes($this->name, + $this->viewBuilder()->getVar('vv_tz')); if(!empty($searchableAttributes)) { // Here we iterate over the attributes, and we add a new where clause for each one foreach($searchableAttributes as $attribute => $options) { // Add the Join Clauses - $query = $table->addJoins($query, $attribute); + $query = $table->addJoins($query, $attribute, $this->request); // Construct and apply the where Clause if(!empty($this->request->getQuery($attribute))) { - $query = $table->whereFilter($query, $attribute, $this->request->getQuery($attribute)); + $newexp = $table->expressionsConstructor($query, $newexp, $attribute, $this->request->getQuery($attribute)); } elseif (!empty($this->request->getQuery($attribute . '_starts_at')) || !empty($this->request->getQuery($attribute . '_ends_at'))) { $search_date = []; // We allow empty for dates since we might refer to infinity (from whenever or to always) $search_date[] = $this->request->getQuery($attribute . '_starts_at') ?? ''; $search_date[] = $this->request->getQuery($attribute . '_ends_at') ?? ''; - $query = $table->whereFilter($query, $attribute, $search_date); + $newexp = $table->expressionsConstructor($query, $newexp, $attribute, $search_date); } } $this->set('vv_searchable_attributes', $searchableAttributes); } } - - // Filter on requested filter, if requested - // QueryModificationTrait - if(method_exists($table, "getIndexFilter")) { - $filter = $table->getIndexFilter(); - - if(is_callable($filter)) { - $query->where($filter($this->request)); - } else { - $query->where($table->getIndexFilter()); - } - } - + + $query = $query->where($newexp); $resultSet = $this->paginate($query); $this->set($tableName, $resultSet); diff --git a/app/src/Lib/Traits/QueryModificationTrait.php b/app/src/Lib/Traits/QueryModificationTrait.php index acdd704bb..08ed2f004 100644 --- a/app/src/Lib/Traits/QueryModificationTrait.php +++ b/app/src/Lib/Traits/QueryModificationTrait.php @@ -81,17 +81,6 @@ public function getIndexContains() { return $this->indexContains; } - /** - * Obtain the index filter for this model. - * - * @since COmanage Registry v5.0.0 - * @return array|Closure Array of index filters or closure that generates an array - */ - - public function getIndexFilter(): array|\Closure|null { - return $this->indexFilter; - } - /** * Obtain the set of associated models to save during a patch. * diff --git a/app/src/Lib/Traits/SearchFilterTrait.php b/app/src/Lib/Traits/SearchFilterTrait.php index 874445495..1e0210952 100644 --- a/app/src/Lib/Traits/SearchFilterTrait.php +++ b/app/src/Lib/Traits/SearchFilterTrait.php @@ -30,37 +30,137 @@ namespace App\Lib\Traits; use Cake\Database\Expression\QueryExpression; +use Cake\Http\ServerRequest; use Cake\ORM\Query; use Cake\Utility\Inflector; use Cake\I18n\FrozenTime; trait SearchFilterTrait { // Array (and configuration) of permitted search filters - private $searchFilters = array(); + private array $searchFilters = []; // Optional filter configuration that dictates display state and allows for related models - private $filterConfig = array(); + private array $filterConfig = []; + /** + * Build the query join associations + * + * @param Query $query + * @param string $attribute + * @param ServerRequest $request + * + * @return object Cake ORM Query object + * @since COmanage Registry v5.0.0 + */ + public function addJoins(Query $query, string $attribute, ServerRequest $request): object { + // not a permitted attribute + if(empty($this->searchFilters[$attribute]) + || $request->getQuery($attribute) === null + || !isset($this->searchFilters[$attribute]['model'])) { + return $query; + } + + $changelog_fk = strtolower(Inflector::underscore($this->searchFilters[$attribute]['model'])) . '_id'; + $fk = strtolower(Inflector::underscore(Inflector::singularize($this->_alias))) . '_id'; + $mtable_name = Inflector::tableize(Inflector::pluralize($this->searchFilters[$attribute]['model'])); + $mtable_alias = Inflector::pluralize($this->searchFilters[$attribute]['model']); + + return $query->join([$mtable_alias => [ + 'table' => $mtable_name, + 'conditions' => [ + $mtable_alias . '.' . $fk . '=' . $this->_alias . '.id', + $mtable_alias . '.' . 'deleted IS NOT TRUE', + $mtable_alias . '.' . $changelog_fk . ' IS NULL' + ], + 'type' => 'INNER' + ]]); + } + + /** + * Construct the Date comparison clause from the query parameters + * + * @param QueryExpression $exp + * + * @param string $attributeWithModelPrefix Model.attribute as required by CAKE ORM + * @param array $dates Contains the list of starting and ending dates in the following order [starts_at, ends_at] + * + * @return QueryExpression + * @since COmanage Registry v5.0.0 + */ + public function constructDateComparisonClause(QueryExpression $exp, string $attributeWithModelPrefix, array $dates): QueryExpression { + // Both are empty, just return + if (empty($dates[0]) && empty($dates[1])) { + return $exp; + } + // The starts_at is non-empty. So the data should be greater than the starts_at date + if (!empty($dates[0]) && empty($dates[1])) { + return $exp->gte("'" . FrozenTime::parse($dates[0]) . "'", $attributeWithModelPrefix); + } + // The ends_at is non-empty. So the data should be less than the ends_at date + if (!empty($dates[1]) + && empty($dates[0])) { + return $exp->lte("'" . FrozenTime::parse($dates[1]) . "'", $attributeWithModelPrefix); + } + + return $exp->between($attributeWithModelPrefix, "'" . $dates[0] . "'", "'" . $dates[1] . "'"); + } + + /** + * Build a query where() clause for the configured attribute. + * + * @param Query $query + * @param QueryExpression $exp + * @param string $attribute Attribute to filter on (database name) + * @param string|array $q Value to filter on + * + * @return object Cake ORM Query object + * @since COmanage Registry v5.0.0 + */ + + public function expressionsConstructor(Query $query, QueryExpression $exp, string $attribute, string|array $q): object { + // not a permitted attribute + if(empty($this->searchFilters[$attribute])) { + return $exp; + } + + // Prepend the Model name to the attribute + $attributeWithModelPrefix = isset($this->searchFilters[$attribute]['model']) ? + Inflector::pluralize($this->searchFilters[$attribute]['model']) . '.' . $attribute : + $this->_alias . '.' . $attribute; + + $search = $q; + // Use the `lower` function to apply uniformity for the search + $lower = $query->func()->lower([$attributeWithModelPrefix => 'identifier']); + + return match ($this->searchFilters[$attribute]['type']) { + 'string' => $exp->like($lower, strtolower('%' . $search . '%')), + 'integer', 'boolean' => $exp->add([$attributeWithModelPrefix => $search]), + 'date' => $exp->add([$attributeWithModelPrefix => FrozenTime::parseDate($search, 'y-M-d')]), + 'timestamp' => $this->constructDateComparisonClause($search), + default => $exp->eq($lower, strtolower($search)) + }; + } + /** * Get explicilty defined filter configuration defined in the table class. * * @since COmanage Registry v5.0.0 */ - + public function getFilterConfig(): array { return $this->filterConfig; } - + /** * Obtain the set of permitted search attributes. * * @since COmanage Registry v5.0.0 * @return array Array of permitted search attributes and configuration elements needed for display */ - + public function getSearchableAttributes(string $controller, string $vv_tz=null): array { $modelname = Inflector::classify(Inflector::underscore($controller)); $filterConfig = $this->getFilterConfig(); - + // Gather up related models defined in the $filterConfig // XXX For now, we'll list these first - but we should probably provide a better way to order these. foreach ($filterConfig as $field => $f) { @@ -69,28 +169,27 @@ public function getSearchableAttributes(string $controller, string $vv_tz=null): $this->searchFilters[$field] = [ 'type' => 'string', // XXX for now - this needs to be looked up. 'label' => \App\Lib\Util\StringUtilities::columnKey($fieldName, $field, $vv_tz, true), - 'active' => isset($f['active']) ? $f['active'] : true, + 'active' => $f['active'] ?? true, 'model' => $f['model'], 'order' => $f['order'] ]; } } - + foreach ($this->filterMetadataFields() as $column => $type) { // If the column is an array then we are accessing the Metadata fields. Skip if(is_array($type)) { continue; } - + // Set defaults $fieldIsActive = true; - + // Gather filter configurations, if any, for local table fields. // An active field is visible in the filter form. An inactive field is not but can be enabled. - if(!empty($filterConfig[$column])) { - if(isset($filterConfig[$column]['active'])) { - $fieldIsActive = $filterConfig[$column]['active']; - } + if(!empty($filterConfig[$column]) + && isset($filterConfig[$column]['active'])) { + $fieldIsActive = $filterConfig[$column]['active']; } $attribute = [ @@ -124,116 +223,15 @@ public function getSearchableAttributes(string $controller, string $vv_tz=null): return $this->searchFilters ?? []; } - + /** * Set explicilty defined filter configuration defined in the table class. - * + * * @since COmanage Registry v5.0.0 */ - + public function setFilterConfig(array $filterConfig): void { $this->filterConfig = $filterConfig; } - /** - * Build the query join associations - * - * @param Query $query - * @param string $attribute - * - * @return object Cake ORM Query object - * * @since COmanage Registry v5.0.0 - */ - public function addJoins(Query $query, string $attribute): object { - // not a permitted attribute - if(empty($this->searchFilters[$attribute]) - || !isset($this->searchFilters[$attribute]['model'])) { - return $query; - } - - $changelog_fk = strtolower(Inflector::underscore($this->searchFilters[$attribute]['model'])) . '_id'; - $fk = strtolower(Inflector::underscore(Inflector::singularize($this->_alias))) . '_id'; - $mtable_name = Inflector::tableize(Inflector::pluralize($this->searchFilters[$attribute]['model'])); - $mtable_alias = Inflector::pluralize($this->searchFilters[$attribute]['model']); - - return $query->join([$mtable_alias => [ - 'table' => $mtable_name, - 'conditions' => [ - $mtable_alias . '.' . $fk . '=' . $this->_alias . '.id', - $mtable_alias . '.' . 'deleted IS NOT TRUE', - $mtable_alias . '.' . $changelog_fk . ' IS NULL' - ], - 'type' => 'INNER' - ]]); - } - - /** - * Build a query where() clause for the configured attribute. - * - * @param Query $query Cake ORM Query object - * @param string $attribute Attribute to filter on (database name) - * @param string|array $q Value to filter on - * - * @return object Cake ORM Query object - * @since COmanage Registry v5.0.0 - */ - - public function whereFilter(Query $query, string $attribute, string|array $q): object { - // not a permitted attribute - if(empty($this->searchFilters[$attribute])) { - return $query; - } - - // Prepend the Model name to the attribute - $attributeWithModelPrefix = isset($this->searchFilters[$attribute]['model']) ? - Inflector::pluralize($this->searchFilters[$attribute]['model']) . '.' . $attribute : - $this->_alias . '.' . $attribute; - - $search = $q; - $sub = false; - // Primitive types - $search_types = ['integer', 'boolean']; - if( $this->searchFilters[$attribute]['type'] === 'string') { - $search = '%' . $search . '%'; - $sub = true; - // Search type - } elseif(in_array($this->searchFilters[$attribute]['type'], $search_types, true)) { - return $query->where([$attributeWithModelPrefix => $search]); - // Date - } elseif($this->searchFilters[$attribute]['type'] === 'date') { - // Parse the date string with FrozenTime to improve error handling - return $query->where([$attributeWithModelPrefix => FrozenTime::parseDate($search, 'y-M-d')]); - // Timestamp - } elseif( $this->searchFilters[$attribute]['type'] === 'timestamp') { - // Date between dates - if(!empty($search[0]) - && !empty($search[1])) { - return $query->where(fn(QueryExpression $exp, Query $query) => $exp->between($attributeWithModelPrefix, "'" . $search[0] . "'", "'" . $search[1] . "'")); - // The starts_at is non-empty. So the data should be greater than the starts_at date - } elseif(!empty($search[0]) - && empty($search[1])) { - return $query->where(fn(QueryExpression $exp, Query $query) => $exp->gte("'" . FrozenTime::parse($search[0]) . "'", $attributeWithModelPrefix)); - // The ends_at is non-empty. So the data should be less than the ends_at date - } elseif(!empty($search[1]) - && empty($search[0])) { - return $query->where(fn(QueryExpression $exp, Query $query) => $exp->lte("'" . FrozenTime::parse($search[1]) . "'", $attributeWithModelPrefix)); - } else { - // We return everything - return $query; - } - } - - // Use the `lower` function to apply uniformity for the search globally - $lower = $query->func()->lower([$attributeWithModelPrefix => 'identifier']); - - // This is the case of the filtering block. We are `and`-ing the conditions - // The like function applies to all string searches - // The eq function applies to everything else - $conditionsCallback = static fn(QueryExpression $exp, Query $query) => - ($sub) ? $exp->like($lower, strtolower($search)) - : $exp->eq($lower, strtolower($search)); - - // String values - return $query->where($conditionsCallback); - } }