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

saygoweb / anorm / 18482579375

14 Oct 2025 01:22AM UTC coverage: 84.134% (+3.7%) from 80.483%
18482579375

Pull #42

github

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

833 of 948 new or added lines in 19 files covered. (87.87%)

75 existing lines in 4 files now uncovered.

1628 of 1935 relevant lines covered (84.13%)

15.51 hits per line

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

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

3
namespace Anorm;
4

5
use Anorm\Relationship\RelationshipManager;
6

7
class Model
8
{
9
    /** @var DataMapper */
10
    public $_mapper;
11

12
    /** @var RelationshipManager */
13
    public $_relationshipManager;
14

15
    /** @var \PDO */
16
    protected $_pdo;
17

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

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

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

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

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

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

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

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

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

91
        return $this->_mapper->write($this);
44✔
92
    }
93

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

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

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

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

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

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

195
    /**
196
     * Load a specific relationship
197
     */
198
    public function loadRelated($relationshipName)
199
    {
200
        return $this->_relationshipManager->loadRelated($relationshipName);
34✔
201
    }
202

203
    /**
204
     * Load all defined relationships
205
     */
206
    public function loadAllRelated()
207
    {
208
        $this->_relationshipManager->loadAllRelated();
1✔
209
    }
210

211
    /**
212
     * Get the relationship manager
213
     */
214
    public function getRelationshipManager()
215
    {
216
        return $this->_relationshipManager;
53✔
217
    }
218

219
    /**
220
     * Generate a property name from a model class name
221
     * @param string $className The model class name
222
     * @param bool $singular Whether to use singular form (for belongsTo)
223
     * @return string The property name
224
     */
225
    private function getPropertyNameFromClass($className, $singular = false)
226
    {
227
        // Remove namespace and 'Model' suffix
UNCOV
228
        $parts = explode('\\', $className);
×
UNCOV
229
        $shortName = end($parts);
×
UNCOV
230
        $shortName = str_replace('Model', '', $shortName);
×
231

232
        // Convert to camelCase
UNCOV
233
        $propertyName = lcfirst($shortName);
×
234

235
        // For hasMany relationships, make it plural (simple approach)
UNCOV
236
        if (!$singular) {
×
UNCOV
237
            $propertyName .= 's';
×
238
        }
239

UNCOV
240
        return $propertyName;
×
241
    }
242

243
    /**
244
     * Create constraint options for CASCADE delete behavior
245
     * Convenience method for common constraint configuration
246
     */
247
    protected function cascadeDelete()
248
    {
249
        return [
1✔
250
            'constraints' => [
1✔
251
                'on_delete' => 'CASCADE',
1✔
252
                'on_update' => 'CASCADE'
1✔
253
            ]
1✔
254
        ];
1✔
255
    }
256

257
    /**
258
     * Create constraint options for SET NULL delete behavior
259
     * Convenience method for common constraint configuration
260
     */
261
    protected function setNullDelete()
262
    {
UNCOV
263
        return [
×
UNCOV
264
            'constraints' => [
×
UNCOV
265
                'on_delete' => 'SET NULL',
×
UNCOV
266
                'on_update' => 'CASCADE'
×
UNCOV
267
            ]
×
UNCOV
268
        ];
×
269
    }
270

271
    /**
272
     * Create constraint options for RESTRICT delete behavior (default)
273
     * Convenience method for common constraint configuration
274
     */
275
    protected function restrictDelete()
276
    {
UNCOV
277
        return [
×
278
            'constraints' => [
×
279
                'on_delete' => 'RESTRICT',
×
280
                'on_update' => 'CASCADE'
×
281
            ]
×
282
        ];
×
283
    }
284

285
    /**
286
     * Create custom constraint options
287
     *
288
     * @param string $onDelete DELETE action: 'RESTRICT', 'CASCADE', 'SET NULL', 'NO ACTION'
289
     * @param string $onUpdate UPDATE action: 'RESTRICT', 'CASCADE', 'SET NULL', 'NO ACTION'
290
     * @param string|null $constraintName Custom constraint name
291
     */
292
    protected function constraintOptions($onDelete = 'RESTRICT', $onUpdate = 'CASCADE', $constraintName = null)
293
    {
294
        $options = [
×
295
            'constraints' => [
×
296
                'on_delete' => $onDelete,
×
297
                'on_update' => $onUpdate
×
UNCOV
298
            ]
×
UNCOV
299
        ];
×
300

UNCOV
301
        if ($constraintName) {
×
UNCOV
302
            $options['constraints']['constraint_name'] = $constraintName;
×
303
        }
304

UNCOV
305
        return $options;
×
306
    }
307

308
    /**
309
     * Create foreign key constraints for all defined relationships
310
     * This method can be called explicitly to ensure foreign keys are created
311
     * when in dynamic mode
312
     */
313
    public function createForeignKeyConstraints()
314
    {
315
        if ($this->_mapper->mode !== DataMapper::MODE_DYNAMIC) {
5✔
316
            return;
×
317
        }
318

319
        $relationships = $this->_relationshipManager->getRelationships();
5✔
320

321
        foreach ($relationships as $relationship) {
5✔
322
            $this->createForeignKeyFromRelationship($relationship);
4✔
323
        }
324
    }
325

326
    /**
327
     * Create a foreign key constraint from a relationship definition
328
     */
329
    private function createForeignKeyFromRelationship($relationship)
330
    {
331
        $type = $relationship->getType();
4✔
332

333
        if ($type === 'manyHasOne') {
4✔
334
            // For belongsTo relationships, create foreign key on current table
335
            $this->createForeignKey(
4✔
336
                $this->_mapper->table,
4✔
337
                $relationship->getForeignKey(),
4✔
338
                $this->getTableNameFromModelClass($relationship->getRelatedModelClass()),
4✔
339
                $relationship->getPrimaryKey(),
4✔
340
                $relationship->getConstraintOptions()
4✔
341
            );
4✔
342
        } elseif ($type === 'oneHasMany') {
4✔
343
            // For hasMany relationships, create foreign key on related table
344
            $this->createForeignKey(
4✔
345
                $this->getTableNameFromModelClass($relationship->getRelatedModelClass()),
4✔
346
                $relationship->getForeignKey(),
4✔
347
                $this->_mapper->table,
4✔
348
                $relationship->getPrimaryKey(),
4✔
349
                $relationship->getConstraintOptions()
4✔
350
            );
4✔
351
        } elseif ($type === 'manyHasMany') {
2✔
352
            // For many-to-many relationships, create join table and foreign keys
353
            $this->createManyToManyConstraints($relationship);
2✔
354
        }
355
    }
356

357
    /**
358
     * Create join table and foreign key constraints for many-to-many relationships
359
     */
360
    private function createManyToManyConstraints($relationship)
361
    {
362
        // Get join table information
363
        $joinTable = $relationship->getJoinTable();
2✔
364
        $joinForeignKey = $relationship->getJoinForeignKey();
2✔
365
        $joinRelatedKey = $relationship->getJoinRelatedKey();
2✔
366
        $sourceTable = $this->_mapper->table;
2✔
367
        $targetTable = $this->getTableNameFromModelClass($relationship->getRelatedModelClass());
2✔
368

369
        // Create join table with proper columns
370
        $this->createJoinTable($joinTable, $joinForeignKey, $joinRelatedKey);
2✔
371

372
        // Create foreign key constraints on join table
373
        $this->createForeignKey(
2✔
374
            $joinTable,
2✔
375
            $joinForeignKey,
2✔
376
            $sourceTable,
2✔
377
            $relationship->getPrimaryKey(),
2✔
378
            $relationship->getConstraintOptions()
2✔
379
        );
2✔
380

381
        $this->createForeignKey(
2✔
382
            $joinTable,
2✔
383
            $joinRelatedKey,
2✔
384
            $targetTable,
2✔
385
            $relationship->getPrimaryKey(),
2✔
386
            $relationship->getConstraintOptions()
2✔
387
        );
2✔
388
    }
389

390
    /**
391
     * Create a join table for many-to-many relationships
392
     */
393
    private function createJoinTable($joinTable, $joinForeignKey, $joinRelatedKey)
394
    {
395
        $sql = "CREATE TABLE IF NOT EXISTS `{$joinTable}` (
2✔
396
                    `{$joinForeignKey}` INT(11) NOT NULL,
2✔
397
                    `{$joinRelatedKey}` INT(11) NOT NULL,
2✔
398
                    PRIMARY KEY (`{$joinForeignKey}`, `{$joinRelatedKey}`),
2✔
399
                    INDEX `idx_{$joinTable}_{$joinForeignKey}` (`{$joinForeignKey}`),
2✔
400
                    INDEX `idx_{$joinTable}_{$joinRelatedKey}` (`{$joinRelatedKey}`)
2✔
401
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
2✔
402

403
        $this->_pdo->query($sql);
2✔
404
    }
405

406
    /**
407
     * Create a foreign key constraint
408
     */
409
    private function createForeignKey($table, $column, $referencedTable, $referencedColumn, $options = [])
410
    {
411
        $constraintName = $options['constraint_name'] ?? "fk_{$table}_{$column}";
4✔
412
        $onDelete = $options['on_delete'] ?? 'RESTRICT';
4✔
413
        $onUpdate = $options['on_update'] ?? 'CASCADE';
4✔
414

415
        // Check if foreign key already exists
416
        if ($this->foreignKeyExists($table, $constraintName)) {
4✔
417
            return;
3✔
418
        }
419

420
        // Ensure the referenced table exists
421
        $this->ensureTableExists($referencedTable);
4✔
422

423
        // Ensure the column exists in the source table
424
        $this->ensureColumnExists($table, $column);
4✔
425

426
        // Ensure the referenced column exists in the target table
427
        $this->ensureColumnExists($referencedTable, $referencedColumn);
4✔
428

429
        $sql = "ALTER TABLE `{$table}`
4✔
430
                ADD CONSTRAINT `{$constraintName}`
4✔
431
                FOREIGN KEY (`{$column}`)
4✔
432
                REFERENCES `{$referencedTable}`(`{$referencedColumn}`)
4✔
433
                ON DELETE {$onDelete}
4✔
434
                ON UPDATE {$onUpdate}";
4✔
435

436
        try {
437
            $this->_pdo->query($sql);
4✔
UNCOV
438
        } catch (\PDOException $e) {
×
439
            // If foreign key creation fails, log the error but don't throw
UNCOV
440
            error_log("Anorm: Failed to create foreign key constraint: " . $e->getMessage());
×
441
        }
442
    }
443

444
    /**
445
     * Check if a foreign key constraint exists
446
     */
447
    private function foreignKeyExists($table, $constraintName)
448
    {
449
        $sql = "SELECT COUNT(*) as count
4✔
450
                FROM information_schema.TABLE_CONSTRAINTS
451
                WHERE TABLE_SCHEMA = DATABASE()
452
                AND TABLE_NAME = ?
453
                AND CONSTRAINT_NAME = ?
454
                AND CONSTRAINT_TYPE = 'FOREIGN KEY'";
4✔
455

456
        $stmt = $this->_pdo->prepare($sql);
4✔
457
        $stmt->execute([$table, $constraintName]);
4✔
458
        $result = $stmt->fetch(\PDO::FETCH_ASSOC);
4✔
459

460
        return $result['count'] > 0;
4✔
461
    }
462

463
    /**
464
     * Ensure a table exists, create it if it doesn't
465
     */
466
    private function ensureTableExists($tableName)
467
    {
468
        $sql = "CREATE TABLE IF NOT EXISTS `{$tableName}` (
4✔
469
            id INT(11) AUTO_INCREMENT PRIMARY KEY
470
        )";
4✔
471
        $this->_pdo->query($sql);
4✔
472
    }
473

474
    /**
475
     * Ensure a column exists in a table, create it if it doesn't
476
     */
477
    private function ensureColumnExists($tableName, $columnName)
478
    {
479
        // First ensure the table exists
480
        $this->ensureTableExists($tableName);
4✔
481

482
        // Check if column exists
483
        $sql = "SELECT COUNT(*) as count
4✔
484
                FROM information_schema.COLUMNS
485
                WHERE TABLE_SCHEMA = DATABASE()
486
                AND TABLE_NAME = ?
487
                AND COLUMN_NAME = ?";
4✔
488

489
        $stmt = $this->_pdo->prepare($sql);
4✔
490
        $stmt->execute([$tableName, $columnName]);
4✔
491
        $result = $stmt->fetch(\PDO::FETCH_ASSOC);
4✔
492

493
        if ($result['count'] == 0) {
4✔
494
            // Column doesn't exist, create it
495
            $columnDefinition = "INT(11) NULL"; // Foreign key columns are typically INT(11)
4✔
496
            $sql = "ALTER TABLE `{$tableName}` ADD `{$columnName}` {$columnDefinition}";
4✔
497
            $this->_pdo->query($sql);
4✔
498
        }
499
    }
500

501
    /**
502
     * Get table name from model class name
503
     */
504
    private function getTableNameFromModelClass($modelClass)
505
    {
506
        // Remove namespace and 'Model' suffix, convert to snake_case
507
        $className = basename(str_replace('\\', '/', $modelClass));
4✔
508
        $className = str_replace('Model', '', $className);
4✔
509

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

513
        // Better pluralization rules
514
        if (substr($tableName, -1) === 'y') {
4✔
515
            $tableName = substr($tableName, 0, -1) . 'ies';
4✔
516
        } elseif (substr($tableName, -1) !== 's') {
4✔
517
            $tableName .= 's';
4✔
518
        }
519

520
        return $tableName;
4✔
521
    }
522
}
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