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

saygoweb / anorm / 25250600993

02 May 2026 11:12AM UTC coverage: 84.558% (+0.2%) from 84.349%
25250600993

Pull #48

github

web-flow
Merge aa160d1f4 into fda280951
Pull Request #48: Read/write lifecycle hooks for change detection

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
namespace Anorm;
4

5
class DataMapper
6
{
7
    public const MODE_DYNAMIC = 'dynamic';
8
    public const MODE_STATIC  = 'static';
9

10
    public $mode = self::MODE_STATIC;
11

12
    /** @var \PDO  */
13
    public $pdo;
14

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

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

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

24
    /** @var string Name of the table */
25
    public $table;
26

27
    /** @var TransformInterface[] */
28
    public $transformers = [];
29

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

247
            $c->_lastSnapshot = $this->captureSnapshot($c);
6✔
248
        }
249

250
        return $c->$key;
58✔
251
    }
252

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

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

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

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

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

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

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

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

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

411
    public static function find($creatable, $pdo)
412
    {
413
        return new QueryBuilder($creatable, $pdo);
102✔
414
    }
415
}
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