Skip to content

Commit 2c8a39b

Browse files
authored
Merge pull request piccolo-orm#21 from piccolo-orm/json_field
JSON field
2 parents 3291a3a + f4e10b0 commit 2c8a39b

File tree

15 files changed

+399
-24
lines changed

15 files changed

+399
-24
lines changed

docs/src/piccolo/query_types/select.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,18 @@ Or making an alias to make it shorter:
3131
3232
.. hint:: All of these examples also work with async by using .run() inside coroutines - see :ref:`SyncAndAsync`.
3333

34+
as_alias
35+
--------
36+
37+
By using ``as_alias``, the name of the row can be overriden in the response.
38+
39+
.. code-block:: python
40+
41+
>>> Band.select(Band.name.as_alias('title')).run_sync()
42+
[{'title': 'Rustaceans'}, {'title': 'Pythonistas'}]
43+
44+
This is equivalent to ``SELECT name AS title FROM band`` in SQL.
45+
3446
Joins
3547
-----
3648

docs/src/piccolo/schema/column_types.rst

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,56 @@ Timestamp
134134
=========
135135

136136
.. autoclass:: Timestamp
137+
138+
-------------------------------------------------------------------------------
139+
140+
****
141+
JSON
142+
****
143+
144+
Storing JSON can be useful in certain situations, for example - raw API
145+
responses, data from a Javascript app, and for storing data with an unknown or
146+
changing schema.
147+
148+
====
149+
JSON
150+
====
151+
152+
.. autoclass:: JSON
153+
154+
=====
155+
JSONB
156+
=====
157+
158+
.. autoclass:: JSONB
159+
160+
arrow
161+
=====
162+
163+
``JSONB`` columns have an ``arrow`` function, which is useful for retrieving
164+
a subset of the JSON data, and for filtering in a where clause.
165+
166+
.. code-block:: python
167+
168+
# Example schema:
169+
class Booking(Table):
170+
data = JSONB()
171+
172+
Booking.create_table().run_sync()
173+
174+
# Example data:
175+
Booking.insert(
176+
Booking(data='{"name": "Alison"}'),
177+
Booking(data='{"name": "Bob"}')
178+
).run_sync()
179+
180+
# Example queries
181+
>>> Booking.select(
182+
>>> Booking.id, Booking.data.arrow('name').as_alias('name')
183+
>>> ).run_sync()
184+
[{'id': 1, 'name': '"Alison"'}, {'id': 2, 'name': '"Bob"'}]
185+
186+
>>> Booking.select(Booking.id).where(
187+
>>> Booking.data.arrow('name') == '"Alison"'
188+
>>> ).run_sync()
189+
[{'id': 1}]

piccolo/columns/__init__.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
from .column_types import ( # noqa
1+
from .column_types import ( # noqa: F401
22
Boolean,
33
Decimal,
44
Float,
55
ForeignKey,
66
Integer,
77
Interval,
8+
JSON,
9+
JSONB,
810
Numeric,
911
PrimaryKey,
1012
Real,
@@ -15,6 +17,6 @@
1517
UUID,
1618
Varchar,
1719
)
18-
from .base import Column, ForeignKeyMeta, Selectable # noqa
19-
from .base import OnDelete, OnUpdate # noqa
20-
from .combination import And, Or, Where # noqa
20+
from .base import Column, ForeignKeyMeta, Selectable # noqa: F401
21+
from .base import OnDelete, OnUpdate # noqa: F401
22+
from .combination import And, Or, Where # noqa: F401

piccolo/columns/base.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22
from abc import ABCMeta, abstractmethod
3+
import copy
34
from dataclasses import dataclass, field
45
import datetime
56
import decimal
@@ -226,6 +227,8 @@ def __init__(
226227
required=required,
227228
)
228229

230+
self.alias: t.Optional[str] = None
231+
229232
def _validate_default(
230233
self,
231234
default: t.Any,
@@ -306,6 +309,20 @@ def __ne__(self, value) -> Where: # type: ignore
306309
def __hash__(self):
307310
return hash(self._meta.name)
308311

312+
def as_alias(self, name: str) -> Column:
313+
"""
314+
Allows column names to be changed in the result of a select.
315+
316+
For example:
317+
318+
>>> await Band.select(Band.name.as_alias('title')).run()
319+
{'title': 'Pythonistas'}
320+
321+
"""
322+
column = copy.deepcopy(self)
323+
column.alias = name
324+
return column
325+
309326
def get_default_value(self) -> t.Any:
310327
"""
311328
If the column has a default attribute, return it. If it's callable,
@@ -323,9 +340,16 @@ def get_select_string(self, engine_type: str, just_alias=False) -> str:
323340
"""
324341
How to refer to this column in a SQL query.
325342
"""
326-
return self._meta.get_full_name(just_alias=just_alias)
343+
if self.alias is None:
344+
return self._meta.get_full_name(just_alias=just_alias)
345+
else:
346+
original_name = self._meta.get_full_name(just_alias=True)
347+
return f"{original_name} AS {self.alias}"
327348

328-
def get_sql_value(self, value: t.Any) -> str:
349+
def get_where_string(self, engine_type: str) -> str:
350+
return self.get_select_string(engine_type=engine_type, just_alias=True)
351+
352+
def get_sql_value(self, value: t.Any) -> t.Any:
329353
"""
330354
When using DDL statements, we can't parameterise the values. An example
331355
is when setting the default for a column. So we have to convert from
@@ -390,6 +414,13 @@ def querystring(self) -> QueryString:
390414
if not self._meta.primary:
391415
default = self.get_default_value()
392416
sql_value = self.get_sql_value(value=default)
417+
# Escape the value if it contains a pair of curly braces, otherwise
418+
# an empty value will appear in the compiled querystring.
419+
sql_value = (
420+
sql_value.replace("{}", "{{}}")
421+
if isinstance(sql_value, str)
422+
else sql_value
423+
)
393424
query += f" DEFAULT {sql_value}"
394425

395426
return QueryString(query)

piccolo/columns/column_types.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
)
1818
from piccolo.columns.defaults.uuid import UUIDArg, UUID4
1919
from piccolo.querystring import Unquoted, QueryString
20+
from piccolo.utils.encoding import dump_json
2021

2122
if t.TYPE_CHECKING:
2223
from piccolo.table import Table
@@ -1039,3 +1040,67 @@ def __getattribute__(self, name: str):
10391040
return new_column
10401041
else:
10411042
return value
1043+
1044+
1045+
###############################################################################
1046+
1047+
1048+
class JSON(Column): # lgtm[py/missing-equals]
1049+
"""
1050+
Used for storing JSON strings. The data is stored as text. This can be
1051+
preferable to JSONB if you just want to store and retrieve JSON without
1052+
querying it directly. It works with SQLite and Postgres.
1053+
1054+
:param default:
1055+
Either a JSON string can be provided, or a Python ``dict`` or ``list``
1056+
which is then converted to a JSON string.
1057+
1058+
"""
1059+
1060+
value_type = str
1061+
1062+
def __init__(
1063+
self, default: t.Union[str, t.List, t.Dict, None] = "{}", **kwargs
1064+
) -> None:
1065+
self._validate_default(default, (str, list, dict, None))
1066+
1067+
if isinstance(default, (list, dict)):
1068+
default = dump_json(default)
1069+
1070+
self.default = default
1071+
kwargs.update({"default": default})
1072+
super().__init__(**kwargs)
1073+
1074+
self.json_operator: t.Optional[str] = None
1075+
1076+
1077+
class JSONB(JSON):
1078+
"""
1079+
Used for storing JSON strings - Postgres only. The data is stored in a
1080+
binary format, and can be queried. Insertion can be slower (as it needs to
1081+
be converted to the binary format). The benefits of JSONB generally
1082+
outweigh the downsides.
1083+
1084+
:param default:
1085+
Either a JSON string can be provided, or a Python ``dict`` or ``list``
1086+
which is then converted to a JSON string.
1087+
1088+
"""
1089+
1090+
def arrow(self, key: str) -> JSONB:
1091+
"""
1092+
Allows part of the JSON structure to be returned - for example,
1093+
for {"a": 1}, and a key value of "a", then 1 will be returned.
1094+
"""
1095+
self.json_operator = f"-> '{key}'"
1096+
return self
1097+
1098+
def get_select_string(self, engine_type: str, just_alias=False) -> str:
1099+
select_string = self._meta.get_full_name(just_alias=just_alias)
1100+
if self.json_operator is None:
1101+
return select_string
1102+
else:
1103+
if self.alias is None:
1104+
return f"{select_string} {self.json_operator}"
1105+
else:
1106+
return f"{select_string} {self.json_operator} AS {self.alias}"

piccolo/columns/combination.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ class Where(CombinableMixin):
6060

6161
def __init__(
6262
self,
63-
column: "Column",
63+
column: Column,
6464
value: t.Any = UNDEFINED,
6565
values: t.Union[Iterable, Undefined] = UNDEFINED,
6666
operator: t.Type[ComparisonOperator] = ComparisonOperator,
@@ -91,7 +91,9 @@ def querystring(self) -> QueryString:
9191
args.append(self.values_querystring)
9292

9393
template = self.operator.template.format(
94-
name=self.column._meta.get_full_name(just_alias=True),
94+
name=self.column.get_where_string(
95+
engine_type=self.column._meta.engine_type
96+
),
9597
value="{}",
9698
values="{}",
9799
)

piccolo/query/base.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from piccolo.querystring import QueryString
77
from piccolo.utils.sync import run_sync
8+
from piccolo.utils.encoding import dump_json
89

910
if t.TYPE_CHECKING:
1011
from piccolo.table import Table # noqa
@@ -68,14 +69,7 @@ async def _process_results(self, results):
6869
else:
6970
raw = list(itertools.chain(*[j.values() for j in raw]))
7071
if output._output.as_json:
71-
try:
72-
import orjson
73-
except ImportError:
74-
import json
75-
76-
raw = json.dumps(raw, default=str)
77-
else:
78-
raw = orjson.dumps(raw, default=str).decode("utf8")
72+
raw = dump_json(raw)
7973

8074
return raw
8175

piccolo/query/methods/select.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,9 @@ def querystrings(self) -> t.Sequence[QueryString]:
248248

249249
#######################################################################
250250

251-
select = "SELECT DISTINCT" if self.distinct else "SELECT"
251+
select = (
252+
"SELECT DISTINCT" if self.distinct_delegate._distinct else "SELECT"
253+
)
252254
query = f"{select} {columns_str} FROM {self.table._meta.tablename}"
253255

254256
for join in joins:

piccolo/query/mixins.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,14 @@ def output(
176176
as_list: t.Optional[bool] = None,
177177
as_json: t.Optional[bool] = None,
178178
):
179+
"""
180+
:param as_list:
181+
If each row only returns a single value, compile all of the results
182+
into a single list.
183+
:param as_json:
184+
The results are serialised into JSON. It's equivalent to running
185+
`json.dumps` on the result.
186+
"""
179187
if as_list is not None:
180188
self._output.as_list = bool(as_list)
181189

piccolo/utils/encoding.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from __future__ import annotations
2+
import typing as t
3+
4+
try:
5+
import orjson
6+
7+
ORJSON = True
8+
except ImportError:
9+
import json
10+
11+
ORJSON = False
12+
13+
14+
def dump_json(data: t.Any) -> str:
15+
if ORJSON:
16+
return orjson.dumps(data, default=str).decode("utf8")
17+
else:
18+
return json.dumps(data, default=str)

0 commit comments

Comments
 (0)