Passed
Pull Request — 5.1 (#282)
by
unknown
04:19
created

FindObjectsFromRawSqlQueryFactory::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 1
Metric Value
eloc 4
c 2
b 0
f 1
dl 0
loc 7
rs 10
cc 1
nc 1
nop 5
1
<?php
2
declare(strict_types=1);
3
4
namespace TheCodingMachine\TDBM\QueryFactory;
5
6
use Doctrine\DBAL\Platforms\MySqlPlatform;
7
use Doctrine\DBAL\Schema\Schema;
8
use PHPSQLParser\builders\OrderByBuilder;
9
use PHPSQLParser\builders\SelectStatementBuilder;
10
use TheCodingMachine\TDBM\TDBMException;
11
use TheCodingMachine\TDBM\TDBMService;
12
use PHPSQLParser\PHPSQLCreator;
13
use PHPSQLParser\PHPSQLParser;
14
15
/**
16
 * This class is in charge of formatting the SQL passed to findObjectsFromRawSql method.
17
 */
18
class FindObjectsFromRawSqlQueryFactory implements QueryFactory
19
{
20
    /**
21
     * @var array[]
22
     */
23
    protected $columnDescriptors;
24
    /**
25
     * @var Schema
26
     */
27
    private $schema;
28
    /**
29
     * @var string
30
     */
31
    private $processedSql;
32
    /**
33
     * @var string
34
     */
35
    private $processedSqlCount;
36
    /**
37
     * @var TDBMService
38
     */
39
    private $tdbmService;
40
    /**
41
     * @var string
42
     */
43
    private $mainTable;
44
45
    /**
46
     * FindObjectsFromRawSqlQueryFactory constructor.
47
     * @param TDBMService $tdbmService
48
     * @param Schema $schema
49
     * @param string $mainTable
50
     * @param string $sql
51
     * @param string $sqlCount
52
     */
53
    public function __construct(TDBMService $tdbmService, Schema $schema, string $mainTable, string $sql, string $sqlCount = null)
54
    {
55
        $this->tdbmService = $tdbmService;
56
        $this->schema = $schema;
57
        $this->mainTable = $mainTable;
58
59
        [$this->processedSql, $this->processedSqlCount, $this->columnDescriptors] = $this->compute($sql, $sqlCount);
60
    }
61
62
    public function sort($orderBy): void
63
    {
64
        throw new TDBMException('sort not supported for raw sql queries');
65
    }
66
67
    public function getMagicSql(): string
68
    {
69
        return $this->processedSql;
70
    }
71
72
    public function getMagicSqlCount(): string
73
    {
74
        return $this->processedSqlCount;
75
    }
76
77
    public function getColumnDescriptors(): array
78
    {
79
        return $this->columnDescriptors;
80
    }
81
82
    /**
83
     * @param string $sql
84
     * @param null|string $sqlCount
85
     * @return mixed[] An array of 3 elements: [$processedSql, $processedSqlCount, $columnDescriptors]
86
     * @throws TDBMException
87
     */
88
    private function compute(string $sql, ?string $sqlCount): array
89
    {
90
        $parser = new PHPSQLParser();
91
        $parsedSql = $parser->parse($sql);
92
93
        if (isset($parsedSql['SELECT'])) {
94
            [$processedSql, $processedSqlCount, $columnDescriptors] = $this->processParsedSelectQuery($parsedSql, $sqlCount);
95
        } elseif (isset($parsedSql['UNION'])) {
96
            [$processedSql, $processedSqlCount, $columnDescriptors] = $this->processParsedUnionQuery($parsedSql, $sqlCount);
97
        } else {
98
            throw new TDBMException('Unable to analyze query "'.$sql.'"');
99
        }
100
101
        return [$processedSql, $processedSqlCount, $columnDescriptors];
102
    }
103
104
    /**
105
     * @param mixed[] $parsedSql
106
     * @param null|string $sqlCount
107
     * @return mixed[] An array of 3 elements: [$processedSql, $processedSqlCount, $columnDescriptors]
108
     * @throws \PHPSQLParser\exceptions\UnsupportedFeatureException|\PHPSQLParser\exceptions\UnableToCreateSQLException
109
     */
110
    private function processParsedUnionQuery(array $parsedSql, ?string $sqlCount): array
111
    {
112
        $selects = $parsedSql['UNION'];
113
114
        $parsedSqlList = [];
115
        $columnDescriptors = [];
116
117
        foreach ($selects as $select) {
118
            [$selectProcessedSql, $selectProcessedCountSql, $columnDescriptors] = $this->processParsedSelectQuery($select, '');
119
120
            // Let's reparse the returned SQL (not the most efficient way of doing things)
121
            $parser = new PHPSQLParser();
122
            $parsedSelectSql = $parser->parse($selectProcessedSql);
123
124
            $parsedSqlList[] = $parsedSelectSql;
125
        }
126
127
        // Let's rebuild the UNION query
128
        $query = ['UNION' => $parsedSqlList];
129
130
        // The count is the SUM of the count of the UNIONs
131
        $countQuery = $this->generateWrappedSqlCount($query);
132
133
        $generator = new PHPSQLCreator();
134
135
        // Replaced the default generator by our own to add parenthesis around each SELECT
136
        $processedSql = $this->buildUnion($query);
137
        $processedSqlCount = $generator->create($countQuery);
138
139
        // Let's add the ORDER BY if any
140
        if (isset($parsedSql['0']['ORDER'])) {
141
            $orderByBuilder = new OrderByBuilder();
142
            $processedSql .= " " . $orderByBuilder->build($parsedSql['0']['ORDER']);
143
        }
144
145
        return [$processedSql, $sqlCount ?? $processedSqlCount, $columnDescriptors];
146
    }
147
148
    /**
149
     * @param mixed[] $parsed
150
     */
151
    private function buildUnion(array $parsed): string
152
    {
153
        $sql = '';
154
        $select_builder = new SelectStatementBuilder();
155
        $first = true;
156
        foreach ($parsed['UNION'] as $clause) {
157
            if (!$first) {
158
                $sql .= ") UNION (";
159
            } else {
160
                $sql .= "(";
161
                $first = false;
162
            }
163
164
            $sql .= $select_builder->build($clause);
165
        }
166
167
        // Close the parenthesis if needed
168
        if (!$first) {
169
            $sql .= ")";
170
        }
171
172
        return $sql;
173
    }
174
175
    /**
176
     * @param mixed[] $parsedSql
177
     * @param null|string $sqlCount
178
     * @return mixed[] An array of 3 elements: [$processedSql, $processedSqlCount, $columnDescriptors]
179
     */
180
    private function processParsedSelectQuery(array $parsedSql, ?string $sqlCount): array
181
    {
182
        // 1: let's reformat the SELECT and construct our columns
183
        list($select, $countSelect, $columnDescriptors) = $this->formatSelect($parsedSql['SELECT']);
184
        $generator = new PHPSQLCreator();
185
        $parsedSql['SELECT'] = $select;
186
        $processedSql = $generator->create($parsedSql);
187
188
        // 2: let's compute the count query if needed
189
        if ($sqlCount === null) {
190
            $parsedCountSql = $parsedSql;
191
            $parsedCountSql['SELECT'] = $countSelect;
192
            $parsedSqlCount = $this->generateParsedSqlCount($parsedCountSql);
193
            $processedSqlCount = $generator->create($parsedSqlCount);
194
        } else {
195
            $processedSqlCount = $sqlCount;
196
        }
197
198
        return [$processedSql, $processedSqlCount, $columnDescriptors];
199
    }
200
201
    /**
202
     * @param mixed[] $baseSelect
203
     * @return mixed[] An array of 2 elements: [$formattedSelect, $columnDescriptors]
204
     * @throws TDBMException
205
     * @throws \Doctrine\DBAL\Schema\SchemaException
206
     */
207
    private function formatSelect(array $baseSelect): array
208
    {
209
        $relatedTables = $this->tdbmService->_getRelatedTablesByInheritance($this->mainTable);
210
        $tableGroup = $this->getTableGroupName($relatedTables);
211
212
        $connection = $this->tdbmService->getConnection();
213
        $formattedSelect = [];
214
        $formattedCountSelect = [];
215
        $columnDescriptors = [];
216
        $fetchedTables = [];
217
218
        foreach ($baseSelect as $entry) {
219
            if ($entry['expr_type'] !== 'colref') {
220
                $formattedSelect[] = $entry;
221
                $formattedCountSelect[] = $entry;
222
                continue;
223
            }
224
225
            $noQuotes = $entry['no_quotes'];
226
            if ($noQuotes['delim'] !== '.' || count($noQuotes['parts']) !== 2) {
227
                $formattedSelect[] = $entry;
228
                $formattedCountSelect[] = $entry;
229
                continue;
230
            }
231
232
            $tableName = $noQuotes['parts'][0];
233
            if (!in_array($tableName, $relatedTables)) {
234
                $formattedSelect[] = $entry;
235
                $formattedCountSelect[] = $entry;
236
                continue;
237
            }
238
239
            $columnName = $noQuotes['parts'][1];
240
            if ($columnName !== '*') {
241
                $formattedSelect[] = $entry;
242
                $formattedCountSelect[] = $entry;
243
                continue;
244
            }
245
246
            $table = $this->schema->getTable($tableName);
247
            $pkColumns = $table->getPrimaryKeyColumns();
248
            foreach ($table->getColumns() as $column) {
249
                $columnName = $column->getName();
250
                $alias = AbstractQueryFactory::getColumnAlias($tableName, $columnName);
251
                $astColumn = [
252
                    'expr_type' => 'colref',
253
                    'base_expr' => $connection->quoteIdentifier($tableName) . '.' . $connection->quoteIdentifier($columnName),
254
                    'no_quotes' => [
255
                        'delim' => '.',
256
                        'parts' => [
257
                            $tableName,
258
                            $columnName
259
                        ]
260
                    ],
261
                    'alias' => [
262
                        'as' => true,
263
                        'name' => $alias,
264
                    ]
265
                ];
266
                $formattedSelect[] = $astColumn;
267
                if (in_array($columnName, $pkColumns)) {
268
                    $formattedCountSelect[] = $astColumn;
269
                }
270
                $columnDescriptors[$alias] = [
271
                    'as' => $alias,
272
                    'table' => $tableName,
273
                    'column' => $columnName,
274
                    'type' => $column->getType(),
275
                    'tableGroup' => $tableGroup,
276
                ];
277
            }
278
            $fetchedTables[] = $tableName;
279
        }
280
281
        $missingTables = array_diff($relatedTables, $fetchedTables);
282
        if (!empty($missingTables)) {
283
            throw new TDBMException('Missing tables '.implode(', ', $missingTables).' in SELECT statement');
284
        }
285
286
        for ($i = 0; $i < count($formattedSelect) - 1; $i++) {
287
            if (!isset($formattedSelect[$i]['delim'])) {
288
                $formattedSelect[$i]['delim'] = ',';
289
            }
290
        }
291
292
        for ($i = 0; $i < count($formattedCountSelect) - 1; $i++) {
293
            if (!isset($formattedCountSelect[$i]['delim'])) {
294
                $formattedCountSelect[$i]['delim'] = ',';
295
            }
296
        }
297
        return [$formattedSelect, $formattedCountSelect, $columnDescriptors];
298
    }
299
300
    /**
301
     * @param mixed[] $parsedSql
302
     * @return mixed[]
303
     */
304
    private function generateParsedSqlCount(array $parsedSql): array
305
    {
306
        if (isset($parsedSql['ORDER'])) {
307
            unset($parsedSql['ORDER']);
308
        }
309
310
        if (!isset($parsedSql['GROUP'])) {
311
            // most simple case:no GROUP BY in query
312
            return $this->generateSimpleSqlCount($parsedSql);
313
        } elseif (!isset($parsedSql['HAVING'])) {
314
            // GROUP BY without HAVING statement: let's COUNT the DISTINCT grouped columns
315
            return $this->generateGroupedSqlCount($parsedSql);
316
        } else {
317
            // GROUP BY with a HAVING statement: we'll have to wrap the query
318
            return $this->generateWrappedSqlCount($parsedSql);
319
        }
320
    }
321
322
    /**
323
     * @param mixed[] $parsedSql The AST of the SQL query
324
     * @return mixed[] An AST representing the matching COUNT query
325
     */
326
    private function generateSimpleSqlCount(array $parsedSql): array
327
    {
328
        // If the query is a DISTINCT, we need to deal with the count.
329
330
331
        // We need to count on the same columns: COUNT(DISTINCT country.id, country.label) ....
332
        // but we need to remove the "alias" bit.
333
334
        if ($this->isDistinctQuery($parsedSql)) {
335
            // Only MySQL can do DISTINCT counts.
336
            // Other databases should wrap the query
337
            if (!$this->tdbmService->getConnection()->getSchemaManager()->getDatabasePlatform() instanceof MySqlPlatform) {
338
                return $this->generateWrappedSqlCount($parsedSql);
339
            }
340
341
            $countSubExpr = array_map(function (array $item) {
342
                unset($item['alias']);
343
                return $item;
344
            }, $parsedSql['SELECT']);
345
        } else {
346
            $countSubExpr = [
347
                [
348
                'expr_type' => 'colref',
349
                'base_expr' => '*',
350
                'sub_tree' => false
351
                ]
352
            ];
353
        }
354
355
        $parsedSql['SELECT'] = [[
356
            'expr_type' => 'aggregate_function',
357
            'alias' => [
358
                'as' => true,
359
                'name' => 'cnt',
360
            ],
361
            'base_expr' => 'COUNT',
362
            'sub_tree' => $countSubExpr,
363
            'delim' => false,
364
        ]];
365
366
        return $parsedSql;
367
    }
368
369
    /**
370
     * @param mixed[] $parsedSql AST to analyze
371
     * @return bool
372
     */
373
    private function isDistinctQuery(array $parsedSql): bool
374
    {
375
        foreach ($parsedSql['SELECT'] as $item) {
376
            if ($item['expr_type'] === 'reserved' && $item['base_expr'] === 'DISTINCT') {
377
                return true;
378
            }
379
        }
380
        return false;
381
    }
382
383
    /**
384
     * @param mixed[] $parsedSql The AST of the SQL query
385
     * @return mixed[] An AST representing the matching COUNT query
386
     */
387
    private function generateGroupedSqlCount(array $parsedSql): array
388
    {
389
        $group = $parsedSql['GROUP'];
390
        unset($parsedSql['GROUP']);
391
        $parsedSql['SELECT'] = [[
392
            'expr_type' => 'aggregate_function',
393
            'alias' => [
394
                'as' => true,
395
                'name' => 'cnt',
396
            ],
397
            'base_expr' => 'COUNT',
398
            'sub_tree' => array_merge([[
399
                'expr_type' => 'reserved',
400
                'base_expr' => 'DISTINCT',
401
                'delim' => ','
402
            ]], $group),
403
            'delim' => false,
404
        ]];
405
        return $parsedSql;
406
    }
407
408
    /**
409
     * @param mixed[] $parsedSql The AST of the SQL query
410
     * @return mixed[] An AST representing the matching COUNT query
411
     */
412
    private function generateWrappedSqlCount(array $parsedSql): array
413
    {
414
        return [
415
            'SELECT' => [[
416
                'expr_type' => 'aggregate_function',
417
                'alias' => [
418
                    'as' => true,
419
                    'name' => 'cnt',
420
                ],
421
                'base_expr' => 'COUNT',
422
                'sub_tree' => [
423
                    [
424
                        'expr_type' => 'colref',
425
                        'base_expr' => '*',
426
                        'sub_tree' => false
427
                    ]
428
                ],
429
                'delim' => false,
430
            ]],
431
            'FROM' => [[
432
                'expr_type' => 'subquery',
433
                'alias' => [
434
                    'as' => true,
435
                    'name' => '____query'
436
                ],
437
                'sub_tree' => $parsedSql,
438
            ]]
439
        ];
440
    }
441
442
    /**
443
     * @param string[] $relatedTables
444
     * @return string
445
     */
446
    protected function getTableGroupName(array $relatedTables): string
447
    {
448
        sort($relatedTables);
449
        return implode('_``_', $relatedTables);
450
    }
451
}
452