summaryrefslogtreecommitdiff
path: root/lib/sqlalchemy/dialects/mysql/json.py
blob: 1eab58eead2a1beef18f12ad810176c15d20c11a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
# postgresql/json.py
# Copyright (C) 2005-2015 the SQLAlchemy authors and contributors
# <see AUTHORS file>
#
# This module is part of SQLAlchemy and is released under
# the MIT License: http://www.opensource.org/licenses/mit-license.php
from __future__ import absolute_import

import collections
import json

from .base import ischema_names
from ... import types as sqltypes
from ...sql import elements
from ...sql import expression
from ... import util
from operator import getitem


__all__ = ('JSON',)


class JSON(sqltypes.Indexable, sqltypes.TypeEngine):

    """Represent the Mysql JSON type.

    The :class:`.JSON` type stores arbitrary JSON format data, e.g.::

        data_table = Table('data_table', metadata,
            Column('id', Integer, primary_key=True),
            Column('data', JSON)
        )

        with engine.connect() as conn:
            conn.execute(
                data_table.insert(),
                data = {"key1": "value1", "key2": "value2"}
            )

    :class:`.JSON` provides several operations:

    * Index operations::

        data_table.c.data['some key']

    * Index operations with CAST
      (equivalent to
        ``CAST(JSON_EXPORT(data_table.data, $."some key" AS <type>)``)::

        data_table.c.data['some key'].cast(Integer)

    * Path index operations::

        data_table.c.data[('key_1', 'key_2', ..., 'key_n')]

    * Path attribute operations::

        data_table.c.data.json.key_1.key_2

        (equivalent to: data_table.c.data.json['key_1']['key_2'])

    * Wildcards::

        data_table.c.data['*'], rendered as [*], array index wildcard
        data_table.c.data['%'], rendered as .*, object key wildcard

    Index operations return an expression object whose type defaults to
    :class:`.JSON` by default, so that further JSON-oriented instructions
    may be called upon the result type.

    The :class:`.JSON` type, when used with the SQLAlchemy ORM, does not
    detect in-place mutations to the structure.  In order to detect these, the
    :mod:`sqlalchemy.ext.mutable` extension must be used.  This extension will
    allow "in-place" changes to the datastructure to produce events which
    will be detected by the unit of work.  See the example at :class:`.HSTORE`
    for a simple example involving a dictionary.

    When working with NULL values, the :class:`.JSON` type recommends the
    use of two specific constants in order to differentiate between a column
    that evaluates to SQL NULL, e.g. no value, vs. the JSON-encoded string
    of ``"null"``.   To insert or select against a value that is SQL NULL,
    use the constant :func:`.null`::

        conn.execute(table.insert(), json_value=null())

    To insert or select against a value that is JSON ``"null"``, use the
    constant :attr:`.JSON.NULL`::

        conn.execute(table.insert(), json_value=JSON.NULL)

    The :class:`.JSON` type supports a flag
    :paramref:`.JSON.none_as_null` which when set to True will result
    in the Python constant ``None`` evaluating to the value of SQL
    NULL, and when set to False results in the Python constant
    ``None`` evaluating to the value of JSON ``"null"``.    The Python
    value ``None`` may be used in conjunction with either
    :attr:`.JSON.NULL` and :func:`.null` in order to indicate NULL
    values, but care must be taken as to the value of the
    :paramref:`.JSON.none_as_null` in these cases.

    Custom serializers and deserializers are specified at the dialect level,
    that is using :func:`.create_engine`.  The reason for this is that when
    using psycopg2, the DBAPI only allows serializers at the per-cursor
    or per-connection level.   E.g.::

        engine = create_engine("postgresql://scott:tiger@localhost/test",
                                json_serializer=my_serialize_fn,
                                json_deserializer=my_deserialize_fn
                        )

    When using the psycopg2 dialect, the json_deserializer is registered
    against the database using ``psycopg2.extras.register_default_json``.

    .. versionadded:: 1.1

    """

    __visit_name__ = 'JSON'

    hashable = False

    NULL = util.symbol('JSON_NULL')
    """Describe the json value of NULL.

    This value is used to force the JSON value of ``"null"`` to be
    used as the value.   A value of Python ``None`` will be recognized
    either as SQL NULL or JSON ``"null"``, based on the setting
    of the :paramref:`.JSON.none_as_null` flag; the :attr:`.JSON.NULL`
    constant can be used to always resolve to JSON ``"null"`` regardless
    of this setting.  This is in contrast to the :func:`.sql.null` construct,
    which always resolves to SQL NULL.  E.g.::

        from sqlalchemy import null
        from sqlalchemy.dialects.postgresql import JSON

        obj1 = MyObject(json_value=null())  # *always* insert SQL NULL
        obj2 = MyObject(json_value=JSON.NULL)  # *always* insert JSON "null"

        session.add_all([obj1, obj2])
        session.commit()

    .. versionadded:: 1.1

    """

    def __init__(self, none_as_null=False):
        """Construct a :class:`.JSON` type.

        :param none_as_null: if True, persist the value ``None`` as a
         SQL NULL value, not the JSON encoding of ``null``.   Note that
         when this flag is False, the :func:`.null` construct can still
         be used to persist a NULL value::

             from sqlalchemy import null
             conn.execute(table.insert(), data=null())

         .. seealso::

              :attr:`.JSON.NULL`

         .. versionadded:: 1.1.0

         """
        self.none_as_null = none_as_null

    @property
    def should_evaluate_none(self):
        return not self.none_as_null

    class Comparator(
            sqltypes.Indexable.Comparator, sqltypes.Concatenable.Comparator):
        """Define comparison operations for :class:`.JSON`."""

        def _setup_getitem(self, index):
            return getitem, JSON._massage_index(index), self.type

        def operate(self, op, *other, **kwargs):
            if op.__name__ == 'getitem':
                return JSON.MySqlJsonExtract(self.expr, *other)
            else:
                if len(other) > 0 and isinstance(
                        other[0], (list, tuple, dict)):
                    # convert list, tuple or dict json doc.
                    other = list(other)
                    other[0] = JSON.MySqlJsonDocument(other[0], op)

                return super(JSON.Comparator, self).operate(
                    op, *other, **kwargs)

        @property
        def json(self):
            """Property which allows attribute access to document fields
            eg:
                data_table.c.data.json.k1

            is equivalent to:
                data_table.c.data['k1']
            """
            return JSON.MySqlJsonExtract(self.expr, "", attribute_access=True)

    comparator_factory = Comparator

    class MySqlJsonExtract(expression.Function, dict):
        """Represents a Json Extract Function which can be invoked via getitem.
        """

        def __init__(self, doc, path, attribute_access=False):
            self.json_doc = doc
            self.json_path = path
            self.attribute_access = attribute_access
            expression.Function.__init__(
                    self, "JSON_EXTRACT", doc, '$' + path, type_=JSON)

            # these two lines, along with the json property on the
            # comparator above, support the __getattr__ method below
            if self.attribute_access:
                local_dict = {}
                dict.__init__(self, local_dict)

        def __getitem__(self, path):
            """getitem on an existing json_extract just adds to the path"""
            new_path = self.json_path + JSON._massage_index(path)
            return JSON.MySqlJsonExtract(
                self.json_doc, new_path, attribute_access=self.attribute_access)

        def __getattr__(self, item):
            """Maps attributes to json doc path.  Only called if this class
            does not have an attribute with this name.  This allows attribute
            based access to sub-documents, but it means that hasattr()
            will now be broken when acting on this class.

            The test for "item[0] != '_' works around this for the known
            cases of hasattr() usage in the framework. So attributes names
            not starting with '_' will be considered to be part of the
            json document.
            """
            if self.attribute_access or item[0] != '_':
                return self.__getitem__(item)
            raise AttributeError(item)

    class MySqlJsonDocument(expression.Function):
        """Represents a literal Json Document"""

        def __init__(self, doc, op=None):
            if isinstance(doc, (list, tuple)):
                name = "json_array"
                empty = '[]'
            else:
                name = "json_object"
                empty = '{}'

            self.obj = doc
            bound_doc = expression.bindparam(
                    name, value=doc, type_=JSON, unique=True,
                    _compared_to_operator=op, _compared_to_type=JSON)

            # could not find a way to represent a json literal document,
            #  so resorted to JSON_MERGE() function which takes as a
            # parameter a json document.
            expression.Function.__init__(
                self, "JSON_MERGE", empty, bound_doc, type_=JSON)

    @staticmethod
    def _massage_index(index):
        """
        Integers are assumed to be array lookups and are formatted as: [x]
        Strings are assumed to be dict lookups and are formatted as: ."x"
        Wildcard ['*'] from python is formatted as an array wildcard [*]
        Wildcard ['%'] from python is formatted as an object wildcard .*
        """

        if isinstance(index, int):
            index = '[%d]' % index
        elif not isinstance(index, util.string_types):
            assert isinstance(index, collections.Sequence)
            tokens = ["%s" % JSON._massage_index(elem) for elem in index]
            index = "".join(tokens)
        elif index == '%':
            index = '.*'
        elif index == '*':
            index = '[*]'
        elif index[:2] != '**':
            index = '."%s"' % index
        return index

    def bind_processor(self, dialect):
        json_serializer = dialect._json_serializer or json.dumps
        if util.py2k:
            encoding = dialect.encoding
        else:
            encoding = None

        def process(value):
            if value is self.NULL:
                value = None
            elif isinstance(value, elements.Null) or (
                value is None and self.none_as_null
            ):
                return None
            if encoding:
                encoded = json_serializer(value).encode(encoding)
            else:
                encoded = json_serializer(value)

            return encoded

        return process

    def result_processor(self, dialect, coltype):
        json_deserializer = dialect._json_deserializer or json.loads
        if util.py2k:
            encoding = dialect.encoding
        else:
            encoding = None

        def process(value):
            if value is None:
                return None
            if encoding:
                value = value.decode(encoding)
            return json_deserializer(value)
        return process


ischema_names['json'] = JSON