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