packages/sql-semantics-mysql/src/TypeRules.php

1<?php
2
3declare(strict_types=1);
4
5namespace SqlSemantics\Platform\MySql;
6
7use SqlParser\Lexer\Token;
8use SqlParser\Parser\Node;
9use SqlSemantics\Core\Ast\Tree;
10use SqlSemantics\Core\Binding\TypeResolution;
11use SqlSemantics\Core\Dialect;
12use SqlSemantics\Core\Model\Expression;
13use SqlSemantics\Core\Policy\TypeRules as Contract;
14use SqlSemantics\Core\SemanticException;
15use SqlSemantics\Core\Type\TypeDescriptor;
16
17/**
18 * MySql TypeRules implementation.
19 *
20 * @visibility SqlSemantics
21 */
22final class TypeRules implements Contract
23{
24    /**
25     * Retains the language identity used in semantic output.
26     */
27    public function __construct(private readonly Dialect $dialect)
28    {
29    }
30
31    /**
32     * Reads a declared type, including table-dependent storage rules and modifiers.
33     */
34    public function read(Node $node, ?Node $table = null): TypeDescriptor
35    {
36        $tokens = $node->tokens();
37        $words = [];
38        $modifiers = [];
39        $inModifiers = false;
40        foreach ($tokens as $token) {
41            if ($token->text === '(') {
42                $inModifiers = true;
43            } elseif ($token->text === ')') {
44                $inModifiers = false;
45            } elseif ($token->text !== ',') {
46                if ($inModifiers) {
47                    $modifiers[] = $token->text;
48                } else {
49                    $words[] = $token->text;
50                }
51            }
52        }
53        $name = implode(' ', $words);
54        $canonical = $this->canonical(strtoupper($name)) ?? $name;
55        return new TypeDescriptor($this->dialect, $canonical, $modifiers);
56    }
57
58    /**
59     * Resolves the built-in aliases modeled for this dialect.
60     */
61    public function canonical(string $name): ?string
62    {
63        return match ($name) {
64            'INT', 'INTEGER', 'INT4' => 'integer',
65            'SMALLINT', 'INT2' => 'smallint',
66            'BIGINT', 'INT8' => 'bigint',
67            'DEC', 'DECIMAL', 'NUMERIC' => 'numeric',
68            'REAL' => 'double precision',
69            'FLOAT4' => 'real',
70            'DOUBLE', 'DOUBLE PRECISION', 'FLOAT8' => 'double precision',
71            'BOOL', 'BOOLEAN' => 'tinyint',
72            'VARCHAR', 'CHARACTER VARYING', 'CHAR VARYING' => 'varchar',
73            'CHAR', 'CHARACTER' => 'char',
74            'TEXT', 'DATE', 'TIME', 'TIMESTAMP', 'JSON' => strtolower($name),
75            'TINYINT', 'MEDIUMINT', 'DATETIME', 'BLOB' => strtolower($name),
76            'UUID', 'BYTEA', 'JSONB', 'TIMESTAMPTZ', 'TIMETZ', 'INTERVAL' => null,
77            default => null,
78        };
79    }
80
81    /**
82     * Computes storage affinity from a declaration name.
83     */
84    public function affinity(string $name): string
85    {
86        if (str_contains($name, 'INT')) {
87            return 'integer';
88        }
89        if (str_contains($name, 'CHAR') || str_contains($name, 'CLOB') || str_contains($name, 'TEXT')) {
90            return 'text';
91        }
92        if ($name === '' || str_contains($name, 'BLOB')) {
93            return 'blob';
94        }
95        if (str_contains($name, 'REAL') || str_contains($name, 'FLOA') || str_contains($name, 'DOUB')) {
96            return 'real';
97        }
98        return 'numeric';
99    }
100
101    /**
102     * Classifies a literal's lexical category without converting its contents.
103     */
104    public function typeName(Token $token): ?string
105    {
106        $name = $token->name;
107        $number = str_replace('_', '', $token->text);
108        if (in_array($name, ['ICONST', 'FCONST', 'INTEGER', 'NUM', 'LONG_NUM', 'ULONGLONG_NUM'], true) && preg_match('/^0[xob]/i', $number) === 1) {
109            Tree::unsupported($token, 'non-decimal numeric literal');
110        }
111        $text = strtoupper($token->text);
112        return match (true) {
113            in_array($name, ['ICONST', 'NUM', 'INTEGER'], true) => $this->integer($number),
114            $name === 'LONG_NUM' => 'bigint',
115            $name === 'ULONGLONG_NUM' => 'bigint unsigned',
116            $name === 'FCONST' => ctype_digit($number) ? $this->integer($number) : 'numeric',
117            $name === 'DECIMAL_NUM' => 'numeric',
118            in_array($name, ['FLOAT_NUM', 'FLOAT'], true) => 'double precision',
119            in_array($name, ['SCONST', 'USCONST', 'TEXT_STRING', 'STRING'], true) => 'text',
120            in_array($name, ['NULL_P', 'NULL_SYM', 'NULL'], true) => 'unknown',
121            in_array($text, ['TRUE', 'FALSE'], true) && !in_array($name, ['IDENT', 'IDENT_QUOTED', 'ID'], true) => 'integer',
122            default => null,
123        };
124    }
125
126    /**
127     * Chooses a MySql integer width from its decimal spelling.
128     */
129    public function integer(string $text): string
130    {
131        $digits = ltrim($text, '0');
132        return 'integer';
133    }
134
135    /**
136     * @param list<Expression> $expressions
137     * @throws SemanticException
138     */
139    public function common(array $expressions, Node|Token $source): TypeDescriptor
140    {
141        $types = [];
142        foreach ($expressions as $expression) {
143            if ($expression->type->name !== 'unknown') {
144                $types[] = $expression->type;
145            }
146        }
147        if ($types === []) {
148            return new TypeDescriptor($this->dialect, 'unknown');
149        }
150        $names = array_values(array_unique(array_map(static fn (TypeDescriptor $type): string => $type->name, $types)));
151        if (count($names) === 1) {
152            return new TypeDescriptor($this->dialect, $types[0]->name, affinity: $types[0]->affinity);
153        }
154        $numeric = ['smallint', 'integer', 'bigint', 'numeric', 'real', 'double precision'];
155        if (array_diff($names, $numeric) === []) {
156            $rank = 0;
157            foreach ($numeric as $index => $name) {
158                if (in_array($name, $names, true)) {
159                    $rank = $index;
160                }
161            }
162            return new TypeDescriptor($this->dialect, $numeric[$rank]);
163        }
164        if (array_diff($names, ['varchar', 'text', 'char']) === []) {
165            return new TypeDescriptor($this->dialect, 'text');
166        }
167        throw new SemanticException('unsupported-coercion', 'Cannot establish a common type for: ' . implode(', ', $names), $source);
168    }
169
170    /**
171     * Returns the dialect result type of a predicate.
172     */
173    public function boolean(): TypeDescriptor
174    {
175        return new TypeDescriptor($this->dialect, 'integer');
176    }
177
178    /**
179     * Resolves supported numeric operations, including signed literal boundaries.
180     *
181     * @param non-empty-list<Expression> $operands
182     */
183    public function arithmetic(string $operator, array $operands, Node $source): TypeDescriptor
184    {
185        $type = (new TypeResolution($this->dialect))->common($operands, $source);
186        if (!in_array(strtolower($type->name), ['smallint', 'integer', 'bigint'], true)) {
187            Tree::unsupported($source, 'arithmetic type');
188        }
189        $type = new TypeDescriptor($this->dialect, 'bigint');
190        return $type;
191    }
192
193    /**
194     * Accepts scalar truth values.
195     */
196    public function predicate(Expression $expression): void
197    {
198    }
199
200    /**
201     * @param list<Expression> $operands
202     * @return list<Expression>
203     */
204    public function coalesce(array $operands, TypeDescriptor $type): array
205    {
206        return $operands;
207    }
208
209    /**
210     * Resolves the output type of a projected literal.
211     */
212    public function project(Expression $expression): Expression
213    {
214        return $expression;
215    }
216}
217