Coverage for graphqler / chains / chain_generator.py: 92%
40 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-20 10:09 -0400
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-20 10:09 -0400
1"""ChainGenerator: orchestrates chain generation using a pluggable strategy."""
3from pathlib import Path
5import networkx
6import yaml
8from graphqler import config
9from graphqler.chains.chain import Chain
10from graphqler.chains.strategies.base_strategy import BaseChainStrategy
11from graphqler.chains.strategies.all_dependencies_strategy import AllDependenciesChainStrategy
12from graphqler.graph.node import Node
14# All possible mutation_type values assigned to Mutation nodes
15_ALL_MUTATION_TYPES = ["CREATE", "UPDATE", "DELETE", "UNKNOWN"]
18class ChainGenerator:
19 """Generates and stores dependency chains for later inspection and fuzzer consumption.
21 The generator applies a **3-pass** strategy (mirroring the original DFS passes) when
22 ``config.DISABLE_MUTATIONS`` is ``False`` (the default):
24 * **Pass 1** — chains containing only CREATE / QUERY nodes (UPDATE, DELETE, UNKNOWN filtered out)
25 * **Pass 2** — chains allowing CREATE, QUERY, and UPDATE (DELETE, UNKNOWN filtered out)
26 * **Pass 3** — all chains (no filter)
28 All three passes are concatenated into a single list so that the fuzzer can simply
29 iterate through ``chains`` without any additional filtering.
31 When ``config.DISABLE_MUTATIONS`` is ``True``, only Query (and Object) chains are
32 produced — all mutation nodes are excluded entirely.
34 Usage::
36 generator = ChainGenerator()
37 chains = generator.generate(dependency_graph, starter_nodes)
38 # chains are also accessible afterwards:
39 print(generator.chains)
41 The default strategy is :class:`AllDependenciesChainStrategy`. Pass a different
42 :class:`BaseChainStrategy` subclass to use an alternative generation method
43 (e.g. :class:`~graphqler.chains.strategies.dfs_strategy.DFSChainStrategy`).
44 """
46 def __init__(self, strategy: BaseChainStrategy | None = None):
47 """Initialise the generator with an optional strategy.
49 Args:
50 strategy (BaseChainStrategy | None): Chain generation strategy.
51 Defaults to :class:`DFSChainStrategy` when *None*.
52 """
53 self._strategy: BaseChainStrategy = strategy if strategy is not None else AllDependenciesChainStrategy()
54 self._chains: list[Chain] = []
56 @property
57 def chains(self) -> list[Chain]:
58 """The chains produced by the most recent :meth:`generate` call (empty until then)."""
59 return self._chains
61 def generate(self, graph: networkx.DiGraph, starter_nodes: list[Node]) -> list[Chain]:
62 """Generate chains and store them for later inspection.
64 Applies the 3-pass filtering strategy internally so that the fuzzer receives a
65 single ordered list of chains ready to execute sequentially.
67 Args:
68 graph (networkx.DiGraph): The compiled dependency graph.
69 starter_nodes (list[Node]): Root nodes to start generation from.
71 Returns:
72 list[Chain]: The generated chains (same object as :attr:`chains`).
73 """
74 if config.DISABLE_MUTATIONS:
75 # Only produce chains that contain Query/Object nodes — exclude all mutations
76 self._chains = self._strategy.generate(
77 graph, starter_nodes, filter_mutation_type=_ALL_MUTATION_TYPES
78 )
79 else:
80 # Pass 1: CREATE + QUERY only (filter UPDATE, DELETE, UNKNOWN)
81 pass1 = self._strategy.generate(
82 graph, starter_nodes,
83 filter_mutation_type=["UPDATE", "DELETE", "UNKNOWN"],
84 )
85 # Pass 2: CREATE + QUERY + UPDATE (filter DELETE, UNKNOWN)
86 pass2 = self._strategy.generate(
87 graph, starter_nodes,
88 filter_mutation_type=["DELETE", "UNKNOWN"],
89 )
90 # Pass 3: all nodes
91 pass3 = self._strategy.generate(
92 graph, starter_nodes,
93 filter_mutation_type=[],
94 )
95 self._chains = pass1 + pass2 + pass3
97 return self._chains
99 def save_to_yaml(self, save_path: str) -> None:
100 """Persist the generated chains to a YAML file for human inspection and optional editing.
102 Each chain is stored as a list of node names. On reload the names are
103 resolved back to :class:`~graphqler.graph.node.Node` objects using the
104 dependency graph.
106 Args:
107 save_path (str): Root output directory (same directory used for compilation).
108 """
109 chains_path = Path(save_path) / config.CHAINS_FILE_NAME
110 chains_path.parent.mkdir(parents=True, exist_ok=True)
111 data = [{"nodes": [n.name for n in chain.nodes]} for chain in self._chains]
112 with open(chains_path, "w") as f:
113 yaml.dump(data, f, default_flow_style=False, sort_keys=False)
115 def load_from_yaml(self, save_path: str, graph: networkx.DiGraph) -> list[Chain]:
116 """Load chains from a previously saved YAML file and populate :attr:`chains`.
118 Node names in the YAML are resolved to :class:`~graphqler.graph.node.Node`
119 objects using *graph*. Any name that no longer exists in the graph is
120 silently skipped so that hand-edited files do not crash the fuzzer.
122 Args:
123 save_path (str): Root output directory (same directory used for compilation).
124 graph (networkx.DiGraph): The dependency graph used to look up Node objects.
126 Returns:
127 list[Chain]: The loaded chains (same object as :attr:`chains`).
128 """
129 chains_path = Path(save_path) / config.CHAINS_FILE_NAME
130 if not chains_path.exists():
131 self._chains = []
132 return self._chains
134 node_map: dict[str, Node] = {node.name: node for node in graph.nodes()}
135 with open(chains_path, "r") as f:
136 data = yaml.safe_load(f) or []
138 self._chains = [
139 Chain(nodes=[node_map[name] for name in entry.get("nodes", []) if name in node_map])
140 for entry in data
141 ]
142 return self._chains