From b8530d00dc2937ee4d7fa20869af4043dbdaeda6 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Sun, 6 Jul 2025 11:58:35 +0200 Subject: [PATCH] Support PG18 virtual generated columns Closes #3568 --- .../NpgsqlMigrationsSqlGenerator.cs | 12 +- .../Internal/NpgsqlDatabaseModelFactory.cs | 23 +- .../Migrations/MigrationsNpgsqlTest.cs | 201 +++++------------- 3 files changed, 72 insertions(+), 164 deletions(-) diff --git a/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs b/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs index fccab7d18..eea5633c5 100644 --- a/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs +++ b/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs @@ -1872,10 +1872,11 @@ protected override void ComputedColumnDefinition( throw new NotSupportedException("Computed/generated columns aren't supported in PostgreSQL prior to version 12"); } - if (operation.IsStored != true) + if (operation.IsStored is not true && _postgresVersion < new Version(18, 0)) { throw new NotSupportedException( - "Generated columns currently must be stored, specify 'stored: true' in " + "Virtual (non-stored) generated columns are only supported on PostgreSQL 18 and up. " + + "On older versions, specify 'stored: true' in " + $"'{nameof(RelationalPropertyBuilderExtensions.HasComputedColumnSql)}' in your context's OnModelCreating."); } @@ -1894,7 +1895,12 @@ protected override void ComputedColumnDefinition( builder .Append(" GENERATED ALWAYS AS (") .Append(operation.ComputedColumnSql!) - .Append(") STORED"); + .Append(")"); + + if (operation.IsStored is true) + { + builder.Append(" STORED"); + } if (!operation.IsNullable) { diff --git a/src/EFCore.PG/Scaffolding/Internal/NpgsqlDatabaseModelFactory.cs b/src/EFCore.PG/Scaffolding/Internal/NpgsqlDatabaseModelFactory.cs index 7bcc5b51a..b305a8626 100644 --- a/src/EFCore.PG/Scaffolding/Internal/NpgsqlDatabaseModelFactory.cs +++ b/src/EFCore.PG/Scaffolding/Internal/NpgsqlDatabaseModelFactory.cs @@ -390,17 +390,22 @@ ORDER BY attnum continue; } - // Default values and PostgreSQL 12 generated columns + // Default values and generated columns var defaultValueSql = record.GetValueOrDefault("default"); - if (record.GetFieldValue("attgenerated") == "s") + switch (record.GetFieldValue("attgenerated")) { - column.ComputedColumnSql = defaultValueSql; - column.IsStored = true; - } - else - { - column.DefaultValueSql = defaultValueSql; - column.DefaultValue = ParseDefaultValueSql(systemTypeName, defaultValueSql); + case "v": + column.ComputedColumnSql = defaultValueSql; + column.IsStored = false; + break; + case "s": + column.ComputedColumnSql = defaultValueSql; + column.IsStored = true; + break; + default: + column.DefaultValueSql = defaultValueSql; + column.DefaultValue = ParseDefaultValueSql(systemTypeName, defaultValueSql); + break; } // Identify IDENTITY columns, as well as SERIAL ones. diff --git a/test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs index b5ee9f0b3..0783272fc 100644 --- a/test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs @@ -114,26 +114,21 @@ More information can public override async Task Create_table_with_computed_column(bool? stored) { - if (TestEnvironment.PostgresVersion.IsUnder(12)) + if (stored is not true && !SupportsVirtualGeneratedColumns) { await Assert.ThrowsAsync(() => base.Create_table_with_computed_column(stored)); return; } - if (stored != true) - { - // Non-stored generated columns aren't yet supported (PG12) - await Assert.ThrowsAsync(() => base.Create_table_with_computed_column(stored)); - return; - } + await base.Create_table_with_computed_column(stored); - await base.Create_table_with_computed_column(stored: true); + var storedSql = stored is true ? " STORED" : ""; AssertSql( - """ + $""" CREATE TABLE "People" ( "Id" integer GENERATED BY DEFAULT AS IDENTITY, - "Sum" text GENERATED ALWAYS AS ("X" + "Y") STORED, + "Sum" text GENERATED ALWAYS AS ("X" + "Y"){storedSql}, "X" integer NOT NULL, "Y" integer NOT NULL, CONSTRAINT "PK_People" PRIMARY KEY ("Id") @@ -616,22 +611,17 @@ public override async Task Add_column_with_defaultValueSql() public override async Task Add_column_with_computedSql(bool? stored) { - if (TestEnvironment.PostgresVersion.IsUnder(12)) + if (stored is not true && !SupportsVirtualGeneratedColumns) { await Assert.ThrowsAsync(() => base.Add_column_with_computedSql(stored)); return; } - if (stored != true) - { - // Non-stored generated columns aren't yet supported (PG12) - await Assert.ThrowsAsync(() => base.Add_column_with_computedSql(stored)); - return; - } + await base.Add_column_with_computedSql(stored); - await base.Add_column_with_computedSql(stored: true); + var storedSql = stored is true ? " STORED" : ""; - AssertSql("""ALTER TABLE "People" ADD "Sum" text GENERATED ALWAYS AS ("X" + "Y") STORED;"""); + AssertSql($"""ALTER TABLE "People" ADD "Sum" text GENERATED ALWAYS AS ("X" + "Y"){storedSql};"""); } public override async Task Add_column_with_required() @@ -696,22 +686,17 @@ public override async Task Add_column_with_collation() public override async Task Add_column_computed_with_collation(bool stored) { - if (TestEnvironment.PostgresVersion.IsUnder(12)) + if (stored is not true && !SupportsVirtualGeneratedColumns) { await Assert.ThrowsAsync(() => base.Add_column_computed_with_collation(stored)); return; } - if (stored != true) - { - // Non-stored generated columns aren't yet supported (PG12) - await Assert.ThrowsAsync(() => base.Create_table_with_computed_column(stored)); - return; - } - await base.Add_column_computed_with_collation(stored); - AssertSql("""ALTER TABLE "People" ADD "Name" text COLLATE "POSIX" GENERATED ALWAYS AS ('hello') STORED;"""); + var storedSql = stored is true ? " STORED" : ""; + + AssertSql($"""ALTER TABLE "People" ADD "Name" text COLLATE "POSIX" GENERATED ALWAYS AS ('hello'){storedSql};"""); } public override async Task Add_column_shared() @@ -1043,73 +1028,43 @@ public override async Task Alter_column_make_required_with_composite_index() public override async Task Alter_column_make_computed(bool? stored) { - if (TestEnvironment.PostgresVersion.IsUnder(12)) + if (stored is not true && !SupportsVirtualGeneratedColumns) { - await Assert.ThrowsAsync(() => base.Add_column_with_computedSql(stored)); - return; - } - - if (stored != true) - { - // Non-stored generated columns aren't yet supported (PG12) - await Assert.ThrowsAsync(() => base.Add_column_with_computedSql(stored)); + await Assert.ThrowsAsync(() => base.Alter_column_make_computed(stored)); return; } await base.Alter_column_make_computed(stored); + var storedSql = stored is true ? " STORED" : ""; + AssertSql( """ALTER TABLE "People" DROP COLUMN "Sum";""", // - """ALTER TABLE "People" ADD "Sum" integer GENERATED ALWAYS AS ("X" + "Y") STORED NOT NULL;"""); + $"""ALTER TABLE "People" ADD "Sum" integer GENERATED ALWAYS AS ("X" + "Y"){storedSql} NOT NULL;"""); } public override async Task Alter_column_change_computed() { - if (TestEnvironment.PostgresVersion.IsUnder(12)) + if (!SupportsVirtualGeneratedColumns) { await Assert.ThrowsAsync(() => base.Alter_column_change_computed()); return; } - // Non-stored generated columns aren't yet supported (PG12), so we override to used stored - await Test( - builder => builder.Entity( - "People", e => - { - e.Property("Id"); - e.Property("X"); - e.Property("Y"); - e.Property("Sum"); - }), - builder => builder.Entity("People").Property("Sum") - .HasComputedColumnSql($"{DelimitIdentifier("X")} + {DelimitIdentifier("Y")}", stored: true), - builder => builder.Entity("People").Property("Sum") - .HasComputedColumnSql($"{DelimitIdentifier("X")} - {DelimitIdentifier("Y")}", stored: true), - model => - { - var table = Assert.Single(model.Tables); - var sumColumn = Assert.Single(table.Columns, c => c.Name == "Sum"); - Assert.Contains("X", sumColumn.ComputedColumnSql); - Assert.Contains("Y", sumColumn.ComputedColumnSql); - Assert.Contains("-", sumColumn.ComputedColumnSql); - }); + await base.Alter_column_change_computed(); AssertSql( """ALTER TABLE "People" DROP COLUMN "Sum";""", // - """ALTER TABLE "People" ADD "Sum" integer GENERATED ALWAYS AS ("X" - "Y") STORED NOT NULL;"""); + """ALTER TABLE "People" ADD "Sum" integer GENERATED ALWAYS AS ("X" - "Y") NOT NULL;"""); } public override async Task Alter_column_change_computed_recreates_indexes() { - if (TestEnvironment.PostgresVersion.IsUnder(12)) - { - await Assert.ThrowsAsync(() => base.Alter_column_change_computed()); - return; - } - - // Non-stored generated columns aren't yet supported (PG12), so we override to used stored + // PostgreSQL does not support indexes on virtual generated columns, which this test requires + // (0A000: indexes on virtual generated columns are not supported). + // So we override the test to use stored generated columns instead. await Test( builder => builder.Entity( "People", e => @@ -1148,37 +1103,31 @@ await Test( """CREATE INDEX "IX_People_Sum" ON "People" ("Sum");"""); } - public override Task Alter_column_change_computed_type() - => Assert.ThrowsAsync(() => base.Alter_column_change_computed()); + public override async Task Alter_column_change_computed_type() + { + if (!SupportsVirtualGeneratedColumns) + { + await Assert.ThrowsAsync(() => base.Alter_column_change_computed_type()); + return; + } + + await base.Alter_column_change_computed_type(); + + AssertSql( + """ALTER TABLE "People" DROP COLUMN "Sum";""", + // + """ALTER TABLE "People" ADD "Sum" integer GENERATED ALWAYS AS ("X" + "Y") STORED NOT NULL;"""); + } public override async Task Alter_column_make_non_computed() { - if (TestEnvironment.PostgresVersion.IsUnder(12)) + if (!SupportsVirtualGeneratedColumns) { await Assert.ThrowsAsync(() => base.Alter_column_make_non_computed()); return; } - await Test( - builder => builder.Entity( - "People", e => - { - e.Property("Id"); - e.Property("X"); - e.Property("Y"); - }), - builder => builder.Entity("People").Property("Sum") - .HasComputedColumnSql(""" - "X" + "Y" - """, stored: true), - builder => builder.Entity("People").Property("Sum"), - model => - { - var table = Assert.Single(model.Tables); - var sumColumn = Assert.Single(table.Columns, c => c.Name == "Sum"); - Assert.Null(sumColumn.ComputedColumnSql); - Assert.NotEqual(true, sumColumn.IsStored); - }); + await base.Alter_column_make_non_computed(); AssertSql( """ALTER TABLE "People" DROP COLUMN "Sum";""", @@ -1195,30 +1144,13 @@ public override async Task Alter_column_add_comment() public override async Task Alter_computed_column_add_comment() { - if (TestEnvironment.PostgresVersion.IsUnder(12)) + if (!SupportsVirtualGeneratedColumns) { - await Assert.ThrowsAsync(() => base.Alter_computed_column_add_comment()); + await Assert.ThrowsAsync(() => base.Alter_column_make_non_computed()); return; } - await Test( - builder => builder.Entity( - "People", x => - { - x.Property("Id"); - x.Property("SomeColumn").HasComputedColumnSql("42", stored: true); - }), - _ => { }, - builder => builder.Entity("People").Property("SomeColumn").HasComment("Some comment"), - model => - { - var table = Assert.Single(model.Tables); - var column = Assert.Single(table.Columns, c => c.Name == "SomeColumn"); - if (AssertComments) - { - Assert.Equal("Some comment", column.Comment); - } - }); + await base.Alter_computed_column_add_comment(); AssertSql("""COMMENT ON COLUMN "People"."SomeColumn" IS 'Some comment';"""); } @@ -1682,11 +1614,6 @@ public override async Task Convert_string_column_to_a_json_column_containing_col [Fact] public virtual async Task Alter_column_computed_set_collation() { - if (TestEnvironment.PostgresVersion.IsUnder(12)) - { - return; - } - await Test( builder => builder.Entity( "People", b => @@ -1774,25 +1701,13 @@ public override async Task Drop_column_primary_key() public override async Task Drop_column_computed_and_non_computed_with_dependency() { - if (TestEnvironment.PostgresVersion.IsUnder(12)) + if (!SupportsVirtualGeneratedColumns) { + await Assert.ThrowsAsync(() => base.Drop_column_computed_and_non_computed_with_dependency()); return; } - await Test( - builder => builder.Entity("People").Property("Id"), - builder => builder.Entity( - "People", e => - { - e.Property("X"); - e.Property("Y").HasComputedColumnSql($"{DelimitIdentifier("X")} + 1", stored: true); - }), - _ => { }, - model => - { - var table = Assert.Single(model.Tables); - Assert.Equal("Id", Assert.Single(table.Columns).Name); - }); + await base.Drop_column_computed_and_non_computed_with_dependency(); AssertSql( """ALTER TABLE "People" DROP COLUMN "Y";""", @@ -3029,11 +2944,6 @@ public virtual Task Alter_collation_throws() [Fact] public virtual async Task Add_column_generated_tsvector_over_text() { - if (TestEnvironment.PostgresVersion.IsUnder(12)) - { - return; - } - await Test( builder => builder.Entity("Blogs", e => e.Property("TextColumn").IsRequired()), _ => { }, @@ -3051,11 +2961,6 @@ await Test( [Fact] public virtual async Task Add_column_generated_tsvector_over_jsonb() { - if (TestEnvironment.PostgresVersion.IsUnder(12)) - { - return; - } - await Test( builder => builder.Entity("People").Property("JsonbColumn").HasColumnType("jsonb").IsRequired(), _ => { }, @@ -3075,11 +2980,6 @@ await Test( [Fact] public virtual async Task Add_column_generated_tsvector_over_mixed() { - if (TestEnvironment.PostgresVersion.IsUnder(12)) - { - return; - } - await Test( builder => { @@ -3106,11 +3006,6 @@ await Test( [Fact] public virtual async Task Alter_column_generated_tsvector_change_config() { - if (TestEnvironment.PostgresVersion.IsUnder(12)) - { - return; - } - await Test( builder => builder.Entity( "Blogs", e => @@ -3264,6 +3159,8 @@ public override async Task Add_required_primitve_collection_with_custom_converte AssertSql("""ALTER TABLE "Customers" ADD "Numbers" text NOT NULL DEFAULT 'some numbers';"""); } + private static bool SupportsVirtualGeneratedColumns + => TestEnvironment.PostgresVersion.AtLeast(18); protected override string NonDefaultCollation => "POSIX";