Skip to content

Commit 1394560

Browse files
committed
SQLAlchemy: Use JSON type adapter for implementing CrateDB's OBJECT
SQLAlchemy's `sqltypes.JSON` provides a facade for vendor-specific JSON types. Since it supports JSON SQL operations, it only works on backends that have an actual JSON type, which are currently PostgreSQL, MySQL, SQLite, and Microsoft SQL Server. This patch starts leveraging the same infrastructure, thus bringing corresponding interfaces to the CrateDB dialect. The major difference is that it will not actually do any JSON marshalling, but propagate corresponding data structures 1:1, because within CrateDB's SQL, `OBJECT`s do not need to be serialized into JSON strings before transfer.
1 parent 0ccab52 commit 1394560

File tree

9 files changed

+114
-29
lines changed

9 files changed

+114
-29
lines changed

CHANGES.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ Unreleased
1515

1616
- SQLAlchemy: Fix SQL statement caching for CrateDB's ``OBJECT`` type.
1717

18+
- SQLAlchemy: Refactor ``OBJECT`` type to use SQLAlchemy's JSON type infrastructure.
19+
1820

1921
2023/04/18 0.31.1
2022
=================

src/crate/client/sqlalchemy/compiler.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from sqlalchemy.dialects.postgresql.base import PGCompiler
2727
from sqlalchemy.sql import compiler
2828
from sqlalchemy.types import String
29-
from .types import MutableDict, _Craty, Geopoint, Geoshape
29+
from .types import MutableDict, ObjectTypeImpl, Geopoint, Geoshape
3030
from .sa_version import SA_VERSION, SA_1_4
3131

3232

@@ -41,7 +41,7 @@ def rewrite_update(clauseelement, multiparams, params):
4141
4242
"col['x'] = ?, col['y'] = ?", (1, 2)
4343
44-
by using the `Craty` (`MutableDict`) type.
44+
by using the `ObjectType` (`MutableDict`) type.
4545
The update statement is only rewritten if an item of the MutableDict was
4646
changed.
4747
"""
@@ -124,7 +124,7 @@ def get_column_specification(self, column, **kwargs):
124124
)
125125

126126
if column.dialect_options['crate'].get('index') is False:
127-
if isinstance(column.type, (Geopoint, Geoshape, _Craty)):
127+
if isinstance(column.type, (Geopoint, Geoshape, ObjectTypeImpl)):
128128
raise sa.exc.CompileError(
129129
"Disabling indexing is not supported for column "
130130
"types OBJECT, GEO_POINT, and GEO_SHAPE"
@@ -217,6 +217,9 @@ def visit_ARRAY(self, type_, **kw):
217217
"CrateDB doesn't support multidimensional arrays")
218218
return 'ARRAY({0})'.format(self.process(type_.item_type))
219219

220+
def visit_OBJECT(self, type_, **kw):
221+
return "OBJECT"
222+
220223

221224
class CrateCompiler(compiler.SQLCompiler):
222225

@@ -226,6 +229,14 @@ def visit_getitem_binary(self, binary, operator, **kw):
226229
binary.right.value
227230
)
228231

232+
def visit_json_getitem_op_binary(
233+
self, binary, operator, _cast_applied=False, **kw
234+
):
235+
return "{0}['{1}']".format(
236+
self.process(binary.left, **kw),
237+
binary.right.value
238+
)
239+
229240
def visit_any(self, element, **kw):
230241
return "%s%sANY (%s)" % (
231242
self.process(element.left, **kw),

src/crate/client/sqlalchemy/dialect.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,17 @@ class CrateDialect(default.DefaultDialect):
183183
insert_returning = True
184184
update_returning = True
185185

186-
def __init__(self, *args, **kwargs):
187-
super(CrateDialect, self).__init__(*args, **kwargs)
188-
# currently our sql parser doesn't support unquoted column names that
189-
# start with _. Adding it here causes sqlalchemy to quote such columns
186+
def __init__(self, **kwargs):
187+
default.DefaultDialect.__init__(self, **kwargs)
188+
189+
# CrateDB does not need `OBJECT` types to be serialized as JSON.
190+
# Corresponding data is forwarded 1:1, and will get marshalled
191+
# by the low-level driver.
192+
self._json_deserializer = lambda x: x
193+
self._json_serializer = lambda x: x
194+
195+
# Currently, our SQL parser doesn't support unquoted column names that
196+
# start with _. Adding it here causes sqlalchemy to quote such columns.
190197
self.identifier_preparer.illegal_initial_characters.add('_')
191198

192199
def initialize(self, connection):

src/crate/client/sqlalchemy/tests/compiler_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from sqlalchemy.sql import text, Update
2828

2929
from crate.client.sqlalchemy.sa_version import SA_VERSION, SA_1_4, SA_2_0
30-
from crate.client.sqlalchemy.types import Craty
30+
from crate.client.sqlalchemy.types import ObjectType
3131

3232

3333
class SqlAlchemyCompilerTest(TestCase):
@@ -38,7 +38,7 @@ def setUp(self):
3838
self.metadata = sa.MetaData()
3939
self.mytable = sa.Table('mytable', self.metadata,
4040
sa.Column('name', sa.String),
41-
sa.Column('data', Craty))
41+
sa.Column('data', ObjectType))
4242

4343
self.update = Update(self.mytable).where(text('name=:name'))
4444
self.values = [{'name': 'crate'}]

src/crate/client/sqlalchemy/tests/dict_test.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
except ImportError:
3232
from sqlalchemy.ext.declarative import declarative_base
3333

34-
from crate.client.sqlalchemy.types import Craty, ObjectArray
34+
from crate.client.sqlalchemy.types import ObjectArray, ObjectType
3535
from crate.client.cursor import Cursor
3636

3737

@@ -47,7 +47,7 @@ def setUp(self):
4747
metadata = sa.MetaData()
4848
self.mytable = sa.Table('mytable', metadata,
4949
sa.Column('name', sa.String),
50-
sa.Column('data', Craty))
50+
sa.Column('data', ObjectType))
5151

5252
def assertSQL(self, expected_str, selectable):
5353
actual_expr = selectable.compile(bind=self.engine)
@@ -124,7 +124,7 @@ class Character(Base):
124124
__tablename__ = 'characters'
125125
name = sa.Column(sa.String, primary_key=True)
126126
age = sa.Column(sa.Integer)
127-
data = sa.Column(Craty)
127+
data = sa.Column(ObjectType)
128128
data_list = sa.Column(ObjectArray)
129129

130130
session = Session(bind=self.engine)
@@ -140,7 +140,7 @@ def test_assign_null_to_object_array(self):
140140
self.assertEqual(char_3.data_list, [None])
141141

142142
@patch('crate.client.connection.Cursor', FakeCursor)
143-
def test_assign_to_craty_type_after_commit(self):
143+
def test_assign_to_object_type_after_commit(self):
144144
session, Character = self.set_up_character_and_cursor(
145145
return_value=[('Trillian', None)]
146146
)

src/crate/client/sqlalchemy/tests/match_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
except ImportError:
3131
from sqlalchemy.ext.declarative import declarative_base
3232

33-
from crate.client.sqlalchemy.types import Craty
33+
from crate.client.sqlalchemy.types import ObjectType
3434
from crate.client.sqlalchemy.predicates import match
3535
from crate.client.cursor import Cursor
3636

@@ -60,7 +60,7 @@ def set_up_character_and_session(self):
6060
class Character(Base):
6161
__tablename__ = 'characters'
6262
name = sa.Column(sa.String, primary_key=True)
63-
info = sa.Column(Craty)
63+
info = sa.Column(ObjectType)
6464

6565
session = Session(bind=self.engine)
6666
return session, Character

src/crate/client/sqlalchemy/tests/query_caching.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def setup_data(self):
7676
self.session.execute(sa.text("REFRESH TABLE testdrive.characters;"))
7777

7878
@skipIf(SA_VERSION < SA_1_4, "On SA13, the 'ResultProxy' object has no attribute 'scalar_one'")
79-
def test_object_multiple_select(self):
79+
def test_object_multiple_select_legacy(self):
8080
"""
8181
The SQLAlchemy implementation of CrateDB's `OBJECT` type offers indexed
8282
access to the instance's content in form of a dictionary. Thus, it must
@@ -85,6 +85,8 @@ def test_object_multiple_select(self):
8585
8686
This test verifies that two subsequent `SELECT` statements are translated
8787
well, and don't trip on incorrect SQL compiled statement caching.
88+
89+
This variant uses direct value matching on the `OBJECT`s attribute.
8890
"""
8991
self.setup_data()
9092
Character = self.Character
@@ -97,6 +99,30 @@ def test_object_multiple_select(self):
9799
result = self.session.execute(selectable).scalar_one().data
98100
self.assertEqual({"y": 2}, result)
99101

102+
@skipIf(SA_VERSION < SA_1_4, "On SA13, the 'ResultProxy' object has no attribute 'scalar_one'")
103+
def test_object_multiple_select_modern(self):
104+
"""
105+
The SQLAlchemy implementation of CrateDB's `OBJECT` type offers indexed
106+
access to the instance's content in form of a dictionary. Thus, it must
107+
not use `cache_ok = True` on its implementation, i.e. this part of the
108+
compiled SQL clause must not be cached.
109+
110+
This test verifies that two subsequent `SELECT` statements are translated
111+
well, and don't trip on incorrect SQL compiled statement caching.
112+
113+
This variant uses comparator method matching on the `OBJECT`s attribute.
114+
"""
115+
self.setup_data()
116+
Character = self.Character
117+
118+
selectable = sa.select(Character).where(Character.data['x'].as_integer() == 1)
119+
result = self.session.execute(selectable).scalar_one().data
120+
self.assertEqual({"x": 1}, result)
121+
122+
selectable = sa.select(Character).where(Character.data['y'].as_integer() == 2)
123+
result = self.session.execute(selectable).scalar_one().data
124+
self.assertEqual({"y": 2}, result)
125+
100126
@skipIf(SA_VERSION < SA_1_4, "On SA13, the 'ResultProxy' object has no attribute 'scalar_one'")
101127
def test_objectarray_multiple_select(self):
102128
"""

src/crate/client/sqlalchemy/tests/warnings_test.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,17 @@
88

99

1010
class SqlAlchemyWarningsTest(TestCase, ExtraAssertions):
11+
"""
12+
Verify a few `DeprecationWarning` spots.
13+
14+
https://docs.python.org/3/library/warnings.html#testing-warnings
15+
"""
1116

1217
@skipIf(SA_VERSION >= SA_1_4, "There is no deprecation warning for "
1318
"SQLAlchemy 1.3 on higher versions")
1419
def test_sa13_deprecation_warning(self):
1520
"""
1621
Verify that a `DeprecationWarning` is issued when running SQLAlchemy 1.3.
17-
18-
https://docs.python.org/3/library/warnings.html#testing-warnings
1922
"""
2023
with warnings.catch_warnings(record=True) as w:
2124

@@ -31,3 +34,31 @@ def test_sa13_deprecation_warning(self):
3134
self.assertEqual(len(w), 1)
3235
self.assertIsSubclass(w[-1].category, DeprecationWarning)
3336
self.assertIn("SQLAlchemy 1.3 is effectively EOL.", str(w[-1].message))
37+
38+
def test_craty_object_deprecation_warning(self):
39+
"""
40+
Verify that a `DeprecationWarning` is issued when accessing the deprecated
41+
module variables `Craty`, and `Object`. The new type is called `ObjectType`.
42+
"""
43+
44+
with warnings.catch_warnings(record=True) as w:
45+
46+
# Import the deprecated symbol.
47+
from crate.client.sqlalchemy.types import Craty # noqa: F401
48+
49+
# Verify details of the deprecation warning.
50+
self.assertEqual(len(w), 1)
51+
self.assertIsSubclass(w[-1].category, DeprecationWarning)
52+
self.assertIn("Craty is deprecated and will be removed in future releases. "
53+
"Please use ObjectType instead.", str(w[-1].message))
54+
55+
with warnings.catch_warnings(record=True) as w:
56+
57+
# Import the deprecated symbol.
58+
from crate.client.sqlalchemy.types import Object # noqa: F401
59+
60+
# Verify details of the deprecation warning.
61+
self.assertEqual(len(w), 1)
62+
self.assertIsSubclass(w[-1].category, DeprecationWarning)
63+
self.assertIn("Object is deprecated and will be removed in future releases. "
64+
"Please use ObjectType instead.", str(w[-1].message))

src/crate/client/sqlalchemy/types.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
# However, if you have executed another commercial license agreement
1919
# with Crate these terms will supersede the license and you may use the
2020
# software solely pursuant to the terms of the relevant commercial agreement.
21+
import warnings
2122

2223
import sqlalchemy.types as sqltypes
2324
from sqlalchemy.sql import operators, expression
@@ -131,24 +132,31 @@ def __eq__(self, other):
131132
return dict.__eq__(self, other)
132133

133134

134-
class _Craty(sqltypes.UserDefinedType):
135+
class ObjectTypeImpl(sqltypes.UserDefinedType, sqltypes.JSON):
136+
137+
__visit_name__ = "OBJECT"
138+
135139
cache_ok = False
140+
none_as_null = False
136141

137-
class Comparator(sqltypes.TypeEngine.Comparator):
138142

139-
def __getitem__(self, key):
140-
return default_comparator._binary_operate(self.expr,
141-
operators.getitem,
142-
key)
143+
# Designated name to refer to. `Object` is too ambiguous.
144+
ObjectType = MutableDict.as_mutable(ObjectTypeImpl)
143145

144-
def get_col_spec(self):
145-
return 'OBJECT'
146+
# Backward-compatibility aliases.
147+
_deprecated_Craty = ObjectType
148+
_deprecated_Object = ObjectType
146149

147-
type = MutableDict
148-
comparator_factory = Comparator
150+
# https://www.lesinskis.com/deprecating-module-scope-variables.html
151+
deprecated_names = ["Craty", "Object"]
149152

150153

151-
Object = Craty = MutableDict.as_mutable(_Craty)
154+
def __getattr__(name):
155+
if name in deprecated_names:
156+
warnings.warn(f"{name} is deprecated and will be removed in future releases. "
157+
f"Please use ObjectType instead.", DeprecationWarning)
158+
return globals()[f"_deprecated_{name}"]
159+
raise AttributeError(f"module {__name__} has no attribute {name}")
152160

153161

154162
class Any(expression.ColumnElement):

0 commit comments

Comments
 (0)