edelooff / sqlalchemy-json

Full-featured JSON type with mutation tracking for SQLAlchemy
http://variable-scope.com/posts/mutation-tracking-in-nested-json-structures-using-sqlalchemy
BSD 2-Clause "Simplified" License
189 stars 34 forks source link

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

Open caffeinatedMike opened 3 years ago

caffeinatedMike commented 3 years ago

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()