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

1"""Type-safe SQL identifier primitives. 

2 

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. 

7 

8Typical usage:: 

9 

10 from lexigram.contracts.data.identifiers import Table, Column, table, column 

11 

12 t = table("users") 

13 c = column("email") 

14 query = f"SELECT {c} FROM {t} WHERE {c} = $1" 

15""" 

16 

17from __future__ import annotations 

18 

19from functools import lru_cache 

20import re 

21from typing import Final 

22 

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) 

29 

30# --------------------------------------------------------------------------- 

31# Validation constants 

32# --------------------------------------------------------------------------- 

33 

34_IDENT_RE: Final[re.Pattern[str]] = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") 

35 

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) 

136 

137 

138# --------------------------------------------------------------------------- 

139# Core Identifier 

140# --------------------------------------------------------------------------- 

141 

142 

143class Identifier: 

144 """Validated, quoted, immutable SQL identifier. 

145 

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``. 

150 

151 Args: 

152 name: The raw identifier name. 

153 dialect: Target SQL dialect for quoting. Defaults to PostgreSQL. 

154 

155 Raises: 

156 InvalidIdentifierError: If the name is empty, contains invalid 

157 characters, or exceeds the dialect's maximum length. 

158 """ 

159 

160 __slots__ = ("_dialect", "_name", "_quoted") 

161 

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) 

189 

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}"' 

196 

197 @property 

198 def name(self) -> str: 

199 """The raw, unquoted identifier name.""" 

200 return self._name 

201 

202 @property 

203 def quoted(self) -> str: 

204 """The dialect-quoted identifier, safe for SQL interpolation.""" 

205 return self._quoted 

206 

207 @property 

208 def dialect(self) -> SQLDialect: 

209 """The SQL dialect this identifier is quoted for.""" 

210 return self._dialect 

211 

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 

216 

217 def __str__(self) -> str: 

218 """Return the quoted identifier (safe for SQL interpolation).""" 

219 return self._quoted 

220 

221 def __repr__(self) -> str: 

222 return f"{self.__class__.__name__}({self._name!r})" 

223 

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 

232 

233 def __hash__(self) -> int: 

234 return hash((type(self).__name__, self._name, self._dialect)) 

235 

236 

237# --------------------------------------------------------------------------- 

238# Concrete identifier types 

239# --------------------------------------------------------------------------- 

240 

241 

242class Table(Identifier): 

243 """A validated SQL table name. 

244 

245 Example:: 

246 

247 t = Table("users") 

248 query = f"SELECT * FROM {t}" # SELECT * FROM "users" 

249 """ 

250 

251 

252class Column(Identifier): 

253 """A validated SQL column name. 

254 

255 Example:: 

256 

257 c = Column("email") 

258 query = f"SELECT {c} FROM users" # SELECT "email" FROM users 

259 """ 

260 

261 

262class Schema(Identifier): 

263 """A validated SQL schema name.""" 

264 

265 

266# --------------------------------------------------------------------------- 

267# Composite types 

268# --------------------------------------------------------------------------- 

269 

270 

271class QualifiedTable: 

272 """A schema-qualified table name: ``schema.table``. 

273 

274 Example:: 

275 

276 qt = QualifiedTable(Schema("public"), Table("users")) 

277 query = f"SELECT * FROM {qt}" # SELECT * FROM "public"."users" 

278 """ 

279 

280 __slots__ = ("_schema", "_table") 

281 

282 def __init__(self, schema: Schema, table: Table) -> None: 

283 self._schema = schema 

284 self._table = table 

285 

286 @property 

287 def schema(self) -> Schema: 

288 """The schema component.""" 

289 return self._schema 

290 

291 @property 

292 def table(self) -> Table: 

293 """The table component.""" 

294 return self._table 

295 

296 @property 

297 def quoted(self) -> str: 

298 """The fully-qualified, quoted identifier.""" 

299 return f"{self._schema.quoted}.{self._table.quoted}" 

300 

301 def __str__(self) -> str: 

302 return self.quoted 

303 

304 def __repr__(self) -> str: 

305 return f"QualifiedTable({self._schema!r}, {self._table!r})" 

306 

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 

311 

312 def __hash__(self) -> int: 

313 return hash((self._schema, self._table)) 

314 

315 

316# --------------------------------------------------------------------------- 

317# Cached factory functions 

318# --------------------------------------------------------------------------- 

319 

320 

321@lru_cache(maxsize=512) 

322def table(name: str, *, dialect: SQLDialect = SQLDialect.POSTGRESQL) -> Table: 

323 """Create a cached, validated :class:`Table` identifier. 

324 

325 Repeated calls with the same arguments return the **same object**. 

326 

327 Args: 

328 name: Raw table name. 

329 dialect: SQL dialect for quoting. 

330 

331 Returns: 

332 A validated, dialect-quoted :class:`Table` identifier. 

333 """ 

334 return Table(name, dialect=dialect) 

335 

336 

337@lru_cache(maxsize=1024) 

338def column(name: str, *, dialect: SQLDialect = SQLDialect.POSTGRESQL) -> Column: 

339 """Create a cached, validated :class:`Column` identifier. 

340 

341 Args: 

342 name: Raw column name. 

343 dialect: SQL dialect for quoting. 

344 

345 Returns: 

346 A validated, dialect-quoted :class:`Column` identifier. 

347 """ 

348 return Column(name, dialect=dialect) 

349 

350 

351@lru_cache(maxsize=64) 

352def schema(name: str, *, dialect: SQLDialect = SQLDialect.POSTGRESQL) -> Schema: 

353 """Create a cached, validated :class:`Schema` identifier. 

354 

355 Args: 

356 name: Raw schema name. 

357 dialect: SQL dialect for quoting. 

358 

359 Returns: 

360 A validated, dialect-quoted :class:`Schema` identifier. 

361 """ 

362 return Schema(name, dialect=dialect) 

363 

364 

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]