GithubHelp home page GithubHelp logo

edelooff / sqlalchemy-json Goto Github PK

View Code? Open in Web Editor NEW
184.0 8.0 34.0 51 KB

Full-featured JSON type with mutation tracking for SQLAlchemy

Home Page: http://variable-scope.com/posts/mutation-tracking-in-nested-json-structures-using-sqlalchemy

License: BSD 2-Clause "Simplified" License

Python 100.00%

sqlalchemy-json's Introduction

sqlalchemy-json

SQLAlchemy-JSON provides mutation-tracked JSON types to SQLAlchemy:

  • MutableJson is a straightforward implementation for keeping track of top-level changes to JSON objects;
  • NestedMutableJson is an extension of this which tracks changes even when these happen in nested objects or arrays (Python dicts and lists).

Examples

Basic change tracking

This is essentially the SQLAlchemy mutable JSON recipe. We define a simple author model which list the author's name and a property handles for various social media handles used:

class Author(Base):
    name = Column(Text)
    handles = Column(MutableJson)

Or, using the declarative mapping style:

class Category(Base):
    __tablename__ = "categories"

    id = mapped_column(Integer, primary_key=True)
    created_at: Mapped[DateTime] = mapped_column(DateTime, default=datetime.now)
    updated_at: Mapped[DateTime] = mapped_column(
        DateTime, default=datetime.now, onupdate=datetime.now
    )
    keywords: Mapped[list[str]] = mapped_column(MutableJson)

The example below loads one of the existing authors and retrieves the mapping of social media handles. The error in the twitter handle is then corrected and committed. The change is detected by SQLAlchemy and the appropriate UPDATE statement is generated.

>>> author = session.query(Author).first()
>>> author.handles
{'twitter': '@JohnDoe', 'facebook': 'JohnDoe'}
>>> author.handles['twitter'] = '@JDoe'
>>> session.commit()
>>> author.handles
{'twitter': '@JDoe', 'facebook': 'JohnDoe'}

Nested change tracking

The example below defines a simple model for articles. One of the properties on this model is a mutable JSON structure called references which includes a count of links that the article contains, grouped by domain:

class Article(Base):
    author = Column(ForeignKey('author.name'))
    content = Column(Text)
    references = Column(NestedMutableJson)

With this in place, an existing article is loaded and its current references inspected. Following that, the count for one of these is increased by ten, and the session is committed:

>>> article = session.query(Article).first()
>>> article.references
{'github.com': {'edelooff/sqlalchemy-json': 4, 'zzzeek/sqlalchemy': 7}}
>>> article.references['github.com']['edelooff/sqlalchemy-json'] += 10
>>> session.commit()
>>> article.references
{'github.com': {'edelooff/sqlalchemy-json': 14, 'zzzeek/sqlalchemy': 7}}

Had the articles model used MutableJson like in the previous example this code would have failed. This is because the top level dictionary is never altered directly. The nested mutable ensures the change happening at the lower level bubbles up to the outermost container.

Non-native JSON / other serialization types

By default, sqlalchemy-json uses the JSON column type provided by SQLAlchemy (specifically sqlalchemy.types.JSON.) If you wish to use another type (e.g. PostgreSQL's JSONB), your database does not natively support JSON (e.g. versions of SQLite before 3.37.2/), or you wish to serialize to a format other than JSON, you'll need to provide a different backing type.

This is done by using the utility function mutable_json_type. This type creator function accepts two parameters:

  • dbtype controls the database type used. This can be an existing type provided by SQLAlchemy or SQLALchemy-utils, or an augmented type to provide serialization to any other format;
  • nested controls whether the created type is made mutable based on MutableDict or NestedMutable (defaults to False for MutableDict).
import json

from sqlalchemy import JSON, String, TypeDecorator
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy_json import mutable_json_type

class JsonString(TypeDecorator):
    """Enables JSON storage by encoding and decoding on the fly."""

    impl = String

    def process_bind_param(self, value, dialect):
        return json.dumps(value)

    def process_result_value(self, value, dialect):
        return json.loads(value)


postgres_jsonb_mutable = mutable_json_type(dbtype=JSONB)
string_backed_nested_mutable = mutable_json_type(dbtype=JsonString, nested=True)

Dependencies

  • sqlalchemy

Development

Here's how to setup your development environment:

python -m venv .venv
. .venv/bin/activate
pip install -e ".[dev]"
# run tests
pytest

Changelog

0.7.0

  • Adds support for top-level list for MutableJson, rather than having that support only be available in the nested variant (#51)
  • Adds pytest as development dependency

0.6.0

  • Fixes pickling support (#36)
  • Drops python 2.x support (previously claimed, but already broken for some time)
  • Removes test runners for CPython 3.6 since Github actions support has been dropped

0.5.0

  • Fixes a lingering Python 3 compatibility issue (cmp parameter for TrackedList.sort)
  • Adds pickling and unpickling support (#28)
  • Adds tracking for dictionary in-place updates (#33)

0.4.0

  • Adds a type creation function to allow for custom or alternate serialization types. This allows for a way around the regression in SQLite compatibility introduced by v0.3.0.

0.3.0

  • Switches JSON base type to sqlalchemy.types.JSON from deprecated JSON type provided by SQLAlchemy-utils.

0.2.2

  • Fixes a bug where assigning None to the column resulted in an error (#10)

0.2.1

  • Fixes a typo in the README found after uploading 0.2.0 to PyPI.

0.2.0 (unreleased)

  • Now uses JSONType provided by SQLAlchemy-utils to handle backend storage;
  • Backwards incompatible: Changed class name JsonObject to MutableJson and NestedJsonObject to NestedMutableJson
  • Outermost container for NestedMutableJson can now be an array (Python list)

0.1.0 (unreleased)

Initial version. This initially carried a 1.0.0 version number but has never been released on PyPI.

sqlalchemy-json's People

Contributors

anler avatar brianmedigate avatar casualuser avatar coryvirok avatar cysnake4713 avatar edelooff avatar felixschwarz avatar idavidmcdonald avatar iloveitaly avatar leovp avatar morganchristiansson avatar mvdbeek avatar raviriley avatar torotil avatar ulope avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

sqlalchemy-json's Issues

Publish on PyPI

As requested in #1, publish this on PyPI for easier dependency management.

Decrease log verbosity?

This is a very useful utility, but it's polluting my debug logs. The changed debug message is useful but I'm less sure about the init message. If it only appeared on insert that would be one thing, but logging the object's repr every time you access an instance from the DB seems overkill. I have a relatively small application and the debug logs would otherwise be human-readable, so it's annoying. I realize it may be useful for other use cases, or for developing sqlalchemy-json, so maybe there could be an option to disable these messages?

Allow choosing column type

Currently sqlalchemy-utils json type doesn't support JSON data type in mysql8.

sqlalchemy-utils will not be updated to support it and sqlalchemy.types.JSON should be used according to kvesteri/sqlalchemy-utils#164 (comment)

We should default to sqlalchemy.types.JSON and maybe allow it to be configurable. Or make new major/minor release for breaking change if needed.

Postgres and MySQL8 support JSON column type. MariaDB supports it as an alias to LONGTEXT. Others DBs probably do not support it.

It looks like the postgres-specific subclass has some extra methods so not sure if it's preferred in some scenario? https://docs.sqlalchemy.org/en/13/dialects/postgresql.html#sqlalchemy.dialects.postgresql.JSON

Library throws 'super' object has no attribute 'coerce' when column value is null()

Code to reproduce the exception

from sqlalchemy import Column
from sqlalchemy import create_engine, null, Integer
from sqlalchemy.dialects.sqlite import JSON
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy_json import mutable_json_type

Base = declarative_base()
eng = create_engine("sqlite://")


class JTable(Base):
    __tablename__ = 'jtable'
    id = Column(Integer, primary_key=True)
    value = Column(mutable_json_type(JSON, nested=True))


def create_table():
    conn = eng.connect()
    conn.execute(
        """
        CREATE TABLE IF NOT EXISTS jtable  (
            id INTEGER PRIMARY KEY,
            value TEXT NULL
        );
        """
    )


if __name__ == "__main__":
    create_table()
    # throws - AttributeError: 'super' object has no attribute 'coerce'
    row = JTable(value=null())

This seem very similar to #10 and probably can be solved by adding similar checks.
I can prepare a PR if necessary

`AttributeError: 'super' object has no attribute 'coerce'` when setting value as `None`

If I have a JSON field, say a_json that has been associated with NestedMutable I cannot set it's value as None without triggering an AttributeError as None does not have the coerce attribute.

For example:

File "example.py", line 83, in <module>
    item1.a_json = None
  File "/Users/davidmcdonald/sqlalchemy-bug-example/venv/lib/python3.6/site-packages/sqlalchemy/orm/attributes.py", line 229, in __set__
    instance_dict(instance), value, None)
  File "/Users/davidmcdonald/sqlalchemy-bug-example/venv/lib/python3.6/site-packages/sqlalchemy/orm/attributes.py", line 708, in set
    value, old, initiator)
  File "/Users/davidmcdonald/sqlalchemy-bug-example/venv/lib/python3.6/site-packages/sqlalchemy/orm/attributes.py", line 715, in fire_replace_event
    state, value, previous, initiator or self._replace_token)
  File "/Users/davidmcdonald/sqlalchemy-bug-example/venv/lib/python3.6/site-packages/sqlalchemy/ext/mutable.py", line 495, in set
    value = cls.coerce(key, value)
  File "/Users/davidmcdonald/sqlalchemy-bug-example/venv/lib/python3.6/site-packages/sqlalchemy_json/__init__.py", line 44, in coerce
    return super(cls).coerce(key, value)

I believe setting the value as None should be allowed and should be quite an easy one to fix. I'm happy to put in a PR if you agree this is sensible, just let me know.

Thanks :)

Pickle throws TypeError

Hello,

I have a few DB objects i want to move around, meaning I have to pickle the actual instances.
I discovered that when the Classes contain a NestedMutableJson column the pickle fails with the error message TypeError: cannot pickle 'weakref' object.

I reduced it to the following test code:

import pickle
from sqlalchemy.orm import registry
from sqlalchemy import Column, BigInteger
from sqlalchemy.orm import relationship, deferred
from sqlalchemy_json import NestedMutableJson

mapper_registry_conf = registry()
ConfBase = mapper_registry_conf.generate_base()
class Test(ConfBase):
    __tablename__ = 'test'
    id = Column(BigInteger(), primary_key=True)
    data = Column(NestedMutableJson)

Now if I create an instance with data=None it works fine:

In [2]: pickle.dumps(Test(id=1,data=None))
Out[2]: b'\x80\x04\x95[\x01\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x04Test\x94\x93\x94)\x81\x94}\x94(\x8c\x12_sa_instance_state\x94\x8c\x14sqlalchemy.orm.state\x94\x8c\rInstanceState\x94\x93\x94)\x81\x94}\x94(\x8c\x08instance\x94h\x03\x8c\x0fcommitted_state\x94}\x94(\x8c\x02id\x94\x8c\x1bsqlalchemy.util.langhelpers\x94\x8c\x06symbol\x94\x93\x94\x8c\x08NO_VALUE\x94\x8c\x01x\x94\x8a\x08\xd3\xad\x9d\xec\x0c\xdceM\x87\x94R\x94\x8c\x04data\x94h\x15u\x8c\x08modified\x94\x88\x8c\x06class_\x94h\x02\x8c\x12expired_attributes\x94\x8f\x94\x8c\x07manager\x94\x8c\x1esqlalchemy.orm.instrumentation\x94\x8c\x11_SerializeManager\x94\x93\x94)\x81\x94}\x94h\x18h\x02sbubh\x0eK\x01h\x16Nub.'

But trying to pickle the instance with an (empty) dict fails:

In [3]: pickle.dumps(Test(id=1,data={}))
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-3-e9731691560e> in <module>
----> 1 pickle.dumps(Test(id=1,data={}))

TypeError: cannot pickle 'weakref' object

The same happens when I use a list:

In [4]: pickle.dumps(Test(id=1,data=[]))
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-4-9bfe292c9e9b> in <module>
----> 1 pickle.dumps(Test(id=1,data=[]))

TypeError: cannot pickle 'weakref' object

My version information. We are talking about a conda environment with python 3.9 on an actual openSuSE 15.4.

(test) msebas@C1168:~/workspace/test>pip list | grep -i sql
PyMySQL                       1.0.2
SQLAlchemy                    1.4.46
sqlalchemy-json               0.5.0
SQLAlchemy-Utils              0.40.0

Is there anything I am doing wrong or is this an inherent property of the lib?

Default column value doesn't apply until after flush

Not sure if it's a bug or just a limitation of SA itself, but I just noticed this. Would this be something you'd happen to know how to fix?

It's not a major issue, but it'd be better if the default value was set automatically after creating the class.

class MyModel(Base):
    data = Column(NestedMutableJson, default={}, nullable=False)

model = MyModel()
session.add(model)
print(model.data)
# None

session.flush()
print(model.data)
# {}

Trivial doc request re SQLite support for JSON

Thanks for this package. I just want to ask if it might be appropriate to update this README.rst text:

.. your database does not natively support JSON (e.g. SQLite),
or you wish to serialize to a format other than JSON,
you'll need to provide a different backing type.

I believe - but please correct me if I got this wrong - that SQLite supports JSON natively since version 3.9. The current version in Python 3.9 is 3.36.

Hope this helps.

Validator support

Hi,

Another weakness in sqlalchemy's mutable object support is the fact that @validates validators aren't run when nested objects are mutated, meaning changes can bypass validation - far from ideal. It would be nice if your library also made an attempt at fixing this, making it more of a "complete solution".

One potential pitfall I guess could be issues with expensive validation functions causing poor performance.

setdefault() on TrackedDict doesn't convert the default parameter to a TrackedObject

In the following code:

foo = nested_mutable_json.setdefault('foo', {})
foo['bar'] = 'baz'

the __setitem__ will convert the dict to a TrackedDict, but setdefault will still return the original dict, so operations on foo will not be saved to the TrackedDict.

I'd be happy to open a PR with a fix (just override setdefault in TrackedDict to convert the default parameter and then call super().setdefault)

Possible to query JSON postgres field?

Hi,

I'm doing something like:

class JsonData(Base):
    __tablename__ = 'json_datas'
    id = Column(Integer, primary_key=True)
    Column(MutableDict.as_mutable(pg_JSONB)) #from sqlalchemy.ext.mutable
    nested_mutable_json = Column(NestedMutableJson)


value = dict(key = 'test')

jd = JsonData(nested_mutable_json = value, jsonb_mutable_dict = value)
session.add(jd)
session.commit()

print(jd.nested_mutable_json) #looks OK
print(jd.nested_mutable_json['key']) #looks OK

jd = session.query(JsonData).filter(
    JsonData.jsonb_mutable_dict['key'].astext == 'test'
).one() #worked OK

print('instance retrieved with jsonb_mutable_dict')

jd = session.query(JsonData).filter(
    JsonData.nested_mutable_json['key'].astext == 'test'
).one()

print('test_search_by_key_value for nested_mutable_json')

Doing the filter query worked with the sqlalchemy MutableDict. However, it failes for the NestedMutableJson withthe error below:

Traceback (most recent call last):
  File "/Users/Victor/Documents/Python/Project/src/scripts/review_postgres_sql-alchmey_json_field_properties.py", line 173, in <module>
    test_search_by_key_value_explicit()
  File "/Users/Victor/Documents/Python/Project/src/scripts/review_postgres_sql-alchmey_json_field_properties.py", line 118, in test_search_by_key_value_explicit
    JsonData.nested_mutable_json['key'].astext == 'test'
  File "/Users/Victor/Documents/Python/Project/venv_project/lib/python3.6/site-packages/sqlalchemy/sql/operators.py", line 371, in __getitem__
	review_postgres_sqleturn self.operate(getitem, index)
  File "/Users/Victor/Documents/Python/Project/venv_project/lib/python3.6/site-packages/sqlalchemy/orm/attributes.py", line 175, in operate
    return op(self.comparator, *other, **kwargs)
  File "/Users/Victor/Documents/Python/Project/venv_project/lib/python3.6/site-packages/sqlalchemy/sql/operators.py", line 371, in __getitem__
    return self.operate(getitem, index)
  File "/Users/Victor/Documents/Python/Project/venv_project/lib/python3.6/site-packages/sqlalchemy/orm/properties.py", line 270, in operate
    return op(self.__clause_element__(), *other, **kwargs)
  File "/Users/Victor/Documents/Python/Project/venv_project/lib/python3.6/site-packages/sqlalchemy/sql/operators.py", line 371, in __getitem__
    return self.operate(getitem, index)
  File "/Users/Victor/Documents/Python/Project/venv_project/lib/python3.6/site-packages/sqlalchemy/sql/elements.py", line 686, in operate
    return op(self.comparator, *other, **kwargs)
  File "/Users/Victor/Documents/Python/Project/venv_project/lib/python3.6/site-packages/sqlalchemy/sql/operators.py", line 371, in __getitem__
    return self.operate(getitem, index)
  File "/Users/Victor/Documents/Python/Project/venv_project/lib/python3.6/site-packages/sqlalchemy/sql/type_api.py", line 815, in operate
    op, *other, **kwargs)
  File "<string>", line 1, in <lambda>
  File "/Users/Victor/Documents/Python/Project/venv_project/lib/python3.6/site-packages/sqlalchemy/sql/type_api.py", line 63, in operate
    return o[0](self.expr, op, *(other + o[1:]), **kwargs)
  File "/Users/Victor/Documents/Python/Project/venv_project/lib/python3.6/site-packages/sqlalchemy/sql/default_comparator.py", line 177, in _getitem_impl
    _unsupported_impl(expr, op, other, **kw)
  File "/Users/Victor/Documents/Python/Project/venv_project/lib/python3.6/site-packages/sqlalchemy/sql/default_comparator.py", line 182, in _unsupported_impl
    "this expression" % op.__name__)
NotImplementedError: Operator 'getitem' is not supported on this expression

Is it possible to search like I do with the sqlalchemy MutableDict? If so. would it be possible to provide an example?

Thank you!

~Victor

Support typing

Since sqlalchemy 2.0 supports mapped columns, is it possible to add typing support to this library. Currently, if you try :

foo_json: Mapped[Optional[Dict[Any, Any]]] = mapped_column(NestedMutableJson)

You get the following warning:
Argument type is partially unknown Argument corresponds to parameter "__name_pos" in function "mapped_column" Argument type is "TypeEngine[Unknown]"Pylance[reportUnknownArgumentType](https://github.com/microsoft/pyright/blob/main/docs/configuration.md#reportUnknownArgumentType) (variable) NestedMutableJson: TypeEngine[_T@as_mutable]

How to get a read-only copy?

In some most cases I use a JSON to store some configs to the database. When editing the config it’s very comfortable to be able to rely on the mutation tracking, when reading the config I want to make sure that the config is not modified. This is especially important when the config needs some normalization before being used.

According to my tests neither copy() nor deepcopy() do the job.

What’s the best way to achieve this? Is it worth implementing the __copy__() and __deepcopy__() magic methods?

AttributeError: 'super' object has no attribute 'coerce' in class NestedMutable(Mutable):

 File "/usr/local/lib/python2.7/dist-packages/sqlalchemy/orm/query.py", line 2399, in all
   return list(self)
 File "/usr/local/lib/python2.7/dist-packages/sqlalchemy/orm/loading.py", line 86, in instances
   util.raise_from_cause(err)
 File "/usr/local/lib/python2.7/dist-packages/sqlalchemy/util/compat.py", line 199, in raise_from_cause
   reraise(type(exception), exception, tb=exc_tb)
 File "/usr/local/lib/python2.7/dist-packages/sqlalchemy/orm/loading.py", line 71, in instances
   rows = [proc(row) for row in fetch]
 File "/usr/local/lib/python2.7/dist-packages/sqlalchemy/orm/loading.py", line 432, in _instance
   state.manager.dispatch.load(state, context)
 File "/usr/local/lib/python2.7/dist-packages/sqlalchemy/event/attr.py", line 256, in __call__
   fn(*args, **kw)
 File "/usr/local/lib/python2.7/dist-packages/sqlalchemy/ext/mutable.py", line 451, in load
   val = cls.coerce(key, val)
 File "/srv/www/conferency/app/utils/customDataType.py", line 214, in coerce
   return super(cls).coerce(key, value)
AttributeError: 'super' object has no attribute 'coerce'
class TrackedObject(object):
    """A base class for delegated change-tracking."""
    _type_mapping = {}

    def __init__(self, *args, **kwds):
        self.logger = logging.getLogger(type(self).__name__)
        self.parent = None
        self.logger.debug('%s: __init__', self._repr())
        super(TrackedObject, self).__init__(*args, **kwds)

    def changed(self, message=None, *args):
        """Marks the object as changed.

        If a `parent` attribute is set, the `changed()` method on the parent
        will be called, propagating the change notification up the chain.

        The message (if provided) will be debug logged.
        """
        if message is not None:
            self.logger.debug('%s: %s', self._repr(), message % args)
        self.logger.debug('%s: changed', self._repr())
        if self.parent is not None:
            self.parent.changed()
        elif isinstance(self, Mutable):
            super(TrackedObject, self).changed()

    @classmethod
    def register(cls, origin_type):
        """Decorator for mutation tracker registration.

        The provided `origin_type` is mapped to the decorated class such that
        future calls to `convert()` will convert the object of `origin_type`
        to an instance of the decorated class.
        """
        def decorator(tracked_type):
            """Adds the decorated class to the `_type_mapping` dictionary."""
            cls._type_mapping[origin_type] = tracked_type
            return tracked_type
        return decorator

    @classmethod
    def convert(cls, obj, parent):
        """Converts objects to registered tracked types

        This checks the type of the given object against the registered tracked
        types. When a match is found, the given object will be converted to the
        tracked type, its parent set to the provided parent, and returned.

        If its type does not occur in the registered types mapping, the object
        is returned unchanged.
        """
        replacement_type = cls._type_mapping.get(type(obj))
        if replacement_type is not None:
            new = replacement_type(obj)
            new.parent = parent
            return new
        return obj

    def convert_iterable(self, iterable):
        """Generator to `convert` every member of the given iterable."""
        return (self.convert(item, self) for item in iterable)

    def convert_items(self, items):
        """Generator like `convert_iterable`, but for 2-tuple iterators."""
        return ((key, self.convert(value, self)) for key, value in items)

    def convert_mapping(self, mapping):
        """Convenience method to track either a dict or a 2-tuple iterator."""
        if isinstance(mapping, dict):
            return self.convert_items(iteritems(mapping))
        return self.convert_items(mapping)

    def _repr(self):
        """Simple object representation."""
        return '<%(namespace)s.%(type)s object at 0x%(address)0xd>' % {
            'namespace': __name__,
            'type': type(self).__name__,
            'address': id(self)}


@TrackedObject.register(dict)
class TrackedDict(TrackedObject, dict):
    """A TrackedObject implementation of the basic dictionary."""
    def __init__(self, source=(), **kwds):
        super(TrackedDict, self).__init__(itertools.chain(
            self.convert_mapping(source),
            self.convert_mapping(kwds)))

    def __setitem__(self, key, value):
        self.changed('__setitem__: %r=%r', key, value)
        super(TrackedDict, self).__setitem__(key, self.convert(value, self))

    def __delitem__(self, key):
        self.changed('__delitem__: %r', key)
        super(TrackedDict, self).__delitem__(key)

    def clear(self):
        self.changed('clear')
        super(TrackedDict, self).clear()

    def pop(self, *key_and_default):
        self.changed('pop: %r', key_and_default)
        return super(TrackedDict, self).pop(*key_and_default)

    def popitem(self):
        self.changed('popitem')
        return super(TrackedDict, self).popitem()

    def update(self, source=(), **kwds):
        self.changed('update(%r, %r)', source, kwds)
        super(TrackedDict, self).update(itertools.chain(
            self.convert_mapping(source),
            self.convert_mapping(kwds)))


@TrackedObject.register(list)
class TrackedList(TrackedObject, list):
    """A TrackedObject implementation of the basic list."""
    def __init__(self, iterable=()):
        super(TrackedList, self).__init__(self.convert_iterable(iterable))

    def __setitem__(self, key, value):
        self.changed('__setitem__: %r=%r', key, value)
        super(TrackedList, self).__setitem__(key, self.convert(value, self))

    def __delitem__(self, key):
        self.changed('__delitem__: %r', key)
        super(TrackedList, self).__delitem__(key)

    def append(self, item):
        self.changed('append: %r', item)
        super(TrackedList, self).append(self.convert(item, self))

    def extend(self, iterable):
        self.changed('extend: %r', iterable)
        super(TrackedList, self).extend(self.convert_iterable(iterable))

    def remove(self, value):
        self.changed('remove: %r', value)
        return super(TrackedList, self).remove(value)

    def pop(self, index):
        self.changed('pop: %d', index)
        return super(TrackedList, self).pop(index)

    def sort(self, cmp=None, key=None, reverse=False):
        self.changed('sort')
        super(TrackedList, self).sort(cmp=cmp, key=key, reverse=reverse)


class NestedMutableDict(TrackedDict, Mutable):
    @classmethod
    def coerce(cls, key, value):
        if isinstance(value, cls):
            return value
        if isinstance(value, dict):
            return cls(value)
        return super(cls).coerce(key, value)


class NestedMutableList(TrackedList, Mutable):
    @classmethod
    def coerce(cls, key, value):
        if isinstance(value, cls):
            return value
        if isinstance(value, list):
            return cls(value)
        return super(cls).coerce(key, value)


class NestedMutable(Mutable):
    """SQLAlchemy `mutable` extension with nested change tracking."""
    @classmethod
    def coerce(cls, key, value):
        """Convert plain dictionary to NestedMutable."""
        if value is None:
            return value
        if isinstance(value, cls):
            return value
        if isinstance(value, dict):
            return NestedMutableDict.coerce(key, value)
        if isinstance(value, list):
            return NestedMutableList.coerce(key, value)
        return super(cls).coerce(key, value)


class MutableJson(JSONType):
    """JSON type for SQLAlchemy with change tracking at top level."""


class NestedMutableJson(JSONType):
    """JSON type for SQLAlchemy with nested change tracking."""


MutableDict.associate_with(MutableJson)
NestedMutable.associate_with(NestedMutableJson)

ValueError: Attribute 'alt_names' does not accept objects of type <class 'list'>

Hello, i took a break in programming. And for now the new type of error is appear, i think that its connected with new sqlalchemy 2.0 update.

I got this table:

class Game(Base):
    __tablename__ = "games"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    other_id:  Mapped[int] = mapped_column(Integer, index=True, unique=True, nullable=False)
    name: Mapped[str] = mapped_column(String, index=True, nullable=False)
    alt_names: Mapped[list] = mapped_column(mutable_json_type(dbtype=JSONB))

and this class to work with object:

class GameObject(Game):
    def __init__(self, session: AsyncSession, **kw: Any):
        super().__init__(**kw)
        self._session = session

    def parse_data(self, data: dict):
        alt_name_data = data.get("alternative_names", [])

        self.name = data["name"]
        self.other_id = data["id"]
        self.alt_names: list[str] = [_['name'] for _ in alt_name_data if _['comment'] in ALLOWED_TYPES]

The insertion is performed by function:

async def add_game_to_db(self):
    mutable_columns = self.__table__.columns.keys()
    del mutable_columns["id"]

    stm = insert(Game).values(**{getattr(self, column_name) for column_name in mutable_columns})
    stm = stm.on_conflict_do_update(index_elements=["other_id"],
                                    set_={field: getattr(stm.excluded, field) for field in self.__table__.columns.keys()})
    await self._session.execute(stm)

this actions result in error with traceback:

future: <Task finished name='Task-1' coro=<run.<locals>.new_coro() done, defined at G:\python projects\rate_bot\.venv\lib\site-packages\aiorun.py:209> exception=ValueError("Attribute 'alt_names' does not accept objects of type <class 'list'>")>
Traceback (most recent call last):
  File "G:\python projects\rate_bot\.venv\lib\site-packages\aiorun.py", line 219, in new_coro
    await coro
  File "G:\python projects\rate_bot\parser_main.py", line 32, in main
    game.parse_data(data)
  File "G:\python projects\rate_bot\data\game_object.py", line 26, in parse_data
    self.alt_names: list = [_['name'] for _ in alt_name_data if _['comment'] in ALLOWED_TYPES]
  File "G:\python projects\rate_bot\.venv\lib\site-packages\sqlalchemy\orm\attributes.py", line 536, in __set__
    self.impl.set(
  File "G:\python projects\rate_bot\.venv\lib\site-packages\sqlalchemy\orm\attributes.py", line 1276, in set
    value = self.fire_replace_event(
  File "G:\python projects\rate_bot\.venv\lib\site-packages\sqlalchemy\orm\attributes.py", line 1291, in fire_replace_event
    value = fn(
  File "G:\python projects\rate_bot\.venv\lib\site-packages\sqlalchemy\orm\events.py", line 2562, in wrap
    return fn(target, *arg)
  File "G:\python projects\rate_bot\.venv\lib\site-packages\sqlalchemy\ext\mutable.py", line 535, in set_
    value = cls.coerce(key, value)
  File "G:\python projects\rate_bot\.venv\lib\site-packages\sqlalchemy\ext\mutable.py", line 865, in coerce
    return Mutable.coerce(key, value)
  File "G:\python projects\rate_bot\.venv\lib\site-packages\sqlalchemy\ext\mutable.py", line 454, in coerce
    raise ValueError(msg % (key, type(value)))
ValueError: Attribute 'alt_names' does not accept objects of type <class 'list'>

I dont understand what should i do to fix this. The only way i see is to write my own mutable object as described in docs, but this way is too complex and looks like the same as this package. Can you help me with the fix? Thank you and sorry for bad english! :)

NestedMutableJson to dict

Hi. I would like to cast the NestedMutableJson to dict.

What can be the best solution for this problem ?

thank you

please create/push tags for new releases

It would be nice if each pypi release had an according git tag so it is easier to see which changes went into a specific release.

For example I'm using this quite often to determine the lowest suitable version of my dependencies.

Feature request: Make NestedMutableJson column values serializable

I’d like to dump the content of one of the NestedMutableJson columns into a JSON or YAML file. But this seems to be impossible at the moment. A json.dumps() fails with:

TypeError: Object of type _AssociationDict is not JSON serializable

I think this is also what #14 was trying to ask for.

Append action on NestedMutableJson field (list) doesn't trigger @validates decorated function

This may be related to the questions posed in #21, but I figured it best to post a fresh topic to express how important validation functions are. Coincidentally, it's the main reason I am having to look into this package. I have a JSON field where I want to store a simple list of strings. Now that SQLite support JSON fields as of version 3.9 (thanks to JSON1) I attempted to setup a list field a few different ways, but unfortunately quickly realized the limitations with mutations on these fields.

I've searched for possible solutions to this issue, which led me to this article, which inevitably led me to this package.

models.py

from sqlalchemy_json import NestedMutableJson  # because I need to store a list, not a dict
from sqlalchemy.ext.mutable import MutableList
from sqlalchemy.orm import validates
from app import db  # SQLAlchemy instance via flask_sqlalchemy
# SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'


class Portal(db.Model):
    __tablename__ = 'portal'

    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(100), nullable=False)
    # ex: valid_kwargs will be json array of ['full_year']
    valid_kwargs = db.Column(MutableList.as_mutable(db.JSON), nullable=False, default=list())


class Account(db.Model):
    __tablename__ = 'account'

    id = db.Column(db.Integer, primary_key=True)
    portal_id = db.Column(db.Integer, db.ForeignKey('portal.id'))  # one-to-one relationship
    username = db.Column(db.String(100), nullable=False)
    password = db.Column(db.String(100), nullable=False)
    # portal = accessible thanks to portal_id/portal.id ForeignKey
    
    # for extra fields, specific to each portal
    # ATTEMPT 1 - using sqlalchemy.ext.mutable.MutableList, no extra packages
    kwargs = db.Column(MutableList.as_mutable(db.JSON), nullable=False, default=list())
    # ATTEMPT 2 - using NestedMutableJson from sqlalchemy_json
    kwargs = db.Column(NestedMutableJson, nullable=False, default=list())
    

    @validates('kwargs')
    def validates_kwargs(self, key, value):
        # make sure that only valid_kwargs are added to the account
        if value and not all([kw in self.portal.valid_kwargs for kw in value]):
            raise ValueError('Invalid value provided in kwargs')
        return value

unit_tests/test_models.py

def test_account(test_db, account):
    """
    GIVEN a database instance
    WHEN a new account is created
    THEN check that the username, password, kwargs, and reports fields are defined correctly
    """
    test_db.session.add(account)
    test_db.session.commit()
    assert Account.query.count() == 1
    assert account.username == 'mhill'
    assert account.password == 'password1'
    assert isinstance(account.kwargs, list) and len(account.kwargs) == 0  # check that kwargs defaults to an empty list
    assert account.reports.count() == 0  # check that reports relationship is present
    assert isinstance(account.portal, Portal)  # check that portal relationship is present
    account.portal.valid_kwargs.append('full_year')
    account.kwargs.append('full_year')
    test_db.session.add(account)
    test_db.session.commit()
    assert len(account.kwargs) == 1
    assert 'full_year' in account.kwargs

    """
    GIVEN an account whose associated portal has valid_kwargs
    WHEN kwargs are added to the account
    THEN check that only the kwargs in the portal's valid_kwargs are allowed to be added
    """
    # FIXME: Doesn't throw an error
    with pytest.raises(ValueError):
        account.kwargs.append('illegal_kwarg')
        test_db.session.add(account)
        test_db.session.commit()

usecase: how to get the object history data to track obj change in event?

I try to get object change using following code, but MutableJSON has no history getted

def get_object_changes(obj):
    """Given a model instance, returns dict of pending
    changes waiting for database flush/commit.

    e.g. {
        "pre_change": {},
        "post_change": {},
        "diff": {}
    }
    """
    inspection = inspect(obj)
    changes = {
        "pre_change": {},
        "post_change": {},
        "diff": {},
    }
    for attr in class_mapper(obj.__class__).column_attrs:
        if getattr(inspection.attrs, attr.key).history.has_changes():
            if get_history(obj, attr.key)[2]:
                before = get_history(obj, attr.key)[2].pop()
                after = getattr(obj, attr.key)
                changes["pre_change"][attr.key] = before
                changes["post_change"][attr.key] = after
                if before != after:
                    if before or after:
                        changes["diff"][attr.key] = {"before": before, "after": after}
    return jsonable_encoder(changes)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.