diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 01d7d59..ea85e0e 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -1,5 +1,7 @@ in([ __DIR__ . '/src', diff --git a/EXAMPLE.md b/EXAMPLE.md index 23cd7c9..6185dcf 100644 --- a/EXAMPLE.md +++ b/EXAMPLE.md @@ -35,7 +35,7 @@ echo $category->created_at; echo $category->updated_at; ``` -`save()` inserts when the primary key is empty and updates when the primary key is present. +`save()` inserts when the primary key is `null` and updates when the primary key is present. ## Load a record by primary key @@ -93,17 +93,7 @@ $count = Category::count(); ## Dynamic finders -Snake_case methods: - -```php -$all = Category::find_by_name('Fiction'); -$one = Category::findOne_by_name('Fiction'); -$first = Category::first_by_name(['Fiction', 'Fantasy']); -$last = Category::last_by_name(['Fiction', 'Fantasy']); -$count = Category::count_by_name('Fiction'); -``` - -CamelCase alternatives: +Preferred camelCase methods: ```php $all = Category::findByName('Fiction'); @@ -113,6 +103,30 @@ $last = Category::lastByName(['Fiction', 'Fantasy']); $count = Category::countByName('Fiction'); ``` +Legacy snake_case dynamic methods still work during the transition, but they are deprecated and should be migrated. + +## Focused query helpers + +Check existence: + +```php +$hasCategories = Category::exists(); +$hasFiction = Category::existsWhere('name = ?', ['Fiction']); +``` + +Fetch ordered results: + +```php +$alphabetical = Category::fetchAllWhereOrderedBy('name', 'ASC'); +$latest = Category::fetchOneWhereOrderedBy('id', 'DESC'); +``` + +Pluck one column: + +```php +$names = Category::pluck('name', '', [], 'name', 'ASC', 10); +``` + ## Custom WHERE clauses Fetch a single matching record: @@ -181,21 +195,43 @@ $rows = $statement->fetchAll(PDO::FETCH_ASSOC); ## Validation -Override `validate()` to enforce model rules before insert and update: +Use instance-aware hooks to enforce model rules before insert and update: ```php class Category extends Freshsauce\Model\Model { protected static $_tableName = 'categories'; - public static function validate() + protected function validateForSave(): void { - return true; + if (trim((string) $this->name) === '') { + throw new RuntimeException('Name is required'); + } } } ``` -Throw an exception from `validate()` whenever your application-specific rule fails, and the write will be aborted before insert or update. +Override `validateForInsert()` or `validateForUpdate()` when the rules are operation-specific. + +The legacy static `validate()` method remains supported for backward compatibility. + +## Strict field mode + +Enable strict field mode when you want unknown assignments to fail immediately instead of being silently ignored on persistence: + +```php +class Category extends Freshsauce\Model\Model +{ + protected static $_tableName = 'categories'; + protected static bool $_strict_fields = true; +} +``` + +Or enable it temporarily: + +```php +Category::useStrictFields(true); +``` ## MySQL example connection diff --git a/README.md b/README.md index be96a41..e699157 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,16 @@ Model ORM for PHP If you want database-backed PHP models without pulling in a heavyweight stack, this library is built for that job. +## When it fits + +Use this library when you want: + +- Active-record style models without adopting a full framework ORM +- Direct access to SQL and PDO when convenience helpers stop helping +- A small API surface that stays easy to understand in legacy or custom PHP apps + +Skip it if you need relationship graphs, migrations, or a chainable query builder comparable to framework ORMs. + ## Why teams pick it - Lightweight by design: point a model at a table and start reading and writing records. @@ -110,6 +120,22 @@ Category::countByName('Science Fiction'); Legacy snake_case dynamic methods still work during the transition, but they are deprecated and emit `E_USER_DEPRECATED` notices. +If you still have legacy calls such as `find_by_name()`, treat them as migration work rather than the preferred API. + +### Focused query helpers + +For common read patterns that do not justify raw SQL: + +```php +Category::exists(); +Category::existsWhere('name = ?', ['Science Fiction']); + +$ordered = Category::fetchAllWhereOrderedBy('name', 'ASC'); +$latest = Category::fetchOneWhereOrderedBy('id', 'DESC'); + +$names = Category::pluck('name', '', [], 'name', 'ASC', 10); +``` + ### Flexible SQL when convenience methods stop helping Use targeted where clauses: @@ -133,21 +159,39 @@ $rows = $statement->fetchAll(PDO::FETCH_ASSOC); ### Validation hooks -Override `validate()` in your model when writes need application rules: +Use instance-aware hooks when writes need application rules: ```php class Category extends Freshsauce\Model\Model { protected static $_tableName = 'categories'; - public static function validate() + protected function validateForSave(): void { - return true; + if (trim((string) $this->name) === '') { + throw new RuntimeException('Name is required'); + } } } ``` -Throw an exception from `validate()` to block invalid inserts or updates. +Use `validateForInsert()` or `validateForUpdate()` when the rules differ by operation. + +The legacy static `validate()` method still works for backward compatibility, but new code should prefer the instance hooks. + +### Strict field mode + +Unknown properties are permissive by default for compatibility. If you want typo-safe writes, enable strict field mode: + +```php +class Category extends Freshsauce\Model\Model +{ + protected static $_tableName = 'categories'; + protected static bool $_strict_fields = true; +} +``` + +You can also opt in at runtime with `Category::useStrictFields(true)`. ### Predictable exceptions diff --git a/src/Model/Exception/ConfigurationException.php b/src/Model/Exception/ConfigurationException.php index 8c2478f..f400387 100644 --- a/src/Model/Exception/ConfigurationException.php +++ b/src/Model/Exception/ConfigurationException.php @@ -1,5 +1,7 @@ clearDirtyFields(); @@ -102,7 +109,7 @@ public function __construct($data = array()) * * @return bool */ - public function hasData() + public function hasData(): bool { return is_object($this->data); } @@ -132,8 +139,11 @@ public function dataPresent() * * @return void */ - public function __set($name, $value) + public function __set(string $name, mixed $value): void { + if (static::strictFieldsEnabled()) { + $name = static::resolveFieldName($name); + } if (!$this->hasData()) { $this->data = new \stdClass(); } @@ -158,7 +168,7 @@ public function markFieldDirty(string $name): void * * @return bool */ - public function isFieldDirty($name) + public function isFieldDirty(string $name): bool { return isset($this->dirty->$name) && ($this->dirty->$name == true); } @@ -181,7 +191,7 @@ public function clearDirtyFields(): void * @throws MissingDataException * @throws UnknownFieldException */ - public function __get($name) + public function __get(string $name): mixed { $data = $this->data; if (!$data instanceof \stdClass) { @@ -211,7 +221,7 @@ public function __get($name) * * @return bool */ - public function __isset($name) + public function __isset(string $name): bool { $data = $this->data; if ($data instanceof \stdClass && property_exists($data, $name)) { @@ -327,7 +337,7 @@ public static function driverName(): string * * @return string */ - protected static function _quote_identifier($identifier): string + protected static function _quote_identifier(string $identifier): string { $class = get_called_class(); $parts = explode('.', $identifier); @@ -693,7 +703,7 @@ protected static function snakeCaseDynamicMethodToCamelCase(string $name): strin * @throws \PDOException * @throws ModelException */ - protected static function resolveFieldName($fieldname) + protected static function resolveFieldName(string $fieldname): string { $fieldnames = static::getFieldnames(); if (in_array($fieldname, $fieldnames, true)) { @@ -723,7 +733,7 @@ protected static function resolveFieldName($fieldname) * * @return string */ - protected static function camelToSnake($fieldname) + protected static function camelToSnake(string $fieldname): string { $snake = preg_replace('/(?fetchColumn(0); } + /** + * returns true when the table contains at least one row + * + * @return bool + */ + public static function exists(): bool + { + $st = static::execute( + 'SELECT 1 FROM ' . static::_quote_identifier(static::$_tableName) . ' LIMIT 1' + ); + + return $st->fetchColumn(0) !== false; + } + /** * returns an integer count of matching rows * * @param string $SQLfragment conditions, grouping to apply (to right of WHERE keyword) - * @param array $params optional params to be escaped and injected into the SQL query (standrd PDO syntax) + * @param array $params optional params to be escaped and injected into the SQL query (standard PDO syntax) * * @return integer count of rows matching conditions */ - public static function countAllWhere($SQLfragment = '', $params = array()) + public static function countAllWhere(string $SQLfragment = '', array $params = []): int { $SQLfragment = self::addWherePrefix($SQLfragment); $st = static::execute('SELECT COUNT(*) FROM ' . static::_quote_identifier(static::$_tableName) . $SQLfragment, $params); return (int)$st->fetchColumn(0); } + /** + * returns true when at least one row matches the conditions + * + * @param string $SQLfragment + * @param array $params + * + * @return bool + */ + public static function existsWhere(string $SQLfragment = '', array $params = []): bool + { + $SQLfragment = self::addWherePrefix($SQLfragment); + $sql = 'SELECT 1 FROM ' . static::_quote_identifier(static::$_tableName) . $SQLfragment . ' LIMIT 1'; + $st = static::execute($sql, $params); + + return $st->fetchColumn(0) !== false; + } + /** * if $SQLfragment is not empty prefix with the WHERE keyword * @@ -853,27 +894,72 @@ public static function countAllWhere($SQLfragment = '', $params = array()) * * @return string */ - protected static function addWherePrefix($SQLfragment) + protected static function addWherePrefix(string $SQLfragment): string { return $SQLfragment ? ' WHERE ' . $SQLfragment : $SQLfragment; } + /** + * Build ORDER BY / LIMIT clauses for helper queries. + * + * @param string|null $orderByField + * @param string $direction + * @param int|null $limit + * + * @return string + * @throws ConfigurationException + * @throws UnknownFieldException + * @throws \PDOException + * @throws ModelException + */ + protected static function buildOrderAndLimitClause(?string $orderByField = null, string $direction = 'ASC', ?int $limit = null): string + { + $suffix = ''; + if ($orderByField !== null) { + $suffix .= ' ORDER BY ' . static::_quote_identifier(static::resolveFieldName($orderByField)) . ' ' . static::normaliseOrderDirection($direction); + } + if ($limit !== null) { + if ($limit < 1) { + throw new ConfigurationException('Limit must be greater than zero.'); + } + $suffix .= ' LIMIT ' . $limit; + } + + return $suffix; + } + + /** + * @param string $direction + * + * @return string + * @throws ConfigurationException + */ + protected static function normaliseOrderDirection(string $direction): string + { + $direction = strtoupper($direction); + if (!in_array($direction, ['ASC', 'DESC'], true)) { + throw new ConfigurationException('Unsupported order direction [' . $direction . ']. Use ASC or DESC.'); + } + + return $direction; + } + /** * returns an array of objects of the sub-class which match the conditions * * @param string $SQLfragment conditions, sorting, grouping and limit to apply (to right of WHERE keywords) - * @param array $params optional params to be escaped and injected into the SQL query (standrd PDO syntax) + * @param array $params optional params to be escaped and injected into the SQL query (standard PDO syntax) * @param bool $limitOne if true the first match will be returned * * @return array|static|null object[]|object of objects of calling class */ - public static function fetchWhere($SQLfragment = '', $params = array(), $limitOne = false): array|static|null + protected static function fetchWhereWithSuffix(string $SQLfragment = '', array $params = [], bool $limitOne = false, string $suffix = ''): array|static|null { $class = get_called_class(); $SQLfragment = self::addWherePrefix($SQLfragment); $st = static::execute( - 'SELECT * FROM ' . static::_quote_identifier(static::$_tableName) . $SQLfragment . ($limitOne ? ' LIMIT 1' : ''), + 'SELECT * FROM ' . static::_quote_identifier(static::$_tableName) . $SQLfragment . $suffix . ($limitOne ? ' LIMIT 1' : ''), $params ); $st->setFetchMode(\PDO::FETCH_ASSOC); @@ -895,6 +981,20 @@ public static function fetchWhere($SQLfragment = '', $params = array(), $limitOn return $results; } + /** + * returns an array of objects of the sub-class which match the conditions + * + * @param string $SQLfragment conditions, sorting, grouping and limit to apply (to right of WHERE keywords) + * @param array $params optional params to be escaped and injected into the SQL query (standrd PDO syntax) + * @param bool $limitOne if true the first match will be returned + * + * @return array|static|null + */ + public static function fetchWhere(string $SQLfragment = '', array $params = [], bool $limitOne = false): array|static|null + { + return static::fetchWhereWithSuffix($SQLfragment, $params, $limitOne); + } + /** * returns an array of objects of the sub-class which match the conditions * @@ -903,7 +1003,7 @@ public static function fetchWhere($SQLfragment = '', $params = array(), $limitOn * * @return array object[] of objects of calling class */ - public static function fetchAllWhere($SQLfragment = '', $params = array()): array + public static function fetchAllWhere(string $SQLfragment = '', array $params = []): array { /** @var array $results */ $results = static::fetchWhere($SQLfragment, $params, false); @@ -918,13 +1018,96 @@ public static function fetchAllWhere($SQLfragment = '', $params = array()): arra * * @return static|null object of calling class */ - public static function fetchOneWhere($SQLfragment = '', $params = array()): ?static + public static function fetchOneWhere(string $SQLfragment = '', array $params = []): ?static { /** @var static $result */ $result = static::fetchWhere($SQLfragment, $params, true); return $result; } + /** + * Fetch all matching rows ordered by a real model field. + * + * @param string $orderByField + * @param string $direction + * @param string $SQLfragment + * @param array $params + * @param int|null $limit + * + * @return array + */ + public static function fetchAllWhereOrderedBy( + string $orderByField, + string $direction = 'ASC', + string $SQLfragment = '', + array $params = [], + ?int $limit = null + ): array { + $suffix = static::buildOrderAndLimitClause($orderByField, $direction, $limit); + + /** @var array $results */ + $results = static::fetchWhereWithSuffix($SQLfragment, $params, false, $suffix); + return $results; + } + + /** + * Fetch the first matching row using an explicit model-field ordering. + * + * @param string $orderByField + * @param string $direction + * @param string $SQLfragment + * @param array $params + * + * @return static|null + */ + public static function fetchOneWhereOrderedBy( + string $orderByField, + string $direction = 'ASC', + string $SQLfragment = '', + array $params = [] + ): ?static { + /** @var static|null $result */ + $result = static::fetchWhereWithSuffix( + $SQLfragment, + $params, + true, + static::buildOrderAndLimitClause($orderByField, $direction) + ); + + return $result; + } + + /** + * Return a single column from matching rows. + * + * @param string $fieldname + * @param string $SQLfragment + * @param array $params + * @param string|null $orderByField + * @param string $direction + * @param int|null $limit + * + * @return array + */ + public static function pluck( + string $fieldname, + string $SQLfragment = '', + array $params = [], + ?string $orderByField = null, + string $direction = 'ASC', + ?int $limit = null + ): array { + $query = 'SELECT ' . static::_quote_identifier(static::resolveFieldName($fieldname)) . + ' FROM ' . static::_quote_identifier(static::$_tableName) . + static::addWherePrefix($SQLfragment) . + static::buildOrderAndLimitClause($orderByField, $direction, $limit); + $st = static::execute($query, $params); + + /** @var array $values */ + $values = $st->fetchAll(\PDO::FETCH_COLUMN, 0); + return $values; + } + /** * Delete a record by its primary key * @@ -958,7 +1141,7 @@ public function delete() * * @return \PDOStatement */ - public static function deleteAllWhere($where, $params = array()) + public static function deleteAllWhere(string $where, array $params = []): \PDOStatement { $st = static::execute( 'DELETE FROM ' . static::_quote_identifier(static::$_tableName) . ' WHERE ' . $where, @@ -968,16 +1151,61 @@ public static function deleteAllWhere($where, $params = array()) } /** - * do any validation in this function called before update and insert - * should throw errors on validation failure. + * Legacy static validation hook kept for backward compatibility. * * @return boolean true or throws exception on error */ - public static function validate() + public static function validate(): bool { return true; } + /** + * Shared instance-aware validation hook for insert and update. + * + * @return void + */ + protected function validateForSave(): void + { + static::validate(); + } + + /** + * Instance-aware validation hook that runs after validateForSave() on insert. + * + * @return void + */ + protected function validateForInsert(): void + { + } + + /** + * Instance-aware validation hook that runs after validateForSave() on update. + * + * @return void + */ + protected function validateForUpdate(): void + { + } + + /** + * @return void + */ + protected function runInsertValidation(): void + { + $this->validateForSave(); + $this->validateForInsert(); + } + + /** + * @return void + */ + protected function runUpdateValidation(): void + { + $this->validateForSave(); + $this->validateForUpdate(); + } + /** * insert a row into the database table, and update the primary key field with the one generated on insert * @@ -988,7 +1216,7 @@ public static function validate() * @throws \PDOException * @throws ModelException */ - public function insert($autoTimestamp = true, $allowSetPrimaryKey = false) + public function insert(bool $autoTimestamp = true, bool $allowSetPrimaryKey = false): bool { $pk = static::$_primary_column_name; $timeStr = gmdate('Y-m-d H:i:s'); @@ -998,7 +1226,7 @@ public function insert($autoTimestamp = true, $allowSetPrimaryKey = false) if ($autoTimestamp && in_array('updated_at', static::getFieldnames())) { $this->updated_at = $timeStr; } - $this->validate(); + $this->runInsertValidation(); if ($allowSetPrimaryKey !== true) { $this->$pk = null; // ensure id is null } @@ -1060,12 +1288,12 @@ public function insert($autoTimestamp = true, $allowSetPrimaryKey = false) * * @return boolean indicating success */ - public function update($autoTimestamp = true) + public function update(bool $autoTimestamp = true): bool { if ($autoTimestamp && in_array('updated_at', static::getFieldnames())) { $this->updated_at = gmdate('Y-m-d H:i:s'); } - $this->validate(); + $this->runUpdateValidation(); $set = $this->setString(); if ($set['sql'] === '') { return false; @@ -1093,7 +1321,7 @@ public function update($autoTimestamp = true) * * @return \PDOStatement handle */ - public static function execute($query, $params = array()) + public static function execute(string $query, array $params = []): \PDOStatement { $st = static::_prepare($query); $st->execute($params); @@ -1107,7 +1335,7 @@ public static function execute($query, $params = array()) * * @return \PDOStatement */ - protected static function _prepare($query) + protected static function _prepare(string $query): \PDOStatement { $db = static::$_db; if (!$db) { @@ -1129,7 +1357,7 @@ protected static function _prepare($query) * * @return boolean indicating success */ - public function save() + public function save(): bool { if ($this->hasPrimaryKeyValue()) { return $this->update(); @@ -1138,6 +1366,26 @@ public function save() } } + /** + * Enable or disable strict field assignment for the current model class. + * + * @param bool $strict + * + * @return void + */ + public static function useStrictFields(bool $strict = true): void + { + static::$_strict_fields = $strict; + } + + /** + * @return bool + */ + public static function strictFieldsEnabled(): bool + { + return static::$_strict_fields; + } + /** * @param mixed $state * @@ -1188,7 +1436,7 @@ protected static function isEmptyMatchList($match): bool * * @return array ['sql' => string, 'params' => mixed[] ] */ - protected function setString($ignorePrimary = true) + protected function setString(bool $ignorePrimary = true): array { // escapes and builds mysql SET string returning false, empty string or `field` = 'val'[, `field` = 'val']... /** @@ -1242,7 +1490,7 @@ protected function setString($ignorePrimary = true) * @return bool * @throws ConnectionException */ - protected static function supportsUpdateLimit() + protected static function supportsUpdateLimit(): bool { $driver = static::getDriverName(); return ($driver === 'mysql'); @@ -1254,7 +1502,7 @@ protected static function supportsUpdateLimit() * @return bool * @throws ConnectionException */ - protected static function supportsDeleteLimit() + protected static function supportsDeleteLimit(): bool { $driver = static::getDriverName(); return ($driver === 'mysql'); diff --git a/test-src/Model/Category.php b/test-src/Model/Category.php index b91e63b..4c9b0bb 100644 --- a/test-src/Model/Category.php +++ b/test-src/Model/Category.php @@ -1,5 +1,7 @@ + */ + public static array $validationLog = []; + + protected static $_tableName = 'categories'; + + protected function validateForSave(): void + { + self::$validationLog[] = 'save:' . (string) $this->name; + if (trim((string) $this->name) === '') { + throw new \RuntimeException('Name is required'); + } + } + + protected function validateForInsert(): void + { + self::$validationLog[] = 'insert:' . (string) $this->name; + } + + protected function validateForUpdate(): void + { + self::$validationLog[] = 'update:' . (string) $this->name; + } +} diff --git a/tests/Model/CategoryTest.php b/tests/Model/CategoryTest.php index d5ec5b6..15c7afc 100644 --- a/tests/Model/CategoryTest.php +++ b/tests/Model/CategoryTest.php @@ -1,5 +1,7 @@ assertSame(0, App\Model\Category::count()); } + public function testFocusedQueryHelpers(): void + { + $this->assertFalse(App\Model\Category::exists()); + $this->assertFalse(App\Model\Category::existsWhere('name = ?', ['Alpha'])); + + $this->createCategory('Alpha'); + $this->createCategory('Gamma'); + $this->createCategory('Beta'); + + $this->assertTrue(App\Model\Category::exists()); + $this->assertTrue(App\Model\Category::existsWhere('name = ?', ['Alpha'])); + $this->assertFalse(App\Model\Category::existsWhere('name = ?', ['Delta'])); + + $ordered = App\Model\Category::fetchAllWhereOrderedBy('name', 'DESC'); + $this->assertSame(['Gamma', 'Beta', 'Alpha'], array_map( + static fn (App\Model\Category $category): ?string => $category->name, + $ordered + )); + + $top = App\Model\Category::fetchOneWhereOrderedBy('name', 'DESC'); + $this->assertNotNull($top); + $this->assertSame('Gamma', $top->name); + + $names = App\Model\Category::pluck('name', '', [], 'name', 'ASC', 2); + $this->assertSame(['Alpha', 'Beta'], $names); + } + public function testDynamicFindersCamelCase(): void { $_names = [ @@ -446,6 +479,84 @@ public function testDynamicFindersHandleEmptyMatchArrays(): void $this->assertNull(App\Model\Category::fetchOneWhereMatchingSingleField('name', [], 'ASC')); } + public function testInstanceValidationHooksCanInspectCurrentState(): void + { + $category = new App\Model\ValidatingCategory([ + 'name' => 'Validated insert', + ]); + + $this->assertTrue($category->save()); + $this->assertSame( + ['save:Validated insert', 'insert:Validated insert'], + App\Model\ValidatingCategory::$validationLog + ); + + App\Model\ValidatingCategory::$validationLog = []; + $category->name = 'Validated update'; + $this->assertTrue($category->save()); + $this->assertSame( + ['save:Validated update', 'update:Validated update'], + App\Model\ValidatingCategory::$validationLog + ); + } + + public function testInstanceValidationHooksCanRejectWrites(): void + { + $category = new App\Model\ValidatingCategory([ + 'name' => ' ', + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Name is required'); + $category->save(); + } + + public function testLegacyStaticValidationRemainsSupported(): void + { + $category = new App\Model\LegacyValidatingCategory([ + 'name' => 'Legacy validation', + ]); + + $this->assertTrue($category->save()); + $category->name = 'Legacy validation updated'; + $this->assertTrue($category->save()); + + $this->assertSame(2, App\Model\LegacyValidatingCategory::$validateCalls); + } + + public function testStrictFieldModeRejectsUnknownFieldsWhenConfiguredPerModel(): void + { + $category = new App\Model\StrictCategory(); + + $this->expectException(UnknownFieldException::class); + $this->expectExceptionMessage('Unknown field [unknown_field] for model App\Model\StrictCategory'); + $category->__set('unknown_field', 'nope'); + } + + public function testStrictFieldModeCanBeEnabledAtRuntime(): void + { + App\Model\Category::useStrictFields(true); + + try { + $category = new App\Model\Category(); + + $this->expectException(UnknownFieldException::class); + $this->expectExceptionMessage('Unknown field [unknown_field] for model App\Model\Category'); + $category->__set('unknown_field', 'nope'); + } finally { + App\Model\Category::useStrictFields(false); + } + } + + public function testStrictFieldModeStillAllowsRealFields(): void + { + $category = new App\Model\StrictCategory(); + $category->__set('updatedAt', '2026-03-08 12:00:00'); + + $this->assertSame('2026-03-08 12:00:00', $category->updated_at); + $this->assertTrue($category->isFieldDirty('updated_at')); + } + private function captureUserDeprecation(string $expectedMessage, callable $callback): mixed { $result = null; diff --git a/tests/Model/SqliteModelTest.php b/tests/Model/SqliteModelTest.php index 00a815e..243b8aa 100644 --- a/tests/Model/SqliteModelTest.php +++ b/tests/Model/SqliteModelTest.php @@ -1,5 +1,7 @@