From 20e426c01bb9bc463e842c69558e55c4b4e54e07 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fran=C3=A7oise=20Conil?= <francoise.conil@liris.cnrs.fr>
Date: Mon, 3 Jan 2022 16:50:25 +0100
Subject: [PATCH] SQLAlchemy : basic relationship patterns

---
 relationship/association_object.py     | 106 ++++++++++++++++++++++
 relationship/basic-relationship.py     |  73 +++++++++++++++
 relationship/many_to_many.delete_pb.py |  91 +++++++++++++++++++
 relationship/many_to_many.py           | 119 +++++++++++++++++++++++++
 relationship/many_to_one.py            |  41 +++++++++
 relationship/one_to_many.1.py          |  35 ++++++++
 relationship/one_to_many.2.py          |  41 +++++++++
 relationship/one_to_one.py             |  75 ++++++++++++++++
 8 files changed, 581 insertions(+)
 create mode 100644 relationship/association_object.py
 create mode 100644 relationship/basic-relationship.py
 create mode 100644 relationship/many_to_many.delete_pb.py
 create mode 100644 relationship/many_to_many.py
 create mode 100644 relationship/many_to_one.py
 create mode 100644 relationship/one_to_many.1.py
 create mode 100644 relationship/one_to_many.2.py
 create mode 100644 relationship/one_to_one.py

diff --git a/relationship/association_object.py b/relationship/association_object.py
new file mode 100644
index 0000000..da637d2
--- /dev/null
+++ b/relationship/association_object.py
@@ -0,0 +1,106 @@
+"""
+https://docs.sqlalchemy.org/en/14/orm/basic_relationships.html#association-object
+
+The association object pattern is a variant on many-to-many: it’s used when
+your association table contains additional columns beyond those which are
+foreign keys to the left and right tables. Instead of using the
+relationship.secondary argument, you map a new class directly to the
+association table. The left side of the relationship references the association
+object via one-to-many, and the association class references the right side
+via many-to-one.
+
+As always, the bidirectional version makes use of relationship.back_populates
+or relationship.backref.
+
+WARNING : A EXPLICITER
+
+The association object pattern does not coordinate changes with a separate
+relationship that maps the association table as “secondary”.
+"""
+
+from sqlalchemy import create_engine
+from sqlalchemy import Column, ForeignKey, Integer, String
+from sqlalchemy.orm import declarative_base, relationship
+from sqlalchemy.orm.session import Session
+
+
+Base = declarative_base()
+
+
+class ParentChild(Base):
+    __tablename__ = "parent_child"
+
+    parent_id = Column(
+        ForeignKey("parent.id", ondelete="CASCADE"), primary_key=True
+    )
+    child_id = Column(
+        ForeignKey("child.id", ondelete="CASCADE"), primary_key=True
+    )
+    # First child, ...
+    rank = Column(Integer)
+
+    # and the association class references the right side via many-to-one
+    # As always, the bidirectional version makes use of
+    # relationship.back_populates or relationship.backref
+    child = relationship("Child", back_populates="parents")
+    parent = relationship("Parent", back_populates="children")
+
+
+class Parent(Base):
+    __tablename__ = "parent"
+
+    id = Column(Integer, primary_key=True)
+    name = Column(String)
+
+    # The left side of the relationship references the association object via
+    # one-to-many
+    children = relationship("ParentChild", back_populates="parent")
+
+    def __repr__(self):
+        return f"<Parent (name={self.name}, children={self.children})>"
+
+
+class Child(Base):
+    __tablename__ = "child"
+
+    id = Column(Integer, primary_key=True)
+    name = Column(String)
+
+    parents = relationship("ParentChild", back_populates="child")
+
+    def __repr__(self):
+        return f"<Child (name={self.name})>"
+
+
+if __name__ == "__main__":
+    engine = create_engine("sqlite:///association_object.db", echo=False)
+
+    Base.metadata.drop_all(engine)
+    Base.metadata.create_all(engine)
+
+    # cf ../basic-session.1.4.py
+    with Session(engine) as session:
+        # Working with the association pattern in its direct form requires that
+        # child objects are associated with an association instance before
+        # being appended to the parent; similarly, access from parent to child
+        # goes through the association object
+
+        # create parent, append a child via association
+        jack = Parent(name="Jack")
+        john = Child(name="John")
+
+        pc = ParentChild(rank=1)
+        pc.child = john
+        jack.children.append(pc)
+
+        with session.begin():
+            session.add(jack)
+            session.add(pc)
+
+        # iterate through child objects via association, including association
+        # attributes
+        for assoc in jack.children:
+            print(f"{assoc.rank=}")
+            print(f"{assoc.child=}")
+
+        print(jack)
diff --git a/relationship/basic-relationship.py b/relationship/basic-relationship.py
new file mode 100644
index 0000000..acbfb9d
--- /dev/null
+++ b/relationship/basic-relationship.py
@@ -0,0 +1,73 @@
+"""
+https://docs.sqlalchemy.org/en/14/orm/tutorial.html#building-a-relationship
+https://docs.sqlalchemy.org/en/14/orm/basic_relationships.html
+
+The two complementing relationships Address.user and User.addresses are referred
+to as a bidirectional relationship, and is a key feature of the SQLAlchemy ORM.
+"""
+from sqlalchemy import create_engine
+from sqlalchemy import Column, ForeignKey, Integer, String
+from sqlalchemy.orm import declarative_base, relationship
+from sqlalchemy.orm.session import Session
+
+Base = declarative_base()
+
+
+class Address(Base):
+    __tablename__ = "addresses"
+
+    id = Column(Integer, primary_key=True)
+    email_address = Column(String, nullable=True)
+    user_id = Column(Integer, ForeignKey("users.id"))
+
+    user = relationship("User", back_populates="addresses")
+
+    def __repr__(self):
+        return (
+            f"<Address (email_address={self.email_address}, user={self.user})>"
+        )
+
+
+class User(Base):
+    __tablename__ = "users"
+
+    id = Column(Integer, primary_key=True)
+    name = Column(String)
+    fullname = Column(String)
+    nickname = Column(String)
+
+    addresses = relationship(
+        "Address", order_by="Address.id", back_populates="user"
+    )
+
+    def __repr__(self):
+        return (
+            f"<User (name={self.name}, fullname={self.fullname},"
+            f"nickname={self.nickname}, addresses={self.addresses})>"
+        )
+
+
+if __name__ == "__main__":
+    engine = create_engine("sqlite:///building_a_relationship.db", echo=False)
+
+    Base.metadata.drop_all(engine)
+    Base.metadata.create_all(engine)
+
+    # cf ../basic-session.1.4.py
+    with Session(engine) as session:
+        with session.begin():
+            ed_user = User(
+                name="ed", fullname="Ed Jones", nickname="edsnickname"
+            )
+            session.add(ed_user)
+
+            jack = User(name="jack", fullname="Jack Bean", nickname="gjffdd")
+
+            addr1 = Address(email_address="jack@google.com")
+            addr2 = Address(email_address="j25@yahoo.com")
+            jack.addresses = [addr1, addr2]
+
+            session.add(jack)
+
+            print(jack)
+            print(addr1)
diff --git a/relationship/many_to_many.delete_pb.py b/relationship/many_to_many.delete_pb.py
new file mode 100644
index 0000000..79cbc07
--- /dev/null
+++ b/relationship/many_to_many.delete_pb.py
@@ -0,0 +1,91 @@
+"""
+https://docs.sqlalchemy.org/en/14/orm/basic_relationships.html
+
+Many to Many adds an association table between two classes. The association
+table is indicated by the relationship.secondary argument to relationship().
+
+Usually, the Table uses the MetaData object associated with the declarative
+base class, so that the ForeignKey directives can locate the remote tables
+with which to link.
+
+It is also recommended, though not in any way required by SQLAlchemy, that
+the columns which refer to the two entity tables are established within either
+a unique constraint or more commonly as the primary key constraint; this
+ensures that duplicate rows won’t be persisted within the table regardless of
+issues on the application side.
+"""
+
+from sqlalchemy import create_engine
+from sqlalchemy import Column, ForeignKey, Integer, String, Table
+from sqlalchemy.orm import declarative_base, relationship
+from sqlalchemy.orm.session import Session
+
+
+Base = declarative_base()
+
+parent_child_table = Table(
+    "parent_child",
+    Base.metadata,
+    # Create a composed primary key : PRIMARY KEY (parent_id, child_id)
+    Column("parent_id", ForeignKey("parent.id"), primary_key=True),
+    Column("child_id", ForeignKey("child.id"), primary_key=True),
+)
+
+
+class Parent(Base):
+    __tablename__ = "parent"
+
+    id = Column(Integer, primary_key=True)
+    name = Column(String)
+    children = relationship(
+        "Child", secondary=parent_child_table, back_populates="parents"
+    )
+
+    def __repr__(self):
+        return f"<Parent (name={self.name}, children={self.children})>"  # , children={self.children})>"  # noqa E501
+
+
+class Child(Base):
+    __tablename__ = "child"
+
+    id = Column(Integer, primary_key=True)
+    name = Column(String)
+    parents = relationship(
+        "Parent", secondary=parent_child_table, back_populates="children"
+    )
+
+    def __repr__(self):
+        return f"<Child (name={self.name})>"  # , parents={self.parents})>"
+
+
+if __name__ == "__main__":
+    engine = create_engine("sqlite:///many_to_many.delete_pb.db", echo=False)
+
+    Base.metadata.drop_all(engine)
+    Base.metadata.create_all(engine)
+
+    # cf ../basic-session.1.4.py
+    with Session(engine) as session:
+        jack = Parent(name="Jack")
+        wendy = Parent(name="Wendy")
+        jim = Parent(name="Jim")
+
+        john = Child(name="John")
+        alice = Child(name="Alice")
+        john.parents = [jack, wendy]
+        alice.parents = [wendy, jim]
+
+        with session.begin():
+            session.add(jack)
+            session.add(wendy)
+            session.add(jim)
+
+            session.add(john)
+            session.add(alice)
+
+        with session.begin():
+            # wendy.children.remove(john)
+            session.delete(alice)
+
+        print(wendy)
+        print(john)
diff --git a/relationship/many_to_many.py b/relationship/many_to_many.py
new file mode 100644
index 0000000..6cdca4b
--- /dev/null
+++ b/relationship/many_to_many.py
@@ -0,0 +1,119 @@
+"""
+https://docs.sqlalchemy.org/en/14/orm/basic_relationships.html
+
+Many to Many adds an association table between two classes. The association
+table is indicated by the relationship.secondary argument to relationship().
+
+Usually, the Table uses the MetaData object associated with the declarative
+base class, so that the ForeignKey directives can locate the remote tables
+with which to link.
+
+It is also recommended, though not in any way required by SQLAlchemy, that
+the columns which refer to the two entity tables are established within either
+a unique constraint or more commonly as the primary key constraint; this
+ensures that duplicate rows won’t be persisted within the table regardless of
+issues on the application side.
+
+DELETE PB
+
+https://docs.sqlalchemy.org/en/14/orm/basic_relationships.html#deleting-rows-from-the-many-to-many-table
+https://docs.sqlalchemy.org/en/14/orm/cascades.html#passive-deletes
+https://docs.sqlalchemy.org/en/14/orm/relationship_api.html
+
+In order to use ON DELETE foreign key cascades in conjunction with
+relationship(), it’s important to note first and foremost that the
+relationship.cascade setting must still be configured to match the
+desired “delete” or “set null” behavior (using delete cascade or
+leaving it omitted).
+
+Le comportement du DELETE est plus clair dans la page suivante :
+
+https://docs.sqlalchemy.org/en/14/orm/session_basics.html#deleting
+"""
+
+from sqlalchemy import create_engine
+from sqlalchemy import Column, ForeignKey, Integer, String, Table
+from sqlalchemy.orm import declarative_base, relationship
+from sqlalchemy.orm.session import Session
+
+
+Base = declarative_base()
+
+parent_child_table = Table(
+    "parent_child",
+    Base.metadata,
+    # Create a composed primary key : PRIMARY KEY (parent_id, child_id)
+    Column(
+        "parent_id",
+        ForeignKey("parent.id", ondelete="CASCADE"),
+        primary_key=True,
+    ),
+    Column(
+        "child_id", ForeignKey("child.id", ondelete="CASCADE"), primary_key=True
+    ),
+)
+
+
+class Parent(Base):
+    __tablename__ = "parent"
+
+    id = Column(Integer, primary_key=True)
+    name = Column(String)
+    children = relationship(
+        "Child",
+        secondary=parent_child_table,
+        back_populates="parents",
+        cascade="all, delete",
+    )
+
+    def __repr__(self):
+        return f"<Parent (name={self.name}, children={self.children})>"  # , children={self.children})>"  # noqa E501
+
+
+class Child(Base):
+    __tablename__ = "child"
+
+    id = Column(Integer, primary_key=True)
+    name = Column(String)
+    parents = relationship(
+        "Parent",
+        secondary=parent_child_table,
+        back_populates="children",
+        passive_deletes=True,
+    )
+
+    def __repr__(self):
+        return f"<Child (name={self.name})>"  # , parents={self.parents})>"
+
+
+if __name__ == "__main__":
+    engine = create_engine("sqlite:///many_to_many.db", echo=False)
+
+    Base.metadata.drop_all(engine)
+    Base.metadata.create_all(engine)
+
+    # cf ../basic-session.1.4.py
+    with Session(engine) as session:
+        jack = Parent(name="Jack")
+        wendy = Parent(name="Wendy")
+        jim = Parent(name="Jim")
+
+        john = Child(name="John")
+        alice = Child(name="Alice")
+        john.parents = [jack, wendy]
+        alice.parents = [wendy, jim]
+
+        with session.begin():
+            session.add(jack)
+            session.add(wendy)
+            session.add(jim)
+
+            session.add(john)
+            session.add(alice)
+
+        with session.begin():
+            # wendy.children.remove(john)
+            session.delete(alice)
+
+        print(wendy)
+        print(john)
diff --git a/relationship/many_to_one.py b/relationship/many_to_one.py
new file mode 100644
index 0000000..3196944
--- /dev/null
+++ b/relationship/many_to_one.py
@@ -0,0 +1,41 @@
+"""
+https://docs.sqlalchemy.org/en/14/orm/basic_relationships.html
+
+Je n'arrive pas à comprendre la différence avec l'exemple one-to-many
+puisque dans ce cas on semble avoir plusieurs parents associés à un enfant.
+"""
+
+from sqlalchemy import create_engine
+from sqlalchemy import Column, ForeignKey, Integer
+from sqlalchemy.orm import declarative_base, relationship
+from sqlalchemy.orm.session import Session
+
+Base = declarative_base()
+
+
+class Parent(Base):
+    __tablename__ = "parent"
+
+    id = Column(Integer, primary_key=True)
+    child_id = Column(Integer, ForeignKey("child.id"))
+    child = relationship("Child", back_populates="parents")
+
+
+class Child(Base):
+    __tablename__ = "child"
+
+    id = Column(Integer, primary_key=True)
+
+    parents = relationship("Parent", back_populates="child")
+
+
+if __name__ == "__main__":
+    engine = create_engine("sqlite:///many_to_one.db", echo=False)
+
+    Base.metadata.drop_all(engine)
+    Base.metadata.create_all(engine)
+
+    # cf ../basic-session.1.4.py
+    with Session(engine) as session:
+        with session.begin():
+            pass
diff --git a/relationship/one_to_many.1.py b/relationship/one_to_many.1.py
new file mode 100644
index 0000000..4215fbd
--- /dev/null
+++ b/relationship/one_to_many.1.py
@@ -0,0 +1,35 @@
+"""
+https://docs.sqlalchemy.org/en/14/orm/basic_relationships.html
+"""
+from sqlalchemy import create_engine
+from sqlalchemy import Column, ForeignKey, Integer
+from sqlalchemy.orm import declarative_base, relationship
+from sqlalchemy.orm.session import Session
+
+Base = declarative_base()
+
+
+class Parent(Base):
+    __tablename__ = "parent"
+
+    id = Column(Integer, primary_key=True)
+    children = relationship("Child")
+
+
+class Child(Base):
+    __tablename__ = "child"
+
+    id = Column(Integer, primary_key=True)
+    parent_id = Column(Integer, ForeignKey("parent.id"))
+
+
+if __name__ == "__main__":
+    engine = create_engine("sqlite:///one_to_many.1.db", echo=False)
+
+    Base.metadata.drop_all(engine)
+    Base.metadata.create_all(engine)
+
+    # cf ../basic-session.1.4.py
+    with Session(engine) as session:
+        with session.begin():
+            pass
diff --git a/relationship/one_to_many.2.py b/relationship/one_to_many.2.py
new file mode 100644
index 0000000..e9855e9
--- /dev/null
+++ b/relationship/one_to_many.2.py
@@ -0,0 +1,41 @@
+"""
+https://docs.sqlalchemy.org/en/14/orm/basic_relationships.html
+
+To establish a bidirectional relationship in one-to-many, where the “reverse” side is a
+many to one, specify an additional relationship() and connect the two using the
+relationship.back_populates parameter.
+"""
+from sqlalchemy import create_engine
+from sqlalchemy import Column, ForeignKey, Integer
+from sqlalchemy.orm import declarative_base, relationship
+from sqlalchemy.orm.session import Session
+
+Base = declarative_base()
+
+
+class Parent(Base):
+    __tablename__ = "parent"
+
+    id = Column(Integer, primary_key=True)
+    children = relationship("Child", back_populates="parent")
+
+
+class Child(Base):
+    __tablename__ = "child"
+
+    id = Column(Integer, primary_key=True)
+    parent_id = Column(Integer, ForeignKey("parent.id"))
+
+    parent = relationship("Parent", back_populates="children")
+
+
+if __name__ == "__main__":
+    engine = create_engine("sqlite:///one_to_many.2.db", echo=False)
+
+    Base.metadata.drop_all(engine)
+    Base.metadata.create_all(engine)
+
+    # cf ../basic-session.1.4.py
+    with Session(engine) as session:
+        with session.begin():
+            pass
diff --git a/relationship/one_to_one.py b/relationship/one_to_one.py
new file mode 100644
index 0000000..1b62262
--- /dev/null
+++ b/relationship/one_to_one.py
@@ -0,0 +1,75 @@
+"""
+https://docs.sqlalchemy.org/en/14/orm/basic_relationships.html
+
+Pourquoi ne pas utiliser ce que Nicolas m'a indiqué avec "unique" ?
+=> Si c'est évoqué dans le 3ème paragraphe
+
+The “one-to-one” convention is achieved by applying a value of False
+to the relationship.uselist parameter of the relationship() construct,
+
+As mentioned previously, the ORM considers the “one-to-one” pattern as
+a convention, where it makes the assumption that when it loads the
+Parent.child attribute on a Parent object, it will get only one row back.
+If more than one row is returned, the ORM will emit a warning.
+
+However, the Child.parent side of the above relationship remains as a
+“many-to-one” relationship and is unchanged, and there is no intrinsic
+system within the ORM itself that prevents more than one Child object
+to be created against the same Parent during persistence.
+Instead, techniques such as unique constraints may be used in the actual
+database schema to enforce this arrangement, where a unique constraint on
+the Child.parent_id column would ensure that only one Child row may refer
+to a particular Parent row at a time.
+"""
+
+from sqlalchemy import create_engine
+from sqlalchemy import Column, ForeignKey, Integer, String
+from sqlalchemy.orm import declarative_base, relationship
+from sqlalchemy.orm.session import Session
+
+Base = declarative_base()
+
+
+class Parent(Base):
+    __tablename__ = "parent"
+
+    id = Column(Integer, primary_key=True)
+    name = Column(String)
+    child = relationship("Child", back_populates="parent", uselist=False)
+
+    def __repr__(self):
+        return f"<Parent (name={self.name})>"
+
+
+class Child(Base):
+    __tablename__ = "child"
+
+    id = Column(Integer, primary_key=True)
+    name = Column(String)
+    # TODO : ajouter une contrainte "unique" sur la colonne
+    parent_id = Column(Integer, ForeignKey("parent.id"), unique=True)
+
+    parent = relationship("Parent", back_populates="child")
+
+    def __repr__(self):
+        return f"<Child (name={self.name}, parent={self.parent})>"
+
+
+if __name__ == "__main__":
+    engine = create_engine("sqlite:///one_to_one.db", echo=False)
+
+    Base.metadata.drop_all(engine)
+    Base.metadata.create_all(engine)
+
+    # cf ../basic-session.1.4.py
+    with Session(engine) as session:
+        with session.begin():
+            jack = Parent(name="Jack")
+            session.add(jack)
+
+            john = Child(name="John")
+            john.parent = jack
+            session.add(john)
+
+            print(jack)
+            print(john)
-- 
GitLab