ModelFilters

A reusable Festi library plugin: it gives any host record a set of stored, user-editable filters and turns them into a database WHERE condition — so a host can offer "filter this thing by these columns" without building the storage, the editor UI, or the query composition itself.

What it is

A host often has a record that needs to carry a configurable filter — "show only these rows", defined by a non-developer through the UI and applied later when the host runs its own query. ModelFilters provides exactly that, generically:

  • it stores the filter rows (one table, model_filters);
  • it renders the editor (a ready-made DGS store the host mounts on its page);
  • it composes the stored rows back into a Festi search array the host merges into its own query.

It owns no modules, sections or routes — it is a library, driven entirely through its facade. A host attaches filters to one of its records (a "model"); each model is identified by a model_type string and an integer id.

Requirements

  • PHP 8.0 or later. The source uses match expressions and union types.
  • The Modules plugin (plugin\Modules\ModulesManager). The plugin calls the listeners through it.
  • The Festi Theme package (Festi\Theme\Plugin\DisplayPlugin).
  • A MySQL, PostgreSQL or MSSQL database.

Installation

Use the Festi CLI to install the plugin. Run the command from the project root:

./vendor/bin/festi-plugin --mode install --path ./src/dashboard/ --dump ./dumps/ \
    --plugin ModelFilters --repository [email protected]:FestiPlugins/php_festi_plugin_model_filters.git

This example was not run for this page.

The command does these steps:

  1. It adds the repository to the plugins directory as a git submodule.
  2. It copies install/install.<database type>.sql into the dumps directory as a new migration.
  3. It applies the migration.

install/ contains one file for each database type: install.mysql.sql, install.pgsql.sql and install.mssql.sql. Each file creates the table model_filters with indexes on model_type and id_model. Each file also adds the plugin to festi_plugins with the status active. The files add no modules, sections, permissions or routes.

Set --path to the directory that contains the project config.php. Commit the submodule and the new migration together.

Configuration

The plugin reads the application setting db_datetime_format. It uses the value as the PHP date() format for the columns cdate and mdate. Make sure that the project has this setting before you save a filter. The plugin has no other options.

Using it from a host plugin

  1. Mark the host record as a model. Implement IModelFilter on it — getID() and getModelType() — so filters can be scoped to that specific record.

  2. Mount the editor. Call getStore($tableName, $modelFilter, $filterableColumns, $backLink) on the facade and render the returned ModelFiltersStore on a host page. $tableName is the table the filter will run against. $filterableColumns is the list of FilterableColumn objects the user can filter on. Get this list from getFilterableColumns($tableName) on the facade. $backLink is an optional FiltersBackLink (URL and caption); it adds a top button that goes back to the host page. The store handles add / edit / remove and validates every row on save.

If $filterableColumns is empty, the store reads the column names with getColumns($tableName). That method uses the MySQL statements SHOW TABLES and SHOW COLUMNS. Thus, on PostgreSQL and MSSQL, always give $filterableColumns.

  1. Apply the filters when you query. Read the stored rows back with FilterHelper::getSearch($modelFilter) (or getByModel() on the facade for the raw ModelFilterValuesObject[]) and merge the returned search array into your own DAO search. The plugin never runs your query — it only hands you the condition.

The example below shows the three steps in a host plugin. The example was not run for this page.

use Plugins\ModelFilters\Domain\Helper\FilterHelper;
use Plugins\ModelFilters\Domain\Model\IModelFilter;
use Plugins\ModelFilters\Domain\Navigation\FiltersBackLink;

class ReportModel implements IModelFilter
{
    public function __construct(private int $_id)
    {
    }

    public function getID(): int
    {
        return $this->_id;
    }

    public function getModelType(): string
    {
        return 'report';
    }
}

// In a display method of the host plugin:
$model = new ReportModel($idReport);

$facade = Core::getInstance()->getPluginInstance('ModelFilters');

$columns = $facade->getFilterableColumns('orders');
$backLink = new FiltersBackLink('/reports/', __('Back to Reports'));

$store = $facade->getStore('orders', $model, $columns, $backLink);
$store->onRequest($response);

// In the code that reads the data:
$search = FilterHelper::getSearch($model);
$rows = $this->object->searchOrders($search);

ReportModel, the table orders and searchOrders() are names of the host, not of this plugin.

Extending it through listeners

The defaults work with no listeners at all (columns are reflected from the table, value inputs are inferred from column types). The listeners exist for the cases the plugin cannot know on its own — they are how a host teaches it about its own schema and data.

The plugin calls each listener with ModulesManager::getInstance()->dispatchObserverEndpoint(). Refer to the Modules plugin for how to add an observer endpoint listener to a module. The plugin calls ICollectFilterableColumnsListener only in getFilterableColumns().

  • ICollectFilterableColumnsListener — adjust the column list. By default the editor reflects the raw table columns, captions each one through ColumnLabelFormatter (id_workspace reads "Workspace ID", not "Id Workspace") and guesses its input type. A reflected caption reaches no .po file of its own, so it is translated only where the host already ships that exact string. Implement this listener to relabel a column, correct its inferred type, hide it, add a column, or mark a column as a foreign key. Needed whenever the raw schema is not the right thing to show the user.

  • ICollectFilterForeignKeySourcesListener — supply the options for a foreign-key column. A column that holds an id (e.g. id_user) should let the user pick by name, not type a number. This listener registers, per id, where the labels come from (table, id column, label column, optional where/order). The target table is usually far too large to list, so the value becomes a searchable foreignKey field: the framework's ForeignKeyLoadAction answers the keystrokes from the source's own table and columns, and the editor lists the saved row by the label that was picked rather than by the id it stores. The lookup stays server-side — the client only ever sends the id, so the source table is never exposed. Without a registered source the value stays a plain text input holding the raw id. Needed for any id column you want shown as a named select.

  • IModelFiltersStoreInitListener — final say over the built store. Fires after the DGS store is assembled, letting a host tweak it before it renders. Needed only for host-specific store adjustments the two listeners above don't cover.

How filters are stored and applied

Each row in model_filters ties a filter to a model (model_type, id_model) and names a column_name, an operator, a value, and a join type:

  • operators — equal, not_equal, in, not_in, is, is_not, between, greater, greater_or_equal, less, less_or_equal;
  • type — and rows AND into the search directly; or rows are grouped under a single sql_or clause.

FilterHelper maps each row to a column&OPERATOR => value entry (for equal, the key is the bare column, with no &), translating the operator to the Festi search token (!=, IN, NOT IN, IS, IS NOT, >, >=, <, <=, BETWEEN). Multi-value operators (in, not_in, between) split the comma-separated value into an array; is / is_not become NULL / NOT NULL.

The editor keeps rows valid so a bad filter never reaches the table, in three layers on save:

  1. operator arity — between needs exactly two values, in / not_in at least one, the rest exactly one; is / is_not accept only a NULL / NOT NULL token;
  2. value type — each value is checked against the column's declared type (number, date, boolean, enum membership — which also blocks a forged foreign-key id);
  3. SQL backstop — the full filter set for the model is assembled and executed (isCorrectSearch()); anything that fails to build into a valid query is rejected.