How to set sqlalchemy relationship using a list of ids? - sqlalchemy

I'm trying to figure out how to update a relationship using a list of foriegn keys.
Take for example the standard parent/children relationship from the SQLAlchemy documentation:
class Parent(Base):
__tablename__ = "parent_table"
id: Mapped[int] = mapped_column(primary_key=True)
children: Mapped[list["Child"]] = relationship(back_populates="parent")
class Child(Base):
__tablename__ = "child_table"
id: Mapped[int] = mapped_column(primary_key=True)
parent_id: Mapped[int] = mapped_column(ForeignKey("parent_table.id"), nullable=True)
parent: Mapped["Parent"] = relationship(back_populates="children")
The normal relationship modelling means that if I want to update the list of children from the parent I need to have all of those child objects. Instead I'd like to have a property on the Parent class that is the id's of the ascociated Child classes but can be updated as well.
I've seen this answer: how to access list of keys for a relationship in SQLAlchemy? which gives the "read" side of the equation, but how can I write an efficient setter to do the other half?
Note: The children in my scenario already exist. I suspect I need to use the update_expression function in order to build a sql query that will go and look for the existing children and link them accross. But I'm not sure if that's right...
This is what I've got so far.
#hybrid_property
def child_ids(self) -> List[uuid.UUID]:
return [child.id for child in self.children]
#child_ids.expression
def child_ids(self):
select([Child.id]).where(
Child.parent_id == self.id
)
#child_ids.setter
def child_ids(self, value):
# What here?
pass
For completeness, I'm specifically asking how to do this in the Model using properties/hybrid_properties.

This seems to work but I'm not sure how reliable it will be with a lot of simultaneous requests to set child_ids. If it is a big deal you might get a row lock on the parent to synchronize calls to set child_ids.
delete and insert differences between value and self.children
you need to set delete-orphan cascade or else Child objects end up with Child(id=5, parent_id=None) when using self.children.remove(child)
class Parent(Base):
__tablename__ = 'parents'
id = Column(Integer, primary_key=True)
#hybrid_property
def child_ids(self):
return [child.id for child in self.children]
#child_ids.expression
def child_ids(self):
return select([Child.id]).where(
Child.parent_id == self.id
)
#child_ids.setter
def child_ids(self, value):
"""
Sync children with given children ids.
value: list[int]
List of children ids.
"""
# Child ids in value found in self.children.
found_child_ids = set()
# Childs in self.children whose id is not in value.
to_delete = set()
for child in self.children:
if child.id not in value:
to_delete.add(child)
else:
found_child_ids.add(child.id)
# Delete children not in value.
for child in to_delete:
# This only deletes the Child object because we have delete-orphan cascade set.
self.children.remove(child)
# Create children with child ids in value that were not found in self.children.
for child_id in (set(value) - found_child_ids):
self.children.append(Child(id=child_id, parent_id=self.id))
children = relationship('Child', back_populates="parent",
# Need this to delete orphaned children.
cascade="all, delete-orphan")
class Child(Base):
__tablename__ = 'childs'
id = Column(Integer, primary_key=True)
parent_id = Column(Integer, ForeignKey('parents.id'))
parent = relationship('Parent', back_populates="children")
metadata.create_all(engine)
with Session(engine) as session, session.begin():
p1 = Parent()
session.add(p1)
with Session(engine) as session:
p1 = session.get(Parent, 1)
# Add 1, 5
p1.child_ids = [1, 5]
session.commit()
# Add 2, Remove 1, Leave 5
p1.child_ids = [2, 5]
session.commit()
# Remove 2, Remove 5
p1.child_ids = []
session.commit()
Echo after create_all
2023-01-17 21:35:51,060 INFO sqlalchemy.engine.Engine BEGIN (implicit)
2023-01-17 21:35:51,064 INFO sqlalchemy.engine.Engine INSERT INTO parents DEFAULT VALUES RETURNING parents.id
2023-01-17 21:35:51,065 INFO sqlalchemy.engine.Engine [generated in 0.00058s] {}
2023-01-17 21:35:51,067 INFO sqlalchemy.engine.Engine COMMIT
2023-01-17 21:35:51,104 INFO sqlalchemy.engine.Engine BEGIN (implicit)
2023-01-17 21:35:51,106 INFO sqlalchemy.engine.Engine SELECT parents.id AS parents_id
FROM parents
WHERE parents.id = %(pk_1)s
2023-01-17 21:35:51,106 INFO sqlalchemy.engine.Engine [generated in 0.00021s] {'pk_1': 1}
2023-01-17 21:35:51,109 INFO sqlalchemy.engine.Engine SELECT childs.id AS childs_id, childs.parent_id AS childs_parent_id
FROM childs
WHERE %(param_1)s = childs.parent_id
2023-01-17 21:35:51,110 INFO sqlalchemy.engine.Engine [generated in 0.00017s] {'param_1': 1}
2023-01-17 21:35:51,112 INFO sqlalchemy.engine.Engine INSERT INTO childs (id, parent_id) VALUES (%(id)s, %(parent_id)s)
2023-01-17 21:35:51,112 INFO sqlalchemy.engine.Engine [generated in 0.00017s] ({'id': 1, 'parent_id': 1}, {'id': 5, 'parent_id': 1})
2023-01-17 21:35:51,114 INFO sqlalchemy.engine.Engine COMMIT
2023-01-17 21:35:51,163 INFO sqlalchemy.engine.Engine BEGIN (implicit)
2023-01-17 21:35:51,165 INFO sqlalchemy.engine.Engine SELECT parents.id AS parents_id
FROM parents
WHERE parents.id = %(pk_1)s
2023-01-17 21:35:51,165 INFO sqlalchemy.engine.Engine [generated in 0.00025s] {'pk_1': 1}
2023-01-17 21:35:51,166 INFO sqlalchemy.engine.Engine SELECT childs.id AS childs_id, childs.parent_id AS childs_parent_id
FROM childs
WHERE %(param_1)s = childs.parent_id
2023-01-17 21:35:51,166 INFO sqlalchemy.engine.Engine [cached since 0.05671s ago] {'param_1': 1}
2023-01-17 21:35:51,168 INFO sqlalchemy.engine.Engine INSERT INTO childs (id, parent_id) VALUES (%(id)s, %(parent_id)s)
2023-01-17 21:35:51,168 INFO sqlalchemy.engine.Engine [generated in 0.00017s] {'id': 2, 'parent_id': 1}
2023-01-17 21:35:51,170 INFO sqlalchemy.engine.Engine DELETE FROM childs WHERE childs.id = %(id)s
2023-01-17 21:35:51,170 INFO sqlalchemy.engine.Engine [generated in 0.00015s] {'id': 1}
2023-01-17 21:35:51,170 INFO sqlalchemy.engine.Engine COMMIT
2023-01-17 21:35:51,204 INFO sqlalchemy.engine.Engine BEGIN (implicit)
2023-01-17 21:35:51,206 INFO sqlalchemy.engine.Engine SELECT parents.id AS parents_id
FROM parents
WHERE parents.id = %(pk_1)s
2023-01-17 21:35:51,206 INFO sqlalchemy.engine.Engine [cached since 0.04138s ago] {'pk_1': 1}
2023-01-17 21:35:51,208 INFO sqlalchemy.engine.Engine SELECT childs.id AS childs_id, childs.parent_id AS childs_parent_id
FROM childs
WHERE %(param_1)s = childs.parent_id
2023-01-17 21:35:51,209 INFO sqlalchemy.engine.Engine [cached since 0.09924s ago] {'param_1': 1}
2023-01-17 21:35:51,211 INFO sqlalchemy.engine.Engine DELETE FROM childs WHERE childs.id = %(id)s
2023-01-17 21:35:51,211 INFO sqlalchemy.engine.Engine [generated in 0.00025s] ({'id': 2}, {'id': 5})
2023-01-17 21:35:51,212 INFO sqlalchemy.engine.Engine COMMIT

Related

sqlalchemy update using list of tuples

Is there an efficient way to update rows based on list of tuples in sqlalchemy?
If its a single row, then I can simply do:
session.query(table).filter(table.id == 10).update({'values': 'x'})
session.commit
however, the data i'm getting is a list of tuples
[(10, 'x'),(20,'y'),(30,'z'),(40,'p')]
table has IDs 10,20,30,40 etc.
is there efficient way to update instead of multiple individual updates?
You can convert the list of tuples to a list of dicts and then use update() with bindparam() as illustrated in the tutorial:
from pprint import pprint
import sqlalchemy as sa
engine = sa.create_engine("sqlite://")
tbl = sa.Table(
"tbl",
sa.MetaData(),
sa.Column("id", sa.Integer, primary_key=True, autoincrement=False),
sa.Column("value", sa.String(50)),
)
tbl.create(engine)
with engine.begin() as conn:
conn.execute(
tbl.insert(),
[
{"id": 10, "value": "old_10"},
{"id": 20, "value": "old_20"},
{"id": 30, "value": "old_30"},
],
)
with engine.begin() as conn:
# initial state
print(conn.execute(sa.select(tbl)).all())
# [(10, 'old_10'), (20, 'old_20'), (30, 'old_30')]
new_data = [(10, "x"), (20, "y"), (30, "z")]
params = [dict(tbl_id=a, new_value=b) for (a, b) in new_data]
pprint(params, sort_dicts=False)
"""
[{'tbl_id': 10, 'new_value': 'x'},
{'tbl_id': 20, 'new_value': 'y'},
{'tbl_id': 30, 'new_value': 'z'}]
"""
upd = (
sa.update(tbl)
.values(value=sa.bindparam("new_value"))
.where(tbl.c.id == sa.bindparam("tbl_id"))
)
print(upd)
# UPDATE tbl SET value=:new_value WHERE tbl.id = :tbl_id
conn.execute(upd, params)
# check results
print(conn.execute(sa.select(tbl)).all())
# [(10, 'x'), (20, 'y'), (30, 'z')]

SQLAlchemy: Updates on persistent object are auto flushed when autoflush=False

When applying updates to a persistent object, it seems that each update is automatically pushed to the transaction buffer even when the autoflush property is set as false.
Consider the following example. There are two entities - Employee and Department, and they have a many-to-many relationship between them.
The ORM definition is as follows:
from sqlalchemy.orm import registry, relationship
mapper_registry = registry()
mapper_registry.map_imperatively(
models.Employee,
employee_table,
properties={
"employee_id": employee_table.c.EmployeeId,
"first_name": employee_table.c.FirstName,
"last_name": employee_table.c.LastName,
"departments": relationship(
models.Department, secondary=joinEmployeeDepartment
),
},
)
The initial database state looks like this:
Employee:
EmployeeId
FirstName
FirstName
1
John
Doe
Department:
DepartmentId
DepartmentName
1
Sales
joinEmployeeDepartment:
RelationshipId
EmployeeId
DepartmentId
1
1
1
Then the following code is executed:
from sqlalchemy.orm.session import Session, sessionmaker
Session = sessionmaker(bind=engine, autocommit=False, autoflush=False, expire_on_commit=False)
session = Session()
session.begin()
try:
employee: Employee = session.query(Employee).where(Employee.first_name == 'John').one_or_none()
employee.departments = [Department(department_name='support')] # 1
employee.departments = [Department(department_name='IT')] # 2
session.commit()
finally:
session.close()
Since autoflush is turned off, I would expect that when the persistent employee object is updated in-memory in #1, the changes would not be flushed to transaction buffer. Since #2 overwrites the changes made in #1, only the latter would be flushed and committed when session.commit() is called. However, I observed that is not the case. The change in #1 is also added to the transaction buffer. The resulting DB state is as follows:
Department:
DepartmentId
DepartmentName
1
Sales
2
support
3
IT
joinEmployeeDepartment:
RelationshipId
EmployeeId
DepartmentId
2
1
3
My question is that is the autoflush setting ignored when updates are made to a persistent object?
What's happening is that as each new Department instance is created, it's added to session.new and enters the pending state. A pending object
[isn't] actually flushed to the database yet, but it will be when the next flush occurs.
So when the session is flushed commit, it finds two pending Department instances and sends them to the database.
The "delete" of the first Department instance is not tracked in session.deleted because that instance is not in a persistent state. Thus at flush time no DELETE is sent for that instance. However SQLAlchemy's attribute tracking tracks it as having been deleted from the relationship, so SQLAlchemy fixes the many-to-many mappings to reflect the final desired state.
We can see this by printing session.new and the relationship's history, and enabling logging on the engine:
with Session() as s:
employee: Employee = s.query(Employee).where(Employee.first_name == 'John').one_or_none()
hist = sa.inspect(employee).attrs.departments.history
employee.departments = [Department(department_name='support')]
print(s.new)
print(sa.inspect(employee).attrs.departments.history)
employee.departments = [Department(department_name='IT')]
print(s.new)
print(sa.inspect(employee).attrs.departments.history)
s.commit()
Output:
2021-12-08 11:42:40,134 INFO sqlalchemy.engine.Engine BEGIN (implicit)
2021-12-08 11:42:40,139 INFO sqlalchemy.engine.Engine SELECT employees.id AS employees_id, employees.first_name AS employees_first_name, employees.last_name AS employees_last_name
FROM employees
WHERE employees.first_name = ?
2021-12-08 11:42:40,139 INFO sqlalchemy.engine.Engine [generated in 0.00028s] ('John',)
2021-12-08 11:42:40,143 INFO sqlalchemy.engine.Engine SELECT departments.id AS departments_id, departments.department_name AS departments_department_name
FROM departments, association
WHERE ? = association.employee_id AND departments.id = association.department_id
2021-12-08 11:42:40,143 INFO sqlalchemy.engine.Engine [generated in 0.00024s] (1,)
IdentitySet([<__main__.Department object at 0x7fdbf4998eb0>])
History(added=[<__main__.Department object at 0x7fdbf4998eb0>], unchanged=[], deleted=[<__main__.Department object at 0x7fdbf4882860>])
IdentitySet([<__main__.Department object at 0x7fdbf4998eb0>, <__main__.Department object at 0x7fdbf4881420>])
History(added=[<__main__.Department object at 0x7fdbf4881420>], unchanged=[], deleted=[<__main__.Department object at 0x7fdbf4882860>])
2021-12-08 11:42:40,145 INFO sqlalchemy.engine.Engine INSERT INTO departments (department_name) VALUES (?)
2021-12-08 11:42:40,145 INFO sqlalchemy.engine.Engine [cached since 0.01444s ago] ('support',)
2021-12-08 11:42:40,145 INFO sqlalchemy.engine.Engine INSERT INTO departments (department_name) VALUES (?)
2021-12-08 11:42:40,145 INFO sqlalchemy.engine.Engine [cached since 0.01477s ago] ('IT',)
2021-12-08 11:42:40,147 INFO sqlalchemy.engine.Engine DELETE FROM association WHERE association.employee_id = ? AND association.department_id = ?
2021-12-08 11:42:40,147 INFO sqlalchemy.engine.Engine [generated in 0.00023s] (1, 1)
2021-12-08 11:42:40,148 INFO sqlalchemy.engine.Engine INSERT INTO association (employee_id, department_id) VALUES (?, ?)
2021-12-08 11:42:40,148 INFO sqlalchemy.engine.Engine [cached since 0.01509s ago] (1, 3)
2021-12-08 11:42:40,148 INFO sqlalchemy.engine.Engine COMMIT
To prevent the insertion of the first Department it must be removed from session.new by expunging it from the session:
employee.departments = [Department(department_name='support')]
s.expunge(employee.departments[0])
employee.departments = [Department(department_name='IT')]
See the docs for sessions and object states for more detail.

How to define cte/query/join in hybrid_property in sqlalchemy without repeating for each row in the model?

Models
class Category(Base):
__tablename__ = 'category'
__table_args__ = ({'schema': 'management'},)
category_id_seq = Sequence('management.category_id_seq')
id = Column(Integer, category_id_seq, server_default=category_id_seq.next_value(),
primary_key=True, unique=True, nullable=False, )
description = Column(String, nullable=False)
class Brand(Base):
__tablename__ = 'brand'
__table_args__ = ({'schema': 'management'},)
brand_id_seq = Sequence('management.brand_id_seq')
id = Column(Integer, brand_id_seq, server_default=brand_id_seq.next_value(),
primary_key=True, unique=True, nullable=False)
description = Column(String, nullable=False)
class Quality(Base):
__tablename__ = 'quality'
__table_args__ = ({'schema': 'management'},)
quality_id_seq = Sequence('management.quality_id_seq')
id = Column(Integer, quality_id_seq, server_default=quality_id_seq.next_value(),
primary_key=True, unique=True, nullable=False)
description = Column(String, nullable=False)
class Product(Base):
# region
__tablename__ = 'product'
__table_args__ = (UniqueConstraint('category_id', 'brand_id', 'description',
name='product_uc'), {'schema': 'operation'})
product_id_seq = Sequence('operation.product_id_seq')
id = Column(Integer, product_id_seq, server_default=product_id_seq.next_value(),
primary_key=True, unique=True, nullable=False)
category_id = Column(Integer, ForeignKey(
'management.category.id'), nullable=False)
brand_id = Column(Integer, ForeignKey(
'management.brand.id'), nullable=False)
description = Column(String, nullable=False)
category = relationship(
'Category', foreign_keys='[Product.category_id]')
brand = relationship(
'Brand', foreign_keys='[Product.brand_id]')
# endregion
#hybrid_property
def full_desc(self):
cte_product = session.query(func.concat(Category.description, ' - ',
Brand.description, ' - ',
self.description).label('desc')) \
.select_from(self) \
.join(Category, Brand) \
.filter(Category.id == self.category_id, Brand.id == self.brand_id) \
.cte('cte_product')
# return session.query(cte_product.c.desc).select_from(self).join(cte_product).filter(self.id == cte_product.c.id).cte('test')
return cte_product.c.desc
#full_desc.expression
def full_desc(cls):
cte_product = session.query(func.concat(Category.description, ' - ',
Brand.description, ' - ',
cls.description).label('desc')) \
.select_from(cls)\
.join(Category, Brand) \
.filter(Category.id == cls.category_id, Brand.id == cls.brand_id) \
.cte('cte_product')
return cte_product.c.desc
Test 1 - Using hybrid_property - Undesired Results
result_1 = session.query(Product.id, Product.full_desc).all()
for i in result_1:
print(i)
Running the code above results in a list of all 'full_desc' repeated for every single 'id', like so:
(1, 'Category6 - Brand1 - Product1')
(2, 'Category6 - Brand1 - Product1')
(3, 'Category6 - Brand1 - Product1')
(4, 'Category6 - Brand1 - Product1')
(5, 'Category6 - Brand1 - Product1')
(6, 'Category6 - Brand1 - Product1')
(7, 'Category6 - Brand1 - Product1')
(8, 'Category6 - Brand1 - Product1')
(9, 'Category6 - Brand1 - Product1')
(1, 'Category5 - Brand2 - Product2')
(2, 'Category5 - Brand2 - Product2')
(3, 'Category5 - Brand2 - Product2')
(4, 'Category5 - Brand2 - Product2')
... 81 rows in total
CTE defined outside models
cte_product = session.query(Product.id.label('id'), func.concat(Category.description, ' - ',
Brand.description, ' - ',
Product.description).label('desc')) \
.select_from(Product) \
.join(Category, Brand) \
.filter(Category.id == Product.category_id, Brand.id == Product.brand_id) \
.cte('cte_product')
Test 2 - Using CTE definition above (outside of Models) - Desired Results - Undesired Method
result_2 = session.query(cte_product.c.id, cte_product.c.desc).all()
for i in result_2:
print(i)
Running the code above results in a list of right desc for each id with no repetition (only 9 rows), like so:
(1, 'Category6 - Brand1 - Product1')
(2, 'Category5 - Brand2 - Product2')
(3, 'Category7 - Brand4 - Product3')
(4, 'Category7 - Brand3 - Product4')
(5, 'Category5 - Brand1 - Product5')
(6, 'Category7 - Brand5 - Product6')
(7, 'Category3 - Brand2 - Product7')
(8, 'Category1 - Brand3 - Product8')
(9, 'Category4 - Brand3 - Product9')
Question
What should I change on the #hybrid_property and/or #hybridproperty.extension to get to the desired result as in Test 2 but using the methods in Test 1? Or is there a better way of doing this altogether?
Environment
SQLAlchemy==1.3.20
SQLAlchemy-Utils==0.36.7
PostgreSQL 13
I am not sure if you should use cte at all. And the non-expression part of the #hybrid_property should not use any queries.
Please see the code below, which should work (using sqlalchemy version 1.4):
#hybrid_property
def full_description(self):
return self.category.description + " - " + self.brand.description + " - " + self.description
#full_description.expression
def full_description(cls):
subq = (
select(
func.concat(
Category.description,
" - ",
Brand.description,
" - ",
cls.description,
)
)
.where(Category.id == cls.category_id).where(Brand.id == cls.brand_id)
.scalar_subquery()
.label("full_desc_subquery")
)
return subq
and respective queries:
# in-memory (not using expressions, but potentially loading categories and brands from the database)
result_1 = session.query(Product).all()
for product in result_1:
print(product.id, product.full_description)
# using .expression - get 'full_description' in one query.
result_2 = session.query(Product.id, Product.full_description).all()
for product_id, fulldesc in result_2:
print(product_id, fulldesc)
, where the latter would produce the following SQL statement:
SELECT product.id,
(SELECT concat(category.description, %(concat_2)s, brand.description, %(concat_3)s, product.description) AS concat_1
FROM category,
brand
WHERE category.id = product.category_id
AND brand.id = product.brand_id) AS full_desc_subquery
FROM product

How to serialize nested objects with related models?

i am really new to DRM and i have to following problem
I have three related models. Now i want to for each sensor values to the related patient. My models look like:
class Sensor(models.Model):
sensor_name = models.CharField(max_length=200, primary_key=True)
sensor_refreshRate = models.FloatField()
sensor_prio = models.IntegerField(choices=[
(1, 1), (2, 2), (3, 3), (4, 4), ], default='1')
sensor_typ = models.CharField(
max_length=200,
choices=[
('bar', 'bar'), ('pie', 'pie'),
('line', 'line'), ('text', 'text'),
],
default='bar'
)
class Patient(models.Model):
firstName = models.CharField(max_length=200)
lastName = models.CharField(max_length=200)
age = models.IntegerField()
doctor = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
sensor = models.ManyToManyField(Sensor)
class Value(models.Model):
value_date = models.DateTimeField(auto_now_add=True)
value = models.FloatField()
sensor = models.ForeignKey(Sensor, on_delete=models.CASCADE)
Now i would like to send a JSON file which looks like:
[
{
"value": 445.0,
"sensor": "Pressure",
"patient": 3
},
{
"value": 478.0,
"sensor": "Temperature",
"patient": 3
}
]
Now i am not sure how to serialize my JSON.
Thanks in advance
your data models seems incomplete.
There's no link between the Value model and the Patient one.
So first I'd suggest to modify the Value model
class Value(models.Model):
value_date = models.DateTimeField(auto_now_add=True)
value = models.FloatField()
sensor = models.ForeignKey(Sensor, on_delete=models.CASCADE)
patient = models.ForeignKey(Patient, on_delete=models.CASCADE)
then you could create a queryset
qs = Value.objects.filter(.....).values(
'patient_id', 'sensor__sensor_name', 'value'
)
and the write your serializer

Extending an ActiveRecord Polymorphic Association with JOIN statements?

My tables are set up such that Child has a 1:1 relationship with Parent.
They share a primary key (id):
Parent has id and type and name
Child has id and health
The Child is a polymorphic inheritance of the Parent. My goal is that Child.find(1) should return a Child object which responds to both name and health. The SQL statement would hypothetically look something like this:
SELECT parents.id, parents.name, childs.health FROM parents
LEFT JOIN childs ON childs.id = Parents.id
WHERE parents.id = 1 AND parents.type = 'Child' LIMIT 1
Thus I've attempted to use Polymorphic Inheritance in ActiveRecord:
class Parent < ApplicationRecord
class Child < Parent
When I attempt to execute Child.find(1), I see:
SELECT `parents`.* FROM `parents` WHERE `parents`.`type` IN ('Child') AND `parents`.`ID` = 1 LIMIT 1
=> #<Child id: 1, type: "Child", name: "Hello">
Notably, there's no JOIN on the child table, yet I receive a Child object back. This leads to the unexpected behavior that the Child object does not respond to health. Curiously, if I add an explicit table association to the Child class (self.table_name = "childs"), then the query pattern changes to:
> c = Child.find(1)
Obtainable Load (0.3ms) SELECT `childs`.* FROM `childs` WHERE `childs`.`ID` = 2 LIMIT 1
Now I can access the health, but not the type or name.
How can I correctly create this JOIN association such that an attempt to load a Child object successfully JOINs the data from the parent?
Edit: these tables were created outside of an ActiveRecord migration (they are also accessed by other, pre-existing, non-Ruby applications) so I have no ability to change their schema. I can think of some fancy metaprogramming approaches, like responding to method_missing, that might let me lazy-load the missing attributes through a join... but I'm afraid I'll end up having to re-implement a bunch of ActiveRecord, like delete, create, etc. (which will lead to bugs). So I'm looking for some native/clean(ish) way to accomplish this.
This is not a typical Rails polymorphic association as described here: http://guides.rubyonrails.org/association_basics.html#polymorphic-associations
So, in this case, when tables were created earlier by some other app, I suggest that you do something like this:
class Child < ApplicationRecord
self.table_name = "childs"
belongs_to :parent, foreign_key: :id, dependent: :destroy
delegate :name, :type, to: :parent
delegate :name=, to: :parent, allow_nil: true
after_initialize do
self.build_parent(type: "Child") if parent.nil?
end
end
class Parent < ApplicationRecord
self.inheritance_column = 'foo' # otherwise, type column will be used for STI
has_one :child, foreign_key: :id
delegate :health, to: :child
end
and now you can access the health, type and name:
> c = Child.joins(:parent).find(1)
Child Load (0.2ms) SELECT "childs".* FROM "childs" INNER JOIN "parents" ON "parents"."id" = "childs"."id" WHERE "childs"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> #<Child id: 1, health: "Good", created_at: "2016-10-27 21:42:55", updated_at: "2016-10-27 21:44:08">
irb(main):002:0> c.health
=> "Good"
irb(main):003:0> c.type
Parent Load (0.1ms) SELECT "parents".* FROM "parents" WHERE "parents"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> "Child"
irb(main):004:0> c.name
=> "Hello"
and similar is for the parent:
p = Parent.joins(:child).find(1)
Parent Load (0.1ms) SELECT "parents".* FROM "parents" INNER JOIN "childs" ON "childs"."id" = "parents"."id" WHERE "parents"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> #<Parent id: 1, type: nil, name: "Hello", created_at: "2016-10-27 21:40:35", updated_at: "2016-10-27 21:40:35">
irb(main):003:0> p.name
=> "Hello"
irb(main):004:0> p.health
Child Load (0.1ms) SELECT "childs".* FROM "childs" WHERE "childs"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> "Good"
If you prefer, you can specify LEFT JOIN like this:
irb(main):003:0> p = Parent.joins("LEFT JOIN childs ON (childs.id = parents.id)").select("parents.id, parents.name, childs.health").find(1)
Parent Load (0.2ms) SELECT parents.id, parents.name, childs.health FROM "parents" LEFT JOIN childs ON (childs.id = parents.id) WHERE "parents"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> #<Parent id: 1, name: "Hello">
irb(main):004:0> p.name
=> "Hello"
irb(main):005:0> p.health
Child Load (0.1ms) SELECT "childs".* FROM "childs" WHERE "childs"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
=> "Good"