Skip to content

Commit a8b480d

Browse files
authored
feat: implement phase 4 optional ORM features (#28)
* Implement phase 4 optional ORM features * Address PR review feedback * Trim roadmap to remaining backlog
1 parent 8ef11f5 commit a8b480d

9 files changed

Lines changed: 1001 additions & 154 deletions

File tree

README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Skip it if you need relationship graphs, migrations, or a chainable query builde
2424
- PDO-first: keep the convenience methods, keep full access to SQL, keep control.
2525
- Framework-agnostic: use it in custom apps, legacy codebases, small services, or greenfield projects.
2626
- Productive defaults: CRUD helpers, dynamic finders, counters, hydration, and timestamp handling are ready out of the box.
27+
- Practical opt-ins: transaction helpers, configurable timestamp columns, and attribute casting stay lightweight but cover common app needs.
2728
- Portable across databases: exercised against MySQL/MariaDB, PostgreSQL, and SQLite.
2829

2930
## Install in minutes
@@ -111,11 +112,74 @@ The base model gives you the methods most applications reach for first:
111112
- `first()`
112113
- `last()`
113114
- `count()`
115+
- `transaction()`
116+
- `beginTransaction()`
117+
- `commit()`
118+
- `rollBack()`
114119

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

117122
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.
118123

124+
### Transactions without leaving the model
125+
126+
Use the built-in transaction helper when several writes should succeed or fail together:
127+
128+
```php
129+
Category::transaction(function (): void {
130+
$first = new Category(['name' => 'Sci-Fi']);
131+
$first->save();
132+
133+
$second = new Category(['name' => 'Fantasy']);
134+
$second->save();
135+
});
136+
```
137+
138+
If you need lower-level control, the model also exposes `beginTransaction()`, `commit()`, and `rollBack()` as thin wrappers around the current PDO connection.
139+
140+
### Timestamp columns can be configured per model
141+
142+
The default convention remains `created_at` and `updated_at`, but models can now opt into different column names or disable automatic timestamps entirely:
143+
144+
```php
145+
class AuditLog extends Freshsauce\Model\Model
146+
{
147+
protected static $_tableName = 'audit_logs';
148+
protected static ?string $_created_at_column = 'created_on';
149+
protected static ?string $_updated_at_column = 'modified_on';
150+
}
151+
152+
class LegacyCategory extends Freshsauce\Model\Model
153+
{
154+
protected static $_tableName = 'legacy_categories';
155+
protected static bool $_auto_timestamps = false;
156+
}
157+
```
158+
159+
### Attribute casting
160+
161+
Cast common fields to application-friendly PHP types:
162+
163+
```php
164+
class Product extends Freshsauce\Model\Model
165+
{
166+
protected static $_tableName = 'products';
167+
168+
protected static array $_casts = [
169+
'stock' => 'integer',
170+
'price' => 'float',
171+
'is_active' => 'boolean',
172+
'published_at' => 'datetime',
173+
'tags' => 'array',
174+
'settings' => 'object',
175+
];
176+
}
177+
```
178+
179+
Supported cast types are `integer`, `float`, `boolean`, `datetime`, `array`, and `object`.
180+
181+
`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.
182+
119183
### Dynamic finders and counters
120184

121185
Build expressive queries straight from method names:

ROADMAP.md

Lines changed: 8 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,124 +1,21 @@
11
# Roadmap
22

3-
This roadmap tracks the improvement work for `freshsauce/model`.
3+
This roadmap now tracks only the remaining optional backlog for `freshsauce/model`.
44

5-
Current status:
5+
Phases 1 through 3 are complete, and Phase 4.1 through 4.3 have been delivered.
66

7-
1. Phase 1 is complete.
8-
2. Phase 2 is complete.
9-
3. Phase 3 is complete.
10-
4. Phase 4 remains optional and has not been started.
11-
12-
The sequencing remains intentional:
13-
14-
1. Fix correctness issues before expanding the API.
15-
2. Improve developer ergonomics without turning the library into a heavyweight ORM.
16-
3. Tighten quality and portability before considering broader feature growth.
17-
4. Add optional features only where they preserve the package's lightweight position.
18-
19-
## Principles
20-
21-
- Keep PDO-first escape hatches intact.
22-
- Prefer additive changes with low migration cost.
23-
- Tighten behavior with tests before changing public APIs.
24-
- Avoid feature growth that pushes the library toward framework-scale complexity.
25-
26-
## Phase 1: Core correctness and safety
27-
28-
Status: completed
29-
30-
Summary:
31-
32-
- Fixed serialization, zero-like primary key handling, invalid dynamic finder failures, and empty-array query behavior.
33-
- Replaced generic exceptions with a small library exception hierarchy.
34-
- Added regression coverage for the above edge cases.
35-
36-
## Phase 2: API ergonomics and typing
37-
38-
Status: completed
39-
40-
Summary:
41-
42-
- Added instance-aware validation hooks with legacy compatibility.
43-
- Added optional strict field handling and focused query helpers.
44-
- Tightened typing, static analysis, and public documentation around the preferred API.
45-
46-
## Phase 3: Quality, portability, and maintenance
47-
48-
Status: completed
49-
50-
Summary:
51-
52-
- Expanded cross-driver integration coverage for connection sharing, custom keys, metadata refresh, timestamp behavior, and PostgreSQL schema-qualified tables.
53-
- Added `refreshTableMetadata()` and made UTC timestamp behavior explicit.
54-
- Normalized no-op update handling while preserving single-row primary key update expectations.
55-
56-
## Phase 4: Optional feature expansion
57-
58-
Goal: add features that help real applications, but only if they fit the package's lightweight position.
59-
60-
Priority: lower
61-
62-
Phases 1 through 3 are complete, so this is now the remaining backlog.
63-
64-
### Candidate 4.1: Transaction helpers
65-
66-
Possible scope:
67-
68-
- `transaction(callable $callback)`
69-
- pass through `beginTransaction()`, `commit()`, `rollBack()` wrappers
70-
71-
Why:
72-
This adds practical value without changing the core model shape.
73-
74-
### Candidate 4.2: Configurable timestamp columns
75-
76-
Possible scope:
77-
78-
- opt-in timestamp column names
79-
- disable automatic timestamps per model
80-
81-
Why:
82-
The current `created_at` / `updated_at` convention is convenient but rigid.
83-
84-
### Candidate 4.3: Attribute casting
85-
86-
Possible scope:
87-
88-
- integer
89-
- float
90-
- boolean
91-
- datetime
92-
- JSON array/object
93-
94-
Why:
95-
Casting improves ergonomics substantially without requiring relationships or a large query layer.
96-
97-
### Candidate 4.4: Composite keys or relationship support
7+
## Remaining item: 4.4 Composite keys or relationship support
988

999
Why this is last:
100-
This is where complexity rises sharply. It should only happen if the maintainer wants the library to move beyond lightweight active-record usage.
10+
11+
- This is where complexity rises sharply.
12+
- It should only happen if the maintainer wants the library to move beyond lightweight active-record usage.
10113

10214
Recommendation:
10315

10416
- Do not start here by default.
105-
- Re-evaluate only after the earlier phases have shipped and real user demand is clear.
106-
107-
## Suggested issue order
108-
109-
If this work is split into GitHub issues, the most practical order is:
110-
111-
1. Add transaction helpers.
112-
2. Add configurable timestamp column support.
113-
3. Add attribute casting.
114-
4. Re-evaluate whether composite keys or relationship support are warranted.
115-
116-
## Suggested release strategy
117-
118-
- Release 1: Phase 1 correctness and exception work. Shipped.
119-
- Release 2: Phase 2 ergonomics, typing, and documentation updates. Shipped.
120-
- Release 3: Phase 3 portability and maintenance hardening. Shipped.
121-
- Release 4: optional feature work only if it still fits the package scope.
17+
- Re-evaluate only after clear real-world demand.
18+
- Preserve the package's lightweight, PDO-first position if any further expansion happens.
12219

12320
## Out of scope unless demand changes
12421

docs/api-reference.md

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,24 @@ protected static $_primary_column_name = 'code';
2626

2727
Optional. Defaults to `false`. When enabled, unknown assignments throw `UnknownFieldException`.
2828

29+
### `protected static bool $_auto_timestamps`
30+
31+
Optional. Defaults to `true`. Set to `false` to disable built-in automatic timestamp handling for the model.
32+
33+
### `protected static ?string $_created_at_column`
34+
35+
Optional. Defaults to `created_at`. Set to a different column name to customise insert timestamps, or `null` to disable created-at writes.
36+
37+
### `protected static ?string $_updated_at_column`
38+
39+
Optional. Defaults to `updated_at`. Set to a different column name to customise insert/update timestamps, or `null` to disable updated-at writes.
40+
41+
### `protected static array $_casts`
42+
43+
Optional. Field cast map. Supported cast types are `integer`, `float`, `boolean`, `datetime`, `array`, and `object`.
44+
45+
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.
46+
2947
### `public static $_db`
3048

3149
Inherited shared PDO connection. Redeclare this in a subclass only when that subclass needs an isolated connection.
@@ -90,12 +108,15 @@ Behavior:
90108

91109
- in strict mode, resolves the name against real fields first
92110
- creates the internal data object on first assignment
111+
- applies configured attribute casts before storing the value
93112
- marks the field as dirty
94113

95114
### `__get(string $name): mixed`
96115

97116
Returns a field value from the internal data store.
98117

118+
When a field is configured in `$_casts`, the returned value is the cast PHP value.
119+
99120
Throws:
100121

101122
- `MissingDataException` when data has not been initialised
@@ -131,6 +152,28 @@ Use this after runtime schema changes.
131152

132153
Prepares and executes a statement, returning the `PDOStatement`.
133154

155+
### `beginTransaction(): bool`
156+
157+
Begins a transaction on the current model connection.
158+
159+
### `commit(): bool`
160+
161+
Commits the current transaction on the current model connection.
162+
163+
### `rollBack(): bool`
164+
165+
Rolls back the current transaction on the current model connection.
166+
167+
### `transaction(callable $callback): mixed`
168+
169+
Runs the callback inside a transaction and returns the callback result.
170+
171+
Behavior:
172+
173+
- begins and commits a transaction when no transaction is active
174+
- rolls back automatically when the callback throws
175+
- reuses an already-open outer transaction instead of nesting another one
176+
134177
### `datetimeToMysqldatetime(int|string $dt): string`
135178

136179
Converts a Unix timestamp or date string into `Y-m-d H:i:s`.
@@ -160,7 +203,7 @@ Inserts the current model as a new row.
160203

161204
Behavior:
162205

163-
- auto-fills `created_at` and `updated_at` when enabled and the fields exist
206+
- auto-fills the configured created/update timestamp columns when enabled and the fields exist
164207
- runs `validateForSave()` and `validateForInsert()`
165208
- clears dirty flags on success
166209
- updates the model's primary key from the database when the key is generated by the database
@@ -174,7 +217,7 @@ Updates the current row by primary key.
174217

175218
Behavior:
176219

177-
- auto-fills `updated_at` when enabled and the field exists
220+
- auto-fills the configured update timestamp column when enabled and the field exists
178221
- runs `validateForSave()` and `validateForUpdate()`
179222
- updates only dirty known fields
180223
- returns `false` when there are no dirty fields to write

0 commit comments

Comments
 (0)