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: 4 additions & 0 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,6 +28,7 @@ jobs:
shell: bash
env:
Build__ProjectFile: 'src/NSchema.Postgres/NSchema.Postgres.csproj'
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

deploy:
name: Deploy
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.301" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.400" />
<PackageVersion Include="Npgsql.DependencyInjection" Version="10.0.3" />
<PackageVersion Include="NSchema.Core" Version="5.9.0" />
<PackageVersion Include="NSchema.Core" Version="5.10.2" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="Npgsql" Version="10.0.3" />
<PackageVersion Include="NSubstitute" Version="6.1.0" />
<PackageVersion Include="NSubstitute" Version="6.2.0" />
<PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="SSH.NET" Version="2026.0.0" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.13.0" />
<PackageVersion Include="Verify.XunitV3" Version="31.28.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
Expand Down
2 changes: 1 addition & 1 deletion src/NSchema.Postgres/NSchema.Postgres.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<Version>5.6.0</Version>
<Version>5.6.1</Version>
<AssemblyVersion>$(Version.Split('-')[0])</AssemblyVersion>
<FileVersion>$(Version.Split('-')[0])</FileVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
Expand Down
93 changes: 48 additions & 45 deletions src/NSchema.Postgres/Sql/PostgresDatabaseIntrospector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -733,22 +733,35 @@ private static async Task<List<ExtensionRow>> 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
""";

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
));
}

Expand Down Expand Up @@ -1678,35 +1691,18 @@ List<ExtensionRow> 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()
{
Expand Down Expand Up @@ -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)),
};
}

/// <summary>
/// The canonical model spelling of a catalog type name (<c>int4</c> to <c>int</c>, <c>uuid</c> to
Expand Down
36 changes: 28 additions & 8 deletions src/NSchema.Postgres/Sql/PostgresSqlDialect.Columns.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using NSchema.Model.Columns;
using NSchema.Plan.Domain;
using NSchema.Plan.Domain.Columns;

Expand All @@ -21,29 +22,48 @@ protected override Result<IReadOnlyList<SqlStatement>> 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<IReadOnlyList<SqlStatement>> AlterIdentitySequence(AlterIdentitySequence action)
{
var opts = action.NewOptions;
var (old, @new) = (action.OldOptions, action.NewOptions);
var parts = new List<string>();
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.
Expand Down
98 changes: 96 additions & 2 deletions src/NSchema.Postgres/Sql/PostgresSqlEquivalence.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using NSchema.Diff.Plugins;
using NSchema.Model;
using NSchema.Model.Columns;
using NSchema.Model.Sequences;

namespace NSchema.Postgres.Sql;

Expand All @@ -25,6 +26,74 @@ public sealed class PostgresSqlEquivalence : SqlEquivalence
/// </remarks>
public override IEqualityComparer<SqlType> Types { get; } = new TypeEquality();

/// <inheritdoc/>
/// <remarks>
/// <c>pg_sequence</c> holds a row of concrete values whatever was declared, so every option the engine would
/// have chosen anyway folds back to <see langword="null"/>: <c>bigint</c>, <c>INCREMENT BY 1</c>,
/// <c>CACHE 1</c>, the bound at the ascending or descending end of the type, and the start that follows from
/// the effective bound — <c>CREATE SEQUENCE q MINVALUE 5</c> starts at 5, not at 1.
/// </remarks>
public override SequenceOptions WithDefaults(SequenceOptions options) => FoldOptions(options);

/// <inheritdoc cref="WithDefaults(SequenceOptions)"/>
/// <remarks>Static so introspection folds a catalog row through the same rules the comparison uses.</remarks>
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);
}

/// <inheritdoc/>
/// <remarks>
/// An identity is a sequence Postgres owns, and <c>pg_sequence</c> 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.
/// </remarks>
public override IdentityOptions WithDefaults(IdentityOptions options, SqlType columnType) => FoldOptions(options, columnType);

/// <inheritdoc cref="WithDefaults(IdentityOptions, SqlType)"/>
/// <remarks>Static so introspection folds a catalog row through the same rules the comparison uses.</remarks>
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";

/// <summary>
/// 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.
Expand Down Expand Up @@ -103,7 +172,32 @@ private sealed class TypeEquality : IEqualityComparer<SqlType>

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;
}
}
}
1 change: 1 addition & 0 deletions tests/NSchema.Postgres.Tests/NSchema.Postgres.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<PackageReference Include="Npgsql" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="Shouldly" />
<PackageReference Include="SSH.NET" />
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="Verify.XunitV3" />
<PackageReference Include="xunit.runner.visualstudio">
Expand Down
Loading