diff --git a/examples/schema-update-example.raku b/examples/schema-update-example.raku new file mode 100644 index 00000000..a99aa942 --- /dev/null +++ b/examples/schema-update-example.raku @@ -0,0 +1,52 @@ +#!/usr/bin/env raku + +# Example usage of the new schema.update method +# This demonstrates how to use schema updates for intelligent migration + +use Red; +use Red::Schema; + +# Set up database connection +red-defaults "SQLite", :database; + +# Define initial models +model User { + has Int $.id is serial; + has Str $.name is column; +} + +model Post { + has Int $.id is serial; + has Int $.user-id is referencing(model => 'User', column => 'id'); + has Str $.title is column; + has Str $.content is column; +} + +# Create schema +my $schema = schema(User, Post); + +# Example 1: Initial creation +say "Creating initial schema..."; +$schema.drop; # Clean slate +$schema.create; + +# Add some test data +User.^create: :name("Alice"); +User.^create: :name("Bob"); + +say "Initial users: ", User.^all.map(*.name).join(", "); + +# Example 2: Schema update (simulating model changes) +# In real usage, you would modify your model definitions and then call update +say "\nUpdating schema..."; +$schema.update; + +# Example 3: Idempotent updates +say "Running update again (should be safe/idempotent)..."; +$schema.update; + +# Example 4: Chaining methods +say "Demonstrating method chaining..."; +$schema.update.drop; + +say "Schema update example completed successfully!"; \ No newline at end of file diff --git a/lib/Red/Schema.rakumod b/lib/Red/Schema.rakumod index c3230e67..9c8f2e0a 100644 --- a/lib/Red/Schema.rakumod +++ b/lib/Red/Schema.rakumod @@ -68,3 +68,28 @@ method create(:$where) { } self } + +#| Updates the database schema to match the model definitions. +#| Compares the current schema definition with the database state +#| and applies necessary changes (add/modify/remove columns, etc.). +#| This method is idempotent and preserves existing data. +method update(:$where) { + red-do (:$where with $where), :transaction, { + my $db = get-RED-DB; + + # Get differences for each model in the schema and execute changes + for %!models.values -> $model { + my @diffs = $model.^diff-from-db; + if @diffs { + # Convert differences to AST nodes grouped by execution order + for $db.diff-to-ast(@diffs) -> @ast-group { + for @ast-group -> $ast { + my $sql = $db.translate($ast).key; + $db.execute($sql) if $sql; + } + } + } + } + } + self +} diff --git a/t/99-schema-update.rakutest b/t/99-schema-update.rakutest new file mode 100644 index 00000000..abf0ff1c --- /dev/null +++ b/t/99-schema-update.rakutest @@ -0,0 +1,185 @@ +use Test; +use Red; +use Red::Schema; +use lib "t/lib"; + +my $*RED-FALLBACK = $_ with %*ENV; +my $*RED-DEBUG = $_ with %*ENV; +my $*RED-DEBUG-RESPONSE = $_ with %*ENV; +my $*RED-DEBUG-AST = $_ with %*ENV; +my @conf = (%*ENV // "SQLite").split(" "); +my $driver = @conf.shift; +my $*RED-DB = database $driver, |%( @conf.map: { do given .split: "=" { .[0] => val .[1] } } ); + +plan 6; + +# Create a simple model for testing schema updates +model TestUpdatePerson is table { + has Int $.id is serial; + has Str $.name is column; +} + +# Test basic update method functionality +subtest "Schema update method exists and works", { + plan 3; + + my $schema = schema(TestUpdatePerson); + isa-ok $schema, Red::Schema, "Schema created successfully"; + + # Drop and create initial schema + $schema.drop; + lives-ok { $schema.create }, "Initial schema creation works"; + + # Update should work even when no changes are needed + lives-ok { $schema.update }, "Schema update method executes without errors"; +} + +# Test that update handles missing tables by creating them +subtest "Update creates missing tables", { + plan 3; + + # Define a new model for this test + model TestMissingTable is table { + has Int $.id is serial; + has Str $.value is column; + } + + my $schema = schema(TestMissingTable); + + # Make sure table doesn't exist + $schema.drop; + + # Verify table doesn't exist initially + my $table-exists = False; + try { + TestMissingTable.^all.head; + $table-exists = True; + } + + nok $table-exists, "Table doesn't exist initially"; + + # Update should create the missing table + lives-ok { $schema.update }, "Update executes successfully"; + + # Verify table now exists and works + lives-ok { + TestMissingTable.^create: :value("test"); + my $count = TestMissingTable.^all.elems; + }, "Table was created and is functional"; + + # Cleanup + $schema.drop; +} + +# Test update with multiple tables +subtest "Update works with multiple tables", { + plan 2; + + # Use existing Person and Post models + my $schema = schema(Person, Post); + + # Start fresh + $schema.drop; + + # Create only one table initially (Person) + Person.^create-table; + + # Update should create the missing Post table and handle the foreign key relationship + lives-ok { $schema.update }, "Update with multiple related tables works"; + + # Verify both tables exist and relationships work + lives-ok { + my $person = Person.^create: :name("Test Author"); + my $post = Post.^create: :title("Test Post"), :body("Test Body"), :author-id($person.id); + }, "Both tables exist and relationships work"; + + # Cleanup + $schema.drop; +} + +# Test update is idempotent +subtest "Update is idempotent", { + plan 3; + + model TestIdempotent is table { + has Int $.id is serial; + has Str $.name is column; + } + + my $schema = schema(TestIdempotent); + + # Start fresh and create schema + $schema.drop; + $schema.create; + + # First update should be safe + lives-ok { $schema.update }, "First update on existing schema works"; + + # Second update should also be safe (idempotent) + lives-ok { $schema.update }, "Second update is idempotent"; + + # Verify table still works + lives-ok { + TestIdempotent.^create: :name("test"); + my $count = TestIdempotent.^all.elems; + }, "Table remains functional after multiple updates"; + + # Cleanup + $schema.drop; +} + +# Test update with data preservation +subtest "Update preserves existing data", { + plan 3; + + model TestDataPreservation is table { + has Int $.id is serial; + has Str $.name is column; + } + + my $schema = schema(TestDataPreservation); + + # Start fresh, create schema and add data + $schema.drop; + $schema.create; + TestDataPreservation.^create: :name("existing data"); + + my $initial-count = TestDataPreservation.^all.elems; + is $initial-count, 1, "Initial data exists"; + + # Update should preserve data + lives-ok { $schema.update }, "Update preserves existing data"; + + # Verify data is still there + my $final-count = TestDataPreservation.^all.elems; + is $final-count, $initial-count, "Data count preserved after update"; + + # Cleanup + $schema.drop; +} + +# Test schema update method can be chained +subtest "Update method returns schema object for chaining", { + plan 2; + + model TestChaining is table { + has Int $.id is serial; + has Str $.name is column; + } + + my $schema = schema(TestChaining); + $schema.drop; + + # Test method chaining + my $result; + lives-ok { + $result = $schema.create.update; + }, "Schema methods can be chained"; + + isa-ok $result, Red::Schema, "Update returns schema object"; + + # Cleanup + $schema.drop; +} + +done-testing; \ No newline at end of file