diff --git a/src/EFCore.PG/Design/Internal/NpgsqlAnnotationCodeGenerator.cs b/src/EFCore.PG/Design/Internal/NpgsqlAnnotationCodeGenerator.cs index dfc526133..d42f497c4 100644 --- a/src/EFCore.PG/Design/Internal/NpgsqlAnnotationCodeGenerator.cs +++ b/src/EFCore.PG/Design/Internal/NpgsqlAnnotationCodeGenerator.cs @@ -139,6 +139,9 @@ public override IReadOnlyList GenerateFluentApiCalls( if (annotation.Name == NpgsqlAnnotationNames.UnloggedTable) return new MethodCallCodeFragment(nameof(NpgsqlEntityTypeBuilderExtensions.IsUnlogged), annotation.Value); + if (annotation.Name == NpgsqlAnnotationNames.TablePartitioning && annotation.Value is TablePartitioning tablePartitioning) + return new MethodCallCodeFragment(nameof(NpgsqlEntityTypeBuilderExtensions.IsPartitioned), tablePartitioning.Type, tablePartitioning.PartitionKeyProperties.Select(x => x.Name)); + return null; } diff --git a/src/EFCore.PG/Extensions/BuilderExtensions/NpgsqlEntityTypeBuilderExtensions.cs b/src/EFCore.PG/Extensions/BuilderExtensions/NpgsqlEntityTypeBuilderExtensions.cs index d4d4dbfa4..27795c3f1 100644 --- a/src/EFCore.PG/Extensions/BuilderExtensions/NpgsqlEntityTypeBuilderExtensions.cs +++ b/src/EFCore.PG/Extensions/BuilderExtensions/NpgsqlEntityTypeBuilderExtensions.cs @@ -289,6 +289,62 @@ public static bool CanSetIsUnlogged( #endregion + #region Partitioning + /// + /// Configures the entity to use table partitioning when targeting Npsql. + /// + /// The builder for the entity type being configured. + /// The type of partitioning to use on the table. + /// The entity's properties to use as key for the partitioning. + /// + public static EntityTypeBuilder IsPartitioned( + this EntityTypeBuilder entityTypeBuilder, + TablePartitioningType tablePartitioningType, + params string[] partitionKeyPropertyNames) + { + Check.NotNull(entityTypeBuilder, nameof(entityTypeBuilder)); + Check.NotEmpty(partitionKeyPropertyNames, nameof(partitionKeyPropertyNames)); + + entityTypeBuilder.Metadata.SetTablePartitioning(tablePartitioningType, partitionKeyPropertyNames); + + return entityTypeBuilder; + } + + /// + /// Configures the entity to use table partitioning when targeting Npsql. + /// + /// The builder for the entity type being configured. + /// The type of partitioning to use on the table. + /// The entity's properties to use as key for the partitioning. + /// + public static EntityTypeBuilder IsPartitioned( + this EntityTypeBuilder entityTypeBuilder, + TablePartitioningType tablePartitioningType, + IEnumerable partitionKeyPropertyNames) + => IsPartitioned( + entityTypeBuilder, + tablePartitioningType, + partitionKeyPropertyNames.ToArray()); + + /// + /// Configures the entity to use table partitioning when targeting Npsql. + /// + /// The builder for the entity type being configured. + /// The type of partitioning to use on the table. + /// An expression representinng the entity's properties to use as key of the partition. + public static EntityTypeBuilder IsPartitioned( + this EntityTypeBuilder entityTypeBuilder, + TablePartitioningType tablePartitioningType, + Expression> partitionKeyPropertyExpression) + where TEntity : class + => (EntityTypeBuilder)IsPartitioned( + entityTypeBuilder, + tablePartitioningType, + Check.NotNull(partitionKeyPropertyExpression, nameof(partitionKeyPropertyExpression)) + .GetMemberAccessList().Select(x => x.GetSimpleMemberName())); + + #endregion + #region CockroachDB Interleave-in-parent public static EntityTypeBuilder UseCockroachDbInterleaveInParent( diff --git a/src/EFCore.PG/Extensions/MetadataExtensions/NpgsqlEntityTypeExtensions.cs b/src/EFCore.PG/Extensions/MetadataExtensions/NpgsqlEntityTypeExtensions.cs index e3f68ad13..bb6c6bd53 100644 --- a/src/EFCore.PG/Extensions/MetadataExtensions/NpgsqlEntityTypeExtensions.cs +++ b/src/EFCore.PG/Extensions/MetadataExtensions/NpgsqlEntityTypeExtensions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Utilities; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; @@ -88,6 +89,24 @@ public static bool SetIsUnlogged( #endregion Unlogged + #region Partitioning + public static void SetTablePartitioning( + this IMutableEntityType entityType, + TablePartitioningType tablePartitioningType, + params string[] partitionKeyPropertyNames) + { + Check.NotEmpty(partitionKeyPropertyNames, nameof(partitionKeyPropertyNames)); + + var properties = partitionKeyPropertyNames + .Select(x => entityType.FindProperty(x)) + .ToArray(); + + entityType.SetOrRemoveAnnotation( + NpgsqlAnnotationNames.TablePartitioning, + new TablePartitioning(tablePartitioningType, properties!)); + } + #endregion + #region CockroachDb interleave in parent public static CockroachDbInterleaveInParent GetCockroachDbInterleaveInParent(this IReadOnlyEntityType entityType) diff --git a/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationNames.cs b/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationNames.cs index 81eb251fe..36fb608e5 100644 --- a/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationNames.cs +++ b/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationNames.cs @@ -22,6 +22,7 @@ public static class NpgsqlAnnotationNames public const string Tablespace = Prefix + "Tablespace"; public const string StorageParameterPrefix = Prefix + "StorageParameter:"; public const string UnloggedTable = Prefix + "UnloggedTable"; + public const string TablePartitioning = Prefix + "TablePartitioning"; public const string IdentityOptions = Prefix + "IdentitySequenceOptions"; public const string TsVectorConfig = Prefix + "TsVectorConfig"; public const string TsVectorProperties = Prefix + "TsVectorProperties"; diff --git a/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationProvider.cs b/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationProvider.cs index 41f12852a..8ee8b9627 100644 --- a/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationProvider.cs +++ b/src/EFCore.PG/Metadata/Internal/NpgsqlAnnotationProvider.cs @@ -24,6 +24,8 @@ public override IEnumerable For(ITable table) yield return new Annotation(NpgsqlAnnotationNames.UnloggedTable, entityType.GetIsUnlogged()); if (entityType[CockroachDbAnnotationNames.InterleaveInParent] != null) yield return new Annotation(CockroachDbAnnotationNames.InterleaveInParent, entityType[CockroachDbAnnotationNames.InterleaveInParent]); + if (entityType[NpgsqlAnnotationNames.TablePartitioning] != null) + yield return new Annotation(NpgsqlAnnotationNames.TablePartitioning, entityType[NpgsqlAnnotationNames.TablePartitioning]); foreach (var storageParamAnnotation in entityType.GetAnnotations() .Where(a => a.Name.StartsWith(NpgsqlAnnotationNames.StorageParameterPrefix, StringComparison.Ordinal))) { diff --git a/src/EFCore.PG/Metadata/TablePartitioning.cs b/src/EFCore.PG/Metadata/TablePartitioning.cs new file mode 100644 index 000000000..6578c6255 --- /dev/null +++ b/src/EFCore.PG/Metadata/TablePartitioning.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore.Utilities; + +namespace Microsoft.EntityFrameworkCore.Metadata +{ + /// + /// Represents the Metadata for the partitioning of a table. + /// + public class TablePartitioning + { + /// + /// The type of partitioning to use. + /// + public TablePartitioningType Type { get; } + + /// + /// The entity's properties to use as key for the partitioning. + /// + public IReadOnlyProperty[] PartitionKeyProperties { get; } + + /// + /// Creates a . + /// + /// The type of partitioning to use. + /// The entity properties to use as key for the partitioning. + /// + public TablePartitioning(TablePartitioningType type, IReadOnlyProperty[] partitionKeyProperties) + { + Check.NotEmpty(partitionKeyProperties, nameof(partitionKeyProperties)); + + Type = type; + PartitionKeyProperties = partitionKeyProperties; + } + } +} diff --git a/src/EFCore.PG/Metadata/TablePartitioningType.cs b/src/EFCore.PG/Metadata/TablePartitioningType.cs new file mode 100644 index 000000000..1c2d503ec --- /dev/null +++ b/src/EFCore.PG/Metadata/TablePartitioningType.cs @@ -0,0 +1,29 @@ +namespace Microsoft.EntityFrameworkCore.Metadata +{ + /// + /// Represents the supported forms of postgres table partitioning as defined in the official documentation: + /// https://www.postgresql.org/docs/current/ddl-partitioning.html#DDL-PARTITIONING-OVERVIEW + /// + public enum TablePartitioningType + { + /// + /// + /// The table is partitioned into “ranges” defined by a key column or set of columns, + /// with no overlap between the ranges of values assigned to different partitions. + /// + /// + Range, + + /// + /// + /// The table is partitioned by explicitly listing which key value(s) appear in each partition. + /// + /// + List, + + /// + /// The table is partitioned by specifying a modulus and a remainder for each partition + /// + Hash + } +} diff --git a/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs b/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs index 97022f057..f0ca454ef 100644 --- a/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs +++ b/src/EFCore.PG/Migrations/NpgsqlMigrationsSqlGenerator.cs @@ -166,6 +166,22 @@ protected override void Generate( builder.Append(")"); + // Table Partitioning (https://www.postgresql.org/docs/current/ddl-partitioning.html) + if (operation[NpgsqlAnnotationNames.TablePartitioning] is TablePartitioning tablePartitioning) + { + var columnNames = tablePartitioning.PartitionKeyProperties + .Select(property => + property.GetColumnName(StoreObjectIdentifier.Table(operation.Name, operation.Schema))) + .ToArray(); + + builder.AppendLine() + .Append("PARTITION BY ") + .Append(GetPartitionTypeString(tablePartitioning.Type)) + .Append(" (") + .Append(ColumnList(columnNames!)) + .Append(") "); + } + // CockroachDB "interleave in parent" (https://www.cockroachlabs.com/docs/stable/interleave-in-parent.html) if (operation[CockroachDbAnnotationNames.InterleaveInParent] is string) { @@ -231,6 +247,12 @@ protected override void Generate(AlterTableOperation operation, IModel? model, M { var madeChanges = false; + // Table Partitioning may not be added after table creation + if (HasTablePartioningChanges(operation)) + { + throw new ArgumentException($"When generating migrations SQL for {nameof(AlterTableOperation)}, can't alter a table's partitioning after it was created."); + } + // Storage parameters var oldStorageParameters = GetStorageParameters(operation.OldTable); var newStorageParameters = GetStorageParameters(operation); @@ -1676,6 +1698,38 @@ private static string GenerateStorageParameterValue(object value) #endregion Storage parameter utilities + #region Partitioning utilities + private static string GetPartitionTypeString(TablePartitioningType type) + { + return type switch + { + TablePartitioningType.Range => "Range", + TablePartitioningType.List => "List", + TablePartitioningType.Hash => "Hash", + _ => throw new NotSupportedException("Given TablePartitioningType is not supported as part of the NpgsqlMigrationSqlGenerator.") + }; + } + + private static bool HasTablePartioningChanges(AlterTableOperation operation) + { + var oldPartitioningConfiguration = operation.OldTable[NpgsqlAnnotationNames.TablePartitioning] as TablePartitioning; + var newPartitioningConfiguration = operation[NpgsqlAnnotationNames.TablePartitioning] as TablePartitioning; + + var oldPartitionPropertyNames = oldPartitioningConfiguration + ?.PartitionKeyProperties + ?.Select(x => x.Name) ?? new List(); + + var newPartitionPropertyNames = newPartitioningConfiguration + ?.PartitionKeyProperties + ?.Select(x => x.Name) ?? new List(); + + return oldPartitioningConfiguration?.Type != newPartitioningConfiguration?.Type || + !oldPartitionPropertyNames.SequenceEqual(newPartitionPropertyNames); + + + } + #endregion + #region Helpers private string DelimitIdentifier(string identifier) => diff --git a/test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs index 32e60bb2a..220d2de8f 100644 --- a/test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Migrations/MigrationsNpgsqlTest.cs @@ -296,6 +296,120 @@ await Test( );"); } + [Fact] + public virtual async Task Create_table_with_list_partitioning() + { + await Test( + builder => { }, + builder => builder.Entity( + "People", e => + { + e.Property("Id"); + e.HasKey("Id"); + e.IsPartitioned(TablePartitioningType.List, "Id"); + }), + asserter: null); + + AssertSql( + @"CREATE TABLE ""People"" ( + ""Id"" integer NOT NULL, + CONSTRAINT ""PK_People"" PRIMARY KEY (""Id"") +) +PARTITION BY List (""Id"") ;"); + } + + [Fact] + public virtual async Task Create_table_with_range_partitioning() + { + await Test( + builder => { }, + builder => builder.Entity( + "People", e => + { + e.Property("Id"); + e.HasKey("Id"); + e.IsPartitioned(TablePartitioningType.Range, "Id"); + }), + asserter: null); + + AssertSql( + @"CREATE TABLE ""People"" ( + ""Id"" integer NOT NULL, + CONSTRAINT ""PK_People"" PRIMARY KEY (""Id"") +) +PARTITION BY Range (""Id"") ;"); + } + + [Fact] + public virtual async Task Create_table_with_hash_partitioning() + { + await Test( + builder => { }, + builder => builder.Entity( + "People", e => + { + e.Property("Id"); + e.HasKey("Id"); + e.IsPartitioned(TablePartitioningType.Hash, "Id"); + }), + asserter: null); + + AssertSql( + @"CREATE TABLE ""People"" ( + ""Id"" integer NOT NULL, + CONSTRAINT ""PK_People"" PRIMARY KEY (""Id"") +) +PARTITION BY Hash (""Id"") ;"); + } + + [Fact] + public virtual async Task Create_table_with_multiple_partitioning_columns() + { + await Test( + builder => { }, + builder => builder.Entity( + "People", e => + { + e.Property("Id"); + e.Property("Location"); + e.HasKey("Id", "Location"); + e.IsPartitioned(TablePartitioningType.Hash, "Id", "Location"); + }), + asserter: null); + + AssertSql( + @"CREATE TABLE ""People"" ( + ""Id"" integer NOT NULL, + ""Location"" integer NOT NULL, + CONSTRAINT ""PK_People"" PRIMARY KEY (""Id"", ""Location"") +) +PARTITION BY Hash (""Id"", ""Location"") ;"); + } + + [Fact] + public virtual async Task Create_table_with_partitioning_with_custom_column_name() + { + await Test( + builder => { }, + builder => builder.Entity( + "People", e => + { + e.Property("Id"); + e.Property("Location").HasColumnName("loc"); + e.HasKey("Id", "Location"); + e.IsPartitioned(TablePartitioningType.Hash, "Id", "Location"); + }), + asserter: null); + + AssertSql( + @"CREATE TABLE ""People"" ( + ""Id"" integer NOT NULL, + cat integer NOT NULL, + CONSTRAINT ""PK_People"" PRIMARY KEY (""Id"", loc) +) +PARTITION BY Hash (""Id"", loc) ;"); + } + public override async Task Drop_table() { await base.Drop_table(); @@ -389,6 +503,54 @@ await Test( @"ALTER TABLE ""People"" SET UNLOGGED;"); } + [Fact] + public virtual async Task Alter_table_add_partitioning_throws_exception() + { + await Assert.ThrowsAsync(async () => await Test( + builder => builder.Entity( + "People", e => + { + e.Property("Id"); + e.HasKey("Id"); + }), + builder => { }, + builder => builder.Entity("People", e => e.IsPartitioned(TablePartitioningType.List, "Id")), + asserter: null)); + } + + [Fact] + public virtual async Task Alter_table_change_partitioning_type_throws_exception() + { + await Assert.ThrowsAsync(async () => await Test( + builder => builder.Entity( + "People", e => + { + e.Property("Id"); + e.HasKey("Id"); + e.IsPartitioned(TablePartitioningType.List, "Id"); + }), + builder => { }, + builder => builder.Entity("People", e => e.IsPartitioned(TablePartitioningType.Range, "Id")), + asserter: null)); + } + + [Fact] + public virtual async Task Alter_table_change_partitioning_key_throws_exception() + { + await Assert.ThrowsAsync(async () => await Test( + builder => builder.Entity( + "People", e => + { + e.Property("Id"); + e.Property("Location"); + e.HasKey("Id", "Location"); + e.IsPartitioned(TablePartitioningType.List, "Id"); + }), + builder => { }, + builder => builder.Entity("People", e => e.IsPartitioned(TablePartitioningType.List, "Id", "Location")), + asserter: null)); + } + [Fact] public virtual async Task Alter_table_make_logged() { diff --git a/test/EFCore.PG.Tests/Metadata/NpgsqlBuilderExtensionsTest.cs b/test/EFCore.PG.Tests/Metadata/NpgsqlBuilderExtensionsTest.cs index 118d8f5cc..48a4e446a 100644 --- a/test/EFCore.PG.Tests/Metadata/NpgsqlBuilderExtensionsTest.cs +++ b/test/EFCore.PG.Tests/Metadata/NpgsqlBuilderExtensionsTest.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Internal; using Npgsql.EntityFrameworkCore.PostgreSQL.TestUtilities; using Xunit; @@ -62,6 +63,100 @@ public void Can_set_identity_sequence_options_on_property() Assert.Null(model.FindSequence(NpgsqlModelExtensions.DefaultHiLoSequenceName)); } + [Fact] + public void Is_partitioned_string_params() + { + var modelBuilder = CreateConventionModelBuilder(); + + modelBuilder.Entity() + .ToTable("orders", "my_schema") + .IsPartitioned(TablePartitioningType.Range, "CustomerId", "Date"); + + var entityType = modelBuilder.Model.FindEntityType(typeof(Order)); + + var tablePartitioning = entityType.GetAnnotation(NpgsqlAnnotationNames.TablePartitioning)?.Value as TablePartitioning; + Assert.NotNull(tablePartitioning); + Assert.Equal(TablePartitioningType.Range, tablePartitioning.Type); + Assert.Equal(2, tablePartitioning.PartitionKeyProperties.Length); + Assert.Equal("CustomerId", tablePartitioning.PartitionKeyProperties[0].Name); + Assert.Equal("Date", tablePartitioning.PartitionKeyProperties[1].Name); + } + + [Fact] + public void Is_partitioned_string_list() + { + var modelBuilder = CreateConventionModelBuilder(); + + modelBuilder.Entity() + .ToTable("orders", "my_schema") + .IsPartitioned(TablePartitioningType.Range, new List { "CustomerId", "Date" }); + + var entityType = modelBuilder.Model.FindEntityType(typeof(Order)); + + var tablePartitioning = entityType.GetAnnotation(NpgsqlAnnotationNames.TablePartitioning)?.Value as TablePartitioning; + Assert.NotNull(tablePartitioning); + Assert.Equal(TablePartitioningType.Range, tablePartitioning.Type); + Assert.Equal(2, tablePartitioning.PartitionKeyProperties.Length); + Assert.Equal("CustomerId", tablePartitioning.PartitionKeyProperties[0].Name); + Assert.Equal("Date", tablePartitioning.PartitionKeyProperties[1].Name); + } + + [Fact] + public void Is_partitioned_properties_expression() + { + var modelBuilder = CreateConventionModelBuilder(); + + modelBuilder.Entity() + .ToTable("orders", "my_schema") + .IsPartitioned(TablePartitioningType.Range, x => new { x.CustomerId, x.Date }); + + var entityType = modelBuilder.Model.FindEntityType(typeof(Order)); + + var tablePartitioning = entityType.GetAnnotation(NpgsqlAnnotationNames.TablePartitioning)?.Value as TablePartitioning; + Assert.NotNull(tablePartitioning); + Assert.Equal(TablePartitioningType.Range, tablePartitioning.Type); + Assert.Equal(2, tablePartitioning.PartitionKeyProperties.Length); + Assert.Equal("CustomerId", tablePartitioning.PartitionKeyProperties[0].Name); + Assert.Equal("Date", tablePartitioning.PartitionKeyProperties[1].Name); + } + + [Fact] + public void Is_partitioned_property_expression() + { + var modelBuilder = CreateConventionModelBuilder(); + + modelBuilder.Entity() + .ToTable("orders", "my_schema") + .IsPartitioned(TablePartitioningType.Range, x => x.Date); + + var entityType = modelBuilder.Model.FindEntityType(typeof(Order)); + + var tablePartitioning = entityType.GetAnnotation(NpgsqlAnnotationNames.TablePartitioning)?.Value as TablePartitioning; + Assert.NotNull(tablePartitioning); + Assert.Equal(TablePartitioningType.Range, tablePartitioning.Type); + Assert.Single(tablePartitioning.PartitionKeyProperties); + Assert.Equal("Date", tablePartitioning.PartitionKeyProperties[0].Name); + } + + [Fact] + public void Is_partitioned_null_schema() + { + var modelBuilder = CreateConventionModelBuilder(); + + modelBuilder.Entity() + .ToTable("orders") + .IsPartitioned(TablePartitioningType.Range, x => new { x.CustomerId, x.Date }); + + var entityType = modelBuilder.Model.FindEntityType(typeof(Order)); + + var tablePartitioning = entityType.GetAnnotation(NpgsqlAnnotationNames.TablePartitioning)?.Value as TablePartitioning; + Assert.NotNull(tablePartitioning); + Assert.Equal(TablePartitioningType.Range, tablePartitioning.Type); + Assert.Equal(2, tablePartitioning.PartitionKeyProperties.Length); + Assert.Equal("CustomerId", tablePartitioning.PartitionKeyProperties[0].Name); + Assert.Equal("Date", tablePartitioning.PartitionKeyProperties[1].Name); + } + protected virtual ModelBuilder CreateConventionModelBuilder() => NpgsqlTestHelpers.Instance.CreateConventionBuilder(); @@ -78,6 +173,9 @@ private class Order public int OrderId { get; set; } public int CustomerId { get; set; } + + public DateTime Date { get; set; } + public Customer Customer { get; set; } public OrderDetails Details { get; set; }