Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/Weasel.Core/Migrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,13 @@ public async Task ReadTemplatesAsync(string directory, CancellationToken ct = de
{
foreach (var file in FileSystem.FindFiles(directory, FileSet.Shallow("*.function")))
{
var name = Path.GetFileNameWithoutExtension(file).ToLower();
var name = Path.GetFileNameWithoutExtension(file).ToLowerInvariant();
Templates[name].FunctionCreation = await File.ReadAllTextAsync(file, ct).ConfigureAwait(false);
}

foreach (var file in FileSystem.FindFiles(directory, FileSet.Shallow("*.table")))
{
var name = Path.GetFileNameWithoutExtension(file).ToLower();
var name = Path.GetFileNameWithoutExtension(file).ToLowerInvariant();

Templates[name].TableCreation = await File.ReadAllTextAsync(file, ct).ConfigureAwait(false);
}
Expand Down
20 changes: 20 additions & 0 deletions src/Weasel.Postgresql.Tests/Tables/TableColumnTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using NetTopologySuite.Geometries;
using Shouldly;
using Weasel.Postgresql.Tables;
Expand Down Expand Up @@ -143,4 +144,23 @@ public void to_function_update_should_quote_reserved_keywords(string keyword)

column.ToFunctionUpdate().ShouldBe($"\"{keyword}\" = p_{keyword}");
}

[Fact]
public void column_name_normalization_is_culture_invariant()
{
// In Turkish locale, "I".ToLower() produces dotless 'ı' (U+0131) instead of 'i',
// which would corrupt SQL identifiers like "INFORMATION_SCHEMA" → "ınformation_schema".
var originalCulture = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("tr-TR");
var column = new TableColumn("INFORMATION_SCHEMA", "VARCHAR");
column.Name.ShouldBe("information_schema");
column.Type.ShouldBe("varchar");
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
}
}
}
4 changes: 2 additions & 2 deletions src/Weasel.Postgresql.Tests/Tables/detecting_table_deltas.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,13 @@ public async Task using_reserved_keywords_for_columns()
[MemberData(nameof(PostgresReservedKeywords))]
public async Task verify_all_postgres_reserved_keywords_work_as_column_names(string keyword)
{
var tableName = $"deltas.keyword_{keyword.ToLower()}";
var tableName = $"deltas.keyword_{keyword.ToLowerInvariant()}";
var table = new Table(tableName);
table.AddColumn<int>("id").AsPrimaryKey();

await CreateSchemaObjectInDatabase(table);

table.AddColumn<string>(keyword.ToLower());
table.AddColumn<string>(keyword.ToLowerInvariant());

await AssertNoDeltasAfterPatching(table);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
using JasperFx;
using Shouldly;
using Weasel.Core;
using Weasel.Postgresql.Tables;
using Xunit;

namespace Weasel.Postgresql.Tests.Tables;

/// <summary>
/// Tests for GitHub issue #45: PK columns with FK constraints don't migrate.
/// When a table's primary key is dropped and recreated (CASCADE drops referencing FKs),
/// the migration must recreate the FKs from other tables afterwards.
/// </summary>
[Collection("pk_fk_migration")]
public class pk_migration_with_referencing_fks : IntegrationContext
{
public pk_migration_with_referencing_fks() : base("pk_fk_migration")
{
}

public override async Task InitializeAsync()
{
await ResetSchema();
}

[Fact]
public async Task can_change_pk_columns_when_fk_references_all_pk_columns()
{
// Parent with single-column PK
var parent = new Table(new PostgresqlObjectName(SchemaName, "accounts"));
parent.AddColumn<int>("id").AsPrimaryKey();
parent.AddColumn<string>("name").NotNull();

// Child FK references parent's full PK
var child = new Table(new PostgresqlObjectName(SchemaName, "transactions"));
child.AddColumn<int>("id").AsPrimaryKey();
child.AddColumn<int>("account_id").NotNull().ForeignKeyTo(parent, "id");
child.AddColumn<decimal>("amount");

var migrator = new PostgresqlMigrator();
if (theConnection.State != System.Data.ConnectionState.Open) await theConnection.OpenAsync();
await theConnection.EnsureSchemaExists(SchemaName);
var migration1 = await SchemaMigration.DetermineAsync(theConnection, CancellationToken.None, parent, child);
await migrator.ApplyAllAsync(theConnection, migration1, AutoCreate.CreateOrUpdate);

// Now change parent PK to (id, name) — composite
// The child FK stays on (id) alone, so we need a unique constraint on id
// to satisfy PostgreSQL. But the PK change forces a DROP CASCADE + recreate.
var parentV2 = new Table(new PostgresqlObjectName(SchemaName, "accounts"));
parentV2.AddColumn<int>("id").AsPrimaryKey();
parentV2.AddColumn<string>("name").AsPrimaryKey(); // added to PK

// Add a unique index on id alone so the FK can still reference it
var uniqueIdx = new IndexDefinition("ix_accounts_id") { IsUnique = true };
uniqueIdx.Columns = new[] { "id" };
parentV2.Indexes.Add(uniqueIdx);

var childV2 = new Table(new PostgresqlObjectName(SchemaName, "transactions"));
childV2.AddColumn<int>("id").AsPrimaryKey();
childV2.AddColumn<int>("account_id").NotNull().ForeignKeyTo(parentV2, "id");
childV2.AddColumn<decimal>("amount");

// This should succeed — PK change + FK recreation
var migration2 = await SchemaMigration.DetermineAsync(theConnection, CancellationToken.None, parentV2, childV2);
migration2.Difference.ShouldNotBe(SchemaPatchDifference.None);
await migrator.ApplyAllAsync(theConnection, migration2, AutoCreate.CreateOrUpdate);

// Verify clean state
var finalMigration = await SchemaMigration.DetermineAsync(theConnection, CancellationToken.None, parentV2, childV2);
finalMigration.Difference.ShouldBe(SchemaPatchDifference.None);
}

[Fact]
public async Task can_rename_pk_constraint_with_referencing_fk()
{
var parent = new Table(new PostgresqlObjectName(SchemaName, "parent_rename"));
parent.AddColumn<int>("id").AsPrimaryKey();
parent.AddColumn<string>("name").NotNull();
parent.PrimaryKeyName = "pk_parent_old";

var child = new Table(new PostgresqlObjectName(SchemaName, "child_rename"));
child.AddColumn<int>("id").AsPrimaryKey();
child.AddColumn<int>("parent_id").NotNull().ForeignKeyTo(parent, "id");

var migrator = new PostgresqlMigrator();
if (theConnection.State != System.Data.ConnectionState.Open) await theConnection.OpenAsync();
await theConnection.EnsureSchemaExists(SchemaName);
var migration1 = await SchemaMigration.DetermineAsync(theConnection, CancellationToken.None, parent, child);
await migrator.ApplyAllAsync(theConnection, migration1, AutoCreate.CreateOrUpdate);

// Change only the PK name
var parentV2 = new Table(new PostgresqlObjectName(SchemaName, "parent_rename"));
parentV2.AddColumn<int>("id").AsPrimaryKey();
parentV2.AddColumn<string>("name").NotNull();
parentV2.PrimaryKeyName = "pk_parent_new";

var childV2 = new Table(new PostgresqlObjectName(SchemaName, "child_rename"));
childV2.AddColumn<int>("id").AsPrimaryKey();
childV2.AddColumn<int>("parent_id").NotNull().ForeignKeyTo(parentV2, "id");

var migration2 = await SchemaMigration.DetermineAsync(theConnection, CancellationToken.None, parentV2, childV2);
await migrator.ApplyAllAsync(theConnection, migration2, AutoCreate.CreateOrUpdate);

var finalMigration = await SchemaMigration.DetermineAsync(theConnection, CancellationToken.None, parentV2, childV2);
finalMigration.Difference.ShouldBe(SchemaPatchDifference.None);
}

[Fact]
public async Task ddl_output_includes_fk_recreation_after_pk_change()
{
var parent = new Table(new PostgresqlObjectName(SchemaName, "ddl_parent"));
parent.AddColumn<int>("id").AsPrimaryKey();
parent.AddColumn<string>("code");

var child = new Table(new PostgresqlObjectName(SchemaName, "ddl_child"));
child.AddColumn<int>("id").AsPrimaryKey();
child.AddColumn<int>("parent_id").NotNull().ForeignKeyTo(parent, "id");

var migrator = new PostgresqlMigrator();
if (theConnection.State != System.Data.ConnectionState.Open) await theConnection.OpenAsync();
await theConnection.EnsureSchemaExists(SchemaName);
var migration1 = await SchemaMigration.DetermineAsync(theConnection, CancellationToken.None, parent, child);
await migrator.ApplyAllAsync(theConnection, migration1, AutoCreate.CreateOrUpdate);

// Change PK — keep same column but add unique index so FK still works
var parentV2 = new Table(new PostgresqlObjectName(SchemaName, "ddl_parent"));
parentV2.AddColumn<int>("id").AsPrimaryKey();
parentV2.AddColumn<string>("code").AsPrimaryKey(); // added to PK
var uniqueIdx = new IndexDefinition("ix_ddl_parent_id") { IsUnique = true };
uniqueIdx.Columns = new[] { "id" };
parentV2.Indexes.Add(uniqueIdx);

var childV2 = new Table(new PostgresqlObjectName(SchemaName, "ddl_child"));
childV2.AddColumn<int>("id").AsPrimaryKey();
childV2.AddColumn<int>("parent_id").NotNull().ForeignKeyTo(parentV2, "id");

var migration2 = await SchemaMigration.DetermineAsync(theConnection, CancellationToken.None, parentV2, childV2);

var writer = new StringWriter();
migration2.WriteAllUpdates(writer, migrator, AutoCreate.CreateOrUpdate);
var ddl = writer.ToString();

// Should contain: PK drop, PK add, FK recreation
ddl.ShouldContain("drop constraint");
ddl.ShouldContain("PRIMARY KEY");
ddl.ShouldContain("FOREIGN KEY");
}
}
2 changes: 1 addition & 1 deletion src/Weasel.Postgresql/Extension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public class Extension: ISchemaObject
{
public Extension(string extensionName)
{
ExtensionName = extensionName.Trim().ToLower();
ExtensionName = extensionName.Trim().ToLowerInvariant();
}

public string ExtensionName { get; }
Expand Down
2 changes: 1 addition & 1 deletion src/Weasel.Postgresql/PostgresqlProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ protected override Type[] determineClrTypesForParameterType(NpgsqlDbType dbType)

public string ConvertSynonyms(string type)
{
switch (type.ToLower())
switch (type.ToLowerInvariant())
{
case "character varying":
case "varchar":
Expand Down
2 changes: 1 addition & 1 deletion src/Weasel.Postgresql/Tables/IndexDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ public static IndexDefinition Parse(string definition)
while (tokens.Any())
{
var current = tokens.Dequeue();
switch (current.ToUpper())
switch (current.ToUpperInvariant())
{
case "CREATE":
case "CONCURRENTLY":
Expand Down
4 changes: 2 additions & 2 deletions src/Weasel.Postgresql/Tables/TableColumn.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ public TableColumn(string name, string type)
throw new ArgumentOutOfRangeException(nameof(type));
}

Name = name.ToLower().Trim().Replace(' ', '_');
Type = type.ToLower();
Name = name.ToLowerInvariant().Trim().Replace(' ', '_');
Type = type.ToLowerInvariant();
}


Expand Down
38 changes: 38 additions & 0 deletions src/Weasel.Postgresql/Tables/TableDelta.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ public TableDelta(Table expected, Table? actual): base(expected, actual)

internal ItemDelta<ForeignKey> ForeignKeys { get; private set; } = null!;

/// <summary>
/// Foreign keys from OTHER tables that reference this table's primary key.
/// When the PK changes, these must be dropped and recreated.
/// Populated during <see cref="PostProcess" />.
/// </summary>
internal List<(Table OwnerTable, ForeignKey ForeignKey)> ReferencingForeignKeys { get; } = new();

public SchemaPatchDifference PrimaryKeyDifference { get; private set; }

protected override SchemaPatchDifference compare(Table expected, Table? actual)
Expand Down Expand Up @@ -171,8 +178,17 @@ private void writePrimaryKeyChanges(TextWriter writer)
break;
}

// CASCADE will also drop FKs from other tables that reference this PK.
// We must recreate those FKs after the PK is altered.
writer.WriteLine($"alter table {Expected.Identifier} drop constraint {Actual!.PrimaryKeyName} CASCADE;");
writer.WriteLine($"alter table {Expected.Identifier} add {Expected.PrimaryKeyDeclaration()};");

// Recreate foreign keys from other tables that were dropped by CASCADE
foreach (var (ownerTable, fk) in ReferencingForeignKeys)
{
fk.WriteAddStatement(ownerTable, writer);
}

break;

case SchemaPatchDifference.Create:
Expand Down Expand Up @@ -251,6 +267,28 @@ public void PostProcess(IList<ISchemaObjectDelta> allDeltas)
ForeignKeys = new ItemDelta<ForeignKey>(Expected.ForeignKeys, Actual.ForeignKeys);
Difference = compare(Expected, Actual);
}

// When this table's PK is changing, find FKs from other tables that reference it.
// Those FKs will be dropped by CASCADE and must be recreated after the PK is altered.
if (PrimaryKeyDifference is SchemaPatchDifference.Invalid or SchemaPatchDifference.Update
&& Expected.PrimaryKeyColumns.Any())
{
foreach (var otherDelta in allDeltas.OfType<TableDelta>())
{
if (ReferenceEquals(otherDelta, this)) continue;

// Check expected FKs that reference this table
foreach (var fk in otherDelta.Expected.ForeignKeys)
{
if (fk.LinkedTable != null &&
fk.LinkedTable.QualifiedName.Equals(Expected.Identifier.QualifiedName,
StringComparison.OrdinalIgnoreCase))
{
ReferencingForeignKeys.Add((otherDelta.Expected, fk));
}
}
}
}
}

private void rollbackIndexes(TextWriter writer)
Expand Down
20 changes: 20 additions & 0 deletions src/Weasel.SqlServer.Tests/Tables/TableColumnTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using Shouldly;
using Weasel.SqlServer.Tables;
using Xunit;
Expand Down Expand Up @@ -134,4 +135,23 @@ public void change_column_type_sql()
table.ColumnFor(columnName)!.AlterColumnTypeSql(table, actualColumn)
.ShouldBe($"alter table dbo.people alter column [{columnName}] varchar(200) NOT NULL;");
}

[Fact]
public void column_name_normalization_is_culture_invariant()
{
// In Turkish locale, "I".ToLower() produces dotless 'ı' (U+0131) instead of 'i',
// which would corrupt SQL identifiers like "INFORMATION_SCHEMA" → "ınformation_schema".
var originalCulture = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("tr-TR");
var column = new TableColumn("INFORMATION_SCHEMA", "VARCHAR");
column.Name.ShouldBe("information_schema");
column.Type.ShouldBe("varchar");
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
}
}
}
4 changes: 2 additions & 2 deletions src/Weasel.SqlServer/SqlServerProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ public override string AddApplicationNameToConnectionString(string connectionStr

public string ConvertSynonyms(string type)
{
switch (type.ToLower())
switch (type.ToLowerInvariant())
{
case "text":
case "varchar":
Expand Down Expand Up @@ -189,7 +189,7 @@ public override void SetParameterType(SqlParameter parameter, SqlDbType dbType)

public static CascadeAction ReadAction(string description)
{
switch (description.ToUpper().Trim())
switch (description.ToUpperInvariant().Trim())
{
case "CASCADE":
return CascadeAction.Cascade;
Expand Down
2 changes: 1 addition & 1 deletion src/Weasel.SqlServer/Tables/Table.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ public void WriteCreateStatement(Migrator migrator, TextWriter writer)
if (migrator.TableCreation == CreationStyle.DropThenCreate)
{
// drop all FK constraints
var sqlVariableName = $"@sql_{Guid.NewGuid().ToString().ToLower().Replace("-", "_")}";
var sqlVariableName = $"@sql_{Guid.NewGuid().ToString().ToLowerInvariant().Replace("-", "_")}";
writer.WriteLine("DECLARE {0} NVARCHAR(MAX) = '';", sqlVariableName);
writer.WriteLine("SELECT {0} = {1} + 'ALTER TABLE ' + QUOTENAME(OBJECT_SCHEMA_NAME(fk.parent_object_id)) + '.' + QUOTENAME(OBJECT_NAME(fk.parent_object_id)) + ' DROP CONSTRAINT ' + QUOTENAME(fk.name) + ';'",
sqlVariableName, sqlVariableName);
Expand Down
4 changes: 2 additions & 2 deletions src/Weasel.SqlServer/Tables/TableColumn.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ public TableColumn(string name, string type)
throw new ArgumentOutOfRangeException(nameof(type));
}

Name = name.ToLower().Trim().Replace(' ', '_');
Type = type.ToLower();
Name = name.ToLowerInvariant().Trim().Replace(' ', '_');
Type = type.ToLowerInvariant();
}


Expand Down
Loading