sqlalchemy-cubrid 1.1.0__tar.gz → 1.2.0__tar.gz

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.
Files changed (37) hide show
  1. {sqlalchemy_cubrid-1.1.0/sqlalchemy_cubrid.egg-info → sqlalchemy_cubrid-1.2.0}/PKG-INFO +2 -2
  2. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/README.md +1 -1
  3. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/pyproject.toml +1 -1
  4. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/sqlalchemy_cubrid/__init__.py +7 -1
  5. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/sqlalchemy_cubrid/aio_pycubrid_dialect.py +1 -2
  6. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/sqlalchemy_cubrid/compiler.py +54 -1
  7. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/sqlalchemy_cubrid/dialect.py +8 -0
  8. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/sqlalchemy_cubrid/types.py +89 -0
  9. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0/sqlalchemy_cubrid.egg-info}/PKG-INFO +2 -2
  10. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/sqlalchemy_cubrid.egg-info/SOURCES.txt +1 -0
  11. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/test/test_aio_pycubrid_dialect.py +0 -2
  12. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/test/test_integration.py +6 -9
  13. sqlalchemy_cubrid-1.2.0/test/test_json.py +312 -0
  14. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/AUTHORS +0 -0
  15. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/LICENSE +0 -0
  16. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/setup.cfg +0 -0
  17. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/sqlalchemy_cubrid/alembic_impl.py +0 -0
  18. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/sqlalchemy_cubrid/base.py +0 -0
  19. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/sqlalchemy_cubrid/dml.py +0 -0
  20. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/sqlalchemy_cubrid/py.typed +0 -0
  21. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/sqlalchemy_cubrid/pycubrid_dialect.py +0 -0
  22. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/sqlalchemy_cubrid/requirements.py +0 -0
  23. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/sqlalchemy_cubrid/trace.py +0 -0
  24. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/sqlalchemy_cubrid.egg-info/dependency_links.txt +0 -0
  25. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/sqlalchemy_cubrid.egg-info/entry_points.txt +0 -0
  26. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/sqlalchemy_cubrid.egg-info/requires.txt +0 -0
  27. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/sqlalchemy_cubrid.egg-info/top_level.txt +0 -0
  28. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/test/test_alembic.py +0 -0
  29. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/test/test_base.py +0 -0
  30. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/test/test_compiler.py +0 -0
  31. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/test/test_dialect_offline.py +0 -0
  32. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/test/test_dialects.py +0 -0
  33. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/test/test_pycubrid_dialect.py +0 -0
  34. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/test/test_requirements.py +0 -0
  35. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/test/test_suite.py +0 -0
  36. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/test/test_trace.py +0 -0
  37. {sqlalchemy_cubrid-1.1.0 → sqlalchemy_cubrid-1.2.0}/test/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlalchemy-cubrid
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: CUBRID dialect for SQLAlchemy
5
5
  Author-email: Yeongseon Choe <yeongseon.choe@gmail.com>, Gyeongjun Paik <paikend@gmail.com>
6
6
  License-Expression: MIT
@@ -180,7 +180,7 @@ async with AsyncSession(engine) as session:
180
180
 
181
181
  ## Features
182
182
 
183
- - Complete type system -- numeric, string, date/time, bit, LOB, and collection types
183
+ - Complete type system -- numeric, string, date/time, bit, LOB, collection, and JSON types
184
184
  - SQL compilation -- SELECT, JOIN, CAST, LIMIT/OFFSET, subqueries, CTEs, window functions
185
185
  - DML extensions -- `ON DUPLICATE KEY UPDATE`, `MERGE`, `REPLACE INTO`, `FOR UPDATE`, `TRUNCATE`
186
186
  - DDL support -- `COMMENT`, `IF NOT EXISTS` / `IF EXISTS`, `AUTO_INCREMENT`
@@ -131,7 +131,7 @@ async with AsyncSession(engine) as session:
131
131
 
132
132
  ## Features
133
133
 
134
- - Complete type system -- numeric, string, date/time, bit, LOB, and collection types
134
+ - Complete type system -- numeric, string, date/time, bit, LOB, collection, and JSON types
135
135
  - SQL compilation -- SELECT, JOIN, CAST, LIMIT/OFFSET, subqueries, CTEs, window functions
136
136
  - DML extensions -- `ON DUPLICATE KEY UPDATE`, `MERGE`, `REPLACE INTO`, `FOR UPDATE`, `TRUNCATE`
137
137
  - DDL support -- `COMMENT`, `IF NOT EXISTS` / `IF EXISTS`, `AUTO_INCREMENT`
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sqlalchemy-cubrid"
7
- version = "1.1.0"
7
+ version = "1.2.0"
8
8
  description = "CUBRID dialect for SQLAlchemy"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -25,6 +25,9 @@ from .types import (
25
25
  DOUBLE,
26
26
  DOUBLE_PRECISION,
27
27
  FLOAT,
28
+ JSON,
29
+ JSONIndexType,
30
+ JSONPathType,
28
31
  MONETARY,
29
32
  MULTISET,
30
33
  NCHAR,
@@ -49,7 +52,7 @@ from sqlalchemy.sql.sqltypes import (
49
52
  TIMESTAMP,
50
53
  )
51
54
 
52
- __version__ = "1.0.0"
55
+ __version__ = "1.2.0"
53
56
 
54
57
  __all__ = (
55
58
  "insert",
@@ -82,4 +85,7 @@ __all__ = (
82
85
  "MULTISET",
83
86
  "SEQUENCE",
84
87
  "OBJECT",
88
+ "JSON",
89
+ "JSONIndexType",
90
+ "JSONPathType",
85
91
  )
@@ -7,8 +7,7 @@
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- import collections
11
- from typing import Any, Callable, Optional, cast
10
+ from typing import Any, Callable, cast
12
11
 
13
12
  from sqlalchemy.connectors.asyncio import (
14
13
  AsyncAdapt_dbapi_connection,
@@ -11,7 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  from typing import Any
13
13
 
14
- from sqlalchemy.sql import compiler
14
+ from sqlalchemy.sql import compiler, elements
15
15
  from sqlalchemy.sql import sqltypes
16
16
 
17
17
 
@@ -320,6 +320,56 @@ class CubridCompiler(compiler.SQLCompiler):
320
320
  return "REPLACE" + text[len("INSERT") :]
321
321
  return text
322
322
 
323
+ def _render_json_extract_from_binary(
324
+ self, binary: elements.BinaryExpression[Any], operator: Any, **kw: Any
325
+ ) -> str:
326
+ if binary.type._type_affinity is sqltypes.JSON:
327
+ return "JSON_EXTRACT(%s, %s)" % (
328
+ self.process(binary.left, **kw),
329
+ self.process(binary.right, **kw),
330
+ )
331
+
332
+ # When the target type is not JSON, convert JSON 'null' to SQL NULL
333
+ case_expression = "CASE JSON_EXTRACT(%s, %s) WHEN 'null' THEN NULL" % (
334
+ self.process(binary.left, **kw),
335
+ self.process(binary.right, **kw),
336
+ )
337
+
338
+ if binary.type._type_affinity is sqltypes.Boolean:
339
+ type_expression = (
340
+ "WHEN 'true' THEN 1 WHEN 'false' THEN 0 ELSE CAST(JSON_EXTRACT(%s, %s) AS INTEGER)"
341
+ ) % (
342
+ self.process(binary.left, **kw),
343
+ self.process(binary.right, **kw),
344
+ )
345
+ elif binary.type._type_affinity is sqltypes.Integer:
346
+ type_expression = "ELSE CAST(JSON_EXTRACT(%s, %s) AS INTEGER)" % (
347
+ self.process(binary.left, **kw),
348
+ self.process(binary.right, **kw),
349
+ )
350
+ elif binary.type._type_affinity is sqltypes.Numeric:
351
+ type_expression = "ELSE CAST(JSON_EXTRACT(%s, %s) AS DOUBLE)" % (
352
+ self.process(binary.left, **kw),
353
+ self.process(binary.right, **kw),
354
+ )
355
+ else:
356
+ type_expression = "ELSE JSON_UNQUOTE(JSON_EXTRACT(%s, %s))" % (
357
+ self.process(binary.left, **kw),
358
+ self.process(binary.right, **kw),
359
+ )
360
+
361
+ return case_expression + " " + type_expression + " END"
362
+
363
+ def visit_json_getitem_op_binary(
364
+ self, binary: elements.BinaryExpression[Any], operator: Any, **kw: Any
365
+ ) -> str:
366
+ return self._render_json_extract_from_binary(binary, operator, **kw)
367
+
368
+ def visit_json_path_getitem_op_binary(
369
+ self, binary: elements.BinaryExpression[Any], operator: Any, **kw: Any
370
+ ) -> str:
371
+ return self._render_json_extract_from_binary(binary, operator, **kw)
372
+
323
373
 
324
374
  class CubridDDLCompiler(compiler.DDLCompiler):
325
375
  """DDLCompiler subclass for CUBRID.
@@ -534,3 +584,6 @@ class CubridTypeCompiler(compiler.GenericTypeCompiler):
534
584
  else:
535
585
  parts.append(value.__visit_name__)
536
586
  return f"{collection_type}({','.join(parts)})"
587
+
588
+ def visit_JSON(self, type_: Any, **kw: Any) -> str:
589
+ return "JSON"
@@ -48,6 +48,9 @@ from sqlalchemy_cubrid.types import (
48
48
  DOUBLE,
49
49
  DOUBLE_PRECISION,
50
50
  FLOAT,
51
+ JSON,
52
+ JSONIndexType,
53
+ JSONPathType,
51
54
  MULTISET,
52
55
  NCHAR,
53
56
  NUMERIC,
@@ -84,6 +87,9 @@ colspecs = {
84
87
  sqltypes.Numeric: NUMERIC,
85
88
  sqltypes.Float: FLOAT,
86
89
  sqltypes.Time: TIME,
90
+ sqltypes.JSON: JSON,
91
+ sqltypes.JSON.JSONIndexType: JSONIndexType,
92
+ sqltypes.JSON.JSONPathType: JSONPathType,
87
93
  }
88
94
 
89
95
  # ischema_names maps CUBRID type names from SHOW COLUMNS to SA types.
@@ -120,6 +126,8 @@ ischema_names = {
120
126
  "SET": SET,
121
127
  "MULTISET": MULTISET,
122
128
  "SEQUENCE": SEQUENCE,
129
+ # JSON (CUBRID 10.2+)
130
+ "JSON": JSON,
123
131
  }
124
132
 
125
133
 
@@ -354,3 +354,92 @@ class OBJECT(sqltypes.TypeEngine[Any]):
354
354
  """
355
355
 
356
356
  __visit_name__ = "OBJECT"
357
+
358
+
359
+ # ---------------------------------------------------------------------------
360
+ # JSON Type (CUBRID 10.2+)
361
+ # ---------------------------------------------------------------------------
362
+
363
+
364
+ class _FormatTypeMixin:
365
+ """Mixin for formatting JSON path index/path values for bind parameters."""
366
+
367
+ def _format_value(self, value: Any) -> str:
368
+ raise NotImplementedError()
369
+
370
+ def bind_processor(self, dialect: Any) -> Any:
371
+ super_proc = self.string_bind_processor(dialect) # type: ignore[attr-defined] # noqa: E501
372
+
373
+ def process(value: Any) -> Any:
374
+ value = self._format_value(value)
375
+ if super_proc:
376
+ value = super_proc(value)
377
+ return value
378
+
379
+ return process
380
+
381
+ def literal_processor(self, dialect: Any) -> Any:
382
+ super_proc = self.string_literal_processor(dialect) # type: ignore[attr-defined] # noqa: E501
383
+
384
+ def process(value: Any) -> str:
385
+ value = self._format_value(value)
386
+ if super_proc:
387
+ value = super_proc(value)
388
+ return value
389
+
390
+ return process
391
+
392
+
393
+ class JSONIndexType(_FormatTypeMixin, sqltypes.JSON.JSONIndexType):
394
+ """CUBRID JSON index type for single-key access.
395
+
396
+ Converts Python index values to CUBRID JSON path syntax:
397
+ - Integer index: ``$[0]``
398
+ - String key: ``$."key"``
399
+ """
400
+
401
+ def _format_value(self, value: Any) -> str:
402
+ if isinstance(value, int):
403
+ return "$[%s]" % value
404
+ else:
405
+ return '$."%s"' % str(value).replace('"', '""')
406
+
407
+
408
+ class JSONPathType(_FormatTypeMixin, sqltypes.JSON.JSONPathType):
409
+ """CUBRID JSON path type for multi-level access.
410
+
411
+ Converts Python tuple paths to CUBRID JSON path syntax:
412
+ - ``("a", 1, "b")`` → ``$."a"[1]."b"``
413
+ """
414
+
415
+ def _format_value(self, value: Any) -> str:
416
+ return "$%s" % (
417
+ "".join(
418
+ "[%s]" % elem if isinstance(elem, int) else '."%s"' % str(elem).replace('"', '""')
419
+ for elem in value
420
+ )
421
+ )
422
+
423
+
424
+ class JSON(sqltypes.JSON): # type: ignore[type-arg]
425
+ """CUBRID JSON type.
426
+
427
+ CUBRID supports JSON as of version 10.2 (RFC 7159 compliant).
428
+
429
+ :class:`_cubrid.JSON` is used automatically whenever the base
430
+ :class:`_types.JSON` datatype is used against a CUBRID backend.
431
+
432
+ The :class:`.cubrid.JSON` type supports persistence of JSON values
433
+ as well as the core index operations provided by :class:`_types.JSON`
434
+ datatype, by adapting the operations to render the ``JSON_EXTRACT``
435
+ function at the database level.
436
+
437
+ .. seealso::
438
+
439
+ :class:`_types.JSON` - main documentation for the generic
440
+ cross-platform JSON datatype.
441
+
442
+ .. versionadded:: 1.2.0
443
+ """
444
+
445
+ __visit_name__ = "JSON"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlalchemy-cubrid
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: CUBRID dialect for SQLAlchemy
5
5
  Author-email: Yeongseon Choe <yeongseon.choe@gmail.com>, Gyeongjun Paik <paikend@gmail.com>
6
6
  License-Expression: MIT
@@ -180,7 +180,7 @@ async with AsyncSession(engine) as session:
180
180
 
181
181
  ## Features
182
182
 
183
- - Complete type system -- numeric, string, date/time, bit, LOB, and collection types
183
+ - Complete type system -- numeric, string, date/time, bit, LOB, collection, and JSON types
184
184
  - SQL compilation -- SELECT, JOIN, CAST, LIMIT/OFFSET, subqueries, CTEs, window functions
185
185
  - DML extensions -- `ON DUPLICATE KEY UPDATE`, `MERGE`, `REPLACE INTO`, `FOR UPDATE`, `TRUNCATE`
186
186
  - DDL support -- `COMMENT`, `IF NOT EXISTS` / `IF EXISTS`, `AUTO_INCREMENT`
@@ -27,6 +27,7 @@ test/test_compiler.py
27
27
  test/test_dialect_offline.py
28
28
  test/test_dialects.py
29
29
  test/test_integration.py
30
+ test/test_json.py
30
31
  test/test_pycubrid_dialect.py
31
32
  test/test_requirements.py
32
33
  test/test_suite.py
@@ -2,10 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import sys
4
4
  import types
5
- from typing import Any
6
5
  from unittest.mock import AsyncMock, MagicMock, patch
7
6
 
8
- import pytest
9
7
  from sqlalchemy.engine import url
10
8
 
11
9
  from sqlalchemy_cubrid.aio_pycubrid_dialect import (
@@ -36,7 +36,7 @@ from sqlalchemy import (
36
36
  select,
37
37
  text,
38
38
  )
39
- from sqlalchemy.orm import Session
39
+ from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
40
40
 
41
41
  # ---------------------------------------------------------------------------
42
42
  # Fixtures
@@ -587,7 +587,6 @@ class TestFetchShapeCompatibility:
587
587
  execute successfully against a live CUBRID instance, confirming
588
588
  the dialect's use_insertmanyvalues=True is honored end-to-end.
589
589
  """
590
- from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
591
590
 
592
591
  class _Base(DeclarativeBase):
593
592
  pass
@@ -595,22 +594,20 @@ class TestFetchShapeCompatibility:
595
594
  class BulkUser(_Base):
596
595
  __tablename__ = "imv_bulk_test"
597
596
  id: Mapped[int] = mapped_column(
598
- Integer, primary_key=True, autoincrement=True,
597
+ Integer,
598
+ primary_key=True,
599
+ autoincrement=True,
599
600
  )
600
601
  name: Mapped[str] = mapped_column(String(100))
601
602
 
602
603
  _Base.metadata.create_all(engine)
603
604
  try:
604
605
  with Session(engine) as session:
605
- session.add_all(
606
- [BulkUser(name=f"user_{i}") for i in range(10)]
607
- )
606
+ session.add_all([BulkUser(name=f"user_{i}") for i in range(10)])
608
607
  session.commit()
609
608
 
610
609
  with engine.connect() as conn:
611
- count = conn.execute(
612
- text("SELECT COUNT(*) FROM imv_bulk_test")
613
- ).scalar()
610
+ count = conn.execute(text("SELECT COUNT(*) FROM imv_bulk_test")).scalar()
614
611
  assert count == 10
615
612
  finally:
616
613
  _Base.metadata.drop_all(engine)
@@ -0,0 +1,312 @@
1
+ # test/test_json.py
2
+ from __future__ import annotations
3
+
4
+
5
+ import pytest
6
+ import sqlalchemy as sa
7
+ from sqlalchemy import Column, Integer, MetaData, String, Table, func, select
8
+
9
+ from sqlalchemy_cubrid.dialect import CubridDialect
10
+ from sqlalchemy_cubrid.types import JSON, JSONIndexType, JSONPathType
11
+
12
+
13
+ def _compile(stmt, dialect=None):
14
+ if dialect is None:
15
+ dialect = CubridDialect()
16
+ return stmt.compile(dialect=dialect, compile_kwargs={"literal_binds": True}).string
17
+
18
+
19
+ metadata = MetaData()
20
+ json_table = Table(
21
+ "json_test",
22
+ metadata,
23
+ Column("id", Integer, primary_key=True),
24
+ Column("data", JSON),
25
+ Column("name", String(100)),
26
+ )
27
+
28
+
29
+ class TestJSONType:
30
+ def test_visit_name(self):
31
+ assert JSON.__visit_name__ == "JSON"
32
+
33
+ def test_is_subclass_of_sa_json(self):
34
+ from sqlalchemy.sql import sqltypes
35
+
36
+ assert issubclass(JSON, sqltypes.JSON)
37
+
38
+ def test_instantiation(self):
39
+ t = JSON()
40
+ assert t is not None
41
+
42
+ def test_none_as_null_parameter(self):
43
+ t = JSON(none_as_null=True)
44
+ assert t.none_as_null is True
45
+
46
+
47
+ class TestJSONDDLCompilation:
48
+ def test_json_column_ddl(self):
49
+ dialect = CubridDialect()
50
+ compiled = dialect.type_compiler_instance.process(JSON())
51
+ assert compiled == "JSON"
52
+
53
+ def test_generic_sa_json_compiles_to_json(self):
54
+ tbl = Table(
55
+ "ddl_test",
56
+ MetaData(),
57
+ Column("id", Integer, primary_key=True),
58
+ Column("payload", sa.JSON),
59
+ )
60
+ dialect = CubridDialect()
61
+ create_ddl = sa.schema.CreateTable(tbl).compile(dialect=dialect).string
62
+ assert "JSON" in create_ddl
63
+
64
+ def test_cubrid_json_compiles_to_json(self):
65
+ tbl = Table(
66
+ "ddl_test2",
67
+ MetaData(),
68
+ Column("id", Integer, primary_key=True),
69
+ Column("payload", JSON),
70
+ )
71
+ dialect = CubridDialect()
72
+ create_ddl = sa.schema.CreateTable(tbl).compile(dialect=dialect).string
73
+ assert "JSON" in create_ddl
74
+
75
+
76
+ class TestJSONIndexType:
77
+ def test_format_integer_index(self):
78
+ idx = JSONIndexType()
79
+ assert idx._format_value(0) == "$[0]"
80
+ assert idx._format_value(3) == "$[3]"
81
+
82
+ def test_format_string_key(self):
83
+ idx = JSONIndexType()
84
+ assert idx._format_value("name") == '$."name"'
85
+ assert idx._format_value("address") == '$."address"'
86
+
87
+
88
+ class TestJSONPathType:
89
+ def test_format_simple_path(self):
90
+ pth = JSONPathType()
91
+ assert pth._format_value(("a",)) == '$."a"'
92
+
93
+ def test_format_nested_path(self):
94
+ pth = JSONPathType()
95
+ assert pth._format_value(("a", "b", "c")) == '$."a"."b"."c"'
96
+
97
+ def test_format_mixed_path(self):
98
+ pth = JSONPathType()
99
+ assert pth._format_value(("a", 1, "b")) == '$."a"[1]."b"'
100
+
101
+ def test_format_array_only_path(self):
102
+ pth = JSONPathType()
103
+ assert pth._format_value((0, 1, 2)) == "$[0][1][2]"
104
+
105
+
106
+ class TestJSONPathExpressionCompilation:
107
+ def test_json_getitem_string_key(self):
108
+ stmt = select(json_table.c.data["name"])
109
+ sql = _compile(stmt)
110
+ assert "JSON_EXTRACT" in sql
111
+
112
+ def test_json_getitem_integer_index(self):
113
+ stmt = select(json_table.c.data[0])
114
+ sql = _compile(stmt)
115
+ assert "JSON_EXTRACT" in sql
116
+
117
+ def test_json_path_getitem(self):
118
+ stmt = select(json_table.c.data[("a", "b")])
119
+ sql = _compile(stmt)
120
+ assert "JSON_EXTRACT" in sql
121
+
122
+ def test_json_getitem_in_where(self):
123
+ stmt = select(json_table).where(json_table.c.data["status"].as_string() == "active")
124
+ sql = _compile(stmt)
125
+ assert "JSON_EXTRACT" in sql
126
+ assert "JSON_UNQUOTE" in sql
127
+
128
+ def test_json_getitem_as_integer(self):
129
+ stmt = select(json_table).where(json_table.c.data["count"].as_integer() > 5)
130
+ sql = _compile(stmt)
131
+ assert "JSON_EXTRACT" in sql
132
+ assert "CAST" in sql
133
+ assert "INTEGER" in sql
134
+
135
+ def test_json_getitem_as_float(self):
136
+ stmt = select(json_table).where(json_table.c.data["score"].as_float() > 3.5)
137
+ sql = _compile(stmt)
138
+ assert "JSON_EXTRACT" in sql
139
+ assert "CAST" in sql
140
+ assert "DOUBLE" in sql
141
+
142
+ def test_json_null_handling_for_string(self):
143
+ stmt = select(json_table.c.data["name"].as_string())
144
+ sql = _compile(stmt)
145
+ assert "WHEN 'null' THEN NULL" in sql
146
+
147
+ def test_json_getitem_as_boolean(self):
148
+ stmt = select(json_table).where(
149
+ json_table.c.data["active"].as_boolean() == True # noqa: E712
150
+ )
151
+ sql = _compile(stmt)
152
+ assert "WHEN 'true' THEN 1" in sql
153
+ assert "WHEN 'false' THEN 0" in sql
154
+
155
+
156
+ class TestJSONKeyEscaping:
157
+ def test_key_with_embedded_quote(self):
158
+ idx = JSONIndexType()
159
+ assert idx._format_value('a"b') == '$."a""b"'
160
+
161
+ def test_key_with_dot(self):
162
+ idx = JSONIndexType()
163
+ assert idx._format_value("a.b") == '$."a.b"'
164
+
165
+ def test_key_with_space(self):
166
+ idx = JSONIndexType()
167
+ assert idx._format_value("a b") == '$."a b"'
168
+
169
+ def test_empty_string_key(self):
170
+ idx = JSONIndexType()
171
+ assert idx._format_value("") == '$.""'
172
+
173
+ def test_path_with_embedded_quote(self):
174
+ pth = JSONPathType()
175
+ assert pth._format_value(('a"b', "c")) == '$."a""b"."c"'
176
+
177
+
178
+ class TestFuncJSONExtract:
179
+ def test_func_json_extract(self):
180
+ stmt = select(func.JSON_EXTRACT(json_table.c.data, "$.name"))
181
+ sql = _compile(stmt)
182
+ assert "JSON_EXTRACT" in sql
183
+ assert "$.name" in sql
184
+
185
+ def test_func_json_contains(self):
186
+ stmt = select(json_table).where(
187
+ func.JSON_CONTAINS(json_table.c.data, '"value"', "$.key") == 1
188
+ )
189
+ sql = _compile(stmt)
190
+ assert "JSON_CONTAINS" in sql
191
+
192
+ def test_func_json_object(self):
193
+ stmt = select(func.JSON_OBJECT("key", "value"))
194
+ sql = _compile(stmt)
195
+ assert "JSON_OBJECT" in sql
196
+
197
+ def test_func_json_array(self):
198
+ stmt = select(func.JSON_ARRAY(1, 2, 3))
199
+ sql = _compile(stmt)
200
+ assert "JSON_ARRAY" in sql
201
+
202
+
203
+ class TestColspecs:
204
+ def test_generic_json_maps_to_cubrid_json(self):
205
+ from sqlalchemy_cubrid.dialect import colspecs
206
+ from sqlalchemy.sql import sqltypes
207
+
208
+ assert sqltypes.JSON in colspecs
209
+ assert colspecs[sqltypes.JSON] is JSON
210
+
211
+ def test_json_index_type_mapped(self):
212
+ from sqlalchemy_cubrid.dialect import colspecs
213
+ from sqlalchemy.sql import sqltypes
214
+
215
+ assert sqltypes.JSON.JSONIndexType in colspecs
216
+ assert colspecs[sqltypes.JSON.JSONIndexType] is JSONIndexType
217
+
218
+ def test_json_path_type_mapped(self):
219
+ from sqlalchemy_cubrid.dialect import colspecs
220
+ from sqlalchemy.sql import sqltypes
221
+
222
+ assert sqltypes.JSON.JSONPathType in colspecs
223
+ assert colspecs[sqltypes.JSON.JSONPathType] is JSONPathType
224
+
225
+
226
+ class TestIschemaNames:
227
+ def test_json_in_ischema_names(self):
228
+ from sqlalchemy_cubrid.dialect import ischema_names
229
+
230
+ assert "JSON" in ischema_names
231
+ assert ischema_names["JSON"] is JSON
232
+
233
+
234
+ class TestJSONExport:
235
+ def test_json_in_init_all(self):
236
+ import sqlalchemy_cubrid
237
+
238
+ assert "JSON" in sqlalchemy_cubrid.__all__
239
+ assert "JSONIndexType" in sqlalchemy_cubrid.__all__
240
+ assert "JSONPathType" in sqlalchemy_cubrid.__all__
241
+
242
+ def test_json_importable(self):
243
+ from sqlalchemy_cubrid import JSON as ImportedJSON
244
+
245
+ assert ImportedJSON is JSON
246
+
247
+ def test_json_index_type_importable(self):
248
+ from sqlalchemy_cubrid import JSONIndexType as ImportedJIT
249
+
250
+ assert ImportedJIT is JSONIndexType
251
+
252
+ def test_json_path_type_importable(self):
253
+ from sqlalchemy_cubrid import JSONPathType as ImportedJPT
254
+
255
+ assert ImportedJPT is JSONPathType
256
+
257
+
258
+ class TestFormatTypeMixinProcessors:
259
+ def _dialect(self):
260
+ from sqlalchemy_cubrid.dialect import CubridDialect
261
+
262
+ return CubridDialect()
263
+
264
+ def test_format_value_not_implemented(self):
265
+ from sqlalchemy_cubrid.types import _FormatTypeMixin
266
+
267
+ with pytest.raises(NotImplementedError):
268
+ _FormatTypeMixin()._format_value("anything")
269
+
270
+ def test_index_bind_processor_int(self):
271
+ proc = JSONIndexType().bind_processor(self._dialect())
272
+ assert proc is not None
273
+ assert proc(0) == "$[0]"
274
+
275
+ def test_index_bind_processor_string_escapes(self):
276
+ proc = JSONIndexType().bind_processor(self._dialect())
277
+ assert proc('a"b') == '$."a""b"'
278
+
279
+ def test_index_literal_processor_int(self):
280
+ proc = JSONIndexType().literal_processor(self._dialect())
281
+ assert proc is not None
282
+ result = proc(2)
283
+ assert "$[2]" in result
284
+
285
+ def test_index_literal_processor_string(self):
286
+ proc = JSONIndexType().literal_processor(self._dialect())
287
+ result = proc("name")
288
+ assert '$."name"' in result
289
+
290
+ def test_path_bind_processor(self):
291
+ proc = JSONPathType().bind_processor(self._dialect())
292
+ assert proc is not None
293
+ assert proc(("a", 1, "b")) == '$."a"[1]."b"'
294
+
295
+ def test_path_literal_processor(self):
296
+ proc = JSONPathType().literal_processor(self._dialect())
297
+ result = proc(("x", 0))
298
+ assert '$."x"[0]' in result
299
+
300
+ def test_index_bind_processor_no_super(self, monkeypatch):
301
+ """Cover the branch where string_bind_processor returns None."""
302
+ idx = JSONIndexType()
303
+ monkeypatch.setattr(idx, "string_bind_processor", lambda dialect: None)
304
+ proc = idx.bind_processor(self._dialect())
305
+ assert proc("k") == '$."k"'
306
+
307
+ def test_index_literal_processor_no_super(self, monkeypatch):
308
+ """Cover the branch where string_literal_processor returns None."""
309
+ idx = JSONIndexType()
310
+ monkeypatch.setattr(idx, "string_literal_processor", lambda dialect: None)
311
+ proc = idx.literal_processor(self._dialect())
312
+ assert proc("k") == '$."k"'