Skip to content

Commit

Permalink
fix JSONB column where null (#413)
Browse files Browse the repository at this point in the history
* fix JSON column where null

* improve JSONB tests

* bump dev requirements and fix black

* Update doc-requirements.txt

* change ipython version

* make None always mean SQL null with JSON columns

* improve JSON docs

* added test for raw queries with JSON
  • Loading branch information
dantownsend committed Feb 1, 2022
1 parent ee38ccd commit a654254
Show file tree
Hide file tree
Showing 14 changed files with 291 additions and 52 deletions.
2 changes: 1 addition & 1 deletion docs/src/piccolo/engines/index.rst
Expand Up @@ -75,7 +75,7 @@ In your terminal:
export PICCOLO_CONF=piccolo_conf_test
Or at the entypoint of your app, before any other imports:
Or at the entrypoint of your app, before any other imports:

.. code-block:: python
Expand Down
31 changes: 30 additions & 1 deletion docs/src/piccolo/getting_started/example_schema.rst
Expand Up @@ -3,7 +3,10 @@
Example Schema
==============

This is the schema used by the example queries throughout the docs.
This is the schema used by the example queries throughout the docs, and also
in the :ref:`playground<Playground>`.

``Manager`` and ``Band`` are most commonly used:

.. code-block:: python
Expand All @@ -20,4 +23,30 @@ This is the schema used by the example queries throughout the docs.
manager = ForeignKey(references=Manager)
popularity = Integer()
We sometimes use these other tables in the examples too:

.. code-block:: python
class Venue(Table):
name = Varchar()
capacity = Integer()
class Concert(Table):
band_1 = ForeignKey(references=Band)
band_2 = ForeignKey(references=Band)
venue = ForeignKey(references=Venue)
starts = Timestamp()
duration = Interval()
class Ticket(Table):
concert = ForeignKey(references=Concert)
price = Numeric()
class RecordingStudio(Table):
name = Varchar()
facilities = JSONB()
To understand more about defining your own schemas, see :ref:`DefiningSchema`.
2 changes: 2 additions & 0 deletions docs/src/piccolo/query_clauses/output.rst
Expand Up @@ -54,6 +54,8 @@ Output any data from related tables in nested dictionaries.
Select and Objects queries
--------------------------

.. _load_json:

load_json
~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion docs/src/piccolo/query_types/django_comparison.rst
Expand Up @@ -173,7 +173,7 @@ Piccolo has something similar:
.. code-block:: python
# Piccolo
band = Band.objects(Band.manager).get(name='Pythonistas')
band = Band.objects(Band.manager).get(Band.name == 'Pythonistas').run_sync()
>>> band.manager
<Manager: 1>
Expand Down
114 changes: 97 additions & 17 deletions docs/src/piccolo/schema/column_types.rst
Expand Up @@ -192,36 +192,116 @@ JSONB

.. autoclass:: JSONB

Serialising
===========

Piccolo automatically converts Python values into JSON strings:

.. code-block:: python
studio = RecordingStudio(
name="Abbey Road",
facilities={"restaurant": True, "mixing_desk": True} # Automatically serialised
)
await studio.save()
You can also pass in a JSON string if you prefer:

.. code-block:: python
studio = RecordingStudio(
name="Abbey Road",
facilities='{"restaurant": true, "mixing_desk": true}'
)
await studio.save()
Deserialising
=============

The contents of a ``JSON`` / ``JSONB`` column are returned as a string by
default:

.. code-block:: python
>>> await RecordingStudio.select(RecordingStudio.facilities)
[{facilities: '{"restaurant": true, "mixing_desk": true}'}]
However, we can ask Piccolo to deserialise the JSON automatically (see :ref:`load_json`):

.. code-block:: python
>>> await RecordingStudio.select(
>>> RecordingStudio.facilities
>>> ).output(
>>> load_json=True
>>> )
[facilities: {"restaurant": True, "mixing_desk": True}}]
With ``objects`` queries, we can modify the returned JSON, and then save it:

.. code-block:: python
studio = await RecordingStudio.objects().get(
RecordingStudio.name == 'Abbey Road'
).output(load_json=True)
studio['facilities']['restaurant'] = False
await studio.save()
arrow
=====

``JSONB`` columns have an ``arrow`` function, which is useful for retrieving
a subset of the JSON data, and for filtering in a where clause.
a subset of the JSON data:

.. code-block:: python
# Example schema:
class Booking(Table):
data = JSONB()
>>> await RecordingStudio.select(
>>> RecordingStudio.name,
>>> RecordingStudio.facilities.arrow('mixing_desk').as_alias('mixing_desk')
>>> ).output(load_json=True)
[{'name': 'Abbey Road', 'mixing_desk': True}]
await Booking.create_table()
It can also be used for filtering in a where clause:

# Example data:
await Booking.insert(
Booking(data='{"name": "Alison"}'),
Booking(data='{"name": "Bob"}')
)
.. code-block:: python
>>> await RecordingStudio.select(RecordingStudio.name).where(
>>> RecordingStudio.facilities.arrow('mixing_desk') == True
>>> )
[{'name': 'Abbey Road'}]
# Example queries
>>> await Booking.select(
>>> Booking.id, Booking.data.arrow('name').as_alias('name')
Handling null
=============

When assigning a value of ``None`` to a ``JSON`` or ``JSONB`` column, this is
treated as null in the database.

.. code-block:: python
await RecordingStudio(name="ABC Studios", facilities=None).save()
>>> await RecordingStudio.select(
>>> RecordingStudio.facilities
>>> ).where(
>>> RecordingStudio.name == "ABC Studios"
>>> )
[{'id': 1, 'name': '"Alison"'}, {'id': 2, 'name': '"Bob"'}]
[{'facilities': None}]
If instead you want to store JSON null in the database, assign a value of ``'null'``
instead.

.. code-block:: python
await RecordingStudio(name="ABC Studios", facilities='null').save()
>>> await Booking.select(Booking.id).where(
>>> Booking.data.arrow('name') == '"Alison"'
>>> await RecordingStudio.select(
>>> RecordingStudio.facilities
>>> ).where(
>>> RecordingStudio.name == "ABC Studios"
>>> )
[{'id': 1}]
[{'facilities': 'null'}]
-------------------------------------------------------------------------------

Expand Down
2 changes: 1 addition & 1 deletion piccolo/apps/playground/commands/run.py
Expand Up @@ -58,7 +58,7 @@ class DiscountCode(Table):

class RecordingStudio(Table):
name = Varchar(length=100)
facilities = JSON()
facilities = JSON(null=True)


TABLES = (Manager, Band, Venue, Concert, Ticket, DiscountCode, RecordingStudio)
Expand Down
4 changes: 2 additions & 2 deletions piccolo/columns/base.py
Expand Up @@ -668,14 +668,14 @@ def __hash__(self):
def is_null(self) -> Where:
"""
Can be used instead of ``MyTable.column != None``, because some linters
don't like a comparison to None.
don't like a comparison to ``None``.
"""
return Where(column=self, operator=IsNull)

def is_not_null(self) -> Where:
"""
Can be used instead of ``MyTable.column == None``, because some linters
don't like a comparison to None.
don't like a comparison to ``None``.
"""
return Where(column=self, operator=IsNotNull)

Expand Down
17 changes: 16 additions & 1 deletion piccolo/columns/column_types.py
Expand Up @@ -1996,13 +1996,28 @@ def get_select_string(self, engine_type: str, just_alias=False) -> str:
just_alias=just_alias, include_quotes=True
)
if self.json_operator is None:
return select_string
if self.alias is None:
return select_string
else:
return f"{select_string} AS {self.alias}"
else:
if self.alias is None:
return f"{select_string} {self.json_operator}"
else:
return f"{select_string} {self.json_operator} AS {self.alias}"

def eq(self, value) -> Where:
"""
See ``Boolean.eq`` for more details.
"""
return self.__eq__(value)

def ne(self, value) -> Where:
"""
See ``Boolean.ne`` for more details.
"""
return self.__ne__(value)

###########################################################################
# Descriptors

Expand Down
8 changes: 6 additions & 2 deletions piccolo/columns/combination.py
Expand Up @@ -122,7 +122,11 @@ def __init__(
omitted, vs None, which is a valid value for a where clause.
"""
self.column = column
self.value = self.clean_value(value)

if value == UNDEFINED:
self.value = value
else:
self.value = self.clean_value(value)

if values == UNDEFINED:
self.values = values
Expand All @@ -133,7 +137,7 @@ def __init__(

def clean_value(self, value: t.Any) -> t.Any:
"""
If a where clause contains a Table instance, we should convert that
If a where clause contains a ``Table`` instance, we should convert that
to a column reference. For example:
.. code-block:: python
Expand Down
5 changes: 5 additions & 0 deletions piccolo/query/base.py
Expand Up @@ -80,6 +80,11 @@ async def _process_results(self, results): # noqa: C901
for column in json_columns:
if column.alias is not None:
json_column_names.append(column.alias)
elif column.json_operator is not None:
# If no alias is specified, then the default column name
# that Postgres gives when using the `->` operator is
# `?column?`.
json_column_names.append("?column?")
elif len(column._meta.call_chain) > 0:
json_column_names.append(
column.get_select_string(
Expand Down
5 changes: 4 additions & 1 deletion piccolo/utils/sql_values.py
Expand Up @@ -34,6 +34,9 @@ def convert_to_sql_value(value: t.Any, column: Column) -> t.Any:
elif isinstance(value, Enum):
return value.value
elif isinstance(column, (JSON, JSONB)) and not isinstance(value, str):
return dump_json(value)
if value is None:
return None
else:
return dump_json(value)
else:
return value
2 changes: 1 addition & 1 deletion requirements/dev-requirements.txt
@@ -1,6 +1,6 @@
black==22.1.0
ipdb==0.13.9
ipython==7.31.1
ipython>=7.31.1
flake8==4.0.1
isort==5.10.1
twine==3.7.1
Expand Down
2 changes: 1 addition & 1 deletion requirements/doc-requirements.txt
@@ -1,3 +1,3 @@
Sphinx==4.3.1
Sphinx==4.4.0
sphinx-rtd-theme==1.0.0
livereload==2.6.3

0 comments on commit a654254

Please sign in to comment.