grai-build 0.3.2__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 +1 -2
- grai/cli/main.py +287 -0
- grai/core/migrations/__init__.py +30 -0
- grai/core/migrations/differ.py +285 -0
- grai/core/migrations/executor.py +346 -0
- grai/core/migrations/generator.py +431 -0
- grai/core/migrations/models.py +160 -0
- grai/core/models.py +20 -0
- {grai_build-0.3.2.dist-info → grai_build-0.4.1.dist-info}/METADATA +4 -4
- {grai_build-0.3.2.dist-info → grai_build-0.4.1.dist-info}/RECORD +14 -9
- {grai_build-0.3.2.dist-info → grai_build-0.4.1.dist-info}/WHEEL +1 -1
- {grai_build-0.3.2.dist-info → grai_build-0.4.1.dist-info}/entry_points.txt +0 -0
- {grai_build-0.3.2.dist-info → grai_build-0.4.1.dist-info}/licenses/LICENSE +0 -0
- {grai_build-0.3.2.dist-info → grai_build-0.4.1.dist-info}/top_level.txt +0 -0
grai/__init__.py
CHANGED
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()
|