marshmallow icon indicating copy to clipboard operation
marshmallow copied to clipboard

Allow conditionally disabling fields

Open ThiefMaster opened this issue 3 years ago • 1 comments

I have this schema:

class RequestAccessSchema(mm.Schema):
    request_cern_access = fields.Bool(load_default=False, data_key='cern_access_request_cern_access')
    birth_date = fields.Date(load_default=None, data_key='cern_access_birth_date',
                             validate=validate_with_message(lambda x: x <= date.today(),
                                                            'The specified date is in the future'))
    nationality = fields.String(load_default='', data_key='cern_access_nationality')
    birth_place = fields.String(load_default='', data_key='cern_access_birth_place')
    by_car = fields.Bool(load_default=False, data_key='cern_access_by_car')
    license_plate = fields.String(data_key='cern_access_license_plate', load_default='')

    @validates_schema
    def validate_everything(self, data, **kwargs):
        # This ugly mess is needed since we can't skip fields conditionally...
        if not data['request_cern_access']:
            return
        required_fields = {'birth_date', 'nationality', 'birth_place'}
        if data['by_car']:
            required_fields.add('license_plate')
        errors = {}
        for field in required_fields:
            if not data[field]:
                errors[self.fields[field].data_key] = ['This field is required.']
        if data['by_car'] and data['license_plate']:
            try:
                validate.And(
                    validate.Length(min=3),
                    validate.Regexp(r'^[0-9A-Za-z]+([- ][ ]*[0-9A-Za-z]+)*$')
                )(data['license_plate'])
            except ValidationError as exc:
                errors.setdefault(self.fields['license_plate'].data_key, []).extend(exc.messages)
        if errors:
            raise ValidationError(errors)

Depending on the bool fields in there, the rest of the data is either completely ignored (I set it to falsy values in a post_load function) or required (with some validation needed). It would be really nice if there was some way to disable fields during parse time; that way I could simply make them required and specify the validators declaratively, instead of having to fall back to this abomination up there.

If you have any other ideas on how this could be done without the mess above, maybe even using features already present in marshmallow, please let me know! :)

ThiefMaster avatar Mar 02 '22 16:03 ThiefMaster

This is effectively polymorphism, but with multiple inheritance. AFAIK none of the community polymorphism libraries handle this use case. If I were in this situation I would want to declare field validators conditionally:

birth_date = fields.Date(validate=When(['request_cern_access'], Required))
# or
birth_date = fields.Date(required=['request_cern_access'])
# but...

Field validators don't have access to the full data. Field.deserialize() has access to the serialized data though. If you are willing to hide the hackiness in a field instrumentation helper, I would do something like:

from marshmallow import missing


def required_for(prerequisites, field, *args, **kwargs):
    class _Field(field):
        def deserialize(self, value, attr, data, **dkwargs):
            required = True
            for p in prerequisites:
                if p not in data or not data[p]:
                    required = False
                    break
            if required and value is missing:
                raise self.make_error('required')
            return super().deserialize(value, attr, data, **dkwargs)
    return _Field(*args, **kwargs)
from marshmallow import Schema, fields


class TestSchema(Schema):
    foo = fields.Bool()
    bar = required_for(['foo'], fields.String)


schema = TestSchema()

print(schema.load({}))
# {}

print(schema.load({'foo': False}))
# {'foo': False}

print(schema.load({'foo': True, 'bar': 'baz'}))
# {'bar': 'baz', 'foo': True}

print(schema.load({'foo': True}))
# marshmallow.exceptions.ValidationError: {'bar': ['Missing data for required field.']}

deckar01 avatar Mar 03 '22 00:03 deckar01