packages/ztd-query-sqlite/src/Sql/Returning/ReturningItemParser.php

1<?php
2
3declare(strict_types=1);
4
5namespace ZtdQuery\Platform\Sqlite\Sql\Returning;
6
7use ZtdQuery\Platform\Sqlite\Sql\SqliteLexerProfile;
8use ZtdQuery\Sql\SqlToken;
9use ZtdQuery\Sql\SqlTokenKind;
10use ZtdQuery\Sql\SqlTokenStream;
11
12/**
13 * Parses RETURNING expressions and their output column names.
14 *
15 * @visibility ZtdQuery\Platform\Sqlite
16 */
17final class ReturningItemParser
18{
19    /**
20     * @return array{source: string|null, output: string|null}|null
21     */
22    public function parseItem(string $expression): ?array
23    {
24        $tokens = SqlTokenStream::tokenize($expression, SqliteLexerProfile::create())->significantTokens();
25        $alias = null;
26        $asIndex = $this->asIndex($tokens);
27        if ($asIndex !== null) {
28            if ($asIndex + 2 !== count($tokens)) {
29                return null;
30            }
31            $aliasToken = $tokens[$asIndex + 1] ?? null;
32            if ($aliasToken === null) {
33                return null;
34            }
35            $alias = $this->identifierName($aliasToken);
36            if ($alias === null) {
37                return null;
38            }
39            $tokens = array_slice($tokens, 0, $asIndex);
40        }
41
42        if (!$this->isIdentifierPath($tokens)) {
43            return null;
44        }
45        $last = $tokens[count($tokens) - 1];
46        if ($last->text === '*') {
47            return $alias === null ? ['source' => null, 'output' => null] : null;
48        }
49
50        $source = $this->identifierName($last);
51        if ($source === null) {
52            return null;
53        }
54
55        return ['source' => $source, 'output' => $alias];
56    }
57
58    /**
59     * @param list<SqlToken> $tokens
60     */
61    public function asIndex(array $tokens): ?int
62    {
63        foreach ($tokens as $index => $token) {
64            if ($token->isKeyword('AS')) {
65                return $index;
66            }
67        }
68
69        return null;
70    }
71
72    /**
73     * @param list<SqlToken> $tokens
74     */
75    public function isIdentifierPath(array $tokens): bool
76    {
77        $lastIndex = count($tokens) - 1;
78        foreach ($tokens as $index => $token) {
79            if ($index % 2 === 0) {
80                if ($this->identifierName($token) !== null) {
81                    continue;
82                }
83
84                return $index === $lastIndex
85                    && $token->kind === SqlTokenKind::Symbol
86                    && $token->text === '*';
87            }
88            if ($token->kind !== SqlTokenKind::Symbol || $token->text !== '.') {
89                return false;
90            }
91        }
92
93        return true;
94    }
95
96    /**
97     * Parses RETURNING expressions and their output column names.
98     */
99    public function identifierName(SqlToken $token): ?string
100    {
101        if ($token->kind === SqlTokenKind::Word) {
102            return $token->text;
103        }
104        if ($token->kind !== SqlTokenKind::QuotedIdentifier || strlen($token->text) <= 2) {
105            return null;
106        }
107
108        $identifier = $token->text;
109        $first = $identifier[0];
110        if (!in_array($first, ['"', '`'], true) || $identifier[strlen($identifier) - 1] !== $first) {
111            return null;
112        }
113
114        return str_replace($first . $first, $first, substr($identifier, 1, -1));
115    }
116}
117