sqlalchemy-cubrid 0.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,515 @@
1
+ # sqlalchemy_cubrid/compiler.py
2
+ # Copyright (C) 2021-2026 by sqlalchemy-cubrid authors and contributors
3
+ # <see AUTHORS file>
4
+ #
5
+ # This module is part of sqlalchemy-cubrid and is released under
6
+ # the MIT License: http://www.opensource.org/licenses/mit-license.php
7
+
8
+ """CUBRID SQL, DDL, and type compilers for SQLAlchemy 2.0."""
9
+
10
+ from __future__ import annotations
11
+
12
+ from sqlalchemy.sql import compiler
13
+ from sqlalchemy.sql import sqltypes
14
+
15
+
16
+ class CubridCompiler(compiler.SQLCompiler):
17
+ """SQLCompiler subclass for CUBRID."""
18
+
19
+ def visit_sysdate_func(self, fn, **kw):
20
+ return "SYSDATE"
21
+
22
+ def visit_utc_timestamp_func(self, fn, **kw):
23
+ return "UTC_TIMESTAMP()"
24
+
25
+ def visit_group_concat_func(self, fn, **kw):
26
+ """Render GROUP_CONCAT aggregate function.
27
+
28
+ CUBRID supports GROUP_CONCAT([DISTINCT] expr [ORDER BY ...] [SEPARATOR '...'])
29
+ """
30
+ return "GROUP_CONCAT(%s)" % self.function_argspec(fn, **kw)
31
+
32
+ def visit_cast(self, cast, **kw):
33
+ # https://www.cubrid.org/manual/en/11.0/sql/function/typecast_fn.html#cast
34
+ type_ = self.process(cast.typeclause)
35
+ if type_ is None:
36
+ return self.process(cast.clause.self_group())
37
+ return f"CAST({self.process(cast.clause)} AS {type_})"
38
+
39
+ def render_literal_value(self, value, type_):
40
+ value = super().render_literal_value(value, type_)
41
+ value = value.replace("\\", "\\\\")
42
+ return value
43
+
44
+ def get_select_precolumns(self, select, **kw):
45
+ if bool(select._distinct):
46
+ return "DISTINCT "
47
+ return ""
48
+
49
+ def visit_join(self, join, asfrom=False, **kwargs):
50
+ # https://www.cubrid.org/manual/en/11.0/sql/query/select.html#join-query
51
+ return "".join(
52
+ (
53
+ self.process(join.left, asfrom=True, **kwargs),
54
+ (join.isouter and " LEFT OUTER JOIN " or " INNER JOIN "),
55
+ self.process(join.right, asfrom=True, **kwargs),
56
+ " ON ",
57
+ self.process(join.onclause, **kwargs),
58
+ )
59
+ )
60
+
61
+ def for_update_clause(self, select, **kw):
62
+ """Render FOR UPDATE clause.
63
+
64
+ CUBRID supports::
65
+
66
+ SELECT ... FOR UPDATE
67
+ SELECT ... FOR UPDATE OF col1, col2
68
+
69
+ CUBRID does NOT support NOWAIT or SKIP LOCKED.
70
+ """
71
+ if select._for_update_arg is None:
72
+ return ""
73
+ text = " FOR UPDATE"
74
+ if select._for_update_arg.of:
75
+ text += " OF " + ", ".join(self.process(col, **kw) for col in select._for_update_arg.of)
76
+ return text
77
+
78
+ def limit_clause(self, select, **kw):
79
+ # https://www.cubrid.org/manual/en/11.0/sql/query/select.html#limit-clause
80
+ # SA 2.0: _limit_clause / _offset_clause are ClauseElements, not raw ints.
81
+ limit_clause = select._limit_clause
82
+ offset_clause = select._offset_clause
83
+ if limit_clause is None and offset_clause is None:
84
+ return ""
85
+ elif limit_clause is None and offset_clause is not None:
86
+ return " \n LIMIT %s, 1073741823" % (self.process(offset_clause, **kw),)
87
+ elif offset_clause is not None:
88
+ return " \n LIMIT %s, %s" % (
89
+ self.process(offset_clause, **kw),
90
+ self.process(limit_clause, **kw),
91
+ )
92
+ else:
93
+ return " \n LIMIT %s" % (self.process(limit_clause, **kw),)
94
+
95
+ def update_limit_clause(self, update_stmt):
96
+ # https://www.cubrid.org/manual/en/11.0/sql/query/update.html
97
+ limit = update_stmt.kwargs.get(f"{self.dialect.name}_limit", None)
98
+ if limit:
99
+ return f"LIMIT {limit}"
100
+ return None
101
+
102
+ def update_tables_clause(self, update_stmt, from_table, extra_froms, **kw):
103
+ return ", ".join(
104
+ t._compiler_dispatch(self, asfrom=True, **kw) for t in [from_table] + list(extra_froms)
105
+ )
106
+
107
+ def update_from_clause(self, update_stmt, from_table, extra_froms, from_hints, **kw):
108
+ return None
109
+
110
+ def visit_on_duplicate_key_update(self, on_duplicate, **kw):
111
+ """Render ON DUPLICATE KEY UPDATE clause.
112
+
113
+ CUBRID uses VALUES() function to reference inserted values,
114
+ identical to MySQL's pre-8.0 syntax.
115
+ """
116
+ from sqlalchemy.sql import coercions, elements, roles, visitors
117
+ from sqlalchemy.sql.expression import literal_column
118
+
119
+ statement = self.current_executable
120
+ table = getattr(statement, "table", None)
121
+ if table is None:
122
+ return "ON DUPLICATE KEY UPDATE"
123
+
124
+ if on_duplicate._parameter_ordering:
125
+ parameter_ordering = [
126
+ coercions.expect(roles.DMLColumnRole, key)
127
+ for key in on_duplicate._parameter_ordering
128
+ ]
129
+ ordered_keys = set(parameter_ordering)
130
+ cols = [table.c[key] for key in parameter_ordering if key in table.c] + [
131
+ c for c in table.c if c.key not in ordered_keys
132
+ ]
133
+ else:
134
+ cols = list(table.c)
135
+
136
+ clauses = []
137
+ on_duplicate_update = {
138
+ coercions.expect_as_key(roles.DMLColumnRole, key): value
139
+ for key, value in on_duplicate.update.items()
140
+ }
141
+
142
+ for column in (col for col in cols if col.key in on_duplicate_update):
143
+ val = on_duplicate_update[column.key]
144
+ if coercions._is_literal(val):
145
+ val = elements.BindParameter(None, val, type_=column.type)
146
+ value_text = self.process(val.self_group(), use_schema=False)
147
+ else:
148
+
149
+ def replace(element, captured_column=column, **kw):
150
+ if isinstance(element, elements.BindParameter) and element.type._isnull:
151
+ return element._with_binary_element_type(captured_column.type)
152
+ elif (
153
+ isinstance(element, elements.ColumnClause)
154
+ and element.table is on_duplicate.inserted_alias
155
+ ):
156
+ return literal_column(f"VALUES({self.preparer.quote(element.name)})")
157
+ else:
158
+ return None
159
+
160
+ val = visitors.replacement_traverse(val, {}, replace)
161
+ value_text = self.process(val.self_group(), use_schema=False)
162
+
163
+ name_text = self.preparer.quote(column.name)
164
+ clauses.append(f"{name_text} = {value_text}")
165
+
166
+ non_matching = set(on_duplicate_update) - {c.key for c in cols}
167
+ if non_matching:
168
+ from sqlalchemy import util
169
+
170
+ table_name = getattr(table, "name", "<unknown>")
171
+ util.warn(
172
+ "Additional column names not matching "
173
+ "any column keys in table '%s': %s"
174
+ % (
175
+ table_name,
176
+ (", ".join("'%s'" % c for c in non_matching)),
177
+ )
178
+ )
179
+
180
+ return f"ON DUPLICATE KEY UPDATE {', '.join(clauses)}"
181
+
182
+ def visit_merge(self, merge_stmt, **kw):
183
+ from sqlalchemy import exc
184
+ from sqlalchemy.sql import coercions, elements
185
+
186
+ target = merge_stmt._target
187
+ source = merge_stmt._using_source
188
+ on_condition = merge_stmt._on_condition
189
+ when_matched = merge_stmt._when_matched
190
+ when_not_matched = merge_stmt._when_not_matched
191
+
192
+ if target is None:
193
+ raise exc.CompileError("MERGE statement requires a target table")
194
+ if source is None:
195
+ raise exc.CompileError("MERGE statement requires a USING source")
196
+ if on_condition is None:
197
+ raise exc.CompileError("MERGE statement requires an ON condition")
198
+ if when_matched is None and when_not_matched is None:
199
+ raise exc.CompileError(
200
+ "MERGE statement must include WHEN MATCHED and/or WHEN NOT MATCHED"
201
+ )
202
+
203
+ target_columns = getattr(target, "c", None)
204
+
205
+ def _resolve_target_column(column_key):
206
+ if isinstance(column_key, str):
207
+ if target_columns is not None and column_key in target_columns:
208
+ return target_columns[column_key]
209
+ return None
210
+ if hasattr(column_key, "name"):
211
+ return column_key
212
+ return None
213
+
214
+ def _render_column_name(column_key):
215
+ if isinstance(column_key, str):
216
+ return self.preparer.quote(column_key)
217
+ if hasattr(column_key, "name"):
218
+ return self.preparer.quote(column_key.name)
219
+ return self.process(column_key, **kw)
220
+
221
+ def _render_value(value, target_column):
222
+ if coercions._is_literal(value):
223
+ value = elements.BindParameter(
224
+ None,
225
+ value,
226
+ type_=getattr(target_column, "type", None),
227
+ )
228
+ return self.process(value.self_group(), use_schema=False, **kw)
229
+
230
+ lines = [
231
+ f"MERGE INTO {self.process(target, asfrom=True, **kw)}",
232
+ f"USING {self.process(source, asfrom=True, **kw)}",
233
+ f"ON ({self.process(on_condition, **kw)})",
234
+ ]
235
+
236
+ if when_matched is not None:
237
+ matched_values = when_matched.get("values") or {}
238
+ if not matched_values:
239
+ raise exc.CompileError(
240
+ "MERGE WHEN MATCHED clause requires at least one UPDATE value"
241
+ )
242
+
243
+ set_clauses = []
244
+ for column_key, value in matched_values.items():
245
+ target_column = _resolve_target_column(column_key)
246
+ set_clauses.append(
247
+ f"{_render_column_name(column_key)} = {_render_value(value, target_column)}"
248
+ )
249
+
250
+ matched_clause = f"WHEN MATCHED THEN UPDATE SET {', '.join(set_clauses)}" # nosec B608
251
+ matched_where = when_matched.get("where")
252
+ if matched_where is not None:
253
+ matched_clause += f" WHERE {self.process(matched_where, **kw)}"
254
+
255
+ delete_where = when_matched.get("delete_where")
256
+ if delete_where is not None:
257
+ matched_clause += f" DELETE WHERE {self.process(delete_where, **kw)}"
258
+
259
+ lines.append(matched_clause)
260
+
261
+ if when_not_matched is not None:
262
+ insert_columns = when_not_matched.get("columns") or []
263
+ insert_values = when_not_matched.get("values") or []
264
+
265
+ if not insert_columns or not insert_values:
266
+ raise exc.CompileError(
267
+ "MERGE WHEN NOT MATCHED clause requires INSERT columns and values"
268
+ )
269
+ if len(insert_columns) != len(insert_values):
270
+ raise exc.CompileError(
271
+ "MERGE WHEN NOT MATCHED INSERT columns and values must match"
272
+ )
273
+
274
+ rendered_columns = []
275
+ rendered_values = []
276
+ for column_key, value in zip(insert_columns, insert_values):
277
+ target_column = _resolve_target_column(column_key)
278
+ rendered_columns.append(_render_column_name(column_key))
279
+ rendered_values.append(_render_value(value, target_column))
280
+
281
+ not_matched_clause = (
282
+ "WHEN NOT MATCHED THEN INSERT "
283
+ f"({', '.join(rendered_columns)}) "
284
+ f"VALUES ({', '.join(rendered_values)})"
285
+ )
286
+ insert_where = when_not_matched.get("where")
287
+ if insert_where is not None:
288
+ not_matched_clause += f" WHERE {self.process(insert_where, **kw)}"
289
+
290
+ lines.append(not_matched_clause)
291
+
292
+ return "\n".join(lines)
293
+
294
+ def visit_replace(self, replace_stmt, **kw):
295
+ text = super().visit_insert(replace_stmt, **kw)
296
+ if "INSERT INTO" in text:
297
+ return text.replace("INSERT INTO", "REPLACE INTO", 1)
298
+ if text.startswith("INSERT"):
299
+ return "REPLACE" + text[len("INSERT") :]
300
+ return text
301
+
302
+
303
+ class CubridDDLCompiler(compiler.DDLCompiler):
304
+ """DDLCompiler subclass for CUBRID.
305
+
306
+ Handles AUTO_INCREMENT for autoincrement columns and column defaults.
307
+ """
308
+
309
+ def get_column_specification(self, column, **kw):
310
+ """Build column DDL specification.
311
+
312
+ CUBRID syntax::
313
+
314
+ column_name TYPE [NOT NULL] [AUTO_INCREMENT] [DEFAULT value]
315
+ """
316
+ colspec = [
317
+ self.preparer.format_column(column),
318
+ self.dialect.type_compiler_instance.process(column.type, type_expression=column),
319
+ ]
320
+
321
+ if not column.nullable:
322
+ colspec.append("NOT NULL")
323
+
324
+ if (
325
+ column.table is not None
326
+ and column is column.table._autoincrement_column
327
+ and (column.server_default is None)
328
+ ):
329
+ colspec.append("AUTO_INCREMENT")
330
+ else:
331
+ default = self.get_column_default_string(column)
332
+ if default is not None:
333
+ colspec.append("DEFAULT " + default)
334
+
335
+ if column.comment is not None:
336
+ literal = self.sql_compiler.render_literal_value(
337
+ column.comment,
338
+ sqltypes.String(),
339
+ )
340
+ colspec.append("COMMENT " + literal)
341
+
342
+ return " ".join(colspec)
343
+
344
+ def post_create_table(self, table):
345
+ table_opts = []
346
+ if table.comment is not None:
347
+ literal = self.sql_compiler.render_literal_value(
348
+ table.comment,
349
+ sqltypes.String(),
350
+ )
351
+ table_opts.append(f"\n COMMENT = {literal}")
352
+ return "".join(table_opts)
353
+
354
+ def visit_set_table_comment(self, create, **kw):
355
+ return "ALTER TABLE %s COMMENT = %s" % (
356
+ self.preparer.format_table(create.element),
357
+ self.sql_compiler.render_literal_value(
358
+ create.element.comment,
359
+ sqltypes.String(),
360
+ ),
361
+ )
362
+
363
+ def visit_drop_table_comment(self, drop, **kw):
364
+ return "ALTER TABLE %s COMMENT = ''" % (self.preparer.format_table(drop.element),)
365
+
366
+ def visit_set_column_comment(self, create, **kw):
367
+ return "ALTER TABLE %s MODIFY %s %s COMMENT %s" % (
368
+ self.preparer.format_table(create.element.table),
369
+ self.preparer.format_column(create.element),
370
+ self.dialect.type_compiler_instance.process(
371
+ create.element.type,
372
+ type_expression=create.element,
373
+ ),
374
+ self.sql_compiler.render_literal_value(
375
+ create.element.comment,
376
+ sqltypes.String(),
377
+ ),
378
+ )
379
+
380
+
381
+ class CubridTypeCompiler(compiler.GenericTypeCompiler):
382
+ """TypeCompiler for CUBRID data types."""
383
+
384
+ def _get(self, key, type_, kw):
385
+ return kw.get(key, getattr(type_, key, None))
386
+
387
+ def visit_BOOLEAN(self, type_, **kw):
388
+ # CUBRID has no native BOOLEAN; map to SMALLINT.
389
+ return self.visit_SMALLINT(type_)
390
+
391
+ def visit_NUMERIC(self, type_, **kw):
392
+ if type_.precision is None:
393
+ return "NUMERIC"
394
+ elif type_.scale is None:
395
+ return f"NUMERIC({type_.precision})"
396
+ else:
397
+ return f"NUMERIC({type_.precision}, {type_.scale})"
398
+
399
+ def visit_DECIMAL(self, type_, **kw):
400
+ if type_.precision is None:
401
+ return "DECIMAL"
402
+ elif type_.scale is None:
403
+ return f"DECIMAL({type_.precision})"
404
+ else:
405
+ return f"DECIMAL({type_.precision}, {type_.scale})"
406
+
407
+ def visit_FLOAT(self, type_, **kw):
408
+ if type_.precision is None:
409
+ return "FLOAT"
410
+ else:
411
+ return f"FLOAT({type_.precision})"
412
+
413
+ def visit_DOUBLE(self, type_, **kw):
414
+ return "DOUBLE"
415
+
416
+ def visit_MONETARY(self, type_, **kw):
417
+ return "MONETARY"
418
+
419
+ def visit_SMALLINT(self, type_, **kw):
420
+ return "SMALLINT"
421
+
422
+ def visit_BIGINT(self, type_, **kw):
423
+ return "BIGINT"
424
+
425
+ def visit_BIT(self, type_, **kw):
426
+ if type_.varying:
427
+ compiled = "BIT VARYING"
428
+ if type_.length is not None:
429
+ compiled += f"({type_.length})"
430
+ else:
431
+ compiled = f"BIT({type_.length})"
432
+ return compiled
433
+
434
+ def visit_datetime(self, type_, **kw):
435
+ return "DATETIME"
436
+
437
+ def visit_DATETIME(self, type_, **kw):
438
+ return "DATETIME"
439
+
440
+ def visit_DATE(self, type_, **kw):
441
+ return "DATE"
442
+
443
+ def visit_TIME(self, type_, **kw):
444
+ return "TIME"
445
+
446
+ def visit_TIMESTAMP(self, type_, **kw):
447
+ return "TIMESTAMP"
448
+
449
+ def visit_VARCHAR(self, type_, **kw):
450
+ if hasattr(type_, "national") and type_.national:
451
+ return self.visit_NVARCHAR(type_)
452
+ elif type_.length:
453
+ return "VARCHAR(%d)" % type_.length
454
+ else:
455
+ return "VARCHAR(4096)"
456
+
457
+ def visit_CHAR(self, type_, **kw):
458
+ if hasattr(type_, "national") and type_.national:
459
+ return self.visit_NCHAR(type_)
460
+ elif type_.length:
461
+ return f"CHAR({type_.length})"
462
+ else:
463
+ return "CHAR"
464
+
465
+ def visit_NVARCHAR(self, type_, **kw):
466
+ if type_.length:
467
+ return f"NCHAR VARYING({type_.length})"
468
+ else:
469
+ return "NCHAR VARYING(4096)"
470
+
471
+ def visit_NCHAR(self, type_, **kw):
472
+ if type_.length:
473
+ return f"NCHAR({type_.length})"
474
+ else:
475
+ return "NCHAR"
476
+
477
+ def visit_OBJECT(self, type_, **kw):
478
+ return "OBJECT"
479
+
480
+ def visit_large_binary(self, type_, **kw):
481
+ return self.visit_BLOB(type_)
482
+
483
+ def visit_text(self, type_, **kw):
484
+ return self.visit_STRING(type_)
485
+
486
+ def visit_BLOB(self, type_, **kw):
487
+ return "BLOB"
488
+
489
+ def visit_CLOB(self, type_, **kw):
490
+ return "CLOB"
491
+
492
+ def visit_STRING(self, type_, **kw):
493
+ return "STRING"
494
+
495
+ def visit_SET(self, type_, **kw):
496
+ return self._visit_collection(type_, "SET")
497
+
498
+ def visit_MULTISET(self, type_, **kw):
499
+ return self._visit_collection(type_, "MULTISET")
500
+
501
+ def visit_SEQUENCE(self, type_, **kw):
502
+ return self._visit_collection(type_, "SEQUENCE")
503
+
504
+ def _visit_collection(self, type_, collection_type, **kw):
505
+ """Compile CUBRID collection types (SET, MULTISET, LIST/SEQUENCE).
506
+
507
+ See: https://www.cubrid.org/manual/en/11.0/sql/datatype.html#collection-types
508
+ """
509
+ parts = []
510
+ for value in type_._ddl_values:
511
+ if isinstance(value, str):
512
+ parts.append(value)
513
+ else:
514
+ parts.append(value.__visit_name__)
515
+ return f"{collection_type}({','.join(parts)})"