فهرست منبع

fix(migrations): stop dropping .indexed() and sibling flags after .references()

ForeignKeyBuilder extends MigrationColumnBuilder but wraps the real column builder that TableBuilder/AlterTableBuilder read at build time. Its constructor called base.for_subclass(), which builds a detached throwaway ColumnDefinition, and none of the inherited column methods were overridden - so every column method called after .references() (indexed(), not_null(), unique(), auto_increment(), type_*(), primary_key(), default_value(), default_now()) mutated the wrapper's dead state and was silently dropped. The reported symptom was .references().indexed() compiling and running without ever creating the index; the FK constraint itself was unaffected because TableBuilder reads it from the inner builder's fk_builder.

Fix: mark the column configuration methods and the should_create_index/index_name/fk_builder getters virtual on MigrationColumnBuilder, and override them all on ForeignKeyBuilder to delegate to the wrapped inner builder and return the wrapper so the chain stays fluent. Both chain orders (.indexed().references() and .references().indexed()) now behave identically, in create_table and alter_table forms.

Regression tests (8, in migration-test.vala): the end-to-end one runs a chained FK+indexed migration through MigrationRunner against a real temp SQLite file and asserts the CREATE INDEX exists in sqlite_master (what sqlite3 .schema reads); the rest cover the operation model, custom index names, not_null/unique/type override propagation, the alter_table variant, and a double-references chain. Verified red on the old code (bail out on /migrations/fk_indexed/e2e_index_created) and green after the fix; full suite 60/60, all 5 meson targets OK with LD_LIBRARY_PATH= so RUNPATH beats the stale pre-installed copy.
saddle-Clanker-Saddle 3 هفته پیش
والد
کامیت
8fb3c026e3
3فایلهای تغییر یافته به همراه492 افزوده شده و 24 حذف شده
  1. 22 20
      src/migrations/column-builder.vala
  2. 161 4
      src/migrations/foreign-key-builder.vala
  3. 309 0
      src/tests/migration-test.vala

+ 22 - 20
src/migrations/column-builder.vala

@@ -80,7 +80,7 @@ namespace InvercargillSql.Migrations {
          * 
          * @return this builder for method chaining
          */
-        public MigrationColumnBuilder type_int() {
+        public virtual MigrationColumnBuilder type_int() {
             _column.column_type = ColumnType.INT_64;
             return this;
         }
@@ -90,7 +90,7 @@ namespace InvercargillSql.Migrations {
          * 
          * @return this builder for method chaining
          */
-        public MigrationColumnBuilder type_text() {
+        public virtual MigrationColumnBuilder type_text() {
             _column.column_type = ColumnType.TEXT;
             return this;
         }
@@ -100,7 +100,7 @@ namespace InvercargillSql.Migrations {
          * 
          * @return this builder for method chaining
          */
-        public MigrationColumnBuilder type_real() {
+        public virtual MigrationColumnBuilder type_real() {
             _column.column_type = ColumnType.DECIMAL;
             return this;
         }
@@ -110,7 +110,7 @@ namespace InvercargillSql.Migrations {
          * 
          * @return this builder for method chaining
          */
-        public MigrationColumnBuilder type_blob() {
+        public virtual MigrationColumnBuilder type_blob() {
             _column.column_type = ColumnType.BINARY;
             return this;
         }
@@ -120,7 +120,7 @@ namespace InvercargillSql.Migrations {
          * 
          * @return this builder for method chaining
          */
-        public MigrationColumnBuilder type_datetime() {
+        public virtual MigrationColumnBuilder type_datetime() {
             _column.column_type = ColumnType.DATETIME;
             return this;
         }
@@ -130,7 +130,7 @@ namespace InvercargillSql.Migrations {
          * 
          * @return this builder for method chaining
          */
-        public MigrationColumnBuilder type_boolean() {
+        public virtual MigrationColumnBuilder type_boolean() {
             _column.column_type = ColumnType.BOOLEAN;
             return this;
         }
@@ -142,7 +142,7 @@ namespace InvercargillSql.Migrations {
          * 
          * @return this builder for method chaining
          */
-        public MigrationColumnBuilder primary_key() {
+        public virtual MigrationColumnBuilder primary_key() {
             _column.is_primary_key = true;
             return this;
         }
@@ -152,7 +152,7 @@ namespace InvercargillSql.Migrations {
          * 
          * @return this builder for method chaining
          */
-        public MigrationColumnBuilder not_null() {
+        public virtual MigrationColumnBuilder not_null() {
             _column.is_required = true;
             return this;
         }
@@ -162,7 +162,7 @@ namespace InvercargillSql.Migrations {
          * 
          * @return this builder for method chaining
          */
-        public MigrationColumnBuilder unique() {
+        public virtual MigrationColumnBuilder unique() {
             _column.is_unique = true;
             return this;
         }
@@ -174,7 +174,7 @@ namespace InvercargillSql.Migrations {
          * 
          * @return this builder for method chaining
          */
-        public MigrationColumnBuilder auto_increment() {
+        public virtual MigrationColumnBuilder auto_increment() {
             _column.auto_increment = true;
             return this;
         }
@@ -185,7 +185,7 @@ namespace InvercargillSql.Migrations {
          * @param value the default value to use when inserting records
          * @return this builder for method chaining
          */
-        public MigrationColumnBuilder default_value<T>(T value) {
+        public virtual MigrationColumnBuilder default_value<T>(T value) {
             _column.default_value = new Invercargill.NativeElement<T>(value);
             return this;
         }
@@ -197,7 +197,7 @@ namespace InvercargillSql.Migrations {
          * 
          * @return this builder for method chaining
          */
-        public MigrationColumnBuilder default_now() {
+        public virtual MigrationColumnBuilder default_now() {
             _column.default_now = true;
             return this;
         }
@@ -207,8 +207,10 @@ namespace InvercargillSql.Migrations {
          *
          * This method creates a ForeignKeyBuilder that allows configuring the FK
          * constraint with methods like .name(), .on_delete_cascade(), etc.
-         * The ForeignKeyBuilder extends MigrationColumnBuilder, so column methods
-         * can still be called after FK configuration.
+         * The ForeignKeyBuilder extends MigrationColumnBuilder and delegates all
+         * column methods to this builder, so column configuration (indexed(),
+         * not_null(), unique(), ...) works both before and after references()
+         * with identical effect.
          *
          * Example:
          * {{{
@@ -217,14 +219,14 @@ namespace InvercargillSql.Migrations {
          *     .references("users", "id")
          *         .name("fk_orders_users")
          *         .on_delete_cascade()
-         *     .indexed();  // Can continue with column methods
+         *         .indexed();  // Can continue with column methods
          * }}}
          *
          * @param table the referenced table name
          * @param column the referenced column name in the foreign table
          * @return a ForeignKeyBuilder for configuring the FK constraint
          */
-        public ForeignKeyBuilder references(string table, string column) {
+        public virtual ForeignKeyBuilder references(string table, string column) {
             _fk_builder = new ForeignKeyBuilder(this, table, column);
             return _fk_builder;
         }
@@ -250,7 +252,7 @@ namespace InvercargillSql.Migrations {
          * @param name optional custom index name; if null, auto-generated as idx_{table}_{column}
          * @return this builder for method chaining
          */
-        public MigrationColumnBuilder indexed(string? name = null) {
+        public virtual MigrationColumnBuilder indexed(string? name = null) {
             _should_create_index = true;
             _index_name = name;
             return this;
@@ -260,19 +262,19 @@ namespace InvercargillSql.Migrations {
          * Whether an index should be created for this column.
          * TableBuilder uses this to determine if an index operation is needed.
          */
-        public bool should_create_index { get { return _should_create_index; } }
+        public virtual bool should_create_index { get { return _should_create_index; } }
         
         /**
          * The custom index name, or null if auto-generation should be used.
          * Auto-generated format: idx_{table}_{column}
          */
-        public string? index_name { get { return _index_name; } }
+        public virtual string? index_name { get { return _index_name; } }
         
         /**
          * The ForeignKeyBuilder if a foreign key was configured via references().
          * TableBuilder uses this to collect FK constraints from column definitions.
          */
-        public ForeignKeyBuilder? fk_builder { get { return _fk_builder; } }
+        public virtual ForeignKeyBuilder? fk_builder { get { return _fk_builder; } }
         
         /**
          * Returns to the parent MigrationBuilder.

+ 161 - 4
src/migrations/foreign-key-builder.vala

@@ -7,9 +7,11 @@ namespace InvercargillSql.Migrations {
      * Builder for configuring foreign key constraints on a column.
      * 
      * This builder extends MigrationColumnBuilder to allow method chaining between
-     * FK configuration and column configuration. FK-specific methods return this
-     * ForeignKeyBuilder, while column methods delegate to the parent and return
-     * MigrationColumnBuilder (ending the FK configuration chain).
+     * FK configuration and column configuration. FK-specific methods mutate this
+     * builder, while inherited column methods (indexed(), not_null(), type_*(), ...)
+     * are overridden to delegate to the wrapped inner column builder - the object
+     * the parent TableBuilder/AlterTableBuilder reads at build time - and return
+     * this ForeignKeyBuilder, so both chain orders behave identically:
      * 
      * Example usage:
      * {{{
@@ -18,7 +20,8 @@ namespace InvercargillSql.Migrations {
      *     .references("users", "id")
      *         .name("fk_orders_users")
      *         .on_delete_cascade()
-     *     .indexed();  // Returns to column builder
+     *         .indexed()
+     *         .unique();  // column methods after FK methods keep working
      * }}}
      */
     public class ForeignKeyBuilder : MigrationColumnBuilder {
@@ -249,6 +252,160 @@ namespace InvercargillSql.Migrations {
             return _inner.return_to_migration();
         }
         
+        // ========== Column configuration delegation ==========
+        //
+        // ForeignKeyBuilder wraps the real MigrationColumnBuilder (the one the
+        // parent TableBuilder/AlterTableBuilder tracks and reads at build
+        // time), so every inherited column method MUST be forwarded to that
+        // inner builder. Without these overrides the inherited
+        // MigrationColumnBuilder implementations would mutate this wrapper's
+        // own throwaway state (base.for_subclass() builds a detached
+        // ColumnDefinition) and the flags would be silently dropped.
+        
+        /**
+         * {@inheritDoc}
+         * 
+         * Delegates to the wrapped column builder so the type override lands
+         * on the column the parent builder will actually build, and returns
+         * this ForeignKeyBuilder to keep the FK chain alive.
+         */
+        public override MigrationColumnBuilder type_int() {
+            _inner.type_int();
+            return this;
+        }
+        
+        /**
+         * {@inheritDoc}
+         */
+        public override MigrationColumnBuilder type_text() {
+            _inner.type_text();
+            return this;
+        }
+        
+        /**
+         * {@inheritDoc}
+         */
+        public override MigrationColumnBuilder type_real() {
+            _inner.type_real();
+            return this;
+        }
+        
+        /**
+         * {@inheritDoc}
+         */
+        public override MigrationColumnBuilder type_blob() {
+            _inner.type_blob();
+            return this;
+        }
+        
+        /**
+         * {@inheritDoc}
+         */
+        public override MigrationColumnBuilder type_datetime() {
+            _inner.type_datetime();
+            return this;
+        }
+        
+        /**
+         * {@inheritDoc}
+         */
+        public override MigrationColumnBuilder type_boolean() {
+            _inner.type_boolean();
+            return this;
+        }
+        
+        /**
+         * {@inheritDoc}
+         */
+        public override MigrationColumnBuilder primary_key() {
+            _inner.primary_key();
+            return this;
+        }
+        
+        /**
+         * {@inheritDoc}
+         */
+        public override MigrationColumnBuilder not_null() {
+            _inner.not_null();
+            return this;
+        }
+        
+        /**
+         * {@inheritDoc}
+         */
+        public override MigrationColumnBuilder unique() {
+            _inner.unique();
+            return this;
+        }
+        
+        /**
+         * {@inheritDoc}
+         */
+        public override MigrationColumnBuilder auto_increment() {
+            _inner.auto_increment();
+            return this;
+        }
+        
+        /**
+         * {@inheritDoc}
+         */
+        public override MigrationColumnBuilder default_value<T>(T value) {
+            _inner.default_value<T>(value);
+            return this;
+        }
+        
+        /**
+         * {@inheritDoc}
+         */
+        public override MigrationColumnBuilder default_now() {
+            _inner.default_now();
+            return this;
+        }
+        
+        /**
+         * {@inheritDoc}
+         * 
+         * Registers a nested FK on the wrapped column builder so the parent
+         * builder (which reads fk_builder from the inner object) sees it.
+         */
+        public override ForeignKeyBuilder references(string table, string column) {
+            return _inner.references(table, column);
+        }
+        
+        /**
+         * Marks the wrapped column to have an index created for it.
+         * 
+         * This is the regression fix for the silently-dropped index: calling
+         * .indexed() after .references() used to set the flag on this wrapper
+         * (invisible to TableBuilder.build_operation()) so no CREATE INDEX
+         * was ever emitted. The flag now goes to the inner builder.
+         */
+        public override MigrationColumnBuilder indexed(string? name = null) {
+            _inner.indexed(name);
+            return this;
+        }
+        
+        /**
+         * Whether an index should be created for the wrapped column.
+         */
+        public override bool should_create_index {
+            get { return _inner.should_create_index; }
+        }
+        
+        /**
+         * The custom index name of the wrapped column, or null.
+         */
+        public override string? index_name {
+            get { return _inner.index_name; }
+        }
+        
+        /**
+         * The nested ForeignKeyBuilder of the wrapped column, if any.
+         */
+        public override ForeignKeyBuilder? fk_builder {
+            get { return _inner.fk_builder; }
+        }
+        
         /**
          * Gets the inner MigrationColumnBuilder being wrapped.
          * @return the inner builder

+ 309 - 0
src/tests/migration-test.vala

@@ -342,6 +342,52 @@ public class Test_Missing_Serial : Migration {
     }
 }
 
+// --- FK + indexed regression test migrations ---
+
+/**
+ * Chained FK + indexed: the exact shape from the bug report.
+ * The index must actually be created on the FK column.
+ */
+public class Test_FkIndexed_V1 : Migration {
+    public override string migration_namespace { get { return "fkidx"; } }
+    public override uint64 serial { get { return 1; } }
+    public override string name { get { return "CreateRepos"; } }
+
+    public override void up(MigrationBuilder b) throws SqlError {
+        b.create_table("repos", t => {
+            t.column<int64?>("id").primary_key().auto_increment();
+            t.column<string>("name").not_null();
+        });
+    }
+
+    public override void down(MigrationBuilder b) throws SqlError {
+        b.drop_table("repos");
+    }
+}
+
+public class Test_FkIndexed_V2 : Migration {
+    public override string migration_namespace { get { return "fkidx"; } }
+    public override uint64 serial { get { return 2; } }
+    public override string name { get { return "CreateOrgRepos"; } }
+    public override string[] dependencies { owned get { return {"fkidx:1"}; } }
+
+    public override void up(MigrationBuilder b) throws SqlError {
+        b.create_table("org_repos", t => {
+            t.column<int64?>("id").primary_key().auto_increment();
+            t.column<int64?>("repo_id")
+                .not_null()
+                .references("repos", "id")
+                    .on_delete_cascade()
+                .indexed();
+            t.column<string>("slug").not_null();
+        });
+    }
+
+    public override void down(MigrationBuilder b) throws SqlError {
+        b.drop_table("org_repos");
+    }
+}
+
 // ============================================================================
 // TEST MAIN
 // ============================================================================
@@ -441,6 +487,18 @@ public int main(string[] args) {
     Test.add_func("/migrations/indexed/unique", test_unique_indexed_creates_unique_index);
     Test.add_func("/migrations/indexed/drop", test_drop_index_on);
     
+    // ========================================
+    // 11. FK + Indexed Regression Tests
+    // ========================================
+    Test.add_func("/migrations/fk_indexed/e2e_index_created", test_fk_indexed_creates_index_in_database);
+    Test.add_func("/migrations/fk_indexed/operation_model", test_fk_indexed_emits_index_operation);
+    Test.add_func("/migrations/fk_indexed/custom_name", test_fk_indexed_with_custom_name);
+    Test.add_func("/migrations/fk_indexed/not_null_propagates", test_fk_chain_not_null_propagates);
+    Test.add_func("/migrations/fk_indexed/unique_propagates", test_fk_chain_unique_propagates);
+    Test.add_func("/migrations/fk_indexed/type_overrides_propagate", test_fk_chain_type_overrides_propagate);
+    Test.add_func("/migrations/fk_indexed/alter_table_index_created", test_fk_indexed_in_alter_table_add_column);
+    Test.add_func("/migrations/fk_indexed/double_fk_not_required", test_fk_chain_double_references_returns_fk_builder);
+    
     return Test.run();
 }
 
@@ -1643,3 +1701,254 @@ void test_drop_index_on() {
     assert(drop_op.index_name == "idx_users_email");
     assert(drop_op.table_name == "users");
 }
+
+// ============================================================================
+// 11. FK + INDEXED REGRESSION TESTS
+//
+// Regression guard for the dropped-flag bug class: ForeignKeyBuilder wraps a
+// MigrationColumnBuilder, and any column method called after .references()
+// (indexed(), not_null(), unique(), type_*(), ...) used to mutate the
+// wrapper's own throwaway state instead of the wrapped column builder that
+// TableBuilder/AlterTableBuilder read at build time.
+// ============================================================================
+
+/**
+ * End-to-end: the chained FK+indexed form must produce a real index in an
+ * actual SQLite database file, verified via sqlite_master (what
+ * `sqlite3 db .schema` reads).
+ */
+void test_fk_indexed_creates_index_in_database() {
+    string db_path = GLib.Path.build_filename(
+        GLib.Environment.get_tmp_dir(),
+        "invercargill-sql-fk-indexed-test.db"
+    );
+    GLib.FileUtils.remove(db_path);  // ignore missing
+
+    try {
+        var conn = ConnectionFactory.create_and_open(@"sqlite://$db_path");
+        var dialect = new SqliteDialect();
+        var runner = new MigrationRunner(conn, dialect);
+
+        runner.register_migration(new Test_FkIndexed_V1());
+        runner.register_migration(new Test_FkIndexed_V2());
+        runner.migrate_to_latest();
+
+        assert(runner.get_current_serial("fkidx") == 2);
+
+        // The FK constraint itself must still be there
+        var fk_cmd = conn.create_command(
+            "SELECT sql FROM sqlite_master WHERE type='table' AND name='org_repos'"
+        );
+        var create_sql = fk_cmd.execute_scalar();
+        assert(create_sql != null);
+        string table_sql = create_sql.as_string_or_null() ?? "";
+        assert("FOREIGN KEY" in table_sql);
+        assert("REFERENCES repos (id)" in table_sql);
+        assert("ON DELETE CASCADE" in table_sql);
+
+        // THE regression: a CREATE INDEX for the FK column must exist
+        var idx_cmd = conn.create_command(
+            "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND tbl_name='org_repos' AND name='idx_org_repos_repo_id'"
+        );
+        var count_elem = idx_cmd.execute_scalar();
+        assert(count_elem != null);
+        assert(count_elem.as_int64_or_null() == 1);
+
+        conn.close();
+    } catch (SqlError e) {
+        stderr.printf("ERROR: %s\n", e.message);
+        assert_not_reached();
+    } finally {
+        GLib.FileUtils.remove(db_path);
+    }
+}
+
+/**
+ * Unit level: the chained form must emit a CreateIndexOperation alongside
+ * the CreateTableOperation, with the auto-generated idx_{table}_{column} name.
+ */
+void test_fk_indexed_emits_index_operation() {
+    var dialect = new SqliteDialect();
+    var builder = new MigrationBuilder(dialect);
+
+    builder.create_table("org_repos", t => {
+        t.column<int64?>("id").primary_key().auto_increment();
+        t.column<int64?>("repo_id")
+            .not_null()
+            .references("repos", "id")
+                .on_delete_cascade()
+            .indexed();
+    });
+
+    var ops = builder.get_operations();
+    assert(ops.length == 2);
+
+    var table_op = ops[0] as CreateTableOperation;
+    assert(table_op != null);
+
+    // FK constraint still collected from the inner builder chain
+    assert(table_op.constraints.length == 1);
+    var constraint = table_op.constraints.get(0);
+    assert(constraint.constraint_type == "FOREIGN KEY");
+    assert(constraint.name == "fk_org_repos_repo_id");
+    assert(constraint.reference_table == "repos");
+    assert(constraint.on_delete_action == ReferentialAction.CASCADE);
+
+    // The index operation must ALSO be emitted
+    var idx_op = ops[1] as CreateIndexOperation;
+    assert(idx_op != null);
+    assert(idx_op.index_name == "idx_org_repos_repo_id");
+    assert(idx_op.table_name == "org_repos");
+    assert(idx_op.columns.length == 1);
+    assert(idx_op.columns.get(0) == "repo_id");
+    assert(idx_op.is_unique == false);
+
+    var sql = dialect.create_index_sql(idx_op);
+    assert("CREATE INDEX idx_org_repos_repo_id ON org_repos (repo_id)" == sql);
+}
+
+/**
+ * Custom index names given after .references() must survive the wrapper.
+ */
+void test_fk_indexed_with_custom_name() {
+    var dialect = new SqliteDialect();
+    var builder = new MigrationBuilder(dialect);
+
+    builder.create_table("org_repos", t => {
+        t.column<int64?>("repo_id")
+            .references("repos", "id")
+                .indexed("idx_org_repos_repo_fk");
+    });
+
+    var ops = builder.get_operations();
+    assert(ops.length == 2);
+
+    var idx_op = ops[1] as CreateIndexOperation;
+    assert(idx_op != null);
+    assert(idx_op.index_name == "idx_org_repos_repo_fk");
+}
+
+/**
+ * not_null() called after .references() must reach the real column.
+ */
+void test_fk_chain_not_null_propagates() {
+    var dialect = new SqliteDialect();
+    var builder = new MigrationBuilder(dialect);
+
+    builder.create_table("org_repos", t => {
+        t.column<int64?>("repo_id")
+            .references("repos", "id")
+                .not_null();
+    });
+
+    var ops = builder.get_operations();
+    var table_op = ops[0] as CreateTableOperation;
+    assert(table_op != null);
+    assert(table_op.columns.length == 1);
+    assert(table_op.columns.get(0).is_required == true);
+
+    var sql = dialect.create_table_sql(table_op);
+    assert("NOT NULL" in sql);
+}
+
+/**
+ * unique() called after .references() must reach the real column.
+ */
+void test_fk_chain_unique_propagates() {
+    var dialect = new SqliteDialect();
+    var builder = new MigrationBuilder(dialect);
+
+    builder.create_table("org_repos", t => {
+        t.column<int64?>("repo_id")
+            .references("repos", "id")
+                .unique()
+                .indexed();
+    });
+
+    var ops = builder.get_operations();
+    assert(ops.length == 2);
+
+    var table_op = ops[0] as CreateTableOperation;
+    assert(table_op.columns.get(0).is_unique == true);
+
+    var idx_op = ops[1] as CreateIndexOperation;
+    assert(idx_op != null);
+    assert(idx_op.is_unique == true);
+    var sql = dialect.create_index_sql(idx_op);
+    assert("CREATE UNIQUE INDEX" in sql);
+}
+
+/**
+ * Column type overrides called after .references() must reach the real column
+ * (type_text() overriding the generic int64 inference).
+ */
+void test_fk_chain_type_overrides_propagate() {
+    var dialect = new SqliteDialect();
+    var builder = new MigrationBuilder(dialect);
+
+    builder.create_table("org_repos", t => {
+        t.column<int64?>("repo_id")
+            .references("repos", "id")
+                .type_text();
+    });
+
+    var ops = builder.get_operations();
+    var table_op = ops[0] as CreateTableOperation;
+    assert(table_op.columns.get(0).column_type == ColumnType.TEXT);
+
+    var sql = dialect.create_table_sql(table_op);
+    assert("TEXT" in sql);
+}
+
+/**
+ * ALTER TABLE form: add_column with the chained FK+indexed must emit the
+ * index operation too.
+ */
+void test_fk_indexed_in_alter_table_add_column() {
+    var dialect = new SqliteDialect();
+    var builder = new MigrationBuilder(dialect);
+
+    builder.alter_table("org_repos", t => {
+        t.add_column<int64?>("repo_id")
+            .references("repos", "id")
+                .indexed();
+    });
+
+    var ops = builder.get_operations();
+    assert(ops.length == 2);
+
+    var add_col_op = ops[0] as AddColumnOperation;
+    assert(add_col_op != null);
+    assert(add_col_op.foreign_key_constraint != null);
+    assert(add_col_op.foreign_key_constraint.reference_table == "repos");
+
+    var idx_op = ops[1] as CreateIndexOperation;
+    assert(idx_op != null);
+    assert(idx_op.index_name == "idx_org_repos_repo_id");
+    assert(idx_op.columns.get(0) == "repo_id");
+}
+
+/**
+ * Chaining a second .references() after FK configuration must still return a
+ * ForeignKeyBuilder (documented behaviour) and register on the inner builder.
+ */
+void test_fk_chain_double_references_returns_fk_builder() {
+    var dialect = new SqliteDialect();
+    var builder = new MigrationBuilder(dialect);
+
+    builder.create_table("org_repos", t => {
+        var fk = t.column<int64?>("repo_id")
+            .references("repos", "id")
+                .on_delete_cascade()
+            .references("other_table", "id");
+        assert(fk is ForeignKeyBuilder);
+    });
+
+    // The inner column builder must expose the LAST fk configured
+    var ops = builder.get_operations();
+    var table_op = ops[0] as CreateTableOperation;
+    assert(table_op != null);
+    assert(table_op.constraints.length == 1);
+    assert(table_op.constraints.get(0).reference_table == "other_table");
+}
+