packages/ztd-query-core/src/Sql/Reader/SqlDelimitedReader.php

1<?php
2
3declare(strict_types=1);
4
5namespace ZtdQuery\Sql\Reader;
6
7use ZtdQuery\Sql\SqlLexerProfile;
8use ZtdQuery\Sql\SqlTokenKind;
9
10/**
11 * Reads what a pair of delimiters closes: strings and quoted identifiers.
12 *
13 * Which byte opens a run, which one closes it, and whether a backslash
14 * escapes inside it are all the dialect's business, so they are asked of the
15 * profile. A dollar-quoted string is closed by the same tag that opened it
16 * and nothing inside it escapes, so it is read on its own terms.
17 */
18final class SqlDelimitedReader
19{
20    /**
21     * Answers what delimited run starts at an offset.
22     *
23     * @param string $sql The statement, as written
24     * @param int $offset Where to look
25     * @param SqlLexerProfile $profile What the dialect spells things with
26     *
27     * @return SqlLexeme|null The run read there, or null when none starts there
28     */
29    public function readAt(string $sql, int $offset, SqlLexerProfile $profile): ?SqlLexeme
30    {
31        $opening = $sql[$offset];
32
33        $stringClosing = $profile->stringQuoteClosing($opening);
34        if ($stringClosing !== null) {
35            $backslashEscapes = $profile->stringUsesBackslashEscapes($sql, $offset);
36
37            return new SqlLexeme(
38                SqlTokenKind::String,
39                $this->endOfDelimited($sql, $offset, $opening, $stringClosing, $backslashEscapes),
40            );
41        }
42
43        $identifierClosing = $profile->identifierQuoteClosing($opening);
44        if ($identifierClosing !== null) {
45            return new SqlLexeme(
46                SqlTokenKind::QuotedIdentifier,
47                $this->endOfDelimited($sql, $offset, $opening, $identifierClosing, false),
48            );
49        }
50
51        $tag = $profile->dollarQuoteDelimiterAt($sql, $offset);
52        if ($tag === null) {
53            return null;
54        }
55
56        $closing = strpos($sql, $tag, $offset + strlen($tag));
57
58        return new SqlLexeme(
59            SqlTokenKind::String,
60            $closing === false ? strlen($sql) : $closing + strlen($tag),
61        );
62    }
63
64    /**
65     * Answers where a run closed by a delimiter ends.
66     *
67     * A doubled closing delimiter is how SQL writes the delimiter itself, so
68     * it does not close the run. A run left unclosed at the end of the
69     * statement ends there, because there is nothing further to read.
70     *
71     * @param string $sql The statement, as written
72     * @param int $offset Where the opening delimiter is
73     * @param string $opening The delimiter that opened the run
74     * @param string $closing The delimiter that will close it
75     * @param bool $backslashEscapes Whether a backslash escapes the byte after it
76     *
77     * @return int The offset just past the closing delimiter
78     */
79    public function endOfDelimited(
80        string $sql,
81        int $offset,
82        string $opening,
83        string $closing,
84        bool $backslashEscapes,
85    ): int {
86        $offset += strlen($opening);
87        while (isset($sql[$offset])) {
88            if (substr_compare($sql, $closing, $offset, strlen($closing)) === 0) {
89                if (substr_compare($sql, $closing . $closing, $offset, strlen($closing) * 2) === 0) {
90                    $offset += strlen($closing) * 2;
91                    continue;
92                }
93
94                return $offset + strlen($closing);
95            }
96            if ($backslashEscapes && $sql[$offset] === '\\') {
97                $offset += 2;
98                continue;
99            }
100            $offset++;
101        }
102
103        return strlen($sql);
104    }
105}
106