Source code for examples.materialized_paths.materialized_paths

  1. """Illustrates the "materialized paths" pattern.
  2. Materialized paths is a way to represent a tree structure in SQL with fast
  3. descendant and ancestor queries at the expense of moving nodes (which require
  4. O(n) UPDATEs in the worst case, where n is the number of nodes in the tree). It
  5. is a good balance in terms of performance and simplicity between the nested
  6. sets model and the adjacency list model.
  7. It works by storing all nodes in a table with a path column, containing a
  8. string of delimited IDs. Think file system paths:
  9. 1
  10. 1.2
  11. 1.3
  12. 1.3.4
  13. 1.3.5
  14. 1.3.6
  15. 1.7
  16. 1.7.8
  17. 1.7.9
  18. 1.7.9.10
  19. 1.7.11
  20. Descendant queries are simple left-anchored LIKE queries, and ancestors are
  21. already stored in the path itself. Updates require going through all
  22. descendants and changing the prefix.
  23. """
  24. from sqlalchemy import Column
  25. from sqlalchemy import create_engine
  26. from sqlalchemy import func
  27. from sqlalchemy import Integer
  28. from sqlalchemy import select
  29. from sqlalchemy import String
  30. from sqlalchemy.dialects.postgresql import ARRAY
  31. from sqlalchemy.ext.declarative import declarative_base
  32. from sqlalchemy.orm import foreign
  33. from sqlalchemy.orm import relationship
  34. from sqlalchemy.orm import remote
  35. from sqlalchemy.orm import Session
  36. from sqlalchemy.sql.expression import cast
  37. Base = declarative_base()
  38. class Node(Base):
  39. __tablename__ = "node"
  40. id = Column(Integer, primary_key=True, autoincrement=False)
  41. path = Column(String(500), nullable=False, index=True)
  42. # To find the descendants of this node, we look for nodes whose path
  43. # starts with this node's path.
  44. descendants = relationship(
  45. "Node",
  46. viewonly=True,
  47. order_by=path,
  48. primaryjoin=remote(foreign(path)).like(path.concat(".%")),
  49. )
  50. # Finding the ancestors is a little bit trickier. We need to create a fake
  51. # secondary table since this behaves like a many-to-many join.
  52. secondary = select(
  53. id.label("id"),
  54. func.unnest(
  55. cast(
  56. func.string_to_array(
  57. func.regexp_replace(path, r"\.?\d+$", ""), "."
  58. ),
  59. ARRAY(Integer),
  60. )
  61. ).label("ancestor_id"),
  62. ).alias()
  63. ancestors = relationship(
  64. "Node",
  65. viewonly=True,
  66. secondary=secondary,
  67. primaryjoin=id == secondary.c.id,
  68. secondaryjoin=secondary.c.ancestor_id == id,
  69. order_by=path,
  70. )
  71. @property
  72. def depth(self):
  73. return len(self.path.split(".")) - 1
  74. def __repr__(self):
  75. return "Node(id={})".format(self.id)
  76. def __str__(self):
  77. root_depth = self.depth
  78. s = [str(self.id)]
  79. s.extend(
  80. ((n.depth - root_depth) * " " + str(n.id))
  81. for n in self.descendants
  82. )
  83. return "\n".join(s)
  84. def move_to(self, new_parent):
  85. new_path = new_parent.path + "." + str(self.id)
  86. for n in self.descendants:
  87. n.path = new_path + n.path[len(self.path) :]
  88. self.path = new_path
  89. if __name__ == "__main__":
  90. engine = create_engine(
  91. "postgresql://scott:tiger@localhost/test", echo=True
  92. )
  93. Base.metadata.create_all(engine)
  94. session = Session(engine)
  95. print("-" * 80)
  96. print("create a tree")
  97. session.add_all(
  98. [
  99. Node(id=1, path="1"),
  100. Node(id=2, path="1.2"),
  101. Node(id=3, path="1.3"),
  102. Node(id=4, path="1.3.4"),
  103. Node(id=5, path="1.3.5"),
  104. Node(id=6, path="1.3.6"),
  105. Node(id=7, path="1.7"),
  106. Node(id=8, path="1.7.8"),
  107. Node(id=9, path="1.7.9"),
  108. Node(id=10, path="1.7.9.10"),
  109. Node(id=11, path="1.7.11"),
  110. ]
  111. )
  112. session.flush()
  113. print(str(session.query(Node).get(1)))
  114. print("-" * 80)
  115. print("move 7 under 3")
  116. session.query(Node).get(7).move_to(session.query(Node).get(3))
  117. session.flush()
  118. print(str(session.query(Node).get(1)))
  119. print("-" * 80)
  120. print("move 3 under 2")
  121. session.query(Node).get(3).move_to(session.query(Node).get(2))
  122. session.flush()
  123. print(str(session.query(Node).get(1)))
  124. print("-" * 80)
  125. print("find the ancestors of 10")
  126. print([n.id for n in session.query(Node).get(10).ancestors])
  127. session.close()
  128. Base.metadata.drop_all(engine)