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
matchexpressions 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:
- It adds the repository to the plugins directory as a git submodule.
- It copies
install/install.<database type>.sqlinto the dumps directory as a new migration. - 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
-
Mark the host record as a model. Implement
IModelFilteron it —getID()andgetModelType()— so filters can be scoped to that specific record. -
Mount the editor. Call
getStore($tableName, $modelFilter, $filterableColumns, $backLink)on the facade and render the returnedModelFiltersStoreon a host page.$tableNameis the table the filter will run against.$filterableColumnsis the list ofFilterableColumnobjects the user can filter on. Get this list fromgetFilterableColumns($tableName)on the facade.$backLinkis an optionalFiltersBackLink(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.
- Apply the filters when you query. Read the stored rows back with
FilterHelper::getSearch($modelFilter)(orgetByModel()on the facade for the rawModelFilterValuesObject[]) 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 throughColumnLabelFormatter(id_workspacereads "Workspace ID", not "Id Workspace") and guesses its input type. A reflected caption reaches no.pofile 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 searchableforeignKeyfield: the framework'sForeignKeyLoadActionanswers 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 —
androws AND into the search directly;orrows are grouped under a singlesql_orclause.
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:
- operator arity —
betweenneeds exactly two values,in/not_inat least one, the rest exactly one;is/is_notaccept only aNULL/NOT NULLtoken; - 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);
- 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.