diff --git a/src/EFCore.MySql/Extensions/MySqlPropertyExtensions.cs b/src/EFCore.MySql/Extensions/MySqlPropertyExtensions.cs index f90d51ef6..c8f08b6dc 100644 --- a/src/EFCore.MySql/Extensions/MySqlPropertyExtensions.cs +++ b/src/EFCore.MySql/Extensions/MySqlPropertyExtensions.cs @@ -3,8 +3,11 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Pomelo.EntityFrameworkCore.MySql.Internal; using Pomelo.EntityFrameworkCore.MySql.Metadata.Internal; +using Pomelo.EntityFrameworkCore.MySql.Storage.Internal; namespace Pomelo.EntityFrameworkCore.MySql.Extensions { @@ -102,7 +105,11 @@ public static bool IsCompatibleIdentityColumn(IProperty property) { var type = property.ClrType; - return (type.IsInteger() || type == typeof(decimal) || type == typeof(DateTime) || type == typeof(DateTimeOffset)) && !HasConverter(property); + return (type.IsInteger() + || type == typeof(decimal) + || type == typeof(DateTime) + || type == typeof(DateTimeOffset)) + && !HasConverter(property); } /// @@ -114,11 +121,21 @@ public static bool IsCompatibleComputedColumn(IProperty property) { var type = property.ClrType; - return (type == typeof(DateTime) || type == typeof(DateTimeOffset)) && !HasConverter(property); + // RowVersion uses byte[] and the BytesToDateTimeConverter. + return (type == typeof(DateTime) || type == typeof(DateTimeOffset)) && !HasConverter(property) + || type == typeof(byte[]) && !HasExternalConverter(property); } private static bool HasConverter(IProperty property) - => (property.FindTypeMapping()?.Converter - ?? property.GetValueConverter()) != null; + => GetConverter(property) != null; + + private static bool HasExternalConverter(IProperty property) + { + var converter = GetConverter(property); + return converter != null && !(converter is BytesToDateTimeConverter); + } + + private static ValueConverter GetConverter(IProperty property) + => property.FindTypeMapping()?.Converter ?? property.GetValueConverter(); } } diff --git a/src/EFCore.MySql/Migrations/MySqlMigrationsSqlGenerator.cs b/src/EFCore.MySql/Migrations/MySqlMigrationsSqlGenerator.cs index c342453cd..61a573cca 100644 --- a/src/EFCore.MySql/Migrations/MySqlMigrationsSqlGenerator.cs +++ b/src/EFCore.MySql/Migrations/MySqlMigrationsSqlGenerator.cs @@ -561,11 +561,10 @@ protected override void Generate( ? Array.CreateInstance(clrType.GetElementType(), 0) : clrType.GetDefaultValue()); - var isRowVersion = property.ClrType == typeof(byte[]) + var isRowVersion = (property.ClrType == typeof(DateTime) || property.ClrType == typeof(byte[])) && property.IsConcurrencyToken && property.ValueGenerated == ValueGenerated.OnAddOrUpdate; - - + var addColumnOperation = new AddColumnOperation { Schema = operation.Schema, diff --git a/src/EFCore.MySql/Scaffolding/Internal/MySqlDatabaseModelFactory.cs b/src/EFCore.MySql/Scaffolding/Internal/MySqlDatabaseModelFactory.cs index 97046c14f..bd6374137 100644 --- a/src/EFCore.MySql/Scaffolding/Internal/MySqlDatabaseModelFactory.cs +++ b/src/EFCore.MySql/Scaffolding/Internal/MySqlDatabaseModelFactory.cs @@ -5,13 +5,12 @@ using System.Collections.Generic; using System.Data; using System.Data.Common; +using System.Diagnostics; using System.Linq; -using System.Text; using System.Text.RegularExpressions; using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; -using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Scaffolding; @@ -20,7 +19,6 @@ using Microsoft.Extensions.Logging; using MySql.Data.MySqlClient; using Pomelo.EntityFrameworkCore.MySql.Infrastructure.Internal; -using Pomelo.EntityFrameworkCore.MySql.Internal; using Pomelo.EntityFrameworkCore.MySql.Storage.Internal; namespace Pomelo.EntityFrameworkCore.MySql.Scaffolding.Internal @@ -117,7 +115,16 @@ private static Func GenerateTableFilter( return tables.Count > 0 ? (s, t) => tables.Contains(t) : (Func)null; } - private const string GetTablesQuery = @"SHOW FULL TABLES WHERE TABLE_TYPE = 'BASE TABLE'"; + private const string GetTablesQuery = @"SELECT + `TABLE_NAME`, + `TABLE_TYPE`, + IF(`TABLE_COMMENT` = 'VIEW' AND `TABLE_TYPE` = 'VIEW', '', `TABLE_COMMENT`) AS `TABLE_COMMENT` +FROM + `INFORMATION_SCHEMA`.`TABLES` +WHERE + `TABLE_SCHEMA` = SCHEMA() +AND + `TABLE_TYPE` IN ('BASE TABLE', 'VIEW');"; private IEnumerable GetTables( DbConnection connection, @@ -131,11 +138,17 @@ private IEnumerable GetTables( { while (reader.Read()) { - var table = new DatabaseTable - { - Schema = null, - Name = reader.GetString(0).Replace("`", "") - }; + var name = reader.GetString("TABLE_NAME"); + var type = reader.GetString("TABLE_TYPE"); + var comment = reader.GetString("TABLE_COMMENT"); + + var table = string.Equals(type, "base table", StringComparison.OrdinalIgnoreCase) + ? new DatabaseTable() + : new DatabaseView(); + + table.Schema = null; + table.Name = name; + table.Comment = string.IsNullOrEmpty(comment) ? null : comment; if (filter?.Invoke(table.Schema, table.Name) ?? true) { @@ -154,7 +167,20 @@ private IEnumerable GetTables( } } - private const string GetColumnsQuery = @"SHOW COLUMNS FROM `{0}`"; + private const string GetColumnsQuery = @"SELECT + `COLUMN_NAME`, + `COLUMN_DEFAULT`, + IF(`IS_NULLABLE` = 'YES', 1, 0) AS `IS_NULLABLE`, + `DATA_TYPE`, + `COLUMN_TYPE`, + `COLUMN_COMMENT`, + `EXTRA` +FROM + `INFORMATION_SCHEMA`.`COLUMNS` +WHERE + `TABLE_SCHEMA` = SCHEMA() +AND + `TABLE_NAME` = '{0}'"; private void GetColumns( DbConnection connection, @@ -170,21 +196,46 @@ private void GetColumns( { while (reader.Read()) { - var extra = reader.GetString(5); + var name = reader.GetValueOrDefault("COLUMN_NAME"); + var defaultValue = reader.GetValueOrDefault("COLUMN_DEFAULT"); + var nullable = reader.GetBoolean("IS_NULLABLE"); + var dataType = reader.GetValueOrDefault("DATA_TYPE"); + var columType = reader.GetValueOrDefault("COLUMN_TYPE"); + var extra = reader.GetValueOrDefault("EXTRA"); + var comment = reader.GetValueOrDefault("COLUMN_COMMENT"); + ValueGenerated valueGenerated; + if (extra.IndexOf("auto_increment", StringComparison.Ordinal) >= 0) { valueGenerated = ValueGenerated.OnAdd; } else if (extra.IndexOf("on update", StringComparison.Ordinal) >= 0) { - if (reader[4] != DBNull.Value && extra.IndexOf(reader[4].ToString(), StringComparison.Ordinal) > 0) + if (defaultValue != null + && (extra.IndexOf(defaultValue, StringComparison.Ordinal) > 0 + || string.Equals(dataType, "timestamp", StringComparison.OrdinalIgnoreCase) + && extra.IndexOf("CURRENT_TIMESTAMP", StringComparison.Ordinal) > 0)) { valueGenerated = ValueGenerated.OnAddOrUpdate; } else { - valueGenerated = ValueGenerated.OnUpdate; + // BUG: EF Core does not handle code generation for `OnUpdate`. + // Instead, it just generates an empty method call ".()". + // Tracked by: https://github.com/aspnet/EntityFrameworkCore/issues/18579 + // + // As a partial workaround, use `OnAddOrUpdate`, if a default value + // has been specified. + + if (defaultValue != null) + { + valueGenerated = ValueGenerated.OnAddOrUpdate; + } + else + { + valueGenerated = ValueGenerated.OnUpdate; + } } } else @@ -192,18 +243,17 @@ private void GetColumns( valueGenerated = ValueGenerated.Never; } + defaultValue = FilterClrDefaults(dataType, nullable, defaultValue); var column = new DatabaseColumn { Table = table, - Name = reader.GetString(0), - StoreType = Regex.Replace(reader.GetString(1), @"(?<=int)\(\d+\)(?=\sunsigned)", - string.Empty), - IsNullable = reader.GetString(2) == "YES", - DefaultValueSql = reader[4] == DBNull.Value - ? null - : '\'' + ParseToMySqlString(reader[4].ToString()) + '\'', - ValueGenerated = valueGenerated + Name = name, + StoreType = columType, + IsNullable = nullable, + DefaultValueSql = CreateDefaultValueString(defaultValue, dataType), + ValueGenerated = valueGenerated, + Comment = string.IsNullOrEmpty(comment) ? null : comment, }; table.Columns.Add(column); } @@ -212,13 +262,70 @@ private void GetColumns( } } - private string ParseToMySqlString(string str) + private static string FilterClrDefaults(string dataTypeName, bool nullable, string defaultValue) + { + if (defaultValue == null) + { + return null; + } + + if (nullable) + { + return defaultValue; + } + + if (defaultValue == string.Empty) + { + if (dataTypeName.Contains("char") + || dataTypeName.Contains("text")) + { + return null; + } + } + + if (defaultValue == "0") + { + if (dataTypeName == "bit" + || dataTypeName == "tinyint" + || dataTypeName == "smallint" + || dataTypeName == "int" + || dataTypeName == "bigint" + || dataTypeName == "decimal" + || dataTypeName == "double" + || dataTypeName == "float") + { + return null; + } + } + else if (Regex.IsMatch(defaultValue, @"^0\.0+$")) + { + if (dataTypeName == "decimal" + || dataTypeName == "double" + || dataTypeName == "float") + { + return null; + } + } + + return defaultValue; + } + + private string CreateDefaultValueString(string defaultValue, string dataType) { // Pending the MySqlConnector implement MySqlCommandBuilder class - return str - .Replace("\\", "\\\\") - .Replace("'", "\\'") - .Replace("\"", "\\\""); + if (string.Equals(dataType, "timestamp", StringComparison.OrdinalIgnoreCase) + && string.Equals(defaultValue, "CURRENT_TIMESTAMP", StringComparison.OrdinalIgnoreCase)) + { + return defaultValue; + } + else if (defaultValue != null) + { + return "'" + defaultValue.Replace(@"\", @"\\").Replace("'", "''") + "'"; + } + else + { + return null; + } } private const string GetPrimaryQuery = @"SELECT `INDEX_NAME`, @@ -300,9 +407,7 @@ private void GetIndexes(DbConnection connection, IReadOnlyList ta { Table = table, Name = reader.GetString(0), - IsUnique = reader.GetFieldType(1) == typeof(string) - ? reader.GetString(1) == "1" - : !reader.GetBoolean(1) + IsUnique = !reader.GetBoolean(1) }; foreach (var column in reader.GetString(2).Split(',')) diff --git a/src/EFCore.MySql/Scaffolding/Internal/SqlDataReaderExtension.cs b/src/EFCore.MySql/Scaffolding/Internal/SqlDataReaderExtension.cs new file mode 100644 index 000000000..7b14bccec --- /dev/null +++ b/src/EFCore.MySql/Scaffolding/Internal/SqlDataReaderExtension.cs @@ -0,0 +1,42 @@ +using System.Data.Common; +using JetBrains.Annotations; + +namespace Pomelo.EntityFrameworkCore.MySql.Scaffolding.Internal +{ + /// + /// 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 class SqlDataReaderExtension + { + /// + /// 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 T GetValueOrDefault([NotNull] this DbDataReader reader, [NotNull] string name) + { + var idx = reader.GetOrdinal(name); + return reader.IsDBNull(idx) + ? default + : reader.GetFieldValue(idx); + } + + /// + /// 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 T GetValueOrDefault([NotNull] this DbDataRecord record, [NotNull] string name) + { + var idx = record.GetOrdinal(name); + return record.IsDBNull(idx) + ? default + : (T)record.GetValue(idx); + } + } +} diff --git a/src/EFCore.MySql/Storage/Internal/MySqlDateTimeTypeMapping.cs b/src/EFCore.MySql/Storage/Internal/MySqlDateTimeTypeMapping.cs index b8d01857d..bedcb53e8 100644 --- a/src/EFCore.MySql/Storage/Internal/MySqlDateTimeTypeMapping.cs +++ b/src/EFCore.MySql/Storage/Internal/MySqlDateTimeTypeMapping.cs @@ -30,12 +30,13 @@ public class MySqlDateTimeTypeMapping : DateTimeTypeMapping /// public MySqlDateTimeTypeMapping( [NotNull] string storeType, + Type clrType, ValueConverter converter = null, ValueComparer comparer = null, int? precision = null) : this( new RelationalTypeMappingParameters( - new CoreTypeMappingParameters(typeof(DateTime), converter, comparer), + new CoreTypeMappingParameters(clrType, converter, comparer), storeType, precision == null ? StoreTypePostfix.None : StoreTypePostfix.Precision, System.Data.DbType.DateTime, diff --git a/src/EFCore.MySql/Storage/Internal/MySqlTypeMappingSource.cs b/src/EFCore.MySql/Storage/Internal/MySqlTypeMappingSource.cs index 4c05b1c86..6229b9136 100644 --- a/src/EFCore.MySql/Storage/Internal/MySqlTypeMappingSource.cs +++ b/src/EFCore.MySql/Storage/Internal/MySqlTypeMappingSource.cs @@ -53,9 +53,9 @@ public class MySqlTypeMappingSource : RelationalTypeMappingSource // DateTime private readonly MySqlDateTypeMapping _date = new MySqlDateTypeMapping("date", DbType.Date); - private readonly MySqlDateTimeTypeMapping _dateTime6 = new MySqlDateTimeTypeMapping("datetime", precision: 6); - private readonly MySqlDateTimeTypeMapping _dateTime = new MySqlDateTimeTypeMapping("datetime"); - private readonly MySqlDateTimeTypeMapping _timeStamp6 = new MySqlDateTimeTypeMapping("timestamp", precision: 6); + private readonly MySqlDateTimeTypeMapping _dateTime6 = new MySqlDateTimeTypeMapping("datetime", typeof(DateTime), precision: 6); + private readonly MySqlDateTimeTypeMapping _dateTime = new MySqlDateTimeTypeMapping("datetime", typeof(DateTime)); + private readonly MySqlDateTimeTypeMapping _timeStamp6 = new MySqlDateTimeTypeMapping("timestamp", typeof(DateTime), precision: 6); private readonly MySqlDateTimeOffsetTypeMapping _dateTimeOffset6 = new MySqlDateTimeOffsetTypeMapping("datetime", precision: 6); private readonly MySqlDateTimeOffsetTypeMapping _dateTimeOffset = new MySqlDateTimeOffsetTypeMapping("datetime"); private readonly MySqlDateTimeOffsetTypeMapping _timeStampOffset6 = new MySqlDateTimeOffsetTypeMapping("timestamp", precision: 6); @@ -65,11 +65,13 @@ public class MySqlTypeMappingSource : RelationalTypeMappingSource private readonly RelationalTypeMapping _binaryRowVersion = new MySqlDateTimeTypeMapping( "timestamp", + typeof(byte[]), new BytesToDateTimeConverter(), new ByteArrayComparer()); private readonly RelationalTypeMapping _binaryRowVersion6 = new MySqlDateTimeTypeMapping( "timestamp", + typeof(byte[]), new BytesToDateTimeConverter(), new ByteArrayComparer(), precision: 6); @@ -426,7 +428,9 @@ private RelationalTypeMapping FindRawMapping(RelationalTypeMappingInfo mappingIn { if (mappingInfo.IsRowVersion == true) { - return _connectionInfo.ServerVersion.SupportsDateTime6 ? _binaryRowVersion6 : _binaryRowVersion; + return _connectionInfo.ServerVersion.SupportsDateTime6 + ? _binaryRowVersion6 + : _binaryRowVersion; } var size = mappingInfo.Size ?? @@ -440,5 +444,17 @@ private RelationalTypeMapping FindRawMapping(RelationalTypeMappingInfo mappingIn return null; } + + protected override string ParseStoreTypeName(string storeTypeName, out bool? unicode, out int? size, out int? precision, out int? scale) + { + var storeTypeBaseName = base.ParseStoreTypeName(storeTypeName, out unicode, out size, out precision, out scale); + + if (storeTypeName?.Contains("unsigned", StringComparison.OrdinalIgnoreCase) ?? false) + { + return storeTypeBaseName + " unsigned"; + } + + return storeTypeBaseName; + } } } diff --git a/test/EFCore.MySql.FunctionalTests/TestUtilities/MySqlDatabaseCleaner.cs b/test/EFCore.MySql.FunctionalTests/TestUtilities/MySqlDatabaseCleaner.cs index 04c93ec03..527435ce6 100644 --- a/test/EFCore.MySql.FunctionalTests/TestUtilities/MySqlDatabaseCleaner.cs +++ b/test/EFCore.MySql.FunctionalTests/TestUtilities/MySqlDatabaseCleaner.cs @@ -34,5 +34,19 @@ protected override IDatabaseModelFactory CreateDatabaseModelFactory(ILoggerFacto _options); protected override bool AcceptIndex(DatabaseIndex index) => false; + protected override bool AcceptTable(DatabaseTable table) => !(table is DatabaseView); + + protected override string BuildCustomSql(DatabaseModel databaseModel) + => @"SET @views = NULL; + +SELECT GROUP_CONCAT(CONCAT('`', `TABLE_SCHEMA`, '.', `TABLE_NAME`, '`')) INTO @views +FROM `INFORMATION_SCHEMA`.`VIEWS` +WHERE `TABLE_SCHEMA` = SCHEMA(); + +SET @views = IFNULL(CONCAT('DROP VIEW IF EXISTS ', @views), 'SELECT 0'); + +PREPARE stmt FROM @views; +EXECUTE stmt; +DEALLOCATE PREPARE stmt;"; } }