Skip to content

Commit 7781848

Browse files
committed
Fix JSON property index.
* also support JSON property on non-crud base * add docs for JSON property * fixes #661 * add prop_name type check
1 parent 6a253b8 commit 7781848

File tree

4 files changed

+207
-9
lines changed

4 files changed

+207
-9
lines changed

docs/how-to/json-props.rst

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
JSON Property
2+
=============
3+
4+
GINO provides additional support to leverage native JSON type in the database as
5+
flexible GINO model fields.
6+
7+
Quick Start
8+
-----------
9+
10+
::
11+
12+
from gino import Gino
13+
from sqlalchemy.dialects.postgresql import JSONB
14+
15+
db = Gino()
16+
17+
class User(db.Model):
18+
__tablename__ = "users"
19+
20+
id = db.Column(db.Integer, primary_key=True)
21+
name = db.Column(db.String)
22+
profile = db.Column(JSONB, nullable=False, server_default="{}")
23+
24+
age = db.IntegerProperty()
25+
birthday = db.DateTimeProperty()
26+
27+
The ``age`` and ``birthday`` are JSON properties stored in the ``profile`` column. You
28+
may use them the same way as a normal GINO model field::
29+
30+
u = await User.create(name="daisy", age=18)
31+
print(u.name, u.age) # daisy 18
32+
33+
.. note::
34+
35+
``profile`` is the default column name for all JSON properties in a model. If you
36+
need a different column name for some JSON properties, you'll need to specify
37+
explicitly::
38+
39+
audit_profile = db.Column(JSON, nullable=False, server_default="{}")
40+
41+
access_log = db.ArrayProperty(prop_name="audit_profile")
42+
abnormal_detected = db.BooleanProperty(prop_name="audit_profile")
43+
44+
Using JSON properties in queries is supported::
45+
46+
await User.query.where(User.age > 16).gino.all()
47+
48+
This is simply translated into a native JSON query like this:
49+
50+
.. code-block:: plpgsql
51+
52+
SELECT users.id, users.name, users.profile
53+
FROM users
54+
WHERE CAST((users.profile ->> $1) AS INTEGER) > $2; -- ('age', 16)
55+
56+
Datetime type is very much the same::
57+
58+
from datetime import datetime
59+
60+
await User.query.where(User.birthday > datetime(1990, 1, 1)).gino.all()
61+
62+
And the generated SQL:
63+
64+
.. code-block:: plpgsql
65+
66+
SELECT users.id, users.name, users.profile
67+
FROM users
68+
WHERE CAST((users.profile ->> $1) AS TIMESTAMP WITHOUT TIME ZONE) > $2
69+
-- ('birthday', datetime.datetime(1990, 1, 1, 0, 0))
70+
71+
Here's a list of all the supported JSON properties:
72+
73+
+----------------------------+-----------------------------+-------------+---------------+
74+
| JSON Property | Python type | JSON type | Database Type |
75+
+============================+=============================+=============+===============+
76+
| :class:`.StringProperty` | :class:`str` | ``string`` | ``text`` |
77+
+----------------------------+-----------------------------+-------------+---------------+
78+
| :class:`.IntegerProperty` | :class:`int` | ``number`` | ``int`` |
79+
+----------------------------+-----------------------------+-------------+---------------+
80+
| :class:`.BooleanProperty` | :class:`bool` | ``boolean`` | ``boolean`` |
81+
+----------------------------+-----------------------------+-------------+---------------+
82+
| :class:`.DateTimeProperty` | :class:`~datetime.datetime` | ``string`` | ``text`` |
83+
+----------------------------+-----------------------------+-------------+---------------+
84+
| :class:`.ObjectProperty` | :class:`dict` | ``object`` | JSON |
85+
+----------------------------+-----------------------------+-------------+---------------+
86+
| :class:`.ArrayProperty` | :class:`list` | ``array`` | JSON |
87+
+----------------------------+-----------------------------+-------------+---------------+
88+
89+
90+
Hooks
91+
-----
92+
93+
JSON property provides 2 instance-level hooks to customize the data::
94+
95+
class User(db.Model):
96+
__tablename__ = "users"
97+
98+
id = db.Column(db.Integer, primary_key=True)
99+
profile = db.Column(JSONB, nullable=False, server_default="{}")
100+
101+
age = db.IntegerProperty()
102+
103+
@age.before_set
104+
def age(self, val):
105+
return val - 1
106+
107+
@age.after_get
108+
def age(self, val):
109+
return val + 1
110+
111+
u = await User.create(name="daisy", age=18)
112+
print(u.name, u.profile, u.age) # daisy {'age': 17} 18
113+
114+
And 1 class-level hook to customize the SQLAlchemy expression of the property::
115+
116+
class User(db.Model):
117+
__tablename__ = "users"
118+
119+
id = db.Column(db.Integer, primary_key=True)
120+
profile = db.Column(JSONB, nullable=False, server_default="{}")
121+
122+
height = db.JSONProperty()
123+
124+
@height.expression
125+
def height(cls, exp):
126+
return exp.cast(db.Float) # CAST(profile -> 'height' AS FLOAT)
127+
128+
129+
Create Index on JSON Properties
130+
-------------------------------
131+
132+
We'll need to use :meth:`~gino.declarative.declared_attr` to wait until the model class
133+
is initialized. The rest is very much the same as defining a usual index::
134+
135+
class User(db.Model):
136+
__tablename__ = "users"
137+
138+
id = db.Column(db.Integer, primary_key=True)
139+
profile = db.Column(JSONB, nullable=False, server_default="{}")
140+
141+
age = db.IntegerProperty()
142+
143+
@db.declared_attr
144+
def age_idx(cls):
145+
return db.Index("age_idx", cls.age)
146+
147+
This will lead to the SQL below executed if you run ``db.gino.create_all()``:
148+
149+
.. code-block:: plpgsql
150+
151+
CREATE INDEX age_idx ON users (CAST(profile ->> 'age' AS INTEGER));
152+
153+
.. warning::
154+
155+
Alembic doesn't support auto-generating revisions for functional indexes yet. You'll
156+
need to manually edit the revision. Please follow `this issue
157+
<https://github.com/sqlalchemy/alembic/issues/676>`__ for updates.

src/gino/crud.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -435,14 +435,6 @@ def __init__(self, **values):
435435
@classmethod
436436
def _init_table(cls, sub_cls):
437437
rv = Model._init_table(sub_cls)
438-
for each_cls in sub_cls.__mro__[::-1]:
439-
for k, v in each_cls.__dict__.items():
440-
if isinstance(v, json_support.JSONProperty):
441-
if not hasattr(sub_cls, v.prop_name):
442-
raise AttributeError(
443-
'Requires "{}" JSON[B] column.'.format(v.prop_name)
444-
)
445-
v.name = k
446438
if rv is not None:
447439
rv.__model__ = weakref.ref(sub_cls)
448440
return rv

src/gino/declarative.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import sqlalchemy as sa
44
from sqlalchemy.exc import InvalidRequestError
55

6+
from . import json_support
67
from .exceptions import GinoException
78

89

@@ -75,6 +76,8 @@ def invert_get(self, value, default=None):
7576
class Dict(collections.OrderedDict):
7677
def __setitem__(self, key, value):
7778
if isinstance(value, sa.Column) and not value.name:
79+
value.name = value.key = key
80+
if isinstance(value, json_support.JSONProperty) and not value.name:
7881
value.name = key
7982
return super().__setitem__(key, value)
8083

@@ -272,7 +275,7 @@ def _init_table(cls, sub_cls):
272275
if isinstance(v, sa.Column):
273276
v = v.copy()
274277
if not v.name:
275-
v.name = k
278+
v.name = v.key = k
276279
column_name_map[k] = v.name
277280
columns.append(v)
278281
updates[k] = sub_cls.__attr_factory__(k, v)
@@ -311,6 +314,21 @@ def _init_table(cls, sub_cls):
311314
rv = sa.Table(table_name, sub_cls.__metadata__, *args, **table_kw)
312315
for k, v in updates.items():
313316
setattr(sub_cls, k, v)
317+
for each_cls in sub_cls.__mro__[::-1]:
318+
for k, v in each_cls.__dict__.items():
319+
if isinstance(v, json_support.JSONProperty):
320+
json_col = getattr(
321+
sub_cls.__dict__.get(v.prop_name), "column", None
322+
)
323+
if not isinstance(json_col, sa.Column) or not isinstance(
324+
json_col.type, sa.JSON
325+
):
326+
raise AttributeError(
327+
'{} "{}" requires a JSON[B] column "{}" '
328+
"which is not found or has a wrong type.".format(
329+
type(v).__name__, v.name, v.prop_name,
330+
)
331+
)
314332
return rv
315333

316334

tests/test_json.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,21 @@ class Test(db.Model):
230230

231231
age = db.IntegerProperty(default=18)
232232

233+
with pytest.raises(AttributeError, match=r"JSON\[B\] column"):
234+
# noinspection PyUnusedLocal,PyRedeclaration
235+
class Test(db.Model):
236+
__tablename__ = "tests_no_profile"
237+
238+
profile = db.StringProperty()
239+
240+
with pytest.raises(AttributeError, match=r"JSON\[B\] column"):
241+
# noinspection PyUnusedLocal,PyRedeclaration
242+
class Test(db.Model):
243+
__tablename__ = "tests_no_profile"
244+
245+
profile1 = db.StringProperty(prop_name="profile2")
246+
profile2 = db.IntegerProperty(prop_name="profile1")
247+
233248

234249
async def test_t291_t402(bind):
235250
from gino.dialects.asyncpg import JSON, JSONB
@@ -281,3 +296,19 @@ class PathTest(db.Model):
281296
assert t1.data == t2.data
282297
finally:
283298
await PathTest.gino.drop()
299+
300+
301+
async def test_index(bind):
302+
from gino.dialects.asyncpg import JSONB
303+
304+
class IndexTest(db.Model):
305+
__tablename__ = "index_test"
306+
profile = db.Column(JSONB())
307+
age = db.IntegerProperty()
308+
309+
@db.declared_attr
310+
def age_idx(cls):
311+
return db.Index("age_idx", cls.age)
312+
313+
await IndexTest.gino.create()
314+
await IndexTest.gino.drop()

0 commit comments

Comments
 (0)