From 377fa28b10d6d3a41ddbee5440f258d0cd283296 Mon Sep 17 00:00:00 2001 From: gurcuff91 Date: Fri, 29 Mar 2024 15:31:27 -0400 Subject: [PATCH] Improvement into mapper, added extra validators and FieldOptions and MapperOptions containers. --- mongotoy/db.py | 2 +- mongotoy/documents.py | 154 ++++++------- mongotoy/fields.py | 217 +++++-------------- mongotoy/mappers.py | 487 +++++++++++++++++++++++++----------------- pyproject.toml | 2 +- 5 files changed, 428 insertions(+), 434 deletions(-) diff --git a/mongotoy/db.py b/mongotoy/db.py index e44f9e7..04d1bb4 100644 --- a/mongotoy/db.py +++ b/mongotoy/db.py @@ -220,7 +220,7 @@ def _get_document_indexes( # Unwrap ManyMapper mapper = field.mapper - if isinstance(mapper, mappers.ManyMapper): + if isinstance(mapper, mappers.SequenceMapper): mapper = mapper.unwrap() # Add Geo Index diff --git a/mongotoy/documents.py b/mongotoy/documents.py index 51761ba..d925d67 100644 --- a/mongotoy/documents.py +++ b/mongotoy/documents.py @@ -12,8 +12,8 @@ __all__ = ( 'EmbeddedDocument', - 'Document', 'DocumentConfig', + 'Document', ) @@ -46,16 +46,19 @@ def __new__(mcls, name, bases, namespace, **kwargs): # Add class namespace declared fields for field_name, anno_type in namespace.get('__annotations__', {}).items(): - info = namespace.get(field_name, {}) - if not isinstance(info, dict): + options = namespace.get(field_name, fields.FieldOptions()) + if not isinstance(options, fields.FieldOptions): # noinspection PyTypeChecker,SpellCheckingInspection raise DocumentError( loc=(name, field_name), - msg=f'Invalid field descriptor {type(info)}. ' + msg=f'Invalid field descriptor {type(options)}. ' f'Use mongotoy.field() or mongotoy.reference() descriptors' ) try: - _fields[field_name] = fields.Field.from_annotated_type(anno_type, info=info) + _fields[field_name] = fields.Field( + mapper=mappers.build_mapper(anno_type, options=options), + options=options + ) except TypeError as e: # noinspection PyTypeChecker raise DocumentError( @@ -81,76 +84,6 @@ def __new__(mcls, name, bases, namespace, **kwargs): return _cls -class DocumentMeta(BaseDocumentMeta): - """ - Metaclass for document class. - """ - - # noinspection PyUnresolvedReferences - def __new__(mcls, name, bases, namespace, **kwargs): - """ - Creates a new instance of the document class. - - Args: - name (str): The name of the class. - bases (tuple): The base classes of the class. - namespace (dict): The namespace of the class. - **kwargs: Additional keyword arguments. - - Returns: - type: The new class instance. - - Raises: - DocumentError: If an error occurs during class creation. - - """ - _cls = super().__new__(mcls, name, bases, namespace) - - _id_field = None - _references = OrderedDict() - for field in _cls.__fields__.values(): - # Check id field - # noinspection PyProtectedMember - if field._id_field: - _id_field = field - - # Unwrap ManyMapper - _mapper = field.mapper - _is_many = False - if isinstance(_mapper, mappers.ManyMapper): - _mapper = _mapper.unwrap() - _is_many = True - - # Add references - if isinstance(_mapper, mappers.ReferencedDocumentMapper): - # noinspection PyProtectedMember,PyUnresolvedReferences - _references[field.name] = references.Reference( - document_cls=_mapper._document_cls, - ref_field=_mapper._ref_field, - key_name=_mapper._key_name or f'{field.alias}_{_mapper._ref_field}', - is_many=_is_many, - name=field.alias - ) - - if not _id_field: - _id_field = fields.Field( - mapper=mappers.ObjectIdMapper( - default_factory=lambda: bson.ObjectId() - ), - id_field=True - ) - _id_field.__set_name__(_cls, 'id') - - # Set class props - _cls.__fields__['id'] = _id_field - _cls.__references__ = _references - _cls.__collection_name__ = namespace.get('__collection_name__', f'{name.lower()}s') - _cls.id = _id_field - _cls.document_config = namespace.get('document_config', DocumentConfig()) - - return _cls - - # noinspection PyUnresolvedReferences class BaseDocument(abc.ABC, metaclass=BaseDocumentMeta): """ @@ -306,8 +239,79 @@ class EmbeddedDocument(BaseDocument): This class serves as a base for defining embedded documents within other documents. It inherits functionality from the BaseDocument class. + """ + +class DocumentMeta(BaseDocumentMeta): """ + Metaclass for document class. + """ + + # noinspection PyUnresolvedReferences + def __new__(mcls, name, bases, namespace, **kwargs): + """ + Creates a new instance of the document class. + + Args: + name (str): The name of the class. + bases (tuple): The base classes of the class. + namespace (dict): The namespace of the class. + **kwargs: Additional keyword arguments. + + Returns: + type: The new class instance. + + Raises: + DocumentError: If an error occurs during class creation. + + """ + _cls = super().__new__(mcls, name, bases, namespace) + + _id_field = None + _references = OrderedDict() + for field in _cls.__fields__.values(): + # Check id field + # noinspection PyProtectedMember + if field._options.id_field: + _id_field = field + + # Unwrap ManyMapper + _mapper = field.mapper + _is_many = False + if isinstance(_mapper, mappers.SequenceMapper): + _mapper = _mapper.unwrap() + _is_many = True + + # Add references + if isinstance(_mapper, mappers.ReferencedDocumentMapper): + # noinspection PyProtectedMember,PyUnresolvedReferences + _references[field.name] = references.Reference( + document_cls=_mapper._document_cls, + ref_field=_mapper._options.ref_field, + key_name=_mapper._options.key_name or f'{field.alias}_{_mapper._options.ref_field}', + is_many=_is_many, + name=field.alias + ) + + if not _id_field: + _options = fields.FieldOptions( + id_field=True, + default_factory=bson.ObjectId + ) + _id_field = fields.Field( + mapper=mappers.ObjectIdMapper(options=_options), + options=_options + ) + _id_field.__set_name__(_cls, 'id') + + # Set class props + _cls.__fields__['id'] = _id_field + _cls.__references__ = _references + _cls.__collection_name__ = namespace.get('__collection_name__', f'{name.lower()}s') + _cls.id = _id_field + _cls.document_config = namespace.get('document_config', DocumentConfig()) + + return _cls @dataclasses.dataclass diff --git a/mongotoy/fields.py b/mongotoy/fields.py index 5881c30..676df40 100644 --- a/mongotoy/fields.py +++ b/mongotoy/fields.py @@ -1,22 +1,38 @@ -import types +import dataclasses import typing import pymongo -from mongotoy import expressions, cache, mappers +from mongotoy import expressions, mappers from mongotoy.errors import ValidationError, ErrorWrapper -if typing.TYPE_CHECKING: - from mongotoy import documents - -# Expose specific symbols for external use __all__ = ( + 'FieldOptions', 'field', 'reference', ) +@dataclasses.dataclass +class FieldOptions(mappers.MapperOptions): + """ + Represents the options for configuring a field in a document. + Inherits from MapperOptions and adds additional attributes for field customization. + Attributes: + alias (str): Alias name for the field. Defaults to None. + id_field (bool): Flag indicating whether the field is the primary key. Defaults to False. + index (expressions.IndexType): Type of index to apply to the field. Defaults to None. + sparse (bool): Flag indicating whether the index should be sparse. Defaults to False. + unique (bool): Flag indicating whether the field value must be unique. Defaults to False. + """ + alias: str = dataclasses.field(default=None) + id_field: bool = dataclasses.field(default=False) + index: expressions.IndexType = dataclasses.field(default=None) + sparse: bool = dataclasses.field(default=False) + unique: bool = dataclasses.field(default=False) + + def field( alias: str = None, id_field: bool = False, @@ -24,8 +40,9 @@ def field( default_factory: typing.Callable[[], typing.Any] = None, index: expressions.IndexType = None, sparse: bool = False, - unique: bool = False -) -> dict: + unique: bool = False, + **extra +) -> FieldOptions: """ Create a field descriptor for a document. @@ -37,24 +54,25 @@ def field( index (IndexType, optional): Type of index for the field. Defaults to None. sparse (bool, optional): Whether the index should be sparse. Defaults to False. unique (bool, optional): Whether the index should be unique. Defaults to False. + extra: Extra configurations Returns: - dict: Field descriptor. + FieldOptions: Field descriptor. """ - return { - 'type': 'field', - 'alias': alias, - 'id_field': id_field, - 'default': default, - 'default_factory': default_factory, - 'index': index, - 'sparse': sparse, - 'unique': unique - } - - -def reference(ref_field: str = 'id', key_name: str = None) -> dict: + return FieldOptions( + alias=alias, + id_field=id_field, + default=default, + default_factory=default_factory, + index=index, + sparse=sparse, + unique=unique, + extra=extra + ) + + +def reference(ref_field: str = 'id', key_name: str = None) -> FieldOptions: """ Create a reference field descriptor for a document. @@ -63,14 +81,10 @@ def reference(ref_field: str = 'id', key_name: str = None) -> dict: key_name (str, optional): Key name for the reference. Defaults to None. Returns: - dict: Reference field descriptor. + RefFieldOptions: Reference field descriptor. """ - return { - 'type': 'reference', - 'ref_field': ref_field, - 'key_name': key_name, - } + return FieldOptions(ref_field=ref_field, key_name=key_name) class Field: @@ -82,135 +96,23 @@ class Field: Args: mapper (Mapper): The mapper object for the field. - alias (str, optional): Alias for the field. Defaults to None. - id_field (bool, optional): Indicates if the field is an ID field. Defaults to False. - index (IndexType, optional): Type of index for the field. Defaults to None. - sparse (bool, optional): Whether the index should be sparse. Defaults to False. - unique (bool, optional): Whether the index should be unique. Defaults to False. - + options (FieldOptions): File configuration params. """ def __init__( self, mapper: mappers.Mapper, - alias: str = None, - id_field: bool = False, - index: expressions.IndexType = None, - sparse: bool = False, - unique: bool = False + options: FieldOptions ): # If it's an ID field, enforce specific settings - if id_field: - alias = '_id' + if options.id_field: + options.alias = '_id' # Initialize field attributes self._owner = None self._name = None self._mapper = mapper - self._alias = alias - self._id_field = id_field - self._index = index - self._sparse = sparse - self._unique = unique - - @classmethod - def _build_mapper(cls, mapper_bind: typing.Type, **options) -> mappers.Mapper: - """ - Build a data mapper based on annotations. - - Args: - mapper_bind (Type): Type annotation for the mapper. - **options: Additional options. - - Returns: - Mapper: The constructed data mapper. - - Raises: - TypeError: If the mapper type is not recognized. - """ - from mongotoy import documents - - # Simple type annotation - if not typing.get_args(mapper_bind): - # Extract mapper_bind from ForwardRef - if isinstance(mapper_bind, typing.ForwardRef): - mapper_bind = getattr(mapper_bind, '__forward_arg__') - - # Set up mapper parameters - mapper_params = { - 'nullable': options.get('nullable', False), - 'default': options.get('default', expressions.EmptyValue), - 'default_factory': options.get('default_factory', None), - } - is_reference = options.get('type') == 'reference' - is_document_cls = isinstance(mapper_bind, type) and issubclass(mapper_bind, documents.Document) - - # Create embedded or reference document mapper - if isinstance(mapper_bind, str) or issubclass(mapper_bind, documents.BaseDocument): - mapper_params['document_cls'] = mapper_bind - mapper_bind = mappers.EmbeddedDocumentMapper - - # Create reference document mapper - if is_reference or is_document_cls: - mapper_params['ref_field'] = options.get('ref_field', 'id') - mapper_params['key_name'] = options.get('key_name') - mapper_bind = mappers.ReferencedDocumentMapper - - # Create the mapper - mapper_cls = cache.mappers.get_type(mapper_bind) - if not mapper_cls: - raise TypeError(f'Data mapper not found for type {mapper_bind}') - - return mapper_cls(**mapper_params) - - # Get type origin and arguments - type_origin = typing.get_origin(mapper_bind) - type_args = typing.get_args(mapper_bind) - mapper_bind = type_args[0] - - # Check for nullable type - if type_origin in (typing.Union, types.UnionType) and len(type_args) > 1 and type_args[1] is types.NoneType: - options['nullable'] = True - return cls._build_mapper(mapper_bind, **options) - - # Create many mapper - if type_origin in (list, tuple, set): - mapper_cls = cache.mappers.get_type(type_origin) - return mapper_cls( - nullable=options.pop('nullable', False), - default=options.pop('default', expressions.EmptyValue), - default_factory=options.pop('default_factory', None), - mapper=cls._build_mapper(mapper_bind, **options) - ) - - raise TypeError( - f'Invalid outer annotation {type_origin}, allowed are [{list, tuple, set}, {typing.Optional}]' - ) - - @classmethod - def from_annotated_type(cls, anno_type: typing.Type, info: dict) -> 'Field': - """ - Create a Field instance from an annotated type. - - Args: - anno_type (Type): Annotated type for the field. - info (dict, optional): Additional information about the field. Defaults to {}. - - Returns: - Field: The constructed Field instance. - - """ - return Field( - mapper=cls._build_mapper( - mapper_bind=anno_type, - **info - ), - alias=info.get('alias'), - id_field=info.get('id_field', False), - index=info.get('index'), - sparse=info.get('sparse', False), - unique=info.get('unique', False) - ) + self._options = options @property def mapper(self) -> mappers.Mapper: @@ -243,18 +145,7 @@ def alias(self) -> str: str: The alias of the field, or its name if no alias is set. """ - return self._alias or self._name - - @property - def id_field(self) -> bool: - """ - Check if the field is an ID field. - - Returns: - bool: True if it's an ID field, False otherwise. - - """ - return self._id_field + return self._options.alias or self._name def __set_name__(self, owner, name): """ @@ -306,15 +197,15 @@ def get_index(self) -> pymongo.IndexModel | None: """ index = None - if self._unique or self._sparse: + if self._options.unique or self._options.sparse: index = pymongo.ASCENDING - if self._index is not None: - index = self._index + if self._options.index is not None: + index = self._options.index if index: return pymongo.IndexModel( keys=[(self.alias, index)], - unique=self._unique, - sparse=self._sparse + unique=self._options.unique, + sparse=self._options.sparse ) def validate(self, value) -> typing.Any: @@ -472,7 +363,7 @@ def __getattr__(self, item): """ # Unwrap ManyMapper mapper = self._field.mapper - if isinstance(mapper, mappers.ManyMapper): + if isinstance(mapper, mappers.SequenceMapper): mapper = mapper.unwrap() # Unwrap EmbeddedDocumentMapper or ReferencedDocumentMapper diff --git a/mongotoy/mappers.py b/mongotoy/mappers.py index f0df79f..aaeb77a 100644 --- a/mongotoy/mappers.py +++ b/mongotoy/mappers.py @@ -1,8 +1,11 @@ import abc +import dataclasses import datetime import decimal +import json import typing import uuid +from types import UnionType, NoneType import bson @@ -13,6 +16,113 @@ from mongotoy import documents, fields +__all__ = ( + 'MapperOptions', + 'build_mapper', + 'Mapper', +) + + +@dataclasses.dataclass +class MapperOptions: + """ + Represents the options for configuring a Mapper instance. + Attributes: + nullable (bool): Flag indicating whether the field is nullable. Defaults to False. + default (Any): Default value for the field. Defaults to expressions.EmptyValue. + default_factory (Callable[[], Any] | None): Factory function to generate default values. Defaults to None. + ref_field (str): Field name to use as a reference. Defaults to None. + key_name (str): Name of the key when using a reference. Defaults to None. + extra (dict): Extra options for customization. Defaults to None. + """ + nullable: bool = dataclasses.field(default=False) + default: typing.Any = dataclasses.field(default=expressions.EmptyValue) + default_factory: typing.Callable[[], typing.Any] | None = dataclasses.field(default=None) + ref_field: str = dataclasses.field(default=None) + key_name: str = dataclasses.field(default=None) + extra: dict = dataclasses.field(default=None) + + +def build_mapper(mapper_bind: typing.Type, options: MapperOptions) -> 'Mapper': + """ + Build a data mapper based on annotations. + + Args: + mapper_bind (Type): Type annotation for the mapper. + options (FieldOptions, RefFieldOptions): Additional information about the mapper. + + Returns: + Mapper: The constructed data mapper. + + Raises: + TypeError: If the mapper type is not recognized. + """ + from mongotoy import documents + + # Simple type annotation + if not typing.get_args(mapper_bind): + # Extract mapper_bind from ForwardRef + if isinstance(mapper_bind, typing.ForwardRef): + mapper_bind = getattr(mapper_bind, '__forward_arg__') + + # Create mappers for forwarded types + if isinstance(mapper_bind, str): + # Create ReferencedDocumentMapper + if options.ref_field: + return ReferencedDocumentMapper( + document_cls=mapper_bind, + options=options + ) + # Create EmbeddedDocumentMapper + return EmbeddedDocumentMapper( + document_cls=mapper_bind, + options=options + ) + + # Create ReferencedDocumentMapper + if issubclass(mapper_bind, documents.Document): + return ReferencedDocumentMapper( + document_cls=mapper_bind, + options=options + ) + + # Create EmbeddedDocumentMapper + if issubclass(mapper_bind, documents.EmbeddedDocument): + return EmbeddedDocumentMapper( + document_cls=mapper_bind, + options=options + ) + + # Create mappers for other types + mapper_cls = cache.mappers.get_type(mapper_bind) + if not mapper_cls: + raise TypeError(f'Data mapper not found for type {mapper_bind}') + + return mapper_cls(options) + + # Get type origin and arguments + type_origin = typing.get_origin(mapper_bind) + type_args = typing.get_args(mapper_bind) + mapper_bind = type_args[0] + + # Check for nullable type + if type_origin in (typing.Union, UnionType) and len(type_args) > 1 and type_args[1] is NoneType: + options.nullable = True + return build_mapper(mapper_bind, options) + + # Create sequence mapper + if type_origin in (list, tuple, set): + mapper_cls = cache.mappers.get_type(type_origin) + return mapper_cls( + mapper=build_mapper(mapper_bind, options), + options=options + ) + + raise TypeError( + f'Invalid outer annotation {type_origin}, allowed are [{list, tuple, set}, {typing.Optional}]' + ) + + class MapperMeta(abc.ABCMeta): """ Metaclass for Mapper classes. @@ -49,23 +159,16 @@ class Mapper(abc.ABC, metaclass=MapperMeta): This class defines the interface for data mappers and provides basic functionality for validation and dumping. Args: - nullable (bool, optional): Whether the value can be None. Defaults to False. - default (Any, optional): Default value for the mapper. Defaults to expressions.EmptyValue. - default_factory (Callable[[], Any], optional): A callable that returns the default value. Defaults to None. + options (MapperOptions): Mapper configuration params. """ if typing.TYPE_CHECKING: __bind__: typing.Type - def __init__( - self, - nullable: bool = False, - default: typing.Any = expressions.EmptyValue, - default_factory: typing.Callable[[], typing.Any] = None - ): - self._nullable = nullable - self._default_factory = default_factory if default_factory else lambda: default + def __init__(self, options: MapperOptions): + self._options = options + self._default_factory = options.default_factory if options.default_factory else lambda: options.default def __call__(self, value) -> typing.Any: """ @@ -86,14 +189,12 @@ def __call__(self, value) -> typing.Any: if value is expressions.EmptyValue: return value - if value is None: - if not self._nullable: - raise ValidationError([ - ErrorWrapper(loc=tuple(), error=ValueError('Null value not allowed')) - ]) - return value - try: + if value is None: + if not self._options.nullable: + raise ValueError('Null value not allowed') + return value + value = self.validate(value) except (TypeError, ValueError) as e: raise ValidationError(errors=[ErrorWrapper(loc=tuple(), error=e)]) from None @@ -157,30 +258,74 @@ def dump_bson(self, value, **options) -> typing.Any: return value -class ManyMapper(Mapper): +class ComparableMapper(Mapper): """ - Mapper for handling lists of elements. + Base mapper for data types supporting the following comparators: + - lt (less than) + - lte (less than or equal to) + - gt (greater than) + - gte (greater than or equal to) """ - def __init__( - self, - mapper: Mapper, - nullable: bool = False, - default: typing.Any = expressions.EmptyValue, - default_factory: typing.Callable[[], typing.Any] = None, - ): + def validate(self, value) -> typing.Any: + """ + Validate the input value against the specified comparators. + Args: + value: The value to be validated. + Returns: + The validated value. + Raises: + TypeError: If the input value is not of the expected data type. + ValueError: If the value does not meet the specified comparator conditions. + """ + if not isinstance(value, self.__bind__): + raise TypeError(f'Invalid data type {type(value)}, expected {self.__bind__}') + + # Validate extra options + if self._options.extra: + if 'lt' in self._options.extra: + if value >= self._options.extra['lt']: + raise ValueError( + f'Invalid value {value}, required lt={self._options.extra["lt"]}' + ) + if 'lte' in self._options.extra: + if value > self._options.extra['lte']: + raise ValueError( + f'Invalid value {value}, required lte={self._options.extra["lte"]}' + ) + if 'gt' in self._options.extra: + if value <= self._options.extra['gt']: + raise ValueError( + f'Invalid value {value}, required gt={self._options.extra["gt"]}' + ) + if 'gte' in self._options.extra: + if value < self._options.extra['gte']: + raise ValueError( + f'Invalid value {value}, required gte={self._options.extra["gte"]}' + ) + + return value + + +class SequenceMapper(Mapper): + """ + Base mapper for handling sequence of elements. + """ + + def __init__(self, mapper: Mapper, options: MapperOptions): """ Initialize the ManyMapper. Args: mapper (Mapper): The mapper for the list items. + options (MapperOptions): Mapper configuration params. """ self._mapper = mapper - # ManyMapper must be at least empty list not an EmptyValue for ReferencedDocumentMapper - if default is expressions.EmptyValue and isinstance(self.unwrap(), ReferencedDocumentMapper): - default = [] - super().__init__(nullable, default, default_factory) + # SequenceMapper must be at least empty list not an EmptyValue for ReferencedDocumentMapper + if options.default is expressions.EmptyValue and isinstance(self.unwrap(), ReferencedDocumentMapper): + options.default = [] + super().__init__(options) @property def mapper(self) -> Mapper: @@ -194,12 +339,13 @@ def mapper(self) -> Mapper: return self._mapper def unwrap(self) -> Mapper: - """Get the innermost mapper that isn't a ManyMapper""" + """Get the innermost mapper that isn't a SequenceMapper""" mapper_ = self.mapper - while isinstance(mapper_, ManyMapper): + while isinstance(mapper_, SequenceMapper): mapper_ = mapper_.mapper return mapper_ + # noinspection PyTypeChecker def validate(self, value) -> typing.Any: """ Validate the list value. @@ -219,7 +365,6 @@ def validate(self, value) -> typing.Any: new_value = [] errors = [] - # noinspection PyTypeChecker for i, val in enumerate(value): try: new_value.append(self.mapper(val)) @@ -273,33 +418,6 @@ def dump_bson(self, value, **options) -> typing.Any: return [self.mapper.dump_bson(i, **options) for i in value] -class ListMapper(ManyMapper, bind=list): - """ - Mapper for handling lists. - - Inherits from ManyMapper and specifies 'list' as the binding type. - - """ - - -class TupleMapper(ManyMapper, bind=tuple): - """ - Mapper for handling tuples. - - Inherits from ManyMapper and specifies 'tuple' as the binding type. - - """ - - -class SetMapper(ManyMapper, bind=set): - """ - Mapper for handling sets. - - Inherits from ManyMapper and specifies 'set' as the binding type. - - """ - - # noinspection PyUnresolvedReferences class EmbeddedDocumentMapper(Mapper): """ @@ -307,18 +425,17 @@ class EmbeddedDocumentMapper(Mapper): Attributes: document_cls (Type['documents.BaseDocument'] | str): The class or name of the embedded document. + options (MapperOptions): Mapper configuration params. """ def __init__( self, document_cls: typing.Type['documents.BaseDocument'] | str, - nullable: bool = False, - default: typing.Any = expressions.EmptyValue, - default_factory: typing.Callable[[], typing.Any] = None, + options: MapperOptions ): self._document_cls = document_cls - super().__init__(nullable, default, default_factory) + super().__init__(options) @property def document_cls(self) -> typing.Type['documents.EmbeddedDocument']: @@ -400,22 +517,18 @@ class ReferencedDocumentMapper(EmbeddedDocumentMapper): Mapper for referenced documents. Attributes: - ref_field (str): The name of the referenced field. - key_name (str, optional): The key name for the reference. + document_cls (Type['documents.BaseDocument'] | str): The class or name of the referenced document. + options (MapperOptions): Mapper configuration params. """ def __init__( self, document_cls: typing.Type['documents.BaseDocument'] | str, - ref_field: str, - key_name: str = None, - nullable: bool = False, - default: typing.Any = expressions.EmptyValue, - default_factory: typing.Callable[[], typing.Any] = None, + options: MapperOptions ): - self._ref_field = ref_field - self._key_name = key_name - super().__init__(document_cls, nullable, default, default_factory) + if not options.ref_field: + options.ref_field = 'id' + super().__init__(document_cls, options) def validate(self, value) -> typing.Any: """ @@ -457,7 +570,7 @@ def ref_field(self) -> 'fields.Field': Field: The reference field. """ - return references.get_field(self._ref_field, document_cls=self.document_cls) + return references.get_field(self._options.ref_field, document_cls=self.document_cls) def dump_bson(self, value, **options) -> typing.Any: """ @@ -474,38 +587,56 @@ def dump_bson(self, value, **options) -> typing.Any: return getattr(value, self.ref_field.name) -class StrMapper(Mapper, bind=str): +class ListMapper(SequenceMapper, bind=list): """ - Mapper for handling string values. + Mapper for handling lists. + + Inherits from ManyMapper and specifies 'list' as the binding type. + """ + +class TupleMapper(SequenceMapper, bind=tuple): + """ + Mapper for handling tuples. + + Inherits from ManyMapper and specifies 'tuple' as the binding type. + + """ def validate(self, value) -> typing.Any: - """ - Validate the string value. + if isinstance(value, list): + value = tuple(value) + return super().validate(value) - Args: - value: The value to be validated. - Returns: - Any: The validated value. +class SetMapper(SequenceMapper, bind=set): + """ + Mapper for handling sets. - Raises: - TypeError: If validation fails due to incorrect data type. + Inherits from ManyMapper and specifies 'set' as the binding type. - """ - if not isinstance(value, str): - raise TypeError(f'Invalid data type {type(value)}, required is {str}') - return value + """ + + def validate(self, value) -> typing.Any: + if isinstance(value, list): + value = set(value) + return super().validate(value) + def dump_json(self, value, **options) -> typing.Any: + return list(value) + + def dump_bson(self, value, **options) -> typing.Any: + return list(value) -class IntMapper(Mapper, bind=int): + +class StrMapper(Mapper, bind=str): """ - Mapper for handling integer values. + Mapper for handling string values. """ def validate(self, value) -> typing.Any: """ - Validate the integer value. + Validate the string value. Args: value: The value to be validated. @@ -517,8 +648,27 @@ def validate(self, value) -> typing.Any: TypeError: If validation fails due to incorrect data type. """ - if not isinstance(value, int): - raise TypeError(f'Invalid data type {type(value)}, required is {int}') + if not isinstance(value, str): + raise TypeError(f'Invalid data type {type(value)}, required is {str}') + + # Validate extra options + if self._options.extra: + if 'min_len' in self._options.extra: + if len(value) < self._options.extra['min_len']: + raise ValueError( + f'Invalid value len {len(value)}, required min_len={self._options.extra["min_len"]}' + ) + if 'max_len' in self._options.extra: + if len(value) > self._options.extra['max_len']: + raise ValueError( + f'Invalid value len {len(value)}, required max_len={self._options.extra["max_len"]}' + ) + if 'choices' in self._options.extra: + if value not in self._options.extra['choices']: + raise ValueError( + f'Invalid value {value}, required choices={self._options.extra["choices"]}' + ) + return value @@ -585,30 +735,6 @@ def dump_json(self, value, **options) -> typing.Any: return base64.b64encode(value).decode() -class FloatMapper(Mapper, bind=float): - """ - Mapper for handling float values. - """ - - def validate(self, value) -> typing.Any: - """ - Validate the float value. - - Args: - value: The value to be validated. - - Returns: - Any: The validated value. - - Raises: - TypeError: If validation fails due to incorrect data type. - - """ - if not isinstance(value, float): - raise TypeError(f'Invalid data type {type(value)}, required is {float}') - return value - - class ObjectIdMapper(Mapper, bind=bson.ObjectId): """ Mapper for handling BSON ObjectId values. @@ -647,14 +773,14 @@ def dump_json(self, value, **options) -> typing.Any: return str(value) -class DecimalMapper(Mapper, bind=decimal.Decimal): +class UUIDMapper(Mapper, bind=uuid.UUID): """ - Mapper for handling decimal values. + Mapper for handling UUID values. """ def validate(self, value) -> typing.Any: """ - Validate the decimal value. + Validate the UUID value. Args: value: The value to be validated. @@ -666,19 +792,13 @@ def validate(self, value) -> typing.Any: TypeError: If validation fails due to incorrect data type. """ - if isinstance(value, bson.Decimal128): - value = value.to_decimal() - if not isinstance(value, decimal.Decimal): - raise TypeError(f'Invalid data type {type(value)}, required is {decimal.Decimal}') - - # Ensure decimal limits for MongoDB - # https://www.mongodb.com/docs/upcoming/release-notes/3.4/#decimal-type - ctx = decimal.Context(prec=34) - return ctx.create_decimal(value) + if not isinstance(value, uuid.UUID): + raise TypeError(f'Invalid data type {type(value)}, required is {uuid.UUID}') + return value def dump_json(self, value, **options) -> typing.Any: """ - Dump the decimal value to a JSON-serializable format. + Dump the UUID value to a JSON-serializable format. Args: value: The value to be dumped. @@ -688,31 +808,29 @@ def dump_json(self, value, **options) -> typing.Any: Any: The dumped value. """ - return float(value) + return str(value) - def dump_bson(self, value, **options) -> typing.Any: - """ - Dump the decimal value to BSON. - Args: - value: The value to be dumped. - **options: Additional options. +class IntMapper(ComparableMapper, bind=int): + """ + Mapper for handling integer values. + """ - Returns: - Any: The dumped value. - """ - return bson.Decimal128(value) +class FloatMapper(ComparableMapper, bind=float): + """ + Mapper for handling float values. + """ -class UUIDMapper(Mapper, bind=uuid.UUID): +class DecimalMapper(ComparableMapper, bind=decimal.Decimal): """ - Mapper for handling UUID values. + Mapper for handling decimal values. """ def validate(self, value) -> typing.Any: """ - Validate the UUID value. + Validate the decimal value. Args: value: The value to be validated. @@ -724,13 +842,17 @@ def validate(self, value) -> typing.Any: TypeError: If validation fails due to incorrect data type. """ - if not isinstance(value, uuid.UUID): - raise TypeError(f'Invalid data type {type(value)}, required is {uuid.UUID}') - return value + if isinstance(value, bson.Decimal128): + value = value.to_decimal() + value = super().validate(value) + # Ensure decimal limits for MongoDB + # https://www.mongodb.com/docs/upcoming/release-notes/3.4/#decimal-type + ctx = decimal.Context(prec=34) + return ctx.create_decimal(value) def dump_json(self, value, **options) -> typing.Any: """ - Dump the UUID value to a JSON-serializable format. + Dump the decimal value to a JSON-serializable format. Args: value: The value to be dumped. @@ -740,31 +862,27 @@ def dump_json(self, value, **options) -> typing.Any: Any: The dumped value. """ - return str(value) - - -class DateTimeMapper(Mapper, bind=datetime.datetime): - """ - Mapper for handling datetime values. - """ + return float(value) - def validate(self, value) -> typing.Any: + def dump_bson(self, value, **options) -> typing.Any: """ - Validate the datetime value. + Dump the decimal value to BSON. Args: - value: The value to be validated. + value: The value to be dumped. + **options: Additional options. Returns: - Any: The validated value. - - Raises: - TypeError: If validation fails due to incorrect data type. + Any: The dumped value. """ - if not isinstance(value, datetime.datetime): - raise TypeError(f'Invalid data type {type(value)}, required is {datetime.datetime}') - return value + return bson.Decimal128(value) + + +class DateTimeMapper(ComparableMapper, bind=datetime.datetime): + """ + Mapper for handling datetime values. + """ def dump_json(self, value, **options) -> typing.Any: """ @@ -781,7 +899,7 @@ def dump_json(self, value, **options) -> typing.Any: return value.isoformat() -class DateMapper(Mapper, bind=datetime.date): +class DateMapper(ComparableMapper, bind=datetime.date): """ Mapper for handling date values. """ @@ -802,9 +920,7 @@ def validate(self, value) -> typing.Any: """ if isinstance(value, datetime.datetime): value = value.date() - if not isinstance(value, datetime.date): - raise TypeError(f'Invalid data type {type(value)}, required is {datetime.date}') - return value + return super().validate(value) def dump_json(self, value, **options) -> typing.Any: """ @@ -835,7 +951,7 @@ def dump_bson(self, value, **options) -> typing.Any: return datetime.datetime.combine(date=value, time=datetime.time.min) -class TimeMapper(Mapper, bind=datetime.time): +class TimeMapper(ComparableMapper, bind=datetime.time): """ Mapper for handling time values. """ @@ -856,9 +972,7 @@ def validate(self, value) -> typing.Any: """ if isinstance(value, datetime.datetime): value = value.time() - if not isinstance(value, datetime.time): - raise TypeError(f'Invalid data type {type(value)}, required is {datetime.time}') - return value + return super().validate(value) def dump_json(self, value, **options) -> typing.Any: """ @@ -1125,7 +1239,6 @@ def validate(self, value) -> typing.Any: raise TypeError(f'Invalid data type {type(value)}, expected {types.Json}') # Check if the JSON data is valid - import json try: json.dumps(value) except Exception as e: @@ -1157,7 +1270,7 @@ def dump_bson(self, value, **options) -> typing.Any: Returns: Any: The dumped JSON data. """ - return self.dump_json(value, **options) + return dict(value) class BsonMapper(Mapper, bind=types.Bson): @@ -1232,23 +1345,9 @@ class FileMapper(ReferencedDocumentMapper, bind=types.File): This mapper handles references to files stored in a database. Args: - nullable (bool, optional): Whether the file reference can be None. Defaults to False. - default (Any, optional): Default value for the file reference. Defaults to expressions.EmptyValue. - default_factory (Callable[[], Any], optional): A callable that returns the default value. Defaults to None. + options (MapperOptions): Mapper configuration params. """ - def __init__( - self, - nullable: bool = False, - default: typing.Any = expressions.EmptyValue, - default_factory: typing.Callable[[], typing.Any] = None - ): + def __init__(self, options: MapperOptions): from mongotoy import db - - super().__init__( - document_cls=db.FsObject, - ref_field='id', - nullable=nullable, - default=default, - default_factory=default_factory - ) + super().__init__(db.FsObject, options) diff --git a/pyproject.toml b/pyproject.toml index 6e6fe24..3ad42a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "mongotoy" -version = "0.1.1.post2" +version = "0.1.2" description = "Async ODM for MongoDB" license = "Apache-2.0" authors = ["gurcuff91 "]