grai-build 0.3.0__py3-none-any.whl → 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
grai/__init__.py CHANGED
@@ -7,5 +7,4 @@ try:
7
7
 
8
8
  __version__ = version("grai-build")
9
9
  except Exception:
10
- # Fallback for development or if package metadata is not available
11
- __version__ = "0.3.0"
10
+ __version__ = "0.0.0.dev0"
grai/cli/main.py CHANGED
@@ -2537,6 +2537,293 @@ def _generate_lineage_html(project: Project, mermaid_diagram: str) -> str:
2537
2537
  """
2538
2538
 
2539
2539
 
2540
+ @app.command()
2541
+ def migrate_generate(
2542
+ path: Path = typer.Argument(
2543
+ Path("."),
2544
+ help="Project directory.",
2545
+ ),
2546
+ message: Optional[str] = typer.Option(
2547
+ None,
2548
+ "--message",
2549
+ "-m",
2550
+ help="Migration description message.",
2551
+ ),
2552
+ ):
2553
+ """
2554
+ Generate a new migration from schema changes.
2555
+
2556
+ Compares current YAML schema with last migration state
2557
+ and creates a new migration file with up/down scripts.
2558
+ """
2559
+ try:
2560
+ from grai.core.migrations import MigrationGenerator
2561
+
2562
+ console.print("[bold blue]Generating migration...[/bold blue]")
2563
+
2564
+ # Load current project
2565
+ project = load_project(path)
2566
+
2567
+ # Initialize generator
2568
+ generator = MigrationGenerator(path)
2569
+
2570
+ # Generate migration
2571
+ migration = generator.generate(
2572
+ current_entities=project.entities,
2573
+ current_relations=project.relations,
2574
+ description=message,
2575
+ )
2576
+
2577
+ if not migration.changes.has_changes():
2578
+ console.print("[yellow]No schema changes detected. No migration created.[/yellow]")
2579
+ return
2580
+
2581
+ # Save migration
2582
+ filepath = generator.save_migration(migration)
2583
+
2584
+ console.print(f"[green]✓[/green] Migration created: {filepath.name}")
2585
+ console.print(f"\n[bold]Changes:[/bold] {migration.changes.summary()}")
2586
+ console.print(f"[dim]Version:[/dim] {migration.version}")
2587
+ console.print(f"[dim]Up statements:[/dim] {len(migration.up_cypher)}")
2588
+ console.print(f"[dim]Down statements:[/dim] {len(migration.down_cypher)}")
2589
+
2590
+ except Exception as e:
2591
+ console.print(f"[red]✗ Error generating migration: {e}[/red]")
2592
+ raise typer.Exit(1)
2593
+
2594
+
2595
+ @app.command()
2596
+ def migrate_status(
2597
+ path: Path = typer.Argument(
2598
+ Path("."),
2599
+ help="Project directory.",
2600
+ ),
2601
+ uri: str = typer.Option(
2602
+ "bolt://localhost:7687",
2603
+ "--uri",
2604
+ help="Neo4j connection URI.",
2605
+ ),
2606
+ user: str = typer.Option(
2607
+ "neo4j",
2608
+ "--user",
2609
+ help="Neo4j username.",
2610
+ ),
2611
+ password: str = typer.Option(
2612
+ ...,
2613
+ "--password",
2614
+ prompt=True,
2615
+ hide_input=True,
2616
+ help="Neo4j password.",
2617
+ ),
2618
+ ):
2619
+ """
2620
+ Show migration status (pending and applied).
2621
+
2622
+ Lists all migrations and their application status.
2623
+ """
2624
+ try:
2625
+ from neo4j import GraphDatabase
2626
+
2627
+ from grai.core.migrations import MigrationExecutor
2628
+
2629
+ console.print("[bold blue]Checking migration status...[/bold blue]\n")
2630
+
2631
+ # Connect to Neo4j
2632
+ driver = GraphDatabase.driver(uri, auth=(user, password))
2633
+
2634
+ try:
2635
+ executor = MigrationExecutor(driver, path)
2636
+
2637
+ # Get migration info
2638
+ pending = executor.get_pending_migrations()
2639
+ history = executor.get_migration_history()
2640
+
2641
+ # Show applied migrations
2642
+ if history:
2643
+ console.print("[bold green]Applied Migrations:[/bold green]")
2644
+ table = Table()
2645
+ table.add_column("Version", style="cyan")
2646
+ table.add_column("Description")
2647
+ table.add_column("Status")
2648
+ table.add_column("Applied At")
2649
+
2650
+ for h in history:
2651
+ status_color = "green" if h.status.value == "applied" else "red"
2652
+ table.add_row(
2653
+ h.version,
2654
+ h.description[:50],
2655
+ f"[{status_color}]{h.status.value}[/{status_color}]",
2656
+ h.applied_at.strftime("%Y-%m-%d %H:%M:%S"),
2657
+ )
2658
+
2659
+ console.print(table)
2660
+ console.print()
2661
+
2662
+ # Show pending migrations
2663
+ if pending:
2664
+ console.print(f"[bold yellow]Pending Migrations: {len(pending)}[/bold yellow]")
2665
+ for migration in pending:
2666
+ console.print(f" • {migration.version}: {migration.description}")
2667
+ else:
2668
+ console.print("[green]✓ No pending migrations[/green]")
2669
+
2670
+ finally:
2671
+ driver.close()
2672
+
2673
+ except Exception as e:
2674
+ console.print(f"[red]✗ Error: {e}[/red]")
2675
+ raise typer.Exit(1)
2676
+
2677
+
2678
+ @app.command()
2679
+ def migrate_apply(
2680
+ path: Path = typer.Argument(
2681
+ Path("."),
2682
+ help="Project directory.",
2683
+ ),
2684
+ uri: str = typer.Option(
2685
+ "bolt://localhost:7687",
2686
+ "--uri",
2687
+ help="Neo4j connection URI.",
2688
+ ),
2689
+ user: str = typer.Option(
2690
+ "neo4j",
2691
+ "--user",
2692
+ help="Neo4j username.",
2693
+ ),
2694
+ password: str = typer.Option(
2695
+ ...,
2696
+ "--password",
2697
+ prompt=True,
2698
+ hide_input=True,
2699
+ help="Neo4j password.",
2700
+ ),
2701
+ dry_run: bool = typer.Option(
2702
+ False,
2703
+ "--dry-run",
2704
+ help="Validate migrations without applying.",
2705
+ ),
2706
+ ):
2707
+ """
2708
+ Apply pending migrations to Neo4j.
2709
+
2710
+ Executes all pending migration scripts in order.
2711
+ """
2712
+ try:
2713
+ from neo4j import GraphDatabase
2714
+
2715
+ from grai.core.migrations import MigrationExecutor
2716
+
2717
+ mode = "dry run" if dry_run else "applying"
2718
+ console.print(f"[bold blue]Migrations {mode}...[/bold blue]\n")
2719
+
2720
+ # Connect to Neo4j
2721
+ driver = GraphDatabase.driver(uri, auth=(user, password))
2722
+
2723
+ try:
2724
+ executor = MigrationExecutor(driver, path)
2725
+
2726
+ # Get pending migrations
2727
+ pending = executor.get_pending_migrations()
2728
+
2729
+ if not pending:
2730
+ console.print("[green]✓ No pending migrations[/green]")
2731
+ return
2732
+
2733
+ console.print(f"Found {len(pending)} pending migration(s):\n")
2734
+
2735
+ # Apply migrations
2736
+ for migration in pending:
2737
+ console.print(f"• {migration.version}: {migration.description}")
2738
+ console.print(f" [dim]Statements: {len(migration.up_cypher)}[/dim]")
2739
+
2740
+ if not dry_run:
2741
+ result = executor.apply_migration(migration)
2742
+
2743
+ if result.status.value == "applied":
2744
+ console.print(
2745
+ f" [green]✓ Applied in {result.execution_time_ms}ms[/green]\n"
2746
+ )
2747
+ else:
2748
+ console.print(f" [red]✗ Failed: {result.error_message}[/red]\n")
2749
+ break
2750
+ else:
2751
+ console.print(" [yellow]↺ Validated (not applied)[/yellow]\n")
2752
+
2753
+ if dry_run:
2754
+ console.print("[yellow]Dry run complete. No changes made.[/yellow]")
2755
+ else:
2756
+ console.print("[green]✓ All migrations applied successfully[/green]")
2757
+
2758
+ finally:
2759
+ driver.close()
2760
+
2761
+ except Exception as e:
2762
+ console.print(f"[red]✗ Error: {e}[/red]")
2763
+ raise typer.Exit(1)
2764
+
2765
+
2766
+ @app.command()
2767
+ def migrate_rollback(
2768
+ path: Path = typer.Argument(
2769
+ Path("."),
2770
+ help="Project directory.",
2771
+ ),
2772
+ uri: str = typer.Option(
2773
+ "bolt://localhost:7687",
2774
+ "--uri",
2775
+ help="Neo4j connection URI.",
2776
+ ),
2777
+ user: str = typer.Option(
2778
+ "neo4j",
2779
+ "--user",
2780
+ help="Neo4j username.",
2781
+ ),
2782
+ password: str = typer.Option(
2783
+ ...,
2784
+ "--password",
2785
+ prompt=True,
2786
+ hide_input=True,
2787
+ help="Neo4j password.",
2788
+ ),
2789
+ version: Optional[str] = typer.Option(
2790
+ None,
2791
+ "--version",
2792
+ help="Specific version to rollback (default: last migration).",
2793
+ ),
2794
+ ):
2795
+ """
2796
+ Rollback a migration using its down script.
2797
+
2798
+ Rolls back the last applied migration or a specific version.
2799
+ """
2800
+ try:
2801
+ from neo4j import GraphDatabase
2802
+
2803
+ from grai.core.migrations import MigrationExecutor
2804
+
2805
+ console.print("[bold yellow]Rolling back migration...[/bold yellow]\n")
2806
+
2807
+ # Connect to Neo4j
2808
+ driver = GraphDatabase.driver(uri, auth=(user, password))
2809
+
2810
+ try:
2811
+ executor = MigrationExecutor(driver, path)
2812
+
2813
+ # Rollback
2814
+ result = executor.rollback_migration(version)
2815
+
2816
+ console.print(f"[green]✓[/green] Rolled back migration {result.version}")
2817
+ console.print(f"[dim]Time: {result.execution_time_ms}ms[/dim]")
2818
+
2819
+ finally:
2820
+ driver.close()
2821
+
2822
+ except Exception as e:
2823
+ console.print(f"[red]✗ Error: {e}[/red]")
2824
+ raise typer.Exit(1)
2825
+
2826
+
2540
2827
  def main_cli():
2541
2828
  """Entry point for the CLI."""
2542
2829
  app()
@@ -0,0 +1,30 @@
1
+ """Migration system for schema versioning and change management."""
2
+
3
+ from grai.core.migrations.differ import SchemaDiffer, diff_schemas
4
+ from grai.core.migrations.executor import MigrationExecutor
5
+ from grai.core.migrations.generator import MigrationGenerator
6
+ from grai.core.migrations.models import (
7
+ ChangeType,
8
+ EntityChange,
9
+ Migration,
10
+ MigrationHistory,
11
+ MigrationStatus,
12
+ PropertyChange,
13
+ RelationChange,
14
+ SchemaChanges,
15
+ )
16
+
17
+ __all__ = [
18
+ "ChangeType",
19
+ "EntityChange",
20
+ "Migration",
21
+ "MigrationExecutor",
22
+ "MigrationGenerator",
23
+ "MigrationHistory",
24
+ "MigrationStatus",
25
+ "PropertyChange",
26
+ "RelationChange",
27
+ "SchemaChanges",
28
+ "SchemaDiffer",
29
+ "diff_schemas",
30
+ ]
@@ -0,0 +1,285 @@
1
+ """
2
+ Schema differ for detecting changes between schema versions.
3
+
4
+ This module compares two schema states and generates a structured
5
+ representation of the differences.
6
+ """
7
+
8
+ from typing import List, Optional
9
+
10
+ from grai.core.migrations.models import (
11
+ ChangeType,
12
+ EntityChange,
13
+ PropertyChange,
14
+ RelationChange,
15
+ SchemaChanges,
16
+ )
17
+ from grai.core.models import Entity, Relation
18
+
19
+
20
+ class SchemaDiffer:
21
+ """
22
+ Compares two schema states and generates change descriptions.
23
+
24
+ This is used by the migration generator to detect what has changed
25
+ between the last migration and the current schema definition.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ old_entities: Optional[List[Entity]] = None,
31
+ old_relations: Optional[List[Relation]] = None,
32
+ new_entities: Optional[List[Entity]] = None,
33
+ new_relations: Optional[List[Relation]] = None,
34
+ ):
35
+ """
36
+ Initialize the differ with old and new schema states.
37
+
38
+ Args:
39
+ old_entities: Entities from previous schema state.
40
+ old_relations: Relations from previous schema state.
41
+ new_entities: Entities from current schema state.
42
+ new_relations: Relations from current schema state.
43
+ """
44
+ self.old_entities = {e.name: e for e in (old_entities or [])}
45
+ self.old_relations = {r.name: r for r in (old_relations or [])}
46
+ self.new_entities = {e.name: e for e in (new_entities or [])}
47
+ self.new_relations = {r.name: r for r in (new_relations or [])}
48
+
49
+ def diff(self) -> SchemaChanges:
50
+ """
51
+ Compute the differences between old and new schemas.
52
+
53
+ Returns:
54
+ SchemaChanges object containing all detected changes.
55
+ """
56
+ entity_changes = self._diff_entities()
57
+ relation_changes = self._diff_relations()
58
+
59
+ return SchemaChanges(entities=entity_changes, relations=relation_changes)
60
+
61
+ def _diff_entities(self) -> List[EntityChange]:
62
+ """Compute changes to entities."""
63
+ changes = []
64
+
65
+ # Find added entities
66
+ for name, entity in self.new_entities.items():
67
+ if name not in self.old_entities:
68
+ changes.append(
69
+ EntityChange(
70
+ name=name,
71
+ change_type=ChangeType.ADDED,
72
+ properties_added=[p.model_dump(mode="json") for p in entity.properties],
73
+ new_keys=entity.keys,
74
+ )
75
+ )
76
+
77
+ # Find removed entities
78
+ for name in self.old_entities:
79
+ if name not in self.new_entities:
80
+ old_entity = self.old_entities[name]
81
+ changes.append(
82
+ EntityChange(
83
+ name=name,
84
+ change_type=ChangeType.REMOVED,
85
+ properties_removed=[p.name for p in old_entity.properties],
86
+ old_keys=old_entity.keys,
87
+ )
88
+ )
89
+
90
+ # Find modified entities
91
+ for name in set(self.old_entities.keys()) & set(self.new_entities.keys()):
92
+ old_entity = self.old_entities[name]
93
+ new_entity = self.new_entities[name]
94
+
95
+ entity_change = self._diff_entity_properties(old_entity, new_entity)
96
+ if entity_change:
97
+ changes.append(entity_change)
98
+
99
+ return changes
100
+
101
+ def _diff_entity_properties(
102
+ self, old_entity: Entity, new_entity: Entity
103
+ ) -> Optional[EntityChange]:
104
+ """Compare properties of a single entity."""
105
+ old_props = {p.name: p for p in old_entity.properties}
106
+ new_props = {p.name: p for p in new_entity.properties}
107
+
108
+ properties_added = []
109
+ properties_removed = []
110
+ properties_modified = []
111
+
112
+ # Find added properties
113
+ for name, prop in new_props.items():
114
+ if name not in old_props:
115
+ properties_added.append(prop.model_dump(mode="json"))
116
+
117
+ # Find removed properties
118
+ for name in old_props:
119
+ if name not in new_props:
120
+ properties_removed.append(name)
121
+
122
+ # Find modified properties
123
+ for name in set(old_props.keys()) & set(new_props.keys()):
124
+ old_prop = old_props[name]
125
+ new_prop = new_props[name]
126
+
127
+ if old_prop.type != new_prop.type or old_prop.required != new_prop.required:
128
+ properties_modified.append(
129
+ PropertyChange(
130
+ name=name,
131
+ old_type=old_prop.type.value if old_prop.type else None,
132
+ new_type=new_prop.type.value if new_prop.type else None,
133
+ old_required=old_prop.required,
134
+ new_required=new_prop.required,
135
+ change_type=ChangeType.MODIFIED,
136
+ )
137
+ )
138
+
139
+ # Check if keys changed
140
+ keys_changed = set(old_entity.keys or []) != set(new_entity.keys or [])
141
+
142
+ # Only create change if something actually changed
143
+ if properties_added or properties_removed or properties_modified or keys_changed:
144
+ return EntityChange(
145
+ name=new_entity.name,
146
+ change_type=ChangeType.MODIFIED,
147
+ properties_added=properties_added,
148
+ properties_modified=properties_modified,
149
+ properties_removed=properties_removed,
150
+ keys_changed=keys_changed,
151
+ old_keys=old_entity.keys,
152
+ new_keys=new_entity.keys,
153
+ )
154
+
155
+ return None
156
+
157
+ def _diff_relations(self) -> List[RelationChange]:
158
+ """Compute changes to relations."""
159
+ changes = []
160
+
161
+ # Find added relations
162
+ for name, relation in self.new_relations.items():
163
+ if name not in self.old_relations:
164
+ changes.append(
165
+ RelationChange(
166
+ name=name,
167
+ change_type=ChangeType.ADDED,
168
+ new_from=relation.from_entity,
169
+ new_to=relation.to_entity,
170
+ properties_added=[p.model_dump(mode="json") for p in relation.properties],
171
+ )
172
+ )
173
+
174
+ # Find removed relations
175
+ for name in self.old_relations:
176
+ if name not in self.new_relations:
177
+ old_relation = self.old_relations[name]
178
+ changes.append(
179
+ RelationChange(
180
+ name=name,
181
+ change_type=ChangeType.REMOVED,
182
+ old_from=old_relation.from_entity,
183
+ old_to=old_relation.to_entity,
184
+ properties_removed=[p.name for p in old_relation.properties],
185
+ )
186
+ )
187
+
188
+ # Find modified relations
189
+ for name in set(self.old_relations.keys()) & set(self.new_relations.keys()):
190
+ old_relation = self.old_relations[name]
191
+ new_relation = self.new_relations[name]
192
+
193
+ relation_change = self._diff_relation_properties(old_relation, new_relation)
194
+ if relation_change:
195
+ changes.append(relation_change)
196
+
197
+ return changes
198
+
199
+ def _diff_relation_properties(
200
+ self, old_relation: Relation, new_relation: Relation
201
+ ) -> Optional[RelationChange]:
202
+ """Compare properties of a single relation."""
203
+ old_props = {p.name: p for p in old_relation.properties}
204
+ new_props = {p.name: p for p in new_relation.properties}
205
+
206
+ properties_added = []
207
+ properties_removed = []
208
+ properties_modified = []
209
+
210
+ # Find added properties
211
+ for name, prop in new_props.items():
212
+ if name not in old_props:
213
+ properties_added.append(prop.model_dump(mode="json"))
214
+
215
+ # Find removed properties
216
+ for name in old_props:
217
+ if name not in new_props:
218
+ properties_removed.append(name)
219
+
220
+ # Find modified properties
221
+ for name in set(old_props.keys()) & set(new_props.keys()):
222
+ old_prop = old_props[name]
223
+ new_prop = new_props[name]
224
+
225
+ if old_prop.type != new_prop.type or old_prop.required != new_prop.required:
226
+ properties_modified.append(
227
+ PropertyChange(
228
+ name=name,
229
+ old_type=old_prop.type.value if old_prop.type else None,
230
+ new_type=new_prop.type.value if new_prop.type else None,
231
+ old_required=old_prop.required,
232
+ new_required=new_prop.required,
233
+ change_type=ChangeType.MODIFIED,
234
+ )
235
+ )
236
+
237
+ # Check if from/to entities changed
238
+ from_changed = old_relation.from_entity != new_relation.from_entity
239
+ to_changed = old_relation.to_entity != new_relation.to_entity
240
+
241
+ # Only create change if something actually changed
242
+ if (
243
+ properties_added
244
+ or properties_removed
245
+ or properties_modified
246
+ or from_changed
247
+ or to_changed
248
+ ):
249
+ return RelationChange(
250
+ name=new_relation.name,
251
+ change_type=ChangeType.MODIFIED,
252
+ from_entity_changed=from_changed,
253
+ to_entity_changed=to_changed,
254
+ old_from=old_relation.from_entity if from_changed else None,
255
+ new_from=new_relation.from_entity if from_changed else None,
256
+ old_to=old_relation.to_entity if to_changed else None,
257
+ new_to=new_relation.to_entity if to_changed else None,
258
+ properties_added=properties_added,
259
+ properties_modified=properties_modified,
260
+ properties_removed=properties_removed,
261
+ )
262
+
263
+ return None
264
+
265
+
266
+ def diff_schemas(
267
+ old_entities: Optional[List[Entity]] = None,
268
+ old_relations: Optional[List[Relation]] = None,
269
+ new_entities: Optional[List[Entity]] = None,
270
+ new_relations: Optional[List[Relation]] = None,
271
+ ) -> SchemaChanges:
272
+ """
273
+ Convenience function to compute schema differences.
274
+
275
+ Args:
276
+ old_entities: Entities from previous schema state.
277
+ old_relations: Relations from previous schema state.
278
+ new_entities: Entities from current schema state.
279
+ new_relations: Relations from current schema state.
280
+
281
+ Returns:
282
+ SchemaChanges object containing all detected changes.
283
+ """
284
+ differ = SchemaDiffer(old_entities, old_relations, new_entities, new_relations)
285
+ return differ.diff()