Upgrade Guide
This file tells a developer or a coding agent what to change in a project
when it moves to a new version of Festi Framework. It covers the changes
marked (BREAKING) in the Unreleased section of
./CHANGELOG.md, which is the release after 7.2.0. Every change
that breaks project code adds a section here, in the same merge request.
All changes described here were made after the git tag 7.3.0. Where this
file says "before", it means the code at that tag. Changes made between
7.2.0 and that tag are not described here; the load check in step 2 still
reports the ones that stop a class from loading.
How to upgrade a project
- Update the dependency.
composer update festi-team/festi-framework-core --with-dependencies
- Run the class-load check below. Fix every
FAILand run it again until it prints0 failed. - Run the static analysis of the project (Phan, PHPStan or what it uses).
- Run the tests of the project.
- Go through the summary. For each row, run the command in "How to find it" of the section. No match means no work for that row.
- Open the pages that use DGS lists, forms, dependent selects and file uploads once. Some errors appear only when a request runs (see the error table below).
Class-load check
PHP compares an overriding method or a redeclared property with its parent
when it loads the class. An incompatible class stops with a fatal error at
that moment. php -l does not find this: it parses one file and never
loads the parent class. Tests find it only for classes they load.
Save this as load-check.php in the project root:
<?php
// Usage: php load-check.php <bootstrap.php> <dir> [<dir> ...]
// Loads every PHP file that declares a class, each in its own process,
// and prints the error of every file PHP refuses to load.
if ($argv[1] === '--load') {
require $argv[2];
if (isset($argv[3])) {
require_once $argv[3];
}
exit(0);
}
function declaresClass(string $path): bool
{
$kinds = [T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM];
$skip = [T_DOUBLE_COLON, T_NEW];
$previous = null;
foreach (token_get_all(file_get_contents($path)) as $token) {
if (!is_array($token) || $token[0] === T_WHITESPACE) {
continue;
}
if (in_array($token[0], $kinds) && !in_array($previous, $skip)) {
return true;
}
$previous = $token[0];
}
return false;
}
function load(string $bootstrap, ?string $path = null): array
{
$command = escapeshellarg(PHP_BINARY).
' -d display_errors=1 -d log_errors=0 '.
escapeshellarg(__FILE__).' --load '.escapeshellarg($bootstrap);
if ($path !== null) {
$command .= ' '.escapeshellarg($path);
}
exec($command.' 2>&1', $output, $code);
return [$code, trim(implode("\n ", array_filter($output)))];
}
$bootstrap = $argv[1];
[$code, $output] = load($bootstrap);
if ($code !== 0) {
echo "The bootstrap itself fails:\n ", $output, "\n";
exit(2);
}
$failed = 0;
$checked = 0;
foreach (array_slice($argv, 2) as $dir) {
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir)
);
foreach ($files as $file) {
$path = $file->getPathname();
if ($file->getExtension() !== 'php' || !declaresClass($path)) {
continue;
}
[$code, $output] = load($bootstrap, $path);
$checked++;
if ($code !== 0) {
$failed++;
echo "FAIL ", $path, "\n ", $output, "\n";
}
}
}
echo $checked, " files loaded, ", $failed, " failed\n";
exit($failed ? 1 : 0);
Run it with the file that sets up class loading for the project, and the directories that hold project classes:
php load-check.php vendor/autoload.php plugins src tests
Notes:
- The first argument must make the parent classes loadable. If plugin
classes of the project are not loaded by Composer, pass a bootstrap file
that registers the project autoloader, for example the bootstrap of the
test suite. The script stops with
The bootstrap itself failswhen that file cannot run (for example because it opens a database connection). - A file is loaded only when it declares a class, interface, trait or enum. Code at the top level of such a file runs too.
Class "X" not foundin the output means the bootstrap does not loadX. It is not a framework change, unlessXis one of the classes in Removed classes, removed methods and wider visibility.- PHP reports one fatal error per file. Run the check again after a fix.
Which error belongs to which section
| Message | Found by | Section |
|---|---|---|
Fatal error: Declaration of X::m() must be compatible with Y::m(...) |
load check | Native types and strict_types |
Fatal error: Type of X::$p must be T (as in class Y) |
load check | Native types and strict_types |
Fatal error: Declaration of X::m(...) must be compatible with Y::m(...), m is onInit, install, getResponseModel, onDisplayMain, getSettings, getUrlRules, addUrlRule, get, set, getID, getRole, getSessionData, getFilterTemplateName, getInputType, callHandlerMethod, getCurrencySymbol, insert or getOption |
load check | The last untyped members have native types |
Fatal error: Type of X::$object must be ?IDataAccessObject (as in class ObjectPlugin), Fatal error: Type of X::$requiredFields must be array (as in class DefaultUser) |
load check | The last untyped members have native types |
Fatal error: A void function must not return a value, in onInit() or install() |
php -l, load check |
The last untyped members have native types |
Fatal error: A function with return type must return a value (did you mean "return null;" instead of "return;"?), in one of the methods of the first row |
php -l, load check |
The last untyped members have native types |
TypeError: X::onDisplayMain(): Return value must be of type ?bool, none returned (also the other methods of the first row, with their type) |
tests, requests | The last untyped members have native types |
TypeError: Cannot assign U to property X::$object of type ?IDataAccessObject |
tests, requests | The last untyped members have native types |
Fatal error: Access level to X::m() must be public (as in class Y) |
load check | Removed classes, removed methods and wider visibility |
Class "AbstractDisplayAction" not found (also AbstractSelectField, ComboField, SetField) |
load check | Removed classes, removed methods and wider visibility |
TypeError: Y::m(): Argument #N ($p) must be of type T, U given, called in <project file>, Y is a framework class |
tests, requests | Calls from project code into the framework |
TypeError: Store::removeByPrimaryKey(): Argument #1 ($primaryKeyValue) must be of type string\|int, null given |
tests, requests | Store key methods refuse null |
TypeError: Y::m(): Return value must be of type T, U returned, Y is a framework class |
tests, requests | Field models built from an array or a class |
TypeError: Cannot assign U to reference held by property ListAction::$storageValues of type ?array |
tests, requests | Values received by reference must stay arrays |
TypeError: Illegal offset type (PHP 8.1 and 8.2) or TypeError: Cannot access offset of type Festi\Store\Field\ElementName on array (PHP 8.3 and later); with isset() the text ends in in isset or empty |
tests, requests | getElementName() returns an ElementName |
Error: Call to undefined method ApiPlugin::setActiveMenu() |
tests, requests | ISystemPlugin has no menu methods |
TypeError: Y::m(): Argument #N ($p) must be of type Festi\Core\Utils\T, U given, Y::m is ImageUtils::setImageResize, setImageResizeMagic, createWatermark, Curl::getUrl or ApiUtils::send |
tests, requests | Utility methods take value objects |
ArgumentCountError: Too few arguments to function ApiUtils::send(), 1 passed |
tests, requests | Utility methods take value objects |
Fatal error: Declaration of X::m(...) must be compatible with Y::m(...), m is updateForeignTableValues, getDataForUpdateForeignTableValues, loadRemoteListValues or getListValuesEndpointUrl |
load check | Proxy methods take value objects |
TypeError: Y::m(): Argument #N ($p) must be of type Festi\Store\Proxy\T, U given, m is one of the same four methods |
tests, requests | Proxy methods take value objects |
Error: SqlProxy::updateForeignTableValues(): Argument #2 ($values) could not be passed by reference |
tests, requests | Proxy methods take value objects |
TypeError: SystemException::__construct(): Argument #3 ($context) must be of type Festi\Core\Exception\ExceptionContext, U given, or PermissionsException::__construct(): Argument #3 ($context), or FieldException::__construct(): Argument #4 ($context) |
tests, requests | Exception constructors take an ExceptionContext |
Error: Unknown named parameter $data (also $source, and $displayMessage for FieldException), in a line that creates an exception |
tests, requests | Exception constructors take an ExceptionContext |
SystemException: X::$fields is not a property of the store model: the schema part is in $this->parts, ... (also $actions, $filters and the other ten names), X is a model class of the project |
tests, requests | StoreModel keeps the schema parts in a StoreSchemaParts |
Error: Typed property StoreModel::$parts must not be accessed before initialization |
tests, requests | StoreModel keeps the schema parts in a StoreSchemaParts |
SystemException: Cannot access property X::$primaryKey (also $store, $_name, $attributes, $options), X is a store model class |
tests, requests | StoreModel keeps the schema parts in a StoreSchemaParts |
SystemException: X::$orderByFieldName is not a property of the store: the value is in $this->pagination, ... (also $orderByDirection, $rowsPerPage, $currentPage, $totalRows), X is a store class |
tests, requests | Store keeps its pagination and its components in two objects |
SystemException: X::$proxy is not a property of the store: the value is in $this->components, ... (also $model, $view, $rulesManager), X is a store class |
tests, requests | Store keeps its pagination and its components in two objects |
Error: Cannot access property X::$ident (also $_name, $_options and every other property a store declares), X is a store class |
tests, requests | Store keeps its pagination and its components in two objects |
Error: Typed property Store::$pagination must not be accessed before initialization (also $components) |
tests, requests | Store keeps its pagination and its components in two objects |
Fatal error: Declaration of & X::getStore(): core\store\IStore must be compatible with & AbstractAction::getStore(): Store |
load check | AbstractAction::getStore() returns Store |
SystemException: X::$plugin is not a property of the store: the value is in $this->components, read it and set it as $this->components->plugin, X is a store class |
tests, requests | Store keeps its plugin in its components |
SystemException: X::getRulesManager() is not a method of the store: the rules manager is in $this->components, ..., or Error: Call to undefined method X::getRulesManager(), X is a store class |
tests, requests | Store keeps its plugin in its components |
Error: Attempt to assign property "plugin" on null, in a store constructor that sets the plugin before parent::__construct(); Error: Attempt to modify property "plugin" on null, in getPlugin() or setPlugin() of a store created without its constructor |
tests, requests | Store keeps its plugin in its components |
SystemException: X::listeners() is not a section method of the schema: declare the section in bindings(ISchemaBindings $bindings), with $bindings->listeners() (also relations(), routers(), rules(), externalValues()), X is a schema class |
tests, requests | ClassSchema declares five sections in bindings() |
Error: Call to undefined method Festi\Store\Schema\ClassSchema::listeners() (also relations(), routers(), rules(), externalValues()) |
tests, requests | ClassSchema declares five sections in bindings() |
Summary
Effort: S is a few lines, M is several files, L is every class that extends a framework class.
| Change | Who is affected | Effort | Section |
|---|---|---|---|
| Framework classes declare native types | Classes that override a framework method or redeclare a framework property | L | Native types and strict_types |
| Framework parameters are typed | Code that passes null, an array or an object where a scalar is declared, and files with strict_types=1 |
S | Calls from project code into the framework |
| Field attributes must be strings | Stores whose model is built from an array or a class | S | Field models built from an array or a class |
removeByPrimaryKey(), removeChildByPrimaryKey(), updateByPrimaryKey() take int\|string |
Code that can pass null as the key |
S | Store key methods refuse null |
List rows and foreign key values are typed ?array |
Listeners that replace these values through the event target | S | Values received by reference must stay arrays |
| The framework does not log DGS action failures | Projects that want these failures in their log | S | DGS action failures are not logged by the framework |
IStoreField::getElementName() returns an object |
Code that uses the result as an array key, in ===, in json_encode() or in http_build_query() |
S | getElementName() returns an ElementName |
ISystemPlugin has no setActiveMenu() and getActiveMenu() |
Code that calls them on the system plugin | S | ISystemPlugin has no menu methods |
ForeignKeyLoadAction answers dependent field loading with JSON |
Projects with ajaxChild fields and their own theme or JavaScript |
M | ForeignKeyLoadAction answers with JSON |
| Classes and methods removed, four methods made public | Classes that extend or call them | S | Removed classes, removed methods and wider visibility |
ImageUtils::setImageResize(), setImageResizeMagic(), createWatermark(), Curl::getUrl() and ApiUtils::send() take value objects |
Code that calls them, except getUrl() with one or two arguments |
S | Utility methods take value objects |
Four protected proxy methods take value objects: updateForeignTableValues(), getDataForUpdateForeignTableValues(), loadRemoteListValues() and getListValuesEndpointUrl() |
Proxy classes of the project that override or call one of them | S | Proxy methods take value objects |
SystemException and FieldException take an ExceptionContext in place of $data and $source; FieldException has no $displayMessage |
Code that creates such an exception with a third positional argument (a fourth for FieldException) or with the named argument data or source, and project exceptions that pass these to parent::__construct() |
S | Exception constructors take an ExceptionContext |
StoreModel keeps $fields, $actions, $filters and ten more schema parts in $this->parts, a StoreSchemaParts; $store, $primaryKey and $_name are no longer items of the model |
Project classes that extend a store model and use or declare one of these properties, and code that reads a property or an item of a model from outside | S | StoreModel keeps the schema parts in a StoreSchemaParts |
Store keeps $rowsPerPage, $currentPage, $totalRows, $orderByFieldName and $orderByDirection in $this->pagination, a StorePagination, and $model, $view, $proxy and $rulesManager in $this->components, a StoreComponents |
Project classes that extend Store or PluginStore and use or declare one of these nine properties |
S | Store keeps its pagination and its components in two objects |
AbstractAction::getStore() returns Store instead of IStore |
Action classes of the project that override getStore() with the return type IStore |
S | AbstractAction::getStore() returns Store |
Store keeps $plugin in $this->components, and has no getRulesManager(): the rules manager is $this->components->rulesManager |
Project classes that extend Store or PluginStore and use or declare $plugin, or call or declare getRulesManager() |
S | Store keeps its plugin in its components |
ClassSchema has no relations(), listeners(), routers(), rules() and externalValues(): a schema declares these five sections in bindings(ISchemaBindings $bindings) |
Project classes that extend ClassSchema or a schema class of the project and declare one of the five methods |
S | ClassSchema declares five sections in bindings() |
onInit() of a plugin and seventeen more methods declare a return type, ObjectPlugin::$object and DefaultUser::$requiredFields declare a type, PermissionsException has a constructor |
Every plugin class that declares onInit(); system plugins, system objects, user classes, field classes, proxy classes and plugins that override one of the methods or redeclare one of the two properties |
L | The last untyped members have native types |
Native types and strict_types
CHANGELOG entries: src/util, src/exception, src/response, src/event
and src/locale; Store, its traits, PluginStore, the models, rules, views,
events and schema builders; the fields; the proxies; the actions; Core,
Entity, Response, DefaultUser, Logger, the Core traits, the plugin base
classes and the system object; FestiTestCase and TestUtils.
What changed
The framework files declare strict_types=1 and native parameter, return
and property types. PHP checks a project class against them when it loads
the class:
- An override that leaves out a return type the parent declares is a fatal error.
- An override that declares a parameter or return type the parent does not accept is a fatal error. A parameter type may be wider than the parent type, a return type may be narrower.
- An override must return by reference (
function &name()) when the parent does. - A redeclared property must have exactly the type of the parent property. A redeclaration without a type is a fatal error.
- An override that declares no parameter type stays valid. Only the return type has to be added to such a method.
EditAction::load() and EditAction::loadRow() return static.
Who is affected
Every project class that extends or implements a framework class and
overrides a method or redeclares a property: stores, models, actions,
fields, proxies, plugins, Response subclasses and test classes.
A class that only calls framework methods is not affected by this part. See the two caller sections below.
How to find it
Run the class-load check. It reports each incompatible class with the parent signature in the message.
To list the candidate classes:
grep -rnE --include='*.php' --exclude-dir=vendor \
'extends (Store|PluginStore|StoreModel|Response|DefaultUser|[A-Za-z]*(Action|Field|Proxy|Plugin|TestCase))\b' .
What to do
Copy the parameter and return types of the parent method into the
override. Copy the type of the parent property into the redeclaration, or
delete the redeclaration and set the value in the constructor or onInit().
The signatures that projects override most are listed under
Signatures by area. For any other method, the fatal
error message prints the parent signature.
Before:
class OrdersStore extends Store
{
protected $parentFieldName = 'id_customer';
public function getPrimaryKey()
{
return 'id_order';
}
public function updateByPrimaryKey(int $primaryKeyValue, $values)
{
$values['mdate'] = date('Y-m-d H:i:s');
return parent::updateByPrimaryKey($primaryKeyValue, $values);
}
}
class OrderDateField extends DatetimeField
{
public function getFormat()
{
return 'd.m.Y';
}
}
PHP reports the first of these for each class:
Type of OrdersStore::$parentFieldName must be ?string (as in class Store)
Declaration of OrdersStore::getPrimaryKey() must be compatible with Store::getPrimaryKey(): string
Declaration of OrdersStore::updateByPrimaryKey(int $primaryKeyValue, $values) must be compatible with Store::updateByPrimaryKey(string|int $primaryKeyValue, array $values): bool
Declaration of OrderDateField::getFormat() must be compatible with DatetimeField::getFormat(): string
After:
class OrdersStore extends Store
{
protected ?string $parentFieldName = 'id_customer';
public function getPrimaryKey(): string
{
return 'id_order';
}
public function updateByPrimaryKey(
int|string $primaryKeyValue,
array $values
): bool
{
$values['mdate'] = date('Y-m-d H:i:s');
return parent::updateByPrimaryKey($primaryKeyValue, $values);
}
}
class OrderDateField extends DatetimeField
{
public function getFormat(): string
{
return 'd.m.Y';
}
}
The members that got their types last, onInit() of a plugin among
them, are in
The last untyped members have native types,
with a script that adds the return types.
Signatures by area
The lists hold the current signatures, copied from src/ and tests/
and written on one line. [Trait] after a line names the trait that
declares the method.
Stores
Store (src/store/Store.php and the Store*Trait files):
public function __construct(IDataAccessObject &$connection, string $ident, array &$options = [], ?Core $core = null)
protected function onInit(): void
protected function onPrepareOptions(array &$options): void
public function getDefaultErrorMessage(): string
public function getCurrentUrl(): string
public function getFieldFiltersConditions(): array
public function cloneInstance(): Store
public function onRequest(Response &$response): bool
public function createActionInstance(string $action, bool $isSaveActon = true): AbstractAction
public function getActionNameFromRequest(): string
public function getStoreName(): string
public function getIdent(): string
public function getName(): string
public function getPrimaryKey(): string
public function &getModel(): StoreModel
public function &getView(): StoreView
public function &getProxy(): IProxy
public function &getConnection(): IDataAccessObject
public function &getPlugin(): ?AbstractPlugin
public function load(bool $isAllColumns = false): array
public function search(array $search = []): array
public function loadRow(array $search = [], bool $isAllColumns = false): ?array
public function loadRowByPrimaryKey(mixed $primaryKeyValue, bool $isCheckPermission = true, bool $isAllColumns = false): ?array
public function hasPermissionToLoadRowByPrimaryKey(mixed $primaryKeyValue): bool
public function addAllowedTableRow(array|int|string $primaryValues): void
public function removeByPrimaryKey(int|string $primaryKeyValue): bool
public function removeChildByPrimaryKey(int|string $primaryKeyValue): bool
public function updateByPrimaryKey(int|string $primaryKeyValue, array $values): bool
public function aggregate(?array &$sourceData): ?array
public function loadForeignKeys(): bool
public function loadForeignKeyValues(IStoreForeignField &$field): bool
public function getRequestParam(string $key, ?array &$request = null): mixed
public function setRequestParam(string $key, mixed $value): void
public function getPostParam(int|string $key): mixed
public function getSessionValue(string $key, ?string $ident = null): mixed
public function setSessionValue(string $key, mixed $value, ?string $ident = null): void
public function getParentValue(): mixed
public function getParentValues(string|false|null $storeIdent = false): mixed
public function getParentFieldName(): string|false
public function getOrderByFieldName(): ?string
public function getOrderByDirection(): string
public function getOption(string $name): mixed [OptionsTrait]
public function &getOptionByRef(string $name): mixed [OptionsTrait]
public function setOption(string $key, mixed $value): void [OptionsTrait]
core\store\PluginStore:
public function __construct(string $ident, ?IPlugin $plugin = null, array $options = [])
core\store\IStore, for a class that implements it without extending
Store:
public function getOption(string $name): mixed
Typed properties of Store:
protected string $ident
protected mixed $session
protected IDataAccessObject $connection
protected Festi\Store\StoreComponents $components
protected Festi\Store\StorePagination $pagination
protected ?string $parentFieldName
protected array $request
protected Core $core
The model, the view, the proxy, the rules manager, the page and the order
of a store are not properties of Store: see
Store keeps its pagination and its components in two objects.
Its plugin is not one either: see
Store keeps its plugin in its components.
Listener interfaces a store or a plugin implements
(src/store/event/, src/store/proxy/):
IBeforeInsertListener::onBeforeInsertHandler(StoreActionEvent &$event): void
IBeforeUpdateListener::onBeforeUpdateHandler(\StoreActionEvent &$event): void
IInsertListener::onInsertHandler(StoreActionEvent &$event): void
IUpdateListener::onUpdateHandler(StoreActionEvent &$event): void
IUpdatedValuesListener::onUpdatedValuesHandler(\StoreActionEvent &$event): void
IBeforeRemoveListener::onBeforeRemoveHandler(StoreActionEvent &$event): void
IRemoveListener::onRemoveHandler(StoreActionEvent &$event): void
IActionItemsListener::onActionItemsHandler(StoreActionEvent &$event): void
IListActionsListener::onListActionsHandler(StoreActionEvent &$event): void
IListGropedActionsListener::onListGropedActionsHandler(StoreActionEvent &$event): void
IListRowStyleListener::onListRowStyleHandler(ListRowStyleEvent &$event): void
ILoadChildFieldValuesListener::onLoadChildFieldValuesHandler(StoreActionEvent $event): void
IPrefillFieldsValues::onPrefillFieldsValues(\StoreActionEvent &$event): void
IPrepareActionFormListener::onPrepareActionFormHandler(FestiEvent &$event): void
IPrepareListItem::onPrepareListItemHandler(IStoreField &$field, mixed &$value, array &$row): void
IRowPermissionListener::hasPermissionToAccessRowHandler(mixed $primaryKeyValue): bool
IFieldEncryptValueListener::onFieldEncryptValueHandler(StoreFieldCryptoEvent &$event): void
IFieldDecryptValueListener::onFieldDecryptValueHandler(StoreFieldCryptoEvent &$event): void
IFrontendEventListener::onStoreFrontendEventHandler(FrontendStoreActionEvent &$event): void
IStoreProxyForeignKeyValuesListener::onPrepareStoreForeignKeyFieldValues(Store &$store, IStoreForeignField &$field, array &$info): void
Models, rules and views:
StoreModel::__construct(Store &$store)
StoreModel::load(): void
StoreModel::get(string $name): mixed
StoreModel::set(string $key, mixed $value): void
StoreModel::getPrimaryKey(): string
StoreModel::getAttributesOptions(): array (protected)
StoreModel::getActionsAttributesOptions(): array (protected)
StoreModel::getSectionsAttributesOptions(): array (protected)
StoreModel::createFieldInstance(FieldModel $model, ?int $index = null): IStoreField
StoreModel: protected Festi\Store\Model\StoreSchemaParts $parts
Festi\Store\Model\StoreSchemaParts implements ArrayAccess
Festi\Store\Model\StoreSchemaParts::offsetGet(mixed $offset): array (returns a reference)
Festi\Store\Model\StoreSchemaParts::offsetSet(mixed $offset, mixed $value): void
FieldModel::getAttributes(): array (abstract)
FieldModel::hasOptions(): bool (abstract)
FieldModel::getOptions(): array (abstract)
Festi\Store\Rule\AbstractRule::__construct(protected array $attributes = [])
Festi\Store\Rule\AbstractRule::onInit(IStore &$store): void
Festi\Store\Rule\AbstractRule::getAttributesFields(): ?array (abstract protected)
IActionView::onResponse(AbstractAction &$action, Response &$response, ?array &$vars): bool
core\store\view\IActionViewProvider::createViewInstance(StoreView $storeView): IActionView
StoreView::onActionResponse(AbstractAction &$action, Response &$response, ?array &$vars = null): bool
Actions
AbstractAction:
public function __construct(Store &$store, array $options = [])
protected function onInit(): void
public function isGeneral(): bool
public function onStart(Response &$response): bool
protected function getRequestFields(): ?array
protected function getDataFromRequest(): array
public function getUrl(array $params = []): string
protected function error(Response &$response): bool
public function doApplyError(Response &$response): bool
public function doHandleException(Throwable $exp, bool $hasParentTransaction = false): void
public function event(string $eventName, array &$data = []): StoreActionEvent
protected function fireEvent(IEvent &$event): void
public function &getStore(): Store
public function isExec(): bool
abstract public function getActionName(): string
public function getAttribute(string $key): mixed
public function onDisplayForm(Response &$response): void
public function getValues(): ?array
protected function hasUserPermission(string $section): bool
public function setError(?string $error, ?Exception $exp = null): void [ErrorStateTrait]
public function getLastError(): ?string [ErrorStateTrait]
InsertAction (parent of EditAction):
public function onUpdate(Response &$response): ?array
protected function getRequest(): ?array
public function apply(array $request, ?Response &$response = null): ?array
protected function getPreparedValues(array $request): array
public function getPrimaryKeyValue(): mixed
public function setValues(?array $data): void
public function isCustomUpdate(): bool
public function isDisplaySubmitButton(): bool
public function getTemplateName(): string [DisplayActionTrait]
public function isDisplayFieldIntoForm(IStoreField $field): bool [InsertActionFieldRenderTrait]
public function fetchFieldInput(IStoreField &$field): ?string [InsertActionFieldRenderTrait]
public function fetchFieldPreview(IStoreField &$field): ?string [InsertActionFieldRenderTrait]
EditAction and RemoveAction:
EditAction::load(mixed $primaryKeyValue, bool $isCheckPermission = false): static
EditAction::loadRow(array $search): static
EditAction::fetchFieldInput(IStoreField &$field): ?string
RemoveAction::onRemove(Response &$response): ?array (protected)
ListAction:
public function getTemplateName(): string
protected function getListControlsPresenter(): IListControlsPresenter
protected function getListRowPresenter(): IListRowPresenter
protected function onStorageRow(array $row, array $fields): array [ListActionRowsTrait]
protected function onStorageRowValue(IStoreField $field, array $row): array [ListActionRowsTrait]
public function getAllowedActions(array $rowValues = []): array [ListActionRowsTrait]
public function fetchDisplayValue(IStoreField $field, mixed $value, ?array $row = null): mixed [ListActionRowsTrait]
public function getActionAttribute(string $actionName, array $rowData = []): ActionOptions [DisplayActionTrait]
protected function getValueWithLink(mixed $value, IStoreField $field, mixed $primaryKeyValue, array|bool|null $row = false): mixed [DisplayActionTrait]
protected function getCellValue(IStoreField $field, ?string $value, ?array $row = null): mixed [DisplayActionTrait]
Typed properties:
AbstractAction: protected Store $store
AbstractAction: protected StoreModel $model
AbstractAction: protected bool $hasError
AbstractAction: protected ?string $lastErrorMessage
AbstractAction: protected ?Exception $exception
InsertAction: protected ?array $updateInfo
InsertAction: protected ?array $data
InsertAction: public mixed $primaryKeyValue
ListAction: protected ?array $storageValues
ListAction: protected mixed $storageTotals
ListAction: public mixed $primaryKeyValue
Fields
AbstractField and the IStoreField* interfaces:
public function __construct(Store &$store)
public function onInit(FieldModel $scheme): void
protected function getAttributesFields(): array
protected function getTemplatePath(): string
protected function getBaseTemplatePath(): string
public function has(string $name): bool
public function get(string $name): mixed
public function set(string $name, mixed $value): void
public function getAttributes(): array
public function getQueryWhere(): ?string
public function getName(): string [AbstractFieldIdentityTrait]
public function getType(): string [AbstractFieldIdentityTrait]
public function getCaption(): string [AbstractFieldIdentityTrait]
public function getElementName(): ElementName [AbstractFieldIdentityTrait]
public function getEditInput(mixed $value = null, ?array $row = null): ?string [AbstractFieldRenderTrait]
public function getDisplayValue(mixed $value, ?array $row = null): ?string [AbstractFieldRenderTrait]
public function getPreviewValue(mixed $value, ?array $row = null): ?string [AbstractFieldRenderTrait]
protected function getDefaultCellValue(mixed $value, ?array $row = null): string [AbstractFieldRenderTrait]
public function getValueFromRequest(array $request = []): mixed [AbstractFieldValueTrait]
public function isValidValue(mixed $value): bool [AbstractFieldValueTrait]
public function setValue(mixed $value): void [AbstractFieldValueTrait]
public function getValue(): mixed [AbstractFieldValueTrait]
protected function onPrepareValue(mixed &$value): void [AbstractFieldValueTrait]
public function getKeyInRequest(): string [AbstractFieldValueTrait]
public function getInputType(): string [AbstractFieldValueTrait]
public function getFormattedValue(mixed $value): ?string [AbstractFieldFormatTrait]
public function getFormatedValue(mixed $value, string $format): string [AbstractFieldFormatTrait]
public function getFormattedFilterValue(mixed $value): ?string [AbstractFieldFilterTrait]
public function getFilterType(): ?string [AbstractFieldFilterTrait]
public function getFilterKey(): string [AbstractFieldFilterTrait]
protected function getFilterTemplateName(): string [AbstractFieldFilterTrait]
protected function fetchFilterTemplate(): string [AbstractFieldFilterTrait]
protected function onFilterFetch(): void [AbstractFieldFilterTrait]
public function fetchFilter(): ?string [AbstractFieldFilterTrait]
public function getFilterValues(): ?array [AbstractFieldFilterTrait]
public function getCssClassName(bool|string $postfix = false): string [AbstractFieldCssTrait]
public function getElementAttributes(): string [AbstractFieldCssTrait]
public function getEncryptValue(?string $value): string [AbstractFieldCryptoTrait]
public function getDecryptedValue(string $value): ?string [AbstractFieldCryptoTrait]
public function getViewValue(mixed $value): ?string [AbstractFieldCryptoTrait]
public function isVirtualField(?string $actionName = null): bool [AbstractFieldStateTrait]
public function isCustom(?string $section = null): bool [AbstractFieldStateTrait]
public function isRequired(): bool [AbstractFieldStateTrait]
public function hasUserPermission(): bool [AbstractFieldStateTrait]
Other field classes:
DatetimeField::getFormat(): string
DatetimeField::getPreparedValueForEdit(mixed $value, string $format): string (protected)
TimestampField::getPreparedValueForEdit(mixed $value, string $format): string (protected)
SelectField::getValuesList(): array
SelectField::onInitOptions(FieldModel $scheme): void (protected)
SelectField: public array $valuesList
ForeignKeyField::setValuesList(array $values): void
ForeignKeyField::getValuesList(): array
ForeignKeyField::getValuesWhere(): ?string
FileField::getUploadFilePath(array $rowData = []): string
FileField::getAllowedMimeTypes(): array (protected)
FileField::onUploadFile(StoreActionEvent $event): void
HandlerField::callHandlerMethod(array $params, string $methodPostfix): mixed
PriceField::getCurrencySymbol(): string|false (protected)
Typed properties of AbstractField:
protected Store $store
protected core\store\action\IStoreAction $action
protected array $attributes
protected ?int $index
protected mixed $itemValue
protected ?array $filterValues
protected mixed $filterValue
public ?string $lastErrorMessage
Proxies
IProxy and IProxyRepository, implemented by SqlProxy and its
subclasses and by core\store\proxy\OpenApiProxy:
public function loadListValues(bool $isAllColumns = false): array
public function getCount(): int
public function loadForeignAssigns(int|string|null $primaryValue, array $options): array
public function loadForeignValues(array $options): array
public function loadForeignKeyValues(IStoreForeignField &$fields): bool
public function updateManyToManyValues(Many2manyField $field, int $id, array $values): bool
public function removeAllManyToManyValuesByPrimaryKey(int|string $primaryKeyValue): bool
public function getQueryColumns(bool $isAllColumns = false): array
public function getQueryJoins(array $columns): array
public function getQueryWhere(): array
public function setUseLimit(bool $isUseLimit): void
public function isUseLimit(): bool
public function loadAggregations(): array
public function createAuditTable(string $auditTableName, string $originalTableName): bool
public function getLikeCondition(string $column, string $value, bool $caseSensitive = false, bool $isNotClause = false): array
public function getFieldTableName(string $table, string $column): string
public function isBegin(): bool
public function begin(): bool
public function commit(): void
public function rollback(): void
public function insert(array $values): mixed
public function updateByPrimaryKey(string|int|bool $primaryKeyValue, array $values): bool
public function search(array $search): array
public function loadRow(array $search, bool $isAllColumns = false): ?array
public function removeByPrimaryKey(string $primaryKeyValue): int
public function loadRowByPrimaryKey(mixed $id, bool $isAllColumns = false): ?array
StoreProxy and SqlProxy:
StoreProxy::__construct(Store &$store)
StoreProxy::&getConnection(): IDataAccessObject
StoreProxy::getConcatCondition(array $condition): string (abstract protected)
StoreProxy::getFilterConditionsByField(IStoreField $field): ?array (protected)
StoreProxy::getColumnNameByFilter(IStoreField $field): ?string (protected)
SqlProxy::getWhereClause(array $conditions): string (protected)
SqlProxy::getQueryGroupBy(): ?string (protected)
SqlProxy::getQueryLimit(): string (protected)
SqlProxy::getQueryHaving(): array (protected)
SqlProxy::getSqlColumnName(IStoreField $field): string (protected)
SqlProxy::getSearchSql(array $search, bool $isAllColumns = false): string (protected)
SqlProxy::getMany2manyQuery(Many2manyField $field): string (abstract protected)
Typed properties of StoreProxy: protected Store $store,
protected StoreModel $model, protected IDataAccessObject $connection.
Plugins, Response, Core and the system object
AbstractPlugin and ObjectPlugin:
AbstractPlugin::onInit(): void
AbstractPlugin::getSetting(string $key): mixed
AbstractPlugin::hasSetting(string $key): bool
AbstractPlugin::createStoreInstance(string $storeName, array $params = []): Store
AbstractPlugin::createStoreInstanceByConnection(IDataAccessObject $connection, string $storeName, array $params = []): Store
AbstractPlugin::getName(): string
AbstractPlugin::__isInstalled(): bool
AbstractPlugin::__getSettings(): array|false
AbstractPlugin::hasUserPermissionToSection(string $sectionIdent, ?DefaultUser $user = null): bool
AbstractPlugin::getPluginTemplatePath(): ?string
AbstractPlugin::getPath(): string
AbstractPlugin: protected Core $core
AbstractPlugin: protected core\util\PluginWrapper $plugin
ObjectPlugin: protected ?IDataAccessObject $object
ObjectPlugin::onInit(): void
ObjectPlugin::getObject(?string $name = null, ?string $pluginName = null): ?IDataAccessObject
ObjectPlugin::getSystemObject(string $name = 'System'): object
Response:
public function __construct(string $type = self::NORMAL, ?string $actionType = null)
public function setType(string $type): void
public function getType(): string
public function setAction(string $type): void
public function getAction(): ?string
public function setPlugin(AbstractPlugin &$plugin): void
public function addParam(array|string $name, mixed $value = false): void
public function flush(bool $status = true): void
public function isFlush(): bool
public function getContent(): mixed
public function setStatus(int $status): void
public function setOverride(bool $isOverride): void
public function setAfter(mixed $type, mixed $command): void [ResponseAfterActionTrait]
public function setAfterCallback(string $pluginName, string $method): void [ResponseAfterActionTrait]
public function addMessage(string|array $message): void [ResponseMessageTrait]
public function addNotification(mixed $message): void [ResponseMessageTrait]
public function send(?IPlugin $plugin = null): bool [ResponseSenderTrait]
Typed properties of Response: protected int $_status,
protected string $type, protected ?string $action,
protected ?AbstractPlugin $plugin, protected bool $_isOverride.
Entity, Core, ISystemPlugin and SystemObject:
Entity::getPreparedData(array $request, array $needles, ?array &$errors = []): array
Entity::getExtendData(array $request, array $needles, ?array &$errors = []): array
Entity::fillString(?string $string, array|bool|null $values = false): string (static)
Core::call(string $plugin, string $method, array $params = [], array $options = []): mixed
Core::&getPluginInstance(string $plugin, array $options = []): mixed
Core::&getSystemPlugin(): ISystemPlugin
Core::fireEvent(mixed $eventType, mixed &$data = null): ?array
ISystemPlugin::bindRequest(array $options = []): mixed
ISystemPlugin::onInitRequest(array $options = []): void
ISystemPlugin::getResponseModel(array $call, array $regs = []): Response
ISystemPlugin::install(): void
ISystemPlugin::onDisplayMain(Response &$response, string|false $storeName = false, string|false $pluginName = false, array $params = []): ?bool
ISystemPlugin::getSetting(string $key): mixed
ISystemPlugin::hasSetting(string $key): bool
ISystemPlugin::hasUserPermissionToSection(string $sectionIdent, ?DefaultUser $user = null): bool
ISystemPlugin::getName(): string
ISystemPlugin::onException(array $event): void
ISystemPlugin::setPermissionSection(string $name, int $mask, ?int $userType = null, ?array $users = null): int
ISystemPlugin::refreshPermissionSections(): void
ISystemPlugin::getUserTypesBySection(string $section): array
ISystemPlugin::getUsersBySection(string $section, array $search = []): array
SystemObject::getPrefix(): string
SystemObject::getSettings(): array
SystemObject::getUrlRules(array $search): array [SystemObjectUrlTrait]
SystemObject::addUrlRule(array $values): mixed [SystemObjectUrlTrait]
SystemObject::isInstalled(): bool
SystemObject::getPlugin(array|string $search): array
SystemObject::getPlugins(array $search): array
SystemObject::getTable(string $name): string (protected)
DefaultUser::__construct(array &$_sessionData)
DefaultUser::doLogin(array $fields): bool
DefaultUser::logout(): void
DefaultUser::isLogin(): bool
DefaultUser::getValue(string $name): mixed
DefaultUser::get(string $name): mixed
DefaultUser::set(string $name, mixed $value): mixed
DefaultUser::getID(): mixed
DefaultUser::getRole(): mixed
DefaultUser::getSessionData(): array
DefaultUser: protected array $requiredFields
Events, exceptions and utilities
EventDispatcher::addEventListener(string $type, mixed $listener): bool
EventDispatcher::dispatchEvent(IEvent &$event): ?array
EventDispatcher::removeEventListener(string $type, callable|array|string $listener): void
EventDispatcher::hasEventListener(string $type, callable|array|string $listener): bool
FestiEvent::__construct(mixed $type, mixed &$currentTarget = null)
FestiEvent::getType(): string
FestiEvent::&getTarget(): mixed
FestiEvent::&getTargetValueByKey(mixed $key): mixed
FestiEvent::setTargetValueByKey(mixed $key, mixed $value): void
SystemException::__construct(?string $message = "", int $code = 0, ExceptionContext $context = new ExceptionContext(), string|false|null $displayMessage = false, ?Throwable $previous = null)
PermissionsException::__construct(?string $message = "", int $code = 0, ExceptionContext $context = new ExceptionContext(), string|false|null $displayMessage = false, ?Throwable $previous = null)
SystemException::setDisplayMessage(string|false|null $message): void
SystemException::getDisplayMessage(): string
Permissions::doValidate(array $options = []): void (static)
OptionsTrait::getOption(string $name): mixed
OptionsTrait::&getOptionByRef(string $name): mixed
OptionsTrait::setOption(string $key, mixed $value): void
OptionsTrait::getOptions(): array
OptionsTrait is used by Core, AbstractPlugin, Store and
AbstractAction.
Test helpers
FestiTestCase (tests/FestiTestCase.php):
protected function setUp(): void
protected function tearDown(): void
public function &getCoreWithConnection(): Core
public function getDbConnection(): IDataAccessObject
protected function getDefaultStore(array $params = []): Store
public static function preparePostForStore(string $action, array $values, Store $store, mixed $primaryKeyValue = null): void
public static function prepareFiltersForStore(array $values, Store &$store): bool
public static function cleanFiltersForStore(Store &$store): bool
public static function cleanPostByStoreName(string $storeName): void
public static function cleanPostByStore(Store &$store): void
public static function preparePostByStoreName(string $storeName, array $values, mixed $primaryKeyValue = false): void
public static function prepareChildRelationPostByStoreName(string $storeName, int|string $idParent, array $values, array $parentValues = [], mixed $primaryKeyValue = false): void
public static function preparePrimaryKey(Store &$store, mixed $primaryKeyValue): void
public function getPluginMockBuilder(string $pluginName, ?array $methods = [], array $options = []): MockObject
public function getActionMockBuilder(string $actionName, array $methods = [], array $options = []): MockObject
public function doTestStore(string $storeName, array $params = [], array $values = []): void
protected function addDatabaseData(Store $store, array $values): mixed
protected function onTestInsertStoreAction(Store $store, array $values): Response
protected function onTestListStoreAction(Store $store, array $values): void
protected function onTestEditStoreAction(Store $store, array $values): void
protected function onTestRemoveStoreAction(Store $store, array $values): void
protected function onTestInfoStoreAction(Store $store, array $values): void
protected function onTestBatchInsertStoreAction(Store $store, array $values): void
protected function onTestPluginStoreAction(Store $store, array $values): void
protected function onTestCsvImportStoreAction(Store $store, array $values): void
protected function getSearch(Store $store, array $values): array
protected function getPreparedValueByType(Store $store, int|string $name, mixed $value): mixed
protected function getPreparedValues(Store $store, array $values): array
protected function getRandomString(int $length = 20): string
protected function getRandomNumber(int $min = 1, int $max = 2000000): int
public function applyInsertAction(Store $store, array $values): array
public function applyEditAction(Store $store, mixed $primaryKeyValue, array $values): array
public function applyRemoveAction(Store $store, mixed $primaryKeyValue): void
public function createStore(array $overrideOptions = [], string $storeName = STORE_DEFAULT): Store
Typed properties: FestiTestCase: protected Core $core,
PluginTestCase: protected core\util\PluginWrapper $plugin.
TestUtils:
public static function getDbConnection(array $dbConfig): IDataAccessObject
public static function cleanDatabase(): void
public static function installDatabase(string $dumpPath): bool
public static function run(string $path): mixed
Calls from project code into the framework
What changed
Framework parameters have types. PHP chooses strict or coercive checking
of scalar arguments by the file that makes the call. strict_types=1 in
the framework files does not change how arguments from a project file are
checked. So:
- A project file without
declare(strict_types=1)still has itsint,float,stringandboolarguments converted to the declared scalar type, where PHP can convert them (5to'5','5'to5). - In every file,
nullfor a parameter that is not nullable is aTypeError. So is an array or an object for a scalar parameter, a scalar for anarrayparameter, and a string that is not numeric for anint. - A project file with
declare(strict_types=1)must pass the declared type. Anintfor astringparameter is aTypeErrorthere.
Before, many of these parameters had no type and accepted any value.
Who is affected
Code that passes null, an array or an object to a framework parameter
declared as a scalar, or a scalar to one declared as array. Project
files that declare strict_types=1 and pass a scalar of another type,
for example an int event type to addEventListener(string $type, ...).
Project files without strict_types=1 that pass scalars are not affected.
How to find it
Run the tests and the static analysis. The error is
TypeError: Y::m(): Argument #N ($p) must be of type T, U given, called in
<file> on line <line>. The file and line name the call to fix.
To list the project files where every scalar argument must match:
grep -rlE --include='*.php' --exclude-dir=vendor \
'declare\(strict_types ?= ?1\)' .
What to do
Pass a value of the declared type. Handle null before the call.
Before, with $values that can be null and an event type that is an
int:
FestiTestCase::preparePostByStoreName('orders', $values);
$store->addEventListener(self::EVENT_DONE, $listener);
After:
FestiTestCase::preparePostByStoreName('orders', $values ?? []);
$store->addEventListener((string) self::EVENT_DONE, $listener);
The cast in the second line is needed only in a file that declares
strict_types=1. Both parameters had no type before.
The framework calls project code in coercive mode
This is not a change a project has to act on. It is here because the framework files are strict now, and a reader may expect the opposite.
What changed
Nothing for a project method. PHP checks the arguments of a call in the
mode of the file that makes it, and the framework files declare
strict_types=1. The framework therefore calls project code through
Festi\Core\ProjectCallback::call(), which checks the arguments in
coercive mode, as a file without strict_types does:
Core::call()calls a plugin method with the values a URL rule captured. These values are strings. A parameter declaredint $idOrderreceives5for'5'.- The same holds for event listeners, hooks, the handler methods of a
handlerfield,cellViewHandlermethods, row actions of a plugin,rulecallables of a request field,getFormatValue<Format>()methods of a field class and log interceptors. - A value PHP cannot convert is refused with a
TypeError: a non-numeric string forint,nullfor a parameter that is not nullable, an array for a scalar.
Two classes got a protected method for this: Entity::callProjectCode()
and, in every field, callFormatMethod().
Who is affected
A project class that extends Entity (a plugin, a store, a model, an
action) and already has a method named callProjectCode() with another
signature, and a field class that already has a method named
callFormatMethod() with another signature. PHP reports both when the
class is loaded.
How to find it
grep -rnE --include='*.php' --exclude-dir=vendor \
'function &?(callProjectCode|callFormatMethod)\(' .
What to do
Rename the project method. The framework methods are:
Entity::callProjectCode(callable $callback, array $arguments = []): mixed
AbstractFieldFormatTrait::callFormatMethod(string $methodName, mixed $value): mixed
Field models built from an array or a class
What changed
A field keeps a string attribute as the schema gives it. The getters now
declare string, in a strict file. AbstractField::getCaption(): string
throws a TypeError when the caption attribute holds an int. The
name attribute is assigned to a string property in
AbstractField::onInit() the same way.
Who is affected
Stores whose model is built from an array or from a schema class and
give a string attribute, such as caption or name, as an int, a
float or a bool.
Stores with an XML schema are not affected by this part: the attributes of an XML element are strings.
How to find it
Run the tests and open the stores that use an array or class schema. The
error is TypeError: AbstractField::getCaption(): Return value must be of
type string, int returned.
To list array and class schemas that give a number as caption or name:
grep -rnE --include='*.php' --exclude-dir=vendor \
"'(caption|name)' *=> *[0-9]" .
What to do
Give the attribute as a string.
Before:
$field = [
'type' => 'text',
'name' => 'year',
'caption' => 2026,
];
After:
$field = [
'type' => 'text',
'name' => 'year',
'caption' => '2026',
];
Store key methods refuse null
CHANGELOG entry: Store, its traits, PluginStore (key types), and the
store proxies (updateByPrimaryKey()).
What changed
Store::removeByPrimaryKey(int|string $primaryKeyValue): bool
Store::removeChildByPrimaryKey(int|string $primaryKeyValue): bool
Store::updateByPrimaryKey(int|string $primaryKeyValue, array $values): bool
IProxyRepository::updateByPrimaryKey(string|int|bool $primaryKeyValue, array $values): bool
Before, the key parameter of the three Store methods had no type. Now a
null key is a TypeError before the method runs, so no child row is
removed for it. Store::updateByPrimaryKey() also requires $values to
be an array.
Who is affected
Code that calls one of these methods with a key that can be null, for
example the result of a lookup that found no row.
Calls with an int or string key are not affected. In a file without
strict_types=1 a float or bool key is still converted.
How to find it
grep -rnE --include='*.php' --exclude-dir=vendor \
'(remove|removeChild|update)ByPrimaryKey\(' .
Check for each match whether the key can be null.
What to do
Before:
$row = $store->loadRow($search);
$store->removeByPrimaryKey($row['id'] ?? null);
After:
$row = $store->loadRow($search);
if ($row !== null) {
$store->removeByPrimaryKey($row['id']);
}
Values received by reference must stay arrays
CHANGELOG entry: the store actions under src/store/action.
What changed
Actions hand some values to listeners by reference. The properties behind
these references are typed ?array:
| Event | Target key | Property | Typed before |
|---|---|---|---|
Store::EVENT_ON_LOAD_ACTION_ROWS |
values |
ListAction::$storageValues |
no |
AbstractAction::EVENT_PREPARE_FOREIGN_FIELD_VALUES |
results |
ForeignKeyLoadAction::$_values |
no |
Store::EVENT_ON_LOAD_ACTION_VALUES |
values |
InsertAction::$data |
yes |
A listener that writes a string, a number, false or an object there gets
TypeError: Cannot assign string to reference held by property
ListAction::$storageValues of type ?array.
The values target of Store::EVENT_BEFORE_INSERT and
Store::EVENT_BEFORE_UPDATE is passed on to a parameter declared array.
That was already so before.
ListAction::$storageTotals, the totals target of
Store::EVENT_ON_LOAD_LIST_DATA, is typed mixed and accepts any value.
Who is affected
Listeners of the first two events in the table that replace the value with something that is not an array.
Listeners that add, change or remove elements of the array are not affected.
How to find it
grep -rnE --include='*.php' --include='*.xml' --exclude-dir=vendor \
'EVENT_ON_LOAD_ACTION_ROWS|EVENT_ON_LOAD_ACTION_VALUES|EVENT_PREPARE_FOREIGN_FIELD_VALUES|load_action_rows|load_action_values|prepare_foreign_field_values' .
Read each listener and check what it assigns to the target value.
What to do
Before:
$store->addEventListener(
Store::EVENT_ON_LOAD_ACTION_ROWS,
function (FestiEvent &$event) {
$rows = &$event->getTargetValueByKey('values');
if (!$rows) {
$rows = false;
}
}
);
After:
$store->addEventListener(
Store::EVENT_ON_LOAD_ACTION_ROWS,
function (FestiEvent &$event) {
$rows = &$event->getTargetValueByKey('values');
if (!$rows) {
$rows = [];
}
}
);
DGS action failures are not logged by the framework
CHANGELOG entry: "The framework no longer logs DGS action failures".
What changed
Every exception an action handles (AbstractAction::doHandleException())
is reported through
Festi\Store\Event\ActionErrorEvent::EVENT_ON_ACTION_ERROR, dispatched on
the store. A failing Store::EVENT_ROLLBACK listener of an insert, edit
or remove action is reported through the same event. The framework
writes no log entry for either. A listener of EVENT_ON_ACTION_ERROR that
throws is ignored.
Who is affected
Projects that want DGS action failures in their log.
A project that upgrades from the tag 7.3.0 loses nothing: at that tag
src/store wrote no log entry and Store::EVENT_ROLLBACK did not exist.
Only a project that followed develop saw failing Store::EVENT_ROLLBACK
listeners in the store log, through FestiUtils::doHandleCoreException().
How to find it
grep -rn --include='*.php' --exclude-dir=vendor \
'EVENT_ON_ACTION_ERROR' .
No match means the project does not log DGS action failures.
What to do
Subscribe for every store from Core::EVENT_ON_CREATE_STORE, for example
in the init.php of a plugin. Nothing to remove, so there is no "before".
After:
use Festi\Store\Event\ActionErrorEvent;
$core = Core::getInstance();
$core->addEventListener(
Core::EVENT_ON_CREATE_STORE,
function (FestiEvent &$event) {
$store = &$event->getTargetValueByKey('store');
$store->addEventListener(
ActionErrorEvent::EVENT_ON_ACTION_ERROR,
function (ActionErrorEvent $event) {
FestiUtils::doHandleCoreException(
$event->getException(),
'store',
false
);
}
);
}
);
ActionErrorEvent also gives getActionInstance() and getActionName().
See ./docs/DGS/Events.md.
getElementName() returns an ElementName
CHANGELOG entry: IStoreField::getElementName() returns a
Festi\Store\Field\ElementName.
What changed
IStoreField::getElementName(): ElementName (before: string)
ElementName::getValue(): string
ElementName::__toString(): string
ElementName::isNameless(): bool
ElementName::getModelKey(): int|string
StoreModel::getFieldByElementName(ElementName $elementName): ?IStoreField
ElementName is a final class that implements Stringable.
Who is affected
Code that calls getElementName() and uses the result where PHP does not
convert an object to a string.
| Use of the result | Result |
|---|---|
echo, ., "$name", sprintf(), implode() |
works |
string parameter in a file without strict_types=1 |
works |
== with a string, in_array() without the strict flag |
works |
$row[$name], isset($row[$name]), array_key_exists($name, $row) |
TypeError |
=== with a string, in_array($name, $list, true), match |
false, no match |
json_encode() |
writes {} |
http_build_query() |
drops the value |
string parameter in a file with strict_types=1 |
TypeError |
A field class that overrides getElementName() must return an
ElementName.
How to find it
grep -rn --include='*.php' --include='*.phtml' --exclude-dir=vendor \
'getElementName()' .
What to do
Call getValue() at the sites of the last five rows of the table.
Before:
$name = $field->getElementName();
$value = $row[$name] ?? null;
$json = json_encode(['field' => $name]);
After:
$name = $field->getElementName()->getValue();
$value = $row[$name] ?? null;
$json = json_encode(['field' => $name]);
ISystemPlugin has no menu methods
CHANGELOG entry: ISystemPlugin no longer declares setActiveMenu and
getActiveMenu.
What changed
ISystemPlugin does not declare setActiveMenu(string $ident): void and
getActiveMenu(): ?string. ApiPlugin does not have these methods any
more. They are declared by Festi\Theme\UI\IMenuStructureProvider, in the
package festi-team/festi-framework-theme.
Who is affected
Code that calls setActiveMenu() or getActiveMenu() on the system
plugin. When the system plugin is ApiPlugin, or a subclass that does not
add the methods, the call is Error: Call to undefined method. When the
system plugin has the methods, the call still works at run time. Static
analysis can report it, because Core::getSystemPlugin() returns
ISystemPlugin.
A system plugin class that implements ISystemPlugin and still has both
methods keeps loading.
How to find it
grep -rnE --include='*.php' --include='*.phtml' --exclude-dir=vendor \
'(set|get)ActiveMenu\(' .
What to do
Before:
$systemPlugin = Core::getInstance()->getSystemPlugin();
$systemPlugin->setActiveMenu('orders');
After:
use Festi\Theme\UI\IMenuStructureProvider;
$systemPlugin = Core::getInstance()->getSystemPlugin();
if ($systemPlugin instanceof IMenuStructureProvider) {
$systemPlugin->setActiveMenu('orders');
}
ForeignKeyLoadAction answers with JSON
CHANGELOG entry: ForeignKeyLoadAction returns JSON for dependent field
loading.
What changed
A request with ajaxChild and ajaxParent used to render the template
actions/foreign_key_load.php once per child field and answer with the
rendered content. The framework does not ship that template. Now the
action sets the response type to Response::JSON and answers:
{
"results": [
{
"fieldName": "studygroup_id",
"childValue": "42",
"values": [
{"key": "Group A", "value": "42"},
{"key": "Group B", "value": "43"}
]
}
]
}
key is the label and value is the identifier. childValue is null
when the request carries no value for the child field.
The autocomplete branch (a request with fieldName) answered with JSON
before and is not changed.
Who is affected
Projects that use fields with the ajaxChild attribute and have their
own theme or JavaScript that loads the child values, or their own
actions/foreign_key_load.php template. The framework does not read that
template any more.
The client code has to read the JSON. The commit that made this change
names a companion change to dbaForeignKeyLoad in the theme kit. That
code is not in this repository, so check the version of the theme the
project uses.
How to find it
grep -rnE --exclude-dir=vendor --exclude-dir=node_modules \
'ajaxChild|foreign_key_load' .
find . -name 'foreign_key_load.php' -not -path './vendor/*'
What to do
Delete the project copy of actions/foreign_key_load.php. Where the
project has its own client code for dependent selects, fill each select
named by fieldName from values and select childValue. The format is
described in ./docs/DGS/Fields.md.
A PHP test that asserted on the rendered script changes like this.
Before:
$store->onRequest($response);
$html = $response->content;
After:
$store->onRequest($response);
$fields = $response->results;
$options = $fields[0]['values'];
Removed classes, removed methods and wider visibility
These changes are not marked (BREAKING) in ./CHANGELOG.md. They were found
by comparing src/ with the tag 7.3.0.
What changed
| Before | Now |
|---|---|
abstract class AbstractDisplayAction extends AbstractAction |
removed; its methods are in the trait DisplayActionTrait |
InsertAction, ListAction, ColumnsAction, CsvImportAction, RelationAction extend AbstractDisplayAction |
they extend AbstractAction |
RemoveAction extends InfoAction |
RemoveAction extends EditAction |
abstract class AbstractSelectField extends AbstractField |
removed |
SelectField, ForeignKeyField extend AbstractSelectField |
they extend AbstractField |
ComboField, SetField |
removed; the field types combo and set do not exist |
ISystemPlugin::onBind(), ApiPlugin::onBind() |
removed |
AbstractField::displayRow(string $value): string |
removed |
AbstractField::getFilter($value): string |
removed |
FileField::copy(), FileField::isUploaded() |
removed |
ApiPlugin::__getDefaultUrlRules(): array |
ApiPlugin::getDefaultUrlRules(): array |
protected Store::setTotalCount(?int $total): void |
public |
protected AbstractField::getViewValue(mixed $value): ?string |
public |
protected AbstractField::getFormatedValue(mixed $value, string $format): string |
public |
protected StoreModel::getActionAttributesOptions(): array |
public |
Who is affected
Classes that extend a removed class, instanceof checks against one,
schemas with type="combo" or type="set", code that calls a removed
method, and classes that override one of the four methods as protected.
How to find it
The class-load check reports the classes. To find the other uses:
grep -rnE --include='*.php' --include='*.xml' --exclude-dir=vendor \
'AbstractDisplayAction|AbstractSelectField|ComboField|SetField|type="(combo|set)"|instanceof InfoAction' .
grep -rnE --include='*.php' --include='*.phtml' --exclude-dir=vendor \
'(onBind|displayRow|getFilter|__getDefaultUrlRules)\(|function (copy|isUploaded|setTotalCount|getViewValue|getFormatedValue|getActionAttributesOptions)\(' .
What to do
Extend AbstractAction or AbstractField. Declare the four methods
public in the override.
Before:
class ReportAction extends AbstractDisplayAction
{
public function getActionName(): string
{
return 'report';
}
}
class MoneyField extends NumberField
{
protected function getViewValue(mixed $value): ?string
{
return parent::getViewValue($value);
}
}
After:
use Festi\Store\Action\DisplayActionTrait;
class ReportAction extends AbstractAction
{
use DisplayActionTrait;
public function getActionName(): string
{
return 'report';
}
}
class MoneyField extends NumberField
{
public function getViewValue(mixed $value): ?string
{
return parent::getViewValue($value);
}
}
Utility methods take value objects
CHANGELOG entry: ImageUtils::setImageResize(), setImageResizeMagic(),
createWatermark(), Curl::getUrl() and ApiUtils::send() take value
objects.
What changed
| Before | Now |
|---|---|
ImageUtils::setImageResize(string $outfile, string $infile, ?int $neww, ?int $newh = null, array $options = [], int $permission = self::DEFAULT_FILE_PERMISSION): bool |
ImageUtils::setImageResize(string $outfile, string $infile, ImageSize $size, ?ImageOutput $output = null): bool |
ImageUtils::setImageResizeMagic(string $outfile, string $infile, int\|float\|string $neww, int\|float\|string\|null $newh = null, bool $isCrop = false): void |
ImageUtils::setImageResizeMagic(string $outfile, string $infile, ImageSize $size, bool $isCrop = false): void |
ImageUtils::createWatermark(\GdImage $mainImgObj, string $text, string $font, int $r = 0, int $g = 0, int $b = 255, int $alphaLevel = 95): \GdImage\|false |
ImageUtils::createWatermark(\GdImage $mainImgObj, Watermark $watermark): \GdImage\|false |
Curl::getUrl(string $url, array\|string\|bool\|null $postParams = false, string\|bool $proxy = false, string\|bool $proxyPswd = false, bool $isIgnoreErrors = false): string\|bool |
Curl::getUrl(string $url, array\|string\|bool\|null $postParams = false, ?CurlProxy $proxy = null, bool $isIgnoreErrors = false): string\|bool |
ApiUtils::send(string $url, array\|string\|bool\|null $data = false, string\|bool\|null $requestMethod = false, array\|bool\|null $headers = false, mixed &$responseHeaders = null): mixed |
ApiUtils::send(string $url, ApiRequest $request): ApiResponse |
The new classes are in the namespace Festi\Core\Utils. Their values are
set by the constructor and read with getters:
ImageSize::__construct(int|float|string|null $width, int|float|string|null $height = null)
ImageOutput::__construct(int $quality = 100, ?Watermark $watermark = null, int $permission = ImageUtils::DEFAULT_FILE_PERMISSION)
Watermark::__construct(string $text, string $font, ?WatermarkColor $color = null)
WatermarkColor::__construct(int $red = 0, int $green = 0, int $blue = 255, int $alpha = 95)
CurlProxy::__construct(string $host, string $credentials = '')
ApiRequest::__construct(array|string|bool|null $data = false, string|bool|null $method = false, array|bool|null $headers = false)
ApiResponse::getData(): mixed
ApiResponse::getHeaders(): array
The methods do the same work as before for the same values: the same
resize arithmetic, the same JPEG, the same file mode 0644, the same
watermark color, the same cURL options and the same exceptions.
ImageSize takes a number or a numeric string for a side. A string that
is not a number, such as '100px' or '', is refused by the constructor
with a SystemException that names the side: Image width must be a
number, "100px" given. Before, setImageResize() refused such a value
with a TypeError on its ?int parameter.
ApiResponse::getData() is the value send() returned before, and
ApiResponse::getHeaders() is the value the fifth argument received.
These calls do not change:
Curl::getUrl()with one or two arguments.ApiUtils::sendGet(),sendPost(),sendPut()andsendDelete(). They return the decoded body as before.OpenApiProxy::send()andOpenApiProxy::getResponseHeaders().
Who is affected
Code that calls one of the five methods with the old arguments, and
classes that extend Curl, ApiUtils or ImageUtils and override one of
them.
PHP ignores arguments a method does not declare, but none of the old calls
runs with a changed meaning. In each new signature the first changed
position takes an object, and PHP never converts a number, a string,
false or null to an object, with or without strict_types=1:
| Old call | Result |
|---|---|
ImageUtils::setImageResize($out, $in, 320), also with more arguments |
TypeError: ImageUtils::setImageResize(): Argument #3 ($size) must be of type Festi\Core\Utils\ImageSize, int given |
ImageUtils::setImageResizeMagic($out, $in, '320', '240', true) |
TypeError: ImageUtils::setImageResizeMagic(): Argument #3 ($size) must be of type Festi\Core\Utils\ImageSize, string given |
ImageUtils::createWatermark($image, 'DRAFT', $font), also with colors |
TypeError: ImageUtils::createWatermark(): Argument #2 ($watermark) must be of type Festi\Core\Utils\Watermark, string given |
$curl->getUrl($url), $curl->getUrl($url, $body) |
works as before |
$curl->getUrl($url, $body, false, false, true) |
TypeError: Curl::getUrl(): Argument #3 ($proxy) must be of type ?Festi\Core\Utils\CurlProxy, false given (PHP 8.1 and 8.2 print bool given) |
$curl->getUrl($url, $body, '10.0.0.1:3128', 'user:secret') |
the same TypeError, with string given |
ApiUtils::send($url) |
ArgumentCountError: Too few arguments to function ApiUtils::send(), 1 passed in <project file> and exactly 2 expected |
ApiUtils::send($url, $data, 'POST', $headers, $responseHeaders), also with fewer arguments |
TypeError: ApiUtils::send(): Argument #2 ($request) must be of type Festi\Core\Utils\ApiRequest, array given |
| A class that overrides one of the five methods with the old parameters | Fatal error: Declaration of X::getUrl(...) must be compatible with Curl::getUrl(...), reported by the class-load check |
The errors appear only when the line runs. Use the commands below to find calls that tests and requests do not reach.
How to find it
grep -rnE --include='*.php' --include='*.phtml' --exclude-dir=vendor \
'setImageResize(Magic)?\(|createWatermark\(|ApiUtils::send\(' .
Every match of this command needs a change.
getUrl( is also the name of the url methods of Core and of the
plugins. This command looks only in files that use Curl, and prints the
getUrl() calls that pass three or more arguments or continue on the next
line:
grep -rlE --include='*.php' --exclude-dir=vendor \
'new \\?Curl\(|extends \\?Curl|Curl \$' . \
| xargs grep -HnE \
'getUrl\(([^()]|\([^()]*\))*,([^()]|\([^()]*\))*,|getUrl\($'
A match needs a change when the object is a Curl and the call has more
than two arguments.
What to do
Import the classes a file uses:
use Festi\Core\Utils\ApiRequest;
use Festi\Core\Utils\CurlProxy;
use Festi\Core\Utils\ImageOutput;
use Festi\Core\Utils\ImageSize;
use Festi\Core\Utils\Watermark;
use Festi\Core\Utils\WatermarkColor;
ImageUtils::setImageResize(): put the width and the height into
new ImageSize($width, $height), and when the call passed $options or
$permission, pass new ImageOutput($quality, $watermark, $permission)
as the fourth argument, where $watermark is
new Watermark($options['watermark'], $options['font']) or null.
Before:
$options = [
'quality' => 85,
'watermark' => 'DRAFT',
'font' => $fontPath,
];
ImageUtils::setImageResize($thumbPath, $uploadPath, 320);
ImageUtils::setImageResize($smallPath, $uploadPath, null, 240);
ImageUtils::setImageResize($previewPath, $uploadPath, 800, 600, $options, 0640);
After:
$watermark = new Watermark('DRAFT', $fontPath);
$output = new ImageOutput(85, $watermark, 0640);
$previewSize = new ImageSize(800, 600);
ImageUtils::setImageResize($thumbPath, $uploadPath, new ImageSize(320));
ImageUtils::setImageResize($smallPath, $uploadPath, new ImageSize(null, 240));
ImageUtils::setImageResize($previewPath, $uploadPath, $previewSize, $output);
A call that changed only the file mode can name the argument:
new ImageOutput(permission: 0640).
ImageUtils::setImageResizeMagic(): put the third and the fourth argument
into new ImageSize(...); $isCrop moves from the fifth position to the
fourth. Numeric strings from the config are accepted as before.
Before:
ImageUtils::setImageResizeMagic($thumbPath, $uploadPath, $config['width']);
ImageUtils::setImageResizeMagic($coverPath, $uploadPath, '640', '360', true);
After:
$thumbSize = new ImageSize($config['width']);
$coverSize = new ImageSize('640', '360');
ImageUtils::setImageResizeMagic($thumbPath, $uploadPath, $thumbSize);
ImageUtils::setImageResizeMagic($coverPath, $uploadPath, $coverSize, true);
ImageUtils::createWatermark(): put the text and the font into
new Watermark($text, $font), and when the call passed colors, pass
new WatermarkColor($r, $g, $b, $alphaLevel) as its third argument.
Before:
$image = ImageUtils::createWatermark($image, 'DRAFT', $fontPath);
$image = ImageUtils::createWatermark($image, 'SOLD', $fontPath, 255, 0, 0, 60);
After:
$draft = new Watermark('DRAFT', $fontPath);
$sold = new Watermark('SOLD', $fontPath, new WatermarkColor(255, 0, 0, 60));
$image = ImageUtils::createWatermark($image, $draft);
$image = ImageUtils::createWatermark($image, $sold);
Curl::getUrl(): replace the third and the fourth argument by one
argument, null when they were false, or
new CurlProxy($proxy, $proxyPswd); $isIgnoreErrors moves from the
fifth position to the fourth. In the common call this is the replacement
of false, false, by null,.
Before:
$options = [
CURLOPT_FAILONERROR => 0,
CURLOPT_TIMEOUT => 30,
];
$curl = new Curl($options);
$page = $curl->getUrl($url);
$response = $curl->getUrl($url, $body, false, false, true);
$remote = $curl->getUrl($url, $body, '10.0.0.1:3128', 'user:secret');
After:
$options = [
CURLOPT_FAILONERROR => 0,
CURLOPT_TIMEOUT => 30,
];
$curl = new Curl($options);
$page = $curl->getUrl($url);
$response = $curl->getUrl($url, $body, null, true);
$proxy = new CurlProxy('10.0.0.1:3128', 'user:secret');
$remote = $curl->getUrl($url, $body, $proxy);
The call can also name the argument and leave the proxy out:
$curl->getUrl($url, $body, isIgnoreErrors: true). As before, a Curl
object keeps the proxy for its next requests.
ApiUtils::send(): put the second, third and fourth argument, unchanged
and in the same order, into new ApiRequest(...), read the decoded body
with getData(), and read the headers the fifth argument received with
getHeaders(). A call that passed only the url passes new ApiRequest().
Before:
$headers = [
'Content-type: application/json',
'Access-Token: '.$token,
];
$orders = ApiUtils::send($url);
$order = ApiUtils::send($url, $values, 'POST', $headers, $responseHeaders);
$total = $responseHeaders['x-total-count'][0] ?? null;
After:
$headers = [
'Content-type: application/json',
'Access-Token: '.$token,
];
$orders = ApiUtils::send($url, new ApiRequest())->getData();
$request = new ApiRequest($values, 'POST', $headers);
$response = ApiUtils::send($url, $request);
$order = $response->getData();
$total = $response->getHeaders()['x-total-count'][0] ?? null;
A class that overrides one of the five methods declares the new parameters and the new return type.
Before:
class ShopApi extends ApiUtils
{
public static function send(
string $url,
array|string|bool|null $data = false,
string|bool|null $requestMethod = false,
array|bool|null $headers = false,
mixed &$responseHeaders = null
): mixed
{
$headers = ['Access-Token: '.static::getToken()];
return parent::send($url, $data, $requestMethod, $headers);
}
}
After:
use Festi\Core\Utils\ApiRequest;
use Festi\Core\Utils\ApiResponse;
class ShopApi extends ApiUtils
{
public static function send(string $url, ApiRequest $request): ApiResponse
{
$headers = ['Access-Token: '.static::getToken()];
$signed = new ApiRequest(
$request->getBody(),
$request->getMethod(),
$headers
);
return parent::send($url, $signed);
}
}
Proxy methods take value objects
CHANGELOG entry: updateForeignTableValues() and
getDataForUpdateForeignTableValues() of the SQL proxies and
loadRemoteListValues() and getListValuesEndpointUrl() of OpenApiProxy
take value objects.
What changed
All four methods are protected. SqlProxy gets the first two from the
trait SqlProxyRepository, and MssqlProxy overrides the second one.
| Before | Now |
|---|---|
SqlProxy::updateForeignTableValues(string $tableName, string $foreignTableName, array $foreignValues, array &$values, ?array $currentRow = null): bool\|string |
SqlProxy::updateForeignTableValues(ForeignTableValues $foreign, array &$values): bool\|string |
SqlProxy::getDataForUpdateForeignTableValues(string $tableName, string $foreignTableName, array $values, array $foreignValues, ?array $currentRow = null): array, also in MssqlProxy |
SqlProxy::getDataForUpdateForeignTableValues(ForeignTableValues $foreign): array, also in MssqlProxy |
OpenApiProxy::loadRemoteListValues(string $storeName, array $columns, array $conditions, array $orderBy, array $limit, bool $isAllColumns = false): array |
OpenApiProxy::loadRemoteListValues(string $storeName, ListValuesQuery $query): array |
OpenApiProxy::getListValuesEndpointUrl(string $baseUrl, array $columns, array $conditions, array $orderBy, array $limit): string |
OpenApiProxy::getListValuesEndpointUrl(string $baseUrl, ListValuesQuery $query): string |
The new classes are in the namespace Festi\Store\Proxy. Their values are
set by the constructor and read with getters; they have no setters:
ForeignTableValues::__construct(string $tableName, string $foreignTableName, array $foreignValues, ?array $currentRow = null)
ForeignTableValues::getTableName(): string
ForeignTableValues::getForeignTableName(): string
ForeignTableValues::getForeignValues(): array
ForeignTableValues::getCurrentRow(): ?array
ListValuesQuery::__construct(array $columns, array $conditions, array $orderBy, array $limit, bool $isAllColumns = false)
ListValuesQuery::getColumns(): array
ListValuesQuery::getConditions(): array
ListValuesQuery::getOrderBy(): array
ListValuesQuery::getLimit(): array
ListValuesQuery::isAllColumns(): bool
ForeignTableValues is what a store with several tables writes to one
foreign table: the main table of the store, the foreign table a <route>
joins to it, the values for the row of the foreign table, and the row of
the main table as it is stored now (null or empty for a new row).
ListValuesQuery is the list a store asks the remote API for.
In both SQL methods the object is the first parameter.
updateForeignTableValues() takes $values, the values of the main row,
as the second: it still receives them by reference and still writes the
key of the foreign row into them. getDataForUpdateForeignTableValues()
no longer takes the values of the main row, because the framework never
read them there: the object is its only parameter. Before, the two methods
took $values and $foreignValues in opposite orders; now
$foreignValues has one place, inside the object.
The methods do the same work as before for the same values: the same queries, the same request url, the same result and the same exceptions.
These do not change:
- The public methods of the proxies:
loadListValues(),insert(),updateByPrimaryKey()and the rest ofIProxyandIProxyRepository. - Schemas, routes and the
Store::EVENT_PREPARE_REPOSITORY_VALUESevent. - The other protected methods of the proxies, among them
appendListValuesLimitRequestParam(),appendListValuesOrderByRequestParam(),convertConditionsToRequest(),getListValuesOrderBy()andgetQueryLimit().
Who is affected
A class of the project that extends MysqlProxy, PgsqlProxy,
MssqlProxy, SqlProxy or core\store\proxy\OpenApiProxy and overrides
or calls one of the four methods. A proxy that does neither needs no
change, and code outside a proxy class cannot call them.
None of the old overrides and calls runs with a changed meaning. In each
new signature the first changed position takes an object, and PHP never
converts a string or an array to an object, with or without
strict_types=1:
| Old code | Result |
|---|---|
A class that overrides updateForeignTableValues() with the old parameters |
Fatal error: Declaration of X::updateForeignTableValues(string $tableName, string $foreignTableName, array $foreignValues, array &$values, ?array $currentRow = null): string\|bool must be compatible with SqlProxy::updateForeignTableValues(Festi\Store\Proxy\ForeignTableValues $foreign, array &$values): string\|bool, reported by the class-load check |
A class that overrides getDataForUpdateForeignTableValues() with the old parameters |
Fatal error: Declaration of X::getDataForUpdateForeignTableValues(string $tableName, string $foreignTableName, array $values, array $foreignValues, ?array $currentRow = null): array must be compatible with SqlProxy::getDataForUpdateForeignTableValues(Festi\Store\Proxy\ForeignTableValues $foreign): array; the message names MssqlProxy when X extends it |
A class that overrides loadRemoteListValues() with the old parameters |
Fatal error: Declaration of X::loadRemoteListValues(string $storeName, array $columns, array $conditions, array $orderBy, array $limit, bool $isAllColumns = false): array must be compatible with core\store\proxy\OpenApiProxy::loadRemoteListValues(string $storeName, Festi\Store\Proxy\ListValuesQuery $query): array |
A class that overrides getListValuesEndpointUrl() with the old parameters |
Fatal error: Declaration of X::getListValuesEndpointUrl(string $baseUrl, array $columns, array $conditions, array $orderBy, array $limit): string must be compatible with core\store\proxy\OpenApiProxy::getListValuesEndpointUrl(string $baseUrl, Festi\Store\Proxy\ListValuesQuery $query): string |
$this->updateForeignTableValues($tableName, $foreignTableName, $foreignValues, $values, $currentRow), also without the last argument |
TypeError: SqlProxy::updateForeignTableValues(): Argument #1 ($foreign) must be of type Festi\Store\Proxy\ForeignTableValues, string given |
The same call with a literal or an expression as the second argument, such as 'shop_settings' |
Error: SqlProxy::updateForeignTableValues(): Argument #2 ($values) could not be passed by reference |
$this->getDataForUpdateForeignTableValues($tableName, $foreignTableName, $values, $foreignValues, $currentRow), also parent:: |
TypeError: SqlProxy::getDataForUpdateForeignTableValues(): Argument #1 ($foreign) must be of type Festi\Store\Proxy\ForeignTableValues, string given; the message names MssqlProxy in a class that extends it |
$this->loadRemoteListValues($storeName, $columns, $conditions, $orderBy, $limit), also with $isAllColumns |
TypeError: core\store\proxy\OpenApiProxy::loadRemoteListValues(): Argument #2 ($query) must be of type Festi\Store\Proxy\ListValuesQuery, array given |
$this->getListValuesEndpointUrl($url, $columns, $conditions, $orderBy, $limit) |
TypeError: core\store\proxy\OpenApiProxy::getListValuesEndpointUrl(): Argument #2 ($query) must be of type Festi\Store\Proxy\ListValuesQuery, array given |
An old call passes more arguments than the new signature declares, so
none of them ends in an ArgumentCountError. The fatal error stops the
class from loading, which stops every store that uses the proxy. The
TypeError and the Error appear only when the line runs: a call to a
SQL method runs when a store with a <route> saves a field of the joined
table, and a call to an OpenApiProxy method runs when a list is loaded.
How to find it
grep -rnE --include='*.php' --exclude-dir=vendor \
'(updateForeignTableValues|getDataForUpdateForeignTableValues|loadRemoteListValues|getListValuesEndpointUrl)\(' .
Every match in a class of the project needs a change: a line with
function is an override, the other lines are calls. A copy of the
framework that is kept inside the project matches too; it is replaced by
the new version and not edited.
What to do
Import the class a file uses:
use Festi\Store\Proxy\ForeignTableValues;
use Festi\Store\Proxy\ListValuesQuery;
updateForeignTableValues(): put the first three arguments and the fifth,
in this order, into
new ForeignTableValues($tableName, $foreignTableName, $foreignValues, $currentRow)
and pass it as the first argument; $values moves from the fourth
position to the second and stays by reference.
getDataForUpdateForeignTableValues(): put the first, the second, the
fourth and the fifth argument into the same
new ForeignTableValues($tableName, $foreignTableName, $foreignValues, $currentRow)
and pass it as the only argument. The third argument, $values, is no
longer passed: the values of the main row do not reach this method. An
override that needs them also overrides updateForeignTableValues(),
which still receives $values and is the only method of the framework
that calls this one, and keeps them, for example in a property, before it
calls its parent.
Calls, before:
$result = $this->updateForeignTableValues(
$tableName,
$settingsTable,
$settings,
$values,
$currentRow
);
$data = $this->getDataForUpdateForeignTableValues(
$tableName,
$settingsTable,
$values,
$settings,
$currentRow
);
After:
$foreign = new ForeignTableValues(
$tableName,
$settingsTable,
$settings,
$currentRow
);
$result = $this->updateForeignTableValues($foreign, $values);
$data = $this->getDataForUpdateForeignTableValues($foreign);
A call that had no $currentRow leaves the fourth argument of the
constructor out.
An override declares the new parameters and reads the tables, the foreign values and the current row with the getters. The object cannot be changed: an override that changes the foreign values builds a new one for its parent.
Before:
class ShopProxy extends MysqlProxy
{
protected function updateForeignTableValues(
string $tableName,
string $foreignTableName,
array $foreignValues,
array &$values,
?array $currentRow = null
): bool|string
{
$result = parent::updateForeignTableValues(
$tableName,
$foreignTableName,
$foreignValues,
$values,
$currentRow
);
if ($foreignTableName === 'shop_settings') {
$values['mdate'] = date('Y-m-d H:i:s');
}
return $result;
}
protected function getDataForUpdateForeignTableValues(
string $tableName,
string $foreignTableName,
array $values,
array $foreignValues,
?array $currentRow = null
): array
{
$foreignValues['id_author'] = $this->getAuthorId();
return parent::getDataForUpdateForeignTableValues(
$tableName,
$foreignTableName,
$values,
$foreignValues,
$currentRow
);
}
private function getAuthorId(): int
{
return 1;
}
}
After:
use Festi\Store\Proxy\ForeignTableValues;
class ShopProxy extends MysqlProxy
{
protected function updateForeignTableValues(
ForeignTableValues $foreign,
array &$values
): bool|string
{
$result = parent::updateForeignTableValues($foreign, $values);
if ($foreign->getForeignTableName() === 'shop_settings') {
$values['mdate'] = date('Y-m-d H:i:s');
}
return $result;
}
protected function getDataForUpdateForeignTableValues(
ForeignTableValues $foreign
): array
{
$foreignValues = $foreign->getForeignValues();
$foreignValues['id_author'] = $this->getAuthorId();
$changed = new ForeignTableValues(
$foreign->getTableName(),
$foreign->getForeignTableName(),
$foreignValues,
$foreign->getCurrentRow()
);
return parent::getDataForUpdateForeignTableValues($changed);
}
private function getAuthorId(): int
{
return 1;
}
}
loadRemoteListValues(): keep $storeName and put the other arguments,
unchanged and in the same order, into
new ListValuesQuery($columns, $conditions, $orderBy, $limit, $isAllColumns).
getListValuesEndpointUrl(): keep $baseUrl and put the other four
arguments, unchanged and in the same order, into
new ListValuesQuery($columns, $conditions, $orderBy, $limit).
Calls, before:
$rows = $this->loadRemoteListValues(
'orders',
$columns,
$conditions,
$orderBy,
$limit,
true
);
$url = $this->getListValuesEndpointUrl(
$baseUrl,
$columns,
$conditions,
$orderBy,
$limit
);
After:
$query = new ListValuesQuery($columns, $conditions, $orderBy, $limit, true);
$rows = $this->loadRemoteListValues('orders', $query);
$url = $this->getListValuesEndpointUrl($baseUrl, $query);
An override declares the two parameters and reads the columns, the conditions, the order, the page and the all-columns flag with the getters.
Before:
use core\store\proxy\OpenApiProxy;
abstract class ShopApiProxy extends OpenApiProxy
{
protected function loadRemoteListValues(
string $storeName,
array $columns,
array $conditions,
array $orderBy,
array $limit,
bool $isAllColumns = false
): array
{
$conditions['id_shop'] = 7;
return parent::loadRemoteListValues(
$storeName,
$columns,
$conditions,
$orderBy,
$limit,
$isAllColumns
);
}
protected function getListValuesEndpointUrl(
string $baseUrl,
array $columns,
array $conditions,
array $orderBy,
array $limit
): string
{
$url = parent::getListValuesEndpointUrl(
$baseUrl,
$columns,
$conditions,
$orderBy,
$limit
);
return $url.'&fields='.join(',', $columns);
}
}
After:
use core\store\proxy\OpenApiProxy;
use Festi\Store\Proxy\ListValuesQuery;
abstract class ShopApiProxy extends OpenApiProxy
{
protected function loadRemoteListValues(
string $storeName,
ListValuesQuery $query
): array
{
$conditions = $query->getConditions();
$conditions['id_shop'] = 7;
$shopQuery = new ListValuesQuery(
$query->getColumns(),
$conditions,
$query->getOrderBy(),
$query->getLimit(),
$query->isAllColumns()
);
return parent::loadRemoteListValues($storeName, $shopQuery);
}
protected function getListValuesEndpointUrl(
string $baseUrl,
ListValuesQuery $query
): string
{
$url = parent::getListValuesEndpointUrl($baseUrl, $query);
return $url.'&fields='.join(',', $query->getColumns());
}
}
Exception constructors take an ExceptionContext
CHANGELOG entry: the constructors of SystemException and
FieldException take an ExceptionContext.
What changed
| Before | Now |
|---|---|
SystemException::__construct(?string $message = "", int $code = 0, mixed $data = false, mixed $source = null, string\|false\|null $displayMessage = false, ?Throwable $previous = null) |
SystemException::__construct(?string $message = "", int $code = 0, ExceptionContext $context = new ExceptionContext(), string\|false\|null $displayMessage = false, ?Throwable $previous = null) |
FieldException::__construct(string $message, ?string $selector = null, int $code = 0, mixed $data = false, mixed $source = null, ?string $displayMessage = null, ?Throwable $previous = null) |
FieldException::__construct(string $message, ?string $selector = null, int $code = 0, ExceptionContext $context = new ExceptionContext(), ?Throwable $previous = null) |
$data and $source are now one object, at the position $data had.
The new class is in the namespace Festi\Core\Exception. Its values are
set by the constructor and read with getters; it has no setters:
ExceptionContext::__construct(mixed $data = false, mixed $source = null)
ExceptionContext::getData(): ?array
ExceptionContext::getLabel(): mixed
ExceptionContext::getSource(): mixed
$data and $source mean what the third and the fourth parameter of
SystemException meant: an array given as $data is the data of the
exception, any other value is its label, and $source is the object the
exception is about, such as a store or an action.
The classes that have no constructor of their own take the same
parameters as SystemException: StoreException,
Festi\Core\Exception\StoreRuleException, MoLocaleException,
Festi\Store\Exception\InvalidStoreSchemaException, and every project
class that extends one of them without declaring a constructor.
PermissionsException declares a constructor with these same five
parameters: see
The last untyped members have native types.
FieldException has no $displayMessage parameter. The message of a
field error is the message shown to the user, as it was before when the
parameter was left out; setDisplayMessage() sets another one.
$context does not accept null. That is on purpose: with a nullable
parameter the old call new SystemException($message, 0, null, null,
$displayMessage) would run and show another message than before.
One behaviour changes. NotFoundException::__construct(?string $message =
"", int $code = 0, ?Throwable $previous = null) passed $previous to
its parent as $data: getPrevious() returned null and getLabel()
returned the exception. Now getPrevious() returns the exception and
getLabel() returns null. The signature is the same.
These do not change:
- A call with no argument, with a message, or with a message and a code:
new SystemException($message),new SystemException($message, $code). ForFieldException, a call with a message, a selector and a code. - The named arguments
message,codeandpreviousof both classes,selectorofFieldException, anddisplayMessageofSystemException:new SystemException($message, displayMessage: $message)gives the same exception as before. - The constructors of
ApiException(message, code, data, source, display message) andStoreActionException(message, action, code, previous), and what they store. - Every other method:
getData(),getLabel(),getSource(),setSource(),hasSource(),getDisplayMessage(),setDisplayMessage(),hasDisplayMessage(),getSelector(),getAction(). An exception built from the same values answers them with the same results.
Who is affected
Code that creates one of these exceptions with a third positional
argument (a fourth for FieldException), or with the named argument
data or source, and a project exception whose constructor passes
such arguments to parent::__construct().
None of the old calls runs with a changed meaning. No value an old call
could pass as $data is an ExceptionContext, so PHP stops at that
argument, with or without strict_types=1:
| Old call | Result |
|---|---|
new SystemException(), new SystemException($message), new SystemException($message, $code) |
Works as before |
new SystemException($message, displayMessage: $shown), also with a code |
Works as before |
new SystemException($message, previous: $exception) |
Works as before |
new SystemException($message, 0, false, null, $shown), also with a source and with $previous as the sixth argument |
TypeError: SystemException::__construct(): Argument #3 ($context) must be of type Festi\Core\Exception\ExceptionContext, false given (PHP 8.1 and 8.2 print bool given) |
new SystemException($message, 0, null, null, $shown, $exception) |
The same TypeError, ending in null given |
new SystemException($message, 0, $data) with an array, also followed by a source, a display message and $previous |
The same TypeError, ending in array given |
new SystemException($message, 0, 'Label') |
The same TypeError, ending in string given |
new SystemException($message, 0, $exception) |
The same TypeError, ending in the class of the exception, such as RuntimeException given |
new SystemException($message, 0, $level, $file, $line) and any other value at the third position |
The same TypeError, ending in the type of that value, such as int given |
new SystemException(message: $message, data: $label) |
Error: Unknown named parameter $data |
new SystemException($message, source: $store) |
Error: Unknown named parameter $source |
The same calls on StoreException and the other classes without a constructor of their own |
The same errors; the message names SystemException::__construct() |
The same calls on PermissionsException |
The same errors; the message names PermissionsException::__construct() |
parent::__construct($message, 0, false, null, $message) in a project exception |
The same TypeError, when the exception is created |
parent::__construct($message, $code, $previous) in a project exception |
The same TypeError, ending in null given when there is no previous exception |
parent::__construct($message, displayMessage: $message) in a project exception |
Works as before |
new FieldException($message), new FieldException($message, $selector), new FieldException($message, $selector, $code) |
Works as before |
new FieldException($message, $selector, previous: $exception) |
Works as before |
new FieldException($message, $selector, 0, false) and every longer call |
TypeError: FieldException::__construct(): Argument #4 ($context) must be of type Festi\Core\Exception\ExceptionContext, false given (PHP 8.1 and 8.2 print bool given); it ends in null given, array given or string given for these values |
new FieldException($message, $selector, displayMessage: $shown) |
Error: Unknown named parameter $displayMessage |
new FieldException($message, $selector, data: $data), also source: |
Error: Unknown named parameter $data, or $source |
new ApiException($message, $code, $data, $source, $shown) and every shorter call |
Works as before |
new StoreActionException($message, $action, $code, $previous) and every shorter call |
Works as before |
new NotFoundException($message, $code, $exception) |
Works; $exception is now the previous exception and no longer the label |
PHP adds , called in <file> on line <N> to each TypeError.
A constructor is not compared with the constructor of its parent, so the class-load check does not report a project exception that declares the old parameters. Each error appears only when the line runs, which is when the exception is created. These lines are error paths that tests often do not reach, so use the script below and do not wait for the error.
How to find it
Save this as exception-calls.php in the project root:
<?php
// Usage: php exception-calls.php <dir> [<dir> ...]
// Prints every call of the SystemException or FieldException constructor
// that the new signatures refuse: `new X(...)` and `parent::__construct(...)`
// with a third positional argument (a fourth for FieldException) or with
// the named argument data or source (also displayMessage for
// FieldException). X is one of the two classes or a class that inherits
// its constructor. A call whose argument at that position is written
// `new ExceptionContext(...)` or `$context` is taken as changed already.
const NAMES = [T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_STATIC];
const OPEN = [T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES, T_ATTRIBUTE];
const SKIP = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];
function shortName(string $name): string
{
return substr('\\'.$name, strrpos('\\'.$name, '\\') + 1);
}
function text(mixed $token): string
{
return is_array($token) ? $token[1] : $token;
}
function isToken(mixed $token, int ...$kinds): bool
{
return is_array($token) && in_array($token[0], $kinds, true);
}
function readArguments(array $tokens, int $index): array
{
$arguments = [];
$current = '';
$depth = 0;
for ($count = count($tokens); $index < $count; $index++) {
$token = $tokens[$index];
$text = text($token);
$isOpen = isToken($token, ...OPEN) ||
in_array($token, ['(', '[', '{'], true);
$isClose = in_array($token, [')', ']', '}'], true);
$depth += $isOpen ? 1 : ($isClose ? -1 : 0);
if ($depth === 0) {
break;
}
if ($text === ',' && $depth === 1) {
$arguments[] = $current;
$current = '';
} else if ($depth > 1 || !$isOpen) {
$current .= $text;
}
}
if ($current !== '') {
$arguments[] = $current;
}
return $arguments;
}
function readSource(string $path, array &$classes, array &$calls): void
{
$source = file_get_contents($path);
if (stripos($source, 'Exception') === false) {
return;
}
$isCode = fn($token) => !isToken($token, ...SKIP);
$tokens = array_values(array_filter(token_get_all($source), $isCode));
$tokens = array_merge($tokens, ['', '', '', '']);
$class = null;
foreach ($tokens as $index => $token) {
$next = $tokens[$index + 1] ?? '';
$after = $tokens[$index + 2] ?? '';
$before = $tokens[$index - 1] ?? '';
if (isToken($token, T_CLASS) && isToken($next, T_STRING) &&
!isToken($before, T_DOUBLE_COLON)) {
$class = $next[1];
$parent = isToken($after, T_EXTENDS) ? $tokens[$index + 3] : '';
$classes[$class] = [shortName(text($parent)), false];
} else if (isToken($token, T_FUNCTION) && $class !== null &&
strtolower(text($next)) === '__construct') {
$classes[$class][1] = true;
} else if (isToken($token, T_NEW) && isToken($next, ...NAMES) &&
$after === '(') {
$name = shortName($next[1]);
$isOwn = in_array(strtolower($name), ['self', 'static'], true);
$calls[] = [
$isOwn ? $class : $name,
readArguments($tokens, $index + 2),
$path.':'.$token[2].' new '.$next[1].'(...)',
];
} else if (strtolower(text($token)) === 'parent' && $class !== null &&
isToken($next, T_DOUBLE_COLON) &&
strtolower(text($after)) === '__construct') {
$calls[] = [
$classes[$class][0],
readArguments($tokens, $index + 3),
$path.':'.$token[2].' parent::__construct(...) in '.$class,
];
}
}
}
function findConstructor(?string $class, array $classes): ?string
{
$framework = [
'SystemException' => 'SystemException',
'StoreException' => 'SystemException',
'PermissionsException' => 'SystemException',
'StoreRuleException' => 'SystemException',
'MoLocaleException' => 'SystemException',
'InvalidStoreSchemaException' => 'SystemException',
'FieldException' => 'FieldException',
];
for ($depth = 0; $class && $depth < 20; $depth++) {
if (isset($framework[$class])) {
return $framework[$class];
}
if (!isset($classes[$class]) || $classes[$class][1]) {
return null;
}
$class = $classes[$class][0];
}
return null;
}
function findReason(string $constructor, array $arguments): ?string
{
$isField = $constructor === 'FieldException';
$refused = $isField ? 'data|source|displayMessage' : 'data|source';
$limit = $isField ? 3 : 2;
$changed = '/^(new[A-Za-z_\\\\]*ExceptionContext\b|\$context$)/';
$positional = 0;
foreach ($arguments as $argument) {
if (preg_match('/^('.$refused.'):(?!:)/', $argument, $match)) {
return 'named argument '.$match[1];
}
if (!preg_match('/^[A-Za-z_]+:(?!:)/', $argument)) {
$positional++;
}
}
if ($positional <= $limit || preg_match($changed, $arguments[$limit])) {
return null;
}
return $positional.' positional arguments';
}
$classes = [];
$calls = [];
foreach (array_slice($argv, 1) as $dir) {
if (!is_dir($dir)) {
echo 'Skipped, not a directory: ', $dir, "\n";
continue;
}
$files = new RecursiveIteratorIterator(
new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
function (SplFileInfo $file): bool {
$skipped = ['vendor', 'node_modules', '.git'];
if ($file->isDir()) {
return !$file->isLink() &&
!in_array($file->getFilename(), $skipped, true);
}
return $file->getExtension() === 'php';
}
)
);
foreach ($files as $file) {
readSource($file->getPathname(), $classes, $calls);
}
}
$found = 0;
foreach ($calls as [$class, $arguments, $place]) {
$constructor = findConstructor($class, $classes);
$reason = $constructor ? findReason($constructor, $arguments) : null;
if ($reason !== null) {
$found++;
echo $place, ' ', $constructor, ': ', $reason, "\n";
}
}
echo $found, " calls to change\n";
exit($found ? 1 : 0);
Run it with the directories that hold project code:
php exception-calls.php plugins src tests
It prints one line per call that needs a change and ends with the number
of calls. It reads new X(...), new self(...), new static(...) and
parent::__construct(...), also when the call is written on several
lines, and follows the project classes that extend an exception without
declaring a constructor. It skips vendor and node_modules. A copy of
the framework that is kept inside the project is reported too; it is
replaced by the new version and not edited. A call that unpacks an array
(new SystemException(...$arguments)) or creates the class from a
variable (new $class(...)) is not found. A call is taken as changed
when its third argument (the fourth for FieldException) is written
new ExceptionContext(...) or $context, so the script prints
0 calls to change when the work is done.
To list the project exceptions that declare a constructor of their own:
grep -rlE --include='*.php' --exclude-dir=vendor \
'extends +[A-Za-z_\\]*Exception\b' . \
| xargs grep -lE 'function +__construct'
Such a class needs a change only when the script reports its
parent::__construct() call. Its own parameters can stay as they are.
What to do
Import the class a file uses:
use Festi\Core\Exception\ExceptionContext;
SystemException and the classes that share its constructor: put the
third and the fourth argument into new ExceptionContext($data, $source)
and pass it as the third argument. The display message moves from the
fifth position to the fourth and $previous from the sixth to the fifth.
Before:
throw new SystemException(
$message,
$code,
$data,
$store,
$displayMessage,
$exception
);
After:
throw new SystemException(
$message,
$code,
new ExceptionContext($data, $store),
$displayMessage,
$exception
);
A call that passed false, null only to reach the display message names
the argument and needs no ExceptionContext. A call that passed
false, null, null or false, null, false only to reach $previous
does the same with previous.
Before:
throw new SystemException($message, 0, false, null, $displayMessage);
throw new SystemException($message, 0, false, null, null, $exception);
After:
throw new SystemException($message, displayMessage: $displayMessage);
throw new SystemException($message, previous: $exception);
A call with a label, or with an array of data, as the third argument wraps it.
Before:
throw new SystemException($message, 0, 'Something went wrong.');
throw new SystemException($message, 0, $data);
After:
throw new SystemException(
$message,
0,
new ExceptionContext('Something went wrong.')
);
throw new SystemException($message, 0, new ExceptionContext($data));
A call that passed an exception as the third argument stored it as the
label and did not chain it. Pass it as previous to chain it, which is
what such a call was written for; wrap it in an ExceptionContext to
keep it as the label.
Before:
throw new SystemException($message, 0, $exception);
After:
throw new SystemException($message, previous: $exception);
The named arguments data and source become one context argument.
Before:
throw new SystemException(message: $message, data: $label);
After:
throw new SystemException(
message: $message,
context: new ExceptionContext($label)
);
A project exception changes the call to its parent by the same rules. Its own parameters do not change, so the code that creates it does not change either.
Before:
class DisplayUserException extends SystemException
{
public function __construct(string $message = "")
{
parent::__construct($message, 0, false, null, $message);
}
}
class ModuleRequiredException extends SystemException
{
public function __construct(
string $message,
int $code = 0,
?Throwable $previous = null
)
{
parent::__construct($message, $code, $previous);
}
}
After:
class DisplayUserException extends SystemException
{
public function __construct(string $message = "")
{
parent::__construct($message, displayMessage: $message);
}
}
class ModuleRequiredException extends SystemException
{
public function __construct(
string $message,
int $code = 0,
?Throwable $previous = null
)
{
parent::__construct($message, $code, previous: $previous);
}
}
FieldException: put the fourth and the fifth argument into
new ExceptionContext($data, $source) and pass it as the fourth
argument; $previous moves from the seventh position to the fifth. The
display message leaves the constructor: set it with
setDisplayMessage() when it differs from the message.
Before:
throw new FieldException(
$message,
'#email',
0,
$data,
$store,
$displayMessage,
$exception
);
After:
$fieldException = new FieldException(
$message,
'#email',
0,
new ExceptionContext($data, $store),
$exception
);
$fieldException->setDisplayMessage($displayMessage);
throw $fieldException;
With an empty display message getDisplayMessage() returns the message,
as it did before.
NotFoundException: no change is needed. Code that read the cause of a
NotFoundException from getLabel() reads it from getPrevious().
StoreModel keeps the schema parts in a StoreSchemaParts
CHANGELOG entry: StoreModel keeps the thirteen parts of a schema in a
StoreSchemaParts.
What changed
Thirteen protected properties of StoreModel are gone. Their arrays are
the parts of one object, which the model holds in a new protected
property:
StoreModel: protected Festi\Store\Model\StoreSchemaParts $parts
| Before | Now, inside a model class |
|---|---|
$this->fields |
$this->parts[StoreSchemaParts::FIELDS] |
$this->actions |
$this->parts[StoreSchemaParts::ACTIONS] |
$this->relations |
$this->parts[StoreSchemaParts::RELATIONS] |
$this->grouped |
$this->parts[StoreSchemaParts::GROUPED] |
$this->filters |
$this->parts[StoreSchemaParts::FILTERS] |
$this->sections |
$this->parts[StoreSchemaParts::SECTIONS] |
$this->externalValues |
$this->parts[StoreSchemaParts::EXTERNAL_VALUES] |
$this->routers |
$this->parts[StoreSchemaParts::ROUTERS] |
$this->search |
$this->parts[StoreSchemaParts::SEARCH] |
$this->listeners |
$this->parts[StoreSchemaParts::LISTENERS] |
$this->highlights |
$this->parts[StoreSchemaParts::HIGHLIGHTS] |
$this->aggregations |
$this->parts[StoreSchemaParts::AGGREGATIONS] |
$this->rules |
$this->parts[StoreSchemaParts::RULES] |
The value of each constant is the old property name: 'fields',
'actions', and so on to 'rules'. The new class is in the namespace
Festi\Store\Model:
StoreSchemaParts implements ArrayAccess
StoreSchemaParts::__construct()
StoreSchemaParts::offsetExists(mixed $offset): bool
StoreSchemaParts::offsetGet(mixed $offset): array (returns a reference)
StoreSchemaParts::offsetSet(mixed $offset, mixed $value): void
StoreSchemaParts::offsetUnset(mixed $offset): void
StoreSchemaParts::getNames(): array (static)
StoreSchemaParts::isPartName(string $name): bool (static)
The parts are read and written with array access, by part name, which
is what calls the four offset methods:
| Code | Result |
|---|---|
$this->parts[StoreSchemaParts::FIELDS] |
The part, an array |
$this->parts[StoreSchemaParts::FIELDS] = $fields; |
Replaces the part |
$this->parts[StoreSchemaParts::FIELDS][$name] = $field; |
Changes one item of the part |
unset($this->parts[StoreSchemaParts::FIELDS][$name]); |
Removes one item of the part |
$fields = &$this->parts[StoreSchemaParts::FIELDS]; |
Reference to the part: a write to $fields changes the model |
$fields = $this->parts[StoreSchemaParts::FIELDS]; |
A copy: a write to $fields does not change the model |
isset($this->parts[$name]) |
true for each of the thirteen names, also when the part is empty; false for any other name |
$this->parts['columns'], $this->parts['columns'] = []; |
SystemException: Undefined schema part: columns |
$this->parts[] = $part; |
SystemException: Undefined schema part: null |
$this->parts[StoreSchemaParts::FIELDS] = null; |
TypeError: Schema part "fields" must be of type array, null given |
unset($this->parts[StoreSchemaParts::FIELDS]); |
SystemException: Schema part "fields" cannot be removed: assign an empty array to clear it |
A new model holds every part as an empty array, as the properties did.
StoreModel also has the methods __get(), __set(), __isset() and
__unset() now. They refuse the thirteen names (see "Who is affected")
and keep any other property a model class does not declare where it was
kept before: as an item of the model, which is an ArrayObject.
For PHP to call these methods the model no longer answers for
properties as an ArrayObject with ARRAY_AS_PROPS does. That changes
what the three properties without a default value are. $store,
$primaryKey and the private $_name were items of the ArrayObject
and are ordinary properties now:
| Code | Before | Now |
|---|---|---|
count($model) after load() |
3 |
0 |
$model['primaryKey'], outside the model |
The key | null with Warning: Undefined array key "primaryKey" |
$model->primaryKey, outside the model |
The key | SystemException: Cannot access property X::$primaryKey |
$model->primaryKey = 'id';, outside the model |
Changed the key of the model | The same exception |
$this->_name in a class that extends the model |
The name of the store | SystemException: Cannot access property X::$_name |
$model->attributes, outside the model |
null with Warning: Undefined array key "attributes" |
SystemException: Cannot access property X::$attributes |
getName() or getPrimaryKey() before the model has a name or a key |
TypeError: StoreModel::getName(): Return value must be of type string, null returned |
Error: Typed property StoreModel::$_name must not be accessed before initialization |
Use getPrimaryKey(), getName(), setName() and getAttributes()
for these values.
These do not change:
- Every public method of
StoreModel, with its name, its parameters and its result:getFields(),getField(),getFieldByName(),getFieldByOption(),getFieldByElementName(),removeFieldByName(),createFieldInstance(),getActions(),getAction(),getActionByRef(),hasAction(),removeAction(),getGroupActions(),getRelation(),getFilters(),getSections(),getExternalValues(),getRouters(),getSearch(),getHighlights(),getAggregations(),getRules(),getOptions(),getOption(),getAttributes(),get(),set()and the others. - The methods that return a reference still return one. Code such as
$filters = &$store->getModel()->getFilters(); $filters['status'] = 'active';changes the model as before. - The protected properties
$attributes,$options,$primaryKey,$charset,$storeand$stream, and every protected method. - The model an XML file, an array or a
ClassSchemais loaded into: the same parts with the same keys in the same order, and the same exceptions for a schema with a mistake.
One message changes. An array schema without the key actions, or with a
part that is not an array, was refused with TypeError: Cannot assign
null to property StoreModel::$actions of type array. It is refused with
TypeError: Schema part "actions" must be of type array, null given.
Who is affected
A project class that extends StoreModel, XmlStoreModel,
ArrayStoreModel or ClassStoreModel and uses one of the thirteen
properties, or declares a property with one of these names. Code that
only calls the public methods of a model, which is what stores, actions,
proxies and plugins do, is not affected.
A model class that still uses one of the names is stopped with an exception. The message is the same on PHP 8.1, 8.2 and 8.3:
| Code in the model class | Result |
|---|---|
$this->actions = []; |
SystemException: X::$actions is not a property of the store model: the schema part is in $this->parts, read it with $this->parts['actions'] and replace it with $this->parts['actions'] = $part |
$this->fields[$name] = $field;, $this->rules[] = $rule; |
The same exception, with the name of the part |
$fields = $this->fields;, foreach ($this->fields as $field) |
The same exception |
isset($this->fields[$name]), empty($this->filters) |
The same exception |
unset($this->fields[$name]);, unset($this->rules); |
The same exception |
protected array $filters = [...]; declared in the class |
The same exception, when the model is created |
$this->parts['columns'] |
SystemException: Undefined schema part: columns |
A constructor that does not call parent::__construct($store), or a model created without its constructor, as a partial mock is |
Error: Typed property StoreModel::$parts must not be accessed before initialization, at the first use of a part |
X is the class of the model. The exception is thrown when the line
runs, which for a line in load() is when the store is created.
Without these checks PHP would say nothing, on any version. A model is
an ArrayObject that keeps a property its class does not declare as one
of its items, so $this->fields[$name] = $field would be kept in an
item nothing reads, with no warning and no deprecation notice, and the
store would have no fields.
Code outside a model that reads $model->primaryKey, or another
protected property, or an item of the model such as
$model['primaryKey'], is affected by the second table of
"What changed".
Two things change for a property of a model class that is not declared and is not one of the thirteen names. They concern only code that has a mistake already:
- A read before the first write gave
Warning: Undefined array key "name". It givesnullwithout a warning. - After such a read,
count($model)and a loop over the model include the name, with the valuenull.
How to find it
Run this in the project root. It prints the lines of every class that extends a store model and uses or declares one of the thirteen names:
grep -rlE --include='*.php' --exclude-dir=vendor \
'extends +\\?(Xml|Array|Class)?StoreModel([^A-Za-z0-9_]|$)' . \
| while read -r file; do
grep -nHE \
-e '\$this->(fields|actions|relations|grouped|filters|sections|externalValues|routers|search|listeners|highlights|aggregations|rules)([^A-Za-z0-9_(]|$)' \
-e '(public|protected|private|var) [^;=(]*\$(fields|actions|relations|grouped|filters|sections|externalValues|routers|search|listeners|highlights|aggregations|rules)([^A-Za-z0-9_]|$)' \
"$file"
done
No output means no work. The first grep alone lists the model classes
of the project. A class that extends a model class of the project and
not a framework one is not listed: run the second grep on it by hand.
The command does not find a property whose name is in a variable
($this->$name); the exception above reports that one.
In the framework before this change the command prints 18 lines of
ArrayStoreModel, 7 of XmlStoreModel and the one line of the test
model shown below. In the projects and shared plugins it was run on, no
class extends a store model, so it printed nothing.
This one prints the code that reads a property or an item of a model from outside:
grep -rnE --include='*.php' --exclude-dir=vendor \
-e 'getModel\(\)->[A-Za-z_]+([^A-Za-z_(]|$)' \
-e 'getModel\(\)\[' \
-e '[mM]odel->(primaryKey|store|_name|attributes|options|charset|stream)([^A-Za-z_(]|$)' .
The last pattern also matches a class of the project that is called a model and is not a store model. In the framework the command prints only the two lines of the test of this exception, and it printed nothing in the same projects.
What to do
Read a part with $this->parts[$name] and replace it with
$this->parts[$name] = $part. To add to a part or to change an item,
write to the item: $this->parts[$name][$key] = $value.
| Before | After |
|---|---|
$this->actions = $actions; |
$this->parts[StoreSchemaParts::ACTIONS] = $actions; |
$this->fields[$name] = $field; |
$this->parts[StoreSchemaParts::FIELDS][$name] = $field; |
unset($this->actions[$name]); |
$this->removeAction($name); |
foreach ($this->fields as $field) |
foreach ($this->getFields() as $field) |
isset($this->fields[$name]) |
$this->getField($name) !== null |
protected array $filters = ['status' => 'active']; |
Remove the declaration and run $this->parts[StoreSchemaParts::FILTERS] = $filters; in load() |
The public methods of the model work inside a model class too, and they are the shortest replacement for a read.
Before:
class ProductsStoreModel extends StoreModel
{
public function load(): void
{
$values = [
'name' => 'products',
'primaryKey' => 'id',
];
$this->attributes = $this->getExtendData(
$values,
$this->getAttributesOptions(),
$errors
);
$this->setName($this->attributes['name']);
$this->primaryKey = $this->attributes['primaryKey'];
$attributes = [
'type' => 'text',
'name' => 'caption',
'caption' => 'Caption',
];
$field = $this->createFieldInstance(
new ArrayFieldModel($attributes),
0
);
$this->fields[$field->getName()] = $field;
$this->actions = [
Store::ACTION_LIST => [
'type' => Store::ACTION_LIST,
'caption' => 'List',
],
];
$this->doPrepareActions();
$this->filters['caption'] = 'welcome';
}
}
After:
use Festi\Store\Model\StoreSchemaParts;
class ProductsStoreModel extends StoreModel
{
public function load(): void
{
$values = [
'name' => 'products',
'primaryKey' => 'id',
];
$this->attributes = $this->getExtendData(
$values,
$this->getAttributesOptions(),
$errors
);
$this->setName($this->attributes['name']);
$this->primaryKey = $this->attributes['primaryKey'];
$attributes = [
'type' => 'text',
'name' => 'caption',
'caption' => 'Caption',
];
$field = $this->createFieldInstance(
new ArrayFieldModel($attributes),
0
);
$this->parts[StoreSchemaParts::FIELDS][$field->getName()] = $field;
$actions = [
Store::ACTION_LIST => [
'type' => Store::ACTION_LIST,
'caption' => 'List',
],
];
$this->parts[StoreSchemaParts::ACTIONS] = $actions;
$this->doPrepareActions();
$filters = &$this->parts[StoreSchemaParts::FILTERS];
$filters['caption'] = 'welcome';
}
}
tests/Resources/LegacyModel/SchemaPartsCustomStoreModel.php is this
model in the framework, and
tests/Resources/LegacyModel/LegacyCustomStoreModel.php is the model
that only had the line $this->actions = [];.
A test that creates a model without its constructor and then reads a part must call the constructor, which creates the parts. A store mock is enough for it:
$store = $this->createMock(Store::class);
$model = new ProductsStoreModel($store);
Store keeps its pagination and its components in two objects
CHANGELOG entry: Store keeps the page and the order of its list in a
StorePagination and its model, view, proxy and rules manager in a
StoreComponents.
What changed
Nine protected properties of Store are gone. Their values are in two
objects, which the store holds in two new protected properties:
Store: protected Festi\Store\StorePagination $pagination
Store: protected Festi\Store\StoreComponents $components
| Before | Now, inside a store class |
|---|---|
$this->rowsPerPage |
$this->pagination->getRowsPerPage(), setRowsPerPage(int $rowsPerPage) |
$this->currentPage |
$this->pagination->getCurrentPage(), setCurrentPage(?int $currentPage) |
$this->totalRows |
$this->pagination->getTotalRows(), setTotalRows(?int $totalRows) |
$this->orderByFieldName |
$this->pagination->getOrderByFieldName(), setOrderByFieldName(?string $orderByFieldName) |
$this->orderByDirection |
$this->pagination->getOrderByDirection(), setOrderByDirection(?string $orderByDirection) |
$this->model |
$this->components->model |
$this->view |
$this->components->view |
$this->proxy |
$this->components->proxy |
$this->rulesManager |
$this->components->rulesManager |
Both classes are in the namespace Festi\Store. A StorePagination
has public methods, a StoreComponents has public typed properties:
StorePagination::getRowsPerPage(): int
StorePagination::setRowsPerPage(int $rowsPerPage): void
StorePagination::getCurrentPage(): ?int
StorePagination::setCurrentPage(?int $currentPage): void
StorePagination::getTotalRows(): ?int
StorePagination::setTotalRows(?int $totalRows): void
StorePagination::getOrderByFieldName(): ?string
StorePagination::setOrderByFieldName(?string $orderByFieldName): void
StorePagination::getOrderByDirection(): ?string
StorePagination::setOrderByDirection(?string $orderByDirection): void
StoreComponents: public StoreModel $model
StoreComponents: public StoreView $view
StoreComponents: public IProxy $proxy
StoreComponents: public ?Festi\Store\Rule\IRulesManager $rulesManager = null
A StorePagination keeps what it is given and resolves nothing. A value
the store has not resolved yet is 0 for the rows per page and null
for the other four, as the properties were; $currentPage had no value
at all before the first setCurrentPageIndex() and is null now. The
store still resolves each value from the request, the session and the
model in getRowsPerPageCount(), getTotalCount(),
getCurrentPageIndex(), getOrderByFieldName() and
getOrderByDirection(), and keeps the result in the pagination.
The store sets the model, the view and the proxy of its
StoreComponents in its constructor, in that order, and the rules
manager in onInit(). $this->components->rulesManager is null
until then, where isset($this->rulesManager) was false. A read of
$model, $view or $proxy of the components before the store has set
it is stopped by PHP with an Error: Typed property
Festi\Store\StoreComponents::$model must not be accessed before
initialization, or Cannot access uninitialized non-nullable property
Festi\Store\StoreComponents::$model by reference when the read is
getModel() of the store. Each of the four properties accepts only a
value of its type: PHP throws a TypeError for any other, also when the
value is assigned through a reference.
A new class Festi\Store\StoreComponentsFactory creates the model, the
view, the proxy and the rules manager. The private methods of Store
that did it are gone; a project class could not call or override them.
Store also has the methods __get(), __set(), __isset(),
__unset() and __clone() now. The first four refuse the nine names
(see "Who is affected") and leave every other property a store class
does not declare where PHP keeps it: in a property of the object.
__clone() gives a clone its own StorePagination and its own
StoreComponents, so cloneInstance() returns what it returned before:
a store that shares the model, the view and the proxy objects with the
original and changes its page, or replaces a component, without the
original.
These do not change:
- Every public method of
Store,PluginStoreandIStore, with its name, its parameters and its result, among themgetModel(),getView(),getProxy(),getRowsPerPageCount(),setRowsPerPageCount(),getTotalCount(),setTotalCount(),getCurrentPageIndex(),setCurrentPageIndex(),getOrderByFieldName(),getOrderByDirection()andcloneInstance(). getModel(),getView()andgetProxy()still return a reference. Code such as$proxy = &$store->getProxy(); $proxy = $other;replaces the proxy of the store as before.- The protected properties
$ident,$session,$connection,$request,$coreand$parentFieldName, and the protected methodsonInit()andonPrepareOptions().$pluginandgetRulesManager()change in Store keeps its plugin in its components. - A store is an
ArrayObjectwithoutARRAY_AS_PROPS, as it was:getFlags()is0,count($store)is0, a property is not an item, and$store['key'], a loop over the store andgetArrayCopy()see only the items written with$store['key'] = ....
Who is affected
A project class that extends Store, PluginStore or a store class of
the project and uses one of the nine properties, or declares a property
with one of these names. Code that only calls the public methods of a
store, which is what plugins, actions, proxies and listeners do, is not
affected.
A store class that still uses one of the names is stopped with an exception. The message is the same on PHP 8.1, 8.2 and 8.3:
| Code in the store class | Result |
|---|---|
$this->orderByFieldName = 'name'; |
SystemException: X::$orderByFieldName is not a property of the store: the value is in $this->pagination, read it with $this->pagination->getOrderByFieldName() and set it with $this->pagination->setOrderByFieldName($value) |
$this->proxy->loadForeignKeyValues($field); |
SystemException: X::$proxy is not a property of the store: the value is in $this->components, read it and set it as $this->components->proxy |
$total = $this->totalRows;, $this->currentPage++;, $this->model->getFields() |
The exception of the row above that names the object of the property: the first for the page and the order, the second for a component |
isset($this->rulesManager), empty($this->view) |
The same exception |
unset($this->rowsPerPage); |
The same exception |
protected int $rowsPerPage = 50; declared in the class |
The same exception, when the store is created |
$store->model, $store->rowsPerPage = 5;, isset($store->proxy), outside the store |
The same exception. Before it was Error: Cannot access protected property X::$model, and false for isset() |
A constructor that uses $this->pagination or $this->components before parent::__construct(), or a store created without its constructor, as a partial mock is |
Error: Typed property Store::$pagination must not be accessed before initialization (or $components), at the first use |
X is the class of the store. The exception is thrown when the line
runs.
Without these checks PHP would not stop such a class. Measured with the same class and the checks removed:
| Code in the store class | PHP 8.1 | PHP 8.2 and 8.3 |
|---|---|---|
$this->orderByFieldName = 'name'; |
Nothing. The value is kept in a new property nothing reads, and the list keeps its order | The same, with Deprecated: Creation of dynamic property X::$orderByFieldName is deprecated |
$total = $this->totalRows; |
null with Warning: Undefined property: X::$totalRows |
The same |
$this->proxy->loadForeignKeyValues($field); |
The same warning and Error: Call to a member function loadForeignKeyValues() on null |
The same |
isset($this->rulesManager), empty($this->view) |
false and true, no message |
The same |
protected int $rowsPerPage = 50; declared in the class |
Nothing. The store is created and never reads the property | The same |
The four methods also change what PHP does for other properties of a store. Each of these concerns code that has a mistake already or that is unusual. The third and the fourth were searched for in the store classes of the projects the command below was run on, and not found; a search cannot find the first:
| Code | Before | Now |
|---|---|---|
A read of a property that is not declared and was never written, such as $this->cahce |
null with Warning: Undefined property: X::$cahce |
null. PHP 8.1 gives no message; PHP 8.2 and 8.3 give Deprecated: Creation of dynamic property X::$cahce is deprecated. The object has the property afterwards, with the value null |
$store->ident, $store->ident = 'x';, unset($store->ident);, outside the store; the same for any other protected or private property |
Error: Cannot access protected property X::$ident |
Error: Cannot access property X::$ident |
$this->_name or $this->_options in a class that extends Store, which are private properties of Store and its traits |
A read gave null with Warning: Undefined property; a write created a second property of that name on the object |
Error: Cannot access property X::$_name |
unset($this->cache); on a property the class declares, and then $this->cache = $value; or a read of it |
The write set the property again; the read gave null with a warning |
Error: Cannot access property X::$cache |
A store created without its constructor, then setRowsPerPageCount() |
Set the value | Error: Typed property Store::$pagination must not be accessed before initialization |
A write to a property that is not declared, $this->list[] = $value
on such a property, isset() and unset() of it behave as before,
with the same Deprecated: Creation of dynamic property message on
PHP 8.2 and 8.3 for a class without #[AllowDynamicProperties].
How to find it
Run this in the project root. It prints the lines of every class that extends a store and uses or declares one of the nine names:
grep -rlE --include='*.php' --exclude-dir=vendor \
'extends +[A-Za-z0-9_\\]*Store[A-Za-z0-9_]*' . \
| while read -r file; do
grep -nHE \
-e '\$this->(rowsPerPage|currentPage|totalRows|orderByFieldName|orderByDirection|model|view|proxy|rulesManager)([^A-Za-z0-9_(]|$)' \
-e '(public|protected|private|var) [^;=(]*\$(rowsPerPage|currentPage|totalRows|orderByFieldName|orderByDirection|model|view|proxy|rulesManager)([^A-Za-z0-9_]|$)' \
"$file"
done
No output means no work. The first grep takes every class whose parent
has Store in its name, so that a class that extends a store class of
the project is found too. It also takes a class that extends
StoreModel, StoreView or StoreProxy; such a class has a $model or
a $view of its own and is not affected. A store class whose parent has
no Store in its name is not listed: run the second grep on it by
hand. The command does not find a property whose name is in a variable
($this->$name); the exception above reports that one.
In the framework the command prints the line of the test store that
declares $proxy and twelve lines of SqlProxy, which is a proxy and
uses its own $model. In the fourteen projects and shared plugins it
was run on, its first grep lists 676 files and the command prints
three lines in two classes: one class that sets
$this->orderByFieldName and $this->orderByDirection, and one that
calls $this->proxy->loadForeignKeyValues().
What to do
For the model, the view and the proxy use the public methods of the
store, which are the shortest replacement for a read. For the page and
the order use $this->pagination.
| Before | After |
|---|---|
$this->model->getFields() |
$this->getModel()->getFields() |
$this->proxy->loadForeignKeyValues($field) |
$this->getProxy()->loadForeignKeyValues($field) |
$this->view->fetch($template) |
$this->getView()->fetch($template) |
$this->proxy = $proxy; |
$this->components->proxy = $proxy; |
isset($this->rulesManager) |
$this->components->rulesManager !== null |
$this->rulesManager->createRule($type) |
$this->components->rulesManager->createRule($type) |
$this->orderByFieldName = $expression; |
$this->pagination->setOrderByFieldName($expression); |
$this->orderByDirection = 'ASC'; |
$this->pagination->setOrderByDirection('ASC'); |
$this->rowsPerPage = 50; |
$this->setRowsPerPageCount(50); |
$this->totalRows = $total; |
$this->pagination->setTotalRows($total);, or $this->setTotalCount($total); to save it in the session too |
$this->totalRows |
$this->pagination->getTotalRows() for the kept value, $this->getTotalCount() to resolve it |
protected int $rowsPerPage = 50; |
Remove the declaration and call $this->pagination->setRowsPerPage(50); in onInit() |
setOrderByFieldName() and setOrderByDirection() are the only way to
give a store an order that is not a field of its model, as a write to
the two properties was: getOrderByFieldName() returns the value as it
is and does not check it.
Before:
class CandidatesStore extends Store
{
protected int $rowsPerPage = 50;
public function orderByRelevance(array $ids): void
{
$this->orderByFieldName = 'FIELD(id, '.implode(', ', $ids).')';
$this->orderByDirection = 'ASC';
$this->totalRows = count($ids);
}
public function loadGrades(IStoreForeignField $field): bool
{
if (!$this->model->getFieldByName($field->getName())) {
return false;
}
return $this->proxy->loadForeignKeyValues($field);
}
public function isFirstPage(): bool
{
return !isset($this->currentPage) || $this->currentPage === 1;
}
}
After:
class CandidatesStore extends Store
{
protected function onInit(): void
{
parent::onInit();
$this->pagination->setRowsPerPage(50);
}
public function orderByRelevance(array $ids): void
{
$this->pagination->setOrderByFieldName(
'FIELD(id, '.implode(', ', $ids).')'
);
$this->pagination->setOrderByDirection('ASC');
$this->pagination->setTotalRows(count($ids));
}
public function loadGrades(IStoreForeignField $field): bool
{
if (!$this->getModel()->getFieldByName($field->getName())) {
return false;
}
return $this->getProxy()->loadForeignKeyValues($field);
}
public function isFirstPage(): bool
{
$page = $this->pagination->getCurrentPage();
return $page === null || $page === 1;
}
}
tests/Resources/store/RelevanceOrderStore.php is a store of this kind
in the framework, and tests/Resources/store/LegacyPropertyStore.php is
the one every method of which is stopped.
A test that creates a store without its constructor and then calls a method that uses the page, the order, the model, the view or the proxy must create the store with its constructor. A connection mock that names its database type is enough for a store with an XML schema:
$connection = $this->createMock(IDataAccessObject::class);
$connection->method('getDatabaseType')->willReturn('mysql');
$options = [];
$store = new CandidatesStore($connection, 'candidates', $options);
AbstractAction::getStore() returns Store
CHANGELOG entry: AbstractAction builds its url through an
ActionUrlBuilder and handles an exception through an
ActionExceptionHandler.
What changed
AbstractAction::getStore() declares the class of the store it returns,
which is the class of the store an action is created with:
before: public function &getStore(): IStore
now: public function &getStore(): Store
IStoreAction::getStore() still declares IStore. getUrl(),
doHandleException() and every other method of AbstractAction keep
their signatures and do what they did.
Who is affected
A project class that extends AbstractAction, or an action that extends
it (InsertAction, EditAction, RemoveAction, InfoAction,
ListAction, PluginAction and the others), and overrides getStore()
with the return type IStore. PHP refuses to load such a class:
Fatal error: Declaration of & ReportAction::getStore(): core\store\IStore
must be compatible with & AbstractAction::getStore(): Store
Code that calls getStore() is not affected: a Store is an IStore.
An action class that does not override getStore() is not affected. A
class that implements IStoreAction without extending AbstractAction,
and a rule or a view with a getStore() of its own, is not affected.
How to find it
The class-load check of How to upgrade a project
reports every such class. This command lists every getStore() that
declares IStore; only those in a class that extends an action have to
change:
grep -rnE --include='*.php' --exclude-dir=vendor \
'function +&? *getStore\(\) *: *\\?(core\\store\\)?IStore' .
What to do
Declare Store as the return type, or remove the override when it only
returns $this->store.
Before:
<?php
use core\store\IStore;
class ReportAction extends AbstractAction
{
public function getActionName(): string
{
return 'report';
}
public function &getStore(): IStore
{
return $this->store;
}
}
After:
<?php
class ReportAction extends AbstractAction
{
public function getActionName(): string
{
return 'report';
}
public function &getStore(): Store
{
return $this->store;
}
}
Store keeps its plugin in its components
CHANGELOG entry: Store keeps its plugin in its StoreComponents, and
StoreComponentsFactory creates its plugin, its rules manager and its
audit.
What changed
One protected property and one protected method of Store are gone:
before: Store: protected ?AbstractPlugin $plugin
now: StoreComponents: public ?AbstractPlugin $plugin = null
before: protected function getRulesManager(): IRulesManager
now: no method; the rules manager is $this->components->rulesManager
| Before, inside a store class | Now |
|---|---|
$this->plugin (read) |
$this->components->plugin, or $this->getPlugin() |
$this->plugin = $plugin; |
$this->components->plugin = $plugin; |
isset($this->plugin) |
$this->components->plugin !== null |
$this->getRulesManager() |
$this->components->rulesManager |
protected function getRulesManager(): IRulesManager that returns a rules manager of the project |
onInit() that sets $this->components->rulesManager before parent::onInit() |
$this->components is the Festi\Store\StoreComponents of the section
above. It has two more public typed properties:
StoreComponents: public ?AbstractPlugin $plugin = null
StoreComponents: public ?core\store\StoreAudit $audit = null
$plugin is the plugin the plugin attribute of the schema names, or the
one given to setPlugin(); PluginStore gives it the plugin it was
created with. $audit is what records the changes of a store whose
schema has auditMode; null for any other store. Both accept only a
value of their type: PHP throws a TypeError for any other.
Festi\Store\StoreComponentsFactory creates the three:
public function createPlugin(Store $store): ?AbstractPlugin
public function createRulesManager(Store $store): IRulesManager
public function createAudit(Store $store): ?StoreAudit
createRulesManager() had no parameter; it initialises the rules manager
with the store now, which Store did. No release had the method.
The store sets $this->components->plugin in its constructor, after the
model, the view and the proxy. onInit() then sets
$this->components->rulesManager, unless the components keep one
already, and $this->components->audit.
Three public methods are new, and Store uses them:
Festi\Store\StoreIdentResolver::isSchemaClass(string $reference): bool
StoreModel::getErrorMessage(): mixed
StoreModel::getPermissionSection(): mixed
The last two return the errorMessage and the permission attribute of
the table.
These do not change:
- Every public method of
Store,PluginStoreandIStore, with its name, its parameters and its result.getPlugin()still returns a reference, so$plugin = &$store->getPlugin(); $plugin = $other;replaces the plugin of the store, andsetPlugin()still keeps a reference to the variable it is given. - The constructor of
Storeand ofPluginStore. - The protected properties
$ident,$session,$connection,$request,$core,$parentFieldName,$paginationand$components, and the protected methodsonInit()andonPrepareOptions(). - A clone shares the plugin with the store it was made from, and
setPlugin()on the clone leaves the store its own. - The plugin a store has, the listeners of an audited store, the exception a user without the permission gets and its message.
One error text changes. A schema built from an array or a class whose
plugin attribute is an object that is not a plugin was refused with
TypeError: Store::_getPluginInstance(): Return value must be of type
?AbstractPlugin, stdClass returned. The TypeError now names
Festi\Store\StoreComponentsFactory::createPlugin().
Who is affected
A project class that extends Store, PluginStore or a store class of
the project and uses $this->plugin, declares a property $plugin, or
calls or declares getRulesManager(). Code that calls getPlugin() or
setPlugin() is not affected. A plugin class is not affected:
$this->plugin of an AbstractPlugin is another property.
A store class that still uses one of them is stopped. The message is the same on PHP 8.1, 8.2 and 8.3:
| Code in the store class | Result |
|---|---|
$this->plugin->getByID($id), $plugin = $this->plugin;, $this->plugin instanceof InvoicesPlugin |
SystemException: X::$plugin is not a property of the store: the value is in $this->components, read it and set it as $this->components->plugin |
$this->plugin = $plugin;, $this->plugin = &$plugin; |
The same exception |
isset($this->plugin), empty($this->plugin), unset($this->plugin); |
The same exception |
protected ?InvoicesPlugin $plugin = null; declared in the class, with any type or none |
The same exception, when the store is created |
$store->plugin, outside the store |
The same exception. Before it was Error: Cannot access protected property X::$plugin |
$this->getRulesManager() |
Error: Call to undefined method X::getRulesManager() |
protected function getRulesManager(): IRulesManager declared in the class, with any visibility |
SystemException: X::getRulesManager() is not a method of the store: the rules manager is in $this->components, read it and set it as $this->components->rulesManager, when the store is created |
$this->plugin = $plugin; before parent::__construct() |
The same exception. Written as $this->components->plugin = $plugin; in that place it is Error: Attempt to assign property "plugin" on null |
getPlugin() or setPlugin() of a store created without its constructor, as a partial mock is |
Error: Attempt to modify property "plugin" on null. Before, getPlugin() returned null and setPlugin() set the plugin |
(new StoreComponentsFactory())->createRulesManager() |
ArgumentCountError: Too few arguments to function Festi\Store\StoreComponentsFactory::createRulesManager(), 0 passed in <file> on line <n> and exactly 1 expected |
X is the class of the store. The exception is thrown when the line
runs.
Without these checks PHP would not stop such a class. Measured with the same class and the checks removed:
| Code in the store class | PHP 8.1 | PHP 8.2 and 8.3 |
|---|---|---|
$this->plugin->getByID($id) |
Warning: Undefined property: X::$plugin and Error: Call to a member function getByID() on null |
The same |
$this->plugin instanceof InvoicesPlugin |
false with the same warning |
The same |
$this->plugin = $plugin; |
Nothing. The value is kept in a new property; getPlugin() and the framework keep the plugin of the components |
The same, with Deprecated: Creation of dynamic property X::$plugin is deprecated |
protected ?InvoicesPlugin $plugin = null; declared in the class |
Nothing. The store is created and never sets the property | The same |
protected function getRulesManager(): IRulesManager declared in the class |
Nothing. The store is created and never calls the method | The same |
How to find it
Save this as store-plugin-property.php in the project root:
<?php
// Usage: php store-plugin-property.php [--write] [--store=Name] <dir> ...
// Lists, in every class that extends Store, PluginStore or a store class
// found in the directories, each `$this->plugin` ([use], or [write] when it
// is assigned) and what else the section "Store keeps its plugin in its
// components" names. With --write it rewrites `$this->plugin` to
// `$this->components->plugin` in those classes and lists the rest, which is
// changed by hand. --store=Name adds a store class whose file is not in
// the directories, such as one of a package.
const SKIPPED_DIRECTORIES = ['vendor', 'node_modules', '.git'];
const TYPE_NAMES = [T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED];
const BLANK = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];
const ARROWS = [T_OBJECT_OPERATOR, T_NULLSAFE_OBJECT_OPERATOR];
const BRACES = [T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES];
const VISIBILITY = [T_PUBLIC, T_PROTECTED, T_PRIVATE, T_VAR];
const REWRITTEN_KINDS = ['use', 'write'];
function shortName(string $name): string
{
return substr('\\'.$name, strrpos('\\'.$name, '\\') + 1);
}
function isToken(mixed $token, int ...$kinds): bool
{
return is_array($token) && in_array($token[0], $kinds, true);
}
function nextIndex(array $tokens, int $index): int
{
do {
$index++;
} while (isset($tokens[$index]) && isToken($tokens[$index], ...BLANK));
return $index;
}
function previousIndex(array $tokens, int $index): int
{
do {
$index--;
} while ($index >= 0 && isToken($tokens[$index], ...BLANK));
return $index;
}
function findFiles(string $directory): array
{
$files = [];
$entries = scandir($directory) ?: [];
foreach (array_diff($entries, ['.', '..']) as $entry) {
$path = rtrim($directory, '/').'/'.$entry;
if (is_link($path)) {
continue;
}
if (is_dir($path) && !in_array($entry, SKIPPED_DIRECTORIES, true)) {
$files = array_merge($files, findFiles($path));
} else if (is_file($path) && str_ends_with($entry, '.php')) {
$files[] = $path;
}
}
return $files;
}
// Returns the classes of a file: name, parent and the token range of the
// body, with the bodies of the classes declared inside it left out.
function readClasses(array $tokens): array
{
$classes = [];
$open = [];
$depth = 0;
$pending = null;
foreach ($tokens as $index => $token) {
if (isToken($token, T_CLASS)) {
$before = $tokens[previousIndex($tokens, $index)] ?? null;
if (isToken($before, T_DOUBLE_COLON)) {
continue;
}
$name = $tokens[nextIndex($tokens, $index)] ?? null;
$pending = [
'name' => isToken($name, T_STRING) ? $name[1] : null,
'parent' => null,
'holes' => [],
];
} else if ($pending && !$pending['parent'] &&
isToken($token, T_EXTENDS)) {
$parent = $tokens[nextIndex($tokens, $index)];
$pending['parent'] = shortName($parent[1]);
} else if ($token === '{' || isToken($token, ...BRACES)) {
$depth++;
if ($pending && $token === '{') {
$open[$depth] = $pending + ['from' => $index];
$pending = null;
}
} else if ($token === '}') {
if (isset($open[$depth])) {
$class = $open[$depth] + ['to' => $index];
unset($open[$depth]);
$outer = array_key_last($open);
if ($outer !== null) {
$open[$outer]['holes'][] = [$class['from'], $index];
}
$classes[] = $class;
}
$depth--;
}
}
return $classes;
}
function isInHole(array $class, int $index): bool
{
foreach ($class['holes'] as [$from, $to]) {
if ($index > $from && $index < $to) {
return true;
}
}
return false;
}
// Returns what a store class has to change: [token index, line, kind].
function readFindings(array $tokens, array $class): array
{
$findings = [];
$inString = false;
$braces = 0;
for ($index = $class['from']; $index < $class['to']; $index++) {
$token = $tokens[$index];
if ($token === '"' || isToken($token, T_START_HEREDOC, T_END_HEREDOC)) {
$inString = !$inString;
$braces = 0;
} else if ($inString && isToken($token, ...BRACES)) {
$braces++;
} else if ($inString && $token === '}') {
$braces--;
}
if (isInHole($class, $index) || !is_array($token)) {
continue;
}
$next = $tokens[nextIndex($tokens, $index)] ?? null;
$before = $tokens[previousIndex($tokens, $index)] ?? null;
$kind = null;
if (isToken($token, T_STRING) && $token[1] === 'plugin' &&
isToken($before, ...ARROWS) && $next !== '(') {
$owner = $tokens[previousIndex(
$tokens,
previousIndex($tokens, $index)
)];
if (isToken($owner, T_VARIABLE) && $owner[1] === '$this') {
$kind = $next === '=' ? 'write' : 'use';
$kind = $inString && $braces === 0 ? 'in a string' : $kind;
}
} else if (isToken($token, T_VARIABLE) && $token[1] === '$plugin') {
$start = $index;
do {
$start = previousIndex($tokens, $start);
$part = $tokens[$start] ?? null;
} while (isToken($part, T_STATIC, T_READONLY, ...TYPE_NAMES) ||
in_array($part, ['?', '|'], true));
$kind = isToken($part, ...VISIBILITY) ? 'declaration' : null;
} else if (isToken($token, T_STRING) &&
strtolower($token[1]) === 'getrulesmanager') {
$kind = 'getRulesManager';
}
if ($kind !== null) {
$findings[] = [$index, $token[2], $kind];
}
}
return $findings;
}
// Returns the tokens of a file and its classes that extend another class;
// null when the file has none or PHP cannot parse it.
function readSource(string $path): ?array
{
$source = file_get_contents($path);
if (stripos($source, 'extends') === false) {
return null;
}
try {
$tokens = token_get_all($source, TOKEN_PARSE);
} catch (ParseError $error) {
fwrite(STDERR, $path.': not parsed, '.$error->getMessage()."\n");
return null;
}
$classes = array_filter(
readClasses($tokens),
fn (array $class): bool => $class['parent'] !== null
);
return $classes ? [$tokens, array_values($classes), $source] : null;
}
error_reporting(E_ALL & ~E_DEPRECATED);
$arguments = array_slice($argv, 1);
$isWrite = in_array('--write', $arguments, true);
$stores = ['Store' => true, 'PluginStore' => true];
$files = [];
foreach ($arguments as $argument) {
if (str_starts_with($argument, '--store=')) {
$stores[shortName(substr($argument, 8))] = true;
} else if ($argument !== '--write') {
$files = array_merge($files, findFiles($argument));
}
}
$parents = [];
foreach ($files as $path) {
foreach (readSource($path)[1] ?? [] as $class) {
$parents[$path][] = [$class['name'], $class['parent']];
}
}
do {
$count = count($stores);
foreach ($parents as $pairs) {
foreach ($pairs as [$name, $parent]) {
if ($name !== null && isset($stores[$parent])) {
$stores[$name] = true;
}
}
}
} while (count($stores) > $count);
$total = ['use' => 0, 'by hand' => 0, 'files' => 0];
foreach ($parents as $path => $pairs) {
$isStoreFile = false;
foreach ($pairs as [$name, $parent]) {
$isStoreFile = $isStoreFile || isset($stores[$parent]);
}
if (!$isStoreFile) {
continue;
}
[$tokens, $classes, $source] = readSource($path);
$lines = explode("\n", $source);
$isChanged = false;
foreach ($classes as $class) {
if (!isset($stores[$class['parent']])) {
continue;
}
foreach (readFindings($tokens, $class) as [$index, $line, $kind]) {
$isUse = in_array($kind, REWRITTEN_KINDS, true);
if ($isWrite && $isUse) {
$tokens[$index][1] = 'components->plugin';
$isChanged = true;
$kind .= ', rewritten';
}
$total[$isUse ? 'use' : 'by hand']++;
printf(
"%s:%d: [%s] %s\n",
$path,
$line,
$kind,
trim($lines[$line - 1])
);
}
}
if ($isChanged) {
$parts = array_map(
fn (mixed $token): string => is_array($token) ? $token[1] : $token,
$tokens
);
file_put_contents($path, implode('', $parts));
$total['files']++;
}
}
printf(
"%d store classes; \$this->plugin: %d %s; to change by hand: %d\n",
count($stores) - 2,
$total['use'],
$isWrite ? 'rewritten in '.$total['files'].' files' : 'found',
$total['by hand']
);
Run it in the project root on the directories that hold the code of the
project. Without --write it changes nothing:
php store-plugin-property.php .
It prints one line for each place and a total:
./plugins/Invoices/domain/store/InvoicesStore.php:104: [use] $invoiceValuesObject = $this->plugin->getByID($event->getPrimaryKeyValue());
./plugins/Redirects/RedirectsStore.php:44: [write] $this->plugin = $plugin;
./plugins/Reports/ReportsStore.php:12: [declaration] protected ?ReportsPlugin $plugin = null;
./plugins/Reports/ReportsStore.php:40: [in a string] $title = "Report of $this->plugin";
./plugins/Reports/ReportsStore.php:51: [getRulesManager] $rule = $this->getRulesManager()->createRule($type);
62 store classes; $this->plugin: 8 found; to change by hand: 3
0 found; to change by hand: 0 means no work. The script reads the
extends of every class in the directories and follows it up to Store
and PluginStore, so it lists a class that extends a store class of the
project and leaves out a class that only has Store in its name: a
wrapper that implements IStore without extending Store, a widget or
a proxy has a $plugin of its own, which is not affected. It does not
look into vendor, node_modules and .git. It does not see:
- A store class whose parent is in a package: name the parent with
--store=Name, as inphp store-plugin-property.php --store=PluginStoreWrapper .. - A trait that a store class uses.
- A property whose name is in a variable (
$this->$name) or in a string ($this->{'plugin'}).
The exceptions above report these when the line runs.
In the framework the script prints the two test stores that declare
$plugin and getRulesManager(). In the projects and shared plugins it
was run on it finds 23 lines in 13 classes of two projects, one of them
a [write], and nothing to change by hand: no class declares $plugin
and none calls or declares getRulesManager().
A grep for \$this->plugin in the files of classes whose parent has
Store in its name lists more than is affected: in one project 72 lines
in 18 files, of which 13 lines in 10 files are in a store class. The
other lines belong to wrappers and widgets, which must stay as they are.
What to do
- Run the script with
--write. It replaces$this->pluginwith$this->components->pluginin every line it lists as[use]or[write], and changes nothing else in the file:
php store-plugin-property.php --write .
The replacement is right for a read, a write, isset(), a reference
and {$this->plugin->name} in a string. Review the result with
git diff; run the script again and it prints only what is left.
-
Look at each
[write]line. A constructor that sets the plugin beforeparent::__construct()is stopped now withError: Attempt to assign property "plugin" on null: the store creates its components in its constructor. Such a line had one effect: the view of a store readsgetPlugin()when it is created and addsgetPluginTemplatePath()of the plugin to the folders it looks for templates in, and at that moment the store has only a plugin that was set this early. To keep the effect, keep the plugin in a property of the class and set$this->components->plugininonPrepareOptions(), which the store calls after it has created its components and before it creates its model, its view and its proxy; see the second example below. Remove the line instead when the plugin has nogetPluginTemplatePath()of its own, or the store shows no template of the plugin:PluginStoremakes the plugin it is created with the plugin of the store afterparent::__construct(). -
Change by hand what the script lists with another label:
| Label | Before | After |
|---|---|---|
[declaration] |
protected ?InvoicesPlugin $plugin = null; |
Remove the declaration. For the type, add a method such as getInvoicesPlugin(): InvoicesPlugin that returns $this->components->plugin after an instanceof check |
[in a string] |
"Report of $this->plugin" |
"Report of {$this->components->plugin}" |
[getRulesManager], a call |
$this->getRulesManager()->createRule($type) |
$this->components->rulesManager->createRule($type) |
[getRulesManager], a declaration |
protected function getRulesManager(): IRulesManager |
See the third example below |
$this->components->rulesManager is null until Store::onInit() has
run, and in a store class whose onInit() does not call
parent::onInit(). getRulesManager() created the rules manager at the
first call instead; nothing but Store::onInit() called it.
Before:
<?php
use core\store\PluginStore;
class InvoicesStore extends PluginStore
{
public function __construct(InvoicesPlugin $plugin, array $options = [])
{
parent::__construct('invoices', $plugin, $options);
}
public function onUpdate(StoreActionEvent &$event): void
{
$invoice = $this->plugin->getByID($event->getPrimaryKeyValue());
if ($invoice && isset($this->plugin)) {
$this->plugin->generateDocumentsByID($invoice->getID());
}
}
public function getDateFormat(): string
{
return $this->plugin->getSetting('db_datetime_format');
}
}
After:
<?php
use core\store\PluginStore;
class InvoicesStore extends PluginStore
{
public function __construct(InvoicesPlugin $plugin, array $options = [])
{
parent::__construct('invoices', $plugin, $options);
}
public function onUpdate(StoreActionEvent &$event): void
{
$plugin = $this->components->plugin;
$invoice = $plugin->getByID($event->getPrimaryKeyValue());
if ($invoice && $this->components->plugin !== null) {
$plugin->generateDocumentsByID($invoice->getID());
}
}
public function getDateFormat(): string
{
return $this->getPlugin()->getSetting('db_datetime_format');
}
}
A store whose view shows the templates of its plugin, before:
<?php
use core\store\PluginStore;
class RedirectsStore extends PluginStore
{
public function __construct(RedirectsPlugin $plugin, array $options = [])
{
$this->plugin = $plugin;
parent::__construct('redirects', $plugin, $options);
}
}
After:
<?php
use core\store\PluginStore;
class RedirectsStore extends PluginStore
{
private RedirectsPlugin $_facade;
public function __construct(RedirectsPlugin $plugin, array $options = [])
{
$this->_facade = $plugin;
parent::__construct('redirects', $plugin, $options);
}
protected function onPrepareOptions(array &$options): void
{
parent::onPrepareOptions($options);
$this->components->plugin = $this->_facade;
}
}
A store that applies its rules with a rules manager of the project, before:
<?php
use Festi\Store\Rule\IRulesManager;
class ContractsStore extends Store
{
private ?IRulesManager $_contractRules = null;
protected function getRulesManager(): IRulesManager
{
if ($this->_contractRules === null) {
$this->_contractRules = new ContractRulesManager();
$this->_contractRules->onInit($this);
}
return $this->_contractRules;
}
}
After:
<?php
class ContractsStore extends Store
{
protected function onInit(): void
{
$rulesManager = new ContractRulesManager();
$rulesManager->onInit($this);
$this->components->rulesManager = $rulesManager;
parent::onInit();
}
}
tests/Resources/store/PluginFacadeStore.php,
tests/Resources/store/EarlyPluginStore.php and
tests/Resources/store/OwnRulesManagerStore.php are stores of these
three kinds in the framework.
ClassSchema declares five sections in bindings()
CHANGELOG entry: ClassSchema declares the relations, listeners,
routers, rules and external values of a schema in one method,
bindings().
What changed
Five protected section methods of Festi\Store\Schema\ClassSchema are
gone and one is new:
before: protected function relations(IRelationsBuilder $relations): IRelationsBuilder
before: protected function listeners(IListenersBuilder $listeners): IListenersBuilder
before: protected function routers(IRoutersBuilder $routers): IRoutersBuilder
before: protected function rules(IRulesBuilder $rules): IRulesBuilder
before: protected function externalValues(IExternalValuesBuilder $values): IExternalValuesBuilder
now: protected function bindings(ISchemaBindings $bindings): ISchemaBindings
bindings() follows the contract of the other section methods: it takes
an object, fills it and returns it, and ClassSchema has a default that
adds nothing. $bindings is a
Festi\Store\Schema\Builder\ISchemaBindings. It has one method for each
of the five sections, which returns the builder the removed section
method received, the same builder on every call:
public function relations(): IRelationsBuilder
public function listeners(): IListenersBuilder
public function routers(): IRoutersBuilder
public function rules(): IRulesBuilder
public function externalValues(): IExternalValuesBuilder
All fourteen sections of a schema:
| Key in the built array | Before, in a schema class | Now |
|---|---|---|
table |
table(ITableBuilder $table): ITableBuilder |
The same |
fields |
fields(IFieldsBuilder $fields): IFieldsBuilder |
The same |
actions |
actions(IActionsBuilder $actions): IActionsBuilder |
The same |
grouped |
groupActions(IGroupActionsBuilder $groupActions): IGroupActionsBuilder |
The same |
filters |
filters(IFiltersBuilder $filters): IFiltersBuilder |
The same |
search |
search(ISearchBuilder $search): ISearchBuilder |
The same |
aggregations |
aggregations(IAggregationsBuilder $aggregations): IAggregationsBuilder |
The same |
sections |
sections(ISectionsBuilder $sections): ISectionsBuilder |
The same |
highlights |
highlights(IHighlightsBuilder $highlights): IHighlightsBuilder |
The same |
relations |
relations(IRelationsBuilder $relations): IRelationsBuilder |
$bindings->relations() in bindings() |
listeners |
listeners(IListenersBuilder $listeners): IListenersBuilder |
$bindings->listeners() in bindings() |
routers |
routers(IRoutersBuilder $routers): IRoutersBuilder |
$bindings->routers() in bindings() |
rules |
rules(IRulesBuilder $rules): IRulesBuilder |
$bindings->rules() in bindings() |
externalValues |
externalValues(IExternalValuesBuilder $values): IExternalValuesBuilder |
$bindings->externalValues() in bindings() |
| Before, inside a section method | Now, inside bindings() |
|---|---|
$relations->add()->child()->field('id'); |
$bindings->relations()->add()->child()->field('id'); |
$listeners->add(Store::EVENT_INSERT)->plugin('Users')->method('onInsert'); |
$bindings->listeners()->add(Store::EVENT_INSERT)->plugin('Users')->method('onInsert'); |
$routers->add('users')->joinStore('profiles'); |
$bindings->routers()->add('users')->joinStore('profiles'); |
$rules->add()->attribute('field', 'email'); |
$bindings->rules()->add()->attribute('field', 'email'); |
return $values->add('id_company', $id); |
$bindings->externalValues()->add('id_company', $id); |
return $listeners;, return $values; at the end |
return $bindings; |
parent::listeners($listeners); in a schema that extends a schema of the project |
parent::bindings($bindings); once, for all five sections |
Two classes of Festi\Store\Schema\Builder are new. SchemaBindings
implements ISchemaBindings and keeps the five builders.
SchemaBuilders creates the builders ClassSchema::build() gives to
the section methods:
public function createTable(): TableBuilder
public function createFields(): FieldsBuilder
public function createActions(): ActionsBuilder
public function createGroupActions(): GroupActionsBuilder
public function createFilters(): FiltersBuilder
public function createSearch(): SearchBuilder
public function createAggregations(): AggregationsBuilder
public function createSections(): SectionsBuilder
public function createHighlights(): HighlightsBuilder
public function createBindings(): SchemaBindings
ISchemaBindings::SECTIONS lists the names of the five sections.
build() calls the nine section methods in the order of the first table
and bindings() after them. Before, it called relations() after
groupActions(), listeners() after search() and routers() after
sections(); rules() and externalValues() were the last two then
too. Only a schema whose section methods change a property that a later
one reads can notice the order.
The five names are reserved in a schema class. build() refuses a
schema that has a method named relations, listeners, routers,
rules or externalValues, with any visibility and any parameters, its
own, inherited or from a trait: nothing calls such a method, so its
section would stay empty.
These do not change:
- The array
build()returns, for every schema: the same fourteen keys in the same order with the same values. - The nine section methods of the first table, and
return parent::fields($fields)->...in a subclass. - The fourteen builder interfaces and their classes. A helper method or
a trait of the project that takes an
IFieldsBuilder, anIListenersBuilderor another builder keeps working:$this->addAuditListeners($bindings->listeners()). IStoreModelSchema,IStoreSchemaProviderand a class that implementsIStoreModelSchemawithout extendingClassSchema.- XML and array schemas.
Who is affected
A project class that extends ClassSchema, or a schema class of the
project, and declares relations(), listeners(), routers(),
rules() or externalValues(). A schema that declares only the other
nine section methods is not affected.
PHP loads such a class without an error: the method no longer overrides anything. It is stopped when the schema is built, which is when the store that uses it is created. The message is the same on PHP 8.1, 8.2 and 8.3:
| Code in the schema class | Result of build() |
|---|---|
protected function listeners(IListenersBuilder $listeners): IListenersBuilder (also relations, routers, rules, externalValues) |
SystemException: X::listeners() is not a section method of the schema: declare the section in bindings(ISchemaBindings $bindings), with $bindings->listeners() |
The same method declared public or private, with other parameters, or in a trait the class uses |
The same exception |
| No such method in the class, but one in a schema class it extends | The same exception; X is the parent class that declares the method |
listeners() that calls parent::listeners($listeners) |
The same exception; the call is not reached |
bindings() next to a routers() that was left |
The same exception, for routers() |
parent::listeners($bindings->listeners()) inside bindings() of a class that extends ClassSchema |
Error: Call to undefined method Festi\Store\Schema\ClassSchema::listeners() |
X is the class that declares the method. When a schema has several
such methods the message names the first in the order relations,
listeners, routers, rules, externalValues.
Without this check PHP would not stop such a class. Measured with the same classes and the check removed, on PHP 8.1, 8.2 and 8.3:
| Code in the schema class | Result of build() |
|---|---|
protected function listeners(IListenersBuilder $listeners): IListenersBuilder |
An array whose listeners is empty. No error and no warning, so the store would work without its listeners |
protected function externalValues(IExternalValuesBuilder $values): IExternalValuesBuilder |
An array whose externalValues is empty, so the store would save its records without these values |
bindings() next to a routers() that was left |
The sections of bindings() are built, routers is empty |
How to find it
Save this as class-schema-bindings.php in the project root:
<?php
// Usage: php class-schema-bindings.php [--write] [--schema=Name] <dir> ...
// Lists, in every class that extends ClassSchema or a schema class found in
// the directories, each method relations(), listeners(), routers(), rules()
// and externalValues(): the section "ClassSchema declares five sections in
// bindings()" removes them. With --write it moves their bodies into one
// bindings() method of the class and lists the rest, which is changed by
// hand. --schema=Name adds a schema class whose file is not in the
// directories, such as one of a package.
const SKIPPED_DIRECTORIES = ['vendor', 'node_modules', '.git'];
const BLANK = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];
const BRACES = [T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES];
const MODIFIERS = [
T_PUBLIC,
T_PROTECTED,
T_PRIVATE,
T_FINAL,
T_STATIC,
T_ABSTRACT,
];
const SECTIONS = [
'relations' => 'IRelationsBuilder',
'listeners' => 'IListenersBuilder',
'routers' => 'IRoutersBuilder',
'rules' => 'IRulesBuilder',
'externalvalues' => 'IExternalValuesBuilder',
];
const GETTERS = ['externalvalues' => 'externalValues'];
const BINDINGS = 'Festi\\Store\\Schema\\Builder\\ISchemaBindings';
const BY_HAND = [
'bindings' => 'the class has bindings() already',
'parent' => 'a parent schema class declares bindings() or a section',
'shape' => 'not a method with one parameter and a body',
'return' => 'its only return is not its last statement and parameter',
'call' => 'calls a section method, or uses $bindings or its own name',
'class' => 'another section method of the class is changed by hand',
];
function shortName(string $name): string
{
return substr('\\'.$name, strrpos('\\'.$name, '\\') + 1);
}
function isToken(mixed $token, int ...$kinds): bool
{
return is_array($token) && in_array($token[0], $kinds, true);
}
function text(mixed $token): string
{
return is_array($token) ? $token[1] : $token;
}
function nextIndex(array $tokens, int $index): int
{
do {
$index++;
} while (isset($tokens[$index]) && isToken($tokens[$index], ...BLANK));
return $index;
}
function previousIndex(array $tokens, int $index): int
{
do {
$index--;
} while ($index >= 0 && isToken($tokens[$index], ...BLANK));
return $index;
}
function findFiles(string $directory): array
{
$files = [];
$entries = scandir($directory) ?: [];
foreach (array_diff($entries, ['.', '..']) as $entry) {
$path = rtrim($directory, '/').'/'.$entry;
if (is_link($path)) {
continue;
}
if (is_dir($path) && !in_array($entry, SKIPPED_DIRECTORIES, true)) {
$files = array_merge($files, findFiles($path));
} else if (is_file($path) && str_ends_with($entry, '.php')) {
$files[] = $path;
}
}
return $files;
}
// Returns the index of the brace that closes the one at $index.
function closingIndex(array $tokens, int $index): int
{
$depth = 0;
for ($count = count($tokens); $index < $count; $index++) {
if ($tokens[$index] === '{' || isToken($tokens[$index], ...BRACES)) {
$depth++;
} else if ($tokens[$index] === '}' && --$depth === 0) {
break;
}
}
return $index;
}
// Returns the classes of a file that extend another class: name, parent and
// the token range of the body.
function readClasses(array $tokens): array
{
$classes = [];
foreach ($tokens as $index => $token) {
$before = $tokens[previousIndex($tokens, $index)] ?? null;
if (!isToken($token, T_CLASS) || isToken($before, T_DOUBLE_COLON)) {
continue;
}
$name = $tokens[nextIndex($tokens, $index)];
$class = ['name' => isToken($name, T_STRING) ? $name[1] : null];
for ($from = $index; $tokens[$from] !== '{'; $from++) {
$isFirst = !isset($class['parent']);
if ($isFirst && isToken($tokens[$from], T_EXTENDS)) {
$parent = $tokens[nextIndex($tokens, $from)];
$class['parent'] = shortName($parent[1]);
}
}
if (isset($class['parent'])) {
$class += ['from' => $from, 'to' => closingIndex($tokens, $from)];
$classes[] = $class;
}
}
return $classes;
}
// Returns the methods a class declares itself, by their lower-case names:
// where the declaration starts, its parameter and the braces of its body.
function readMethods(array $tokens, array $class): array
{
$methods = [];
$depth = 0;
for ($index = $class['from']; $index < $class['to']; $index++) {
$token = $tokens[$index];
if ($token === '{' || isToken($token, ...BRACES)) {
$depth++;
} else if ($token === '}') {
$depth--;
}
$name = $tokens[nextIndex($tokens, $index)];
if ($depth !== 1 || !isToken($token, T_FUNCTION) ||
!isToken($name, T_STRING)) {
continue;
}
$start = $index;
$modifiers = [];
while (isToken($tokens[previousIndex($tokens, $start)], ...MODIFIERS)) {
$start = previousIndex($tokens, $start);
$modifiers[] = $tokens[$start][0];
}
$doc = $start;
while (isToken($tokens[$doc - 1], ...BLANK)) {
$doc--;
$start = isToken($tokens[$doc], T_DOC_COMMENT) ? $doc : $start;
}
$open = nextIndex($tokens, nextIndex($tokens, $index));
$variable = $tokens[nextIndex($tokens, nextIndex($tokens, $open))];
$close = $open;
$parameters = [];
while ($tokens[$close] !== ')') {
$close++;
if (isToken($tokens[$close], T_VARIABLE)) {
$parameters[] = $tokens[$close][1];
}
}
$body = $close;
while ($tokens[$body] !== '{' && $tokens[$body] !== ';') {
$body++;
}
$isPlain = count($parameters) === 1 && $tokens[$body] === '{' &&
isToken($variable, T_VARIABLE) &&
$tokens[previousIndex($tokens, $close)] === $variable &&
$tokens[previousIndex($tokens, $start)] !== ']' &&
!array_intersect($modifiers, [T_STATIC, T_ABSTRACT, T_PRIVATE]);
$methods[strtolower($name[1])] = [
'name' => $name[1],
'line' => $name[2],
'start' => $start,
'function' => $index,
'variable' => $isPlain ? $variable[1] : null,
'from' => $body,
'to' => $tokens[$body] === '{' ?
closingIndex($tokens, $body) : $body,
];
}
return $methods;
}
// Returns why the body of a section method is not moved, or null.
function findObstacle(array $tokens, array $method): ?string
{
if ($method['variable'] === null) {
return 'shape';
}
$returns = [];
for ($index = $method['from'] + 1; $index < $method['to']; $index++) {
$token = $tokens[$index];
$word = strtolower(text($token));
$isName = isToken($token, T_STRING) && isset(SECTIONS[$word]) &&
$tokens[nextIndex($tokens, $index)] === '(';
if ($isName || isToken($token, T_FUNC_C, T_METHOD_C) ||
$word === '$bindings' || $word === 'func_get_args') {
return 'call';
}
if (isToken($token, T_RETURN)) {
$returns[] = $index;
}
}
$last = previousIndex($tokens, $method['to']);
$statement = $last;
while ($statement > $method['from'] &&
!in_array($tokens[$statement - 1], [';', '{', '}'], true)) {
$statement--;
}
$statement = nextIndex($tokens, $statement - 1);
$value = $tokens[nextIndex($tokens, $statement)];
$isLastReturn = $returns === [$statement] && $tokens[$last] === ';' &&
isToken($value, T_VARIABLE) && $value[1] === $method['variable'];
return $isLastReturn ? null : 'return';
}
// Returns the statements of a section method without its return, after a
// line that takes the builder from the bindings.
function moveBody(array $tokens, array $method, string $indent): string
{
$last = previousIndex($tokens, $method['to']);
$return = $last;
while (!isToken($tokens[$return], T_RETURN)) {
$return--;
}
$parts = array_slice(
$tokens,
$method['from'] + 1,
$return - $method['from'] - 1
);
if (nextIndex($tokens, nextIndex($tokens, $return)) !== $last) {
$parts = array_merge($parts, array_slice(
$tokens,
nextIndex($tokens, $return),
$last - nextIndex($tokens, $return) + 1
));
}
$body = rtrim(implode('', array_map('text', $parts)));
if ($body === '') {
return '';
}
$name = strtolower($method['name']);
return $indent.$method['variable'].' = $bindings->'.
(GETTERS[$name] ?? $name).'();'.$body;
}
// Returns the source with a `use` of ISchemaBindings in its place among
// the imports.
function addImport(string $source): string
{
$pattern = '/^use\s+([\w\\\\]+);[^\n]*\n/m';
preg_match_all($pattern, $source, $uses, PREG_OFFSET_CAPTURE);
$names = array_column($uses[1], 0);
if (in_array(BINDINGS, $names, true)) {
return $source;
}
$at = end($uses[0])[1] + strlen(end($uses[0])[0]);
foreach ($names as $index => $name) {
if (strcasecmp($name, BINDINGS) > 0) {
$at = $uses[0][$index][1];
break;
}
}
return substr($source, 0, $at).'use '.BINDINGS.";\n".
substr($source, $at);
}
// Removes the imports of the builder interfaces that nothing in the file
// names any more.
function removeUnusedImports(string $source): string
{
foreach (SECTIONS as $interface) {
$pattern = '/^use\s+[\w\\\\]*\\\\'.$interface.';[^\n]*\n/m';
$rest = preg_replace($pattern, '', $source, 1);
if (!preg_match('/\b'.$interface.'\b/', $rest)) {
$source = $rest;
}
}
return $source;
}
// Returns the tokens of a file and its classes that extend another class;
// null when the file has none or PHP cannot parse it.
function readSource(string $path): ?array
{
$source = file_get_contents($path);
if (stripos($source, 'extends') === false) {
return null;
}
try {
$tokens = token_get_all($source, TOKEN_PARSE);
} catch (ParseError $error) {
fwrite(STDERR, $path.': not parsed, '.$error->getMessage()."\n");
return null;
}
$classes = readClasses($tokens);
return $classes ? [$tokens, $classes, $source] : null;
}
// Returns the source of a class with its movable section methods replaced
// by one bindings() method; $moved is [lower-case name => method].
function rewriteClass(array $tokens, array $moved, string $type): string
{
$offsets = [0];
foreach ($tokens as $token) {
$offsets[] = end($offsets) + strlen(text($token));
}
$source = implode('', array_map('text', $tokens));
$first = reset($moved);
$at = $offsets[$first['function']];
$line = substr($source, 0, $at);
$line = substr($line, (int) strrpos($line, "\n") + 1);
$indent = substr($line, 0, strlen($line) - strlen(ltrim($line)));
$inner = $indent.(str_contains($indent, "\t") ? "\t" : ' ');
$blocks = [];
foreach ($moved as $method) {
$blocks[] = moveBody($tokens, $method, $inner);
}
$blocks = array_filter($blocks);
$head = substr(
$source,
$offsets[$first['start']],
$offsets[$first['function']] - $offsets[$first['start']]
);
$gap = $offsets[previousIndex($tokens, $first['from']) + 1];
$brace = substr($source, $gap, $offsets[$first['from']] - $gap);
$bindings = $head.'function bindings('.$type.' $bindings): '.$type.
$brace."{\n".implode("\n\n", $blocks)."\n\n".$inner.
'return $bindings;'."\n".$indent.'}';
foreach (array_reverse($moved) as $method) {
$from = $method['start'];
$isFirst = $method === $first && $blocks;
if (!$isFirst && isToken($tokens[$from - 1], T_WHITESPACE)) {
$from--;
}
$source = substr($source, 0, $offsets[$from]).
($isFirst ? $bindings : '').
substr($source, $offsets[$method['to'] + 1]);
}
return $source;
}
error_reporting(E_ALL & ~E_DEPRECATED);
$arguments = array_slice($argv, 1);
$isWrite = in_array('--write', $arguments, true);
$schemas = ['ClassSchema' => true];
$files = [];
foreach ($arguments as $argument) {
if (str_starts_with($argument, '--schema=')) {
$schemas[shortName(substr($argument, 9))] = true;
} else if ($argument !== '--write') {
$files = array_merge($files, findFiles($argument));
}
}
$bases = count($schemas);
$bound = array_merge(array_keys(SECTIONS), ['bindings']);
$parents = [];
$declared = [];
foreach ($files as $path) {
[$tokens, $classes] = readSource($path) ?? [[], []];
foreach ($classes as $class) {
$parents[$path][] = [$class['name'], $class['parent']];
if ($class['name'] !== null) {
$names = array_keys(readMethods($tokens, $class));
$declared[$class['name']] = [
$class['parent'],
(bool) array_intersect($names, $bound),
];
}
}
}
do {
$count = count($schemas);
foreach ($parents as $pairs) {
foreach ($pairs as [$name, $parent]) {
if ($name !== null && isset($schemas[$parent])) {
$schemas[$name] = true;
}
}
}
} while (count($schemas) > $count);
// Tells whether a schema class of the project above $parent declares
// bindings() or one of the five sections.
$hasBindingParent = function (string $parent) use ($declared): bool {
for ($depth = 0; isset($declared[$parent]) && $depth < 50; $depth++) {
if ($declared[$parent][1]) {
return true;
}
$parent = $declared[$parent][0];
}
return false;
};
$total = ['moved' => 0, 'by hand' => 0, 'files' => 0];
foreach ($parents as $path => $pairs) {
$isSchemaFile = false;
foreach ($pairs as [$name, $parent]) {
$isSchemaFile = $isSchemaFile || isset($schemas[$parent]);
}
if (!$isSchemaFile) {
continue;
}
[$tokens, $classes, $source] = readSource($path);
$isChanged = false;
$hasImports = preg_match('/^use\s+[\w\\\\]+;/m', $source) === 1;
$type = $hasImports ? shortName(BINDINGS) : '\\'.BINDINGS;
// The last class of the file first: a rewrite keeps the token indexes
// of the classes above it.
foreach (array_reverse($classes) as $class) {
if (!isset($schemas[$class['parent']])) {
continue;
}
$methods = readMethods($tokens, $class);
$sections = array_intersect_key($methods, SECTIONS);
$obstacles = [];
foreach ($sections as $key => $method) {
$obstacles[$key] = findObstacle($tokens, $method);
if (isset($methods['bindings'])) {
$obstacles[$key] = 'bindings';
} else if ($hasBindingParent($class['parent'])) {
$obstacles[$key] = 'parent';
}
}
$isMovable = !array_filter($obstacles);
foreach ($sections as $key => $method) {
$obstacle = $obstacles[$key] ?? ($isMovable ? null : 'class');
$total[$obstacle === null ? 'moved' : 'by hand']++;
printf(
"%s:%d: [%s] %s::%s()\n",
$path,
$method['line'],
$obstacle === null ?
'to bindings()'.($isWrite ? ', rewritten' : '') :
'by hand: '.BY_HAND[$obstacle],
$class['name'] ?? 'class@anonymous',
$method['name']
);
}
if ($isWrite && $isMovable && $sections) {
$source = rewriteClass($tokens, $sections, $type);
$tokens = token_get_all($source);
$isChanged = true;
}
}
if ($isChanged) {
$isNamed = str_contains($source, $type.' $bindings');
$source = $hasImports && $isNamed ? addImport($source) : $source;
file_put_contents($path, removeUnusedImports($source));
$total['files']++;
}
}
printf(
"%d schema classes; section methods to move to bindings(): %d %s; ".
"to change by hand: %d\n",
count($schemas) - $bases,
$total['moved'],
$isWrite ? 'rewritten in '.$total['files'].' files' : 'found',
$total['by hand']
);
Run it in the project root on the directories that hold the code of the
project. Without --write it changes nothing:
php class-schema-bindings.php .
It prints one line for each of the five methods a schema class declares and a total:
./plugins/School/domain/store/schema/SchoolsSchema.php:358: [to bindings()] SchoolsSchema::listeners()
./plugins/Apps/domain/store/schema/ManageAppsSchema.php:168: [to bindings()] ManageAppsSchema::externalValues()
./plugins/Reports/ReportsSchema.php:40: [by hand: its only return is not its last statement and parameter] ReportsSchema::listeners()
./plugins/Reports/ReportsSchema.php:61: [by hand: another section method of the class is changed by hand] ReportsSchema::routers()
73 schema classes; section methods to move to bindings(): 2 found; to change by hand: 2
0 found; to change by hand: 0 means no work. The script reads the
extends of every class in the directories and follows it up to
ClassSchema, so it lists a schema that extends a schema class of the
project, and an anonymous class that extends one, and leaves out a
class that has a rules() or a listeners() and is not a schema. It
does not look into vendor, node_modules and .git. It does not
see:
- A schema class whose parent is in a package: name the parent with
--schema=Name, as inphp class-schema-bindings.php --schema=PackageSchema .. - A trait that a schema class uses.
- A method declared as
function &listeners(...).
The exception above reports these when the schema is built.
In the framework the script prints eight methods:
tests/Resources/Schema/UncalledListenersSchema.php and seven anonymous
schemas of tests/Bundle/Store/Schema/ClassSchemaTest.php keep such a
method to test the exception, and stay as they are. In the
projects and shared plugins it was run on it finds 8 methods, in 8 of
the 73 schema classes of one project: five externalValues(), two
listeners() and one routers(), all labelled [to bindings()].
--write rewrote the eight; a second run found nothing.
What to do
- Run the script with
--write:
php class-schema-bindings.php --write .
In every class it labels [to bindings()] it replaces the first of
the five methods with bindings(), moves the statements of the
others into it in the order they have in the file, and removes them.
Each moved body starts with a line that takes its builder from the
bindings under the name the parameter had, such as
$listeners = $bindings->listeners();, so the statements stay as
they were; the return of the parameter is left out and
return $bindings; ends the method. It adds
use Festi\Store\Schema\Builder\ISchemaBindings; in its place among
the imports, or writes the full name in a file without imports, and
removes the import of a builder interface nothing in the file names
any more. A method that only returns its parameter is removed.
Nothing else in the file changes. Review the result with git diff;
run the script again and it prints only what is left.
-
The docblock of the first moved method stays above
bindings(); the docblocks of the other moved methods are removed with them. Correct a docblock that names the old parameter. -
Change by hand what the script labels
[by hand: ...]. It rewrites no method of a class that has one such line:
| Label | What to do |
|---|---|
its only return is not its last statement and parameter |
The method returns early, returns from a closure, or returns something other than its parameter. Move its statements into bindings() and replace an early return $listeners; with a condition around the statements |
calls a section method, or uses $bindings or its own name |
The method calls parent::listeners() or another of the five methods, or has a variable $bindings. Call parent::bindings($bindings) once at the start of bindings() |
a parent schema class declares bindings() or a section |
A schema of the project above this class declares bindings too. Write bindings() with parent::bindings($bindings); first when the class adds to the sections of its parent. Leave the call out for a section method that replaced the one of the parent without calling it, and declare in the class what it still needs from the parent: bindings() replaces all five sections together |
the class has bindings() already |
Move the statements into the bindings() the class has |
not a method with one parameter and a body |
A static or abstract method, a method with another parameter list, or a method that has the name for another purpose. Rename a method that is not a section method: the five names are reserved |
another section method of the class is changed by hand |
Move this method together with the one that has another label |
- Check a schema whose section methods depend on the order they are
called in:
bindings()runs after the nine section methods.
Before:
<?php
use Festi\Store\Schema\Builder\IExternalValuesBuilder;
use Festi\Store\Schema\Builder\IFieldsBuilder;
use Festi\Store\Schema\Builder\IListenersBuilder;
use Festi\Store\Schema\Builder\IRoutersBuilder;
use Festi\Store\Schema\Builder\ITableBuilder;
use Festi\Store\Schema\ClassSchema;
class StudyGroupsSchema extends ClassSchema
{
public function __construct(private int $_idSchool)
{
}
protected function table(ITableBuilder $table): ITableBuilder
{
return $table->name('study_groups')->primaryKey('id');
}
protected function routers(IRoutersBuilder $routers): IRoutersBuilder
{
$routers->add('study_groups')
->joinStore('grade_groups')
->type('LEFT')
->on('grade_groups.id_school = study_groups.id_school');
return $routers;
}
protected function fields(IFieldsBuilder $fields): IFieldsBuilder
{
$fields->add('caption')->type(TextField::class);
return $fields;
}
protected function listeners(IListenersBuilder $listeners): IListenersBuilder
{
$listeners->add(Store::EVENT_INSERT)
->plugin('StudyGroups')
->method('onAddGroup');
return $listeners;
}
protected function externalValues(
IExternalValuesBuilder $values
): IExternalValuesBuilder
{
return $values->add('id_school', $this->_idSchool);
}
}
After, as the script writes it:
<?php
use Festi\Store\Schema\Builder\IFieldsBuilder;
use Festi\Store\Schema\Builder\ISchemaBindings;
use Festi\Store\Schema\Builder\ITableBuilder;
use Festi\Store\Schema\ClassSchema;
class StudyGroupsSchema extends ClassSchema
{
public function __construct(private int $_idSchool)
{
}
protected function table(ITableBuilder $table): ITableBuilder
{
return $table->name('study_groups')->primaryKey('id');
}
protected function bindings(ISchemaBindings $bindings): ISchemaBindings
{
$routers = $bindings->routers();
$routers->add('study_groups')
->joinStore('grade_groups')
->type('LEFT')
->on('grade_groups.id_school = study_groups.id_school');
$listeners = $bindings->listeners();
$listeners->add(Store::EVENT_INSERT)
->plugin('StudyGroups')
->method('onAddGroup');
$values = $bindings->externalValues();
$values->add('id_school', $this->_idSchool);
return $bindings;
}
protected function fields(IFieldsBuilder $fields): IFieldsBuilder
{
$fields->add('caption')->type(TextField::class);
return $fields;
}
}
The local variables are not needed. The same method written by hand:
<?php
use Festi\Store\Schema\Builder\ISchemaBindings;
use Festi\Store\Schema\ClassSchema;
class StudyGroupsSchema extends ClassSchema
{
public function __construct(private int $_idSchool)
{
}
protected function bindings(ISchemaBindings $bindings): ISchemaBindings
{
$bindings->routers()->add('study_groups')
->joinStore('grade_groups')
->type('LEFT')
->on('grade_groups.id_school = study_groups.id_school');
$bindings->listeners()->add(Store::EVENT_INSERT)
->plugin('StudyGroups')
->method('onAddGroup');
$bindings->externalValues()->add('id_school', $this->_idSchool);
return $bindings;
}
}
A schema that adds to the bindings of a schema of the project, before:
<?php
use Festi\Store\Schema\Builder\IListenersBuilder;
class ArchivedStudyGroupsSchema extends StudyGroupsSchema
{
protected function listeners(IListenersBuilder $listeners): IListenersBuilder
{
parent::listeners($listeners)
->add(Store::EVENT_REMOVE)
->plugin('StudyGroups')
->method('onRemoveGroup');
return $listeners;
}
}
After:
<?php
use Festi\Store\Schema\Builder\ISchemaBindings;
class ArchivedStudyGroupsSchema extends StudyGroupsSchema
{
protected function bindings(ISchemaBindings $bindings): ISchemaBindings
{
parent::bindings($bindings)
->listeners()
->add(Store::EVENT_REMOVE)
->plugin('StudyGroups')
->method('onRemoveGroup');
return $bindings;
}
}
tests/Resources/Schema/ParityFullSchema.php and
tests/Resources/Schema/SampleUsersSchema.php are schemas with
bindings() in the framework, rewritten by the script.
The last untyped members have native types
CHANGELOG entry: The eighteen methods and two properties that kept a signature without a native type declare it now.
What changed
Eighteen methods and two properties kept a signature without a native type until now, because a plugin, a package or a project overrides them without one. They declare the type now, and no member of the framework is left without one.
| Member | Before | Now |
|---|---|---|
AbstractPlugin::onInit(), ObjectPlugin::onInit() |
public function onInit() |
public function onInit(): void |
ObjectPlugin::$object |
protected $object |
protected ?IDataAccessObject $object = null |
ISystemPlugin::getResponseModel(), ApiPlugin::getResponseModel() |
public function getResponseModel($call, $regs = []) |
public function getResponseModel(array $call, array $regs = []): Response |
ISystemPlugin::install(), ApiPlugin::install() |
public function install() |
public function install(): void |
ISystemPlugin::onDisplayMain(), ApiPlugin::onDisplayMain() |
public function onDisplayMain(Response &$response, $storeName = false, $pluginName = false, $params = []) |
public function onDisplayMain(Response &$response, string\|false $storeName = false, string\|false $pluginName = false, array $params = []): ?bool |
ISystemObject::getSettings(), SystemObject::getSettings() |
public function getSettings() |
public function getSettings(): array |
ISystemObjectUrlRules::getUrlRules(), SystemObject::getUrlRules() |
public function getUrlRules($search) |
public function getUrlRules(array $search): array |
ISystemObjectUrlRules::addUrlRule(), SystemObject::addUrlRule() |
public function addUrlRule($values) |
public function addUrlRule(array $values): mixed |
DefaultUser::get() |
public function get($name) |
public function get(string $name): mixed |
DefaultUser::set() |
public function set($name, $value) |
public function set(string $name, mixed $value): mixed |
DefaultUser::getID() |
public function getID() |
public function getID(): mixed |
DefaultUser::getRole() |
public function getRole() |
public function getRole(): mixed |
DefaultUser::getSessionData() |
public function getSessionData() |
public function getSessionData(): array |
DefaultUser::$requiredFields |
protected $requiredFields |
protected array $requiredFields |
AbstractField::getFilterTemplateName() |
protected function getFilterTemplateName() |
protected function getFilterTemplateName(): string |
AbstractField::getInputType() |
public function getInputType() |
public function getInputType(): string |
HandlerField::callHandlerMethod() |
public function callHandlerMethod($params, $methodPostfix) |
public function callHandlerMethod(array $params, string $methodPostfix): mixed |
PriceField::getCurrencySymbol() |
protected function getCurrencySymbol() |
protected function getCurrencySymbol(): string\|false |
IProxyRepository::insert(), OpenApiProxy::insert() |
public function insert(array $values) |
public function insert(array $values): mixed |
IStore::getOption() |
public function getOption(string $name) |
public function getOption(string $name): mixed |
PermissionsException::$code |
protected $code = 403 |
No property. A constructor sets the code |
What the types say:
void:onInit()andinstall()return nothing. An override that hasreturn parent::onInit();orreturn true;does not compile.?bool:onDisplayMain()returnstrue,falseornull.mixed: any value, but a value. PHP throws aTypeErrorwhen a method with a return type other thanvoidends withoutreturn <value>;, also when the type ismixedor?bool.return null;is a value.?IDataAccessObjectandarrayof the two properties are exact: a redeclaration has to repeat the type as it is written here. PHP accepts no class that implementsIDataAccessObjectin its place.- An override may leave its parameters without a type, as before.
PermissionsException no longer redeclares $code. It declares a
constructor with the five parameters of SystemException, which sets the
code 403 when the exception has none. The code is the public constant
PermissionsException::CODE_FORBIDDEN, which a class that extends the
exception can redeclare to have another default:
public function __construct(?string $message = "", int $code = 0, ExceptionContext $context = new ExceptionContext(), string|false|null $displayMessage = false, ?Throwable $previous = null)
Measured before and after the change, with the same result on PHP 8.1, 8.2 and 8.3:
| Code | getCode() before |
getCode() now |
|---|---|---|
new PermissionsException(), new PermissionsException($message), new PermissionsException($message, 0) |
403 | 403 |
new PermissionsException($message, 401) |
401 | 401 |
new PermissionsException(null, 0, new ExceptionContext(), $shown) |
403 | 403 |
A subclass that declares protected $code = 401;, created without a code or with the code 0 |
401 | 401 |
| The same subclass, created with the code 500 | 500 | 500 |
A subclass whose constructor does not call parent::__construct() |
403 | 0 |
An exception created without its constructor: ReflectionClass::newInstanceWithoutConstructor(), a PHPUnit mock |
403 | 0 |
A call with the arguments of the old signature is refused as before; the
TypeError names PermissionsException::__construct() where it named
SystemException::__construct().
These do not change:
- The values the framework passes to these methods and the values it
gets back. A method bound to a url,
onDisplayMain()among them, is still called in coercive mode with the strings the url rule captured: see The framework calls project code in coercive mode. - An override that already declares the type.
SqlProxy::insert(),CassandraProxy::insert(),Store::getOption(),DatetimeField::getFilterTemplateName(),CheckboxField::getFilterTemplateName(),PasswordField::getInputType()andEmailField::getInputType()declared it before, so a class that extends one of them was already held to it. - A name given to
DefaultUser::get()orset()as a number by a file withoutstrict_types: PHP converts it to a string, and the session array keeps5and'5'under one key.
Who is affected
| Project class | What PHP checks in it |
|---|---|
A plugin: a class that extends AbstractPlugin, ObjectPlugin, DisplayPlugin, StagePlugin or a plugin class |
onInit(); $object when the plugin has an object |
A system plugin: a class that implements ISystemPlugin or extends ApiPlugin, such as the Jimbo plugin, and the traits it uses |
getResponseModel(), install(), onDisplayMain() |
A system object: a class that extends SystemObject or implements ISystemObject |
getSettings(), getUrlRules(), addUrlRule() |
A user class: a class that extends DefaultUser |
get(), set(), getID(), getRole(), getSessionData(), $requiredFields |
| A field class | getFilterTemplateName(), getInputType(); callHandlerMethod() under HandlerField; getCurrencySymbol() under PriceField |
A proxy class that extends OpenApiProxy or StoreProxy, or implements IProxy |
insert() |
A class that implements IStore without extending Store |
getOption() |
A class that extends PermissionsException |
Its code, when the constructor of the class does not call parent::__construct() |
A class is affected only when it declares one of these members. A class that declares none of them, and code that only calls them with the types of the table, is not affected.
PHP stops an affected class. The text is the same on PHP 8.1, 8.2 and
8.3; php -l finds the two errors that are marked so, the
class-load check finds the others of the first seven
rows.
| Code in the project class | Error |
|---|---|
public function getSettings() in a class that extends SystemObject: an override without the return type |
Fatal error: Declaration of JimboObject::getSettings() must be compatible with SystemObject::getSettings(): array |
public function onDisplayMain(Response &$response): void: a return type the framework type does not include; void is not part of ?bool or mixed |
Fatal error: Declaration of X::onDisplayMain(Response &$response): void must be compatible with ISystemPlugin::onDisplayMain(Response &$response, string\|false $storeName = false, string\|false $pluginName = false, array $params = []): ?bool |
public function get(int $name): a parameter type that does not include the framework type |
Fatal error: Declaration of X::get(int $name): mixed must be compatible with DefaultUser::get(string $name): mixed |
protected $object;, also with @var UsersObject above it |
Fatal error: Type of X::$object must be ?IDataAccessObject (as in class ObjectPlugin) |
protected ?UsersObject $object = null; |
The same error |
protected $requiredFields = ['auth', 'id']; |
Fatal error: Type of X::$requiredFields must be array (as in class DefaultUser) |
public function onInit(): void with return parent::onInit(); or another return <value>; |
Fatal error: A void function must not return a value (php -l) |
public function onDisplayMain(...): ?bool with a return; |
Fatal error: A function with return type must return a value (did you mean "return null;" instead of "return;"?) (php -l) |
public function onDisplayMain(...): ?bool with an empty body, or a path that reaches the end of the method |
TypeError: X::onDisplayMain(): Return value must be of type ?bool, none returned, when the method is called |
$this->object = new stdClass(); in a plugin |
TypeError: Cannot assign stdClass to property ObjectPlugin::$object of type ?IDataAccessObject; the message names the plugin class when that class redeclares the property |
protected ?IDataAccessObject $object; without = null, read before onInit() has run |
Error: Typed property X::$object must not be accessed before initialization |
$user->get(null), $object->getUrlRules('id = 1'), $field->callHandlerMethod($params, null) |
TypeError: DefaultUser::get(): Argument #1 ($name) must be of type string, null given, and the same for the other typed parameters |
Two things PHP checks in the other direction, against the release before this one:
- A return type on an override is valid while the framework method has none. So the return types of this section can be added before the framework is updated, and a plugin or a package that has them works with both releases.
- A type on a redeclared property, and a parameter type on an override,
are not valid while the framework member has none:
Fatal error: Type of X::$object must not be defined (as in class ObjectPlugin). A class that has to work with both releases declares neither: it removes the redeclaration of the property (see "What to do") and leaves the parameters without a type.
How to find it
Run the class-load check: it reports every class PHP refuses.
To list every declaration, and to add the return types and the property
types, save this as native-type-overrides.php in the project root:
<?php
// Usage: php native-type-overrides.php [--write] [--vendor] <dir> ...
// Lists, in every class, interface and trait of the directories that
// extends, implements or is used by a framework class of the section
// "The last untyped members have native types", each member PHP refuses:
// [return] an override without the return type: --write adds it
// [property] a redeclared property without the type: --write adds it
// [by hand] a type PHP refuses, a `return <value>;` in a void method,
// a method that has to return a value and does not
// The directories named vendor are read to find parent classes and are
// not reported; --vendor reports them too.
const SKIPPED_DIRECTORIES = ['node_modules', '.git'];
const BLANK = [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT];
const BRACES = [T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES];
const CLASS_KINDS = [T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM];
const NAME_KINDS = [T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED];
const MODIFIERS = [
T_PUBLIC, T_PROTECTED, T_PRIVATE, T_VAR, T_STATIC, T_READONLY,
T_ABSTRACT, T_FINAL,
];
const REFERENCES = [
T_AMPERSAND_FOLLOWED_BY_VAR_OR_VARARG,
T_AMPERSAND_NOT_FOLLOWED_BY_VAR_OR_VARARG,
];
const NOT_A_TYPE = [
T_PUBLIC, T_PROTECTED, T_PRIVATE, T_READONLY, T_ELLIPSIS,
T_AMPERSAND_FOLLOWED_BY_VAR_OR_VARARG,
];
const BUILT_IN = [
'array', 'bool', 'callable', 'false', 'float', 'int', 'iterable',
'mixed', 'never', 'null', 'object', 'string', 'true', 'void',
];
const FIELDS = [
'AbstractField', 'AudioField', 'AutocompleteField', 'CheckboxField',
'CompositeField', 'DatetimeField', 'EmailField', 'FileField',
'ForeignKeyField', 'HandlerField', 'HiddenField', 'ImageField',
'Many2manyField', 'Md5Field', 'MultiAutocompleteField',
'MultiFileField', 'NumberField', 'NumeratorField', 'PasswordField',
'PercentField', 'PriceField', 'RadioField', 'ReadonlyField',
'SelectField', 'SerializeField', 'TagsField', 'TextareaField',
'TextcomboboxField', 'TextField', 'TimestampField',
];
const PROXIES = [
'IProxyRepository', 'IProxy', 'StoreProxy', 'SqlProxy', 'MysqlProxy',
'PgsqlProxy', 'MssqlProxy', 'CassandraProxy', 'OpenApiProxy',
];
const OBJECT_PLUGINS = [
'ObjectPlugin', 'ApiPlugin', 'DisplayPlugin', 'StagePlugin',
];
// Per family: the framework classes it starts from, the methods as
// name => [parameter types, return type], the properties as name => type.
const FAMILIES = [
'plugin' => [
['AbstractPlugin', ...OBJECT_PLUGINS],
['onInit' => [[], 'void']],
[],
],
'object plugin' => [
OBJECT_PLUGINS,
[],
['$object' => '?IDataAccessObject'],
],
'system plugin' => [
['ISystemPlugin', 'ApiPlugin'],
[
'getResponseModel' => [['array', 'array'], 'Response'],
'install' => [[], 'void'],
'onDisplayMain' => [
[null, 'string|false', 'string|false', 'array'],
'?bool',
],
],
[],
],
'system object' => [
['ISystemObject', 'ISystemObjectUrlRules', 'SystemObject'],
[
'getSettings' => [[], 'array'],
'getUrlRules' => [['array'], 'array'],
'addUrlRule' => [['array'], 'mixed'],
],
[],
],
'user' => [
['DefaultUser'],
[
'get' => [['string'], 'mixed'],
'set' => [['string', 'mixed'], 'mixed'],
'getID' => [[], 'mixed'],
'getRole' => [[], 'mixed'],
'getSessionData' => [[], 'array'],
],
['$requiredFields' => 'array'],
],
'field' => [
FIELDS,
[
'getFilterTemplateName' => [[], 'string'],
'getInputType' => [[], 'string'],
],
[],
],
'handler field' => [
['HandlerField'],
['callHandlerMethod' => [['array', 'string'], 'mixed']],
[],
],
'price field' => [
['PriceField'],
['getCurrencySymbol' => [[], 'string|false']],
[],
],
'proxy' => [
PROXIES,
['insert' => [['array'], 'mixed']],
[],
],
'store' => [
['IStore'],
['getOption' => [['string'], 'mixed']],
[],
],
];
function shortName(string $name): string
{
return substr('\\'.$name, strrpos('\\'.$name, '\\') + 1);
}
function isToken(mixed $token, int ...$kinds): bool
{
return is_array($token) && in_array($token[0], $kinds, true);
}
function nextIndex(array $tokens, int $index): int
{
do {
$index++;
} while (isset($tokens[$index]) && isToken($tokens[$index], ...BLANK));
return $index;
}
function previousIndex(array $tokens, int $index): int
{
do {
$index--;
} while ($index >= 0 && isToken($tokens[$index], ...BLANK));
return $index;
}
// Returns the index of the bracket that closes the one at $index.
function closingIndex(array $tokens, int $index, string $close): int
{
$open = $tokens[$index];
$depth = 0;
for ($count = count($tokens); $index < $count; $index++) {
$token = $tokens[$index];
$isBrace = $open === '{' && isToken($token, ...BRACES);
if ($token === $open || $isBrace) {
$depth++;
} else if ($token === $close && --$depth === 0) {
return $index;
}
}
return $count - 1;
}
function findFiles(string $directory): array
{
$files = [];
$entries = scandir($directory) ?: [];
foreach (array_diff($entries, ['.', '..']) as $entry) {
$path = rtrim($directory, '/').'/'.$entry;
if (is_link($path)) {
continue;
}
if (is_dir($path) && !in_array($entry, SKIPPED_DIRECTORIES, true)) {
$files = array_merge($files, findFiles($path));
} else if (is_file($path) && str_ends_with($entry, '.php')) {
$files[] = $path;
}
}
return $files;
}
function typeText(array $tokens, int $from, int $to): string
{
$text = '';
for ($index = $from; $index <= $to; $index++) {
$token = $tokens[$index];
if (!isToken($token, ...BLANK, ...NOT_A_TYPE)) {
$text .= is_array($token) ? $token[1] : $token;
}
}
return $text;
}
// Returns the members of a type: '?Response' gives ['response', 'null'].
function typeMembers(string $type): array
{
$type = strtolower(str_replace(['(', ')', ' '], '', $type));
if (str_starts_with($type, '?')) {
$type = substr($type, 1).'|null';
}
return array_map('shortName', explode('|', $type));
}
function isCovered(string $member, array $members): bool
{
$isBool = in_array($member, ['true', 'false'], true);
return in_array($member, $members, true) ||
in_array('mixed', $members, true) ||
($isBool && in_array('bool', $members, true));
}
function isReturnAccepted(string $declared, string $parent): bool
{
$declared = typeMembers($declared);
$parent = typeMembers($parent);
if ($declared === ['never']) {
return true;
}
if ($parent === ['void'] || $declared === ['void']) {
return $declared === $parent;
}
foreach ($declared as $member) {
$isClass = !in_array($member, BUILT_IN, true);
$isParentClass = (bool) array_diff($parent, BUILT_IN);
if (!isCovered($member, $parent) && !($isClass && $isParentClass)) {
return false;
}
}
return true;
}
function isParameterAccepted(string $declared, string $parent): bool
{
$declared = typeMembers($declared);
foreach (typeMembers($parent) as $member) {
if (!isCovered($member, $declared)) {
return false;
}
}
return true;
}
// Returns the class, interface, trait and enum declarations of a file:
// name, the names after extends and implements, and the body range.
function readClasses(array $tokens): array
{
$classes = [];
foreach ($tokens as $index => $token) {
if (!isToken($token, ...CLASS_KINDS)) {
continue;
}
$before = $tokens[previousIndex($tokens, $index)] ?? null;
$name = $tokens[nextIndex($tokens, $index)] ?? null;
$isEnumWord = isToken($token, T_ENUM) && !isToken($name, T_STRING);
if (isToken($before, T_DOUBLE_COLON) || $isEnumWord) {
continue;
}
$class = [
'name' => isToken($name, T_STRING) ? $name[1] : '(anonymous)',
'parents' => [],
'extends' => [],
];
$from = $index + 1;
if ($tokens[nextIndex($tokens, $index)] === '(') {
$from = closingIndex($tokens, nextIndex($tokens, $index), ')');
}
$list = null;
while (isset($tokens[$from]) && $tokens[$from] !== '{') {
if (isToken($tokens[$from], T_EXTENDS, T_IMPLEMENTS)) {
$list = isToken($tokens[$from], T_EXTENDS) &&
isToken($token, T_CLASS) ? 'extends' : 'parents';
} else if ($list && isToken($tokens[$from], ...NAME_KINDS)) {
$class['parents'][] = shortName($tokens[$from][1]);
$class[$list][] = shortName($tokens[$from][1]);
}
$from++;
}
$class['from'] = $from;
$class['to'] = closingIndex($tokens, $from, '}');
$classes[] = $class;
}
return $classes;
}
// Returns the method that starts at the `function` token at $index.
function readMethod(array $tokens, int $index): array
{
$start = $index;
while (isToken($tokens[previousIndex($tokens, $start)], ...MODIFIERS)) {
$start = previousIndex($tokens, $start);
}
$name = nextIndex($tokens, $index);
if (isToken($tokens[$name], ...REFERENCES)) {
$name = nextIndex($tokens, $name);
}
$open = nextIndex($tokens, $name);
$close = closingIndex($tokens, $open, ')');
$parameters = [];
$from = $open + 1;
$depth = 0;
$isDefault = false;
for ($at = $open + 1; $at < $close; $at++) {
$token = $tokens[$at];
if (in_array($token, ['(', '['], true)) {
$depth++;
} else if (in_array($token, [')', ']'], true)) {
$depth--;
} else if ($depth === 0 && $token === ',') {
$from = $at + 1;
$isDefault = false;
} else if ($depth === 0 && !$isDefault && isToken($token, T_VARIABLE)) {
$parameters[] = typeText($tokens, $from, $at - 1);
$isDefault = true;
}
}
$end = $close;
$return = '';
$after = nextIndex($tokens, $close);
if ($tokens[$after] === ':') {
$end = $after;
while (!in_array($tokens[$end + 1] ?? ';', ['{', ';'], true)) {
$end++;
}
$end = previousIndex($tokens, $end + 1);
$return = typeText($tokens, $after + 1, $end);
$after = nextIndex($tokens, $end);
}
$text = '';
for ($at = $start; $at <= $end; $at++) {
$text .= is_array($tokens[$at]) ? $tokens[$at][1] : $tokens[$at];
}
$text = preg_replace('/\s+/', ' ', $text);
return [
'name' => $tokens[$name][1],
'line' => $tokens[$index][2],
'parameters' => $parameters,
'return' => $return,
'close' => $close,
'text' => str_replace(['( ', ' )'], ['(', ')'], $text),
'body' => $tokens[$after] === '{' ? $after : null,
'to' => $tokens[$after] === '{'
? closingIndex($tokens, $after, '}')
: $after,
];
}
// Returns what the body that opens at $open has of its own, outside the
// closures and classes declared in it: [a `return <value>;`, a `return;`,
// a `throw`].
function readReturns(array $tokens, int $open): array
{
$close = closingIndex($tokens, $open, '}');
$found = [false, false, false];
for ($index = $open + 1; $index < $close; $index++) {
$token = $tokens[$index];
if (isToken($token, T_FUNCTION, ...CLASS_KINDS)) {
while (!in_array($tokens[$index], ['{', ';'], true)) {
$index++;
}
$index = closingIndex($tokens, $index, '}');
} else if (isToken($token, T_RETURN)) {
$isBare = $tokens[nextIndex($tokens, $index)] === ';';
$found[$isBare ? 1 : 0] = true;
} else if (isToken($token, T_THROW)) {
$found[2] = true;
}
}
return $found;
}
// Returns the traits a class body uses, its methods and its properties.
function readMembers(array $tokens, array $class): array
{
$members = ['traits' => [], 'methods' => [], 'properties' => []];
for ($index = $class['from'] + 1; $index < $class['to']; $index++) {
$token = $tokens[$index];
if ($token === '{' || isToken($token, ...BRACES)) {
$index = closingIndex($tokens, $index, '}');
} else if (isToken($token, T_USE)) {
while (!in_array($tokens[++$index], [';', '{'], true)) {
if (isToken($tokens[$index], ...NAME_KINDS)) {
$members['traits'][] = shortName($tokens[$index][1]);
}
}
$index--;
} else if (isToken($token, T_FUNCTION)) {
$method = readMethod($tokens, $index);
$members['methods'][] = $method;
$index = $method['to'];
} else if (isToken($token, T_VARIABLE)) {
$from = $index;
$stops = [';', '{', '}', ','];
while (!in_array($tokens[$from - 1], $stops, true) &&
!isToken($tokens[$from - 1], ...MODIFIERS)) {
$from--;
}
$next = $tokens[nextIndex($tokens, $index)];
$members['properties'][] = [
'name' => $token[1],
'line' => $token[2],
'index' => $index,
'type' => typeText($tokens, $from, $index - 1),
'hasDefault' => $next === '=',
];
while (!in_array($tokens[$index], [';', '{'], true)) {
$index++;
}
$index--;
}
}
return $members;
}
// Returns the tokens of a file, its declarations with their members and
// whether it declares a namespace; null when PHP cannot parse it.
function readSource(string $path): ?array
{
$source = file_get_contents($path);
if (!preg_match('/\b(class|interface|trait|enum)\b/i', $source)) {
return null;
}
try {
$tokens = token_get_all($source, TOKEN_PARSE);
} catch (ParseError $error) {
fwrite(STDERR, $path.': not parsed, '.$error->getMessage()."\n");
return null;
}
$classes = [];
$isNamespaced = false;
foreach ($tokens as $token) {
$isNamespaced = $isNamespaced || isToken($token, T_NAMESPACE);
}
foreach (readClasses($tokens) as $class) {
$classes[] = $class + readMembers($tokens, $class);
}
return [$tokens, $classes, $isNamespaced];
}
// Returns the type as it is written in a file: a class name of the
// global namespace gets a leading backslash in a namespaced file.
function writtenType(string $type, bool $isNamespaced): string
{
$name = ltrim($type, '?');
if (!$isNamespaced || in_array(strtolower($name), BUILT_IN, true) ||
str_contains($name, '|')) {
return $type;
}
return str_replace($name, '\\'.$name, $type);
}
// Returns what a declaration has to change for the families it is in:
// [line, kind, text, token index or null, text to write or null].
function readFindings(
array $tokens,
array $class,
array $families,
bool $isNamespaced
): array
{
$findings = [];
$owner = $class['name'];
foreach ($families as $family) {
[, $methods, $properties] = FAMILIES[$family];
foreach ($class['methods'] as $method) {
$rule = $methods[$method['name']] ?? null;
if ($rule === null) {
continue;
}
[$types, $return] = $rule;
$written = writtenType($return, $isNamespaced);
$line = $method['line'];
$name = $owner.'::'.$method['name'].'()';
[$isValue, $isBare, $isThrow] = $method['body'] === null
? [true, false, false]
: readReturns($tokens, $method['body']);
$isEmpty = $isBare || (!$isValue && !$isThrow);
foreach ($method['parameters'] as $position => $declared) {
$parent = $types[$position] ?? null;
if ($declared !== '' && $parent !== null &&
!isParameterAccepted($declared, $parent)) {
$findings[] = [
$line,
'by hand',
$name.': parameter '.
($position + 1).' declares '.$declared.
', the framework declares '.$parent.': `'.
$method['text'].'`',
null,
null,
];
}
}
if ($return === 'void' && $isValue) {
$findings[] = [
$line,
'by hand',
$name.' returns a value and the framework declares '.
'`: void`: `'.$method['text'].'`',
null,
null,
];
} else if ($return !== 'void' && $isEmpty) {
$findings[] = [
$line,
'by hand',
$name.' has a `return;` or no `return`, and the '.
'framework declares `: '.$return.'`: return a value '.
'and declare the type: `'.$method['text'].'`',
null,
null,
];
} else if ($method['return'] === '') {
$findings[] = [
$line,
'return',
$owner.': `'.$method['text'].'` -> `'.$method['text'].
': '.$written.'`',
$method['close'],
'): '.$written,
];
} else if (!isReturnAccepted($method['return'], $return)) {
$findings[] = [
$line,
'by hand',
$name.' declares `: '.
$method['return'].'`, the framework declares `: '.
$return.'`: `'.$method['text'].'`',
null,
null,
];
}
}
foreach ($class['properties'] as $property) {
$type = $properties[$property['name']] ?? null;
if ($type === null) {
continue;
}
$written = writtenType($type, $isNamespaced);
$isNullable = str_starts_with($type, '?');
$default = $isNullable && !$property['hasDefault']
? ' = null'
: '';
if ($property['type'] === '') {
$findings[] = [
$property['line'],
'property',
$owner.': `'.$property['name'].'` -> `'.$written.' '.
$property['name'].$default.'`',
$property['index'],
$written.' '.$property['name'].$default,
];
} else if (typeMembers($property['type']) !== typeMembers($type)) {
$findings[] = [
$property['line'],
'by hand',
$owner.'::'.$property['name'].' declares '.
$property['type'].', the framework declares '.$type,
null,
null,
];
}
}
}
return $findings;
}
error_reporting(E_ALL & ~E_DEPRECATED);
$arguments = array_slice($argv, 1);
$isWrite = in_array('--write', $arguments, true);
$isVendor = in_array('--vendor', $arguments, true);
$files = [];
foreach (array_diff($arguments, ['--write', '--vendor']) as $argument) {
$files = array_merge($files, findFiles($argument));
}
// Where the families of a name come from, by short name: a class has
// the families of what it extends and implements, a trait has the
// families of the classes that use it.
$sources = [];
$declared = [];
$paths = [];
foreach ($files as $path) {
foreach (readSource($path)[1] ?? [] as $class) {
$name = $class['name'];
$declared[$name] = true;
$paths[$path] = true;
foreach ($class['parents'] as $parent) {
$sources[$name][$parent] = true;
}
foreach ($class['traits'] as $trait) {
$sources[$trait][$name] = true;
}
}
}
$familiesOf = [];
foreach (FAMILIES as $family => [$roots]) {
foreach ($roots as $root) {
$familiesOf[$root][$family] = true;
}
}
do {
$isGrown = false;
foreach ($sources as $name => $from) {
foreach (array_keys($from) as $source) {
$new = array_diff_key(
$familiesOf[$source] ?? [],
$familiesOf[$name] ?? []
);
$familiesOf[$name] = ($familiesOf[$name] ?? []) + $new;
$isGrown = $isGrown || $new;
}
}
} while ($isGrown);
$total = ['return' => 0, 'property' => 0, 'by hand' => 0, 'files' => 0];
$unknown = [];
foreach ($files as $path) {
$isVendorFile = str_contains('/'.$path, '/vendor/');
if (!isset($paths[$path]) || ($isVendorFile && !$isVendor)) {
continue;
}
[$tokens, $classes, $isNamespaced] = readSource($path);
$isChanged = false;
foreach ($classes as $class) {
foreach ($class['extends'] as $parent) {
$isKnown = isset($declared[$parent]) ||
isset($familiesOf[$parent]) || class_exists($parent, false);
if (!$isKnown && preg_match(
'/(Plugin|Field|Proxy|Object|User)$/',
$parent
)) {
$unknown[$parent] = true;
}
}
$families = array_keys($familiesOf[$class['name']] ?? []);
$isRoot = false;
foreach (FAMILIES as [$roots]) {
$isRoot = $isRoot || in_array($class['name'], $roots, true);
}
if ($isRoot || !$families) {
continue;
}
$findings = readFindings($tokens, $class, $families, $isNamespaced);
usort($findings, fn (array $one, array $two) => $one[0] <=> $two[0]);
foreach ($findings as [$line, $kind, $text, $index, $new]) {
if ($isWrite && $index !== null) {
$tokens[$index] = $new;
$isChanged = true;
$kind .= ', rewritten';
}
$total[explode(',', $kind)[0]]++;
printf("%s:%d: [%s] %s\n", $path, $line, $kind, $text);
}
}
if ($isChanged) {
$source = '';
foreach ($tokens as $token) {
$source .= is_array($token) ? $token[1] : $token;
}
file_put_contents($path, $source);
$total['files']++;
}
}
if ($unknown) {
ksort($unknown);
echo "\nNot checked, the parent class is not in the directories: ",
implode(', ', array_keys($unknown)), "\n";
}
printf(
"\n%d return types and %d property types %s, %d by hand%s\n",
$total['return'],
$total['property'],
$isWrite ? 'added' : 'to add (--write adds them)',
$total['by hand'],
$isWrite ? ', '.$total['files'].' files rewritten' : ''
);
exit($total['by hand'] || (!$isWrite && $total['return'] + $total['property'])
? 1
: 0);
Run it on the directories that hold classes, then let it add the types:
php native-type-overrides.php .
php native-type-overrides.php --write .
plugins/Jimbo/JimboObject.php:74: [return] JimboObject: `public function getSettings()` -> `public function getSettings(): array`
plugins/Contents/ContentsPlugin.php:34: [property] ContentsPlugin: `$object` -> `?IDataAccessObject $object = null`
tests/Helper/MockPlainSystemPlugin.php:55: [by hand] MockPlainSystemPlugin::onDisplayMain() has a `return;` or no `return`, and the framework declares `: ?bool`: return a value and declare the type: `public function onDisplayMain(Response &$response, $storeName = false, $pluginName = false, $params = array())`
2 return types and 1 property types to add (--write adds them), 1 by hand
Notes:
- The script reads the files and loads no class. It follows
extends,implementsand theuseof a trait by the short name of a class, so two classes with one name in different namespaces count as one. - A directory named
vendoris read to find the parents and is not reported.--vendorreports it too, to see what a package has to change. Not checked, the parent class is not in the directories: Xnames a parent the script did not find. Give it the directory that holdsX, such as the plugins of the project orvendor.[return]and[property]are what--writechanges: it adds the return type after the parameters, and the type in front of the property;$objectalso gets= nullwhen it has no default. In a file with a namespace it writes\Responseand\IDataAccessObject. It changes nothing else in the file.[by hand]is never rewritten: areturn <value>;inonInit()orinstall(), a method that has to return a value and has areturn;or noreturn(a method whose body only throws is not reported), a return type or a parameter type the framework type does not include, and a property declared with another type.- An added return type checks what the method returns. Read each
rewritten method once: one that returns a value of another type, on
some path, throws a
TypeErrorwhen that path runs. - The exit code is 1 while something is left to change.
Run php -l on the rewritten files, the class-load check and the tests
after --write.
What to do
- Add the return type of the table to each override. Remove the value
from a
returninonInit()andinstall(). Addreturn null;where a method that now declares?boolormixedends without a value. - For
$object, remove the redeclaration and keep the class of the object in the docblock of the class, or repeat the type exactly:protected ?IDataAccessObject $object = null;with@var UsersObjectabove it. - For
$requiredFields, declareprotected array $requiredFields, or remove the redeclaration and set the property in the constructor. - In a class that extends
PermissionsExceptionand declares a constructor, callparent::__construct(), or declareprotected $code = 403;in that class. - Pass a string as the name to
DefaultUser::get()andset(), and an array togetUrlRules(),addUrlRule()andcallHandlerMethod().
Before:
<?php
class OrdersPlugin extends DisplayPlugin
{
/** @var OrdersObject */
protected $object;
public function onInit()
{
$this->core->includeCss('orders.css');
return parent::onInit();
}
}
class OrdersObject extends SystemObject
{
public function getSettings()
{
return $this->getAssoc('SELECT name, value FROM orders_settings');
}
}
class OrdersSystemPlugin extends ApiPlugin
{
public function install()
{
return true;
}
public function onDisplayMain(
Response &$response,
$storeName = false,
$pluginName = false,
$params = array()
)
{
if (!$storeName) {
return;
}
$store = $this->createStoreInstance($storeName, $params);
$store->onRequest($response);
}
}
class OrdersUser extends DefaultUser
{
protected $requiredFields = ['auth', 'auth_id', 'id_shop'];
public function getID()
{
return (int) parent::getID();
}
}
class OrderStatusField extends SelectField
{
public function getInputType()
{
return 'hidden';
}
}
abstract class ShopApiProxy extends core\store\proxy\OpenApiProxy
{
public function insert(array $values)
{
$values['source'] = 'shop';
return parent::insert($values);
}
}
After:
<?php
/**
* @property OrdersObject $object
*/
class OrdersPlugin extends DisplayPlugin
{
public function onInit(): void
{
$this->core->includeCss('orders.css');
parent::onInit();
}
}
class OrdersObject extends SystemObject
{
public function getSettings(): array
{
return $this->getAssoc('SELECT name, value FROM orders_settings');
}
}
class OrdersSystemPlugin extends ApiPlugin
{
public function install(): void
{
}
public function onDisplayMain(
Response &$response,
$storeName = false,
$pluginName = false,
$params = array()
): ?bool
{
if (!$storeName) {
return null;
}
$store = $this->createStoreInstance($storeName, $params);
$store->onRequest($response);
return true;
}
}
class OrdersUser extends DefaultUser
{
protected array $requiredFields = ['auth', 'auth_id', 'id_shop'];
public function getID(): int
{
return (int) parent::getID();
}
}
class OrderStatusField extends SelectField
{
public function getInputType(): string
{
return 'hidden';
}
}
abstract class ShopApiProxy extends core\store\proxy\OpenApiProxy
{
public function insert(array $values): mixed
{
$values['source'] = 'shop';
return parent::insert($values);
}
}
OrdersUser::getID() declares int: an override may declare any type
that is part of the framework type, and every type but void is part of
mixed.
Checklist per repository
The lists below are what the script reports for the repositories of the
shared plugins and packages, as they were cloned on
one machine on 2026-10-10. A clone can be behind its repository, and a
repository that was not cloned there is not listed: the script, run in
the repository, is the authority, and the lists are a start. Each line
is file:line in the repository. All but the lines marked "by hand" are
what --write does.
Order of the work:
- The shared plugins and packages first. A return type is valid with
the release before this one too, so a plugin that adds the return
types, and removes the redeclaration of
$objectinstead of typing it, works with both releases and can be released before the framework. - The framework.
- The projects, each with its update of the framework.
The test helpers of festi-framework-theme (tests/Helper) are loaded
by the tests of the theme package and by the tests of a project theme
that extend Festi\Theme\Tests\ThemeTestCase. Those tests stop with the
first error of the table above from the moment they run with this
release, until the theme package has the four changes listed for it.
The tests of the framework do not load these helpers.
The shared plugins and packages:
FestiCore/php_festi_cli
Read at commit 46bbe2d of 2026-10-07, branch v7.2: 6 declarations, 2 of them by hand.
public function getResponseModel($call, $regs = array())becomespublic function getResponseModel($call, $regs = array()): Response:tests/Resources/plugins/CliSystemPluginMock/CliSystemPluginMockPlugin.php:24tests/Resources/plugins/System/SystemPlugin.php:14public function install()becomespublic function install(): void:tests/Resources/plugins/CliSystemPluginMock/CliSystemPluginMockPlugin.php:39tests/Resources/plugins/System/SystemPlugin.php:29- By hand:
public function onDisplayMain(Response &$response, $storeName = false, $pluginName = false, $params = array())becomespublic function onDisplayMain(Response &$response, $storeName = false, $pluginName = false, $params = array()): ?bool, and the method gets areturnwith a value, which it does not have now: tests/Resources/plugins/CliSystemPluginMock/CliSystemPluginMockPlugin.php:43- By hand:
public function onDisplayMain(Response &$response, $storeName = false, $pluginName = false, $params = array())becomespublic function onDisplayMain(Response &$response, $storeName = false, $pluginName = false, $params = array()): ?bool, and the method gets areturnwith a value, which it does not have now: tests/Resources/plugins/System/SystemPlugin.php:33
FestiPlugins/php_festi_plugin_acl
Read at commit dd50f79 of 2026-01-15: 1 declaration.
public function onInit()becomespublic function onInit(): void:ACLManagePlugin.php:32
FestiPlugins/php_festi_plugin_chat
Read at commit 4f05ae5 of 2026-09-16: 1 declaration.
public function onInit()becomespublic function onInit(): void:ChatPlugin.php:79
FestiPlugins/PHP_Festi_Plugin_Contents
Read at commit 6a7c935 of 2026-07-15: 1 declaration.
- The property
$object, declared without a type, becomes?IDataAccessObject $object = null: ContentsPlugin.php:34
FestiPlugins/php_festi_plugin_courses
Read at commit 82c45de of 2026-09-14: 1 declaration.
public function onInit()becomespublic function onInit(): void:CoursesPlugin.php:47
FestiCore/PHP_Festi_Plugin_GameWorld
Read at commit 09c12be of 2021-04-28: 5 declarations.
public function install()becomespublic function install(): void:GameWorldPlugin.php:17public function onInit()becomespublic function onInit(): void:GameWorldPlugin.php:22- The property
$requiredFields, declared without a type, becomesarray $requiredFields: world/platform/server/GameUser.php:9public function get($name)becomespublic function get($name): mixed:world/platform/server/GameUser.php:27public function getID()becomespublic function getID(): mixed:world/platform/server/GameUser.php:51
FestiSystemPlugins/PHP_Festi_Plugin_Jimbo
The Jimbo plugin. Some clones name the repository
FestiCore/PHP_Festi_Plugin_Jimbo.
Read at commit dac2aba of 2026-10-06: 7 declarations.
public function install()becomespublic function install(): void:Domain/Install/InstallTrait.php:43public function getResponseModel($call, $regs = array())becomespublic function getResponseModel($call, $regs = array()): \Response:Domain/Request/RequestHandlerTrait.php:637public function getUrlRules($search)becomespublic function getUrlRules($search): array:JimboObject.php:58public function getSettings()becomespublic function getSettings(): array:JimboObject.php:74public function addUrlRule($values)becomespublic function addUrlRule($values): mixed:JimboObject.php:128public function onInit()becomespublic function onInit(): void:JimboPlugin.php:60public function onDisplayMain(Response &$response, $table = false, $pluginName = false, $params = array())becomespublic function onDisplayMain(Response &$response, $table = false, $pluginName = false, $params = array()): ?bool:JimboPlugin.php:168
FestiPlugins/php_festi_plugin_modules
Read at commit e4c48e9 of 2026-09-18: 1 declaration.
public function onInit()becomespublic function onInit(): void:ModulesPlugin.php:64
FestiCore/php_festiframework_compatibilitywrapper
Read at commit 0712734 of 2025-03-14, branch master: 5 declarations, 2 of them by hand.
public function getRole()becomespublic function getRole(): mixed:src/wordpress/WpUserBridge.php:23public function get($name)becomespublic function get($name): mixed:src/wordpress/WpUserBridge.php:38public function getID()becomespublic function getID(): mixed:src/wordpress/WpUserBridge.php:64- By hand:
public function set($name, $value)becomespublic function set($name, $value): mixed, and the method gets areturnwith a value, which it does not have now: src/wordpress/WpUserBridge.php:56- By hand:
public function getSessionData()becomespublic function getSessionData(): array, and the method gets areturnwith a value, which it does not have now: src/wordpress/WpUserBridge.php:60
FestiCore/php_festiframework_theme
Read at commit b5864cf of 2026-09-18, branch develop: 4 declarations, 1 of them by hand.
public function getResponseModel($call, $regs = array())becomespublic function getResponseModel($call, $regs = array()): \Response:tests/Helper/MockPlainSystemPlugin.php:34public function install()becomespublic function install(): void:tests/Helper/MockPlainSystemPlugin.php:50public function insert(array $values)becomespublic function insert(array $values): mixed:tests/Helper/TestStoreProxy.php:58- By hand:
public function onDisplayMain(Response &$response, $storeName = false, $pluginName = false, $params = array())becomespublic function onDisplayMain(Response &$response, $storeName = false, $pluginName = false, $params = array()): ?bool, and the method gets areturnwith a value, which it does not have now: tests/Helper/MockPlainSystemPlugin.php:55
Read with the script, with classes of the kinds above and nothing to change:
FestiCore/PHP_Festi_Plugin_GameWorldManager,
FestiCore/PHP_Festi_Plugin_Queues, FestiCore/PHP_Festi_Plugin_RESTful,
FestiCore/php_festi_plugin_users, FestiCore/php_festi_workbench,
FestiCore/PHP_FestiServer, FestiCore/PHP_ObjectDB,
FestiPlugins/PHP_Festi_Plugin_Activity_Logs,
FestiPlugins/php_festi_plugin_ai_connector,
FestiPlugins/php_festi_plugin_contactus,
FestiPlugins/php_festi_plugin_dashboard,
FestiPlugins/php_festi_plugin_festiide,
FestiPlugins/PHP_Festi_Plugin_Mail,
FestiPlugins/php_festi_plugin_mcpserver,
FestiPlugins/php_festi_plugin_nativeappintegration,
FestiPlugins/php_festi_plugin_pipeline,
FestiPlugins/php_festi_plugin_reportconstructor,
FestiPlugins/php_festi_plugin_scraper,
FestiPlugins/PHP_Festi_Plugin_Subscribe,
FestiPlugins/php_festi_plugin_tinymce,
FestiPlugins/php_festiasync_plugin_rpc,
festistorefields/php_festi_markdownfield,
FestiSystemPlugins/php_festi_plugin_rpc. FestiCore/PHP_Festi_Cron has no class
of these kinds. The tests of FestiPlugins/php_festi_plugin_mcpserver and
of FestiSystemPlugins/php_festi_plugin_rpc use the Jimbo plugin as a
submodule, which has to point at a commit with the changes of the plugin.
A project runs the script in its own repository: most of what it
reports there is onInit() of the project plugins, which --write
types. A project that keeps a copy of the Jimbo plugin in its tree
takes the changes of the plugin for the copy too.
Adding a section
A merge request that breaks project code adds a section to this file:
- Add a
##section after the last change section and before this one, or a###subsection of "Native types and strict_types" when the change is the type of a member. - Use the four labels in this order: What changed, Who is affected, How to find it, What to do.
- Give real signatures, a command that finds the affected project code,
and a before and after example that passes
php -l. - Say which part is not breaking in practice, if there is one.
- Add a row to the summary, and a row to the error table when the change has an error message of its own.