Source code for system.query_optimization.join_graph

#
#    This is ExplainDB, educational database systems materials.
#
#    Copyright (C) 2026 Prof. Dr. Jens Dittrich, Saarland University
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU Affero General Public License as
#    published by the Free Software Foundation, either version 3 of the
#    License, or (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU Affero General Public License for more details.
#
#    You should have received a copy of the GNU Affero General Public License
#    along with this program.  If not, see <https://www.gnu.org/licenses/>.
#
#

"""Join graph model and factories for chain, star, cycle, and clique queries."""

import itertools
from abc import ABC, abstractmethod
from collections import deque
from typing import Deque
from system.query_optimization.problems import Problem


[docs] class JoinGraph: """ A join graph for a query. Internally, it uses an adjacency list to represent the graph. """ def __init__(self, number_of_relations: int): """ Create an empty join graph over the given number of relations. :param number_of_relations: The number of relations (nodes) in the join graph. """ self.number_of_relations: int = number_of_relations self.adjacency_matrix: list[Problem] = [ Problem.create_all_false_bit_sequence(self.number_of_relations) for _ in range(number_of_relations) ] def __len__(self) -> int: """Returns the number of relations (nodes) in the join graph.""" return len(self.adjacency_matrix) def __iter__(self): """Iterates over the relation ids (0 to n-1) of the join graph.""" return iter(range(len(self)))
[docs] def add_join(self, left_relation: int, right_relation: int): """ Adds a join between the left and the right relation. :param left_relation: The id of the left relation. :param right_relation: The id of the right relation. """ if any( size >= len(self.adjacency_matrix) for size in [left_relation, right_relation] ): raise ValueError("A relation is not part of the join graph!") left_problem = Problem.get_problem_for_relation(left_relation, len(self)) right_problem = Problem.get_problem_for_relation(right_relation, len(self)) self.adjacency_matrix[left_relation] += right_problem self.adjacency_matrix[right_relation] += left_problem
[docs] def are_connected(self, left: Problem, right: Problem) -> bool: """ Checks whether the left and right problem are connected. :param left: The left problem. :param right: The right problem. :return: True, if the problems are connected, False if not. """ # Get all problems that are connected with the left problem connected_with_left: Problem = left for problem in left: connected_with_left |= self.adjacency_matrix[problem.get_relation_id()] return (connected_with_left & right) != Problem()
[docs] def is_connected(self, problem: Problem) -> bool: """ Checks whether the problem is connected in the join graph. :param problem: The problem to be checked. :return: True, if the problem is connected, False if not. """ # Starting Singleton for the DFS visited: Problem = problem.get_least_significant_bit() # Use stack to_be_checked: Deque[int] = deque([visited.get_relation_id()]) # Perform regular DFS while to_be_checked: next_relation: int = to_be_checked.pop() # Each neighbor is a singleton problem for neighbor in self.adjacency_matrix[next_relation] & problem: if not neighbor.intersects(visited): visited |= neighbor to_be_checked.append(neighbor.get_relation_id()) # If all problems were visited, we know that the entire problem is connected return visited == problem
[docs] def get_neighbors(self, s: Problem) -> Problem: """ Get all neighbors of a given problem s (excluding s). :param s: The set whose neighbors are of interest. :return: The neighbors as problem. """ # Start with empty problem neighbors: Problem = Problem.create_all_false_bit_sequence( self.number_of_relations ) # Add each connected neighbor from all relations in S for problem in s: neighbors += self.adjacency_matrix[problem.get_relation_id()] # Remove S from neighbors given that they are already included return neighbors - s
[docs] class JoinGraphFactory(ABC): """ A simple, abstract factory class to create certain join graph schemes. """
[docs] @staticmethod @abstractmethod def construct_join_graph(num_nodes: int) -> JoinGraph: """ Construct a join graph following this factory's scheme. :param num_nodes: The number of relations in the join graph. :return: The constructed join graph. """
[docs] class ChainQueryFactory(JoinGraphFactory): """ Creates a chain query of the given size. """
[docs] @staticmethod def construct_join_graph(num_nodes: int) -> JoinGraph: """See :meth:`JoinGraphFactory.construct_join_graph`. Builds a chain query, joining each relation ``i`` with relation ``i + 1``. """ chain_query = JoinGraph(num_nodes) for i in range(num_nodes - 1): chain_query.add_join(i, i + 1) return chain_query
[docs] class StarQueryFactory(JoinGraphFactory): """ Creates a star query of the given size. """
[docs] @staticmethod def construct_join_graph(num_nodes: int) -> JoinGraph: """See :meth:`JoinGraphFactory.construct_join_graph`. Builds a star query, joining the fact table (relation ``0``) with every other relation. """ star_query = JoinGraph(num_nodes) for i in range(1, num_nodes): star_query.add_join(0, i) # 0 is the fact table return star_query
[docs] class CycleQueryFactory(JoinGraphFactory): """ Creates a cycle query of the given size. """
[docs] @staticmethod def construct_join_graph(num_nodes: int) -> JoinGraph: """See :meth:`JoinGraphFactory.construct_join_graph`. Builds a cycle query, i.e., a chain query with an additional edge closing the last relation back to the first. """ cycle_query = ChainQueryFactory.construct_join_graph(num_nodes) # A cycle is just a chain with an additional edge cycle_query.add_join(num_nodes - 1, 0) return cycle_query
[docs] class CliqueQueryFactory(JoinGraphFactory): """ Creates a clique query of the given size. """
[docs] @staticmethod def construct_join_graph(num_nodes: int) -> JoinGraph: """See :meth:`JoinGraphFactory.construct_join_graph`. Builds a clique query, joining every relation with every relation (self-joins included). """ clique_query = JoinGraph(num_nodes) for tup in itertools.product(range(num_nodes), range(num_nodes)): clique_query.add_join(tup[0], tup[1]) return clique_query