Passed
Pull Request — 5.1 (#282)
by
unknown
03:34
created

FindObjectsFromRawSqlQueryFactory::buildUnion()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 4
c 3
b 0
f 0
dl 0
loc 7
rs 10
cc 1
nc 1
nop 1
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
        $selectBuilder = new SelectStatementBuilder();
154
155
        return implode(' UNION ', array_map(function ($clause) use ($selectBuilder) {
156
            return '(' . $selectBuilder->build($clause) . ')';
157
        }, $parsed['UNION']));
158
    }
159
160
    /**
161
     * @param mixed[] $parsedSql
162
     * @param null|string $sqlCount
163
     * @return mixed[] An array of 3 elements: [$processedSql, $processedSqlCount, $columnDescriptors]
164
     */
165
    private function processParsedSelectQuery(array $parsedSql, ?string $sqlCount): array
166
    {
167
        // 1: let's reformat the SELECT and construct our columns
168
        list($select, $countSelect, $columnDescriptors) = $this->formatSelect($parsedSql['SELECT']);
169
        $generator = new PHPSQLCreator();
170
        $parsedSql['SELECT'] = $select;
171
        $processedSql = $generator->create($parsedSql);
172
173
        // 2: let's compute the count query if needed
174
        if ($sqlCount === null) {
175
            $parsedCountSql = $parsedSql;
176
            $parsedCountSql['SELECT'] = $countSelect;
177
            $parsedSqlCount = $this->generateParsedSqlCount($parsedCountSql);
178
            $processedSqlCount = $generator->create($parsedSqlCount);
179
        } else {
180
            $processedSqlCount = $sqlCount;
181
        }
182
183
        return [$processedSql, $processedSqlCount, $columnDescriptors];
184
    }
185
186
    /**
187
     * @param mixed[] $baseSelect
188
     * @return mixed[] An array of 2 elements: [$formattedSelect, $columnDescriptors]
189
     * @throws TDBMException
190
     * @throws \Doctrine\DBAL\Schema\SchemaException
191
     */
192
    private function formatSelect(array $baseSelect): array
193
    {
194
        $relatedTables = $this->tdbmService->_getRelatedTablesByInheritance($this->mainTable);
195
        $tableGroup = $this->getTableGroupName($relatedTables);
196
197
        $connection = $this->tdbmService->getConnection();
198
        $formattedSelect = [];
199
        $formattedCountSelect = [];
200
        $columnDescriptors = [];
201
        $fetchedTables = [];
202
203
        foreach ($baseSelect as $entry) {
204
            if ($entry['expr_type'] !== 'colref') {
205
                $formattedSelect[] = $entry;
206
                $formattedCountSelect[] = $entry;
207
                continue;
208
            }
209
210
            $noQuotes = $entry['no_quotes'];
211
            if ($noQuotes['delim'] !== '.' || count($noQuotes['parts']) !== 2) {
212
                $formattedSelect[] = $entry;
213
                $formattedCountSelect[] = $entry;
214
                continue;
215
            }
216
217
            $tableName = $noQuotes['parts'][0];
218
            if (!in_array($tableName, $relatedTables)) {
219
                $formattedSelect[] = $entry;
220
                $formattedCountSelect[] = $entry;
221
                continue;
222
            }
223
224
            $columnName = $noQuotes['parts'][1];
225
            if ($columnName !== '*') {
226
                $formattedSelect[] = $entry;
227
                $formattedCountSelect[] = $entry;
228
                continue;
229
            }
230
231
            $table = $this->schema->getTable($tableName);
232
            $pkColumns = $table->getPrimaryKeyColumns();
233
            foreach ($table->getColumns() as $column) {
234
                $columnName = $column->getName();
235
                $alias = AbstractQueryFactory::getColumnAlias($tableName, $columnName);
236
                $astColumn = [
237
                    'expr_type' => 'colref',
238
                    'base_expr' => $connection->quoteIdentifier($tableName) . '.' . $connection->quoteIdentifier($columnName),
239
                    'no_quotes' => [
240
                        'delim' => '.',
241
                        'parts' => [
242
                            $tableName,
243
                            $columnName
244
                        ]
245
                    ],
246
                    'alias' => [
247
                        'as' => true,
248
                        'name' => $alias,
249
                    ]
250
                ];
251
                $formattedSelect[] = $astColumn;
252
                if (in_array($columnName, $pkColumns)) {
253
                    $formattedCountSelect[] = $astColumn;
254
                }
255
                $columnDescriptors[$alias] = [
256
                    'as' => $alias,
257
                    'table' => $tableName,
258
                    'column' => $columnName,
259
                    'type' => $column->getType(),
260
                    'tableGroup' => $tableGroup,
261
                ];
262
            }
263
            $fetchedTables[] = $tableName;
264
        }
265
266
        $missingTables = array_diff($relatedTables, $fetchedTables);
267
        if (!empty($missingTables)) {
268
            throw new TDBMException('Missing tables '.implode(', ', $missingTables).' in SELECT statement');
269
        }
270
271
        for ($i = 0; $i < count($formattedSelect) - 1; $i++) {
272
            if (!isset($formattedSelect[$i]['delim'])) {
273
                $formattedSelect[$i]['delim'] = ',';
274
            }
275
        }
276
277
        for ($i = 0; $i < count($formattedCountSelect) - 1; $i++) {
278
            if (!isset($formattedCountSelect[$i]['delim'])) {
279
                $formattedCountSelect[$i]['delim'] = ',';
280
            }
281
        }
282
        return [$formattedSelect, $formattedCountSelect, $columnDescriptors];
283
    }
284
285
    /**
286
     * @param mixed[] $parsedSql
287
     * @return mixed[]
288
     */
289
    private function generateParsedSqlCount(array $parsedSql): array
290
    {
291
        if (isset($parsedSql['ORDER'])) {
292
            unset($parsedSql['ORDER']);
293
        }
294
295
        if (!isset($parsedSql['GROUP'])) {
296
            // most simple case:no GROUP BY in query
297
            return $this->generateSimpleSqlCount($parsedSql);
298
        } elseif (!isset($parsedSql['HAVING'])) {
299
            // GROUP BY without HAVING statement: let's COUNT the DISTINCT grouped columns
300
            return $this->generateGroupedSqlCount($parsedSql);
301
        } else {
302
            // GROUP BY with a HAVING statement: we'll have to wrap the query
303
            return $this->generateWrappedSqlCount($parsedSql);
304
        }
305
    }
306
307
    /**
308
     * @param mixed[] $parsedSql The AST of the SQL query
309
     * @return mixed[] An AST representing the matching COUNT query
310
     */
311
    private function generateSimpleSqlCount(array $parsedSql): array
312
    {
313
        // If the query is a DISTINCT, we need to deal with the count.
314
315
316
        // We need to count on the same columns: COUNT(DISTINCT country.id, country.label) ....
317
        // but we need to remove the "alias" bit.
318
319
        if ($this->isDistinctQuery($parsedSql)) {
320
            // Only MySQL can do DISTINCT counts.
321
            // Other databases should wrap the query
322
            if (!$this->tdbmService->getConnection()->getSchemaManager()->getDatabasePlatform() instanceof MySqlPlatform) {
323
                return $this->generateWrappedSqlCount($parsedSql);
324
            }
325
326
            $countSubExpr = array_map(function (array $item) {
327
                unset($item['alias']);
328
                return $item;
329
            }, $parsedSql['SELECT']);
330
        } else {
331
            $countSubExpr = [
332
                [
333
                'expr_type' => 'colref',
334
                'base_expr' => '*',
335
                'sub_tree' => false
336
                ]
337
            ];
338
        }
339
340
        $parsedSql['SELECT'] = [[
341
            'expr_type' => 'aggregate_function',
342
            'alias' => [
343
                'as' => true,
344
                'name' => 'cnt',
345
            ],
346
            'base_expr' => 'COUNT',
347
            'sub_tree' => $countSubExpr,
348
            'delim' => false,
349
        ]];
350
351
        return $parsedSql;
352
    }
353
354
    /**
355
     * @param mixed[] $parsedSql AST to analyze
356
     * @return bool
357
     */
358
    private function isDistinctQuery(array $parsedSql): bool
359
    {
360
        foreach ($parsedSql['SELECT'] as $item) {
361
            if ($item['expr_type'] === 'reserved' && $item['base_expr'] === 'DISTINCT') {
362
                return true;
363
            }
364
        }
365
        return false;
366
    }
367
368
    /**
369
     * @param mixed[] $parsedSql The AST of the SQL query
370
     * @return mixed[] An AST representing the matching COUNT query
371
     */
372
    private function generateGroupedSqlCount(array $parsedSql): array
373
    {
374
        $group = $parsedSql['GROUP'];
375
        unset($parsedSql['GROUP']);
376
        $parsedSql['SELECT'] = [[
377
            'expr_type' => 'aggregate_function',
378
            'alias' => [
379
                'as' => true,
380
                'name' => 'cnt',
381
            ],
382
            'base_expr' => 'COUNT',
383
            'sub_tree' => array_merge([[
384
                'expr_type' => 'reserved',
385
                'base_expr' => 'DISTINCT',
386
                'delim' => ','
387
            ]], $group),
388
            'delim' => false,
389
        ]];
390
        return $parsedSql;
391
    }
392
393
    /**
394
     * @param mixed[] $parsedSql The AST of the SQL query
395
     * @return mixed[] An AST representing the matching COUNT query
396
     */
397
    private function generateWrappedSqlCount(array $parsedSql): array
398
    {
399
        return [
400
            'SELECT' => [[
401
                'expr_type' => 'aggregate_function',
402
                'alias' => [
403
                    'as' => true,
404
                    'name' => 'cnt',
405
                ],
406
                'base_expr' => 'COUNT',
407
                'sub_tree' => [
408
                    [
409
                        'expr_type' => 'colref',
410
                        'base_expr' => '*',
411
                        'sub_tree' => false
412
                    ]
413
                ],
414
                'delim' => false,
415
            ]],
416
            'FROM' => [[
417
                'expr_type' => 'subquery',
418
                'alias' => [
419
                    'as' => true,
420
                    'name' => '____query'
421
                ],
422
                'sub_tree' => $parsedSql,
423
            ]]
424
        ];
425
    }
426
427
    /**
428
     * @param string[] $relatedTables
429
     * @return string
430
     */
431
    protected function getTableGroupName(array $relatedTables): string
432
    {
433
        sort($relatedTables);
434
        return implode('_``_', $relatedTables);
435
    }
436
}
437