diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index a46ffc5..e37b2ee 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -9,6 +9,9 @@ jobs: build: name: Build runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write if: github.event_name == 'pull_request' steps: - name: Checkout repository @@ -25,6 +28,7 @@ jobs: shell: bash env: Build__ProjectFile: 'src/NSchema.Postgres/NSchema.Postgres.csproj' + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} deploy: name: Deploy diff --git a/CHANGELOG.md b/CHANGELOG.md index 3220498..90839be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ This package uses **lockstep major versioning** with the core NSchema package: ` As a consequence, breaking changes that are specific to this provider (rather than the core API) are signalled by a **minor version bump** rather than a major one, and called out explicitly in this changelog. +## [5.6.1] - 2026-08-13 + +### Fixed + +- **The canonical types Postgres renders are no longer refused.** `tinyint`, `nchar`, `nvarchar` and `binary` are written as `smallint`, `character`, `character varying` and `bytea`, but the engine's vocabulary is read from its own catalog and so never names them, leaving a plan that rendered the column correctly and then refused it as an unresolved type. They now compare equal to the type they are rendered as. +- **A `varbinary` length no longer drifts.** `bytea` carries none, so a declared one was never read back and every plan asked to change the column again. +- **A sequence option declared with the default value now settles.** +- **An identity column reports no minimum where none was declared.** +- **An identity option reset to the engine's default is now written.** +- **Changing an identity's increment or minimum no longer restarts its counter.** `RESTART` was appended to every identity alter. Only a start that actually moved carries a `RESTART` now, and that case is reported as a data hazard. + ## [5.6.0] - 2026-08-11 ### Added diff --git a/Directory.Packages.props b/Directory.Packages.props index 60c5329..1925433 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,13 +5,14 @@ - + - + - + + diff --git a/src/NSchema.Postgres/NSchema.Postgres.csproj b/src/NSchema.Postgres/NSchema.Postgres.csproj index 7e1137f..0c3e495 100644 --- a/src/NSchema.Postgres/NSchema.Postgres.csproj +++ b/src/NSchema.Postgres/NSchema.Postgres.csproj @@ -31,7 +31,7 @@ true true snupkg - 5.6.0 + 5.6.1 $(Version.Split('-')[0]) $(Version.Split('-')[0]) true diff --git a/src/NSchema.Postgres/Sql/PostgresDatabaseIntrospector.cs b/src/NSchema.Postgres/Sql/PostgresDatabaseIntrospector.cs index b6906ef..390ff41 100644 --- a/src/NSchema.Postgres/Sql/PostgresDatabaseIntrospector.cs +++ b/src/NSchema.Postgres/Sql/PostgresDatabaseIntrospector.cs @@ -733,11 +733,22 @@ private static async Task> QueryExtensions(NpgsqlConnection c await using var cmd = conn.CreateCommand(); // Extensions are database-global (not schema-scoped), so they are not filtered by the schema list. plpgsql // is the always-installed procedural language and never part of a declared schema, so it is excluded. + // + // CREATE EXTENSION records the description from the extension's control file as a comment on the extension, + // so obj_description reports one for an extension nobody has documented. The control file is still readable + // through pg_available_extension_versions, so a comment matching it is the one the extension shipped with + // and is reported as no comment at all — otherwise every plan asks to remove documentation the project + // never wrote. A project that declares that exact text is asking for what is already there; it is left to + // say nothing instead. Where the extension's files are gone from disk the join finds nothing and the + // description stands, which is the safe way round: a comment kept is drift, a comment dropped is data lost. cmd.CommandText = """ SELECT e.extname AS name, e.extversion AS version, - obj_description(e.oid, 'pg_extension') AS comment + obj_description(e.oid, 'pg_extension') AS comment, + av.comment AS shipped_comment FROM pg_extension e + LEFT JOIN pg_available_extension_versions av + ON av.name = e.extname AND av.version = e.extversion WHERE e.extname <> 'plpgsql' ORDER BY e.extname """; @@ -745,10 +756,12 @@ ORDER BY e.extname await using var reader = await cmd.ExecuteReaderAsync(ct); while (await reader.ReadAsync(ct)) { + var comment = reader.IsDBNull(2) ? null : reader.GetString(2); + var shipped = reader.IsDBNull(3) ? null : reader.GetString(3); rows.Add(new ExtensionRow( Name: reader.GetString(0), Version: reader.IsDBNull(1) ? null : reader.GetString(1), - Comment: reader.IsDBNull(2) ? null : reader.GetString(2) + Comment: comment == shipped ? null : comment )); } @@ -1678,35 +1691,18 @@ List extensions return new Database { Schemas = dbSchemas, Extensions = dbExtensions }; } - // Postgres engine defaults are folded to null so a bare "CREATE SEQUENCE" round-trips to an all-null - // SequenceOptions and the core's plain record equality sees no drift. Documented trade-off: a desired schema - // that *explicitly* declares an engine default (e.g. START 1 on an ascending sequence) shows drift against the - // normalized null; the fix is to omit the option. - internal static SequenceOptions NormalizeSequenceOptions(SequenceRow row) - { - var ascending = row.Increment > 0; - var (typeMin, typeMax) = row.DataType switch - { - "smallint" => ((long)short.MinValue, (long)short.MaxValue), - "integer" => ((long)int.MinValue, (long)int.MaxValue), - _ => (long.MinValue, long.MaxValue), // bigint - }; - - var defaultMin = ascending ? 1L : typeMin; - var defaultMax = ascending ? typeMax : -1L; - // The default start is the sequence's *effective* minvalue (ascending) / maxvalue (descending), not the - // default min/max — CREATE SEQUENCE q MINVALUE 5 starts at 5. - var defaultStart = ascending ? row.MinValue : row.MaxValue; - - return new SequenceOptions( - DataType: row.DataType == "bigint" ? null : SqlType.Parse(row.DataType), - StartWith: row.Start == defaultStart ? null : row.Start, - IncrementBy: row.Increment == 1 ? null : row.Increment, - MinValue: row.MinValue == defaultMin ? null : row.MinValue, - MaxValue: row.MaxValue == defaultMax ? null : row.MaxValue, - Cache: row.Cache == 1 ? null : row.Cache, - Cycle: row.Cycle); - } + // pg_sequence holds a concrete value for every option whatever was declared, so the row is folded onto the + // engine's defaults — through the same rules the comparison folds a desired schema with, so the two meet — and + // an imported project says only what was asked for. + internal static SequenceOptions NormalizeSequenceOptions(SequenceRow row) => + PostgresSqlEquivalence.FoldOptions(new SequenceOptions( + DataType: SqlType.Parse(row.DataType), + StartWith: row.Start, + IncrementBy: row.Increment, + MinValue: row.MinValue, + MaxValue: row.MaxValue, + Cache: row.Cache, + Cycle: row.Cycle)); private static Routine BuildRoutine(RoutineRow row, RoutineKind kind, Dictionary<(string, string), string?> comments) => new() { @@ -1979,21 +1975,28 @@ private static (IndexSort Sort, IndexNulls Nulls) DecodeIndexOption(int option) return (sort, nulls); } - private static Column MapColumn(ColumnRow row, Dictionary<(string, string, string), string?> columnComments) => new() + private static Column MapColumn(ColumnRow row, Dictionary<(string, string, string), string?> columnComments) { - Name = row.ColumnName, - Type = MapSqlType(row.DataType, row.UdtName, row.UdtSchema, row.DomainSchema, row.DomainName, row.MaxLength, row.NumericPrecision, row.NumericScale), - IsNullable = row.IsNullable, - IsIdentity = row.IsIdentity, - DefaultExpression = row.DefaultExpression, - IdentityOptions = row.IsIdentity - ? new IdentityOptions(row.IdentityStart, row.IdentityMinValue, row.IdentityIncrement) - : null, - GeneratedExpression = row.GeneratedExpression, - // Postgres has only stored generated columns, so one that exists is stored by construction. - IsStored = row.GeneratedExpression is not null, - Comment = columnComments.GetValueOrDefault((row.TableSchema, row.TableName, row.ColumnName)), - }; + var type = MapSqlType(row.DataType, row.UdtName, row.UdtSchema, row.DomainSchema, row.DomainName, row.MaxLength, row.NumericPrecision, row.NumericScale); + return new Column + { + Name = row.ColumnName, + Type = type, + IsNullable = row.IsNullable, + IsIdentity = row.IsIdentity, + DefaultExpression = row.DefaultExpression, + // The identity's own sequence reports a start and a minimum whether or not either was declared, so they + // are folded onto the engine's defaults exactly as a standalone sequence's are. + IdentityOptions = row.IsIdentity + ? PostgresSqlEquivalence.FoldOptions( + new IdentityOptions(row.IdentityStart, row.IdentityMinValue, row.IdentityIncrement), type) + : null, + GeneratedExpression = row.GeneratedExpression, + // Postgres has only stored generated columns, so one that exists is stored by construction. + IsStored = row.GeneratedExpression is not null, + Comment = columnComments.GetValueOrDefault((row.TableSchema, row.TableName, row.ColumnName)), + }; + } /// /// The canonical model spelling of a catalog type name (int4 to int, uuid to diff --git a/src/NSchema.Postgres/Sql/PostgresSqlDialect.Columns.cs b/src/NSchema.Postgres/Sql/PostgresSqlDialect.Columns.cs index e69356a..2b06a76 100644 --- a/src/NSchema.Postgres/Sql/PostgresSqlDialect.Columns.cs +++ b/src/NSchema.Postgres/Sql/PostgresSqlDialect.Columns.cs @@ -1,3 +1,4 @@ +using NSchema.Model.Columns; using NSchema.Plan.Domain; using NSchema.Plan.Domain.Columns; @@ -21,29 +22,48 @@ protected override Result> AlterColumn(AlterColumn a _ => Statements(), }; + // One clause per option that differs, as AlterSequence does: an option going back to null is the engine's own + // default asked for explicitly, so the next introspection folds it away again and no residual drift is left. protected override Result> AlterIdentitySequence(AlterIdentitySequence action) { - var opts = action.NewOptions; + var (old, @new) = (action.OldOptions, action.NewOptions); var parts = new List(); - if (opts?.MinValue is { } min) + if (old?.MinValue != @new?.MinValue) { - parts.Add($"SET MINVALUE {min}"); + parts.Add(@new?.MinValue is { } min ? $"SET MINVALUE {min}" : "SET NO MINVALUE"); } - if (opts?.StartWith is { } start) + // Only a start that actually moved restarts the counter. `SET START` records where a restart begins and + // does not move the current value, so the RESTART is what makes the new start take effect — and it is + // also what reissues values the table already holds, which is why nothing else may drag it along. + var startChanged = old?.StartWith != @new?.StartWith; + if (startChanged) { - parts.Add($"SET START {start}"); + // There is no NO START form; a reset asks for the start a freshly declared identity would have — its + // effective minimum ascending, its maximum descending — which introspection then folds back to null. + parts.Add($"SET START {@new?.StartWith ?? DefaultIdentityStart(@new)}"); } - if (opts?.IncrementBy is { } increment) + if (old?.IncrementBy != @new?.IncrementBy) { - parts.Add($"SET INCREMENT BY {increment}"); + parts.Add($"SET INCREMENT BY {@new?.IncrementBy ?? 1}"); } - parts.Add("RESTART"); + if (startChanged) + { + parts.Add("RESTART"); + } + + if (parts.Count == 0) + { + return Statements(); + } return Statement($"ALTER TABLE {Qualify(action.Column.Owner)} ALTER COLUMN {Quote(action.Column.Member)} {string.Join(" ", parts)}"); } + private static long DefaultIdentityStart(IdentityOptions? options) => + (options?.IncrementBy ?? 1) > 0 ? options?.MinValue ?? 1 : -1; + // Changing a column's generation expression in place: PG 17+ replaces it with SET EXPRESSION, and a generated // column is converted back to a plain one with DROP EXPRESSION (data is kept). PostgreSQL has no in-place way // to make an existing plain column generated, so that transition is unsupported — the column must be re-added. diff --git a/src/NSchema.Postgres/Sql/PostgresSqlEquivalence.cs b/src/NSchema.Postgres/Sql/PostgresSqlEquivalence.cs index b82bf94..a92dc93 100644 --- a/src/NSchema.Postgres/Sql/PostgresSqlEquivalence.cs +++ b/src/NSchema.Postgres/Sql/PostgresSqlEquivalence.cs @@ -2,6 +2,7 @@ using NSchema.Diff.Plugins; using NSchema.Model; using NSchema.Model.Columns; +using NSchema.Model.Sequences; namespace NSchema.Postgres.Sql; @@ -25,6 +26,74 @@ public sealed class PostgresSqlEquivalence : SqlEquivalence /// public override IEqualityComparer Types { get; } = new TypeEquality(); + /// + /// + /// pg_sequence holds a row of concrete values whatever was declared, so every option the engine would + /// have chosen anyway folds back to : bigint, INCREMENT BY 1, + /// CACHE 1, the bound at the ascending or descending end of the type, and the start that follows from + /// the effective bound — CREATE SEQUENCE q MINVALUE 5 starts at 5, not at 1. + /// + public override SequenceOptions WithDefaults(SequenceOptions options) => FoldOptions(options); + + /// + /// Static so introspection folds a catalog row through the same rules the comparison uses. + internal static SequenceOptions FoldOptions(SequenceOptions options) + { + var bounds = Bounds(options.DataType, options.IncrementBy); + var start = options.IncrementBy is null or > 0 + ? options.MinValue ?? bounds.Min + : options.MaxValue ?? bounds.Max; + + return new SequenceOptions( + DataType: IsBigInt(options.DataType) ? null : options.DataType, + StartWith: options.StartWith == start ? null : options.StartWith, + IncrementBy: options.IncrementBy == 1 ? null : options.IncrementBy, + MinValue: options.MinValue == bounds.Min ? null : options.MinValue, + MaxValue: options.MaxValue == bounds.Max ? null : options.MaxValue, + Cache: options.Cache == 1 ? null : options.Cache, + Cycle: options.Cycle); + } + + /// + /// + /// An identity is a sequence Postgres owns, and pg_sequence reports its minimum and start whether or + /// not either was declared — so a column that asked only to be an identity reads back carrying both, and + /// differs from itself on every deploy until they are folded away. + /// + public override IdentityOptions WithDefaults(IdentityOptions options, SqlType columnType) => FoldOptions(options, columnType); + + /// + /// Static so introspection folds a catalog row through the same rules the comparison uses. + internal static IdentityOptions FoldOptions(IdentityOptions options, SqlType columnType) + { + var bounds = Bounds(columnType, options.IncrementBy); + var start = options.IncrementBy is null or > 0 ? options.MinValue ?? bounds.Min : bounds.Max; + + return new IdentityOptions( + StartWith: options.StartWith == start ? null : options.StartWith, + MinValue: options.MinValue == bounds.Min ? null : options.MinValue, + IncrementBy: options.IncrementBy == 1 ? null : options.IncrementBy, + NotForReplication: options.NotForReplication); + } + + // The bounds a sequence of this type takes when neither end is declared: an ascending one runs from 1 to the + // type's maximum, a descending one from the type's minimum to -1. + private static (long Min, long Max) Bounds(SqlType? dataType, long? increment) + { + var (typeMin, typeMax) = TypeRange(dataType); + return increment is null or > 0 ? (1L, typeMax) : (typeMin, -1L); + } + + // Postgres has no tinyint; the dialect renders one as smallint, so it carries smallint's range. + private static (long Min, long Max) TypeRange(SqlType? dataType) => dataType?.Name.Value switch + { + "tinyint" or "smallint" => (short.MinValue, short.MaxValue), + "int" => (int.MinValue, int.MaxValue), + _ => (long.MinValue, long.MaxValue), + }; + + private static bool IsBigInt(SqlType? dataType) => dataType is null || dataType.Name.Value == "bigint"; + /// /// Folds the cast Postgres adds when it stores a literal default: the whole expression must be a single /// quoted literal cast to a type name; anything larger is left untouched. @@ -103,7 +172,32 @@ private sealed class TypeEquality : IEqualityComparer public int GetHashCode(SqlType obj) => Fold(obj)!.GetHashCode(); - private static SqlType? Fold(SqlType? type) => - type?.Schema?.Value is "public" or "pg_catalog" ? type with { Schema = null } : type; + private static SqlType? Fold(SqlType? type) + { + if (type is null) + { + return null; + } + + // The canonical names the dialect renders onto a type Postgres already has. Without this the engine's + // vocabulary — read from its own catalog, so it never contains these — cannot resolve a reference the + // dialect renders perfectly well, and a portable schema is refused rather than applied. + var folded = type.Name.Value switch + { + "tinyint" => type with { Name = new SqlIdentifier("smallint") }, + "nchar" => type with { Name = new SqlIdentifier("char") }, + "nvarchar" => type with { Name = new SqlIdentifier("varchar") }, + "binary" => type with { Name = new SqlIdentifier("varbinary") }, + _ => type, + }; + + // bytea carries no length, so a declared one is never read back and must not read as a difference. + if (folded is { Name.Value: "varbinary", Length: not null }) + { + folded = folded with { Length = null }; + } + + return folded.Schema?.Value is "public" or "pg_catalog" ? folded with { Schema = null } : folded; + } } } diff --git a/tests/NSchema.Postgres.Tests/NSchema.Postgres.Tests.csproj b/tests/NSchema.Postgres.Tests/NSchema.Postgres.Tests.csproj index 4238577..7236cfe 100644 --- a/tests/NSchema.Postgres.Tests/NSchema.Postgres.Tests.csproj +++ b/tests/NSchema.Postgres.Tests/NSchema.Postgres.Tests.csproj @@ -18,6 +18,7 @@ + diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresDatabaseIntrospectorTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresDatabaseIntrospectorTests.cs index e9362d9..37081ee 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresDatabaseIntrospectorTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresDatabaseIntrospectorTests.cs @@ -192,6 +192,44 @@ email TEXT NOT NULL idCol.DefaultExpression.ShouldBeNull(); } + [Fact] + public async Task GetDatabase_IdentityDeclaringNoOptions_ReportsNoneBack() + { + // Arrange — the identity's own sequence records a start and a minimum whether or not either was declared, + // so reporting them verbatim makes a column that asked for nothing differ from itself on every deploy. + await Exec($""" + CREATE TABLE "{_schema}".users ( + id INTEGER GENERATED ALWAYS AS IDENTITY + ) + """); + + // Act + var idCol = (await Introspect(_schema)) + .Schemas[0].Tables[0].Columns.Single(c => c.Name == "id"); + + // Assert + idCol.IsIdentity.ShouldBeTrue(); + idCol.IdentityOptions.ShouldNotBeNull().ShouldBe(new IdentityOptions(null, null, null)); + } + + [Fact] + public async Task GetDatabase_IdentityDeclaringAMinimum_KeepsIt() + { + // Arrange — the start follows the declared minimum, so only the minimum survives the fold. + await Exec($""" + CREATE TABLE "{_schema}".users ( + id INTEGER GENERATED ALWAYS AS IDENTITY (MINVALUE 50) + ) + """); + + // Act + var idCol = (await Introspect(_schema)) + .Schemas[0].Tables[0].Columns.Single(c => c.Name == "id"); + + // Assert + idCol.IdentityOptions.ShouldNotBeNull().ShouldBe(new IdentityOptions(null, 50, null)); + } + [Fact] public async Task GetDatabase_ColumnDefault_CapturesExpression() { @@ -1130,6 +1168,39 @@ public async Task GetDatabase_Extensions_AreReportedAtRootWithVersion() schema.Extensions.ShouldNotContain(e => e.Name == "plpgsql"); } + [Fact] + public async Task GetDatabase_ExtensionsShippedDescription_IsNotReportedAsAComment() + { + // Arrange — CREATE EXTENSION records the control file's description as a comment on the extension, so an + // extension nobody has documented still has one and every plan asked to remove it. + + // Act + var citext = (await Introspect(_schema)).Extensions.Single(e => e.Name == "citext"); + + // Assert + citext.Comment.ShouldBeNull(); + } + + [Fact] + public async Task GetDatabase_ExtensionCommentedByHand_IsReported() + { + // Arrange — only the shipped description is folded away; documentation the project wrote is still its own. + await Exec("COMMENT ON EXTENSION citext IS 'ours, not theirs'"); + try + { + // Act + var citext = (await Introspect(_schema)).Extensions.Single(e => e.Name == "citext"); + + // Assert + citext.Comment.ShouldBe("ours, not theirs"); + } + finally + { + // The extension is database-wide and the fixture is shared, so the shipped description goes back. + await Exec("COMMENT ON EXTENSION citext IS 'data type for case-insensitive character strings'"); + } + } + // ── Same table name across schemas ──────────────────────────────────────── // Regression: the columns query joined pg_class on relname alone (not namespace), so a table name shared by diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlDialectSnapshotTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlDialectSnapshotTests.cs index 7141daf..6d5d9d1 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlDialectSnapshotTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlDialectSnapshotTests.cs @@ -148,6 +148,14 @@ public Task AlterIdentitySequence() => VerifyActions( OldOptions: new IdentityOptions(StartWith: 1, MinValue: 1, IncrementBy: 1), NewOptions: new IdentityOptions(StartWith: 500, MinValue: 100, IncrementBy: 2))); + // RESTART reissues every value from the start onwards, so only a start that actually moved may carry one. + // SET START on its own records where a later restart begins and leaves the current value where it is. + [Fact] + public Task AlterIdentitySequence_WithoutAStartChange_DoesNotRestart() => VerifyActions( + new AlterIdentitySequence(new MemberAddress("public", "users", "id"), + OldOptions: new IdentityOptions(StartWith: null, MinValue: null, IncrementBy: 1), + NewOptions: new IdentityOptions(StartWith: null, MinValue: null, IncrementBy: 2))); + [Fact] public Task GeneratedColumnOperations() => VerifyActions( new CreateTable("public", new Table diff --git a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlEquivalenceTests.cs b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlEquivalenceTests.cs index eca2f2b..f14cd34 100644 --- a/tests/NSchema.Postgres.Tests/Sql/PostgresSqlEquivalenceTests.cs +++ b/tests/NSchema.Postgres.Tests/Sql/PostgresSqlEquivalenceTests.cs @@ -1,13 +1,15 @@ using NSchema.Model; using NSchema.Model.Columns; +using NSchema.Model.Sequences; using NSchema.Postgres.Sql; namespace NSchema.Postgres.Tests.Sql; /// /// Pins : the spellings Postgres and a project may legitimately disagree -/// on — a stored literal's cast, a public/pg_catalog type qualifier — compare equal in either -/// direction, while real differences survive. Pure unit tests — no Docker. +/// on — a stored literal's cast, a public/pg_catalog type qualifier, a sequence option declared +/// with the value the engine would have chosen anyway — compare equal in either direction, while real differences +/// survive. Pure unit tests — no Docker. /// public sealed class PostgresSqlEquivalenceTests { @@ -93,6 +95,95 @@ public void Types_DifferentNames_DoNotMatch() public void Types_BuiltIn_MatchesItself() => AssertTypesEqual(SqlType.VarChar(255), SqlType.VarChar(255)); + [Theory] + [MemberData(nameof(RenderedAlike))] + public void Types_CanonicalNamesTheDialectRendersAlike_Match(SqlType canonical, SqlType native) + => AssertTypesEqual(canonical, native); + + /// + /// The canonical spellings ToPostgresType renders onto a type Postgres has, paired with that type. + /// The engine's own vocabulary only ever names the right-hand side. + /// + public static TheoryData RenderedAlike() => new() + { + { SqlType.TinyInt, SqlType.SmallInt }, + { SqlType.NChar(4), SqlType.Char(4) }, + { SqlType.NVarChar(64), SqlType.VarChar(64) }, + { SqlType.NVarChar(), SqlType.VarChar() }, + { SqlType.Binary(16), SqlType.VarBinary() }, + }; + + [Fact] + public void Types_VarBinaryLength_IsNotSignificant() + // bytea has no length to carry, so declaring one cannot be a difference the plan could act on. + => AssertTypesEqual(SqlType.VarBinary(32), SqlType.VarBinary()); + + [Fact] + public void Types_LengthOnATypeThatCarriesOne_IsStillSignificant() + => _sut.Types.Equals(SqlType.VarChar(32), SqlType.VarChar(64)).ShouldBeFalse(); + + // ── Sequence options ────────────────────────────────────────────────────── + + [Fact] + public void WithDefaults_SequenceDeclaringNothing_IsUnchanged() + => _sut.WithDefaults(new SequenceOptions()).ShouldBe(new SequenceOptions()); + + [Fact] + public void WithDefaults_SequenceDeclaringTheEngineDefaults_FoldsToNothingDeclared() + // The whole point: a project that says out loud what Postgres would have chosen anyway has to compare equal + // to the catalog row, which cannot report which of the two happened. + => _sut.WithDefaults(new SequenceOptions( + DataType: SqlType.BigInt, StartWith: 1, IncrementBy: 1, MinValue: 1, MaxValue: long.MaxValue, Cache: 1)) + .ShouldBe(new SequenceOptions()); + + [Fact] + public void WithDefaults_SequenceDeclaringTheDescendingDefaults_KeepsOnlyTheIncrement() + => _sut.WithDefaults(new SequenceOptions(StartWith: -1, IncrementBy: -1, MinValue: long.MinValue, MaxValue: -1)) + .ShouldBe(new SequenceOptions(IncrementBy: -1)); + + [Fact] + public void WithDefaults_SequenceStartFollowingADeclaredMinimum_FoldsTheStartOnly() + // CREATE SEQUENCE q MINVALUE 5 starts at 5, so a declared START 5 is the default while the minimum is not. + => _sut.WithDefaults(new SequenceOptions(StartWith: 5, MinValue: 5)).ShouldBe(new SequenceOptions(MinValue: 5)); + + [Fact] + public void WithDefaults_SequenceOptionsThatDifferFromTheDefaults_AreKept() + => _sut.WithDefaults(new SequenceOptions(SqlType.Int, StartWith: 20, IncrementBy: 5, MinValue: 10, MaxValue: 1000, Cache: 10, Cycle: true)) + .ShouldBe(new SequenceOptions(SqlType.Int, StartWith: 20, IncrementBy: 5, MinValue: 10, MaxValue: 1000, Cache: 10, Cycle: true)); + + [Fact] + public void WithDefaults_SequenceBoundsFollowTheDeclaredType() + // int's maximum is the default ceiling for an integer sequence and a real one for a bigint sequence. + => _sut.WithDefaults(new SequenceOptions(SqlType.Int, MaxValue: int.MaxValue)).ShouldBe(new SequenceOptions(SqlType.Int)); + + [Fact] + public void WithDefaults_IntegerMaximumOnABigintSequence_IsKept() + => _sut.WithDefaults(new SequenceOptions(MaxValue: int.MaxValue)).ShouldBe(new SequenceOptions(MaxValue: int.MaxValue)); + + // ── Identity options ────────────────────────────────────────────────────── + + [Fact] + public void WithDefaults_IdentityDeclaringTheEngineDefaults_FoldsToNothingDeclared() + // pg_sequence reports a start and a minimum for every identity, asked for or not. + => _sut.WithDefaults(new IdentityOptions(StartWith: 1, MinValue: 1, IncrementBy: 1), SqlType.BigInt) + .ShouldBe(new IdentityOptions(null, null, null)); + + [Fact] + public void WithDefaults_IdentityStartFollowingADeclaredMinimum_FoldsTheStartOnly() + => _sut.WithDefaults(new IdentityOptions(StartWith: 5, MinValue: 5, IncrementBy: null), SqlType.Int) + .ShouldBe(new IdentityOptions(null, 5, null)); + + [Fact] + public void WithDefaults_IdentityOptionsThatDifferFromTheDefaults_AreKept() + => _sut.WithDefaults(new IdentityOptions(StartWith: 100, MinValue: 10, IncrementBy: 5), SqlType.Int) + .ShouldBe(new IdentityOptions(100, 10, 5)); + + [Fact] + public void WithDefaults_IdentityKeepsNotForReplication() + // Not an option Postgres has a default for, and it has to survive the trip. + => _sut.WithDefaults(new IdentityOptions(1, 1, 1, NotForReplication: true), SqlType.BigInt) + .NotForReplication.ShouldBeTrue(); + private void AssertDefaultsEqual(string x, string y) { // Equivalence is symmetric — neither side's spelling is the sanctioned one — and equal values hash equal. diff --git a/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlDialectSnapshotTests.AlterIdentitySequence_WithoutAStartChange_DoesNotRestart.verified.txt b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlDialectSnapshotTests.AlterIdentitySequence_WithoutAStartChange_DoesNotRestart.verified.txt new file mode 100644 index 0000000..bf6e91e --- /dev/null +++ b/tests/NSchema.Postgres.Tests/Sql/Snapshots/PostgresSqlDialectSnapshotTests.AlterIdentitySequence_WithoutAStartChange_DoesNotRestart.verified.txt @@ -0,0 +1,6 @@ +[ + { + Sql: ALTER TABLE "public"."users" ALTER COLUMN "id" SET INCREMENT BY 2, + RunOutsideTransaction: false + } +] \ No newline at end of file