Skip to content

Commit 2cf61f2

Browse files
committed
Added the SqlServerSimulator.EFCore adapter package, unblocking the EF Core SqlServer provider's narrowed date/time and money mappings (DateOnly/TimeOnly/TimeSpan, DateTime→date/smalldatetime, decimal→money/smallmoney).
1 parent 2db9d03 commit 2cf61f2

15 files changed

Lines changed: 826 additions & 21 deletions

CLAUDE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ High-fidelity SQL Server simulation, eventually including transactions, locks, M
1212

1313
When SQL Server's actual behavior is quirky or lossy (CP1252's silent `?` replacement for out-of-codepage characters, ANSI trailing-space padding for `=`, `LEN` excluding trailing spaces), mirror it rather than "fixing" it — authenticity over desirability is what makes the simulator a faithful stand-in.
1414

15-
The overall plan is incremental and non-specific: pick the lowest-effort path that unlocks the most useful application compatibility next. Transactions / locks / MVCC, the `varchar(MAX)` family, the EF Core adapter — all of these are eventual targets, but the order is opportunistic, driven by what's blocking the user's actual application stand-ins. Over time, this accretion gets the simulator to "most apps just work"; near term, work flows to the smallest unlock for the biggest cohort of pain points.
15+
The overall plan is incremental and non-specific: pick the lowest-effort path that unlocks the most useful application compatibility next. Transactions / locks / MVCC, the `varchar(MAX)` family — both are eventual targets, but the order is opportunistic, driven by what's blocking the user's actual application stand-ins. Over time, this accretion gets the simulator to "most apps just work"; near term, work flows to the smallest unlock for the biggest cohort of pain points.
1616

1717
## Public API surface
1818

@@ -27,6 +27,8 @@ Top-level subsystems (in `SqlServerSimulator/`):
2727
- `Parser/` — tokenizer, expression tree, query planner
2828
- root — `Simulated*` implementations of `DbCommand`/`DbConnection`/`DbDataReader`/etc., plus `Simulation` itself
2929

30+
`SqlServerSimulator.EFCore/` is a sibling package whose only public surface is `UseSqlServerSimulator(DbContextOptionsBuilder, DbConnection)`. It registers an `IRelationalTypeMappingSourcePlugin` that substitutes provider-agnostic mappings for the (CLR type, store type) pairs whose default `SqlServer`-provider mappings downcast `DbParameter` to `SqlParameter`: `DateOnly` / `DateTime → date`, `DateTime → smalldatetime`, `TimeOnly` / `TimeSpan → time(N)`, and `decimal → money` / `smallmoney`.
31+
3032
Test projects: main feature tests, internal-access tests for storage/parser internals, EF Core integration (the oracle), and analyzer tests.
3133

3234
## Build, test, format
@@ -71,12 +73,12 @@ Heavy-hitters someone might assume work but don't. Source and `git log` are the
7173

7274
- Transactions / locks / MVCC.
7375
- Row-overflow / LOB pages and the `varchar(MAX)` / `nvarchar(MAX)` / `varbinary(MAX)` types they enable.
74-
- `decimal` / `numeric` values are backed by .NET `decimal`, so values requiring more than 28 significant digits aren't modeled (the type declarations up through `decimal(38, *)` are accepted so storage byte-width still matches SQL Server). `float` text formatting uses .NET's `G15`/`G7` conventions rather than SQL Server's exact `1e+015`-style scientific layout. `money` / `smallmoney` are wired in raw SQL but unreachable through EF Core for the same `SqlParameter`-downcast reason as the date-only / time-only mappings — adding a money column to an EF entity breaks that entity's whole save path.
76+
- `decimal` / `numeric` values are backed by .NET `decimal`, so values requiring more than 28 significant digits aren't modeled (the type declarations up through `decimal(38, *)` are accepted so storage byte-width still matches SQL Server). `float` text formatting uses .NET's `G15`/`G7` conventions rather than SQL Server's exact `1e+015`-style scientific layout.
7577
- `NEWSEQUENTIALID()` (deferred until `DEFAULT`-clause support exists in `CREATE TABLE`; the function is only valid in that context).
7678
- `CONVERT` / `TRY_CONVERT` style-code coverage. Only styles `0`, `120`, and `121` are wired up for date-like sources targeting a character string (the EF Core code-generation defaults). Other style numbers raise Msg 281; money / float / binary style codes and `CONVERT(date, str, 103)`-style date-parsing styles aren't modeled.
7779
- `LIKE` `COLLATE` override. The default collation (case-insensitive, Latin1_General-shaped) is what every `LIKE` runs under; explicit `COLLATE` clauses on the predicate aren't parsed yet.
7880
- Cross-category `Promote` for integer ↔ string. Only CAST works for that pair.
79-
- EF Core compatibility: the SqlServer provider downcasts `DbParameter` to `SqlParameter` for some mappings `DateTime → date`, `DateTime → smalldatetime`, `DateOnly`, `TimeOnly`, `TimeSpan`, and `decimal → money` / `decimal → smallmoney` (via `SqlServerDecimalTypeMapping`) all break at SaveChanges. See `SimulatedDbParameter` for the matrix; a `SqlServerSimulator.EFCore` adapter package is planned to close the gap.
81+
- EF Core compatibility: the `SqlServerSimulator.EFCore` adapter (`UseSqlServerSimulator(...)`) covers the seven `SqlParameter`-downcast pairs `DateOnly → date`, `DateTime → date`, `DateTime → smalldatetime`, `TimeOnly → time(N)`, `TimeSpan → time(N)`, `decimal → money`, `decimal → smallmoney`. Without the adapter (plain `UseSqlServer`), those mappings still throw at SaveChanges as before. Other type pairs the SqlServer provider supports but aren't modeled here (e.g. `hierarchyid`, `geography`, `geometry`, `rowversion`) are not bridged.
8082
- `OUTPUT` and `MERGE` are scoped to the EF Core SaveChanges shape: `INSERT ... OUTPUT INSERTED.<col>` (single-row) and `MERGE INTO target USING (VALUES ...) AS alias (cols) ON predicate WHEN NOT MATCHED THEN INSERT ... [OUTPUT ...]` (multi-row batch). `OUTPUT INTO @table_var`, `OUTPUT DELETED.*`, OUTPUT on UPDATE/DELETE, `INSERTED.*` star expansion, MERGE source subqueries, MERGE target with column refs in `ON`, the `WHEN MATCHED` UPDATE/DELETE branches, and `$action` aren't supported. The `WHEN MATCHED` branch parses syntactically but throws `NotSupportedException` if the per-row predicate ever evaluates true.
8183
- Session-scoped state. `SCOPE_IDENTITY()` / `@@IDENTITY`, `SET IDENTITY_INSERT`'s active table, and `DBCC TRACEON(N)` flags all live on `Simulation` rather than per-connection. The simulator collapses session vs global scope until a real connection-scoped state model exists; revisit when transactions/locks/MVCC bring the per-session shape with them.
8284
- CAST to a smaller `varchar`/`nvarchar` than the value renders: SQL Server silently truncates; the simulator returns the full string.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<Title>SQL Server Simulator EF Core Adapter</Title>
5+
<AssemblyTitle>SQL Server Simulator EF Core Adapter</AssemblyTitle>
6+
<Authors>Ryan Lamansky</Authors>
7+
<Description>EF Core adapter for SQL Server Simulator that closes the SqlServer provider's SqlParameter-downcast gaps (DateOnly, TimeOnly, TimeSpan, DateTime → date / smalldatetime, decimal → money / smallmoney).</Description>
8+
<PackageLicenseExpression>MIT</PackageLicenseExpression>
9+
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
10+
<PackageProjectUrl>https://github.com/RyanLamansky/sql-server-simulator</PackageProjectUrl>
11+
<Copyright>Copyright © Ryan Lamansky. All rights reserved.</Copyright>
12+
<PackageTags>SqlServer SQL EFCore EntityFramework</PackageTags>
13+
<RepositoryUrl>https://github.com/RyanLamansky/sql-server-simulator</RepositoryUrl>
14+
<RepositoryType>git</RepositoryType>
15+
<PublishRepositoryUrl>true</PublishRepositoryUrl>
16+
<EmbedUntrackedSources>true</EmbedUntrackedSources>
17+
<TargetFramework>net10.0</TargetFramework>
18+
<Nullable>enable</Nullable>
19+
<GenerateDocumentationFile>True</GenerateDocumentationFile>
20+
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
21+
<DebugType>embedded</DebugType>
22+
<DebugSymbols>true</DebugSymbols>
23+
<ImplicitUsings>enable</ImplicitUsings>
24+
<IsTrimmable>true</IsTrimmable>
25+
<AnalysisMode>All</AnalysisMode>
26+
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
27+
<RootNamespace>SqlServerSimulator.EFCore</RootNamespace>
28+
</PropertyGroup>
29+
30+
<ItemGroup>
31+
<None Include="../.editorconfig" Link=".editorconfig" />
32+
<None Include="../LICENSE.txt" Pack="true" PackagePath="/" />
33+
</ItemGroup>
34+
35+
<ItemGroup>
36+
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.2" />
37+
</ItemGroup>
38+
39+
<ItemGroup>
40+
<ProjectReference Include="..\SqlServerSimulator\SqlServerSimulator.csproj" />
41+
<ProjectReference Include="..\SqlServerSimulator.Analyzers\SqlServerSimulator.Analyzers.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
42+
</ItemGroup>
43+
44+
</Project>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using System.Data.Common;
2+
using Microsoft.EntityFrameworkCore;
3+
using Microsoft.EntityFrameworkCore.Infrastructure;
4+
5+
namespace SqlServerSimulator.EFCore;
6+
7+
/// <summary>
8+
/// Entry point for using <see cref="Simulation"/> from EF Core. Wraps
9+
/// <see cref="SqlServerDbContextOptionsExtensions.UseSqlServer(DbContextOptionsBuilder, DbConnection, Action{Microsoft.EntityFrameworkCore.Infrastructure.SqlServerDbContextOptionsBuilder}?)"/>
10+
/// and registers the simulator's type-mapping plugin so the SqlServer
11+
/// provider's <c>SqlParameter</c>-downcast pairs (DateOnly, TimeOnly,
12+
/// TimeSpan, DateTime → date / smalldatetime, decimal → money /
13+
/// smallmoney) succeed against a <see cref="Simulation"/>-backed
14+
/// connection.
15+
/// </summary>
16+
public static class SqlServerSimulatorDbContextOptionsBuilderExtensions
17+
{
18+
/// <summary>
19+
/// Configures the context to use the SqlServer provider against a
20+
/// simulator-backed <see cref="DbConnection"/> and registers the
21+
/// simulator's type-mapping plugin. Drop-in replacement for
22+
/// <c>UseSqlServer(simulation.CreateDbConnection())</c>.
23+
/// </summary>
24+
/// <param name="optionsBuilder">The options builder to configure.</param>
25+
/// <param name="connection">A connection from <see cref="Simulation.CreateDbConnection"/>.</param>
26+
/// <returns>The same builder so calls can chain.</returns>
27+
public static DbContextOptionsBuilder UseSqlServerSimulator(this DbContextOptionsBuilder optionsBuilder, DbConnection connection)
28+
{
29+
ArgumentNullException.ThrowIfNull(optionsBuilder);
30+
_ = optionsBuilder.UseSqlServer(connection);
31+
32+
var extension = optionsBuilder.Options.FindExtension<SqlServerSimulatorOptionsExtension>() ?? new SqlServerSimulatorOptionsExtension();
33+
((IDbContextOptionsBuilderInfrastructure)optionsBuilder).AddOrUpdateExtension(extension);
34+
return optionsBuilder;
35+
}
36+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using Microsoft.EntityFrameworkCore.Infrastructure;
2+
using Microsoft.EntityFrameworkCore.Storage;
3+
using Microsoft.Extensions.DependencyInjection;
4+
5+
namespace SqlServerSimulator.EFCore;
6+
7+
/// <summary>
8+
/// Registers <see cref="SqlServerSimulatorTypeMappingSourcePlugin"/> with
9+
/// EF Core's relational type-mapping infrastructure when
10+
/// <c>UseSqlServerSimulator</c> is on the options builder. Layers on top of
11+
/// the standard <c>UseSqlServer</c> extension — does not replace the
12+
/// SqlServer provider, only its broken type-mapping cases.
13+
/// </summary>
14+
internal sealed class SqlServerSimulatorOptionsExtension : IDbContextOptionsExtension
15+
{
16+
public DbContextOptionsExtensionInfo Info => new ExtensionInfo(this);
17+
18+
public void ApplyServices(IServiceCollection services) =>
19+
services.AddSingleton<IRelationalTypeMappingSourcePlugin, SqlServerSimulatorTypeMappingSourcePlugin>();
20+
21+
public void Validate(IDbContextOptions options)
22+
{
23+
}
24+
25+
private sealed class ExtensionInfo(SqlServerSimulatorOptionsExtension extension) : DbContextOptionsExtensionInfo(extension)
26+
{
27+
public override bool IsDatabaseProvider => false;
28+
29+
public override string LogFragment => "using SqlServerSimulator type-mapping plugin ";
30+
31+
public override int GetServiceProviderHashCode() => 0;
32+
33+
public override bool ShouldUseSameServiceProvider(DbContextOptionsExtensionInfo other) => other is ExtensionInfo;
34+
35+
public override void PopulateDebugInfo(IDictionary<string, string> debugInfo) =>
36+
debugInfo["SqlServerSimulator:TypeMappingPlugin"] = "1";
37+
}
38+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using System.Data;
2+
using System.Diagnostics.CodeAnalysis;
3+
using Microsoft.EntityFrameworkCore.Storage;
4+
5+
namespace SqlServerSimulator.EFCore;
6+
7+
/// <summary>
8+
/// Returns substitute relational type mappings for the (CLR type, store type)
9+
/// pairs whose default <c>SqlServer</c>-provider mappings downcast
10+
/// <see cref="System.Data.Common.DbParameter"/> to <c>SqlParameter</c>. Each
11+
/// substitute inherits from the provider-agnostic base
12+
/// (<see cref="DateOnlyTypeMapping"/>, <see cref="TimeOnlyTypeMapping"/>, …),
13+
/// which uses the default <c>ConfigureParameter</c> that simply sets
14+
/// <see cref="System.Data.Common.DbParameter.DbType"/> — no
15+
/// <c>Microsoft.Data.SqlClient</c> dependency, no failed cast.
16+
/// </summary>
17+
/// <remarks>
18+
/// Pairs covered:
19+
/// <list type="bullet">
20+
/// <item><see cref="DateOnly"/> → <c>date</c></item>
21+
/// <item><see cref="DateTime"/> → <c>date</c></item>
22+
/// <item><see cref="DateTime"/> → <c>smalldatetime</c></item>
23+
/// <item><see cref="TimeOnly"/> → <c>time</c> / <c>time(N)</c></item>
24+
/// <item><see cref="TimeSpan"/> → <c>time</c> / <c>time(N)</c></item>
25+
/// <item><see cref="decimal"/> → <c>money</c></item>
26+
/// <item><see cref="decimal"/> → <c>smallmoney</c></item>
27+
/// </list>
28+
/// All other mappings flow through unchanged: returning <c>null</c> from
29+
/// <see cref="FindMapping"/> tells EF Core to consult the next plugin or
30+
/// the provider's built-in mappings. The default <see cref="DateTime"/>
31+
/// → <c>datetime2</c> path already works under vanilla
32+
/// <c>UseSqlServer</c>, so it's not intercepted here.
33+
/// </remarks>
34+
[SuppressMessage("Performance", "CA1812", Justification = "Activated by EF Core's DI container via IRelationalTypeMappingSourcePlugin.")]
35+
internal sealed class SqlServerSimulatorTypeMappingSourcePlugin : IRelationalTypeMappingSourcePlugin
36+
{
37+
public RelationalTypeMapping? FindMapping(in RelationalTypeMappingInfo mappingInfo)
38+
{
39+
var clrType = mappingInfo.ClrType;
40+
var storeBase = mappingInfo.StoreTypeNameBase;
41+
if (clrType is null || storeBase is null)
42+
return null;
43+
44+
// For time(N), prefer the user-declared store name (e.g. "time(7)")
45+
// so EF emits the correct DECLARE; fall back to the base name when
46+
// no precision was specified.
47+
var fullStoreName = mappingInfo.StoreTypeName ?? storeBase;
48+
49+
// money / smallmoney share DbType.Currency. Precision/scale match
50+
// SQL Server's documented widths so EF migrations and any literal
51+
// emitter produce the right shape; the simulator's parameter
52+
// pipeline routes Currency → its Money type regardless.
53+
return (clrType, storeBase) switch
54+
{
55+
(Type t, "date") when t == typeof(DateOnly) => new DateOnlyTypeMapping(fullStoreName, DbType.Date),
56+
(Type t, "date") when t == typeof(DateTime) => new DateTimeTypeMapping(fullStoreName, DbType.Date),
57+
(Type t, "smalldatetime") when t == typeof(DateTime) => new DateTimeTypeMapping(fullStoreName, DbType.DateTime),
58+
(Type t, "time") when t == typeof(TimeOnly) => new TimeOnlyTypeMapping(fullStoreName, DbType.Time),
59+
(Type t, "time") when t == typeof(TimeSpan) => new TimeSpanTypeMapping(fullStoreName, DbType.Time),
60+
(Type t, "money") when t == typeof(decimal) => new DecimalTypeMapping(fullStoreName, DbType.Currency, precision: 19, scale: 4),
61+
(Type t, "smallmoney") when t == typeof(decimal) => new DecimalTypeMapping(fullStoreName, DbType.Currency, precision: 10, scale: 4),
62+
_ => null,
63+
};
64+
}
65+
}

0 commit comments

Comments
 (0)