diff --git a/django_neomodel/__init__.py b/django_neomodel/__init__.py index 0c7ca57..274abdb 100644 --- a/django_neomodel/__init__.py +++ b/django_neomodel/__init__.py @@ -8,9 +8,16 @@ from django.conf import settings from django.forms import fields as form_fields from django.db.models.options import Options -from django.core.exceptions import ValidationError - -from neomodel import RequiredProperty, DeflateError, StructuredNode, UniqueIdProperty, AliasProperty, UniqueProperty +from django.core.exceptions import ValidationError + +from neomodel import ( + RequiredProperty, + DeflateError, + StructuredNode, + UniqueIdProperty, + AliasProperty, + UniqueProperty, +) from neomodel.sync_.core import NodeMeta from neomodel.sync_.match import NodeSet from neomodel.sync_.cardinality import OneOrMore, One, ZeroOrOne, ZeroOrMore @@ -21,7 +28,7 @@ from django.db.models.fields.related import lazy_related_operation # Need to following to get the relationships to work -RECURSIVE_RELATIONSHIP_CONSTANT = 'self' +RECURSIVE_RELATIONSHIP_CONSTANT = "self" __author__ = "Robin Edwards" __email__ = "robin.ge@gmail.com" @@ -49,20 +56,22 @@ class NOT_PROVIDED: class DjangoFormFieldMultipleChoice(form_fields.MultipleChoiceField): - """ Sublcass of Djangos MultipleChoiceField but without working validator """ + """Sublcass of Djangos MultipleChoiceField but without working validator""" + def validate(self, value): - return True + return True class DjangoFormFieldTypedChoice(form_fields.TypedChoiceField): - """ Sublcass of Djangos TypedChoiceField but without working validator """ + """Sublcass of Djangos TypedChoiceField but without working validator""" + def validate(self, value): return True @total_ordering class DjangoBaseField(object): - """ Base field where Properties and Relations Field should subclass from """ + """Base field where Properties and Relations Field should subclass from""" is_relation = False concrete = True @@ -78,7 +87,7 @@ class DjangoBaseField(object): one_to_one = None many_to_many = None many_to_one = None - + creation_counter = 0 def __init__(self): @@ -109,15 +118,15 @@ def __hash__(self): def clone(self): return self - + class DjangoEmptyField(DjangoBaseField): - """ Empty field """ + """Empty field""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.remote_field = None - + class DjangoPropertyField(DjangoBaseField): """ @@ -126,7 +135,7 @@ class DjangoPropertyField(DjangoBaseField): is_relation = False concrete = True - editable = True + editable = True unique = False primary_key = False auto_created = False @@ -148,15 +157,15 @@ def __init__(self, prop, name): self.primary_key = getattr(prop, "primary_key", False) self.label = prop.label if prop.label else name - form_cls = getattr(prop, 'form_field_class', 'Field') # get field string + form_cls = getattr(prop, "form_field_class", "Field") # get field string self.form_clsx = form_cls # Use for class faking in __class__ self.form_class = getattr(form_fields, form_cls, form_fields.CharField) self._has_default = prop.has_default self.required = prop.required self.blank = not self.required - self.choices = getattr(prop, 'choices', None) - + self.choices = getattr(prop, "choices", None) + super().__init__() def save_form_data(self, instance, data): @@ -177,16 +186,17 @@ def formfield(self, **kwargs): } if self.has_default(): - defaults['initial'] = self.prop.default_value() + defaults["initial"] = self.prop.default_value() if self.choices: # Fields with choices get special treatment. # So following this: https://github.com/django/django/blob/35c2474f168fd10ac50886024d5879de81be5bd3/django/db/models/fields/__init__.py#L1005 - include_blank = (not self.required and - not (self.has_default() or 'initial' in kwargs)) - - defaults['choices'] = self.get_choices(include_blank=include_blank) - defaults['coerce'] = self.to_python + include_blank = not self.required and not ( + self.has_default() or "initial" in kwargs + ) + + defaults["choices"] = self.get_choices(include_blank=include_blank) + defaults["coerce"] = self.to_python # Many of the subclass-specific formfield arguments (min_value, # max_value) don't apply for choice fields, so be sure to only pass @@ -210,28 +220,26 @@ def formfield(self, **kwargs): # This needs to be fixed but at https://github.com/django/django/blob/dc9deea8e85641695e489e43ed5d5638134c15c7/django/contrib/admin/options.py#L80 # the Admin injects a form_class. For now just remove this - defaults.pop('form_class', None) + defaults.pop("form_class", None) return self.form_class(**defaults) def get_choices(self, include_blank=True): - blank_defined = False blank_choice = BLANK_CHOICE_DASH choices = list(self.choices) if self.choices else [] if issubclass(type(self.choices), dict): # Ensure list of tuples with proper key-value pairing when passing dict - choices = [(k, v) for k, v in self.choices.items()] - + choices = [(k, v) for k, v in self.choices.items()] + for choice, __ in choices: if choice in ("", None): blank_defined = True break - + # For now overwrite include_blank, so neomodel will not error on '' not being in self.choices - include_blank = False - first_choice = (blank_choice if include_blank and - not blank_defined else []) + include_blank = False + first_choice = blank_choice if include_blank and not blank_defined else [] return first_choice + choices def clone(self): @@ -239,12 +247,12 @@ def clone(self): @property def __class__(self): - # Fake the class for + # Fake the class for # https://github.com/django/django/blob/dc9deea8e85641695e489e43ed5d5638134c15c7/django/contrib/admin/options.py#L144 # so we can get the admin Field specific widgets to work, ie the Date widget # the SplitDateTimewidget (which is invoked by the admin when a DateTimeField is passed) doesn't work yet. - - if self.form_clsx == 'DateField': + + if self.form_clsx == "DateField": return DateField # elif self.form_clsx == 'DateTimeField': # return DateTimeField @@ -253,25 +261,26 @@ def __class__(self): class DjangoRemoteField(object): - """ Fake RemoteField to let the Django Admin work """ + """Fake RemoteField to let the Django Admin work""" def __init__(self, name): # Fake this call https://github.com/django/django/blob/ac5cc6cf01463d90aa333d5f6f046c311019827b/django/contrib/admin/widgets.py#L278 - self.related_name = name + self.related_name = name self.related_query_name = name self.model = name self.through = SimpleNamespace(_meta=SimpleNamespace(auto_created=True)) - + def get_related_field(self): # Fake call https://github.com/django/django/blob/ac5cc6cf01463d90aa333d5f6f046c311019827b/django/contrib/admin/widgets.py#L282 # from the Django Admin return SimpleNamespace(name=self.model.pk.target) - + class DjangoRelationField(DjangoBaseField): """ Fake Django model field object which wraps a neomodel Relationship """ + one_to_many = False one_to_one = False many_to_one = False @@ -280,7 +289,7 @@ class DjangoRelationField(DjangoBaseField): @property def __class__(self): - # Fake the class for + # Fake the class for # https://github.com/django/django/blob/ac5cc6cf01463d90aa333d5f6f046c311019827b/django/contrib/admin/options.py#L144 # so we can get the admin ManyToMany field widgets to work return ManyToManyField @@ -297,8 +306,8 @@ def __init__(self, prop, name): if prop.manager is OneOrMore or prop.manager is One: self.required = True - self.blank = False - + self.blank = False + # See https://docs.djangoproject.com/en/2.0/_modules/django/db/models/fields/ # Need a way to signal that there is no default self._has_default = NOT_PROVIDED @@ -306,33 +315,34 @@ def __init__(self, prop, name): self.name = name self.attname = name self.verbose_name = name - self.help_text = getattr(prop, 'help_text', '') - - if prop.manager is ZeroOrOne: + self.help_text = getattr(prop, "help_text", "") + + if prop.manager is ZeroOrOne: # This form_class has its validator set to True self.form_class = DjangoFormFieldTypedChoice else: # This form_class has its validator set to True - self.form_class = DjangoFormFieldMultipleChoice - + self.form_class = DjangoFormFieldMultipleChoice + # Need to load the related model in so we can fetch - # all nodes. + # all nodes. self.remote_field = DjangoRemoteField(self.prop._raw_class) - + super().__init__() def set_attributes_from_rel(self): - """ From https://github.com/django/django/blob/1be99e4e0a590d9a008da49e8e3b118b57e14075/django/db/models/fields/related.py#L393 """ - self.name = ( - self.name or - (self.remote_field.model._meta.model_name + '_' + self.remote_field.model._meta.pk.name) + """From https://github.com/django/django/blob/1be99e4e0a590d9a008da49e8e3b118b57e14075/django/db/models/fields/related.py#L393""" + self.name = self.name or ( + self.remote_field.model._meta.model_name + + "_" + + self.remote_field.model._meta.pk.name ) if self.verbose_name is None: self.verbose_name = self.remote_field.model._meta.verbose_name # self.remote_field.set_field_name() def do_related_class(self, other, cls): - """ from https://github.com/django/django/blob/1be99e4e0a590d9a008da49e8e3b118b57e14075/django/db/models/fields/related.py#L402 """ + """from https://github.com/django/django/blob/1be99e4e0a590d9a008da49e8e3b118b57e14075/django/db/models/fields/related.py#L402""" self.set_attributes_from_rel() # self.contribute_to_related_class(other, self.remote_field) @@ -342,7 +352,7 @@ def value_from_object(self, instance): for this_object in instance_relation.all(): node_ids_selected.append(this_object.pk) return node_ids_selected - + def save_form_data(self, instance, data): # instance is the current node which needs to get connected # data is a list of ids/uids of the nodes-to-connect-to @@ -350,60 +360,62 @@ def save_form_data(self, instance, data): instance_relation = getattr(instance, self.name) # Need to define which nodes to disconnect from first! - related_model = current_apps.get_model(self.prop.module_name.split('.')[-2], - self.prop._raw_class) + related_model = current_apps.get_model( + self.prop.module_name.split(".")[-2], self.prop._raw_class + ) all_possible_nodes = related_model.nodes.all() - + # Gather the pks from these nodes list_of_ids = [] for this_node in all_possible_nodes: list_of_ids.append(this_node.pk) # So which nodes are not selected? should_not_be_connected = set(list_of_ids) - set(data) - + # Need to save the instance before relations can be made try: instance.save() except UniqueProperty as e: - raise ValidationError(e) + raise ValidationError(e) # Cardinality needs to be observed, so use following order: # if One: replace # if OneOreMore: first connect, then disconnect # if ZeroOrMore: doesn't matter # if ZeroOrOne: First disconnect, then connect - + if self.prop.manager is ZeroOrMore or self.prop.manager is ZeroOrOne: - # Instead of checking the relationship exists, just disconnect + # Instead of checking the relationship exists, just disconnect # In the future when specific relationships are implemented, this # should be updated self._disconnect_node(should_not_be_connected, instance_relation) # Now time to setup new connections - if data: # In case we selected an empty unit, don't do anything + if data: # In case we selected an empty unit, don't do anything self._connect_node(data, instance_relation) - + elif self.prop.manager is OneOrMore: # First setup new connections self._connect_node(data, instance_relation) try: instance.save() except UniqueProperty as e: - raise ValidationError(e) - - # Instead of checking the relationship exists, just disconnect + raise ValidationError(e) + + # Instead of checking the relationship exists, just disconnect self._disconnect_node(should_not_be_connected, instance_relation) else: # This would require replacing the current relation with a new one - raise NotImplementedError('Cardinality of One is not supported yet') + raise NotImplementedError("Cardinality of One is not supported yet") def _disconnect_node(self, should_not_be_connected, instance_relation): - """ Given a list pk's, remove the relationship """ + """Given a list pk's, remove the relationship""" - related_model = current_apps.get_model(self.prop.module_name.split('.')[-2], - self.prop._raw_class) + related_model = current_apps.get_model( + self.prop.module_name.split(".")[-2], self.prop._raw_class + ) - # Internals used by save_form_data to + # Internals used by save_form_data to # TODO: first get list of connected nodes, so don't run lots of disconnects for this_object in should_not_be_connected: remover = related_model.nodes.get_or_none(pk=this_object) @@ -411,50 +423,53 @@ def _disconnect_node(self, should_not_be_connected, instance_relation): instance_relation.disconnect(remover) def _connect_node(self, data, instance_relation): - """ Given a list pk's, add the relationship """ + """Given a list pk's, add the relationship""" - related_model = current_apps.get_model(self.prop.module_name.split('.')[-2], - self.prop._raw_class) + related_model = current_apps.get_model( + self.prop.module_name.split(".")[-2], self.prop._raw_class + ) # If ChoiceField, it is not a list - data = [data] if not isinstance(data, list) else data - + data = [data] if not isinstance(data, list) else data + for this_object in data: # Retreive the node-to-connect-to adder = related_model.nodes.get_or_none(pk=this_object) # If the connection is there, leave it if not adder: - raise ValidationError({self.name: ' not found'}) + raise ValidationError({self.name: " not found"}) if not instance_relation.is_connected(adder): instance_relation.connect(adder) - + def formfield(self, *args, **kwargs): """Return a django.forms.Field instance for this field.""" - + node_options = [] # Fetch the related_module from the apps registry (instead of circular imports) - related_model = current_apps.get_model(self.prop.module_name.split('.')[-2], - self.prop._raw_class) + related_model = current_apps.get_model( + self.prop.module_name.split(".")[-2], self.prop._raw_class + ) if self.prop.manager is ZeroOrOne: node_options = BLANK_CHOICE_DASH.copy() - + for this_object in related_model.nodes.all(): node_options.append((this_object.pk, this_object.__str__)) - defaults = {'required': self.required, - 'label': self.verbose_name, - 'help_text': self.help_text, - **kwargs, - } + defaults = { + "required": self.required, + "label": self.verbose_name, + "help_text": self.help_text, + **kwargs, + } - defaults['choices'] = node_options + defaults["choices"] = node_options return self.form_class(**defaults) def clone(self): - """ + """ Upon cloning a relationship, provide an empty field wrapper, so circular imports are prevented by the Django app registry """ @@ -462,8 +477,8 @@ def clone(self): return DjangoEmptyField() def contribute_to_class(self, cls, name, private_only=False, **kwargs): - """ Modified from https://github.com/django/django/blob/2a66c102d9c674fadab252a28d8def32a8b626ec/django/db/models/fields/related.py#L305 """ - #super().contribute_to_class(cls, name, private_only=private_only, **kwargs) + """Modified from https://github.com/django/django/blob/2a66c102d9c674fadab252a28d8def32a8b626ec/django/db/models/fields/related.py#L305""" + # super().contribute_to_class(cls, name, private_only=private_only, **kwargs) self.opts = cls._meta if not cls._meta.abstract: @@ -473,23 +488,26 @@ def contribute_to_class(self, cls, name, private_only=False, **kwargs): related_name = self.opts.default_related_name if related_name: related_name = related_name % { - 'class': cls.__name__.lower(), - 'model_name': cls._meta.model_name.lower(), - 'app_label': cls._meta.app_label.lower() + "class": cls.__name__.lower(), + "model_name": cls._meta.model_name.lower(), + "app_label": cls._meta.app_label.lower(), } self.remote_field.related_name = related_name if self.remote_field.related_query_name: related_query_name = self.remote_field.related_query_name % { - 'class': cls.__name__.lower(), - 'app_label': cls._meta.app_label.lower(), + "class": cls.__name__.lower(), + "app_label": cls._meta.app_label.lower(), } self.remote_field.related_query_name = related_query_name def resolve_related_class(model, related, field): field.remote_field.model = related field.do_related_class(related, model) - lazy_related_operation(resolve_related_class, cls, self.remote_field.model, field=self) + + lazy_related_operation( + resolve_related_class, cls, self.remote_field.model, field=self + ) class Query: @@ -503,27 +521,59 @@ class NeoNodeSet(NodeSet): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.model = self.source - + self._prefetch_related_lookups = [] + self._result_cache = None + def count(self): return len(self) def _clone(self): return self - def iterator(self): - """ Needed to run pytest after adding app/model register """ + def iterator(self, chunk_size=2000): + """Needed to run pytest after adding app/model register""" + # Basic iterator implementation to fetch nodes + # This is a placeholder to demonstrate functionality + # return self.source_class.nodes.all() return [] + # The code below was added to stop the tests from failing for Python 3.10+ + # It would be interesting to actually implement prefetching + def prefetch_related(self, *lookups): + self._prefetch_related_lookups.extend(lookups) + return self + + def _fetch_all(self): + if self._result_cache is None: + self._result_cache = list(self.iterator()) + self._prefetch_related_objects() + + def _prefetch_related_objects(self): + for lookup in self._prefetch_related_lookups: + self._perform_prefetch(lookup) + + def _perform_prefetch(self, lookup): + # Simplified example of prefetching related objects + # Assuming `lookup` is a relation name from the node class + # if hasattr(self.source_class, lookup): + # relationship_manager = getattr(self.node_class, lookup) + # for parent_node in self._result_cache: + # # Accessing the related nodes directly via neomodel relationship manager + # related_nodes = list(getattr(parent_node, lookup).all()) + # # Store the fetched nodes in a cache attribute + # setattr(parent_node, f"_{lookup}_cache", related_nodes) + pass + class NeoManager: def __init__(self, model): self.model = model - + def get_queryset(self): return NeoNodeSet(self.model) def using(self, connection): - """ Needed to run pytest after adding app/model register""" + """Needed to run pytest after adding app/model register""" return NeoNodeSet(self.model) @@ -538,68 +588,70 @@ def __new__(cls, *args, **kwargs): if new_cls.__module__ is __package__: # Do not populate DjangoNode pass - elif new_cls.__module__.split('.')[-2] == 'tests': # Also skip test signals + elif new_cls.__module__.split(".")[-2] == "tests": # Also skip test signals pass else: - - meta = getattr(new_cls, 'Meta', None) - # Register the model in the Django app registry. + meta = getattr(new_cls, "Meta", None) + # Register the model in the Django app registry. # Django will try to clone to make a ModelState and upon cloning # the relations will result in an empty object, so there are no # circular imports - current_apps.register_model(new_cls.__module__.split('.')[-2], new_cls) + current_apps.register_model(new_cls.__module__.split(".")[-2], new_cls) return new_cls class DjangoNode(StructuredNode, metaclass=MetaClass): __abstract_node__ = True - + @classproperty def _meta(self): - - if hasattr(self.Meta, 'unique_together'): - raise NotImplementedError('unique_together property not supported by neomodel') + if hasattr(self.Meta, "unique_together"): + raise NotImplementedError( + "unique_together property not supported by neomodel" + ) # Need a ModelState for the admin to delete an object - self._state = ModelState() - self._state.adding = False + self._state = ModelState() + self._state.adding = False opts = Options(self.Meta, app_label=self.Meta.app_label) opts.contribute_to_class(self, self.__name__) - # Again, otherwise delete from admin doesn't work, see: - # https://github.com/django/django/blob/0e656c02fe945389246f0c08f51c6db4a0849bd2/django/db/models/deletion.py#L252 - opts.concrete_model = self + # Again, otherwise delete from admin doesn't work, see: + # https://github.com/django/django/blob/0e656c02fe945389246f0c08f51c6db4a0849bd2/django/db/models/deletion.py#L252 + opts.concrete_model = self for key, prop in self.__all_properties__: - opts.add_field(DjangoPropertyField(prop, key), getattr(prop, 'private', False)) + opts.add_field( + DjangoPropertyField(prop, key), getattr(prop, "private", False) + ) if getattr(prop, "primary_key", False): # a reference using self.pk = prop fails in some cases where - # django references the .pk attribute directly. ie in + # django references the .pk attribute directly. ie in # https://github.com/django/django/blob/ac5cc6cf01463d90aa333d5f6f046c311019827b/django/contrib/admin/options.py#L860 - # causes non-consistent behaviour because Django sometimes looks up the + # causes non-consistent behaviour because Django sometimes looks up the # attribute name via 'pk = cl.lookup_opts.pk.attname'. # instead provide an AliasProperty to the property tagged # as primary_key self.pk = AliasProperty(to=key) - + for key, relation in self.__all_relationships__: new_relation_field = DjangoRelationField(relation, key) new_relation_field.contribute_to_class(self, key) - opts.add_field(new_relation_field, getattr(prop, 'private', False)) - + opts.add_field(new_relation_field, getattr(prop, "private", False)) + return opts @classmethod def check(cls, **kwargs): - """ Needed for app registry, always provide empty list of errors """ + """Needed for app registry, always provide empty list of errors""" return [] def __hash__(self): # The delete function in the Admin requires a hash return hash(self.pk) - + def full_clean(self, exclude, validate_unique=False): """ Validate node, on error raising ValidationErrors which can be handled by django forms @@ -615,7 +667,7 @@ def full_clean(self, exclude, validate_unique=False): except DeflateError as e: raise ValidationError({e.property_name: e.msg}) except RequiredProperty as e: - raise ValidationError({e.property_name: 'is required'}) + raise ValidationError({e.property_name: "is required"}) except UniqueProperty as e: raise ValidationError({e.property_name: e.msg})