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

saygoweb / anorm / 18485531299

14 Oct 2025 04:31AM UTC coverage: 84.381% (+3.9%) from 80.483%
18485531299

Pull #42

github

web-flow
Merge 93e4d6a17 into 025025bcf
Pull Request #42: Implement comprehensive join optimization system to solve N+1 query problem

847 of 962 new or added lines in 19 files covered. (88.05%)

18 existing lines in 1 file now uncovered.

1637 of 1940 relevant lines covered (84.38%)

15.7 hits per line

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

80.46
/src/Model.php
1
<?php
2

3
// phpcs:disable PSR2.Classes.PropertyDeclaration.Underscore
4

5
namespace Anorm;
6

7
use Anorm\Relationship\RelationshipManager;
8

9
class Model
10
{
11
    /** @var DataMapper */
12
    public $_mapper;
13

14
    /** @var RelationshipManager */
15
    public $_relationshipManager;
16

17
    /** @var \PDO */
18
    protected $_pdo;
19

20
    /** @var array|null Fields that have been loaded (for partial loading) */
21
    private $_loadedFields = null;
22

23
    public function __construct(\PDO $pdo, DataMapper $mapper)
24
    {
25
        $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
182✔
26
        $this->_mapper = $mapper;
182✔
27
        $this->_pdo = $pdo;
182✔
28
        $this->_relationshipManager = new RelationshipManager($this, $pdo);
182✔
29
    }
30

31
    /**
32
     * Get the PDO connection
33
     * @return \PDO
34
     */
35
    public function getPdo(): \PDO
36
    {
37
        return $this->_pdo;
14✔
38
    }
39

40
    /**
41
     * Set which fields have been loaded (for partial loading)
42
     * @param array|null $fields Array of field names that were loaded, or null to reset
43
     * @return void
44
     */
45
    public function setLoadedFields(?array $fields): void
46
    {
47
        $this->_loadedFields = $fields;
13✔
48
    }
49

50
    /**
51
     * Check if a specific field has been loaded
52
     * @param mixed $fieldName Name of the field to check
53
     * @return bool True if field is loaded, false otherwise
54
     */
55
    public function isFieldLoaded($fieldName): bool
56
    {
57
        // If no partial loading is active, all fields are considered loaded
58
        if ($this->_loadedFields === null) {
10✔
59
            return true;
2✔
60
        }
61

62
        return in_array($fieldName, $this->_loadedFields, true);
9✔
63
    }
64

65
    /**
66
     * Get the list of loaded fields
67
     * @return array|null Array of loaded field names, or null if all fields are loaded
68
     */
69
    public function getLoadedFields(): ?array
70
    {
71
        return $this->_loadedFields;
8✔
72
    }
73

74
    /**
75
     * Check if this model is partially loaded
76
     * @return bool True if only specific fields were loaded
77
     */
78
    public function isPartiallyLoaded(): bool
79
    {
80
        return $this->_loadedFields !== null;
7✔
81
    }
82

83
    /**
84
     * @return int The primary key id of the model.
85
     */
86
    public function write()
87
    {
88
        // In dynamic mode, ensure foreign key constraints are created before writing
89
        if ($this->_mapper->mode === DataMapper::MODE_DYNAMIC) {
44✔
90
            $this->createForeignKeyConstraints();
5✔
91
        }
92

93
        return $this->_mapper->write($this);
44✔
94
    }
95

96
    /**
97
     * @param int|string $id The primary key id of the model to read.
98
     * @return bool Returns false if not found.
99
     */
100
    public function read($id)
101
    {
102
        return $this->_mapper->read($this, $id);
13✔
103
    }
104

105
    /**
106
     * @param int|string $id The primary key id of the model to read.
107
     * @return bool Returns true if found, throws \Exception if not found.
108
     * @throws \Exception if not found.
109
     */
110
    public function readOrThrow($id)
111
    {
112
        $result = $this->_mapper->read($this, $id);
3✔
113
        if (!$result) {
3✔
114
            $className = get_class($this);
2✔
115
            $className = str_replace('Model', '', $className);
2✔
116
            $tokens = explode('\\', $className);
2✔
117
            if ($tokens && count($tokens) > 0) {
2✔
118
                $className = $tokens[count($tokens) - 1];
2✔
119
            }
120
            throw new \Exception("$className id '$id' not found");
2✔
121
        }
122
        return $result;
1✔
123
    }
124

125
    /**
126
     * Define a one-to-many relationship
127
     * This model has many instances of another model
128
     *
129
     * @param string $relatedModelClass The class name of the related model
130
     * @param string $foreignKey The foreign key column in the related table
131
     * @param string $primaryKey The primary key column in this table (default: 'id')
132
     * @param string|null $propertyName The property name to store the relationship (auto-generated if null)
133
     * @param array $options Additional options including constraint options
134
     *   - constraints: array of foreign key constraint options
135
     *     - on_delete: 'RESTRICT', 'CASCADE', 'SET NULL', 'NO ACTION' (default: 'RESTRICT')
136
     *     - on_update: 'RESTRICT', 'CASCADE', 'SET NULL', 'NO ACTION' (default: 'CASCADE')
137
     *     - constraint_name: custom constraint name (auto-generated if not provided)
138
     */
139
    protected function hasMany($relatedModelClass, $foreignKey, $primaryKey = 'id', $propertyName = null, $options = [])
140
    {
141
        // Use explicit property name or generate from class name
142
        if ($propertyName === null) {
95✔
UNCOV
143
            $propertyName = $this->getPropertyNameFromClass($relatedModelClass);
×
144
        }
145

146
        $this->_relationshipManager->hasMany($relatedModelClass, $propertyName, $foreignKey, $primaryKey, $options);
95✔
147
    }
148

149
    /**
150
     * Define a many-to-one relationship (belongs to)
151
     * This model belongs to one instance of another model
152
     *
153
     * @param string $relatedModelClass The class name of the related model
154
     * @param string $foreignKey The foreign key column in this table
155
     * @param string $primaryKey The primary key column in the related table (default: 'id')
156
     * @param string|null $propertyName The property name to store the relationship (auto-generated if null)
157
     * @param array $options Additional options including constraint options
158
     *   - constraints: array of foreign key constraint options
159
     *     - on_delete: 'RESTRICT', 'CASCADE', 'SET NULL', 'NO ACTION' (default: 'RESTRICT')
160
     *     - on_update: 'RESTRICT', 'CASCADE', 'SET NULL', 'NO ACTION' (default: 'CASCADE')
161
     *     - constraint_name: custom constraint name (auto-generated if not provided)
162
     */
163
    protected function belongsTo($relatedModelClass, $foreignKey, $primaryKey = 'id', $propertyName = null, $options = [])
164
    {
165
        // Use explicit property name or generate from class name
166
        if ($propertyName === null) {
95✔
UNCOV
167
            $propertyName = $this->getPropertyNameFromClass($relatedModelClass, true);
×
168
        }
169
        $this->_relationshipManager->belongsTo($relatedModelClass, $propertyName, $foreignKey, $primaryKey, $options);
95✔
170
    }
171

172
    /**
173
     * Define a many-to-many relationship
174
     * This model has many instances of another model through a join table
175
     *
176
     * @param string $relatedModelClass The class name of the related model
177
     * @param string $joinForeignKey The foreign key column in the join table pointing to this table
178
     * @param string $joinRelatedKey The foreign key column in the join table pointing to the related table
179
     * @param string $joinTable The name of the join table
180
     * @param string $primaryKey The primary key column in this table (default: 'id')
181
     * @param string|null $propertyName The property name to store the relationship (auto-generated if null)
182
     * @param array $options Additional options including constraint options
183
     *   - constraints: array of foreign key constraint options
184
     *     - on_delete: 'RESTRICT', 'CASCADE', 'SET NULL', 'NO ACTION' (default: 'RESTRICT')
185
     *     - on_update: 'RESTRICT', 'CASCADE', 'SET NULL', 'NO ACTION' (default: 'CASCADE')
186
     *     - constraint_name: custom constraint name (auto-generated if not provided)
187
     */
188
    protected function hasManyThrough(
189
        $relatedModelClass,
190
        $joinForeignKey,
191
        $joinRelatedKey,
192
        $joinTable,
193
        $primaryKey = 'id',
194
        $propertyName = null,
195
        $options = []
196
    ) {
197
        // Use explicit property name or generate from class name
198
        if ($propertyName === null) {
57✔
199
            $propertyName = $this->getPropertyNameFromClass($relatedModelClass);
×
200
        }
201
        $this->_relationshipManager->hasManyThrough(
57✔
202
            $relatedModelClass,
57✔
203
            $propertyName,
57✔
204
            $joinForeignKey,
57✔
205
            $joinRelatedKey,
57✔
206
            $joinTable,
57✔
207
            $primaryKey,
57✔
208
            $options
57✔
209
        );
57✔
210
    }
211

212
    /**
213
     * Load a specific relationship
214
     */
215
    public function loadRelated($relationshipName)
216
    {
217
        return $this->_relationshipManager->loadRelated($relationshipName);
34✔
218
    }
219

220
    /**
221
     * Load all defined relationships
222
     */
223
    public function loadAllRelated()
224
    {
225
        $this->_relationshipManager->loadAllRelated();
1✔
226
    }
227

228
    /**
229
     * Get the relationship manager
230
     */
231
    public function getRelationshipManager()
232
    {
233
        return $this->_relationshipManager;
53✔
234
    }
235

236
    /**
237
     * Generate a property name from a model class name
238
     * @param string $className The model class name
239
     * @param bool $singular Whether to use singular form (for belongsTo)
240
     * @return string The property name
241
     */
242
    private function getPropertyNameFromClass($className, $singular = false)
243
    {
244
        // Remove namespace and 'Model' suffix
245
        $parts = explode('\\', $className);
×
UNCOV
246
        $shortName = end($parts);
×
UNCOV
247
        $shortName = str_replace('Model', '', $shortName);
×
248

249
        // Convert to camelCase
UNCOV
250
        $propertyName = lcfirst($shortName);
×
251

252
        // For hasMany relationships, make it plural (simple approach)
UNCOV
253
        if (!$singular) {
×
UNCOV
254
            $propertyName .= 's';
×
255
        }
256

UNCOV
257
        return $propertyName;
×
258
    }
259

260
    /**
261
     * Create constraint options for CASCADE delete behavior
262
     * Convenience method for common constraint configuration
263
     */
264
    protected function cascadeDelete()
265
    {
266
        return [
1✔
267
            'constraints' => [
1✔
268
                'on_delete' => 'CASCADE',
1✔
269
                'on_update' => 'CASCADE'
1✔
270
            ]
1✔
271
        ];
1✔
272
    }
273

274
    /**
275
     * Create constraint options for SET NULL delete behavior
276
     * Convenience method for common constraint configuration
277
     */
278
    protected function setNullDelete()
279
    {
280
        return [
×
281
            'constraints' => [
×
282
                'on_delete' => 'SET NULL',
×
283
                'on_update' => 'CASCADE'
×
UNCOV
284
            ]
×
UNCOV
285
        ];
×
286
    }
287

288
    /**
289
     * Create constraint options for RESTRICT delete behavior (default)
290
     * Convenience method for common constraint configuration
291
     */
292
    protected function restrictDelete()
293
    {
294
        return [
×
295
            'constraints' => [
×
296
                'on_delete' => 'RESTRICT',
×
297
                'on_update' => 'CASCADE'
×
UNCOV
298
            ]
×
UNCOV
299
        ];
×
300
    }
301

302
    /**
303
     * Create custom constraint options
304
     *
305
     * @param string $onDelete DELETE action: 'RESTRICT', 'CASCADE', 'SET NULL', 'NO ACTION'
306
     * @param string $onUpdate UPDATE action: 'RESTRICT', 'CASCADE', 'SET NULL', 'NO ACTION'
307
     * @param string|null $constraintName Custom constraint name
308
     */
309
    protected function constraintOptions($onDelete = 'RESTRICT', $onUpdate = 'CASCADE', $constraintName = null)
310
    {
311
        $options = [
×
312
            'constraints' => [
×
313
                'on_delete' => $onDelete,
×
314
                'on_update' => $onUpdate
×
UNCOV
315
            ]
×
316
        ];
×
317

UNCOV
318
        if ($constraintName) {
×
UNCOV
319
            $options['constraints']['constraint_name'] = $constraintName;
×
320
        }
321

UNCOV
322
        return $options;
×
323
    }
324

325
    /**
326
     * Create foreign key constraints for all defined relationships
327
     * This method can be called explicitly to ensure foreign keys are created
328
     * when in dynamic mode
329
     */
330
    public function createForeignKeyConstraints()
331
    {
332
        if ($this->_mapper->mode !== DataMapper::MODE_DYNAMIC) {
5✔
UNCOV
333
            return;
×
334
        }
335

336
        $relationships = $this->_relationshipManager->getRelationships();
5✔
337

338
        foreach ($relationships as $relationship) {
5✔
339
            $this->createForeignKeyFromRelationship($relationship);
4✔
340
        }
341
    }
342

343
    /**
344
     * Create a foreign key constraint from a relationship definition
345
     */
346
    private function createForeignKeyFromRelationship($relationship)
347
    {
348
        $type = $relationship->getType();
4✔
349

350
        if ($type === 'manyHasOne') {
4✔
351
            // For belongsTo relationships, create foreign key on current table
352
            $this->createForeignKey(
4✔
353
                $this->_mapper->table,
4✔
354
                $relationship->getForeignKey(),
4✔
355
                $this->getTableNameFromModelClass($relationship->getRelatedModelClass()),
4✔
356
                $relationship->getPrimaryKey(),
4✔
357
                $relationship->getConstraintOptions()
4✔
358
            );
4✔
359
        } elseif ($type === 'oneHasMany') {
4✔
360
            // For hasMany relationships, create foreign key on related table
361
            $this->createForeignKey(
4✔
362
                $this->getTableNameFromModelClass($relationship->getRelatedModelClass()),
4✔
363
                $relationship->getForeignKey(),
4✔
364
                $this->_mapper->table,
4✔
365
                $relationship->getPrimaryKey(),
4✔
366
                $relationship->getConstraintOptions()
4✔
367
            );
4✔
368
        } elseif ($type === 'manyHasMany') {
2✔
369
            // For many-to-many relationships, create join table and foreign keys
370
            $this->createManyToManyConstraints($relationship);
2✔
371
        }
372
    }
373

374
    /**
375
     * Create join table and foreign key constraints for many-to-many relationships
376
     */
377
    private function createManyToManyConstraints($relationship)
378
    {
379
        // Get join table information
380
        $joinTable = $relationship->getJoinTable();
2✔
381
        $joinForeignKey = $relationship->getJoinForeignKey();
2✔
382
        $joinRelatedKey = $relationship->getJoinRelatedKey();
2✔
383
        $sourceTable = $this->_mapper->table;
2✔
384
        $targetTable = $this->getTableNameFromModelClass($relationship->getRelatedModelClass());
2✔
385

386
        // Create join table with proper columns
387
        $this->createJoinTable($joinTable, $joinForeignKey, $joinRelatedKey);
2✔
388

389
        // Create foreign key constraints on join table
390
        $this->createForeignKey(
2✔
391
            $joinTable,
2✔
392
            $joinForeignKey,
2✔
393
            $sourceTable,
2✔
394
            $relationship->getPrimaryKey(),
2✔
395
            $relationship->getConstraintOptions()
2✔
396
        );
2✔
397

398
        $this->createForeignKey(
2✔
399
            $joinTable,
2✔
400
            $joinRelatedKey,
2✔
401
            $targetTable,
2✔
402
            $relationship->getPrimaryKey(),
2✔
403
            $relationship->getConstraintOptions()
2✔
404
        );
2✔
405
    }
406

407
    /**
408
     * Create a join table for many-to-many relationships
409
     */
410
    private function createJoinTable($joinTable, $joinForeignKey, $joinRelatedKey)
411
    {
412
        $sql = "CREATE TABLE IF NOT EXISTS `{$joinTable}` (
2✔
413
                    `{$joinForeignKey}` INT(11) NOT NULL,
2✔
414
                    `{$joinRelatedKey}` INT(11) NOT NULL,
2✔
415
                    PRIMARY KEY (`{$joinForeignKey}`, `{$joinRelatedKey}`),
2✔
416
                    INDEX `idx_{$joinTable}_{$joinForeignKey}` (`{$joinForeignKey}`),
2✔
417
                    INDEX `idx_{$joinTable}_{$joinRelatedKey}` (`{$joinRelatedKey}`)
2✔
418
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
2✔
419

420
        $this->_pdo->query($sql);
2✔
421
    }
422

423
    /**
424
     * Create a foreign key constraint
425
     */
426
    private function createForeignKey($table, $column, $referencedTable, $referencedColumn, $options = [])
427
    {
428
        $constraintName = $options['constraint_name'] ?? "fk_{$table}_{$column}";
4✔
429
        $onDelete = $options['on_delete'] ?? 'RESTRICT';
4✔
430
        $onUpdate = $options['on_update'] ?? 'CASCADE';
4✔
431

432
        // Check if foreign key already exists
433
        if ($this->foreignKeyExists($table, $constraintName)) {
4✔
434
            return;
3✔
435
        }
436

437
        // Ensure the referenced table exists
438
        $this->ensureTableExists($referencedTable);
4✔
439

440
        // Ensure the column exists in the source table
441
        $this->ensureColumnExists($table, $column);
4✔
442

443
        // Ensure the referenced column exists in the target table
444
        $this->ensureColumnExists($referencedTable, $referencedColumn);
4✔
445

446
        $sql = "ALTER TABLE `{$table}`
4✔
447
                ADD CONSTRAINT `{$constraintName}`
4✔
448
                FOREIGN KEY (`{$column}`)
4✔
449
                REFERENCES `{$referencedTable}`(`{$referencedColumn}`)
4✔
450
                ON DELETE {$onDelete}
4✔
451
                ON UPDATE {$onUpdate}";
4✔
452

453
        try {
454
            $this->_pdo->query($sql);
4✔
455
        } catch (\PDOException $e) {
×
456
            // If foreign key creation fails, log the error but don't throw
UNCOV
457
            error_log("Anorm: Failed to create foreign key constraint: " . $e->getMessage());
×
458
        }
459
    }
460

461
    /**
462
     * Check if a foreign key constraint exists
463
     */
464
    private function foreignKeyExists($table, $constraintName)
465
    {
466
        $sql = "SELECT COUNT(*) as count
4✔
467
                FROM information_schema.TABLE_CONSTRAINTS
468
                WHERE TABLE_SCHEMA = DATABASE()
469
                AND TABLE_NAME = ?
470
                AND CONSTRAINT_NAME = ?
471
                AND CONSTRAINT_TYPE = 'FOREIGN KEY'";
4✔
472

473
        $stmt = $this->_pdo->prepare($sql);
4✔
474
        $stmt->execute([$table, $constraintName]);
4✔
475
        $result = $stmt->fetch(\PDO::FETCH_ASSOC);
4✔
476

477
        return $result['count'] > 0;
4✔
478
    }
479

480
    /**
481
     * Ensure a table exists, create it if it doesn't
482
     */
483
    private function ensureTableExists($tableName)
484
    {
485
        $sql = "CREATE TABLE IF NOT EXISTS `{$tableName}` (
4✔
486
            id INT(11) AUTO_INCREMENT PRIMARY KEY
487
        )";
4✔
488
        $this->_pdo->query($sql);
4✔
489
    }
490

491
    /**
492
     * Ensure a column exists in a table, create it if it doesn't
493
     */
494
    private function ensureColumnExists($tableName, $columnName)
495
    {
496
        // First ensure the table exists
497
        $this->ensureTableExists($tableName);
4✔
498

499
        // Check if column exists
500
        $sql = "SELECT COUNT(*) as count
4✔
501
                FROM information_schema.COLUMNS
502
                WHERE TABLE_SCHEMA = DATABASE()
503
                AND TABLE_NAME = ?
504
                AND COLUMN_NAME = ?";
4✔
505

506
        $stmt = $this->_pdo->prepare($sql);
4✔
507
        $stmt->execute([$tableName, $columnName]);
4✔
508
        $result = $stmt->fetch(\PDO::FETCH_ASSOC);
4✔
509

510
        if ($result['count'] == 0) {
4✔
511
            // Column doesn't exist, create it
512
            $columnDefinition = "INT(11) NULL"; // Foreign key columns are typically INT(11)
4✔
513
            $sql = "ALTER TABLE `{$tableName}` ADD `{$columnName}` {$columnDefinition}";
4✔
514
            $this->_pdo->query($sql);
4✔
515
        }
516
    }
517

518
    /**
519
     * Get table name from model class name
520
     */
521
    private function getTableNameFromModelClass($modelClass)
522
    {
523
        // Remove namespace and 'Model' suffix, convert to snake_case
524
        $className = basename(str_replace('\\', '/', $modelClass));
4✔
525
        $className = str_replace('Model', '', $className);
4✔
526

527
        // Convert CamelCase to snake_case and pluralize
528
        $tableName = strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $className));
4✔
529

530
        // Better pluralization rules
531
        if (substr($tableName, -1) === 'y') {
4✔
532
            $tableName = substr($tableName, 0, -1) . 'ies';
4✔
533
        } elseif (substr($tableName, -1) !== 's') {
4✔
534
            $tableName .= 's';
4✔
535
        }
536

537
        return $tableName;
4✔
538
    }
539
}
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