From a8ae50276e9104758af8e1472c79a68a809736e1 Mon Sep 17 00:00:00 2001 From: collerek Date: Wed, 3 Mar 2021 19:48:40 +0100 Subject: [PATCH] update docs, add load_all(), tests for load_all, make through field optional --- README.md | 2 +- docs/api/fields/base-field.md | 21 ++ docs/api/fields/foreign-key.md | 29 +++ docs/api/fields/many-to-many.md | 41 +++- docs/api/models/excludable-items.md | 188 ++++++++++++++++++ docs/api/models/helpers/models.md | 42 ++-- docs/api/models/helpers/pydantic.md | 6 +- .../helpers/related-names-validation.md | 25 +++ docs/api/models/helpers/relations.md | 17 +- docs/api/models/helpers/sqlalchemy.md | 8 +- docs/api/models/helpers/validation.md | 120 +++++++++++ docs/api/models/mixins/excludable-mixin.md | 90 +-------- docs/api/models/mixins/relation-mixin.md | 39 +++- docs/api/models/mixins/save-prepare-mixin.md | 19 ++ docs/api/models/model-metaclass.md | 114 ++--------- docs/api/models/model-row.md | 132 ++++++++++++ docs/api/models/model.md | 157 ++++----------- docs/api/models/new-basemodel.md | 4 +- docs/api/query-set/join.md | 97 +++------ docs/api/query-set/prefetch-query.md | 31 +-- docs/api/query-set/query-set.md | 13 +- docs/api/query-set/query.md | 32 --- docs/api/query-set/utils.md | 2 +- docs/api/relations/alias-manager.md | 23 ++- docs/api/relations/queryset-proxy.md | 46 ++++- docs/api/relations/relation-manager.md | 98 ++++----- docs/api/relations/relation-proxy.md | 3 +- docs/api/relations/relation.md | 6 +- docs/index.md | 2 +- docs/models/methods.md | 34 ++++ docs/relations/index.md | 60 ++++-- docs/relations/many-to-many.md | 166 +++++++++++++++- docs/relations/queryset-proxy.md | 47 +++++ docs/releases.md | 26 ++- docs_src/relations/docs002.py | 13 +- docs_src/relations/docs004.py | 29 +++ mkdocs.yml | 2 + ormar/fields/many_to_many.py | 22 +- ormar/models/helpers/sqlalchemy.py | 2 + ormar/models/metaclass.py | 10 +- ormar/models/mixins/merge_mixin.py | 4 +- ormar/models/mixins/relation_mixin.py | 53 ++++- ormar/models/model.py | 45 +++++ ormar/models/model_row.py | 15 +- ormar/queryset/queryset.py | 6 +- ormar/relations/relation.py | 8 + ormar/relations/relation_manager.py | 94 ++++----- ormar/relations/relation_proxy.py | 3 + pydoc-markdown.yml | 6 + tests/test_aliases.py | 13 +- tests/test_fastapi_docs.py | 7 +- tests/test_inheritance_concrete.py | 14 +- tests/test_load_all.py | 171 ++++++++++++++++ tests/test_m2m_through_fields.py | 26 ++- tests/test_many_to_many.py | 14 +- tests/test_order_by.py | 9 +- 56 files changed, 1653 insertions(+), 653 deletions(-) create mode 100644 docs/api/models/excludable-items.md create mode 100644 docs/api/models/helpers/related-names-validation.md create mode 100644 docs/api/models/helpers/validation.md create mode 100644 docs/api/models/model-row.md create mode 100644 docs_src/relations/docs004.py create mode 100644 tests/test_load_all.py 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 9d98f9f..ef6342a 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,9 +1,19 @@ -# 0.9.5 +# 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 praent model. To get the refreshed data on parent model you need to refresh + 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()`) * 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 @@ -14,13 +24,21 @@ * 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) - * check the updated docs for more information + * 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` + * check the updated ManyToMany relation docs for more information # Other * Updated docs and api docs -* Refactors and optimisations mainly related to filters and order bys +* 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 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/fields/many_to_many.py b/ormar/fields/many_to_many.py index 2b2b300..0e7602e 100644 --- a/ormar/fields/many_to_many.py +++ b/ormar/fields/many_to_many.py @@ -1,5 +1,5 @@ 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 @@ -43,7 +43,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, @@ -212,3 +212,21 @@ class ManyToManyField(ForeignKeyField, ormar.QuerySetProtocol, ormar.RelationPro :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/models/helpers/sqlalchemy.py b/ormar/models/helpers/sqlalchemy.py index b641969..3475c66 100644 --- a/ormar/models/helpers/sqlalchemy.py +++ b/ormar/models/helpers/sqlalchemy.py @@ -154,6 +154,8 @@ 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 field.is_multi: diff --git a/ormar/models/metaclass.py b/ormar/models/metaclass.py index 304fd19..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__), ) @@ -338,6 +345,7 @@ def copy_data_from_parent_model( # noqa: CCR001 parent_fields=parent_fields, attrs=attrs, meta=meta, + base_class=base_class, # type: ignore ) elif field.is_relation and field.related_name: 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/relation_mixin.py b/ormar/models/mixins/relation_mixin.py index aebaa20..4fdd9e7 100644 --- a/ormar/models/mixins/relation_mixin.py +++ b/ormar/models/mixins/relation_mixin.py @@ -1,5 +1,5 @@ import inspect -from typing import List, Optional, Set, TYPE_CHECKING +from typing import List, Optional, Set, TYPE_CHECKING, Type, Union class RelationMixin: @@ -8,7 +8,7 @@ class RelationMixin: """ if TYPE_CHECKING: # pragma no cover - from ormar import ModelMeta + from ormar import ModelMeta, Model Meta: ModelMeta _related_names: Optional[Set] @@ -63,7 +63,7 @@ class RelationMixin: return related_fields @classmethod - def extract_related_names(cls) -> Set: + 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. @@ -118,3 +118,50 @@ 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[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. + + :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] + """ + visited = visited or set() + visited.add(cls) + 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 visited: + visited.add(target_model) + deep_relations = target_model._iterate_related_models( + visited=visited, source_relation=relation, source_model=cls + ) + processed_relations.extend(deep_relations) + # TODO add test for circular deps + else: # pragma: no cover + 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 diff --git a/ormar/models/model.py b/ormar/models/model.py index 2c14888..26ef420 100644 --- a/ormar/models/model.py +++ b/ormar/models/model.py @@ -1,8 +1,11 @@ from typing import ( Any, + Dict, + List, Set, TYPE_CHECKING, Tuple, + Union, ) import ormar.queryset # noqa I100 @@ -265,3 +268,45 @@ class Model(ModelRow): self.update_from_dict(kwargs) self.set_save_status(True) return self + + async def load_all( + self, 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. + + :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 + print(relations) + 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 index c4ea9d0..a14d418 100644 --- a/ormar/models/model_row.py +++ b/ormar/models/model_row.py @@ -88,6 +88,7 @@ class ModelRow(NewBaseModel): current_relation_str=current_relation_str, source_model=source_model, # type: ignore proxy_source_model=proxy_source_model, # type: ignore + table_prefix=table_prefix, ) item = cls.extract_prefixed_table_columns( item=item, row=row, table_prefix=table_prefix, excludable=excludable @@ -110,6 +111,7 @@ class ModelRow(NewBaseModel): source_model: Type["Model"], related_models: Any, excludable: ExcludableItems, + table_prefix: str, current_relation_str: str = None, proxy_source_model: Type["Model"] = None, ) -> dict: @@ -143,15 +145,20 @@ class ModelRow(NewBaseModel): """ 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 ) - field = cls.Meta.model_fields[related] - field = cast(Type["ForeignKeyField"], field) - model_cls = field.to - remainder = None if isinstance(related_models, dict) and related_models[related]: remainder = related_models[related] diff --git a/ormar/queryset/queryset.py b/ormar/queryset/queryset.py index 7fadcb8..8adc4f3 100644 --- a/ormar/queryset/queryset.py +++ b/ormar/queryset/queryset.py @@ -148,8 +148,8 @@ class QuerySet: ) 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. @@ -169,7 +169,7 @@ class QuerySet: ) 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. diff --git a/ormar/relations/relation.py b/ormar/relations/relation.py index 0bdc671..0f9be3d 100644 --- a/ormar/relations/relation.py +++ b/ormar/relations/relation.py @@ -68,6 +68,14 @@ class Relation: 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] = [] + @property def through(self) -> Type["Model"]: if not self._through: # pragma: no cover diff --git a/ormar/relations/relation_manager.py b/ormar/relations/relation_manager.py index 0719550..19c0dc5 100644 --- a/ormar/relations/relation_manager.py +++ b/ormar/relations/relation_manager.py @@ -26,37 +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 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), - ) - def __contains__(self, item: str) -> bool: """ Checks if relation with given name is already registered. @@ -68,6 +37,10 @@ class RelationsManager: """ return item in self._related_names + 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. @@ -83,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: """ @@ -164,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 596f594..ce4b86f 100644 --- a/ormar/relations/relation_proxy.py +++ b/ormar/relations/relation_proxy.py @@ -75,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. 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_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 6c4211a..3f600e0 100644 --- a/tests/test_m2m_through_fields.py +++ b/tests/test_m2m_through_fields.py @@ -288,6 +288,25 @@ async def test_update_through_models_from_queryset_on_through() -> Any: 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: @@ -371,9 +390,10 @@ async def test_excluding_fields_on_through_model() -> Any: # ordering by in order_by (V) # updating in query (V) # updating from querysetproxy (V) -# including/excluding in fields? +# including/excluding in fields? (V) +# make through optional? auto-generated for cases other fields are missing? (V) # modifying from instance (both sides?) (X) <= no, the loaded one doesn't have relations +# allowing to change fk fields names in through model? (X) <= separate issue -# allowing to change fk fields names in through model? -# make through optional? auto-generated for cases other fields are missing? +# prevent adding relation on through field definition 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_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")