Skip to content
This repository was archived by the owner on Mar 29, 2026. It is now read-only.

Commit 065503c

Browse files
sphildrethCopilot
andcommitted
feat: v1.2.0 — GROUP_CONCAT, INSERT INTO...SELECT, IN subquery, filtered indexes, SQL standard compliance
New features: - GROUP_CONCAT and STRING_AGG aggregate functions - INSERT INTO ... SELECT statement support (all 3 execution paths) - IN (subquery) predicate support - printf() scalar function - General filtered/partial index support with complex WHERE clauses SQL standard compliance fixes: - SUM/AVG/MIN/MAX on empty sets return NULL (not 0) - AND/OR implement three-valued NULL logic (FALSE AND NULL = FALSE) - IS TRUE / IS FALSE / IS NOT TRUE / IS NOT FALSE support - INSERT INTO...SELECT now fires AFTER INSERT triggers - SUM preserves integer type for integer inputs Engine fixes: - Float-to-DECIMAL coercion in INSERT/UPDATE - Filtered index predicate evaluator rewritten - Predicate cache defensively initialized per thread - GROUP_CONCAT recognized as aggregate by query planner .NET fixes: - EF Core DECIMAL type mapping respects model precision/scale Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f502b81 commit 065503c

19 files changed

Lines changed: 1182 additions & 5721 deletions

File tree

bindings/dotnet/src/DecentDB.EntityFrameworkCore/Storage/Internal/DecentDBTypeMappingSource.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,13 @@ public DecentDBTypeMappingSource(
129129
var clrType = Nullable.GetUnderlyingType(mappingInfo.ClrType ?? typeof(object)) ?? mappingInfo.ClrType;
130130
if (clrType != null && _clrMappings.TryGetValue(clrType, out var clrMapping))
131131
{
132+
// For decimal types, respect precision/scale from EF Core model configuration
133+
// (e.g. HavePrecision, HasPrecision, or HasColumnType with precision)
134+
if (clrType == typeof(decimal))
135+
{
136+
return CreateDecimalMapping(mappingInfo, mappingInfo.StoreTypeName);
137+
}
138+
132139
return clrMapping;
133140
}
134141

@@ -138,13 +145,68 @@ public DecentDBTypeMappingSource(
138145
var normalized = NormalizeStoreTypeName(storeType);
139146
if (_storeMappings.TryGetValue(normalized, out var storeMapping))
140147
{
148+
// For DECIMAL/NUMERIC store types, respect precision/scale from store type name or mappingInfo
149+
if (normalized is "DECIMAL" or "NUMERIC")
150+
{
151+
return CreateDecimalMapping(mappingInfo, mappingInfo.StoreTypeName ?? storeType);
152+
}
153+
141154
return storeMapping;
142155
}
143156
}
144157

145158
return null;
146159
}
147160

161+
private static DecimalTypeMapping CreateDecimalMapping(
162+
in RelationalTypeMappingInfo mappingInfo,
163+
string? storeTypeName)
164+
{
165+
const int defaultPrecision = 18;
166+
const int defaultScale = 4;
167+
168+
var precision = mappingInfo.Precision;
169+
var scale = mappingInfo.Scale;
170+
171+
// If precision/scale not provided by EF Core model, try parsing from store type name
172+
if (!precision.HasValue && !scale.HasValue && !string.IsNullOrWhiteSpace(storeTypeName))
173+
{
174+
(precision, scale) = ParsePrecisionScale(storeTypeName);
175+
}
176+
177+
var p = precision ?? defaultPrecision;
178+
var s = scale ?? defaultScale;
179+
180+
return new DecimalTypeMapping($"DECIMAL({p},{s})", DbType.Decimal, precision: p, scale: s);
181+
}
182+
183+
private static (int? precision, int? scale) ParsePrecisionScale(string storeTypeName)
184+
{
185+
var openParen = storeTypeName.IndexOf('(');
186+
var closeParen = storeTypeName.IndexOf(')');
187+
if (openParen < 0 || closeParen <= openParen)
188+
{
189+
return (null, null);
190+
}
191+
192+
var inner = storeTypeName.AsSpan()[(openParen + 1)..closeParen];
193+
var commaIdx = inner.IndexOf(',');
194+
195+
if (commaIdx >= 0
196+
&& int.TryParse(inner[..commaIdx].Trim(), out var p)
197+
&& int.TryParse(inner[(commaIdx + 1)..].Trim(), out var s))
198+
{
199+
return (p, s);
200+
}
201+
202+
if (int.TryParse(inner.Trim(), out var pOnly))
203+
{
204+
return (pOnly, null);
205+
}
206+
207+
return (null, null);
208+
}
209+
148210
private static string NormalizeStoreTypeName(string storeTypeName)
149211
{
150212
var idx = storeTypeName.IndexOf('(');
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
using DecentDB.EntityFrameworkCore;
2+
using Microsoft.EntityFrameworkCore;
3+
using Microsoft.EntityFrameworkCore.Infrastructure;
4+
using Microsoft.EntityFrameworkCore.Storage;
5+
using Xunit;
6+
7+
namespace DecentDB.EntityFrameworkCore.Tests;
8+
9+
/// <summary>
10+
/// Verifies that the EF Core provider respects decimal precision and scale
11+
/// from ConfigureConventions, HasPrecision, and HasColumnType.
12+
/// </summary>
13+
public sealed class DecimalPrecisionTests : IDisposable
14+
{
15+
private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"decentdb_prec_{Guid.NewGuid():N}");
16+
17+
public DecimalPrecisionTests()
18+
{
19+
Directory.CreateDirectory(_tempDir);
20+
}
21+
22+
public void Dispose()
23+
{
24+
if (Directory.Exists(_tempDir))
25+
{
26+
Directory.Delete(_tempDir, true);
27+
}
28+
}
29+
30+
[Fact]
31+
public void DefaultDecimalMapping_UsesDefaultPrecisionAndScale()
32+
{
33+
var dbPath = Path.Combine(_tempDir, "default.ddb");
34+
using var context = CreateContext<DefaultDecimalContext>(dbPath);
35+
var mappingSource = context.GetService<IRelationalTypeMappingSource>();
36+
37+
var mapping = (RelationalTypeMapping)mappingSource.FindMapping(typeof(decimal))!;
38+
Assert.Equal("DECIMAL(18,4)", mapping.StoreType);
39+
}
40+
41+
[Fact]
42+
public void ConfigureConventions_HavePrecision_IsRespected()
43+
{
44+
var dbPath = Path.Combine(_tempDir, "conventions.ddb");
45+
using var context = CreateContext<CustomPrecisionConventionContext>(dbPath);
46+
47+
context.Database.EnsureCreated();
48+
49+
context.Items.Add(new PrecisionItem { Value = 123.456789m });
50+
context.SaveChanges();
51+
52+
var item = context.Items.First();
53+
Assert.Equal(123.456789m, item.Value);
54+
}
55+
56+
[Fact]
57+
public void HasPrecision_OnProperty_IsRespected()
58+
{
59+
var dbPath = Path.Combine(_tempDir, "hasprecision.ddb");
60+
using var context = CreateContext<PropertyPrecisionContext>(dbPath);
61+
62+
context.Database.EnsureCreated();
63+
64+
context.Items.Add(new PrecisionItem { Value = 99.12m });
65+
context.SaveChanges();
66+
67+
var item = context.Items.First();
68+
Assert.Equal(99.12m, item.Value);
69+
}
70+
71+
[Fact]
72+
public void HasColumnType_WithPrecisionScale_IsRespected()
73+
{
74+
var dbPath = Path.Combine(_tempDir, "columntype.ddb");
75+
using var context = CreateContext<ColumnTypeContext>(dbPath);
76+
77+
context.Database.EnsureCreated();
78+
79+
context.Items.Add(new PrecisionItem { Value = 12345.67m });
80+
context.SaveChanges();
81+
82+
var item = context.Items.First();
83+
Assert.Equal(12345.67m, item.Value);
84+
}
85+
86+
[Fact]
87+
public void NullableDecimal_WithPrecision_IsRespected()
88+
{
89+
var dbPath = Path.Combine(_tempDir, "nullable.ddb");
90+
using var context = CreateContext<NullableDecimalContext>(dbPath);
91+
92+
context.Database.EnsureCreated();
93+
94+
context.Items.Add(new NullableDecimalItem { Value = 42.123456m });
95+
context.Items.Add(new NullableDecimalItem { Value = null });
96+
context.SaveChanges();
97+
98+
var items = context.Items.OrderBy(i => i.Id).ToList();
99+
Assert.Equal(42.123456m, items[0].Value);
100+
Assert.Null(items[1].Value);
101+
}
102+
103+
[Fact]
104+
public void MultipleDecimalProperties_WithDifferentPrecisions()
105+
{
106+
var dbPath = Path.Combine(_tempDir, "multi.ddb");
107+
using var context = CreateContext<MultiPrecisionContext>(dbPath);
108+
109+
context.Database.EnsureCreated();
110+
111+
context.Items.Add(new MultiDecimalItem
112+
{
113+
Price = 99.99m,
114+
TaxRate = 0.0825m,
115+
Weight = 1234.5m
116+
});
117+
context.SaveChanges();
118+
119+
var item = context.Items.First();
120+
Assert.Equal(99.99m, item.Price);
121+
Assert.Equal(0.0825m, item.TaxRate);
122+
Assert.Equal(1234.5m, item.Weight);
123+
}
124+
125+
private static TContext CreateContext<TContext>(string dbPath) where TContext : DbContext
126+
{
127+
var options = new DbContextOptionsBuilder<TContext>()
128+
.UseDecentDB($"Data Source={dbPath}")
129+
.Options;
130+
return (TContext)Activator.CreateInstance(typeof(TContext), options)!;
131+
}
132+
133+
#region Test entities and contexts
134+
135+
public class PrecisionItem
136+
{
137+
public int Id { get; set; }
138+
public decimal Value { get; set; }
139+
}
140+
141+
public class NullableDecimalItem
142+
{
143+
public int Id { get; set; }
144+
public decimal? Value { get; set; }
145+
}
146+
147+
public class MultiDecimalItem
148+
{
149+
public int Id { get; set; }
150+
public decimal Price { get; set; }
151+
public decimal TaxRate { get; set; }
152+
public decimal Weight { get; set; }
153+
}
154+
155+
private sealed class DefaultDecimalContext : DbContext
156+
{
157+
public DefaultDecimalContext(DbContextOptions<DefaultDecimalContext> options) : base(options) { }
158+
public DbSet<PrecisionItem> Items { get; set; } = null!;
159+
}
160+
161+
private sealed class CustomPrecisionConventionContext : DbContext
162+
{
163+
public CustomPrecisionConventionContext(DbContextOptions<CustomPrecisionConventionContext> options) : base(options) { }
164+
public DbSet<PrecisionItem> Items { get; set; } = null!;
165+
166+
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
167+
{
168+
configurationBuilder.Properties<decimal>().HavePrecision(18, 6);
169+
}
170+
}
171+
172+
private sealed class PropertyPrecisionContext : DbContext
173+
{
174+
public PropertyPrecisionContext(DbContextOptions<PropertyPrecisionContext> options) : base(options) { }
175+
public DbSet<PrecisionItem> Items { get; set; } = null!;
176+
177+
protected override void OnModelCreating(ModelBuilder modelBuilder)
178+
{
179+
modelBuilder.Entity<PrecisionItem>().Property(e => e.Value).HasPrecision(10, 2);
180+
}
181+
}
182+
183+
private sealed class ColumnTypeContext : DbContext
184+
{
185+
public ColumnTypeContext(DbContextOptions<ColumnTypeContext> options) : base(options) { }
186+
public DbSet<PrecisionItem> Items { get; set; } = null!;
187+
188+
protected override void OnModelCreating(ModelBuilder modelBuilder)
189+
{
190+
modelBuilder.Entity<PrecisionItem>().Property(e => e.Value).HasColumnType("DECIMAL(10,2)");
191+
}
192+
}
193+
194+
private sealed class NullableDecimalContext : DbContext
195+
{
196+
public NullableDecimalContext(DbContextOptions<NullableDecimalContext> options) : base(options) { }
197+
public DbSet<NullableDecimalItem> Items { get; set; } = null!;
198+
199+
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
200+
{
201+
configurationBuilder.Properties<decimal>().HavePrecision(18, 6);
202+
}
203+
}
204+
205+
private sealed class MultiPrecisionContext : DbContext
206+
{
207+
public MultiPrecisionContext(DbContextOptions<MultiPrecisionContext> options) : base(options) { }
208+
public DbSet<MultiDecimalItem> Items { get; set; } = null!;
209+
210+
protected override void OnModelCreating(ModelBuilder modelBuilder)
211+
{
212+
modelBuilder.Entity<MultiDecimalItem>().Property(e => e.Price).HasPrecision(10, 2);
213+
modelBuilder.Entity<MultiDecimalItem>().Property(e => e.TaxRate).HasPrecision(8, 4);
214+
modelBuilder.Entity<MultiDecimalItem>().Property(e => e.Weight).HasPrecision(12, 1);
215+
}
216+
}
217+
218+
#endregion
219+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using DecentDB.EntityFrameworkCore;
2+
using Microsoft.EntityFrameworkCore;
3+
using Xunit;
4+
5+
namespace DecentDB.EntityFrameworkCore.Tests;
6+
7+
/// <summary>
8+
/// Verifies EF.Functions.Like translation for DecentDB.
9+
/// EF Core's base RelationalMethodCallTranslatorProvider should translate
10+
/// EF.Functions.Like to SQL LIKE. This test confirms it works with DecentDB.
11+
/// </summary>
12+
public sealed class EfFunctionsLikeTests : IDisposable
13+
{
14+
private readonly string _tempDir = Path.Combine(Path.GetTempPath(), $"decentdb_like_{Guid.NewGuid():N}");
15+
16+
public EfFunctionsLikeTests()
17+
{
18+
Directory.CreateDirectory(_tempDir);
19+
}
20+
21+
public void Dispose()
22+
{
23+
if (Directory.Exists(_tempDir))
24+
Directory.Delete(_tempDir, true);
25+
}
26+
27+
[Fact]
28+
public void EfFunctionsLike_TranslatesToSqlLike()
29+
{
30+
var dbPath = Path.Combine(_tempDir, "like.ddb");
31+
using var context = CreateContext(dbPath);
32+
context.Database.EnsureCreated();
33+
34+
context.Items.AddRange(
35+
new LikeTestItem { Name = "Alice" },
36+
new LikeTestItem { Name = "Bob" },
37+
new LikeTestItem { Name = "Charlie" });
38+
context.SaveChanges();
39+
40+
var results = context.Items.Where(i => EF.Functions.Like(i.Name, "%li%")).ToList();
41+
Assert.Equal(2, results.Count); // Alice, Charlie
42+
}
43+
44+
private static LikeTestContext CreateContext(string dbPath)
45+
{
46+
var options = new DbContextOptionsBuilder<LikeTestContext>()
47+
.UseDecentDB($"Data Source={dbPath}")
48+
.Options;
49+
return new LikeTestContext(options);
50+
}
51+
52+
public class LikeTestItem
53+
{
54+
public int Id { get; set; }
55+
public string Name { get; set; } = "";
56+
}
57+
58+
private sealed class LikeTestContext : DbContext
59+
{
60+
public LikeTestContext(DbContextOptions<LikeTestContext> options) : base(options) { }
61+
public DbSet<LikeTestItem> Items { get; set; } = null!;
62+
}
63+
}

0 commit comments

Comments
 (0)