Passed
Pull Request — master (#181)
by
unknown
05:45
created

setResultIterator()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 0

Duplication

Lines 0
Ratio 0 %

Importance

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