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

saygoweb / anorm / 18482713611

14 Oct 2025 01:31AM UTC coverage: 84.126% (+3.6%) from 80.483%
18482713611

Pull #42

github

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

834 of 949 new or added lines in 19 files covered. (87.88%)

80 existing lines in 4 files now uncovered.

1627 of 1934 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
// 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 $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 $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($relatedModelClass, $joinForeignKey, $joinRelatedKey, $joinTable, $primaryKey = 'id', $propertyName = null, $options = [])
189
    {
190
        // Use explicit property name or generate from class name
191
        if ($propertyName === null) {
57✔
192
            $propertyName = $this->getPropertyNameFromClass($relatedModelClass);
×
193
        }
194
        $this->_relationshipManager->hasManyThrough($relatedModelClass, $propertyName, $joinForeignKey, $joinRelatedKey, $joinTable, $primaryKey, $options);
57✔
195
    }
196

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

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

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

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

234
        // Convert to camelCase
UNCOV
235
        $propertyName = lcfirst($shortName);
×
236

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

UNCOV
242
        return $propertyName;
×
243
    }
244

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

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

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

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

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

UNCOV
307
        return $options;
×
308
    }
309

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

321
        $relationships = $this->_relationshipManager->getRelationships();
5✔
322

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

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

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

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

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

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

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

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

405
        $this->_pdo->query($sql);
2✔
406
    }
407

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

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

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

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

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

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

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

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

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

462
        return $result['count'] > 0;
4✔
463
    }
464

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

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

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

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

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

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

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

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

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