Phase 3: Query Methods
Phase 3: Reading the Graph
Objective
Add methods to query the graph: look up what an entity is associated with, find what points back at it, list all known entities, and enumerate all relation types. The graph can now answer questions.
Python Concepts
| Concept | Plain English |
|---|---|
|
Look up a key in a dict.
If the key exists, return its value.
If not, return |
|
An unordered collection of unique items.
|
|
Combine two sets: |
|
Return a new list with elements in ascending order.
Does not modify the original.
Equivalent to piping through |
|
Convert something into a list.
|
Dictionary |
Returns a view of all keys in the dict.
|
Return type hints |
|
Steps
1. The query method — forward lookup
Add to AssociationGraph in graph.py:
def query(self, source: str) -> dict[str, list[str]]:
"""Return all forward associations for an entity.
Returns an empty dict if the entity has no associations.
Example:
>>> graph.associate("CISSP", "covers", "access-control")
>>> graph.query("CISSP")
{"covers": ["access-control"]}
"""
return dict(self._forward.get(source, {}))
dict(…) makes a shallow copy.
This prevents callers from accidentally mutating the internal state.
Without the copy, someone could do graph.query("CISSP")["covers"].append("oops") and corrupt your data.
2. The reverse_query method — backward lookup
def reverse_query(self, target: str) -> dict[str, list[str]]:
"""Return all reverse associations pointing at an entity.
Example:
>>> graph.associate("CISSP", "covers", "access-control")
>>> graph.reverse_query("access-control")
{"covered-by": ["CISSP"]}
"""
return dict(self._reverse.get(target, {}))
Same pattern, different dict.
query traverses forward edges; reverse_query traverses backward edges.
3. The keys method — all known entities
def keys(self) -> list[str]:
"""Return all entities that appear in the graph, sorted.
Combines sources (forward keys) and targets (reverse keys)
to capture every entity regardless of which side it was added from.
"""
all_keys = set(self._forward.keys()) | set(self._reverse.keys())
return sorted(all_keys)
Walk through this:
-
set(self._forward.keys())— all entities that are sources (left side of an association). -
set(self._reverse.keys())— all entities that are targets (right side of an association). -
\|— union. An entity might appear only as a source, only as a target, or both. Union captures all of them. -
sorted(…)— alphabetical order for deterministic output.
4. The relations method — all relation types
def relations(self) -> list[str]:
"""Return all relation types used in the graph, sorted."""
rels: set[str] = set()
for source_relations in self._forward.values():
rels.update(source_relations.keys())
return sorted(rels)
New concepts here:
-
.values()— returns the values of a dict (not the keys). Here, each value is itself a dict of{relation: [targets]}. -
.update()— add multiple items to a set at once. Likerels = rels | new_itemsbut modifies in place.
5. Write the tests
Add to tests/test_graph.py:
class TestQuery:
"""Verify query methods."""
def _populated_graph(self) -> AssociationGraph:
"""Helper: return a graph with test data."""
g = AssociationGraph()
g.associate("CISSP", "covers", "access-control")
g.associate("CISSP", "covers", "cryptography")
g.associate("CISSP", "requires", "5-years-experience")
g.associate("CCNP", "covers", "routing")
return g
def test_query_returns_all_relations(self) -> None:
g = self._populated_graph()
result = g.query("CISSP")
assert "covers" in result
assert "requires" in result
assert "access-control" in result["covers"]
assert "cryptography" in result["covers"]
def test_query_unknown_returns_empty(self) -> None:
g = self._populated_graph()
assert g.query("nonexistent") == {}
def test_reverse_query(self) -> None:
g = self._populated_graph()
result = g.reverse_query("access-control")
assert "covered-by" in result
assert "CISSP" in result["covered-by"]
def test_reverse_query_unknown_returns_empty(self) -> None:
g = self._populated_graph()
assert g.reverse_query("nonexistent") == {}
def test_keys_includes_all_entities(self) -> None:
g = self._populated_graph()
k = g.keys()
# Sources
assert "CISSP" in k
assert "CCNP" in k
# Targets (only in reverse dict)
assert "access-control" in k
assert "cryptography" in k
assert "routing" in k
def test_keys_are_sorted(self) -> None:
g = self._populated_graph()
k = g.keys()
assert k == sorted(k)
def test_relations(self) -> None:
g = self._populated_graph()
rels = g.relations()
assert "covers" in rels
assert "requires" in rels
def test_query_returns_copy_not_reference(self) -> None:
"""Mutating the query result must not affect the graph."""
g = self._populated_graph()
result = g.query("CISSP")
result["covers"].append("CORRUPTED")
# The graph should be unaffected
assert "CORRUPTED" not in g.query("CISSP")["covers"]
The last test is defensive — it proves your dict() copy in query() works.
6. Run
uv run pytest tests/ -v
uv run ruff check src/ tests/
Checklist
-
query()method returns forward associations -
reverse_query()method returns reverse associations -
keys()returns all entities from both dicts -
relations()returns all relation types -
All methods return copies, not references
-
Unknown keys return empty dicts/lists, never raise errors
-
Eight new tests pass
-
Linter clean
Verification
uv run pytest tests/ -v --tb=short
The graph can now record associations (Phase 2) and answer queries (Phase 3). Phase 4 builds out the complete test suite before adding more features — tests first, then capabilities.