sdispater / orator

The Orator ORM provides a simple yet beautiful ActiveRecord implementation.
https://orator-orm.com
MIT License
1.43k stars 174 forks source link

morph_to_many eager loading raises and error #319

Open danielstandby opened 5 years ago

danielstandby commented 5 years ago

Hello,

First of all I'd like to tell you I really like Orator ORM, I've worked previuosly with Laravel's Eloquent :) and I like it too.

Now, I have a scenario for taggable models, very similar to the docs example.

These are the models:

src/models/tag.py

from orator import Model, SoftDeletes
from orator.orm import morphed_by_many
from .organization import *
from .location import *

class Tag(SoftDeletes, Model):

    __timestamps__ = False
    __dates__ = ['deleted_at']
    __guarded__ = ['id']

    @morphed_by_many('taggable')
    def organization(self):
        return Organization

    @morphed_by_many('taggable')
    def location(self):
        return Location

src/models/organization.py

from orator import Model, SoftDeletes
from orator.orm import has_many, morph_to_many
from .location import *
from .tag import *

class Organization(SoftDeletes, Model):

    __dates__ = ['deleted_at']
    __guarded__ = ['id', 'created_at', 'updated_at']

    @has_many
    def locations(self):
        return Location

    @morph_to_many('taggable')
    def tags(self):
        return Tag

src/models/location.py

from orator import Model, SoftDeletes
from orator.orm import belongs_to, has_many, morph_to_many
from .organization import *
from .tag import *

class Location(SoftDeletes, Model):
    __dates__ = ['deleted_at']
    __guarded__ = ['id', 'organization_id', 'created_at', 'updated_at']
    __fillable__ = ['parent_id','organization_id','name','description','slug','one_line_diagram']

    @belongs_to('parent_id')
    def parent(self):
        return Location

    @belongs_to
    def organization(self):
        return Organization

    @has_many('parent_id')
    def locations(self):
        return Location

    @morph_to_many('taggable')
    def tags(self):
        return Tag

These are the migrations:

src/migrations/2019_07_26_061519_create_organizations_table.py

from orator.migrations import Migration

class CreateOrganizationsTable(Migration):

    def up(self):
        """
        Run the migrations.
        """
        with self.schema.create('organizations') as table:
            table.increments('id')
            table.string('name', 120)
            table.text('description').nullable()
            table.string('slug', 255).nullable()
            table.string('logo', 255).nullable()
            table.timestamps()
            table.soft_deletes()

    def down(self):
        """
        Revert the migrations.
        """
        self.schema.drop('organizations')

src/migrations/2019_07_26_061735_create_locations_table.py

from orator.migrations import Migration

class CreateLocationsTable(Migration):

    def up(self):
        """
        Run the migrations.
        """
        with self.schema.create('locations') as table:
            table.increments('id')
            table.integer('parent_id').nullable().unsigned()
            table.integer('organization_id').unsigned()
            table.string('name', 120)
            table.text('description').nullable()
            table.string('slug', 255).nullable()
            table.json('one_line_diagram')
            table.timestamps()
            table.soft_deletes()

            table.foreign('parent_id').references('id').on('locations')
            table.foreign('organization_id').references('id').on('organizations')

    def down(self):
        """
        Revert the migrations.
        """
        with self.schema.table('locations') as table:
            table.drop_foreign('locations_parent_id_foreign')
            table.drop_foreign('locations_organization_id_foreign')
        self.schema.drop('locations')

src/migrations/2019_07_26_162058_create_tags_table.py

from orator.migrations import Migration

class CreateTagsTable(Migration):

    def up(self):
        """
        Run the migrations.
        """
        with self.schema.create('tags') as table:
            table.increments('id')
            table.string('name', 100)
            table.soft_deletes()

    def down(self):
        """
        Revert the migrations.
        """
        self.schema.drop('tags')

src/migrations/2019_07_26_162121_create_taggables_table.py

from orator.migrations import Migration

class CreateTaggablesTable(Migration):

    def up(self):
        """
        Run the migrations.
        """
        with self.schema.create('taggables') as table:
            table.integer('tag_id')
            table.morphs('taggable')

            table.foreign('tag_id').references('id').on('tags')

    def down(self):
        """
        Revert the migrations.
        """
        with self.schema.table('taggables') as table:
            table.drop_foreign('taggables_tag_id_foreign')
        self.schema.drop('taggables')

After running the migrations, the database setup looks like this (I wrote down only the relevant fields):

organizations
    id - integer
    ...

locations
    id - integer
    parent_id - integer (recursive FK)
    organization_id - integer (FK)
    ...

tags
    id - integer
    name - string
    ...

taggables
    tag_id - integer
    taggable_id - integer
    taggable_type - string

DB connector looks like this: src/repository/db.py

import os
from orator import DatabaseManager, Model

DATABASES = {
    'pg': {
        'driver': 'pgsql',
        'host': os.environ['ORGANIZATIONS_DB_HOST'],
        'port': os.environ['ORGANIZATIONS_DB_PORT'],
        'database': 'organizations',
        'user': os.environ['ORGANIZATIONS_DB_USER'],
        'password': os.environ['ORGANIZATIONS_DB_PWD'],
        'prefix': ''
    }
}

db = DatabaseManager(DATABASES)
Model.set_connection_resolver(db)

I have these records in database:

These are de dependencies in requirements.txt:

psycopg2-binary==2.8.3
orator==0.9.8

Requirements are installed in a local folder using -t option and that local folder path is added to python's path so it works without issues when importing stuff.

Then in shell I run:

>>> from src.repository import db
>>> from src.models.location import Location
>>> location = Location.with_('tags').where('id', 5).first()

Expected result: Location model corresponding to id 5 is assigned to variable location with tags relationship already loaded.

Actual result:

Traceback (most recent call last):
  File "/home/volume/utilities/python/lambda-layers/postgresql/python/lib/python3.7/site-packages/orator/orm/model.py", line 57, in __getattr__
    return type.__getattribute__(cls, item)
AttributeError: type object 'Pivot' has no attribute 'boot_pivot'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/volume/utilities/python/lambda-layers/postgresql/python/lib/python3.7/site-packages/orator/orm/builder.py", line 170, in first
    return self.take(1).get(columns).first()
  File "/home/volume/utilities/python/lambda-layers/postgresql/python/lib/python3.7/site-packages/orator/orm/builder.py", line 204, in get
    models = self.eager_load_relations(models)
  File "/home/volume/utilities/python/lambda-layers/postgresql/python/lib/python3.7/site-packages/orator/orm/builder.py", line 464, in eager_load_relations
    models = self._load_relation(models, name, constraints)
  File "/home/volume/utilities/python/lambda-layers/postgresql/python/lib/python3.7/site-packages/orator/orm/builder.py", line 485, in _load_relation
    results = relation.get_eager()
  File "/home/volume/utilities/python/lambda-layers/postgresql/python/lib/python3.7/site-packages/orator/orm/relations/relation.py", line 75, in get_eager
    return self.get()
  File "/home/volume/utilities/python/lambda-layers/postgresql/python/lib/python3.7/site-packages/orator/orm/relations/belongs_to_many.py", line 148, in get
    self._hydrate_pivot_relation(models)
  File "/home/volume/utilities/python/lambda-layers/postgresql/python/lib/python3.7/site-packages/orator/orm/relations/belongs_to_many.py", line 162, in _hydrate_pivot_relation
    pivot = self.new_existing_pivot(self._clean_pivot_attributes(model))
  File "/home/volume/utilities/python/lambda-layers/postgresql/python/lib/python3.7/site-packages/orator/orm/relations/belongs_to_many.py", line 807, in new_existing_pivot
    return self.new_pivot(attributes, True)
  File "/home/volume/utilities/python/lambda-layers/postgresql/python/lib/python3.7/site-packages/orator/orm/relations/morph_to_many.py", line 101, in new_pivot
    pivot = MorphPivot(self._parent, attributes, self._table, exists)
  File "/home/volume/utilities/python/lambda-layers/postgresql/python/lib/python3.7/site-packages/orator/orm/relations/pivot.py", line 27, in __init__
    super(Pivot, self).__init__()
  File "/home/volume/utilities/python/lambda-layers/postgresql/python/lib/python3.7/site-packages/orator/orm/model.py", line 122, in __init__
    self._boot_if_not_booted()
  File "/home/volume/utilities/python/lambda-layers/postgresql/python/lib/python3.7/site-packages/orator/orm/model.py", line 149, in _boot_if_not_booted
    klass._boot()
  File "/home/volume/utilities/python/lambda-layers/postgresql/python/lib/python3.7/site-packages/orator/orm/model.py", line 167, in _boot
    cls._boot_mixins()
  File "/home/volume/utilities/python/lambda-layers/postgresql/python/lib/python3.7/site-packages/orator/orm/model.py", line 185, in _boot_mixins
    if hasattr(mixin, method):
  File "/home/volume/utilities/python/lambda-layers/postgresql/python/lib/python3.7/site-packages/orator/orm/model.py", line 59, in __getattr__
    query = cls.query()
  File "/home/volume/utilities/python/lambda-layers/postgresql/python/lib/python3.7/site-packages/orator/orm/model.py", line 1815, in query
    return cls().new_query()
TypeError: __init__() missing 3 required positional arguments: 'parent', 'attributes', and 'table'

The curious thing is if I run the last line again after the error, it works:

>>> location = Location.with_('tags').where('id', 5).first()
>>> location.tags
<Result at 0x7f45d97e9640 for Collection at 0x7f45d97e4490>
>>> list(location.tags)
[<src.models.tag.Tag object at 0x7f45d97e4350>]
>>> [tag for tag in location.tags][0].name
'test'

Also, if the location doesn't have any references in taggables table, it works ok the first run.

I would appreciate if you could help us find a workaround, or even better have a hotfix ASAP. We have a deadline for September 1st. And if we cannot overcome this issue soon we would be force to use a different approach. But I would really like to keep using Orator :)

Thanks.

danielstandby commented 5 years ago

I just made another test. A different error appears even without using eager loading:

I just run this in shell:

from src.repository import db
from src.models.location import Location
location = Location.where('id', 5).first()
location.tags

And got this:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/volume/utilities/python/lambda-layers/postgresql/python/lib/python3.7/site-packages/orator/orm/relations/wrapper.py", line 40, in __repr__
    return repr(self.__wrapped__)
AttributeError: 'Collection' object has no attribute '__wrapped__'

But again, if I run the last line again after the error, it works:

>>> location.tags
<orator.orm.collection.Collection object at 0x7f11e55bcf90>
>>> list(location.tags)
[<src.models.tag.Tag object at 0x7f11e55bc190>]
>>> [tag for tag in location.tags][0].name
'test'