diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fecd3c2648f..2d15c4e1f22 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -18,6 +18,7 @@ updates: - dependency-name: Microsoft.Data.SqlClient - dependency-name: Microsoft.DotNet.PlatformAbstractions - dependency-name: mod_spatialite + - dependency-name: Mono.TextTemplating - dependency-name: NetTopologySuite* - dependency-name: Newtonsoft.Json - dependency-name: SQLitePCLRaw* diff --git a/Directory.Build.props b/Directory.Build.props index 5e985fd1e50..94132fb6e9b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -35,10 +35,6 @@ $(NoWarn.Replace(';1591', '')) - - - true - diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 401c4812c95..368101f46fe 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,59 +1,59 @@ - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/runtime - 4017327955f1d8ddc43980eb1848c52fbb131dfc + 7698a9a80c5f6270aa1122d79ce419c7b03f2498 - + https://github.com/dotnet/arcade - f7136626d0109856df867481219eb7366951985d + 8ed47fcae6a5d2d40483ed81858f4ede8eab7ae2 - + https://github.com/dotnet/arcade - f7136626d0109856df867481219eb7366951985d + 8ed47fcae6a5d2d40483ed81858f4ede8eab7ae2 diff --git a/eng/Versions.props b/eng/Versions.props index 1cd42cab6ea..1424e4e05ae 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -15,17 +15,17 @@ False - 7.0.0-preview.3.22120.1 - 7.0.0-preview.3.22120.1 - 7.0.0-preview.3.22120.1 - 7.0.0-preview.3.22120.1 - 7.0.0-preview.3.22120.1 - 7.0.0-preview.3.22120.1 - 7.0.0-preview.3.22120.1 - 7.0.0-preview.3.22120.1 - 7.0.0-preview.3.22120.1 - 7.0.0-preview.3.22120.1 - 7.0.0-preview.3.22120.1 + 7.0.0-preview.3.22157.1 + 7.0.0-preview.3.22157.1 + 7.0.0-preview.3.22157.1 + 7.0.0-preview.3.22157.1 + 7.0.0-preview.3.22157.1 + 7.0.0-preview.3.22157.1 + 7.0.0-preview.3.22157.1 + 7.0.0-preview.3.22157.1 + 7.0.0-preview.3.22157.1 + 7.0.0-preview.3.22157.1 + 7.0.0-preview.3.22157.1 4.0.1 diff --git a/global.json b/global.json index d6fcb9e26c5..c6b1bc1bbab 100644 --- a/global.json +++ b/global.json @@ -18,7 +18,7 @@ "rollForward": "latestMajor" }, "msbuild-sdks": { - "Microsoft.DotNet.Arcade.Sdk": "7.0.0-beta.22124.4", - "Microsoft.DotNet.Helix.Sdk": "7.0.0-beta.22124.4" + "Microsoft.DotNet.Arcade.Sdk": "7.0.0-beta.22154.3", + "Microsoft.DotNet.Helix.Sdk": "7.0.0-beta.22154.3" } } diff --git a/src/EFCore.Analyzers/InternalUsageDiagnosticAnalyzer.cs b/src/EFCore.Analyzers/InternalUsageDiagnosticAnalyzer.cs index b4d65f6a132..55faac08b05 100644 --- a/src/EFCore.Analyzers/InternalUsageDiagnosticAnalyzer.cs +++ b/src/EFCore.Analyzers/InternalUsageDiagnosticAnalyzer.cs @@ -16,7 +16,8 @@ public sealed class InternalUsageDiagnosticAnalyzer : DiagnosticAnalyzer private static readonly int EFLen = "EntityFrameworkCore".Length; private static readonly DiagnosticDescriptor Descriptor - = new( + // HACK: Work around dotnet/roslyn-analyzers#5828 by not using target-typed new + = new DiagnosticDescriptor( Id, title: AnalyzerStrings.InternalUsageTitle, messageFormat: AnalyzerStrings.InternalUsageMessageFormat, diff --git a/src/EFCore.Cosmos/Query/Internal/RandomTranslator.cs b/src/EFCore.Cosmos/Query/Internal/RandomTranslator.cs index 116a52898ab..fefab0714ff 100644 --- a/src/EFCore.Cosmos/Query/Internal/RandomTranslator.cs +++ b/src/EFCore.Cosmos/Query/Internal/RandomTranslator.cs @@ -43,7 +43,7 @@ public virtual SqlExpression Translate( => MethodInfo.Equals(method) ? _sqlExpressionFactory.Function( "RAND", - Array.Empty(), + Enumerable.Empty(), method.ReturnType) : null; } diff --git a/src/EFCore.Design/Design/DesignTimeServiceCollectionExtensions.cs b/src/EFCore.Design/Design/DesignTimeServiceCollectionExtensions.cs index 15a2dfe99cc..e198619bc55 100644 --- a/src/EFCore.Design/Design/DesignTimeServiceCollectionExtensions.cs +++ b/src/EFCore.Design/Design/DesignTimeServiceCollectionExtensions.cs @@ -7,6 +7,8 @@ using Microsoft.EntityFrameworkCore.Migrations.Internal; using Microsoft.EntityFrameworkCore.Scaffolding; using Microsoft.EntityFrameworkCore.Scaffolding.Internal; +using Microsoft.EntityFrameworkCore.TextTemplating; +using Microsoft.EntityFrameworkCore.TextTemplating.Internal; namespace Microsoft.EntityFrameworkCore.Design; @@ -52,7 +54,9 @@ public static IServiceCollection AddEntityFrameworkDesignTimeServices( .TryAddSingleton(reporter) .TryAddSingleton() .TryAddSingleton() - .TryAddSingleton() + .TryAddSingleton() + .TryAddSingletonEnumerable() + .TryAddSingletonEnumerable() .TryAddSingleton() .TryAddSingleton() .TryAddSingleton() diff --git a/src/EFCore.Design/Design/Internal/DatabaseOperations.cs b/src/EFCore.Design/Design/Internal/DatabaseOperations.cs index 0aded8b9714..e6db7ce4479 100644 --- a/src/EFCore.Design/Design/Internal/DatabaseOperations.cs +++ b/src/EFCore.Design/Design/Internal/DatabaseOperations.cs @@ -96,7 +96,8 @@ public virtual SavedModelFiles ScaffoldContext( UseNullableReferenceTypes = _nullable, ContextDir = MakeDirRelative(outputDir, outputContextDir), ContextName = dbContextClassName, - SuppressOnConfiguring = suppressOnConfiguring + SuppressOnConfiguring = suppressOnConfiguring, + ProjectDir = _projectDir }); return scaffolder.Save( diff --git a/src/EFCore.Design/EFCore.Design.csproj b/src/EFCore.Design/EFCore.Design.csproj index f18008e2f39..55c208b9953 100644 --- a/src/EFCore.Design/EFCore.Design.csproj +++ b/src/EFCore.Design/EFCore.Design.csproj @@ -60,6 +60,7 @@ + diff --git a/src/EFCore.Design/Migrations/Design/CSharpSnapshotGenerator.cs b/src/EFCore.Design/Migrations/Design/CSharpSnapshotGenerator.cs index 533cb70ebf9..42c13f2477e 100644 --- a/src/EFCore.Design/Migrations/Design/CSharpSnapshotGenerator.cs +++ b/src/EFCore.Design/Migrations/Design/CSharpSnapshotGenerator.cs @@ -931,7 +931,8 @@ protected virtual void GenerateCheckConstraint( .Append(", ") .Append(Code.Literal(checkConstraint.Sql)); - if (checkConstraint.Name != (checkConstraint.GetDefaultName() ?? checkConstraint.ModelName)) + if (checkConstraint.Name != null + && checkConstraint.Name != (checkConstraint.GetDefaultName() ?? checkConstraint.ModelName)) { stringBuilder .Append(", c => c.HasName(") diff --git a/src/EFCore.Design/Properties/DesignStrings.Designer.cs b/src/EFCore.Design/Properties/DesignStrings.Designer.cs index 1f43f910c25..84c4d344d98 100644 --- a/src/EFCore.Design/Properties/DesignStrings.Designer.cs +++ b/src/EFCore.Design/Properties/DesignStrings.Designer.cs @@ -1,5 +1,7 @@ // +using System; +using System.Reflection; using System.Resources; #nullable enable @@ -118,7 +120,7 @@ public static string CompiledModelTypeMapping(object? entityType, object? proper entityType, property, customize, className); /// - /// The property '{entityType}.{property}' has a value comparer configured using a ValueComparer instance. Instead, create types that inherit from ValueConverter and ValueComparer and use '{method}HasConversion=<ConverterType, ComparerType=>()' or '{method}(Type converterType, Type comparerType)' to configure the value converter and comparer. + /// The property '{entityType}.{property}' has a value comparer configured using a ValueComparer instance. Instead, create types that inherit from ValueConverter and ValueComparer and use '{method}=<ConverterType, ComparerType=>()' or '{method}(Type converterType, Type comparerType)' to configure the value converter and comparer. /// public static string CompiledModelValueComparer(object? entityType, object? property, object? method) => string.Format( @@ -126,7 +128,7 @@ public static string CompiledModelValueComparer(object? entityType, object? prop entityType, property, method); /// - /// The property '{entityType}.{property}' has a value converter configured using a ValueConverter instance or inline expressions. Instead, create a type that inherits from ValueConverter and use '{method}HasConversion=<ConverterType=>()' or '{method}(Type converterType)' to configure the value converter. + /// The property '{entityType}.{property}' has a value converter configured using a ValueConverter instance or inline expressions. Instead, create a type that inherits from ValueConverter and use '{method}=<ConverterType=>()' or '{method}(Type converterType)' to configure the value converter. /// public static string CompiledModelValueConverter(object? entityType, object? property, object? method) => string.Format( @@ -199,6 +201,14 @@ public static string DuplicateMigrationName(object? migrationName) GetString("DuplicateMigrationName", nameof(migrationName)), migrationName); + /// + /// The encoding '{encoding}' specified in the output directive will be ignored. EF Core always scaffolds files using the encoding 'utf-8'. + /// + public static string EncodingIgnored(object? encoding) + => string.Format( + GetString("EncodingIgnored", nameof(encoding)), + encoding); + /// /// An error occurred while accessing the database. Continuing without the information provided by the database. Error: {message} /// @@ -657,6 +667,14 @@ public static string UnhandledEnumValue(object? enumValue) GetString("UnhandledEnumValue", nameof(enumValue)), enumValue); + /// + /// Failed to resolve type for directive processor {name}. + /// + public static string UnknownDirectiveProcessor(object? name) + => string.Format( + GetString("UnknownDirectiveProcessor", nameof(name)), + name); + /// /// Cannot scaffold C# literals of type '{literalType}'. The provider should implement CoreTypeMapping.GenerateCodeLiteral to support using it at design time. /// @@ -777,3 +795,4 @@ private static string GetString(string name, params string[] formatterNames) } } } + diff --git a/src/EFCore.Design/Properties/DesignStrings.resx b/src/EFCore.Design/Properties/DesignStrings.resx index 0a7e6577b7f..c8e89f0c6b7 100644 --- a/src/EFCore.Design/Properties/DesignStrings.resx +++ b/src/EFCore.Design/Properties/DesignStrings.resx @@ -1,17 +1,17 @@  - @@ -189,6 +189,9 @@ The name '{migrationName}' is used by an existing migration. + + The encoding '{encoding}' specified in the output directive will be ignored. EF Core always scaffolds files using the encoding 'utf-8'. + An error occurred while accessing the database. Continuing without the information provided by the database. Error: {message} @@ -378,6 +381,9 @@ Change your target project to the migrations project by using the Package Manage Unhandled enum value '{enumValue}'. + + Failed to resolve type for directive processor {name}. + Cannot scaffold C# literals of type '{literalType}'. The provider should implement CoreTypeMapping.GenerateCodeLiteral to support using it at design time. @@ -420,4 +426,4 @@ Change your target project to the migrations project by using the Package Manage Writing model snapshot to '{file}'. - + \ No newline at end of file diff --git a/src/EFCore.Design/Scaffolding/IModelCodeGeneratorSelector.cs b/src/EFCore.Design/Scaffolding/IModelCodeGeneratorSelector.cs index b2d40980104..1e8d73b84a1 100644 --- a/src/EFCore.Design/Scaffolding/IModelCodeGeneratorSelector.cs +++ b/src/EFCore.Design/Scaffolding/IModelCodeGeneratorSelector.cs @@ -16,5 +16,16 @@ public interface IModelCodeGeneratorSelector /// /// The programming language. /// The . + [Obsolete("Use the overload that takes ModelCodeGenerationOptions instead.")] IModelCodeGenerator Select(string? language); + + /// + /// Selects an service for a given set of options. + /// + /// The options. + /// The . + IModelCodeGenerator Select(ModelCodeGenerationOptions options) +#pragma warning disable CS0618 // Type or member is obsolete + => Select(options.Language); +#pragma warning restore CS0618 } diff --git a/src/EFCore.Design/Scaffolding/Internal/CSharpDbContextGenerator.cs b/src/EFCore.Design/Scaffolding/Internal/CSharpDbContextGenerator.cs index 78e4ff45d77..57459df310e 100644 --- a/src/EFCore.Design/Scaffolding/Internal/CSharpDbContextGenerator.cs +++ b/src/EFCore.Design/Scaffolding/Internal/CSharpDbContextGenerator.cs @@ -558,7 +558,7 @@ private void GenerateIndex(IIndex index) var lines = new List { - $".{nameof(EntityTypeBuilder.HasIndex)}({_code.Lambda(index.Properties, "e")}, {_code.Literal(index.GetDatabaseName())})" + $".{nameof(EntityTypeBuilder.HasIndex)}({_code.Lambda(index.Properties, "e")}, {_code.Literal(index.GetDatabaseName()!)})" }; annotations.Remove(RelationalAnnotationNames.Name); @@ -845,7 +845,7 @@ private void GenerateManyToMany(ISkipNavigation skipNavigation) _annotationCodeGenerator.RemoveAnnotationsHandledByConventions(index, indexAnnotations); lines.Add( - $"j.{nameof(EntityTypeBuilder.HasIndex)}({_code.Literal(index.Properties.Select(e => e.Name).ToArray())}, {_code.Literal(index.GetDatabaseName())})"); + $"j.{nameof(EntityTypeBuilder.HasIndex)}({_code.Literal(index.Properties.Select(e => e.Name).ToArray())}, {_code.Literal(index.GetDatabaseName()!)})"); indexAnnotations.Remove(RelationalAnnotationNames.Name); if (index.IsUnique) diff --git a/src/EFCore.Design/Scaffolding/Internal/ModelCodeGeneratorSelector.cs b/src/EFCore.Design/Scaffolding/Internal/ModelCodeGeneratorSelector.cs index 517182fe6ac..5eaff106a3c 100644 --- a/src/EFCore.Design/Scaffolding/Internal/ModelCodeGeneratorSelector.cs +++ b/src/EFCore.Design/Scaffolding/Internal/ModelCodeGeneratorSelector.cs @@ -13,6 +13,8 @@ namespace Microsoft.EntityFrameworkCore.Scaffolding.Internal; /// public class ModelCodeGeneratorSelector : LanguageBasedSelector, IModelCodeGeneratorSelector { + private readonly IEnumerable _templatedModelGenerators; + /// /// 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 @@ -20,7 +22,13 @@ public class ModelCodeGeneratorSelector : LanguageBasedSelector public ModelCodeGeneratorSelector(IEnumerable services) - : base(services) - { - } + : base(services.Except(services.OfType()).ToList()) + => _templatedModelGenerators = services.OfType().ToList(); + + /// + public virtual IModelCodeGenerator Select(ModelCodeGenerationOptions options) + => _templatedModelGenerators + .Where(g => options.ProjectDir != null && g.HasTemplates(options.ProjectDir)) + .LastOrDefault() + ?? Select(options.Language); } diff --git a/src/EFCore.Design/Scaffolding/Internal/ReverseEngineerScaffolder.cs b/src/EFCore.Design/Scaffolding/Internal/ReverseEngineerScaffolder.cs index 9d984db84dd..894569031a4 100644 --- a/src/EFCore.Design/Scaffolding/Internal/ReverseEngineerScaffolder.cs +++ b/src/EFCore.Design/Scaffolding/Internal/ReverseEngineerScaffolder.cs @@ -114,7 +114,7 @@ public virtual ScaffoldedModel ScaffoldModel( : DefaultDbContextName; } - var codeGenerator = ModelCodeGeneratorSelector.Select(codeOptions.Language); + var codeGenerator = ModelCodeGeneratorSelector.Select(codeOptions); return codeGenerator.GenerateModel(model, codeOptions); } diff --git a/src/EFCore.Design/Scaffolding/Internal/TextTemplatingModelGenerator.cs b/src/EFCore.Design/Scaffolding/Internal/TextTemplatingModelGenerator.cs new file mode 100644 index 00000000000..4b524c61332 --- /dev/null +++ b/src/EFCore.Design/Scaffolding/Internal/TextTemplatingModelGenerator.cs @@ -0,0 +1,172 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CodeDom.Compiler; +using System.Text; +using Microsoft.EntityFrameworkCore.Design.Internal; +using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.EntityFrameworkCore.TextTemplating; +using Microsoft.EntityFrameworkCore.TextTemplating.Internal; + +namespace Microsoft.EntityFrameworkCore.Scaffolding.Internal; + +internal class TextTemplatingModelGenerator : TemplatedModelGenerator +{ + private readonly ITextTemplating _host; + private readonly IOperationReporter _reporter; + + public TextTemplatingModelGenerator( + ModelCodeGeneratorDependencies dependencies, + ITextTemplating textTemplatingService, + IOperationReporter reporter) + : base(dependencies) + { + _host = textTemplatingService; + _reporter = reporter; + } + + public override bool HasTemplates(string projectDir) + => File.Exists(Path.Combine(projectDir, TemplatesDirectory, "DbContext.t4")); + + public override ScaffoldedModel GenerateModel(IModel model, ModelCodeGenerationOptions options) + { + if (options.ContextName == null) + { + throw new ArgumentException( + CoreStrings.ArgumentPropertyNull(nameof(options.ContextName), nameof(options)), nameof(options)); + } + + if (options.ConnectionString == null) + { + throw new ArgumentException( + CoreStrings.ArgumentPropertyNull(nameof(options.ConnectionString), nameof(options)), nameof(options)); + } + + var resultingFiles = new ScaffoldedModel(); + + var contextTemplate = Path.Combine(options.ProjectDir!, TemplatesDirectory, "DbContext.t4"); + + Check.DebugAssert(_host.Session == null, "Session is not null."); + _host.Session = _host.CreateSession(); + try + { + _host.Session.Add("Model", model); + _host.Session.Add("Options", options); + _host.Session.Add("NamespaceHint", options.ContextNamespace ?? options.ModelNamespace); + _host.Session.Add("ProjectDefaultNamespace", options.RootNamespace); + + var handler = new TextTemplatingCallback(); + var generatedCode = ProcessTemplate(contextTemplate, handler); + + var dbContextFileName = options.ContextName + handler.Extension; + resultingFiles.ContextFile = new ScaffoldedFile + { + Path = options.ContextDir != null + ? Path.Combine(options.ContextDir, dbContextFileName) + : dbContextFileName, + Code = generatedCode + }; + } + finally + { + _host.Session = null; + } + + var entityTypeTemplate = Path.Combine(options.ProjectDir!, TemplatesDirectory, "EntityType.t4"); + if (File.Exists(entityTypeTemplate)) + { + foreach (var entityType in model.GetEntityTypes()) + { + // TODO: Should this be handled inside the template? + if (CSharpDbContextGenerator.IsManyToManyJoinEntityType(entityType)) + { + continue; + } + + _host.Session = _host.CreateSession(); + try + { + _host.Session.Add("EntityType", entityType); + _host.Session.Add("Options", options); + _host.Session.Add("NamespaceHint", options.ModelNamespace); + _host.Session.Add("ProjectDefaultNamespace", options.RootNamespace); + + var handler = new TextTemplatingCallback(); + var generatedCode = ProcessTemplate(entityTypeTemplate, handler); + if (string.IsNullOrWhiteSpace(generatedCode)) + { + continue; + } + + var entityTypeFileName = entityType.Name + handler.Extension; + resultingFiles.AdditionalFiles.Add( + new ScaffoldedFile { Path = entityTypeFileName, Code = generatedCode }); + } + finally + { + _host.Session = null; + } + } + } + + return resultingFiles; + } + + private string ProcessTemplate(string inputFile, TextTemplatingCallback handler) + { + var output = _host.ProcessTemplate( + inputFile, + File.ReadAllText(inputFile), + handler); + + foreach (CompilerError error in handler.Errors) + { + var builder = new StringBuilder(); + + if (!string.IsNullOrEmpty(error.FileName)) + { + builder.Append(error.FileName); + + if (error.Line > 0) + { + builder + .Append("(") + .Append(error.Line); + + if (error.Column > 0) + { + builder + .Append(",") + .Append(error.Line); + } + builder.Append(")"); + } + + builder.Append(" : "); + } + + builder + .Append(error.IsWarning ? "warning" : "error") + .Append(" ") + .Append(error.ErrorNumber) + .Append(": ") + .AppendLine(error.ErrorText); + + if (error.IsWarning) + { + _reporter.WriteWarning(builder.ToString()); + } + else + { + _reporter.WriteError(builder.ToString()); + } + } + + if (handler.OutputEncoding != Encoding.UTF8) + { + _reporter.WriteWarning(DesignStrings.EncodingIgnored(handler.OutputEncoding.WebName)); + } + + return output; + } +} diff --git a/src/EFCore.Design/Scaffolding/ModelCodeGenerationOptions.cs b/src/EFCore.Design/Scaffolding/ModelCodeGenerationOptions.cs index 430a7254dc1..84e6e295ae9 100644 --- a/src/EFCore.Design/Scaffolding/ModelCodeGenerationOptions.cs +++ b/src/EFCore.Design/Scaffolding/ModelCodeGenerationOptions.cs @@ -73,4 +73,10 @@ public class ModelCodeGenerationOptions /// /// The connection string. public virtual string? ConnectionString { get; set; } + + /// + /// Gets or sets the root project directory. + /// + /// The directory. + public virtual string? ProjectDir { get; set; } } diff --git a/src/EFCore.Design/Scaffolding/ModelCodeGenerator.cs b/src/EFCore.Design/Scaffolding/ModelCodeGenerator.cs index 21dcc65c7bc..9d1eb72129b 100644 --- a/src/EFCore.Design/Scaffolding/ModelCodeGenerator.cs +++ b/src/EFCore.Design/Scaffolding/ModelCodeGenerator.cs @@ -24,7 +24,7 @@ protected ModelCodeGenerator(ModelCodeGeneratorDependencies dependencies) /// Gets the programming language supported by this service. /// /// The language. - public abstract string Language { get; } + public abstract string? Language { get; } /// /// Dependencies for this service. diff --git a/src/EFCore.Design/Scaffolding/TemplatedModelGenerator.cs b/src/EFCore.Design/Scaffolding/TemplatedModelGenerator.cs new file mode 100644 index 00000000000..688aabd9790 --- /dev/null +++ b/src/EFCore.Design/Scaffolding/TemplatedModelGenerator.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Scaffolding; + +/// +/// Base type for model code generators that use templates. +/// +internal abstract class TemplatedModelGenerator : ModelCodeGenerator +{ + /// + /// Initializes a new instance of the class. + /// + /// The dependencies. + protected TemplatedModelGenerator(ModelCodeGeneratorDependencies dependencies) + : base(dependencies) + { + } + + /// + /// Gets the subdirectory under the project to look for templates in. + /// + /// The subdirectory. + protected static string TemplatesDirectory { get; } = Path.Combine("Templates", "EFCore"); + + /// + public override string? Language + => null; + + /// + /// Checks whether the templates required for this generator are present. + /// + /// The root project directory. + /// if the templates are present; otherwise, . + public abstract bool HasTemplates(string projectDir); +} diff --git a/src/EFCore.Design/TextTemplating/ITextTemplating.cs b/src/EFCore.Design/TextTemplating/ITextTemplating.cs new file mode 100644 index 00000000000..c0e7a822cd2 --- /dev/null +++ b/src/EFCore.Design/TextTemplating/ITextTemplating.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.VisualStudio.TextTemplating; + +namespace Microsoft.EntityFrameworkCore.TextTemplating; + +/// +/// The text template transformation service. +/// +public interface ITextTemplating : ITextTemplatingSessionHost +{ + /// + /// Transforms the contents of a text template file to produce the generated text output. + /// + /// The path of the template file. + /// The contents of the template file. + /// The callback used to process errors and information. + /// The output. + string ProcessTemplate(string inputFile, string content, ITextTemplatingCallback? callback = null); +} diff --git a/src/EFCore.Design/TextTemplating/ITextTemplatingCallback.cs b/src/EFCore.Design/TextTemplating/ITextTemplatingCallback.cs new file mode 100644 index 00000000000..b29afc78fb4 --- /dev/null +++ b/src/EFCore.Design/TextTemplating/ITextTemplatingCallback.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CodeDom.Compiler; +using System.Text; + +namespace Microsoft.EntityFrameworkCore.TextTemplating; + +/// +/// Callback interface to be implemented by clients of that wish to process errors and information. +/// +public interface ITextTemplatingCallback +{ + /// + /// Receives errors and warnings. + /// + /// An error or warning. + void ErrorCallback(CompilerError error); + + /// + /// Receives the file name extension that is expected for the generated text output. + /// + /// The extension. + void SetFileExtension(string extension); + + /// + /// Receives the encoding that is expected for the generated text output. + /// + /// The encoding. + /// A value indicating whether the encoding was specified in the encoding parameter of the output directive. + void SetOutputEncoding(Encoding encoding, bool fromOutputDirective); +} diff --git a/src/EFCore.Design/TextTemplating/Internal/TextTemplatingCallback.cs b/src/EFCore.Design/TextTemplating/Internal/TextTemplatingCallback.cs new file mode 100644 index 00000000000..6899abcbe26 --- /dev/null +++ b/src/EFCore.Design/TextTemplating/Internal/TextTemplatingCallback.cs @@ -0,0 +1,65 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CodeDom.Compiler; +using System.Text; + +namespace Microsoft.EntityFrameworkCore.TextTemplating.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 class TextTemplatingCallback : ITextTemplatingCallback +{ + private CompilerErrorCollection? _errors; + private string _extension = ".cs"; + private Encoding _outputEncoding = Encoding.UTF8; + private bool _fromOutputDirective; + + /// + /// 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 virtual string Extension + => _extension; + + /// + /// 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 virtual CompilerErrorCollection Errors + => _errors ??= new CompilerErrorCollection(); + + /// + /// 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 virtual Encoding OutputEncoding + => _outputEncoding; + + void ITextTemplatingCallback.ErrorCallback(CompilerError error) + => Errors.Add(error); + + void ITextTemplatingCallback.SetFileExtension(string extension) + => _extension = extension; + + void ITextTemplatingCallback.SetOutputEncoding(Encoding? encoding, bool fromOutputDirective) + { + if (_fromOutputDirective) + { + return; + } + + _outputEncoding = encoding ?? Encoding.UTF8; + _fromOutputDirective = fromOutputDirective; + } +} diff --git a/src/EFCore.Design/TextTemplating/Internal/TextTemplatingService.cs b/src/EFCore.Design/TextTemplating/Internal/TextTemplatingService.cs new file mode 100644 index 00000000000..b52984b3ab4 --- /dev/null +++ b/src/EFCore.Design/TextTemplating/Internal/TextTemplatingService.cs @@ -0,0 +1,241 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CodeDom.Compiler; +using System.Text; +using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.VisualStudio.TextTemplating; +using Engine = Mono.TextTemplating.TemplatingEngine; + +namespace Microsoft.EntityFrameworkCore.TextTemplating.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 class TextTemplatingService : ITextTemplating, ITextTemplatingEngineHost, IServiceProvider +{ + private readonly IServiceProvider _serviceProvider; + private ITextTemplatingCallback? _callback; + private string? _templateFile; + + /// + /// 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 TextTemplatingService(IServiceProvider serviceProvider) + => _serviceProvider = serviceProvider; + + /// + /// 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 virtual ITextTemplatingSession? Session { get; set; } + + /// + /// 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 virtual IList StandardAssemblyReferences { get; } = new string[] + { + typeof(ITextTemplatingEngineHost).Assembly.Location, + typeof(CompilerErrorCollection).Assembly.Location + }; + + /// + /// 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 virtual IList StandardImports { get; } = new[] + { + "System" + }; + + /// + /// 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 virtual string? TemplateFile + => _templateFile; + + /// + /// 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 virtual string ProcessTemplate(string inputFile, string content, ITextTemplatingCallback? callback = null) + { + _templateFile = inputFile; + _callback = callback; + + var sessionCreated = false; + if (Session == null) + { + Session = CreateSession(); + sessionCreated = true; + } + + try + { + return new Engine().ProcessTemplate(content, this); + } + finally + { + _templateFile = null; + _callback = null; + + if (sessionCreated) + { + Session = null; + } + } + } + + /// + /// 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 virtual ITextTemplatingSession CreateSession() + => new TextTemplatingSession(); + + /// + /// 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 virtual object? GetHostOption(string optionName) + => null; + + /// + /// 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 virtual bool LoadIncludeText(string requestFileName, out string content, out string location) + { + // TODO: Expand variables? + location = ResolvePath(requestFileName); + var exists = File.Exists(location); + content = exists + ? File.ReadAllText(location) + : string.Empty; + + return exists; + } + + /// + /// 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 virtual void LogErrors(CompilerErrorCollection errors) + { + foreach (CompilerError error in errors) + { + _callback?.ErrorCallback(error); + } + } + + /// + /// 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 virtual AppDomain ProvideTemplatingAppDomain(string content) + => AppDomain.CurrentDomain; + + /// + /// 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 virtual string ResolveAssemblyReference(string assemblyReference) + { + try + { + return Assembly.Load(assemblyReference).Location; + } + catch + { + } + + // TODO: Expand variables? + return assemblyReference; + } + + /// + /// 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 virtual Type ResolveDirectiveProcessor(string processorName) + => throw new FileNotFoundException(DesignStrings.UnknownDirectiveProcessor(processorName)); + + /// + /// 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 virtual string ResolveParameterValue(string directiveId, string processorName, string parameterName) + => string.Empty; + + /// + /// 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 virtual string ResolvePath(string path) + => !Path.IsPathRooted(path) + ? Path.Combine(Path.GetDirectoryName(TemplateFile)!, path) + : path; + + /// + /// 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 virtual void SetFileExtension(string extension) + => _callback?.SetFileExtension(extension); + + /// + /// 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 virtual void SetOutputEncoding(Encoding encoding, bool fromOutputDirective) + => _callback?.SetOutputEncoding(encoding, fromOutputDirective); + + /// + /// 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 virtual object? GetService(Type serviceType) + => _serviceProvider.GetService(serviceType); +} diff --git a/src/EFCore.Relational/Extensions/RelationalEntityTypeBuilderExtensions.cs b/src/EFCore.Relational/Extensions/RelationalEntityTypeBuilderExtensions.cs index 3998a996b4a..98000b2490b 100644 --- a/src/EFCore.Relational/Extensions/RelationalEntityTypeBuilderExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalEntityTypeBuilderExtensions.cs @@ -51,7 +51,7 @@ public static EntityTypeBuilder ToTable( { Check.NotNull(buildAction, nameof(buildAction)); - buildAction(new TableBuilder(entityTypeBuilder)); + buildAction(new TableBuilder(null, null, entityTypeBuilder)); return entityTypeBuilder; } @@ -76,7 +76,7 @@ public static EntityTypeBuilder ToTable( entityTypeBuilder.Metadata.SetTableName(name); entityTypeBuilder.Metadata.SetSchema(null); - buildAction(new TableBuilder(entityTypeBuilder)); + buildAction(new TableBuilder(name, null, entityTypeBuilder)); return entityTypeBuilder; } @@ -192,7 +192,7 @@ public static EntityTypeBuilder ToTable( entityTypeBuilder.Metadata.SetTableName(name); entityTypeBuilder.Metadata.SetSchema(schema); - buildAction(new TableBuilder(entityTypeBuilder)); + buildAction(new TableBuilder(name, schema, entityTypeBuilder)); return entityTypeBuilder; } @@ -281,7 +281,7 @@ public static OwnedNavigationBuilder ToTable( { Check.NotNull(buildAction, nameof(buildAction)); - buildAction(new OwnedNavigationTableBuilder(referenceOwnershipBuilder)); + buildAction(new OwnedNavigationTableBuilder(null, null, referenceOwnershipBuilder)); return referenceOwnershipBuilder; } @@ -303,7 +303,7 @@ public static OwnedNavigationBuilder ToTable(referenceOwnershipBuilder)); + buildAction(new OwnedNavigationTableBuilder(null, null, referenceOwnershipBuilder)); return referenceOwnershipBuilder; } @@ -344,7 +344,7 @@ public static OwnedNavigationBuilder ToTable( referenceOwnershipBuilder.OwnedEntityType.SetTableName(name); referenceOwnershipBuilder.OwnedEntityType.SetSchema(null); - buildAction(new OwnedNavigationTableBuilder(referenceOwnershipBuilder)); + buildAction(new OwnedNavigationTableBuilder(name, null, referenceOwnershipBuilder)); return referenceOwnershipBuilder; } @@ -371,7 +371,7 @@ public static OwnedNavigationBuilder ToTable(referenceOwnershipBuilder)); + buildAction(new OwnedNavigationTableBuilder(name, null, referenceOwnershipBuilder)); return referenceOwnershipBuilder; } @@ -423,7 +423,7 @@ public static OwnedNavigationBuilder ToTable( referenceOwnershipBuilder.OwnedEntityType.SetTableName(name); referenceOwnershipBuilder.OwnedEntityType.SetSchema(schema); - buildAction(new OwnedNavigationTableBuilder(referenceOwnershipBuilder)); + buildAction(new OwnedNavigationTableBuilder(name, schema, referenceOwnershipBuilder)); return referenceOwnershipBuilder; } @@ -472,7 +472,7 @@ public static OwnedNavigationBuilder ToTable(referenceOwnershipBuilder)); + buildAction(new OwnedNavigationTableBuilder(name, schema, referenceOwnershipBuilder)); return referenceOwnershipBuilder; } diff --git a/src/EFCore.Relational/Extensions/RelationalEntityTypeExtensions.cs b/src/EFCore.Relational/Extensions/RelationalEntityTypeExtensions.cs index 7d609106682..fea3d7d740d 100644 --- a/src/EFCore.Relational/Extensions/RelationalEntityTypeExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalEntityTypeExtensions.cs @@ -39,7 +39,7 @@ public static class RelationalEntityTypeExtensions return entityType.GetRootType().GetTableName(); } - return (entityType as IConventionEntityType)?.GetViewNameConfigurationSource() == null + return ((entityType as IConventionEntityType)?.GetViewNameConfigurationSource() == null) && ((entityType as IConventionEntityType)?.GetFunctionNameConfigurationSource() == null) #pragma warning disable CS0618 // Type or member is obsolete && ((entityType as IConventionEntityType)?.GetDefiningQueryConfigurationSource() == null) @@ -254,7 +254,7 @@ public static void SetSchema(this IMutableEntityType entityType, string? value) public static IEnumerable GetDefaultMappings(this IEntityType entityType) => (IEnumerable?)entityType.FindRuntimeAnnotationValue( RelationalAnnotationNames.DefaultMappings) - ?? Array.Empty(); + ?? Enumerable.Empty(); /// /// Returns the tables to which the entity type is mapped. @@ -264,7 +264,7 @@ public static IEnumerable GetDefaultMappings(this IEntityType public static IEnumerable GetTableMappings(this IEntityType entityType) => (IEnumerable?)entityType.FindRuntimeAnnotationValue( RelationalAnnotationNames.TableMappings) - ?? Array.Empty(); + ?? Enumerable.Empty(); /// /// Returns the name of the view to which the entity type is mapped or if not mapped to a view. @@ -286,7 +286,7 @@ public static IEnumerable GetTableMappings(this IEntityType entit return ((entityType as IConventionEntityType)?.GetFunctionNameConfigurationSource() == null) #pragma warning disable CS0618 // Type or member is obsolete - && (entityType as IConventionEntityType)?.GetDefiningQueryConfigurationSource() == null + && ((entityType as IConventionEntityType)?.GetDefiningQueryConfigurationSource() == null) #pragma warning restore CS0618 // Type or member is obsolete && ((entityType as IConventionEntityType)?.GetSqlQueryConfigurationSource() == null) ? GetDefaultViewName(entityType) @@ -428,7 +428,7 @@ public static void SetViewSchema(this IMutableEntityType entityType, string? val public static IEnumerable GetViewMappings(this IEntityType entityType) => (IEnumerable?)entityType.FindRuntimeAnnotationValue( RelationalAnnotationNames.ViewMappings) - ?? Array.Empty(); + ?? Enumerable.Empty(); /// /// Gets the default SQL query name that would be used for this entity type when mapped using @@ -493,7 +493,7 @@ public static void SetSqlQuery(this IMutableEntityType entityType, string? name) public static IEnumerable GetSqlQueryMappings(this IEntityType entityType) => (IEnumerable?)entityType.FindRuntimeAnnotationValue( RelationalAnnotationNames.SqlQueryMappings) - ?? Array.Empty(); + ?? Enumerable.Empty(); /// /// Returns the name of the function to which the entity type is mapped or if not mapped to a function. @@ -549,7 +549,7 @@ public static void SetFunctionName(this IMutableEntityType entityType, string? n public static IEnumerable GetFunctionMappings(this IEntityType entityType) => (IEnumerable?)entityType.FindRuntimeAnnotationValue( RelationalAnnotationNames.FunctionMappings) - ?? Array.Empty(); + ?? Enumerable.Empty(); /// /// Finds an with the given name. diff --git a/src/EFCore.Relational/Extensions/RelationalForeignKeyExtensions.cs b/src/EFCore.Relational/Extensions/RelationalForeignKeyExtensions.cs index 1be45e2ac3b..efeff92fa22 100644 --- a/src/EFCore.Relational/Extensions/RelationalForeignKeyExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalForeignKeyExtensions.cs @@ -21,6 +21,12 @@ public static class RelationalForeignKeyExtensions /// The foreign key constraint name. public static string? GetConstraintName(this IReadOnlyForeignKey foreignKey) { + var tableName = foreignKey.DeclaringEntityType.GetTableName(); + if (tableName == null) + { + return null; + } + var annotation = foreignKey.FindAnnotation(RelationalAnnotationNames.Name); return annotation != null ? (string?)annotation.Value @@ -39,6 +45,12 @@ public static class RelationalForeignKeyExtensions in StoreObjectIdentifier storeObject, in StoreObjectIdentifier principalStoreObject) { + if (storeObject.StoreObjectType != StoreObjectType.Table + || principalStoreObject.StoreObjectType != StoreObjectType.Table) + { + return null; + } + var annotation = foreignKey.FindAnnotation(RelationalAnnotationNames.Name); return annotation != null ? (string?)annotation.Value @@ -50,10 +62,15 @@ public static class RelationalForeignKeyExtensions /// /// The foreign key. /// The default constraint name that would be used for this foreign key. - public static string GetDefaultName(this IReadOnlyForeignKey foreignKey) + public static string? GetDefaultName(this IReadOnlyForeignKey foreignKey) { var tableName = foreignKey.DeclaringEntityType.GetTableName(); var principalTableName = foreignKey.PrincipalEntityType.GetTableName(); + if (tableName == null + || principalTableName == null) + { + return null; + } var name = new StringBuilder() .Append("FK_") @@ -79,6 +96,12 @@ public static string GetDefaultName(this IReadOnlyForeignKey foreignKey) in StoreObjectIdentifier storeObject, in StoreObjectIdentifier principalStoreObject) { + if (storeObject.StoreObjectType != StoreObjectType.Table + || principalStoreObject.StoreObjectType != StoreObjectType.Table) + { + return null; + } + var propertyNames = foreignKey.Properties.GetColumnNames(storeObject); var principalPropertyNames = foreignKey.PrincipalKey.Properties.GetColumnNames(principalStoreObject); if (propertyNames == null diff --git a/src/EFCore.Relational/Extensions/RelationalIndexExtensions.cs b/src/EFCore.Relational/Extensions/RelationalIndexExtensions.cs index 705c7004d33..288071d3225 100644 --- a/src/EFCore.Relational/Extensions/RelationalIndexExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalIndexExtensions.cs @@ -19,8 +19,10 @@ public static class RelationalIndexExtensions /// /// The index. /// The name of the index in the database. - public static string GetDatabaseName(this IReadOnlyIndex index) - => (string?)index[RelationalAnnotationNames.Name] + public static string? GetDatabaseName(this IReadOnlyIndex index) + => index.DeclaringEntityType.GetTableName() == null + ? null + : (string?)index[RelationalAnnotationNames.Name] ?? index.Name ?? index.GetDefaultDatabaseName(); @@ -31,7 +33,9 @@ public static string GetDatabaseName(this IReadOnlyIndex index) /// The identifier of the store object. /// The name of the index in the database. public static string? GetDatabaseName(this IReadOnlyIndex index, in StoreObjectIdentifier storeObject) - => (string?)index[RelationalAnnotationNames.Name] + => storeObject.StoreObjectType != StoreObjectType.Table + ? null + : (string?)index[RelationalAnnotationNames.Name] ?? index.Name ?? index.GetDefaultDatabaseName(storeObject); @@ -40,9 +44,14 @@ public static string GetDatabaseName(this IReadOnlyIndex index) /// /// The index. /// The default name that would be used for this index. - public static string GetDefaultDatabaseName(this IReadOnlyIndex index) + public static string? GetDefaultDatabaseName(this IReadOnlyIndex index) { var tableName = index.DeclaringEntityType.GetTableName(); + if (tableName == null) + { + return null; + } + var baseName = new StringBuilder() .Append("IX_") .Append(tableName) @@ -61,6 +70,11 @@ public static string GetDefaultDatabaseName(this IReadOnlyIndex index) /// The default name that would be used for this index. public static string? GetDefaultDatabaseName(this IReadOnlyIndex index, in StoreObjectIdentifier storeObject) { + if (storeObject.StoreObjectType != StoreObjectType.Table) + { + return null; + } + var columnNames = index.Properties.GetColumnNames(storeObject); if (columnNames == null) { diff --git a/src/EFCore.Relational/Extensions/RelationalKeyExtensions.cs b/src/EFCore.Relational/Extensions/RelationalKeyExtensions.cs index 5bb15a3cf93..b1590465495 100644 --- a/src/EFCore.Relational/Extensions/RelationalKeyExtensions.cs +++ b/src/EFCore.Relational/Extensions/RelationalKeyExtensions.cs @@ -20,7 +20,10 @@ public static class RelationalKeyExtensions /// The key. /// The key constraint name for this key. public static string? GetName(this IReadOnlyKey key) - => key.GetName(StoreObjectIdentifier.Table(key.DeclaringEntityType.GetTableName()!, key.DeclaringEntityType.GetSchema())); + { + var table = StoreObjectIdentifier.Create(key.DeclaringEntityType, StoreObjectType.Table); + return !table.HasValue ? null : key.GetName(table.Value); + } /// /// Returns the key constraint name for this key for a particular table. @@ -29,17 +32,36 @@ public static class RelationalKeyExtensions /// The identifier of the containing store object. /// The key constraint name for this key. public static string? GetName(this IReadOnlyKey key, in StoreObjectIdentifier storeObject) - => (string?)key[RelationalAnnotationNames.Name] - ?? key.GetDefaultName(storeObject); + { + if (storeObject.StoreObjectType != StoreObjectType.Table) + { + return null; + } + + foreach (var containingType in key.DeclaringEntityType.GetDerivedTypesInclusive()) + { + if (StoreObjectIdentifier.Create(containingType, storeObject.StoreObjectType) == storeObject) + { + return (string?)key[RelationalAnnotationNames.Name] ?? key.GetDefaultName(storeObject); + } + } + + return null; + } /// /// Returns the default key constraint name that would be used for this key. /// /// The key. /// The default key constraint name that would be used for this key. - public static string GetDefaultName(this IReadOnlyKey key) + public static string? GetDefaultName(this IReadOnlyKey key) { var tableName = key.DeclaringEntityType.GetTableName(); + if (tableName == null) + { + return null; + } + var name = key.IsPrimaryKey() ? "PK_" + tableName : new StringBuilder() @@ -60,6 +82,11 @@ public static string GetDefaultName(this IReadOnlyKey key) /// The default key constraint name that would be used for this key. public static string? GetDefaultName(this IReadOnlyKey key, in StoreObjectIdentifier storeObject) { + if (storeObject.StoreObjectType != StoreObjectType.Table) + { + return null; + } + string? name; if (key.IsPrimaryKey()) { diff --git a/src/EFCore.Relational/Metadata/Builders/OwnedNavigationTableBuilder.cs b/src/EFCore.Relational/Metadata/Builders/OwnedNavigationTableBuilder.cs index 0c36a863602..f8c5214cbe4 100644 --- a/src/EFCore.Relational/Metadata/Builders/OwnedNavigationTableBuilder.cs +++ b/src/EFCore.Relational/Metadata/Builders/OwnedNavigationTableBuilder.cs @@ -18,11 +18,23 @@ public class OwnedNavigationTableBuilder /// doing so can result in application failures when updating to a new Entity Framework Core release. /// [EntityFrameworkInternal] - public OwnedNavigationTableBuilder(OwnedNavigationBuilder ownedNavigationBuilder) + public OwnedNavigationTableBuilder(string? name, string? schema, OwnedNavigationBuilder ownedNavigationBuilder) { + Name = name; + Schema = schema; OwnedNavigationBuilder = ownedNavigationBuilder; } + /// + /// The specified table name. + /// + public virtual string? Name { get; } + + /// + /// The specified table schema. + /// + public virtual string? Schema { get; } + /// /// The entity type being configured. /// diff --git a/src/EFCore.Relational/Metadata/Builders/OwnedNavigationTableBuilder`.cs b/src/EFCore.Relational/Metadata/Builders/OwnedNavigationTableBuilder`.cs index ff74ddd1933..af93328a20f 100644 --- a/src/EFCore.Relational/Metadata/Builders/OwnedNavigationTableBuilder`.cs +++ b/src/EFCore.Relational/Metadata/Builders/OwnedNavigationTableBuilder`.cs @@ -18,8 +18,8 @@ public class OwnedNavigationTableBuilder : OwnedNavigationTableBuilder /// doing so can result in application failures when updating to a new Entity Framework Core release. /// [EntityFrameworkInternal] - public OwnedNavigationTableBuilder(OwnedNavigationBuilder referenceOwnershipBuilder) - : base(referenceOwnershipBuilder) + public OwnedNavigationTableBuilder(string? name, string? schema, OwnedNavigationBuilder referenceOwnershipBuilder) + : base(name, schema, referenceOwnershipBuilder) { } diff --git a/src/EFCore.Relational/Metadata/Builders/TableBuilder.cs b/src/EFCore.Relational/Metadata/Builders/TableBuilder.cs index cd913ad8774..8a74222b55d 100644 --- a/src/EFCore.Relational/Metadata/Builders/TableBuilder.cs +++ b/src/EFCore.Relational/Metadata/Builders/TableBuilder.cs @@ -18,11 +18,23 @@ public class TableBuilder /// doing so can result in application failures when updating to a new Entity Framework Core release. /// [EntityFrameworkInternal] - public TableBuilder(EntityTypeBuilder entityTypeBuilder) + public TableBuilder(string? name, string? schema, EntityTypeBuilder entityTypeBuilder) { + Name = name; + Schema = schema; EntityTypeBuilder = entityTypeBuilder; } + /// + /// The specified table name. + /// + public virtual string? Name { get; } + + /// + /// The specified table schema. + /// + public virtual string? Schema { get; } + /// /// The entity type being configured. /// diff --git a/src/EFCore.Relational/Metadata/Builders/TableBuilder`.cs b/src/EFCore.Relational/Metadata/Builders/TableBuilder`.cs index 2a238815dd9..c02dd30e4f5 100644 --- a/src/EFCore.Relational/Metadata/Builders/TableBuilder`.cs +++ b/src/EFCore.Relational/Metadata/Builders/TableBuilder`.cs @@ -19,7 +19,7 @@ public class TableBuilder : TableBuilder /// [EntityFrameworkInternal] public TableBuilder(string? name, string? schema, EntityTypeBuilder entityTypeBuilder) - : base(entityTypeBuilder) + : base(name, schema, entityTypeBuilder) { } diff --git a/src/EFCore.Relational/Metadata/IMutableCheckConstraint.cs b/src/EFCore.Relational/Metadata/IMutableCheckConstraint.cs index 6afcc51acc3..6f7e4349037 100644 --- a/src/EFCore.Relational/Metadata/IMutableCheckConstraint.cs +++ b/src/EFCore.Relational/Metadata/IMutableCheckConstraint.cs @@ -19,5 +19,5 @@ public interface IMutableCheckConstraint : IReadOnlyCheckConstraint, IMutableAnn /// /// Gets or sets the name of the check constraint in the database. /// - new string Name { get; set; } + new string? Name { get; set; } } diff --git a/src/EFCore.Relational/Metadata/IReadOnlyCheckConstraint.cs b/src/EFCore.Relational/Metadata/IReadOnlyCheckConstraint.cs index 5de8906d38d..a9f2f811206 100644 --- a/src/EFCore.Relational/Metadata/IReadOnlyCheckConstraint.cs +++ b/src/EFCore.Relational/Metadata/IReadOnlyCheckConstraint.cs @@ -19,7 +19,7 @@ public interface IReadOnlyCheckConstraint : IReadOnlyAnnotatable /// /// Gets the database name of the check constraint. /// - string Name { get; } + string? Name { get; } /// /// Returns the default database name that would be used for this check constraint. @@ -43,8 +43,10 @@ public interface IReadOnlyCheckConstraint : IReadOnlyAnnotatable /// /// The identifier of the store object. /// The default name that would be used for this check constraint. - string GetDefaultName(in StoreObjectIdentifier storeObject) - => Uniquifier.Truncate(ModelName, EntityType.Model.GetMaxIdentifierLength()); + string? GetDefaultName(in StoreObjectIdentifier storeObject) + => storeObject.StoreObjectType == StoreObjectType.Table + ? Uniquifier.Truncate(ModelName, EntityType.Model.GetMaxIdentifierLength()) + : null; /// /// Gets the entity type on which this check constraint is defined. diff --git a/src/EFCore.Relational/Metadata/ITable.cs b/src/EFCore.Relational/Metadata/ITable.cs index 9a1362af677..21ba462211d 100644 --- a/src/EFCore.Relational/Metadata/ITable.cs +++ b/src/EFCore.Relational/Metadata/ITable.cs @@ -51,9 +51,7 @@ public interface ITable : ITableBase /// /// Gets the check constraints for this table. /// - IEnumerable CheckConstraints - => EntityTypeMappings.SelectMany(m => m.EntityType.GetDeclaredCheckConstraints()) - .Distinct((x, y) => x!.Name == y!.Name); + IEnumerable CheckConstraints { get; } /// /// Gets the comment for this table. diff --git a/src/EFCore.Relational/Metadata/Internal/CheckConstraint.cs b/src/EFCore.Relational/Metadata/Internal/CheckConstraint.cs index 07409a22446..f8655df492d 100644 --- a/src/EFCore.Relational/Metadata/Internal/CheckConstraint.cs +++ b/src/EFCore.Relational/Metadata/Internal/CheckConstraint.cs @@ -280,15 +280,32 @@ public override bool IsReadOnly /// 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 virtual string Name + public virtual string? Name { - get => _name ?? ((IReadOnlyCheckConstraint)this).GetDefaultName() ?? ModelName; + get => EntityType.GetTableName() == null + ? null + : _name ?? ((IReadOnlyCheckConstraint)this).GetDefaultName(); set => SetName(value, ConfigurationSource.Explicit); } /// public virtual string? GetName(in StoreObjectIdentifier storeObject) - => _name ?? ((IReadOnlyCheckConstraint)this).GetDefaultName(storeObject) ?? ModelName; + { + if (storeObject.StoreObjectType != StoreObjectType.Table) + { + return null; + } + + foreach (var containingType in EntityType.GetDerivedTypesInclusive()) + { + if (StoreObjectIdentifier.Create(containingType, storeObject.StoreObjectType) == storeObject) + { + return _name ?? ((IReadOnlyCheckConstraint)this).GetDefaultName(storeObject); + } + } + + return null; + } /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to diff --git a/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs b/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs index 98db63d5249..1c6b8dd5d43 100644 --- a/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs +++ b/src/EFCore.Relational/Metadata/Internal/RelationalModel.cs @@ -151,7 +151,7 @@ public static IRelationalModel Create( foreach (var table in databaseModel.Tables.Values) { PopulateRowInternalForeignKeys(table); - PopulateConstraints(table); + PopulateConstraints(table, designTime); if (relationalAnnotationProvider != null) { @@ -177,9 +177,9 @@ public static IRelationalModel Create( if (designTime) { - foreach (var checkConstraint in ((ITable)table).CheckConstraints) + foreach (var checkConstraint in table.CheckConstraints.Values) { - ((AnnotatableBase)checkConstraint).AddAnnotations( + checkConstraint.AddAnnotations( relationalAnnotationProvider.For(checkConstraint, designTime)); } } @@ -751,7 +751,7 @@ private static StoreFunction GetOrCreateStoreFunction(IRuntimeDbFunction dbFunct return storeFunction; } - private static void PopulateConstraints(Table table) + private static void PopulateConstraints(Table table, bool designTime) { var storeObject = StoreObjectIdentifier.Table(table.Name, table.Schema); foreach (var entityTypeMapping in ((ITable)table).EntityTypeMappings) @@ -967,6 +967,23 @@ private static void PopulateConstraints(Table table) tableIndexes.Add(tableIndex); tableIndex.MappedIndexes.Add(index); } + + if (designTime) + { + foreach (var checkConstraint in entityType.GetCheckConstraints()) + { + var name = checkConstraint.GetName(storeObject); + if (name == null) + { + continue; + } + + if (!table.CheckConstraints.ContainsKey(name)) + { + table.CheckConstraints.Add(name, (CheckConstraint)checkConstraint); + } + } + } } } diff --git a/src/EFCore.Relational/Metadata/Internal/Table.cs b/src/EFCore.Relational/Metadata/Internal/Table.cs index 2be86c69d4a..2ed34a918d8 100644 --- a/src/EFCore.Relational/Metadata/Internal/Table.cs +++ b/src/EFCore.Relational/Metadata/Internal/Table.cs @@ -113,6 +113,15 @@ public virtual UniqueConstraint? PrimaryKey public virtual SortedDictionary Indexes { get; } = new(); + /// + /// 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 virtual SortedDictionary CheckConstraints { get; } + = new(); + /// public virtual bool IsExcludedFromMigrations => EntityTypeMappings.First().EntityType.IsTableExcludedFromMigrations(); @@ -174,6 +183,15 @@ IEnumerable ITable.Indexes get => Indexes.Values; } + /// + IEnumerable ITable.CheckConstraints + { + [DebuggerStepThrough] + get => EntityTypeMappings.First().EntityType is RuntimeEntityType + ? throw new InvalidOperationException(CoreStrings.RuntimeModelMissingData) + : CheckConstraints.Values; + } + /// [DebuggerStepThrough] IColumn? ITable.FindColumn(string name) diff --git a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs index cba40a8ebbd..7e9ebc3ba4e 100644 --- a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs +++ b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs @@ -1527,7 +1527,7 @@ protected virtual IEnumerable Remove(ICheckConstraint source var operation = new DropCheckConstraintOperation { - Name = source.Name, + Name = source.Name!, Schema = sourceEntityType.GetSchema(), Table = sourceEntityType.GetTableName()! }; @@ -2189,7 +2189,7 @@ private IEnumerable GetDataOperations( var commandBatches = new CommandBatchPreparer(CommandBatchPreparerDependencies) .BatchCommands(entries, updateAdapter); - foreach (var commandBatch in commandBatches) + foreach (var (commandBatch, _) in commandBatches) { InsertDataOperation? batchInsertOperation = null; foreach (var command in commandBatch.ModificationCommands) diff --git a/src/EFCore.Relational/Migrations/Operations/AddCheckConstraintOperation.cs b/src/EFCore.Relational/Migrations/Operations/AddCheckConstraintOperation.cs index 1547f775081..7747f10915a 100644 --- a/src/EFCore.Relational/Migrations/Operations/AddCheckConstraintOperation.cs +++ b/src/EFCore.Relational/Migrations/Operations/AddCheckConstraintOperation.cs @@ -46,7 +46,7 @@ public static AddCheckConstraintOperation CreateFrom(ICheckConstraint checkConst var operation = new AddCheckConstraintOperation { - Name = checkConstraint.Name, + Name = checkConstraint.Name!, Sql = checkConstraint.Sql, Schema = checkConstraint.EntityType.GetSchema(), Table = checkConstraint.EntityType.GetTableName()! diff --git a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs index d891f6405b1..a96438e2c9b 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs +++ b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs @@ -843,6 +843,18 @@ public static string MissingParameterValue(object? parameter) GetString("MissingParameterValue", nameof(parameter)), parameter); + /// + /// Cannot execute an ModificationCommandBatch which hasn't been completed. + /// + public static string ModificationCommandBatchAlreadyComplete + => GetString("ModificationCommandBatchAlreadyComplete"); + + /// + /// Cannot execute an ModificationCommandBatch which hasn't been completed. + /// + public static string ModificationCommandBatchNotComplete + => GetString("ModificationCommandBatchNotCompleted"); + /// /// Cannot save changes for an entity of type '{entityType}' in state '{entityState}'. This may indicate a bug in Entity Framework, please open an issue at https://go.microsoft.com/fwlink/?linkid=2142044. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the key values of the entity. /// diff --git a/src/EFCore.Relational/Properties/RelationalStrings.resx b/src/EFCore.Relational/Properties/RelationalStrings.resx index 57b5e40ab57..9ba10958462 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.resx +++ b/src/EFCore.Relational/Properties/RelationalStrings.resx @@ -669,6 +669,12 @@ No value was provided for the required parameter '{parameter}'. + + Cannot add commands to a completed ModificationCommandBatch. + + + Cannot execute an ModificationCommandBatch which hasn't been completed. + Cannot save changes for an entity of type '{entityType}' in state '{entityState}'. This may indicate a bug in Entity Framework, please open an issue at https://go.microsoft.com/fwlink/?linkid=2142044. Consider using 'DbContextOptionsBuilder.EnableSensitiveDataLogging' to see the key values of the entity. diff --git a/src/EFCore.Relational/Query/Internal/RandomTranslator.cs b/src/EFCore.Relational/Query/Internal/RandomTranslator.cs index 5fc45186084..9c1ab0e26db 100644 --- a/src/EFCore.Relational/Query/Internal/RandomTranslator.cs +++ b/src/EFCore.Relational/Query/Internal/RandomTranslator.cs @@ -43,9 +43,9 @@ public RandomTranslator(ISqlExpressionFactory sqlExpressionFactory) => MethodInfo.Equals(method) ? _sqlExpressionFactory.Function( "RAND", - Array.Empty(), + Enumerable.Empty(), nullable: false, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), method.ReturnType) : null; } diff --git a/src/EFCore.Relational/Update/IBatchExecutor.cs b/src/EFCore.Relational/Update/IBatchExecutor.cs index e8209d5328b..20ebf876acc 100644 --- a/src/EFCore.Relational/Update/IBatchExecutor.cs +++ b/src/EFCore.Relational/Update/IBatchExecutor.cs @@ -28,17 +28,21 @@ public interface IBatchExecutor /// /// Executes the commands in the batches against the given database connection. /// - /// The batches to execute. + /// + /// A list of value tuples, each of which contains a batch to execute, and whether more batches are available. + /// /// The database connection to use. /// The total number of rows affected. int Execute( - IEnumerable commandBatches, + IEnumerable<(ModificationCommandBatch Batch, bool HasMore)> commandBatches, IRelationalConnection connection); /// /// Executes the commands in the batches against the given database connection. /// - /// The batches to execute. + /// + /// A list of value tuples, each of which contains a batch to execute, and whether more batches are available. + /// /// The database connection to use. /// A to observe while waiting for the task to complete. /// @@ -47,7 +51,7 @@ int Execute( /// /// If the is canceled. Task ExecuteAsync( - IEnumerable commandBatches, + IEnumerable<(ModificationCommandBatch Batch, bool HasMore)> commandBatches, IRelationalConnection connection, CancellationToken cancellationToken = default); } diff --git a/src/EFCore.Relational/Update/ICommandBatchPreparer.cs b/src/EFCore.Relational/Update/ICommandBatchPreparer.cs index 9bfdd8ca3e8..b045c936d51 100644 --- a/src/EFCore.Relational/Update/ICommandBatchPreparer.cs +++ b/src/EFCore.Relational/Update/ICommandBatchPreparer.cs @@ -32,8 +32,6 @@ public interface ICommandBatchPreparer /// /// The entries that represent the entities to be modified. /// The model data. - /// The list of batches to execute. - IEnumerable BatchCommands( - IList entries, - IUpdateAdapter updateAdapter); + /// A list of value tuples, each of which contains a batch to execute, and whether more batches are available. + IEnumerable<(ModificationCommandBatch Batch, bool HasMore)> BatchCommands(IList entries, IUpdateAdapter updateAdapter); } diff --git a/src/EFCore.Relational/Update/IUpdateSqlGenerator.cs b/src/EFCore.Relational/Update/IUpdateSqlGenerator.cs index 3022c1c7e29..9fbaca6a97f 100644 --- a/src/EFCore.Relational/Update/IUpdateSqlGenerator.cs +++ b/src/EFCore.Relational/Update/IUpdateSqlGenerator.cs @@ -54,6 +54,26 @@ void AppendNextSequenceValueOperation( /// The builder to which the SQL fragment should be appended. void AppendBatchHeader(StringBuilder commandStringBuilder); + /// + /// Prepends a SQL command for turning on autocommit mode in the database, in case it is off. + /// + /// The builder to which the SQL should be prepended. + void PrependEnsureAutocommit(StringBuilder commandStringBuilder); + + /// + /// Appends a SQL command for deleting a row to the commands being built. + /// + /// The builder to which the SQL should be appended. + /// The command that represents the delete operation. + /// The ordinal of this command in the batch. + /// Returns whether the SQL appended must be executed in a transaction to work correctly. + /// The for the command. + ResultSetMapping AppendDeleteOperation( + StringBuilder commandStringBuilder, + IReadOnlyModificationCommand command, + int commandPosition, + out bool requiresTransaction); + /// /// Appends a SQL command for deleting a row to the commands being built. /// @@ -64,7 +84,22 @@ void AppendNextSequenceValueOperation( ResultSetMapping AppendDeleteOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition); + int commandPosition) + => AppendDeleteOperation(commandStringBuilder, command, commandPosition, out _); + + /// + /// Appends a SQL command for inserting a row to the commands being built. + /// + /// The builder to which the SQL should be appended. + /// The command that represents the delete operation. + /// The ordinal of this command in the batch. + /// Returns whether the SQL appended must be executed in a transaction to work correctly. + /// The for the command. + ResultSetMapping AppendInsertOperation( + StringBuilder commandStringBuilder, + IReadOnlyModificationCommand command, + int commandPosition, + out bool requiresTransaction); /// /// Appends a SQL command for inserting a row to the commands being built. @@ -76,7 +111,22 @@ ResultSetMapping AppendDeleteOperation( ResultSetMapping AppendInsertOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition); + int commandPosition) + => AppendInsertOperation(commandStringBuilder, command, commandPosition, out _); + + /// + /// Appends a SQL command for updating a row to the commands being built. + /// + /// The builder to which the SQL should be appended. + /// The command that represents the delete operation. + /// The ordinal of this command in the batch. + /// Returns whether the SQL appended must be executed in a transaction to work correctly. + /// The for the command. + ResultSetMapping AppendUpdateOperation( + StringBuilder commandStringBuilder, + IReadOnlyModificationCommand command, + int commandPosition, + out bool requiresTransaction); /// /// Appends a SQL command for updating a row to the commands being built. @@ -88,5 +138,6 @@ ResultSetMapping AppendInsertOperation( ResultSetMapping AppendUpdateOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition); + int commandPosition) + => AppendUpdateOperation(commandStringBuilder, command, commandPosition, out _); } diff --git a/src/EFCore.Relational/Update/Internal/BatchExecutor.cs b/src/EFCore.Relational/Update/Internal/BatchExecutor.cs index 0ac9a116e26..6eb20e22be9 100644 --- a/src/EFCore.Relational/Update/Internal/BatchExecutor.cs +++ b/src/EFCore.Relational/Update/Internal/BatchExecutor.cs @@ -49,9 +49,18 @@ public BatchExecutor( /// doing so can result in application failures when updating to a new Entity Framework Core release. /// public virtual int Execute( - IEnumerable commandBatches, + IEnumerable<(ModificationCommandBatch Batch, bool HasMore)> commandBatches, IRelationalConnection connection) { + using var batchEnumerator = commandBatches.GetEnumerator(); + + if (!batchEnumerator.MoveNext()) + { + return 0; + } + + var (batch, hasMoreBatches) = batchEnumerator.Current; + var rowsAffected = 0; var transaction = connection.CurrentTransaction; var beganTransaction = false; @@ -62,7 +71,9 @@ public virtual int Execute( if (transaction == null && transactionEnlistManager?.EnlistedTransaction is null && transactionEnlistManager?.CurrentAmbientTransaction is null - && CurrentContext.Context.Database.AutoTransactionsEnabled) + && CurrentContext.Context.Database.AutoTransactionsEnabled + // Don't start a transaction if we have a single batch which doesn't require a transaction (single command), for perf. + && (hasMoreBatches || batch.RequiresTransaction)) { transaction = connection.BeginTransaction(); beganTransaction = true; @@ -79,11 +90,13 @@ public virtual int Execute( } } - foreach (var batch in commandBatches) + do { + batch = batchEnumerator.Current.Batch; batch.Execute(connection); rowsAffected += batch.ModificationCommands.Count; } + while (batchEnumerator.MoveNext()); if (beganTransaction) { @@ -143,10 +156,19 @@ public virtual int Execute( /// doing so can result in application failures when updating to a new Entity Framework Core release. /// public virtual async Task ExecuteAsync( - IEnumerable commandBatches, + IEnumerable<(ModificationCommandBatch Batch, bool HasMore)> commandBatches, IRelationalConnection connection, CancellationToken cancellationToken = default) { + using var batchEnumerator = commandBatches.GetEnumerator(); + + if (!batchEnumerator.MoveNext()) + { + return 0; + } + + var (batch, hasMoreBatches) = batchEnumerator.Current; + var rowsAffected = 0; var transaction = connection.CurrentTransaction; var beganTransaction = false; @@ -157,7 +179,9 @@ public virtual async Task ExecuteAsync( if (transaction == null && transactionEnlistManager?.EnlistedTransaction is null && transactionEnlistManager?.CurrentAmbientTransaction is null - && CurrentContext.Context.Database.AutoTransactionsEnabled) + && CurrentContext.Context.Database.AutoTransactionsEnabled + // Don't start a transaction if we have a single batch which doesn't require a transaction (single command), for perf. + && (hasMoreBatches || batch.RequiresTransaction)) { transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); beganTransaction = true; @@ -174,11 +198,13 @@ public virtual async Task ExecuteAsync( } } - foreach (var batch in commandBatches) + do { + batch = batchEnumerator.Current.Batch; await batch.ExecuteAsync(connection, cancellationToken).ConfigureAwait(false); rowsAffected += batch.ModificationCommands.Count; } + while (batchEnumerator.MoveNext()); if (beganTransaction) { diff --git a/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs b/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs index 099058647df..9bd866d77dd 100644 --- a/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs +++ b/src/EFCore.Relational/Update/Internal/CommandBatchPreparer.cs @@ -50,7 +50,7 @@ public CommandBatchPreparer(CommandBatchPreparerDependencies dependencies) /// 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 virtual IEnumerable BatchCommands( + public virtual IEnumerable<(ModificationCommandBatch Batch, bool HasMore)> BatchCommands( IList entries, IUpdateAdapter updateAdapter) { @@ -58,8 +58,10 @@ public virtual IEnumerable BatchCommands( var commands = CreateModificationCommands(entries, updateAdapter, parameterNameGenerator.GenerateNext); var sortedCommandSets = TopologicalSort(commands); - foreach (var independentCommandSet in sortedCommandSets) + for (var commandSetIndex = 0; commandSetIndex < sortedCommandSets.Count; commandSetIndex++) { + var independentCommandSet = sortedCommandSets[commandSetIndex]; + independentCommandSet.Sort(Dependencies.ModificationCommandComparer); var batch = Dependencies.ModificationCommandBatchFactory.Create(); @@ -83,7 +85,9 @@ public virtual IEnumerable BatchCommands( batch.ModificationCommands.SelectMany(c => c.Entries), batch.ModificationCommands.Count); } - yield return batch; + batch.Complete(); + + yield return (batch, true); } else { @@ -92,7 +96,10 @@ public virtual IEnumerable BatchCommands( foreach (var command in batch.ModificationCommands) { - yield return StartNewBatch(parameterNameGenerator, command); + batch = StartNewBatch(parameterNameGenerator, command); + batch.Complete(); + + yield return (batch, true); } } @@ -100,6 +107,8 @@ public virtual IEnumerable BatchCommands( } } + var hasMoreCommandSets = commandSetIndex < sortedCommandSets.Count - 1; + if (batch.ModificationCommands.Count == 1 || batch.ModificationCommands.Count >= _minBatchSize) { @@ -109,16 +118,21 @@ public virtual IEnumerable BatchCommands( batch.ModificationCommands.SelectMany(c => c.Entries), batch.ModificationCommands.Count); } - yield return batch; + batch.Complete(); + + yield return (batch, hasMoreCommandSets); } else { Dependencies.UpdateLogger.BatchSmallerThanMinBatchSize( batch.ModificationCommands.SelectMany(c => c.Entries), batch.ModificationCommands.Count, _minBatchSize); - foreach (var command in batch.ModificationCommands) + for (var commandIndex = 0; commandIndex < batch.ModificationCommands.Count; commandIndex++) { - yield return StartNewBatch(parameterNameGenerator, command); + var singleCommandBatch = StartNewBatch(parameterNameGenerator, batch.ModificationCommands[commandIndex]); + singleCommandBatch.Complete(); + + yield return (singleCommandBatch, hasMoreCommandSets || commandIndex < batch.ModificationCommands.Count - 1); } } } @@ -156,7 +170,7 @@ protected virtual IEnumerable CreateModificationCo continue; } - var mappings = (IReadOnlyCollection)entry.EntityType.GetTableMappings(); + var mappings = entry.EntityType.GetTableMappings(); IModificationCommand? firstCommands = null; foreach (var mapping in mappings) { diff --git a/src/EFCore.Relational/Update/ModificationCommandBatch.cs b/src/EFCore.Relational/Update/ModificationCommandBatch.cs index b01c52628af..152e665be45 100644 --- a/src/EFCore.Relational/Update/ModificationCommandBatch.cs +++ b/src/EFCore.Relational/Update/ModificationCommandBatch.cs @@ -33,6 +33,16 @@ public abstract class ModificationCommandBatch /// public abstract bool AddCommand(IReadOnlyModificationCommand modificationCommand); + /// + /// Indicates that no more commands will be added to this batch, and prepares it for execution. + /// + public abstract void Complete(); + + /// + /// Indicates whether the batch requires a transaction in order to execute correctly. + /// + public abstract bool RequiresTransaction { get; } + /// /// Sends insert/update/delete commands to the database. /// diff --git a/src/EFCore.Relational/Update/ReaderModificationCommandBatch.cs b/src/EFCore.Relational/Update/ReaderModificationCommandBatch.cs index b84e25a6b57..1a35f6aa66c 100644 --- a/src/EFCore.Relational/Update/ReaderModificationCommandBatch.cs +++ b/src/EFCore.Relational/Update/ReaderModificationCommandBatch.cs @@ -21,6 +21,8 @@ namespace Microsoft.EntityFrameworkCore.Update; public abstract class ReaderModificationCommandBatch : ModificationCommandBatch { private readonly List _modificationCommands = new(); + private string? _finalCommandText; + private bool _requiresTransaction = true; /// /// Creates a new instance. @@ -74,6 +76,11 @@ public override IReadOnlyList ModificationCommands /// public override bool AddCommand(IReadOnlyModificationCommand modificationCommand) { + if (_finalCommandText is not null) + { + throw new InvalidOperationException(RelationalStrings.ModificationCommandBatchAlreadyComplete); + } + if (ModificationCommands.Count == 0) { ResetCommandText(); @@ -103,15 +110,35 @@ public override bool AddCommand(IReadOnlyModificationCommand modificationCommand /// protected virtual void ResetCommandText() { - if (CachedCommandText.Length > 0) - { - CachedCommandText = new StringBuilder(); - } + CachedCommandText.Clear(); UpdateSqlGenerator.AppendBatchHeader(CachedCommandText); + _batchHeaderLength = CachedCommandText.Length; + + SetRequiresTransaction(true); + LastCachedCommandIndex = -1; } + private int _batchHeaderLength; + + /// + /// Whether any SQL has already been added to the batch command text. + /// + protected virtual bool IsCachedCommandTextEmpty + => CachedCommandText.Length == _batchHeaderLength; + + /// + public override bool RequiresTransaction + => _requiresTransaction; + + /// + /// Sets whether the batch requires a transaction in order to execute correctly. + /// + /// Whether the batch requires a transaction in order to execute correctly. + protected virtual void SetRequiresTransaction(bool requiresTransaction) + => _requiresTransaction = requiresTransaction; + /// /// Checks whether a new command can be added to the batch. /// @@ -126,18 +153,15 @@ protected virtual void ResetCommandText() protected abstract bool IsCommandTextValid(); /// - /// Gets the command text for all the commands in the current batch and also caches it - /// on . + /// Processes all unprocessed commands in the batch, making sure their corresponding SQL is populated in + /// . /// - /// The command text. - protected virtual string GetCommandText() + protected virtual void UpdateCachedCommandText() { for (var i = LastCachedCommandIndex + 1; i < ModificationCommands.Count; i++) { UpdateCachedCommandText(i); } - - return CachedCommandText.ToString(); } /// @@ -149,22 +173,35 @@ protected virtual void UpdateCachedCommandText(int commandPosition) { var newModificationCommand = ModificationCommands[commandPosition]; + bool requiresTransaction; + switch (newModificationCommand.EntityState) { case EntityState.Added: CommandResultSet[commandPosition] = - UpdateSqlGenerator.AppendInsertOperation(CachedCommandText, newModificationCommand, commandPosition); + UpdateSqlGenerator.AppendInsertOperation( + CachedCommandText, newModificationCommand, commandPosition, out requiresTransaction); break; case EntityState.Modified: CommandResultSet[commandPosition] = - UpdateSqlGenerator.AppendUpdateOperation(CachedCommandText, newModificationCommand, commandPosition); + UpdateSqlGenerator.AppendUpdateOperation( + CachedCommandText, newModificationCommand, commandPosition, out requiresTransaction); break; case EntityState.Deleted: CommandResultSet[commandPosition] = - UpdateSqlGenerator.AppendDeleteOperation(CachedCommandText, newModificationCommand, commandPosition); + UpdateSqlGenerator.AppendDeleteOperation( + CachedCommandText, newModificationCommand, commandPosition, out requiresTransaction); break; + + default: + throw new InvalidOperationException( + RelationalStrings.ModificationCommandInvalidEntityState( + newModificationCommand.Entries[0].EntityType, + newModificationCommand.EntityState)); } + _requiresTransaction = commandPosition > 0 || requiresTransaction; + LastCachedCommandIndex = commandPosition; } @@ -175,15 +212,33 @@ protected virtual void UpdateCachedCommandText(int commandPosition) protected virtual int GetParameterCount() => ModificationCommands.Sum(c => c.ColumnModifications.Count); + /// + public override void Complete() + { + UpdateCachedCommandText(); + + // Some database have a mode where autocommit is off, and so executing a command outside of an explicit transaction implicitly + // creates a new transaction (which needs to be explicitly committed). + // The below is a hook for allowing providers to turn autocommit on, in case it's off. + if (!RequiresTransaction) + { + UpdateSqlGenerator.PrependEnsureAutocommit(CachedCommandText); + } + + _finalCommandText = CachedCommandText.ToString(); + } + /// /// Generates a for the batch. /// /// The command. protected virtual RawSqlCommand CreateStoreCommand() { + Check.DebugAssert(_finalCommandText is not null, "_finalCommandText is not null, checked in Execute"); + var commandBuilder = Dependencies.CommandBuilderFactory .Create() - .Append(GetCommandText()); + .Append(_finalCommandText); var parameterValues = new Dictionary(GetParameterCount()); @@ -229,6 +284,11 @@ protected virtual RawSqlCommand CreateStoreCommand() /// The connection to the database to update. public override void Execute(IRelationalConnection connection) { + if (_finalCommandText is null) + { + throw new InvalidOperationException(RelationalStrings.ModificationCommandBatchNotComplete); + } + var storeCommand = CreateStoreCommand(); try @@ -263,6 +323,11 @@ public override async Task ExecuteAsync( IRelationalConnection connection, CancellationToken cancellationToken = default) { + if (_finalCommandText is null) + { + throw new InvalidOperationException(RelationalStrings.ModificationCommandBatchNotComplete); + } + var storeCommand = CreateStoreCommand(); try diff --git a/src/EFCore.Relational/Update/UpdateSqlGenerator.cs b/src/EFCore.Relational/Update/UpdateSqlGenerator.cs index f18c7f3fc3b..f1768588955 100644 --- a/src/EFCore.Relational/Update/UpdateSqlGenerator.cs +++ b/src/EFCore.Relational/Update/UpdateSqlGenerator.cs @@ -53,11 +53,13 @@ protected virtual ISqlGenerationHelper SqlGenerationHelper /// The builder to which the SQL should be appended. /// The command that represents the delete operation. /// The ordinal of this command in the batch. + /// Returns whether the SQL appended must be executed in a transaction to work correctly. /// The for the command. public virtual ResultSetMapping AppendInsertOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition) + int commandPosition, + out bool requiresTransaction) { var name = command.TableName; var schema = command.Schema; @@ -72,9 +74,13 @@ public virtual ResultSetMapping AppendInsertOperation( { var keyOperations = operations.Where(o => o.IsKey).ToList(); + requiresTransaction = true; + return AppendSelectAffectedCommand(commandStringBuilder, name, schema, readOperations, keyOperations, commandPosition); } + requiresTransaction = false; + return ResultSetMapping.NoResultSet; } @@ -84,11 +90,13 @@ public virtual ResultSetMapping AppendInsertOperation( /// The builder to which the SQL should be appended. /// The command that represents the delete operation. /// The ordinal of this command in the batch. + /// Returns whether the SQL appended must be executed in a transaction to work correctly. /// The for the command. public virtual ResultSetMapping AppendUpdateOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition) + int commandPosition, + out bool requiresTransaction) { var name = command.TableName; var schema = command.Schema; @@ -104,9 +112,13 @@ public virtual ResultSetMapping AppendUpdateOperation( { var keyOperations = operations.Where(o => o.IsKey).ToList(); + requiresTransaction = true; + return AppendSelectAffectedCommand(commandStringBuilder, name, schema, readOperations, keyOperations, commandPosition); } + requiresTransaction = false; + return AppendSelectAffectedCountCommand(commandStringBuilder, name, schema, commandPosition); } @@ -116,11 +128,13 @@ public virtual ResultSetMapping AppendUpdateOperation( /// The builder to which the SQL should be appended. /// The command that represents the delete operation. /// The ordinal of this command in the batch. + /// Returns whether the SQL appended must be executed in a transaction to work correctly. /// The for the command. public virtual ResultSetMapping AppendDeleteOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition) + int commandPosition, + out bool requiresTransaction) { var name = command.TableName; var schema = command.Schema; @@ -128,6 +142,8 @@ public virtual ResultSetMapping AppendDeleteOperation( AppendDeleteCommand(commandStringBuilder, name, schema, conditionOperations); + requiresTransaction = false; + return AppendSelectAffectedCountCommand(commandStringBuilder, name, schema, commandPosition); } @@ -530,6 +546,14 @@ public virtual void AppendBatchHeader(StringBuilder commandStringBuilder) { } + /// + /// Prepends a SQL command for turning on autocommit mode in the database, in case it is off. + /// + /// The builder to which the SQL should be prepended. + public virtual void PrependEnsureAutocommit(StringBuilder commandStringBuilder) + { + } + /// /// Generates SQL that will obtain the next value in the given sequence. /// diff --git a/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerGeometryCollectionMemberTranslator.cs b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerGeometryCollectionMemberTranslator.cs index d42e7c6008a..516cd05ae9a 100644 --- a/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerGeometryCollectionMemberTranslator.cs +++ b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerGeometryCollectionMemberTranslator.cs @@ -46,10 +46,10 @@ public SqlServerGeometryCollectionMemberTranslator(ISqlExpressionFactory sqlExpr return _sqlExpressionFactory.Function( instance!, "STNumGeometries", - Array.Empty(), + Enumerable.Empty(), nullable: true, instancePropagatesNullability: true, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), returnType); } diff --git a/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerGeometryMemberTranslator.cs b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerGeometryMemberTranslator.cs index 508c6a70ba0..ce8ad4c2b4a 100644 --- a/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerGeometryMemberTranslator.cs +++ b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerGeometryMemberTranslator.cs @@ -86,10 +86,10 @@ public SqlServerGeometryMemberTranslator( return _sqlExpressionFactory.Function( instance, functionName, - Array.Empty(), + Enumerable.Empty(), nullable: true, instancePropagatesNullability: true, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), returnType, resultTypeMapping); } @@ -131,10 +131,10 @@ public SqlServerGeometryMemberTranslator( _sqlExpressionFactory.Function( instance, "STGeometryType", - Array.Empty(), + Enumerable.Empty(), nullable: true, instancePropagatesNullability: true, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), typeof(string)), whenClauses, null); diff --git a/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerMultiLineStringMemberTranslator.cs b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerMultiLineStringMemberTranslator.cs index 59f8598680c..c65692b3920 100644 --- a/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerMultiLineStringMemberTranslator.cs +++ b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerMultiLineStringMemberTranslator.cs @@ -46,10 +46,10 @@ public SqlServerMultiLineStringMemberTranslator(ISqlExpressionFactory sqlExpress return _sqlExpressionFactory.Function( instance!, "STIsClosed", - Array.Empty(), + Enumerable.Empty(), nullable: true, instancePropagatesNullability: true, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), returnType); } diff --git a/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerPolygonMemberTranslator.cs b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerPolygonMemberTranslator.cs index 1904aba0a7b..db7e90e0f3b 100644 --- a/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerPolygonMemberTranslator.cs +++ b/src/EFCore.SqlServer.NTS/Query/Internal/SqlServerPolygonMemberTranslator.cs @@ -80,10 +80,10 @@ public SqlServerPolygonMemberTranslator( _sqlExpressionFactory.Function( instance, "NumRings", - Array.Empty(), + Enumerable.Empty(), nullable: true, instancePropagatesNullability: true, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), returnType), _sqlExpressionFactory.Constant(1)); } @@ -98,10 +98,10 @@ public SqlServerPolygonMemberTranslator( return _sqlExpressionFactory.Function( instance, functionName, - Array.Empty(), + Enumerable.Empty(), nullable: true, instancePropagatesNullability: true, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), returnType, resultTypeMapping); } diff --git a/src/EFCore.SqlServer/Metadata/Builders/OwnedNavigationTemporalTableBuilder.cs b/src/EFCore.SqlServer/Metadata/Builders/OwnedNavigationTemporalTableBuilder.cs index 2824ca108ab..c067f3a93de 100644 --- a/src/EFCore.SqlServer/Metadata/Builders/OwnedNavigationTemporalTableBuilder.cs +++ b/src/EFCore.SqlServer/Metadata/Builders/OwnedNavigationTemporalTableBuilder.cs @@ -35,11 +35,7 @@ public OwnedNavigationTemporalTableBuilder(OwnedNavigationBuilder referenceOwner /// The name of the history table. /// The same builder instance so that multiple calls can be chained. public virtual OwnedNavigationTemporalTableBuilder UseHistoryTable(string name) - { - _referenceOwnershipBuilder.OwnedEntityType.SetHistoryTableName(name); - - return this; - } + => UseHistoryTable(name, null); /// /// Configures a history table for the entity mapped to a temporal table. diff --git a/src/EFCore.SqlServer/Metadata/Builders/OwnedNavigationTemporalTableBuilder`.cs b/src/EFCore.SqlServer/Metadata/Builders/OwnedNavigationTemporalTableBuilder`.cs index c5ae7530334..2eb7cc7a7f1 100644 --- a/src/EFCore.SqlServer/Metadata/Builders/OwnedNavigationTemporalTableBuilder`.cs +++ b/src/EFCore.SqlServer/Metadata/Builders/OwnedNavigationTemporalTableBuilder`.cs @@ -23,6 +23,18 @@ public OwnedNavigationTemporalTableBuilder(OwnedNavigationBuilder referenceOwner { } + /// + /// Configures a history table for the entity mapped to a temporal table. + /// + /// + /// See Using SQL Server temporal tables with EF Core + /// for more information. + /// + /// The name of the history table. + /// The same builder instance so that multiple calls can be chained. + public new virtual OwnedNavigationTemporalTableBuilder UseHistoryTable(string name) + => (OwnedNavigationTemporalTableBuilder)base.UseHistoryTable(name); + /// /// Configures a history table for the entity mapped to a temporal table. /// @@ -33,6 +45,6 @@ public OwnedNavigationTemporalTableBuilder(OwnedNavigationBuilder referenceOwner /// The name of the history table. /// The schema of the history table. /// The same builder instance so that multiple calls can be chained. - public new virtual OwnedNavigationTemporalTableBuilder UseHistoryTable(string name, string? schema = null) + public new virtual OwnedNavigationTemporalTableBuilder UseHistoryTable(string name, string? schema) => (OwnedNavigationTemporalTableBuilder)base.UseHistoryTable(name, schema); } diff --git a/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder.cs b/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder.cs index c7b9f353eff..a62bfc54250 100644 --- a/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder.cs +++ b/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder.cs @@ -35,11 +35,7 @@ public TemporalTableBuilder(EntityTypeBuilder entityTypeBuilder) /// The name of the history table. /// The same builder instance so that multiple calls can be chained. public virtual TemporalTableBuilder UseHistoryTable(string name) - { - _entityTypeBuilder.Metadata.SetHistoryTableName(name); - - return this; - } + => UseHistoryTable(name, null); /// /// Configures a history table for the entity mapped to a temporal table. diff --git a/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder`.cs b/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder`.cs index fd006adbc99..8afbd9aab12 100644 --- a/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder`.cs +++ b/src/EFCore.SqlServer/Metadata/Builders/TemporalTableBuilder`.cs @@ -23,6 +23,18 @@ public TemporalTableBuilder(EntityTypeBuilder entityTypeBuilder) { } + /// + /// Configures a history table for the entity mapped to a temporal table. + /// + /// + /// See Using SQL Server temporal tables with EF Core + /// for more information and examples. + /// + /// The name of the history table. + /// The same builder instance so that multiple calls can be chained. + public new virtual TemporalTableBuilder UseHistoryTable(string name) + => (TemporalTableBuilder)base.UseHistoryTable(name); + /// /// Configures a history table for the entity mapped to a temporal table. /// @@ -33,6 +45,6 @@ public TemporalTableBuilder(EntityTypeBuilder entityTypeBuilder) /// The name of the history table. /// The schema of the history table. /// The same builder instance so that multiple calls can be chained. - public new virtual TemporalTableBuilder UseHistoryTable(string name, string? schema = null) + public new virtual TemporalTableBuilder UseHistoryTable(string name, string? schema) => (TemporalTableBuilder)base.UseHistoryTable(name, schema); } diff --git a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs index 7734540838a..e8c2670cd64 100644 --- a/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs +++ b/src/EFCore.SqlServer/Metadata/Internal/SqlServerAnnotationProvider.cs @@ -264,13 +264,12 @@ public override IEnumerable For(IColumn column, bool designTime) ? periodEndProperty.GetColumnName(storeObjectIdentifier) : periodEndPropertyName; - if (column.Name == periodStartColumnName - || column.Name == periodEndColumnName) - { - yield return new Annotation(SqlServerAnnotationNames.IsTemporal, true); - yield return new Annotation(SqlServerAnnotationNames.TemporalPeriodStartColumnName, periodStartColumnName); - yield return new Annotation(SqlServerAnnotationNames.TemporalPeriodEndColumnName, periodEndColumnName); - } + // TODO: issue #27459 - we want to avoid having those annotations on every column + yield return new Annotation(SqlServerAnnotationNames.IsTemporal, true); + yield return new Annotation(SqlServerAnnotationNames.TemporalHistoryTableName, entityType.GetHistoryTableName()); + yield return new Annotation(SqlServerAnnotationNames.TemporalHistoryTableSchema, entityType.GetHistoryTableSchema()); + yield return new Annotation(SqlServerAnnotationNames.TemporalPeriodStartColumnName, periodStartColumnName); + yield return new Annotation(SqlServerAnnotationNames.TemporalPeriodEndColumnName, periodEndColumnName); } } } diff --git a/src/EFCore.SqlServer/Migrations/Internal/SqlServerMigrationsAnnotationProvider.cs b/src/EFCore.SqlServer/Migrations/Internal/SqlServerMigrationsAnnotationProvider.cs index 9ef5261bad3..f94295e8c5d 100644 --- a/src/EFCore.SqlServer/Migrations/Internal/SqlServerMigrationsAnnotationProvider.cs +++ b/src/EFCore.SqlServer/Migrations/Internal/SqlServerMigrationsAnnotationProvider.cs @@ -104,4 +104,35 @@ public override IEnumerable ForRename(ITable table) table[SqlServerAnnotationNames.TemporalHistoryTableSchema]); } } + + /// + public override IEnumerable ForRename(IColumn column) + { + if (column.Table[SqlServerAnnotationNames.IsTemporal] as bool? == true) + { + yield return new Annotation(SqlServerAnnotationNames.IsTemporal, true); + + yield return new Annotation( + SqlServerAnnotationNames.TemporalHistoryTableName, + column.Table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + yield return new Annotation( + SqlServerAnnotationNames.TemporalHistoryTableSchema, + column.Table[SqlServerAnnotationNames.TemporalHistoryTableSchema]); + + if (column[SqlServerAnnotationNames.TemporalPeriodStartColumnName] is string periodStartColumnName) + { + yield return new Annotation( + SqlServerAnnotationNames.TemporalPeriodStartColumnName, + periodStartColumnName); + } + + if (column[SqlServerAnnotationNames.TemporalPeriodEndColumnName] is string periodEndColumnName) + { + yield return new Annotation( + SqlServerAnnotationNames.TemporalPeriodEndColumnName, + periodEndColumnName); + } + } + } } diff --git a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs index 616cbdcc5d1..c51474cd220 100644 --- a/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs +++ b/src/EFCore.SqlServer/Migrations/SqlServerMigrationsSqlGenerator.cs @@ -1374,7 +1374,7 @@ protected override void Generate( GenerateIdentityInsert(builder, operation, on: true, model); var sqlBuilder = new StringBuilder(); - ((SqlServerUpdateSqlGenerator)Dependencies.UpdateSqlGenerator).AppendBulkInsertOperation( + ((ISqlServerUpdateSqlGenerator)Dependencies.UpdateSqlGenerator).AppendBulkInsertOperation( sqlBuilder, GenerateModificationCommands(operation, model).ToList(), 0); @@ -2355,21 +2355,47 @@ alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalHistoryTableSchema // if only difference is in temporal annotations being removed or history table changed etc - we can ignore this operation if (!CanSkipAlterColumnOperation(alterColumnOperation.OldColumn, alterColumnOperation)) { + operations.Add(operation); + // when modifying a period column, we need to perform the operations as a normal column first, and only later enable period // removing the period information now, so that when we generate SQL that modifies the column we won't be making them auto generated as period // (making column auto generated is not allowed in ALTER COLUMN statement) // in later operation we enable the period and the period columns get set to auto generated automatically - if (alterColumnOperation[SqlServerAnnotationNames.IsTemporal] as bool? == true - && alterColumnOperation.OldColumn[SqlServerAnnotationNames.IsTemporal] is null) + // + // if the column is not period we just remove temporal information - it's no longer needed and could affect the generated sql + // we will generate all the necessary operations involved with temporal tables here + alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.IsTemporal); + alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodStartColumnName); + alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodEndColumnName); + alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalHistoryTableName); + alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalHistoryTableSchema); + + // this is the case where we are not converting from normal table to temporal + // just a normal modification to a column on a temporal table + // in that case we need to double check if we need have disabled versioning earlier in this migration + // if so, we need to mirror the operation to the history table + if (alterColumnOperation.OldColumn[SqlServerAnnotationNames.IsTemporal] as bool? == true) { - alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.IsTemporal); - alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodStartColumnName); - alterColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodEndColumnName); + alterColumnOperation.OldColumn.RemoveAnnotation(SqlServerAnnotationNames.IsTemporal); + alterColumnOperation.OldColumn.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodStartColumnName); + alterColumnOperation.OldColumn.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodEndColumnName); + alterColumnOperation.OldColumn.RemoveAnnotation(SqlServerAnnotationNames.TemporalHistoryTableName); + alterColumnOperation.OldColumn.RemoveAnnotation(SqlServerAnnotationNames.TemporalHistoryTableSchema); + + if (versioningMap.ContainsKey((table, schema))) + { + var alterHistoryTableColumn = CopyColumnOperation(alterColumnOperation); + alterHistoryTableColumn.Table = historyTableName!; + alterHistoryTableColumn.Schema = historyTableSchema; + alterHistoryTableColumn.OldColumn = CopyColumnOperation(alterColumnOperation.OldColumn); + alterHistoryTableColumn.OldColumn.Table = historyTableName!; + alterHistoryTableColumn.OldColumn.Schema = historyTableSchema; + + operations.Add(alterHistoryTableColumn); + } // TODO: test what happens if default value just changes (from temporal to temporal) } - - operations.Add(operation); } break; @@ -2396,6 +2422,8 @@ alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalHistoryTableSchema break; case AddColumnOperation addColumnOperation: + operations.Add(addColumnOperation); + // when adding a period column, we need to add it as a normal column first, and only later enable period // removing the period information now, so that when we generate SQL that adds the column we won't be making them auto generated as period // it won't work, unless period is enabled @@ -2403,6 +2431,8 @@ alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalHistoryTableSchema if (addColumnOperation[SqlServerAnnotationNames.IsTemporal] as bool? == true) { addColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.IsTemporal); + addColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalHistoryTableName); + addColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalHistoryTableSchema); addColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodStartColumnName); addColumnOperation.RemoveAnnotation(SqlServerAnnotationNames.TemporalPeriodEndColumnName); @@ -2411,14 +2441,48 @@ alterTableOperation.OldTable[SqlServerAnnotationNames.TemporalHistoryTableSchema { addColumnOperation.DefaultValue = DateTime.MaxValue; } + + // when adding (non-period) column to an exisiting temporal table we need to check if we have disabled the period + // due to some other operations in the same migration (e.g. delete column) + // if so, we need to also add the same column to history table + if (addColumnOperation.Name != periodStartColumnName + && addColumnOperation.Name != periodEndColumnName) + { + if (versioningMap.ContainsKey((table, schema))) + { + var addHistoryTableColumnOperation = CopyColumnOperation(addColumnOperation); + addHistoryTableColumnOperation.Table = historyTableName!; + addHistoryTableColumnOperation.Schema = historyTableSchema; + + operations.Add(addHistoryTableColumnOperation); + } + } + } + + break; + + case RenameColumnOperation renameColumnOperation: + operations.Add(renameColumnOperation); + + // if we disabled period for the temporal table and now we are renaming the column, + // we need to also rename this same column in history table + if (versioningMap.ContainsKey((table, schema))) + { + var renameHistoryTableColumnOperation = new RenameColumnOperation + { + IsDestructiveChange = renameColumnOperation.IsDestructiveChange, + Name = renameColumnOperation.Name, + NewName = renameColumnOperation.NewName, + Table = historyTableName!, + Schema = historyTableSchema + }; + + operations.Add(renameHistoryTableColumnOperation); } - operations.Add(addColumnOperation); break; default: - // CreateTableOperation - // RenameColumnOperation operations.Add(operation); break; } @@ -2607,6 +2671,7 @@ static bool CanSkipAlterColumnOperation(ColumnOperation first, ColumnOperation s && ColumnOperationsOnlyDifferByTemporalTableAnnotation(first, second) && ColumnOperationsOnlyDifferByTemporalTableAnnotation(second, first); + // don't compare name, table or schema - they are not being set in the model differ (since they should always be the same) static bool ColumnPropertiesAreTheSame(ColumnOperation first, ColumnOperation second) => first.ClrType == second.ClrType && first.Collation == second.Collation @@ -2651,5 +2716,39 @@ static bool ColumnOperationsOnlyDifferByTemporalTableAnnotation(ColumnOperation || a.Name == SqlServerAnnotationNames.TemporalPeriodStartColumnName || a.Name == SqlServerAnnotationNames.TemporalPeriodEndColumnName); } + + static TOperation CopyColumnOperation(ColumnOperation source) + where TOperation : ColumnOperation, new() + { + var result = new TOperation + { + ClrType = source.ClrType, + Collation = source.Collation, + ColumnType = source.ColumnType, + Comment = source.Comment, + ComputedColumnSql = source.ComputedColumnSql, + DefaultValue = source.DefaultValue, + DefaultValueSql = source.DefaultValueSql, + IsDestructiveChange = source.IsDestructiveChange, + IsFixedLength = source.IsFixedLength, + IsNullable = source.IsNullable, + IsRowVersion = source.IsRowVersion, + IsStored = source.IsStored, + IsUnicode = source.IsUnicode, + MaxLength = source.MaxLength, + Name = source.Name, + Precision = source.Precision, + Scale = source.Scale, + Table = source.Table, + Schema = source.Schema + }; + + foreach (var annotation in source.GetAnnotations()) + { + result.AddAnnotation(annotation.Name, annotation.Value); + } + + return result; + } } } diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerDateTimeMemberTranslator.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerDateTimeMemberTranslator.cs index 5a20742cc90..5ce73b14346 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerDateTimeMemberTranslator.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerDateTimeMemberTranslator.cs @@ -96,17 +96,17 @@ public SqlServerDateTimeMemberTranslator( case nameof(DateTime.Now): return _sqlExpressionFactory.Function( declaringType == typeof(DateTime) ? "GETDATE" : "SYSDATETIMEOFFSET", - Array.Empty(), + Enumerable.Empty(), nullable: false, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), returnType); case nameof(DateTime.UtcNow): var serverTranslation = _sqlExpressionFactory.Function( declaringType == typeof(DateTime) ? "GETUTCDATE" : "SYSUTCDATETIME", - Array.Empty(), + Enumerable.Empty(), nullable: false, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), returnType); return declaringType == typeof(DateTime) @@ -121,9 +121,9 @@ public SqlServerDateTimeMemberTranslator( _sqlExpressionFactory.Fragment("date"), _sqlExpressionFactory.Function( "GETDATE", - Array.Empty(), + Enumerable.Empty(), nullable: false, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), typeof(DateTime)) }, nullable: true, diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerNewGuidTranslator.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerNewGuidTranslator.cs index 87036517db0..65b7d074c31 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerNewGuidTranslator.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerNewGuidTranslator.cs @@ -41,9 +41,9 @@ public SqlServerNewGuidTranslator(ISqlExpressionFactory sqlExpressionFactory) => MethodInfo.Equals(method) ? _sqlExpressionFactory.Function( "NEWID", - Array.Empty(), + Enumerable.Empty(), nullable: false, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), method.ReturnType) : null; } diff --git a/src/EFCore.SqlServer/Update/Internal/ISqlServerUpdateSqlGenerator.cs b/src/EFCore.SqlServer/Update/Internal/ISqlServerUpdateSqlGenerator.cs index 4099dd73dec..c7f92fb002a 100644 --- a/src/EFCore.SqlServer/Update/Internal/ISqlServerUpdateSqlGenerator.cs +++ b/src/EFCore.SqlServer/Update/Internal/ISqlServerUpdateSqlGenerator.cs @@ -27,5 +27,18 @@ public interface ISqlServerUpdateSqlGenerator : IUpdateSqlGenerator ResultSetMapping AppendBulkInsertOperation( StringBuilder commandStringBuilder, IReadOnlyList modificationCommands, - int commandPosition); + int commandPosition, + out bool requiresTransaction); + + /// + /// 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. + /// + ResultSetMapping AppendBulkInsertOperation( + StringBuilder commandStringBuilder, + IReadOnlyList modificationCommands, + int commandPosition) + => AppendBulkInsertOperation(commandStringBuilder, modificationCommands, commandPosition, out _); } diff --git a/src/EFCore.SqlServer/Update/Internal/SqlServerModificationCommandBatch.cs b/src/EFCore.SqlServer/Update/Internal/SqlServerModificationCommandBatch.cs index c775d2b83f0..18e4ee6d501 100644 --- a/src/EFCore.SqlServer/Update/Internal/SqlServerModificationCommandBatch.cs +++ b/src/EFCore.SqlServer/Update/Internal/SqlServerModificationCommandBatch.cs @@ -85,7 +85,8 @@ protected override bool IsCommandTextValid() { if (--_commandsLeftToLengthCheck < 0) { - var commandTextLength = GetCommandText().Length; + UpdateCachedCommandText(); + var commandTextLength = CachedCommandText.Length; if (commandTextLength >= MaxScriptLength) { return false; @@ -138,28 +139,24 @@ private static int CountParameters(IReadOnlyModificationCommand modificationComm protected override void ResetCommandText() { base.ResetCommandText(); + _bulkInsertCommands.Clear(); } - /// - /// 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 override string GetCommandText() - => base.GetCommandText() + GetBulkInsertCommandText(ModificationCommands.Count); - - private string GetBulkInsertCommandText(int lastIndex) + private void AppendBulkInsertCommandText(int lastIndex) { if (_bulkInsertCommands.Count == 0) { - return string.Empty; + return; } - var stringBuilder = new StringBuilder(); + var wasCachedCommandTextEmpty = IsCachedCommandTextEmpty; + var resultSetMapping = UpdateSqlGenerator.AppendBulkInsertOperation( - stringBuilder, _bulkInsertCommands, lastIndex - _bulkInsertCommands.Count); + CachedCommandText, _bulkInsertCommands, lastIndex - _bulkInsertCommands.Count, out var requiresTransaction); + + SetRequiresTransaction(!wasCachedCommandTextEmpty || requiresTransaction); + for (var i = lastIndex - _bulkInsertCommands.Count; i < lastIndex; i++) { CommandResultSet[i] = resultSetMapping; @@ -169,8 +166,19 @@ private string GetBulkInsertCommandText(int lastIndex) { CommandResultSet[lastIndex - 1] = ResultSetMapping.LastInResultSet; } + } - return stringBuilder.ToString(); + /// + /// 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 override void UpdateCachedCommandText() + { + base.UpdateCachedCommandText(); + + AppendBulkInsertCommandText(ModificationCommands.Count); } /// @@ -188,7 +196,9 @@ protected override void UpdateCachedCommandText(int commandPosition) if (_bulkInsertCommands.Count > 0 && !CanBeInsertedInSameStatement(_bulkInsertCommands[0], newModificationCommand)) { - CachedCommandText.Append(GetBulkInsertCommandText(commandPosition)); + // The new Add command cannot be added to the pending bulk insert commands (e.g. different table). + // Write out the pending commands before starting a new pending chain. + AppendBulkInsertCommandText(commandPosition); _bulkInsertCommands.Clear(); } @@ -198,8 +208,14 @@ protected override void UpdateCachedCommandText(int commandPosition) } else { - CachedCommandText.Append(GetBulkInsertCommandText(commandPosition)); - _bulkInsertCommands.Clear(); + // If we have any pending bulk insert commands, write them out before the next non-Add command + if (_bulkInsertCommands.Count > 0) + { + // Note that we don't care about the transactionality of the bulk insert SQL, since there's the additional non-Add + // command coming right afterwards, and so a transaction is required in any case. + AppendBulkInsertCommandText(commandPosition); + _bulkInsertCommands.Clear(); + } base.UpdateCachedCommandText(commandPosition); } diff --git a/src/EFCore.SqlServer/Update/Internal/SqlServerUpdateSqlGenerator.cs b/src/EFCore.SqlServer/Update/Internal/SqlServerUpdateSqlGenerator.cs index 709e7558e76..1d18cee739b 100644 --- a/src/EFCore.SqlServer/Update/Internal/SqlServerUpdateSqlGenerator.cs +++ b/src/EFCore.SqlServer/Update/Internal/SqlServerUpdateSqlGenerator.cs @@ -35,7 +35,8 @@ public SqlServerUpdateSqlGenerator( public virtual ResultSetMapping AppendBulkInsertOperation( StringBuilder commandStringBuilder, IReadOnlyList modificationCommands, - int commandPosition) + int commandPosition, + out bool requiresTransaction) { var table = StoreObjectIdentifier.Table(modificationCommands[0].TableName, modificationCommands[0].Schema); if (modificationCommands.Count == 1) @@ -45,13 +46,16 @@ public virtual ResultSetMapping AppendBulkInsertOperation( !o.IsKey || !o.IsRead || o.Property?.GetValueGenerationStrategy(table) == SqlServerValueGenerationStrategy.IdentityColumn) - ? AppendInsertOperation(commandStringBuilder, modificationCommands[0], commandPosition) + // Do a regular INSERT+SELECT for IDENTITY, but not if there are any non-IDENTITY generated columns + ? AppendInsertOperation(commandStringBuilder, modificationCommands[0], commandPosition, out requiresTransaction) + // If we have a non-identity generated column, do INSERT ... OUTPUT INTO @inserted; SELECT ... FROM @inserted : AppendInsertOperationWithServerKeys( commandStringBuilder, modificationCommands[0], modificationCommands[0].ColumnModifications.Where(o => o.IsKey).ToList(), modificationCommands[0].ColumnModifications.Where(o => o.IsRead).ToList(), - commandPosition); + commandPosition, + out requiresTransaction); } var readOperations = modificationCommands[0].ColumnModifications.Where(o => o.IsRead).ToList(); @@ -59,18 +63,23 @@ public virtual ResultSetMapping AppendBulkInsertOperation( var keyOperations = modificationCommands[0].ColumnModifications.Where(o => o.IsKey).ToList(); var defaultValuesOnly = writeOperations.Count == 0; - var nonIdentityOperations = modificationCommands[0].ColumnModifications - .Where(o => o.Property?.GetValueGenerationStrategy(table) != SqlServerValueGenerationStrategy.IdentityColumn) + var writableOperations = modificationCommands[0].ColumnModifications + .Where(o => + o.Property?.GetValueGenerationStrategy(table) != SqlServerValueGenerationStrategy.IdentityColumn + && o.Property?.GetComputedColumnSql() is null + && o.Property?.GetColumnType() is not "rowversion" and not "timestamp") .ToList(); if (defaultValuesOnly) { - if (nonIdentityOperations.Count == 0 + if (writableOperations.Count == 0 || readOperations.Count == 0) { + requiresTransaction = modificationCommands.Count > 1; foreach (var modification in modificationCommands) { - AppendInsertOperation(commandStringBuilder, modification, commandPosition); + AppendInsertOperation(commandStringBuilder, modification, commandPosition, out var localRequiresTransaction); + requiresTransaction = requiresTransaction || localRequiresTransaction; } return readOperations.Count == 0 @@ -78,31 +87,36 @@ public virtual ResultSetMapping AppendBulkInsertOperation( : ResultSetMapping.LastInResultSet; } - if (nonIdentityOperations.Count > 1) + if (writableOperations.Count > 1) { - nonIdentityOperations.RemoveRange(1, nonIdentityOperations.Count - 1); + writableOperations.RemoveRange(1, writableOperations.Count - 1); } } if (readOperations.Count == 0) { - return AppendBulkInsertWithoutServerValues(commandStringBuilder, modificationCommands, writeOperations); + return AppendBulkInsertWithoutServerValues( + commandStringBuilder, modificationCommands, writeOperations, out requiresTransaction); } if (defaultValuesOnly) { return AppendBulkInsertWithServerValuesOnly( - commandStringBuilder, modificationCommands, commandPosition, nonIdentityOperations, keyOperations, readOperations); + commandStringBuilder, modificationCommands, commandPosition, writableOperations, keyOperations, readOperations, + out requiresTransaction); } if (modificationCommands[0].Entries.SelectMany(e => e.EntityType.GetAllBaseTypesInclusive()) .Any(e => e.IsMemoryOptimized())) { - if (!nonIdentityOperations.Any(o => o.IsRead && o.IsKey)) + requiresTransaction = modificationCommands.Count > 1; + + if (!writableOperations.Any(o => o.IsRead && o.IsKey)) { foreach (var modification in modificationCommands) { - AppendInsertOperation(commandStringBuilder, modification, commandPosition++); + AppendInsertOperation(commandStringBuilder, modification, commandPosition++, out var localRequiresTransaction); + requiresTransaction = requiresTransaction || localRequiresTransaction; } } else @@ -110,7 +124,9 @@ public virtual ResultSetMapping AppendBulkInsertOperation( foreach (var modification in modificationCommands) { AppendInsertOperationWithServerKeys( - commandStringBuilder, modification, keyOperations, readOperations, commandPosition++); + commandStringBuilder, modification, keyOperations, readOperations, commandPosition++, + out var localRequiresTransaction); + requiresTransaction = requiresTransaction || localRequiresTransaction; } } @@ -118,13 +134,15 @@ public virtual ResultSetMapping AppendBulkInsertOperation( } return AppendBulkInsertWithServerValues( - commandStringBuilder, modificationCommands, commandPosition, writeOperations, keyOperations, readOperations); + commandStringBuilder, modificationCommands, commandPosition, writeOperations, keyOperations, readOperations, + out requiresTransaction); } private ResultSetMapping AppendBulkInsertWithoutServerValues( StringBuilder commandStringBuilder, IReadOnlyList modificationCommands, - List writeOperations) + List writeOperations, + out bool requiresTransaction) { Check.DebugAssert(writeOperations.Count > 0, $"writeOperations.Count is {writeOperations.Count}"); @@ -143,6 +161,8 @@ private ResultSetMapping AppendBulkInsertWithoutServerValues( commandStringBuilder.AppendLine(SqlGenerationHelper.StatementTerminator); + requiresTransaction = false; + return ResultSetMapping.NoResultSet; } @@ -158,7 +178,8 @@ private ResultSetMapping AppendBulkInsertWithServerValues( int commandPosition, List writeOperations, List keyOperations, - List readOperations) + List readOperations, + out bool requiresTransaction) { AppendDeclareTable( commandStringBuilder, @@ -190,6 +211,8 @@ private ResultSetMapping AppendBulkInsertWithServerValues( commandStringBuilder, readOperations, keyOperations, InsertedTableBaseName, commandPosition, name, schema, orderColumn: PositionColumnName); + requiresTransaction = true; + return ResultSetMapping.NotLastInResultSet; } @@ -197,28 +220,31 @@ private ResultSetMapping AppendBulkInsertWithServerValuesOnly( StringBuilder commandStringBuilder, IReadOnlyList modificationCommands, int commandPosition, - List nonIdentityOperations, + List writableOperations, List keyOperations, - List readOperations) + List readOperations, + out bool requiresTransaction) { AppendDeclareTable(commandStringBuilder, InsertedTableBaseName, commandPosition, keyOperations); var name = modificationCommands[0].TableName; var schema = modificationCommands[0].Schema; - AppendInsertCommandHeader(commandStringBuilder, name, schema, nonIdentityOperations); + AppendInsertCommandHeader(commandStringBuilder, name, schema, writableOperations); AppendOutputClause(commandStringBuilder, keyOperations, InsertedTableBaseName, commandPosition); - AppendValuesHeader(commandStringBuilder, nonIdentityOperations); - AppendValues(commandStringBuilder, name, schema, nonIdentityOperations); + AppendValuesHeader(commandStringBuilder, writableOperations); + AppendValues(commandStringBuilder, name, schema, writableOperations); for (var i = 1; i < modificationCommands.Count; i++) { commandStringBuilder.AppendLine(","); - AppendValues(commandStringBuilder, name, schema, nonIdentityOperations); + AppendValues(commandStringBuilder, name, schema, writableOperations); } commandStringBuilder.Append(SqlGenerationHelper.StatementTerminator); AppendSelectCommand(commandStringBuilder, readOperations, keyOperations, InsertedTableBaseName, commandPosition, name, schema); + requiresTransaction = true; + return ResultSetMapping.NotLastInResultSet; } @@ -399,7 +425,8 @@ private ResultSetMapping AppendInsertOperationWithServerKeys( IReadOnlyModificationCommand command, IReadOnlyList keyOperations, IReadOnlyList readOperations, - int commandPosition) + int commandPosition, + out bool requiresTransaction) { var name = command.TableName; var schema = command.Schema; @@ -415,6 +442,8 @@ private ResultSetMapping AppendInsertOperationWithServerKeys( AppendValues(commandStringBuilder, name, schema, writeOperations); commandStringBuilder.Append(SqlGenerationHelper.StatementTerminator); + requiresTransaction = true; + return AppendSelectCommand( commandStringBuilder, readOperations, keyOperations, InsertedTableBaseName, commandPosition, name, schema); } @@ -515,6 +544,19 @@ public override void AppendBatchHeader(StringBuilder commandStringBuilder) .Append("SET NOCOUNT ON") .AppendLine(SqlGenerationHelper.StatementTerminator); + /// + /// 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 override void PrependEnsureAutocommit(StringBuilder commandStringBuilder) + { + // SQL Server allows turning off autocommit via the IMPLICIT_TRANSACTIONS setting (see + // https://docs.microsoft.com/sql/t-sql/statements/set-implicit-transactions-transact-sql). + commandStringBuilder.Insert(0, $"SET IMPLICIT_TRANSACTIONS OFF{SqlGenerationHelper.StatementTerminator}{Environment.NewLine}"); + } + /// /// 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.Sqlite.Core/Query/Internal/SqliteRandomTranslator.cs b/src/EFCore.Sqlite.Core/Query/Internal/SqliteRandomTranslator.cs index 0670c539e9b..bee733a576a 100644 --- a/src/EFCore.Sqlite.Core/Query/Internal/SqliteRandomTranslator.cs +++ b/src/EFCore.Sqlite.Core/Query/Internal/SqliteRandomTranslator.cs @@ -49,14 +49,14 @@ public SqliteRandomTranslator(ISqlExpressionFactory sqlExpressionFactory) _sqlExpressionFactory.Divide( _sqlExpressionFactory.Function( "random", - Array.Empty(), + Enumerable.Empty(), nullable: false, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), method.ReturnType), _sqlExpressionFactory.Constant(9223372036854780000.0)) }, nullable: false, - argumentsPropagateNullability: Array.Empty(), + argumentsPropagateNullability: Enumerable.Empty(), method.ReturnType) : null; } diff --git a/src/EFCore/DbContext.cs b/src/EFCore/DbContext.cs index b83d8f0ef41..1f68bb0335a 100644 --- a/src/EFCore/DbContext.cs +++ b/src/EFCore/DbContext.cs @@ -482,7 +482,7 @@ protected internal virtual void OnConfiguring(DbContextOptionsBuilder optionsBui /// /// /// If a model is explicitly set on the options for this context (via ) - /// then this method will not be run. + /// then this method will not be run. However, it will still run when creating a compiled model. /// /// /// See Pre-convention model building in EF Core for more information and @@ -504,7 +504,7 @@ protected internal virtual void ConfigureConventions(ModelConfigurationBuilder c /// /// /// If a model is explicitly set on the options for this context (via ) - /// then this method will not be run. + /// then this method will not be run. However, it will still run when creating a compiled model. /// /// /// See Modeling entity types and relationships for more information and diff --git a/src/EFCore/Metadata/IReadOnlyEntityType.cs b/src/EFCore/Metadata/IReadOnlyEntityType.cs index 01d84043020..a13bb8dd4ff 100644 --- a/src/EFCore/Metadata/IReadOnlyEntityType.cs +++ b/src/EFCore/Metadata/IReadOnlyEntityType.cs @@ -159,7 +159,17 @@ bool IsAssignableFrom(IReadOnlyEntityType derivedType) { Check.NotNull(derivedType, nameof(derivedType)); - var baseType = derivedType; + if (derivedType == this) + { + return true; + } + + if (!GetDirectlyDerivedTypes().Any()) + { + return false; + } + + var baseType = derivedType.BaseType; while (baseType != null) { if (baseType == this) diff --git a/src/EFCore/Metadata/Internal/Model.cs b/src/EFCore/Metadata/Internal/Model.cs index 51448d255e4..e1a419d65a6 100644 --- a/src/EFCore/Metadata/Internal/Model.cs +++ b/src/EFCore/Metadata/Internal/Model.cs @@ -458,11 +458,11 @@ public virtual IEnumerable FindEntityTypes(Type type) /// 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 virtual IReadOnlyCollection GetEntityTypes(string name) + public virtual IEnumerable GetEntityTypes(string name) { var entityType = FindEntityType(name); return entityType == null - ? Array.Empty() + ? Enumerable.Empty() : new[] { entityType }; } diff --git a/src/EFCore/Metadata/RuntimeModel.cs b/src/EFCore/Metadata/RuntimeModel.cs index 3c41e2924d2..75968bbab15 100644 --- a/src/EFCore/Metadata/RuntimeModel.cs +++ b/src/EFCore/Metadata/RuntimeModel.cs @@ -126,7 +126,7 @@ private IEnumerable FindEntityTypes(Type type) { var entityType = FindEntityType(GetDisplayName(type)); var result = entityType == null - ? Array.Empty() + ? Enumerable.Empty() : new[] { entityType }; return _sharedTypes.TryGetValue(type, out var sharedTypes) diff --git a/test/EFCore.Design.Tests/Design/DesignTimeServicesTest.cs b/test/EFCore.Design.Tests/Design/DesignTimeServicesTest.cs index ac5b86ad43a..7c871ad77d0 100644 --- a/test/EFCore.Design.Tests/Design/DesignTimeServicesTest.cs +++ b/test/EFCore.Design.Tests/Design/DesignTimeServicesTest.cs @@ -8,6 +8,8 @@ using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; using Microsoft.EntityFrameworkCore.SqlServer.Design.Internal; using Microsoft.EntityFrameworkCore.SqlServer.Scaffolding.Internal; +using Microsoft.EntityFrameworkCore.TextTemplating; +using Microsoft.EntityFrameworkCore.TextTemplating.Internal; using Microsoft.Extensions.DependencyInjection.Extensions; namespace Microsoft.EntityFrameworkCore.Design; @@ -94,7 +96,11 @@ public class UserMigrationsIdGenerator : IMigrationsIdGenerator Assert.Equal(typeof(CSharpMigrationsGenerator), serviceProvider.GetRequiredService().GetType()); Assert.Equal( typeof(MigrationsCodeGeneratorSelector), serviceProvider.GetRequiredService().GetType()); - Assert.Equal(typeof(CSharpModelGenerator), serviceProvider.GetRequiredService().GetType()); + Assert.Equal(typeof(TextTemplatingService), serviceProvider.GetRequiredService().GetType()); + Assert.Collection( + serviceProvider.GetServices(), + s => Assert.Equal(typeof(TextTemplatingModelGenerator), s.GetType()), + s => Assert.Equal(typeof(CSharpModelGenerator), s.GetType())); Assert.Equal(typeof(ModelCodeGeneratorSelector), serviceProvider.GetRequiredService().GetType()); Assert.Equal( typeof(CSharpRuntimeModelCodeGenerator), serviceProvider.GetRequiredService().GetType()); diff --git a/test/EFCore.Design.Tests/Migrations/Design/MigrationsBundleTest.cs b/test/EFCore.Design.Tests/Migrations/Design/MigrationsBundleTest.cs index 3a2da1d4292..49b9e305c8f 100644 --- a/test/EFCore.Design.Tests/Migrations/Design/MigrationsBundleTest.cs +++ b/test/EFCore.Design.Tests/Migrations/Design/MigrationsBundleTest.cs @@ -7,7 +7,7 @@ namespace Microsoft.EntityFrameworkCore.Migrations.Design; public class MigrationsBundleTest { - [Fact] + [ConditionalFact] public void Short_names_are_unique() { foreach (var command in GetCommands()) @@ -24,7 +24,7 @@ public void Short_names_are_unique() } } - [Fact] + [ConditionalFact] public void Long_names_are_unique() { foreach (var command in GetCommands()) @@ -41,7 +41,7 @@ public void Long_names_are_unique() } } - [Fact] + [ConditionalFact] public void HandleResponseFiles_is_true() { var app = new CommandLineApplication { Name = "efbundle" }; @@ -50,7 +50,7 @@ public void HandleResponseFiles_is_true() Assert.True(app.HandleResponseFiles); } - [Fact] + [ConditionalFact] public void AllowArgumentSeparator_is_true() { var app = new CommandLineApplication { Name = "efbundle" }; diff --git a/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs b/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs index 0049e6289ee..285844ce4c9 100644 --- a/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs +++ b/test/EFCore.Design.Tests/Migrations/ModelSnapshotSqlServerTest.cs @@ -1276,7 +1276,7 @@ public virtual void Primary_key_is_stored_in_snapshot() ); }); - [Fact] + [ConditionalFact] public void HasNoKey_is_handled() => Test( builder => builder.Entity().Ignore(e => e.EntityWithTwoProperties).HasNoKey(), @@ -2085,7 +2085,7 @@ public virtual void Temporal_table_information_is_stored_in_snapshot() "Microsoft.EntityFrameworkCore.Migrations.ModelSnapshotSqlServerTest+EntityWithStringProperty"); var annotations = temporalEntity.GetAnnotations().ToList(); - Assert.Equal(6, annotations.Count); + Assert.Equal(7, annotations.Count); Assert.Contains(annotations, a => a.Name == SqlServerAnnotationNames.IsTemporal && a.Value as bool? == true); Assert.Contains( annotations, @@ -2147,7 +2147,7 @@ public virtual void Temporal_table_information_is_stored_in_snapshot_minimal_set "Microsoft.EntityFrameworkCore.Migrations.ModelSnapshotSqlServerTest+EntityWithStringProperty"); var annotations = temporalEntity.GetAnnotations().ToList(); - Assert.Equal(6, annotations.Count); + Assert.Equal(7, annotations.Count); Assert.Contains(annotations, a => a.Name == SqlServerAnnotationNames.IsTemporal && a.Value as bool? == true); Assert.Contains( annotations, diff --git a/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpDbContextGeneratorTest.cs b/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpDbContextGeneratorTest.cs index a785ec29210..3b8eaf0a1ba 100644 --- a/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpDbContextGeneratorTest.cs +++ b/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpDbContextGeneratorTest.cs @@ -191,7 +191,8 @@ public void Required_options_to_GenerateModel_are_not_null() var generator = CreateServices() .AddSingleton() .BuildServiceProvider(validateScopes: true) - .GetRequiredService(); + .GetServices() + .Last(g => g is CSharpModelGenerator); Assert.StartsWith( CoreStrings.ArgumentPropertyNull(nameof(ModelCodeGenerationOptions.ContextName), "options"), @@ -217,7 +218,8 @@ public void Plugins_work() var generator = CreateServices() .AddSingleton() .BuildServiceProvider(validateScopes: true) - .GetRequiredService(); + .GetServices() + .Last(g => g is CSharpModelGenerator); var scaffoldedModel = generator.GenerateModel( new Model(), diff --git a/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpModelGeneratorTest.cs b/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpModelGeneratorTest.cs index 7da73280ac2..9023d6205a3 100644 --- a/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpModelGeneratorTest.cs +++ b/test/EFCore.Design.Tests/Scaffolding/Internal/CSharpModelGeneratorTest.cs @@ -53,6 +53,7 @@ private static IModelCodeGenerator CreateGenerator() .AddSingleton() .AddSingleton() .BuildServiceProvider(validateScopes: true) - .GetRequiredService(); + .GetServices() + .Last(g => g is CSharpModelGenerator); } } diff --git a/test/EFCore.Design.Tests/Scaffolding/Internal/ModelCodeGeneratorSelectorTest.cs b/test/EFCore.Design.Tests/Scaffolding/Internal/ModelCodeGeneratorSelectorTest.cs new file mode 100644 index 00000000000..e05d4609c8d --- /dev/null +++ b/test/EFCore.Design.Tests/Scaffolding/Internal/ModelCodeGeneratorSelectorTest.cs @@ -0,0 +1,119 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore.Internal; + +namespace Microsoft.EntityFrameworkCore.Scaffolding.Internal; + +public class ModelCodeGeneratorSelectorTest +{ + [ConditionalFact] + public void Select_returns_last_service_for_language() + { + var expected = new TestModelCodeGenerator("C#"); + var selector = new ModelCodeGeneratorSelector( + new[] + { + new TestModelCodeGenerator("C#"), + expected + }); + + var result = selector.Select( + new ModelCodeGenerationOptions + { + Language = "C#" + }); + + Assert.Same(expected, result); + } + + [ConditionalFact] + public void Select_throws_when_no_service_for_language() + { + var selector = new ModelCodeGeneratorSelector( + new[] + { + new TestModelCodeGenerator("C#") + }); + var options = new ModelCodeGenerationOptions + { + Language = "VB" + }; + + var ex = Assert.Throws( + () => selector.Select(options)); + + Assert.Equal(DesignStrings.NoLanguageService("VB", nameof(IModelCodeGenerator)), ex.Message); + } + + [ConditionalFact] + public void Select_returns_last_templated_service_with_templates() + { + var expected = new TestTemplatedModelGenerator(hasTemplates: true); + var selector = new ModelCodeGeneratorSelector( + new IModelCodeGenerator[] + { + new TestTemplatedModelGenerator(hasTemplates: true), + expected, + new TestTemplatedModelGenerator(hasTemplates: false), + new TestModelCodeGenerator("C#") + }); + + var result = selector.Select( + new ModelCodeGenerationOptions + { + Language = "C#", + ProjectDir = Directory.GetCurrentDirectory() + }); + + Assert.Same(expected, result); + } + + [ConditionalFact] + public void Select_returns_last_service_for_language_when_no_templates() + { + var expected = new TestModelCodeGenerator("C#"); + var selector = new ModelCodeGeneratorSelector( + new IModelCodeGenerator[] + { + new TestTemplatedModelGenerator(hasTemplates: false), + new TestModelCodeGenerator("C#"), + expected + }); + + var result = selector.Select( + new ModelCodeGenerationOptions + { + Language = "C#" + }); + + Assert.Same(expected, result); + } + + private class TestModelCodeGenerator : ModelCodeGenerator + { + public TestModelCodeGenerator(string language) + : base(new()) + => Language = language; + + public override string Language { get; } + + public override ScaffoldedModel GenerateModel(IModel model, ModelCodeGenerationOptions options) + => throw new NotImplementedException(); + } + + private class TestTemplatedModelGenerator : TemplatedModelGenerator + { + private readonly bool _hasTemplates; + + public TestTemplatedModelGenerator(bool hasTemplates) + : base(new()) + => _hasTemplates = hasTemplates; + + public override ScaffoldedModel GenerateModel(IModel model, ModelCodeGenerationOptions options) + => throw new NotImplementedException(); + + public override bool HasTemplates(string projectDir) + => _hasTemplates; + } +} diff --git a/test/EFCore.Design.Tests/Scaffolding/Internal/ModelCodeGeneratorTestBase.cs b/test/EFCore.Design.Tests/Scaffolding/Internal/ModelCodeGeneratorTestBase.cs index a4f7385ad32..00fec7696b0 100644 --- a/test/EFCore.Design.Tests/Scaffolding/Internal/ModelCodeGeneratorTestBase.cs +++ b/test/EFCore.Design.Tests/Scaffolding/Internal/ModelCodeGeneratorTestBase.cs @@ -28,7 +28,8 @@ protected void Test( AddScaffoldingServices(services); var generator = services.BuildServiceProvider(validateScopes: true) - .GetRequiredService(); + .GetServices() + .Last(g => g is CSharpModelGenerator); options.ModelNamespace ??= "TestNamespace"; options.ContextName = "TestDbContext"; diff --git a/test/EFCore.Design.Tests/Scaffolding/Internal/TextTemplatingModelGeneratorTest.cs b/test/EFCore.Design.Tests/Scaffolding/Internal/TextTemplatingModelGeneratorTest.cs new file mode 100644 index 00000000000..c359d40c655 --- /dev/null +++ b/test/EFCore.Design.Tests/Scaffolding/Internal/TextTemplatingModelGeneratorTest.cs @@ -0,0 +1,316 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore.Design.Internal; +using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.EntityFrameworkCore.SqlServer.Design.Internal; +using Microsoft.EntityFrameworkCore.TestUtilities.Xunit; + +namespace Microsoft.EntityFrameworkCore.Scaffolding.Internal; + +[PlatformSkipCondition(TestPlatform.Linux, SkipReason = "CI time out")] +public class TextTemplatingModelGeneratorTest +{ + [ConditionalFact] + public void HasTemplates_works_when_templates() + { + using var projectDir = new TempDirectory(); + + var template = Path.Combine(projectDir, "Templates", "EFCore", "DbContext.t4"); + Directory.CreateDirectory(Path.GetDirectoryName(template)); + File.Create(template).Close(); + + var generator = CreateGenerator(); + + var result = generator.HasTemplates(projectDir); + + Assert.True(result); + } + + [ConditionalFact] + public void HasTemplates_works_when_no_templates() + { + using var projectDir = new TempDirectory(); + + var generator = CreateGenerator(); + + var result = generator.HasTemplates(projectDir); + + Assert.False(result); + } + + [ConditionalFact] + public void GenerateModel_uses_templates() + { + using var projectDir = new TempDirectory(); + + var contextTemplate = Path.Combine(projectDir, "Templates", "EFCore", "DbContext.t4"); + Directory.CreateDirectory(Path.GetDirectoryName(contextTemplate)); + File.WriteAllText( + contextTemplate, + "My DbContext template"); + + File.WriteAllText( + Path.Combine(projectDir, "Templates", "EFCore", "EntityType.t4"), + "My entity type template"); + + var generator = CreateGenerator(); + var model = new ModelBuilder() + .Entity("Entity1", b => { }) + .FinalizeModel(); + + var result = generator.GenerateModel( + model, + new() + { + ContextName = "Context", + ConnectionString = @"Name=DefaultConnection", + ProjectDir = projectDir + }); + + Assert.Equal("Context.cs", result.ContextFile.Path); + Assert.Equal("My DbContext template", result.ContextFile.Code); + + var entityType = Assert.Single(result.AdditionalFiles); + Assert.Equal("Entity1.cs", entityType.Path); + Assert.Equal("My entity type template", entityType.Code); + } + + [ConditionalFact] + public void GenerateModel_works_when_no_entity_type_template() + { + using var projectDir = new TempDirectory(); + + var contextTemplate = Path.Combine(projectDir, "Templates", "EFCore", "DbContext.t4"); + Directory.CreateDirectory(Path.GetDirectoryName(contextTemplate)); + File.WriteAllText( + contextTemplate, + "My DbContext template"); + + var generator = CreateGenerator(); + var model = new ModelBuilder() + .Entity("Entity1", b => { }) + .FinalizeModel(); + + var result = generator.GenerateModel( + model, + new() + { + ContextName = "Context", + ConnectionString = @"Name=DefaultConnection", + ProjectDir = projectDir + }); + + Assert.Equal("Context.cs", result.ContextFile.Path); + Assert.Equal("My DbContext template", result.ContextFile.Code); + + Assert.Empty(result.AdditionalFiles); + } + + [ConditionalFact] + public void GenerateModel_sets_session_variables() + { + using var projectDir = new TempDirectory(); + + var contextTemplate = Path.Combine(projectDir, "Templates", "EFCore", "DbContext.t4"); + Directory.CreateDirectory(Path.GetDirectoryName(contextTemplate)); + File.WriteAllText( + contextTemplate, + @"Model not null: <#= Session[""Model""] != null #> +Options not null: <#= Session[""Options""] != null #> +NamespaceHint: <#= Session[""NamespaceHint""] #> +ProjectDefaultNamespace: <#= Session[""ProjectDefaultNamespace""] #>"); + + File.WriteAllText( + Path.Combine(projectDir, "Templates", "EFCore", "EntityType.t4"), + @"EntityType not null: <#= Session[""EntityType""] != null #> +Options not null: <#= Session[""Options""] != null #> +NamespaceHint: <#= Session[""NamespaceHint""] #> +ProjectDefaultNamespace: <#= Session[""ProjectDefaultNamespace""] #>"); + + var generator = CreateGenerator(); + var model = new ModelBuilder() + .Entity("Entity1", b => { }) + .FinalizeModel(); + + var result = generator.GenerateModel( + model, + new() + { + ContextName = "Context", + ConnectionString = @"Name=DefaultConnection", + ContextNamespace = "ContextNamespace", + ModelNamespace = "ModelNamespace", + RootNamespace = "RootNamespace", + ProjectDir = projectDir + }); + + Assert.Equal( + @"Model not null: True +Options not null: True +NamespaceHint: ContextNamespace +ProjectDefaultNamespace: RootNamespace", + result.ContextFile.Code); + + var entityType = Assert.Single(result.AdditionalFiles); + Assert.Equal( + @"EntityType not null: True +Options not null: True +NamespaceHint: ModelNamespace +ProjectDefaultNamespace: RootNamespace", + entityType.Code); + } + + [ConditionalFact] + public void GenerateModel_defaults_to_model_namespace_when_no_context_namespace() + { + using var projectDir = new TempDirectory(); + + var contextTemplate = Path.Combine(projectDir, "Templates", "EFCore", "DbContext.t4"); + Directory.CreateDirectory(Path.GetDirectoryName(contextTemplate)); + File.WriteAllText( + contextTemplate, + @"<#= Session[""NamespaceHint""] #>"); + + var generator = CreateGenerator(); + var model = new ModelBuilder() + .FinalizeModel(); + + var result = generator.GenerateModel( + model, + new() + { + ContextName = "Context", + ConnectionString = @"Name=DefaultConnection", + ModelNamespace = "ModelNamespace", + ProjectDir = projectDir + }); + + Assert.Equal( + "ModelNamespace", + result.ContextFile.Code); + } + + [ConditionalFact] + public void GenerateModel_uses_output_extension() + { + using var projectDir = new TempDirectory(); + + var contextTemplate = Path.Combine(projectDir, "Templates", "EFCore", "DbContext.t4"); + Directory.CreateDirectory(Path.GetDirectoryName(contextTemplate)); + File.WriteAllText( + contextTemplate, + @"<#@ output extension="".vb"" #>"); + + File.WriteAllText( + Path.Combine(projectDir, "Templates", "EFCore", "EntityType.t4"), + @"<#@ output extension="".fs"" #> +My entity type template"); + + var generator = CreateGenerator(); + var model = new ModelBuilder() + .Entity("Entity1", b => { }) + .FinalizeModel(); + + var result = generator.GenerateModel( + model, + new() + { + ContextName = "Context", + ConnectionString = @"Name=DefaultConnection", + ProjectDir = projectDir + }); + + Assert.Equal("Context.vb", result.ContextFile.Path); + + var entityType = Assert.Single(result.AdditionalFiles); + Assert.Equal("Entity1.fs", entityType.Path); + } + + [ConditionalFact] + public void GenerateModel_warns_when_output_encoding() + { + using var projectDir = new TempDirectory(); + + var contextTemplate = Path.Combine(projectDir, "Templates", "EFCore", "DbContext.t4"); + Directory.CreateDirectory(Path.GetDirectoryName(contextTemplate)); + File.WriteAllText( + contextTemplate, + @"<#@ output encoding=""us-ascii"" #>"); + + var reporter = new TestOperationReporter(); + var generator = CreateGenerator(reporter); + var model = new ModelBuilder() + .FinalizeModel(); + + var result = generator.GenerateModel( + model, + new() + { + ContextName = "Context", + ConnectionString = @"Name=DefaultConnection", + ProjectDir = projectDir + }); + + Assert.Collection( + reporter.Messages, + x => + { + Assert.Equal(LogLevel.Warning, x.Level); + Assert.Equal(DesignStrings.EncodingIgnored("us-ascii"), x.Message); + }); + } + + [ConditionalFact] + public void GenerateModel_reports_errors() + { + using var projectDir = new TempDirectory(); + + var contextTemplate = Path.Combine(projectDir, "Templates", "EFCore", "DbContext.t4"); + Directory.CreateDirectory(Path.GetDirectoryName(contextTemplate)); + File.WriteAllText( + contextTemplate, + @"<# Warning(""This is a warning""); +Error(""This is an error""); #>"); + + var reporter = new TestOperationReporter(); + var generator = CreateGenerator(reporter); + var model = new ModelBuilder() + .FinalizeModel(); + + var result = generator.GenerateModel( + model, + new() + { + ContextName = "Context", + ConnectionString = @"Name=DefaultConnection", + ProjectDir = projectDir + }); + + Assert.Collection( + reporter.Messages, + x => + { + Assert.Equal(LogLevel.Warning, x.Level); + Assert.Contains("This is a warning", x.Message); + }, + x => + { + Assert.Equal(LogLevel.Error, x.Level); + Assert.Contains("This is an error", x.Message); + }); + } + + private static TemplatedModelGenerator CreateGenerator(IOperationReporter reporter = null) + { + var serviceCollection = new ServiceCollection() + .AddEntityFrameworkDesignTimeServices(reporter); + new SqlServerDesignTimeServices().ConfigureDesignTimeServices(serviceCollection); + + return serviceCollection + .BuildServiceProvider() + .GetServices() + .OfType() + .Last(); + } +} diff --git a/test/EFCore.Design.Tests/TestUtilities/TempDirectory.cs b/test/EFCore.Design.Tests/TestUtilities/TempDirectory.cs index 06cf980780e..c38c5b2c951 100644 --- a/test/EFCore.Design.Tests/TestUtilities/TempDirectory.cs +++ b/test/EFCore.Design.Tests/TestUtilities/TempDirectory.cs @@ -15,6 +15,9 @@ public TempDirectory() public string Path { get; } + public static implicit operator string(TempDirectory dir) + => dir.Path; + public void Dispose() => Directory.Delete(Path, recursive: true); } diff --git a/test/EFCore.Design.Tests/TextTemplating/Internal/TextTemplatingServiceTest.cs b/test/EFCore.Design.Tests/TextTemplating/Internal/TextTemplatingServiceTest.cs new file mode 100644 index 00000000000..64159cb9898 --- /dev/null +++ b/test/EFCore.Design.Tests/TextTemplating/Internal/TextTemplatingServiceTest.cs @@ -0,0 +1,183 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.CodeDom.Compiler; +using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.EntityFrameworkCore.TestUtilities.Xunit; +using Microsoft.VisualStudio.TextTemplating; + +namespace Microsoft.EntityFrameworkCore.TextTemplating.Internal; + +[PlatformSkipCondition(TestPlatform.Linux, SkipReason = "CI time out")] +public class TextTemplatingServiceTest +{ + [ConditionalFact] + public void Service_works() + { + var host = new TextTemplatingService( + new ServiceCollection() + .AddSingleton("Hello, Services!") + .BuildServiceProvider()); + var callback = new TextTemplatingCallback(); + + var result = host.ProcessTemplate( + @"T:\test.tt", + @"<#@ template hostSpecific=""true"" #><#= ((IServiceProvider)Host).GetService(typeof(string)) #>", + callback); + + Assert.Empty(callback.Errors); + Assert.Equal("Hello, Services!", result); + } + + [ConditionalFact] + public void Session_works() + { + var host = new TextTemplatingService( + new ServiceCollection() + .BuildServiceProvider()); + host.Session = new TextTemplatingSession + { + ["Value"] = "Hello, Session!" + }; + var callback = new TextTemplatingCallback(); + + var result = host.ProcessTemplate( + @"T:\test.tt", + @"<#= Session[""Value""] #>", + callback); + + Assert.Empty(callback.Errors); + Assert.Equal("Hello, Session!", result); + } + + [ConditionalFact] + public void Session_works_with_parameter() + { + var host = new TextTemplatingService( + new ServiceCollection() + .BuildServiceProvider()); + host.Session = new TextTemplatingSession + { + ["Value"] = "Hello, Session!" + }; + var callback = new TextTemplatingCallback(); + + var result = host.ProcessTemplate( + @"T:\test.tt", + @"<#@ parameter name=""Value"" type=""System.String"" #><#= Value #>", + callback); + + Assert.Empty(callback.Errors); + Assert.Equal("Hello, Session!", result); + } + + [ConditionalFact] + public void Include_works() + { + using var dir = new TempDirectory(); + File.WriteAllText( + Path.Combine(dir, "test.ttinclude"), + "Hello, Include!"); + + var host = new TextTemplatingService( + new ServiceCollection() + .BuildServiceProvider()); + var callback = new TextTemplatingCallback(); + + var result = host.ProcessTemplate( + Path.Combine(dir, "test.tt"), + @"<#@ include file=""test.ttinclude"" #>", + callback); + + Assert.Empty(callback.Errors); + Assert.Equal("Hello, Include!", result); + } + + [ConditionalFact] + public void Error_works() + { + var host = new TextTemplatingService( + new ServiceCollection() + .BuildServiceProvider()); + var callback = new TextTemplatingCallback(); + + host.ProcessTemplate( + @"T:\test.tt", + @"<# Error(""Hello, Error!""); #>", + callback); + + var error = Assert.Single(callback.Errors.Cast()); + Assert.Equal("Hello, Error!", error.ErrorText); + } + + [ConditionalFact] + public void Directive_throws_when_processor_unknown() + { + var host = new TextTemplatingService( + new ServiceCollection() + .BuildServiceProvider()); + var callback = new TextTemplatingCallback(); + + var ex = Assert.Throws( + () => host.ProcessTemplate( + @"T:\test.tt", + @"<#@ test processor=""TestDirectiveProcessor"" #>", + callback)); + + Assert.Equal(DesignStrings.UnknownDirectiveProcessor("TestDirectiveProcessor"), ex.Message); + } + + [ConditionalFact] + public void ResolvePath_work() + { + using var dir = new TempDirectory(); + + var host = new TextTemplatingService( + new ServiceCollection() + .BuildServiceProvider()); + var callback = new TextTemplatingCallback(); + + var result = host.ProcessTemplate( + Path.Combine(dir, "test.tt"), + @"<#@ template hostSpecific=""true"" #><#= Host.ResolvePath(""data.json"") #>", + callback); + + Assert.Empty(callback.Errors); + Assert.Equal(Path.Combine(dir, "data.json"), result); + } + + [ConditionalFact] + public void Output_works() + { + var host = new TextTemplatingService( + new ServiceCollection() + .BuildServiceProvider()); + var callback = new TextTemplatingCallback(); + + host.ProcessTemplate( + @"T:\test.tt", + @"<#@ output extension="".txt"" encoding=""us-ascii"" #>", + callback); + + Assert.Empty(callback.Errors); + Assert.Equal(".txt", callback.Extension); + Assert.Equal(Encoding.ASCII, callback.OutputEncoding); + } + + [ConditionalFact] + public void Assembly_works() + { + var host = new TextTemplatingService( + new ServiceCollection() + .BuildServiceProvider()); + var callback = new TextTemplatingCallback(); + + var result = host.ProcessTemplate( + @"T:\test.tt", + @"<#@ assembly name=""Microsoft.EntityFrameworkCore"" #><#= nameof(Microsoft.EntityFrameworkCore.DbContext) #>", + callback); + + Assert.Empty(callback.Errors); + Assert.Equal("DbContext", result); + } +} diff --git a/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs b/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs index 439686def68..b8ecced9d14 100644 --- a/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Migrations/MigrationsTestBase.cs @@ -935,7 +935,7 @@ public virtual Task Alter_column_remove_comment() Assert.Null(column.Comment); }); - [Fact] + [ConditionalFact] public virtual Task Alter_column_set_collation() => Test( builder => builder.Entity("People").Property("Name"), @@ -951,7 +951,7 @@ public virtual Task Alter_column_set_collation() } }); - [Fact] + [ConditionalFact] public virtual Task Alter_column_reset_collation() => Test( builder => builder.Entity("People").Property("Name"), diff --git a/test/EFCore.Relational.Specification.Tests/TestModels/StoreValueGenerationModel/StoreValueGenerationContext.cs b/test/EFCore.Relational.Specification.Tests/TestModels/StoreValueGenerationModel/StoreValueGenerationContext.cs new file mode 100644 index 00000000000..37ad0921d58 --- /dev/null +++ b/test/EFCore.Relational.Specification.Tests/TestModels/StoreValueGenerationModel/StoreValueGenerationContext.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.TestModels.StoreValueGenerationModel; + +#nullable enable + +public class StoreValueGenerationContext : PoolableDbContext +{ + public StoreValueGenerationContext(DbContextOptions options) + : base(options) + { + } + + public DbSet WithSomeDatabaseGenerated + => Set(nameof(WithSomeDatabaseGenerated)); + public DbSet WithSomeDatabaseGenerated2 + => Set(nameof(WithSomeDatabaseGenerated2)); + + public DbSet WithNoDatabaseGenerated + => Set(nameof(WithNoDatabaseGenerated)); + public DbSet WithNoDatabaseGenerated2 + => Set(nameof(WithNoDatabaseGenerated2)); + + public DbSet WithAllDatabaseGenerated + => Set(nameof(WithAllDatabaseGenerated)); + public DbSet WithAllDatabaseGenerated2 + => Set(nameof(WithAllDatabaseGenerated2)); +} diff --git a/test/EFCore.Relational.Specification.Tests/TestModels/StoreValueGenerationModel/StoreValueGenerationData.cs b/test/EFCore.Relational.Specification.Tests/TestModels/StoreValueGenerationModel/StoreValueGenerationData.cs new file mode 100644 index 00000000000..b19365b9f8d --- /dev/null +++ b/test/EFCore.Relational.Specification.Tests/TestModels/StoreValueGenerationModel/StoreValueGenerationData.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.TestModels.StoreValueGenerationModel; + +#nullable enable + +public class StoreValueGenerationData : IEquatable +{ + // Generated on add (except for WithNoDatabaseGenerated2) + public int Id { get; set; } + + // Generated on update (except for WithNoDatabaseGenerated2) + public int Data1 { get; set; } + + // Not generated, except for for WithAllDatabaseGenerated + public int Data2 { get; set; } + + public bool Equals(StoreValueGenerationData? other) + => other is not null + && (ReferenceEquals(this, other) + || (Id == other.Id + && Data1 == other.Data1 + && Data2 == other.Data2)); +} diff --git a/test/EFCore.Relational.Specification.Tests/TestUtilities/BuildReference.cs b/test/EFCore.Relational.Specification.Tests/TestUtilities/BuildReference.cs index 41437f641f3..e56c51a4b99 100644 --- a/test/EFCore.Relational.Specification.Tests/TestUtilities/BuildReference.cs +++ b/test/EFCore.Relational.Specification.Tests/TestUtilities/BuildReference.cs @@ -24,6 +24,7 @@ private BuildReference(IEnumerable references, bool copyLocal public static BuildReference ByName(string name, bool copyLocal = false) { var references = (from l in DependencyContext.Default.CompileLibraries + where l.Assemblies.Any(a => IOPath.GetFileNameWithoutExtension(a) == name) from r in l.ResolveReferencePaths() where IOPath.GetFileNameWithoutExtension(r) == name select MetadataReference.CreateFromFile(r)).ToList(); diff --git a/test/EFCore.Relational.Specification.Tests/Update/StoreValueGenerationFixtureBase.cs b/test/EFCore.Relational.Specification.Tests/Update/StoreValueGenerationFixtureBase.cs new file mode 100644 index 00000000000..b0b27159638 --- /dev/null +++ b/test/EFCore.Relational.Specification.Tests/Update/StoreValueGenerationFixtureBase.cs @@ -0,0 +1,91 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore.TestModels.StoreValueGenerationModel; + +namespace Microsoft.EntityFrameworkCore.Update; + +#nullable enable + +public abstract class StoreValueGenerationFixtureBase : SharedStoreFixtureBase +{ + protected override string StoreName { get; } = "StoreValueGenerationTest"; + + protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context) + { + foreach (var name in new[] + { + nameof(StoreValueGenerationContext.WithNoDatabaseGenerated), + nameof(StoreValueGenerationContext.WithNoDatabaseGenerated2) + }) + { + modelBuilder + .SharedTypeEntity(name) + .Property(w => w.Id) + .ValueGeneratedNever(); + } + + foreach (var name in new[] + { + nameof(StoreValueGenerationContext.WithSomeDatabaseGenerated), + nameof(StoreValueGenerationContext.WithSomeDatabaseGenerated2), + nameof(StoreValueGenerationContext.WithAllDatabaseGenerated), + nameof(StoreValueGenerationContext.WithAllDatabaseGenerated2) + }) + { + modelBuilder + .SharedTypeEntity(name) + .Property(w => w.Data1) + .HasComputedColumnSql("80"); + } + + foreach (var name in new[] + { + nameof(StoreValueGenerationContext.WithAllDatabaseGenerated), + nameof(StoreValueGenerationContext.WithAllDatabaseGenerated2) + }) + { + modelBuilder + .SharedTypeEntity(name) + .Property(w => w.Data2) + .HasComputedColumnSql("81"); + } + } + + protected override void Seed(StoreValueGenerationContext context) + { + context.WithSomeDatabaseGenerated.AddRange(new() { Data2 = 1 }, new() { Data2 = 2 }); + context.WithSomeDatabaseGenerated2.AddRange(new() { Data2 = 1 }, new() { Data2 = 2 }); + + context.WithNoDatabaseGenerated.AddRange(new() { Id = 1, Data1 = 10, Data2 = 20 }, new() { Id = 2, Data1 = 11, Data2 = 21 }); + context.WithNoDatabaseGenerated2.AddRange(new() { Id = 1, Data1 = 10, Data2 = 20 }, new() { Id = 2, Data1 = 11, Data2 = 21 }); + + context.WithAllDatabaseGenerated.AddRange(new(), new()); + context.WithAllDatabaseGenerated2.AddRange(new(), new()); + + context.SaveChanges(); + } + + protected override void Clean(DbContext context) + { + var storeValueGenerationContext = CreateContext(); + + storeValueGenerationContext.WithSomeDatabaseGenerated.RemoveRange(storeValueGenerationContext.WithSomeDatabaseGenerated); + storeValueGenerationContext.WithSomeDatabaseGenerated2.RemoveRange(storeValueGenerationContext.WithSomeDatabaseGenerated2); + + storeValueGenerationContext.WithNoDatabaseGenerated.RemoveRange(storeValueGenerationContext.WithNoDatabaseGenerated); + storeValueGenerationContext.WithNoDatabaseGenerated2.RemoveRange(storeValueGenerationContext.WithNoDatabaseGenerated2); + + storeValueGenerationContext.WithAllDatabaseGenerated.RemoveRange(storeValueGenerationContext.WithAllDatabaseGenerated); + storeValueGenerationContext.WithAllDatabaseGenerated2.RemoveRange(storeValueGenerationContext.WithAllDatabaseGenerated2); + + storeValueGenerationContext.SaveChanges(); + } + + protected override bool ShouldLogCategory(string logCategory) + => logCategory == DbLoggerCategory.Database.Transaction.Name + || logCategory == DbLoggerCategory.Database.Command.Name; + + public TestSqlLoggerFactory TestSqlLoggerFactory + => (TestSqlLoggerFactory)ListLoggerFactory; +} diff --git a/test/EFCore.Relational.Specification.Tests/Update/StoreValueGenerationTestBase.cs b/test/EFCore.Relational.Specification.Tests/Update/StoreValueGenerationTestBase.cs new file mode 100644 index 00000000000..5b89e789ace --- /dev/null +++ b/test/EFCore.Relational.Specification.Tests/Update/StoreValueGenerationTestBase.cs @@ -0,0 +1,379 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore.TestModels.StoreValueGenerationModel; + +namespace Microsoft.EntityFrameworkCore.Update; + +#nullable enable + +public abstract class StoreValueGenerationTestBase : IClassFixture + where TFixture : StoreValueGenerationFixtureBase +{ + protected StoreValueGenerationTestBase(TFixture fixture) + { + Fixture = fixture; + + fixture.Reseed(); + + ClearLog(); + } + + #region Single operation + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Add_with_generated_values(bool async) + => Test(EntityState.Added, secondOperationType: null, GeneratedValues.Some, async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Add_with_no_generated_values(bool async) + => Test(EntityState.Added, secondOperationType: null, GeneratedValues.None, async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Add_with_all_generated_values(bool async) + => Test(EntityState.Added, secondOperationType: null, GeneratedValues.All, async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Modify_with_generated_values(bool async) + => Test(EntityState.Modified, secondOperationType: null, GeneratedValues.Some, async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Modify_with_no_generated_values(bool async) + => Test(EntityState.Modified, secondOperationType: null, GeneratedValues.None, async); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Delete(bool async) + => Test(EntityState.Deleted, secondOperationType: null, GeneratedValues.Some, async); + + #endregion Single operation + + #region Two operations with same entity type + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Add_Add_with_same_entity_type_and_generated_values(bool async) + => Test(EntityState.Added, EntityState.Added, GeneratedValues.Some, async, withSameEntityType: true); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Add_Add_with_same_entity_type_and_no_generated_values(bool async) + => Test(EntityState.Added, EntityState.Added, GeneratedValues.None, async, withSameEntityType: true); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Add_Add_with_same_entity_type_and_all_generated_values(bool async) + => Test(EntityState.Added, EntityState.Added, GeneratedValues.All, async, withSameEntityType: true); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Modify_Modify_with_same_entity_type_and_generated_values(bool async) + => Test(EntityState.Modified, EntityState.Modified, GeneratedValues.Some, async, withSameEntityType: true); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Modify_Modify_with_same_entity_type_and_no_generated_values(bool async) + => Test(EntityState.Modified, EntityState.Modified, GeneratedValues.None, async, withSameEntityType: true); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Delete_Delete_with_same_entity_type(bool async) + => Test(EntityState.Deleted, EntityState.Deleted, GeneratedValues.Some, async, withSameEntityType: true); + + #endregion Two operations with same entity type + + #region Two operations with different entity types + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Add_Add_with_different_entity_types_and_generated_values(bool async) + => Test(EntityState.Added, EntityState.Added, GeneratedValues.Some, async, withSameEntityType: false); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Add_Add_with_different_entity_types_and_no_generated_values(bool async) + => Test(EntityState.Added, EntityState.Added, GeneratedValues.None, async, withSameEntityType: false); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Add_Add_with_different_entity_types_and_all_generated_values(bool async) + => Test(EntityState.Added, EntityState.Added, GeneratedValues.All, async, withSameEntityType: false); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Modify_Modify_with_different_entity_types_and_generated_values(bool async) + => Test(EntityState.Modified, EntityState.Modified, GeneratedValues.Some, async, withSameEntityType: false); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Modify_Modify_with_different_entity_types_and_no_generated_values(bool async) + => Test(EntityState.Modified, EntityState.Modified, GeneratedValues.None, async, withSameEntityType: false); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Delete_Delete_with_different_entity_types(bool async) + => Test(EntityState.Deleted, EntityState.Deleted, GeneratedValues.Some, async, withSameEntityType: false); + + #endregion Two operations with different entity types + + protected virtual async Task Test( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool async, + bool withSameEntityType = true) + { + await using var context = CreateContext(); + + var firstDbSet = generatedValues switch + { + GeneratedValues.Some => context.WithSomeDatabaseGenerated, + GeneratedValues.None => context.WithNoDatabaseGenerated, + GeneratedValues.All => context.WithAllDatabaseGenerated, + _ => throw new ArgumentOutOfRangeException(nameof(generatedValues)) + }; + + var secondDbSet = secondOperationType is null + ? null + : (generatedValues, withSameEntityType) switch + { + (GeneratedValues.Some, true) => context.WithSomeDatabaseGenerated, + (GeneratedValues.Some, false) => context.WithSomeDatabaseGenerated2, + (GeneratedValues.None, true) => context.WithNoDatabaseGenerated, + (GeneratedValues.None, false) => context.WithNoDatabaseGenerated2, + (GeneratedValues.All, true) => context.WithAllDatabaseGenerated, + (GeneratedValues.All, false) => context.WithAllDatabaseGenerated2, + _ => throw new ArgumentOutOfRangeException(nameof(generatedValues)) + }; + + StoreValueGenerationData first; + StoreValueGenerationData? second; + + switch (firstOperationType) + { + case EntityState.Added: + switch (generatedValues) + { + case GeneratedValues.Some: + first = new StoreValueGenerationData { Data2 = 1000 }; + firstDbSet.Add(first); + break; + case GeneratedValues.None: + first = new StoreValueGenerationData { Id = 100, Data1 = 1000, Data2 = 1000 }; + firstDbSet.Add(first); + break; + case GeneratedValues.All: + first = new StoreValueGenerationData(); + firstDbSet.Add(first); + break; + default: + throw new ArgumentOutOfRangeException(nameof(generatedValues)); + } + break; + + case EntityState.Modified: + switch (generatedValues) + { + case GeneratedValues.Some: + first = firstDbSet.OrderBy(w => w.Id).First(); + first.Data2 = 1000; + break; + case GeneratedValues.None: + first = firstDbSet.OrderBy(w => w.Id).First(); + (first.Data1, first.Data2) = (1000, 1000); + break; + default: + throw new ArgumentOutOfRangeException(nameof(generatedValues)); + } + break; + + case EntityState.Deleted: + switch (generatedValues) + { + case GeneratedValues.Some: + first = firstDbSet.OrderBy(w => w.Id).First(); + context.Remove(first); + break; + case GeneratedValues.None: + first = firstDbSet.OrderBy(w => w.Id).First(); + context.Remove(first); + break; + default: + throw new ArgumentOutOfRangeException(nameof(generatedValues)); + } + break; + + default: + throw new ArgumentOutOfRangeException(nameof(firstOperationType)); + } + + switch (secondOperationType) + { + case EntityState.Added: + switch (generatedValues) + { + case GeneratedValues.Some: + second = new StoreValueGenerationData { Data2 = 1001 }; + secondDbSet!.Add(second); + break; + case GeneratedValues.None: + second = new StoreValueGenerationData { Id = 101, Data1 = 1001, Data2 = 1001 }; + secondDbSet!.Add(second); + break; + case GeneratedValues.All: + second = new StoreValueGenerationData(); + secondDbSet!.Add(second); + break; + default: + throw new ArgumentOutOfRangeException(nameof(generatedValues)); + } + break; + + case EntityState.Modified: + switch (generatedValues) + { + case GeneratedValues.Some: + second = secondDbSet!.OrderBy(w => w.Id).Skip(1).First(); + second.Data2 = 1001; + break; + case GeneratedValues.None: + second = secondDbSet!.OrderBy(w => w.Id).Skip(1).First(); + (second.Data1, second.Data2) = (1001, 1001); + break; + default: + throw new ArgumentOutOfRangeException(nameof(generatedValues)); + } + break; + + case EntityState.Deleted: + switch (generatedValues) + { + case GeneratedValues.Some: + second = secondDbSet!.OrderBy(w => w.Id).Skip(1).First(); + context.Remove(second); + break; + case GeneratedValues.None: + second = secondDbSet!.OrderBy(w => w.Id).Skip(1).First(); + context.Remove(second); + break; + default: + throw new ArgumentOutOfRangeException(nameof(generatedValues)); + } + break; + + case null: + second = null; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(firstOperationType)); + } + + // Execute + Fixture.ListLoggerFactory.Clear(); + + if (async) + { + await context.SaveChangesAsync(); + } + else + { + context.SaveChanges(); + } + + // Make sure a transaction was created (or not) + if (ShouldCreateImplicitTransaction(firstOperationType, secondOperationType, generatedValues, withSameEntityType)) + { + Assert.Contains(Fixture.ListLoggerFactory.Log, l => l.Id == RelationalEventId.TransactionStarted); + Assert.Contains(Fixture.ListLoggerFactory.Log, l => l.Id == RelationalEventId.TransactionCommitted); + } + else + { + Assert.DoesNotContain(Fixture.ListLoggerFactory.Log, l => l.Id == RelationalEventId.TransactionStarted); + Assert.DoesNotContain(Fixture.ListLoggerFactory.Log, l => l.Id == RelationalEventId.TransactionCommitted); + } + + // Make sure the updates executed in the expected number of commands + Assert.Equal( + ShouldExecuteInNumberOfCommands(firstOperationType, secondOperationType, generatedValues, withSameEntityType), + Fixture.ListLoggerFactory.Log.Count(l => l.Id == RelationalEventId.CommandExecuted)); + + // To make sure generated values have been propagated, re-load the rows from the database and compare + context.ChangeTracker.Clear(); + + using (Fixture.TestSqlLoggerFactory.SuspendRecordingEvents()) + { + if (firstOperationType != EntityState.Deleted) + { + Assert.Equal(await firstDbSet.FindAsync(first.Id), first); + } + + if (second is not null && secondOperationType != EntityState.Deleted) + { + Assert.Equal(await secondDbSet!.FindAsync(second.Id), second); + } + } + } + + /// + /// Providers can override this to specify when should create a transaction, and when not. + /// By default, it's assumed that multiple updates always require a transaction, whereas a single update never does. + /// + protected virtual bool ShouldCreateImplicitTransaction( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool withSameEntityType) + { + // By default, two changes require a transaction + if (secondOperationType is not null) + { + return true; + } + + // Deletes don't ever need to bring back database-generated values + if (firstOperationType == EntityState.Deleted) + { + return false; + } + + // By default, assume that fetching back database-generated values requires a transaction + return generatedValues != GeneratedValues.None; + } + + /// + /// Providers can override this to specify how many commands (batches) are used to execute the update. + /// By default, it's assumed all operations are batched in one command. + /// + protected virtual int ShouldExecuteInNumberOfCommands( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool withSameEntityType) + => 1; + + protected TFixture Fixture { get; } + + protected StoreValueGenerationContext CreateContext() + => Fixture.CreateContext(); + + public static IEnumerable IsAsyncData = new[] { new object[] { false }, new object[] { true } }; + + protected virtual void AssertSql(params string[] expected) + => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); + + protected virtual void ClearLog() + => Fixture.TestSqlLoggerFactory.Clear(); + + protected enum GeneratedValues + { + Some, + None, + All + } +} diff --git a/test/EFCore.Relational.Tests/Metadata/RelationalModelTest.cs b/test/EFCore.Relational.Tests/Metadata/RelationalModelTest.cs index 5e4d681368b..3bc63e87639 100644 --- a/test/EFCore.Relational.Tests/Metadata/RelationalModelTest.cs +++ b/test/EFCore.Relational.Tests/Metadata/RelationalModelTest.cs @@ -172,8 +172,9 @@ private static void AssertViews(IRelationalModel model, Mapping mapping) Assert.Equal("viewSchema", ordersView.Schema); Assert.Null(ordersView.ViewDefinitionSql); - var orderDate = orderType.FindProperty(nameof(Order.OrderDate)); + var orderPk = orderType.FindPrimaryKey(); + var orderDate = orderType.FindProperty(nameof(Order.OrderDate)); var orderDateMapping = orderDate.GetViewColumnMappings().Single(); Assert.NotNull(orderDateMapping.TypeMapping); Assert.Equal("default_datetime_mapping", orderDateMapping.TypeMapping.StoreType); @@ -212,6 +213,56 @@ private static void AssertViews(IRelationalModel model, Mapping mapping) Assert.False(customerView.IsOptional(specialCustomerType)); Assert.False(customerView.IsOptional(extraSpecialCustomerType)); + var mappedToTable = orderType.GetTableName() != null; + var ordersCustomerForeignKey = orderType.FindNavigation(nameof(Order.Customer)).ForeignKey; + Assert.Equal(mappedToTable + ? "FK_Order_Customer_CustomerId" + : null, ordersCustomerForeignKey.GetConstraintName()); + Assert.Null(ordersCustomerForeignKey.GetConstraintName( + StoreObjectIdentifier.View(ordersView.Name, ordersView.Schema), + StoreObjectIdentifier.View(customerView.Name, customerView.Schema))); + Assert.Equal(mappedToTable + ? "FK_Order_Customer_CustomerId" + : null, ordersCustomerForeignKey.GetDefaultName()); + Assert.Null(ordersCustomerForeignKey.GetDefaultName( + StoreObjectIdentifier.View(ordersView.Name, ordersView.Schema), + StoreObjectIdentifier.View(customerView.Name, customerView.Schema))); + + var ordersCustomerIndex = orderType.FindIndex(ordersCustomerForeignKey.Properties); + Assert.Equal(mappedToTable + ? "IX_Order_CustomerId" + : null, ordersCustomerIndex.GetDatabaseName()); + Assert.Null(ordersCustomerIndex.GetDatabaseName( + StoreObjectIdentifier.Table(ordersView.Name, ordersView.Schema))); + Assert.Equal(mappedToTable + ? "IX_Order_CustomerId" + : null, ordersCustomerIndex.GetDefaultDatabaseName()); + Assert.Null(ordersCustomerIndex.GetDefaultDatabaseName( + StoreObjectIdentifier.Table(ordersView.Name, ordersView.Schema))); + + var specialityCK = specialCustomerType.GetCheckConstraints().Single(); + Assert.Equal(mappedToTable + ? "Speciality" + : null, specialityCK.Name); + Assert.Null(specialityCK.GetName( + StoreObjectIdentifier.Table(ordersView.Name, ordersView.Schema))); + Assert.Equal(mappedToTable + ? "Speciality" + : null, specialityCK.GetDefaultName()); + Assert.Equal("Speciality", specialityCK.GetDefaultName( + StoreObjectIdentifier.Table(ordersView.Name, ordersView.Schema))); + + Assert.Equal(mappedToTable + ? "PK_Order" + : null, orderPk.GetName()); + Assert.Null(orderPk.GetName( + StoreObjectIdentifier.Table(ordersView.Name, ordersView.Schema))); + Assert.Equal(mappedToTable + ? "PK_Order" + : null, orderPk.GetDefaultName()); + Assert.Equal("PK_OrderView", orderPk.GetDefaultName( + StoreObjectIdentifier.Table(ordersView.Name, ordersView.Schema))); + if (mapping == Mapping.TPT) { Assert.Equal(2, specialCustomerType.GetViewMappings().Count()); @@ -365,6 +416,7 @@ private static void AssertTables(IRelationalModel model, Mapping mapping) Assert.Equal(orderCustomerFk, orderCustomerFkConstraint.MappedForeignKeys.Single()); Assert.Equal(new[] { orderDateFkConstraint, orderCustomerFkConstraint }, ordersTable.ForeignKeyConstraints); + var customerType = model.Model.FindEntityType(typeof(Customer)); var specialCustomerType = model.Model.FindEntityType(typeof(SpecialCustomer)); var extraSpecialCustomerType = model.Model.FindEntityType(typeof(ExtraSpecialCustomer)); var orderDetailsOwnership = orderType.FindNavigation(nameof(Order.Details)).ForeignKey; @@ -425,10 +477,44 @@ private static void AssertTables(IRelationalModel model, Mapping mapping) Assert.Equal("FK_DateDetails", orderDateFkConstraint.Name); - var customerType = model.Model.FindEntityType(typeof(Customer)); var customerTable = customerType.GetTableMappings().Single().Table; Assert.Equal("Customer", customerTable.Name); + var ordersCustomerForeignKey = orderType.FindNavigation(nameof(Order.Customer)).ForeignKey; + Assert.Equal("FK_Order_Customer_CustomerId", ordersCustomerForeignKey.GetConstraintName()); + Assert.Equal("FK_Order_Customer_CustomerId", ordersCustomerForeignKey.GetConstraintName( + StoreObjectIdentifier.Table(ordersTable.Name, ordersTable.Schema), + StoreObjectIdentifier.Table(customerTable.Name, customerTable.Schema))); + Assert.Equal("FK_Order_Customer_CustomerId", ordersCustomerForeignKey.GetDefaultName()); + Assert.Equal("FK_Order_Customer_CustomerId", ordersCustomerForeignKey.GetDefaultName( + StoreObjectIdentifier.Table(ordersTable.Name, ordersTable.Schema), + StoreObjectIdentifier.Table(customerTable.Name, customerTable.Schema))); + + var ordersCustomerIndex = orderType.FindIndex(ordersCustomerForeignKey.Properties); + Assert.Equal("IX_Order_CustomerId", ordersCustomerIndex.GetDatabaseName()); + Assert.Equal("IX_Order_CustomerId", ordersCustomerIndex.GetDatabaseName( + StoreObjectIdentifier.Table(ordersTable.Name, ordersTable.Schema))); + Assert.Equal("IX_Order_CustomerId", ordersCustomerIndex.GetDefaultDatabaseName()); + Assert.Equal("IX_Order_CustomerId", ordersCustomerIndex.GetDefaultDatabaseName( + StoreObjectIdentifier.Table(ordersTable.Name, ordersTable.Schema))); + + Assert.Equal("PK_Order", orderPk.GetName()); + Assert.Equal("PK_Order", orderPk.GetName( + StoreObjectIdentifier.Table(ordersTable.Name, ordersTable.Schema))); + Assert.Equal("PK_Order", orderPk.GetDefaultName()); + Assert.Equal("PK_Order", orderPk.GetDefaultName( + StoreObjectIdentifier.Table(ordersTable.Name, ordersTable.Schema))); + + var specialCustomerTable = + specialCustomerType.GetTableMappings().Select(t => t.Table).Last(); + var specialityCK = specialCustomerType.GetCheckConstraints().Single(); + Assert.Equal("Speciality", specialityCK.Name); + Assert.Equal("Speciality", specialityCK.GetName( + StoreObjectIdentifier.Table(specialCustomerTable.Name, specialCustomerTable.Schema))); + Assert.Equal("Speciality", specialityCK.GetDefaultName()); + Assert.Equal("Speciality", specialityCK.GetDefaultName( + StoreObjectIdentifier.Table(specialCustomerTable.Name, specialCustomerTable.Schema))); + Assert.False(customerTable.IsOptional(customerType)); Assert.False(customerTable.IsOptional(specialCustomerType)); Assert.False(customerTable.IsOptional(extraSpecialCustomerType)); @@ -443,8 +529,7 @@ private static void AssertTables(IRelationalModel model, Mapping mapping) Assert.True(specialCustomerType.GetTableMappings().Last().IsSplitEntityTypePrincipal); Assert.True(specialCustomerType.GetTableMappings().Last().IncludesDerivedTypes); - var specialCustomerTable = - specialCustomerType.GetTableMappings().Select(t => t.Table).First(t => t.Name == "SpecialCustomer"); + Assert.Equal("SpecialCustomer", specialCustomerTable.Name); Assert.Equal("SpecialSchema", specialCustomerTable.Schema); Assert.Equal(5, specialCustomerTable.Columns.Count()); @@ -455,8 +540,7 @@ private static void AssertTables(IRelationalModel model, Mapping mapping) Assert.False(specialityColumn.IsNullable); var addressColumn = specialCustomerTable.Columns.Single( - c => - c.Name == nameof(SpecialCustomer.Details) + "_" + nameof(CustomerDetails.Address)); + c => c.Name == nameof(SpecialCustomer.Details) + "_" + nameof(CustomerDetails.Address)); Assert.False(addressColumn.IsNullable); var specialityProperty = specialityColumn.PropertyMappings.First().Property; @@ -464,10 +548,16 @@ private static void AssertTables(IRelationalModel model, Mapping mapping) RelationalStrings.PropertyNotMappedToTable( nameof(SpecialCustomer.Speciality), nameof(SpecialCustomer), "Customer"), Assert.Throws( - () => - specialityProperty.IsColumnNullable(StoreObjectIdentifier.Table(customerTable.Name, customerTable.Schema))) + () => specialityProperty.IsColumnNullable(StoreObjectIdentifier.Table(customerTable.Name, customerTable.Schema))) .Message); + var extraSpecialCustomerTable = + extraSpecialCustomerType.GetTableMappings().Select(t => t.Table).First(t => t.Name == "ExtraSpecialCustomer"); + + Assert.Empty(customerTable.CheckConstraints); + Assert.Same(specialityCK, specialCustomerTable.CheckConstraints.Single()); + Assert.Same(specialityCK, extraSpecialCustomerTable.CheckConstraints.Single()); + Assert.Equal(3, customerPk.GetMappedConstraints().Count()); var specialCustomerPkConstraint = specialCustomerTable.PrimaryKey; Assert.Equal("PK_SpecialCustomer", specialCustomerPkConstraint.Name); @@ -520,7 +610,6 @@ private static void AssertTables(IRelationalModel model, Mapping mapping) Assert.True(specialCustomerTypeMapping.IsSplitEntityTypePrincipal); Assert.True(specialCustomerTypeMapping.IncludesDerivedTypes); - var specialCustomerTable = specialCustomerTypeMapping.Table; Assert.Same(customerTable, specialCustomerTable); Assert.Equal(4, specialCustomerTable.EntityTypeMappings.Count()); @@ -530,11 +619,12 @@ private static void AssertTables(IRelationalModel model, Mapping mapping) var specialityColumn = specialCustomerTable.Columns.Single(c => c.Name == nameof(SpecialCustomer.Speciality)); Assert.True(specialityColumn.IsNullable); - var addressColumn = specialCustomerTable.Columns.Single( - c => + var addressColumn = specialCustomerTable.Columns.Single(c => c.Name == nameof(SpecialCustomer.Details) + "_" + nameof(CustomerDetails.Address)); Assert.True(addressColumn.IsNullable); + Assert.Same(specialityCK, specialCustomerTable.CheckConstraints.Single()); + var specialCustomerPkConstraint = specialCustomerTable.PrimaryKey; Assert.Equal("PK_Customer", specialCustomerPkConstraint.Name); Assert.Same(specialCustomerPkConstraint.MappedKeys.First(), customerPk); @@ -598,6 +688,8 @@ private IRelationalModel CreateTestModel(bool mapToTables = false, bool mapToVie cb.ToTable("SpecialCustomer", "SpecialSchema"); } + cb.HasCheckConstraint($"Speciality", $"[Speciality] IN ('Specialist', 'Generalist')"); + cb.Property(s => s.Speciality).IsRequired(); cb.HasOne(c => c.RelatedCustomer).WithOne() diff --git a/test/EFCore.Relational.Tests/Migrations/Operations/CreateIndexOperationTest.cs b/test/EFCore.Relational.Tests/Migrations/Operations/CreateIndexOperationTest.cs index c1ea868be05..61e3afdf453 100644 --- a/test/EFCore.Relational.Tests/Migrations/Operations/CreateIndexOperationTest.cs +++ b/test/EFCore.Relational.Tests/Migrations/Operations/CreateIndexOperationTest.cs @@ -5,7 +5,7 @@ namespace Microsoft.EntityFrameworkCore.Migrations.Operations; public class CreateIndexOperationTest { - [Fact] + [ConditionalFact] public void IsDescending_count_matches_column_count() { var operation = new CreateIndexOperation(); diff --git a/test/EFCore.Relational.Tests/ModelBuilding/RelationalModelBuilderTest.cs b/test/EFCore.Relational.Tests/ModelBuilding/RelationalModelBuilderTest.cs index eec6cde04b5..6ff38786a8d 100644 --- a/test/EFCore.Relational.Tests/ModelBuilding/RelationalModelBuilderTest.cs +++ b/test/EFCore.Relational.Tests/ModelBuilding/RelationalModelBuilderTest.cs @@ -11,6 +11,10 @@ public class RelationalModelBuilderTest : ModelBuilderTest public abstract class TestTableBuilder where TEntity : class { + public abstract string? Name { get; } + + public abstract string? Schema { get; } + public abstract TestTableBuilder ExcludeFromMigrations(bool excluded = true); } @@ -22,9 +26,15 @@ public GenericTestTableBuilder(TableBuilder tableBuilder) TableBuilder = tableBuilder; } - protected TableBuilder TableBuilder { get; } + private TableBuilder TableBuilder { get; } + + public override string? Name + => TableBuilder.Name; + + public override string? Schema + => TableBuilder.Schema; - public TableBuilder Instance + TableBuilder IInfrastructure>.Instance => TableBuilder; protected virtual TestTableBuilder Wrap(TableBuilder tableBuilder) @@ -42,9 +52,15 @@ public NonGenericTestTableBuilder(TableBuilder tableBuilder) TableBuilder = tableBuilder; } - protected TableBuilder TableBuilder { get; } + private TableBuilder TableBuilder { get; } - public TableBuilder Instance + public override string? Name + => TableBuilder.Name; + + public override string? Schema + => TableBuilder.Schema; + + TableBuilder IInfrastructure.Instance => TableBuilder; protected virtual TestTableBuilder Wrap(TableBuilder tableBuilder) @@ -57,10 +73,16 @@ public override TestTableBuilder ExcludeFromMigrations(bool excluded = public abstract class TestOwnedNavigationTableBuilder where TEntity : class { + public abstract string? Name { get; } + + public abstract string? Schema { get; } + public abstract TestOwnedNavigationTableBuilder ExcludeFromMigrations(bool excluded = true); } - public class GenericTestOwnedNavigationTableBuilder : TestOwnedNavigationTableBuilder, IInfrastructure> + public class GenericTestOwnedNavigationTableBuilder : + TestOwnedNavigationTableBuilder, + IInfrastructure> where TEntity : class { public GenericTestOwnedNavigationTableBuilder(OwnedNavigationTableBuilder tableBuilder) @@ -68,9 +90,16 @@ public GenericTestOwnedNavigationTableBuilder(OwnedNavigationTableBuilder TableBuilder { get; } + private OwnedNavigationTableBuilder TableBuilder { get; } + + public override string? Name + => TableBuilder.Name; - public OwnedNavigationTableBuilder Instance => TableBuilder; + public override string? Schema + => TableBuilder.Schema; + + OwnedNavigationTableBuilder IInfrastructure>.Instance + => TableBuilder; protected virtual TestOwnedNavigationTableBuilder Wrap(OwnedNavigationTableBuilder tableBuilder) => new GenericTestOwnedNavigationTableBuilder(tableBuilder); @@ -87,9 +116,16 @@ public NonGenericTestOwnedNavigationTableBuilder(OwnedNavigationTableBuilder tab TableBuilder = tableBuilder; } - protected OwnedNavigationTableBuilder TableBuilder { get; } + private OwnedNavigationTableBuilder TableBuilder { get; } - public OwnedNavigationTableBuilder Instance => TableBuilder; + public override string? Name + => TableBuilder.Name; + + public override string? Schema + => TableBuilder.Schema; + + OwnedNavigationTableBuilder IInfrastructure.Instance + => TableBuilder; protected virtual TestOwnedNavigationTableBuilder Wrap(OwnedNavigationTableBuilder tableBuilder) => new NonGenericTestOwnedNavigationTableBuilder(tableBuilder); @@ -110,9 +146,9 @@ public NonGenericTestCheckConstraintBuilder(CheckConstraintBuilder checkConstrai CheckConstraintBuilder = checkConstraintBuilder; } - protected CheckConstraintBuilder CheckConstraintBuilder { get; } + private CheckConstraintBuilder CheckConstraintBuilder { get; } - public CheckConstraintBuilder Instance + CheckConstraintBuilder IInfrastructure.Instance => CheckConstraintBuilder; protected virtual TestCheckConstraintBuilder Wrap(CheckConstraintBuilder checkConstraintBuilder) diff --git a/test/EFCore.Relational.Tests/ModelBuilding/RelationalTestModelBuilderExtensions.cs b/test/EFCore.Relational.Tests/ModelBuilding/RelationalTestModelBuilderExtensions.cs index 4c7b694d633..51ce5387750 100644 --- a/test/EFCore.Relational.Tests/ModelBuilding/RelationalTestModelBuilderExtensions.cs +++ b/test/EFCore.Relational.Tests/ModelBuilding/RelationalTestModelBuilderExtensions.cs @@ -146,6 +146,25 @@ public static ModelBuilderTest.TestEntityTypeBuilder ToTable( return builder; } + public static ModelBuilderTest.TestEntityTypeBuilder ToTable( + this ModelBuilderTest.TestEntityTypeBuilder builder, + Action> buildAction) + where TEntity : class + { + switch (builder) + { + case IInfrastructure> genericBuilder: + genericBuilder.Instance.ToTable(b => buildAction(new RelationalModelBuilderTest.GenericTestTableBuilder(b))); + break; + case IInfrastructure nongenericBuilder: + nongenericBuilder.Instance.ToTable( + b => buildAction(new RelationalModelBuilderTest.NonGenericTestTableBuilder(b))); + break; + } + + return builder; + } + public static ModelBuilderTest.TestEntityTypeBuilder ToTable( this ModelBuilderTest.TestEntityTypeBuilder builder, string? name, @@ -232,6 +251,27 @@ public static ModelBuilderTest.TestOwnedNavigationBuilder ToTable( + this ModelBuilderTest.TestOwnedNavigationBuilder builder, + Action> buildAction) + where TOwnerEntity : class + where TRelatedEntity : class + { + switch (builder) + { + case IInfrastructure> genericBuilder: + genericBuilder.Instance.ToTable( + b => buildAction(new RelationalModelBuilderTest.GenericTestOwnedNavigationTableBuilder(b))); + break; + case IInfrastructure nongenericBuilder: + nongenericBuilder.Instance.ToTable( + b => buildAction(new RelationalModelBuilderTest.NonGenericTestOwnedNavigationTableBuilder(b))); + break; + } + + return builder; + } + public static ModelBuilderTest.TestOwnedNavigationBuilder ToTable( this ModelBuilderTest.TestOwnedNavigationBuilder builder, string? name, diff --git a/test/EFCore.Relational.Tests/Query/Internal/QuerySqlGeneratorTest.cs b/test/EFCore.Relational.Tests/Query/Internal/QuerySqlGeneratorTest.cs index 97af72b1cc9..ad30087f12a 100644 --- a/test/EFCore.Relational.Tests/Query/Internal/QuerySqlGeneratorTest.cs +++ b/test/EFCore.Relational.Tests/Query/Internal/QuerySqlGeneratorTest.cs @@ -7,7 +7,7 @@ namespace Microsoft.EntityFrameworkCore.Query.Internal; public class QuerySqlGeneratorTest { - [Theory] + [ConditionalTheory] [InlineData("INSERT something")] [InlineData("SELECTANDSOMEOTHERSTUFF")] [InlineData("SELECT")] @@ -29,7 +29,7 @@ public void CheckComposableSql_throws(string sql) () => CreateDummyQuerySqlGenerator().CheckComposableSql(sql.Replace("SELECT", "WITH"))).Message); } - [Theory] + [ConditionalTheory] [InlineData("SELECT something")] [InlineData(" SELECT something")] [InlineData("-- comment\n SELECT something")] diff --git a/test/EFCore.Relational.Tests/TestUtilities/FakeProvider/FakeSqlGenerator.cs b/test/EFCore.Relational.Tests/TestUtilities/FakeProvider/FakeSqlGenerator.cs index 16607104898..3a45ae9629b 100644 --- a/test/EFCore.Relational.Tests/TestUtilities/FakeProvider/FakeSqlGenerator.cs +++ b/test/EFCore.Relational.Tests/TestUtilities/FakeProvider/FakeSqlGenerator.cs @@ -13,28 +13,31 @@ public FakeSqlGenerator(UpdateSqlGeneratorDependencies dependencies) public override ResultSetMapping AppendInsertOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition) + int commandPosition, + out bool requiresTransaction) { AppendInsertOperationCalls++; - return base.AppendInsertOperation(commandStringBuilder, command, commandPosition); + return base.AppendInsertOperation(commandStringBuilder, command, commandPosition, out requiresTransaction); } public override ResultSetMapping AppendUpdateOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition) + int commandPosition, + out bool requiresTransaction) { AppendUpdateOperationCalls++; - return base.AppendUpdateOperation(commandStringBuilder, command, commandPosition); + return base.AppendUpdateOperation(commandStringBuilder, command, commandPosition, out requiresTransaction); } public override ResultSetMapping AppendDeleteOperation( StringBuilder commandStringBuilder, IReadOnlyModificationCommand command, - int commandPosition) + int commandPosition, + out bool requiresTransaction) { AppendDeleteOperationCalls++; - return base.AppendDeleteOperation(commandStringBuilder, command, commandPosition); + return base.AppendDeleteOperation(commandStringBuilder, command, commandPosition, out requiresTransaction); } public int AppendBatchHeaderCalls { get; set; } diff --git a/test/EFCore.Relational.Tests/Update/BatchExecutorTest.cs b/test/EFCore.Relational.Tests/Update/BatchExecutorTest.cs index 4188eba9ccb..4e5cbc3f560 100644 --- a/test/EFCore.Relational.Tests/Update/BatchExecutorTest.cs +++ b/test/EFCore.Relational.Tests/Update/BatchExecutorTest.cs @@ -15,8 +15,8 @@ public async Task ExecuteAsync_calls_Commit_if_no_transaction(bool async) using var context = new TestContext(); var connection = SetupConnection(context); - context.Add( - new Foo { Id = "1" }); + context.Add(new Foo { Id = "1" }); + context.Add(new Bar { Id = "1" }); if (async) { @@ -83,10 +83,16 @@ public TestContext() } public DbSet Foos { get; set; } + public DbSet Bars { get; set; } } private class Foo { public string Id { get; set; } } + + private class Bar + { + public string Id { get; set; } + } } diff --git a/test/EFCore.Relational.Tests/Update/CommandBatchPreparerTest.cs b/test/EFCore.Relational.Tests/Update/CommandBatchPreparerTest.cs index 058c1356a52..cf221e4cac1 100644 --- a/test/EFCore.Relational.Tests/Update/CommandBatchPreparerTest.cs +++ b/test/EFCore.Relational.Tests/Update/CommandBatchPreparerTest.cs @@ -25,7 +25,7 @@ public void BatchCommands_creates_valid_batch_for_added_entities() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { entry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { entry }, modelData); Assert.Single(commandBatches); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); @@ -66,7 +66,7 @@ public void BatchCommands_creates_valid_batch_for_modified_entities() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { entry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { entry }, modelData); Assert.Single(commandBatches); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); @@ -107,7 +107,7 @@ public void BatchCommands_creates_valid_batch_for_deleted_entities() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { entry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { entry }, modelData); Assert.Single(commandBatches); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); @@ -142,7 +142,7 @@ public void BatchCommands_sorts_related_added_entities() new RelatedFakeEntity { Id = 42 }); relatedEntry.SetEntityState(EntityState.Added); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { relatedEntry, entry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { relatedEntry, entry }, modelData); Assert.Equal( new[] { entry, relatedEntry }, @@ -165,7 +165,7 @@ public void BatchCommands_sorts_added_and_related_modified_entities() new RelatedFakeEntity { Id = 42 }); relatedEntry.SetEntityState(EntityState.Modified); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { relatedEntry, entry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { relatedEntry, entry }, modelData); Assert.Equal( new[] { entry, relatedEntry }, @@ -188,7 +188,7 @@ public void BatchCommands_sorts_unrelated_entities() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { secondEntry, firstEntry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { secondEntry, firstEntry }, modelData); Assert.Equal( new[] { firstEntry, secondEntry }, @@ -216,8 +216,7 @@ public void BatchCommands_sorts_entities_when_reparenting() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { relatedEntry, previousParent, newParent }, modelData) - .ToArray(); + var commandBatches = CreateBatches(new[] { relatedEntry, previousParent, newParent }, modelData); Assert.Equal( new[] { newParent, relatedEntry, previousParent }, @@ -244,7 +243,7 @@ public void BatchCommands_sorts_when_reassigning_child() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer().BatchCommands(new[] { newChild, previousChild }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { newChild, previousChild }, modelData); Assert.Equal( new[] { previousChild, newChild }, @@ -279,8 +278,7 @@ public void BatchCommands_sorts_entities_while_reassigning_child_tree() var modelData = new UpdateAdapter(stateManager); - var sortedEntities = CreateCommandBatchPreparer() - .BatchCommands(new[] { newEntity, newChildEntity, oldEntity, oldChildEntity }, modelData) + var sortedEntities = CreateBatches(new[] { newEntity, newChildEntity, oldEntity, oldChildEntity }, modelData) .Select(cb => cb.ModificationCommands.Single()).Select(mc => mc.Entries.Single()).ToArray(); Assert.Equal( @@ -348,8 +346,8 @@ public void Batch_command_does_not_order_non_unique_index_values() var modelData = new UpdateAdapter(stateManager); - var sortedEntities = CreateCommandBatchPreparer() - .BatchCommands(new[] { fakeEntry, fakeEntry2, relatedFakeEntry }, modelData) + var sortedEntities = + CreateBatches(new[] { fakeEntry, fakeEntry2, relatedFakeEntry }, modelData) .Select(cb => cb.ModificationCommands.Single()).Select(mc => mc.Entries.Single()).ToArray(); Assert.Equal( @@ -404,8 +402,7 @@ public void Batch_command_throws_on_commands_with_circular_dependencies(bool sen Assert.Equal( CoreStrings.CircularDependency(ListLoggerFactory.NormalizeLineEndings(expectedCycle)), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: sensitiveLogging) - .BatchCommands(new[] { fakeEntry, relatedFakeEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { fakeEntry, relatedFakeEntry }, modelData, sensitiveLogging)).Message); } [InlineData(true)] @@ -451,8 +448,7 @@ public void Batch_command_throws_on_commands_with_circular_dependencies_includin Assert.Equal( CoreStrings.CircularDependency(ListLoggerFactory.NormalizeLineEndings(expectedCycle)), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: sensitiveLogging) - .BatchCommands(new[] { fakeEntry, relatedFakeEntry, fakeEntry2 }, modelData).ToArray()).Message); + () => CreateBatches(new[] { fakeEntry, relatedFakeEntry, fakeEntry2 }, modelData, sensitiveLogging)).Message); } [InlineData(true)] @@ -490,9 +486,8 @@ FakeEntity [Deleted]" Assert.Equal( CoreStrings.CircularDependency(ListLoggerFactory.NormalizeLineEndings(expectedCycle)), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: sensitiveLogging).BatchCommands( - // Order is important for this test. Entry which is not part of cycle but tail should come first. - new[] { anotherFakeEntry, fakeEntry, relatedFakeEntry }, modelData).ToArray()).Message); + // Order is important for this test. Entry which is not part of cycle but tail should come first. + () => CreateBatches(new[] { anotherFakeEntry, fakeEntry, relatedFakeEntry }, modelData, sensitiveLogging)).Message); } [ConditionalFact] @@ -513,10 +508,9 @@ public void BatchCommands_works_with_duplicate_values_for_unique_indexes() var modelData = new UpdateAdapter(stateManager); - var batches = CreateCommandBatchPreparer(updateAdapter: modelData) - .BatchCommands(new[] { fakeEntry, fakeEntry2 }, modelData).ToArray(); + var batches = CreateBatches(new[] { fakeEntry, fakeEntry2 }, modelData); - Assert.Equal(2, batches.Length); + Assert.Equal(2, batches.Count); } [ConditionalFact] @@ -534,9 +528,8 @@ public void BatchCommands_creates_valid_batch_for_shared_table_added_entities() var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer(updateAdapter: modelData) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData) - .ToArray(); + var commandBatches = CreateBatches(new[] { firstEntry, secondEntry }, modelData); + Assert.Single(commandBatches); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); @@ -588,9 +581,7 @@ public void BatchCommands_creates_valid_batch_for_shared_table_modified_entities var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer(updateAdapter: modelData) - .BatchCommands(new[] { entry }, modelData) - .ToArray(); + var commandBatches = CreateBatches(new[] { entry }, modelData); Assert.Single(commandBatches); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); @@ -645,8 +636,7 @@ public void BatchCommands_creates_valid_batch_for_shared_table_deleted_entities( var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer(updateAdapter: modelData) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { firstEntry, secondEntry }, modelData); Assert.Single(commandBatches); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); @@ -701,8 +691,7 @@ public void BatchCommands_throws_on_conflicting_updates_for_shared_table_added_e nameof(RelatedFakeEntity), "{Id: 42}", EntityState.Deleted, nameof(FakeEntity), "{Id: 42}", EntityState.Added), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: true) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: true)).Message); } else { @@ -711,8 +700,7 @@ public void BatchCommands_throws_on_conflicting_updates_for_shared_table_added_e nameof(RelatedFakeEntity), EntityState.Deleted, nameof(FakeEntity), EntityState.Added), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: false) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: false)).Message); } } @@ -755,8 +743,7 @@ public void BatchCommands_throws_on_conflicting_values_for_shared_table_added_en nameof(FakeEntity), nameof(RelatedFakeEntity), "{Id: 42}", "{RelatedId: 1}", "{RelatedId: 2}", "RelatedId"), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: true) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: true)).Message); } else { @@ -765,8 +752,7 @@ public void BatchCommands_throws_on_conflicting_values_for_shared_table_added_en nameof(FakeEntity), nameof(RelatedFakeEntity), "{'RelatedId'}", "{'RelatedId'}", "RelatedId"), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: false) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: false)).Message); } } else @@ -778,8 +764,7 @@ public void BatchCommands_throws_on_conflicting_values_for_shared_table_added_en nameof(FakeEntity), nameof(RelatedFakeEntity), "{Id: 42}", "{RelatedId: 1}", "{RelatedId: 2}", "RelatedId"), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: true) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: true)).Message); } else { @@ -788,8 +773,7 @@ public void BatchCommands_throws_on_conflicting_values_for_shared_table_added_en nameof(FakeEntity), nameof(RelatedFakeEntity), "{'RelatedId'}", "{'RelatedId'}", "RelatedId"), Assert.Throws( - () => CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: false) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray()).Message); + () => CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: false)).Message); } } } @@ -812,8 +796,7 @@ public void BatchCommands_creates_batch_on_incomplete_updates_for_shared_table_n var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: true) - .BatchCommands(new[] { firstEntry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { firstEntry }, modelData, sensitiveLogging: true); if (state == EntityState.Deleted) { @@ -896,8 +879,7 @@ public void BatchCommands_works_with_incomplete_updates_for_shared_table_no_leaf var modelData = new UpdateAdapter(stateManager); - var batches = CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: false) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray(); + var batches = CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: false); Assert.Single(batches); } @@ -920,8 +902,7 @@ public void BatchCommands_creates_batch_on_incomplete_updates_for_shared_table_n var modelData = new UpdateAdapter(stateManager); - var commandBatches = CreateCommandBatchPreparer(updateAdapter: modelData, sensitiveLogging: true) - .BatchCommands(new[] { firstEntry, secondEntry }, modelData).ToArray(); + var commandBatches = CreateBatches(new[] { firstEntry, secondEntry }, modelData, sensitiveLogging: true); if (state == EntityState.Deleted) { @@ -929,11 +910,11 @@ public void BatchCommands_creates_batch_on_incomplete_updates_for_shared_table_n Assert.Equal( "1", Assert.Throws( - () => Assert.Equal(2, commandBatches.Length)).Actual); + () => Assert.Equal(2, commandBatches.Count)).Actual); } else { - Assert.Equal(2, commandBatches.Length); + Assert.Equal(2, commandBatches.Count); Assert.Equal(1, commandBatches.First().ModificationCommands.Count); var command = commandBatches.First().ModificationCommands.Single(); @@ -966,6 +947,15 @@ public void BatchCommands_creates_batch_on_incomplete_updates_for_shared_table_n private static IServiceProvider CreateContextServices(IModel model) => RelationalTestHelpers.Instance.CreateContextServices(model); + public List CreateBatches( + IUpdateEntry[] entries, + IUpdateAdapter updateAdapter, + bool sensitiveLogging = false) + => CreateCommandBatchPreparer(updateAdapter: updateAdapter, sensitiveLogging: sensitiveLogging) + .BatchCommands(entries, updateAdapter) + .Select(t => t.Batch) + .ToList(); + public ICommandBatchPreparer CreateCommandBatchPreparer( IModificationCommandBatchFactory modificationCommandBatchFactory = null, IUpdateAdapter updateAdapter = null, diff --git a/test/EFCore.Relational.Tests/Update/ReaderModificationCommandBatchTest.cs b/test/EFCore.Relational.Tests/Update/ReaderModificationCommandBatchTest.cs index e2f17161743..f4848969fe0 100644 --- a/test/EFCore.Relational.Tests/Update/ReaderModificationCommandBatchTest.cs +++ b/test/EFCore.Relational.Tests/Update/ReaderModificationCommandBatchTest.cs @@ -25,6 +25,7 @@ public void AddCommand_adds_command_if_possible() batch.ShouldValidateSql = true; batch.AddCommand(command); + batch.Complete(); Assert.Equal(2, batch.ModificationCommands.Count); Assert.Same(command, batch.ModificationCommands[0]); @@ -42,6 +43,7 @@ public void AddCommand_does_not_add_command_if_not_possible() batch.ShouldValidateSql = true; batch.AddCommand(command); + batch.Complete(); Assert.Equal(1, batch.ModificationCommands.Count); Assert.Equal(".", batch.CommandText); @@ -58,6 +60,7 @@ public void AddCommand_does_not_add_command_if_resulting_sql_is_invalid() batch.ShouldValidateSql = false; batch.AddCommand(command); + batch.Complete(); Assert.Equal(1, batch.ModificationCommands.Count); Assert.Equal(".", batch.CommandText); @@ -75,6 +78,7 @@ public void UpdateCommandText_compiles_inserts() RelationalTestHelpers.Instance.CreateContextServices().GetRequiredService()); var batch = new ModificationCommandBatchFake(fakeSqlGenerator); batch.AddCommand(command); + batch.Complete(); batch.UpdateCachedCommandTextBase(0); @@ -96,6 +100,7 @@ public void UpdateCommandText_compiles_updates() batch.AddCommand(command); batch.UpdateCachedCommandTextBase(0); + batch.Complete(); Assert.Equal(1, fakeSqlGenerator.AppendBatchHeaderCalls); Assert.Equal(1, fakeSqlGenerator.AppendUpdateOperationCalls); @@ -115,6 +120,7 @@ public void UpdateCommandText_compiles_deletes() batch.AddCommand(command); batch.UpdateCachedCommandTextBase(0); + batch.Complete(); Assert.Equal(1, fakeSqlGenerator.AppendBatchHeaderCalls); Assert.Equal(1, fakeSqlGenerator.AppendDeleteOperationCalls); @@ -133,6 +139,7 @@ public void UpdateCommandText_compiles_multiple_commands() var batch = new ModificationCommandBatchFake(fakeSqlGenerator); batch.AddCommand(command); batch.AddCommand(command); + batch.Complete(); Assert.Equal("..", batch.CommandText); @@ -153,6 +160,7 @@ public async Task ExecuteAsync_executes_batch_commands_and_consumes_reader() var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); await batch.ExecuteAsync(connection); @@ -175,6 +183,7 @@ public async Task ExecuteAsync_saves_store_generated_values() var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); await batch.ExecuteAsync(connection); @@ -198,6 +207,7 @@ public async Task ExecuteAsync_saves_store_generated_values_on_non_key_columns() var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); await batch.ExecuteAsync(connection); @@ -220,6 +230,7 @@ public async Task ExecuteAsync_saves_store_generated_values_when_updating() var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); await batch.ExecuteAsync(connection); @@ -243,6 +254,7 @@ public async Task Exception_not_thrown_for_more_than_one_row_returned_for_single var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); await batch.ExecuteAsync(connection); @@ -265,6 +277,7 @@ public async Task Exception_thrown_if_rows_returned_for_command_without_store_ge var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); var exception = async ? await Assert.ThrowsAsync(() => batch.ExecuteAsync(connection)) @@ -289,6 +302,7 @@ public async Task Exception_thrown_if_no_rows_returned_for_command_with_store_ge var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); var exception = async ? await Assert.ThrowsAsync(() => batch.ExecuteAsync(connection)) @@ -316,6 +330,7 @@ public async Task DbException_is_wrapped_with_DbUpdateException(bool async) var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); var actualException = async ? await Assert.ThrowsAsync(() => batch.ExecuteAsync(connection)) @@ -343,6 +358,7 @@ public async Task OperationCanceledException_is_not_wrapped_with_DbUpdateExcepti var batch = new ModificationCommandBatchFake(); batch.AddCommand(command); + batch.Complete(); var actualException = async ? await Assert.ThrowsAsync(() => batch.ExecuteAsync(connection)) @@ -393,6 +409,8 @@ public void CreateStoreCommand_creates_parameters_for_each_ModificationCommand() false, true, false, false, true) })); + batch.Complete(); + var storeCommand = batch.CreateStoreCommandBase(); Assert.Equal(2, storeCommand.RelationalCommand.Parameters.Count); @@ -430,6 +448,8 @@ public void PopulateParameters_creates_parameter_for_write_ModificationCommand() sensitiveLoggingEnabled: true) })); + batch.Complete(); + var storeCommand = batch.CreateStoreCommandBase(); Assert.Equal(1, storeCommand.RelationalCommand.Parameters.Count); @@ -465,6 +485,8 @@ public void PopulateParameters_creates_parameter_for_condition_ModificationComma sensitiveLoggingEnabled: true) })); + batch.Complete(); + var storeCommand = batch.CreateStoreCommandBase(); Assert.Equal(1, storeCommand.RelationalCommand.Parameters.Count); @@ -500,6 +522,8 @@ public void PopulateParameters_creates_parameters_for_write_and_condition_Modifi sensitiveLoggingEnabled: true) })); + batch.Complete(); + var storeCommand = batch.CreateStoreCommandBase(); Assert.Equal(2, storeCommand.RelationalCommand.Parameters.Count); @@ -537,6 +561,8 @@ public void PopulateParameters_does_not_create_parameter_for_read_ModificationCo sensitiveLoggingEnabled: true) })); + batch.Complete(); + var storeCommand = batch.CreateStoreCommandBase(); Assert.Equal(0, storeCommand.RelationalCommand.Parameters.Count); @@ -626,7 +652,7 @@ private static ModificationCommandBatchFactoryDependencies CreateDependencies( } public string CommandText - => GetCommandText(); + => CachedCommandText.ToString(); public bool ShouldAddCommand { get; set; } diff --git a/test/EFCore.SqlServer.FunctionalTests/DataAnnotationSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/DataAnnotationSqlServerTest.cs index 5338e33bbb9..b2c5db89b7b 100644 --- a/test/EFCore.SqlServer.FunctionalTests/DataAnnotationSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/DataAnnotationSqlServerTest.cs @@ -164,6 +164,7 @@ FROM [Sample] AS [s] @p1='00000000-0000-0000-0003-000000000001' @p3='00000001-0000-0000-0000-000000000001' +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Sample] SET [Name] = @p0, [RowVersion] = @p1 WHERE [Unique_No] = @p2 AND [RowVersion] = @p3; @@ -174,6 +175,7 @@ FROM [Sample] AS [s] @p1='00000000-0000-0000-0002-000000000001' @p3='00000001-0000-0000-0000-000000000001' +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Sample] SET [Name] = @p0, [RowVersion] = @p1 WHERE [Unique_No] = @p2 AND [RowVersion] = @p3; diff --git a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs index 36394b3efb6..dada1fa78a0 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Migrations/MigrationsSqlServerTest.cs @@ -3229,6 +3229,88 @@ await Test( EXEC(N'ALTER TABLE [RenamedCustomers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[HistoryTable]))')"); } + [ConditionalFact] + public virtual async Task Rename_temporal_table_rename_and_modify_column_in_same_migration() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Name"); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + e.Property("Discount"); + e.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("DoB"); + e.ToTable("Customers"); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Discount").HasComment("for VIP only"); + e.Property("DateOfBirth"); + e.ToTable("RenamedCustomers"); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("RenamedCustomers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartPropertyName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndPropertyName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Discount", c.Name), + c => Assert.Equal("DateOfBirth", c.Name), + c => Assert.Equal("Name", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = OFF)", + // + @"ALTER TABLE [Customers] DROP CONSTRAINT [PK_Customers];", + // + @"EXEC sp_rename N'[Customers]', N'RenamedCustomers';", + // + @"EXEC sp_rename N'[RenamedCustomers].[DoB]', N'DateOfBirth', N'COLUMN';", + // + @"EXEC sp_rename N'[HistoryTable].[DoB]', N'DateOfBirth', N'COLUMN';", + // + @"DECLARE @defaultSchema AS sysname; +SET @defaultSchema = SCHEMA_NAME(); +DECLARE @description AS sql_variant; +SET @description = N'for VIP only'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'RenamedCustomers', 'COLUMN', N'Discount';", + // + @"DECLARE @defaultSchema AS sysname; +SET @defaultSchema = SCHEMA_NAME(); +DECLARE @description AS sql_variant; +SET @description = N'for VIP only'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'HistoryTable', 'COLUMN', N'Discount';", + // + @"ALTER TABLE [RenamedCustomers] ADD CONSTRAINT [PK_RenamedCustomers] PRIMARY KEY ([Id]);", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [RenamedCustomers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[HistoryTable]))')"); + } + [ConditionalFact] public virtual async Task Rename_temporal_table_with_custom_history_table_schema() { @@ -5296,6 +5378,104 @@ await Test( @"EXEC sp_rename N'[Customer].[End]', N'ModifiedEnd', N'COLUMN';"); } + [ConditionalFact] + public virtual async Task Alter_period_column_of_temporal_table() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + e.Property("Name"); + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + builder => { }, + builder => builder.Entity("Customer").Property("End").HasComment("My comment").ValueGeneratedOnAddOrUpdate(), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartPropertyName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndPropertyName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"DECLARE @defaultSchema AS sysname; +SET @defaultSchema = SCHEMA_NAME(); +DECLARE @description AS sql_variant; +SET @description = N'My comment'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'Customers', 'COLUMN', N'End';"); + } + + [ConditionalFact] + public virtual async Task Rename_regular_columns_of_temporal_table() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable(tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Name"); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("FullName"); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customer", table.Name); + Assert.NotNull(table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartPropertyName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndPropertyName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("FullName", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"EXEC sp_rename N'[Customer].[Name]', N'FullName', N'COLUMN';"); + } + [ConditionalFact] public virtual async Task Create_temporal_table_with_comments() { @@ -6008,6 +6188,585 @@ await Test( @"ALTER TABLE [myModifiedDefaultSchema].[Customers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [myModifiedDefaultSchema].[CustomersHistory]))"); } + [ConditionalFact] + public virtual async Task Temporal_table_rename_and_delete_columns_in_one_migration() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Name"); + e.Property("Number"); + e.Property("Dob"); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("FullName"); + e.Property("DateOfBirth"); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartPropertyName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndPropertyName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("DateOfBirth", c.Name), + c => Assert.Equal("FullName", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = OFF)", + // + @"DECLARE @var0 sysname; +SELECT @var0 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Customers]') AND [c].[name] = N'Number'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Customers] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Customers] DROP COLUMN [Number];", + // + @"DECLARE @var1 sysname; +SELECT @var1 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[HistoryTable]') AND [c].[name] = N'Number'); +IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [HistoryTable] DROP CONSTRAINT [' + @var1 + '];'); +ALTER TABLE [HistoryTable] DROP COLUMN [Number];", + // + @"EXEC sp_rename N'[Customers].[Name]', N'FullName', N'COLUMN';", + // + @"EXEC sp_rename N'[HistoryTable].[Name]', N'FullName', N'COLUMN';", + // + @"EXEC sp_rename N'[Customers].[Dob]', N'DateOfBirth', N'COLUMN';", + // + @"EXEC sp_rename N'[HistoryTable].[Dob]', N'DateOfBirth', N'COLUMN';", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[HistoryTable]))')"); + } + + [ConditionalFact] + public virtual async Task Temporal_table_rename_and_delete_columns_and_also_rename_table_in_one_migration() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Name"); + e.Property("Number"); + + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + + builder => builder.Entity( + "Customer", e => + { + e.Property("FullName"); + + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable("ModifiedCustomers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("ModifiedCustomers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartPropertyName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndPropertyName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("FullName", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = OFF)", + // + @"ALTER TABLE [Customers] DROP CONSTRAINT [PK_Customers];", + // + @"DECLARE @var0 sysname; +SELECT @var0 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Customers]') AND [c].[name] = N'Number'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Customers] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Customers] DROP COLUMN [Number];", + // + @"DECLARE @var1 sysname; +SELECT @var1 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[HistoryTable]') AND [c].[name] = N'Number'); +IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [HistoryTable] DROP CONSTRAINT [' + @var1 + '];'); +ALTER TABLE [HistoryTable] DROP COLUMN [Number];", + // + @"EXEC sp_rename N'[Customers]', N'ModifiedCustomers';", + // + @"EXEC sp_rename N'[ModifiedCustomers].[Name]', N'FullName', N'COLUMN';", + // + @"EXEC sp_rename N'[HistoryTable].[Name]', N'FullName', N'COLUMN';", + // + @"ALTER TABLE [ModifiedCustomers] ADD CONSTRAINT [PK_ModifiedCustomers] PRIMARY KEY ([Id]);", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [ModifiedCustomers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[HistoryTable]))')"); + } + + [ConditionalFact] + public virtual async Task Temporal_table_rename_and_delete_columns_and_also_rename_history_table_in_one_migration() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Name"); + e.Property("Number"); + + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + + builder => builder.Entity( + "Customer", e => + { + e.Property("FullName"); + + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("ModifiedHistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartPropertyName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndPropertyName]); + Assert.Equal("ModifiedHistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("FullName", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = OFF)", + // + @"DECLARE @var0 sysname; +SELECT @var0 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Customers]') AND [c].[name] = N'Number'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Customers] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Customers] DROP COLUMN [Number];", + // + @"DECLARE @var1 sysname; +SELECT @var1 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[HistoryTable]') AND [c].[name] = N'Number'); +IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [HistoryTable] DROP CONSTRAINT [' + @var1 + '];'); +ALTER TABLE [HistoryTable] DROP COLUMN [Number];", + // + @"EXEC sp_rename N'[Customers].[Name]', N'FullName', N'COLUMN';", + // + @"EXEC sp_rename N'[HistoryTable].[Name]', N'FullName', N'COLUMN';", + // + @"EXEC sp_rename N'[HistoryTable]', N'ModifiedHistoryTable';", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[ModifiedHistoryTable]))')"); + } + + [ConditionalFact] + public virtual async Task Temporal_table_delete_column_and_add_another_column_in_one_migration() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Name"); + e.Property("Number"); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Name"); + e.Property("DateOfBirth"); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartPropertyName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndPropertyName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name), + c => Assert.Equal("DateOfBirth", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = OFF)", + // + @"DECLARE @var0 sysname; +SELECT @var0 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Customers]') AND [c].[name] = N'Number'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Customers] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Customers] DROP COLUMN [Number];", + // + @"DECLARE @var1 sysname; +SELECT @var1 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[HistoryTable]') AND [c].[name] = N'Number'); +IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [HistoryTable] DROP CONSTRAINT [' + @var1 + '];'); +ALTER TABLE [HistoryTable] DROP COLUMN [Number];", + // + @"ALTER TABLE [Customers] ADD [DateOfBirth] datetime2 NOT NULL DEFAULT '0001-01-01T00:00:00.0000000';", + // + @"ALTER TABLE [HistoryTable] ADD [DateOfBirth] datetime2 NOT NULL DEFAULT '0001-01-01T00:00:00.0000000';", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[HistoryTable]))')"); + } + + [ConditionalFact] + public virtual async Task Temporal_table_delete_column_and_alter_another_column_in_one_migration() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Name"); + e.Property("Number"); + e.Property("DateOfBirth"); + }), + builder => builder.Entity( + "Customer", e => + { + e.Property("Name").HasComment("My comment"); + e.Property("DateOfBirth"); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("Start", table[SqlServerAnnotationNames.TemporalPeriodStartPropertyName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndPropertyName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("DateOfBirth", c.Name), + c => Assert.Equal("Name", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = OFF)", + // + @"DECLARE @var0 sysname; +SELECT @var0 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Customers]') AND [c].[name] = N'Number'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Customers] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Customers] DROP COLUMN [Number];", + // + @"DECLARE @var1 sysname; +SELECT @var1 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[HistoryTable]') AND [c].[name] = N'Number'); +IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [HistoryTable] DROP CONSTRAINT [' + @var1 + '];'); +ALTER TABLE [HistoryTable] DROP COLUMN [Number];", + // + @"DECLARE @defaultSchema AS sysname; +SET @defaultSchema = SCHEMA_NAME(); +DECLARE @description AS sql_variant; +SET @description = N'My comment'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'Customers', 'COLUMN', N'Name';", + // + @"DECLARE @defaultSchema AS sysname; +SET @defaultSchema = SCHEMA_NAME(); +DECLARE @description AS sql_variant; +SET @description = N'My comment'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'HistoryTable', 'COLUMN', N'Name';", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[HistoryTable]))')"); + } + + [ConditionalFact] + public virtual async Task Temporal_table_rename_and_alter_period_column_in_one_migration() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + e.Property("Name"); + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").HasComment("My comment").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + e.Property("Name"); + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start").HasColumnName("ModifiedStart"); + ttb.HasPeriodEnd("End"); + })); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("ModifiedStart", table[SqlServerAnnotationNames.TemporalPeriodStartPropertyName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndPropertyName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"EXEC sp_rename N'[Customers].[Start]', N'ModifiedStart', N'COLUMN';", + // + @"DECLARE @defaultSchema AS sysname; +SET @defaultSchema = SCHEMA_NAME(); +DECLARE @description AS sql_variant; +SET @description = N'My comment'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'Customers', 'COLUMN', N'End';"); + } + + [ConditionalFact] + public virtual async Task Temporal_table_delete_column_rename_and_alter_period_column_in_one_migration() + { + await Test( + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + e.Property("Name"); + e.Property("DateOfBirth"); + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start"); + ttb.HasPeriodEnd("End"); + })); + }), + + builder => builder.Entity( + "Customer", e => + { + e.Property("Id").ValueGeneratedOnAdd(); + e.Property("Start").ValueGeneratedOnAddOrUpdate(); + e.Property("End").HasComment("My comment").ValueGeneratedOnAddOrUpdate(); + e.HasKey("Id"); + e.Property("Name"); + e.ToTable("Customers", tb => tb.IsTemporal(ttb => + { + ttb.UseHistoryTable("HistoryTable"); + ttb.HasPeriodStart("Start").HasColumnName("ModifiedStart"); + ttb.HasPeriodEnd("End"); + })); + }), + model => + { + var table = Assert.Single(model.Tables); + Assert.Equal("Customers", table.Name); + Assert.Equal(true, table[SqlServerAnnotationNames.IsTemporal]); + Assert.Equal("ModifiedStart", table[SqlServerAnnotationNames.TemporalPeriodStartPropertyName]); + Assert.Equal("End", table[SqlServerAnnotationNames.TemporalPeriodEndPropertyName]); + Assert.Equal("HistoryTable", table[SqlServerAnnotationNames.TemporalHistoryTableName]); + + Assert.Collection( + table.Columns, + c => Assert.Equal("Id", c.Name), + c => Assert.Equal("Name", c.Name)); + Assert.Same( + table.Columns.Single(c => c.Name == "Id"), + Assert.Single(table.PrimaryKey!.Columns)); + }); + + AssertSql( + @"ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = OFF)", + // + @"DECLARE @var0 sysname; +SELECT @var0 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[Customers]') AND [c].[name] = N'DateOfBirth'); +IF @var0 IS NOT NULL EXEC(N'ALTER TABLE [Customers] DROP CONSTRAINT [' + @var0 + '];'); +ALTER TABLE [Customers] DROP COLUMN [DateOfBirth];", + // + @"DECLARE @var1 sysname; +SELECT @var1 = [d].[name] +FROM [sys].[default_constraints] [d] +INNER JOIN [sys].[columns] [c] ON [d].[parent_column_id] = [c].[column_id] AND [d].[parent_object_id] = [c].[object_id] +WHERE ([d].[parent_object_id] = OBJECT_ID(N'[HistoryTable]') AND [c].[name] = N'DateOfBirth'); +IF @var1 IS NOT NULL EXEC(N'ALTER TABLE [HistoryTable] DROP CONSTRAINT [' + @var1 + '];'); +ALTER TABLE [HistoryTable] DROP COLUMN [DateOfBirth];", + // + @"EXEC sp_rename N'[Customers].[Start]', N'ModifiedStart', N'COLUMN';", + // + @"EXEC sp_rename N'[HistoryTable].[Start]', N'ModifiedStart', N'COLUMN';", + // + @"DECLARE @defaultSchema AS sysname; +SET @defaultSchema = SCHEMA_NAME(); +DECLARE @description AS sql_variant; +SET @description = N'My comment'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'Customers', 'COLUMN', N'End';", + // + @"DECLARE @defaultSchema AS sysname; +SET @defaultSchema = SCHEMA_NAME(); +DECLARE @description AS sql_variant; +SET @description = N'My comment'; +EXEC sp_addextendedproperty 'MS_Description', @description, 'SCHEMA', @defaultSchema, 'TABLE', N'HistoryTable', 'COLUMN', N'End';", + // + @"DECLARE @historyTableSchema sysname = SCHEMA_NAME() +EXEC(N'ALTER TABLE [Customers] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + '].[HistoryTable]))')"); + } + protected override string NonDefaultCollation => _nonDefaultCollation ??= GetDatabaseCollation() == "German_PhoneBook_CI_AS" ? "French_CI_AS" diff --git a/test/EFCore.SqlServer.FunctionalTests/OptimisticConcurrencySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/OptimisticConcurrencySqlServerTest.cs index bc860720142..469a2b2120b 100644 --- a/test/EFCore.SqlServer.FunctionalTests/OptimisticConcurrencySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/OptimisticConcurrencySqlServerTest.cs @@ -166,6 +166,7 @@ FROM [Engines] AS [e] @p4='47.64491' (Nullable = true) @p5='-122.128101' (Nullable = true) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Engines] SET [Name] = @p0 WHERE [Id] = @p1 AND [EngineSupplierId] = @p2 AND [Name] = @p3 AND [StorageLocation_Latitude] = @p4 AND [StorageLocation_Longitude] = @p5; diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/IncompleteMappingInheritanceQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/IncompleteMappingInheritanceQuerySqlServerTest.cs index 26b289461a3..85a40748b6c 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/IncompleteMappingInheritanceQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/IncompleteMappingInheritanceQuerySqlServerTest.cs @@ -389,6 +389,7 @@ FROM [Countries] AS [c] @p5='True' (Nullable = true) @p6='Little spotted kiwi' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Animals] ([Species], [CountryId], [Discriminator], [EagleId], [FoundOn], [IsFlightless], [Name]) VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6);", @@ -400,6 +401,7 @@ FROM [Animals] AS [a] @"@p1='Apteryx owenii' (Nullable = false) (Size = 100) @p0='Aquila chrysaetos canadensis' (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Animals] SET [EagleId] = @p0 WHERE [Species] = @p1; @@ -411,6 +413,7 @@ FROM [Animals] AS [a] // @"@p0='Apteryx owenii' (Nullable = false) (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; DELETE FROM [Animals] WHERE [Species] = @p0; diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/InheritanceQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/InheritanceQuerySqlServerTest.cs index 155f1612578..3f58c6ef539 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/InheritanceQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/InheritanceQuerySqlServerTest.cs @@ -363,6 +363,7 @@ FROM [Countries] AS [c] @p5='True' (Nullable = true) @p6='Little spotted kiwi' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Animals] ([Species], [CountryId], [Discriminator], [EagleId], [FoundOn], [IsFlightless], [Name]) VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6);", @@ -374,6 +375,7 @@ FROM [Animals] AS [a] @"@p1='Apteryx owenii' (Nullable = false) (Size = 100) @p0='Aquila chrysaetos canadensis' (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Animals] SET [EagleId] = @p0 WHERE [Species] = @p1; @@ -385,6 +387,7 @@ FROM [Animals] AS [a] // @"@p0='Apteryx owenii' (Nullable = false) (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; DELETE FROM [Animals] WHERE [Species] = @p0; diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/TPTInheritanceQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/TPTInheritanceQuerySqlServerTest.cs index dee1a0b6105..6c45fc18098 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/TPTInheritanceQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/TPTInheritanceQuerySqlServerTest.cs @@ -102,6 +102,7 @@ FROM [Countries] AS [c] @p1='1' @p2='Little spotted kiwi' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Animals] ([Species], [CountryId], [Name]) VALUES (@p0, @p1, @p2);", @@ -110,6 +111,7 @@ INSERT INTO [Animals] ([Species], [CountryId], [Name]) @p4=NULL (Size = 100) @p5='True' +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Birds] ([Species], [EagleId], [IsFlightless]) VALUES (@p3, @p4, @p5);", @@ -117,6 +119,7 @@ INSERT INTO [Birds] ([Species], [EagleId], [IsFlightless]) @"@p6='Apteryx owenii' (Nullable = false) (Size = 100) @p7='0' (Size = 1) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Kiwi] ([Species], [FoundOn]) VALUES (@p6, @p7);", @@ -130,6 +133,7 @@ FROM [Animals] AS [a] @"@p1='Apteryx owenii' (Nullable = false) (Size = 100) @p0='Aquila chrysaetos canadensis' (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Birds] SET [EagleId] = @p0 WHERE [Species] = @p1; @@ -143,6 +147,7 @@ FROM [Animals] AS [a] // @"@p0='Apteryx owenii' (Nullable = false) (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; DELETE FROM [Kiwi] WHERE [Species] = @p0; @@ -150,6 +155,7 @@ DELETE FROM [Kiwi] // @"@p1='Apteryx owenii' (Nullable = false) (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; DELETE FROM [Birds] WHERE [Species] = @p1; @@ -157,6 +163,7 @@ DELETE FROM [Birds] // @"@p2='Apteryx owenii' (Nullable = false) (Size = 100) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; DELETE FROM [Animals] WHERE [Species] = @p2; @@ -527,6 +534,7 @@ FROM [Animals] AS [a] @p1='0' @p2='Bald eagle' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Animals] ([Species], [CountryId], [Name]) VALUES (@p0, @p1, @p2);"); diff --git a/test/EFCore.SqlServer.FunctionalTests/SqlServerApiConsistencyTest.cs b/test/EFCore.SqlServer.FunctionalTests/SqlServerApiConsistencyTest.cs index b345098aae3..a176589a48d 100644 --- a/test/EFCore.SqlServer.FunctionalTests/SqlServerApiConsistencyTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/SqlServerApiConsistencyTest.cs @@ -30,7 +30,14 @@ public class SqlServerApiConsistencyFixture : ApiConsistencyFixtureBase typeof(SqlServerModelBuilderExtensions), typeof(SqlServerPropertyBuilderExtensions), typeof(SqlServerEntityTypeBuilderExtensions), - typeof(SqlServerServiceCollectionExtensions) + typeof(SqlServerServiceCollectionExtensions), + typeof(SqlServerDbFunctionsExtensions), + typeof(OwnedNavigationTemporalPeriodPropertyBuilder), + typeof(TemporalPeriodPropertyBuilder), + typeof(OwnedNavigationTemporalTableBuilder), + typeof(OwnedNavigationTemporalTableBuilder<>), + typeof(TemporalTableBuilder), + typeof(TemporalTableBuilder<>) }; public override diff --git a/test/EFCore.SqlServer.FunctionalTests/SqlServerEndToEndTest.cs b/test/EFCore.SqlServer.FunctionalTests/SqlServerEndToEndTest.cs index 36097abba24..7bc02009588 100644 --- a/test/EFCore.SqlServer.FunctionalTests/SqlServerEndToEndTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/SqlServerEndToEndTest.cs @@ -980,7 +980,9 @@ public class Student public enum Grade { +#pragma warning disable SA1602 // Enumeration items should be documented A, B, C, D, F +#pragma warning restore SA1602 // Enumeration items should be documented } public class Enrollment diff --git a/test/EFCore.SqlServer.FunctionalTests/TPTTableSplittingSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/TPTTableSplittingSqlServerTest.cs index 0181ac8adef..4f9bbb9011a 100644 --- a/test/EFCore.SqlServer.FunctionalTests/TPTTableSplittingSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/TPTTableSplittingSqlServerTest.cs @@ -187,6 +187,7 @@ public override async Task Can_change_dependent_instance_non_derived() @"@p1='Trek Pro Fit Madone 6 Series' (Nullable = false) (Size = 450) @p0='repairman' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Vehicles] SET [Operator_Name] = @p0 WHERE [Name] = @p1; @@ -195,6 +196,7 @@ public override async Task Can_change_dependent_instance_non_derived() @"@p2='Trek Pro Fit Madone 6 Series' (Nullable = false) (Size = 450) @p3='Repair' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [LicensedOperators] ([VehicleName], [LicenseType]) VALUES (@p2, @p3);", @@ -228,6 +230,7 @@ public override async Task Can_change_principal_instance_non_derived() @"@p1='Trek Pro Fit Madone 6 Series' (Nullable = false) (Size = 450) @p0='2' +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Vehicles] SET [SeatingCapacity] = @p0 WHERE [Name] = @p1; diff --git a/test/EFCore.SqlServer.FunctionalTests/TableSplittingSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/TableSplittingSqlServerTest.cs index a80051258f6..4fa4ba61080 100644 --- a/test/EFCore.SqlServer.FunctionalTests/TableSplittingSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/TableSplittingSqlServerTest.cs @@ -210,6 +210,7 @@ public override async Task Can_change_dependent_instance_non_derived() @p1='Repair' (Size = 4000) @p2='repairman' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Vehicles] SET [Operator_Discriminator] = @p0, [LicenseType] = @p1, [Operator_Name] = @p2 WHERE [Name] = @p3; @@ -233,6 +234,7 @@ public override async Task Can_change_principal_instance_non_derived() @"@p1='Trek Pro Fit Madone 6 Series' (Nullable = false) (Size = 450) @p0='2' +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Vehicles] SET [SeatingCapacity] = @p0 WHERE [Name] = @p1; diff --git a/test/EFCore.SqlServer.FunctionalTests/Update/StoreValueGenerationIdentitySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Update/StoreValueGenerationIdentitySqlServerTest.cs new file mode 100644 index 00000000000..7bb46716dd1 --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/Update/StoreValueGenerationIdentitySqlServerTest.cs @@ -0,0 +1,472 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Update; + +#nullable enable + +public class StoreValueGenerationIdentitySqlServerTest : StoreValueGenerationTestBase< + StoreValueGenerationIdentitySqlServerTest.StoreValueGenerationIdentitySqlServerFixture> +{ + public StoreValueGenerationIdentitySqlServerTest( + StoreValueGenerationIdentitySqlServerFixture fixture, + ITestOutputHelper testOutputHelper) + : base(fixture) + { + Fixture.TestSqlLoggerFactory.Clear(); + // Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); + } + + protected override int ShouldExecuteInNumberOfCommands( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool withDatabaseGenerated) + => secondOperationType is null ? 1 : 2; + + #region Single operation + + public override async Task Add_with_generated_values(bool async) + { + await base.Add_with_generated_values(async); + + AssertSql( + @"@p0='1000' + +SET NOCOUNT ON; +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +VALUES (@p0); +SELECT [Id], [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();"); + } + + public override async Task Add_with_no_generated_values(bool async) + { + await base.Add_with_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_with_all_generated_values(bool async) + { + await base.Add_with_all_generated_values(async); + + AssertSql( + @"SET NOCOUNT ON; +INSERT INTO [WithAllDatabaseGenerated] +DEFAULT VALUES; +SELECT [Id], [Data1], [Data2] +FROM [WithAllDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();"); + } + + public override async Task Modify_with_generated_values(bool async) + { + await base.Modify_with_generated_values(async); + + AssertSql( + @"@p1='1' +@p0='1000' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;"); + } + + public override async Task Modify_with_no_generated_values(bool async) + { + await base.Modify_with_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;"); + } + + public override async Task Delete(bool async) + { + await base.Delete(async); + + AssertSql( + @"@p0='1' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;"); + } + + #endregion Single operation + + #region Two operations with same entity type + + public override async Task Add_Add_with_same_entity_type_and_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_generated_values(async); + + AssertSql( + @"@p0='1000' + +SET NOCOUNT ON; +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +VALUES (@p0); +SELECT [Id], [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();", + // + @"@p0='1001' + +SET NOCOUNT ON; +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +VALUES (@p0); +SELECT [Id], [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();"); + } + + public override async Task Add_Add_with_same_entity_type_and_no_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);", + // + @"@p0='101' +@p1='1001' +@p2='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_Add_with_same_entity_type_and_all_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_all_generated_values(async); + + AssertSql( + @"SET NOCOUNT ON; +INSERT INTO [WithAllDatabaseGenerated] +DEFAULT VALUES; +SELECT [Id], [Data1], [Data2] +FROM [WithAllDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();", + // + @"SET NOCOUNT ON; +INSERT INTO [WithAllDatabaseGenerated] +DEFAULT VALUES; +SELECT [Id], [Data1], [Data2] +FROM [WithAllDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();"); + } + + public override async Task Modify_Modify_with_same_entity_type_and_generated_values(bool async) + { + await base.Modify_Modify_with_same_entity_type_and_generated_values(async); + + AssertSql( + @"@p1='1' +@p0='1000' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;", + // + @"@p1='2' +@p0='1001' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;"); + } + + public override async Task Modify_Modify_with_same_entity_type_and_no_generated_values(bool async) + { + await base.Modify_Modify_with_same_entity_type_and_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;", + // + @"@p2='2' +@p0='1001' +@p1='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;"); + } + + public override async Task Delete_Delete_with_same_entity_type(bool async) + { + await base.Delete_Delete_with_same_entity_type(async); + + AssertSql( + @"@p0='1' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;", + // + @"@p0='2' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;"); + } + + #endregion Two operations with same entity type + + #region Two operations with different entity types + + public override async Task Add_Add_with_different_entity_types_and_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_generated_values(async); + + AssertSql( + @"@p0='1000' + +SET NOCOUNT ON; +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +VALUES (@p0); +SELECT [Id], [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();", + // + @"@p0='1001' + +SET NOCOUNT ON; +INSERT INTO [WithSomeDatabaseGenerated2] ([Data2]) +VALUES (@p0); +SELECT [Id], [Data1] +FROM [WithSomeDatabaseGenerated2] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();"); + } + + public override async Task Add_Add_with_different_entity_types_and_no_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);", + // + @"@p0='101' +@p1='1001' +@p2='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated2] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_Add_with_different_entity_types_and_all_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_all_generated_values(async); + + AssertSql( + @"SET NOCOUNT ON; +INSERT INTO [WithAllDatabaseGenerated] +DEFAULT VALUES; +SELECT [Id], [Data1], [Data2] +FROM [WithAllDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();", + // + @"SET NOCOUNT ON; +INSERT INTO [WithAllDatabaseGenerated2] +DEFAULT VALUES; +SELECT [Id], [Data1], [Data2] +FROM [WithAllDatabaseGenerated2] +WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();"); + } + + public override async Task Modify_Modify_with_different_entity_types_and_generated_values(bool async) + { + await base.Modify_Modify_with_different_entity_types_and_generated_values(async); + + AssertSql( + @"@p1='1' +@p0='1000' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;", + // + @"@p1='2' +@p0='1001' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated2] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated2] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;"); + } + + public override async Task Modify_Modify_with_different_entity_types_and_no_generated_values(bool async) + { + await base.Modify_Modify_with_different_entity_types_and_no_generated_values(async); +AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;", + // + @"@p2='2' +@p0='1001' +@p1='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated2] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;"); + } + + public override async Task Delete_Delete_with_different_entity_types(bool async) + { + await base.Delete_Delete_with_different_entity_types(async); + + AssertSql( + @"@p0='1' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;", + // + @"@p0='2' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated2] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;"); + } + + #endregion Two operations with different entity types + + protected override async Task Test( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool async, + bool withSameEntityType = true) + { + await base.Test(firstOperationType, secondOperationType, generatedValues, async, withSameEntityType); + + if (!ShouldCreateImplicitTransaction(firstOperationType, secondOperationType, generatedValues, withSameEntityType)) + { + Assert.Contains("SET IMPLICIT_TRANSACTIONS OFF", Fixture.TestSqlLoggerFactory.SqlStatements[0]); + } + } + + public class StoreValueGenerationIdentitySqlServerFixture : StoreValueGenerationFixtureBase + { + private string? _identityResetCommand; + + protected override string StoreName { get; } = "StoreValueGenerationIdentityTest"; + + protected override ITestStoreFactory TestStoreFactory + => SqlServerTestStoreFactory.Instance; + + public override void Reseed() + { + using var context = CreateContext(); + Clean(context); + Seed(context); + } + + protected override void Clean(DbContext context) + { + base.Clean(context); + + // Reset the IDENTITY values since we assert on them + context.Database.ExecuteSqlRaw(GetIdentityResetCommand()); + } + + private string GetIdentityResetCommand() + { + if (_identityResetCommand is not null) + { + return _identityResetCommand; + } + + var context = CreateContext(); + var builder = new StringBuilder(); + + var tablesWithIdentity = context.Model.GetEntityTypes() + .Where(e => e.GetProperties().Any(p => p.GetValueGenerationStrategy() == SqlServerValueGenerationStrategy.IdentityColumn)) + .Select(e => e.GetTableName()); + + foreach (var table in tablesWithIdentity) + { + builder.AppendLine($"DBCC CHECKIDENT ('{table}', RESEED, 0);"); + } + + return _identityResetCommand = builder.ToString(); + } + } +} diff --git a/test/EFCore.SqlServer.FunctionalTests/Update/StoreValueGenerationSequenceSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Update/StoreValueGenerationSequenceSqlServerTest.cs new file mode 100644 index 00000000000..eb65ef829db --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/Update/StoreValueGenerationSequenceSqlServerTest.cs @@ -0,0 +1,491 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore.TestModels.StoreValueGenerationModel; + +namespace Microsoft.EntityFrameworkCore.Update; + +#nullable enable + +public class StoreValueGenerationSequenceSqlServerTest : StoreValueGenerationTestBase< + StoreValueGenerationSequenceSqlServerTest.StoreValueGenerationSequenceSqlServerFixture> +{ + public StoreValueGenerationSequenceSqlServerTest( + StoreValueGenerationSequenceSqlServerFixture fixture, + ITestOutputHelper testOutputHelper) + : base(fixture) + { + Fixture.TestSqlLoggerFactory.Clear(); + // Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); + } + + protected override int ShouldExecuteInNumberOfCommands( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool withDatabaseGenerated) + => secondOperationType is null ? 1 : 2; + + #region Single operation + + public override async Task Add_with_generated_values(bool async) + { + await base.Add_with_generated_values(async); + + AssertSql( + @"@p0='1000' + +SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +OUTPUT INSERTED.[Id] +INTO @inserted0 +VALUES (@p0); +SELECT [t].[Id], [t].[Data1] FROM [WithSomeDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);"); + } + + public override async Task Add_with_no_generated_values(bool async) + { + await base.Add_with_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_with_all_generated_values(bool async) + { + await base.Add_with_all_generated_values(async); + + AssertSql( + @"SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithAllDatabaseGenerated] +OUTPUT INSERTED.[Id] +INTO @inserted0 +DEFAULT VALUES; +SELECT [t].[Id], [t].[Data1], [t].[Data2] FROM [WithAllDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);"); + } + + public override async Task Modify_with_generated_values(bool async) + { + await base.Modify_with_generated_values(async); + + AssertSql( + @"@p1='5' +@p0='1000' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;"); + } + + public override async Task Modify_with_no_generated_values(bool async) + { + await base.Modify_with_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;"); + } + + public override async Task Delete(bool async) + { + await base.Delete(async); + + AssertSql( + @"@p0='5' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;"); + } + + #endregion Single operation + + #region Two operations with same entity type + + public override async Task Add_Add_with_same_entity_type_and_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_generated_values(async); + + AssertSql( + @"@p0='1000' + +SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +OUTPUT INSERTED.[Id] +INTO @inserted0 +VALUES (@p0); +SELECT [t].[Id], [t].[Data1] FROM [WithSomeDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);", + // + @"@p0='1001' + +SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +OUTPUT INSERTED.[Id] +INTO @inserted0 +VALUES (@p0); +SELECT [t].[Id], [t].[Data1] FROM [WithSomeDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);"); + } + + public override async Task Add_Add_with_same_entity_type_and_no_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);", + // + @"@p0='101' +@p1='1001' +@p2='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_Add_with_same_entity_type_and_all_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_all_generated_values(async); + + AssertSql( + @"SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithAllDatabaseGenerated] +OUTPUT INSERTED.[Id] +INTO @inserted0 +DEFAULT VALUES; +SELECT [t].[Id], [t].[Data1], [t].[Data2] FROM [WithAllDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);", + // + @"SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithAllDatabaseGenerated] +OUTPUT INSERTED.[Id] +INTO @inserted0 +DEFAULT VALUES; +SELECT [t].[Id], [t].[Data1], [t].[Data2] FROM [WithAllDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);"); + } + + public override async Task Modify_Modify_with_same_entity_type_and_generated_values(bool async) + { + await base.Modify_Modify_with_same_entity_type_and_generated_values(async); + + AssertSql( + @"@p1='5' +@p0='1000' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;", + // + @"@p1='6' +@p0='1001' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;"); + } + + public override async Task Modify_Modify_with_same_entity_type_and_no_generated_values(bool async) + { + await base.Modify_Modify_with_same_entity_type_and_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;", + // + @"@p2='2' +@p0='1001' +@p1='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;"); + } + + public override async Task Delete_Delete_with_same_entity_type(bool async) + { + await base.Delete_Delete_with_same_entity_type(async); + + AssertSql( + @"@p0='5' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;", + // + @"@p0='6' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;"); + } + + #endregion Two operations with same entity type + + #region Two operations with different entity types + + public override async Task Add_Add_with_different_entity_types_and_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_generated_values(async); + + AssertSql( + @"@p0='1000' + +SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithSomeDatabaseGenerated] ([Data2]) +OUTPUT INSERTED.[Id] +INTO @inserted0 +VALUES (@p0); +SELECT [t].[Id], [t].[Data1] FROM [WithSomeDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);", + // + @"@p0='1001' + +SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithSomeDatabaseGenerated2] ([Data2]) +OUTPUT INSERTED.[Id] +INTO @inserted0 +VALUES (@p0); +SELECT [t].[Id], [t].[Data1] FROM [WithSomeDatabaseGenerated2] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);"); + } + + public override async Task Add_Add_with_different_entity_types_and_no_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);", + // + @"@p0='101' +@p1='1001' +@p2='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +INSERT INTO [WithNoDatabaseGenerated2] ([Id], [Data1], [Data2]) +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_Add_with_different_entity_types_and_all_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_all_generated_values(async); + + AssertSql( + @"SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithAllDatabaseGenerated] +OUTPUT INSERTED.[Id] +INTO @inserted0 +DEFAULT VALUES; +SELECT [t].[Id], [t].[Data1], [t].[Data2] FROM [WithAllDatabaseGenerated] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);", + // + @"SET NOCOUNT ON; +DECLARE @inserted0 TABLE ([Id] int); +INSERT INTO [WithAllDatabaseGenerated2] +OUTPUT INSERTED.[Id] +INTO @inserted0 +DEFAULT VALUES; +SELECT [t].[Id], [t].[Data1], [t].[Data2] FROM [WithAllDatabaseGenerated2] t +INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id]);"); + } + + public override async Task Modify_Modify_with_different_entity_types_and_generated_values(bool async) + { + await base.Modify_Modify_with_different_entity_types_and_generated_values(async); + + AssertSql( + @"@p1='5' +@p0='1000' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;", + // + @"@p1='8' +@p0='1001' + +SET NOCOUNT ON; +UPDATE [WithSomeDatabaseGenerated2] SET [Data2] = @p0 +WHERE [Id] = @p1; +SELECT [Data1] +FROM [WithSomeDatabaseGenerated2] +WHERE @@ROWCOUNT = 1 AND [Id] = @p1;"); + } + + public override async Task Modify_Modify_with_different_entity_types_and_no_generated_values(bool async) + { + await base.Modify_Modify_with_different_entity_types_and_no_generated_values(async); +AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;", + // + @"@p2='2' +@p0='1001' +@p1='1001' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [WithNoDatabaseGenerated2] SET [Data1] = @p0, [Data2] = @p1 +WHERE [Id] = @p2; +SELECT @@ROWCOUNT;"); + } + + public override async Task Delete_Delete_with_different_entity_types(bool async) + { + await base.Delete_Delete_with_different_entity_types(async); + + AssertSql( + @"@p0='5' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;", + // + @"@p0='8' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +DELETE FROM [WithSomeDatabaseGenerated2] +WHERE [Id] = @p0; +SELECT @@ROWCOUNT;"); + } + + #endregion Two operations with different entity types + + protected override async Task Test( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool async, + bool withSameEntityType = true) + { + await base.Test(firstOperationType, secondOperationType, generatedValues, async, withSameEntityType); + + if (!ShouldCreateImplicitTransaction(firstOperationType, secondOperationType, generatedValues, withSameEntityType)) + { + Assert.Contains("SET IMPLICIT_TRANSACTIONS OFF", Fixture.TestSqlLoggerFactory.SqlStatements[0]); + } + } + + public class StoreValueGenerationSequenceSqlServerFixture : StoreValueGenerationFixtureBase + { + protected override string StoreName { get; } = "StoreValueGenerationSequenceTest"; + + protected override ITestStoreFactory TestStoreFactory + => SqlServerTestStoreFactory.Instance; + + protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context) + { + base.OnModelCreating(modelBuilder, context); + + modelBuilder.HasSequence("Ids"); + + foreach (var name in new[] + { + nameof(StoreValueGenerationContext.WithSomeDatabaseGenerated), + nameof(StoreValueGenerationContext.WithSomeDatabaseGenerated2), + nameof(StoreValueGenerationContext.WithAllDatabaseGenerated), + nameof(StoreValueGenerationContext.WithAllDatabaseGenerated2) + }) + { + modelBuilder + .SharedTypeEntity(name) + .Property(w => w.Id) + .HasDefaultValueSql("NEXT VALUE FOR [Ids]"); + } + } + + public override void Reseed() + { + using var context = CreateContext(); + Clean(context); + Seed(context); + } + + protected override void Clean(DbContext context) + { + base.Clean(context); + + // Reset the sequence values since we assert on them + context.Database.ExecuteSqlRaw("ALTER SEQUENCE [Ids] RESTART WITH 1"); + } + } +} diff --git a/test/EFCore.SqlServer.FunctionalTests/UpdatesSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/UpdatesSqlServerTest.cs index 4320cebe7ae..ac06c242721 100644 --- a/test/EFCore.SqlServer.FunctionalTests/UpdatesSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/UpdatesSqlServerTest.cs @@ -44,6 +44,7 @@ public virtual void Save_with_shared_foreign_key() @p1=NULL (Size = 4000) @p2='777' +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; INSERT INTO [Categories] ([Id], [Name], [PrincipalId]) VALUES (@p0, @p1, @p2);", @@ -131,6 +132,7 @@ public override void Save_replaced_principal() @"@p1='78' @p0='New Category' (Size = 4000) +SET IMPLICIT_TRANSACTIONS OFF; SET NOCOUNT ON; UPDATE [Categories] SET [Name] = @p0 WHERE [Id] = @p1; diff --git a/test/EFCore.SqlServer.Tests/Migrations/SqlServerMigrationsAnnotationProviderTest.cs b/test/EFCore.SqlServer.Tests/Migrations/SqlServerMigrationsAnnotationProviderTest.cs index 29435d3d7b4..2bba0d466df 100644 --- a/test/EFCore.SqlServer.Tests/Migrations/SqlServerMigrationsAnnotationProviderTest.cs +++ b/test/EFCore.SqlServer.Tests/Migrations/SqlServerMigrationsAnnotationProviderTest.cs @@ -14,7 +14,7 @@ public SqlServerMigrationsAnnotationProviderTest() _annotations = new SqlServerAnnotationProvider(new RelationalAnnotationProviderDependencies()); } - [Fact] + [ConditionalFact] public void For_property_handles_identity_annotations() { var modelBuilder = SqlServerTestHelpers.Instance.CreateConventionBuilder(); diff --git a/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerModelBuilderGenericTest.cs b/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerModelBuilderGenericTest.cs index 666b2d1f06e..d0fe859cf83 100644 --- a/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerModelBuilderGenericTest.cs +++ b/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerModelBuilderGenericTest.cs @@ -639,7 +639,13 @@ public virtual void Owned_types_can_be_mapped_to_different_tables() modelBuilder.Entity( bb => { - bb.ToTable("BT", "BS", t => t.ExcludeFromMigrations()); + bb.ToTable("BT", "BS", t => + { + t.ExcludeFromMigrations(); + + Assert.Equal("BT", t.Name); + Assert.Equal("BS", t.Schema); + }); bb.OwnsOne( b => b.AlternateLabel, tb => { @@ -652,7 +658,13 @@ public virtual void Owned_types_can_be_mapped_to_different_tables() l => l.AnotherBookLabel, ab => { ab.Ignore(l => l.Book); - ab.ToTable("AT1", "AS1", t => t.ExcludeFromMigrations(false)); + ab.ToTable("AT1", "AS1", t => + { + t.ExcludeFromMigrations(false); + + Assert.Equal("AT1", t.Name); + Assert.Equal("AS1", t.Schema); + }); ab.OwnsOne(s => s.SpecialBookLabel) .ToTable("ST11", "SS11") .Ignore(l => l.Book) @@ -934,7 +946,12 @@ public virtual void Temporal_table_default_settings() var modelBuilder = CreateModelBuilder(); var model = modelBuilder.Model; - modelBuilder.Entity().ToTable(tb => tb.IsTemporal()); + modelBuilder.Entity().ToTable(tb => + { + tb.IsTemporal(); + Assert.Null(tb.Name); + Assert.Null(tb.Schema); + }); modelBuilder.FinalizeModel(); var entity = model.FindEntityType(typeof(Customer)); @@ -1251,7 +1268,6 @@ public abstract class TestTemporalTableBuilder where TEntity : class { public abstract TestTemporalTableBuilder UseHistoryTable(string name, string schema); - public abstract TestTemporalPeriodPropertyBuilder HasPeriodStart(string propertyName); public abstract TestTemporalPeriodPropertyBuilder HasPeriodEnd(string propertyName); } @@ -1265,9 +1281,9 @@ public GenericTestTemporalTableBuilder(TemporalTableBuilder temporalTab TemporalTableBuilder = temporalTableBuilder; } - protected TemporalTableBuilder TemporalTableBuilder { get; } + private TemporalTableBuilder TemporalTableBuilder { get; } - public TemporalTableBuilder Instance + TemporalTableBuilder IInfrastructure>.Instance => TemporalTableBuilder; protected virtual TestTemporalTableBuilder Wrap(TemporalTableBuilder tableBuilder) @@ -1291,9 +1307,9 @@ public NonGenericTestTemporalTableBuilder(TemporalTableBuilder temporalTableBuil TemporalTableBuilder = temporalTableBuilder; } - protected TemporalTableBuilder TemporalTableBuilder { get; } + private TemporalTableBuilder TemporalTableBuilder { get; } - public TemporalTableBuilder Instance + TemporalTableBuilder IInfrastructure.Instance => TemporalTableBuilder; protected virtual TestTemporalTableBuilder Wrap(TemporalTableBuilder temporalTableBuilder) diff --git a/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerTestModelBuilderExtensions.cs b/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerTestModelBuilderExtensions.cs index c0102a8eb0e..030428c099a 100644 --- a/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerTestModelBuilderExtensions.cs +++ b/test/EFCore.SqlServer.Tests/ModelBuilding/SqlServerTestModelBuilderExtensions.cs @@ -44,25 +44,6 @@ public static ModelBuilderTest.TestIndexBuilder IsClustered( return builder; } - public static ModelBuilderTest.TestEntityTypeBuilder ToTable( - this ModelBuilderTest.TestEntityTypeBuilder builder, - Action> buildAction) - where TEntity : class - { - switch (builder) - { - case IInfrastructure> genericBuilder: - genericBuilder.Instance.ToTable(b => buildAction(new RelationalModelBuilderTest.GenericTestTableBuilder(b))); - break; - case IInfrastructure nongenericBuilder: - nongenericBuilder.Instance.ToTable( - b => buildAction(new RelationalModelBuilderTest.NonGenericTestTableBuilder(b))); - break; - } - - return builder; - } - public static RelationalModelBuilderTest.TestTableBuilder IsTemporal( this RelationalModelBuilderTest.TestTableBuilder builder, bool temporal = true) diff --git a/test/EFCore.SqlServer.Tests/SqlServerMigrationBuilderTest.cs b/test/EFCore.SqlServer.Tests/SqlServerMigrationBuilderTest.cs index 8b5c70d0986..3b0b3a26e76 100644 --- a/test/EFCore.SqlServer.Tests/SqlServerMigrationBuilderTest.cs +++ b/test/EFCore.SqlServer.Tests/SqlServerMigrationBuilderTest.cs @@ -5,14 +5,14 @@ namespace Microsoft.EntityFrameworkCore.SqlServer.Tests; public class SqlServerMigrationBuilderTest { - [Fact] + [ConditionalFact] public void IsSqlServer_when_using_SqlServer() { var migrationBuilder = new MigrationBuilder("Microsoft.EntityFrameworkCore.SqlServer"); Assert.True(migrationBuilder.IsSqlServer()); } - [Fact] + [ConditionalFact] public void Not_IsSqlServer_when_using_different_provider() { var migrationBuilder = new MigrationBuilder("Microsoft.EntityFrameworkCore.InMemory"); diff --git a/test/EFCore.Sqlite.FunctionalTests/Update/StoreValueGenerationSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Update/StoreValueGenerationSqliteTest.cs new file mode 100644 index 00000000000..0904be07d53 --- /dev/null +++ b/test/EFCore.Sqlite.FunctionalTests/Update/StoreValueGenerationSqliteTest.cs @@ -0,0 +1,370 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Update; + +#nullable enable + +public class StoreValueGenerationSqliteTest : StoreValueGenerationTestBase< + StoreValueGenerationSqliteTest.StoreValueGenerationSqliteFixture> +{ + public StoreValueGenerationSqliteTest(StoreValueGenerationSqliteFixture fixture, ITestOutputHelper testOutputHelper) + : base(fixture) + { + fixture.TestSqlLoggerFactory.Clear(); + fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); + } + + protected override int ShouldExecuteInNumberOfCommands( + EntityState firstOperationType, + EntityState? secondOperationType, + GeneratedValues generatedValues, + bool withDatabaseGenerated) + => secondOperationType is null ? 1 : 2; + + #region Single operation + + public override async Task Add_with_generated_values(bool async) + { + await base.Add_with_generated_values(async); + + AssertSql( + @"@p0='1000' + +INSERT INTO ""WithSomeDatabaseGenerated"" (""Data2"") +VALUES (@p0); +SELECT ""Id"", ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();"); + } + + public override async Task Add_with_no_generated_values(bool async) + { + await base.Add_with_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +INSERT INTO ""WithNoDatabaseGenerated"" (""Id"", ""Data1"", ""Data2"") +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_with_all_generated_values(bool async) + { + await base.Add_with_all_generated_values(async); + + AssertSql( + @"INSERT INTO ""WithAllDatabaseGenerated"" +DEFAULT VALUES; +SELECT ""Id"", ""Data1"", ""Data2"" +FROM ""WithAllDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();"); + } + + public override async Task Modify_with_generated_values(bool async) + { + await base.Modify_with_generated_values(async); + + AssertSql( + @"@p1='1' +@p0='1000' + +UPDATE ""WithSomeDatabaseGenerated"" SET ""Data2"" = @p0 +WHERE ""Id"" = @p1; +SELECT ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""Id"" = @p1;"); + } + + public override async Task Modify_with_no_generated_values(bool async) + { + await base.Modify_with_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +UPDATE ""WithNoDatabaseGenerated"" SET ""Data1"" = @p0, ""Data2"" = @p1 +WHERE ""Id"" = @p2; +SELECT changes();"); + } + + public override async Task Delete(bool async) + { + await base.Delete(async); + + AssertSql( + @"@p0='1' + +DELETE FROM ""WithSomeDatabaseGenerated"" +WHERE ""Id"" = @p0; +SELECT changes();"); + } + + #endregion Single operation + + #region Two operations with same entity type + + public override async Task Add_Add_with_same_entity_type_and_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_generated_values(async); + + AssertSql( + @"@p0='1000' + +INSERT INTO ""WithSomeDatabaseGenerated"" (""Data2"") +VALUES (@p0); +SELECT ""Id"", ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();", + // + @"@p0='1001' + +INSERT INTO ""WithSomeDatabaseGenerated"" (""Data2"") +VALUES (@p0); +SELECT ""Id"", ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();"); + } + + public override async Task Add_Add_with_same_entity_type_and_no_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +INSERT INTO ""WithNoDatabaseGenerated"" (""Id"", ""Data1"", ""Data2"") +VALUES (@p0, @p1, @p2);", + // + @"@p0='101' +@p1='1001' +@p2='1001' + +INSERT INTO ""WithNoDatabaseGenerated"" (""Id"", ""Data1"", ""Data2"") +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_Add_with_same_entity_type_and_all_generated_values(bool async) + { + await base.Add_Add_with_same_entity_type_and_all_generated_values(async); + + AssertSql( + @"INSERT INTO ""WithAllDatabaseGenerated"" +DEFAULT VALUES; +SELECT ""Id"", ""Data1"", ""Data2"" +FROM ""WithAllDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();", + // + @"INSERT INTO ""WithAllDatabaseGenerated"" +DEFAULT VALUES; +SELECT ""Id"", ""Data1"", ""Data2"" +FROM ""WithAllDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();"); + } + + public override async Task Modify_Modify_with_same_entity_type_and_generated_values(bool async) + { + await base.Modify_Modify_with_same_entity_type_and_generated_values(async); + + AssertSql( + @"@p1='1' +@p0='1000' + +UPDATE ""WithSomeDatabaseGenerated"" SET ""Data2"" = @p0 +WHERE ""Id"" = @p1; +SELECT ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""Id"" = @p1;", + // + @"@p1='2' +@p0='1001' + +UPDATE ""WithSomeDatabaseGenerated"" SET ""Data2"" = @p0 +WHERE ""Id"" = @p1; +SELECT ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""Id"" = @p1;"); + } + + public override async Task Modify_Modify_with_same_entity_type_and_no_generated_values(bool async) + { + await base.Modify_Modify_with_same_entity_type_and_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +UPDATE ""WithNoDatabaseGenerated"" SET ""Data1"" = @p0, ""Data2"" = @p1 +WHERE ""Id"" = @p2; +SELECT changes();", + // + @"@p2='2' +@p0='1001' +@p1='1001' + +UPDATE ""WithNoDatabaseGenerated"" SET ""Data1"" = @p0, ""Data2"" = @p1 +WHERE ""Id"" = @p2; +SELECT changes();"); + } + + public override async Task Delete_Delete_with_same_entity_type(bool async) + { + await base.Delete_Delete_with_same_entity_type(async); + + AssertSql( + @"@p0='1' + +DELETE FROM ""WithSomeDatabaseGenerated"" +WHERE ""Id"" = @p0; +SELECT changes();", + // + @"@p0='2' + +DELETE FROM ""WithSomeDatabaseGenerated"" +WHERE ""Id"" = @p0; +SELECT changes();"); + } + + #endregion Two operations with same entity type + + #region Two operations with different entity types + + public override async Task Add_Add_with_different_entity_types_and_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_generated_values(async); + + AssertSql( + @"@p0='1000' + +INSERT INTO ""WithSomeDatabaseGenerated"" (""Data2"") +VALUES (@p0); +SELECT ""Id"", ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();", + // + @"@p0='1001' + +INSERT INTO ""WithSomeDatabaseGenerated2"" (""Data2"") +VALUES (@p0); +SELECT ""Id"", ""Data1"" +FROM ""WithSomeDatabaseGenerated2"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();"); + } + + public override async Task Add_Add_with_different_entity_types_and_no_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_no_generated_values(async); + + AssertSql( + @"@p0='100' +@p1='1000' +@p2='1000' + +INSERT INTO ""WithNoDatabaseGenerated"" (""Id"", ""Data1"", ""Data2"") +VALUES (@p0, @p1, @p2);", + // + @"@p0='101' +@p1='1001' +@p2='1001' + +INSERT INTO ""WithNoDatabaseGenerated2"" (""Id"", ""Data1"", ""Data2"") +VALUES (@p0, @p1, @p2);"); + } + + public override async Task Add_Add_with_different_entity_types_and_all_generated_values(bool async) + { + await base.Add_Add_with_different_entity_types_and_all_generated_values(async); + + AssertSql( + @"INSERT INTO ""WithAllDatabaseGenerated"" +DEFAULT VALUES; +SELECT ""Id"", ""Data1"", ""Data2"" +FROM ""WithAllDatabaseGenerated"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();", + // + @"INSERT INTO ""WithAllDatabaseGenerated2"" +DEFAULT VALUES; +SELECT ""Id"", ""Data1"", ""Data2"" +FROM ""WithAllDatabaseGenerated2"" +WHERE changes() = 1 AND ""rowid"" = last_insert_rowid();"); + } + + public override async Task Modify_Modify_with_different_entity_types_and_generated_values(bool async) + { + await base.Modify_Modify_with_different_entity_types_and_generated_values(async); + + AssertSql( + @"@p1='1' +@p0='1000' + +UPDATE ""WithSomeDatabaseGenerated"" SET ""Data2"" = @p0 +WHERE ""Id"" = @p1; +SELECT ""Data1"" +FROM ""WithSomeDatabaseGenerated"" +WHERE changes() = 1 AND ""Id"" = @p1;", + // + @"@p1='2' +@p0='1001' + +UPDATE ""WithSomeDatabaseGenerated2"" SET ""Data2"" = @p0 +WHERE ""Id"" = @p1; +SELECT ""Data1"" +FROM ""WithSomeDatabaseGenerated2"" +WHERE changes() = 1 AND ""Id"" = @p1;"); + } + + public override async Task Modify_Modify_with_different_entity_types_and_no_generated_values(bool async) + { + await base.Modify_Modify_with_different_entity_types_and_no_generated_values(async); + + AssertSql( + @"@p2='1' +@p0='1000' +@p1='1000' + +UPDATE ""WithNoDatabaseGenerated"" SET ""Data1"" = @p0, ""Data2"" = @p1 +WHERE ""Id"" = @p2; +SELECT changes();", + // + @"@p2='2' +@p0='1001' +@p1='1001' + +UPDATE ""WithNoDatabaseGenerated2"" SET ""Data1"" = @p0, ""Data2"" = @p1 +WHERE ""Id"" = @p2; +SELECT changes();"); + } + + public override async Task Delete_Delete_with_different_entity_types(bool async) + { + await base.Delete_Delete_with_different_entity_types(async); + + AssertSql( + @"@p0='1' + +DELETE FROM ""WithSomeDatabaseGenerated"" +WHERE ""Id"" = @p0; +SELECT changes();", + // + @"@p0='2' + +DELETE FROM ""WithSomeDatabaseGenerated2"" +WHERE ""Id"" = @p0; +SELECT changes();"); + } + + #endregion Two operations with different entity types + + public class StoreValueGenerationSqliteFixture : StoreValueGenerationFixtureBase + { + protected override ITestStoreFactory TestStoreFactory + => SqliteTestStoreFactory.Instance; + } +} diff --git a/test/EFCore.Sqlite.Tests/Migrations/SqliteMigrationBuilderTest.cs b/test/EFCore.Sqlite.Tests/Migrations/SqliteMigrationBuilderTest.cs index 84305972b12..be15dfbe848 100644 --- a/test/EFCore.Sqlite.Tests/Migrations/SqliteMigrationBuilderTest.cs +++ b/test/EFCore.Sqlite.Tests/Migrations/SqliteMigrationBuilderTest.cs @@ -5,14 +5,14 @@ namespace Microsoft.EntityFrameworkCore.Migrations; public class SqliteMigrationBuilderTest { - [Fact] + [ConditionalFact] public void IsSqlite_when_using_Sqlite() { var migrationBuilder = new MigrationBuilder("Microsoft.EntityFrameworkCore.Sqlite"); Assert.True(migrationBuilder.IsSqlite()); } - [Fact] + [ConditionalFact] public void Not_IsSqlite_when_using_different_provider() { var migrationBuilder = new MigrationBuilder("Microsoft.EntityFrameworkCore.InMemory"); diff --git a/test/EFCore.Sqlite.Tests/Storage/SqliteRelationalConnectionTest.cs b/test/EFCore.Sqlite.Tests/Storage/SqliteRelationalConnectionTest.cs index a370d1088ef..061f914ef25 100644 --- a/test/EFCore.Sqlite.Tests/Storage/SqliteRelationalConnectionTest.cs +++ b/test/EFCore.Sqlite.Tests/Storage/SqliteRelationalConnectionTest.cs @@ -7,7 +7,7 @@ namespace Microsoft.EntityFrameworkCore.Storage; public class SqliteRelationalConnectionTest { - [Fact] + [ConditionalFact] public void Sets_DefaultTimeout_when_connectionString() { var services = SqliteTestHelpers.Instance.CreateContextServices( @@ -20,7 +20,7 @@ public void Sets_DefaultTimeout_when_connectionString() Assert.Equal(42, connection.DefaultTimeout); } - [Fact] + [ConditionalFact] public void Sets_DefaultTimeout_when_connection() { var originalConnection = new SqliteConnection("Data Source=:memory:") { DefaultTimeout = 21 }; diff --git a/test/EFCore.Tests/DbSetTest.cs b/test/EFCore.Tests/DbSetTest.cs index 1f08319ada9..11e5847a297 100644 --- a/test/EFCore.Tests/DbSetTest.cs +++ b/test/EFCore.Tests/DbSetTest.cs @@ -133,7 +133,7 @@ public void Direct_use_of_Set_throws_if_context_disposed() Assert.Throws(() => context.Set()).Message); } - [Fact] + [ConditionalFact] public void Direct_use_of_Set_for_shared_type_throws_if_context_disposed() { var context = new EarlyLearningCenter();