Skip to content

Commit

Permalink
Support for PostgreSQL 12 generated columns
Browse files Browse the repository at this point in the history
Implements EF Core computed columns as PostgreSQL generated columns.

Closes npgsql#939
  • Loading branch information
roji committed Jul 28, 2019
1 parent 8945b40 commit c3f2de1
Show file tree
Hide file tree
Showing 5 changed files with 345 additions and 12 deletions.
82 changes: 77 additions & 5 deletions src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,14 @@
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Internal;
using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations.Operations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal;
using Npgsql.EntityFrameworkCore.PostgreSQL.Update.Internal;
using Npgsql.EntityFrameworkCore.PostgreSQL.Utilities;

namespace Npgsql.EntityFrameworkCore.PostgreSQL.Migrations
{
public class NpgsqlMigrationsSqlGenerator : MigrationsSqlGenerator
{
readonly NpgsqlSqlGenerationHelper _sqlGenerationHelper;
readonly NpgsqlTypeMappingSource _typeMappingSource;
readonly IMigrationsAnnotationProvider _migrationsAnnotations;

/// <summary>
/// The backend version to target.
Expand All @@ -31,12 +29,12 @@ public class NpgsqlMigrationsSqlGenerator : MigrationsSqlGenerator

public NpgsqlMigrationsSqlGenerator(
[NotNull] MigrationsSqlGeneratorDependencies dependencies,
[NotNull] IMigrationsAnnotationProvider migrationsAnnotations,
[NotNull] INpgsqlOptions npgsqlOptions)
: base(dependencies)
{
_sqlGenerationHelper = (NpgsqlSqlGenerationHelper)dependencies.SqlGenerationHelper;
_typeMappingSource = (NpgsqlTypeMappingSource)dependencies.TypeMappingSource;
_postgresVersion = npgsqlOptions.PostgresVersion;
_migrationsAnnotations = migrationsAnnotations;
}

protected override void Generate(MigrationOperation operation, IModel model, MigrationCommandListBuilder builder)
Expand Down Expand Up @@ -314,6 +312,47 @@ protected override void Generate(AlterColumnOperation operation, IModel model, M

var type = operation.ColumnType ?? GetColumnType(operation.Schema, operation.Table, operation.Name, operation, model);

if (operation.ComputedColumnSql != null)
{
var property = FindProperty(model, operation.Schema, operation.Table, operation.Name);

// TODO: The following will fail if the column being altered is part of an index.
// SqlServer recreates indexes, but wait to see if PostgreSQL will introduce a proper ALTER TABLE ALTER COLUMN
// that allows us to do this cleanly.
var dropColumnOperation = new DropColumnOperation
{
Schema = operation.Schema,
Table = operation.Table,
Name = operation.Name
};

if (property != null)
dropColumnOperation.AddAnnotations(_migrationsAnnotations.ForRemove(property));

Generate(dropColumnOperation, model, builder);

var addColumnOperation = new AddColumnOperation
{
Schema = operation.Schema,
Table = operation.Table,
Name = operation.Name,
ClrType = operation.ClrType,
ColumnType = operation.ColumnType,
IsUnicode = operation.IsUnicode,
MaxLength = operation.MaxLength,
IsRowVersion = operation.IsRowVersion,
IsNullable = operation.IsNullable,
DefaultValue = operation.DefaultValue,
DefaultValueSql = operation.DefaultValueSql,
ComputedColumnSql = operation.ComputedColumnSql,
IsFixedLength = operation.IsFixedLength
};
addColumnOperation.AddAnnotations(operation.GetAnnotations());
Generate(addColumnOperation, model, builder);

return;
}

string newSequenceName = null;
var defaultValueSql = operation.DefaultValueSql;

Expand Down Expand Up @@ -1027,6 +1066,39 @@ protected override void ColumnDefinition(
}
}

/// <summary>
/// Generates a SQL fragment for a computed column definition for the given column metadata.
/// </summary>
/// <param name="schema"> The schema that contains the table, or <c>null</c> to use the default schema. </param>
/// <param name="table"> The table that contains the column. </param>
/// <param name="name"> The column name. </param>
/// <param name="operation"> The column metadata. </param>
/// <param name="model"> The target model which may be <c>null</c> if the operations exist without a model. </param>
/// <param name="builder"> The command builder to use to add the SQL fragment. </param>
protected override void ComputedColumnDefinition(
string schema,
string table,
string name,
ColumnOperation operation,
IModel model,
MigrationCommandListBuilder builder)
{
Check.NotEmpty(name, nameof(name));
Check.NotNull(operation, nameof(operation));
Check.NotNull(builder, nameof(builder));

if (_postgresVersion != null && _postgresVersion < new Version(12, 0))
throw new NotSupportedException("Computed/generated columns aren't supported in PostgreSQL prior to version 12");

builder
.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(name))
.Append(" ")
.Append(operation.ColumnType ?? GetColumnType(schema, table, name, operation, model))
.Append(" GENERATED ALWAYS AS (")
.Append(operation.ComputedColumnSql)
.Append(") STORED");
}

#pragma warning disable 618
// Version 1.0 had a bad strategy for expressing serial columns, which depended on a
// ValueGeneratedOnAdd annotation. Detect that and throw.
Expand Down
14 changes: 10 additions & 4 deletions src/EFCore.PG/Scaffolding/Internal/NpgsqlDatabaseModelFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ static void GetColumns(
description,
attisdropped,
{(connection.PostgreSqlVersion >= new Version(10, 0) ? "attidentity" : "''::\"char\" as attidentity")},
{(connection.PostgreSqlVersion >= new Version(12, 0) ? "attgenerated" : "''::\"char\" as attgenerated")},
format_type(typ.oid, atttypmod) AS formatted_typname,
format_type(basetyp.oid, typ.typtypmod) AS formatted_basetypname,
CASE
Expand Down Expand Up @@ -311,8 +312,6 @@ nspname NOT IN ('pg_catalog', 'information_schema') AND
Table = table,
Name = record.GetValueOrDefault<string>("attname"),
IsNullable = record.GetValueOrDefault<bool>("nullable"),
DefaultValueSql = record.GetValueOrDefault<string>("default"),
ComputedColumnSql = null
};

// We need to know about dropped columns because constraints take them into
Expand Down Expand Up @@ -356,6 +355,15 @@ nspname NOT IN ('pg_catalog', 'information_schema') AND
column.IsNullable,
column.DefaultValueSql);

// Default values and PostgreSQL 12 generated columns
if (record.GetValueOrDefault<char>("attgenerated") == 's')
column.ComputedColumnSql = record.GetValueOrDefault<string>("default");
else
{
column.DefaultValueSql = record.GetValueOrDefault<string>("default");
AdjustDefaults(column, systemTypeName);
}

// Identify IDENTITY columns, as well as SERIAL ones.
switch (record.GetValueOrDefault<char>("attidentity"))
{
Expand Down Expand Up @@ -396,8 +404,6 @@ nspname NOT IN ('pg_catalog', 'information_schema') AND
if (column[NpgsqlAnnotationNames.ValueGenerationStrategy] != null)
column.ValueGenerated = ValueGenerated.OnAdd;

AdjustDefaults(column, systemTypeName);

if (record.GetValueOrDefault<string>("description") is string comment)
column[NpgsqlAnnotationNames.Comment] = comment;

Expand Down
160 changes: 160 additions & 0 deletions test/EFCore.PG.FunctionalTests/ComputedColumnTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.TestUtilities;
using Microsoft.Extensions.DependencyInjection;
using Npgsql.EntityFrameworkCore.PostgreSQL.TestUtilities;
using Npgsql.EntityFrameworkCore.PostgreSQL.TestUtilities.Xunit;
using Xunit;

namespace Npgsql.EntityFrameworkCore.PostgreSQL
{
public class ComputedColumnTest : IDisposable
{
[MinimumPostgresVersionFact(12, 0)]
public void Can_use_computed_columns()
{
var serviceProvider = new ServiceCollection()
.AddEntityFrameworkNpgsql()
.BuildServiceProvider();

using (var context = new Context(serviceProvider, TestStore.Name))
{
context.Database.EnsureCreatedResiliently();

var entity = context.Add(new Entity { P1 = 20, P2 = 30, P3 = 80 }).Entity;

context.SaveChanges();

Assert.Equal(50, entity.P4);
Assert.Equal(100, entity.P5);
}
}

[MinimumPostgresVersionFact(12, 0)]
public void Can_use_computed_columns_with_null_values()
{
var serviceProvider = new ServiceCollection()
.AddEntityFrameworkNpgsql()
.BuildServiceProvider();

using (var context = new Context(serviceProvider, TestStore.Name))
{
context.Database.EnsureCreatedResiliently();

var entity = context.Add(new Entity { P1 = 20, P2 = 30 }).Entity;

context.SaveChanges();

Assert.Equal(50, entity.P4);
Assert.Null(entity.P5);
}
}

class Context : DbContext
{
readonly IServiceProvider _serviceProvider;
readonly string _databaseName;

public Context(IServiceProvider serviceProvider, string databaseName)
{
_serviceProvider = serviceProvider;
_databaseName = databaseName;
}

public DbSet<Entity> Entities { get; set; }

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.UseNpgsql(NpgsqlTestStore.CreateConnectionString(_databaseName), b => b.ApplyConfiguration())
.UseInternalServiceProvider(_serviceProvider);

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Entity>()
.Property(e => e.P4)
.HasComputedColumnSql(@"""P1"" + ""P2""");

modelBuilder.Entity<Entity>()
.Property(e => e.P5)
.HasComputedColumnSql(@"""P1"" + ""P3""");
}
}

class Entity
{
public int Id { get; set; }
public int P1 { get; set; }
public int P2 { get; set; }
public int? P3 { get; set; }
public int P4 { get; set; }
public int? P5 { get; set; }
}

[Flags]
public enum FlagEnum
{
None = 0x0,
AValue = 0x1,
BValue = 0x2
}

public class EnumItem
{
public int EnumItemId { get; set; }
public FlagEnum FlagEnum { get; set; }
public FlagEnum? OptionalFlagEnum { get; set; }
public FlagEnum? CalculatedFlagEnum { get; set; }
}

class NullableContext : DbContext
{
readonly IServiceProvider _serviceProvider;
readonly string _databaseName;

public NullableContext(IServiceProvider serviceProvider, string databaseName)
{
_serviceProvider = serviceProvider;
_databaseName = databaseName;
}

public DbSet<EnumItem> EnumItems { get; set; }

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.UseNpgsql(NpgsqlTestStore.CreateConnectionString(_databaseName), b => b.ApplyConfiguration())
.UseInternalServiceProvider(_serviceProvider);

protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.Entity<EnumItem>()
.Property(entity => entity.CalculatedFlagEnum)
.HasComputedColumnSql(@"""FlagEnum"" | ""OptionalFlagEnum""");
}

[MinimumPostgresVersionFact(12, 0)]
public void Can_use_computed_columns_with_nullable_enum()
{
var serviceProvider = new ServiceCollection()
.AddEntityFrameworkNpgsql()
.BuildServiceProvider();

using (var context = new NullableContext(serviceProvider, TestStore.Name))
{
context.Database.EnsureCreatedResiliently();

var entity = context.EnumItems.Add(new EnumItem { FlagEnum = FlagEnum.AValue, OptionalFlagEnum = FlagEnum.BValue }).Entity;
context.SaveChanges();

Assert.Equal(FlagEnum.AValue | FlagEnum.BValue, entity.CalculatedFlagEnum);
}
}

public ComputedColumnTest()
{
TestStore = NpgsqlTestStore.CreateInitialized("ComputedColumnTest");
}

protected NpgsqlTestStore TestStore { get; }

public virtual void Dispose() => TestStore.Dispose();
}
}
37 changes: 37 additions & 0 deletions test/EFCore.PG.FunctionalTests/NpgsqlMigrationSqlGeneratorTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ public override void AddColumnOperation_with_defaultValueSql()
Sql);
}

[ConditionalFact]
public virtual void AddColumnOperation_with_computedSql()
{
Generate(
new AddColumnOperation
{
Table = "People",
Name = "FullName",
ClrType = typeof(string),
ComputedColumnSql = @"""FirstName"" || ' ' || ""LastName"""
});

Assert.Equal(
@"ALTER TABLE ""People"" ADD ""FullName"" text GENERATED ALWAYS AS (""FirstName"" || ' ' || ""LastName"") STORED;" + EOL,
Sql);
}

public override void AddColumnOperation_without_column_type()
{
base.AddColumnOperation_without_column_type();
Expand Down Expand Up @@ -610,6 +627,26 @@ public void AlterColumnOperation_serial_change_type()
Sql);
}

[ConditionalFact]
public void AlterColumnOperation_computed()
{
Generate(
new AlterColumnOperation
{
Table = "People",
Name = "FullName",
ClrType = typeof(string),
ComputedColumnSql = @"""FirstName"" || ' ' || ""LastName"""
});

Assert.Equal(@"ALTER TABLE ""People"" DROP COLUMN ""FullName"";
GO
ALTER TABLE ""People"" ADD ""FullName"" text GENERATED ALWAYS AS (""FirstName"" || ' ' || ""LastName"") STORED;
",
Sql, ignoreLineEndingDifferences: true);
}

#endregion Value generation alter

#region Indexes
Expand Down
Loading

0 comments on commit c3f2de1

Please sign in to comment.