diff --git a/src/EFCore.Design/Metadata/Internal/ScaffoldingAnnotationNames.cs b/src/EFCore.Design/Metadata/Internal/ScaffoldingAnnotationNames.cs index 8fc9d640c92..c16bc3b8ad3 100644 --- a/src/EFCore.Design/Metadata/Internal/ScaffoldingAnnotationNames.cs +++ b/src/EFCore.Design/Metadata/Internal/ScaffoldingAnnotationNames.cs @@ -43,6 +43,14 @@ public static class ScaffoldingAnnotationNames /// public const string ConcurrencyToken = "ConcurrencyToken"; + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public const string ClrType = "ClrType"; + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore.Design/Scaffolding/Internal/IScaffoldingTypeMapper.cs b/src/EFCore.Design/Scaffolding/Internal/IScaffoldingTypeMapper.cs index 69f25691872..6d97aad9f0c 100644 --- a/src/EFCore.Design/Scaffolding/Internal/IScaffoldingTypeMapper.cs +++ b/src/EFCore.Design/Scaffolding/Internal/IScaffoldingTypeMapper.cs @@ -17,5 +17,5 @@ public interface IScaffoldingTypeMapper /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// - TypeScaffoldingInfo? FindMapping(string storeType, bool keyOrIndex, bool rowVersion); + TypeScaffoldingInfo? FindMapping(string storeType, bool keyOrIndex, bool rowVersion, Type? clrType = null); } diff --git a/src/EFCore.Design/Scaffolding/Internal/RelationalScaffoldingModelFactory.cs b/src/EFCore.Design/Scaffolding/Internal/RelationalScaffoldingModelFactory.cs index b1fdaf6da25..ae03e4f4143 100644 --- a/src/EFCore.Design/Scaffolding/Internal/RelationalScaffoldingModelFactory.cs +++ b/src/EFCore.Design/Scaffolding/Internal/RelationalScaffoldingModelFactory.cs @@ -487,7 +487,8 @@ protected virtual EntityTypeBuilder VisitColumns(EntityTypeBuilder builder, ICol property.Metadata.AddAnnotations( column.GetAnnotations().Where( - a => a.Name != ScaffoldingAnnotationNames.ConcurrencyToken)); + a => a.Name != ScaffoldingAnnotationNames.ConcurrencyToken + && a.Name != ScaffoldingAnnotationNames.ClrType)); return property; } @@ -967,7 +968,8 @@ protected virtual List ExistingIdentifiers(IReadOnlyEntityType entityTyp return _scaffoldingTypeMapper.FindMapping( column.StoreType, column.IsKeyOrIndex(), - column.IsRowVersion()); + column.IsRowVersion(), + (Type?)column[ScaffoldingAnnotationNames.ClrType]); } private static void AssignOnDeleteAction( diff --git a/src/EFCore.Design/Scaffolding/Internal/ScaffoldingTypeMapper.cs b/src/EFCore.Design/Scaffolding/Internal/ScaffoldingTypeMapper.cs index 1668b1dd18a..7f62b00e9c5 100644 --- a/src/EFCore.Design/Scaffolding/Internal/ScaffoldingTypeMapper.cs +++ b/src/EFCore.Design/Scaffolding/Internal/ScaffoldingTypeMapper.cs @@ -33,9 +33,12 @@ public ScaffoldingTypeMapper(IRelationalTypeMappingSource typeMappingSource) public virtual TypeScaffoldingInfo? FindMapping( string storeType, bool keyOrIndex, - bool rowVersion) + bool rowVersion, + Type? clrType = null) { - var mapping = _typeMappingSource.FindMapping(storeType); + var mapping = clrType is null + ? _typeMappingSource.FindMapping(storeType) + : _typeMappingSource.FindMapping(clrType, storeType); if (mapping == null) { return null; diff --git a/src/EFCore.Sqlite.Core/Diagnostics/Internal/SqliteLoggingDefinitions.cs b/src/EFCore.Sqlite.Core/Diagnostics/Internal/SqliteLoggingDefinitions.cs index 791ab4de185..493c1b531de 100644 --- a/src/EFCore.Sqlite.Core/Diagnostics/Internal/SqliteLoggingDefinitions.cs +++ b/src/EFCore.Sqlite.Core/Diagnostics/Internal/SqliteLoggingDefinitions.cs @@ -130,4 +130,28 @@ public class SqliteLoggingDefinitions : RelationalLoggingDefinitions /// doing so can result in application failures when updating to a new Entity Framework Core release. /// public EventDefinitionBase? LogCompositeKeyWithValueGeneration; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public EventDefinitionBase? LogInferringTypes; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public EventDefinitionBase? LogOutOfRangeWarning; + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public EventDefinitionBase? LogFormatWarning; } diff --git a/src/EFCore.Sqlite.Core/Diagnostics/SqliteEventId.cs b/src/EFCore.Sqlite.Core/Diagnostics/SqliteEventId.cs index e90221f6e4c..a5c85d3b1ed 100644 --- a/src/EFCore.Sqlite.Core/Diagnostics/SqliteEventId.cs +++ b/src/EFCore.Sqlite.Core/Diagnostics/SqliteEventId.cs @@ -47,7 +47,10 @@ private enum Id PrimaryKeyFound, SchemasNotSupportedWarning, TableFound, - UniqueConstraintFound + UniqueConstraintFound, + InferringTypes, + OutOfRangeWarning, + FormatWarning } private static readonly string ValidationPrefix = DbLoggerCategory.Model.Validation.Name + "."; @@ -213,4 +216,28 @@ private static EventId MakeScaffoldingId(Id id) /// This event is in the category. /// public static readonly EventId UniqueConstraintFound = MakeScaffoldingId(Id.UniqueConstraintFound); + + /// + /// Inferring CLR types. + /// + /// + /// This event is in the category. + /// + public static readonly EventId InferringTypes = MakeScaffoldingId(Id.InferringTypes); + + /// + /// Values are out of range for the type. + /// + /// + /// This event is in the category. + /// + public static readonly EventId OutOfRangeWarning = MakeScaffoldingId(Id.OutOfRangeWarning); + + /// + /// Values are in an invalid format for the type. + /// + /// + /// This event is in the category. + /// + public static readonly EventId FormatWarning = MakeScaffoldingId(Id.FormatWarning); } diff --git a/src/EFCore.Sqlite.Core/Extensions/Internal/SqliteLoggerExtensions.cs b/src/EFCore.Sqlite.Core/Extensions/Internal/SqliteLoggerExtensions.cs index 2c6676ef4cc..99e3ad2f16a 100644 --- a/src/EFCore.Sqlite.Core/Extensions/Internal/SqliteLoggerExtensions.cs +++ b/src/EFCore.Sqlite.Core/Extensions/Internal/SqliteLoggerExtensions.cs @@ -413,4 +413,68 @@ private static string CompositeKeyWithValueGeneration(EventDefinitionBase defini p.Key.DeclaringEntityType.DisplayName(), p.Key.Properties.Format()); } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public static void InferringTypes( + this IDiagnosticsLogger diagnostics, + string? tableName) + { + var definition = SqliteResources.LogInferringTypes(diagnostics); + + if (diagnostics.ShouldLog(definition)) + { + definition.Log(diagnostics, tableName); + } + + // No DiagnosticsSource events because these are purely design-time messages + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public static void OutOfRangeWarning( + this IDiagnosticsLogger diagnostics, + string? columnName, + string? tableName, + string? type) + { + var definition = SqliteResources.LogOutOfRangeWarning(diagnostics); + + if (diagnostics.ShouldLog(definition)) + { + definition.Log(diagnostics, columnName, tableName, type); + } + + // No DiagnosticsSource events because these are purely design-time messages + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public static void FormatWarning( + this IDiagnosticsLogger diagnostics, + string? columnName, + string? tableName, + string? type) + { + var definition = SqliteResources.LogFormatWarning(diagnostics); + + if (diagnostics.ShouldLog(definition)) + { + definition.Log(diagnostics, columnName, tableName, type); + } + + // No DiagnosticsSource events because these are purely design-time messages + } } diff --git a/src/EFCore.Sqlite.Core/Properties/SqliteStrings.Designer.cs b/src/EFCore.Sqlite.Core/Properties/SqliteStrings.Designer.cs index 347b66fae15..8794b7b9b1b 100644 --- a/src/EFCore.Sqlite.Core/Properties/SqliteStrings.Designer.cs +++ b/src/EFCore.Sqlite.Core/Properties/SqliteStrings.Designer.cs @@ -171,6 +171,31 @@ private static readonly ResourceManager _resourceManager return (EventDefinition)definition; } + /// + /// The column '{columnName}' on table '{tableName}' should map to a property of type '{type}', but its values are in an incompatible format. Using a different type. + /// + public static EventDefinition LogFormatWarning(IDiagnosticsLogger logger) + { + var definition = ((Diagnostics.Internal.SqliteLoggingDefinitions)logger.Definitions).LogFormatWarning; + if (definition == null) + { + definition = NonCapturingLazyInitializer.EnsureInitialized( + ref ((Diagnostics.Internal.SqliteLoggingDefinitions)logger.Definitions).LogFormatWarning, + logger, + static logger => new EventDefinition( + logger.Options, + SqliteEventId.FormatWarning, + LogLevel.Warning, + "SqliteEventId.FormatWarning", + level => LoggerMessage.Define( + level, + SqliteEventId.FormatWarning, + _resourceManager.GetString("LogFormatWarning")!))); + } + + return (EventDefinition)definition; + } + /// /// Found column on table '{tableName}' with name: '{columnName}', data type: {dataType}, not nullable: {notNullable}, default value: {defaultValue}. /// @@ -321,6 +346,31 @@ private static readonly ResourceManager _resourceManager return (EventDefinition)definition; } + /// + /// Querying table '{tableName}' to determine an appropriate CLR type for each column. + /// + public static EventDefinition LogInferringTypes(IDiagnosticsLogger logger) + { + var definition = ((Diagnostics.Internal.SqliteLoggingDefinitions)logger.Definitions).LogInferringTypes; + if (definition == null) + { + definition = NonCapturingLazyInitializer.EnsureInitialized( + ref ((Diagnostics.Internal.SqliteLoggingDefinitions)logger.Definitions).LogInferringTypes, + logger, + static logger => new EventDefinition( + logger.Options, + SqliteEventId.InferringTypes, + LogLevel.Debug, + "SqliteEventId.InferringTypes", + level => LoggerMessage.Define( + level, + SqliteEventId.InferringTypes, + _resourceManager.GetString("LogInferringTypes")!))); + } + + return (EventDefinition)definition; + } + /// /// Unable to find a table in the database matching the selected table '{table}'. /// @@ -346,6 +396,31 @@ private static readonly ResourceManager _resourceManager return (EventDefinition)definition; } + /// + /// The column '{columnName}' on table '{tableName}' should map to a property of type '{type}', but its values are out of range. Using a different type. + /// + public static EventDefinition LogOutOfRangeWarning(IDiagnosticsLogger logger) + { + var definition = ((Diagnostics.Internal.SqliteLoggingDefinitions)logger.Definitions).LogOutOfRangeWarning; + if (definition == null) + { + definition = NonCapturingLazyInitializer.EnsureInitialized( + ref ((Diagnostics.Internal.SqliteLoggingDefinitions)logger.Definitions).LogOutOfRangeWarning, + logger, + static logger => new EventDefinition( + logger.Options, + SqliteEventId.OutOfRangeWarning, + LogLevel.Warning, + "SqliteEventId.OutOfRangeWarning", + level => LoggerMessage.Define( + level, + SqliteEventId.OutOfRangeWarning, + _resourceManager.GetString("LogOutOfRangeWarning")!))); + } + + return (EventDefinition)definition; + } + /// /// Skipping foreign key with identity '{id}' on table '{tableName}', since the principal column '{principalColumnName}' on the foreign key's principal table, '{principalTableName}', was not found in the model. /// diff --git a/src/EFCore.Sqlite.Core/Properties/SqliteStrings.resx b/src/EFCore.Sqlite.Core/Properties/SqliteStrings.resx index 681c42f2ce3..aa3444f088b 100644 --- a/src/EFCore.Sqlite.Core/Properties/SqliteStrings.resx +++ b/src/EFCore.Sqlite.Core/Properties/SqliteStrings.resx @@ -143,6 +143,10 @@ Skipping foreign key with identity '{id}' on table '{tableName}' since principal table '{principalTableName}' was not found in the model. This usually happens when the principal table was not included in the selection set. Warning SqliteEventId.ForeignKeyReferencesMissingTableWarning string? string? string? + + The column '{columnName}' on table '{tableName}' should map to a property of type '{type}', but its values are in an incompatible format. Using a different type. + Warning SqliteEventId.FormatWarning string? string? string? + Found column on table '{tableName}' with name: '{columnName}', data type: {dataType}, not nullable: {notNullable}, default value: {defaultValue}. Debug SqliteEventId.ColumnFound string? string? string? bool string? @@ -167,10 +171,18 @@ Found unique constraint on table '{tableName}' with name: {uniqueConstraintName}. Debug SqliteEventId.UniqueConstraintFound string? string? + + Querying table '{tableName}' to determine an appropriate CLR type for each column. + Debug SqliteEventId.InferringTypes string? + Unable to find a table in the database matching the selected table '{table}'. Warning SqliteEventId.MissingTableWarning string? + + The column '{columnName}' on table '{tableName}' should map to a property of type '{type}', but its values are out of range. Using a different type. + Warning SqliteEventId.OutOfRangeWarning string? string? string? + Skipping foreign key with identity '{id}' on table '{tableName}', since the principal column '{principalColumnName}' on the foreign key's principal table, '{principalTableName}', was not found in the model. Warning SqliteEventId.ForeignKeyPrincipalColumnMissingWarning string? string? string? string? diff --git a/src/EFCore.Sqlite.Core/Scaffolding/Internal/SqliteDatabaseModelFactory.cs b/src/EFCore.Sqlite.Core/Scaffolding/Internal/SqliteDatabaseModelFactory.cs index fc7b2dee089..ededa3ceec6 100644 --- a/src/EFCore.Sqlite.Core/Scaffolding/Internal/SqliteDatabaseModelFactory.cs +++ b/src/EFCore.Sqlite.Core/Scaffolding/Internal/SqliteDatabaseModelFactory.cs @@ -3,6 +3,7 @@ using System.Data; using System.Text; +using System.Text.RegularExpressions; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; using Microsoft.EntityFrameworkCore.Sqlite.Internal; @@ -16,8 +17,116 @@ namespace Microsoft.EntityFrameworkCore.Sqlite.Scaffolding.Internal; /// any release. You should only use it directly in your code with extreme caution and knowing that /// doing so can result in application failures when updating to a new Entity Framework Core release. /// -public class SqliteDatabaseModelFactory : DatabaseModelFactory +public partial class SqliteDatabaseModelFactory : DatabaseModelFactory { + private static readonly HashSet _defaultClrTypes = new() + { + typeof(long), + typeof(string), + typeof(byte[]), + typeof(double) + }; + private static readonly HashSet _boolTypes = new(StringComparer.OrdinalIgnoreCase) + { + "BIT", + "BOOL", + "BOOLEAN", + "LOGICAL", + "YESNO" + }; + private static readonly HashSet _uintTypes = new(StringComparer.OrdinalIgnoreCase) + { + "MEDIUMUINT", + "UINT", + "UINT32", + "UNSIGNEDINTEGER32" + }; + private static readonly HashSet _ulongTypes = new(StringComparer.OrdinalIgnoreCase) + { + "BIGUINT", + "UINT64", + "ULONG", + "UNSIGNEDINTEGER", + "UNSIGNEDINTEGER64" + }; + private static readonly HashSet _byteTypes = new(StringComparer.OrdinalIgnoreCase) + { + "BYTE", + "TINYINT", + "UINT8", + "UNSIGNEDINTEGER8" + }; + private static readonly HashSet _shortTypes = new(StringComparer.OrdinalIgnoreCase) + { + "INT16", + "INTEGER16", + "SHORT", + "SMALLINT" + }; + private static readonly HashSet _longTypes = new(StringComparer.OrdinalIgnoreCase) + { + "BIGINT", + "INT64", + "INTEGER64", + "LONG" + }; + private static readonly HashSet _sbyteTypes = new(StringComparer.OrdinalIgnoreCase) + { + "INT8", + "INTEGER8", + "SBYTE", + "TINYSINT" + }; + private static readonly HashSet _floatTypes = new(StringComparer.OrdinalIgnoreCase) + { + "SINGLE" + }; + private static readonly HashSet _ushortTypes = new(StringComparer.OrdinalIgnoreCase) + { + "SMALLUINT", + "UINT16", + "UNSIGNEDINTEGER16", + "USHORT" + }; + private static readonly HashSet _timeOnlyTypes = new(StringComparer.OrdinalIgnoreCase) + { + "TIMEONLY" + }; + private static readonly Dictionary _typesByName = new Dictionary + { + { "CURRENCY", typeof(decimal) }, + { "DATE", typeof(DateTime) }, + { "DATEONLY", typeof(DateOnly) }, + { "DATETIME", typeof(DateTime) }, + { "DATETIME2", typeof(DateTime) }, + { "DATETIMEOFFSET", typeof(DateTimeOffset) }, + { "DECIMAL", typeof(decimal) }, + { "GUID", typeof(Guid) }, + { "JSON", typeof(string) }, + { "MONEY", typeof(decimal) }, + { "NUMBER", typeof(decimal) }, + { "NUMERIC", typeof(decimal) }, + { "SMALLDATE", typeof(DateTime) }, + { "SMALLMONEY", typeof(decimal) }, + { "STRING", typeof(string) }, + { "TIME", typeof(TimeSpan) }, + { "TIMESPAN", typeof(TimeSpan) }, + { "TIMESTAMP", typeof(DateTime) }, + { "UNIQUEIDENTIFIER", typeof(Guid) }, + { "UUID", typeof(Guid) }, + { "XML", typeof(string) } + } + .Concat(_boolTypes.Select(t => KeyValuePair.Create(t, typeof(bool)))) + .Concat(_byteTypes.Select(t => KeyValuePair.Create(t, typeof(byte)))) + .Concat(_shortTypes.Select(t => KeyValuePair.Create(t, typeof(short)))) + .Concat(_sbyteTypes.Select(t => KeyValuePair.Create(t, typeof(sbyte)))) + .Concat(_floatTypes.Select(t => KeyValuePair.Create(t, typeof(float)))) + .Concat(_timeOnlyTypes.Select(t => KeyValuePair.Create(t, typeof(TimeOnly)))) + .Concat(_ushortTypes.Select(t => KeyValuePair.Create(t, typeof(ushort)))) + .Concat(_uintTypes.Select(t => KeyValuePair.Create(t, typeof(uint)))) + .Concat(_ulongTypes.Select(t => KeyValuePair.Create(t, typeof(ulong)))) + .ToDictionary(i => i.Key, i => i.Value, StringComparer.OrdinalIgnoreCase); + private readonly IDiagnosticsLogger _logger; private readonly IRelationalTypeMappingSource _typeMappingSource; @@ -271,6 +380,8 @@ ORDER BY "cid" : collation }); } + + InferClrTypes(connection, table); } private string? FilterClrDefaults(string dataType, bool notNull, string defaultValue) @@ -290,6 +401,344 @@ ORDER BY "cid" return defaultValue; } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected virtual void InferClrTypes(DbConnection connection, DatabaseTable table) + { + var command = connection.CreateCommand(); + var commandText = new StringBuilder(); + commandText.Append("SELECT"); + + var i = 0; + var dictionary = new Dictionary(); + foreach (var column in table.Columns) + { + if (string.Equals(column.StoreType, "BLOB", StringComparison.OrdinalIgnoreCase) + || string.Equals(column.StoreType, "REAL", StringComparison.OrdinalIgnoreCase)) + { + // Trust the column type (for perf) + continue; + } + + var defaultClrType = _typeMappingSource.FindMapping(column.StoreType!)?.ClrType; + if (!_defaultClrTypes.Contains(defaultClrType)) + { + // Handled by a plugin + continue; + } + + if (i != 0) + { + commandText.Append(","); + } + + var columnIdentifier = DelimitIdentifier(column.Name); + commandText + .Append(" typeof(max(") + .Append(columnIdentifier) + .Append(")), min(") + .Append(columnIdentifier) + .Append("), max(") + .Append(columnIdentifier) + .Append(")"); + + dictionary.Add(column, (i, defaultClrType)); + i += 3; + } + + if (dictionary.Count == 0) + { + return; + } + + commandText + .Append(" FROM (SELECT * FROM ") + .Append(DelimitIdentifier(table.Name)) + .Append(" LIMIT 131072)"); + + command.CommandText = commandText.ToString(); + + _logger.InferringTypes(table.Name); + + using var reader = command.ExecuteReader(); + var read = reader.Read(); + Check.DebugAssert(read, "No results"); + + foreach (var (column, (offset, defaultClrTpe)) in dictionary) + { + var valueType = reader.GetString(offset + 0); + + var index = column.StoreType!.IndexOf("(", StringComparison.OrdinalIgnoreCase); + var baseColumnType = index == -1 + ? column.StoreType + : column.StoreType.Substring(0, index); + + if (string.Equals(valueType, "INTEGER", StringComparison.OrdinalIgnoreCase)) + { + var min = reader.GetInt64(offset + 1); + var max = reader.GetInt64(offset + 2); + + if (_boolTypes.Contains(baseColumnType)) + { + if (min >= 0L + && max <= 1L) + { + column["ClrType"] = typeof(bool); + + continue; + } + + _logger.OutOfRangeWarning(column.Name, table.Name, "bool"); + } + if (_byteTypes.Contains(baseColumnType)) + { + if (min >= byte.MinValue + && max <= byte.MaxValue) + { + column["ClrType"] = typeof(byte); + + continue; + } + + _logger.OutOfRangeWarning(column.Name, table.Name, "byte"); + } + if (_shortTypes.Contains(baseColumnType)) + { + if (min >= short.MinValue + && max <= short.MaxValue) + { + column["ClrType"] = typeof(short); + + continue; + } + + _logger.OutOfRangeWarning(column.Name, table.Name, "short"); + } + if (_longTypes.Contains(baseColumnType)) + { + if (defaultClrTpe != typeof(long)) + { + column["ClrType"] = typeof(long); + } + + continue; + } + if (_sbyteTypes.Contains(baseColumnType)) + { + if (min >= sbyte.MinValue + && max <= sbyte.MaxValue) + { + column["ClrType"] = typeof(sbyte); + + continue; + } + + _logger.OutOfRangeWarning(column.Name, table.Name, "sbyte"); + } + if (_ushortTypes.Contains(baseColumnType)) + { + if (min >= ushort.MinValue + && max <= ushort.MaxValue) + { + column["ClrType"] = typeof(ushort); + + continue; + } + + _logger.OutOfRangeWarning(column.Name, table.Name, "ushort"); + } + if (_uintTypes.Contains(baseColumnType)) + { + if (min >= uint.MinValue + && max <= uint.MaxValue) + { + column["ClrType"] = typeof(uint); + + continue; + } + + _logger.OutOfRangeWarning(column.Name, table.Name, "uint"); + } + if (_ulongTypes.Contains(baseColumnType)) + { + column["ClrType"] = typeof(ulong); + + continue; + } + + if (min < int.MinValue + || max > int.MaxValue) + { + if (defaultClrTpe != typeof(long)) + { + column["ClrType"] = typeof(long); + } + + continue; + } + + column["ClrType"] = typeof(int); + + continue; + } + if (string.Equals(valueType, "TEXT", StringComparison.OrdinalIgnoreCase)) + { + var min = reader.GetString(offset + 1); + var max = reader.GetString(offset + 2); + + if (Regex.IsMatch(max, @"^\d{4}-\d{2}-\d{2}$", default, TimeSpan.FromMilliseconds(1000.0))) + { + column["ClrType"] = typeof(DateOnly); + + continue; + } + if (Regex.IsMatch(max, @"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d{1,7})?$", default, TimeSpan.FromMilliseconds(1000.0))) + { + column["ClrType"] = typeof(DateTime); + + continue; + } + if (Regex.IsMatch(max, @"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d{1,7})?[-+]\d{2}:\d{2}$", default, TimeSpan.FromMilliseconds(1000.0))) + { + column["ClrType"] = typeof(DateTimeOffset); + + continue; + } + if (Regex.IsMatch(max, @"^-?\d+\.\d{1,28}$", default, TimeSpan.FromMilliseconds(1000.0))) + { + column["ClrType"] = typeof(decimal); + + continue; + } + if (Regex.IsMatch(max, @"^(\d|[A-F]){8}-(\d|[A-F]){4}-(\d|[A-F]){4}-(\d|[A-F]){4}-(\d|[A-F]){12}$", default, TimeSpan.FromMilliseconds(1000.0))) + { + column["ClrType"] = typeof(Guid); + + continue; + } + if (Regex.IsMatch(max, @"^-?(\d+\.)?\d{2}:\d{2}:\d{2}(\.\d{1,7})?$", default, TimeSpan.FromMilliseconds(1000.0))) + { + if (_timeOnlyTypes.Contains(baseColumnType)) + { + if (TimeSpan.TryParse(min, out var minTimeSpan) + && TimeSpan.TryParse(max, out var maxTimeSpan) + && minTimeSpan >= TimeOnly.MinValue.ToTimeSpan() + && maxTimeSpan <= TimeOnly.MaxValue.ToTimeSpan()) + { + column["ClrType"] = typeof(TimeOnly); + + continue; + } + + _logger.OutOfRangeWarning(column.Name, table.Name, "TimeOnly"); + } + + column["ClrType"] = typeof(TimeSpan); + + continue; + } + + if (DateOnly.TryParse(max, out _)) + { + _logger.FormatWarning(column.Name, table.Name, "DateOnly"); + } + else if (DateTime.TryParse(max, out _)) + { + _logger.FormatWarning(column.Name, table.Name, "DateTime"); + } + else if (DateTimeOffset.TryParse(max, out _)) + { + _logger.FormatWarning(column.Name, table.Name, "DateTimeOffset"); + } + else if (decimal.TryParse(max, out _)) + { + _logger.FormatWarning(column.Name, table.Name, "decimal"); + } + else if (Guid.TryParse(max, out _)) + { + _logger.FormatWarning(column.Name, table.Name, "Guid"); + } + else if (TimeSpan.TryParse(max, out _)) + { + _logger.FormatWarning( + column.Name, + table.Name, + _timeOnlyTypes.Contains(baseColumnType) + ? "TimeOnly" + : "TimeSpan"); + } + + if (defaultClrTpe != typeof(string)) + { + column["ClrType"] = typeof(string); + } + + continue; + } + if (string.Equals(valueType, "BLOB", StringComparison.OrdinalIgnoreCase)) + { + if (defaultClrTpe != typeof(byte[])) + { + column["ClrType"] = typeof(byte[]); + } + + continue; + } + if (string.Equals(valueType, "REAL", StringComparison.OrdinalIgnoreCase)) + { + var min = reader.GetDouble(offset + 1); + var max = reader.GetDouble(offset + 2); + + if (_floatTypes.Contains(baseColumnType)) + { + if (min >= float.MinValue + && max <= float.MaxValue) + { + column["ClrType"] = typeof(float); + + continue; + } + + _logger.OutOfRangeWarning(column.Name, table.Name, "float"); + } + + if (defaultClrTpe != typeof(double)) + { + column["ClrType"] = typeof(double); + } + + continue; + } + + Check.DebugAssert( + string.Equals(valueType, "NULL", StringComparison.OrdinalIgnoreCase), + "Unexpected type: " + valueType); + + if (_typesByName.TryGetValue(baseColumnType, out var type)) + { + Check.DebugAssert(defaultClrTpe != type, "Unnecessary mapping for " + baseColumnType); + + column["ClrType"] = type; + + continue; + } + if (baseColumnType.Contains("INT", StringComparison.OrdinalIgnoreCase) + && !_longTypes.Contains(baseColumnType)) + { + column["ClrType"] = typeof(int); + + continue; + } + } + + static string DelimitIdentifier(string name) + => @$"""{name.Replace(@"""", @"""""")}"""; + } + private void GetPrimaryKey(DbConnection connection, DatabaseTable table) { using var command = connection.CreateCommand(); @@ -315,7 +764,8 @@ ORDER BY "seq" var primaryKey = new DatabasePrimaryKey { - Table = table, Name = name.StartsWith("sqlite_", StringComparison.Ordinal) ? string.Empty : name + Table = table, + Name = name.StartsWith("sqlite_", StringComparison.Ordinal) ? string.Empty : name }; _logger.PrimaryKeyFound(name, table.Name); @@ -404,7 +854,8 @@ ORDER BY "seq" var constraintName = reader1.GetString(0); var uniqueConstraint = new DatabaseUniqueConstraint { - Table = table, Name = constraintName.StartsWith("sqlite_", StringComparison.Ordinal) ? string.Empty : constraintName + Table = table, + Name = constraintName.StartsWith("sqlite_", StringComparison.Ordinal) ? string.Empty : constraintName }; _logger.UniqueConstraintFound(constraintName, table.Name); diff --git a/src/EFCore.Sqlite.Core/Storage/Internal/SqliteTypeMappingSource.cs b/src/EFCore.Sqlite.Core/Storage/Internal/SqliteTypeMappingSource.cs index 08b5cdc4b89..e3cebd332b1 100644 --- a/src/EFCore.Sqlite.Core/Storage/Internal/SqliteTypeMappingSource.cs +++ b/src/EFCore.Sqlite.Core/Storage/Internal/SqliteTypeMappingSource.cs @@ -251,7 +251,6 @@ public static bool IsSpatialiteType(string columnType) ? Text : null, name => Contains(name, "BLOB") - || Contains(name, "BIN") ? Blob : null, name => Contains(name, "REAL") diff --git a/test/EFCore.Design.Tests/Scaffolding/Internal/RelationalScaffoldingModelFactoryTest.cs b/test/EFCore.Design.Tests/Scaffolding/Internal/RelationalScaffoldingModelFactoryTest.cs index b9ba7bce8cd..59b32fd3604 100644 --- a/test/EFCore.Design.Tests/Scaffolding/Internal/RelationalScaffoldingModelFactoryTest.cs +++ b/test/EFCore.Design.Tests/Scaffolding/Internal/RelationalScaffoldingModelFactoryTest.cs @@ -1978,6 +1978,13 @@ public void Correct_arguments_to_scaffolding_typemapper() ValueGenerated = ValueGenerated.OnAddOrUpdate, [ScaffoldingAnnotationNames.ConcurrencyToken] = true }; + var clrTypeColumn = new DatabaseColumn + { + Table = Table, + Name = "ClrType", + StoreType = "char(36)", + [ScaffoldingAnnotationNames.ClrType] = typeof(Guid) + }; var principalTable = new DatabaseTable { @@ -1988,7 +1995,8 @@ public void Correct_arguments_to_scaffolding_typemapper() principalPkColumn, principalAkColumn, principalIndexColumn, - rowversionColumn + rowversionColumn, + clrTypeColumn }, PrimaryKey = new DatabasePrimaryKey { @@ -2070,6 +2078,7 @@ public void Correct_arguments_to_scaffolding_typemapper() Assert.Null(model.FindEntityType("Principal").FindProperty("AlternateKey").GetConfiguredColumnType()); Assert.Null(model.FindEntityType("Principal").FindProperty("Index").GetConfiguredColumnType()); Assert.Null(model.FindEntityType("Principal").FindProperty("Rowversion").GetConfiguredColumnType()); + Assert.Equal(typeof(Guid), model.FindEntityType("Principal").FindProperty("ClrType").ClrType); Assert.Null(model.FindEntityType("Dependent").FindProperty("BlogAlternateKey").GetConfiguredColumnType()); } diff --git a/test/EFCore.Sqlite.FunctionalTests/Scaffolding/SqliteDatabaseModelFactoryTest.cs b/test/EFCore.Sqlite.FunctionalTests/Scaffolding/SqliteDatabaseModelFactoryTest.cs index c9c96b02a8a..39524df0b26 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Scaffolding/SqliteDatabaseModelFactoryTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Scaffolding/SqliteDatabaseModelFactoryTest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.EntityFrameworkCore.Diagnostics.Internal; +using Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; using Microsoft.EntityFrameworkCore.Sqlite.Design.Internal; using Microsoft.EntityFrameworkCore.Sqlite.Diagnostics.Internal; @@ -45,6 +46,7 @@ private void Test( .AddSingleton(); new SqliteDesignTimeServices().ConfigureDesignTimeServices(services); + new SqliteNetTopologySuiteDesignTimeServices().ConfigureDesignTimeServices(services); var databaseModelFactory = services .BuildServiceProvider() // No scope validation; design services only resolved once @@ -357,6 +359,221 @@ RandomProperty randomType }, "DROP TABLE StoreType;"); + [ConditionalTheory] + [InlineData("BIT", typeof(bool))] + [InlineData("BIT(1)", typeof(bool))] + [InlineData("BOOL", typeof(bool))] + [InlineData("BOOLEAN", typeof(bool))] + [InlineData("LOGICAL", typeof(bool))] + [InlineData("YESNO", typeof(bool))] + [InlineData("TINYINT", typeof(byte))] + [InlineData("UINT8", typeof(byte))] + [InlineData("UNSIGNEDINTEGER8", typeof(byte))] + [InlineData("BYTE", typeof(byte))] + [InlineData("SMALLINT", typeof(short))] + [InlineData("INT16", typeof(short))] + [InlineData("INTEGER16", typeof(short))] + [InlineData("SHORT", typeof(short))] + [InlineData("MEDIUMINT", typeof(int))] + [InlineData("INT", typeof(int))] + [InlineData("INT32", typeof(int))] + [InlineData("INTEGER", typeof(int))] + [InlineData("INTEGER32", typeof(int))] + [InlineData("BIGINT", null)] + [InlineData("INT64", null)] + [InlineData("INTEGER64", null)] + [InlineData("LONG", null)] + [InlineData("TINYSINT", typeof(sbyte))] + [InlineData("INT8", typeof(sbyte))] + [InlineData("INTEGER8", typeof(sbyte))] + [InlineData("SBYTE", typeof(sbyte))] + [InlineData("SMALLUINT", typeof(ushort))] + [InlineData("UINT16", typeof(ushort))] + [InlineData("UNSIGNEDINTEGER16", typeof(ushort))] + [InlineData("USHORT", typeof(ushort))] + [InlineData("MEDIUMUINT", typeof(uint))] + [InlineData("UINT", typeof(uint))] + [InlineData("UINT32", typeof(uint))] + [InlineData("UNSIGNEDINTEGER32", typeof(uint))] + [InlineData("BIGUINT", typeof(ulong))] + [InlineData("UINT64", typeof(ulong))] + [InlineData("UNSIGNEDINTEGER", typeof(ulong))] + [InlineData("UNSIGNEDINTEGER64", typeof(ulong))] + [InlineData("ULONG", typeof(ulong))] + [InlineData("REAL", null)] + [InlineData("DOUBLE", null)] + [InlineData("FLOAT", null)] + [InlineData("SINGLE", typeof(float))] + [InlineData("TEXT", null)] + [InlineData("NTEXT", null)] + [InlineData("CHAR(1)", null)] + [InlineData("NCHAR(1)", null)] + [InlineData("VARCHAR(1)", null)] + [InlineData("VARCHAR2(1)", null)] + [InlineData("NVARCHAR(1)", null)] + [InlineData("CLOB", null)] + [InlineData("STRING", typeof(string))] + [InlineData("JSON", typeof(string))] + [InlineData("XML", typeof(string))] + [InlineData("DATEONLY", typeof(DateOnly))] + [InlineData("DATE", typeof(DateTime))] + [InlineData("DATETIME", typeof(DateTime))] + [InlineData("DATETIME2", typeof(DateTime))] + [InlineData("SMALLDATE", typeof(DateTime))] + [InlineData("TIMESTAMP(7)", typeof(DateTime))] + [InlineData("DATETIMEOFFSET", typeof(DateTimeOffset))] + [InlineData("CURRENCY", typeof(decimal))] + [InlineData("DECIMAL(18, 0)", typeof(decimal))] + [InlineData("MONEY", typeof(decimal))] + [InlineData("SMALLMONEY", typeof(decimal))] + [InlineData("NUMBER(18, 0)", typeof(decimal))] + [InlineData("NUMERIC(18, 0)", typeof(decimal))] + [InlineData("GUID", typeof(Guid))] + [InlineData("UNIQUEIDENTIFIER", typeof(Guid))] + [InlineData("UUID", typeof(Guid))] + [InlineData("TIMEONLY", typeof(TimeOnly))] + [InlineData("TIME(7)", typeof(TimeSpan))] + [InlineData("TIMESPAN", typeof(TimeSpan))] + [InlineData("BLOB", null)] + [InlineData("BINARY(10)", null)] + [InlineData("VARBINARY(10)", null)] + [InlineData("IMAGE", null)] + [InlineData("RAW(10)", null)] + [InlineData("GEOMETRY", null)] + [InlineData("GEOMETRYZ", null)] + [InlineData("GEOMETRYM", null)] + [InlineData("GEOMETRYZM", null)] + [InlineData("GEOMETRYCOLLECTION", null)] + [InlineData("GEOMETRYCOLLECTIONZ", null)] + [InlineData("GEOMETRYCOLLECTIONM", null)] + [InlineData("GEOMETRYCOLLECTIONZM", null)] + [InlineData("LINESTRING", null)] + [InlineData("LINESTRINGZ", null)] + [InlineData("LINESTRINGM", null)] + [InlineData("LINESTRINGZM", null)] + [InlineData("MULTILINESTRING", null)] + [InlineData("MULTILINESTRINGZ", null)] + [InlineData("MULTILINESTRINGM", null)] + [InlineData("MULTILINESTRINGZM", null)] + [InlineData("MULTIPOINT", null)] + [InlineData("MULTIPOINTZ", null)] + [InlineData("MULTIPOINTM", null)] + [InlineData("MULTIPOINTZM", null)] + [InlineData("MULTIPOLYGON", null)] + [InlineData("MULTIPOLYGONZ", null)] + [InlineData("MULTIPOLYGONM", null)] + [InlineData("MULTIPOLYGONZM", null)] + [InlineData("POINT", null)] + [InlineData("POINTZ", null)] + [InlineData("POINTM", null)] + [InlineData("POINTZM", null)] + [InlineData("POLYGON", null)] + [InlineData("POLYGONZ", null)] + [InlineData("POLYGONM", null)] + [InlineData("POLYGONZM", null)] + public void Column_ClrType_is_set_when_no_data(string storeType, Type expected) + => Test( + $@" +CREATE TABLE ClrType ( + EmptyColumn {storeType} +);", + Enumerable.Empty(), + Enumerable.Empty(), + model => + { + var table = Assert.Single(model.Tables); + var column = Assert.Single(table.Columns); + Assert.Equal(expected, (Type)column[ScaffoldingAnnotationNames.ClrType]); + }, + "DROP TABLE ClrType"); + + [ConditionalTheory] + [InlineData("INTEGER", "1", typeof(int))] + [InlineData("INTEGER", "2147483648", null)] + [InlineData("BIT", "1", typeof(bool))] + [InlineData("TINYINT", "1", typeof(byte))] + [InlineData("SMALLINT", "1", typeof(short))] + [InlineData("BIGINT", "1", null)] + [InlineData("INT8", "1", typeof(sbyte))] + [InlineData("UINT16", "1", typeof(ushort))] + [InlineData("UINT", "1", typeof(uint))] + [InlineData("UINT64", "1", typeof(ulong))] + [InlineData("UINT64", "-1", typeof(ulong))] + [InlineData("REAL", "0.1", null)] + [InlineData("SINGLE", "0.1", typeof(float))] + [InlineData("TEXT", "'A'", null)] + [InlineData("TEXT", "'2023-01-20'", typeof(DateOnly))] + [InlineData("TEXT", "'2023-01-20 13:37:00'", typeof(DateTime))] + [InlineData("TEXT", "'2023-01-20 13:42:00-08:00'", typeof(DateTimeOffset))] + [InlineData("TEXT", "'0.1'", typeof(decimal))] + [InlineData("TEXT", "'00000000-0000-0000-0000-000000000000'", typeof(Guid))] + [InlineData("TEXT", "'13:44:00'", typeof(TimeSpan))] + [InlineData("TIMEONLY", "'14:34:00'", typeof(TimeOnly))] + [InlineData("BLOB", "x'01'", null)] + [InlineData("GEOMETRY", "x'00010000000000000000000000000000000000000000000000000000000000000000000000007C0100000000000000000000000000000000000000FE'", null)] + [InlineData("POINT", "x'00010000000000000000000000000000000000000000000000000000000000000000000000007C0100000000000000000000000000000000000000FE'", null)] + public void Column_ClrType_is_set_when_data(string storeType, string value, Type expected) + => Test( + $@" +CREATE TABLE IF NOT EXISTS ClrTypeWithData ( + ColumnWithData {storeType} +); + +INSERT INTO ClrTypeWithData VALUES ({value});", + Enumerable.Empty(), + Enumerable.Empty(), + model => + { + var table = Assert.Single(model.Tables); + var column = Assert.Single(table.Columns); + Assert.Equal(expected, (Type)column[ScaffoldingAnnotationNames.ClrType]); + }, + "DROP TABLE ClrTypeWithData"); + + [ConditionalTheory] + [InlineData("INTEGER", "0.1", typeof(double))] + [InlineData("BIT", "2", typeof(int))] + [InlineData("TINYINT", "-1", typeof(int))] + [InlineData("TINYINT", "256", typeof(int))] + [InlineData("SMALLINT", "32768", typeof(int))] + [InlineData("MEDIUMINT", "2147483648", null)] + [InlineData("INT8", "128", typeof(int))] + [InlineData("UINT16", "-1", typeof(int))] + [InlineData("UINT16", "65536", typeof(int))] + [InlineData("UINT", "4294967296", null)] + [InlineData("REAL", "'A'", null)] + [InlineData("SINGLE", "3.402824E+38", typeof(double))] + [InlineData("TEXT", "x'00'", typeof(byte[]))] + [InlineData("DATE", "'A'", typeof(string))] + [InlineData("DATEONLY", "'A'", typeof(string))] + [InlineData("DATETIME", "'A'", typeof(string))] + [InlineData("DATETIMEOFFSET", "'A'", typeof(string))] + [InlineData("DECIMAL", "'A'", typeof(string))] + [InlineData("GUID", "'A'", typeof(string))] + [InlineData("TIME", "'A'", typeof(string))] + [InlineData("TIMEONLY", "'A'", typeof(string))] + [InlineData("TIMEONLY", "'24:00:00'", typeof(TimeSpan))] + [InlineData("BLOB", "1", null)] + [InlineData("GEOMETRY", "1", null)] + [InlineData("POINT", "1", null)] + public void Column_ClrType_is_set_when_insane(string storeType, string value, Type expected) + => Test( + $@" +CREATE TABLE IF NOT EXISTS ClrTypeWithData ( + ColumnWithData {storeType} +); + +INSERT INTO ClrTypeWithData VALUES ({value});", + Enumerable.Empty(), + Enumerable.Empty(), + model => + { + var table = Assert.Single(model.Tables); + var column = Assert.Single(table.Columns); + Assert.Equal(expected, (Type)column[ScaffoldingAnnotationNames.ClrType]); + }, + "DROP TABLE ClrTypeWithData"); + [ConditionalFact] public void Column_nullability_is_set() => Test(