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

saygoweb / anorm / 18483785918

14 Oct 2025 02:39AM UTC coverage: 84.208% (+3.7%) from 80.483%
18483785918

Pull #42

github

web-flow
Merge 32f65bcdb 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%)

60 existing lines in 2 files now uncovered.

1637 of 1944 relevant lines covered (84.21%)

15.67 hits per line

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

80.5
/src/QueryBuilder.php
1
<?php
2

3
namespace Anorm;
4

5
use Anorm\Relationship\BatchLoadingOrchestrator;
6
use Anorm\Relationship\Strategy\NestedRelationshipParser;
7
use Anorm\Relationship\Performance\PerformanceMonitor;
8

9
class QueryBuilder
10
{
11
    public $boundData = null;
12

13
    /** @var string Method used for creating instances @phpstan-ignore-next-line */
14
    private $method;
15

16
    private $instance;
17

18
    private $sql;
19

20
    /** @var array Relationships to eager load */
21
    private $eagerLoadRelationships = [];
22

23
    /** @var BatchLoadingOrchestrator */
24
    private $batchOrchestrator;
25

26
    /** @var bool Enable batch loading optimization */
27
    private $enableBatchLoading = true;
28

29
    /** @var PerformanceMonitor|null Performance monitoring */
30
    private $performanceMonitor = null;
31

32

33
    public function __construct($creatable, \PDO $pdo = null)
34
    {
35
        if (is_string($creatable) && \class_exists($creatable)) {
102✔
36
            $this->method = 'new';
101✔
37
            $this->instance = new $creatable($pdo);
101✔
38
        } else {
39
            throw new \Exception("'\$creatable' is not a class");
1✔
40
        }
41
        $this->sql = '';
101✔
42
        $this->batchOrchestrator = new BatchLoadingOrchestrator();
101✔
43
    }
44

45
    public function select($sql)
46
    {
47
        $this->sql .= 'SELECT ' . $sql;
99✔
48
        return $this;
99✔
49
    }
50

51
    private function ensureSelect()
52
    {
53
        if (false === stripos($this->sql, 'SELECT')) {
99✔
54
            $this->select("*");
95✔
55
        }
56
    }
57

58
    public function from($sql)
59
    {
60
        $this->ensureSelect();
99✔
61
        $this->sql .= ' FROM ' . $sql;
99✔
62
        return $this;
99✔
63
    }
64

65
    private function ensureFrom()
66
    {
67
        if (false === stripos($this->sql, 'FROM')) {
99✔
68
            $this->from('`' . $this->instance->_mapper->table . '`');
97✔
69
        }
70
    }
71

72
    public function join($sql)
73
    {
74
        $this->ensureFrom();
1✔
75
        $this->sql .= ' ' . $sql; // Assume the type of join is included in $sql
1✔
76
        return $this;
1✔
77
    }
78

79
    /**
80
     * Specify relationships to eager load
81
     *
82
     * Supports multiple syntaxes:
83
     * - Simple: with(['posts', 'company'])
84
     * - Field selection: with(['posts:id,title', 'company:name'])
85
     * - Nested: with(['posts.comments', 'posts.comments.author'])
86
     * - Mixed: with(['posts:id,title.comments:id,content', 'company:name'])
87
     *
88
     * @param array|string $relationships Array of relationship names/specs to load
89
     * @return self
90
     */
91
    public function with($relationships)
92
    {
93
        if (is_string($relationships)) {
23✔
94
            $relationships = [$relationships];
×
95
        }
96

97
        // Validate and normalize relationship specifications
98
        foreach ($relationships as $spec) {
23✔
99
            if (!is_string($spec) || empty(trim($spec))) {
23✔
100
                throw new \InvalidArgumentException("Relationship specification must be a non-empty string");
1✔
101
            }
102

103
            // Validate nested relationship syntax
104
            $parser = new NestedRelationshipParser();
22✔
105
            $validation = $parser->validateNestedSpec($spec);
22✔
106

107
            if (!$validation['valid']) {
22✔
NEW
108
                throw new \InvalidArgumentException("Invalid relationship specification '{$spec}': " . implode(', ', $validation['errors']));
×
109
            }
110

111
            // Log warnings for deep nesting
112
            if (!empty($validation['warnings']) && $this->performanceMonitor) {
22✔
NEW
113
                foreach ($validation['warnings'] as $warning) {
×
NEW
114
                    error_log("QueryBuilder Warning: {$warning}");
×
115
                }
116
            }
117
        }
118

119
        $this->eagerLoadRelationships = array_merge($this->eagerLoadRelationships, $relationships);
22✔
120
        return $this;
22✔
121
    }
122

123
    /**
124
     * Join based on a relationship definition
125
     * @param string $relationshipName The name of the relationship
126
     * @param string $joinType The type of join (LEFT, INNER, RIGHT)
127
     * @return self
128
     */
129
    public function joinRelationship($relationshipName, $joinType = 'LEFT')
130
    {
131
        $relationshipManager = $this->instance->getRelationshipManager();
×
132
        $relationship = $relationshipManager->getRelationship($relationshipName);
×
133

134
        if (!$relationship) {
×
135
            throw new \Exception("Relationship '{$relationshipName}' not defined");
×
136
        }
137

138
        // Get the related model to determine its table name
139
        $relatedClass = $relationship->getRelatedModelClass();
×
140
        $relatedInstance = new $relatedClass($this->instance->_mapper->pdo);
×
141
        $relatedTable = $relatedInstance->_mapper->table;
×
142
        $sourceTable = $this->instance->_mapper->table;
×
143

144
        // Generate the join clause
145
        $joinClause = $relationship->generateJoinClause($sourceTable, $relatedTable);
×
146
        $joinClause = str_replace('LEFT JOIN', $joinType . ' JOIN', $joinClause);
×
147

148
        $this->join($joinClause);
×
149
        return $this;
×
150
    }
151

152
    public function where($sql, $data = null)
153
    {
154
        $this->ensureFrom();
38✔
155

156
        if ($sql instanceof SqlCondition) {
38✔
157
            // Handle SqlCondition objects from Mango queries
158
            $this->sql .= ' WHERE ' . $sql->getSql();
25✔
159
            $this->boundData = array_merge($this->boundData ?? [], $sql->getBindings());
25✔
160
        } else {
161
            // Handle traditional string SQL
162
            $this->sql .= ' WHERE ' . $sql;
13✔
163
            $this->boundData = $data;
13✔
164
        }
165

166
        return $this;
38✔
167
    }
168

169
    public function groupBy($sql)
170
    {
171
        $this->ensureFrom();
2✔
172
        $this->sql .= ' GROUP BY ' . $sql;
2✔
173
        return $this;
2✔
174
    }
175

176
    public function having($sql)
177
    {
178
        $this->ensureFrom();
1✔
179
        $this->sql .= ' HAVING ' . $sql;
1✔
180
        return $this;
1✔
181
    }
182

183
    public function orderBy($sql)
184
    {
185
        $this->ensureFrom();
14✔
186
        $this->sql .= ' ORDER BY ' . $sql;
14✔
187
        return $this;
14✔
188
    }
189

190
    public function some()
191
    {
192
        if ($this->enableBatchLoading && !empty($this->eagerLoadRelationships)) {
91✔
193
            // Use batch loading for better performance
194
            yield from $this->someWithBatchLoading();
20✔
195
        } else {
196
            // Use traditional individual loading
197
            yield from $this->someWithIndividualLoading();
74✔
198
        }
199
    }
200

201
    /**
202
     * Fetch models with batch loading optimization
203
     */
204
    private function someWithBatchLoading()
205
    {
206
        $this->ensureFrom();
20✔
207
        /** @var DataMapper */
208
        $mapper = $this->instance->_mapper;
20✔
209
        $result = $mapper->query($this->sql, $this->boundData);
20✔
210

211
        // Collect all models first
212
        $models = [];
20✔
213
        while ($data = $result->fetch(\PDO::FETCH_ASSOC)) {
20✔
214
            // Create a new instance for each row
215
            $modelClass = get_class($this->instance);
20✔
216
            $model = new $modelClass($mapper->pdo);
20✔
217
            $model->_mapper->readArray($model, $data);
20✔
218
            $models[] = $model;
20✔
219
        }
220

221
        // Batch load relationships for all models (including nested)
222
        if (!empty($models) && !empty($this->eagerLoadRelationships)) {
20✔
223
            $operationId = 'batch_load_' . uniqid();
20✔
224

225
            // Start performance monitoring if enabled
226
            if ($this->performanceMonitor) {
20✔
NEW
227
                $this->performanceMonitor->startOperation($operationId, [
×
NEW
228
                    'model_count' => count($models),
×
NEW
229
                    'relationships' => $this->eagerLoadRelationships,
×
NEW
230
                    'model_class' => get_class($this->instance)
×
NEW
231
                ]);
×
232
            }
233

234
            // Check for nested relationships
235
            $hasNestedRelationships = $this->hasNestedRelationships($this->eagerLoadRelationships);
20✔
236

237
            if ($hasNestedRelationships) {
20✔
NEW
238
                $this->loadNestedRelationships($models, $this->eagerLoadRelationships);
×
239
            } else {
240
                $this->batchOrchestrator->loadRelationshipsForModels($models, $this->eagerLoadRelationships);
20✔
241
            }
242

243
            // End performance monitoring
244
            if ($this->performanceMonitor) {
20✔
NEW
245
                $this->performanceMonitor->endOperation($operationId, [
×
NEW
246
                    'loaded_models' => count($models)
×
NEW
247
                ]);
×
248
            }
249
        }
250

251
        // Yield the models
252
        foreach ($models as $model) {
20✔
253
            yield $model;
20✔
254
        }
255
    }
256

257
    /**
258
     * Fetch models with traditional individual loading (fallback)
259
     */
260
    private function someWithIndividualLoading()
261
    {
262
        $this->ensureFrom();
74✔
263
        /** @var DataMapper */
264
        $mapper = $this->instance->_mapper;
74✔
265
        $result = $mapper->query($this->sql, $this->boundData);
74✔
266

267
        while ($data = $result->fetch(\PDO::FETCH_ASSOC)) {
74✔
268
            // Create a new instance for each row
269
            $modelClass = get_class($this->instance);
74✔
270
            $model = new $modelClass($mapper->pdo);
74✔
271
            $model->_mapper->readArray($model, $data);
74✔
272

273
            // Load eager relationships if specified
274
            $this->loadEagerRelationships($model);
74✔
275

276
            yield $model;
74✔
277
        }
278
    }
279

280
    public function limit($n, $offset = 0)
281
    {
282
        $this->ensureFrom();
15✔
283
        if (false === stripos($this->sql, 'LIMIT')) {
15✔
284
            $this->sql .= " LIMIT $offset, $n";
15✔
285
        }
286
        return $this;
15✔
287
    }
288

289
    public function one()
290
    {
291
        /** @var DataMapper */
292
        $mapper = $this->instance->_mapper;
6✔
293
        $this->limit(1);
6✔
294
        $result = $mapper->query($this->sql, $this->boundData);
6✔
295
        $couldRead = $mapper->readRow($this->instance, $result);
6✔
296
        if ($couldRead === false) {
6✔
297
            return false;
3✔
298
        }
299

300
        // Load eager relationships if specified
301
        $this->loadEagerRelationships($this->instance);
3✔
302

303
        return $this->instance;
3✔
304
    }
305

306
    public function oneOrThrow()
307
    {
308
        $result = $this->one();
2✔
309
        if ($result === false) {
2✔
310
            throw new \Exception(sprintf("QueryBuilder: Expected one not found from '%s'", $this->sql));
1✔
311
        }
312
        return $result;
1✔
313
    }
314

315
    /**
316
     * Apply a Mango Query to this QueryBuilder
317
     *
318
     * @param array $mangoQuery The Mango Query object
319
     * @return self
320
     */
321
    public function fromMango(array $mangoQuery): self
322
    {
323
        $query = new MangoQuery($mangoQuery);
29✔
324
        $this->applyMangoQuery($query);
29✔
325
        return $this;
29✔
326
    }
327

328
    /**
329
     * Alias for fromMango()
330
     */
331
    public function mango(array $mangoQuery): self
332
    {
333
        return $this->fromMango($mangoQuery);
1✔
334
    }
335

336
    /**
337
     * Apply a parsed MangoQuery to this QueryBuilder
338
     */
339
    private function applyMangoQuery(MangoQuery $query): void
340
    {
341
        /** @var DataMapper */
342
        $mapper = $this->instance->_mapper;
29✔
343
        $parser = new MangoQueryParser($mapper);
29✔
344

345
        // Apply fields (SELECT clause)
346
        if ($query->hasFields()) {
29✔
347
            $fieldsClause = $parser->parseFields($query->getFields());
2✔
348
            $this->select($fieldsClause);
2✔
349
        }
350

351
        // Apply selector (WHERE clause)
352
        if ($query->hasConditions()) {
29✔
353
            $condition = $parser->parseSelector($query->getSelector());
25✔
354
            if (!$condition->isEmpty()) {
25✔
355
                $this->where($condition);
25✔
356
            }
357
        }
358

359
        // Apply sort (ORDER BY clause)
360
        if ($query->hasSort()) {
29✔
361
            $sortClause = $parser->parseSort($query->getSort());
12✔
362
            if (!empty($sortClause)) {
12✔
363
                $this->orderBy($sortClause);
12✔
364
            }
365
        }
366

367
        // Apply pagination (LIMIT and OFFSET)
368
        if ($query->hasPagination()) {
29✔
369
            $limit = $query->getLimit();
8✔
370
            $skip = $query->getSkip() ?? 0;
8✔
371

372
            if ($limit !== null) {
8✔
373
                $this->limit($limit, $skip);
4✔
374
            } elseif ($skip > 0) {
4✔
375
                // If only skip is specified, use a large limit
376
                $this->limit(PHP_INT_MAX, $skip);
3✔
377
            }
378
        }
379

380
        // TODO: Handle use_index hint in future versions
381
    }
382

383
    /**
384
     * Load eager relationships for a model instance
385
     * @param object $model The model instance to load relationships for
386
     */
387
    private function loadEagerRelationships($model)
388
    {
389
        if (empty($this->eagerLoadRelationships)) {
77✔
390
            return;
72✔
391
        }
392

393
        foreach ($this->eagerLoadRelationships as $relationshipName) {
5✔
394
            $model->loadRelated($relationshipName);
5✔
395
        }
396
    }
397

398
    /**
399
     * Enable or disable batch loading optimization
400
     *
401
     * @param bool $enabled Whether to enable batch loading
402
     * @return self
403
     */
404
    public function enableBatchLoading(bool $enabled = true): self
405
    {
406
        $this->enableBatchLoading = $enabled;
1✔
407
        return $this;
1✔
408
    }
409

410
    /**
411
     * Disable batch loading optimization
412
     *
413
     * @return self
414
     */
415
    public function disableBatchLoading(): self
416
    {
417
        $this->enableBatchLoading = false;
5✔
418
        return $this;
5✔
419
    }
420

421
    /**
422
     * Check if batch loading is enabled
423
     *
424
     * @return bool
425
     */
426
    public function isBatchLoadingEnabled(): bool
427
    {
428
        return $this->enableBatchLoading;
2✔
429
    }
430

431
    /**
432
     * Set batch loading configuration
433
     *
434
     * @param array $config Configuration options
435
     * @return self
436
     */
437
    public function setBatchLoadingConfig(array $config): self
438
    {
439
        $this->batchOrchestrator->setConfig($config);
14✔
440
        return $this;
14✔
441
    }
442

443
    /**
444
     * Set performance monitor
445
     *
446
     * @param PerformanceMonitor $monitor Performance monitor instance
447
     * @return self
448
     */
449
    public function setPerformanceMonitor(PerformanceMonitor $monitor): self
450
    {
NEW
451
        $this->performanceMonitor = $monitor;
×
NEW
452
        return $this;
×
453
    }
454

455
    /**
456
     * Check if any relationships are nested
457
     *
458
     * @param array $relationships Relationship specifications
459
     * @return bool True if nested relationships are present
460
     */
461
    private function hasNestedRelationships(array $relationships): bool
462
    {
463
        foreach ($relationships as $spec) {
20✔
464
            if (strpos($spec, '.') !== false) {
20✔
NEW
465
                return true;
×
466
            }
467
        }
468
        return false;
20✔
469
    }
470

471
    /**
472
     * Load nested relationships for models
473
     *
474
     * @param array $models Models to load relationships for
475
     * @param array $relationshipSpecs Relationship specifications
476
     * @return void
477
     */
478
    private function loadNestedRelationships(array $models, array $relationshipSpecs): void
479
    {
NEW
480
        $parser = new NestedRelationshipParser();
×
NEW
481
        $nestedSpecs = $parser->parseNestedSpecs($relationshipSpecs);
×
NEW
482
        $parser->loadNestedRelationships($models, $nestedSpecs);
×
483
    }
484
}
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