Skip to content

Commit d7abdf3

Browse files
committed
feat: support the paradedb index access method (paradedb/paradedb#5706)
1 parent 8badd9a commit d7abdf3

8 files changed

Lines changed: 209 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format
44

55
## [Unreleased]
66

7+
### Changed
8+
9+
- BM25 index DDL now emits `USING paradedb`, the primary index access method
10+
name in pg_search 0.25.0+ (unreleased). Use `HasMethod("bm25")` to keep the
11+
backwards-compatible `bm25` alias on released ParadeDB versions.
12+
713
## [0.1.2] - 2026-07-14
814

915
## Changed

examples/Shared/ExampleSetup.cs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,25 +39,41 @@ public static string ConnectionString
3939
}
4040
}
4141

42+
// Released ParadeDB versions (<= 0.24.x) only have the `bm25` index access
43+
// method; pg_search 0.25.0+ adds `paradedb` as the primary name
44+
public static async Task<string> GetIndexAccessMethodAsync(DbContext db)
45+
{
46+
var hasParadeDbAm = await db
47+
.Database.SqlQueryRaw<bool>(
48+
"""
49+
SELECT EXISTS (SELECT 1 FROM pg_am WHERE amname = 'paradedb') AS "Value"
50+
"""
51+
)
52+
.SingleAsync();
53+
54+
return hasParadeDbAm ? "paradedb" : "bm25";
55+
}
56+
4257
public static async Task SetupMockItemsAsync(DbContext db)
4358
{
4459
await db.Database.ExecuteSqlRawAsync("CREATE EXTENSION IF NOT EXISTS pg_search");
4560
await db.Database.ExecuteSqlRawAsync(
4661
"CALL paradedb.create_bm25_test_table(schema_name => 'public', table_name => 'mock_items')"
4762
);
4863
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS mock_items_bm25_idx");
64+
var accessMethod = await GetIndexAccessMethodAsync(db);
4965
await db.Database.ExecuteSqlRawAsync(
5066
"""
5167
CREATE INDEX mock_items_bm25_idx ON mock_items
52-
USING bm25 (
68+
USING paradedb (
5369
id,
5470
description,
5571
rating,
5672
(category::pdb.literal('alias=category')),
5773
metadata
5874
)
5975
WITH (key_field='id', json_fields='{{"metadata":{{"fast":true}}}}');
60-
"""
76+
""".Replace("USING paradedb", $"USING {accessMethod}")
6177
);
6278
}
6379

@@ -84,17 +100,18 @@ INSERT INTO autocomplete_items (id, description, category, rating, in_stock, cre
84100
FROM mock_items;
85101
"""
86102
);
103+
var accessMethod = await GetIndexAccessMethodAsync(db);
87104
await db.Database.ExecuteSqlRawAsync(
88105
"""
89106
CREATE INDEX autocomplete_items_idx ON autocomplete_items
90-
USING bm25 (
107+
USING paradedb (
91108
id,
92109
(description::pdb.unicode_words),
93110
(description::pdb.ngram(3,8,'alias=description_ngram')),
94111
(category::pdb.literal('alias=category'))
95112
)
96113
WITH (key_field='id');
97-
"""
114+
""".Replace("USING paradedb", $"USING {accessMethod}")
98115
);
99116
}
100117

src/Extensions/ParadeDbIndexBuilderExtensions.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,21 @@ public Bm25IndexBuilder<TEntity> IsCreatedConcurrently(bool createdConcurrently
131131
return this;
132132
}
133133

134+
public Bm25IndexBuilder<TEntity> HasMethod(string method)
135+
{
136+
if (method is not ("paradedb" or "bm25"))
137+
{
138+
throw new ArgumentException(
139+
$"The index access method must be 'paradedb' or 'bm25', but '{method}' was given.",
140+
nameof(method)
141+
);
142+
}
143+
144+
_indexBuilder.HasAnnotation(ParadeDbAnnotationNames.Bm25IndexMethod, method);
145+
146+
return this;
147+
}
148+
134149
public Bm25IndexBuilder<TEntity> HasSearchTokenizer(Tokenizer tokenizer)
135150
{
136151
_indexBuilder.HasAnnotation(

src/Internal/Metadata/ParadeDbAnnotationNames.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ namespace ParadeDB.EntityFrameworkCore.Internal.Metadata;
22

33
internal static class ParadeDbAnnotationNames
44
{
5+
public const string Bm25IndexMethod = "ParadeDB:Bm25IndexMethod";
56
public const string Bm25KeyProperty = "ParadeDB:Bm25KeyProperty";
67
public const string Bm25FieldProperties = "ParadeDB:Bm25FieldProperties";
78
public const string Bm25FieldKinds = "ParadeDB:Bm25FieldKinds";

src/Internal/Metadata/ParadeDbAnnotationProvider.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,14 @@ is string searchTokenizer
8484
searchTokenizer
8585
);
8686
}
87+
88+
if (
89+
mappedIndex.FindAnnotation(ParadeDbAnnotationNames.Bm25IndexMethod)?.Value
90+
is string indexMethod
91+
)
92+
{
93+
yield return new Annotation(ParadeDbAnnotationNames.Bm25IndexMethod, indexMethod);
94+
}
8795
}
8896

8997
private static string RenderField(

src/Internal/Migrations/ParadeDbMigrationsSqlGenerator.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,19 @@ is not string keyField
4040
operation.FindAnnotation(NpgsqlAnnotationNames.CreatedConcurrently)?.Value is true;
4141
var searchTokenizer =
4242
operation.FindAnnotation(ParadeDbAnnotationNames.Bm25SearchTokenizer)?.Value as string;
43+
var indexMethod =
44+
operation.FindAnnotation(ParadeDbAnnotationNames.Bm25IndexMethod)?.Value as string
45+
?? "paradedb";
4346

4447
builder
4548
.Append("CREATE INDEX ")
4649
.Append(createdConcurrently ? "CONCURRENTLY " : "")
4750
.Append(helper.DelimitIdentifier(operation.Name))
4851
.Append(" ON ")
4952
.Append(helper.DelimitIdentifier(operation.Table, operation.Schema))
50-
.Append(" USING bm25 (")
53+
.Append(" USING ")
54+
.Append(indexMethod)
55+
.Append(" (")
5156
.Append(string.Join(", ", fields))
5257
.Append(") WITH (key_field = ")
5358
.Append(stringMapping.GenerateSqlLiteral(keyField));

tests/IndexingTest.cs

Lines changed: 136 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,11 @@ rating int
106106

107107
sql.ShouldBe(
108108
"""
109-
CREATE INDEX indexing_items_idx ON indexing_items USING bm25 (id, (description::pdb.ngram(3,3,'positions=true')), ((metadata ->> 'color')::pdb.literal('alias=metadata_color')), (rating::pdb.alias('my_rating_alias')), ((rating + 1)::pdb.alias('escape'' me'))) WITH (key_field = 'id', search_tokenizer = 'simple(lowercase=false)') WHERE rating > 0;
109+
CREATE INDEX indexing_items_idx ON indexing_items USING paradedb (id, (description::pdb.ngram(3,3,'positions=true')), ((metadata ->> 'color')::pdb.literal('alias=metadata_color')), (rating::pdb.alias('my_rating_alias')), ((rating + 1)::pdb.alias('escape'' me'))) WITH (key_field = 'id', search_tokenizer = 'simple(lowercase=false)') WHERE rating > 0;
110110
111111
"""
112112
);
113-
await context.Database.ExecuteSqlRawAsync(sql);
113+
await ExecuteCreateIndexSqlAsync(context, sql);
114114
}
115115

116116
private sealed class SimpleIndexContext(DbContextOptions<SimpleIndexContext> options)
@@ -155,11 +155,11 @@ rating int
155155

156156
sql.ShouldBe(
157157
"""
158-
CREATE INDEX CONCURRENTLY indexing_items_idx ON indexing_items USING bm25 (id, description, metadata) WITH (key_field = 'id');
158+
CREATE INDEX CONCURRENTLY indexing_items_idx ON indexing_items USING paradedb (id, description, metadata) WITH (key_field = 'id');
159159
160160
"""
161161
);
162-
await context.Database.ExecuteSqlRawAsync(sql);
162+
await ExecuteCreateIndexSqlAsync(context, sql);
163163
}
164164

165165
private sealed class ArrayExpressionIndexContext(
@@ -215,11 +215,11 @@ tags varchar(255)[]
215215

216216
sql.ShouldBe(
217217
"""
218-
CREATE INDEX indexing_items_idx ON indexing_items USING bm25 (id, categories, (tags::pdb.literal), ((description || ' ' || category)::pdb.simple('alias=description_concat')), (description::pdb.literal), (description::pdb.simple('alias=description_simple'))) WITH (key_field = 'id');
218+
CREATE INDEX indexing_items_idx ON indexing_items USING paradedb (id, categories, (tags::pdb.literal), ((description || ' ' || category)::pdb.simple('alias=description_concat')), (description::pdb.literal), (description::pdb.simple('alias=description_simple'))) WITH (key_field = 'id');
219219
220220
"""
221221
);
222-
await context.Database.ExecuteSqlRawAsync(sql);
222+
await ExecuteCreateIndexSqlAsync(context, sql);
223223
}
224224

225225
public static IEnumerable<(
@@ -317,11 +317,140 @@ description text
317317
var sql = GenerateSearchTokenizerCreateIndexSql(tokenizer);
318318

319319
sql.ShouldBe(
320-
$"CREATE INDEX indexing_items_idx ON indexing_items USING bm25 (id, description) WITH (key_field = 'id', search_tokenizer = '{expectedSearchTokenizer}');\n"
320+
$"CREATE INDEX indexing_items_idx ON indexing_items USING paradedb (id, description) WITH (key_field = 'id', search_tokenizer = '{expectedSearchTokenizer}');\n"
321+
);
322+
await ExecuteCreateIndexSqlAsync(context, sql);
323+
}
324+
325+
private sealed class Bm25MethodIndexContext(DbContextOptions<Bm25MethodIndexContext> options)
326+
: DbContext(options)
327+
{
328+
protected override void OnModelCreating(ModelBuilder modelBuilder)
329+
{
330+
modelBuilder.Entity<IndexingItem>(entity =>
331+
{
332+
entity.ToTable("indexing_items");
333+
entity.Property(e => e.Id).HasColumnName("id");
334+
entity.Property(e => e.Description).HasColumnName("description");
335+
336+
entity
337+
.HasBm25Index("indexing_items_idx", e => e.Id)
338+
.HasMethod("bm25")
339+
.HasField(e => e.Description);
340+
});
341+
}
342+
}
343+
344+
[Test]
345+
public async Task Bm25Index_WithExplicitBm25Method()
346+
{
347+
await using var context = DbFixture.CreateContext();
348+
await context.Database.OpenConnectionAsync();
349+
await context.Database.ExecuteSqlRawAsync(
350+
"""
351+
CREATE TEMP TABLE indexing_items (
352+
id int PRIMARY KEY,
353+
description text
354+
);
355+
"""
356+
);
357+
358+
var sql = GenerateCreateIndexSql<Bm25MethodIndexContext, IndexingItem>();
359+
360+
sql.ShouldBe(
361+
"""
362+
CREATE INDEX indexing_items_idx ON indexing_items USING bm25 (id, description) WITH (key_field = 'id');
363+
364+
"""
365+
);
366+
await context.Database.ExecuteSqlRawAsync(sql);
367+
}
368+
369+
private sealed class ParadeDbMethodIndexContext(
370+
DbContextOptions<ParadeDbMethodIndexContext> options
371+
) : DbContext(options)
372+
{
373+
protected override void OnModelCreating(ModelBuilder modelBuilder)
374+
{
375+
modelBuilder.Entity<IndexingItem>(entity =>
376+
{
377+
entity.ToTable("indexing_items");
378+
entity.Property(e => e.Id).HasColumnName("id");
379+
entity.Property(e => e.Description).HasColumnName("description");
380+
381+
entity
382+
.HasBm25Index("indexing_items_idx", e => e.Id)
383+
.HasMethod("paradedb")
384+
.HasField(e => e.Description);
385+
});
386+
}
387+
}
388+
389+
[Test]
390+
public async Task Bm25Index_WithExplicitParadeDbMethod()
391+
{
392+
var sql = GenerateCreateIndexSql<ParadeDbMethodIndexContext, IndexingItem>();
393+
394+
sql.ShouldBe(
395+
"""
396+
CREATE INDEX indexing_items_idx ON indexing_items USING paradedb (id, description) WITH (key_field = 'id');
397+
398+
"""
399+
);
400+
401+
Skip.When(
402+
!DbFixture.SupportsParadeDbAm,
403+
"This ParadeDB version does not have the 'paradedb' index access method (requires pg_search 0.25.0+, unreleased)"
404+
);
405+
406+
await using var context = DbFixture.CreateContext();
407+
await context.Database.OpenConnectionAsync();
408+
await context.Database.ExecuteSqlRawAsync(
409+
"""
410+
CREATE TEMP TABLE indexing_items (
411+
id int PRIMARY KEY,
412+
description text
413+
);
414+
"""
321415
);
322416
await context.Database.ExecuteSqlRawAsync(sql);
323417
}
324418

419+
private sealed class InvalidMethodIndexContext(
420+
DbContextOptions<InvalidMethodIndexContext> options
421+
) : DbContext(options)
422+
{
423+
protected override void OnModelCreating(ModelBuilder modelBuilder)
424+
{
425+
modelBuilder.Entity<IndexingItem>(entity =>
426+
{
427+
entity.ToTable("indexing_items");
428+
entity.Property(e => e.Id).HasColumnName("id");
429+
entity.Property(e => e.Description).HasColumnName("description");
430+
431+
entity
432+
.HasBm25Index("indexing_items_idx", e => e.Id)
433+
.HasMethod("gin")
434+
.HasField(e => e.Description);
435+
});
436+
}
437+
}
438+
439+
[Test]
440+
public void Bm25Index_RejectsUnknownMethod()
441+
{
442+
var exception = Should.Throw<ArgumentException>(() =>
443+
GenerateCreateIndexSql<InvalidMethodIndexContext, IndexingItem>()
444+
);
445+
446+
exception.Message.ShouldContain("'paradedb' or 'bm25'");
447+
}
448+
449+
private Task ExecuteCreateIndexSqlAsync(Persistence.TestDbContext context, string sql) =>
450+
context.Database.ExecuteSqlRawAsync(
451+
sql.Replace(" USING paradedb ", $" USING {DbFixture.AccessMethod} ")
452+
);
453+
325454
private static string GenerateCreateIndexSql<TContext, TEntity>()
326455
where TContext : DbContext
327456
{

tests/Persistence/DbFixture.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ public sealed class DbFixture : IAsyncInitializer, IAsyncDisposable
1111

1212
private DbContextOptions<TestDbContext> _options = null!;
1313

14+
public string AccessMethod { get; private set; } = "paradedb";
15+
16+
public bool SupportsParadeDbAm => AccessMethod == "paradedb";
17+
1418
public async Task InitializeAsync()
1519
{
1620
_container = new PostgreSqlBuilder("postgres:18")
@@ -28,6 +32,16 @@ public async Task InitializeAsync()
2832
.Options;
2933

3034
await using var context = new TestDbContext(_options);
35+
AccessMethod = await context
36+
.Database.SqlQueryRaw<bool>(
37+
"""
38+
SELECT EXISTS (SELECT 1 FROM pg_am WHERE amname = 'paradedb') AS "Value"
39+
"""
40+
)
41+
.SingleAsync()
42+
? "paradedb"
43+
: "bm25";
44+
3145
await context.Database.ExecuteSqlRawAsync(
3246
"""
3347
DO $$
@@ -45,7 +59,7 @@ CALL paradedb.create_bm25_test_table(
4559
await context.Database.ExecuteSqlRawAsync(
4660
"""
4761
CREATE INDEX IF NOT EXISTS search_idx ON mock_items
48-
USING bm25 (
62+
USING paradedb (
4963
id,
5064
description,
5165
(description::pdb.simple('alias=description_simple')),
@@ -57,7 +71,7 @@ USING bm25 (
5771
weight_range
5872
)
5973
WITH (key_field='id');
60-
"""
74+
""".Replace("USING paradedb", $"USING {AccessMethod}")
6175
);
6276
}
6377

0 commit comments

Comments
 (0)