Coverage for src / lexigram / contracts / data / identifiers.py: 38%
85 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-19 05:41 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-19 05:41 +0800
1"""Type-safe SQL identifier primitives.
3Provides validated, dialect-aware SQL identifiers that can be safely
4interpolated into SQL strings. Any package building SQL queries
5(``lexigram-sql``, ``lexigram-events``, ``lexigram-tasks``, ``lexigram-ai``,
6…) can import directly from here without depending on each other.
8Typical usage::
10 from lexigram.contracts.data.identifiers import Table, Column, table, column
12 t = table("users")
13 c = column("email")
14 query = f"SELECT {c} FROM {t} WHERE {c} = $1"
15"""
17from __future__ import annotations
19from functools import lru_cache
20import re
21from typing import Final
23from lexigram.contracts.data.sql.sql import InvalidIdentifierError
24from lexigram.contracts.data.sql.sql_dialect import (
25 DEFAULT_MAX_IDENTIFIER_LENGTH,
26 MAX_IDENTIFIER_LENGTHS,
27 SQLDialect,
28)
30# ---------------------------------------------------------------------------
31# Validation constants
32# ---------------------------------------------------------------------------
34_IDENT_RE: Final[re.Pattern[str]] = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
36_RESERVED_WORDS: Final[frozenset[str]] = frozenset(
37 {
38 "ABORT",
39 "ADD",
40 "ALL",
41 "ALTER",
42 "ANALYZE",
43 "AND",
44 "AS",
45 "ASC",
46 "BEGIN",
47 "BETWEEN",
48 "BY",
49 "CASCADE",
50 "CASE",
51 "CHECK",
52 "COLLATE",
53 "COLUMN",
54 "COMMIT",
55 "CONFLICT",
56 "CONSTRAINT",
57 "CREATE",
58 "CROSS",
59 "CURRENT_DATE",
60 "CURRENT_TIME",
61 "CURRENT_TIMESTAMP",
62 "DATABASE",
63 "DEFAULT",
64 "DELETE",
65 "DESC",
66 "DISTINCT",
67 "DO",
68 "DROP",
69 "ELSE",
70 "END",
71 "ESCAPE",
72 "EXCEPT",
73 "EXCLUDED",
74 "EXISTS",
75 "EXPLAIN",
76 "FALSE",
77 "FOR",
78 "FOREIGN",
79 "FROM",
80 "FULL",
81 "FUNCTION",
82 "GRANT",
83 "GROUP",
84 "HAVING",
85 "IF",
86 "IN",
87 "INDEX",
88 "INNER",
89 "INSERT",
90 "INTERSECT",
91 "INTO",
92 "IS",
93 "JOIN",
94 "KEY",
95 "LEFT",
96 "LIKE",
97 "LIMIT",
98 "NOT",
99 "NOTHING",
100 "NOWAIT",
101 "NULL",
102 "OFFSET",
103 "ON",
104 "OR",
105 "ORDER",
106 "OUTER",
107 "PRIMARY",
108 "RECURSIVE",
109 "REFERENCES",
110 "RESTRICT",
111 "RETURNING",
112 "REVOKE",
113 "RIGHT",
114 "ROLLBACK",
115 "SCHEMA",
116 "SELECT",
117 "SET",
118 "SHARE",
119 "TABLE",
120 "THEN",
121 "TRANSACTION",
122 "TRIGGER",
123 "TRUE",
124 "UNION",
125 "UNIQUE",
126 "UPDATE",
127 "USING",
128 "VACUUM",
129 "VALUES",
130 "VIEW",
131 "WHEN",
132 "WHERE",
133 "WITH",
134 }
135)
138# ---------------------------------------------------------------------------
139# Core Identifier
140# ---------------------------------------------------------------------------
143class Identifier:
144 """Validated, quoted, immutable SQL identifier.
146 Created once, validated once, safe to interpolate into SQL forever.
147 ``__str__`` returns the dialect-quoted form so
148 ``f"SELECT * FROM {table}"`` is always safe when *table* is an
149 ``Identifier``.
151 Args:
152 name: The raw identifier name.
153 dialect: Target SQL dialect for quoting. Defaults to PostgreSQL.
155 Raises:
156 InvalidIdentifierError: If the name is empty, contains invalid
157 characters, or exceeds the dialect's maximum length.
158 """
160 __slots__ = ("_dialect", "_name", "_quoted")
162 def __init__(
163 self,
164 name: str,
165 *,
166 dialect: SQLDialect = SQLDialect.POSTGRESQL,
167 ) -> None:
168 if not name:
169 raise InvalidIdentifierError(
170 "SQL identifier cannot be empty",
171 identifier=name,
172 )
173 if not _IDENT_RE.match(name):
174 raise InvalidIdentifierError(
175 f"Invalid SQL identifier: {name!r}. "
176 "Must match pattern [a-zA-Z_][a-zA-Z0-9_]*",
177 identifier=name,
178 )
179 max_len = MAX_IDENTIFIER_LENGTHS.get(dialect, DEFAULT_MAX_IDENTIFIER_LENGTH)
180 if len(name) > max_len:
181 raise InvalidIdentifierError(
182 f"SQL identifier {name!r} exceeds maximum length "
183 f"of {max_len} for dialect {dialect.value}",
184 identifier=name,
185 )
186 self._name: str = name
187 self._dialect: SQLDialect = dialect
188 self._quoted: str = self._quote(name, dialect)
190 @staticmethod
191 def _quote(name: str, dialect: SQLDialect) -> str:
192 """Return the dialect-appropriate quoted identifier."""
193 if dialect == SQLDialect.MYSQL:
194 return f"`{name}`"
195 return f'"{name}"'
197 @property
198 def name(self) -> str:
199 """The raw, unquoted identifier name."""
200 return self._name
202 @property
203 def quoted(self) -> str:
204 """The dialect-quoted identifier, safe for SQL interpolation."""
205 return self._quoted
207 @property
208 def dialect(self) -> SQLDialect:
209 """The SQL dialect this identifier is quoted for."""
210 return self._dialect
212 @property
213 def is_reserved(self) -> bool:
214 """Whether this identifier is a SQL reserved word."""
215 return self._name.upper() in _RESERVED_WORDS
217 def __str__(self) -> str:
218 """Return the quoted identifier (safe for SQL interpolation)."""
219 return self._quoted
221 def __repr__(self) -> str:
222 return f"{self.__class__.__name__}({self._name!r})"
224 def __eq__(self, other: object) -> bool:
225 if isinstance(other, Identifier):
226 return (
227 self._name == other._name
228 and self._dialect == other._dialect
229 and type(self) is type(other)
230 )
231 return NotImplemented
233 def __hash__(self) -> int:
234 return hash((type(self).__name__, self._name, self._dialect))
237# ---------------------------------------------------------------------------
238# Concrete identifier types
239# ---------------------------------------------------------------------------
242class Table(Identifier):
243 """A validated SQL table name.
245 Example::
247 t = Table("users")
248 query = f"SELECT * FROM {t}" # SELECT * FROM "users"
249 """
252class Column(Identifier):
253 """A validated SQL column name.
255 Example::
257 c = Column("email")
258 query = f"SELECT {c} FROM users" # SELECT "email" FROM users
259 """
262class Schema(Identifier):
263 """A validated SQL schema name."""
266# ---------------------------------------------------------------------------
267# Composite types
268# ---------------------------------------------------------------------------
271class QualifiedTable:
272 """A schema-qualified table name: ``schema.table``.
274 Example::
276 qt = QualifiedTable(Schema("public"), Table("users"))
277 query = f"SELECT * FROM {qt}" # SELECT * FROM "public"."users"
278 """
280 __slots__ = ("_schema", "_table")
282 def __init__(self, schema: Schema, table: Table) -> None:
283 self._schema = schema
284 self._table = table
286 @property
287 def schema(self) -> Schema:
288 """The schema component."""
289 return self._schema
291 @property
292 def table(self) -> Table:
293 """The table component."""
294 return self._table
296 @property
297 def quoted(self) -> str:
298 """The fully-qualified, quoted identifier."""
299 return f"{self._schema.quoted}.{self._table.quoted}"
301 def __str__(self) -> str:
302 return self.quoted
304 def __repr__(self) -> str:
305 return f"QualifiedTable({self._schema!r}, {self._table!r})"
307 def __eq__(self, other: object) -> bool:
308 if isinstance(other, QualifiedTable):
309 return self._schema == other._schema and self._table == other._table
310 return NotImplemented
312 def __hash__(self) -> int:
313 return hash((self._schema, self._table))
316# ---------------------------------------------------------------------------
317# Cached factory functions
318# ---------------------------------------------------------------------------
321@lru_cache(maxsize=512)
322def table(name: str, *, dialect: SQLDialect = SQLDialect.POSTGRESQL) -> Table:
323 """Create a cached, validated :class:`Table` identifier.
325 Repeated calls with the same arguments return the **same object**.
327 Args:
328 name: Raw table name.
329 dialect: SQL dialect for quoting.
331 Returns:
332 A validated, dialect-quoted :class:`Table` identifier.
333 """
334 return Table(name, dialect=dialect)
337@lru_cache(maxsize=1024)
338def column(name: str, *, dialect: SQLDialect = SQLDialect.POSTGRESQL) -> Column:
339 """Create a cached, validated :class:`Column` identifier.
341 Args:
342 name: Raw column name.
343 dialect: SQL dialect for quoting.
345 Returns:
346 A validated, dialect-quoted :class:`Column` identifier.
347 """
348 return Column(name, dialect=dialect)
351@lru_cache(maxsize=64)
352def schema(name: str, *, dialect: SQLDialect = SQLDialect.POSTGRESQL) -> Schema:
353 """Create a cached, validated :class:`Schema` identifier.
355 Args:
356 name: Raw schema name.
357 dialect: SQL dialect for quoting.
359 Returns:
360 A validated, dialect-quoted :class:`Schema` identifier.
361 """
362 return Schema(name, dialect=dialect)
365__all__ = [
366 "DEFAULT_MAX_IDENTIFIER_LENGTH",
367 "MAX_IDENTIFIER_LENGTHS",
368 "Column",
369 "Identifier",
370 "QualifiedTable",
371 "Schema",
372 "Table",
373 "column",
374 "schema",
375 "table",
376]