Skip to content

[Feature] feat(layout): comb layout for multi-fact schemas #59

Description

@velazco-joseh

Feature description

Add a comb layout for multi-fact schemas: two or more fact tables that reference dozens of near-identical catalogs, often sharing those catalogs between them.

None of the three current layouts handle this shape. Measured on a real 42-table / 53-relationship schema with two fact tables (degree 32 and 13) sharing four catalogs, counting pairs of edges that cross by parsing <path class="erd-edge"> out of the rendered SVG:

layout crossing pairs
star 207
layered 202
force 131 (best today)
hand-rolled comb prototype 80

Two things make this a real defect rather than a taste issue:

  1. The graph is planar. networkx.check_planarity() returns True for this schema, so a zero-crossing drawing provably exists. 131 is a limitation of our algorithms, not of the input.
  2. star performs worse than force here, contradicting its own docstring — "a fact table flanked by catalog columns" assumes exactly one fact table. With two hubs competing for the same catalog columns it degrades below the generic force layout.

Use cases

Staging/warehouse schemas where a wide fact table is progressively extended:

stg_shipments              (hub, ~30 FKs)
stg_shipments_extended     (hub, ~13 FKs, 1:1 with stg_shipments)

cat_carrier, cat_route, cat_depot, cat_incident_code,
cat_package_class, cat_customs_status, cat_edition, ...

Three properties break every current layout:

  • Parallel FKs: stg_shipments has origin_depot_id, destination_depot_id and transit_depot_id, all → cat_depot. Three distinct roles, not duplicates.
  • Shared catalogs: both fact tables reference cat_depot, cat_incident_code, cat_edition.
  • Fact-to-fact edge: stg_shipments_extended.shipment_id → stg_shipments, which is not a catalog edge and should read differently.

Benefits

Anyone diagramming a star/snowflake warehouse with more than one fact table. Today the only escape hatch is writing a bespoke SVG generator per project — which is exactly what happened, and that prototype silently dropped 8 of the 49 fact-table FKs to look tidy (see Requirements). We should ship the layout so nobody re-derives it badly.

Requirements

Register one new entry in LAYOUTS in src/sqlalchemy_erd/layout_select.py (layout="comb") — the existing OCP seam means no caller changes.

  • Fact tables detected by connectivity (degree), not by table-name prefix. A stg_ heuristic is not acceptable in a library.
  • Catalogs ordered vertically by the order their FK column appears in the fact table, so edges stay monotone.
  • One vertical lane per source column, not per target table, so parallel FKs get their own routing channel.
  • A catalog shared by several facts is placed once and receives edges from all of them.
  • Defined for arbitrary N fact tables, not 2. The prototype hardcodes top, bottom = facts[:2] and stacks blocks vertically, which leaves a catalog shared between block 1 and block 4 no route except through everything in between. The layout must specify where a catalog shared by k facts is placed — the natural rule being: assign it to the vertical band spanning the facts that reference it, and reserve lanes accordingly. Fact tables themselves must be ordered to minimise the span of shared catalogs (a 1-D ordering problem — barycenter/median ordering is enough).
  • Never drop an edge. The prototype deduped with if target in seen, keyed on the target table, which hid every parallel FK and every catalog already claimed by an earlier fact. Deduplication, where needed at all, must key on (source_column, target_table). This is the same class of bug as the inheritance-edge dedupe fixed in Support SQLAlchemy polymorphic / table inheritance #54.

Tests this must satisfy

Behavioral, on synthetic fixtures — no real schema needed.

  1. Edge conservation (the important one). For every fixture: number of rendered erd-edge paths == len(relationships) from introspect_models. No layout may lose an edge.
  2. Parallel FKs. Fact with origin_depot_id, destination_depot_id, transit_depot_id → cat_depot: exactly 3 edges rendered, 3 distinct lanes, 3 distinct anchor rows on the fact box.
  3. Shared catalog, two facts. Both facts → cat_edition: cat_edition appears exactly once in positions, and receives 2 edges.
  4. Fact-to-fact edge. stg_shipments_extended.shipment_id → stg_shipments is rendered by the layout itself and styled distinctly from catalog edges — not hardcoded by index like facts[:2], which crashes on a single-fact schema.
  5. Single fact table. Degrades gracefully to star-like output; must not raise.
  6. Zero fact tables (flat schema, all tables similar degree): falls back to a registered layout instead of raising.
  7. Empty metadata / no relationships: returns empty or single-column positions, no exception. Matches the disconnected-graph guarantees from force_directed_layout fails on disconnected graphs and star schemas #22.
  8. Orphan catalogs. A catalog no fact references is still placed and never overlaps another box.
  9. No box overlap. For every pair of placed tables, bounding boxes are disjoint.
  10. Crossing regression. On a two-fact fixture, comb crossings <= force crossings. Guards the whole point of the layout.
  11. Determinism. Same metadata in → byte-identical positions out, across runs and dict-ordering.
  12. Self-referencing FK. cat_region.parent_region_id → cat_region renders one edge and does not place the table twice.
  13. Three facts, chained. stg_a ← stg_b ← stg_c (each 1:1 to the previous): all three fact-to-fact edges rendered; no facts[:2]-style truncation.
  14. Four facts, independent. Four facts with no fact-to-fact FK at all, sharing catalogs: all four placed, invariant 1 holds.
  15. Catalog shared by 3+ facts. cat_edition referenced by facts 1, 2 and 4: placed exactly once, receives exactly 3 edges, and its box overlaps no other box.
  16. Non-adjacent sharing. A catalog referenced only by the first and last fact must not have its edge pass through an intermediate fact's box — assert the routed path intersects no table bounding box.
  17. Parametrized over N = 1..5. Invariants 1 (edge conservation), 9 (no overlap) and 11 (determinism) hold for every N. This is what pins the generalisation.
  18. Role ambiguity. A table that is both referenced by one fact and references catalogs itself (a mid-level dimension) is classified once, deterministically, and does not appear in two bands.
  19. Degree-detection threshold. With four facts of clearly different degree (e.g. 30/18/9/6) plus one unusually well-connected catalog, all four facts are detected and the catalog is not misclassified as a fact.

Links / references

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions