refactor and cleanup - drop of resolving relation names as not fully proper, extract mixins from modelproxy to be more maintainable, add some docstrings

This commit is contained in:
collerek
2020-12-30 16:41:26 +01:00
parent cc23b5a879
commit e695db712f
18 changed files with 488 additions and 406 deletions

View File

@ -50,3 +50,7 @@ def ManyToMany(
class ManyToManyField(ForeignKeyField, ormar.QuerySetProtocol, ormar.RelationProtocol): class ManyToManyField(ForeignKeyField, ormar.QuerySetProtocol, ormar.RelationProtocol):
through: Type["Model"] through: Type["Model"]
@classmethod
def default_target_field_name(cls) -> str:
return cls.to.get_name()

View File

@ -1,12 +1,56 @@
from typing import Dict, List, Optional, TYPE_CHECKING, Type from typing import Dict, List, Optional, TYPE_CHECKING, Tuple, Type
from ormar import ModelDefinitionError import ormar
from ormar.fields.foreign_key import ForeignKeyField from ormar.fields.foreign_key import ForeignKeyField
from ormar.models.helpers.pydantic import populate_pydantic_default_values
if TYPE_CHECKING: # pragma no cover if TYPE_CHECKING: # pragma no cover
from ormar import Model from ormar import Model
def populate_default_options_values(
new_model: Type["Model"], model_fields: Dict
) -> None:
"""
Sets all optional Meta values to it's defaults
and set model_fields that were already previously extracted.
Here should live all options that are not overwritten/set for all models.
Current options are:
* constraints = []
* abstract = False
:param new_model: newly constructed Model
:type new_model: Model class
:param model_fields:
:type model_fields: Union[Dict[str, type], Dict]
"""
if not hasattr(new_model.Meta, "constraints"):
new_model.Meta.constraints = []
if not hasattr(new_model.Meta, "model_fields"):
new_model.Meta.model_fields = model_fields
if not hasattr(new_model.Meta, "abstract"):
new_model.Meta.abstract = False
def extract_annotations_and_default_vals(attrs: Dict) -> Tuple[Dict, Dict]:
"""
Extracts annotations from class namespace dict and triggers
extraction of ormar model_fields.
:param attrs: namespace of the class created
:type attrs: Dict
:return: namespace of the class updated, dict of extracted model_fields
:rtype: Tuple[Dict, Dict]
"""
key = "__annotations__"
attrs[key] = attrs.get(key, {})
attrs, model_fields = populate_pydantic_default_values(attrs)
return attrs, model_fields
# cannot be in relations helpers due to cyclical import
def validate_related_names_in_relations( def validate_related_names_in_relations(
model_fields: Dict, new_model: Type["Model"] model_fields: Dict, new_model: Type["Model"]
) -> None: ) -> None:
@ -28,7 +72,7 @@ def validate_related_names_in_relations(
if issubclass(field, ForeignKeyField): if issubclass(field, ForeignKeyField):
previous_related_names = already_registered.setdefault(field.to, []) previous_related_names = already_registered.setdefault(field.to, [])
if field.related_name in previous_related_names: if field.related_name in previous_related_names:
raise ModelDefinitionError( raise ormar.ModelDefinitionError(
f"Multiple fields declared on {new_model.get_name(lower=False)} " f"Multiple fields declared on {new_model.get_name(lower=False)} "
f"model leading to {field.to.get_name(lower=False)} model without " f"model leading to {field.to.get_name(lower=False)} model without "
f"related_name property set. \nThere can be only one relation with " f"related_name property set. \nThere can be only one relation with "

View File

@ -12,74 +12,6 @@ if TYPE_CHECKING: # pragma no cover
from ormar import Model from ormar import Model
def verify_related_name_dont_duplicate(
child: Type["Model"], parent_model: Type["Model"], related_name: str,
) -> None:
"""
Verifies whether the used related_name (regardless of the fact if user defined or
auto generated) is already used on related model, but is connected with other model
than the one that we connect right now.
:raises: ModelDefinitionError if name is already used but lead to different related
model
:param child: related Model class
:type child: ormar.models.metaclass.ModelMetaclass
:param parent_model: parent Model class
:type parent_model: ormar.models.metaclass.ModelMetaclass
:param related_name:
:type related_name:
:return: None
:rtype: None
"""
if parent_model.Meta.model_fields.get(related_name):
fk_field = parent_model.Meta.model_fields.get(related_name)
if not fk_field: # pragma: no cover
return
if fk_field.to != child and fk_field.to.Meta != child.Meta:
raise ormar.ModelDefinitionError(
f"Relation with related_name "
f"'{related_name}' "
f"leading to model "
f"{parent_model.get_name(lower=False)} "
f"cannot be used on model "
f"{child.get_name(lower=False)} "
f"because it's already used by model "
f"{fk_field.to.get_name(lower=False)}"
)
def reverse_field_not_already_registered(
child: Type["Model"], child_model_name: str, parent_model: Type["Model"]
) -> bool:
"""
Checks if child is already registered in parents pydantic fields.
:raises: ModelDefinitionError if related name is already used but lead to different
related model
:param child: related Model class
:type child: ormar.models.metaclass.ModelMetaclass
:param child_model_name: related_name of the child if provided
:type child_model_name: str
:param parent_model: parent Model class
:type parent_model: ormar.models.metaclass.ModelMetaclass
:return: result of the check
:rtype: bool
"""
check_result = child_model_name not in parent_model.Meta.model_fields
check_result2 = child.get_name() not in parent_model.Meta.model_fields
if not check_result:
verify_related_name_dont_duplicate(
child=child, parent_model=parent_model, related_name=child_model_name
)
if not check_result2:
verify_related_name_dont_duplicate(
child=child, parent_model=parent_model, related_name=child.get_name()
)
return check_result and check_result2
def create_pydantic_field( def create_pydantic_field(
field_name: str, model: Type["Model"], model_field: Type[ManyToManyField] field_name: str, model: Type["Model"], model_field: Type[ManyToManyField]
) -> None: ) -> None:
@ -214,32 +146,6 @@ def get_pydantic_base_orm_config() -> Type[BaseConfig]:
return Config return Config
def populate_default_options_values(
new_model: Type["Model"], model_fields: Dict
) -> None:
"""
Sets all optional Meta values to it's defaults
and set model_fields that were already previously extracted.
Here should live all options that are not overwritten/set for all models.
Current options are:
* constraints = []
* abstract = False
:param new_model: newly constructed Model
:type new_model: Model class
:param model_fields:
:type model_fields: Union[Dict[str, type], Dict]
"""
if not hasattr(new_model.Meta, "constraints"):
new_model.Meta.constraints = []
if not hasattr(new_model.Meta, "model_fields"):
new_model.Meta.model_fields = model_fields
if not hasattr(new_model.Meta, "abstract"):
new_model.Meta.abstract = False
def get_potential_fields(attrs: Dict) -> Dict: def get_potential_fields(attrs: Dict) -> Dict:
""" """
Gets all the fields in current class namespace that are Fields. Gets all the fields in current class namespace that are Fields.
@ -250,19 +156,3 @@ def get_potential_fields(attrs: Dict) -> Dict:
:rtype: Dict :rtype: Dict
""" """
return {k: v for k, v in attrs.items() if lenient_issubclass(v, BaseField)} return {k: v for k, v in attrs.items() if lenient_issubclass(v, BaseField)}
def extract_annotations_and_default_vals(attrs: Dict) -> Tuple[Dict, Dict]:
"""
Extracts annotations from class namespace dict and triggers
extraction of ormar model_fields.
:param attrs: namespace of the class created
:type attrs: Dict
:return: namespace of the class updated, dict of extracted model_fields
:rtype: Tuple[Dict, Dict]
"""
key = "__annotations__"
attrs[key] = attrs.get(key, {})
attrs, model_fields = populate_pydantic_default_values(attrs)
return attrs, model_fields

View File

@ -1,9 +1,9 @@
from typing import TYPE_CHECKING, Type from typing import TYPE_CHECKING, Type
import ormar
from ormar import ForeignKey, ManyToMany from ormar import ForeignKey, ManyToMany
from ormar.fields import ManyToManyField from ormar.fields import ManyToManyField
from ormar.fields.foreign_key import ForeignKeyField from ormar.fields.foreign_key import ForeignKeyField
from ormar.models.helpers.pydantic import reverse_field_not_already_registered
from ormar.models.helpers.sqlalchemy import adjust_through_many_to_many_model from ormar.models.helpers.sqlalchemy import adjust_through_many_to_many_model
from ormar.relations import AliasManager from ormar.relations import AliasManager
@ -31,7 +31,7 @@ def register_relation_on_build(new_model: Type["Model"], field_name: str) -> Non
def register_many_to_many_relation_on_build( def register_many_to_many_relation_on_build(
new_model: Type["Model"], field: Type[ManyToManyField] new_model: Type["Model"], field: Type[ManyToManyField], field_name: str
) -> None: ) -> None:
""" """
Registers connection between through model and both sides of the m2m relation. Registers connection between through model and both sides of the m2m relation.
@ -43,13 +43,22 @@ def register_many_to_many_relation_on_build(
By default relation name is a model.name.lower(). By default relation name is a model.name.lower().
:param field_name: name of the relation key
:type field_name: str
:param new_model: model on which m2m field is declared :param new_model: model on which m2m field is declared
:type new_model: Model class :type new_model: Model class
:param field: relation field :param field: relation field
:type field: ManyToManyField class :type field: ManyToManyField class
""" """
alias_manager.add_relation_type(field.through, new_model.get_name()) alias_manager.add_relation_type(
alias_manager.add_relation_type(field.through, field.to.get_name()) field.through, new_model.get_name(), is_multi=True, reverse_name=field_name
)
alias_manager.add_relation_type(
field.through,
field.to.get_name(),
is_multi=True,
reverse_name=field.related_name or new_model.get_name() + "s",
)
def expand_reverse_relationships(model: Type["Model"]) -> None: def expand_reverse_relationships(model: Type["Model"]) -> None:
@ -133,6 +142,76 @@ def register_relation_in_alias_manager(
:type field_name: str :type field_name: str
""" """
if issubclass(field, ManyToManyField): if issubclass(field, ManyToManyField):
register_many_to_many_relation_on_build(new_model=new_model, field=field) register_many_to_many_relation_on_build(
new_model=new_model, field=field, field_name=field_name
)
elif issubclass(field, ForeignKeyField): elif issubclass(field, ForeignKeyField):
register_relation_on_build(new_model=new_model, field_name=field_name) register_relation_on_build(new_model=new_model, field_name=field_name)
def verify_related_name_dont_duplicate(
child: Type["Model"], parent_model: Type["Model"], related_name: str,
) -> None:
"""
Verifies whether the used related_name (regardless of the fact if user defined or
auto generated) is already used on related model, but is connected with other model
than the one that we connect right now.
:raises: ModelDefinitionError if name is already used but lead to different related
model
:param child: related Model class
:type child: ormar.models.metaclass.ModelMetaclass
:param parent_model: parent Model class
:type parent_model: ormar.models.metaclass.ModelMetaclass
:param related_name:
:type related_name:
:return: None
:rtype: None
"""
if parent_model.Meta.model_fields.get(related_name):
fk_field = parent_model.Meta.model_fields.get(related_name)
if not fk_field: # pragma: no cover
return
if fk_field.to != child and fk_field.to.Meta != child.Meta:
raise ormar.ModelDefinitionError(
f"Relation with related_name "
f"'{related_name}' "
f"leading to model "
f"{parent_model.get_name(lower=False)} "
f"cannot be used on model "
f"{child.get_name(lower=False)} "
f"because it's already used by model "
f"{fk_field.to.get_name(lower=False)}"
)
def reverse_field_not_already_registered(
child: Type["Model"], child_model_name: str, parent_model: Type["Model"]
) -> bool:
"""
Checks if child is already registered in parents pydantic fields.
:raises: ModelDefinitionError if related name is already used but lead to different
related model
:param child: related Model class
:type child: ormar.models.metaclass.ModelMetaclass
:param child_model_name: related_name of the child if provided
:type child_model_name: str
:param parent_model: parent Model class
:type parent_model: ormar.models.metaclass.ModelMetaclass
:return: result of the check
:rtype: bool
"""
check_result = child_model_name not in parent_model.Meta.model_fields
check_result2 = child.get_name() not in parent_model.Meta.model_fields
if not check_result:
verify_related_name_dont_duplicate(
child=child, parent_model=parent_model, related_name=child_model_name
)
if not check_result2:
verify_related_name_dont_duplicate(
child=child, parent_model=parent_model, related_name=child.get_name()
)
return check_result and check_result2

View File

@ -21,12 +21,14 @@ from ormar import ForeignKey, Integer, ModelDefinitionError # noqa I100
from ormar.fields import BaseField from ormar.fields import BaseField
from ormar.fields.foreign_key import ForeignKeyField from ormar.fields.foreign_key import ForeignKeyField
from ormar.fields.many_to_many import ManyToManyField from ormar.fields.many_to_many import ManyToManyField
from ormar.models.helpers.pydantic import ( from ormar.models.helpers.models import (
extract_annotations_and_default_vals, extract_annotations_and_default_vals,
populate_default_options_values,
)
from ormar.models.helpers.pydantic import (
get_potential_fields, get_potential_fields,
get_pydantic_base_orm_config, get_pydantic_base_orm_config,
get_pydantic_field, get_pydantic_field,
populate_default_options_values,
) )
from ormar.models.helpers.relations import ( from ormar.models.helpers.relations import (
alias_manager, alias_manager,
@ -50,6 +52,12 @@ CONFIG_KEY = "Config"
class ModelMeta: class ModelMeta:
"""
Class used for type hinting.
Users can subclass this one for conveniance but it's not required.
The only requirement is that ormar.Model has to have inner class with name Meta.
"""
tablename: str tablename: str
table: sqlalchemy.Table table: sqlalchemy.Table
metadata: sqlalchemy.MetaData metadata: sqlalchemy.MetaData

View File

@ -0,0 +1,5 @@
from ormar.models.mixins.alias_mixin import AliasMixin
from ormar.models.mixins.merge_mixin import MergeModelMixin
from ormar.models.mixins.prefetch_mixin import PrefetchQueryMixin
__all__ = ["MergeModelMixin", "AliasMixin", "PrefetchQueryMixin"]

View File

@ -0,0 +1,83 @@
from typing import Dict, List, Optional, Set, TYPE_CHECKING, Type, Union
class AliasMixin:
if TYPE_CHECKING: # pragma: no cover
from ormar import Model, ModelMeta
Meta: ModelMeta
@classmethod
def get_column_alias(cls, field_name: str) -> str:
field = cls.Meta.model_fields.get(field_name)
return field.get_alias() if field is not None else field_name
@classmethod
def get_column_name_from_alias(cls, alias: str) -> str:
for field_name, field in cls.Meta.model_fields.items():
if field.get_alias() == alias:
return field_name
return alias # if not found it's not an alias but actual name
@classmethod
def translate_columns_to_aliases(cls, new_kwargs: Dict) -> Dict:
for field_name, field in cls.Meta.model_fields.items():
if field_name in new_kwargs:
new_kwargs[field.get_alias()] = new_kwargs.pop(field_name)
return new_kwargs
@classmethod
def translate_aliases_to_columns(cls, new_kwargs: Dict) -> Dict:
for field_name, field in cls.Meta.model_fields.items():
if field.alias and field.alias in new_kwargs:
new_kwargs[field_name] = new_kwargs.pop(field.alias)
return new_kwargs
@staticmethod
def _populate_pk_column(
model: Type["Model"], columns: List[str], use_alias: bool = False,
) -> List[str]:
pk_alias = (
model.get_column_alias(model.Meta.pkname)
if use_alias
else model.Meta.pkname
)
if pk_alias not in columns:
columns.append(pk_alias)
return columns
@classmethod
def own_table_columns(
cls,
model: Type["Model"],
fields: Optional[Union[Set, Dict]],
exclude_fields: Optional[Union[Set, Dict]],
use_alias: bool = False,
) -> List[str]:
columns = [
model.get_column_name_from_alias(col.name) if not use_alias else col.name
for col in model.Meta.table.columns
]
field_names = [
model.get_column_name_from_alias(col.name)
for col in model.Meta.table.columns
]
if fields:
columns = [
col
for col, name in zip(columns, field_names)
if model.is_included(fields, name)
]
if exclude_fields:
columns = [
col
for col, name in zip(columns, field_names)
if not model.is_excluded(exclude_fields, name)
]
# always has to return pk column for ormar to work
columns = cls._populate_pk_column(
model=model, columns=columns, use_alias=use_alias
)
return columns

View File

@ -0,0 +1,46 @@
from collections import OrderedDict
from typing import List, Sequence, TYPE_CHECKING
import ormar
if TYPE_CHECKING: # pragma no cover
from ormar import Model
class MergeModelMixin:
@classmethod
def merge_instances_list(cls, result_rows: Sequence["Model"]) -> Sequence["Model"]:
merged_rows: List["Model"] = []
grouped_instances: OrderedDict = OrderedDict()
for model in result_rows:
grouped_instances.setdefault(model.pk, []).append(model)
for group in grouped_instances.values():
model = group.pop(0)
if group:
for next_model in group:
model = cls.merge_two_instances(next_model, model)
merged_rows.append(model)
return merged_rows
@classmethod
def merge_two_instances(cls, one: "Model", other: "Model") -> "Model":
for field in one.Meta.model_fields.keys():
current_field = getattr(one, field)
if isinstance(current_field, list) and not isinstance(
current_field, ormar.Model
):
setattr(other, field, current_field + getattr(other, field))
elif (
isinstance(current_field, ormar.Model)
and current_field.pk == getattr(other, field).pk
):
setattr(
other,
field,
cls.merge_two_instances(current_field, getattr(other, field)),
)
other.set_save_status(True)
return other

View File

@ -0,0 +1,64 @@
from typing import Callable, Dict, List, TYPE_CHECKING, Tuple, Type
import ormar
from ormar.fields import BaseField
class PrefetchQueryMixin:
if TYPE_CHECKING: # pragma no cover
from ormar import Model
get_name: Callable # defined in NewBaseModel
extract_related_names: Callable # defined in ModelTableProxy
@staticmethod
def get_clause_target_and_filter_column_name(
parent_model: Type["Model"],
target_model: Type["Model"],
reverse: bool,
related: str,
) -> Tuple[Type["Model"], str]:
if reverse:
field_name = (
parent_model.Meta.model_fields[related].related_name
or parent_model.get_name() + "s"
)
field = target_model.Meta.model_fields[field_name]
if issubclass(field, ormar.fields.ManyToManyField):
field_name = field.default_target_field_name()
sub_field = field.through.Meta.model_fields[field_name]
return field.through, sub_field.get_alias()
return target_model, field.get_alias()
target_field = target_model.get_column_alias(target_model.Meta.pkname)
return target_model, target_field
@staticmethod
def get_column_name_for_id_extraction(
parent_model: Type["Model"], reverse: bool, related: str, use_raw: bool,
) -> str:
if reverse:
column_name = parent_model.Meta.pkname
return (
parent_model.get_column_alias(column_name) if use_raw else column_name
)
column = parent_model.Meta.model_fields[related]
return column.get_alias() if use_raw else column.name
@classmethod
def get_related_field_name(cls, target_field: Type["BaseField"]) -> str:
if issubclass(target_field, ormar.fields.ManyToManyField):
return cls.get_name()
if target_field.virtual:
return target_field.related_name or cls.get_name() + "s"
return target_field.to.Meta.pkname
@classmethod
def get_filtered_names_to_extract(cls, prefetch_dict: Dict) -> List:
related_to_extract = []
if prefetch_dict and prefetch_dict is not Ellipsis:
related_to_extract = [
related
for related in cls.extract_related_names()
if related in prefetch_dict
]
return related_to_extract

View File

@ -125,13 +125,11 @@ class Model(NewBaseModel):
) )
): ):
through_field = previous_model.Meta.model_fields[related_name] through_field = previous_model.Meta.model_fields[related_name]
rel_name2 = previous_model.resolve_relation_name( rel_name2 = through_field.default_target_field_name() # type: ignore
through_field.through, through_field.to, explicit_multi=True
)
previous_model = through_field.through # type: ignore previous_model = through_field.through # type: ignore
if previous_model and rel_name2: if previous_model and rel_name2:
table_prefix = cls.Meta.alias_manager.resolve_relation_join( table_prefix = cls.Meta.alias_manager.resolve_relation_alias(
previous_model, rel_name2 previous_model, rel_name2
) )
else: else:

View File

@ -1,5 +1,4 @@
import inspect import inspect
from collections import OrderedDict
from typing import ( from typing import (
AbstractSet, AbstractSet,
Any, Any,
@ -8,25 +7,22 @@ from typing import (
List, List,
Mapping, Mapping,
Optional, Optional,
Sequence,
Set, Set,
TYPE_CHECKING, TYPE_CHECKING,
Tuple,
Type,
TypeVar, TypeVar,
Union, Union,
) )
import ormar # noqa: I100 import ormar # noqa: I100
from ormar.exceptions import ModelPersistenceError from ormar.exceptions import ModelPersistenceError
from ormar.fields import BaseField, ManyToManyField from ormar.fields import BaseField
from ormar.fields.foreign_key import ForeignKeyField from ormar.fields.foreign_key import ForeignKeyField
from ormar.models.metaclass import ModelMeta from ormar.models.metaclass import ModelMeta
from ormar.models.mixins import AliasMixin, MergeModelMixin, PrefetchQueryMixin
from ormar.queryset.utils import translate_list_to_dict, update from ormar.queryset.utils import translate_list_to_dict, update
if TYPE_CHECKING: # pragma no cover if TYPE_CHECKING: # pragma no cover
from ormar import Model from ormar import Model
from ormar.models import NewBaseModel
T = TypeVar("T", bound=Model) T = TypeVar("T", bound=Model)
IntStr = Union[int, str] IntStr = Union[int, str]
@ -36,7 +32,7 @@ if TYPE_CHECKING: # pragma no cover
Field = TypeVar("Field", bound=BaseField) Field = TypeVar("Field", bound=BaseField)
class ModelTableProxy: class ModelTableProxy(PrefetchQueryMixin, MergeModelMixin, AliasMixin):
if TYPE_CHECKING: # pragma no cover if TYPE_CHECKING: # pragma no cover
Meta: ModelMeta Meta: ModelMeta
_related_names: Optional[Set] _related_names: Optional[Set]
@ -46,76 +42,6 @@ class ModelTableProxy:
_props: Set _props: Set
dict: Callable # noqa: A001, VNE003 dict: Callable # noqa: A001, VNE003
def _extract_own_model_fields(self) -> Dict:
related_names = self.extract_related_names()
self_fields = self.dict(exclude=related_names)
return self_fields
@classmethod
def get_related_field_name(cls, target_field: Type["BaseField"]) -> str:
if issubclass(target_field, ormar.fields.ManyToManyField):
return cls.resolve_relation_name(
target_field.through, cls, explicit_multi=True
)
if target_field.virtual:
return target_field.related_name or cls.get_name() + "s"
return target_field.to.Meta.pkname
@staticmethod
def get_clause_target_and_filter_column_name(
parent_model: Type["Model"],
target_model: Type["Model"],
reverse: bool,
related: str,
) -> Tuple[Type["Model"], str]:
if reverse:
field_name = (
parent_model.Meta.model_fields[related].related_name
or parent_model.get_name() + "s"
)
field = target_model.Meta.model_fields[field_name]
if issubclass(field, ormar.fields.ManyToManyField):
field_name = parent_model.resolve_relation_name(
field.through, field.to, explicit_multi=True
)
sub_field = field.through.Meta.model_fields[field_name]
return field.through, sub_field.get_alias()
return target_model, field.get_alias()
target_field = target_model.get_column_alias(target_model.Meta.pkname)
return target_model, target_field
@staticmethod
def get_column_name_for_id_extraction(
parent_model: Type["Model"], reverse: bool, related: str, use_raw: bool,
) -> str:
if reverse:
column_name = parent_model.Meta.pkname
return (
parent_model.get_column_alias(column_name) if use_raw else column_name
)
column = parent_model.Meta.model_fields[related]
return column.get_alias() if use_raw else column.name
@classmethod
def get_filtered_names_to_extract(cls, prefetch_dict: Dict) -> List:
related_to_extract = []
if prefetch_dict and prefetch_dict is not Ellipsis:
related_to_extract = [
related
for related in cls.extract_related_names()
if related in prefetch_dict
]
return related_to_extract
def get_relation_model_id(self, target_field: Type["BaseField"]) -> Optional[int]:
if target_field.virtual or issubclass(
target_field, ormar.fields.ManyToManyField
):
return self.pk
related_name = target_field.name
related_model = getattr(self, related_name)
return None if not related_model else related_model.pk
@classmethod @classmethod
def extract_db_own_fields(cls) -> Set: def extract_db_own_fields(cls) -> Set:
related_names = cls.extract_related_names() related_names = cls.extract_related_names()
@ -124,36 +50,6 @@ class ModelTableProxy:
} }
return self_fields return self_fields
@classmethod
def get_names_to_exclude(
cls,
fields: Optional[Union[Dict, Set]] = None,
exclude_fields: Optional[Union[Dict, Set]] = None,
) -> Set:
fields_names = cls.extract_db_own_fields()
if fields and fields is not Ellipsis:
fields_to_keep = {name for name in fields if name in fields_names}
else:
fields_to_keep = fields_names
fields_to_exclude = fields_names - fields_to_keep
if isinstance(exclude_fields, Set):
fields_to_exclude = fields_to_exclude.union(
{name for name in exclude_fields if name in fields_names}
)
elif isinstance(exclude_fields, Dict):
new_to_exclude = {
name
for name in exclude_fields
if name in fields_names and exclude_fields[name] is Ellipsis
}
fields_to_exclude = fields_to_exclude.union(new_to_exclude)
fields_to_exclude = fields_to_exclude - {cls.Meta.pkname}
return fields_to_exclude
@classmethod @classmethod
def substitute_models_with_pks(cls, model_dict: Dict) -> Dict: # noqa CCR001 def substitute_models_with_pks(cls, model_dict: Dict) -> Dict: # noqa CCR001
for field in cls.extract_related_names(): for field in cls.extract_related_names():
@ -194,20 +90,6 @@ class ModelTableProxy:
new_kwargs.pop(field_name, None) new_kwargs.pop(field_name, None)
return new_kwargs return new_kwargs
@classmethod
def get_column_alias(cls, field_name: str) -> str:
field = cls.Meta.model_fields.get(field_name)
if field is not None and field.alias is not None:
return field.alias
return field_name
@classmethod
def get_column_name_from_alias(cls, alias: str) -> str:
for field_name, field in cls.Meta.model_fields.items():
if field is not None and field.alias == alias:
return field_name
return alias # if not found it's not an alias but actual name
@classmethod @classmethod
def extract_related_fields(cls) -> List: def extract_related_fields(cls) -> List:
@ -270,150 +152,32 @@ class ModelTableProxy:
exclude = update(related_dict, exclude) exclude = update(related_dict, exclude)
return exclude return exclude
def _extract_model_db_fields(self) -> Dict:
self_fields = self._extract_own_model_fields()
self_fields = {
k: v
for k, v in self_fields.items()
if self.get_column_alias(k) in self.Meta.table.columns
}
for field in self._extract_db_related_names():
target_pk_name = self.Meta.model_fields[field].to.Meta.pkname
target_field = getattr(self, field)
self_fields[field] = getattr(target_field, target_pk_name, None)
return self_fields
@staticmethod
def resolve_relation_name( # noqa CCR001
item: Union[
"NewBaseModel",
Type["NewBaseModel"],
"ModelTableProxy",
Type["ModelTableProxy"],
],
related: Union[
"NewBaseModel",
Type["NewBaseModel"],
"ModelTableProxy",
Type["ModelTableProxy"],
],
explicit_multi: bool = False,
) -> str:
for name, field in item.Meta.model_fields.items():
# fastapi is creating clones of response model
# that's why it can be a subclass of the original model
# so we need to compare Meta too as this one is copied as is
if issubclass(field, ManyToManyField):
attrib = "to" if not explicit_multi else "through"
if (
getattr(field, attrib) == related.__class__
or getattr(field, attrib).Meta == related.Meta
):
return name
elif issubclass(field, ForeignKeyField):
if field.to == related.__class__ or field.to.Meta == related.Meta:
return name
raise ValueError(
f"No relation between {item.get_name()} and {related.get_name()}"
) # pragma nocover
@classmethod @classmethod
def translate_columns_to_aliases(cls, new_kwargs: Dict) -> Dict: def get_names_to_exclude(
for field_name, field in cls.Meta.model_fields.items(): cls,
if field_name in new_kwargs: fields: Optional[Union[Dict, Set]] = None,
new_kwargs[field.get_alias()] = new_kwargs.pop(field_name) exclude_fields: Optional[Union[Dict, Set]] = None,
return new_kwargs ) -> Set:
fields_names = cls.extract_db_own_fields()
if fields and fields is not Ellipsis:
fields_to_keep = {name for name in fields if name in fields_names}
else:
fields_to_keep = fields_names
@classmethod fields_to_exclude = fields_names - fields_to_keep
def translate_aliases_to_columns(cls, new_kwargs: Dict) -> Dict:
for field_name, field in cls.Meta.model_fields.items():
if field.alias and field.alias in new_kwargs:
new_kwargs[field_name] = new_kwargs.pop(field.alias)
return new_kwargs
@classmethod if isinstance(exclude_fields, Set):
def merge_instances_list(cls, result_rows: Sequence["Model"]) -> Sequence["Model"]: fields_to_exclude = fields_to_exclude.union(
merged_rows: List["Model"] = [] {name for name in exclude_fields if name in fields_names}
grouped_instances: OrderedDict = OrderedDict() )
elif isinstance(exclude_fields, Dict):
new_to_exclude = {
name
for name in exclude_fields
if name in fields_names and exclude_fields[name] is Ellipsis
}
fields_to_exclude = fields_to_exclude.union(new_to_exclude)
for model in result_rows: fields_to_exclude = fields_to_exclude - {cls.Meta.pkname}
grouped_instances.setdefault(model.pk, []).append(model)
for group in grouped_instances.values(): return fields_to_exclude
model = group.pop(0)
if group:
for next_model in group:
model = cls.merge_two_instances(next_model, model)
merged_rows.append(model)
return merged_rows
@classmethod
def merge_two_instances(cls, one: "Model", other: "Model") -> "Model":
for field in one.Meta.model_fields.keys():
current_field = getattr(one, field)
if isinstance(current_field, list) and not isinstance(
current_field, ormar.Model
):
setattr(other, field, current_field + getattr(other, field))
elif (
isinstance(current_field, ormar.Model)
and current_field.pk == getattr(other, field).pk
):
setattr(
other,
field,
cls.merge_two_instances(current_field, getattr(other, field)),
)
other.set_save_status(True)
return other
@staticmethod
def _populate_pk_column(
model: Type["Model"], columns: List[str], use_alias: bool = False,
) -> List[str]:
pk_alias = (
model.get_column_alias(model.Meta.pkname)
if use_alias
else model.Meta.pkname
)
if pk_alias not in columns:
columns.append(pk_alias)
return columns
@staticmethod
def own_table_columns(
model: Type["Model"],
fields: Optional[Union[Set, Dict]],
exclude_fields: Optional[Union[Set, Dict]],
use_alias: bool = False,
) -> List[str]:
columns = [
model.get_column_name_from_alias(col.name) if not use_alias else col.name
for col in model.Meta.table.columns
]
field_names = [
model.get_column_name_from_alias(col.name)
for col in model.Meta.table.columns
]
if fields:
columns = [
col
for col, name in zip(columns, field_names)
if model.is_included(fields, name)
]
if exclude_fields:
columns = [
col
for col, name in zip(columns, field_names)
if not model.is_excluded(exclude_fields, name)
]
# always has to return pk column
columns = ModelTableProxy._populate_pk_column(
model=model, columns=columns, use_alias=use_alias
)
return columns

View File

@ -379,3 +379,30 @@ class NewBaseModel(
column_name in self.Meta.model_fields column_name in self.Meta.model_fields
and self.Meta.model_fields[column_name].__type__ == pydantic.Json and self.Meta.model_fields[column_name].__type__ == pydantic.Json
) )
def _extract_own_model_fields(self) -> Dict:
related_names = self.extract_related_names()
self_fields = self.dict(exclude=related_names)
return self_fields
def _extract_model_db_fields(self) -> Dict:
self_fields = self._extract_own_model_fields()
self_fields = {
k: v
for k, v in self_fields.items()
if self.get_column_alias(k) in self.Meta.table.columns
}
for field in self._extract_db_related_names():
target_pk_name = self.Meta.model_fields[field].to.Meta.pkname
target_field = getattr(self, field)
self_fields[field] = getattr(target_field, target_pk_name, None)
return self_fields
def get_relation_model_id(self, target_field: Type["BaseField"]) -> Optional[int]:
if target_field.virtual or issubclass(
target_field, ormar.fields.ManyToManyField
):
return self.pk
related_name = target_field.name
related_model = getattr(self, related_name)
return None if not related_model else related_model.pk

View File

@ -137,11 +137,9 @@ class QueryClause:
if issubclass(model_cls.Meta.model_fields[part], ManyToManyField): if issubclass(model_cls.Meta.model_fields[part], ManyToManyField):
through_field = model_cls.Meta.model_fields[part] through_field = model_cls.Meta.model_fields[part]
previous_model = through_field.through previous_model = through_field.through
part2 = model_cls.resolve_relation_name( part2 = through_field.default_target_field_name() # type: ignore
previous_model, through_field.to, explicit_multi=True
)
manager = model_cls.Meta.alias_manager manager = model_cls.Meta.alias_manager
table_prefix = manager.resolve_relation_join(previous_model, part2) table_prefix = manager.resolve_relation_alias(previous_model, part2)
model_cls = model_cls.Meta.model_fields[part].to model_cls = model_cls.Meta.model_fields[part].to
previous_model = model_cls previous_model = model_cls
return select_related, table_prefix, model_cls return select_related, table_prefix, model_cls

View File

@ -135,7 +135,7 @@ class SqlJoin:
model_cls = join_params.model_cls.Meta.model_fields[part].to model_cls = join_params.model_cls.Meta.model_fields[part].to
to_table = model_cls.Meta.table.name to_table = model_cls.Meta.table.name
alias = model_cls.Meta.alias_manager.resolve_relation_join( alias = model_cls.Meta.alias_manager.resolve_relation_alias(
join_params.prev_model, part join_params.prev_model, part
) )
if alias not in self.used_aliases: if alias not in self.used_aliases:

View File

@ -328,7 +328,7 @@ class PrefetchQuery:
if issubclass(target_field, ManyToManyField): if issubclass(target_field, ManyToManyField):
query_target = target_field.through query_target = target_field.through
select_related = [target_name] select_related = [target_name]
table_prefix = target_field.to.Meta.alias_manager.resolve_relation_join( table_prefix = target_field.to.Meta.alias_manager.resolve_relation_alias(
query_target, target_name query_target, target_name
) )
self.already_extracted.setdefault(target_name, {})["prefix"] = table_prefix self.already_extracted.setdefault(target_name, {})["prefix"] = table_prefix

View File

@ -11,11 +11,25 @@ if TYPE_CHECKING: # pragma: no cover
def get_table_alias() -> str: def get_table_alias() -> str:
"""
Creates a random string that is used to alias tables in joins.
It's necessary that each relation has it's own aliases cause you can link
to the same target tables from multiple fields on one model as well as from
multiple different models in one join.
:return: randomly generated alias
:rtype: str
"""
alias = "".join(choices(string.ascii_uppercase, k=2)) + uuid.uuid4().hex[:4] alias = "".join(choices(string.ascii_uppercase, k=2)) + uuid.uuid4().hex[:4]
return alias.lower() return alias.lower()
class AliasManager: class AliasManager:
"""
Keep all aliases of relations between different tables.
One global instance is shared between all models.
"""
def __init__(self) -> None: def __init__(self) -> None:
self._aliases: Dict[str, str] = dict() self._aliases: Dict[str, str] = dict()
self._aliases_new: Dict[str, str] = dict() self._aliases_new: Dict[str, str] = dict()
@ -24,6 +38,22 @@ class AliasManager:
def prefixed_columns( def prefixed_columns(
alias: str, table: sqlalchemy.Table, fields: List = None alias: str, table: sqlalchemy.Table, fields: List = None
) -> List[text]: ) -> List[text]:
"""
Creates a list of aliases sqlalchemy text clauses from
string alias and sqlalchemy.Table.
Optional list of fields to include can be passed to extract only those columns.
List has to have sqlalchemy names of columns (ormar aliases) not the ormar ones.
:param alias: alias of given table
:type alias: str
:param table: table from which fields should be aliased
:type table: sqlalchemy.Table
:param fields: fields to include
:type fields: Optional[List[str]]
:return: list of sqlalchemy text clauses with "column name as aliased name"
:rtype: List[text]
"""
alias = f"{alias}_" if alias else "" alias = f"{alias}_" if alias else ""
all_columns = ( all_columns = (
table.columns table.columns
@ -37,11 +67,49 @@ class AliasManager:
@staticmethod @staticmethod
def prefixed_table_name(alias: str, name: str) -> text: def prefixed_table_name(alias: str, name: str) -> text:
"""
Creates text clause with table name with aliased name.
:param alias: alias of given table
:type alias: str
:param name: table name
:type name: str
:return: sqlalchemy text clause as "table_name aliased_name"
:rtype: sqlalchemy text clause
"""
return text(f"{name} {alias}_{name}") return text(f"{name} {alias}_{name}")
def add_relation_type( def add_relation_type(
self, source_model: Type["Model"], relation_name: str self,
source_model: Type["Model"],
relation_name: str,
reverse_name: str = None,
is_multi: bool = False,
) -> None: ) -> None:
"""
Registers the relations defined in ormar models.
Given the relation it registers also the reverse side of this relation.
Used by both ForeignKey and ManyToMany relations.
Each relation is registered as Model name and relation name.
Each alias registered has to be unique.
Aliases are used to construct joins to assure proper links between tables.
That way you can link to the same target tables from multiple fields
on one model as well as from multiple different models in one join.
:param source_model: model with relation defined
:type source_model: source Model
:param relation_name: name of the relation to define
:type relation_name: str
:param reverse_name: name of related_name fo given relation for m2m relations
:type reverse_name: Optional[str]
:param is_multi: flag if relation being registered is a through m2m model
:type is_multi: bool
:return: none
:rtype: None
"""
parent_key = f"{source_model.get_name()}_{relation_name}" parent_key = f"{source_model.get_name()}_{relation_name}"
if parent_key not in self._aliases_new: if parent_key not in self._aliases_new:
self._aliases_new[parent_key] = get_table_alias() self._aliases_new[parent_key] = get_table_alias()
@ -49,15 +117,24 @@ class AliasManager:
child_model = to_field.to child_model = to_field.to
related_name = to_field.related_name related_name = to_field.related_name
if not related_name: if not related_name:
related_name = child_model.resolve_relation_name( related_name = reverse_name if is_multi else source_model.get_name() + "s"
child_model, source_model, explicit_multi=True
)
child_key = f"{child_model.get_name()}_{related_name}" child_key = f"{child_model.get_name()}_{related_name}"
if child_key not in self._aliases_new: if child_key not in self._aliases_new:
self._aliases_new[child_key] = get_table_alias() self._aliases_new[child_key] = get_table_alias()
def resolve_relation_join( def resolve_relation_alias(
self, from_model: Type["Model"], relation_name: str self, from_model: Type["Model"], relation_name: str
) -> str: ) -> str:
"""
Given model and relation name returns the alias for this relation.
:param from_model: model with relation defined
:type from_model: source Model
:param relation_name: name of the relation field
:type relation_name: str
:return: alias of the relation
:rtype: str
"""
alias = self._aliases_new.get(f"{from_model.get_name()}_{relation_name}", "") alias = self._aliases_new.get(f"{from_model.get_name()}_{relation_name}", "")
return alias return alias

View File

@ -67,7 +67,7 @@ class RelationsManager:
to_field: Type[BaseField] = child.Meta.model_fields[relation_name] to_field: Type[BaseField] = child.Meta.model_fields[relation_name]
# print('comming', child_name, relation_name) # print('comming', child_name, relation_name)
(parent, child, child_name, to_name,) = get_relations_sides_and_names( (parent, child, child_name, to_name,) = get_relations_sides_and_names(
to_field, parent, child, child_name, virtual to_field, parent, child, child_name, virtual, relation_name
) )
# print('adding', parent.get_name(), child.get_name(), child_name) # print('adding', parent.get_name(), child.get_name(), child_name)

View File

@ -14,16 +14,11 @@ def get_relations_sides_and_names(
child: "Model", child: "Model",
child_name: str, child_name: str,
virtual: bool, virtual: bool,
relation_name: str,
) -> Tuple["Model", "Model", str, str]: ) -> Tuple["Model", "Model", str, str]:
to_name = to_field.name to_name = to_field.name
if issubclass(to_field, ManyToManyField): if issubclass(to_field, ManyToManyField):
child_name, to_name = ( child_name = to_field.related_name or child.get_name() + "s"
to_field.related_name
or child.resolve_relation_name(
parent, to_field.through, explicit_multi=True
),
to_name,
)
child = proxy(child) child = proxy(child)
elif virtual: elif virtual:
child_name, to_name = to_name, child_name or child.get_name() child_name, to_name = to_name, child_name or child.get_name()