From cf713b9cfbd801f6eff449a425fd6da317f012e9 Mon Sep 17 00:00:00 2001 From: vento007 Date: Tue, 4 Nov 2025 01:38:57 +0700 Subject: [PATCH] docs: add GraphLayout and startId documentation, remove topological --- CHANGELOG.md | 12 +- CYPHER_GUIDE.md | 101 +++++++++++++++ README.md | 157 +++++++++++++++++++++++- lib/src/layout.dart | 28 ----- pubspec.yaml | 2 +- test/debug_layout.dart | 49 ++++++++ test/debug_matchrows_vs_matchpaths.dart | 44 +++++++ test/layout_test.dart | 11 +- 8 files changed, 364 insertions(+), 40 deletions(-) create mode 100644 test/debug_layout.dart create mode 100644 test/debug_matchrows_vs_matchpaths.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index aedcf72..180b17d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## 0.7.8 + +- **Removed LayerStrategy.topological**: Removed from enum + - Two strategies remain: `pattern` and `longestPath` +- **Documentation updates**: Added missing documentation for 0.7.6 and 0.7.5 features + - Added comprehensive GraphLayout API section to README.md (section 10) + - Enhanced README section 2.4 with startId middle element examples and startType parameter + - Added detailed startId/startType section to CYPHER_GUIDE.md with performance tips + - Updated table of contents + ## 0.7.7 - **CONTAINS operator**: Substring matching in WHERE clauses @@ -11,7 +21,7 @@ - **GraphLayout API**: Automatic layer/column computation for graph visualizations - Eliminates hardcoded column positioning (`computeLayout()` on path results) - Provides `nodeDepths` (structural positioning) and `variableDepths` (grouped positioning) - - Three strategies: `pattern`, `longestPath`, `topological` + - Two strategies: `pattern`, `longestPath` (topological was removed in 0.7.7+2) - Handles cycles, orphan nodes, disconnected components automatically - 13 comprehensive tests including diamonds, cycles, and mixed directions diff --git a/CYPHER_GUIDE.md b/CYPHER_GUIDE.md index 2d8e516..d503da6 100644 --- a/CYPHER_GUIDE.md +++ b/CYPHER_GUIDE.md @@ -42,6 +42,107 @@ MATCH user MATCH person:Person ``` +## Starting from Specific Nodes (startId) + +The `startId` parameter lets you start pattern matching from a specific node, making queries more efficient by only exploring relevant subgraphs. + +### Basic Usage + +```cypher +# Start from a specific person +query.match('person-[:WORKS_FOR]->team', startId: 'alice') +# Only matches paths starting from alice + +# Start from a specific team +query.match('person-[:WORKS_FOR]->team', startId: 'engineering') +# Only matches paths where team = engineering +``` + +### Starting from Any Position + +**Important:** `startId` can match **any element** in the pattern, not just the first: + +```cypher +# Pattern: a->b->c +# startId can match 'a', 'b', OR 'c' + +# Start from first element +query.matchPaths('person->team->project', startId: 'alice') +# Matches paths where person = alice + +# Start from middle element +query.matchPaths('person->team->project', startId: 'engineering') +# Matches paths where team = engineering + +# Start from last element +query.matchPaths('person->team->project', startId: 'web_app') +# Matches paths where project = web_app +``` + +### Performance Optimization with startType + +When starting from middle or last elements, use `startType` to tell graph_kit which position to check: + +```cypher +# Without startType: checks all positions (slower) +query.matchPaths( + 'person->team->project', + startId: 'engineering' +) +# Checks if 'engineering' matches person, team, OR project + +# With startType: only checks specified type (faster!) +query.matchPaths( + 'person->team->project', + startId: 'engineering', + startType: 'Team' +) +# ONLY checks if 'engineering' matches team position +``` + +### When to Use startType + +Use `startType` for better performance when: +- Starting from middle or last elements +- Working with long patterns (4+ elements) +- Running performance-critical queries +- You know the node type of your startId + +**Example with long pattern:** + +```cypher +# 5-element pattern +query.matchPaths( + 'a->b->c->d->e', + startId: 'node_d', + startType: 'NodeTypeD' # Skip checking a, b, c positions +) +``` + +### Common Patterns + +```cypher +# Find all projects for a team (start from middle) +query.matchPaths( + 'person->team->project', + startId: 'engineering', + startType: 'Team' +) + +# Find all people working on a project (start from end) +query.matchPaths( + 'person->team->project', + startId: 'web_app', + startType: 'Project' +) + +# Backward traversal from specific node +query.matchPaths( + 'project<-[:ASSIGNED_TO]-team<-[:WORKS_FOR]-person', + startId: 'web_app' +) +``` + ## Node Types and Labels ### Node Types diff --git a/README.md b/README.md index c2037f6..ab7fd9e 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,8 @@ In-memory, typed directed multigraph with: - [7. Comparison with Cypher](#7-comparison-with-cypher) - [8. Design and performance](#8-design-and-performance) - [9. JSON Serialization](#9-json-serialization) -- [10. Examples index](#10-examples-index) +- [10. Graph Layout for Visualizations](#10-graph-layout-for-visualizations) +- [11. Examples index](#11-examples-index) - [License](#license) @@ -174,6 +175,57 @@ final webAppTeam = query.match( print(webAppTeam); // {project: {web_app}, team: {engineering}, person: {alice, bob}} ``` +#### Starting from Middle or Last Elements + +`startId` can match **any position** in the pattern, not just the first element: + +```dart +// Start from middle element (team) +final teamConnections = query.matchPaths( + 'person-[:WORKS_FOR]->team-[:ASSIGNED_TO]->project', + startId: 'engineering' // team is in the middle! +); +// Returns paths where 'team' variable = 'engineering' + +// Start from last element (project) +final projectPaths = query.matchPaths( + 'person-[:WORKS_FOR]->team-[:ASSIGNED_TO]->project', + startId: 'web_app' // project is last! +); +// Returns paths where 'project' variable = 'web_app' + +// Start from any element in longer chains +final middleChain = query.matchPaths( + 'a->b->c->d->e', + startId: 'node_c' // Start from middle 'c' element +); +``` + +#### Performance Optimization with startType + +When starting from middle/last elements, use `startType` to skip unnecessary position checks: + +```dart +// Without startType: checks all 3 positions (person, team, project) +final paths = query.matchPaths( + 'person-[:WORKS_FOR]->team-[:ASSIGNED_TO]->project', + startId: 'engineering' +); + +// With startType: ONLY checks 'team' position (faster!) +final paths = query.matchPaths( + 'person-[:WORKS_FOR]->team-[:ASSIGNED_TO]->project', + startId: 'engineering', + startType: 'Team' // Optimization hint +); +``` + +**When to use `startType`:** +- Starting from middle or last positions +- Large patterns (4+ elements) +- Performance-critical queries +- You know the node type of your startId + ### 2.5 Row-wise Results - Preserve Path Relationships ```dart @@ -852,7 +904,108 @@ final members = query.match('team<-[:MEMBER_OF]-user', startId: 'team1'); print(members['user']); // {alice} ``` -## 10. Examples index +## 10. Graph Layout for Visualizations + +Automatically compute layer/column positions for graph visualizations, eliminating brittle hardcoded positioning logic. + +### The Problem + +Hardcoding column positions breaks when graph structure changes: + +```dart +// BAD: Hardcoded switch statement - breaks when patterns change +final column = switch (nodeType) { + 'Group' => 0, + 'Policy' => 1, + 'Asset' => 2, + 'Virtual' => 3, + _ => 0, +}; +``` + +### The Solution: GraphLayout + +```dart +final paths = query.matchPaths('group->policy->asset->virtual'); +final layout = paths.computeLayout(); + +// Column positions computed automatically! +final groupColumn = layout.variableLayer('group'); // 0 +final policyColumn = layout.variableLayer('policy'); // 1 +final assetColumn = layout.variableLayer('asset'); // 2 +final virtualColumn = layout.variableLayer('virtual'); // 3 +``` + +### Key Features + +**Automatic positioning**: Computes layer/column for every node based on graph structure + +**Two positioning modes**: +- `nodeDepths` - Exact structural position for each node ID +- `variableDepths` - Typical position for grouping by variable name (uses median to handle outliers) + +**Handles edge cases gracefully**: +- Orphan nodes (disconnected from roots) +- Cycles +- Multiple disconnected components +- Nodes reachable via multiple paths + +### Basic Usage + +```dart +// Get path results +final paths = query.matchPaths('group-[:HAS_POLICY]->policy-[:GRANTS_ACCESS]->asset'); +final layout = paths.computeLayout(); + +// Get column for pattern variables +final policyColumn = layout.variableLayer('policy'); + +// Get column for specific node ID +final nodeColumn = layout.layerFor('node_123'); + +// Render by column +for (var layer = 0; layer <= layout.maxDepth; layer++) { + final nodesInColumn = layout.nodesInLayer(layer); + renderColumn(layer, nodesInColumn); +} +``` + +### Layout Strategies + +Two strategies available (default: `longestPath`): + +```dart +// Pattern order (fast, predictable - follows query left-to-right) +final layout = paths.computeLayout(strategy: LayerStrategy.pattern); + +// Longest path (best for complex graphs with diamonds, minimizes crossings) +final layout = paths.computeLayout(strategy: LayerStrategy.longestPath); +``` + +### GraphLayout Properties + +```dart +layout.maxDepth // Number of layers - 1 +layout.roots // Root nodes (layer 0 entry points) +layout.allNodes // All unique node IDs in paths +layout.allEdges // All unique edges in paths +layout.nodeDepths // Map of node ID → layer +layout.variableDepths // Map of variable → typical layer +layout.nodesByLayer // Map> of layer → node IDs +``` + +### Complete Example + +See `bin/layout_demo.dart` for a comprehensive before/after comparison showing how GraphLayout eliminates hardcoded positioning. + +**Why use GraphLayout?** +- ✓ No hardcoded column positions +- ✓ Automatically adapts to graph structure changes +- ✓ Handles orphan nodes, cycles, disconnected components +- ✓ Works with any pattern, any node types +- ✓ Both structural and grouped positioning available + +## 11. Examples index ### Dart CLI Examples - `bin/showcase.dart` – comprehensive graph demo with multiple query examples diff --git a/lib/src/layout.dart b/lib/src/layout.dart index 21b5fc6..ead9ecd 100644 --- a/lib/src/layout.dart +++ b/lib/src/layout.dart @@ -15,11 +15,6 @@ enum LayerStrategy { /// Assigns each node to MAX depth across all paths to minimize edge crossings. /// Best for: Complex graphs with diamonds, multiple paths, general visualization. longestPath, - - /// Use topological sort (requires DAG + Graph instance). - /// Falls back to longestPath if graph contains cycles. - /// Best for: Dependency visualization, build systems, task scheduling. - topological, } /// Computed layout information for visualizing graph query results. @@ -176,15 +171,6 @@ extension PathMatchLayout on List { case LayerStrategy.longestPath: nodeDepths = _computeLongestPathDepths(allNodes, allEdges); break; - case LayerStrategy.topological: - if (graph == null) { - throw ArgumentError( - 'Graph instance required for topological strategy. ' - 'Either provide graph parameter or use a different strategy.', - ); - } - nodeDepths = _computeTopologicalDepths(graph, allNodes, allEdges); - break; } // 3. Find roots (nodes with no incoming edges in the path set) @@ -338,18 +324,4 @@ extension PathMatchLayout on List { return nodeDepths; } - - /// Strategy 3: Topological sort-based layering. - /// - /// Uses Kahn's algorithm for DAG ordering. Falls back to longestPath - /// if graph contains cycles. - Map _computeTopologicalDepths( - Graph graph, - Set nodes, - Set edges, - ) { - // Just use longest path on the path edges - simpler and more reliable - // than trying to use the full graph topology - return _computeLongestPathDepths(nodes, edges); - } } diff --git a/pubspec.yaml b/pubspec.yaml index fa68ad2..74163bd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: graph_kit description: A lightweight, in-memory graph library with pattern-based queries and efficient traversal for Dart and Flutter applications. -version: 0.7.7 +version: 0.7.8 homepage: https://github.com/vento007/graph_kit repository: https://github.com/vento007/graph_kit issue_tracker: https://github.com/vento007/graph_kit/issues diff --git a/test/debug_layout.dart b/test/debug_layout.dart new file mode 100644 index 0000000..6846aa2 --- /dev/null +++ b/test/debug_layout.dart @@ -0,0 +1,49 @@ +import 'package:graph_kit/graph_kit.dart'; + +void main() { + // Same graph that's failing: + // a -> b -> c -> d + // a --------> d (shortcut) + + final graph = Graph(); + graph.addNode(Node(id: 'a', type: 'N', label: 'A')); + graph.addNode(Node(id: 'b', type: 'N', label: 'B')); + graph.addNode(Node(id: 'c', type: 'N', label: 'C')); + graph.addNode(Node(id: 'd', type: 'N', label: 'D')); + + graph.addEdge('a', 'X', 'b'); + graph.addEdge('b', 'X', 'c'); + graph.addEdge('c', 'X', 'd'); + graph.addEdge('a', 'X', 'd'); // Shortcut + + final query = PatternQuery(graph); + final paths = query.matchPaths('a-[:X*1..3]->d', startId: 'a'); + + print('Number of paths found: ${paths.length}'); + print(''); + + for (var i = 0; i < paths.length; i++) { + final path = paths[i]; + print('Path $i:'); + print(' nodes: ${path.nodes}'); + print(' edges:'); + for (final edge in path.edges) { + print(' ${edge.fromVariable}(${edge.from}) -[${edge.type}]-> ${edge.toVariable}(${edge.to})'); + } + print(''); + } + + print('Computing layout with longestPath strategy...'); + final layout = paths.computeLayout(strategy: LayerStrategy.longestPath); + + print('Node depths:'); + for (final entry in layout.nodeDepths.entries) { + print(' ${entry.key}: ${entry.value}'); + } + + print(''); + print('All edges in layout:'); + for (final edge in layout.allEdges) { + print(' ${edge.src} -> ${edge.dst}'); + } +} diff --git a/test/debug_matchrows_vs_matchpaths.dart b/test/debug_matchrows_vs_matchpaths.dart new file mode 100644 index 0000000..5f686a8 --- /dev/null +++ b/test/debug_matchrows_vs_matchpaths.dart @@ -0,0 +1,44 @@ +import 'package:graph_kit/graph_kit.dart'; + +void main() { + // Same graph: + // a -> b -> c -> d + // a --------> d (shortcut) + + final graph = Graph(); + graph.addNode(Node(id: 'a', type: 'N', label: 'A')); + graph.addNode(Node(id: 'b', type: 'N', label: 'B')); + graph.addNode(Node(id: 'c', type: 'N', label: 'C')); + graph.addNode(Node(id: 'd', type: 'N', label: 'D')); + + graph.addEdge('a', 'X', 'b'); + graph.addEdge('b', 'X', 'c'); + graph.addEdge('c', 'X', 'd'); + graph.addEdge('a', 'X', 'd'); // Shortcut + + final query = PatternQuery(graph); + + print('=== Testing matchRows (what existing tests use) ==='); + final rows = query.matchRows('start-[:X*1..3]->end', startId: 'a'); + print('matchRows returned ${rows.length} rows:'); + for (var i = 0; i < rows.length; i++) { + print(' Row $i: ${rows[i]}'); + } + + print('\n=== Testing matchPaths (what my tests use) ==='); + final paths = query.matchPaths('start-[:X*1..3]->end', startId: 'a'); + print('matchPaths returned ${paths.length} paths:'); + for (var i = 0; i < paths.length; i++) { + final path = paths[i]; + print(' Path $i:'); + print(' nodes: ${path.nodes}'); + print(' edges: ${path.edges.length} edges'); + for (final edge in path.edges) { + print(' ${edge.from} -[${edge.type}]-> ${edge.to}'); + } + } + + print('\n=== Verdict ==='); + print('matchRows works: ${rows.isNotEmpty && rows.every((r) => r["end"] != null)}'); + print('matchPaths works: ${paths.isNotEmpty && paths.every((p) => p.nodes["end"] != null)}'); +} diff --git a/test/layout_test.dart b/test/layout_test.dart index 6f5e5de..1172bea 100644 --- a/test/layout_test.dart +++ b/test/layout_test.dart @@ -291,7 +291,7 @@ void main() { expect(layout.allEdges, isEmpty); }); - test('all three strategies on same simple graph', () { + test('both strategies on same simple graph', () { // Graph: a -> b -> c -> d final graph = Graph(); graph.addNode(Node(id: 'a', type: 'Node', label: 'A')); @@ -308,17 +308,12 @@ void main() { final layoutPattern = paths.computeLayout(strategy: LayerStrategy.pattern); final layoutLongest = paths.computeLayout(strategy: LayerStrategy.longestPath); - final layoutTopo = paths.computeLayout( - strategy: LayerStrategy.topological, - graph: graph, - ); - // For simple linear path, all should give same result + // For simple linear path, both should give same result expect(layoutPattern.nodeDepths, layoutLongest.nodeDepths); - expect(layoutLongest.nodeDepths, layoutTopo.nodeDepths); expect(layoutPattern.variableDepths, layoutLongest.variableDepths); - // All should produce same layering + // Both should produce same layering expect(layoutPattern.nodeDepths['a'], 0); expect(layoutPattern.nodeDepths['b'], 1); expect(layoutPattern.nodeDepths['c'], 2);