diff --git a/.gitignore b/.gitignore index fc07f13..6c5114b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ dist site profile.py *.db +*.db-journal diff --git a/README.md b/README.md index c6c5f41..d4655e5 100644 --- a/README.md +++ b/README.md @@ -306,7 +306,7 @@ async def joins(): # visit: https://collerek.github.io/ormar/relations/ # to read more about joins and subqueries - # visit: https://collerek.github.io/ormar/queries/delete/ + # visit: https://collerek.github.io/ormar/queries/joins-and-subqueries/ async def filter_and_sort(): diff --git a/docs/api/fields/base-field.md b/docs/api/fields/base-field.md index ac7ea01..82b99d3 100644 --- a/docs/api/fields/base-field.md +++ b/docs/api/fields/base-field.md @@ -72,6 +72,27 @@ Excludes defaults and alias as they are populated separately `(bool)`: True if field is present on pydantic.FieldInfo + +#### get\_base\_pydantic\_field\_info + +```python + | @classmethod + | get_base_pydantic_field_info(cls, allow_null: bool) -> FieldInfo +``` + +Generates base pydantic.FieldInfo with only default and optionally +required to fix pydantic Json field being set to required=False. +Used in an ormar Model Metaclass. + +**Arguments**: + +- `allow_null (bool)`: flag if the default value can be None +or if it should be populated by pydantic Undefined + +**Returns**: + +`(pydantic.FieldInfo)`: instance of base pydantic.FieldInfo + #### convert\_to\_pydantic\_field\_info diff --git a/docs/api/fields/foreign-key.md b/docs/api/fields/foreign-key.md index 019e2c9..055b661 100644 --- a/docs/api/fields/foreign-key.md +++ b/docs/api/fields/foreign-key.md @@ -332,3 +332,32 @@ Selects the appropriate constructor based on a passed value. `(Optional[Union["Model", List["Model"]]])`: returns a Model or a list of Models + +#### get\_relation\_name + +```python + | @classmethod + | get_relation_name(cls) -> str +``` + +Returns name of the relation, which can be a own name or through model +names for m2m models + +**Returns**: + +`(bool)`: result of the check + + +#### get\_source\_model + +```python + | @classmethod + | get_source_model(cls) -> Type["Model"] +``` + +Returns model from which the relation comes -> either owner or through model + +**Returns**: + +`(Type["Model"])`: source model + diff --git a/docs/api/fields/many-to-many.md b/docs/api/fields/many-to-many.md index 72c95e1..89570aa 100644 --- a/docs/api/fields/many-to-many.md +++ b/docs/api/fields/many-to-many.md @@ -24,7 +24,7 @@ pydantic field to use and type of the target column field. #### ManyToMany ```python -ManyToMany(to: "ToType", through: "ToType", *, name: str = None, unique: bool = False, virtual: bool = False, **kwargs: Any, ,) -> Any +ManyToMany(to: "ToType", through: Optional["ToType"] = None, *, name: str = None, unique: bool = False, virtual: bool = False, **kwargs: Any, ,) -> Any ``` Despite a name it's a function that returns constructed ManyToManyField. @@ -134,3 +134,42 @@ Evaluates the ForwardRef to actual Field based on global and local namespaces `(None)`: None + +#### get\_relation\_name + +```python + | @classmethod + | get_relation_name(cls) -> str +``` + +Returns name of the relation, which can be a own name or through model +names for m2m models + +**Returns**: + +`(bool)`: result of the check + + +#### get\_source\_model + +```python + | @classmethod + | get_source_model(cls) -> Type["Model"] +``` + +Returns model from which the relation comes -> either owner or through model + +**Returns**: + +`(Type["Model"])`: source model + + +#### create\_default\_through\_model + +```python + | @classmethod + | create_default_through_model(cls) -> None +``` + +Creates default empty through model if no additional fields are required. + diff --git a/docs/api/models/excludable-items.md b/docs/api/models/excludable-items.md new file mode 100644 index 0000000..ee12586 --- /dev/null +++ b/docs/api/models/excludable-items.md @@ -0,0 +1,188 @@ + +# models.excludable + + +## Excludable Objects + +```python +@dataclass +class Excludable() +``` + +Class that keeps sets of fields to exclude and include + + +#### get\_copy + +```python + | get_copy() -> "Excludable" +``` + +Return copy of self to avoid in place modifications + +**Returns**: + +`(ormar.models.excludable.Excludable)`: copy of self with copied sets + + +#### set\_values + +```python + | set_values(value: Set, is_exclude: bool) -> None +``` + +Appends the data to include/exclude sets. + +**Arguments**: + +- `value (set)`: set of values to add +- `is_exclude (bool)`: flag if values are to be excluded or included + + +#### is\_included + +```python + | is_included(key: str) -> bool +``` + +Check if field in included (in set or set is {...}) + +**Arguments**: + +- `key (str)`: key to check + +**Returns**: + +`(bool)`: result of the check + + +#### is\_excluded + +```python + | is_excluded(key: str) -> bool +``` + +Check if field in excluded (in set or set is {...}) + +**Arguments**: + +- `key (str)`: key to check + +**Returns**: + +`(bool)`: result of the check + + +## ExcludableItems Objects + +```python +class ExcludableItems() +``` + +Keeps a dictionary of Excludables by alias + model_name keys +to allow quick lookup by nested models without need to travers +deeply nested dictionaries and passing include/exclude around + + +#### from\_excludable + +```python + | @classmethod + | from_excludable(cls, other: "ExcludableItems") -> "ExcludableItems" +``` + +Copy passed ExcludableItems to avoid inplace modifications. + +**Arguments**: + +- `other (ormar.models.excludable.ExcludableItems)`: other excludable items to be copied + +**Returns**: + +`(ormar.models.excludable.ExcludableItems)`: copy of other + + +#### get + +```python + | get(model_cls: Type["Model"], alias: str = "") -> Excludable +``` + +Return Excludable for given model and alias. + +**Arguments**: + +- `model_cls (ormar.models.metaclass.ModelMetaclass)`: target model to check +- `alias (str)`: table alias from relation manager + +**Returns**: + +`(ormar.models.excludable.Excludable)`: Excludable for given model and alias + + +#### build + +```python + | build(items: Union[List[str], str, Tuple[str], Set[str], Dict], model_cls: Type["Model"], is_exclude: bool = False) -> None +``` + +Receives the one of the types of items and parses them as to achieve +a end situation with one excludable per alias/model in relation. + +Each excludable has two sets of values - one to include, one to exclude. + +**Arguments**: + +- `items (Union[List[str], str, Tuple[str], Set[str], Dict])`: values to be included or excluded +- `model_cls (ormar.models.metaclass.ModelMetaclass)`: source model from which relations are constructed +- `is_exclude (bool)`: flag if items should be included or excluded + + +#### \_set\_excludes + +```python + | _set_excludes(items: Set, model_name: str, is_exclude: bool, alias: str = "") -> None +``` + +Sets set of values to be included or excluded for given key and model. + +**Arguments**: + +- `items (set)`: items to include/exclude +- `model_name (str)`: name of model to construct key +- `is_exclude (bool)`: flag if values should be included or excluded +- `alias (str)`: + + +#### \_traverse\_dict + +```python + | _traverse_dict(values: Dict, source_model: Type["Model"], model_cls: Type["Model"], is_exclude: bool, related_items: List = None, alias: str = "") -> None +``` + +Goes through dict of nested values and construct/update Excludables. + +**Arguments**: + +- `values (Dict)`: items to include/exclude +- `source_model (ormar.models.metaclass.ModelMetaclass)`: source model from which relations are constructed +- `model_cls (ormar.models.metaclass.ModelMetaclass)`: model from which current relation is constructed +- `is_exclude (bool)`: flag if values should be included or excluded +- `related_items (List)`: list of names of related fields chain +- `alias (str)`: alias of relation + + +#### \_traverse\_list + +```python + | _traverse_list(values: Set[str], model_cls: Type["Model"], is_exclude: bool) -> None +``` + +Goes through list of values and construct/update Excludables. + +**Arguments**: + +- `values (set)`: items to include/exclude +- `model_cls (ormar.models.metaclass.ModelMetaclass)`: model from which current relation is constructed +- `is_exclude (bool)`: flag if values should be included or excluded + diff --git a/docs/api/models/helpers/models.md b/docs/api/models/helpers/models.md index b3100c6..2537a70 100644 --- a/docs/api/models/helpers/models.md +++ b/docs/api/models/helpers/models.md @@ -87,28 +87,6 @@ extraction of ormar model_fields. `(Tuple[Dict, Dict])`: namespace of the class updated, dict of extracted model_fields - -#### validate\_related\_names\_in\_relations - -```python -validate_related_names_in_relations(model_fields: Dict, new_model: Type["Model"]) -> None -``` - -Performs a validation of relation_names in relation fields. -If multiple fields are leading to the same related model -only one can have empty related_name param -(populated by default as model.name.lower()+'s'). -Also related_names have to be unique for given related model. - -**Raises**: - -- `ModelDefinitionError`: if validation of related_names fail - -**Arguments**: - -- `model_fields (Dict[str, ormar.Field])`: dictionary of declared ormar model fields -- `new_model (Model class)`: - #### group\_related\_list @@ -134,3 +112,23 @@ Result dictionary is sorted by length of the values and by key `(Dict[str, List])`: list converted to dictionary to avoid repetition and group nested models + +#### meta\_field\_not\_set + +```python +meta_field_not_set(model: Type["Model"], field_name: str) -> bool +``` + +Checks if field with given name is already present in model.Meta. +Then check if it's set to something truthful +(in practice meaning not None, as it's non or ormar Field only). + +**Arguments**: + +- `model (Model class)`: newly constructed model +- `field_name (str)`: name of the ormar field + +**Returns**: + +`(bool)`: result of the check + diff --git a/docs/api/models/helpers/pydantic.md b/docs/api/models/helpers/pydantic.md index 8b38eaf..49e44a5 100644 --- a/docs/api/models/helpers/pydantic.md +++ b/docs/api/models/helpers/pydantic.md @@ -5,7 +5,7 @@ #### create\_pydantic\_field ```python -create_pydantic_field(field_name: str, model: Type["Model"], model_field: Type[ManyToManyField]) -> None +create_pydantic_field(field_name: str, model: Type["Model"], model_field: Type["ManyToManyField"]) -> None ``` Registers pydantic field on through model that leads to passed model @@ -42,7 +42,7 @@ field_name. Returns a pydantic field with type of field_name field type. #### populate\_default\_pydantic\_field\_value ```python -populate_default_pydantic_field_value(ormar_field: Type[BaseField], field_name: str, attrs: dict) -> dict +populate_default_pydantic_field_value(ormar_field: Type["BaseField"], field_name: str, attrs: dict) -> dict ``` Grabs current value of the ormar Field in class namespace @@ -94,7 +94,7 @@ Those annotations are later used by pydantic to construct it's own fields. #### get\_pydantic\_base\_orm\_config ```python -get_pydantic_base_orm_config() -> Type[BaseConfig] +get_pydantic_base_orm_config() -> Type[pydantic.BaseConfig] ``` Returns empty pydantic Config with orm_mode set to True. diff --git a/docs/api/models/helpers/related-names-validation.md b/docs/api/models/helpers/related-names-validation.md new file mode 100644 index 0000000..9fa93cc --- /dev/null +++ b/docs/api/models/helpers/related-names-validation.md @@ -0,0 +1,25 @@ + +# models.helpers.related\_names\_validation + + +#### validate\_related\_names\_in\_relations + +```python +validate_related_names_in_relations(model_fields: Dict, new_model: Type["Model"]) -> None +``` + +Performs a validation of relation_names in relation fields. +If multiple fields are leading to the same related model +only one can have empty related_name param +(populated by default as model.name.lower()+'s'). +Also related_names have to be unique for given related model. + +**Raises**: + +- `ModelDefinitionError`: if validation of related_names fail + +**Arguments**: + +- `model_fields (Dict[str, ormar.Field])`: dictionary of declared ormar model fields +- `new_model (Model class)`: + diff --git a/docs/api/models/helpers/relations.md b/docs/api/models/helpers/relations.md index d470756..8da7561 100644 --- a/docs/api/models/helpers/relations.md +++ b/docs/api/models/helpers/relations.md @@ -23,7 +23,7 @@ aliases for proper sql joins. #### register\_many\_to\_many\_relation\_on\_build ```python -register_many_to_many_relation_on_build(field: Type[ManyToManyField]) -> None +register_many_to_many_relation_on_build(field: Type["ManyToManyField"]) -> None ``` Registers connection between through model and both sides of the m2m relation. @@ -89,11 +89,24 @@ Autogenerated reverse fields also set related_name to the original field name. - `model_field (relation Field)`: original relation ForeignKey field + +#### register\_through\_shortcut\_fields + +```python +register_through_shortcut_fields(model_field: Type["ManyToManyField"]) -> None +``` + +Registers m2m relation through shortcut on both ends of the relation. + +**Arguments**: + +- `model_field (ManyToManyField)`: relation field defined in parent model + #### register\_relation\_in\_alias\_manager ```python -register_relation_in_alias_manager(field: Type[ForeignKeyField]) -> None +register_relation_in_alias_manager(field: Type["ForeignKeyField"]) -> None ``` Registers the relation (and reverse relation) in alias manager. diff --git a/docs/api/models/helpers/sqlalchemy.md b/docs/api/models/helpers/sqlalchemy.md index 02c43c1..473b599 100644 --- a/docs/api/models/helpers/sqlalchemy.md +++ b/docs/api/models/helpers/sqlalchemy.md @@ -5,7 +5,7 @@ #### adjust\_through\_many\_to\_many\_model ```python -adjust_through_many_to_many_model(model_field: Type[ManyToManyField]) -> None +adjust_through_many_to_many_model(model_field: Type["ManyToManyField"]) -> None ``` Registers m2m relation on through model. @@ -21,7 +21,7 @@ Sets pydantic fields with child and parent model types. #### create\_and\_append\_m2m\_fk ```python -create_and_append_m2m_fk(model: Type["Model"], model_field: Type[ManyToManyField], field_name: str) -> None +create_and_append_m2m_fk(model: Type["Model"], model_field: Type["ManyToManyField"], field_name: str) -> None ``` Registers sqlalchemy Column with sqlalchemy.ForeignKey leading to the model. @@ -38,7 +38,7 @@ Newly created field is added to m2m relation through model Meta columns and tabl #### check\_pk\_column\_validity ```python -check_pk_column_validity(field_name: str, field: BaseField, pkname: Optional[str]) -> Optional[str] +check_pk_column_validity(field_name: str, field: "BaseField", pkname: Optional[str]) -> Optional[str] ``` Receives the field marked as primary key and verifies if the pkname @@ -165,7 +165,7 @@ It populates name, metadata, columns and constraints. #### update\_column\_definition ```python -update_column_definition(model: Union[Type["Model"], Type["NewBaseModel"]], field: Type[ForeignKeyField]) -> None +update_column_definition(model: Union[Type["Model"], Type["NewBaseModel"]], field: Type["ForeignKeyField"]) -> None ``` Updates a column with a new type column based on updated parameters in FK fields. diff --git a/docs/api/models/helpers/validation.md b/docs/api/models/helpers/validation.md new file mode 100644 index 0000000..9c2717b --- /dev/null +++ b/docs/api/models/helpers/validation.md @@ -0,0 +1,120 @@ + +# models.helpers.validation + + +#### check\_if\_field\_has\_choices + +```python +check_if_field_has_choices(field: Type[BaseField]) -> bool +``` + +Checks if given field has choices populated. +A if it has one, a validator for this field needs to be attached. + +**Arguments**: + +- `field (BaseField)`: ormar field to check + +**Returns**: + +`(bool)`: result of the check + + +#### convert\_choices\_if\_needed + +```python +convert_choices_if_needed(field: Type["BaseField"], value: Any) -> Tuple[Any, List] +``` + +Converts dates to isoformat as fastapi can check this condition in routes +and the fields are not yet parsed. + +Converts enums to list of it's values. + +Converts uuids to strings. + +Converts decimal to float with given scale. + +**Arguments**: + +- `field (Type[BaseField])`: ormar field to check with choices +- `values (Dict)`: current values of the model to verify + +**Returns**: + +`(Tuple[Any, List])`: value, choices list + + +#### validate\_choices + +```python +validate_choices(field: Type["BaseField"], value: Any) -> None +``` + +Validates if given value is in provided choices. + +**Raises**: + +- `ValueError`: If value is not in choices. + +**Arguments**: + +- `field (Type[BaseField])`: field to validate +- `value (Any)`: value of the field + + +#### choices\_validator + +```python +choices_validator(cls: Type["Model"], values: Dict[str, Any]) -> Dict[str, Any] +``` + +Validator that is attached to pydantic model pre root validators. +Validator checks if field value is in field.choices list. + +**Raises**: + +- `ValueError`: if field value is outside of allowed choices. + +**Arguments**: + +- `cls (Model class)`: constructed class +- `values (Dict[str, Any])`: dictionary of field values (pydantic side) + +**Returns**: + +`(Dict[str, Any])`: values if pass validation, otherwise exception is raised + + +#### construct\_modify\_schema\_function + +```python +construct_modify_schema_function(fields_with_choices: List) -> SchemaExtraCallable +``` + +Modifies the schema to include fields with choices validator. +Those fields will be displayed in schema as Enum types with available choices +values listed next to them. + +**Arguments**: + +- `fields_with_choices (List)`: list of fields with choices validation + +**Returns**: + +`(Callable)`: callable that will be run by pydantic to modify the schema + + +#### populate\_choices\_validators + +```python +populate_choices_validators(model: Type["Model"]) -> None +``` + +Checks if Model has any fields with choices set. +If yes it adds choices validation into pre root validators. + +**Arguments**: + +- `model (Model class)`: newly constructed Model + diff --git a/docs/api/models/mixins/excludable-mixin.md b/docs/api/models/mixins/excludable-mixin.md index a4d9c79..b2ad2f6 100644 --- a/docs/api/models/mixins/excludable-mixin.md +++ b/docs/api/models/mixins/excludable-mixin.md @@ -30,88 +30,12 @@ passed items. `(Union[Set, Dict, None])`: child extracted from items if exists - -#### get\_excluded - -```python - | @staticmethod - | get_excluded(exclude: Union[Set, Dict, None], key: str = None) -> Union[Set, Dict, None] -``` - -Proxy to ExcludableMixin.get_child for exclusions. - -**Arguments**: - -- `exclude (Union[Set, Dict, None])`: bag of items to exclude -- `key (str)`: name of the child to extract - -**Returns**: - -`(Union[Set, Dict, None])`: child extracted from items if exists - - -#### get\_included - -```python - | @staticmethod - | get_included(include: Union[Set, Dict, None], key: str = None) -> Union[Set, Dict, None] -``` - -Proxy to ExcludableMixin.get_child for inclusions. - -**Arguments**: - -- `include (Union[Set, Dict, None])`: bag of items to include -- `key (str)`: name of the child to extract - -**Returns**: - -`(Union[Set, Dict, None])`: child extracted from items if exists - - -#### is\_excluded - -```python - | @staticmethod - | is_excluded(exclude: Union[Set, Dict, None], key: str = None) -> bool -``` - -Checks if given key should be excluded on model/ dict. - -**Arguments**: - -- `exclude (Union[Set, Dict, None])`: bag of items to exclude -- `key (str)`: name of the child to extract - -**Returns**: - -`(Union[Set, Dict, None])`: child extracted from items if exists - - -#### is\_included - -```python - | @staticmethod - | is_included(include: Union[Set, Dict, None], key: str = None) -> bool -``` - -Checks if given key should be included on model/ dict. - -**Arguments**: - -- `include (Union[Set, Dict, None])`: bag of items to include -- `key (str)`: name of the child to extract - -**Returns**: - -`(Union[Set, Dict, None])`: child extracted from items if exists - #### \_populate\_pk\_column ```python | @staticmethod - | _populate_pk_column(model: Type["Model"], columns: List[str], use_alias: bool = False) -> List[str] + | _populate_pk_column(model: Union[Type["Model"], Type["ModelRow"]], columns: List[str], use_alias: bool = False) -> List[str] ``` Adds primary key column/alias (depends on use_alias flag) to list of @@ -132,7 +56,7 @@ column names that are selected. ```python | @classmethod - | own_table_columns(cls, model: Type["Model"], fields: Optional[Union[Set, Dict]], exclude_fields: Optional[Union[Set, Dict]], use_alias: bool = False) -> List[str] + | own_table_columns(cls, model: Union[Type["Model"], Type["ModelRow"]], excludable: ExcludableItems, alias: str = "", use_alias: bool = False) -> List[str] ``` Returns list of aliases or field names for given model. @@ -145,9 +69,9 @@ Primary key field is always added and cannot be excluded (will be added anyway). **Arguments**: +- `alias (str)`: relation prefix +- `excludable (ExcludableItems)`: structure of fields to include and exclude - `model (Type["Model"])`: model on columns are selected -- `fields (Optional[Union[Set, Dict]])`: set/dict of fields to include -- `exclude_fields (Optional[Union[Set, Dict]])`: set/dict of fields to exclude - `use_alias (bool)`: flag if aliases or field names should be used **Returns**: @@ -183,7 +107,7 @@ exclusion, for nested models all related models are excluded. ```python | @classmethod - | get_names_to_exclude(cls, fields: Optional[Union[Dict, Set]] = None, exclude_fields: Optional[Union[Dict, Set]] = None) -> Set + | get_names_to_exclude(cls, excludable: ExcludableItems, alias: str) -> Set ``` Returns a set of models field names that should be explicitly excluded @@ -197,8 +121,8 @@ them with dicts constructed from those db rows. **Arguments**: -- `fields (Optional[Union[Set, Dict]])`: set/dict of fields to include -- `exclude_fields (Optional[Union[Set, Dict]])`: set/dict of fields to exclude +- `alias (str)`: alias of current relation +- `excludable (ExcludableItems)`: structure of fields to include and exclude **Returns**: diff --git a/docs/api/models/mixins/relation-mixin.md b/docs/api/models/mixins/relation-mixin.md index 5e94eb9..50ccb79 100644 --- a/docs/api/models/mixins/relation-mixin.md +++ b/docs/api/models/mixins/relation-mixin.md @@ -40,12 +40,26 @@ List is cached in cls._related_fields for quicker access. `(List)`: list of related fields + +#### extract\_through\_names + +```python + | @classmethod + | extract_through_names(cls) -> Set +``` + +Extracts related fields through names which are shortcuts to through models. + +**Returns**: + +`(Set)`: set of related through fields names + #### extract\_related\_names ```python | @classmethod - | extract_related_names(cls) -> Set + | extract_related_names(cls) -> Set[str] ``` Returns List of fields names for all relations declared on a model. @@ -53,7 +67,7 @@ List is cached in cls._related_names for quicker access. **Returns**: -`(List)`: list of related fields names +`(Set)`: set of related fields names #### \_extract\_db\_related\_names @@ -91,3 +105,24 @@ for nested models all related models are returned. `(Set)`: set of non mandatory related fields + +#### \_iterate\_related\_models + +```python + | @classmethod + | _iterate_related_models(cls, visited: Set[Union[Type["Model"], Type["RelationMixin"]]] = None, source_relation: str = None, source_model: Union[Type["Model"], Type["RelationMixin"]] = None) -> List[str] +``` + +Iterates related models recursively to extract relation strings of +nested not visited models. + +**Arguments**: + +- `visited (Set[str])`: set of already visited models +- `source_relation (str)`: name of the current relation +- `source_model (Type["Model"])`: model from which relation comes in nested relations + +**Returns**: + +`(List[str])`: list of relation strings to be passed to select_related + diff --git a/docs/api/models/mixins/save-prepare-mixin.md b/docs/api/models/mixins/save-prepare-mixin.md index d8c7e57..a3f14cb 100644 --- a/docs/api/models/mixins/save-prepare-mixin.md +++ b/docs/api/models/mixins/save-prepare-mixin.md @@ -91,3 +91,22 @@ passed by the user. `(Dict)`: dictionary of model that is about to be saved + +#### validate\_choices + +```python + | @classmethod + | validate_choices(cls, new_kwargs: Dict) -> Dict +``` + +Receives dictionary of model that is about to be saved and validates the +fields with choices set to see if the value is allowed. + +**Arguments**: + +- `new_kwargs (Dict)`: dictionary of model that is about to be saved + +**Returns**: + +`(Dict)`: dictionary of model that is about to be saved + diff --git a/docs/api/models/model-metaclass.md b/docs/api/models/model-metaclass.md index c31abe9..957a9f7 100644 --- a/docs/api/models/model-metaclass.md +++ b/docs/api/models/model-metaclass.md @@ -12,61 +12,6 @@ Class used for type hinting. Users can subclass this one for convenience but it's not required. The only requirement is that ormar.Model has to have inner class with name Meta. - -#### check\_if\_field\_has\_choices - -```python -check_if_field_has_choices(field: Type[BaseField]) -> bool -``` - -Checks if given field has choices populated. -A if it has one, a validator for this field needs to be attached. - -**Arguments**: - -- `field (BaseField)`: ormar field to check - -**Returns**: - -`(bool)`: result of the check - - -#### choices\_validator - -```python -choices_validator(cls: Type["Model"], values: Dict[str, Any]) -> Dict[str, Any] -``` - -Validator that is attached to pydantic model pre root validators. -Validator checks if field value is in field.choices list. - -**Raises**: - -- `ValueError`: if field value is outside of allowed choices. - -**Arguments**: - -- `cls (Model class)`: constructed class -- `values (Dict[str, Any])`: dictionary of field values (pydantic side) - -**Returns**: - -`(Dict[str, Any])`: values if pass validation, otherwise exception is raised - - -#### populate\_choices\_validators - -```python -populate_choices_validators(model: Type["Model"]) -> None -``` - -Checks if Model has any fields with choices set. -If yes it adds choices validation into pre root validators. - -**Arguments**: - -- `model (Model class)`: newly constructed Model - #### add\_cached\_properties @@ -87,26 +32,6 @@ All properties here are used as "cache" to not recalculate them constantly. - `new_model (Model class)`: newly constructed Model - -#### meta\_field\_not\_set - -```python -meta_field_not_set(model: Type["Model"], field_name: str) -> bool -``` - -Checks if field with given name is already present in model.Meta. -Then check if it's set to something truthful -(in practice meaning not None, as it's non or ormar Field only). - -**Arguments**: - -- `model (Model class)`: newly constructed model -- `field_name (str)`: name of the ormar field - -**Returns**: - -`(bool)`: result of the check - #### add\_property\_fields @@ -141,24 +66,6 @@ Signals are emitted in both model own methods and in selected queryset ones. - `new_model (Model class)`: newly constructed model - -#### update\_attrs\_and\_fields - -```python -update_attrs_and_fields(attrs: Dict, new_attrs: Dict, model_fields: Dict, new_model_fields: Dict, new_fields: Set) -> Dict -``` - -Updates __annotations__, values of model fields (so pydantic FieldInfos) -as well as model.Meta.model_fields definitions from parents. - -**Arguments**: - -- `attrs (Dict)`: new namespace for class being constructed -- `new_attrs (Dict)`: related of the namespace extracted from parent class -- `model_fields (Dict[str, BaseField])`: ormar fields in defined in current class -- `new_model_fields (Dict[str, BaseField])`: ormar fields defined in parent classes -- `new_fields (Set[str])`: set of new fields names - #### verify\_constraint\_names @@ -195,7 +102,7 @@ Updates Meta parameters in child from parent if needed. #### copy\_and\_replace\_m2m\_through\_model ```python -copy_and_replace_m2m_through_model(field: Type[ManyToManyField], field_name: str, table_name: str, parent_fields: Dict, attrs: Dict, meta: ModelMeta) -> None +copy_and_replace_m2m_through_model(field: Type[ManyToManyField], field_name: str, table_name: str, parent_fields: Dict, attrs: Dict, meta: ModelMeta, base_class: Type["Model"]) -> None ``` Clones class with Through model for m2m relations, appends child name to the name @@ -211,6 +118,7 @@ Removes the original sqlalchemy table from metadata if it was not removed. **Arguments**: +- `base_class (Type["Model"])`: base class model - `field (Type[ManyToManyField])`: field with relations definition - `field_name (str)`: name of the relation field - `table_name (str)`: name of the table @@ -281,6 +189,24 @@ If the class is a ormar.Model it is skipped. `(Tuple[Dict, Dict])`: updated attrs and model_fields + +#### update\_attrs\_and\_fields + +```python +update_attrs_and_fields(attrs: Dict, new_attrs: Dict, model_fields: Dict, new_model_fields: Dict, new_fields: Set) -> Dict +``` + +Updates __annotations__, values of model fields (so pydantic FieldInfos) +as well as model.Meta.model_fields definitions from parents. + +**Arguments**: + +- `attrs (Dict)`: new namespace for class being constructed +- `new_attrs (Dict)`: related of the namespace extracted from parent class +- `model_fields (Dict[str, BaseField])`: ormar fields in defined in current class +- `new_model_fields (Dict[str, BaseField])`: ormar fields defined in parent classes +- `new_fields (Set[str])`: set of new fields names + ## ModelMetaclass Objects diff --git a/docs/api/models/model-row.md b/docs/api/models/model-row.md new file mode 100644 index 0000000..60f0b3a --- /dev/null +++ b/docs/api/models/model-row.md @@ -0,0 +1,132 @@ + +# models.model\_row + + +## ModelRow Objects + +```python +class ModelRow(NewBaseModel) +``` + + +#### from\_row + +```python + | @classmethod + | from_row(cls, row: sqlalchemy.engine.ResultProxy, source_model: Type["Model"], select_related: List = None, related_models: Any = None, related_field: Type["ForeignKeyField"] = None, excludable: ExcludableItems = None, current_relation_str: str = "", proxy_source_model: Optional[Type["Model"]] = None) -> Optional["Model"] +``` + +Model method to convert raw sql row from database into ormar.Model instance. +Traverses nested models if they were specified in select_related for query. + +Called recurrently and returns model instance if it's present in the row. +Note that it's processing one row at a time, so if there are duplicates of +parent row that needs to be joined/combined +(like parent row in sql join with 2+ child rows) +instances populated in this method are later combined in the QuerySet. +Other method working directly on raw database results is in prefetch_query, +where rows are populated in a different way as they do not have +nested models in result. + +**Arguments**: + +- `proxy_source_model (Optional[Type["ModelRow"]])`: source model from which querysetproxy is constructed +- `excludable (ExcludableItems)`: structure of fields to include and exclude +- `current_relation_str (str)`: name of the relation field +- `source_model (Type[Model])`: model on which relation was defined +- `row (sqlalchemy.engine.result.ResultProxy)`: raw result row from the database +- `select_related (List)`: list of names of related models fetched from database +- `related_models (Union[List, Dict])`: list or dict of related models +- `related_field (Type[ForeignKeyField])`: field with relation declaration + +**Returns**: + +`(Optional[Model])`: returns model if model is populated from database + + +#### \_populate\_nested\_models\_from\_row + +```python + | @classmethod + | _populate_nested_models_from_row(cls, item: dict, row: sqlalchemy.engine.ResultProxy, source_model: Type["Model"], related_models: Any, excludable: ExcludableItems, table_prefix: str, current_relation_str: str = None, proxy_source_model: Type["Model"] = None) -> dict +``` + +Traverses structure of related models and populates the nested models +from the database row. +Related models can be a list if only directly related models are to be +populated, converted to dict if related models also have their own related +models to be populated. + +Recurrently calls from_row method on nested instances and create nested +instances. In the end those instances are added to the final model dictionary. + +**Arguments**: + +- `proxy_source_model (Optional[Type["ModelRow"]])`: source model from which querysetproxy is constructed +- `excludable (ExcludableItems)`: structure of fields to include and exclude +- `source_model (Type[Model])`: source model from which relation started +- `current_relation_str (str)`: joined related parts into one string +- `item (Dict)`: dictionary of already populated nested models, otherwise empty dict +- `row (sqlalchemy.engine.result.ResultProxy)`: raw result row from the database +- `related_models (Union[Dict, List])`: list or dict of related models + +**Returns**: + +`(Dict)`: dictionary with keys corresponding to model fields names +and values are database values + + +#### populate\_through\_instance + +```python + | @classmethod + | populate_through_instance(cls, row: sqlalchemy.engine.ResultProxy, through_name: str, related: str, excludable: ExcludableItems) -> "ModelRow" +``` + +Initialize the through model from db row. +Excluded all relation fields and other exclude/include set in excludable. + +**Arguments**: + +- `row (sqlalchemy.engine.ResultProxy)`: loaded row from database +- `through_name (str)`: name of the through field +- `related (str)`: name of the relation +- `excludable (ExcludableItems)`: structure of fields to include and exclude + +**Returns**: + +`("ModelRow")`: initialized through model without relation + + +#### extract\_prefixed\_table\_columns + +```python + | @classmethod + | extract_prefixed_table_columns(cls, item: dict, row: sqlalchemy.engine.result.ResultProxy, table_prefix: str, excludable: ExcludableItems) -> Dict +``` + +Extracts own fields from raw sql result, using a given prefix. +Prefix changes depending on the table's position in a join. + +If the table is a main table, there is no prefix. +All joined tables have prefixes to allow duplicate column names, +as well as duplicated joins to the same table from multiple different tables. + +Extracted fields populates the related dict later used to construct a Model. + +Used in Model.from_row and PrefetchQuery._populate_rows methods. + +**Arguments**: + +- `excludable (ExcludableItems)`: structure of fields to include and exclude +- `item (Dict)`: dictionary of already populated nested models, otherwise empty dict +- `row (sqlalchemy.engine.result.ResultProxy)`: raw result row from the database +- `table_prefix (str)`: prefix of the table from AliasManager +each pair of tables have own prefix (two of them depending on direction) - +used in joins to allow multiple joins to the same table. + +**Returns**: + +`(Dict)`: dictionary with keys corresponding to model fields names +and values are database values + diff --git a/docs/api/models/model.md b/docs/api/models/model.md index e78825f..facb8f4 100644 --- a/docs/api/models/model.md +++ b/docs/api/models/model.md @@ -5,122 +5,14 @@ ## Model Objects ```python -class Model(NewBaseModel) +class Model(ModelRow) ``` - -#### from\_row - -```python - | @classmethod - | from_row(cls: Type[T], row: sqlalchemy.engine.ResultProxy, select_related: List = None, related_models: Any = None, previous_model: Type[T] = None, source_model: Type[T] = None, related_name: str = None, fields: Optional[Union[Dict, Set]] = None, exclude_fields: Optional[Union[Dict, Set]] = None, current_relation_str: str = None) -> Optional[T] -``` - -Model method to convert raw sql row from database into ormar.Model instance. -Traverses nested models if they were specified in select_related for query. - -Called recurrently and returns model instance if it's present in the row. -Note that it's processing one row at a time, so if there are duplicates of -parent row that needs to be joined/combined -(like parent row in sql join with 2+ child rows) -instances populated in this method are later combined in the QuerySet. -Other method working directly on raw database results is in prefetch_query, -where rows are populated in a different way as they do not have -nested models in result. - -**Arguments**: - -- `current_relation_str (str)`: name of the relation field -- `source_model (Type[Model])`: model on which relation was defined -- `row (sqlalchemy.engine.result.ResultProxy)`: raw result row from the database -- `select_related (List)`: list of names of related models fetched from database -- `related_models (Union[List, Dict])`: list or dict of related models -- `previous_model (Model class)`: internal param for nested models to specify table_prefix -- `related_name (str)`: internal parameter - name of current nested model -- `fields (Optional[Union[Dict, Set]])`: fields and related model fields to include -if provided only those are included -- `exclude_fields (Optional[Union[Dict, Set]])`: fields and related model fields to exclude -excludes the fields even if they are provided in fields - -**Returns**: - -`(Optional[Model])`: returns model if model is populated from database - - -#### populate\_nested\_models\_from\_row - -```python - | @classmethod - | populate_nested_models_from_row(cls, item: dict, row: sqlalchemy.engine.ResultProxy, related_models: Any, fields: Optional[Union[Dict, Set]] = None, exclude_fields: Optional[Union[Dict, Set]] = None, current_relation_str: str = None, source_model: Type[T] = None) -> dict -``` - -Traverses structure of related models and populates the nested models -from the database row. -Related models can be a list if only directly related models are to be -populated, converted to dict if related models also have their own related -models to be populated. - -Recurrently calls from_row method on nested instances and create nested -instances. In the end those instances are added to the final model dictionary. - -**Arguments**: - -- `source_model (Type[Model])`: source model from which relation started -- `current_relation_str (str)`: joined related parts into one string -- `item (Dict)`: dictionary of already populated nested models, otherwise empty dict -- `row (sqlalchemy.engine.result.ResultProxy)`: raw result row from the database -- `related_models (Union[Dict, List])`: list or dict of related models -- `fields (Optional[Union[Dict, Set]])`: fields and related model fields to include - -if provided only those are included -- `exclude_fields (Optional[Union[Dict, Set]])`: fields and related model fields to exclude -excludes the fields even if they are provided in fields - -**Returns**: - -`(Dict)`: dictionary with keys corresponding to model fields names -and values are database values - - -#### extract\_prefixed\_table\_columns - -```python - | @classmethod - | extract_prefixed_table_columns(cls, item: dict, row: sqlalchemy.engine.result.ResultProxy, table_prefix: str, fields: Optional[Union[Dict, Set]] = None, exclude_fields: Optional[Union[Dict, Set]] = None) -> dict -``` - -Extracts own fields from raw sql result, using a given prefix. -Prefix changes depending on the table's position in a join. - -If the table is a main table, there is no prefix. -All joined tables have prefixes to allow duplicate column names, -as well as duplicated joins to the same table from multiple different tables. - -Extracted fields populates the related dict later used to construct a Model. - -Used in Model.from_row and PrefetchQuery._populate_rows methods. - -**Arguments**: - -- `item (Dict)`: dictionary of already populated nested models, otherwise empty dict -- `row (sqlalchemy.engine.result.ResultProxy)`: raw result row from the database -- `table_prefix (str)`: prefix of the table from AliasManager -each pair of tables have own prefix (two of them depending on direction) - -used in joins to allow multiple joins to the same table. -- `fields (Optional[Union[Dict, Set]])`: fields and related model fields to include - -if provided only those are included -- `exclude_fields (Optional[Union[Dict, Set]])`: fields and related model fields to exclude -excludes the fields even if they are provided in fields - -**Returns**: - -`(Dict)`: dictionary with keys corresponding to model fields names -and values are database values - #### upsert ```python - | async upsert(**kwargs: Any) -> T + | async upsert(**kwargs: Any) -> "Model" ``` Performs either a save or an update depending on the presence of the pk. @@ -139,7 +31,7 @@ For save kwargs are ignored, used only in update if provided. #### save ```python - | async save() -> T + | async save() -> "Model" ``` Performs a save of given Model instance. @@ -203,7 +95,7 @@ number of updated instances ```python | @staticmethod - | async _update_and_follow(rel: T, follow: bool, visited: Set, update_count: int) -> Tuple[int, Set] + | async _update_and_follow(rel: "Model", follow: bool, visited: Set, update_count: int) -> Tuple[int, Set] ``` Internal method used in save_related to follow related models and update numbers @@ -227,7 +119,7 @@ number of updated instances #### update ```python - | async update(**kwargs: Any) -> T + | async update(**kwargs: Any) -> "Model" ``` Performs update of Model instance in the database. @@ -274,7 +166,7 @@ or update and the Model will be saved in database again. #### load ```python - | async load() -> T + | async load() -> "Model" ``` Allow to refresh existing Models fields from database. @@ -289,3 +181,40 @@ Does NOT refresh the related models fields if they were loaded before. `(Model)`: reloaded Model + +#### load\_all + +```python + | async load_all(follow: bool = False, exclude: Union[List, str, Set, Dict] = None) -> "Model" +``` + +Allow to refresh existing Models fields from database. +Performs refresh of the related models fields. + +By default loads only self and the directly related ones. + +If follow=True is set it loads also related models of related models. + +To not get stuck in an infinite loop as related models also keep a relation +to parent model visited models set is kept. + +That way already visited models that are nested are loaded, but the load do not +follow them inside. So Model A -> Model B -> Model C -> Model A -> Model X +will load second Model A but will never follow into Model X. +Nested relations of those kind need to be loaded manually. + +**Raises**: + +- `NoMatch`: If given pk is not found in database. + +**Arguments**: + +- `exclude ()`: +- `follow (bool)`: flag to trigger deep save - +by default only directly related models are saved +with follow=True also related models of related models are saved + +**Returns**: + +`(Model)`: reloaded Model + diff --git a/docs/api/models/new-basemodel.md b/docs/api/models/new-basemodel.md index 6499cf0..88b4ed7 100644 --- a/docs/api/models/new-basemodel.md +++ b/docs/api/models/new-basemodel.md @@ -146,7 +146,7 @@ Raises exception if model is abstract or has ForwardRefs in relation fields. #### \_extract\_related\_model\_instead\_of\_field ```python - | _extract_related_model_instead_of_field(item: str) -> Optional[Union["T", Sequence["T"]]] + | _extract_related_model_instead_of_field(item: str) -> Optional[Union["Model", Sequence["Model"]]] ``` Retrieves the related model/models from RelationshipManager. @@ -276,7 +276,7 @@ cause some dialect require different treatment #### remove ```python - | remove(parent: "T", name: str) -> None + | remove(parent: "Model", name: str) -> None ``` Removes child from relation with given name in RelationshipManager diff --git a/docs/api/query-set/join.md b/docs/api/query-set/join.md index 213d4c0..9a8f898 100644 --- a/docs/api/query-set/join.md +++ b/docs/api/query-set/join.md @@ -22,11 +22,25 @@ Shortcut for ormar's model AliasManager stored on Meta. `(AliasManager)`: alias manager from model's Meta - -#### on\_clause + +#### to\_table ```python - | on_clause(previous_alias: str, from_clause: str, to_clause: str) -> text + | @property + | to_table() -> str +``` + +Shortcut to table name of the next model + +**Returns**: + +`(str)`: name of the target table + + +#### \_on\_clause + +```python + | _on_clause(previous_alias: str, from_clause: str, to_clause: str) -> text ``` Receives aliases and names of both ends of the join and combines them @@ -99,11 +113,11 @@ Updated are: - `related_name (str)`: name of the relation to follow - `remainder (Any)`: deeper tables if there are more nested joins - -#### process\_m2m\_through\_table + +#### \_process\_m2m\_through\_table ```python - | process_m2m_through_table() -> None + | _process_m2m_through_table() -> None ``` Process Through table of the ManyToMany relation so that source table is @@ -119,11 +133,11 @@ Replaces needed parameters like: To point to through model - -#### process\_m2m\_related\_name\_change + +#### \_process\_m2m\_related\_name\_change ```python - | process_m2m_related_name_change(reverse: bool = False) -> str + | _process_m2m_related_name_change(reverse: bool = False) -> str ``` Extracts relation name to link join through the Through model declared on @@ -158,74 +172,21 @@ Updates the used aliases list directly. Process order_by causes for non m2m relations. - -#### \_replace\_many\_to\_many\_order\_by\_columns + +#### \_get\_order\_bys ```python - | _replace_many_to_many_order_by_columns(part: str, new_part: str) -> None -``` - -Substitutes the name of the relation with actual model name in m2m order bys. - -**Arguments**: - -- `part (str)`: name of the field with relation -- `new_part (str)`: name of the target model - - -#### \_check\_if\_condition\_apply - -```python - | @staticmethod - | _check_if_condition_apply(condition: List, part: str) -> bool -``` - -Checks filter conditions to find if they apply to current join. - -**Arguments**: - -- `condition (List[str])`: list of parts of condition split by '__' -- `part (str)`: name of the current relation join. - -**Returns**: - -`(bool)`: result of the check - - -#### set\_aliased\_order\_by - -```python - | set_aliased_order_by(condition: List[str], to_table: str) -> None -``` - -Substitute hyphens ('-') with descending order. -Construct actual sqlalchemy text clause using aliased table and column name. - -**Arguments**: - -- `condition (List[str])`: list of parts of a current condition split by '__' -- `to_table (sqlalchemy.sql.elements.quoted_name)`: target table - - -#### get\_order\_bys - -```python - | get_order_bys(to_table: str, pkname_alias: str) -> None + | _get_order_bys() -> None ``` Triggers construction of order bys if they are given. Otherwise by default each table is sorted by a primary key column asc. -**Arguments**: - -- `to_table (sqlalchemy.sql.elements.quoted_name)`: target table -- `pkname_alias (str)`: alias of the primary key column - - -#### get\_to\_and\_from\_keys + +#### \_get\_to\_and\_from\_keys ```python - | get_to_and_from_keys() -> Tuple[str, str] + | _get_to_and_from_keys() -> Tuple[str, str] ``` Based on the relation type, name of the relation and previous models and parts diff --git a/docs/api/query-set/prefetch-query.md b/docs/api/query-set/prefetch-query.md index cc848f0..ff0a64c 100644 --- a/docs/api/query-set/prefetch-query.md +++ b/docs/api/query-set/prefetch-query.md @@ -1,26 +1,6 @@ # queryset.prefetch\_query - -#### add\_relation\_field\_to\_fields - -```python -add_relation_field_to_fields(fields: Union[Set[Any], Dict[Any, Any], None], related_field_name: str) -> Union[Set[Any], Dict[Any, Any], None] -``` - -Adds related field into fields to include as otherwise it would be skipped. -Related field is added only if fields are already populated. -Empty fields implies all fields. - -**Arguments**: - -- `fields (Dict)`: Union[Set[Any], Dict[Any, Any], None] -- `related_field_name (str)`: name of the field with relation - -**Returns**: - -`(Union[Set[Any], Dict[Any, Any], None])`: updated fields dict - #### sort\_models @@ -232,7 +212,7 @@ on each of the parent models from list. #### \_extract\_related\_models ```python - | async _extract_related_models(related: str, target_model: Type["Model"], prefetch_dict: Dict, select_dict: Dict, fields: Union[Set[Any], Dict[Any, Any], None], exclude_fields: Union[Set[Any], Dict[Any, Any], None], orders_by: Dict) -> None + | async _extract_related_models(related: str, target_model: Type["Model"], prefetch_dict: Dict, select_dict: Dict, excludable: "ExcludableItems", orders_by: Dict) -> None ``` Constructs queries with required ids and extracts data with fields that should @@ -261,7 +241,7 @@ Calls itself recurrently to extract deeper nested relations of related model. #### \_run\_prefetch\_query ```python - | async _run_prefetch_query(target_field: Type["BaseField"], fields: Union[Set[Any], Dict[Any, Any], None], exclude_fields: Union[Set[Any], Dict[Any, Any], None], filter_clauses: List) -> Tuple[str, List] + | async _run_prefetch_query(target_field: Type["BaseField"], excludable: "ExcludableItems", filter_clauses: List, related_field_name: str) -> Tuple[str, str, List] ``` Actually runs the queries against the database and populates the raw response @@ -273,8 +253,6 @@ models. **Arguments**: - `target_field (Type["BaseField"])`: ormar field with relation definition -- `fields (Union[Set[Any], Dict[Any, Any], None])`: fields to include -- `exclude_fields (Union[Set[Any], Dict[Any, Any], None])`: fields to exclude - `filter_clauses (List[sqlalchemy.sql.elements.TextClause])`: list of clauses, actually one clause with ids of relation **Returns**: @@ -320,7 +298,7 @@ Updates models that are already loaded, usually children of children. #### \_populate\_rows ```python - | _populate_rows(rows: List, target_field: Type["ForeignKeyField"], parent_model: Type["Model"], table_prefix: str, fields: Union[Set[Any], Dict[Any, Any], None], exclude_fields: Union[Set[Any], Dict[Any, Any], None], prefetch_dict: Dict, orders_by: Dict) -> None + | _populate_rows(rows: List, target_field: Type["ForeignKeyField"], parent_model: Type["Model"], table_prefix: str, exclude_prefix: str, excludable: "ExcludableItems", prefetch_dict: Dict, orders_by: Dict) -> None ``` Instantiates children models extracted from given relation. @@ -334,12 +312,11 @@ and set on the parent model after sorting if needed. **Arguments**: +- `excludable (ExcludableItems)`: structure of fields to include and exclude - `rows (List[sqlalchemy.engine.result.RowProxy])`: raw sql response from the prefetch query - `target_field (Type["BaseField"])`: field with relation definition from parent model - `parent_model (Type[Model])`: model with relation definition - `table_prefix (str)`: prefix of the target table from current relation -- `fields (Union[Set[Any], Dict[Any, Any], None])`: fields to include -- `exclude_fields (Union[Set[Any], Dict[Any, Any], None])`: fields to exclude - `prefetch_dict (Dict)`: dictionaries of related models to prefetch - `orders_by (Dict)`: dictionary of order by clauses by model diff --git a/docs/api/query-set/query-set.md b/docs/api/query-set/query-set.md index 4c2ad6a..34f0945 100644 --- a/docs/api/query-set/query-set.md +++ b/docs/api/query-set/query-set.md @@ -38,6 +38,16 @@ Shortcut to model class set on QuerySet. `(Type[Model])`: model class + +#### rebuild\_self + +```python + | rebuild_self(filter_clauses: List = None, exclude_clauses: List = None, select_related: List = None, limit_count: int = None, offset: int = None, excludable: "ExcludableItems" = None, order_bys: List = None, prefetch_related: List = None, limit_raw_sql: bool = None, proxy_source_model: Optional[Type["Model"]] = None) -> "QuerySet" +``` + +Method that returns new instance of queryset based on passed params, +all not passed params are taken from current values. + #### \_prefetch\_related\_models @@ -252,7 +262,7 @@ To chain related `Models` relation use double underscores between names. #### fields ```python - | fields(columns: Union[List, str, Set, Dict]) -> "QuerySet" + | fields(columns: Union[List, str, Set, Dict], _is_exclude: bool = False) -> "QuerySet" ``` With `fields()` you can select subset of model columns to limit the data load. @@ -293,6 +303,7 @@ To include whole nested model specify model related field name and ellipsis. **Arguments**: +- `_is_exclude (bool)`: flag if it's exclude or include operation - `columns (Union[List, str, Set, Dict])`: columns to include **Returns**: diff --git a/docs/api/query-set/query.md b/docs/api/query-set/query.md index 4715c36..dc3314d 100644 --- a/docs/api/query-set/query.md +++ b/docs/api/query-set/query.md @@ -17,38 +17,6 @@ class Query() Initialize empty order_by dict to be populated later during the query call - -#### prefixed\_pk\_name - -```python - | @property - | prefixed_pk_name() -> str -``` - -Shortcut for extracting prefixed with alias primary key column name from main -model - -**Returns**: - -`(str)`: alias of pk column prefix with table name. - - -#### alias - -```python - | alias(name: str) -> str -``` - -Shortcut to extracting column alias from given master model. - -**Arguments**: - -- `name (str)`: name of column - -**Returns**: - -`(str)`: alias of given column name - #### apply\_order\_bys\_for\_primary\_model diff --git a/docs/api/query-set/utils.md b/docs/api/query-set/utils.md index 0ba9471..f42e340 100644 --- a/docs/api/query-set/utils.md +++ b/docs/api/query-set/utils.md @@ -154,7 +154,7 @@ with all children models under their relation keys. #### get\_relationship\_alias\_model\_and\_str ```python -get_relationship_alias_model_and_str(source_model: Type["Model"], related_parts: List) -> Tuple[str, Type["Model"], str] +get_relationship_alias_model_and_str(source_model: Type["Model"], related_parts: List) -> Tuple[str, Type["Model"], str, bool] ``` Walks the relation to retrieve the actual model on which the clause should be diff --git a/docs/api/relations/alias-manager.md b/docs/api/relations/alias-manager.md index 24016de..d563cf8 100644 --- a/docs/api/relations/alias-manager.md +++ b/docs/api/relations/alias-manager.md @@ -120,7 +120,7 @@ Adds alias to the dictionary of aliases under given key. #### resolve\_relation\_alias ```python - | resolve_relation_alias(from_model: Type["Model"], relation_name: str) -> str + | resolve_relation_alias(from_model: Union[Type["Model"], Type["ModelRow"]], relation_name: str) -> str ``` Given model and relation name returns the alias for this relation. @@ -134,3 +134,24 @@ Given model and relation name returns the alias for this relation. `(str)`: alias of the relation + +#### resolve\_relation\_alias\_after\_complex + +```python + | resolve_relation_alias_after_complex(source_model: Union[Type["Model"], Type["ModelRow"]], relation_str: str, relation_field: Type["ForeignKeyField"]) -> str +``` + +Given source model and relation string returns the alias for this complex +relation if it exists, otherwise fallback to normal relation from a relation +field definition. + +**Arguments**: + +- `relation_field (Type["ForeignKeyField"])`: field with direct relation definition +- `source_model (source Model)`: model with query starts +- `relation_str (str)`: string with relation joins defined + +**Returns**: + +`(str)`: alias of the relation + diff --git a/docs/api/relations/queryset-proxy.md b/docs/api/relations/queryset-proxy.md index 627e995..1eb9637 100644 --- a/docs/api/relations/queryset-proxy.md +++ b/docs/api/relations/queryset-proxy.md @@ -5,7 +5,7 @@ ## QuerysetProxy Objects ```python -class QuerysetProxy(ormar.QuerySetProtocol) +class QuerysetProxy() ``` Exposes QuerySet methods on relations, but also handles creating and removing @@ -43,7 +43,7 @@ Set's the queryset. Initialized in RelationProxy. #### \_assign\_child\_to\_parent ```python - | _assign_child_to_parent(child: Optional["T"]) -> None + | _assign_child_to_parent(child: Optional["Model"]) -> None ``` Registers child in parents RelationManager. @@ -56,7 +56,7 @@ Registers child in parents RelationManager. #### \_register\_related ```python - | _register_related(child: Union["T", Sequence[Optional["T"]]]) -> None + | _register_related(child: Union["Model", Sequence[Optional["Model"]]]) -> None ``` Registers child/ children in parents RelationManager. @@ -78,20 +78,35 @@ Cleans the current list of the related models. #### create\_through\_instance ```python - | async create_through_instance(child: "T") -> None + | async create_through_instance(child: "Model", **kwargs: Any) -> None ``` Crete a through model instance in the database for m2m relations. **Arguments**: +- `kwargs (Any)`: dict of additional keyword arguments for through instance +- `child (Model)`: child model instance + + +#### update\_through\_instance + +```python + | async update_through_instance(child: "Model", **kwargs: Any) -> None +``` + +Updates a through model instance in the database for m2m relations. + +**Arguments**: + +- `kwargs (Any)`: dict of additional keyword arguments for through instance - `child (Model)`: child model instance #### delete\_through\_instance ```python - | async delete_through_instance(child: "T") -> None + | async delete_through_instance(child: "Model") -> None ``` Removes through model instance from the database for m2m relations. @@ -256,6 +271,27 @@ Actual call delegated to QuerySet. `(Model)`: created model + +#### update + +```python + | async update(each: bool = False, **kwargs: Any) -> int +``` + +Updates the model table after applying the filters from kwargs. + +You have to either pass a filter to narrow down a query or explicitly pass +each=True flag to affect whole table. + +**Arguments**: + +- `each (bool)`: flag if whole table should be affected if no filter is passed +- `kwargs (Any)`: fields names and proper value types + +**Returns**: + +`(int)`: number of updated rows + #### get\_or\_create diff --git a/docs/api/relations/relation-manager.md b/docs/api/relations/relation-manager.md index 57ad512..d83febe 100644 --- a/docs/api/relations/relation-manager.md +++ b/docs/api/relations/relation-manager.md @@ -10,37 +10,6 @@ class RelationsManager() Manages relations on a Model, each Model has it's own instance. - -#### \_get\_relation\_type - -```python - | _get_relation_type(field: Type[BaseField]) -> RelationType -``` - -Returns type of the relation declared on a field. - -**Arguments**: - -- `field (Type[BaseField])`: field with relation declaration - -**Returns**: - -`(RelationType)`: type of the relation defined on field - - -#### \_add\_relation - -```python - | _add_relation(field: Type[BaseField]) -> None -``` - -Registers relation in the manager. -Adds Relation instance under field.name. - -**Arguments**: - -- `field (Type[BaseField])`: field with relation declaration - #### \_\_contains\_\_ @@ -62,7 +31,7 @@ Checks if relation with given name is already registered. #### get ```python - | get(name: str) -> Optional[Union["T", Sequence["T"]]] + | get(name: str) -> Optional[Union["Model", Sequence["Model"]]] ``` Returns the related model/models if relation is set. @@ -76,23 +45,6 @@ Actual call is delegated to Relation instance registered under relation name. `(Optional[Union[Model, List[Model]])`: related model or list of related models if set - -#### \_get - -```python - | _get(name: str) -> Optional[Relation] -``` - -Returns the actual relation and not the related model(s). - -**Arguments**: - -- `name (str)`: name of the relation - -**Returns**: - -`(ormar.relations.relation.Relation)`: Relation instance - #### add @@ -148,3 +100,51 @@ of relation from which you want to remove the parent. - `parent (Model)`: parent Model - `name (str)`: name of the relation + +#### \_get + +```python + | _get(name: str) -> Optional[Relation] +``` + +Returns the actual relation and not the related model(s). + +**Arguments**: + +- `name (str)`: name of the relation + +**Returns**: + +`(ormar.relations.relation.Relation)`: Relation instance + + +#### \_get\_relation\_type + +```python + | _get_relation_type(field: Type["BaseField"]) -> RelationType +``` + +Returns type of the relation declared on a field. + +**Arguments**: + +- `field (Type[BaseField])`: field with relation declaration + +**Returns**: + +`(RelationType)`: type of the relation defined on field + + +#### \_add\_relation + +```python + | _add_relation(field: Type["BaseField"]) -> None +``` + +Registers relation in the manager. +Adds Relation instance under field.name. + +**Arguments**: + +- `field (Type[BaseField])`: field with relation declaration + diff --git a/docs/api/relations/relation-proxy.md b/docs/api/relations/relation-proxy.md index 645bb2a..1d122a7 100644 --- a/docs/api/relations/relation-proxy.md +++ b/docs/api/relations/relation-proxy.md @@ -131,7 +131,7 @@ will be deleted, and not only removed from relation). #### add ```python - | async add(item: "Model") -> None + | async add(item: "Model", **kwargs: Any) -> None ``` Adds child model to relation. @@ -140,5 +140,6 @@ For ManyToMany relations through instance is automatically created. **Arguments**: +- `kwargs (Any)`: dict of additional keyword arguments for through instance - `item (Model)`: child to add to relation diff --git a/docs/api/relations/relation.md b/docs/api/relations/relation.md index 1c50b36..29e8ab7 100644 --- a/docs/api/relations/relation.md +++ b/docs/api/relations/relation.md @@ -27,7 +27,7 @@ Keeps related Models and handles adding/removing of the children. #### \_\_init\_\_ ```python - | __init__(manager: "RelationsManager", type_: RelationType, field_name: str, to: Type["T"], through: Type["T"] = None) -> None + | __init__(manager: "RelationsManager", type_: RelationType, field_name: str, to: Type["Model"], through: Type["Model"] = None) -> None ``` Initialize the Relation and keep the related models either as instances of @@ -73,7 +73,7 @@ Find child model in RelationProxy if exists. #### add ```python - | add(child: "T") -> None + | add(child: "Model") -> None ``` Adds child Model to relation, either sets child as related model or adds @@ -101,7 +101,7 @@ it from the list in RelationProxy depending on relation type. #### get ```python - | get() -> Optional[Union[List["T"], "T"]] + | get() -> Optional[Union[List["Model"], "Model"]] ``` Return the related model or models from RelationProxy. diff --git a/docs/index.md b/docs/index.md index c6c5f41..d4655e5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -306,7 +306,7 @@ async def joins(): # visit: https://collerek.github.io/ormar/relations/ # to read more about joins and subqueries - # visit: https://collerek.github.io/ormar/queries/delete/ + # visit: https://collerek.github.io/ormar/queries/joins-and-subqueries/ async def filter_and_sort(): diff --git a/docs/models/methods.md b/docs/models/methods.md index 084ba25..d8d25b1 100644 --- a/docs/models/methods.md +++ b/docs/models/methods.md @@ -27,6 +27,39 @@ await track.album.load() track.album.name # will return 'Malibu' ``` +## load_all + +`load_all(follow: bool = False, exclude: Union[List, str, Set, Dict] = None) -> Model` + +Method works like `load()` but also goes through all relations of the `Model` on which the method is called, +and reloads them from database. + +By default the `load_all` method loads only models that are directly related (one step away) to the model on which the method is called. + +But you can specify the `follow=True` parameter to traverse through nested models and load all of them in the relation tree. + +!!!warning + To avoid circular updates with `follow=True` set, `load_all` keeps a set of already visited Models, + and won't perform nested `loads` on Models that were already visited. + + So if you have a diamond or circular relations types you need to perform the loads in a manual way. + + ```python + # in example like this the second Street (coming from City) won't be load_all, so ZipCode won't be reloaded + Street -> District -> City -> Street -> ZipCode + ``` + +Method accepts also optional exclude parameter that works exactly the same as exclude_fields method in `QuerySet`. +That way you can remove fields from related models being refreshed or skip whole related models. + +Method performs one database query so it's more efficient than nested calls to `load()` and `all()` on related models. + +!!!tip + To read more about `exclude` read [exclude_fields][exclude_fields] + +!!!warning + All relations are cleared on `load_all()`, so if you exclude some nested models they will be empty after call. + ## save `save() -> self` @@ -128,3 +161,4 @@ But you can specify the `follow=True` parameter to traverse through nested model [alembic]: https://alembic.sqlalchemy.org/en/latest/tutorial.html [save status]: ../models/index/#model-save-status [Internals]: #internals +[exclude_fields]: ../queries/select-columns.md#exclude_fields diff --git a/docs/relations/index.md b/docs/relations/index.md index 0896c13..76ab3ba 100644 --- a/docs/relations/index.md +++ b/docs/relations/index.md @@ -52,7 +52,7 @@ class Department(ormar.Model): To define many-to-many relation use `ManyToMany` field. -```python hl_lines="25-26" +```python hl_lines="18" class Category(ormar.Model): class Meta: tablename = "categories" @@ -62,13 +62,6 @@ class Category(ormar.Model): id: int = ormar.Integer(primary_key=True) name: str = ormar.String(max_length=40) -# note: you need to specify through model -class PostCategory(ormar.Model): - class Meta: - tablename = "posts_categories" - database = database - metadata = metadata - class Post(ormar.Model): class Meta: tablename = "posts" @@ -77,9 +70,7 @@ class Post(ormar.Model): id: int = ormar.Integer(primary_key=True) title: str = ormar.String(max_length=200) - categories: Optional[Union[Category, List[Category]]] = ormar.ManyToMany( - Category, through=PostCategory - ) + categories: Optional[List[Category]] = ormar.ManyToMany(Category) ``` @@ -92,7 +83,52 @@ class Post(ormar.Model): It allows you to use `await post.categories.all()` but also `await category.posts.all()` to fetch data related only to specific post, category etc. -##Self-reference and postponed references +## Through fields + +As part of the `ManyToMany` relation you can define a through model, that can contain additional +fields that you can use to filter, order etc. Fields defined like this are exposed on the reverse +side of the current query for m2m models. + +So if you query from model `A` to model `B`, only model `B` has through field exposed. +Which kind of make sense, since it's a one through model/field for each of related models. + +```python hl_lines="10-15" +class Category(ormar.Model): + class Meta(BaseMeta): + tablename = "categories" + + id = ormar.Integer(primary_key=True) + name = ormar.String(max_length=40) + +# you can specify additional fields on through model +class PostCategory(ormar.Model): + class Meta(BaseMeta): + tablename = "posts_x_categories" + + id: int = ormar.Integer(primary_key=True) + sort_order: int = ormar.Integer(nullable=True) + param_name: str = ormar.String(default="Name", max_length=200) + + +class Post(ormar.Model): + class Meta(BaseMeta): + pass + + id: int = ormar.Integer(primary_key=True) + title: str = ormar.String(max_length=200) + categories = ormar.ManyToMany(Category, through=PostCategory) +``` + +!!!tip + To read more about many-to-many relations and through fields visit [many-to-many][many-to-many] section + + +!!!tip + ManyToMany allows you to query the related models with [queryset-proxy][queryset-proxy]. + + It allows you to use `await post.categories.all()` but also `await category.posts.all()` to fetch data related only to specific post, category etc. + +## Self-reference and postponed references In order to create auto-relation or create two models that reference each other in at least two different relations (remember the reverse side is auto-registered for you), you need to use diff --git a/docs/relations/many-to-many.md b/docs/relations/many-to-many.md index 8038a6f..24be745 100644 --- a/docs/relations/many-to-many.md +++ b/docs/relations/many-to-many.md @@ -1,6 +1,6 @@ # ManyToMany -`ManyToMany(to, through)` has required parameters `to` and `through` that takes target and relation `Model` classes. +`ManyToMany(to, through)` has required parameters `to` and optional `through` that takes target and relation `Model` classes. Sqlalchemy column and Type are automatically taken from target `Model`. @@ -9,7 +9,7 @@ Sqlalchemy column and Type are automatically taken from target `Model`. ## Defining Models -```Python hl_lines="32 49-50" +```Python hl_lines="40" --8<-- "../docs_src/relations/docs002.py" ``` @@ -20,8 +20,154 @@ post = await Post.objects.create(title="Hello, M2M", author=guido) news = await Category.objects.create(name="News") ``` +## Through Model + +Optionally if you want to add additional fields you can explicitly create and pass +the through model class. + +```Python hl_lines="14-20 29" +--8<-- "../docs_src/relations/docs004.py" +``` + +!!!warning + Note that even of you do not provide through model it's going to be created for you automatically and + still has to be included in example in `alembic` migrations. + +!!!tip + Note that you need to provide `through` model if you want to + customize the `Through` model name or the database table name of this model. + +If you do not provide the Through field it will be generated for you. + +The default naming convention is: + +* for class name it's union of both classes name (parent+other) so in example above + it would be `PostCategory` +* for table name it similar but with underscore in between and s in the end of class + lowercase name, in example above would be `posts_categorys` + +## Through Fields + +The through field is auto added to the reverse side of the relation. + +The exposed field is named as lowercase `Through` class name. + +The exposed field **explicitly has no relations loaded** as the relation is already populated in `ManyToMany` field, +so it's useful only when additional fields are provided on `Through` model. + +In a sample model setup as following: + +```Python hl_lines="14-20 29" +--8<-- "../docs_src/relations/docs004.py" +``` + +the through field can be used as a normal model field in most of the QuerySet operations. + +Note that through field is attached only to related side of the query so: + +```python +post = await Post.objects.select_related("categories").get() +# source model has no through field +assert post.postcategory is None +# related models have through field +assert post.categories[0].postcategory is not None + +# same is applicable for reversed query +category = await Category.objects.select_related("posts").get() +assert category.postcategory is None +assert category.posts[0].postcategory is not None +``` + +Through field can be used for filtering the data. +```python +post = ( + await Post.objects.select_related("categories") + .filter(postcategory__sort_order__gt=1) + .get() + ) +``` + +!!!tip + Note that despite that the actual instance is not populated on source model, + in queries, order by statements etc you can access through model from both sides. + So below query has exactly the same effect (note access through `categories`) + + ```python + post = ( + await Post.objects.select_related("categories") + .filter(categories__postcategory__sort_order__gt=1) + .get() + ) + ``` + +Through model can be used in order by queries. +```python +post = ( + await Post.objects.select_related("categories") + .order_by("-postcategory__sort_order") + .get() + ) +``` + +You can also select subset of the columns in a normal `QuerySet` way with `fields` +and `exclude_fields`. + +```python +post2 = ( + await Post.objects.select_related("categories") + .exclude_fields("postcategory__param_name") + .get() + ) +``` + +!!!warning + Note that because through fields explicitly nullifies all relation fields, as relation + is populated in ManyToMany field, you should not use the standard model methods like + `save()` and `update()` before re-loading the field from database. + +If you want to modify the through field in place remember to reload it from database. +Otherwise you will set relations to None so effectively make the field useless! + +```python +# always reload the field before modification +await post2.categories[0].postcategory.load() +# only then update the field +await post2.categories[0].postcategory.update(sort_order=3) +``` +Note that reloading the model effectively reloads the relations as `pk_only` models +(only primary key is set) so they are not fully populated, but it's enough to preserve +the relation on update. + +!!!warning + If you use i.e. `fastapi` the partially loaded related models on through field might cause + `pydantic` validation errors (that's the primary reason why they are not populated by default). + So either you need to exclude the related fields in your response, or fully load the related + models. In example above it would mean: + ```python + await post2.categories[0].postcategory.post.load() + await post2.categories[0].postcategory.category.load() + ``` + Alternatively you can use `load_all()`: + ```python + await post2.categories[0].postcategory.load_all() + ``` + +**Preferred way of update is through queryset proxy `update()` method** + +```python +# filter the desired related model with through field and update only through field params +await post2.categories.filter(name='Test category').update(postcategory={"sort_order": 3}) +``` + + +## Relation methods + ### add +`add(item: Model, **kwargs)` + +Allows you to add model to ManyToMany relation. + ```python # Add a category to a post. await post.categories.add(news) @@ -30,10 +176,24 @@ await news.posts.add(post) ``` !!!warning - In all not None cases the primary key value for related model **has to exist in database**. + In all not `None` cases the primary key value for related model **has to exist in database**. Otherwise an IntegrityError will be raised by your database driver library. +If you declare your models with a Through model with additional fields, you can populate them +during adding child model to relation. + +In order to do so, pass keyword arguments with field names and values to `add()` call. + +Note that this works only for `ManyToMany` relations. + +```python +post = await Post(title="Test post").save() +category = await Category(name="Test category").save() +# apart from model pass arguments referencing through model fields +await post.categories.add(category, sort_order=1, param_name='test') +``` + ### remove Removal of the related model one by one. diff --git a/docs/relations/queryset-proxy.md b/docs/relations/queryset-proxy.md index 9e033b6..db1343d 100644 --- a/docs/relations/queryset-proxy.md +++ b/docs/relations/queryset-proxy.md @@ -104,6 +104,29 @@ assert len(await post.categories.all()) == 2 !!!tip Read more in queries documentation [create][create] +For `ManyToMany` relations there is an additional functionality of passing parameters +that will be used to create a through model if you declared additional fields on explicitly +provided Through model. + +Given sample like this: + +```Python hl_lines="14-20, 29" +--8<-- "../docs_src/relations/docs004.py" +``` + +You can populate fields on through model in the `create()` call in a following way: + +```python + +post = await Post(title="Test post").save() +await post.categories.create( + name="Test category1", + # in arguments pass a dictionary with name of the through field and keys + # corresponding to through model fields + postcategory={"sort_order": 1, "param_name": "volume"}, +) +``` + ### get_or_create `get_or_create(**kwargs) -> Model` @@ -122,6 +145,29 @@ Updates the model, or in case there is no match in database creates a new one. !!!tip Read more in queries documentation [update_or_create][update_or_create] +### update + +`update(**kwargs, each:bool = False) -> int` + +Updates the related model with provided keyword arguments, return number of updated rows. + +!!!tip + Read more in queries documentation [update][update] + +Note that for `ManyToMany` relations update can also accept an argument with through field +name and a dictionary of fields. + +```Python hl_lines="14-20 29" +--8<-- "../docs_src/relations/docs004.py" +``` + +In example above you can update attributes of `postcategory` in a following call: +```python +await post.categories.filter(name="Test category3").update( + postcategory={"sort_order": 4} + ) +``` + ## Filtering and sorting ### filter @@ -251,6 +297,7 @@ Returns a bool value to confirm if there are rows matching the given criteria (a [create]: ../queries/create.md#create [get_or_create]: ../queries/read.md#get_or_create [update_or_create]: ../queries/update.md#update_or_create +[update]: ../queries/update.md#update [filter]: ../queries/filter-and-sort.md#filter [exclude]: ../queries/filter-and-sort.md#exclude [select_related]: ../queries/joins-and-subqueries.md#select_related diff --git a/docs/releases.md b/docs/releases.md index a14b531..5e61041 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,9 +1,50 @@ +# 0.9.6 + +##Important +* `Through` model for `ManyToMany` relations now **becomes optional**. It's not a breaking change + since if you provide it everything works just fine as it used to. So if you don't want or need any additional + fields on `Through` model you can skip it. Note that it's going to be created for you automatically and + still has to be included in example in `alembic` migrations. + If you want to delete existing one check the default naming convention to adjust your existing database structure. + + Note that you still need to provide it if you want to + customize the `Through` model name or the database table name. + +## Features +* Add `update` method to `QuerysetProxy` so now it's possible to update related models directly from parent model + in `ManyToMany` relations and in reverse `ForeignKey` relations. Note that update like in `QuerySet` `update` returns number of + updated models and **does not update related models in place** on parent model. To get the refreshed data on parent model you need to refresh + the related models (i.e. `await model_instance.related.all()`) +* Add `load_all(follow=False, exclude=None)` model method that allows to load current instance of the model + with all related models in one call. By default it loads only directly related models but setting + `follow=True` causes traversing the tree (avoiding loops). You can also pass `exclude` parameter + that works the same as `QuerySet.exclude_fields()` method. +* Added possibility to add more fields on `Through` model for `ManyToMany` relationships: + * name of the through model field is the lowercase name of the Through class + * you can pass additional fields when calling `add(child, **kwargs)` on relation (on `QuerysetProxy`) + * you can pass additional fields when calling `create(**kwargs)` on relation (on `QuerysetProxy`) + when one of the keyword arguments should be the through model name with a dict of values + * you can order by on through model fields + * you can filter on through model fields + * you can include and exclude fields on through models + * through models are attached only to related models (i.e. if you query from A to B -> only on B) + * note that through models are explicitly loaded without relations -> relation is already populated in ManyToMany field. + * note that just like before you cannot declare the relation fields on through model, they will be populated for you by `ormar`, + but now if you try to do so `ModelDefinitionError` will be thrown + * check the updated ManyToMany relation docs for more information + +# Other +* Updated docs and api docs +* Refactors and optimisations mainly related to filters, exclusions and order bys + + # 0.9.5 ## Fixes * Fix creation of `pydantic` FieldInfo after update of `pydantic` to version >=1.8 * Pin required dependency versions to avoid such situations in the future + # 0.9.4 ## Fixes diff --git a/docs_src/relations/docs002.py b/docs_src/relations/docs002.py index 8dd0566..9831ccb 100644 --- a/docs_src/relations/docs002.py +++ b/docs_src/relations/docs002.py @@ -29,15 +29,6 @@ class Category(ormar.Model): name: str = ormar.String(max_length=40) -class PostCategory(ormar.Model): - class Meta: - tablename = "posts_categories" - database = database - metadata = metadata - - # If there are no additional columns id will be created automatically as Integer - - class Post(ormar.Model): class Meta: tablename = "posts" @@ -46,7 +37,5 @@ class Post(ormar.Model): id: int = ormar.Integer(primary_key=True) title: str = ormar.String(max_length=200) - categories: Optional[Union[Category, List[Category]]] = ormar.ManyToMany( - Category, through=PostCategory - ) + categories: Optional[List[Category]] = ormar.ManyToMany(Category) author: Optional[Author] = ormar.ForeignKey(Author) diff --git a/docs_src/relations/docs004.py b/docs_src/relations/docs004.py new file mode 100644 index 0000000..9a3b0c0 --- /dev/null +++ b/docs_src/relations/docs004.py @@ -0,0 +1,29 @@ +class BaseMeta(ormar.ModelMeta): + database = database + metadata = metadata + + +class Category(ormar.Model): + class Meta(BaseMeta): + tablename = "categories" + + id = ormar.Integer(primary_key=True) + name = ormar.String(max_length=40) + + +class PostCategory(ormar.Model): + class Meta(BaseMeta): + tablename = "posts_x_categories" + + id: int = ormar.Integer(primary_key=True) + sort_order: int = ormar.Integer(nullable=True) + param_name: str = ormar.String(default="Name", max_length=200) + + +class Post(ormar.Model): + class Meta(BaseMeta): + pass + + id: int = ormar.Integer(primary_key=True) + title: str = ormar.String(max_length=200) + categories = ormar.ManyToMany(Category, through=PostCategory) diff --git a/mkdocs.yml b/mkdocs.yml index 2bdafce..8aba336 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -53,9 +53,11 @@ nav: - Relation Mixin: api/models/mixins/relation-mixin.md - Save Prepare Mixin: api/models/mixins/save-prepare-mixin.md - api/models/model.md + - Model Row: api/models/model-row.md - New BaseModel: api/models/new-basemodel.md - Model Table Proxy: api/models/model-table-proxy.md - Model Metaclass: api/models/model-metaclass.md + - Excludable Items: api/models/excludable-items.md - Fields: - Base Field: api/fields/base-field.md - Model Fields: api/fields/model-fields.md diff --git a/ormar/__init__.py b/ormar/__init__.py index 0768edf..5dbf884 100644 --- a/ormar/__init__.py +++ b/ormar/__init__.py @@ -54,9 +54,9 @@ from ormar.fields import ( UUID, UniqueColumns, ) # noqa: I100 -from ormar.models import Model +from ormar.models import ExcludableItems, Model from ormar.models.metaclass import ModelMeta -from ormar.queryset import QuerySet +from ormar.queryset import OrderAction, QuerySet from ormar.relations import RelationType from ormar.signals import Signal @@ -106,4 +106,6 @@ __all__ = [ "BaseField", "ManyToManyField", "ForeignKeyField", + "OrderAction", + "ExcludableItems", ] diff --git a/ormar/fields/__init__.py b/ormar/fields/__init__.py index 2e4a013..c5f61d4 100644 --- a/ormar/fields/__init__.py +++ b/ormar/fields/__init__.py @@ -21,6 +21,7 @@ from ormar.fields.model_fields import ( Time, UUID, ) +from ormar.fields.through_field import Through, ThroughField __all__ = [ "Decimal", @@ -41,4 +42,6 @@ __all__ = [ "BaseField", "UniqueColumns", "ForeignKeyField", + "ThroughField", + "Through", ] diff --git a/ormar/fields/base.py b/ormar/fields/base.py index 513d40a..1b5f7a6 100644 --- a/ormar/fields/base.py +++ b/ormar/fields/base.py @@ -37,9 +37,13 @@ class BaseField(FieldInfo): index: bool unique: bool pydantic_only: bool - virtual: bool = False choices: typing.Sequence + virtual: bool = False # ManyToManyFields and reverse ForeignKeyFields + is_multi: bool = False # ManyToManyField + is_relation: bool = False # ForeignKeyField + subclasses + is_through: bool = False # ThroughFields + owner: Type["Model"] to: Type["Model"] through: Type["Model"] @@ -63,7 +67,7 @@ class BaseField(FieldInfo): :return: result of the check :rtype: bool """ - return not issubclass(cls, ormar.fields.ManyToManyField) and not cls.virtual + return not cls.is_multi and not cls.virtual @classmethod def get_alias(cls) -> str: diff --git a/ormar/fields/foreign_key.py b/ormar/fields/foreign_key.py index 5e1e86c..da20325 100644 --- a/ormar/fields/foreign_key.py +++ b/ormar/fields/foreign_key.py @@ -48,7 +48,7 @@ def create_dummy_instance(fk: Type["Model"], pk: Any = None) -> "Model": **{ k: create_dummy_instance(v.to) for k, v in fk.Meta.model_fields.items() - if isinstance(v, ForeignKeyField) and not v.nullable and not v.virtual + if v.is_relation and not v.nullable and not v.virtual }, } return fk(**init_dict) @@ -73,7 +73,9 @@ def create_dummy_model( "".join(choices(string.ascii_uppercase, k=2)) + uuid.uuid4().hex[:4] ).lower() fields = {f"{pk_field.name}": (pk_field.__type__, None)} - dummy_model = create_model( # type: ignore + + dummy_model = create_model( # type: ignore + f"PkOnly{base_model.get_name(lower=False)}{alias}", __module__=base_model.__module__, **fields, # type: ignore @@ -217,6 +219,7 @@ def ForeignKey( # noqa CFQ002 ondelete=ondelete, owner=owner, self_reference=self_reference, + is_relation=True, ) return type("ForeignKey", (ForeignKeyField, BaseField), namespace) @@ -457,3 +460,24 @@ class ForeignKeyField(BaseField): value.__class__.__name__, cls._construct_model_from_pk )(value, child, to_register) return model + + @classmethod + def get_relation_name(cls) -> str: # pragma: no cover + """ + Returns name of the relation, which can be a own name or through model + names for m2m models + + :return: result of the check + :rtype: bool + """ + return cls.name + + @classmethod + def get_source_model(cls) -> Type["Model"]: # pragma: no cover + """ + Returns model from which the relation comes -> either owner or through model + + :return: source model + :rtype: Type["Model"] + """ + return cls.owner diff --git a/ormar/fields/many_to_many.py b/ormar/fields/many_to_many.py index 2228121..ec8a6f1 100644 --- a/ormar/fields/many_to_many.py +++ b/ormar/fields/many_to_many.py @@ -1,8 +1,9 @@ import sys -from typing import Any, List, Optional, TYPE_CHECKING, Tuple, Type, Union +from typing import Any, List, Optional, TYPE_CHECKING, Tuple, Type, Union, cast from pydantic.typing import ForwardRef, evaluate_forwardref import ormar # noqa: I100 +from ormar import ModelDefinitionError from ormar.fields import BaseField from ormar.fields.foreign_key import ForeignKeyField @@ -17,6 +18,21 @@ if TYPE_CHECKING: # pragma no cover REF_PREFIX = "#/components/schemas/" +def forbid_through_relations(through: Type["Model"]) -> None: + """ + Verifies if the through model does not have relations. + + :param through: through Model to be checked + :type through: Type['Model] + """ + if any(field.is_relation for field in through.Meta.model_fields.values()): + raise ModelDefinitionError( + f"Through Models cannot have explicit relations " + f"defined. Remove the relations from Model " + f"{through.get_name(lower=False)}" + ) + + def populate_m2m_params_based_on_to_model( to: Type["Model"], nullable: bool ) -> Tuple[Any, Any]: @@ -43,7 +59,7 @@ def populate_m2m_params_based_on_to_model( def ManyToMany( to: "ToType", - through: "ToType", + through: Optional["ToType"] = None, *, name: str = None, unique: bool = False, @@ -77,6 +93,8 @@ def ManyToMany( nullable = kwargs.pop("nullable", True) owner = kwargs.pop("owner", None) self_reference = kwargs.pop("self_reference", False) + if through is not None and through.__class__ != ForwardRef: + forbid_through_relations(cast(Type["Model"], through)) if to.__class__ == ForwardRef: __type__ = to if not nullable else Optional[to] @@ -103,6 +121,8 @@ def ManyToMany( server_default=None, owner=owner, self_reference=self_reference, + is_relation=True, + is_multi=True, ) return type("ManyToMany", (ManyToManyField, BaseField), namespace) @@ -187,3 +207,45 @@ class ManyToManyField(ForeignKeyField, ormar.QuerySetProtocol, ormar.RelationPro globalns, localns or None, ) + forbid_through_relations(cls.through) + + @classmethod + def get_relation_name(cls) -> str: + """ + Returns name of the relation, which can be a own name or through model + names for m2m models + + :return: result of the check + :rtype: bool + """ + if cls.self_reference and cls.name == cls.self_reference_primary: + return cls.default_source_field_name() + return cls.default_target_field_name() + + @classmethod + def get_source_model(cls) -> Type["Model"]: + """ + Returns model from which the relation comes -> either owner or through model + + :return: source model + :rtype: Type["Model"] + """ + return cls.through + + @classmethod + def create_default_through_model(cls) -> None: + """ + Creates default empty through model if no additional fields are required. + """ + owner_name = cls.owner.get_name(lower=False) + to_name = cls.to.get_name(lower=False) + class_name = f"{owner_name}{to_name}" + table_name = f"{owner_name.lower()}s_{to_name.lower()}s" + new_meta_namespace = { + "tablename": table_name, + "database": cls.owner.Meta.database, + "metadata": cls.owner.Meta.metadata, + } + new_meta = type("Meta", (), new_meta_namespace) + through_model = type(class_name, (ormar.Model,), {"Meta": new_meta}) + cls.through = cast(Type["Model"], through_model) diff --git a/ormar/fields/through_field.py b/ormar/fields/through_field.py new file mode 100644 index 0000000..b25e94b --- /dev/null +++ b/ormar/fields/through_field.py @@ -0,0 +1,66 @@ +import sys +from typing import Any, TYPE_CHECKING, Type, Union + +from ormar.fields.base import BaseField +from ormar.fields.foreign_key import ForeignKeyField + +if TYPE_CHECKING: # pragma no cover + from ormar import Model + from pydantic.typing import ForwardRef + + if sys.version_info < (3, 7): + ToType = Type[Model] + else: + ToType = Union[Type[Model], ForwardRef] + + +def Through( # noqa CFQ002 + to: "ToType", *, name: str = None, related_name: str = None, **kwargs: Any, +) -> Any: + """ + Despite a name it's a function that returns constructed ThroughField. + It's a special field populated only for m2m relations. + Accepts number of relation setting parameters as well as all BaseField ones. + + :param to: target related ormar Model + :type to: Model class + :param name: name of the database field - later called alias + :type name: str + :param related_name: name of reversed FK relation populated for you on to model + :type related_name: str + It is for reversed FK and auto generated FK on through model in Many2Many relations. + :param kwargs: all other args to be populated by BaseField + :type kwargs: Any + :return: ormar ForeignKeyField with relation to selected model + :rtype: ForeignKeyField + """ + nullable = kwargs.pop("nullable", False) + owner = kwargs.pop("owner", None) + namespace = dict( + __type__=to, + to=to, + through=None, + alias=name, + name=kwargs.pop("real_name", None), + related_name=related_name, + virtual=True, + owner=owner, + nullable=nullable, + unique=False, + column_type=None, + primary_key=False, + index=False, + pydantic_only=False, + default=None, + server_default=None, + is_relation=True, + is_through=True, + ) + + return type("Through", (ThroughField, BaseField), namespace) + + +class ThroughField(ForeignKeyField): + """ + Field class used to access ManyToMany model through model. + """ diff --git a/ormar/models/__init__.py b/ormar/models/__init__.py index 88a39c6..eb6bdd7 100644 --- a/ormar/models/__init__.py +++ b/ormar/models/__init__.py @@ -5,6 +5,8 @@ ass well as vast number of helper functions for pydantic, sqlalchemy and relatio """ from ormar.models.newbasemodel import NewBaseModel # noqa I100 +from ormar.models.model_row import ModelRow # noqa I100 from ormar.models.model import Model # noqa I100 +from ormar.models.excludable import ExcludableItems # noqa I100 -__all__ = ["NewBaseModel", "Model"] +__all__ = ["NewBaseModel", "Model", "ModelRow", "ExcludableItems"] diff --git a/ormar/models/excludable.py b/ormar/models/excludable.py new file mode 100644 index 0000000..9b888b0 --- /dev/null +++ b/ormar/models/excludable.py @@ -0,0 +1,271 @@ +from dataclasses import dataclass, field +from typing import Dict, List, Set, TYPE_CHECKING, Tuple, Type, Union + +from ormar.queryset.utils import get_relationship_alias_model_and_str + +if TYPE_CHECKING: # pragma: no cover + from ormar import Model + + +@dataclass +class Excludable: + """ + Class that keeps sets of fields to exclude and include + """ + + include: Set = field(default_factory=set) + exclude: Set = field(default_factory=set) + + def get_copy(self) -> "Excludable": + """ + Return copy of self to avoid in place modifications + :return: copy of self with copied sets + :rtype: ormar.models.excludable.Excludable + """ + _copy = self.__class__() + _copy.include = {x for x in self.include} + _copy.exclude = {x for x in self.exclude} + return _copy + + def set_values(self, value: Set, is_exclude: bool) -> None: + """ + Appends the data to include/exclude sets. + + :param value: set of values to add + :type value: set + :param is_exclude: flag if values are to be excluded or included + :type is_exclude: bool + """ + prop = "exclude" if is_exclude else "include" + current_value = getattr(self, prop) + current_value.update(value) + setattr(self, prop, current_value) + + def is_included(self, key: str) -> bool: + """ + Check if field in included (in set or set is {...}) + :param key: key to check + :type key: str + :return: result of the check + :rtype: bool + """ + return (... in self.include or key in self.include) if self.include else True + + def is_excluded(self, key: str) -> bool: + """ + Check if field in excluded (in set or set is {...}) + :param key: key to check + :type key: str + :return: result of the check + :rtype: bool + """ + return (... in self.exclude or key in self.exclude) if self.exclude else False + + +class ExcludableItems: + """ + Keeps a dictionary of Excludables by alias + model_name keys + to allow quick lookup by nested models without need to travers + deeply nested dictionaries and passing include/exclude around + """ + + def __init__(self) -> None: + self.items: Dict[str, Excludable] = dict() + + @classmethod + def from_excludable(cls, other: "ExcludableItems") -> "ExcludableItems": + """ + Copy passed ExcludableItems to avoid inplace modifications. + + :param other: other excludable items to be copied + :type other: ormar.models.excludable.ExcludableItems + :return: copy of other + :rtype: ormar.models.excludable.ExcludableItems + """ + new_excludable = cls() + for key, value in other.items.items(): + new_excludable.items[key] = value.get_copy() + return new_excludable + + def get(self, model_cls: Type["Model"], alias: str = "") -> Excludable: + """ + Return Excludable for given model and alias. + + :param model_cls: target model to check + :type model_cls: ormar.models.metaclass.ModelMetaclass + :param alias: table alias from relation manager + :type alias: str + :return: Excludable for given model and alias + :rtype: ormar.models.excludable.Excludable + """ + key = f"{alias + '_' if alias else ''}{model_cls.get_name(lower=True)}" + excludable = self.items.get(key) + if not excludable: + excludable = Excludable() + self.items[key] = excludable + return excludable + + def build( + self, + items: Union[List[str], str, Tuple[str], Set[str], Dict], + model_cls: Type["Model"], + is_exclude: bool = False, + ) -> None: + """ + Receives the one of the types of items and parses them as to achieve + a end situation with one excludable per alias/model in relation. + + Each excludable has two sets of values - one to include, one to exclude. + + :param items: values to be included or excluded + :type items: Union[List[str], str, Tuple[str], Set[str], Dict] + :param model_cls: source model from which relations are constructed + :type model_cls: ormar.models.metaclass.ModelMetaclass + :param is_exclude: flag if items should be included or excluded + :type is_exclude: bool + """ + if isinstance(items, str): + items = {items} + + if isinstance(items, Dict): + self._traverse_dict( + values=items, + source_model=model_cls, + model_cls=model_cls, + is_exclude=is_exclude, + ) + + else: + items = set(items) + nested_items = set(x for x in items if "__" in x) + items.difference_update(nested_items) + self._set_excludes( + items=items, + model_name=model_cls.get_name(lower=True), + is_exclude=is_exclude, + ) + if nested_items: + self._traverse_list( + values=nested_items, model_cls=model_cls, is_exclude=is_exclude + ) + + def _set_excludes( + self, items: Set, model_name: str, is_exclude: bool, alias: str = "" + ) -> None: + """ + Sets set of values to be included or excluded for given key and model. + + :param items: items to include/exclude + :type items: set + :param model_name: name of model to construct key + :type model_name: str + :param is_exclude: flag if values should be included or excluded + :type is_exclude: bool + :param alias: + :type alias: str + """ + key = f"{alias + '_' if alias else ''}{model_name}" + excludable = self.items.get(key) + if not excludable: + excludable = Excludable() + excludable.set_values(value=items, is_exclude=is_exclude) + self.items[key] = excludable + + def _traverse_dict( # noqa: CFQ002 + self, + values: Dict, + source_model: Type["Model"], + model_cls: Type["Model"], + is_exclude: bool, + related_items: List = None, + alias: str = "", + ) -> None: + """ + Goes through dict of nested values and construct/update Excludables. + + :param values: items to include/exclude + :type values: Dict + :param source_model: source model from which relations are constructed + :type source_model: ormar.models.metaclass.ModelMetaclass + :param model_cls: model from which current relation is constructed + :type model_cls: ormar.models.metaclass.ModelMetaclass + :param is_exclude: flag if values should be included or excluded + :type is_exclude: bool + :param related_items: list of names of related fields chain + :type related_items: List + :param alias: alias of relation + :type alias: str + """ + self_fields = set() + related_items = related_items[:] if related_items else [] + for key, value in values.items(): + if value is ...: + self_fields.add(key) + elif isinstance(value, set): + ( + table_prefix, + target_model, + _, + _, + ) = get_relationship_alias_model_and_str( + source_model=source_model, related_parts=related_items + [key] + ) + self._set_excludes( + items=value, + model_name=target_model.get_name(), + is_exclude=is_exclude, + alias=table_prefix, + ) + else: + # dict + related_items.append(key) + ( + table_prefix, + target_model, + _, + _, + ) = get_relationship_alias_model_and_str( + source_model=source_model, related_parts=related_items + ) + self._traverse_dict( + values=value, + source_model=source_model, + model_cls=target_model, + is_exclude=is_exclude, + related_items=related_items, + alias=table_prefix, + ) + if self_fields: + self._set_excludes( + items=self_fields, + model_name=model_cls.get_name(), + is_exclude=is_exclude, + alias=alias, + ) + + def _traverse_list( + self, values: Set[str], model_cls: Type["Model"], is_exclude: bool + ) -> None: + """ + Goes through list of values and construct/update Excludables. + + :param values: items to include/exclude + :type values: set + :param model_cls: model from which current relation is constructed + :type model_cls: ormar.models.metaclass.ModelMetaclass + :param is_exclude: flag if values should be included or excluded + :type is_exclude: bool + """ + # here we have only nested related keys + for key in values: + key_split = key.split("__") + related_items, field_name = key_split[:-1], key_split[-1] + (table_prefix, target_model, _, _) = get_relationship_alias_model_and_str( + source_model=model_cls, related_parts=related_items + ) + self._set_excludes( + items={field_name}, + model_name=target_model.get_name(), + is_exclude=is_exclude, + alias=table_prefix, + ) diff --git a/ormar/models/helpers/models.py b/ormar/models/helpers/models.py index 449a920..e0b5d3c 100644 --- a/ormar/models/helpers/models.py +++ b/ormar/models/helpers/models.py @@ -1,3 +1,4 @@ +import collections import itertools import sqlite3 from typing import Any, Dict, List, TYPE_CHECKING, Tuple, Type @@ -21,7 +22,7 @@ def is_field_an_forward_ref(field: Type["BaseField"]) -> bool: :return: result of the check :rtype: bool """ - return issubclass(field, ormar.ForeignKeyField) and ( + return field.is_relation and ( field.to.__class__ == ForwardRef or field.through.__class__ == ForwardRef ) @@ -123,7 +124,7 @@ def extract_annotations_and_default_vals(attrs: Dict) -> Tuple[Dict, Dict]: return attrs, model_fields -def group_related_list(list_: List) -> Dict: +def group_related_list(list_: List) -> collections.OrderedDict: """ Translates the list of related strings into a dictionary. That way nested models are grouped to traverse them in a right order @@ -152,7 +153,9 @@ def group_related_list(list_: List) -> Dict: result_dict[key] = group_related_list(new) else: result_dict.setdefault(key, []).extend(new) - return {k: v for k, v in sorted(result_dict.items(), key=lambda item: len(item[1]))} + return collections.OrderedDict( + sorted(result_dict.items(), key=lambda item: len(item[1])) + ) def meta_field_not_set(model: Type["Model"], field_name: str) -> bool: diff --git a/ormar/models/helpers/pydantic.py b/ormar/models/helpers/pydantic.py index 0ff175e..60646f8 100644 --- a/ormar/models/helpers/pydantic.py +++ b/ormar/models/helpers/pydantic.py @@ -6,14 +6,15 @@ from pydantic.fields import ModelField from pydantic.utils import lenient_issubclass import ormar # noqa: I100, I202 -from ormar.fields import BaseField, ManyToManyField +from ormar.fields import BaseField if TYPE_CHECKING: # pragma no cover from ormar import Model + from ormar.fields import ManyToManyField def create_pydantic_field( - field_name: str, model: Type["Model"], model_field: Type[ManyToManyField] + field_name: str, model: Type["Model"], model_field: Type["ManyToManyField"] ) -> None: """ Registers pydantic field on through model that leads to passed model @@ -59,7 +60,7 @@ def get_pydantic_field(field_name: str, model: Type["Model"]) -> "ModelField": def populate_default_pydantic_field_value( - ormar_field: Type[BaseField], field_name: str, attrs: dict + ormar_field: Type["BaseField"], field_name: str, attrs: dict ) -> dict: """ Grabs current value of the ormar Field in class namespace diff --git a/ormar/models/helpers/related_names_validation.py b/ormar/models/helpers/related_names_validation.py index 8bc32c1..56497b2 100644 --- a/ormar/models/helpers/related_names_validation.py +++ b/ormar/models/helpers/related_names_validation.py @@ -25,7 +25,7 @@ def validate_related_names_in_relations( # noqa CCR001 """ already_registered: Dict[str, List[Optional[str]]] = dict() for field in model_fields.values(): - if issubclass(field, ormar.ForeignKeyField): + if field.is_relation: to_name = ( field.to.get_name() if not field.to.__class__ == ForwardRef diff --git a/ormar/models/helpers/relations.py b/ormar/models/helpers/relations.py index 4cf19ea..48b35be 100644 --- a/ormar/models/helpers/relations.py +++ b/ormar/models/helpers/relations.py @@ -1,14 +1,14 @@ -from typing import TYPE_CHECKING, Type +from typing import TYPE_CHECKING, Type, cast import ormar from ormar import ForeignKey, ManyToMany -from ormar.fields import ManyToManyField -from ormar.fields.foreign_key import ForeignKeyField +from ormar.fields import Through from ormar.models.helpers.sqlalchemy import adjust_through_many_to_many_model from ormar.relations import AliasManager if TYPE_CHECKING: # pragma no cover from ormar import Model + from ormar.fields import ManyToManyField, ForeignKeyField alias_manager = AliasManager() @@ -32,7 +32,7 @@ def register_relation_on_build(field: Type["ForeignKeyField"]) -> None: ) -def register_many_to_many_relation_on_build(field: Type[ManyToManyField]) -> None: +def register_many_to_many_relation_on_build(field: Type["ManyToManyField"]) -> None: """ Registers connection between through model and both sides of the m2m relation. Registration include also reverse relation side to be able to join both sides. @@ -81,11 +81,10 @@ def expand_reverse_relationships(model: Type["Model"]) -> None: :param model: model on which relation should be checked and registered :type model: Model class """ - for model_field in model.Meta.model_fields.values(): - if ( - issubclass(model_field, ForeignKeyField) - and not model_field.has_unresolved_forward_refs() - ): + model_fields = list(model.Meta.model_fields.values()) + for model_field in model_fields: + if model_field.is_relation and not model_field.has_unresolved_forward_refs(): + model_field = cast(Type["ForeignKeyField"], model_field) expand_reverse_relationship(model_field=model_field) @@ -101,7 +100,7 @@ def register_reverse_model_fields(model_field: Type["ForeignKeyField"]) -> None: :type model_field: relation Field """ related_name = model_field.get_related_name() - if issubclass(model_field, ManyToManyField): + if model_field.is_multi: model_field.to.Meta.model_fields[related_name] = ManyToMany( model_field.owner, through=model_field.through, @@ -113,6 +112,8 @@ def register_reverse_model_fields(model_field: Type["ForeignKeyField"]) -> None: self_reference_primary=model_field.self_reference_primary, ) # register foreign keys on through model + model_field = cast(Type["ManyToManyField"], model_field) + register_through_shortcut_fields(model_field=model_field) adjust_through_many_to_many_model(model_field=model_field) else: model_field.to.Meta.model_fields[related_name] = ForeignKey( @@ -125,7 +126,35 @@ def register_reverse_model_fields(model_field: Type["ForeignKeyField"]) -> None: ) -def register_relation_in_alias_manager(field: Type[ForeignKeyField]) -> None: +def register_through_shortcut_fields(model_field: Type["ManyToManyField"]) -> None: + """ + Registers m2m relation through shortcut on both ends of the relation. + + :param model_field: relation field defined in parent model + :type model_field: ManyToManyField + """ + through_model = model_field.through + through_name = through_model.get_name(lower=True) + related_name = model_field.get_related_name() + + model_field.owner.Meta.model_fields[through_name] = Through( + through_model, + real_name=through_name, + virtual=True, + related_name=model_field.name, + owner=model_field.owner, + ) + + model_field.to.Meta.model_fields[through_name] = Through( + through_model, + real_name=through_name, + virtual=True, + related_name=related_name, + owner=model_field.to, + ) + + +def register_relation_in_alias_manager(field: Type["ForeignKeyField"]) -> None: """ Registers the relation (and reverse relation) in alias manager. The m2m relations require registration of through model between @@ -138,11 +167,12 @@ def register_relation_in_alias_manager(field: Type[ForeignKeyField]) -> None: :param field: relation field :type field: ForeignKey or ManyToManyField class """ - if issubclass(field, ManyToManyField): + if field.is_multi: if field.has_unresolved_forward_refs(): return + field = cast(Type["ManyToManyField"], field) register_many_to_many_relation_on_build(field=field) - elif issubclass(field, ForeignKeyField): + elif field.is_relation and not field.is_through: if field.has_unresolved_forward_refs(): return register_relation_on_build(field=field) diff --git a/ormar/models/helpers/sqlalchemy.py b/ormar/models/helpers/sqlalchemy.py index a17e786..3475c66 100644 --- a/ormar/models/helpers/sqlalchemy.py +++ b/ormar/models/helpers/sqlalchemy.py @@ -154,13 +154,11 @@ def sqlalchemy_columns_from_model_fields( pkname = None for field_name, field in model_fields.items(): field.owner = new_model + if field.is_multi and not field.through: + field.create_default_through_model() if field.primary_key: pkname = check_pk_column_validity(field_name, field, pkname) - if ( - not field.pydantic_only - and not field.virtual - and not issubclass(field, ormar.ManyToManyField) - ): + if not field.pydantic_only and not field.virtual and not field.is_multi: columns.append(field.get_column(field.get_alias())) return pkname, columns diff --git a/ormar/models/metaclass.py b/ormar/models/metaclass.py index 4134fd5..cbef18d 100644 --- a/ormar/models/metaclass.py +++ b/ormar/models/metaclass.py @@ -209,13 +209,14 @@ def update_attrs_from_base_meta( # noqa: CCR001 setattr(attrs["Meta"], param, parent_value) -def copy_and_replace_m2m_through_model( +def copy_and_replace_m2m_through_model( # noqa: CFQ002 field: Type[ManyToManyField], field_name: str, table_name: str, parent_fields: Dict, attrs: Dict, meta: ModelMeta, + base_class: Type["Model"], ) -> None: """ Clones class with Through model for m2m relations, appends child name to the name @@ -229,6 +230,8 @@ def copy_and_replace_m2m_through_model( Removes the original sqlalchemy table from metadata if it was not removed. + :param base_class: base class model + :type base_class: Type["Model"] :param field: field with relations definition :type field: Type[ManyToManyField] :param field_name: name of the relation field @@ -249,6 +252,10 @@ def copy_and_replace_m2m_through_model( copy_field.related_name = related_name # type: ignore through_class = field.through + if not through_class: + field.owner = base_class + field.create_default_through_model() + through_class = field.through new_meta: ormar.ModelMeta = type( # type: ignore "Meta", (), dict(through_class.Meta.__dict__), ) @@ -262,7 +269,7 @@ def copy_and_replace_m2m_through_model( new_meta.model_fields = { name: field for name, field in new_meta.model_fields.items() - if not issubclass(field, ForeignKeyField) + if not field.is_relation } _, columns = sqlalchemy_columns_from_model_fields( new_meta.model_fields, copy_through @@ -329,7 +336,8 @@ def copy_data_from_parent_model( # noqa: CCR001 else attrs.get("__name__", "").lower() + "s" ) for field_name, field in base_class.Meta.model_fields.items(): - if issubclass(field, ManyToManyField): + if field.is_multi: + field = cast(Type["ManyToManyField"], field) copy_and_replace_m2m_through_model( field=field, field_name=field_name, @@ -337,9 +345,10 @@ def copy_data_from_parent_model( # noqa: CCR001 parent_fields=parent_fields, attrs=attrs, meta=meta, + base_class=base_class, # type: ignore ) - elif issubclass(field, ForeignKeyField) and field.related_name: + elif field.is_relation and field.related_name: copy_field = type( # type: ignore field.__name__, (ForeignKeyField, BaseField), dict(field.__dict__) ) diff --git a/ormar/models/mixins/excludable_mixin.py b/ormar/models/mixins/excludable_mixin.py index 961e284..a7850d5 100644 --- a/ormar/models/mixins/excludable_mixin.py +++ b/ormar/models/mixins/excludable_mixin.py @@ -4,14 +4,15 @@ from typing import ( Dict, List, Mapping, - Optional, Set, TYPE_CHECKING, Type, TypeVar, Union, + cast, ) +from ormar.models.excludable import ExcludableItems from ormar.models.mixins.relation_mixin import RelationMixin from ormar.queryset.utils import translate_list_to_dict, update @@ -31,6 +32,7 @@ class ExcludableMixin(RelationMixin): if TYPE_CHECKING: # pragma: no cover from ormar import Model + from ormar.models import ModelRow @staticmethod def get_child( @@ -50,87 +52,11 @@ class ExcludableMixin(RelationMixin): return items.get(key, {}) return items - @staticmethod - def get_excluded( - exclude: Union[Set, Dict, None], key: str = None - ) -> Union[Set, Dict, None]: - """ - Proxy to ExcludableMixin.get_child for exclusions. - - :param exclude: bag of items to exclude - :type exclude: Union[Set, Dict, None] - :param key: name of the child to extract - :type key: str - :return: child extracted from items if exists - :rtype: Union[Set, Dict, None] - """ - return ExcludableMixin.get_child(items=exclude, key=key) - - @staticmethod - def get_included( - include: Union[Set, Dict, None], key: str = None - ) -> Union[Set, Dict, None]: - """ - Proxy to ExcludableMixin.get_child for inclusions. - - :param include: bag of items to include - :type include: Union[Set, Dict, None] - :param key: name of the child to extract - :type key: str - :return: child extracted from items if exists - :rtype: Union[Set, Dict, None] - """ - return ExcludableMixin.get_child(items=include, key=key) - - @staticmethod - def is_excluded(exclude: Union[Set, Dict, None], key: str = None) -> bool: - """ - Checks if given key should be excluded on model/ dict. - - :param exclude: bag of items to exclude - :type exclude: Union[Set, Dict, None] - :param key: name of the child to extract - :type key: str - :return: child extracted from items if exists - :rtype: Union[Set, Dict, None] - """ - if exclude is None: - return False - if exclude is Ellipsis: # pragma: nocover - return True - to_exclude = ExcludableMixin.get_excluded(exclude=exclude, key=key) - if isinstance(to_exclude, Set): - return key in to_exclude - if to_exclude is ...: - return True - return False - - @staticmethod - def is_included(include: Union[Set, Dict, None], key: str = None) -> bool: - """ - Checks if given key should be included on model/ dict. - - :param include: bag of items to include - :type include: Union[Set, Dict, None] - :param key: name of the child to extract - :type key: str - :return: child extracted from items if exists - :rtype: Union[Set, Dict, None] - """ - if include is None: - return True - if include is Ellipsis: - return True - to_include = ExcludableMixin.get_included(include=include, key=key) - if isinstance(to_include, Set): - return key in to_include - if to_include is ...: - return True - return False - @staticmethod def _populate_pk_column( - model: Type["Model"], columns: List[str], use_alias: bool = False, + model: Union[Type["Model"], Type["ModelRow"]], + columns: List[str], + use_alias: bool = False, ) -> List[str]: """ Adds primary key column/alias (depends on use_alias flag) to list of @@ -157,9 +83,9 @@ class ExcludableMixin(RelationMixin): @classmethod def own_table_columns( cls, - model: Type["Model"], - fields: Optional[Union[Set, Dict]], - exclude_fields: Optional[Union[Set, Dict]], + model: Union[Type["Model"], Type["ModelRow"]], + excludable: ExcludableItems, + alias: str = "", use_alias: bool = False, ) -> List[str]: """ @@ -171,17 +97,18 @@ class ExcludableMixin(RelationMixin): Primary key field is always added and cannot be excluded (will be added anyway). + :param alias: relation prefix + :type alias: str + :param excludable: structure of fields to include and exclude + :type excludable: ExcludableItems :param model: model on columns are selected :type model: Type["Model"] - :param fields: set/dict of fields to include - :type fields: Optional[Union[Set, Dict]] - :param exclude_fields: set/dict of fields to exclude - :type exclude_fields: Optional[Union[Set, Dict]] :param use_alias: flag if aliases or field names should be used :type use_alias: bool :return: list of column field names or aliases :rtype: List[str] """ + model_excludable = excludable.get(model_cls=model, alias=alias) # type: ignore columns = [ model.get_column_name_from_alias(col.name) if not use_alias else col.name for col in model.Meta.table.columns @@ -190,17 +117,17 @@ class ExcludableMixin(RelationMixin): model.get_column_name_from_alias(col.name) for col in model.Meta.table.columns ] - if fields: + if model_excludable.include: columns = [ col for col, name in zip(columns, field_names) - if model.is_included(fields, name) + if model_excludable.is_included(name) ] - if exclude_fields: + if model_excludable.exclude: columns = [ col for col, name in zip(columns, field_names) - if not model.is_excluded(exclude_fields, name) + if not model_excludable.is_excluded(name) ] # always has to return pk column for ormar to work @@ -241,11 +168,7 @@ class ExcludableMixin(RelationMixin): return exclude @classmethod - def get_names_to_exclude( - cls, - fields: Optional[Union[Dict, Set]] = None, - exclude_fields: Optional[Union[Dict, Set]] = None, - ) -> Set: + def get_names_to_exclude(cls, excludable: ExcludableItems, alias: str) -> Set: """ Returns a set of models field names that should be explicitly excluded during model initialization. @@ -256,33 +179,27 @@ class ExcludableMixin(RelationMixin): Used in parsing data from database rows that construct Models by initializing them with dicts constructed from those db rows. - :param fields: set/dict of fields to include - :type fields: Optional[Union[Set, Dict]] - :param exclude_fields: set/dict of fields to exclude - :type exclude_fields: Optional[Union[Set, Dict]] + :param alias: alias of current relation + :type alias: str + :param excludable: structure of fields to include and exclude + :type excludable: ExcludableItems :return: set of field names that should be excluded :rtype: Set """ + model = cast(Type["Model"], cls) + model_excludable = excludable.get(model_cls=model, alias=alias) fields_names = cls.extract_db_own_fields() - if fields and fields is not Ellipsis: - fields_to_keep = {name for name in fields if name in fields_names} + if model_excludable.include: + fields_to_keep = model_excludable.include.intersection(fields_names) else: fields_to_keep = fields_names fields_to_exclude = fields_names - fields_to_keep - if isinstance(exclude_fields, Set): + if model_excludable.exclude: fields_to_exclude = fields_to_exclude.union( - {name for name in exclude_fields if name in fields_names} + model_excludable.exclude.intersection(fields_names) ) - elif isinstance(exclude_fields, Dict): - new_to_exclude = { - name - for name in exclude_fields - if name in fields_names and exclude_fields[name] is Ellipsis - } - fields_to_exclude = fields_to_exclude.union(new_to_exclude) - fields_to_exclude = fields_to_exclude - {cls.Meta.pkname} return fields_to_exclude diff --git a/ormar/models/mixins/merge_mixin.py b/ormar/models/mixins/merge_mixin.py index 0ad471d..32d7288 100644 --- a/ormar/models/mixins/merge_mixin.py +++ b/ormar/models/mixins/merge_mixin.py @@ -1,5 +1,5 @@ from collections import OrderedDict -from typing import List, Sequence, TYPE_CHECKING +from typing import List, TYPE_CHECKING import ormar @@ -17,7 +17,7 @@ class MergeModelMixin: """ @classmethod - def merge_instances_list(cls, result_rows: Sequence["Model"]) -> Sequence["Model"]: + def merge_instances_list(cls, result_rows: List["Model"]) -> List["Model"]: """ Merges a list of models into list of unique models. diff --git a/ormar/models/mixins/prefetch_mixin.py b/ormar/models/mixins/prefetch_mixin.py index 273dd01..85faec2 100644 --- a/ormar/models/mixins/prefetch_mixin.py +++ b/ormar/models/mixins/prefetch_mixin.py @@ -1,9 +1,10 @@ -from typing import Callable, Dict, List, TYPE_CHECKING, Tuple, Type +from typing import Callable, Dict, List, TYPE_CHECKING, Tuple, Type, cast -import ormar -from ormar.fields.foreign_key import ForeignKeyField from ormar.models.mixins.relation_mixin import RelationMixin +if TYPE_CHECKING: # pragma: no cover + from ormar.fields import ForeignKeyField, ManyToManyField + class PrefetchQueryMixin(RelationMixin): """ @@ -39,7 +40,8 @@ class PrefetchQueryMixin(RelationMixin): if reverse: field_name = parent_model.Meta.model_fields[related].get_related_name() field = target_model.Meta.model_fields[field_name] - if issubclass(field, ormar.fields.ManyToManyField): + if field.is_multi: + field = cast(Type["ManyToManyField"], field) field_name = field.default_target_field_name() sub_field = field.through.Meta.model_fields[field_name] return field.through, sub_field.get_alias() @@ -87,7 +89,7 @@ class PrefetchQueryMixin(RelationMixin): :return: name of the field :rtype: str """ - if issubclass(target_field, ormar.fields.ManyToManyField): + if target_field.is_multi: return cls.get_name() if target_field.virtual: return target_field.get_related_name() diff --git a/ormar/models/mixins/relation_mixin.py b/ormar/models/mixins/relation_mixin.py index 48688d6..53fab3c 100644 --- a/ormar/models/mixins/relation_mixin.py +++ b/ormar/models/mixins/relation_mixin.py @@ -1,7 +1,13 @@ import inspect -from typing import List, Optional, Set, TYPE_CHECKING - -from ormar.fields.foreign_key import ForeignKeyField +from typing import ( + Callable, + List, + Optional, + Set, + TYPE_CHECKING, + Type, + Union, +) class RelationMixin: @@ -10,11 +16,12 @@ class RelationMixin: """ if TYPE_CHECKING: # pragma no cover - from ormar import ModelMeta + from ormar import ModelMeta, Model Meta: ModelMeta _related_names: Optional[Set] _related_fields: Optional[List] + get_name: Callable @classmethod def extract_db_own_fields(cls) -> Set: @@ -43,27 +50,42 @@ class RelationMixin: return cls._related_fields related_fields = [] - for name in cls.extract_related_names(): + for name in cls.extract_related_names().union(cls.extract_through_names()): related_fields.append(cls.Meta.model_fields[name]) cls._related_fields = related_fields return related_fields @classmethod - def extract_related_names(cls) -> Set: + def extract_through_names(cls) -> Set: + """ + Extracts related fields through names which are shortcuts to through models. + + :return: set of related through fields names + :rtype: Set + """ + related_fields = set() + for name in cls.extract_related_names(): + field = cls.Meta.model_fields[name] + if field.is_multi: + related_fields.add(field.through.get_name(lower=True)) + return related_fields + + @classmethod + def extract_related_names(cls) -> Set[str]: """ Returns List of fields names for all relations declared on a model. List is cached in cls._related_names for quicker access. - :return: list of related fields names - :rtype: List + :return: set of related fields names + :rtype: Set """ if isinstance(cls._related_names, Set): return cls._related_names related_names = set() for name, field in cls.Meta.model_fields.items(): - if inspect.isclass(field) and issubclass(field, ForeignKeyField): + if inspect.isclass(field) and field.is_relation and not field.is_through: related_names.add(name) cls._related_names = related_names @@ -105,3 +127,61 @@ class RelationMixin: name for name in related_names if cls.Meta.model_fields[name].nullable } return related_names + + @classmethod + def _iterate_related_models( + cls, + visited: Set[str] = None, + source_visited: Set[str] = None, + source_relation: str = None, + source_model: Union[Type["Model"], Type["RelationMixin"]] = None, + ) -> List[str]: + """ + Iterates related models recursively to extract relation strings of + nested not visited models. + + :param visited: set of already visited models + :type visited: Set[str] + :param source_relation: name of the current relation + :type source_relation: str + :param source_model: model from which relation comes in nested relations + :type source_model: Type["Model"] + :return: list of relation strings to be passed to select_related + :rtype: List[str] + """ + source_visited = source_visited or set() + if not source_model: + source_visited = cls._populate_source_model_prefixes() + relations = cls.extract_related_names() + processed_relations = [] + for relation in relations: + target_model = cls.Meta.model_fields[relation].to + if source_model and target_model == source_model: + continue + if target_model not in source_visited or not source_model: + deep_relations = target_model._iterate_related_models( + visited=visited, + source_visited=source_visited, + source_relation=relation, + source_model=cls, + ) + processed_relations.extend(deep_relations) + else: + processed_relations.append(relation) + if processed_relations: + final_relations = [ + f"{source_relation + '__' if source_relation else ''}{relation}" + for relation in processed_relations + ] + else: + final_relations = [source_relation] if source_relation else [] + return final_relations + + @classmethod + def _populate_source_model_prefixes(cls) -> Set: + relations = cls.extract_related_names() + visited = {cls} + for relation in relations: + target_model = cls.Meta.model_fields[relation].to + visited.add(target_model) + return visited diff --git a/ormar/models/model.py b/ormar/models/model.py index 3c66a0a..48b9f58 100644 --- a/ormar/models/model.py +++ b/ormar/models/model.py @@ -2,23 +2,18 @@ from typing import ( Any, Dict, List, - Optional, Set, TYPE_CHECKING, Tuple, - Type, TypeVar, Union, ) -import sqlalchemy - import ormar.queryset # noqa I100 from ormar.exceptions import ModelPersistenceError, NoMatch -from ormar.fields.many_to_many import ManyToManyField from ormar.models import NewBaseModel # noqa I100 -from ormar.models.helpers.models import group_related_list from ormar.models.metaclass import ModelMeta +from ormar.models.model_row import ModelRow if TYPE_CHECKING: # pragma nocover from ormar import QuerySet @@ -26,7 +21,7 @@ if TYPE_CHECKING: # pragma nocover T = TypeVar("T", bound="Model") -class Model(NewBaseModel): +class Model(ModelRow): __abstract__ = False if TYPE_CHECKING: # pragma nocover Meta: ModelMeta @@ -36,247 +31,6 @@ class Model(NewBaseModel): _repr = {k: getattr(self, k) for k, v in self.Meta.model_fields.items()} return f"{self.__class__.__name__}({str(_repr)})" - @classmethod - def from_row( # noqa CCR001 - cls: Type[T], - row: sqlalchemy.engine.ResultProxy, - select_related: List = None, - related_models: Any = None, - previous_model: Type[T] = None, - source_model: Type[T] = None, - related_name: str = None, - fields: Optional[Union[Dict, Set]] = None, - exclude_fields: Optional[Union[Dict, Set]] = None, - current_relation_str: str = None, - ) -> Optional[T]: - """ - Model method to convert raw sql row from database into ormar.Model instance. - Traverses nested models if they were specified in select_related for query. - - Called recurrently and returns model instance if it's present in the row. - Note that it's processing one row at a time, so if there are duplicates of - parent row that needs to be joined/combined - (like parent row in sql join with 2+ child rows) - instances populated in this method are later combined in the QuerySet. - Other method working directly on raw database results is in prefetch_query, - where rows are populated in a different way as they do not have - nested models in result. - - :param current_relation_str: name of the relation field - :type current_relation_str: str - :param source_model: model on which relation was defined - :type source_model: Type[Model] - :param row: raw result row from the database - :type row: sqlalchemy.engine.result.ResultProxy - :param select_related: list of names of related models fetched from database - :type select_related: List - :param related_models: list or dict of related models - :type related_models: Union[List, Dict] - :param previous_model: internal param for nested models to specify table_prefix - :type previous_model: Model class - :param related_name: internal parameter - name of current nested model - :type related_name: str - :param fields: fields and related model fields to include - if provided only those are included - :type fields: Optional[Union[Dict, Set]] - :param exclude_fields: fields and related model fields to exclude - excludes the fields even if they are provided in fields - :type exclude_fields: Optional[Union[Dict, Set]] - :return: returns model if model is populated from database - :rtype: Optional[Model] - """ - item: Dict[str, Any] = {} - select_related = select_related or [] - related_models = related_models or [] - table_prefix = "" - - if select_related: - source_model = cls - related_models = group_related_list(select_related) - - rel_name2 = related_name - - if ( - previous_model - and related_name - and issubclass( - previous_model.Meta.model_fields[related_name], ManyToManyField - ) - ): - through_field = previous_model.Meta.model_fields[related_name] - if ( - through_field.self_reference - and related_name == through_field.self_reference_primary - ): - rel_name2 = through_field.default_source_field_name() # type: ignore - else: - rel_name2 = through_field.default_target_field_name() # type: ignore - previous_model = through_field.through # type: ignore - - if previous_model and rel_name2: - if current_relation_str and "__" in current_relation_str and source_model: - table_prefix = cls.Meta.alias_manager.resolve_relation_alias( - from_model=source_model, relation_name=current_relation_str - ) - if not table_prefix: - table_prefix = cls.Meta.alias_manager.resolve_relation_alias( - from_model=previous_model, relation_name=rel_name2 - ) - - item = cls.populate_nested_models_from_row( - item=item, - row=row, - related_models=related_models, - fields=fields, - exclude_fields=exclude_fields, - current_relation_str=current_relation_str, - source_model=source_model, - ) - item = cls.extract_prefixed_table_columns( - item=item, - row=row, - table_prefix=table_prefix, - fields=fields, - exclude_fields=exclude_fields, - ) - - instance: Optional[T] = None - if item.get(cls.Meta.pkname, None) is not None: - item["__excluded__"] = cls.get_names_to_exclude( - fields=fields, exclude_fields=exclude_fields - ) - instance = cls(**item) - instance.set_save_status(True) - return instance - - @classmethod - def populate_nested_models_from_row( # noqa: CFQ002 - cls, - item: dict, - row: sqlalchemy.engine.ResultProxy, - related_models: Any, - fields: Optional[Union[Dict, Set]] = None, - exclude_fields: Optional[Union[Dict, Set]] = None, - current_relation_str: str = None, - source_model: Type[T] = None, - ) -> dict: - """ - Traverses structure of related models and populates the nested models - from the database row. - Related models can be a list if only directly related models are to be - populated, converted to dict if related models also have their own related - models to be populated. - - Recurrently calls from_row method on nested instances and create nested - instances. In the end those instances are added to the final model dictionary. - - :param source_model: source model from which relation started - :type source_model: Type[Model] - :param current_relation_str: joined related parts into one string - :type current_relation_str: str - :param item: dictionary of already populated nested models, otherwise empty dict - :type item: Dict - :param row: raw result row from the database - :type row: sqlalchemy.engine.result.ResultProxy - :param related_models: list or dict of related models - :type related_models: Union[Dict, List] - :param fields: fields and related model fields to include - - if provided only those are included - :type fields: Optional[Union[Dict, Set]] - :param exclude_fields: fields and related model fields to exclude - excludes the fields even if they are provided in fields - :type exclude_fields: Optional[Union[Dict, Set]] - :return: dictionary with keys corresponding to model fields names - and values are database values - :rtype: Dict - """ - - for related in related_models: - relation_str = ( - "__".join([current_relation_str, related]) - if current_relation_str - else related - ) - fields = cls.get_included(fields, related) - exclude_fields = cls.get_excluded(exclude_fields, related) - model_cls = cls.Meta.model_fields[related].to - - remainder = None - if isinstance(related_models, dict) and related_models[related]: - remainder = related_models[related] - child = model_cls.from_row( - row, - related_models=remainder, - previous_model=cls, - related_name=related, - fields=fields, - exclude_fields=exclude_fields, - current_relation_str=relation_str, - source_model=source_model, - ) - item[model_cls.get_column_name_from_alias(related)] = child - - return item - - @classmethod - def extract_prefixed_table_columns( # noqa CCR001 - cls, - item: dict, - row: sqlalchemy.engine.result.ResultProxy, - table_prefix: str, - fields: Optional[Union[Dict, Set]] = None, - exclude_fields: Optional[Union[Dict, Set]] = None, - ) -> dict: - """ - Extracts own fields from raw sql result, using a given prefix. - Prefix changes depending on the table's position in a join. - - If the table is a main table, there is no prefix. - All joined tables have prefixes to allow duplicate column names, - as well as duplicated joins to the same table from multiple different tables. - - Extracted fields populates the related dict later used to construct a Model. - - Used in Model.from_row and PrefetchQuery._populate_rows methods. - - :param item: dictionary of already populated nested models, otherwise empty dict - :type item: Dict - :param row: raw result row from the database - :type row: sqlalchemy.engine.result.ResultProxy - :param table_prefix: prefix of the table from AliasManager - each pair of tables have own prefix (two of them depending on direction) - - used in joins to allow multiple joins to the same table. - :type table_prefix: str - :param fields: fields and related model fields to include - - if provided only those are included - :type fields: Optional[Union[Dict, Set]] - :param exclude_fields: fields and related model fields to exclude - excludes the fields even if they are provided in fields - :type exclude_fields: Optional[Union[Dict, Set]] - :return: dictionary with keys corresponding to model fields names - and values are database values - :rtype: Dict - """ - # databases does not keep aliases in Record for postgres, change to raw row - source = row._row if cls.db_backend_name() == "postgresql" else row - - selected_columns = cls.own_table_columns( - model=cls, - fields=fields or {}, - exclude_fields=exclude_fields or {}, - use_alias=False, - ) - - for column in cls.Meta.table.columns: - alias = cls.get_column_name_from_alias(column.name) - if alias not in item and alias in selected_columns: - prefixed_name = ( - f'{table_prefix + "_" if table_prefix else ""}{column.name}' - ) - item[alias] = source[prefixed_name] - - return item - async def upsert(self: T, **kwargs: Any) -> T: """ Performs either a save or an update depending on the presence of the pk. @@ -387,8 +141,9 @@ class Model(NewBaseModel): visited.add(self.__class__) for related in self.extract_related_names(): - if self.Meta.model_fields[related].virtual or issubclass( - self.Meta.model_fields[related], ManyToManyField + if ( + self.Meta.model_fields[related].virtual + or self.Meta.model_fields[related].is_multi ): for rel in getattr(self, related): update_count, visited = await self._update_and_follow( @@ -408,7 +163,7 @@ class Model(NewBaseModel): @staticmethod async def _update_and_follow( - rel: T, follow: bool, visited: Set, update_count: int + rel: "Model", follow: bool, visited: Set, update_count: int ) -> Tuple[int, Set]: """ Internal method used in save_related to follow related models and update numbers @@ -473,7 +228,7 @@ class Model(NewBaseModel): await self.signals.post_update.send(sender=self.__class__, instance=self) return self - async def delete(self: T) -> int: + async def delete(self) -> int: """ Removes the Model instance from the database. @@ -516,3 +271,44 @@ class Model(NewBaseModel): self.update_from_dict(kwargs) self.set_save_status(True) return self + + async def load_all( + self: T, follow: bool = False, exclude: Union[List, str, Set, Dict] = None + ) -> T: + """ + Allow to refresh existing Models fields from database. + Performs refresh of the related models fields. + + By default loads only self and the directly related ones. + + If follow=True is set it loads also related models of related models. + + To not get stuck in an infinite loop as related models also keep a relation + to parent model visited models set is kept. + + That way already visited models that are nested are loaded, but the load do not + follow them inside. So Model A -> Model B -> Model C -> Model A -> Model X + will load second Model A but will never follow into Model X. + Nested relations of those kind need to be loaded manually. + + :raises NoMatch: If given pk is not found in database. + + :param exclude: related models to exclude + :type exclude: Union[List, str, Set, Dict] + :param follow: flag to trigger deep save - + by default only directly related models are saved + with follow=True also related models of related models are saved + :type follow: bool + :return: reloaded Model + :rtype: Model + """ + relations = list(self.extract_related_names()) + if follow: + relations = self._iterate_related_models() + queryset = self.__class__.objects + if exclude: + queryset = queryset.exclude_fields(exclude) + instance = await queryset.select_related(relations).get(pk=self.pk) + self._orm.clear() + self.update_from_dict(instance.dict()) + return self diff --git a/ormar/models/model_row.py b/ormar/models/model_row.py new file mode 100644 index 0000000..d9c674d --- /dev/null +++ b/ormar/models/model_row.py @@ -0,0 +1,299 @@ +from typing import ( + Any, + Dict, + List, + Optional, + TYPE_CHECKING, + Type, + cast, +) + +import sqlalchemy + +from ormar.models import NewBaseModel # noqa: I202 +from ormar.models.excludable import ExcludableItems +from ormar.models.helpers.models import group_related_list + +if TYPE_CHECKING: # pragma: no cover + from ormar.fields import ForeignKeyField + from ormar.models import Model + + +class ModelRow(NewBaseModel): + @classmethod + def from_row( # noqa: CFQ002 + cls, + row: sqlalchemy.engine.ResultProxy, + source_model: Type["Model"], + select_related: List = None, + related_models: Any = None, + related_field: Type["ForeignKeyField"] = None, + excludable: ExcludableItems = None, + current_relation_str: str = "", + proxy_source_model: Optional[Type["Model"]] = None, + used_prefixes: List[str] = None, + ) -> Optional["Model"]: + """ + Model method to convert raw sql row from database into ormar.Model instance. + Traverses nested models if they were specified in select_related for query. + + Called recurrently and returns model instance if it's present in the row. + Note that it's processing one row at a time, so if there are duplicates of + parent row that needs to be joined/combined + (like parent row in sql join with 2+ child rows) + instances populated in this method are later combined in the QuerySet. + Other method working directly on raw database results is in prefetch_query, + where rows are populated in a different way as they do not have + nested models in result. + + :param used_prefixes: list of already extracted prefixes + :type used_prefixes: List[str] + :param proxy_source_model: source model from which querysetproxy is constructed + :type proxy_source_model: Optional[Type["ModelRow"]] + :param excludable: structure of fields to include and exclude + :type excludable: ExcludableItems + :param current_relation_str: name of the relation field + :type current_relation_str: str + :param source_model: model on which relation was defined + :type source_model: Type[Model] + :param row: raw result row from the database + :type row: sqlalchemy.engine.result.ResultProxy + :param select_related: list of names of related models fetched from database + :type select_related: List + :param related_models: list or dict of related models + :type related_models: Union[List, Dict] + :param related_field: field with relation declaration + :type related_field: Type[ForeignKeyField] + :return: returns model if model is populated from database + :rtype: Optional[Model] + """ + item: Dict[str, Any] = {} + select_related = select_related or [] + related_models = related_models or [] + table_prefix = "" + used_prefixes = used_prefixes if used_prefixes is not None else [] + excludable = excludable or ExcludableItems() + + if select_related: + related_models = group_related_list(select_related) + + if related_field: + if related_field.is_multi: + previous_model = related_field.through + else: + previous_model = related_field.owner + table_prefix = cls.Meta.alias_manager.resolve_relation_alias( + from_model=previous_model, relation_name=related_field.name + ) + if not table_prefix or table_prefix in used_prefixes: + manager = cls.Meta.alias_manager + table_prefix = manager.resolve_relation_alias_after_complex( + source_model=source_model, + relation_str=current_relation_str, + relation_field=related_field, + ) + used_prefixes.append(table_prefix) + + item = cls._populate_nested_models_from_row( + item=item, + row=row, + related_models=related_models, + excludable=excludable, + current_relation_str=current_relation_str, + source_model=source_model, # type: ignore + proxy_source_model=proxy_source_model, # type: ignore + table_prefix=table_prefix, + used_prefixes=used_prefixes, + ) + item = cls.extract_prefixed_table_columns( + item=item, row=row, table_prefix=table_prefix, excludable=excludable + ) + + instance: Optional["Model"] = None + if item.get(cls.Meta.pkname, None) is not None: + item["__excluded__"] = cls.get_names_to_exclude( + excludable=excludable, alias=table_prefix + ) + instance = cast("Model", cls(**item)) + instance.set_save_status(True) + return instance + + @classmethod + def _populate_nested_models_from_row( # noqa: CFQ002 + cls, + item: dict, + row: sqlalchemy.engine.ResultProxy, + source_model: Type["Model"], + related_models: Any, + excludable: ExcludableItems, + table_prefix: str, + used_prefixes: List[str], + current_relation_str: str = None, + proxy_source_model: Type["Model"] = None, + ) -> dict: + """ + Traverses structure of related models and populates the nested models + from the database row. + Related models can be a list if only directly related models are to be + populated, converted to dict if related models also have their own related + models to be populated. + + Recurrently calls from_row method on nested instances and create nested + instances. In the end those instances are added to the final model dictionary. + + :param proxy_source_model: source model from which querysetproxy is constructed + :type proxy_source_model: Optional[Type["ModelRow"]] + :param excludable: structure of fields to include and exclude + :type excludable: ExcludableItems + :param source_model: source model from which relation started + :type source_model: Type[Model] + :param current_relation_str: joined related parts into one string + :type current_relation_str: str + :param item: dictionary of already populated nested models, otherwise empty dict + :type item: Dict + :param row: raw result row from the database + :type row: sqlalchemy.engine.result.ResultProxy + :param related_models: list or dict of related models + :type related_models: Union[Dict, List] + :return: dictionary with keys corresponding to model fields names + and values are database values + :rtype: Dict + """ + + for related in related_models: + field = cls.Meta.model_fields[related] + field = cast(Type["ForeignKeyField"], field) + model_cls = field.to + model_excludable = excludable.get( + model_cls=cast(Type["Model"], cls), alias=table_prefix + ) + if model_excludable.is_excluded(related): + return item + + relation_str = ( + "__".join([current_relation_str, related]) + if current_relation_str + else related + ) + remainder = None + if isinstance(related_models, dict) and related_models[related]: + remainder = related_models[related] + child = model_cls.from_row( + row, + related_models=remainder, + related_field=field, + excludable=excludable, + current_relation_str=relation_str, + source_model=source_model, + proxy_source_model=proxy_source_model, + used_prefixes=used_prefixes, + ) + item[model_cls.get_column_name_from_alias(related)] = child + if field.is_multi and child: + through_name = cls.Meta.model_fields[related].through.get_name() + through_child = cls.populate_through_instance( + row=row, + related=related, + through_name=through_name, + excludable=excludable, + ) + + if child.__class__ != proxy_source_model: + setattr(child, through_name, through_child) + else: + item[through_name] = through_child + child.set_save_status(True) + + return item + + @classmethod + def populate_through_instance( + cls, + row: sqlalchemy.engine.ResultProxy, + through_name: str, + related: str, + excludable: ExcludableItems, + ) -> "ModelRow": + """ + Initialize the through model from db row. + Excluded all relation fields and other exclude/include set in excludable. + + :param row: loaded row from database + :type row: sqlalchemy.engine.ResultProxy + :param through_name: name of the through field + :type through_name: str + :param related: name of the relation + :type related: str + :param excludable: structure of fields to include and exclude + :type excludable: ExcludableItems + :return: initialized through model without relation + :rtype: "ModelRow" + """ + model_cls = cls.Meta.model_fields[through_name].to + table_prefix = cls.Meta.alias_manager.resolve_relation_alias( + from_model=cls, relation_name=related + ) + # remove relations on through field + model_excludable = excludable.get(model_cls=model_cls, alias=table_prefix) + model_excludable.set_values( + value=model_cls.extract_related_names(), is_exclude=True + ) + child_dict = model_cls.extract_prefixed_table_columns( + item={}, row=row, excludable=excludable, table_prefix=table_prefix + ) + child_dict["__excluded__"] = model_cls.get_names_to_exclude( + excludable=excludable, alias=table_prefix + ) + child = model_cls(**child_dict) # type: ignore + return child + + @classmethod + def extract_prefixed_table_columns( + cls, + item: dict, + row: sqlalchemy.engine.result.ResultProxy, + table_prefix: str, + excludable: ExcludableItems, + ) -> Dict: + """ + Extracts own fields from raw sql result, using a given prefix. + Prefix changes depending on the table's position in a join. + + If the table is a main table, there is no prefix. + All joined tables have prefixes to allow duplicate column names, + as well as duplicated joins to the same table from multiple different tables. + + Extracted fields populates the related dict later used to construct a Model. + + Used in Model.from_row and PrefetchQuery._populate_rows methods. + + :param excludable: structure of fields to include and exclude + :type excludable: ExcludableItems + :param item: dictionary of already populated nested models, otherwise empty dict + :type item: Dict + :param row: raw result row from the database + :type row: sqlalchemy.engine.result.ResultProxy + :param table_prefix: prefix of the table from AliasManager + each pair of tables have own prefix (two of them depending on direction) - + used in joins to allow multiple joins to the same table. + :type table_prefix: str + :return: dictionary with keys corresponding to model fields names + and values are database values + :rtype: Dict + """ + # databases does not keep aliases in Record for postgres, change to raw row + source = row._row if cls.db_backend_name() == "postgresql" else row + + selected_columns = cls.own_table_columns( + model=cls, excludable=excludable, alias=table_prefix, use_alias=False, + ) + + for column in cls.Meta.table.columns: + alias = cls.get_column_name_from_alias(column.name) + if alias not in item and alias in selected_columns: + prefixed_name = ( + f'{table_prefix + "_" if table_prefix else ""}{column.name}' + ) + item[alias] = source[prefixed_name] + + return item diff --git a/ormar/models/newbasemodel.py b/ormar/models/newbasemodel.py index 3aa359e..9308d83 100644 --- a/ormar/models/newbasemodel.py +++ b/ormar/models/newbasemodel.py @@ -13,7 +13,6 @@ from typing import ( Set, TYPE_CHECKING, Type, - TypeVar, Union, cast, ) @@ -46,11 +45,9 @@ from ormar.relations.alias_manager import AliasManager from ormar.relations.relation_manager import RelationsManager if TYPE_CHECKING: # pragma no cover - from ormar import Model + from ormar.models import Model from ormar.signals import SignalEmitter - T = TypeVar("T", bound=Model) - IntStr = Union[int, str] DictStrAny = Dict[str, Any] AbstractSetIntStr = AbstractSet[IntStr] @@ -129,7 +126,9 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass object.__setattr__( self, "_orm", - RelationsManager(related_fields=self.extract_related_fields(), owner=self,), + RelationsManager( + related_fields=self.extract_related_fields(), owner=cast("Model", self), + ), ) pk_only = kwargs.pop("__pk_only__", False) @@ -172,7 +171,7 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass object.__setattr__(self, "__fields_set__", fields_set) # register the columns models after initialization - for related in self.extract_related_names(): + for related in self.extract_related_names().union(self.extract_through_names()): self.Meta.model_fields[related].expand_relationship( new_kwargs.get(related), self, to_register=True, ) @@ -267,6 +266,10 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass return object.__getattribute__( self, "_extract_related_model_instead_of_field" )(item) + if item in object.__getattribute__(self, "extract_through_names")(): + return object.__getattribute__( + self, "_extract_related_model_instead_of_field" + )(item) if item in object.__getattribute__(self, "Meta").property_fields: value = object.__getattribute__(self, item) return value() if callable(value) else value @@ -294,7 +297,7 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass def _extract_related_model_instead_of_field( self, item: str - ) -> Optional[Union["T", Sequence["T"]]]: + ) -> Optional[Union["Model", Sequence["Model"]]]: """ Retrieves the related model/models from RelationshipManager. @@ -304,7 +307,7 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass :rtype: Optional[Union[Model, List[Model]]] """ if item in self._orm: - return self._orm.get(item) + return self._orm.get(item) # type: ignore return None # pragma no cover def __eq__(self, other: object) -> bool: @@ -391,7 +394,7 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass cause some dialect require different treatment""" return cls.Meta.database._backend._dialect.name - def remove(self, parent: "T", name: str) -> None: + def remove(self, parent: "Model", name: str) -> None: """Removes child from relation with given name in RelationshipManager""" self._orm.remove_parent(self, parent, name) @@ -751,9 +754,7 @@ class NewBaseModel(pydantic.BaseModel, ModelTableProxy, metaclass=ModelMetaclass :return: value of pk if set :rtype: Optional[int] """ - if target_field.virtual or issubclass( - target_field, ormar.fields.ManyToManyField - ): + if target_field.virtual or target_field.is_multi: return self.pk related_name = target_field.name related_model = getattr(self, related_name) diff --git a/ormar/models/quick_access_views.py b/ormar/models/quick_access_views.py index c09b672..c960898 100644 --- a/ormar/models/quick_access_views.py +++ b/ormar/models/quick_access_views.py @@ -34,10 +34,12 @@ quick_access_set = { "_skip_ellipsis", "_update_and_follow", "_update_excluded_with_related_not_required", + "_verify_model_can_be_initialized", "copy", "delete", "dict", "extract_related_names", + "extract_through_names", "update_from_dict", "get_column_alias", "get_column_name_from_alias", diff --git a/ormar/protocols/queryset_protocol.py b/ormar/protocols/queryset_protocol.py index 7eb7092..397f58b 100644 --- a/ormar/protocols/queryset_protocol.py +++ b/ormar/protocols/queryset_protocol.py @@ -52,6 +52,9 @@ class QuerySetProtocol(Protocol): # pragma: nocover async def create(self, **kwargs: Any) -> "Model": ... + async def update(self, each: bool = False, **kwargs: Any) -> int: + ... + async def get_or_create(self, **kwargs: Any) -> "Model": ... diff --git a/ormar/queryset/__init__.py b/ormar/queryset/__init__.py index 8528b05..161d9bb 100644 --- a/ormar/queryset/__init__.py +++ b/ormar/queryset/__init__.py @@ -1,10 +1,19 @@ """ Contains QuerySet and different Query classes to allow for constructing of sql queries. """ +from ormar.queryset.actions import FilterAction, OrderAction from ormar.queryset.filter_query import FilterQuery from ormar.queryset.limit_query import LimitQuery from ormar.queryset.offset_query import OffsetQuery from ormar.queryset.order_query import OrderQuery from ormar.queryset.queryset import QuerySet -__all__ = ["QuerySet", "FilterQuery", "LimitQuery", "OffsetQuery", "OrderQuery"] +__all__ = [ + "QuerySet", + "FilterQuery", + "LimitQuery", + "OffsetQuery", + "OrderQuery", + "FilterAction", + "OrderAction", +] diff --git a/ormar/queryset/actions/__init__.py b/ormar/queryset/actions/__init__.py new file mode 100644 index 0000000..088d68a --- /dev/null +++ b/ormar/queryset/actions/__init__.py @@ -0,0 +1,4 @@ +from ormar.queryset.actions.filter_action import FilterAction +from ormar.queryset.actions.order_action import OrderAction + +__all__ = ["FilterAction", "OrderAction"] diff --git a/ormar/queryset/filter_action.py b/ormar/queryset/actions/filter_action.py similarity index 72% rename from ormar/queryset/filter_action.py rename to ormar/queryset/actions/filter_action.py index 4f26864..ed6277d 100644 --- a/ormar/queryset/filter_action.py +++ b/ormar/queryset/actions/filter_action.py @@ -1,11 +1,11 @@ -from typing import Any, Dict, List, TYPE_CHECKING, Type +from typing import Any, Dict, TYPE_CHECKING, Type import sqlalchemy from sqlalchemy import text import ormar # noqa: I100, I202 from ormar.exceptions import QueryDefinitionError -from ormar.queryset.utils import get_relationship_alias_model_and_str +from ormar.queryset.actions.query_action import QueryAction if TYPE_CHECKING: # pragma: nocover from ormar import Model @@ -28,7 +28,7 @@ FILTER_OPERATORS = { ESCAPE_CHARACTERS = ["%", "_"] -class FilterAction: +class FilterAction(QueryAction): """ Filter Actions is populated by queryset when filter() is called. @@ -39,7 +39,21 @@ class FilterAction: """ def __init__(self, filter_str: str, value: Any, model_cls: Type["Model"]) -> None: - parts = filter_str.split("__") + super().__init__(query_str=filter_str, model_cls=model_cls) + self.filter_value = value + self._escape_characters_in_clause() + self.is_source_model_filter = False + if self.source_model == self.target_model and "__" not in self.related_str: + self.is_source_model_filter = True + + def has_escaped_characters(self) -> bool: + """Check if value is a string that contains characters to escape""" + return isinstance(self.filter_value, str) and any( + c for c in ESCAPE_CHARACTERS if c in self.filter_value + ) + + def _split_value_into_parts(self, query_str: str) -> None: + parts = query_str.split("__") if parts[-1] in FILTER_OPERATORS: self.operator = parts[-1] self.field_name = parts[-2] @@ -49,59 +63,6 @@ class FilterAction: self.field_name = parts[-1] self.related_parts = parts[:-1] - self.filter_value = value - self.table_prefix = "" - self.source_model = model_cls - self.target_model = model_cls - self._determine_filter_target_table() - self._escape_characters_in_clause() - - @property - def table(self) -> sqlalchemy.Table: - """Shortcut to sqlalchemy Table of filtered target model""" - return self.target_model.Meta.table - - @property - def column(self) -> sqlalchemy.Column: - """Shortcut to sqlalchemy column of filtered target model""" - aliased_name = self.target_model.get_column_alias(self.field_name) - return self.target_model.Meta.table.columns[aliased_name] - - def has_escaped_characters(self) -> bool: - """Check if value is a string that contains characters to escape""" - return isinstance(self.filter_value, str) and any( - c for c in ESCAPE_CHARACTERS if c in self.filter_value - ) - - def update_select_related(self, select_related: List[str]) -> List[str]: - """ - Updates list of select related with related part included in the filter key. - That way If you want to just filter by relation you do not have to provide - select_related separately. - - :param select_related: list of relation join strings - :type select_related: List[str] - :return: list of relation joins with implied joins from filter added - :rtype: List[str] - """ - select_related = select_related[:] - if self.related_str and not any( - rel.startswith(self.related_str) for rel in select_related - ): - select_related.append(self.related_str) - return select_related - - def _determine_filter_target_table(self) -> None: - """ - Walks the relation to retrieve the actual model on which the clause should be - constructed, extracts alias based on last relation leading to target model. - """ - ( - self.table_prefix, - self.target_model, - self.related_str, - ) = get_relationship_alias_model_and_str(self.source_model, self.related_parts) - def _escape_characters_in_clause(self) -> None: """ Escapes the special characters ["%", "_"] if needed. @@ -149,7 +110,7 @@ class FilterAction: sufix = "%" if "end" not in self.operator else "" self.filter_value = f"{prefix}{self.filter_value}{sufix}" - def get_text_clause(self,) -> sqlalchemy.sql.expression.TextClause: + def get_text_clause(self) -> sqlalchemy.sql.expression.TextClause: """ Escapes characters if it's required. Substitutes values of the models if value is a ormar Model with its pk value. diff --git a/ormar/queryset/actions/order_action.py b/ormar/queryset/actions/order_action.py new file mode 100644 index 0000000..2173e24 --- /dev/null +++ b/ormar/queryset/actions/order_action.py @@ -0,0 +1,68 @@ +from typing import TYPE_CHECKING, Type + +import sqlalchemy +from sqlalchemy import text + +from ormar.queryset.actions.query_action import QueryAction # noqa: I100, I202 + +if TYPE_CHECKING: # pragma: nocover + from ormar import Model + + +class OrderAction(QueryAction): + """ + Order Actions is populated by queryset when order_by() is called. + + All required params are extracted but kept raw until actual filter clause value + is required -> then the action is converted into text() clause. + + Extracted in order to easily change table prefixes on complex relations. + """ + + def __init__( + self, order_str: str, model_cls: Type["Model"], alias: str = None + ) -> None: + self.direction: str = "" + super().__init__(query_str=order_str, model_cls=model_cls) + self.is_source_model_order = False + if alias: + self.table_prefix = alias + if self.source_model == self.target_model and "__" not in self.related_str: + self.is_source_model_order = True + + @property + def field_alias(self) -> str: + return self.target_model.get_column_alias(self.field_name) + + def get_text_clause(self) -> sqlalchemy.sql.expression.TextClause: + """ + Escapes characters if it's required. + Substitutes values of the models if value is a ormar Model with its pk value. + Compiles the clause. + + :return: complied and escaped clause + :rtype: sqlalchemy.sql.elements.TextClause + """ + prefix = f"{self.table_prefix}_" if self.table_prefix else "" + return text(f"{prefix}{self.table}" f".{self.field_alias} {self.direction}") + + def _split_value_into_parts(self, order_str: str) -> None: + if order_str.startswith("-"): + self.direction = "desc" + order_str = order_str[1:] + parts = order_str.split("__") + self.field_name = parts[-1] + self.related_parts = parts[:-1] + + def check_if_filter_apply(self, target_model: Type["Model"], alias: str) -> bool: + """ + Checks filter conditions to find if they apply to current join. + + :param target_model: model which is now processed + :type target_model: Type["Model"] + :param alias: prefix of the relation + :type alias: str + :return: result of the check + :rtype: bool + """ + return target_model == self.target_model and alias == self.table_prefix diff --git a/ormar/queryset/actions/query_action.py b/ormar/queryset/actions/query_action.py new file mode 100644 index 0000000..2c6ee84 --- /dev/null +++ b/ormar/queryset/actions/query_action.py @@ -0,0 +1,93 @@ +import abc +from typing import Any, List, TYPE_CHECKING, Type + +import sqlalchemy + +from ormar.queryset.utils import get_relationship_alias_model_and_str # noqa: I202 + +if TYPE_CHECKING: # pragma: nocover + from ormar import Model + + +class QueryAction(abc.ABC): + """ + Base QueryAction class with common params for Filter and Order actions. + """ + + def __init__(self, query_str: str, model_cls: Type["Model"]) -> None: + self.query_str = query_str + self.field_name: str = "" + self.related_parts: List[str] = [] + self.related_str: str = "" + + self.table_prefix = "" + self.source_model = model_cls + self.target_model = model_cls + self.is_through = False + + self._split_value_into_parts(query_str) + self._determine_filter_target_table() + + def __eq__(self, other: object) -> bool: # pragma: no cover + if not isinstance(other, QueryAction): + return False + return self.query_str == other.query_str + + def __hash__(self) -> Any: + return hash((self.table_prefix, self.query_str)) + + @abc.abstractmethod + def _split_value_into_parts(self, query_str: str) -> None: # pragma: no cover + """ + Splits string into related parts and field_name + :param query_str: query action string to split (i..e filter or order by) + :type query_str: str + """ + pass + + @abc.abstractmethod + def get_text_clause( + self, + ) -> sqlalchemy.sql.expression.TextClause: # pragma: no cover + pass + + @property + def table(self) -> sqlalchemy.Table: + """Shortcut to sqlalchemy Table of filtered target model""" + return self.target_model.Meta.table + + @property + def column(self) -> sqlalchemy.Column: + """Shortcut to sqlalchemy column of filtered target model""" + aliased_name = self.target_model.get_column_alias(self.field_name) + return self.target_model.Meta.table.columns[aliased_name] + + def update_select_related(self, select_related: List[str]) -> List[str]: + """ + Updates list of select related with related part included in the filter key. + That way If you want to just filter by relation you do not have to provide + select_related separately. + + :param select_related: list of relation join strings + :type select_related: List[str] + :return: list of relation joins with implied joins from filter added + :rtype: List[str] + """ + select_related = select_related[:] + if self.related_str and not any( + rel.startswith(self.related_str) for rel in select_related + ): + select_related.append(self.related_str) + return select_related + + def _determine_filter_target_table(self) -> None: + """ + Walks the relation to retrieve the actual model on which the clause should be + constructed, extracts alias based on last relation leading to target model. + """ + ( + self.table_prefix, + self.target_model, + self.related_str, + self.is_through, + ) = get_relationship_alias_model_and_str(self.source_model, self.related_parts) diff --git a/ormar/queryset/clause.py b/ormar/queryset/clause.py index e52ae4a..b98616d 100644 --- a/ormar/queryset/clause.py +++ b/ormar/queryset/clause.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import Any, List, TYPE_CHECKING, Tuple, Type import ormar # noqa I100 -from ormar.queryset.filter_action import FilterAction +from ormar.queryset.actions.filter_action import FilterAction from ormar.queryset.utils import get_relationship_alias_model_and_str if TYPE_CHECKING: # pragma no cover @@ -16,6 +16,7 @@ class Prefix: table_prefix: str model_cls: Type["Model"] relation_str: str + is_through: bool @property def alias_key(self) -> str: diff --git a/ormar/queryset/filter_query.py b/ormar/queryset/filter_query.py index 4100f16..cb9b880 100644 --- a/ormar/queryset/filter_query.py +++ b/ormar/queryset/filter_query.py @@ -1,7 +1,7 @@ from typing import List import sqlalchemy -from ormar.queryset.filter_action import FilterAction +from ormar.queryset.actions.filter_action import FilterAction class FilterQuery: diff --git a/ormar/queryset/join.py b/ormar/queryset/join.py index 0b44078..b9e71df 100644 --- a/ormar/queryset/join.py +++ b/ormar/queryset/join.py @@ -1,25 +1,24 @@ from collections import OrderedDict from typing import ( Any, - Dict, List, Optional, - Set, TYPE_CHECKING, Tuple, Type, - Union, ) import sqlalchemy from sqlalchemy import text -from ormar.exceptions import RelationshipInstanceError # noqa I100 -from ormar.fields import BaseField, ManyToManyField # noqa I100 +import ormar # noqa I100 +from ormar.exceptions import RelationshipInstanceError from ormar.relations import AliasManager if TYPE_CHECKING: # pragma no cover from ormar import Model + from ormar.queryset import OrderAction + from ormar.models.excludable import ExcludableItems class SqlJoin: @@ -28,9 +27,8 @@ class SqlJoin: used_aliases: List, select_from: sqlalchemy.sql.select, columns: List[sqlalchemy.Column], - fields: Optional[Union[Set, Dict]], - exclude_fields: Optional[Union[Set, Dict]], - order_columns: Optional[List], + excludable: "ExcludableItems", + order_columns: Optional[List["OrderAction"]], sorted_orders: OrderedDict, main_model: Type["Model"], relation_name: str, @@ -43,8 +41,7 @@ class SqlJoin: self.related_models = related_models or [] self.select_from = select_from self.columns = columns - self.fields = fields - self.exclude_fields = exclude_fields + self.excludable = excludable self.order_columns = order_columns self.sorted_orders = sorted_orders self.main_model = main_model @@ -90,7 +87,18 @@ class SqlJoin: """ return self.main_model.Meta.alias_manager - def on_clause(self, previous_alias: str, from_clause: str, to_clause: str,) -> text: + @property + def to_table(self) -> str: + """ + Shortcut to table name of the next model + :return: name of the target table + :rtype: str + """ + return self.next_model.Meta.table.name + + def _on_clause( + self, previous_alias: str, from_clause: str, to_clause: str, + ) -> text: """ Receives aliases and names of both ends of the join and combines them into one text clause used in joins. @@ -118,8 +126,8 @@ class SqlJoin: :return: list of used aliases, select from, list of aliased columns, sort orders :rtype: Tuple[List[str], Join, List[TextClause], collections.OrderedDict] """ - if issubclass(self.target_field, ManyToManyField): - self.process_m2m_through_table() + if self.target_field.is_multi: + self._process_m2m_through_table() self.next_model = self.target_field.to self._forward_join() @@ -188,10 +196,7 @@ class SqlJoin: used_aliases=self.used_aliases, select_from=self.select_from, columns=self.columns, - fields=self.main_model.get_excluded(self.fields, related_name), - exclude_fields=self.main_model.get_excluded( - self.exclude_fields, related_name - ), + excludable=self.excludable, order_columns=self.order_columns, sorted_orders=self.sorted_orders, main_model=self.next_model, @@ -208,7 +213,7 @@ class SqlJoin: self.sorted_orders, ) = sql_join.build_join() - def process_m2m_through_table(self) -> None: + def _process_m2m_through_table(self) -> None: """ Process Through table of the ManyToMany relation so that source table is linked to the through table (one additional join) @@ -223,8 +228,7 @@ class SqlJoin: To point to through model """ - new_part = self.process_m2m_related_name_change() - self._replace_many_to_many_order_by_columns(self.relation_name, new_part) + new_part = self._process_m2m_related_name_change() self.next_model = self.target_field.through self._forward_join() @@ -233,7 +237,7 @@ class SqlJoin: self.own_alias = self.next_alias self.target_field = self.next_model.Meta.model_fields[self.relation_name] - def process_m2m_related_name_change(self, reverse: bool = False) -> str: + def _process_m2m_related_name_change(self, reverse: bool = False) -> str: """ Extracts relation name to link join through the Through model declared on relation field. @@ -273,29 +277,26 @@ class SqlJoin: Process order_by causes for non m2m relations. """ - to_table = self.next_model.Meta.table.name - to_key, from_key = self.get_to_and_from_keys() + to_key, from_key = self._get_to_and_from_keys() - on_clause = self.on_clause( + on_clause = self._on_clause( previous_alias=self.own_alias, from_clause=f"{self.target_field.owner.Meta.tablename}.{from_key}", - to_clause=f"{to_table}.{to_key}", + to_clause=f"{self.to_table}.{to_key}", + ) + target_table = self.alias_manager.prefixed_table_name( + self.next_alias, self.to_table ) - target_table = self.alias_manager.prefixed_table_name(self.next_alias, to_table) self.select_from = sqlalchemy.sql.outerjoin( self.select_from, target_table, on_clause ) - pkname_alias = self.next_model.get_column_alias(self.next_model.Meta.pkname) - if not issubclass(self.target_field, ManyToManyField): - self.get_order_bys( - to_table=to_table, pkname_alias=pkname_alias, - ) + self._get_order_bys() self_related_fields = self.next_model.own_table_columns( model=self.next_model, - fields=self.fields, - exclude_fields=self.exclude_fields, + excludable=self.excludable, + alias=self.next_alias, use_alias=True, ) self.columns.extend( @@ -305,88 +306,35 @@ class SqlJoin: ) self.used_aliases.append(self.next_alias) - def _replace_many_to_many_order_by_columns(self, part: str, new_part: str) -> None: - """ - Substitutes the name of the relation with actual model name in m2m order bys. - - :param part: name of the field with relation - :type part: str - :param new_part: name of the target model - :type new_part: str - """ - if self.order_columns: - split_order_columns = [ - x.split("__") for x in self.order_columns if "__" in x - ] - for condition in split_order_columns: - if self._check_if_condition_apply(condition, part): - condition[-2] = condition[-2].replace(part, new_part) - self.order_columns = [x for x in self.order_columns if "__" not in x] + [ - "__".join(x) for x in split_order_columns - ] - - @staticmethod - def _check_if_condition_apply(condition: List, part: str) -> bool: - """ - Checks filter conditions to find if they apply to current join. - - :param condition: list of parts of condition split by '__' - :type condition: List[str] - :param part: name of the current relation join. - :type part: str - :return: result of the check - :rtype: bool - """ - return len(condition) >= 2 and ( - condition[-2] == part or condition[-2][1:] == part + def _set_default_primary_key_order_by(self) -> None: + clause = ormar.OrderAction( + order_str=self.next_model.Meta.pkname, + model_cls=self.next_model, + alias=self.next_alias, ) + self.sorted_orders[clause] = clause.get_text_clause() - def set_aliased_order_by(self, condition: List[str], to_table: str,) -> None: - """ - Substitute hyphens ('-') with descending order. - Construct actual sqlalchemy text clause using aliased table and column name. - - :param condition: list of parts of a current condition split by '__' - :type condition: List[str] - :param to_table: target table - :type to_table: sqlalchemy.sql.elements.quoted_name - """ - direction = f"{'desc' if condition[0][0] == '-' else ''}" - column_alias = self.next_model.get_column_alias(condition[-1]) - order = text(f"{self.next_alias}_{to_table}.{column_alias} {direction}") - self.sorted_orders["__".join(condition)] = order - - def get_order_bys(self, to_table: str, pkname_alias: str,) -> None: # noqa: CCR001 + def _get_order_bys(self) -> None: # noqa: CCR001 """ Triggers construction of order bys if they are given. Otherwise by default each table is sorted by a primary key column asc. - - :param to_table: target table - :type to_table: sqlalchemy.sql.elements.quoted_name - :param pkname_alias: alias of the primary key column - :type pkname_alias: str """ alias = self.next_alias if self.order_columns: current_table_sorted = False - split_order_columns = [ - x.split("__") for x in self.order_columns if "__" in x - ] - for condition in split_order_columns: - if self._check_if_condition_apply(condition, self.relation_name): + for condition in self.order_columns: + if condition.check_if_filter_apply( + target_model=self.next_model, alias=alias + ): current_table_sorted = True - self.set_aliased_order_by( - condition=condition, to_table=to_table, - ) - if not current_table_sorted: - order = text(f"{alias}_{to_table}.{pkname_alias}") - self.sorted_orders[f"{alias}.{pkname_alias}"] = order + self.sorted_orders[condition] = condition.get_text_clause() + if not current_table_sorted and not self.target_field.is_multi: + self._set_default_primary_key_order_by() - else: - order = text(f"{alias}_{to_table}.{pkname_alias}") - self.sorted_orders[f"{alias}.{pkname_alias}"] = order + elif not self.target_field.is_multi: + self._set_default_primary_key_order_by() - def get_to_and_from_keys(self) -> Tuple[str, str]: + def _get_to_and_from_keys(self) -> Tuple[str, str]: """ Based on the relation type, name of the relation and previous models and parts stored in JoinParameters it resolves the current to and from keys, which are @@ -395,8 +343,8 @@ class SqlJoin: :return: to key and from key :rtype: Tuple[str, str] """ - if issubclass(self.target_field, ManyToManyField): - to_key = self.process_m2m_related_name_change(reverse=True) + if self.target_field.is_multi: + to_key = self._process_m2m_related_name_change(reverse=True) from_key = self.main_model.get_column_alias(self.main_model.Meta.pkname) elif self.target_field.virtual: diff --git a/ormar/queryset/prefetch_query.py b/ormar/queryset/prefetch_query.py index 4c8c6d7..d224c22 100644 --- a/ormar/queryset/prefetch_query.py +++ b/ormar/queryset/prefetch_query.py @@ -1,49 +1,24 @@ from typing import ( - Any, Dict, List, - Optional, Sequence, Set, TYPE_CHECKING, Tuple, Type, - Union, cast, ) import ormar -from ormar.fields import BaseField, ManyToManyField -from ormar.fields.foreign_key import ForeignKeyField from ormar.queryset.clause import QueryClause from ormar.queryset.query import Query from ormar.queryset.utils import extract_models_to_dict_of_lists, translate_list_to_dict if TYPE_CHECKING: # pragma: no cover from ormar import Model - - -def add_relation_field_to_fields( - fields: Union[Set[Any], Dict[Any, Any], None], related_field_name: str -) -> Union[Set[Any], Dict[Any, Any], None]: - """ - Adds related field into fields to include as otherwise it would be skipped. - Related field is added only if fields are already populated. - Empty fields implies all fields. - - :param fields: Union[Set[Any], Dict[Any, Any], None] - :type fields: Dict - :param related_field_name: name of the field with relation - :type related_field_name: str - :return: updated fields dict - :rtype: Union[Set[Any], Dict[Any, Any], None] - """ - if fields and related_field_name not in fields: - if isinstance(fields, dict): - fields[related_field_name] = ... - elif isinstance(fields, set): - fields.add(related_field_name) - return fields + from ormar.fields import ForeignKeyField, BaseField + from ormar.queryset import OrderAction + from ormar.models.excludable import ExcludableItems def sort_models(models: List["Model"], orders_by: Dict) -> List["Model"]: @@ -125,24 +100,25 @@ class PrefetchQuery: def __init__( # noqa: CFQ002 self, model_cls: Type["Model"], - fields: Optional[Union[Dict, Set]], - exclude_fields: Optional[Union[Dict, Set]], + excludable: "ExcludableItems", prefetch_related: List, select_related: List, - orders_by: List, + orders_by: List["OrderAction"], ) -> None: self.model = model_cls self.database = self.model.Meta.database self._prefetch_related = prefetch_related self._select_related = select_related - self._exclude_columns = exclude_fields - self._columns = fields + self.excludable = excludable self.already_extracted: Dict = dict() self.models: Dict = {} self.select_dict = translate_list_to_dict(self._select_related) self.orders_by = orders_by or [] - self.order_dict = translate_list_to_dict(self.orders_by, is_order=True) + # TODO: refactor OrderActions to use it instead of strings from it + self.order_dict = translate_list_to_dict( + [x.query_str for x in self.orders_by], is_order=True + ) async def prefetch_related( self, models: Sequence["Model"], rows: List @@ -316,7 +292,7 @@ class PrefetchQuery: for related in related_to_extract: target_field = model.Meta.model_fields[related] - target_field = cast(Type[ForeignKeyField], target_field) + target_field = cast(Type["ForeignKeyField"], target_field) target_model = target_field.to.get_name() model_id = model.get_relation_model_id(target_field=target_field) @@ -363,8 +339,6 @@ class PrefetchQuery: select_dict = translate_list_to_dict(self._select_related) prefetch_dict = translate_list_to_dict(self._prefetch_related) target_model = self.model - fields = self._columns - exclude_fields = self._exclude_columns orders_by = self.order_dict for related in prefetch_dict.keys(): await self._extract_related_models( @@ -372,8 +346,7 @@ class PrefetchQuery: target_model=target_model, prefetch_dict=prefetch_dict.get(related, {}), select_dict=select_dict.get(related, {}), - fields=fields, - exclude_fields=exclude_fields, + excludable=self.excludable, orders_by=orders_by.get(related, {}), ) final_models = [] @@ -391,8 +364,7 @@ class PrefetchQuery: target_model: Type["Model"], prefetch_dict: Dict, select_dict: Dict, - fields: Union[Set[Any], Dict[Any, Any], None], - exclude_fields: Union[Set[Any], Dict[Any, Any], None], + excludable: "ExcludableItems", orders_by: Dict, ) -> None: """ @@ -421,12 +393,10 @@ class PrefetchQuery: :return: None :rtype: None """ - fields = target_model.get_included(fields, related) - exclude_fields = target_model.get_excluded(exclude_fields, related) target_field = target_model.Meta.model_fields[related] - target_field = cast(Type[ForeignKeyField], target_field) + target_field = cast(Type["ForeignKeyField"], target_field) reverse = False - if target_field.virtual or issubclass(target_field, ManyToManyField): + if target_field.virtual or target_field.is_multi: reverse = True parent_model = target_model @@ -447,18 +417,16 @@ class PrefetchQuery: related_field_name = parent_model.get_related_field_name( target_field=target_field ) - fields = add_relation_field_to_fields( - fields=fields, related_field_name=related_field_name - ) - table_prefix, rows = await self._run_prefetch_query( + table_prefix, exclude_prefix, rows = await self._run_prefetch_query( target_field=target_field, - fields=fields, - exclude_fields=exclude_fields, + excludable=excludable, filter_clauses=filter_clauses, + related_field_name=related_field_name, ) else: rows = [] table_prefix = "" + exclude_prefix = "" if prefetch_dict and prefetch_dict is not Ellipsis: for subrelated in prefetch_dict.keys(): @@ -469,8 +437,7 @@ class PrefetchQuery: select_dict=self._get_select_related_if_apply( subrelated, select_dict ), - fields=fields, - exclude_fields=exclude_fields, + excludable=excludable, orders_by=self._get_select_related_if_apply(subrelated, orders_by), ) @@ -480,8 +447,8 @@ class PrefetchQuery: parent_model=parent_model, target_field=target_field, table_prefix=table_prefix, - fields=fields, - exclude_fields=exclude_fields, + exclude_prefix=exclude_prefix, + excludable=excludable, prefetch_dict=prefetch_dict, orders_by=orders_by, ) @@ -495,10 +462,10 @@ class PrefetchQuery: async def _run_prefetch_query( self, target_field: Type["BaseField"], - fields: Union[Set[Any], Dict[Any, Any], None], - exclude_fields: Union[Set[Any], Dict[Any, Any], None], + excludable: "ExcludableItems", filter_clauses: List, - ) -> Tuple[str, List]: + related_field_name: str, + ) -> Tuple[str, str, List]: """ Actually runs the queries against the database and populates the raw response for given related model. @@ -508,10 +475,6 @@ class PrefetchQuery: :param target_field: ormar field with relation definition :type target_field: Type["BaseField"] - :param fields: fields to include - :type fields: Union[Set[Any], Dict[Any, Any], None] - :param exclude_fields: fields to exclude - :type exclude_fields: Union[Set[Any], Dict[Any, Any], None] :param filter_clauses: list of clauses, actually one clause with ids of relation :type filter_clauses: List[sqlalchemy.sql.elements.TextClause] :return: table prefix and raw rows from sql response @@ -522,14 +485,24 @@ class PrefetchQuery: select_related = [] query_target = target_model table_prefix = "" - if issubclass(target_field, ManyToManyField): + exclude_prefix = target_field.to.Meta.alias_manager.resolve_relation_alias( + from_model=target_field.owner, relation_name=target_field.name + ) + if target_field.is_multi: query_target = target_field.through select_related = [target_name] table_prefix = target_field.to.Meta.alias_manager.resolve_relation_alias( from_model=query_target, relation_name=target_name ) + exclude_prefix = table_prefix self.already_extracted.setdefault(target_name, {})["prefix"] = table_prefix + model_excludable = excludable.get(model_cls=target_model, alias=exclude_prefix) + if model_excludable.include and not model_excludable.is_included( + related_field_name + ): + model_excludable.set_values({related_field_name}, is_exclude=False) + qry = Query( model_cls=query_target, select_related=select_related, @@ -537,8 +510,7 @@ class PrefetchQuery: exclude_clauses=[], offset=None, limit_count=None, - fields=fields, - exclude_fields=exclude_fields, + excludable=excludable, order_bys=None, limit_raw_sql=False, ) @@ -546,7 +518,7 @@ class PrefetchQuery: # print(expr.compile(compile_kwargs={"literal_binds": True})) rows = await self.database.fetch_all(expr) self.already_extracted.setdefault(target_name, {}).update({"raw": rows}) - return table_prefix, rows + return table_prefix, exclude_prefix, rows @staticmethod def _get_select_related_if_apply(related: str, select_dict: Dict) -> Dict: @@ -592,8 +564,8 @@ class PrefetchQuery: target_field: Type["ForeignKeyField"], parent_model: Type["Model"], table_prefix: str, - fields: Union[Set[Any], Dict[Any, Any], None], - exclude_fields: Union[Set[Any], Dict[Any, Any], None], + exclude_prefix: str, + excludable: "ExcludableItems", prefetch_dict: Dict, orders_by: Dict, ) -> None: @@ -607,6 +579,8 @@ class PrefetchQuery: already_extracted dictionary. Later those instances will be fetched by ids and set on the parent model after sorting if needed. + :param excludable: structure of fields to include and exclude + :type excludable: ExcludableItems :param rows: raw sql response from the prefetch query :type rows: List[sqlalchemy.engine.result.RowProxy] :param target_field: field with relation definition from parent model @@ -615,10 +589,6 @@ class PrefetchQuery: :type parent_model: Type[Model] :param table_prefix: prefix of the target table from current relation :type table_prefix: str - :param fields: fields to include - :type fields: Union[Set[Any], Dict[Any, Any], None] - :param exclude_fields: fields to exclude - :type exclude_fields: Union[Set[Any], Dict[Any, Any], None] :param prefetch_dict: dictionaries of related models to prefetch :type prefetch_dict: Dict :param orders_by: dictionary of order by clauses by model @@ -628,14 +598,10 @@ class PrefetchQuery: for row in rows: field_name = parent_model.get_related_field_name(target_field=target_field) item = target_model.extract_prefixed_table_columns( - item={}, - row=row, - table_prefix=table_prefix, - fields=fields, - exclude_fields=exclude_fields, + item={}, row=row, table_prefix=table_prefix, excludable=excludable, ) item["__excluded__"] = target_model.get_names_to_exclude( - fields=fields, exclude_fields=exclude_fields + excludable=excludable, alias=exclude_prefix ) instance = target_model(**item) instance = self._populate_nested_related( diff --git a/ormar/queryset/query.py b/ormar/queryset/query.py index edb28c1..0987bac 100644 --- a/ormar/queryset/query.py +++ b/ormar/queryset/query.py @@ -1,6 +1,5 @@ -import copy from collections import OrderedDict -from typing import Dict, List, Optional, Set, TYPE_CHECKING, Tuple, Type, Union +from typing import List, Optional, TYPE_CHECKING, Tuple, Type import sqlalchemy from sqlalchemy import text @@ -8,11 +7,13 @@ from sqlalchemy import text import ormar # noqa I100 from ormar.models.helpers.models import group_related_list from ormar.queryset import FilterQuery, LimitQuery, OffsetQuery, OrderQuery -from ormar.queryset.filter_action import FilterAction +from ormar.queryset.actions.filter_action import FilterAction from ormar.queryset.join import SqlJoin if TYPE_CHECKING: # pragma no cover from ormar import Model + from ormar.queryset import OrderAction + from ormar.models.excludable import ExcludableItems class Query: @@ -24,9 +25,8 @@ class Query: select_related: List, limit_count: Optional[int], offset: Optional[int], - fields: Optional[Union[Dict, Set]], - exclude_fields: Optional[Union[Dict, Set]], - order_bys: Optional[List], + excludable: "ExcludableItems", + order_bys: Optional[List["OrderAction"]], limit_raw_sql: bool, ) -> None: self.query_offset = offset @@ -34,8 +34,7 @@ class Query: self._select_related = select_related[:] self.filter_clauses = filter_clauses[:] self.exclude_clauses = exclude_clauses[:] - self.fields = copy.deepcopy(fields) if fields else {} - self.exclude_fields = copy.deepcopy(exclude_fields) if exclude_fields else {} + self.excludable = excludable self.model_cls = model_cls self.table = self.model_cls.Meta.table @@ -45,7 +44,7 @@ class Query: self.select_from: List[str] = [] self.columns = [sqlalchemy.Column] self.order_columns = order_bys - self.sorted_orders: OrderedDict = OrderedDict() + self.sorted_orders: OrderedDict[OrderAction, text] = OrderedDict() self._init_sorted_orders() self.limit_raw_sql = limit_raw_sql @@ -58,28 +57,6 @@ class Query: for clause in self.order_columns: self.sorted_orders[clause] = None - @property - def prefixed_pk_name(self) -> str: - """ - Shortcut for extracting prefixed with alias primary key column name from main - model - :return: alias of pk column prefix with table name. - :rtype: str - """ - pkname_alias = self.model_cls.get_column_alias(self.model_cls.Meta.pkname) - return f"{self.table.name}.{pkname_alias}" - - def alias(self, name: str) -> str: - """ - Shortcut to extracting column alias from given master model. - - :param name: name of column - :type name: str - :return: alias of given column name - :rtype: str - """ - return self.model_cls.get_column_alias(name) - def apply_order_bys_for_primary_model(self) -> None: # noqa: CCR001 """ Applies order_by queries on main model when it's used as a subquery. @@ -88,16 +65,13 @@ class Query: """ if self.order_columns: for clause in self.order_columns: - if "__" not in clause: - text_clause = ( - text(f"{self.table.name}.{self.alias(clause[1:])} desc") - if clause.startswith("-") - else text(f"{self.table.name}.{self.alias(clause)}") - ) - self.sorted_orders[clause] = text_clause + if clause.is_source_model_order: + self.sorted_orders[clause] = clause.get_text_clause() else: - order = text(self.prefixed_pk_name) - self.sorted_orders[self.prefixed_pk_name] = order + clause = ormar.OrderAction( + order_str=self.model_cls.Meta.pkname, model_cls=self.model_cls + ) + self.sorted_orders[clause] = clause.get_text_clause() def _pagination_query_required(self) -> bool: """ @@ -128,10 +102,7 @@ class Query: :rtype: sqlalchemy.sql.selectable.Select """ self_related_fields = self.model_cls.own_table_columns( - model=self.model_cls, - fields=self.fields, - exclude_fields=self.exclude_fields, - use_alias=True, + model=self.model_cls, excludable=self.excludable, use_alias=True, ) self.columns = self.model_cls.Meta.alias_manager.prefixed_columns( "", self.table, self_related_fields @@ -145,8 +116,6 @@ class Query: related_models = group_related_list(self._select_related) for related in related_models: - fields = self.model_cls.get_included(self.fields, related) - exclude_fields = self.model_cls.get_excluded(self.exclude_fields, related) remainder = None if isinstance(related_models, dict) and related_models[related]: remainder = related_models[related] @@ -154,8 +123,7 @@ class Query: used_aliases=self.used_aliases, select_from=self.select_from, columns=self.columns, - fields=fields, - exclude_fields=exclude_fields, + excludable=self.excludable, order_columns=self.order_columns, sorted_orders=self.sorted_orders, main_model=self.model_cls, @@ -201,14 +169,16 @@ class Query: filters_to_use = [ filter_clause for filter_clause in self.filter_clauses - if filter_clause.table_prefix == "" + if filter_clause.is_source_model_filter ] excludes_to_use = [ filter_clause for filter_clause in self.exclude_clauses - if filter_clause.table_prefix == "" + if filter_clause.is_source_model_filter ] - sorts_to_use = {k: v for k, v in self.sorted_orders.items() if "__" not in k} + sorts_to_use = { + k: v for k, v in self.sorted_orders.items() if k.is_source_model_order + } expr = FilterQuery(filter_clauses=filters_to_use).apply(expr) expr = FilterQuery(filter_clauses=excludes_to_use, exclude=True).apply(expr) expr = OrderQuery(sorted_orders=sorts_to_use).apply(expr) @@ -253,5 +223,3 @@ class Query: self.select_from = [] self.columns = [] self.used_aliases = [] - self.fields = {} - self.exclude_fields = {} diff --git a/ormar/queryset/queryset.py b/ormar/queryset/queryset.py index 6adec49..d0679f5 100644 --- a/ormar/queryset/queryset.py +++ b/ormar/queryset/queryset.py @@ -1,4 +1,15 @@ -from typing import Any, Dict, List, Optional, Sequence, Set, TYPE_CHECKING, Type, Union +from typing import ( + Any, + Dict, + List, + Optional, + Sequence, + Set, + TYPE_CHECKING, + Type, + Union, + cast, +) import databases import sqlalchemy @@ -8,15 +19,16 @@ import ormar # noqa I100 from ormar import MultipleMatches, NoMatch from ormar.exceptions import ModelError, ModelPersistenceError, QueryDefinitionError from ormar.queryset import FilterQuery +from ormar.queryset.actions.order_action import OrderAction from ormar.queryset.clause import QueryClause from ormar.queryset.prefetch_query import PrefetchQuery from ormar.queryset.query import Query -from ormar.queryset.utils import update, update_dict_from_list if TYPE_CHECKING: # pragma no cover from ormar import Model from ormar.models.metaclass import ModelMeta from ormar.relations.querysetproxy import QuerysetProxy + from ormar.models.excludable import ExcludableItems class QuerySet: @@ -26,18 +38,19 @@ class QuerySet: def __init__( # noqa CFQ002 self, - model_cls: Type["Model"] = None, + model_cls: Optional[Type["Model"]] = None, filter_clauses: List = None, exclude_clauses: List = None, select_related: List = None, limit_count: int = None, offset: int = None, - columns: Dict = None, - exclude_columns: Dict = None, + excludable: "ExcludableItems" = None, order_bys: List = None, prefetch_related: List = None, limit_raw_sql: bool = False, + proxy_source_model: Optional[Type["Model"]] = None, ) -> None: + self.proxy_source_model = proxy_source_model self.model_cls = model_cls self.filter_clauses = [] if filter_clauses is None else filter_clauses self.exclude_clauses = [] if exclude_clauses is None else exclude_clauses @@ -45,8 +58,7 @@ class QuerySet: self._prefetch_related = [] if prefetch_related is None else prefetch_related self.limit_count = limit_count self.query_offset = offset - self._columns = columns or {} - self._exclude_columns = exclude_columns or {} + self._excludable = excludable or ormar.ExcludableItems() self.order_bys = order_bys or [] self.limit_sql_raw = limit_raw_sql @@ -62,7 +74,7 @@ class QuerySet: f"ForwardRefs. \nBefore using the model you " f"need to call update_forward_refs()." ) - if issubclass(owner, ormar.Model): + owner = cast(Type["Model"], owner) return self.__class__(model_cls=owner) return self.__class__() # pragma: no cover @@ -90,9 +102,54 @@ class QuerySet: raise ValueError("Model class of QuerySet is not initialized") return self.model_cls + def rebuild_self( # noqa: CFQ002 + self, + filter_clauses: List = None, + exclude_clauses: List = None, + select_related: List = None, + limit_count: int = None, + offset: int = None, + excludable: "ExcludableItems" = None, + order_bys: List = None, + prefetch_related: List = None, + limit_raw_sql: bool = None, + proxy_source_model: Optional[Type["Model"]] = None, + ) -> "QuerySet": + """ + Method that returns new instance of queryset based on passed params, + all not passed params are taken from current values. + """ + overwrites = { + "select_related": "_select_related", + "offset": "query_offset", + "excludable": "_excludable", + "prefetch_related": "_prefetch_related", + "limit_raw_sql": "limit_sql_raw", + } + passed_args = locals() + + def replace_if_none(arg_name: str) -> Any: + if passed_args.get(arg_name) is None: + return getattr(self, overwrites.get(arg_name, arg_name)) + return passed_args.get(arg_name) + + return self.__class__( + model_cls=self.model_cls, + filter_clauses=replace_if_none("filter_clauses"), + exclude_clauses=replace_if_none("exclude_clauses"), + select_related=replace_if_none("select_related"), + limit_count=replace_if_none("limit_count"), + offset=replace_if_none("offset"), + excludable=replace_if_none("excludable"), + order_bys=replace_if_none("order_bys"), + prefetch_related=replace_if_none("prefetch_related"), + limit_raw_sql=replace_if_none("limit_raw_sql"), + proxy_source_model=replace_if_none("proxy_source_model"), + ) + async def _prefetch_related_models( - self, models: Sequence[Optional["Model"]], rows: List - ) -> Sequence[Optional["Model"]]: + self, models: List[Optional["Model"]], rows: List + ) -> List[Optional["Model"]]: """ Performs prefetch query for selected models names. @@ -105,15 +162,14 @@ class QuerySet: """ query = PrefetchQuery( model_cls=self.model, - fields=self._columns, - exclude_fields=self._exclude_columns, + excludable=self._excludable, prefetch_related=self._prefetch_related, select_related=self._select_related, orders_by=self.order_bys, ) return await query.prefetch_related(models=models, rows=rows) # type: ignore - def _process_query_result_rows(self, rows: List) -> Sequence[Optional["Model"]]: + def _process_query_result_rows(self, rows: List) -> List[Optional["Model"]]: """ Process database rows and initialize ormar Model from each of the rows. @@ -126,8 +182,9 @@ class QuerySet: self.model.from_row( row=row, select_related=self._select_related, - fields=self._columns, - exclude_fields=self._exclude_columns, + excludable=self._excludable, + source_model=self.model, + proxy_source_model=self.proxy_source_model, ) for row in rows ] @@ -191,8 +248,7 @@ class QuerySet: exclude_clauses=self.exclude_clauses, offset=offset or self.query_offset, limit_count=limit or self.limit_count, - fields=self._columns, - exclude_fields=self._exclude_columns, + excludable=self._excludable, order_bys=order_bys or self.order_bys, limit_raw_sql=self.limit_sql_raw, ) @@ -241,18 +297,10 @@ class QuerySet: exclude_clauses = self.exclude_clauses filter_clauses = filter_clauses - return self.__class__( - model_cls=self.model, + return self.rebuild_self( filter_clauses=filter_clauses, exclude_clauses=exclude_clauses, select_related=select_related, - limit_count=self.limit_count, - offset=self.query_offset, - columns=self._columns, - exclude_columns=self._exclude_columns, - order_bys=self.order_bys, - prefetch_related=self._prefetch_related, - limit_raw_sql=self.limit_sql_raw, ) def exclude(self, **kwargs: Any) -> "QuerySet": # noqa: A003 @@ -296,20 +344,8 @@ class QuerySet: if not isinstance(related, list): related = [related] - related = list(set(list(self._select_related) + related)) - return self.__class__( - model_cls=self.model, - filter_clauses=self.filter_clauses, - exclude_clauses=self.exclude_clauses, - select_related=related, - limit_count=self.limit_count, - offset=self.query_offset, - columns=self._columns, - exclude_columns=self._exclude_columns, - order_bys=self.order_bys, - prefetch_related=self._prefetch_related, - limit_raw_sql=self.limit_sql_raw, - ) + related = sorted(list(set(list(self._select_related) + related))) + return self.rebuild_self(select_related=related,) def prefetch_related(self, related: Union[List, str]) -> "QuerySet": """ @@ -333,21 +369,11 @@ class QuerySet: related = [related] related = list(set(list(self._prefetch_related) + related)) - return self.__class__( - model_cls=self.model, - filter_clauses=self.filter_clauses, - exclude_clauses=self.exclude_clauses, - select_related=self._select_related, - limit_count=self.limit_count, - offset=self.query_offset, - columns=self._columns, - exclude_columns=self._exclude_columns, - order_bys=self.order_bys, - prefetch_related=related, - limit_raw_sql=self.limit_sql_raw, - ) + return self.rebuild_self(prefetch_related=related,) - def fields(self, columns: Union[List, str, Set, Dict]) -> "QuerySet": + def fields( + self, columns: Union[List, str, Set, Dict], _is_exclude: bool = False + ) -> "QuerySet": """ With `fields()` you can select subset of model columns to limit the data load. @@ -385,34 +411,22 @@ class QuerySet: To include whole nested model specify model related field name and ellipsis. + :param _is_exclude: flag if it's exclude or include operation + :type _is_exclude: bool :param columns: columns to include :type columns: Union[List, str, Set, Dict] :return: QuerySet :rtype: QuerySet """ - if isinstance(columns, str): - columns = [columns] - - current_included = self._columns - if not isinstance(columns, dict): - current_included = update_dict_from_list(current_included, columns) - else: - current_included = update(current_included, columns) - - return self.__class__( - model_cls=self.model, - filter_clauses=self.filter_clauses, - exclude_clauses=self.exclude_clauses, - select_related=self._select_related, - limit_count=self.limit_count, - offset=self.query_offset, - columns=current_included, - exclude_columns=self._exclude_columns, - order_bys=self.order_bys, - prefetch_related=self._prefetch_related, - limit_raw_sql=self.limit_sql_raw, + excludable = ormar.ExcludableItems.from_excludable(self._excludable) + excludable.build( + items=columns, + model_cls=self.model_cls, # type: ignore + is_exclude=_is_exclude, ) + return self.rebuild_self(excludable=excludable,) + def exclude_fields(self, columns: Union[List, str, Set, Dict]) -> "QuerySet": """ With `exclude_fields()` you can select subset of model columns that will @@ -440,28 +454,7 @@ class QuerySet: :return: QuerySet :rtype: QuerySet """ - if isinstance(columns, str): - columns = [columns] - - current_excluded = self._exclude_columns - if not isinstance(columns, dict): - current_excluded = update_dict_from_list(current_excluded, columns) - else: - current_excluded = update(current_excluded, columns) - - return self.__class__( - model_cls=self.model, - filter_clauses=self.filter_clauses, - exclude_clauses=self.exclude_clauses, - select_related=self._select_related, - limit_count=self.limit_count, - offset=self.query_offset, - columns=self._columns, - exclude_columns=current_excluded, - order_bys=self.order_bys, - prefetch_related=self._prefetch_related, - limit_raw_sql=self.limit_sql_raw, - ) + return self.fields(columns=columns, _is_exclude=True) def order_by(self, columns: Union[List, str]) -> "QuerySet": """ @@ -498,20 +491,13 @@ class QuerySet: if not isinstance(columns, list): columns = [columns] - order_bys = self.order_bys + [x for x in columns if x not in self.order_bys] - return self.__class__( - model_cls=self.model, - filter_clauses=self.filter_clauses, - exclude_clauses=self.exclude_clauses, - select_related=self._select_related, - limit_count=self.limit_count, - offset=self.query_offset, - columns=self._columns, - exclude_columns=self._exclude_columns, - order_bys=order_bys, - prefetch_related=self._prefetch_related, - limit_raw_sql=self.limit_sql_raw, - ) + orders_by = [ + OrderAction(order_str=x, model_cls=self.model_cls) # type: ignore + for x in columns + ] + + order_bys = self.order_bys + [x for x in orders_by if x not in self.order_bys] + return self.rebuild_self(order_bys=order_bys,) async def exists(self) -> bool: """ @@ -551,17 +537,19 @@ class QuerySet: :return: number of updated rows :rtype: int """ + if not each and not self.filter_clauses: + raise QueryDefinitionError( + "You cannot update without filtering the queryset first. " + "If you want to update all rows use update(each=True, **kwargs)" + ) + self_fields = self.model.extract_db_own_fields().union( self.model.extract_related_names() ) updates = {k: v for k, v in kwargs.items() if k in self_fields} updates = self.model.validate_choices(updates) updates = self.model.translate_columns_to_aliases(updates) - if not each and not self.filter_clauses: - raise QueryDefinitionError( - "You cannot update without filtering the queryset first. " - "If you want to update all rows use update(each=True, **kwargs)" - ) + expr = FilterQuery(filter_clauses=self.filter_clauses).apply( self.table.update().values(**updates) ) @@ -610,19 +598,7 @@ class QuerySet: limit_count = page_size query_offset = (page - 1) * page_size - return self.__class__( - model_cls=self.model, - filter_clauses=self.filter_clauses, - exclude_clauses=self.exclude_clauses, - select_related=self._select_related, - limit_count=limit_count, - offset=query_offset, - columns=self._columns, - exclude_columns=self._exclude_columns, - order_bys=self.order_bys, - prefetch_related=self._prefetch_related, - limit_raw_sql=self.limit_sql_raw, - ) + return self.rebuild_self(limit_count=limit_count, offset=query_offset,) def limit(self, limit_count: int, limit_raw_sql: bool = None) -> "QuerySet": """ @@ -639,19 +615,7 @@ class QuerySet: :rtype: QuerySet """ limit_raw_sql = self.limit_sql_raw if limit_raw_sql is None else limit_raw_sql - return self.__class__( - model_cls=self.model, - filter_clauses=self.filter_clauses, - exclude_clauses=self.exclude_clauses, - select_related=self._select_related, - limit_count=limit_count, - offset=self.query_offset, - columns=self._columns, - exclude_columns=self._exclude_columns, - order_bys=self.order_bys, - prefetch_related=self._prefetch_related, - limit_raw_sql=limit_raw_sql, - ) + return self.rebuild_self(limit_count=limit_count, limit_raw_sql=limit_raw_sql,) def offset(self, offset: int, limit_raw_sql: bool = None) -> "QuerySet": """ @@ -668,19 +632,7 @@ class QuerySet: :rtype: QuerySet """ limit_raw_sql = self.limit_sql_raw if limit_raw_sql is None else limit_raw_sql - return self.__class__( - model_cls=self.model, - filter_clauses=self.filter_clauses, - exclude_clauses=self.exclude_clauses, - select_related=self._select_related, - limit_count=self.limit_count, - offset=offset, - columns=self._columns, - exclude_columns=self._exclude_columns, - order_bys=self.order_bys, - prefetch_related=self._prefetch_related, - limit_raw_sql=limit_raw_sql, - ) + return self.rebuild_self(offset=offset, limit_raw_sql=limit_raw_sql,) async def first(self, **kwargs: Any) -> "Model": """ @@ -697,7 +649,14 @@ class QuerySet: return await self.filter(**kwargs).first() expr = self.build_select_expression( - limit=1, order_bys=[f"{self.model.Meta.pkname}"] + self.order_bys + limit=1, + order_bys=[ + OrderAction( + order_str=f"{self.model.Meta.pkname}", + model_cls=self.model_cls, # type: ignore + ) + ] + + self.order_bys, ) rows = await self.database.fetch_all(expr) processed_rows = self._process_query_result_rows(rows) @@ -726,7 +685,14 @@ class QuerySet: if not self.filter_clauses: expr = self.build_select_expression( - limit=1, order_bys=[f"-{self.model.Meta.pkname}"] + self.order_bys + limit=1, + order_bys=[ + OrderAction( + order_str=f"-{self.model.Meta.pkname}", + model_cls=self.model_cls, # type: ignore + ) + ] + + self.order_bys, ) else: expr = self.build_select_expression() diff --git a/ormar/queryset/utils.py b/ormar/queryset/utils.py index e2cf33a..ca3358d 100644 --- a/ormar/queryset/utils.py +++ b/ormar/queryset/utils.py @@ -12,8 +12,6 @@ from typing import ( Union, ) -from ormar.fields import ManyToManyField - if TYPE_CHECKING: # pragma no cover from ormar import Model @@ -219,7 +217,7 @@ def extract_models_to_dict_of_lists( def get_relationship_alias_model_and_str( source_model: Type["Model"], related_parts: List -) -> Tuple[str, Type["Model"], str]: +) -> Tuple[str, Type["Model"], str, bool]: """ Walks the relation to retrieve the actual model on which the clause should be constructed, extracts alias based on last relation leading to target model. @@ -231,19 +229,37 @@ def get_relationship_alias_model_and_str( :rtype: Tuple[str, Type["Model"], str] """ table_prefix = "" - model_cls = source_model - previous_model = model_cls - manager = model_cls.Meta.alias_manager - for relation in related_parts: - related_field = model_cls.Meta.model_fields[relation] - if issubclass(related_field, ManyToManyField): + is_through = False + target_model = source_model + previous_model = target_model + previous_models = [target_model] + manager = target_model.Meta.alias_manager + for relation in related_parts[:]: + related_field = target_model.Meta.model_fields[relation] + + if related_field.is_through: + # through is always last - cannot go further + is_through = True + related_parts.remove(relation) + through_field = related_field.owner.Meta.model_fields[ + related_field.related_name or "" + ] + if len(previous_models) > 1 and previous_models[-2] == through_field.to: + previous_model = through_field.to + relation = through_field.related_name + else: + relation = related_field.related_name + + if related_field.is_multi: previous_model = related_field.through relation = related_field.default_target_field_name() # type: ignore table_prefix = manager.resolve_relation_alias( from_model=previous_model, relation_name=relation ) - model_cls = related_field.to - previous_model = model_cls + target_model = related_field.to + previous_model = target_model + if not is_through: + previous_models.append(previous_model) relation_str = "__".join(related_parts) - return table_prefix, model_cls, relation_str + return table_prefix, target_model, relation_str, is_through diff --git a/ormar/relations/alias_manager.py b/ormar/relations/alias_manager.py index cd3dc8b..815a4dc 100644 --- a/ormar/relations/alias_manager.py +++ b/ormar/relations/alias_manager.py @@ -1,13 +1,15 @@ import string import uuid from random import choices -from typing import Any, Dict, List, TYPE_CHECKING, Type +from typing import Any, Dict, List, TYPE_CHECKING, Type, Union import sqlalchemy from sqlalchemy import text if TYPE_CHECKING: # pragma: no cover from ormar import Model + from ormar.models import ModelRow + from ormar.fields import ForeignKeyField def get_table_alias() -> str: @@ -133,7 +135,7 @@ class AliasManager: return alias def resolve_relation_alias( - self, from_model: Type["Model"], relation_name: str + self, from_model: Union[Type["Model"], Type["ModelRow"]], relation_name: str ) -> str: """ Given model and relation name returns the alias for this relation. @@ -147,3 +149,35 @@ class AliasManager: """ alias = self._aliases_new.get(f"{from_model.get_name()}_{relation_name}", "") return alias + + def resolve_relation_alias_after_complex( + self, + source_model: Union[Type["Model"], Type["ModelRow"]], + relation_str: str, + relation_field: Type["ForeignKeyField"], + ) -> str: + """ + Given source model and relation string returns the alias for this complex + relation if it exists, otherwise fallback to normal relation from a relation + field definition. + + :param relation_field: field with direct relation definition + :type relation_field: Type["ForeignKeyField"] + :param source_model: model with query starts + :type source_model: source Model + :param relation_str: string with relation joins defined + :type relation_str: str + :return: alias of the relation + :rtype: str + """ + alias = "" + if relation_str and "__" in relation_str: + alias = self.resolve_relation_alias( + from_model=source_model, relation_name=relation_str + ) + if not alias: + alias = self.resolve_relation_alias( + from_model=relation_field.get_source_model(), + relation_name=relation_field.get_relation_name(), + ) + return alias diff --git a/ormar/relations/querysetproxy.py b/ormar/relations/querysetproxy.py index 360e863..190dd7b 100644 --- a/ormar/relations/querysetproxy.py +++ b/ormar/relations/querysetproxy.py @@ -1,4 +1,5 @@ -from typing import ( +from _weakref import CallableProxyType +from typing import ( # noqa: I100, I201 Any, Dict, List, @@ -7,12 +8,12 @@ from typing import ( Sequence, Set, TYPE_CHECKING, - TypeVar, Union, + cast, ) import ormar -from ormar.exceptions import ModelPersistenceError +from ormar.exceptions import ModelPersistenceError, QueryDefinitionError if TYPE_CHECKING: # pragma no cover from ormar.relations import Relation @@ -20,10 +21,8 @@ if TYPE_CHECKING: # pragma no cover from ormar.queryset import QuerySet from ormar import RelationType - T = TypeVar("T", bound=Model) - -class QuerysetProxy(ormar.QuerySetProtocol): +class QuerysetProxy: """ Exposes QuerySet methods on relations, but also handles creating and removing of through Models for m2m relations. @@ -38,12 +37,17 @@ class QuerysetProxy(ormar.QuerySetProtocol): self.relation: Relation = relation self._queryset: Optional["QuerySet"] = qryset self.type_: "RelationType" = type_ - self._owner: "Model" = self.relation.manager.owner + self._owner: Union[CallableProxyType, "Model"] = self.relation.manager.owner self.related_field_name = self._owner.Meta.model_fields[ self.relation.field_name ].get_related_name() self.related_field = self.relation.to.Meta.model_fields[self.related_field_name] self.owner_pk_value = self._owner.pk + self.through_model_name = ( + self.related_field.through.get_name() + if self.type_ == ormar.RelationType.MULTIPLE + else "" + ) @property def queryset(self) -> "QuerySet": @@ -65,7 +69,7 @@ class QuerysetProxy(ormar.QuerySetProtocol): """ self._queryset = value - def _assign_child_to_parent(self, child: Optional["T"]) -> None: + def _assign_child_to_parent(self, child: Optional["Model"]) -> None: """ Registers child in parents RelationManager. @@ -77,7 +81,9 @@ class QuerysetProxy(ormar.QuerySetProtocol): rel_name = self.relation.field_name setattr(owner, rel_name, child) - def _register_related(self, child: Union["T", Sequence[Optional["T"]]]) -> None: + def _register_related( + self, child: Union["Model", Sequence[Optional["Model"]]] + ) -> None: """ Registers child/ children in parents RelationManager. @@ -89,6 +95,7 @@ class QuerysetProxy(ormar.QuerySetProtocol): self._assign_child_to_parent(subchild) else: assert isinstance(child, ormar.Model) + child = cast("Model", child) self._assign_child_to_parent(child) def _clean_items_on_load(self) -> None: @@ -99,17 +106,20 @@ class QuerysetProxy(ormar.QuerySetProtocol): for item in self.relation.related_models[:]: self.relation.remove(item) - async def create_through_instance(self, child: "T") -> None: + async def create_through_instance(self, child: "Model", **kwargs: Any) -> None: """ Crete a through model instance in the database for m2m relations. + :param kwargs: dict of additional keyword arguments for through instance + :type kwargs: Any :param child: child model instance :type child: Model """ model_cls = self.relation.through owner_column = self.related_field.default_target_field_name() # type: ignore child_column = self.related_field.default_source_field_name() # type: ignore - kwargs = {owner_column: self._owner.pk, child_column: child.pk} + rel_kwargs = {owner_column: self._owner.pk, child_column: child.pk} + final_kwargs = {**rel_kwargs, **kwargs} if child.pk is None: raise ModelPersistenceError( f"You cannot save {child.get_name()} " @@ -117,18 +127,34 @@ class QuerysetProxy(ormar.QuerySetProtocol): f"Save the child model first." ) expr = model_cls.Meta.table.insert() - expr = expr.values(**kwargs) + expr = expr.values(**final_kwargs) # print("\n", expr.compile(compile_kwargs={"literal_binds": True})) await model_cls.Meta.database.execute(expr) - async def delete_through_instance(self, child: "T") -> None: + async def update_through_instance(self, child: "Model", **kwargs: Any) -> None: + """ + Updates a through model instance in the database for m2m relations. + + :param kwargs: dict of additional keyword arguments for through instance + :type kwargs: Any + :param child: child model instance + :type child: Model + """ + model_cls = self.relation.through + owner_column = self.related_field.default_target_field_name() # type: ignore + child_column = self.related_field.default_source_field_name() # type: ignore + rel_kwargs = {owner_column: self._owner.pk, child_column: child.pk} + through_model = await model_cls.objects.get(**rel_kwargs) + await through_model.update(**kwargs) + + async def delete_through_instance(self, child: "Model") -> None: """ Removes through model instance from the database for m2m relations. :param child: child model instance :type child: Model """ - queryset = ormar.QuerySet(model_cls=self.relation.through) + queryset = ormar.QuerySet(model_cls=self.relation.through) # type: ignore owner_column = self.related_field.default_target_field_name() # type: ignore child_column = self.related_field.default_source_field_name() # type: ignore kwargs = {owner_column: self._owner, child_column: child} @@ -176,10 +202,10 @@ class QuerysetProxy(ormar.QuerySetProtocol): :rtype: int """ if self.type_ == ormar.RelationType.MULTIPLE: - queryset = ormar.QuerySet(model_cls=self.relation.through) + queryset = ormar.QuerySet(model_cls=self.relation.through) # type: ignore owner_column = self._owner.get_name() else: - queryset = ormar.QuerySet(model_cls=self.relation.to) + queryset = ormar.QuerySet(model_cls=self.relation.to) # type: ignore owner_column = self.related_field.name kwargs = {owner_column: self._owner} self._clean_items_on_load() @@ -270,14 +296,47 @@ class QuerysetProxy(ormar.QuerySetProtocol): :return: created model :rtype: Model """ + through_kwargs = kwargs.pop(self.through_model_name, {}) if self.type_ == ormar.RelationType.REVERSE: kwargs[self.related_field.name] = self._owner created = await self.queryset.create(**kwargs) self._register_related(created) if self.type_ == ormar.RelationType.MULTIPLE: - await self.create_through_instance(created) + await self.create_through_instance(created, **through_kwargs) return created + async def update(self, each: bool = False, **kwargs: Any) -> int: + """ + Updates the model table after applying the filters from kwargs. + + You have to either pass a filter to narrow down a query or explicitly pass + each=True flag to affect whole table. + + :param each: flag if whole table should be affected if no filter is passed + :type each: bool + :param kwargs: fields names and proper value types + :type kwargs: Any + :return: number of updated rows + :rtype: int + """ + # queryset proxy always have one filter for pk of parent model + if not each and len(self.queryset.filter_clauses) == 1: + raise QueryDefinitionError( + "You cannot update without filtering the queryset first. " + "If you want to update all rows use update(each=True, **kwargs)" + ) + + through_kwargs = kwargs.pop(self.through_model_name, {}) + children = await self.queryset.all() + for child in children: + await child.update(**kwargs) # type: ignore + if self.type_ == ormar.RelationType.MULTIPLE and through_kwargs: + await self.update_through_instance( + child=child, # type: ignore + **through_kwargs, + ) + return len(children) + async def get_or_create(self, **kwargs: Any) -> "Model": """ Combination of create and get methods. diff --git a/ormar/relations/relation.py b/ormar/relations/relation.py index cf191e0..bb7abd1 100644 --- a/ormar/relations/relation.py +++ b/ormar/relations/relation.py @@ -1,17 +1,13 @@ from enum import Enum -from typing import List, Optional, Set, TYPE_CHECKING, Type, TypeVar, Union +from typing import List, Optional, Set, TYPE_CHECKING, Type, Union import ormar # noqa I100 from ormar.exceptions import RelationshipInstanceError # noqa I100 -from ormar.fields.foreign_key import ForeignKeyField # noqa I100 from ormar.relations.relation_proxy import RelationProxy if TYPE_CHECKING: # pragma no cover - from ormar import Model from ormar.relations import RelationsManager - from ormar.models import NewBaseModel - - T = TypeVar("T", bound=Model) + from ormar.models import Model, NewBaseModel class RelationType(Enum): @@ -26,6 +22,7 @@ class RelationType(Enum): PRIMARY = 1 REVERSE = 2 MULTIPLE = 3 + THROUGH = 4 class Relation: @@ -38,8 +35,8 @@ class Relation: manager: "RelationsManager", type_: RelationType, field_name: str, - to: Type["T"], - through: Type["T"] = None, + to: Type["Model"], + through: Type["Model"] = None, ) -> None: """ Initialize the Relation and keep the related models either as instances of @@ -62,17 +59,25 @@ class Relation: self._owner: "Model" = manager.owner self._type: RelationType = type_ self._to_remove: Set = set() - self.to: Type["T"] = to - self._through: Optional[Type["T"]] = through + self.to: Type["Model"] = to + self._through = through self.field_name: str = field_name - self.related_models: Optional[Union[RelationProxy, "T"]] = ( + self.related_models: Optional[Union[RelationProxy, "Model"]] = ( RelationProxy(relation=self, type_=type_, field_name=field_name) if type_ in (RelationType.REVERSE, RelationType.MULTIPLE) else None ) + def clear(self) -> None: + if self._type in (RelationType.PRIMARY, RelationType.THROUGH): + self.related_models = None + self._owner.__dict__[self.field_name] = None + elif self.related_models is not None: + self.related_models._clear() + self._owner.__dict__[self.field_name] = None + @property - def through(self) -> Type["T"]: + def through(self) -> Type["Model"]: if not self._through: # pragma: no cover raise RelationshipInstanceError("Relation does not have through model!") return self._through @@ -119,7 +124,7 @@ class Relation: self._to_remove.add(ind) return None - def add(self, child: "T") -> None: + def add(self, child: "Model") -> None: """ Adds child Model to relation, either sets child as related model or adds it to the list in RelationProxy depending on relation type. @@ -128,7 +133,7 @@ class Relation: :type child: Model """ relation_name = self.field_name - if self._type == RelationType.PRIMARY: + if self._type in (RelationType.PRIMARY, RelationType.THROUGH): self.related_models = child self._owner.__dict__[relation_name] = child else: @@ -160,7 +165,7 @@ class Relation: self.related_models.pop(position) # type: ignore del self._owner.__dict__[relation_name][position] - def get(self) -> Optional[Union[List["T"], "T"]]: + def get(self) -> Optional[Union[List["Model"], "Model"]]: """ Return the related model or models from RelationProxy. diff --git a/ormar/relations/relation_manager.py b/ormar/relations/relation_manager.py index 511dd7b..19c0dc5 100644 --- a/ormar/relations/relation_manager.py +++ b/ormar/relations/relation_manager.py @@ -1,17 +1,12 @@ -from typing import Dict, List, Optional, Sequence, TYPE_CHECKING, Type, TypeVar, Union +from typing import Dict, List, Optional, Sequence, TYPE_CHECKING, Type, Union from weakref import proxy -from ormar.fields import BaseField -from ormar.fields.foreign_key import ForeignKeyField -from ormar.fields.many_to_many import ManyToManyField from ormar.relations.relation import Relation, RelationType from ormar.relations.utils import get_relations_sides_and_names if TYPE_CHECKING: # pragma no cover - from ormar import Model - from ormar.models import NewBaseModel - - T = TypeVar("T", bound=Model) + from ormar.models import NewBaseModel, Model + from ormar.fields import ForeignKeyField, BaseField class RelationsManager: @@ -21,8 +16,8 @@ class RelationsManager: def __init__( self, - related_fields: List[Type[ForeignKeyField]] = None, - owner: "NewBaseModel" = None, + related_fields: List[Type["ForeignKeyField"]] = None, + owner: Optional["Model"] = None, ) -> None: self.owner = proxy(owner) self._related_fields = related_fields or [] @@ -31,35 +26,6 @@ class RelationsManager: for field in self._related_fields: self._add_relation(field) - def _get_relation_type(self, field: Type[BaseField]) -> RelationType: - """ - Returns type of the relation declared on a field. - - :param field: field with relation declaration - :type field: Type[BaseField] - :return: type of the relation defined on field - :rtype: RelationType - """ - if issubclass(field, ManyToManyField): - return RelationType.MULTIPLE - return RelationType.PRIMARY if not field.virtual else RelationType.REVERSE - - def _add_relation(self, field: Type[BaseField]) -> None: - """ - Registers relation in the manager. - Adds Relation instance under field.name. - - :param field: field with relation declaration - :type field: Type[BaseField] - """ - self._relations[field.name] = Relation( - manager=self, - type_=self._get_relation_type(field), - field_name=field.name, - to=field.to, - through=getattr(field, "through", None), - ) - def __contains__(self, item: str) -> bool: """ Checks if relation with given name is already registered. @@ -71,7 +37,11 @@ class RelationsManager: """ return item in self._related_names - def get(self, name: str) -> Optional[Union["T", Sequence["T"]]]: + def clear(self) -> None: + for relation in self._relations.values(): + relation.clear() + + def get(self, name: str) -> Optional[Union["Model", Sequence["Model"]]]: """ Returns the related model/models if relation is set. Actual call is delegated to Relation instance registered under relation name. @@ -86,20 +56,6 @@ class RelationsManager: return relation.get() return None # pragma nocover - def _get(self, name: str) -> Optional[Relation]: - """ - Returns the actual relation and not the related model(s). - - :param name: name of the relation - :type name: str - :return: Relation instance - :rtype: ormar.relations.relation.Relation - """ - relation = self._relations.get(name, None) - if relation is not None: - return relation - return None - @staticmethod def add(parent: "Model", child: "Model", field: Type["ForeignKeyField"],) -> None: """ @@ -167,3 +123,48 @@ class RelationsManager: relation_name = item.Meta.model_fields[name].get_related_name() item._orm.remove(name, parent) parent._orm.remove(relation_name, item) + + def _get(self, name: str) -> Optional[Relation]: + """ + Returns the actual relation and not the related model(s). + + :param name: name of the relation + :type name: str + :return: Relation instance + :rtype: ormar.relations.relation.Relation + """ + relation = self._relations.get(name, None) + if relation is not None: + return relation + return None + + def _get_relation_type(self, field: Type["BaseField"]) -> RelationType: + """ + Returns type of the relation declared on a field. + + :param field: field with relation declaration + :type field: Type[BaseField] + :return: type of the relation defined on field + :rtype: RelationType + """ + if field.is_multi: + return RelationType.MULTIPLE + if field.is_through: + return RelationType.THROUGH + return RelationType.PRIMARY if not field.virtual else RelationType.REVERSE + + def _add_relation(self, field: Type["BaseField"]) -> None: + """ + Registers relation in the manager. + Adds Relation instance under field.name. + + :param field: field with relation declaration + :type field: Type[BaseField] + """ + self._relations[field.name] = Relation( + manager=self, + type_=self._get_relation_type(field), + field_name=field.name, + to=field.to, + through=getattr(field, "through", None), + ) diff --git a/ormar/relations/relation_proxy.py b/ormar/relations/relation_proxy.py index 518fc71..ce4b86f 100644 --- a/ormar/relations/relation_proxy.py +++ b/ormar/relations/relation_proxy.py @@ -27,7 +27,9 @@ class RelationProxy(list): self.type_: "RelationType" = type_ self.field_name = field_name self._owner: "Model" = self.relation.manager.owner - self.queryset_proxy = QuerysetProxy(relation=self.relation, type_=type_) + self.queryset_proxy: QuerysetProxy = QuerysetProxy( + relation=self.relation, type_=type_ + ) self._related_field_name: Optional[str] = None @property @@ -73,6 +75,9 @@ class RelationProxy(list): self._initialize_queryset() return getattr(self.queryset_proxy, item) + def _clear(self) -> None: + super().clear() + def _initialize_queryset(self) -> None: """ Initializes the QuerySetProxy if not yet initialized. @@ -117,7 +122,9 @@ class RelationProxy(list): self._check_if_model_saved() kwargs = {f"{related_field.get_alias()}__{pkname}": self._owner.pk} queryset = ( - ormar.QuerySet(model_cls=self.relation.to) + ormar.QuerySet( + model_cls=self.relation.to, proxy_source_model=self._owner.__class__ + ) .select_related(related_field.name) .filter(**kwargs) ) @@ -163,19 +170,21 @@ class RelationProxy(list): else: await item.delete() - async def add(self, item: "Model") -> None: + async def add(self, item: "Model", **kwargs: Any) -> None: """ Adds child model to relation. For ManyToMany relations through instance is automatically created. + :param kwargs: dict of additional keyword arguments for through instance + :type kwargs: Any :param item: child to add to relation :type item: Model """ relation_name = self.related_field_name self._check_if_model_saved() if self.type_ == ormar.RelationType.MULTIPLE: - await self.queryset_proxy.create_through_instance(item) + await self.queryset_proxy.create_through_instance(item, **kwargs) setattr(item, relation_name, self._owner) else: setattr(item, relation_name, self._owner) diff --git a/pydoc-markdown.yml b/pydoc-markdown.yml index a7ba311..6f0188f 100644 --- a/pydoc-markdown.yml +++ b/pydoc-markdown.yml @@ -21,9 +21,15 @@ renderer: - title: Model contents: - models.model.* + - title: Model Row + contents: + - models.model_row.* - title: New BaseModel contents: - models.newbasemodel.* + - title: Excludable Items + contents: + - models.excludable.* - title: Model Table Proxy contents: - models.modelproxy.* diff --git a/tests/test_aliases.py b/tests/test_aliases.py index 239c182..bb6b40f 100644 --- a/tests/test_aliases.py +++ b/tests/test_aliases.py @@ -1,4 +1,4 @@ -from typing import Optional, Union, List +from typing import List, Optional import databases import pytest @@ -23,13 +23,6 @@ class Child(ormar.Model): born_year: int = ormar.Integer(name="year_born", nullable=True) -class ArtistChildren(ormar.Model): - class Meta: - tablename = "children_x_artists" - metadata = metadata - database = database - - class Artist(ormar.Model): class Meta: tablename = "artists" @@ -40,9 +33,7 @@ class Artist(ormar.Model): first_name: str = ormar.String(name="fname", max_length=100) last_name: str = ormar.String(name="lname", max_length=100) born_year: int = ormar.Integer(name="year") - children: Optional[Union[Child, List[Child]]] = ormar.ManyToMany( - Child, through=ArtistChildren - ) + children: Optional[List[Child]] = ormar.ManyToMany(Child) class Album(ormar.Model): diff --git a/tests/test_excludable_items.py b/tests/test_excludable_items.py new file mode 100644 index 0000000..95d1319 --- /dev/null +++ b/tests/test_excludable_items.py @@ -0,0 +1,218 @@ +from typing import List, Optional + +import databases +import sqlalchemy + +import ormar +from ormar.models.excludable import ExcludableItems +from tests.settings import DATABASE_URL + +database = databases.Database(DATABASE_URL, force_rollback=True) +metadata = sqlalchemy.MetaData() + + +class BaseMeta(ormar.ModelMeta): + database = database + metadata = metadata + + +class NickNames(ormar.Model): + class Meta(BaseMeta): + tablename = "nicks" + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100, nullable=False, name="hq_name") + is_lame: bool = ormar.Boolean(nullable=True) + + +class NicksHq(ormar.Model): + class Meta(BaseMeta): + tablename = "nicks_x_hq" + + +class HQ(ormar.Model): + class Meta(BaseMeta): + pass + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100, nullable=False, name="hq_name") + nicks: List[NickNames] = ormar.ManyToMany(NickNames, through=NicksHq) + + +class Company(ormar.Model): + class Meta(BaseMeta): + tablename = "companies" + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100, nullable=False, name="company_name") + founded: int = ormar.Integer(nullable=True) + hq: HQ = ormar.ForeignKey(HQ) + + +class Car(ormar.Model): + class Meta(BaseMeta): + pass + + id: int = ormar.Integer(primary_key=True) + manufacturer: Optional[Company] = ormar.ForeignKey(Company) + name: str = ormar.String(max_length=100) + year: int = ormar.Integer(nullable=True) + gearbox_type: str = ormar.String(max_length=20, nullable=True) + gears: int = ormar.Integer(nullable=True) + aircon_type: str = ormar.String(max_length=20, nullable=True) + + +def compare_results(excludable): + car_excludable = excludable.get(Car) + assert car_excludable.exclude == {"year", "gearbox_type", "gears", "aircon_type"} + assert car_excludable.include == set() + + assert car_excludable.is_excluded("year") + + alias = Company.Meta.alias_manager.resolve_relation_alias(Car, "manufacturer") + manu_excludable = excludable.get(Company, alias=alias) + assert manu_excludable.exclude == {"founded"} + assert manu_excludable.include == set() + + assert manu_excludable.is_excluded("founded") + + +def compare_results_include(excludable): + manager = Company.Meta.alias_manager + car_excludable = excludable.get(Car) + assert car_excludable.include == {"id", "name"} + assert car_excludable.exclude == set() + + assert car_excludable.is_included("name") + assert not car_excludable.is_included("gears") + + alias = manager.resolve_relation_alias(Car, "manufacturer") + manu_excludable = excludable.get(Company, alias=alias) + assert manu_excludable.include == {"name"} + assert manu_excludable.exclude == set() + + assert manu_excludable.is_included("name") + assert not manu_excludable.is_included("founded") + + alias = manager.resolve_relation_alias(Company, "hq") + hq_excludable = excludable.get(HQ, alias=alias) + assert hq_excludable.include == {"name"} + assert hq_excludable.exclude == set() + + alias = manager.resolve_relation_alias(NicksHq, "nicknames") + nick_excludable = excludable.get(NickNames, alias=alias) + assert nick_excludable.include == {"name"} + assert nick_excludable.exclude == set() + + +def test_excluding_fields_from_list(): + fields = [ + "gearbox_type", + "gears", + "aircon_type", + "year", + "manufacturer__founded", + ] + excludable = ExcludableItems() + excludable.build(items=fields, model_cls=Car, is_exclude=True) + compare_results(excludable) + + +def test_excluding_fields_from_dict(): + fields = { + "gearbox_type": ..., + "gears": ..., + "aircon_type": ..., + "year": ..., + "manufacturer": {"founded": ...}, + } + excludable = ExcludableItems() + excludable.build(items=fields, model_cls=Car, is_exclude=True) + compare_results(excludable) + + +def test_excluding_fields_from_dict_with_set(): + fields = { + "gearbox_type": ..., + "gears": ..., + "aircon_type": ..., + "year": ..., + "manufacturer": {"founded"}, + } + excludable = ExcludableItems() + excludable.build(items=fields, model_cls=Car, is_exclude=True) + compare_results(excludable) + + +def test_gradual_build_from_lists(): + fields_col = [ + "year", + ["gearbox_type", "gears"], + "aircon_type", + ["manufacturer__founded"], + ] + excludable = ExcludableItems() + for fields in fields_col: + excludable.build(items=fields, model_cls=Car, is_exclude=True) + compare_results(excludable) + + +def test_nested_includes(): + fields = [ + "id", + "name", + "manufacturer__name", + "manufacturer__hq__name", + "manufacturer__hq__nicks__name", + ] + excludable = ExcludableItems() + excludable.build(items=fields, model_cls=Car, is_exclude=False) + compare_results_include(excludable) + + +def test_nested_includes_from_dict(): + fields = { + "id": ..., + "name": ..., + "manufacturer": {"name": ..., "hq": {"name": ..., "nicks": {"name": ...}},}, + } + excludable = ExcludableItems() + excludable.build(items=fields, model_cls=Car, is_exclude=False) + compare_results_include(excludable) + + +def test_nested_includes_from_dict_with_set(): + fields = { + "id": ..., + "name": ..., + "manufacturer": {"name": ..., "hq": {"name": ..., "nicks": {"name"}},}, + } + excludable = ExcludableItems() + excludable.build(items=fields, model_cls=Car, is_exclude=False) + compare_results_include(excludable) + + +def test_includes_and_excludes_combo(): + fields_inc1 = ["id", "name", "year", "gearbox_type", "gears"] + fields_inc2 = {"manufacturer": {"name"}} + fields_exc1 = {"manufacturer__founded"} + fields_exc2 = "aircon_type" + excludable = ExcludableItems() + excludable.build(items=fields_inc1, model_cls=Car, is_exclude=False) + excludable.build(items=fields_inc2, model_cls=Car, is_exclude=False) + excludable.build(items=fields_exc1, model_cls=Car, is_exclude=True) + excludable.build(items=fields_exc2, model_cls=Car, is_exclude=True) + + car_excludable = excludable.get(Car) + assert car_excludable.include == {"id", "name", "year", "gearbox_type", "gears"} + assert car_excludable.exclude == {"aircon_type"} + + assert car_excludable.is_excluded("aircon_type") + assert car_excludable.is_included("name") + + alias = Company.Meta.alias_manager.resolve_relation_alias(Car, "manufacturer") + manu_excludable = excludable.get(Company, alias=alias) + assert manu_excludable.include == {"name"} + assert manu_excludable.exclude == {"founded"} + + assert manu_excludable.is_excluded("founded") diff --git a/tests/test_excluding_fields_in_fastapi.py b/tests/test_excluding_fields_in_fastapi.py index 6568d9b..1f0950f 100644 --- a/tests/test_excluding_fields_in_fastapi.py +++ b/tests/test_excluding_fields_in_fastapi.py @@ -135,26 +135,22 @@ async def create_user3(user: User2): @app.post("/users4/") async def create_user4(user: User2): - user = await user.save() - return user.dict(exclude={"password"}) + return (await user.save()).dict(exclude={"password"}) @app.post("/random/", response_model=RandomModel) async def create_user5(user: RandomModel): - user = await user.save() - return user + return await user.save() @app.post("/random2/", response_model=RandomModel) async def create_user6(user: RandomModel): - user = await user.save() - return user.dict() + return await user.save() @app.post("/random3/", response_model=RandomModel, response_model_exclude={"full_name"}) async def create_user7(user: RandomModel): - user = await user.save() - return user.dict() + return await user.save() def test_excluding_fields_in_endpoints(): diff --git a/tests/test_fastapi_docs.py b/tests/test_fastapi_docs.py index 08118ea..03f0892 100644 --- a/tests/test_fastapi_docs.py +++ b/tests/test_fastapi_docs.py @@ -42,18 +42,13 @@ class Category(ormar.Model): name: str = ormar.String(max_length=100) -class ItemsXCategories(ormar.Model): - class Meta(LocalMeta): - tablename = "items_x_categories" - - class Item(ormar.Model): class Meta(LocalMeta): pass id: int = ormar.Integer(primary_key=True) name: str = ormar.String(max_length=100) - categories = ormar.ManyToMany(Category, through=ItemsXCategories) + categories = ormar.ManyToMany(Category) @pytest.fixture(autouse=True, scope="module") diff --git a/tests/test_inheritance_concrete.py b/tests/test_inheritance_concrete.py index 2ac22ff..6bc3859 100644 --- a/tests/test_inheritance_concrete.py +++ b/tests/test_inheritance_concrete.py @@ -121,11 +121,11 @@ class Bus(Car): max_persons: int = ormar.Integer() -class PersonsCar(ormar.Model): - class Meta: - tablename = "cars_x_persons" - metadata = metadata - database = db +# class PersonsCar(ormar.Model): +# class Meta: +# tablename = "cars_x_persons" +# metadata = metadata +# database = db class Car2(ormar.Model): @@ -138,7 +138,9 @@ class Car2(ormar.Model): name: str = ormar.String(max_length=50) owner: Person = ormar.ForeignKey(Person, related_name="owned") co_owners: List[Person] = ormar.ManyToMany( - Person, through=PersonsCar, related_name="coowned" + Person, + # through=PersonsCar, + related_name="coowned", ) created_date: datetime.datetime = ormar.DateTime(default=datetime.datetime.now) diff --git a/tests/test_load_all.py b/tests/test_load_all.py new file mode 100644 index 0000000..3b4bde5 --- /dev/null +++ b/tests/test_load_all.py @@ -0,0 +1,171 @@ +from typing import List + +import databases +import pytest +import sqlalchemy + +import ormar +from tests.settings import DATABASE_URL + +database = databases.Database(DATABASE_URL, force_rollback=True) +metadata = sqlalchemy.MetaData() + + +class BaseMeta(ormar.ModelMeta): + database = database + metadata = metadata + + +class Language(ormar.Model): + class Meta(BaseMeta): + tablename = "languages" + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100) + level: str = ormar.String(max_length=150, default="Beginner") + + +class CringeLevel(ormar.Model): + class Meta(BaseMeta): + tablename = "levels" + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100) + language = ormar.ForeignKey(Language) + + +class NickName(ormar.Model): + class Meta(BaseMeta): + tablename = "nicks" + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100, nullable=False, name="hq_name") + is_lame: bool = ormar.Boolean(nullable=True) + level: CringeLevel = ormar.ForeignKey(CringeLevel) + + +class HQ(ormar.Model): + class Meta(BaseMeta): + tablename = "hqs" + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100, nullable=False, name="hq_name") + nicks: List[NickName] = ormar.ManyToMany(NickName) + + +class Company(ormar.Model): + class Meta(BaseMeta): + tablename = "companies" + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100, nullable=False, name="company_name") + founded: int = ormar.Integer(nullable=True) + hq: HQ = ormar.ForeignKey(HQ, related_name="companies") + + +@pytest.fixture(autouse=True, scope="module") +def create_test_database(): + engine = sqlalchemy.create_engine(DATABASE_URL) + metadata.drop_all(engine) + metadata.create_all(engine) + yield + metadata.drop_all(engine) + + +@pytest.mark.asyncio +async def test_load_all_fk_rel(): + async with database: + async with database.transaction(force_rollback=True): + hq = await HQ.objects.create(name="Main") + company = await Company.objects.create(name="Banzai", founded=1988, hq=hq) + + hq = await HQ.objects.get(name="Main") + await hq.load_all() + + assert hq.companies[0] == company + assert hq.companies[0].name == "Banzai" + assert hq.companies[0].founded == 1988 + + +@pytest.mark.asyncio +async def test_load_all_many_to_many(): + async with database: + async with database.transaction(force_rollback=True): + nick1 = await NickName.objects.create(name="BazingaO", is_lame=False) + nick2 = await NickName.objects.create(name="Bazinga20", is_lame=True) + hq = await HQ.objects.create(name="Main") + await hq.nicks.add(nick1) + await hq.nicks.add(nick2) + + hq = await HQ.objects.get(name="Main") + await hq.load_all() + + assert hq.nicks[0] == nick1 + assert hq.nicks[0].name == "BazingaO" + + assert hq.nicks[1] == nick2 + assert hq.nicks[1].name == "Bazinga20" + + +@pytest.mark.asyncio +async def test_loading_reversed_relation(): + async with database: + async with database.transaction(force_rollback=True): + hq = await HQ.objects.create(name="Main") + await Company.objects.create(name="Banzai", founded=1988, hq=hq) + + company = await Company.objects.get(name="Banzai") + await company.load_all() + + assert company.hq == hq + + +@pytest.mark.asyncio +async def test_loading_nested(): + async with database: + async with database.transaction(force_rollback=True): + language = await Language.objects.create(name="English") + level = await CringeLevel.objects.create(name="High", language=language) + level2 = await CringeLevel.objects.create(name="Low", language=language) + nick1 = await NickName.objects.create( + name="BazingaO", is_lame=False, level=level + ) + nick2 = await NickName.objects.create( + name="Bazinga20", is_lame=True, level=level2 + ) + hq = await HQ.objects.create(name="Main") + await hq.nicks.add(nick1) + await hq.nicks.add(nick2) + + hq = await HQ.objects.get(name="Main") + await hq.load_all(follow=True) + + assert hq.nicks[0] == nick1 + assert hq.nicks[0].name == "BazingaO" + assert hq.nicks[0].level.name == "High" + assert hq.nicks[0].level.language.name == "English" + + assert hq.nicks[1] == nick2 + assert hq.nicks[1].name == "Bazinga20" + assert hq.nicks[1].level.name == "Low" + assert hq.nicks[1].level.language.name == "English" + + await hq.load_all(follow=True, exclude="nicks__level__language") + assert len(hq.nicks) == 2 + assert hq.nicks[0].level.language is None + assert hq.nicks[1].level.language is None + + await hq.load_all(follow=True, exclude="nicks__level__language__level") + assert len(hq.nicks) == 2 + assert hq.nicks[0].level.language is not None + assert hq.nicks[0].level.language.level is None + assert hq.nicks[1].level.language is not None + assert hq.nicks[1].level.language.level is None + + await hq.load_all(follow=True, exclude="nicks__level") + assert len(hq.nicks) == 2 + assert hq.nicks[0].level is None + assert hq.nicks[1].level is None + + await hq.load_all(follow=True, exclude="nicks") + assert len(hq.nicks) == 0 diff --git a/tests/test_m2m_through_fields.py b/tests/test_m2m_through_fields.py index 96c7f89..ef9847c 100644 --- a/tests/test_m2m_through_fields.py +++ b/tests/test_m2m_through_fields.py @@ -1,6 +1,9 @@ +from typing import Any, Sequence, cast + import databases import pytest import sqlalchemy +from pydantic.typing import ForwardRef import ormar from tests.settings import DATABASE_URL @@ -18,8 +21,8 @@ class Category(ormar.Model): class Meta(BaseMeta): tablename = "categories" - id: int = ormar.Integer(primary_key=True) - name: str = ormar.String(max_length=40) + id = ormar.Integer(primary_key=True) + name = ormar.String(max_length=40) class PostCategory(ormar.Model): @@ -28,6 +31,15 @@ class PostCategory(ormar.Model): id: int = ormar.Integer(primary_key=True) sort_order: int = ormar.Integer(nullable=True) + param_name: str = ormar.String(default="Name", max_length=200) + + +class Blog(ormar.Model): + class Meta(BaseMeta): + pass + + id: int = ormar.Integer(primary_key=True) + title: str = ormar.String(max_length=200) class Post(ormar.Model): @@ -37,30 +49,329 @@ class Post(ormar.Model): id: int = ormar.Integer(primary_key=True) title: str = ormar.String(max_length=200) categories = ormar.ManyToMany(Category, through=PostCategory) + blog = ormar.ForeignKey(Blog) -# -# @pytest.fixture(autouse=True, scope="module") -# async def create_test_database(): -# engine = sqlalchemy.create_engine(DATABASE_URL) -# metadata.create_all(engine) -# yield -# metadata.drop_all(engine) -# -# -# @pytest.mark.asyncio -# async def test_setting_fields_on_through_model(): -# async with database: -# # TODO: check/ modify following -# # loading the data into model instance of though model? -# # <- attach to other side? both sides? access by through, or add to fields? -# # creating while adding to relation (kwargs in add?) -# # creating in query (dividing kwargs between final and through) -# # updating in query -# # sorting in filter (special __through__ notation?) -# # ordering by in order_by -# # accessing from instance (both sides?) -# # modifying from instance (both sides?) -# # including/excluding in fields? -# # allowing to change fk fields names in through model? -# pass +@pytest.fixture(autouse=True, scope="module") +def create_test_database(): + engine = sqlalchemy.create_engine(DATABASE_URL) + metadata.drop_all(engine) + metadata.create_all(engine) + yield + metadata.drop_all(engine) + + +class PostCategory2(ormar.Model): + class Meta(BaseMeta): + tablename = "posts_x_categories2" + + id: int = ormar.Integer(primary_key=True) + sort_order: int = ormar.Integer(nullable=True) + + +class Post2(ormar.Model): + class Meta(BaseMeta): + pass + + id: int = ormar.Integer(primary_key=True) + title: str = ormar.String(max_length=200) + categories = ormar.ManyToMany(Category, through=ForwardRef("PostCategory2")) + + +@pytest.mark.asyncio +async def test_forward_ref_is_updated(): + async with database: + assert Post2.Meta.requires_ref_update + Post2.update_forward_refs() + + assert Post2.Meta.model_fields["postcategory2"].to == PostCategory2 + + +@pytest.mark.asyncio +async def test_setting_fields_on_through_model(): + async with database: + post = await Post(title="Test post").save() + category = await Category(name="Test category").save() + await post.categories.add(category) + + assert hasattr(post.categories[0], "postcategory") + assert post.categories[0].postcategory is None + + +@pytest.mark.asyncio +async def test_setting_additional_fields_on_through_model_in_add(): + async with database: + post = await Post(title="Test post").save() + category = await Category(name="Test category").save() + await post.categories.add(category, sort_order=1) + postcat = await PostCategory.objects.get() + assert postcat.sort_order == 1 + + +@pytest.mark.asyncio +async def test_setting_additional_fields_on_through_model_in_create(): + async with database: + post = await Post(title="Test post").save() + await post.categories.create( + name="Test category2", postcategory={"sort_order": 2} + ) + postcat = await PostCategory.objects.get() + assert postcat.sort_order == 2 + + +@pytest.mark.asyncio +async def test_getting_additional_fields_from_queryset() -> Any: + async with database: + post = await Post(title="Test post").save() + await post.categories.create( + name="Test category1", postcategory={"sort_order": 1} + ) + await post.categories.create( + name="Test category2", postcategory={"sort_order": 2} + ) + + await post.categories.all() + assert post.postcategory is None + assert post.categories[0].postcategory.sort_order == 1 + assert post.categories[1].postcategory.sort_order == 2 + + post2 = await Post.objects.select_related("categories").get( + categories__name="Test category2" + ) + assert post2.categories[0].postcategory.sort_order == 2 + + +@pytest.mark.asyncio +async def test_only_one_side_has_through() -> Any: + async with database: + post = await Post(title="Test post").save() + await post.categories.create( + name="Test category1", postcategory={"sort_order": 1} + ) + await post.categories.create( + name="Test category2", postcategory={"sort_order": 2} + ) + + post2 = await Post.objects.select_related("categories").get() + assert post2.postcategory is None + assert post2.categories[0].postcategory is not None + + await post2.categories.all() + assert post2.postcategory is None + assert post2.categories[0].postcategory is not None + + categories = await Category.objects.select_related("posts").all() + categories = cast(Sequence[Category], categories) + assert categories[0].postcategory is None + assert categories[0].posts[0].postcategory is not None + + +@pytest.mark.asyncio +async def test_filtering_by_through_model() -> Any: + async with database: + post = await Post(title="Test post").save() + await post.categories.create( + name="Test category1", + postcategory={"sort_order": 1, "param_name": "volume"}, + ) + await post.categories.create( + name="Test category2", postcategory={"sort_order": 2, "param_name": "area"} + ) + + post2 = ( + await Post.objects.select_related("categories") + .filter(postcategory__sort_order__gt=1) + .get() + ) + assert len(post2.categories) == 1 + assert post2.categories[0].postcategory.sort_order == 2 + + post3 = await Post.objects.filter( + categories__postcategory__param_name="volume" + ).get() + assert len(post3.categories) == 1 + assert post3.categories[0].postcategory.param_name == "volume" + + +@pytest.mark.asyncio +async def test_deep_filtering_by_through_model() -> Any: + async with database: + blog = await Blog(title="My Blog").save() + post = await Post(title="Test post", blog=blog).save() + + await post.categories.create( + name="Test category1", + postcategory={"sort_order": 1, "param_name": "volume"}, + ) + await post.categories.create( + name="Test category2", postcategory={"sort_order": 2, "param_name": "area"} + ) + + blog2 = ( + await Blog.objects.select_related("posts__categories") + .filter(posts__postcategory__sort_order__gt=1) + .get() + ) + assert len(blog2.posts) == 1 + assert len(blog2.posts[0].categories) == 1 + assert blog2.posts[0].categories[0].postcategory.sort_order == 2 + + blog3 = await Blog.objects.filter( + posts__categories__postcategory__param_name="volume" + ).get() + assert len(blog3.posts) == 1 + assert len(blog3.posts[0].categories) == 1 + assert blog3.posts[0].categories[0].postcategory.param_name == "volume" + + +@pytest.mark.asyncio +async def test_ordering_by_through_model() -> Any: + async with database: + post = await Post(title="Test post").save() + await post.categories.create( + name="Test category1", + postcategory={"sort_order": 2, "param_name": "volume"}, + ) + await post.categories.create( + name="Test category2", postcategory={"sort_order": 1, "param_name": "area"} + ) + await post.categories.create( + name="Test category3", + postcategory={"sort_order": 3, "param_name": "velocity"}, + ) + + post2 = ( + await Post.objects.select_related("categories") + .order_by("-postcategory__sort_order") + .get() + ) + assert len(post2.categories) == 3 + assert post2.categories[0].name == "Test category3" + assert post2.categories[2].name == "Test category2" + + post3 = ( + await Post.objects.select_related("categories") + .order_by("categories__postcategory__param_name") + .get() + ) + assert len(post3.categories) == 3 + assert post3.categories[0].postcategory.param_name == "area" + assert post3.categories[2].postcategory.param_name == "volume" + + +@pytest.mark.asyncio +async def test_update_through_models_from_queryset_on_through() -> Any: + async with database: + post = await Post(title="Test post").save() + await post.categories.create( + name="Test category1", + postcategory={"sort_order": 2, "param_name": "volume"}, + ) + await post.categories.create( + name="Test category2", postcategory={"sort_order": 1, "param_name": "area"} + ) + await post.categories.create( + name="Test category3", + postcategory={"sort_order": 3, "param_name": "velocity"}, + ) + + await PostCategory.objects.filter(param_name="volume", post=post.id).update( + sort_order=4 + ) + post2 = ( + await Post.objects.select_related("categories") + .order_by("-postcategory__sort_order") + .get() + ) + assert len(post2.categories) == 3 + assert post2.categories[0].postcategory.param_name == "volume" + assert post2.categories[2].postcategory.param_name == "area" + + +@pytest.mark.asyncio +async def test_update_through_model_after_load() -> Any: + async with database: + post = await Post(title="Test post").save() + await post.categories.create( + name="Test category1", + postcategory={"sort_order": 2, "param_name": "volume"}, + ) + post2 = await Post.objects.select_related("categories").get() + assert len(post2.categories) == 1 + + await post2.categories[0].postcategory.load() + await post2.categories[0].postcategory.update(sort_order=3) + + post3 = await Post.objects.select_related("categories").get() + assert len(post3.categories) == 1 + assert post3.categories[0].postcategory.sort_order == 3 + + +@pytest.mark.asyncio +async def test_update_through_from_related() -> Any: + async with database: + post = await Post(title="Test post").save() + await post.categories.create( + name="Test category1", + postcategory={"sort_order": 2, "param_name": "volume"}, + ) + await post.categories.create( + name="Test category2", postcategory={"sort_order": 1, "param_name": "area"} + ) + await post.categories.create( + name="Test category3", + postcategory={"sort_order": 3, "param_name": "velocity"}, + ) + + await post.categories.filter(name="Test category3").update( + postcategory={"sort_order": 4} + ) + + post2 = ( + await Post.objects.select_related("categories") + .order_by("postcategory__sort_order") + .get() + ) + assert len(post2.categories) == 3 + assert post2.categories[2].postcategory.sort_order == 4 + + +@pytest.mark.asyncio +async def test_excluding_fields_on_through_model() -> Any: + async with database: + post = await Post(title="Test post").save() + await post.categories.create( + name="Test category1", + postcategory={"sort_order": 2, "param_name": "volume"}, + ) + await post.categories.create( + name="Test category2", postcategory={"sort_order": 1, "param_name": "area"} + ) + await post.categories.create( + name="Test category3", + postcategory={"sort_order": 3, "param_name": "velocity"}, + ) + + post2 = ( + await Post.objects.select_related("categories") + .exclude_fields("postcategory__param_name") + .order_by("postcategory__sort_order") + .get() + ) + assert len(post2.categories) == 3 + assert post2.categories[0].postcategory.param_name is None + assert post2.categories[0].postcategory.sort_order == 1 + + assert post2.categories[2].postcategory.param_name is None + assert post2.categories[2].postcategory.sort_order == 3 + + post3 = ( + await Post.objects.select_related("categories") + .fields({"postcategory": ..., "title": ...}) + .exclude_fields({"postcategory": {"param_name", "sort_order"}}) + .get() + ) + assert len(post3.categories) == 3 + for category in post3.categories: + assert category.postcategory.param_name is None + assert category.postcategory.sort_order is None diff --git a/tests/test_many_to_many.py b/tests/test_many_to_many.py index 8b10eae..3989df8 100644 --- a/tests/test_many_to_many.py +++ b/tests/test_many_to_many.py @@ -1,5 +1,5 @@ import asyncio -from typing import List, Union, Optional +from typing import List, Optional import databases import pytest @@ -34,13 +34,6 @@ class Category(ormar.Model): name: str = ormar.String(max_length=40) -class PostCategory(ormar.Model): - class Meta: - tablename = "posts_categories" - database = database - metadata = metadata - - class Post(ormar.Model): class Meta: tablename = "posts" @@ -49,9 +42,7 @@ class Post(ormar.Model): id: int = ormar.Integer(primary_key=True) title: str = ormar.String(max_length=200) - categories: Optional[Union[Category, List[Category]]] = ormar.ManyToMany( - Category, through=PostCategory - ) + categories: Optional[List[Category]] = ormar.ManyToMany(Category) author: Optional[Author] = ormar.ForeignKey(Author) @@ -74,6 +65,7 @@ async def create_test_database(): async def cleanup(): yield async with database: + PostCategory = Post.Meta.model_fields["categories"].through await PostCategory.objects.delete(each=True) await Post.objects.delete(each=True) await Category.objects.delete(each=True) diff --git a/tests/test_more_same_table_joins.py b/tests/test_more_same_table_joins.py index 9dc086e..b991d13 100644 --- a/tests/test_more_same_table_joins.py +++ b/tests/test_more_same_table_joins.py @@ -108,3 +108,17 @@ async def test_model_multiple_instances_of_same_table_in_schema(): assert len(classes[0].dict().get("students")) == 2 assert classes[0].teachers[0].category.department.name == "Law Department" assert classes[0].students[0].category.department.name == "Math Department" + + +@pytest.mark.asyncio +async def test_load_all_multiple_instances_of_same_table_in_schema(): + async with database: + await create_data() + math_class = await SchoolClass.objects.get(name="Math") + assert math_class.name == "Math" + + await math_class.load_all(follow=True) + assert math_class.students[0].name == "Jane" + assert len(math_class.dict().get("students")) == 2 + assert math_class.teachers[0].category.department.name == "Law Department" + assert math_class.students[0].category.department.name == "Math Department" diff --git a/tests/test_order_by.py b/tests/test_order_by.py index 02639ca..bbb6385 100644 --- a/tests/test_order_by.py +++ b/tests/test_order_by.py @@ -85,13 +85,6 @@ class Car(ormar.Model): factory: Optional[Factory] = ormar.ForeignKey(Factory) -class UsersCar(ormar.Model): - class Meta: - tablename = "cars_x_users" - metadata = metadata - database = database - - class User(ormar.Model): class Meta: tablename = "users" @@ -100,7 +93,7 @@ class User(ormar.Model): id: int = ormar.Integer(primary_key=True) name: str = ormar.String(max_length=100) - cars: List[Car] = ormar.ManyToMany(Car, through=UsersCar) + cars: List[Car] = ormar.ManyToMany(Car) @pytest.fixture(autouse=True, scope="module") diff --git a/tests/test_queryproxy_on_m2m_models.py b/tests/test_queryproxy_on_m2m_models.py index d33aa5d..a91c4f8 100644 --- a/tests/test_queryproxy_on_m2m_models.py +++ b/tests/test_queryproxy_on_m2m_models.py @@ -6,6 +6,7 @@ import pytest import sqlalchemy import ormar +from ormar.exceptions import QueryDefinitionError from tests.settings import DATABASE_URL database = databases.Database(DATABASE_URL, force_rollback=True) @@ -180,3 +181,42 @@ async def test_queryset_methods(): assert len(categories) == 3 == len(post.categories) for cat in post.categories: assert cat.subject.name is not None + + +@pytest.mark.asyncio +async def test_queryset_update(): + async with database: + async with database.transaction(force_rollback=True): + guido = await Author.objects.create( + first_name="Guido", last_name="Van Rossum" + ) + subject = await Subject(name="Random").save() + post = await Post.objects.create(title="Hello, M2M", author=guido) + await post.categories.create(name="News", sort_order=1, subject=subject) + await post.categories.create(name="Breaking", sort_order=3, subject=subject) + + await post.categories.order_by("sort_order").all() + assert len(post.categories) == 2 + assert post.categories[0].sort_order == 1 + assert post.categories[0].name == "News" + assert post.categories[1].sort_order == 3 + assert post.categories[1].name == "Breaking" + + updated = await post.categories.update(each=True, name="Test") + assert updated == 2 + + await post.categories.order_by("sort_order").all() + assert len(post.categories) == 2 + assert post.categories[0].name == "Test" + assert post.categories[1].name == "Test" + + updated = await post.categories.filter(sort_order=3).update(name="Test 2") + assert updated == 1 + + await post.categories.order_by("sort_order").all() + assert len(post.categories) == 2 + assert post.categories[0].name == "Test" + assert post.categories[1].name == "Test 2" + + with pytest.raises(QueryDefinitionError): + await post.categories.update(name="Test WRONG") diff --git a/tests/test_queryset_utils.py b/tests/test_queryset_utils.py index daae2b4..cd96dc8 100644 --- a/tests/test_queryset_utils.py +++ b/tests/test_queryset_utils.py @@ -8,11 +8,6 @@ from ormar.queryset.utils import translate_list_to_dict, update_dict_from_list, from tests.settings import DATABASE_URL -def test_empty_excludable(): - assert ExcludableMixin.is_included(None, "key") # all fields included if empty - assert not ExcludableMixin.is_excluded(None, "key") # none field excluded if empty - - def test_list_to_dict_translation(): tet_list = ["aa", "bb", "cc__aa", "cc__bb", "cc__aa__xx", "cc__aa__yy"] test = translate_list_to_dict(tet_list) diff --git a/tests/test_selecting_subset_of_columns.py b/tests/test_selecting_subset_of_columns.py index a2d57db..809b508 100644 --- a/tests/test_selecting_subset_of_columns.py +++ b/tests/test_selecting_subset_of_columns.py @@ -204,8 +204,8 @@ async def test_selecting_subset(): all_cars_dummy = ( await Car.objects.select_related("manufacturer") .fields(["id", "name", "year", "gearbox_type", "gears", "aircon_type"]) - .fields({"manufacturer": ...}) - .exclude_fields({"manufacturer": ...}) + # .fields({"manufacturer": ...}) + # .exclude_fields({"manufacturer": ...}) .fields({"manufacturer": {"name"}}) .exclude_fields({"manufacturer__founded"}) .all() diff --git a/tests/test_through_relations_fail.py b/tests/test_through_relations_fail.py new file mode 100644 index 0000000..472a8a1 --- /dev/null +++ b/tests/test_through_relations_fail.py @@ -0,0 +1,51 @@ +# type: ignore + +import databases +import pytest +import sqlalchemy + +import ormar +from ormar import ModelDefinitionError +from tests.settings import DATABASE_URL + +database = databases.Database(DATABASE_URL, force_rollback=True) +metadata = sqlalchemy.MetaData() + + +def test_through_with_relation_fails(): + class BaseMeta(ormar.ModelMeta): + database = database + metadata = metadata + + class Category(ormar.Model): + class Meta(BaseMeta): + tablename = "categories" + + id = ormar.Integer(primary_key=True) + name = ormar.String(max_length=40) + + class Blog(ormar.Model): + class Meta(BaseMeta): + pass + + id: int = ormar.Integer(primary_key=True) + title: str = ormar.String(max_length=200) + + class PostCategory(ormar.Model): + class Meta(BaseMeta): + tablename = "posts_x_categories" + + id: int = ormar.Integer(primary_key=True) + sort_order: int = ormar.Integer(nullable=True) + param_name: str = ormar.String(default="Name", max_length=200) + blog = ormar.ForeignKey(Blog) + + with pytest.raises(ModelDefinitionError): + + class Post(ormar.Model): + class Meta(BaseMeta): + pass + + id: int = ormar.Integer(primary_key=True) + title: str = ormar.String(max_length=200) + categories = ormar.ManyToMany(Category, through=PostCategory) diff --git a/tests/test_wekref_exclusion.py b/tests/test_wekref_exclusion.py new file mode 100644 index 0000000..a1140f7 --- /dev/null +++ b/tests/test_wekref_exclusion.py @@ -0,0 +1,147 @@ +from typing import List, Optional +from uuid import UUID, uuid4 + +import databases +import pydantic +import pytest +import sqlalchemy +from fastapi import FastAPI +from starlette.testclient import TestClient + +import ormar +from tests.settings import DATABASE_URL + +app = FastAPI() + +database = databases.Database(DATABASE_URL, force_rollback=True) +metadata = sqlalchemy.MetaData() + +app.state.database = database + + +@app.on_event("startup") +async def startup() -> None: + database_ = app.state.database + if not database_.is_connected: + await database_.connect() + + +@app.on_event("shutdown") +async def shutdown() -> None: + database_ = app.state.database + if database_.is_connected: + await database_.disconnect() + + +@pytest.fixture(autouse=True, scope="module") +def create_test_database(): + engine = sqlalchemy.create_engine(DATABASE_URL) + metadata.create_all(engine) + yield + metadata.drop_all(engine) + + +class BaseMeta(ormar.ModelMeta): + database = database + metadata = metadata + + +class OtherThing(ormar.Model): + class Meta(BaseMeta): + tablename = "other_things" + + id: UUID = ormar.UUID(primary_key=True, default=uuid4) + name: str = ormar.Text(default="") + ot_contents: str = ormar.Text(default="") + + +class Thing(ormar.Model): + class Meta(BaseMeta): + tablename = "things" + + id: UUID = ormar.UUID(primary_key=True, default=uuid4) + name: str = ormar.Text(default="") + js: pydantic.Json = ormar.JSON(nullable=True) + other_thing: Optional[OtherThing] = ormar.ForeignKey(OtherThing, nullable=True) + + +@app.post("/test/1") +async def post_test_1(): + # don't split initialization and attribute assignment + ot = await OtherThing(ot_contents="otc").save() + await Thing(other_thing=ot, name="t1").save() + await Thing(other_thing=ot, name="t2").save() + await Thing(other_thing=ot, name="t3").save() + + # if you do not care about returned object you can even go with bulk_create + # all of them are created in one transaction + # things = [Thing(other_thing=ot, name='t1'), + # Thing(other_thing=ot, name="t2"), + # Thing(other_thing=ot, name="t3")] + # await Thing.objects.bulk_create(things) + + +@app.get("/test/2", response_model=List[Thing]) +async def get_test_2(): + # if you only query for one use get or first + ot = await OtherThing.objects.get() + ts = await ot.things.all() + # specifically null out the relation on things before return + for t in ts: + t.remove(ot, name="other_thing") + return ts + + +@app.get("/test/3", response_model=List[Thing]) +async def get_test_3(): + ot = await OtherThing.objects.select_related("things").get() + # exclude unwanted field while ot is still in scope + # in order not to pass it to fastapi + return [t.dict(exclude={"other_thing"}) for t in ot.things] + + +@app.get("/test/4", response_model=List[Thing], response_model_exclude={"other_thing"}) +async def get_test_4(): + ot = await OtherThing.objects.get() + # query from the active side + return await Thing.objects.all(other_thing=ot) + + +@app.get("/get_ot/", response_model=OtherThing) +async def get_ot(): + return await OtherThing.objects.get() + + +# more real life (usually) is not getting some random OT and get it's Things +# but query for a specific one by some kind of id +@app.get( + "/test/5/{thing_id}", + response_model=List[Thing], + response_model_exclude={"other_thing"}, +) +async def get_test_5(thing_id: UUID): + return await Thing.objects.all(other_thing__id=thing_id) + + +def test_endpoints(): + client = TestClient(app) + with client: + resp = client.post("/test/1") + assert resp.status_code == 200 + + resp2 = client.get("/test/2") + assert resp2.status_code == 200 + assert len(resp2.json()) == 3 + + resp3 = client.get("/test/3") + assert resp3.status_code == 200 + assert len(resp3.json()) == 3 + + resp4 = client.get("/test/4") + assert resp4.status_code == 200 + assert len(resp4.json()) == 3 + + ot = OtherThing(**client.get("/get_ot/").json()) + resp5 = client.get(f"/test/5/{ot.id}") + assert resp5.status_code == 200 + assert len(resp5.json()) == 3