邻接列表关系
邻接列表模式是一种常见的关系模式,其中表包含对自身的外键引用,换句话说是自引用关系。这是在平面表中表示层次数据的最常见方法。其他方法包括嵌套集,有时称为“修改的先序”,以及材料路径。尽管在 SQL 查询中评估其流畅性时修改的先序具有吸引力,但邻接列表模型可能是满足大多数层次存储需求的最合适模式,原因是并发性、减少的复杂性,以及修改的先序对于能够完全加载子树到应用程序空间的应用程序几乎没有优势。
另请参阅
此部分详细说明了自引用关系的单表版本。有关使用第二个表作为关联表的自引用关系,请参阅自引用多对多关系部分。
在本示例中,我们将使用一个名为Node
的单个映射类,表示树结构:
class Node(Base): __tablename__ = "node" id = mapped_column(Integer, primary_key=True) parent_id = mapped_column(Integer, ForeignKey("node.id")) data = mapped_column(String(50)) children = relationship("Node")
使用此结构,可以构建如下的图形:
root --+---> child1 +---> child2 --+--> subchild1 | +--> subchild2 +---> child3
可以用数据表示为:
id parent_id data --- ------- ---- 1 NULL root 2 1 child1 3 1 child2 4 3 subchild1 5 3 subchild2 6 1 child3
这里的relationship()
配置与“正常”的一对多关系的工作方式相同,唯一的例外是,“方向”,即关系是一对多还是多对一,默认假定为一对多。要将关系建立为多对一,需要添加一个额外的指令,称为relationship.remote_side
,它是一个Column
或一组Column
对象,指示应该被视为“远程”的对象:
class Node(Base): __tablename__ = "node" id = mapped_column(Integer, primary_key=True) parent_id = mapped_column(Integer, ForeignKey("node.id")) data = mapped_column(String(50)) parent = relationship("Node", remote_side=[id])
在上述情况下,id
列被应用为parent
relationship()
的relationship.remote_side
,从而将parent_id
建立为“本地”端,并且关系随后表现为多对一。
一如既往,两个方向可以结合成一个双向关系,使用两个由relationship.back_populates
链接的relationship()
构造。
class Node(Base): __tablename__ = "node" id = mapped_column(Integer, primary_key=True) parent_id = mapped_column(Integer, ForeignKey("node.id")) data = mapped_column(String(50)) children = relationship("Node", back_populates="parent") parent = relationship("Node", back_populates="children", remote_side=[id])
另请参阅
邻接列表 - 更新为 SQLAlchemy 2.0 的工作示例
复合邻接列表
邻接列表关系的一个子类别是在连接条件的“本地”和“远程”两侧都存在特定列的罕见情况。下面是Folder
类的一个示例;使用复合主键,account_id
列指向自身,以指示位于与父文件夹相同帐户内的子文件夹;而folder_id
则指向该帐户内的特定文件夹:
class Folder(Base): __tablename__ = "folder" __table_args__ = ( ForeignKeyConstraint( ["account_id", "parent_id"], ["folder.account_id", "folder.folder_id"] ), ) account_id = mapped_column(Integer, primary_key=True) folder_id = mapped_column(Integer, primary_key=True) parent_id = mapped_column(Integer) name = mapped_column(String) parent_folder = relationship( "Folder", back_populates="child_folders", remote_side=[account_id, folder_id] ) child_folders = relationship("Folder", back_populates="parent_folder")
在上述示例中,我们将account_id
传递到relationship.remote_side
列表中。relationship()
识别到这里的account_id
列在两侧都存在,并将“远程”列与它识别为唯一存在于“远程”侧的folder_id
列对齐。
自引用查询策略
查询自引用结构的方式与任何其他查询相同:
# get all nodes named 'child2' session.scalars(select(Node).where(Node.data == "child2"))
但是,当尝试沿着树的一个级别从一个外键连接到下一个级别时,需要额外小心。在 SQL 中,从表连接到自身的连接需要至少对表达式的一侧进行“别名”,以便可以明确引用它。
请回想一下 ORM 教程中的选择 ORM 别名,aliased()
结构通常用于提供 ORM 实体的“别名”。使用此技术从Node
连接到自身的连接如下所示:
from sqlalchemy.orm import aliased nodealias = aliased(Node) session.scalars( select(Node) .where(Node.data == "subchild1") .join(Node.parent.of_type(nodealias)) .where(nodealias.data == "child2") ).all() SELECT node.id AS node_id, node.parent_id AS node_parent_id, node.data AS node_data FROM node JOIN node AS node_1 ON node.parent_id = node_1.id WHERE node.data = ? AND node_1.data = ? ['subchild1', 'child2'] ```## 配置自引用的急切加载 在正常查询操作期间,通过从父表到子表的连接或外连接来发生关系的急切加载,以便可以从单个 SQL 语句或所有子集合的第二个语句中填充父对象及其直接子集合或引用。SQLAlchemy 的连接和子查询急切加载在连接到相关项时在所有情况下使用别名表,因此与自引用连接兼容。然而,要使用自引用关系进行急切加载,SQLAlchemy 需要告知应该连接和/或查询多少级深度;否则,急切加载将根本不会发生。此深度设置通过`relationships.join_depth`进行配置: ```py class Node(Base): __tablename__ = "node" id = mapped_column(Integer, primary_key=True) parent_id = mapped_column(Integer, ForeignKey("node.id")) data = mapped_column(String(50)) children = relationship("Node", lazy="joined", join_depth=2) session.scalars(select(Node)).all() SELECT node_1.id AS node_1_id, node_1.parent_id AS node_1_parent_id, node_1.data AS node_1_data, node_2.id AS node_2_id, node_2.parent_id AS node_2_parent_id, node_2.data AS node_2_data, node.id AS node_id, node.parent_id AS node_parent_id, node.data AS node_data FROM node LEFT OUTER JOIN node AS node_2 ON node.id = node_2.parent_id LEFT OUTER JOIN node AS node_1 ON node_2.id = node_1.parent_id []
复合邻接列表
邻接列表关系的一个子类别是在连接条件的“本地”和“远程”两侧都存在特定列的罕见情况。下面是Folder
类的一个示例;使用复合主键,account_id
列指向自身,以指示位于与父文件夹相同帐户内的子文件夹;而folder_id
则指向该帐户内的特定文件夹:
class Folder(Base): __tablename__ = "folder" __table_args__ = ( ForeignKeyConstraint( ["account_id", "parent_id"], ["folder.account_id", "folder.folder_id"] ), ) account_id = mapped_column(Integer, primary_key=True) folder_id = mapped_column(Integer, primary_key=True) parent_id = mapped_column(Integer) name = mapped_column(String) parent_folder = relationship( "Folder", back_populates="child_folders", remote_side=[account_id, folder_id] ) child_folders = relationship("Folder", back_populates="parent_folder")
在上面的例子中,我们将account_id
传递到relationship.remote_side
列表中。relationship()
识别到这里的account_id
列在两侧均存在,并且将“远程”列与它识别为唯一存在于“远程”一侧的folder_id
列对齐。
自引用查询策略
自引用结构的查询与任何其他查询相同:
# get all nodes named 'child2' session.scalars(select(Node).where(Node.data == "child2"))
但是,在尝试从树的一级到下一级进行连接时需要特别注意。在 SQL 中,从表连接到自身需要至少一个表达式的一侧被“别名”,以便可以明确地引用它。
请回想一下在 ORM 教程中选择 ORM 别名,aliased()
构造通常用于提供 ORM 实体的“别名”。使用这种技术从Node
到自身的连接看起来像:
from sqlalchemy.orm import aliased nodealias = aliased(Node) session.scalars( select(Node) .where(Node.data == "subchild1") .join(Node.parent.of_type(nodealias)) .where(nodealias.data == "child2") ).all() SELECT node.id AS node_id, node.parent_id AS node_parent_id, node.data AS node_data FROM node JOIN node AS node_1 ON node.parent_id = node_1.id WHERE node.data = ? AND node_1.data = ? ['subchild1', 'child2']
配置自引用关系的急切加载
通过在正常查询操作期间从父表到子表使用连接或外连接来进行关系的急切加载,以便可以从单个 SQL 语句或所有直接子集合的第二个语句中填充父表及其直接子集合或引用。SQLAlchemy 的连接和子查询急切加载在加入相关项时始终使用别名表,因此与自引用连接兼容。然而,要想使用自引用关系的急切加载,需要告诉 SQLAlchemy 应该加入和/或查询多少级深度;否则,急切加载将根本不会发生。此深度设置通过relationships.join_depth
进行配置:
class Node(Base): __tablename__ = "node" id = mapped_column(Integer, primary_key=True) parent_id = mapped_column(Integer, ForeignKey("node.id")) data = mapped_column(String(50)) children = relationship("Node", lazy="joined", join_depth=2) session.scalars(select(Node)).all() SELECT node_1.id AS node_1_id, node_1.parent_id AS node_1_parent_id, node_1.data AS node_1_data, node_2.id AS node_2_id, node_2.parent_id AS node_2_parent_id, node_2.data AS node_2_data, node.id AS node_id, node.parent_id AS node_parent_id, node.data AS node_data FROM node LEFT OUTER JOIN node AS node_2 ON node.id = node_2.parent_id LEFT OUTER JOIN node AS node_1 ON node_2.id = node_1.parent_id []
配置关系连接
relationship()
通常会通过检查两个表之间的外键关系来创建两个表之间的连接,以确定应该比较哪些列。有各种情况需要定制此行为。
处理多个连接路径
处理的最常见情况之一是两个表之间存在多个外键路径时。
考虑一个包含两个外键到Address
类的Customer
类:
from sqlalchemy import Integer, ForeignKey, String, Column from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import relationship class Base(DeclarativeBase): pass class Customer(Base): __tablename__ = "customer" id = mapped_column(Integer, primary_key=True) name = mapped_column(String) billing_address_id = mapped_column(Integer, ForeignKey("address.id")) shipping_address_id = mapped_column(Integer, ForeignKey("address.id")) billing_address = relationship("Address") shipping_address = relationship("Address") class Address(Base): __tablename__ = "address" id = mapped_column(Integer, primary_key=True) street = mapped_column(String) city = mapped_column(String) state = mapped_column(String) zip = mapped_column(String)
当我们尝试使用上述映射时,将产生错误:
sqlalchemy.exc.AmbiguousForeignKeysError: Could not determine join condition between parent/child tables on relationship Customer.billing_address - there are multiple foreign key paths linking the tables. Specify the 'foreign_keys' argument, providing a list of those columns which should be counted as containing a foreign key reference to the parent table.
上述消息相当长。relationship()
可以返回许多潜在消息,这些消息已经被精心设计用于检测各种常见的配置问题;大多数都会建议需要哪些额外配置来解决模糊或其他缺失的信息。
在这种情况下,消息希望我们通过为每个指定的relationship()
指定应该考虑哪个外键列来修饰每一个,并且适当的形式如下:
class Customer(Base): __tablename__ = "customer" id = mapped_column(Integer, primary_key=True) name = mapped_column(String) billing_address_id = mapped_column(Integer, ForeignKey("address.id")) shipping_address_id = mapped_column(Integer, ForeignKey("address.id")) billing_address = relationship("Address", foreign_keys=[billing_address_id]) shipping_address = relationship("Address", foreign_keys=[shipping_address_id])
在上面,我们指定了foreign_keys
参数,它是一个Column
或Column
对象的列表,指示要考虑的“外键”列,或者换句话说,包含引用父表的值的列。从Customer
对象加载Customer.billing_address
关系将使用billing_address_id
中的值来标识要加载的Address
行;类似地,shipping_address_id
用于shipping_address
关系。这两列的关联在持久性期间也起到了作用;刚刚插入的Address
对象的新生成的主键将在刷新期间复制到关联的Customer
对象的适当外键列中。
在使用 Declarative 指定foreign_keys
时,我们还可以使用字符串名称进行指定,但是如果使用列表,列表应该是字符串的一部分是很重要的:
billing_address = relationship("Address", foreign_keys="[Customer.billing_address_id]")
在这个具体的例子中,在任何情况下列表都是不必要的,因为我们只需要一个Column
:
billing_address = relationship("Address", foreign_keys="Customer.billing_address_id")
警告
当作为 Python 可执行字符串传递时,relationship.foreign_keys
参数将使用 Python 的 eval()
函数进行解释。请勿将不受信任的输入传递给此字符串。详细信息请参见关系参数的评估。
relationship()
在构建连接时的默认行为是将一侧的主键列的值等同于另一侧的外键引用列的值。我们可以使用 relationship.primaryjoin
参数来更改此条件,以及在使用“次要”表时,还可以使用 relationship.secondaryjoin
参数。
在下面的示例中,我们使用 User
类以及存储街道地址的 Address
类来创建一个关系 boston_addresses
,它将仅加载指定城市为“波士顿”的 Address
对象:
from sqlalchemy import Integer, ForeignKey, String, Column from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import relationship class Base(DeclarativeBase): pass class User(Base): __tablename__ = "user" id = mapped_column(Integer, primary_key=True) name = mapped_column(String) boston_addresses = relationship( "Address", primaryjoin="and_(User.id==Address.user_id, Address.city=='Boston')", ) class Address(Base): __tablename__ = "address" id = mapped_column(Integer, primary_key=True) user_id = mapped_column(Integer, ForeignKey("user.id")) street = mapped_column(String) city = mapped_column(String) state = mapped_column(String) zip = mapped_column(String)
在此字符串 SQL 表达式中,我们使用了 and_()
连接构造来为连接条件建立两个不同的谓词 - 将 User.id
和 Address.user_id
列相互连接,同时将 Address
中的行限制为只有 city='Boston'
。在使用声明式时,诸如 and_()
这样的基本 SQL 函数会自动在字符串 relationship()
参数的评估命名空间中可用。
警告
当作为 Python 可执行字符串传递时,relationship.primaryjoin
参数将使用 Python 的 eval()
函数进行解释。请勿将不受信任的输入传递给此字符串。详细信息请参见关系参数的评估。
我们在relationship.primaryjoin
中使用的自定义标准通常只在 SQLAlchemy 渲染 SQL 以加载或表示此关系时才重要。也就是说,它用于在执行每个属性的延迟加载时发出的 SQL 语句中,或者在查询时构造联接时,例如通过Select.join()
或通过渴望的“joined”或“subquery”加载样式。当操作内存中的对象时,我们可以将任何我们想要的Address
对象放入boston_addresses
集合中,而不管.city
属性的值是什么。这些对象将一直保留在集合中,直到属性过期并重新从应用准则的数据库中加载为止。当发生刷新时,boston_addresses
内的对象将被无条件地刷新,将主键user.id
列的值分配给每一行的持有外键的address.user_id
列。在这里,city
标准没有影响,因为刷新过程只关心将主键值同步到引用外键值中。## 创建自定义外键条件
主要连接条件的另一个元素是如何确定那些被认为是“外部”的列的。通常,一些Column
对象的子集将指定ForeignKey
,或者否则将是与连接条件相关的ForeignKeyConstraint
的一部分。relationship()
查找此外键状态,因为它决定了它应该如何加载和持久化此关系的数据。然而,relationship.primaryjoin
参数可以用来创建不涉及任何“架构”级外键的连接条件。我们可以结合relationship.primaryjoin
以及relationship.foreign_keys
和relationship.remote_side
显式地建立这样一个连接。
下面,一个名为HostEntry
的类与自身连接,将字符串content
列与ip_address
列相等,后者是一种名为INET
的 PostgreSQL 类型。我们需要使用cast()
来将连接的一侧转换为另一侧的类型:
from sqlalchemy import cast, String, Column, Integer from sqlalchemy.orm import relationship from sqlalchemy.dialects.postgresql import INET from sqlalchemy.orm import DeclarativeBase class Base(DeclarativeBase): pass class HostEntry(Base): __tablename__ = "host_entry" id = mapped_column(Integer, primary_key=True) ip_address = mapped_column(INET) content = mapped_column(String(50)) # relationship() using explicit foreign_keys, remote_side parent_host = relationship( "HostEntry", primaryjoin=ip_address == cast(content, INET), foreign_keys=content, remote_side=ip_address, )
上述关系将产生如下连接:
SELECT host_entry.id, host_entry.ip_address, host_entry.content FROM host_entry JOIN host_entry AS host_entry_1 ON host_entry_1.ip_address = CAST(host_entry.content AS INET)
上述的另一种替代语法是在relationship.primaryjoin
表达式内联使用foreign()
和remote()
注释。此语法表示了relationship()
通常自动应用于连接条件的注释,给定了relationship.foreign_keys
和relationship.remote_side
参数。当存在显式连接条件时,这些函数可能更加简洁,并且还标记了“外键”或“远程”列的确切位置,无论该列是否多次声明或在复杂的 SQL 表达式中声明:
from sqlalchemy.orm import foreign, remote class HostEntry(Base): __tablename__ = "host_entry" id = mapped_column(Integer, primary_key=True) ip_address = mapped_column(INET) content = mapped_column(String(50)) # relationship() using explicit foreign() and remote() annotations # in lieu of separate arguments parent_host = relationship( "HostEntry", primaryjoin=remote(ip_address) == cast(foreign(content), INET), ) ```## 在连接条件中使用自定义运算符 关于关系的另一个用例是使用自定义运算符,例如在与`INET`和`CIDR`等类型结合时,使用 PostgreSQL 的“包含于”`<<`运算符。对于自定义布尔运算符,我们使用`Operators.bool_op()`函数: ```py inet_column.bool_op("<<")(cidr_column)
类似上述的比较可以直接在构建relationship()
时,与relationship.primaryjoin
一起使用:
class IPA(Base): __tablename__ = "ip_address" id = mapped_column(Integer, primary_key=True) v4address = mapped_column(INET) network = relationship( "Network", primaryjoin="IPA.v4address.bool_op('<<')(foreign(Network.v4representation))", viewonly=True, ) class Network(Base): __tablename__ = "network" id = mapped_column(Integer, primary_key=True) v4representation = mapped_column(CIDR)
上面的查询如下:
select(IPA).join(IPA.network
将呈现为:
SELECT ip_address.id AS ip_address_id, ip_address.v4address AS ip_address_v4address FROM ip_address JOIN network ON ip_address.v4address << network.v4representation ```## 基于 SQL 函数的自定义运算符 `Operators.op.is_comparison`的用例的一种变体是当我们不使用运算符,而是使用 SQL 函数时。这种用例的典型示例是 PostgreSQL PostGIS 函数,但任何数据库中解析为二进制条件的 SQL 函数都可以应用。为了适应这种用例,`FunctionElement.as_comparison()`方法可以修改任何 SQL 函数,例如从`func`命名空间调用的函数,以指示 ORM 函数生成两个表达式的比较。下面的示例用[Geoalchemy2](https://geoalchemy-2.readthedocs.io/)库说明了这一点: ```py from geoalchemy2 import Geometry from sqlalchemy import Column, Integer, func from sqlalchemy.orm import relationship, foreign class Polygon(Base): __tablename__ = "polygon" id = mapped_column(Integer, primary_key=True) geom = mapped_column(Geometry("POLYGON", srid=4326)) points = relationship( "Point", primaryjoin="func.ST_Contains(foreign(Polygon.geom), Point.geom).as_comparison(1, 2)", viewonly=True, ) class Point(Base): __tablename__ = "point" id = mapped_column(Integer, primary_key=True) geom = mapped_column(Geometry("POINT", srid=4326))
在上面,FunctionElement.as_comparison()
表明func.ST_Contains()
SQL 函数正在比较Polygon.geom
和Point.geom
表达式。foreign()
注释另外指出了在此特定关系中哪个列承担“外键”角色。
1.3 版本中的新增功能:添加了FunctionElement.as_comparison()
。## 重叠的外键
当使用复合外键时,可能会出现罕见的情况,使得单个列可能是通过外键约束引用的多个列的主题。
考虑一个(诚然复杂的)映射,如Magazine
对象,由Writer
对象和Article
对象使用包含magazine_id
的复合主键方案引用;然后为了使Article
也引用Writer
,Article.magazine_id
涉及到两个单独的关系;Article.magazine
和Article.writer
:
class Magazine(Base): __tablename__ = "magazine" id = mapped_column(Integer, primary_key=True) class Article(Base): __tablename__ = "article" article_id = mapped_column(Integer) magazine_id = mapped_column(ForeignKey("magazine.id")) writer_id = mapped_column() magazine = relationship("Magazine") writer = relationship("Writer") __table_args__ = ( PrimaryKeyConstraint("article_id", "magazine_id"), ForeignKeyConstraint( ["writer_id", "magazine_id"], ["writer.id", "writer.magazine_id"] ), ) class Writer(Base): __tablename__ = "writer" id = mapped_column(Integer, primary_key=True) magazine_id = mapped_column(ForeignKey("magazine.id"), primary_key=True) magazine = relationship("Magazine")
配置上述映射后,我们将看到发出此警告:
SAWarning: relationship 'Article.writer' will copy column writer.magazine_id to column article.magazine_id, which conflicts with relationship(s): 'Article.magazine' (copies magazine.id to article.magazine_id). Consider applying viewonly=True to read-only relationships, or provide a primaryjoin condition marking writable columns with the foreign() annotation.
这指的是Article.magazine_id
是两个不同外键约束的主题的事实;它直接引用Magazine.id
作为源列,但也在Writer
的复合键的上下文中引用Writer.magazine_id
作为源列。如果我们将Article
与特定的Magazine
关联起来,然后将Article
与与不同Magazine
关联的Writer
关联起来,ORM 将非确定性地覆盖Article.magazine_id
,在不通知的情况下更改我们引用的杂志;如果我们将Writer
从Article
中解除关联,它还可能尝试将 NULL 放入此列。警告让我们知道这是这种情况。
要解决这个问题,我们需要将Article
的行为分解,包括以下三个特性:
- 首先,
Article
根据仅在Article.magazine
关系中持久化的数据写入Article.magazine_id
,即从Magazine.id
复制的值。 Article
可以代表在Article.writer
关系中持久化的数据写入Article.writer_id
,但仅限于Writer.id
列;Writer.magazine_id
列不应写入Article.magazine_id
,因为它最终源自Magazine.id
。- 当加载
Article.writer
时,Article
会考虑Article.magazine_id
,尽管它在此关系中不会向其写入。
要获得仅#1 和#2,我们可以仅指定Article.writer_id
作为Article.writer
的“外键”:
class Article(Base): # ... writer = relationship("Writer", foreign_keys="Article.writer_id")
然而,这会导致Article.writer
在与Writer
查询时不考虑Article.magazine_id
:
SELECT article.article_id AS article_article_id, article.magazine_id AS article_magazine_id, article.writer_id AS article_writer_id FROM article JOIN writer ON writer.id = article.writer_id
因此,为了获得所有#1、#2 和#3,我们通过完全组合relationship.primaryjoin
和relationship.foreign_keys
参数,或者更简洁地通过用foreign()
注释来表达连接条件以及要写入的列:
class Article(Base): # ... writer = relationship( "Writer", primaryjoin="and_(Writer.id == foreign(Article.writer_id), " "Writer.magazine_id == Article.magazine_id)", )
非关系对比/实现路径
警告
此部分详细介绍了一个实验性功能。
使用自定义表达式意味着我们可以生成不遵循通常的主键/外键模型的非正统连接条件。其中一个例子是实现路径模式,其中我们比较字符串以产生重叠路径标记,以便生成树结构。
通过精心使用foreign()
和remote()
,我们可以构建一个有效地产生基本的物化路径系统的关系。基本上,当foreign()
和remote()
在相同的比较表达式的一侧时,关系被视为“一对多”;当它们在不同的一侧时,关系被视为“多对一”。对于我们将在此处使用的比较,我们将处理集合,因此保持配置为“一对多”:
class Element(Base): __tablename__ = "element" path = mapped_column(String, primary_key=True) descendants = relationship( "Element", primaryjoin=remote(foreign(path)).like(path.concat("/%")), viewonly=True, order_by=path, )
上述情况下,如果给定一个具有路径属性为"/foo/bar2"
的Element
对象,我们寻求加载Element.descendants
以如下形式:
SELECT element.path AS element_path FROM element WHERE element.path LIKE ('/foo/bar2' || '/%') ORDER BY element.path
SqlAlchemy 2.0 中文文档(十二)(2)https://developer.aliyun.com/article/1562926