Skip to content

Commit d066d30

Browse files
Add PostgreSQL expression index support and enhance complex index handling
- Introduced expression index functionality for PostgreSQL, including migration SQL generation for `CREATE INDEX … ((expr))`. - Added `ResolvedIndexPart` for detailed index part resolution, supporting both columns and SQL expressions. - Enhanced `IndexDescriptor` and related builders to handle mixed column and expression parts. - Introduced `IndexPartsSerializer` for serializing index parts. - Exposed `HasExpressionIndex` API for defining functional indexes with advanced options (e.g., unique, filters, include properties). - Refactored PostgreSQL index option methods to support both complex-property and expression indexes. - Updated README.md with PostgreSQL-specific instructions and usage examples. - Incremented package version to 3.0.0.
1 parent e9b43b6 commit d066d30

22 files changed

Lines changed: 847 additions & 91 deletions

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<Project>
22
<PropertyGroup>
3-
<Version>2.0.5</Version>
3+
<Version>3.0.0</Version>
44
<Authors>CaffeinatedCoder</Authors>
55
<PackageLicenseExpression>MIT</PackageLicenseExpression>
66
<GenerateDocumentationFile>true</GenerateDocumentationFile>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
namespace EFCore.ComplexIndexes.PostgreSQL;
2+
3+
/// <summary>
4+
/// Builds a PostgreSQL expression index: an ordered list of parts, each a verbatim SQL fragment.
5+
/// Provider-specific options (e.g. <c>UseGin</c>) are available as extension methods via
6+
/// <see cref="IIndexAnnotationBuilder"/>.
7+
/// </summary>
8+
/// <remarks>
9+
/// Every part is supplied as raw SQL and emitted verbatim — no property-to-column resolution and
10+
/// no identifier quoting.
11+
/// </remarks>
12+
public sealed class ExpressionIndexBuilder : IIndexAnnotationBuilder
13+
{
14+
private readonly List<IndexPartDefinition> _parts = [];
15+
16+
internal Dictionary<string, object?> Annotations { get; } = new();
17+
18+
internal IReadOnlyList<IndexPartDefinition> Parts => _parts;
19+
20+
Dictionary<string, object?> IIndexAnnotationBuilder.Annotations => Annotations;
21+
22+
/// <summary>Adds a verbatim SQL fragment as the next part of the index's column list.</summary>
23+
public ExpressionIndexBuilder Expression(string sql)
24+
{
25+
ArgumentException.ThrowIfNullOrWhiteSpace(sql);
26+
_parts.Add(new IndexPartDefinition { Expression = sql });
27+
return this;
28+
}
29+
30+
/// <summary>Adds a part from an <see cref="IIndexExpression"/>, resolving it to its SQL fragment.</summary>
31+
public ExpressionIndexBuilder Expression(IIndexExpression expression)
32+
{
33+
ArgumentNullException.ThrowIfNull(expression);
34+
return Expression(expression.ToSql());
35+
}
36+
37+
/// <summary>Marks the index as unique.</summary>
38+
public ExpressionIndexBuilder IsUnique(bool unique = true)
39+
{
40+
Annotations[ComplexIndexAnnotations.IsUnique] = unique;
41+
return this;
42+
}
43+
44+
/// <summary>Applies a SQL filter (partial index) expression.</summary>
45+
public ExpressionIndexBuilder HasFilter(string filter)
46+
{
47+
Annotations[ComplexIndexAnnotations.Filter] = filter;
48+
return this;
49+
}
50+
51+
/// <summary>Sets a custom name for the index.</summary>
52+
public ExpressionIndexBuilder HasName(string name)
53+
{
54+
Annotations[ComplexIndexAnnotations.IndexName] = name;
55+
return this;
56+
}
57+
}
Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,54 @@
11
namespace EFCore.ComplexIndexes.PostgreSQL;
22

33
/// <summary>
4-
/// PostgreSQL-specific extension methods for <see cref="ComplexIndexBuilder"/>.
4+
/// PostgreSQL-specific index options. These work on any index option builder
5+
/// (<see cref="ComplexIndexBuilder"/> for complex-property indexes and
6+
/// <see cref="ExpressionIndexBuilder"/> for expression indexes) via <see cref="IIndexAnnotationBuilder"/>.
57
/// </summary>
68
public static class NpgsqlComplexIndexBuilderExtensions
79
{
810
/// <summary>Creates a GIN index — ideal for full-text search, JSONB, and array columns.</summary>
9-
public static ComplexIndexBuilder UseGin(this ComplexIndexBuilder builder)
10-
=> builder.HasAnnotation(NpgsqlAnnotations.IndexMethod, "gin");
11+
public static TBuilder UseGin<TBuilder>(this TBuilder builder) where TBuilder : IIndexAnnotationBuilder
12+
=> builder.Set(NpgsqlAnnotations.IndexMethod, "gin");
1113

1214
/// <summary>Creates a GiST index — ideal for geometric, range, and full-text search columns.</summary>
13-
public static ComplexIndexBuilder UseGist(this ComplexIndexBuilder builder)
14-
=> builder.HasAnnotation(NpgsqlAnnotations.IndexMethod, "gist");
15+
public static TBuilder UseGist<TBuilder>(this TBuilder builder) where TBuilder : IIndexAnnotationBuilder
16+
=> builder.Set(NpgsqlAnnotations.IndexMethod, "gist");
1517

1618
/// <summary>Creates a BRIN index — ideal for large, naturally ordered tables (e.g., time-series data).</summary>
17-
public static ComplexIndexBuilder UseBrin(this ComplexIndexBuilder builder)
18-
=> builder.HasAnnotation(NpgsqlAnnotations.IndexMethod, "brin");
19+
public static TBuilder UseBrin<TBuilder>(this TBuilder builder) where TBuilder : IIndexAnnotationBuilder
20+
=> builder.Set(NpgsqlAnnotations.IndexMethod, "brin");
1921

2022
/// <summary>Creates a hash index — useful for simple equality comparisons.</summary>
21-
public static ComplexIndexBuilder UseHash(this ComplexIndexBuilder builder)
22-
=> builder.HasAnnotation(NpgsqlAnnotations.IndexMethod, "hash");
23+
public static TBuilder UseHash<TBuilder>(this TBuilder builder) where TBuilder : IIndexAnnotationBuilder
24+
=> builder.Set(NpgsqlAnnotations.IndexMethod, "hash");
2325

2426
/// <summary>Creates an SP-GiST index — ideal for partitioned search trees (e.g., IP addresses, phone numbers).</summary>
25-
public static ComplexIndexBuilder UseSpGist(this ComplexIndexBuilder builder)
26-
=> builder.HasAnnotation(NpgsqlAnnotations.IndexMethod, "spgist");
27+
public static TBuilder UseSpGist<TBuilder>(this TBuilder builder) where TBuilder : IIndexAnnotationBuilder
28+
=> builder.Set(NpgsqlAnnotations.IndexMethod, "spgist");
2729

28-
/// <summary>
29-
/// Specifies per-column operator classes for the index (e.g., <c>jsonb_path_ops</c>).
30-
/// </summary>
31-
public static ComplexIndexBuilder HasOperators(this ComplexIndexBuilder builder, params string[] operators)
32-
=> builder.HasAnnotation(NpgsqlAnnotations.IndexOperators, operators);
30+
/// <summary>Specifies per-column operator classes for the index (e.g., <c>jsonb_path_ops</c>).</summary>
31+
public static TBuilder HasOperators<TBuilder>(this TBuilder builder, params string[] operators) where TBuilder : IIndexAnnotationBuilder
32+
=> builder.Set(NpgsqlAnnotations.IndexOperators, operators);
3333

34-
/// <summary>
35-
/// Specifies non-key columns to include in the index (covering index).
36-
/// </summary>
37-
public static ComplexIndexBuilder IncludeProperties(this ComplexIndexBuilder builder, params string[] properties)
38-
=> builder.HasAnnotation(NpgsqlAnnotations.IndexInclude, properties);
34+
/// <summary>Specifies non-key columns to include in the index (covering index).</summary>
35+
public static TBuilder IncludeProperties<TBuilder>(this TBuilder builder, params string[] properties) where TBuilder : IIndexAnnotationBuilder
36+
=> builder.Set(NpgsqlAnnotations.IndexInclude, properties);
3937

40-
/// <summary>
41-
/// Specifies that the index should be created concurrently (non-blocking).
42-
/// </summary>
43-
public static ComplexIndexBuilder IsCreatedConcurrently(this ComplexIndexBuilder builder, bool concurrent = true)
44-
=> builder.HasAnnotation(NpgsqlAnnotations.CreatedConcurrently, concurrent);
38+
/// <summary>Specifies that the index should be created concurrently (non-blocking).</summary>
39+
public static TBuilder IsCreatedConcurrently<TBuilder>(this TBuilder builder, bool concurrent = true) where TBuilder : IIndexAnnotationBuilder
40+
=> builder.Set(NpgsqlAnnotations.CreatedConcurrently, concurrent);
4541

4642
/// <summary>
4743
/// Specifies whether null values are considered distinct in a unique index.
4844
/// When <c>false</c>, multiple nulls violate the uniqueness constraint.
4945
/// </summary>
50-
public static ComplexIndexBuilder AreNullsDistinct(this ComplexIndexBuilder builder, bool nullsDistinct = true)
51-
=> builder.HasAnnotation(NpgsqlAnnotations.NullsDistinct, nullsDistinct);
52-
}
46+
public static TBuilder AreNullsDistinct<TBuilder>(this TBuilder builder, bool nullsDistinct = true) where TBuilder : IIndexAnnotationBuilder
47+
=> builder.Set(NpgsqlAnnotations.NullsDistinct, nullsDistinct);
48+
49+
private static TBuilder Set<TBuilder>(this TBuilder builder, string key, object? value) where TBuilder : IIndexAnnotationBuilder
50+
{
51+
builder.Annotations[key] = value;
52+
return builder;
53+
}
54+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using Microsoft.EntityFrameworkCore.Migrations;
3+
4+
namespace EFCore.ComplexIndexes.PostgreSQL;
5+
6+
/// <summary>
7+
/// Runtime wiring for expression indexes on PostgreSQL.
8+
/// </summary>
9+
public static class NpgsqlComplexIndexDbContextOptionsExtensions
10+
{
11+
/// <summary>
12+
/// Replaces the migrations SQL generator with one that can render expression indexes
13+
/// defined via <c>HasExpressionIndex</c>. Call this after <c>UseNpgsql(...)</c>:
14+
/// <code>options.UseNpgsql(connectionString).UseNpgsqlComplexIndexes();</code>
15+
/// </summary>
16+
public static DbContextOptionsBuilder UseNpgsqlComplexIndexes(this DbContextOptionsBuilder optionsBuilder)
17+
=> optionsBuilder.ReplaceService<IMigrationsSqlGenerator, NpgsqlComplexIndexSqlGenerator>();
18+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
using System.Collections;
2+
using Microsoft.EntityFrameworkCore.Metadata;
3+
using Microsoft.EntityFrameworkCore.Migrations;
4+
using Microsoft.EntityFrameworkCore.Migrations.Operations;
5+
using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal;
6+
using Npgsql.EntityFrameworkCore.PostgreSQL.Migrations;
7+
8+
namespace EFCore.ComplexIndexes.PostgreSQL;
9+
10+
#pragma warning disable EF1001
11+
12+
/// <summary>
13+
/// Renders <c>CREATE INDEX</c> for expression indexes — those whose column list contains one or
14+
/// more verbatim SQL expressions. Npgsql's base generator builds the column list from
15+
/// <c>operation.Columns</c> (quoted identifiers) with no hook to inject an expression, so when the
16+
/// <see cref="ComplexIndexAnnotations.IndexParts"/> annotation is present this generator renders the
17+
/// statement itself; all other operations delegate to the base Npgsql generator unchanged.
18+
/// </summary>
19+
public class NpgsqlComplexIndexSqlGenerator(
20+
MigrationsSqlGeneratorDependencies dependencies,
21+
INpgsqlSingletonOptions npgsqlSingletonOptions
22+
) : NpgsqlMigrationsSqlGenerator(dependencies, npgsqlSingletonOptions)
23+
{
24+
protected override void Generate(
25+
CreateIndexOperation operation,
26+
IModel? model,
27+
MigrationCommandListBuilder builder,
28+
bool terminate = true
29+
)
30+
{
31+
if (operation[ComplexIndexAnnotations.IndexParts] is not string partsJson)
32+
{
33+
base.Generate(operation, model, builder, terminate);
34+
return;
35+
}
36+
37+
var parts = IndexPartsSerializer.Deserialize(partsJson);
38+
var sqlHelper = Dependencies.SqlGenerationHelper;
39+
40+
var concurrently = operation[NpgsqlAnnotations.CreatedConcurrently] is true;
41+
var method = operation[NpgsqlAnnotations.IndexMethod] as string;
42+
var operators = ToStringList(operation[NpgsqlAnnotations.IndexOperators]);
43+
var include = ToStringList(operation[NpgsqlAnnotations.IndexInclude]);
44+
var nullsDistinct = operation[NpgsqlAnnotations.NullsDistinct];
45+
46+
builder.Append("CREATE ");
47+
if (operation.IsUnique)
48+
builder.Append("UNIQUE ");
49+
builder.Append("INDEX ");
50+
if (concurrently)
51+
builder.Append("CONCURRENTLY ");
52+
53+
builder
54+
.Append(sqlHelper.DelimitIdentifier(operation.Name))
55+
.Append(" ON ")
56+
.Append(sqlHelper.DelimitIdentifier(operation.Table, operation.Schema));
57+
58+
if (!string.IsNullOrEmpty(method))
59+
builder.Append(" USING ").Append(method);
60+
61+
builder.Append(" (");
62+
for (var i = 0; i < parts.Count; i++)
63+
{
64+
if (i > 0)
65+
builder.Append(", ");
66+
67+
builder.Append(parts[i].IsExpression
68+
? $"({parts[i].Value})"
69+
: sqlHelper.DelimitIdentifier(parts[i].Value));
70+
71+
if (operators is not null && i < operators.Count && !string.IsNullOrEmpty(operators[i]))
72+
builder.Append(" ").Append(operators[i]);
73+
}
74+
75+
builder.Append(")");
76+
77+
if (include is { Count: > 0 })
78+
{
79+
builder.Append(" INCLUDE (");
80+
builder.Append(string.Join(", ", include.Select(sqlHelper.DelimitIdentifier)));
81+
builder.Append(")");
82+
}
83+
84+
// Default in PostgreSQL is NULLS DISTINCT; only the non-default needs emitting.
85+
if (nullsDistinct is false)
86+
builder.Append(" NULLS NOT DISTINCT");
87+
88+
if (!string.IsNullOrEmpty(operation.Filter))
89+
builder.Append(" WHERE ").Append(operation.Filter);
90+
91+
if (terminate)
92+
{
93+
builder.AppendLine(sqlHelper.StatementTerminator);
94+
EndStatement(builder, suppressTransaction: concurrently);
95+
}
96+
}
97+
98+
private static IReadOnlyList<string>? ToStringList(object? value) =>
99+
value switch
100+
{
101+
null => null,
102+
string[] s => s,
103+
IEnumerable e => [.. e.Cast<object?>().Select(o => o?.ToString() ?? string.Empty)],
104+
_ => null
105+
};
106+
}
107+
108+
#pragma warning restore EF1001
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
using Microsoft.EntityFrameworkCore.Metadata.Builders;
2+
3+
namespace EFCore.ComplexIndexes.PostgreSQL;
4+
5+
/// <summary>
6+
/// PostgreSQL expression-index API. Expression indexes are provider-specific (PostgreSQL renders
7+
/// <c>CREATE INDEX … ((expr))</c> natively; other providers model the same intent differently, e.g.
8+
/// SQL Server via persisted computed columns), so the entry point lives in the provider package
9+
/// rather than the provider-agnostic core.
10+
/// </summary>
11+
public static class NpgsqlExpressionIndexExtensions
12+
{
13+
/// <summary>
14+
/// Configures an index whose single entry is a verbatim SQL expression (e.g. <c>lower(name)</c>).
15+
/// The expression is emitted exactly as given — it must reference real column names.
16+
/// Requires runtime wiring via <c>UseNpgsqlComplexIndexes()</c>.
17+
/// </summary>
18+
public static EntityTypeBuilder<TEntity> HasExpressionIndex<TEntity>(
19+
this EntityTypeBuilder<TEntity> builder,
20+
string expression,
21+
bool isUnique = false,
22+
string? filter = null,
23+
string? indexName = null
24+
)
25+
where TEntity : class
26+
{
27+
ArgumentException.ThrowIfNullOrWhiteSpace(expression);
28+
29+
var definition = new CompositeIndexDefinition
30+
{
31+
Parts = [new IndexPartDefinition { Expression = expression }],
32+
IsUnique = isUnique,
33+
Filter = filter,
34+
IndexName = indexName
35+
};
36+
37+
ComplexIndexStorage.AddOrReplace(builder, definition);
38+
return builder;
39+
}
40+
41+
/// <summary>
42+
/// Configures an index from an ordered list of verbatim SQL parts using a builder callback.
43+
/// Provider-specific options (GIN, INCLUDE, …) are available as extension methods on
44+
/// <see cref="ExpressionIndexBuilder"/>. Requires runtime wiring via <c>UseNpgsqlComplexIndexes()</c>.
45+
/// </summary>
46+
public static EntityTypeBuilder<TEntity> HasExpressionIndex<TEntity>(
47+
this EntityTypeBuilder<TEntity> builder,
48+
Action<ExpressionIndexBuilder> configure
49+
)
50+
where TEntity : class
51+
{
52+
ArgumentNullException.ThrowIfNull(configure);
53+
54+
var indexBuilder = new ExpressionIndexBuilder();
55+
configure(indexBuilder);
56+
57+
if (indexBuilder.Parts.Count == 0)
58+
throw new ArgumentException(
59+
"An expression index requires at least one part. Call Expression(...) at least once."
60+
);
61+
62+
var annotations = indexBuilder.Annotations;
63+
64+
var providerAnnotations = annotations
65+
.Where(kv => kv.Key != ComplexIndexAnnotations.IsUnique
66+
&& kv.Key != ComplexIndexAnnotations.Filter
67+
&& kv.Key != ComplexIndexAnnotations.IndexName)
68+
.ToDictionary(kv => kv.Key, kv => kv.Value);
69+
70+
var definition = new CompositeIndexDefinition
71+
{
72+
Parts = [.. indexBuilder.Parts],
73+
IsUnique = annotations.TryGetValue(ComplexIndexAnnotations.IsUnique, out var u) && u is true,
74+
Filter = annotations.GetValueOrDefault(ComplexIndexAnnotations.Filter) as string,
75+
IndexName = annotations.GetValueOrDefault(ComplexIndexAnnotations.IndexName) as string,
76+
ProviderAnnotations = providerAnnotations.Count > 0 ? providerAnnotations : null
77+
};
78+
79+
ComplexIndexStorage.AddOrReplace(builder, definition);
80+
return builder;
81+
}
82+
}

0 commit comments

Comments
 (0)