From e8d9e02dc29799bb403558e3cfa66ab1f026b430 Mon Sep 17 00:00:00 2001 From: Dave Barnwell Date: Sun, 8 Mar 2026 19:22:48 +0000 Subject: [PATCH 1/3] Implement phase 4 optional ORM features --- README.md | 62 +++ docs/api-reference.md | 45 +- docs/guide.md | 87 ++- src/Model/Model.php | 555 +++++++++++++++++-- test-src/Model/CastedCategory.php | 29 + test-src/Model/CustomTimestampCategory.php | 20 + test-src/Model/DisabledTimestampCategory.php | 18 + tests/Model/CategoryTest.php | 202 ++++++- 8 files changed, 975 insertions(+), 43 deletions(-) create mode 100644 test-src/Model/CastedCategory.php create mode 100644 test-src/Model/CustomTimestampCategory.php create mode 100644 test-src/Model/DisabledTimestampCategory.php diff --git a/README.md b/README.md index e2cbb23..12e8300 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Skip it if you need relationship graphs, migrations, or a chainable query builde - PDO-first: keep the convenience methods, keep full access to SQL, keep control. - Framework-agnostic: use it in custom apps, legacy codebases, small services, or greenfield projects. - Productive defaults: CRUD helpers, dynamic finders, counters, hydration, and timestamp handling are ready out of the box. +- Practical opt-ins: transaction helpers, configurable timestamp columns, and attribute casting stay lightweight but cover common app needs. - Portable across databases: exercised against MySQL/MariaDB, PostgreSQL, and SQLite. ## Install in minutes @@ -111,11 +112,72 @@ The base model gives you the methods most applications reach for first: - `first()` - `last()` - `count()` +- `transaction()` +- `beginTransaction()` +- `commit()` +- `rollBack()` If your table includes `created_at` and `updated_at`, they are populated automatically on insert and update. Timestamps are generated in UTC using the `Y-m-d H:i:s` format. SQLite stores those values as text, while MySQL/MariaDB and PostgreSQL accept them in timestamp-style columns. +### Transactions without leaving the model + +Use the built-in transaction helper when several writes should succeed or fail together: + +```php +Category::transaction(function (): void { + $first = new Category(['name' => 'Sci-Fi']); + $first->save(); + + $second = new Category(['name' => 'Fantasy']); + $second->save(); +}); +``` + +If you need lower-level control, the model also exposes `beginTransaction()`, `commit()`, and `rollBack()` as thin wrappers around the current PDO connection. + +### Timestamp columns can be configured per model + +The default convention remains `created_at` and `updated_at`, but models can now opt into different column names or disable automatic timestamps entirely: + +```php +class AuditLog extends Freshsauce\Model\Model +{ + protected static $_tableName = 'audit_logs'; + protected static ?string $_created_at_column = 'created_on'; + protected static ?string $_updated_at_column = 'modified_on'; +} + +class LegacyCategory extends Freshsauce\Model\Model +{ + protected static $_tableName = 'legacy_categories'; + protected static bool $_auto_timestamps = false; +} +``` + +### Attribute casting + +Cast common fields to application-friendly PHP types: + +```php +class Product extends Freshsauce\Model\Model +{ + protected static $_tableName = 'products'; + + protected static array $_casts = [ + 'stock' => 'integer', + 'price' => 'float', + 'is_active' => 'boolean', + 'published_at' => 'datetime', + 'tags' => 'array', + 'settings' => 'object', + ]; +} +``` + +Supported cast types are `integer`, `float`, `boolean`, `datetime`, `array`, and `object`. + ### Dynamic finders and counters Build expressive queries straight from method names: diff --git a/docs/api-reference.md b/docs/api-reference.md index cccd717..8fd16d4 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -26,6 +26,22 @@ protected static $_primary_column_name = 'code'; Optional. Defaults to `false`. When enabled, unknown assignments throw `UnknownFieldException`. +### `protected static bool $_auto_timestamps` + +Optional. Defaults to `true`. Set to `false` to disable built-in automatic timestamp handling for the model. + +### `protected static ?string $_created_at_column` + +Optional. Defaults to `created_at`. Set to a different column name to customise insert timestamps, or `null` to disable created-at writes. + +### `protected static ?string $_updated_at_column` + +Optional. Defaults to `updated_at`. Set to a different column name to customise insert/update timestamps, or `null` to disable updated-at writes. + +### `protected static array $_casts` + +Optional. Field cast map. Supported cast types are `integer`, `float`, `boolean`, `datetime`, `array`, and `object`. + ### `public static $_db` Inherited shared PDO connection. Redeclare this in a subclass only when that subclass needs an isolated connection. @@ -90,12 +106,15 @@ Behavior: - in strict mode, resolves the name against real fields first - creates the internal data object on first assignment +- applies configured attribute casts before storing the value - marks the field as dirty ### `__get(string $name): mixed` Returns a field value from the internal data store. +When a field is configured in `$_casts`, the returned value is the cast PHP value. + Throws: - `MissingDataException` when data has not been initialised @@ -131,6 +150,28 @@ Use this after runtime schema changes. Prepares and executes a statement, returning the `PDOStatement`. +### `beginTransaction(): bool` + +Begins a transaction on the current model connection. + +### `commit(): bool` + +Commits the current transaction on the current model connection. + +### `rollBack(): bool` + +Rolls back the current transaction on the current model connection. + +### `transaction(callable $callback): mixed` + +Runs the callback inside a transaction and returns the callback result. + +Behavior: + +- begins and commits a transaction when no transaction is active +- rolls back automatically when the callback throws +- reuses an already-open outer transaction instead of nesting another one + ### `datetimeToMysqldatetime(int|string $dt): string` Converts a Unix timestamp or date string into `Y-m-d H:i:s`. @@ -160,7 +201,7 @@ Inserts the current model as a new row. Behavior: -- auto-fills `created_at` and `updated_at` when enabled and the fields exist +- auto-fills the configured created/update timestamp columns when enabled and the fields exist - runs `validateForSave()` and `validateForInsert()` - clears dirty flags on success - updates the model's primary key from the database when the key is generated by the database @@ -174,7 +215,7 @@ Updates the current row by primary key. Behavior: -- auto-fills `updated_at` when enabled and the field exists +- auto-fills the configured update timestamp column when enabled and the field exists - runs `validateForSave()` and `validateForUpdate()` - updates only dirty known fields - returns `false` when there are no dirty fields to write diff --git a/docs/guide.md b/docs/guide.md index 8643785..993e2df 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -80,6 +80,7 @@ class Category extends Freshsauce\Model\Model protected static $_tableName = 'categories'; protected static $_primary_column_name = 'id'; protected static bool $_strict_fields = false; + protected static bool $_auto_timestamps = true; } ``` @@ -88,6 +89,10 @@ Available configuration members: - `protected static $_tableName`: required; the database table to use - `protected static $_primary_column_name`: defaults to `id` - `protected static bool $_strict_fields`: defaults to `false` +- `protected static bool $_auto_timestamps`: defaults to `true` +- `protected static ?string $_created_at_column`: defaults to `created_at` +- `protected static ?string $_updated_at_column`: defaults to `updated_at` +- `protected static array $_casts`: optional field cast map - `public static $_db`: only redeclare this when a subclass needs its own isolated connection Custom primary keys are supported: @@ -159,8 +164,8 @@ $category->insert(); Notes: -- if `created_at` exists, `insert()` sets it automatically -- if `updated_at` exists, `insert()` sets it automatically +- if the configured created timestamp column exists, `insert()` sets it automatically +- if the configured updated timestamp column exists, `insert()` sets it automatically - timestamps are generated in UTC using `Y-m-d H:i:s` - `insert(false)` disables automatic timestamps - `insert(false, true)` allows you to include an explicit primary key value @@ -225,7 +230,7 @@ $category->update(); Update behavior: -- `updated_at` is refreshed automatically when that column exists +- the configured updated timestamp column is refreshed automatically when it exists - `update(false)` disables automatic timestamp updates - only dirty known fields are included in the SQL `SET` clause - `update()` returns `false` when there is nothing dirty to write @@ -495,16 +500,84 @@ Serialisation is supported: - `serialize()` and `unserialize()` preserve values - dirty state is preserved across serialisation round-trips +## Transactions + +Use `transaction()` when several writes should succeed or fail together: + +```php +Category::transaction(function (): void { + $first = new Category(['name' => 'Sci-Fi']); + $first->save(); + + $second = new Category(['name' => 'Fantasy']); + $second->save(); +}); +``` + +What it does: + +- starts and commits a transaction when none is active +- rolls back automatically if the callback throws +- reuses an existing outer transaction instead of nesting another one + +If you need manual control, the model also exposes `beginTransaction()`, `commit()`, and `rollBack()`. + ## Timestamp behavior -Automatic timestamp handling is convention-based: +Automatic timestamp handling is configurable: -- `created_at` is filled on insert when the column exists -- `updated_at` is filled on insert and update when the column exists +- `created_at` is filled on insert by default when that column exists +- `updated_at` is filled on insert and update by default when that column exists - timestamps are generated in UTC with `gmdate('Y-m-d H:i:s')` - models without those columns save normally +- set `protected static bool $_auto_timestamps = false;` to disable the feature for a model +- set `protected static ?string $_created_at_column` or `$_updated_at_column` to use custom column names + +Example: + +```php +class AuditLog extends Freshsauce\Model\Model +{ + protected static $_tableName = 'audit_logs'; + protected static ?string $_created_at_column = 'created_on'; + protected static ?string $_updated_at_column = 'modified_on'; +} +``` + +## Attribute casting + +Use `$_casts` to normalise fields to PHP types on assignment and when rows are loaded from the database: + +```php +class Product extends Freshsauce\Model\Model +{ + protected static $_tableName = 'products'; + + protected static array $_casts = [ + 'stock' => 'integer', + 'price' => 'float', + 'is_active' => 'boolean', + 'published_at' => 'datetime', + 'tags' => 'array', + 'settings' => 'object', + ]; +} +``` + +Supported cast types: + +- `integer` +- `float` +- `boolean` +- `datetime` +- `array` +- `object` + +Notes: -If you need custom timestamp columns, that is currently outside the built-in feature set. +- `datetime` returns `DateTimeImmutable` +- `array` and `object` are stored as JSON strings in the database +- `toArray()` returns the current cast PHP values ## Exceptions diff --git a/src/Model/Model.php b/src/Model/Model.php index 00583f8..d129672 100644 --- a/src/Model/Model.php +++ b/src/Model/Model.php @@ -37,6 +37,8 @@ * @property string|null $updated_at optional datetime in table that will automatically get updated on insert/update * * @package Freshsauce\Model + * + * @phpstan-consistent-constructor */ class Model { @@ -90,6 +92,26 @@ class Model */ protected static bool $_strict_fields = false; + /** + * @var bool whether built-in automatic timestamp handling is enabled + */ + protected static bool $_auto_timestamps = true; + + /** + * @var string|null column name to auto-populate on insert, or null to disable + */ + protected static ?string $_created_at_column = 'created_at'; + + /** + * @var string|null column name to auto-populate on insert/update, or null to disable + */ + protected static ?string $_updated_at_column = 'updated_at'; + + /** + * @var array field cast map + */ + protected static array $_casts = []; + /** * Model constructor. * @@ -141,14 +163,7 @@ public function dataPresent() */ public function __set(string $name, mixed $value): void { - if (static::strictFieldsEnabled()) { - $name = static::resolveFieldName($name); - } - if (!$this->hasData()) { - $this->data = new \stdClass(); - } - $this->data->$name = $value; - $this->markFieldDirty($name); + $this->assignAttribute($name, $value); } /** @@ -443,10 +458,10 @@ protected static function splitTableName(string $tableName): array public function hydrate(array $data): void { foreach (static::getFieldnames() as $fieldname) { - if (isset($data[$fieldname])) { - $this->$fieldname = $data[$fieldname]; + if (array_key_exists($fieldname, $data)) { + $this->assignAttribute($fieldname, $data[$fieldname]); } elseif (!isset($this->$fieldname)) { // PDO pre populates fields before calling the constructor, so dont null unless not set - $this->$fieldname = null; + $this->assignAttribute($fieldname, null); } } } @@ -966,7 +981,6 @@ protected static function normaliseOrderDirection(string $direction): string */ 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 . $suffix . ($limitOne ? ' LIMIT 1' : ''), @@ -978,15 +992,17 @@ protected static function fetchWhereWithSuffix(string $SQLfragment = '', array $ if ($row === false) { return null; } - $instance = new $class($row); - $instance->clearDirtyFields(); - return $instance; + if (!is_array($row)) { + throw new ConfigurationException('Expected associative row data from PDO.'); + } + return static::newInstanceFromDatabaseRow($row); } $results = []; while ($row = $st->fetch()) { - $instance = new $class($row); - $instance->clearDirtyFields(); - $results[] = $instance; + if (!is_array($row)) { + throw new ConfigurationException('Expected associative row data from PDO.'); + } + $results[] = static::newInstanceFromDatabaseRow($row); } return $results; } @@ -1226,6 +1242,68 @@ protected static function currentTimestamp(): string return gmdate('Y-m-d H:i:s'); } + /** + * Begin a transaction on the current model connection. + * + * @return bool + */ + public static function beginTransaction(): bool + { + return static::requireDb()->beginTransaction(); + } + + /** + * Commit the current transaction on the model connection. + * + * @return bool + */ + public static function commit(): bool + { + return static::requireDb()->commit(); + } + + /** + * Roll back the current transaction on the model connection. + * + * @return bool + */ + public static function rollBack(): bool + { + return static::requireDb()->rollBack(); + } + + /** + * Run work inside a transaction, reusing any outer transaction when already active. + * + * @param callable $callback + * + * @return mixed + */ + public static function transaction(callable $callback): mixed + { + $db = static::requireDb(); + if ($db->inTransaction()) { + return $callback(); + } + + $db->beginTransaction(); + + try { + $result = $callback(); + $db->commit(); + + return $result; + } catch (\Throwable $throwable) { + try { + $db->rollBack(); + } catch (\PDOException) { + // Ignore rollback failures when the callback already closed the transaction. + } + + throw $throwable; + } + } + /** * insert a row into the database table, and update the primary key field with the one generated on insert * @@ -1240,12 +1318,7 @@ public function insert(bool $autoTimestamp = true, bool $allowSetPrimaryKey = fa { $pk = static::$_primary_column_name; $timeStr = static::currentTimestamp(); - if ($autoTimestamp && in_array('created_at', static::getFieldnames())) { - $this->created_at = $timeStr; - } - if ($autoTimestamp && in_array('updated_at', static::getFieldnames())) { - $this->updated_at = $timeStr; - } + $this->applyAutomaticInsertTimestamps($timeStr, $autoTimestamp); $this->runInsertValidation(); if ($allowSetPrimaryKey !== true) { $this->$pk = null; // ensure id is null @@ -1310,9 +1383,7 @@ public function insert(bool $autoTimestamp = true, bool $allowSetPrimaryKey = fa */ public function update(bool $autoTimestamp = true): bool { - if ($autoTimestamp && in_array('updated_at', static::getFieldnames())) { - $this->updated_at = static::currentTimestamp(); - } + $this->applyAutomaticUpdateTimestamp($autoTimestamp); $this->runUpdateValidation(); $set = $this->setString(); if ($set['sql'] === '') { @@ -1358,10 +1429,7 @@ public static function execute(string $query, array $params = []): \PDOStatement */ protected static function _prepare(string $query): \PDOStatement { - $db = static::$_db; - if (!$db) { - throw new ConnectionException('No database connection setup'); - } + $db = static::requireDb(); $connectionId = spl_object_id($db); if (!isset(static::$_stmt[$connectionId])) { static::$_stmt[$connectionId] = array(); @@ -1516,7 +1584,8 @@ protected function setString(bool $ignorePrimary = true): array continue; } $columns[] = static::_quote_identifier($field); - if ($this->$field === null) { + $value = $this->prepareAttributeForDatabase($field, $this->$field); + if ($value === null) { // if empty set to NULL $fragments[] = static::_quote_identifier($field) . ' = NULL'; $values[] = 'NULL'; @@ -1524,7 +1593,7 @@ protected function setString(bool $ignorePrimary = true): array // Just set value normally as not empty string with NULL allowed $fragments[] = static::_quote_identifier($field) . ' = ?'; $values[] = '?'; - $params[] = $this->$field; + $params[] = $value; } } $sqlFragment = implode(", ", $fragments); @@ -1560,6 +1629,426 @@ protected static function supportsDeleteLimit(): bool return ($driver === 'mysql'); } + /** + * Hydrate a model instance from a database row and clear dirty tracking. + * + * @param array $row + * + * @return static + */ + protected static function newInstanceFromDatabaseRow(array $row): static + { + $instance = new static(); + $instance->hydrateFromDatabase($row); + $instance->clearDirtyFields(); + + return $instance; + } + + /** + * Hydrate known fields from database results so casts are applied on read. + * + * @param array $data + * + * @return void + */ + protected function hydrateFromDatabase(array $data): void + { + foreach (static::getFieldnames() as $fieldname) { + if (array_key_exists($fieldname, $data)) { + $this->assignAttribute($fieldname, $data[$fieldname], true, true); + } elseif (!isset($this->$fieldname)) { + $this->assignAttribute($fieldname, null, true, true); + } + } + } + + /** + * Assign a value to the model, optionally treating it as database input. + * + * @param string $name + * @param mixed $value + * @param bool $markDirty + * @param bool $fromDatabase + * + * @return void + */ + protected function assignAttribute(string $name, mixed $value, bool $markDirty = true, bool $fromDatabase = false): void + { + if (static::strictFieldsEnabled()) { + $name = static::resolveFieldName($name); + } + if (!$this->hasData()) { + $this->data = new \stdClass(); + } + $this->data->$name = $fromDatabase + ? $this->castAttributeFromDatabase($name, $value) + : $this->castAttributeForAssignment($name, $value); + if ($markDirty) { + $this->markFieldDirty($name); + } + } + + /** + * @return \PDO + */ + protected static function requireDb(): \PDO + { + $db = static::$_db; + if (!$db instanceof \PDO) { + throw new ConnectionException('No database connection setup'); + } + + return $db; + } + + /** + * @param string $field + * @param mixed $value + * + * @return mixed + */ + protected function castAttributeForAssignment(string $field, mixed $value): mixed + { + return $this->castAttributeValue($field, $value, false); + } + + /** + * @param string $field + * @param mixed $value + * + * @return mixed + */ + protected function castAttributeFromDatabase(string $field, mixed $value): mixed + { + return $this->castAttributeValue($field, $value, true); + } + + /** + * @param string $field + * @param mixed $value + * @param bool $fromDatabase + * + * @return mixed + */ + protected function castAttributeValue(string $field, mixed $value, bool $fromDatabase): mixed + { + $castType = static::normaliseCastType(static::$_casts[$field] ?? null); + if ($castType === null || $value === null) { + return $value; + } + + return match ($castType) { + 'integer' => $this->castIntegerValue($field, $value), + 'float' => $this->castFloatValue($field, $value), + 'boolean' => $this->castBooleanValue($value), + 'datetime' => $this->castDateTimeValue($field, $value), + 'array' => $this->castArrayValue($field, $value, $fromDatabase), + 'object' => $this->castObjectValue($field, $value, $fromDatabase), + default => $value, + }; + } + + /** + * @param string $field + * @param mixed $value + * + * @return mixed + */ + protected function prepareAttributeForDatabase(string $field, mixed $value): mixed + { + $castType = static::normaliseCastType(static::$_casts[$field] ?? null); + if ($castType === null || $value === null) { + return $value; + } + + return match ($castType) { + 'datetime' => $this->formatDateTimeValue($field, $value), + 'array', 'object' => $this->encodeJsonValue($field, $value), + default => $value, + }; + } + + /** + * @param string|null $castType + * + * @return string|null + */ + protected static function normaliseCastType(?string $castType): ?string + { + return match ($castType) { + null => null, + 'int', 'integer' => 'integer', + 'float', 'double', 'real' => 'float', + 'bool', 'boolean' => 'boolean', + 'datetime' => 'datetime', + 'array', 'json', 'json_array' => 'array', + 'object', 'json_object' => 'object', + default => throw new ConfigurationException('Unsupported cast type [' . $castType . '] for model ' . static::class), + }; + } + + /** + * @param mixed $value + * + * @return bool + */ + protected function castBooleanValue(mixed $value): bool + { + if (is_bool($value)) { + return $value; + } + if (is_int($value) || is_float($value)) { + return $value != 0; + } + if (is_string($value)) { + $normalised = strtolower(trim($value)); + if (in_array($normalised, ['1', 'true', 't', 'yes', 'y', 'on'], true)) { + return true; + } + if (in_array($normalised, ['0', 'false', 'f', 'no', 'n', 'off', ''], true)) { + return false; + } + } + + return (bool) $value; + } + + /** + * @param string $field + * @param mixed $value + * + * @return int + */ + protected function castIntegerValue(string $field, mixed $value): int + { + if (is_int($value)) { + return $value; + } + if (is_string($value) || is_float($value) || is_bool($value)) { + return (int) $value; + } + + throw new ModelException('Unable to cast [' . $field . '] to integer for model ' . static::class); + } + + /** + * @param string $field + * @param mixed $value + * + * @return float + */ + protected function castFloatValue(string $field, mixed $value): float + { + if (is_float($value)) { + return $value; + } + if (is_int($value) || is_string($value) || is_bool($value)) { + return (float) $value; + } + + throw new ModelException('Unable to cast [' . $field . '] to float for model ' . static::class); + } + + /** + * @param string $field + * @param mixed $value + * + * @return \DateTimeImmutable + */ + protected function castDateTimeValue(string $field, mixed $value): \DateTimeImmutable + { + if ($value instanceof \DateTimeImmutable) { + return $value; + } + if ($value instanceof \DateTimeInterface) { + return \DateTimeImmutable::createFromInterface($value); + } + if (is_int($value)) { + return (new \DateTimeImmutable('@' . $value))->setTimezone(new \DateTimeZone('UTC')); + } + if (is_string($value)) { + return new \DateTimeImmutable($value, new \DateTimeZone('UTC')); + } + + throw new ModelException('Unable to cast [' . $field . '] to datetime for model ' . static::class); + } + + /** + * @param string $field + * @param mixed $value + * + * @return string + */ + protected function formatDateTimeValue(string $field, mixed $value): string + { + if ($value instanceof \DateTimeInterface) { + return $value->format('Y-m-d H:i:s'); + } + + throw new ModelException('Unable to format [' . $field . '] datetime value for model ' . static::class); + } + + /** + * @param string $field + * @param mixed $value + * @param bool $fromDatabase + * + * @return array + */ + protected function castArrayValue(string $field, mixed $value, bool $fromDatabase): array + { + if (is_array($value)) { + return $value; + } + if ($value instanceof \stdClass) { + return get_object_vars($value); + } + if (is_string($value)) { + $decoded = $this->decodeJsonValue($field, $value, true); + if (!is_array($decoded)) { + throw new ModelException('Expected JSON array for [' . $field . '] on model ' . static::class); + } + + return $decoded; + } + if (!$fromDatabase && is_object($value)) { + return get_object_vars($value); + } + + throw new ModelException('Unable to cast [' . $field . '] to array for model ' . static::class); + } + + /** + * @param string $field + * @param mixed $value + * @param bool $fromDatabase + * + * @return object + */ + protected function castObjectValue(string $field, mixed $value, bool $fromDatabase): object + { + if (is_object($value)) { + return $value; + } + if (is_array($value)) { + return (object) $value; + } + if (is_string($value)) { + $decoded = $this->decodeJsonValue($field, $value, false); + if (!is_object($decoded)) { + throw new ModelException('Expected JSON object for [' . $field . '] on model ' . static::class); + } + + return $decoded; + } + + throw new ModelException('Unable to cast [' . $field . '] to object for model ' . static::class); + } + + /** + * @param string $field + * @param string $value + * @param bool $assoc + * + * @return mixed + */ + protected function decodeJsonValue(string $field, string $value, bool $assoc): mixed + { + try { + return json_decode($value, $assoc, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new ModelException( + 'Unable to decode JSON for [' . $field . '] on model ' . static::class . ': ' . $exception->getMessage(), + 0, + $exception + ); + } + } + + /** + * @param string $field + * @param mixed $value + * + * @return string + */ + protected function encodeJsonValue(string $field, mixed $value): string + { + try { + return json_encode($value, JSON_THROW_ON_ERROR); + } catch (\JsonException $exception) { + throw new ModelException( + 'Unable to encode JSON for [' . $field . '] on model ' . static::class . ': ' . $exception->getMessage(), + 0, + $exception + ); + } + } + + /** + * @param string $timestamp + * @param bool $autoTimestamp + * + * @return void + */ + protected function applyAutomaticInsertTimestamps(string $timestamp, bool $autoTimestamp): void + { + if (!$autoTimestamp || !static::automaticTimestampsEnabled()) { + return; + } + + $createdColumn = static::createdTimestampColumn(); + if ($createdColumn !== null && in_array($createdColumn, static::getFieldnames(), true)) { + $this->$createdColumn = $timestamp; + } + + $updatedColumn = static::updatedTimestampColumn(); + if ($updatedColumn !== null && in_array($updatedColumn, static::getFieldnames(), true)) { + $this->$updatedColumn = $timestamp; + } + } + + /** + * @param bool $autoTimestamp + * + * @return void + */ + protected function applyAutomaticUpdateTimestamp(bool $autoTimestamp): void + { + if (!$autoTimestamp || !static::automaticTimestampsEnabled()) { + return; + } + + $updatedColumn = static::updatedTimestampColumn(); + if ($updatedColumn !== null && in_array($updatedColumn, static::getFieldnames(), true)) { + $this->$updatedColumn = static::currentTimestamp(); + } + } + + /** + * @return bool + */ + protected static function automaticTimestampsEnabled(): bool + { + return static::$_auto_timestamps; + } + + /** + * @return string|null + */ + protected static function createdTimestampColumn(): ?string + { + return static::$_created_at_column; + } + + /** + * @return string|null + */ + protected static function updatedTimestampColumn(): ?string + { + return static::$_updated_at_column; + } + /** * convert a date string or timestamp into a string suitable for assigning to a SQl datetime or timestamp field * diff --git a/test-src/Model/CastedCategory.php b/test-src/Model/CastedCategory.php new file mode 100644 index 0000000..5f6b2ea --- /dev/null +++ b/test-src/Model/CastedCategory.php @@ -0,0 +1,29 @@ +|null $meta_array + * @property object|null $meta_object + */ +class CastedCategory extends \Freshsauce\Model\Model +{ + protected static $_tableName = 'casted_categories'; + + protected static array $_casts = [ + 'quantity' => 'integer', + 'rating' => 'float', + 'is_active' => 'boolean', + 'published_at' => 'datetime', + 'meta_array' => 'array', + 'meta_object' => 'object', + ]; +} diff --git a/test-src/Model/CustomTimestampCategory.php b/test-src/Model/CustomTimestampCategory.php new file mode 100644 index 0000000..41e2611 --- /dev/null +++ b/test-src/Model/CustomTimestampCategory.php @@ -0,0 +1,20 @@ +resetSqliteSequenceIfPresent(); } } @@ -176,7 +238,7 @@ public function setUp(): void private function resetSqliteSequenceIfPresent(): void { try { - foreach (['categories', 'metadata_refresh_categories', 'untimed_categories'] as $tableName) { + foreach (['categories', 'metadata_refresh_categories', 'untimed_categories', 'custom_timestamp_categories', 'casted_categories'] as $tableName) { Freshsauce\Model\Model::execute( 'DELETE FROM `' . self::SQLITE_SEQUENCE_TABLE . '` WHERE `name` = ?', [$tableName] @@ -488,6 +550,88 @@ public function testInsertAndUpdateCanOptOutOfAutomaticTimestamps(): void $this->assertNull($reloaded->updated_at); } + public function testModelConfigurationCanDisableAutomaticTimestamps(): void + { + $category = new App\Model\DisabledTimestampCategory([ + 'name' => 'Config-disabled timestamps', + ]); + + $this->assertTrue($category->save()); + $this->assertNull($category->created_at); + $this->assertNull($category->updated_at); + + $category->name = 'Still config-disabled'; + $this->assertTrue($category->save()); + $this->assertNull($category->updated_at); + + $reloaded = App\Model\DisabledTimestampCategory::getById((int) $category->id); + $this->assertNotNull($reloaded); + $this->assertNull($reloaded->created_at); + $this->assertNull($reloaded->updated_at); + } + + public function testCustomTimestampColumnsCanBeConfiguredPerModel(): void + { + $category = new App\Model\CustomTimestampCategory([ + 'name' => 'Custom timestamps', + ]); + + $this->assertTrue($category->save()); + $this->assertNotEmpty($category->created_on); + $this->assertNotEmpty($category->modified_on); + + $createdOn = $category->created_on; + $modifiedOn = $category->modified_on; + + $category->name = 'Custom timestamps updated'; + sleep(1); + $this->assertTrue($category->save()); + $this->assertSame($createdOn, $category->created_on); + $this->assertNotSame($modifiedOn, $category->modified_on); + + $reloaded = App\Model\CustomTimestampCategory::getById((int) $category->id); + $this->assertNotNull($reloaded); + $this->assertSame($category->created_on, $reloaded->created_on); + $this->assertSame($category->modified_on, $reloaded->modified_on); + } + + public function testAttributeCastingPersistsAndHydratesSupportedTypes(): void + { + $publishedAt = new DateTimeImmutable('2026-03-08 15:16:17', new DateTimeZone('UTC')); + $category = new App\Model\CastedCategory([ + 'name' => 'Casted', + 'quantity' => '7', + 'rating' => '4.5', + 'is_active' => '1', + 'published_at' => $publishedAt, + 'meta_array' => ['tags' => ['fiction', 'history'], 'count' => 2], + 'meta_object' => (object) ['featured' => true, 'score' => 9], + ]); + + $this->assertSame(7, $category->quantity); + $this->assertSame(4.5, $category->rating); + $this->assertTrue($category->is_active); + $this->assertInstanceOf(DateTimeImmutable::class, $category->published_at); + $this->assertSame('2026-03-08 15:16:17', $category->published_at->format('Y-m-d H:i:s')); + $this->assertIsArray($category->meta_array); + $this->assertIsObject($category->meta_object); + + $this->assertTrue($category->save()); + + $reloaded = App\Model\CastedCategory::getById((int) $category->id); + $this->assertNotNull($reloaded); + $this->assertSame(7, $reloaded->quantity); + $this->assertSame(4.5, $reloaded->rating); + $this->assertTrue($reloaded->is_active); + $this->assertInstanceOf(DateTimeImmutable::class, $reloaded->published_at); + $this->assertSame('2026-03-08 15:16:17', $reloaded->published_at->format('Y-m-d H:i:s')); + $this->assertSame(['tags' => ['fiction', 'history'], 'count' => 2], $reloaded->meta_array); + $metaObject = $reloaded->meta_object; + $this->assertInstanceOf(stdClass::class, $metaObject); + $this->assertTrue($metaObject->featured); + $this->assertSame(9, $metaObject->score); + } + public function testModelsWithoutTimestampColumnsSaveNormally(): void { $category = new App\Model\UntimedCategory([ @@ -745,6 +889,62 @@ public function testStrictFieldModeStillAllowsRealFields(): void $this->assertTrue($category->isFieldDirty('updated_at')); } + public function testTransactionHelperCommitsOnSuccess(): void + { + /** @var int $id */ + $id = App\Model\Category::transaction(function (): int { + $category = new App\Model\Category([ + 'name' => 'Transactional commit', + ]); + $category->save(); + + return (int) $category->id; + }); + + $this->assertSame(1, App\Model\Category::count()); + $this->assertSame('Transactional commit', App\Model\Category::getById($id)?->name); + } + + public function testTransactionHelperRollsBackOnFailure(): void + { + try { + App\Model\Category::transaction(function (): void { + $category = new App\Model\Category([ + 'name' => 'Transactional rollback', + ]); + $category->save(); + + throw new RuntimeException('Force rollback'); + }); + $this->fail('Expected transaction helper to rethrow callback exceptions.'); + } catch (RuntimeException $exception) { + $this->assertSame('Force rollback', $exception->getMessage()); + } + + $this->assertSame(0, App\Model\Category::count()); + } + + public function testManualTransactionWrappersOperateOnCurrentConnection(): void + { + $this->assertTrue(App\Model\Category::beginTransaction()); + + $category = new App\Model\Category([ + 'name' => 'Manual transaction', + ]); + $this->assertTrue($category->save()); + $this->assertTrue(App\Model\Category::rollBack()); + $this->assertSame(0, App\Model\Category::count()); + + $this->assertTrue(App\Model\Category::beginTransaction()); + + $category = new App\Model\Category([ + 'name' => 'Manual transaction committed', + ]); + $this->assertTrue($category->save()); + $this->assertTrue(App\Model\Category::commit()); + $this->assertSame(1, App\Model\Category::count()); + } + private function captureUserDeprecation(string $expectedMessage, callable $callback): mixed { $result = null; From d63a82cff0691eb59e21523b662580ca8237ac2a Mon Sep 17 00:00:00 2001 From: Dave Barnwell Date: Sun, 8 Mar 2026 19:31:39 +0000 Subject: [PATCH 2/3] Address PR review feedback --- README.md | 2 ++ docs/api-reference.md | 2 ++ docs/guide.md | 1 + src/Model/Model.php | 29 +++++++++++++++++++++-------- tests/Model/CategoryTest.php | 2 +- 5 files changed, 27 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 12e8300..4c855c9 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,8 @@ class Product extends Freshsauce\Model\Model Supported cast types are `integer`, `float`, `boolean`, `datetime`, `array`, and `object`. +`datetime` casts assume stored strings are UTC wall-time values. If you do not want implicit timezone conversion by the database, prefer `DATETIME`-style columns or ensure the connection session timezone is UTC before using `TIMESTAMP` columns. + ### Dynamic finders and counters Build expressive queries straight from method names: diff --git a/docs/api-reference.md b/docs/api-reference.md index 8fd16d4..8465499 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -42,6 +42,8 @@ Optional. Defaults to `updated_at`. Set to a different column name to customise Optional. Field cast map. Supported cast types are `integer`, `float`, `boolean`, `datetime`, `array`, and `object`. +For `datetime`, string values are interpreted as UTC wall-time values. Prefer `DATETIME`-style columns, or ensure the connection session timezone is UTC when using database types that perform timezone conversion. + ### `public static $_db` Inherited shared PDO connection. Redeclare this in a subclass only when that subclass needs an isolated connection. diff --git a/docs/guide.md b/docs/guide.md index 993e2df..5f0f39d 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -578,6 +578,7 @@ Notes: - `datetime` returns `DateTimeImmutable` - `array` and `object` are stored as JSON strings in the database - `toArray()` returns the current cast PHP values +- `datetime` assumes stored strings are UTC wall-time values; prefer `DATETIME`-style columns or a UTC session timezone when using databases that convert `TIMESTAMP` ## Exceptions diff --git a/src/Model/Model.php b/src/Model/Model.php index d129672..2438ef4 100644 --- a/src/Model/Model.php +++ b/src/Model/Model.php @@ -1286,18 +1286,29 @@ public static function transaction(callable $callback): mixed return $callback(); } - $db->beginTransaction(); + if (!$db->beginTransaction()) { + throw new \PDOException('Failed to start transaction.'); + } try { $result = $callback(); - $db->commit(); + + if (!$db->commit()) { + try { + $db->rollBack(); + } catch (\Throwable) { + // Ignore rollback failures when the transaction is already closed or rollback itself fails. + } + + throw new \PDOException('Failed to commit transaction.'); + } return $result; } catch (\Throwable $throwable) { try { $db->rollBack(); - } catch (\PDOException) { - // Ignore rollback failures when the callback already closed the transaction. + } catch (\Throwable) { + // Ignore rollback failures when the callback already closed the transaction or rollback itself fails. } throw $throwable; @@ -1638,9 +1649,11 @@ protected static function supportsDeleteLimit(): bool */ protected static function newInstanceFromDatabaseRow(array $row): static { - $instance = new static(); - $instance->hydrateFromDatabase($row); + $reflection = new \ReflectionClass(static::class); + /** @var static $instance */ + $instance = $reflection->newInstanceWithoutConstructor(); $instance->clearDirtyFields(); + $instance->hydrateFromDatabase($row); return $instance; } @@ -1656,9 +1669,9 @@ protected function hydrateFromDatabase(array $data): void { foreach (static::getFieldnames() as $fieldname) { if (array_key_exists($fieldname, $data)) { - $this->assignAttribute($fieldname, $data[$fieldname], true, true); + $this->assignAttribute($fieldname, $data[$fieldname], false, true); } elseif (!isset($this->$fieldname)) { - $this->assignAttribute($fieldname, null, true, true); + $this->assignAttribute($fieldname, null, false, true); } } } diff --git a/tests/Model/CategoryTest.php b/tests/Model/CategoryTest.php index 3e384cf..9031066 100644 --- a/tests/Model/CategoryTest.php +++ b/tests/Model/CategoryTest.php @@ -77,7 +77,7 @@ public static function setUpBeforeClass(): void `quantity` INT(11) NULL, `rating` DOUBLE NULL, `is_active` TINYINT(1) NULL, - `published_at` TIMESTAMP NULL DEFAULT NULL, + `published_at` DATETIME NULL DEFAULT NULL, `meta_array` TEXT NULL, `meta_object` TEXT NULL, PRIMARY KEY (`id`) From 043572839c3a2392b1eddd384c2c8cf42595a329 Mon Sep 17 00:00:00 2001 From: Dave Barnwell Date: Sun, 8 Mar 2026 19:36:27 +0000 Subject: [PATCH 3/3] Trim roadmap to remaining backlog --- ROADMAP.md | 119 ++++------------------------------------------------- 1 file changed, 8 insertions(+), 111 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a0e6057..bca58a0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,124 +1,21 @@ # Roadmap -This roadmap tracks the improvement work for `freshsauce/model`. +This roadmap now tracks only the remaining optional backlog for `freshsauce/model`. -Current status: +Phases 1 through 3 are complete, and Phase 4.1 through 4.3 have been delivered. -1. Phase 1 is complete. -2. Phase 2 is complete. -3. Phase 3 is complete. -4. Phase 4 remains optional and has not been started. - -The sequencing remains intentional: - -1. Fix correctness issues before expanding the API. -2. Improve developer ergonomics without turning the library into a heavyweight ORM. -3. Tighten quality and portability before considering broader feature growth. -4. Add optional features only where they preserve the package's lightweight position. - -## Principles - -- Keep PDO-first escape hatches intact. -- Prefer additive changes with low migration cost. -- Tighten behavior with tests before changing public APIs. -- Avoid feature growth that pushes the library toward framework-scale complexity. - -## Phase 1: Core correctness and safety - -Status: completed - -Summary: - -- Fixed serialization, zero-like primary key handling, invalid dynamic finder failures, and empty-array query behavior. -- Replaced generic exceptions with a small library exception hierarchy. -- Added regression coverage for the above edge cases. - -## Phase 2: API ergonomics and typing - -Status: completed - -Summary: - -- Added instance-aware validation hooks with legacy compatibility. -- Added optional strict field handling and focused query helpers. -- Tightened typing, static analysis, and public documentation around the preferred API. - -## Phase 3: Quality, portability, and maintenance - -Status: completed - -Summary: - -- Expanded cross-driver integration coverage for connection sharing, custom keys, metadata refresh, timestamp behavior, and PostgreSQL schema-qualified tables. -- Added `refreshTableMetadata()` and made UTC timestamp behavior explicit. -- Normalized no-op update handling while preserving single-row primary key update expectations. - -## Phase 4: Optional feature expansion - -Goal: add features that help real applications, but only if they fit the package's lightweight position. - -Priority: lower - -Phases 1 through 3 are complete, so this is now the remaining backlog. - -### Candidate 4.1: Transaction helpers - -Possible scope: - -- `transaction(callable $callback)` -- pass through `beginTransaction()`, `commit()`, `rollBack()` wrappers - -Why: -This adds practical value without changing the core model shape. - -### Candidate 4.2: Configurable timestamp columns - -Possible scope: - -- opt-in timestamp column names -- disable automatic timestamps per model - -Why: -The current `created_at` / `updated_at` convention is convenient but rigid. - -### Candidate 4.3: Attribute casting - -Possible scope: - -- integer -- float -- boolean -- datetime -- JSON array/object - -Why: -Casting improves ergonomics substantially without requiring relationships or a large query layer. - -### Candidate 4.4: Composite keys or relationship support +## Remaining item: 4.4 Composite keys or relationship support Why this is last: -This is where complexity rises sharply. It should only happen if the maintainer wants the library to move beyond lightweight active-record usage. + +- This is where complexity rises sharply. +- It should only happen if the maintainer wants the library to move beyond lightweight active-record usage. Recommendation: - Do not start here by default. -- Re-evaluate only after the earlier phases have shipped and real user demand is clear. - -## Suggested issue order - -If this work is split into GitHub issues, the most practical order is: - -1. Add transaction helpers. -2. Add configurable timestamp column support. -3. Add attribute casting. -4. Re-evaluate whether composite keys or relationship support are warranted. - -## Suggested release strategy - -- Release 1: Phase 1 correctness and exception work. Shipped. -- Release 2: Phase 2 ergonomics, typing, and documentation updates. Shipped. -- Release 3: Phase 3 portability and maintenance hardening. Shipped. -- Release 4: optional feature work only if it still fits the package scope. +- Re-evaluate only after clear real-world demand. +- Preserve the package's lightweight, PDO-first position if any further expansion happens. ## Out of scope unless demand changes