Passed
Pull Request — master (#161)
by David
05:10 queued 02:05
created

getSubQueryColumnDescriptors()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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