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