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

saygoweb / anorm / 18483020557

14 Oct 2025 01:51AM UTC coverage: 84.208% (+3.7%) from 80.483%
18483020557

Pull #42

github

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

845 of 960 new or added lines in 19 files covered. (88.02%)

66 existing lines in 3 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
    private $method;
14

15
    private $instance;
16

17
    private $sql;
18

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

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

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

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

31

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

165
        return $this;
38✔
166
    }
167

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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