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

getMagicSqlCount()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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