packages/ztd-query-core/src/Sql/Profile/SqlQuoteProfile.php
1<?php
2
3declare(strict_types=1);
4
5namespace ZtdQuery\Sql\Profile;
6
7use ZtdQuery\Exception\InvalidDefinitionException;
8use ZtdQuery\Sql\LexicalDelimiters;
9use ZtdQuery\Sql\LexicalPattern;
10
11/**
12 * How one dialect closes a run of text it opened with a quote.
13 *
14 * Strings and identifiers are both written between delimiters, and a dialect
15 * decides which pairs spell each, whether a run may instead be closed by a
16 * tag of its own, and whether a backslash escapes inside a string.
17 */
18final class SqlQuoteProfile
19{
20 /** @var array<non-empty-string, non-empty-string> */
21 private readonly array $stringQuotePairs;
22
23 /** @var array<non-empty-string, non-empty-string> */
24 private readonly array $identifierQuotePairs;
25
26 /** @var list<non-empty-string> */
27 private readonly array $backslashEscapedStringPrefixes;
28
29 /**
30 * @param array<string, string> $stringQuotePairs Opening quote => the one that closes the string
31 * @param array<string, string> $identifierQuotePairs Opening quote => the one that closes the identifier
32 * @param string|null $dollarQuoteDelimiterPattern Pattern a dollar-quoted delimiter is written as, or null where the dialect has none
33 * @param list<string> $backslashEscapedStringPrefixes Prefixes that make the string they introduce use backslash escapes
34 * @param bool $backslashEscapedStrings Whether every string uses backslash escapes
35 * @param LexicalPattern $patterns Reads a regular expression against a position
36 * @param LexicalDelimiters $delimiters Refuses lexical data a scanner could not use
37 *
38 * @throws InvalidDefinitionException When a delimiter is empty or a pattern is unreadable
39 */
40 public function __construct(
41 array $stringQuotePairs,
42 array $identifierQuotePairs,
43 private readonly ?string $dollarQuoteDelimiterPattern,
44 array $backslashEscapedStringPrefixes,
45 private readonly bool $backslashEscapedStrings,
46 private readonly LexicalPattern $patterns = new LexicalPattern(),
47 LexicalDelimiters $delimiters = new LexicalDelimiters(),
48 ) {
49 $this->stringQuotePairs = $delimiters->pairs($stringQuotePairs, 'String quote');
50 $this->identifierQuotePairs = $delimiters->pairs($identifierQuotePairs, 'Identifier quote');
51 $this->backslashEscapedStringPrefixes = $delimiters->nonEmpty($backslashEscapedStringPrefixes);
52 $this->patterns->assertValid($this->dollarQuoteDelimiterPattern);
53 }
54
55 /**
56 * Answers the quote that closes a string this one opened.
57 *
58 * @param string $opening Quote that opened it
59 *
60 * @return string|null The closing quote, or null when nothing opens a string with that
61 */
62 public function stringQuoteClosing(string $opening): ?string
63 {
64 return $this->stringQuotePairs[$opening] ?? null;
65 }
66
67 /**
68 * Answers the quote that closes an identifier this one opened.
69 *
70 * @param string $opening Quote that opened it
71 *
72 * @return string|null The closing quote, or null when nothing opens an identifier with that
73 */
74 public function identifierQuoteClosing(string $opening): ?string
75 {
76 return $this->identifierQuotePairs[$opening] ?? null;
77 }
78
79 /**
80 * Answers the name a quoted identifier stands for.
81 *
82 * A closing quote doubled inside the name is one such character rather than
83 * the end of it, which is how every dialect here writes a quote in a name.
84 *
85 * @param string $identifier Identifier as it was written
86 *
87 * @return string The name, or the identifier unchanged when it was not quoted
88 */
89 public function unquoteIdentifier(string $identifier): string
90 {
91 foreach ($this->identifierQuotePairs as $opening => $closing) {
92 if (!str_starts_with($identifier, $opening) || !str_ends_with($identifier, $closing)) {
93 continue;
94 }
95 $body = substr($identifier, strlen($opening), -strlen($closing));
96
97 return str_replace($closing . $closing, $closing, $body);
98 }
99
100 return $identifier;
101 }
102
103 /**
104 * Answers the name a quoted identifier stands for, and nothing for anything else.
105 *
106 * This differs from unquoteIdentifier() in what it says about an identifier
107 * that was never quoted: here that is not an identifier this can speak for,
108 * rather than one that stands for itself.
109 *
110 * @param string $identifier Identifier as it was written
111 *
112 * @return string|null The name, or null when it was not a complete quoted identifier
113 */
114 public function quotedIdentifierValue(string $identifier): ?string
115 {
116 foreach ($this->identifierQuotePairs as $opening => $closing) {
117 if (!str_starts_with($identifier, $opening)) {
118 continue;
119 }
120 if (strlen($identifier) <= strlen($opening) + strlen($closing)
121 || !str_ends_with($identifier, $closing)
122 ) {
123 return null;
124 }
125 $body = substr($identifier, strlen($opening), -strlen($closing));
126
127 return str_replace($closing . $closing, $closing, $body);
128 }
129
130 return null;
131 }
132
133 /**
134 * Answers the dollar-quoted delimiter starting here, if any.
135 *
136 * @param string $sql Statement being scanned
137 * @param int $offset Position to look at
138 *
139 * @return string|null The delimiter, or null when none starts there
140 */
141 public function dollarQuoteDelimiterAt(string $sql, int $offset): ?string
142 {
143 return $this->patterns->matchAt($this->dollarQuoteDelimiterPattern, $sql, $offset);
144 }
145
146 /**
147 * Reports whether the string opening here treats a backslash as an escape.
148 *
149 * Some dialects say so for every string; others only for a string introduced
150 * by a particular prefix, and only where that prefix is a prefix rather than
151 * the tail of an identifier, which is why what spells an identifier is asked.
152 *
153 * @param string $sql Statement being scanned
154 * @param int $quoteOffset Position of the quote that opens the string
155 * @param SqlSymbolProfile $symbols What the dialect spells an identifier with
156 *
157 * @return bool True when a backslash escapes inside it
158 */
159 public function stringUsesBackslashEscapes(string $sql, int $quoteOffset, SqlSymbolProfile $symbols): bool
160 {
161 if ($this->backslashEscapedStrings) {
162 return true;
163 }
164 foreach ($this->backslashEscapedStringPrefixes as $prefix) {
165 $prefixLength = strlen($prefix);
166 if ($quoteOffset < $prefixLength) {
167 continue;
168 }
169 $prefixOffset = $quoteOffset - $prefixLength;
170 if (substr_compare($sql, $prefix, $prefixOffset, $prefixLength) !== 0) {
171 continue;
172 }
173 $preceding = $prefixOffset === 0 ? '' : $sql[$prefixOffset - 1];
174 if ($preceding === '' || !$symbols->isIdentifierPart($preceding)) {
175 return true;
176 }
177 }
178
179 return false;
180 }
181}
182