Skip to content

Commit 1f604d8

Browse files
authored
refactor: implement phase 3 ORM hardening (#25)
* Implement phase 3 ORM hardening * Address phase 3 review comments
1 parent bfdbc35 commit 1f604d8

7 files changed

Lines changed: 317 additions & 12 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ The base model gives you the methods most applications reach for first:
106106

107107
If your table includes `created_at` and `updated_at`, they are populated automatically on insert and update.
108108

109+
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.
110+
109111
### Dynamic finders and counters
110112

111113
Build expressive queries straight from method names:
@@ -157,6 +159,8 @@ $statement = Freshsauce\Model\Model::execute(
157159
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
158160
```
159161

162+
If you change a table schema at runtime and need the model to see the new columns without reconnecting, call `YourModel::refreshTableMetadata()`.
163+
160164
### Validation hooks
161165

162166
Use instance-aware hooks when writes need application rules:
@@ -226,6 +230,8 @@ Freshsauce\Model\Model::connectDb(
226230

227231
SQLite is supported in the library and covered by the automated test suite.
228232

233+
Schema-qualified table names such as `reporting.categories` are supported for PostgreSQL models.
234+
229235
## Built for real projects
230236

231237
The repository includes:

src/Model/Model.php

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,16 @@ protected static function getFieldnames(): array
403403
return self::$_tableColumns[$class];
404404
}
405405

406+
/**
407+
* Refresh cached table metadata for the current model class.
408+
*
409+
* @return void
410+
*/
411+
public static function refreshTableMetadata(): void
412+
{
413+
unset(self::$_tableColumns[static::class]);
414+
}
415+
406416
/**
407417
* Split a table name into schema and table, defaulting schema to public.
408418
*
@@ -985,7 +995,7 @@ protected static function fetchWhereWithSuffix(string $SQLfragment = '', array $
985995
* returns an array of objects of the sub-class which match the conditions
986996
*
987997
* @param string $SQLfragment conditions, sorting, grouping and limit to apply (to right of WHERE keywords)
988-
* @param array<int, mixed> $params optional params to be escaped and injected into the SQL query (standrd PDO syntax)
998+
* @param array<int, mixed> $params optional params to be escaped and injected into the SQL query (standard PDO syntax)
989999
* @param bool $limitOne if true the first match will be returned
9901000
*
9911001
* @return array|static|null
@@ -999,7 +1009,7 @@ public static function fetchWhere(string $SQLfragment = '', array $params = [],
9991009
* returns an array of objects of the sub-class which match the conditions
10001010
*
10011011
* @param string $SQLfragment conditions, sorting, grouping and limit to apply (to right of WHERE keywords)
1002-
* @param array $params optional params to be escaped and injected into the SQL query (standrd PDO syntax)
1012+
* @param array $params optional params to be escaped and injected into the SQL query (standard PDO syntax)
10031013
*
10041014
* @return array object[] of objects of calling class
10051015
*/
@@ -1014,7 +1024,7 @@ public static function fetchAllWhere(string $SQLfragment = '', array $params = [
10141024
* returns an object of the sub-class which matches the conditions
10151025
*
10161026
* @param string $SQLfragment conditions, sorting, grouping and limit to apply (to right of WHERE keywords)
1017-
* @param array $params optional params to be escaped and injected into the SQL query (standrd PDO syntax)
1027+
* @param array $params optional params to be escaped and injected into the SQL query (standard PDO syntax)
10181028
*
10191029
* @return static|null object of calling class
10201030
*/
@@ -1137,7 +1147,7 @@ public function delete()
11371147
* Delete records based on an SQL conditions
11381148
*
11391149
* @param string $where SQL fragment of conditions
1140-
* @param array $params optional params to be escaped and injected into the SQL query (standrd PDO syntax)
1150+
* @param array $params optional params to be escaped and injected into the SQL query (standard PDO syntax)
11411151
*
11421152
* @return \PDOStatement
11431153
*/
@@ -1206,6 +1216,16 @@ protected function runUpdateValidation(): void
12061216
$this->validateForUpdate();
12071217
}
12081218

1219+
/**
1220+
* Return a UTC timestamp string suitable for the built-in timestamp columns.
1221+
*
1222+
* @return string
1223+
*/
1224+
protected static function currentTimestamp(): string
1225+
{
1226+
return gmdate('Y-m-d H:i:s');
1227+
}
1228+
12091229
/**
12101230
* insert a row into the database table, and update the primary key field with the one generated on insert
12111231
*
@@ -1219,7 +1239,7 @@ protected function runUpdateValidation(): void
12191239
public function insert(bool $autoTimestamp = true, bool $allowSetPrimaryKey = false): bool
12201240
{
12211241
$pk = static::$_primary_column_name;
1222-
$timeStr = gmdate('Y-m-d H:i:s');
1242+
$timeStr = static::currentTimestamp();
12231243
if ($autoTimestamp && in_array('created_at', static::getFieldnames())) {
12241244
$this->created_at = $timeStr;
12251245
}
@@ -1291,7 +1311,7 @@ public function insert(bool $autoTimestamp = true, bool $allowSetPrimaryKey = fa
12911311
public function update(bool $autoTimestamp = true): bool
12921312
{
12931313
if ($autoTimestamp && in_array('updated_at', static::getFieldnames())) {
1294-
$this->updated_at = gmdate('Y-m-d H:i:s');
1314+
$this->updated_at = static::currentTimestamp();
12951315
}
12961316
$this->runUpdateValidation();
12971317
$set = $this->setString();
@@ -1305,10 +1325,11 @@ public function update(bool $autoTimestamp = true): bool
13051325
$query,
13061326
$set['params']
13071327
);
1308-
if ($st->rowCount() == 1) {
1328+
if ($this->updateSucceeded($st)) {
13091329
$this->clearDirtyFields();
1330+
return true;
13101331
}
1311-
return ($st->rowCount() == 1);
1332+
return false;
13121333
}
13131334

13141335
/**
@@ -1417,6 +1438,37 @@ protected function hasPrimaryKeyValue(): bool
14171438
return $this->$primaryKey !== null;
14181439
}
14191440

1441+
/**
1442+
* Determine whether an update succeeded even when the driver reports zero changed rows.
1443+
*
1444+
* @param \PDOStatement $statement
1445+
*
1446+
* @return bool
1447+
*/
1448+
protected function updateSucceeded(\PDOStatement $statement): bool
1449+
{
1450+
$count = $statement->rowCount();
1451+
1452+
if ($count === 1) {
1453+
return true;
1454+
}
1455+
1456+
if ($count === 0) {
1457+
return static::existsWhere(
1458+
static::_quote_identifier(static::$_primary_column_name) . ' = ?',
1459+
[$this->{static::$_primary_column_name}]
1460+
);
1461+
}
1462+
1463+
throw new ModelException(
1464+
sprintf(
1465+
'Update affected %d rows for %s; expected at most one row.',
1466+
$count,
1467+
static::class
1468+
)
1469+
);
1470+
}
1471+
14201472
/**
14211473
* @param mixed $match
14221474
*

test-src/Model/CodedCategory.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Model;
6+
7+
/**
8+
* @property int|null $code
9+
* @property string|null $name
10+
*/
11+
class CodedCategory extends \Freshsauce\Model\Model
12+
{
13+
protected static $_primary_column_name = 'code';
14+
15+
protected static $_tableName = 'coded_categories';
16+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Model;
6+
7+
/**
8+
* @property int|null $id
9+
* @property string|null $name
10+
* @property string|null $description
11+
*/
12+
class MetadataRefreshCategory extends \Freshsauce\Model\Model
13+
{
14+
protected static $_tableName = 'metadata_refresh_categories';
15+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Model;
6+
7+
/**
8+
* @property int|null $id
9+
* @property string|null $name
10+
*/
11+
class SchemaQualifiedCategory extends \Freshsauce\Model\Model
12+
{
13+
protected static $_tableName = 'orm_phase3.schema_categories';
14+
}

test-src/Model/UntimedCategory.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Model;
6+
7+
/**
8+
* @property int|null $id
9+
* @property string|null $name
10+
*/
11+
class UntimedCategory extends \Freshsauce\Model\Model
12+
{
13+
protected static $_tableName = 'untimed_categories';
14+
}

0 commit comments

Comments
 (0)