• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

saygoweb / anorm / 25251759671

02 May 2026 12:20PM UTC coverage: 84.558% (+0.2%) from 84.349%
25251759671

push

github

web-flow
Read/write lifecycle hooks for change detection (#48)

* Add CLAUDE.md and Serena project config/memories

Onboards AI agents (Claude Code, Serena) with project layout, conventions,
commands, and done-criteria so future sessions can ramp up without
re-deriving context. Memories cover the in-flight 2026-05 change-detection
work as well as general project conventions.

* feat(lifecycle): add ChangeListenerInterface and listener registry

Bootstraps the new Anorm\Lifecycle namespace with the listener interface,
re-entrancy exception type, and static set/get on DataMapper. No call sites
yet — purely additive.

* fix(lifecycle): reset insideListener flag and drop unused import

Address code-review feedback on Task 1: setChangeListener(null) now
also resets the re-entrancy guard so tearDown is exhaustive ahead of
Task 6 landing the actual write-path guard. Drop the pre-staged
ChangeListenerInterface use from the test file — Task 6 will reintroduce
it when first needed.

* test(lifecycle): add fixture schema, model, and value-object classes

LifecycleModel exposes id, name, email, dtu, payload — enough to cover
infrastructureProperties exclusion (dtu) and partial-load (email)
scenarios. Three value-object classes cover the three object-equality
paths: LifecycleMoney (equals), LifecycleSameMoney (isSame),
LifecycleBagMoney (loose ==).

* style(lifecycle): import TestEnvironment instead of inline FQCN

Match the convention of every other test file in the suite and the
already-imported LifecycleModel / RecordingListener in this same file.
No behavioural change.

* feat(lifecycle): add Model::$_lastSnapshot property

Underscore-prefixed so it stays out of the column map. Defaults to null.
Will be populated by DataMapper::readArray when a listener is registered.

Also fixes DataMapper::autoMap to properly skip underscore-prefixed
properties per the existing convention used in write() and readArray().

* refactor(autoMap): drop dead unset and noisy comment

The un... (continued)

61 of 65 new or added lines in 1 file covered. (93.85%)

2 existing lines in 1 file now uncovered.

1692 of 2001 relevant lines covered (84.56%)

17.05 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.91
/src/DataMapper.php
1
<?php
2

3
// phpcs:disable Generic.Commenting.Todo.TaskFound
4

5
namespace Anorm;
6

7
class DataMapper
8
{
9
    public const MODE_DYNAMIC = 'dynamic';
10
    public const MODE_STATIC  = 'static';
11

12
    public $mode = self::MODE_STATIC;
13

14
    /** @var \PDO  */
15
    public $pdo;
16

17
    /** @var array<string, string> Map of property names to column names */
18
    public $map;
19

20
    /** @var string The property in the model that is used as the primary key */
21
    public $modelPrimaryKey = 'id';
22

23
    /** @var bool If true REPLACE is used rather than INSERT and UPDATE */
24
    public $useReplace = false;
25

26
    /** @var string Name of the table */
27
    public $table;
28

29
    /** @var TransformInterface[] */
30
    public $transformers = [];
31

32
    /**
33
     * Property names that should not appear in diff output.
34
     * Default empty — Anorm has no opinion on which properties are "infrastructure."
35
     * Consumers set this per DataMapper (e.g. ['dtc','dtu','uc','uu']).
36
     * @var array<int, string>
37
     */
38
    public $infrastructureProperties = [];
39

40
    /** @var \Anorm\Lifecycle\ChangeListenerInterface|null */
41
    private static $changeListener = null;
42

43
    /** @var bool true while a listener's onWrite is executing (re-entrancy guard) */
44
    private static $insideListener = false;
45

46
    public static function create(\PDO $pdo, $table, $map)
47
    {
48
        $mapper = new DataMapper($pdo, $table, $map);
214✔
49
        return $mapper;
214✔
50
    }
51

52
    public static function createByClass(\PDO $pdo, $c, $tablePrefix = '')
53
    {
54
        return self::create($pdo, $tablePrefix . self::autoTable($c), self::autoMap($c));
212✔
55
    }
56

57
    public static function setChangeListener(?\Anorm\Lifecycle\ChangeListenerInterface $listener): void
58
    {
59
        self::$changeListener = $listener;
25✔
60
        if ($listener === null) {
25✔
61
            self::$insideListener = false;
25✔
62
        }
63
    }
64

65
    public static function getChangeListener(): ?\Anorm\Lifecycle\ChangeListenerInterface
66
    {
67
        return self::$changeListener;
1✔
68
    }
69

70
    /**
71
     * Private constructor, use the public create methods instead.
72
     * @see createByClass
73
     * @see create
74
     */
75
    private function __construct(\PDO $pdo, $table, $map)
76
    {
77
        $this->pdo = $pdo;
214✔
78
        $this->table = $table;
214✔
79
        $this->map = $map;
214✔
80
    }
81

82
    public static function autoTable($c)
83
    {
84
        $className = get_class($c);
213✔
85
        $parts = explode('\\', $className);
213✔
86
        $partCount = count($parts);
213✔
87
        if ($partCount > 1) {
213✔
88
            $className = $parts[$partCount - 1];
204✔
89
        }
90
        $parts = self::splitUpper($className);
213✔
91
        $tableName = '';
213✔
92
        if ($parts) {
213✔
93
            $tableName .= strtolower($parts[0]);
213✔
94
            $partCount = count($parts);
213✔
95
            for ($i = 1; $i < $partCount; $i++) {
213✔
96
                if ($parts[$i] == 'Model') {
213✔
97
                    continue;
213✔
98
                }
99
                $tableName .= '_' . strtolower($parts[$i]);
96✔
100
            }
101
        }
102
        return $tableName;
213✔
103
    }
104

105
    public static function splitUpper($s)
106
    {
107
        $matches = [];
218✔
108
        $matchCount = preg_match_all('/[A-Z][a-z0-9]*/', $s, $matches);
218✔
109
        if ($matchCount > 0) {
218✔
110
            return $matches[0];
217✔
111
        }
112
        return [];
212✔
113
    }
114

115
    public static function propertyName($s)
116
    {
117
        $matches = [];
216✔
118
        $matchCount = preg_match_all('/^([a-z0-9]+)((?:[A-Z][a-z0-9]*)*)/', $s, $matches);
216✔
119
        $propertyName = '';
216✔
120
        if ($matchCount == 1) {
216✔
121
            $propertyName .= strtolower($matches[1][0]);
216✔
122
            $parts = self::splitUpper($matches[2][0]);
216✔
123
            foreach ($parts as $part) {
216✔
124
                $propertyName .= '_' . strtolower($part);
94✔
125
            }
126
        }
127
        return $propertyName;
216✔
128
    }
129

130
    public static function autoMap($c)
131
    {
132
        $properties = get_object_vars($c);
213✔
133
        foreach ($properties as $key => $value) {
213✔
134
            // Framework properties (_mapper, _lastSnapshot, etc.) are never columns.
135
            // get_object_vars() has already populated $properties with these keys
136
            // before the loop, so the unset is required — `continue` alone would
137
            // leave the key in place with its original value.
138
            if ($key[0] === '_') {
213✔
139
                unset($properties[$key]);
212✔
140
                continue;
212✔
141
            }
142
            $properties[$key] = self::propertyName($key);
213✔
143
        }
144
        return $properties;
213✔
145
    }
146

147
    public function write(&$c)
148
    {
149
        if (self::$insideListener) {
62✔
150
            throw new \Anorm\Lifecycle\ReentrantWriteException(
2✔
151
                'DataMapper::write() called inside a ChangeListener. Defer the write until after the listener returns.'
2✔
152
            );
2✔
153
        }
154

155
        $hasListener = (self::$changeListener !== null);
62✔
156
        $isInsert    = $hasListener && ($c->_lastSnapshot === null);
62✔
157
        $snapshot    = $hasListener ? $c->_lastSnapshot : null;
62✔
158

159
        $key = $this->modelPrimaryKey;
62✔
160
        if ($this->useReplace) {
62✔
161
            if (!$c->$key) {
2✔
162
                throw new \Exception("Key '$key' must be set when using replace mode");
1✔
163
            }
164
            $fields = '';
1✔
165
            $values = '';
1✔
166
            foreach ($this->map as $property => $field) {
1✔
167
                if ($property[0] == '_') {
1✔
UNCOV
168
                    continue;
×
169
                }
170
                if ($fields) {
1✔
171
                    $fields .= ', ';
1✔
172
                }
173
                if ($values) {
1✔
174
                    $values .= ', ';
1✔
175
                }
176
                $fields .= $field;
1✔
177
                if ($c->$property === null) {
1✔
178
                    $value = 'NULL';
1✔
179
                } else {
180
                    if (array_key_exists($field, $this->transformers)) {
1✔
181
                        $transformedValue = $this->transformers[$field]->txModelToDatabase($c->$property);
1✔
182
                        $value = $transformedValue === null ? 'NULL' : $this->pdo->quote($transformedValue);
1✔
183
                    } else {
184
                        $value = $this->pdo->quote($c->$property);
1✔
185
                    }
186
                }
187
                $values .= $value;
1✔
188
            }
189
            $keyField = $this->map[$key];
1✔
190
            $id = $c->$key;
1✔
191
            // CP Maybe REPLACE isn't the best to use? It requires a unique key in the db
192
            // An alternative would be to detect based on SELECT query WHERE key and if found ...
193
            $sql = 'REPLACE INTO`' . $this->table . '` (' . $fields . ') VALUES (' . $values . ')';
1✔
194
            $this->dynamicWrapper(function () use ($sql) {
1✔
195
                $this->pdo->query($sql);
1✔
196
            }, $c);
1✔
197
        } else {
198
            $set = '';
60✔
199
            foreach ($this->map as $property => $field) {
60✔
200
                if ($property == $key || $property[0] == '_') {
60✔
201
                    continue;
60✔
202
                }
203
                if ($set) {
60✔
204
                    $set .= ', ';
60✔
205
                }
206
                if ($c->$property === null) {
60✔
207
                    $value = 'NULL';
55✔
208
                } else {
209
                    if (array_key_exists($field, $this->transformers)) {
59✔
210
                        $transformedValue = $this->transformers[$field]->txModelToDatabase($c->$property);
39✔
211
                        $value = $transformedValue === null ? 'NULL' : $this->pdo->quote($transformedValue);
39✔
212
                    } else {
213
                        $value = $this->pdo->quote($c->$property);
58✔
214
                    }
215
                }
216
                // TODO Move this to bound value CP 2020-06
217
                $set .= "$field=$value";
60✔
218
            }
219
            if ($c->$key === null || $c->$key === '') {
60✔
220
                $sql = 'INSERT INTO `' . $this->table . '` SET ' . $set;
60✔
221
                $this->dynamicWrapper(function () use ($sql, $c, $key) {
60✔
222
                    $result = $this->pdo->query($sql);
60✔
223
                    $c->$key = $this->pdo->lastInsertId();
59✔
224
                }, $c);
60✔
225
            } else {
226
                $keyField = $this->map[$key];
8✔
227
                $id = $c->$key;
8✔
228
                $sql = 'UPDATE `' . $this->table . '` SET ' . $set . ' WHERE ' . $keyField . "='" . $id . "'";
8✔
229
                $this->dynamicWrapper(function () use ($sql) {
8✔
230
                    $this->pdo->query($sql);
8✔
231
                }, $c);
8✔
232
            }
233
        }
234

235
        if ($hasListener) {
60✔
236
            $diff = $isInsert ? [] : $this->diff($snapshot, $c);
8✔
237
            self::$insideListener = true;
8✔
238
            try {
239
                self::$changeListener->onWrite($c, $diff, $isInsert);
8✔
240
            } catch (\Anorm\Lifecycle\ReentrantWriteException $e) {
3✔
241
                $c->_lastSnapshot = $this->captureSnapshot($c);
2✔
242
                throw $e;
2✔
243
            } catch (\Throwable $e) {
1✔
244
                error_log('Anorm change listener threw: ' . $e->getMessage());
1✔
245
            } finally {
246
                self::$insideListener = false;
8✔
247
            }
248

249
            $c->_lastSnapshot = $this->captureSnapshot($c);
6✔
250
        }
251

252
        return $c->$key;
58✔
253
    }
254

255
    public function read(&$c, $id)
256
    {
257
        $databasePrimaryKey = $this->map[$this->modelPrimaryKey];
21✔
258
        // TODO Could make the '*' explicit from the map
259
        $sql = 'SELECT * FROM `' . $this->table . '` WHERE ' . $databasePrimaryKey . "='" . $id . "'";
21✔
260
        $result = $this->dynamicWrapper(function () use ($sql) {
21✔
261
            return $this->pdo->query($sql);
21✔
262
        }, $c);
21✔
263
        return $this->readRow($c, $result);
21✔
264
    }
265

266
    private function dynamicWrapper(callable $fn, $model = null)
267
    {
268
        $lastException = '';
140✔
269
        for ($strike = 0; $strike < 100; ++$strike) {
140✔
270
            try {
271
                return $fn();
140✔
272
            } catch (\PDOException $e) {
8✔
273
                if ($this->mode !== self::MODE_DYNAMIC) {
8✔
274
                    throw $e;
2✔
275
                }
276
                TableMaker::fix($e, $this, $model);
6✔
277
                if ($e->getMessage() == $lastException) {
6✔
278
                    // Same exception twice in a row so throw.
279
                    throw $e; // @codeCoverageIgnore
280
                }
281
                $lastException = $e->getMessage();
6✔
282
            }
283
        }
284
        throw new \Exception("$strike strikes in DataMapper."); // @codeCoverageIgnore
285
    }
286

287
    /**
288
     * @param string $sql SQL query
289
     * @param array|null $data array of values for bound parameters
290
     * @return \PDOStatement
291
     */
292
    public function query($sql, $data = null)
293
    {
294
        return $this->dynamicWrapper(function () use ($sql, $data) {
112✔
295
            $statement = $this->pdo->prepare($sql);
112✔
296
            $statement->execute($data);
112✔
297
            return $statement;
111✔
298
        });
112✔
299
    }
300

301
    /**
302
     * @param mixed $c A reference to the model
303
     * @param \PDOStatement $result The result returned from a prior call to query
304
     * @see query
305
     */
306
    public function readRow(&$c, \PDOStatement $result)
307
    {
308
        $data = $result->fetch(\PDO::FETCH_ASSOC);
27✔
309
        return $this->readArray($c, $data);
27✔
310
    }
311

312
    public function readArray(&$c, $data, $exclude = [])
313
    {
314
        if (!$data) {
121✔
315
            return false;
6✔
316
        }
317
        foreach ($this->map as $property => $field) {
115✔
318
            if ($property[0] == '_') {
115✔
UNCOV
319
                continue;
×
320
            }
321
            if (!in_array($property, $exclude) && array_key_exists($field, $data)) {
115✔
322
                if (array_key_exists($field, $this->transformers)) {
115✔
323
                    $c->$property = $this->transformers[$field]->txDatabaseToModel($data[$field]);
37✔
324
                } else {
325
                    $c->$property = $data[$field];
115✔
326
                }
327
            }
328
        }
329
        if (self::$changeListener !== null) {
115✔
330
            $c->_lastSnapshot = $this->captureSnapshot($c);
4✔
331
        }
332
        return true;
115✔
333
    }
334

335
    private function captureSnapshot(Model $c): array
336
    {
337
        $out = [];
9✔
338
        foreach ($this->map as $property => $field) {
9✔
339
            if ($property[0] === '_') {
9✔
NEW
340
                continue;
×
341
            }
342
            $v = $c->$property;
9✔
343
            $out[$property] = is_object($v) ? clone $v : $v;
9✔
344
        }
345
        return $out;
9✔
346
    }
347

348
    /**
349
     * Compute the per-property delta between a snapshot and the current model state.
350
     * @return array<string, array{from: mixed, to: mixed}>
351
     */
352
    public function diff(array $snapshot, Model $current): array
353
    {
354
        $loaded = $current->getLoadedFields();
14✔
355
        $out = [];
14✔
356
        foreach ($this->map as $property => $field) {
14✔
357
            if ($property[0] === '_') {
14✔
NEW
358
                continue;
×
359
            }
360
            if ($property === $this->modelPrimaryKey) {
14✔
361
                continue;
14✔
362
            }
363
            if (in_array($property, $this->infrastructureProperties, true)) {
14✔
364
                continue;
1✔
365
            }
366
            if ($loaded !== null && !in_array($property, $loaded, true)) {
14✔
367
                continue;
2✔
368
            }
369
            $from = array_key_exists($property, $snapshot) ? $snapshot[$property] : null;
14✔
370
            $to   = $current->$property;
14✔
371
            if (!$this->valuesEqual($from, $to)) {
14✔
372
                $out[$property] = ['from' => $from, 'to' => $to];
7✔
373
            }
374
        }
375
        return $out;
14✔
376
    }
377

378
    private function valuesEqual($a, $b): bool
379
    {
380
        if ($a === $b) {
14✔
381
            return true;
12✔
382
        }
383
        if ($a === null || $b === null) {
10✔
NEW
384
            return false;
×
385
        }
386
        if (is_object($a) && is_object($b)) {
10✔
387
            if (get_class($a) !== get_class($b)) {
5✔
388
                return false;
1✔
389
            }
390
            if (method_exists($a, 'equals')) {
4✔
391
                return (bool) $a->equals($b);
1✔
392
            }
393
            if (method_exists($a, 'isSame')) {
3✔
394
                return (bool) $a->isSame($b);
1✔
395
            }
396
            return $a == $b; // PHP property-by-property equality
2✔
397
        }
398
        if (is_array($a) && is_array($b)) {
5✔
NEW
399
            return $a === $b;
×
400
        }
401
        return $a === $b;
5✔
402
    }
403

404
    public function delete($id)
405
    {
406
        $keyField = $this->map[$this->modelPrimaryKey];
6✔
407
        $sql = 'DELETE FROM `' . $this->table . '` WHERE ' . $keyField . "='" . $id . "'";
6✔
408
        $result = $this->query($sql);
6✔
409
        // This allows for imprecise deletes which may not be the best idea. CP 25 Nov 2018
410
        return $result->rowCount() >= 1;
5✔
411
    }
412

413
    public static function find($creatable, $pdo)
414
    {
415
        return new QueryBuilder($creatable, $pdo);
102✔
416
    }
417
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc