Skip to content

Commit 2f80d77

Browse files
gtfierroTShapinsky
andauthored
Cascade DELETEs in the database (#363)
* add errors! * made these changes when building the demo; this removes dangling templates/etc when deleting libraries * Fix styling issues * Add tests for cascading delete behavior on models and libraries * formatting * adding comments to tests, add another test case * formatting * delete dependant templates and libraries * patch test by using overwrite=False to prevent library deletion cascade * Implement lazy resolution * begin refactor with new dependency design * add error_on_missing_dependency * Fix template library resolution for cases where library is being created from a ttl file * fix dict to Dict * default resolution is to current library --------- Co-authored-by: TShapinsky <[email protected]>
1 parent 5759706 commit 2f80d77

File tree

15 files changed

+570
-305
lines changed

15 files changed

+570
-305
lines changed

buildingmotif/database/errors.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,11 @@ def __str__(self):
4747
if self.template_name:
4848
return f"Name: {self.template_name}"
4949
return f"ID: {self.template_id}"
50+
51+
52+
class TemplateDependencyNotFound(Exception):
53+
def __init__(self, idnum: int):
54+
self.template_dependency_id = idnum
55+
56+
def __str__(self):
57+
return f"ID: {self.template_dependency_id}"

buildingmotif/database/table_connection.py

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22
import uuid
33
from functools import lru_cache
4-
from typing import Dict, List, Tuple
4+
from typing import Dict, List, Optional, Tuple
55

66
from sqlalchemy.engine import Engine
77
from sqlalchemy.exc import NoResultFound
@@ -17,7 +17,7 @@
1717
DBModel,
1818
DBShapeCollection,
1919
DBTemplate,
20-
DepsAssociation,
20+
DBTemplateDependency,
2121
)
2222

2323

@@ -287,7 +287,7 @@ def create_db_template(self, name: str, library_id: int) -> DBTemplate:
287287
template = DBTemplate(
288288
name=name,
289289
body_id=str(uuid.uuid4()),
290-
optional_args=[],
290+
optional_args=[], # type: ignore
291291
library=library,
292292
)
293293

@@ -347,7 +347,7 @@ def get_library_defining_db_template(self, id: int) -> DBLibrary:
347347
"""
348348
return self.get_db_template(id).library
349349

350-
def get_db_template_dependencies(self, id: int) -> Tuple[DepsAssociation, ...]:
350+
def get_db_template_dependencies(self, id: int) -> Tuple[DBTemplateDependency, ...]:
351351
"""Get a template's dependencies and its arguments.
352352
353353
If you don't need the arguments, consider using
@@ -360,12 +360,28 @@ def get_db_template_dependencies(self, id: int) -> Tuple[DepsAssociation, ...]:
360360
:rtype: tuple[tuple[int, list[str]]]
361361
"""
362362
db_template_dependencies = tuple(
363-
self.bm.session.query(DepsAssociation)
364-
.filter(DepsAssociation.dependant_id == id)
363+
self.bm.session.query(DBTemplateDependency)
364+
.filter(DBTemplateDependency.template_id == id)
365365
.all()
366366
)
367367
return db_template_dependencies
368368

369+
def get_db_template_dependency(self, id: int) -> Optional[DBTemplateDependency]:
370+
"""Get template dependency object by its id
371+
372+
:param id: template dependency id
373+
:type id: int
374+
:return: the template dependency or None
375+
:rtype: Optional[DBTemplateDependency]"""
376+
try:
377+
return (
378+
self.bm.session.query(DBTemplateDependency)
379+
.filter(DBTemplateDependency.id == id)
380+
.one()
381+
)
382+
except NoResultFound:
383+
return None
384+
369385
def update_db_template_name(self, id: int, name: str) -> None:
370386
"""Update database template name.
371387
@@ -396,7 +412,11 @@ def update_db_template_optional_args(
396412
db_template.optional_args = optional_args
397413

398414
def add_template_dependency_preliminary(
399-
self, template_id: int, dependency_id: int, args: Dict[str, str]
415+
self,
416+
template_id: int,
417+
dependency_library: str,
418+
dependency_template: str,
419+
args: Dict[str, str],
400420
):
401421
"""Creates a *preliminary* dependency between two templates. This dependency
402422
is preliminary because the bindings between the dependent/dependency templates
@@ -421,7 +441,7 @@ def add_template_dependency_preliminary(
421441
:raises ValueError: if dependant and dependency template don't share a
422442
"""
423443
self.logger.debug(
424-
f"Creating depencency from templates with ids: '{template_id}' and: '{dependency_id}'"
444+
f"Creating depencency from templates with id: '{template_id}' and target: '{dependency_library}:{dependency_template}'"
425445
)
426446
templ = self.get_db_template(template_id)
427447
if "name" not in args.keys():
@@ -431,14 +451,16 @@ def add_template_dependency_preliminary(
431451
# In the past we had a check here to make sure the two templates were in the same library.
432452
# This has been removed because it wasn't actually necessary, but we may add it back in
433453
# in the future.
434-
relationship = DepsAssociation(
435-
dependant_id=template_id,
436-
dependee_id=dependency_id,
437-
args=args,
454+
relationship = DBTemplateDependency(
455+
template_id=template_id,
456+
dependency_library_name=dependency_library,
457+
dependency_template_name=dependency_template,
458+
args=args, # type: ignore
438459
)
439460

440461
self.bm.session.add(relationship)
441462
self.bm.session.flush()
463+
self.logger.debug(f"Created Dependency with id: '{relationship.id}'")
442464

443465
def check_all_template_dependencies(self):
444466
"""
@@ -453,7 +475,7 @@ def check_all_template_dependencies(self):
453475
self.check_template_dependency_relationship(dep)
454476

455477
@lru_cache(maxsize=128)
456-
def check_template_dependency_relationship(self, dep: DepsAssociation):
478+
def check_template_dependency_relationship(self, dep: DBTemplateDependency):
457479
"""Verify that the dependency between two templates is well-formed. This involves
458480
a series of checks:
459481
- existence of the dependent and dependency templates is performed during the
@@ -469,17 +491,17 @@ def check_template_dependency_relationship(self, dep: DepsAssociation):
469491
:raises ValueError: if dependant and dependency template don't share a
470492
library
471493
"""
472-
template_id = dep.dependant_id
473-
dependency_id = dep.dependee_id
494+
template_id = dep.template_id
474495
args = dep.args
475496
self.logger.debug(
476-
f"Creating depencency from templates with ids: '{template_id}' and: '{dependency_id}'"
497+
f"Creating depencency from templates with ids: '{template_id}' and: '{dep.dependency_template_name}'"
477498
)
478499
from buildingmotif.dataclasses import Template
479500

480501
templ = Template.load(template_id)
481502
params = templ.transitive_parameters
482-
dep_templ = Template.load(dependency_id)
503+
504+
dep_templ = Template.load(dep.dependency_template.id)
483505
dep_params = dep_templ.transitive_parameters
484506

485507
# check parameters are valid
@@ -520,11 +542,16 @@ def delete_template_dependency(self, template_id: int, dependency_id: int):
520542
f"Deleting depencency from templates with ids: '{template_id}' and: '{dependency_id}'" # noqa
521543
)
522544

545+
dependency_template = self.get_db_template(dependency_id)
546+
523547
relationship = (
524-
self.bm.session.query(DepsAssociation)
548+
self.bm.session.query(DBTemplateDependency)
525549
.filter(
526-
DepsAssociation.dependant_id == template_id,
527-
DepsAssociation.dependee_id == dependency_id,
550+
DBTemplateDependency.template_id == template_id,
551+
DBTemplateDependency.dependency_library_name
552+
== dependency_template.library.name,
553+
DBTemplateDependency.dependency_template_name
554+
== dependency_template.name,
528555
)
529556
.one()
530557
)

buildingmotif/database/tables.py

Lines changed: 93 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,34 @@
1-
from typing import Dict, List
2-
3-
from sqlalchemy import Column, ForeignKey, Integer, String, Text, UniqueConstraint
4-
from sqlalchemy.orm import Mapped, declarative_base, relationship
1+
from typing import Dict, List, Optional
2+
3+
from sqlalchemy import (
4+
Column,
5+
ForeignKey,
6+
Integer,
7+
String,
8+
Text,
9+
UniqueConstraint,
10+
event,
11+
select,
12+
)
13+
from sqlalchemy.engine import Engine
14+
from sqlalchemy.exc import NoResultFound
15+
from sqlalchemy.ext.hybrid import hybrid_property
16+
from sqlalchemy.orm import Mapped, Session, declarative_base, relationship
517

618
# from sqlalchemy.dialects.postgresql import JSON
719
from buildingmotif.database.utils import JSONType
820

921
Base = declarative_base()
1022

1123

24+
# https://docs.sqlalchemy.org/en/14/dialects/sqlite.html#foreign-key-support
25+
@event.listens_for(Engine, "connect")
26+
def set_sqlite_pragma(dbapi_connection, connection_record):
27+
cursor = dbapi_connection.cursor()
28+
cursor.execute("PRAGMA foreign_keys=ON")
29+
cursor.close()
30+
31+
1232
class DBModel(Base):
1333
"""A Model is a metadata model of all or part of a building."""
1434

@@ -18,12 +38,13 @@ class DBModel(Base):
1838
description: Mapped[str] = Column(Text(), default="", nullable=False)
1939
graph_id: Mapped[str] = Column(String())
2040
manifest_id: Mapped[int] = Column(
21-
Integer, ForeignKey("shape_collection.id"), nullable=False
41+
Integer, ForeignKey("shape_collection.id", ondelete="CASCADE"), nullable=False
2242
)
2343
manifest: "DBShapeCollection" = relationship(
2444
"DBShapeCollection",
2545
uselist=False,
26-
cascade="all,delete",
46+
cascade="all",
47+
passive_deletes=True,
2748
)
2849

2950

@@ -44,38 +65,19 @@ class DBLibrary(Base):
4465
id: Mapped[int] = Column(Integer, primary_key=True)
4566
name: Mapped[str] = Column(String(), nullable=False, unique=True)
4667

68+
# do not use passive_deletes here because we want to handle the deletion of templates
4769
templates: Mapped[List["DBTemplate"]] = relationship(
48-
"DBTemplate", back_populates="library", cascade="all,delete"
70+
"DBTemplate", back_populates="library", cascade="all"
4971
)
5072

5173
shape_collection_id = Column(
52-
Integer, ForeignKey("shape_collection.id"), nullable=False
74+
Integer, ForeignKey("shape_collection.id", ondelete="CASCADE"), nullable=False
5375
)
5476
shape_collection: DBShapeCollection = relationship(
5577
"DBShapeCollection",
5678
uselist=False,
57-
cascade="all,delete",
58-
)
59-
60-
61-
class DepsAssociation(Base):
62-
"""Many-to-many relationship between dependant templates."""
63-
64-
__tablename__ = "deps_association_table"
65-
66-
id: Mapped[int] = Column(Integer, primary_key=True)
67-
dependant_id: Mapped[int] = Column(ForeignKey("template.id"))
68-
dependee_id: Mapped[int] = Column(ForeignKey("template.id"))
69-
# args are a mapping of dependee args to dependant args
70-
args: Mapped[Dict[str, str]] = Column(JSONType) # type: ignore
71-
72-
__table_args__ = (
73-
UniqueConstraint(
74-
"dependant_id",
75-
"dependee_id",
76-
"args",
77-
name="deps_association_unique_constraint",
78-
),
79+
cascade="all",
80+
passive_deletes=True,
7981
)
8082

8183

@@ -89,21 +91,13 @@ class DBTemplate(Base):
8991
body_id: Mapped[str] = Column(String())
9092
optional_args: Mapped[List[str]] = Column(JSONType) # type: ignore
9193

92-
library_id: Mapped[int] = Column(Integer, ForeignKey("library.id"), nullable=False)
93-
library: Mapped[DBLibrary] = relationship("DBLibrary", back_populates="templates")
94-
dependencies: Mapped[List["DBTemplate"]] = relationship(
95-
"DBTemplate",
96-
secondary="deps_association_table",
97-
primaryjoin=id == DepsAssociation.dependant_id,
98-
secondaryjoin=id == DepsAssociation.dependee_id,
99-
back_populates="dependants",
94+
library_id: Mapped[int] = Column(
95+
Integer, ForeignKey("library.id", ondelete="CASCADE"), nullable=False
10096
)
101-
dependants: Mapped[List["DBTemplate"]] = relationship(
102-
"DBTemplate",
103-
secondary="deps_association_table",
104-
primaryjoin=id == DepsAssociation.dependee_id,
105-
secondaryjoin=id == DepsAssociation.dependant_id,
106-
back_populates="dependencies",
97+
library: Mapped[DBLibrary] = relationship("DBLibrary", back_populates="templates")
98+
99+
dependencies: Mapped["DBTemplateDependency"] = relationship(
100+
"DBTemplateDependency", back_populates="template", cascade="all,delete-orphan"
107101
)
108102

109103
__table_args__ = (
@@ -113,3 +107,58 @@ class DBTemplate(Base):
113107
name="name_library_unique_constraint",
114108
),
115109
)
110+
111+
112+
class DBTemplateDependency(Base):
113+
__tablename__ = "template_dependency"
114+
id: Mapped[int] = Column(Integer, primary_key=True)
115+
116+
template_id: Mapped[int] = Column(
117+
Integer, ForeignKey("template.id", ondelete="CASCADE"), nullable=False
118+
)
119+
template: Mapped[DBTemplate] = relationship(
120+
DBTemplate, back_populates="dependencies"
121+
)
122+
123+
dependency_library_name: Mapped[str] = Column(String, nullable=False)
124+
dependency_template_name: Mapped[str] = Column(String, nullable=False)
125+
126+
# args are a mapping of dependee args to dependant args
127+
args: Mapped[Dict[str, str]] = Column(JSONType) # type: ignore
128+
129+
@hybrid_property
130+
def dependency_template(self) -> Optional[DBTemplate]:
131+
session = Session.object_session(self)
132+
statement = (
133+
select(DBTemplate)
134+
.join(DBTemplate.library)
135+
.where(
136+
DBTemplate.name == self.dependency_template_name,
137+
DBLibrary.name == self.dependency_library_name,
138+
)
139+
)
140+
try:
141+
return session.scalars(statement).one()
142+
except NoResultFound:
143+
return None
144+
145+
@dependency_template.expression
146+
def _dependency_tempalate(self):
147+
return (
148+
select(DBTemplate)
149+
.join(DBTemplate.library)
150+
.where(
151+
DBTemplate.name == self.dependency_template_name,
152+
DBLibrary.name == self.dependency_library_name,
153+
)
154+
)
155+
156+
__table_args__ = (
157+
UniqueConstraint(
158+
"template_id",
159+
"dependency_library_name",
160+
"dependency_template_name",
161+
"args",
162+
name="template_dependency_unique_constraint",
163+
),
164+
)

0 commit comments

Comments
 (0)