Passed
Pull Request — 2.x (#231)
by
unknown
20:16
created

SQLServerCompiler::limit()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 39
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 42

Importance

Changes 0
Metric Value
cc 6
eloc 19
nc 5
nop 5
dl 0
loc 39
ccs 0
cts 0
cp 0
crap 42
rs 9.0111
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * This file is part of Cycle ORM package.
5
 *
6
 * For the full copyright and license information, please view the LICENSE
7
 * file that was distributed with this source code.
8
 */
9
10
declare(strict_types=1);
11
12
namespace Cycle\Database\Driver\SQLServer;
13
14
use Cycle\Database\Driver\Compiler;
15
use Cycle\Database\Driver\Quoter;
16
use Cycle\Database\Driver\SQLServer\Injection\CompileJson;
17
use Cycle\Database\Exception\CompilerException;
18
use Cycle\Database\Injection\Fragment;
19
use Cycle\Database\Injection\FragmentInterface;
20
use Cycle\Database\Injection\Parameter;
21
use Cycle\Database\Query\QueryParameters;
22
23
/**
24
 * Microsoft SQL server specific syntax compiler.
25
 */
26
class SQLServerCompiler extends Compiler
27
{
28
    /**
29
     * Column to be used as ROW_NUMBER in fallback selection mechanism, attention! Amount of columns
30
     * in result set will be increaced by 1!
31
     */
32
    public const ROW_NUMBER = '_ROW_NUMBER_';
33
34
    /**
35
     * @psalm-return non-empty-string
36
     */
37
    protected function insertQuery(QueryParameters $params, Quoter $q, array $tokens): string
38
    {
39
        if (empty($tokens['return'])) {
40 450
            return parent::insertQuery($params, $q, $tokens);
41
        }
42 450
43 450
        $values = [];
44
        foreach ($tokens['values'] as $value) {
45 450
            $values[] = $this->value($params, $q, $value);
46
        }
47 432
48
        $output = \implode(',', \array_map(
49
            fn(string|FragmentInterface|null $return) => $return instanceof FragmentInterface
50
                ? $this->fragment($params, $q, $return)
51
                : 'INSERTED.' . $this->quoteIdentifier($return),
0 ignored issues
show
Bug introduced by
It seems like $return can also be of type null; however, parameter $identifier of Cycle\Database\Driver\Compiler::quoteIdentifier() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

51
                : 'INSERTED.' . $this->quoteIdentifier(/** @scrutinizer ignore-type */ $return),
Loading history...
52
            $tokens['return'],
53
        ));
54
55
        if ($tokens['columns'] === []) {
56 24
            return \sprintf(
57 24
                'INSERT INTO %s OUTPUT %s DEFAULT VALUES',
58 24
                $this->name($params, $q, $tokens['table'], true),
59 24
                $output,
60
            );
61
        }
62
63 24
        return \sprintf(
64 24
            'INSERT INTO %s (%s) OUTPUT %s VALUES %s',
65
            $this->name($params, $q, $tokens['table'], true),
66 24
            $this->columns($params, $q, $tokens['columns']),
67 24
            $output,
68 24
            \implode(', ', $values),
69 24
        );
70
    }
71
72
    /**
73
     * @psalm-return non-empty-string
74
     */
75
    protected function upsertQuery(QueryParameters $params, Quoter $q, array $tokens): string
76
    {
77
        if (\count($tokens['conflicts']) === 0) {
78
            throw new CompilerException('Upsert query must define conflicting index column names');
79
        }
80
81 450
        if (\count($tokens['columns']) === 0) {
82
            throw new CompilerException('Upsert query must define at least one column');
83
        }
84
85
        $values = [];
86
87
        foreach ($tokens['values'] as $value) {
88 450
            $values[] = $this->value($params, $q, $value);
89 446
        }
90
91
        $target = 'target';
92
        $source = 'source';
93 30
94 6
        $conflicts = \array_map(
95 6
            function (string $column) use ($params, $q, $target, $source) {
96
                $name = $this->name($params, $q, $column);
97 6
                $target = $this->name($params, $q, $target);
98 6
                $source = $this->name($params, $q, $source);
99 6
                return \sprintf('%s.%s = %s.%s', $target, $name, $source, $name);
100
            },
101
            $tokens['conflicts'],
102 6
        );
103
104
        $updates = \array_map(
105 24
            function (string $column) use ($params, $q, $target, $source) {
106
                $name = $this->name($params, $q, $column);
107
                $target = $this->name($params, $q, $target);
108 24
                $source = $this->name($params, $q, $source);
109
                return \sprintf('%s.%s = %s.%s', $target, $name, $source, $name);
110 24
            },
111 20
            $tokens['columns'],
112 20
        );
113 20
114
        $inserts = \array_map(
115 4
            function (string $column) use ($params, $q, $source) {
116 4
                $name = $this->name($params, $q, $column);
117
                $source = $this->name($params, $q, $source);
118
                return \sprintf('%s.%s', $source, $name);
119 24
            },
120
            $tokens['columns'],
121
        );
122
123
        return \sprintf(
124
            'MERGE INTO %s WITH (holdlock) AS %s USING ( VALUES %s) AS %s (%s) ON %s WHEN MATCHED THEN UPDATE SET %s WHEN NOT MATCHED THEN INSERT (%s) VALUES (%s);',
125 450
            $this->name($params, $q, $tokens['table'], true),
126
            $this->name($params, $q, $target),
127
            \implode(', ', $values),
128 450
            $this->name($params, $q, 'source'),
129 450
            $this->columns($params, $q, $tokens['columns']),
130 450
            \implode(' AND ', $conflicts),
131
            \implode(', ', $updates),
132 450
            $this->columns($params, $q, $tokens['columns']),
133 70
            \implode(', ', $inserts),
134
        );
135
    }
136 450
137 450
    /**
138 450
     * {@inheritDoc}
139 450
     *
140 450
     * Attention, limiting and ordering UNIONS will fail in SQL SERVER < 2012.
141 450
     * For future upgrades: think about using top command.
142 450
     *
143 450
     * @link http://stackoverflow.com/questions/603724/how-to-implement-limit-with-microsoft-sql-server
144 450
     * @link http://stackoverflow.com/questions/971964/limit-10-20-in-sql-server
145 450
     */
146 450
    protected function selectQuery(QueryParameters $params, Quoter $q, array $tokens): string
147 450
    {
148 450
        $limit = $tokens['limit'];
149
        $offset = $tokens['offset'];
150
151
        if (($limit === null && $offset === null) || $tokens['orderBy'] !== []) {
152
            //When no limits are specified we can use normal query syntax
153
            return \call_user_func_array([$this, 'baseSelect'], \func_get_args());
154
        }
155
156
        /**
157
         * We are going to use fallback mechanism here in order to properly select limited data from
158
         * database. Avoid usage of LIMIT/OFFSET without proper ORDER BY statement.
159
         *
160
         * Please see set of alerts raised in SelectQuery builder.
161
         */
162
        $tokens['columns'][] = new Fragment(
163
            \sprintf(
164
                'ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS %s',
165
                $this->name($params, $q, self::ROW_NUMBER),
166
            ),
167
        );
168
169
        $tokens['limit'] = null;
170
        $tokens['offset'] = null;
171
172
        return \sprintf(
173
            "SELECT * FROM (\n%s\n) AS [ORD_FALLBACK] %s",
174
            $this->baseSelect($params, $q, $tokens),
175
            $this->limit($params, $q, $limit, $offset, self::ROW_NUMBER),
176
        );
177
    }
178
179
    /**
180
     *
181
     *
182
     * @param string $rowNumber Row used in a fallback sorting mechanism, ONLY when no ORDER BY
183
     *                          specified.
184
     *
185
     * @link http://stackoverflow.com/questions/2135418/equivalent-of-limit-and-offset-for-sql-server
186
     */
187
    protected function limit(
188
        QueryParameters $params,
189
        Quoter $q,
190
        ?int $limit = null,
191
        ?int $offset = null,
192
        ?string $rowNumber = null,
193
    ): string {
194
        if ($limit === null && $offset === null) {
195
            return '';
196
        }
197
198
        //Modern SQLServer are easier to work with
199
        if ($rowNumber === null) {
200
            $statement = 'OFFSET ? ROWS ';
201
            $params->push(new Parameter((int) $offset));
202
203
            if ($limit !== null) {
204
                $statement .= 'FETCH FIRST ? ROWS ONLY';
205
                $params->push(new Parameter($limit));
206
            }
207
208
            return \trim($statement);
209
        }
210
211
        $statement = "WHERE {$this->name($params, $q, $rowNumber)} ";
212
213
        //0 = row_number(1)
214
        ++$offset;
215
216
        if ($limit !== null) {
217
            $statement .= 'BETWEEN ? AND ?';
218
            $params->push(new Parameter((int) $offset));
219
            $params->push(new Parameter($offset + $limit - 1));
220
        } else {
221
            $statement .= '>= ?';
222
            $params->push(new Parameter((int) $offset));
223
        }
224
225
        return $statement;
226
    }
227
228
    protected function compileJsonOrderBy(string $path): FragmentInterface
229
    {
230
        return new CompileJson($path);
231
    }
232
233
    private function baseSelect(QueryParameters $params, Quoter $q, array $tokens): string
234
    {
235
        // This statement(s) parts should be processed first to define set of table and column aliases
236
        $tables = [];
237
        foreach ($tokens['from'] as $table) {
238
            $tables[] = $this->name($params, $q, $table, true);
239
        }
240
        foreach ($tokens['join'] as $join) {
241
            $this->nameWithAlias(new QueryParameters(), $q, $join['outer'], $join['alias'], true);
242
        }
243
244
        return \sprintf(
245
            "SELECT%s %s\nFROM %s%s%s%s%s%s%s%s%s%s%s",
246
            $this->optional(' ', $this->distinct($params, $q, $tokens['distinct'])),
247
            $this->columns($params, $q, $tokens['columns']),
248
            \implode(', ', $tables),
249
            $this->optional(' ', $tokens['forUpdate'] ? 'WITH (UPDLOCK,ROWLOCK)' : '', ' '),
250
            $this->optional(' ', $this->joins($params, $q, $tokens['join']), ' '),
251
            $this->optional("\nWHERE", $this->where($params, $q, $tokens['where'])),
252
            $this->optional("\nGROUP BY", $this->groupBy($params, $q, $tokens['groupBy']), ' '),
253
            $this->optional("\nHAVING", $this->where($params, $q, $tokens['having'])),
254
            $this->optional("\n", $this->unions($params, $q, $tokens['union'])),
255
            $this->optional("\n", $this->intersects($params, $q, $tokens['intersect'])),
256
            $this->optional("\n", $this->excepts($params, $q, $tokens['except'])),
257
            $this->optional("\nORDER BY", $this->orderBy($params, $q, $tokens['orderBy'])),
258
            $this->optional("\n", $this->limit($params, $q, $tokens['limit'], $tokens['offset'])),
259
        );
260
    }
261
}
262