From a16b873caf35b201eda4e6b1d22d3a10b7e81ee3 Mon Sep 17 00:00:00 2001 From: Niek Schoemaker <32094562+niekschoemaker@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:21:55 +0100 Subject: [PATCH] Support type mappings in NativeAOT compiled models. dotnet ef dbcontext optimize --nativeaot failed because the Firebird type mappings had no public static Default property. Every Fb*TypeMapping now has one. The generated code recreates a mapping as Default.Clone(...) from the standard RelationalTypeMappingParameters only, so FbDateTimeTypeMapping, FbStringTypeMapping and FbTimeSpanTypeMapping additionally expose FbDbType and Clone(FbDbType), and the new FbCSharpRuntimeAnnotationCodeGenerator emits that Clone when the FbDbType differs from the one of Default (e.g. DATE vs TIMESTAMP, CHAR vs VARCHAR). Co-Authored-By: Claude Opus 5.5 --- ...harpRuntimeAnnotationCodeGeneratorTests.cs | 153 ++++++++++++++++++ ....EntityFrameworkCore.Firebird.Tests.csproj | 1 + .../FbCSharpRuntimeAnnotationCodeGenerator.cs | 89 ++++++++++ .../Design/Internal/FbDesignTimeServices.cs | 4 + .../Storage/Internal/FbBoolTypeMapping.cs | 2 + .../Internal/FbByteArrayTypeMapping.cs | 2 + .../Storage/Internal/FbDateOnlyTypeMapping.cs | 2 + .../Storage/Internal/FbDateTimeTypeMapping.cs | 7 + .../Storage/Internal/FbGuidTypeMapping.cs | 2 + .../Storage/Internal/FbStringTypeMapping.cs | 7 + .../Storage/Internal/FbTimeOnlyTypeMapping.cs | 2 + .../Storage/Internal/FbTimeSpanTypeMapping.cs | 7 + 12 files changed, 278 insertions(+) create mode 100644 src/FirebirdSql.EntityFrameworkCore.Firebird.Tests/Design/FbCSharpRuntimeAnnotationCodeGeneratorTests.cs create mode 100644 src/FirebirdSql.EntityFrameworkCore.Firebird/Design/Internal/FbCSharpRuntimeAnnotationCodeGenerator.cs diff --git a/src/FirebirdSql.EntityFrameworkCore.Firebird.Tests/Design/FbCSharpRuntimeAnnotationCodeGeneratorTests.cs b/src/FirebirdSql.EntityFrameworkCore.Firebird.Tests/Design/FbCSharpRuntimeAnnotationCodeGeneratorTests.cs new file mode 100644 index 000000000..8c988198a --- /dev/null +++ b/src/FirebirdSql.EntityFrameworkCore.Firebird.Tests/Design/FbCSharpRuntimeAnnotationCodeGeneratorTests.cs @@ -0,0 +1,153 @@ +/* + * The contents of this file are subject to the Initial + * Developer's Public License Version 1.0 (the "License"); + * you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * https://github.com/FirebirdSQL/NETProvider/raw/master/license.txt. + * + * Software distributed under the License is distributed on + * an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, either + * express or implied. See the License for the specific + * language governing rights and limitations under the License. + * + * All Rights Reserved. + */ + +//$Authors = Niek Schoemaker + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using FirebirdSql.Data.FirebirdClient; +using FirebirdSql.EntityFrameworkCore.Firebird.Design.Internal; +using FirebirdSql.EntityFrameworkCore.Firebird.Storage.Internal; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Design.Internal; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace FirebirdSql.EntityFrameworkCore.Firebird.Tests.Design; + +#pragma warning disable EF1001 +public class FbCSharpRuntimeAnnotationCodeGeneratorTests +{ + ServiceProvider _serviceProvider; + IRelationalTypeMappingSource _typeMappingSource; + ICSharpRuntimeAnnotationCodeGenerator _generator; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + var services = new ServiceCollection().AddEntityFrameworkDesignTimeServices(); + new FbDesignTimeServices().ConfigureDesignTimeServices(services); + _serviceProvider = services.BuildServiceProvider(); + _typeMappingSource = _serviceProvider.GetRequiredService(); + _generator = _serviceProvider.GetRequiredService(); + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + _serviceProvider.Dispose(); + } + + [Test] + public void DesignTimeServicesUseFirebirdGenerator() + { + Assert.IsInstanceOf(_generator); + } + + [Test] + public void AllTypeMappingsHaveDefaultUsableByCompiledModel() + { + var mappingTypes = typeof(FbTypeMappingSource).Assembly.GetTypes() + .Where(t => typeof(RelationalTypeMapping).IsAssignableFrom(t) && !t.IsAbstract) + .ToList(); + Assert.IsNotEmpty(mappingTypes); + + foreach (var mappingType in mappingTypes) + { + // Mirrors the requirements of CSharpRuntimeAnnotationCodeGenerator.CreateDefaultTypeMapping. + var defaultProperty = mappingType.GetProperty("Default"); + Assert.IsNotNull(defaultProperty, mappingType.Name); + Assert.IsTrue(defaultProperty.GetMethod.IsStatic && defaultProperty.GetMethod.IsPublic, mappingType.Name); + Assert.IsTrue(mappingType.IsAssignableFrom(defaultProperty.PropertyType), mappingType.Name); + Assert.IsInstanceOf(mappingType, defaultProperty.GetValue(null), mappingType.Name); + } + } + + [TestCase(typeof(DateTime), "TIMESTAMP", null)] + [TestCase(typeof(DateTime), "DATE", FbDbType.Date)] + [TestCase(typeof(TimeSpan), "TIME", null)] + [TestCase(typeof(string), "VARCHAR(50)", FbDbType.VarChar)] + [TestCase(typeof(string), "CHAR(10)", FbDbType.Char)] + [TestCase(typeof(string), "BLOB SUB_TYPE TEXT", null)] + public void GeneratesFbDbTypeOnlyWhenItDiffersFromDefault(Type clrType, string storeType, FbDbType? expectedFbDbType) + { + var mapping = _typeMappingSource.FindMapping(clrType, storeType); + + var code = Generate(mapping); + + if (expectedFbDbType == null) + { + StringAssert.DoesNotContain("fbDbType", code); + } + else + { + StringAssert.Contains($"property.TypeMapping = (({mapping.GetType().Name})property.TypeMapping).Clone(fbDbType: FbDbType.{expectedFbDbType})", code); + } + } + + [TestCase(typeof(DateTime), "DATE")] + [TestCase(typeof(DateTime), "TIMESTAMP")] + [TestCase(typeof(string), "VARCHAR(50)")] + [TestCase(typeof(string), "CHAR(10)")] + [TestCase(typeof(string), "BLOB SUB_TYPE TEXT")] + [TestCase(typeof(TimeSpan), "TIME")] + public void RecreatedMappingBehavesLikeOriginal(Type clrType, string storeType) + { + var mapping = _typeMappingSource.FindMapping(clrType, storeType); + var fbDbType = (FbDbType)mapping.GetType().GetProperty("FbDbType").GetValue(mapping); + + // What the generated code does: Default.Clone(mappingInfo: ...), followed by Clone(fbDbType: ...) when needed. + var defaultMapping = (RelationalTypeMapping)mapping.GetType().GetProperty("Default", BindingFlags.Public | BindingFlags.Static).GetValue(null); + var recreated = defaultMapping.Clone(new RelationalTypeMappingInfo(storeTypeName: mapping.StoreType, size: mapping.Size, fixedLength: mapping.IsFixedLength, dbType: mapping.DbType)); + recreated = (RelationalTypeMapping)recreated.GetType().GetMethod("Clone", [typeof(FbDbType)]).Invoke(recreated, [fbDbType]); + + var value = clrType == typeof(DateTime) ? new DateTime(2024, 5, 6, 7, 8, 9) : clrType == typeof(TimeSpan) ? new TimeSpan(7, 8, 9) : (object)"x"; + using var command = new FbCommand(); + var original = (FbParameter)mapping.CreateParameter(command, "p", value); + var copy = (FbParameter)recreated.CreateParameter(command, "p", value); + + Assert.AreEqual(mapping.StoreType, recreated.StoreType); + Assert.AreEqual(original.FbDbType, copy.FbDbType); + // FbTimeSpanTypeMapping cannot generate literals at all: its format string leaves the '.' unescaped. + if (clrType != typeof(TimeSpan)) + { + Assert.AreEqual(mapping.GenerateSqlLiteral(value), recreated.GenerateSqlLiteral(value)); + } + } + + string Generate(RelationalTypeMapping mapping) + { + var parameters = new CSharpRuntimeAnnotationCodeGeneratorParameters( + "property", + "TestEntityType", + "TestNamespace", + new IndentedStringBuilder(), + new IndentedStringBuilder(), + new HashSet(), + new Dictionary(), + new Dictionary(), + new Dictionary(), + nullable: false, + nativeAot: true); + _generator.Create(mapping, parameters); + return parameters.MainBuilder.ToString(); + } +} +#pragma warning restore EF1001 diff --git a/src/FirebirdSql.EntityFrameworkCore.Firebird.Tests/FirebirdSql.EntityFrameworkCore.Firebird.Tests.csproj b/src/FirebirdSql.EntityFrameworkCore.Firebird.Tests/FirebirdSql.EntityFrameworkCore.Firebird.Tests.csproj index 97754a322..68c08142a 100644 --- a/src/FirebirdSql.EntityFrameworkCore.Firebird.Tests/FirebirdSql.EntityFrameworkCore.Firebird.Tests.csproj +++ b/src/FirebirdSql.EntityFrameworkCore.Firebird.Tests/FirebirdSql.EntityFrameworkCore.Firebird.Tests.csproj @@ -17,6 +17,7 @@ + diff --git a/src/FirebirdSql.EntityFrameworkCore.Firebird/Design/Internal/FbCSharpRuntimeAnnotationCodeGenerator.cs b/src/FirebirdSql.EntityFrameworkCore.Firebird/Design/Internal/FbCSharpRuntimeAnnotationCodeGenerator.cs new file mode 100644 index 000000000..0aa0ad583 --- /dev/null +++ b/src/FirebirdSql.EntityFrameworkCore.Firebird/Design/Internal/FbCSharpRuntimeAnnotationCodeGenerator.cs @@ -0,0 +1,89 @@ +/* + * The contents of this file are subject to the Initial + * Developer's Public License Version 1.0 (the "License"); + * you may not use this file except in compliance with the + * License. You may obtain a copy of the License at + * https://github.com/FirebirdSQL/NETProvider/raw/master/license.txt. + * + * Software distributed under the License is distributed on + * an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, either + * express or implied. See the License for the specific + * language governing rights and limitations under the License. + * + * All Rights Reserved. + */ + +//$Authors = Niek Schoemaker + +using System; +using FirebirdSql.Data.FirebirdClient; +using FirebirdSql.EntityFrameworkCore.Firebird.Storage.Internal; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Design.Internal; +using Microsoft.EntityFrameworkCore.Storage; + +namespace FirebirdSql.EntityFrameworkCore.Firebird.Design.Internal; + +#pragma warning disable EF1001 +public class FbCSharpRuntimeAnnotationCodeGenerator : RelationalCSharpRuntimeAnnotationCodeGenerator +{ + int _typeMappingNestingCount; + + public FbCSharpRuntimeAnnotationCodeGenerator( + CSharpRuntimeAnnotationCodeGeneratorDependencies dependencies, + RelationalCSharpRuntimeAnnotationCodeGeneratorDependencies relationalDependencies) + : base(dependencies, relationalDependencies) + { } + + public override bool Create( + CoreTypeMapping typeMapping, + CSharpRuntimeAnnotationCodeGeneratorParameters parameters, + ValueComparer valueComparer = null, + ValueComparer keyValueComparer = null, + ValueComparer providerValueComparer = null) + { + _typeMappingNestingCount++; + try + { + var result = base.Create(typeMapping, parameters, valueComparer, keyValueComparer, providerValueComparer); + AddFbDbType(typeMapping, parameters); + return result; + } + finally + { + _typeMappingNestingCount--; + } + } + + // The base generator recreates a mapping as Default.Clone(...) from the standard RelationalTypeMappingParameters only, + // so the FbDbType of mappings that have several variants (e.g. TIMESTAMP and DATE) would silently be the one of Default. + void AddFbDbType(CoreTypeMapping typeMapping, CSharpRuntimeAnnotationCodeGeneratorParameters parameters) + { + var (fbDbType, defaultFbDbType) = typeMapping switch + { + FbDateTimeTypeMapping mapping => (mapping.FbDbType, FbDateTimeTypeMapping.Default.FbDbType), + FbStringTypeMapping mapping => (mapping.FbDbType, FbStringTypeMapping.Default.FbDbType), + FbTimeSpanTypeMapping mapping => (mapping.FbDbType, FbTimeSpanTypeMapping.Default.FbDbType), + _ => ((FbDbType?)null, (FbDbType?)null), + }; + if (fbDbType == defaultFbDbType) + { + return; + } + + if (_typeMappingNestingCount > 1) + { + throw new NotSupportedException( + $"Type mapping '{typeMapping.GetType().Name}' with store type '{((RelationalTypeMapping)typeMapping).StoreType}' and FbDbType '{fbDbType}' is not supported as an element type mapping in a compiled model."); + } + + var code = Dependencies.CSharpHelper; + AddNamespace(typeof(FbDbType), parameters.Namespaces); + parameters.MainBuilder + .AppendLine(";") + .Append($"{parameters.TargetName}.TypeMapping = (({typeMapping.GetType().Name}){parameters.TargetName}.TypeMapping).Clone(fbDbType: ") + .Append(code.Literal(fbDbType.Value)) + .Append(")"); + } +} +#pragma warning restore EF1001 diff --git a/src/FirebirdSql.EntityFrameworkCore.Firebird/Design/Internal/FbDesignTimeServices.cs b/src/FirebirdSql.EntityFrameworkCore.Firebird/Design/Internal/FbDesignTimeServices.cs index b01da4de0..5cd931314 100644 --- a/src/FirebirdSql.EntityFrameworkCore.Firebird/Design/Internal/FbDesignTimeServices.cs +++ b/src/FirebirdSql.EntityFrameworkCore.Firebird/Design/Internal/FbDesignTimeServices.cs @@ -18,6 +18,7 @@ using FirebirdSql.EntityFrameworkCore.Firebird.Scaffolding.Internal; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Design.Internal; using Microsoft.EntityFrameworkCore.Scaffolding; using Microsoft.Extensions.DependencyInjection; @@ -28,10 +29,13 @@ public class FbDesignTimeServices : IDesignTimeServices public void ConfigureDesignTimeServices(IServiceCollection serviceCollection) { serviceCollection.AddEntityFrameworkFirebird(); +#pragma warning disable EF1001 new EntityFrameworkRelationalDesignServicesBuilder(serviceCollection) .TryAdd() + .TryAdd() .TryAdd() .TryAdd() .TryAddCoreServices(); +#pragma warning restore EF1001 } } diff --git a/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbBoolTypeMapping.cs b/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbBoolTypeMapping.cs index a4c0a89a8..5b175fbd0 100644 --- a/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbBoolTypeMapping.cs +++ b/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbBoolTypeMapping.cs @@ -21,6 +21,8 @@ namespace FirebirdSql.EntityFrameworkCore.Firebird.Storage.Internal; public class FbBoolTypeMapping : BoolTypeMapping { + public static new FbBoolTypeMapping Default { get; } = new(); + public FbBoolTypeMapping() : base("BOOLEAN", System.Data.DbType.Boolean) { } diff --git a/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbByteArrayTypeMapping.cs b/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbByteArrayTypeMapping.cs index dbd4bd513..ee3923e7d 100644 --- a/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbByteArrayTypeMapping.cs +++ b/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbByteArrayTypeMapping.cs @@ -22,6 +22,8 @@ namespace FirebirdSql.EntityFrameworkCore.Firebird.Storage.Internal; public class FbByteArrayTypeMapping : ByteArrayTypeMapping { + public static new FbByteArrayTypeMapping Default { get; } = new(); + public FbByteArrayTypeMapping() : base("BLOB SUB_TYPE BINARY", System.Data.DbType.Binary) { } diff --git a/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbDateOnlyTypeMapping.cs b/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbDateOnlyTypeMapping.cs index 2cddcda2b..5edad4de7 100644 --- a/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbDateOnlyTypeMapping.cs +++ b/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbDateOnlyTypeMapping.cs @@ -21,6 +21,8 @@ namespace FirebirdSql.EntityFrameworkCore.Firebird.Storage.Internal; public class FbDateOnlyTypeMapping : DateOnlyTypeMapping { + public static new FbDateOnlyTypeMapping Default { get; } = new("DATE"); + public FbDateOnlyTypeMapping(string storeType) : base(storeType) { } diff --git a/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbDateTimeTypeMapping.cs b/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbDateTimeTypeMapping.cs index b9dc140d9..1709ff5ac 100644 --- a/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbDateTimeTypeMapping.cs +++ b/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbDateTimeTypeMapping.cs @@ -26,6 +26,8 @@ public class FbDateTimeTypeMapping : DateTimeTypeMapping { readonly FbDbType _fbDbType; + public static new FbDateTimeTypeMapping Default { get; } = new("TIMESTAMP", FbDbType.TimeStamp); + public FbDateTimeTypeMapping(string storeType, FbDbType fbDbType) : base(storeType) { @@ -38,6 +40,11 @@ protected FbDateTimeTypeMapping(RelationalTypeMappingParameters parameters, FbDb _fbDbType = fbDbType; } + public FbDbType FbDbType => _fbDbType; + + public virtual FbDateTimeTypeMapping Clone(FbDbType fbDbType) + => new FbDateTimeTypeMapping(Parameters, fbDbType); + protected override void ConfigureParameter(DbParameter parameter) { ((FbParameter)parameter).FbDbType = _fbDbType; diff --git a/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbGuidTypeMapping.cs b/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbGuidTypeMapping.cs index 2220ea5b2..ebef1c320 100644 --- a/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbGuidTypeMapping.cs +++ b/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbGuidTypeMapping.cs @@ -23,6 +23,8 @@ namespace FirebirdSql.EntityFrameworkCore.Firebird.Storage.Internal; public class FbGuidTypeMapping : GuidTypeMapping { + public static new FbGuidTypeMapping Default { get; } = new(); + public FbGuidTypeMapping() : base("CHAR(16) CHARACTER SET OCTETS") { } diff --git a/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbStringTypeMapping.cs b/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbStringTypeMapping.cs index d61439c13..e00e93fca 100644 --- a/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbStringTypeMapping.cs +++ b/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbStringTypeMapping.cs @@ -26,6 +26,8 @@ public class FbStringTypeMapping : StringTypeMapping { readonly FbDbType _fbDbType; + public static new FbStringTypeMapping Default { get; } = new("BLOB SUB_TYPE TEXT", System.Data.DbType.String, FbDbType.Text); + public FbStringTypeMapping(string storeType, DbType dbType, FbDbType fbDbType, int? size = null, bool unicode = true) : base(storeType, dbType, unicode: unicode, size: size) { @@ -38,6 +40,11 @@ protected FbStringTypeMapping(RelationalTypeMappingParameters parameters, FbDbTy _fbDbType = fbDbType; } + public FbDbType FbDbType => _fbDbType; + + public virtual FbStringTypeMapping Clone(FbDbType fbDbType) + => new FbStringTypeMapping(Parameters, fbDbType); + protected override void ConfigureParameter(DbParameter parameter) { ((FbParameter)parameter).FbDbType = _fbDbType; diff --git a/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbTimeOnlyTypeMapping.cs b/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbTimeOnlyTypeMapping.cs index d9d4138a0..d1b7b95b8 100644 --- a/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbTimeOnlyTypeMapping.cs +++ b/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbTimeOnlyTypeMapping.cs @@ -21,6 +21,8 @@ namespace FirebirdSql.EntityFrameworkCore.Firebird.Storage.Internal; public class FbTimeOnlyTypeMapping : TimeOnlyTypeMapping { + public static new FbTimeOnlyTypeMapping Default { get; } = new("TIME"); + public FbTimeOnlyTypeMapping(string storeType) : base(storeType) { } diff --git a/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbTimeSpanTypeMapping.cs b/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbTimeSpanTypeMapping.cs index 0dac60aff..835ad0140 100644 --- a/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbTimeSpanTypeMapping.cs +++ b/src/FirebirdSql.EntityFrameworkCore.Firebird/Storage/Internal/FbTimeSpanTypeMapping.cs @@ -26,6 +26,8 @@ public class FbTimeSpanTypeMapping : TimeSpanTypeMapping { readonly FbDbType _fbDbType; + public static new FbTimeSpanTypeMapping Default { get; } = new("TIME", FbDbType.Time); + public FbTimeSpanTypeMapping(string storeType, FbDbType fbDbType) : base(storeType) { @@ -38,6 +40,11 @@ protected FbTimeSpanTypeMapping(RelationalTypeMappingParameters parameters, FbDb _fbDbType = fbDbType; } + public FbDbType FbDbType => _fbDbType; + + public virtual FbTimeSpanTypeMapping Clone(FbDbType fbDbType) + => new FbTimeSpanTypeMapping(Parameters, fbDbType); + protected override void ConfigureParameter(DbParameter parameter) { ((FbParameter)parameter).FbDbType = _fbDbType;