diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9264ac97d22..e5f7b03613c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -17,6 +17,7 @@ updates: - dependency-name: Microsoft.CSharp - dependency-name: Microsoft.Data.SqlClient - dependency-name: Microsoft.DotNet.PlatformAbstractions + - dependency-name: Microsoft.SqlServer.Types - dependency-name: mod_spatialite - dependency-name: Mono.TextTemplating - dependency-name: NetTopologySuite* diff --git a/All.sln b/All.sln index dcb613bda12..07a3e2b33ea 100644 --- a/All.sln +++ b/All.sln @@ -125,6 +125,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EFCore.Trimming.Tests", "te EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EFCore.Templates", "src\EFCore.Templates\EFCore.Templates.csproj", "{1FE385D8-8F8B-4EC9-A1A9-AFCC38B8546C}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EFCore.SqlServer.HierarchyId", "src\EFCore.SqlServer.HierarchyId\EFCore.SqlServer.HierarchyId.csproj", "{8F722A02-71A4-4787-ACD8-FB7D5B7AE648}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EFCore.SqlServer.HierarchyId.Tests", "test\EFCore.SqlServer.HierarchyId.Tests\EFCore.SqlServer.HierarchyId.Tests.csproj", "{01F86E65-6448-424C-AAB5-9C6427EF6FD4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EFCore.SqlServer.Abstractions", "src\EFCore.SqlServer.Abstractions\EFCore.SqlServer.Abstractions.csproj", "{3D935B7D-80BD-49AD-BDC9-E1B0C9D9494F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -331,6 +337,18 @@ Global {1FE385D8-8F8B-4EC9-A1A9-AFCC38B8546C}.Debug|Any CPU.Build.0 = Debug|Any CPU {1FE385D8-8F8B-4EC9-A1A9-AFCC38B8546C}.Release|Any CPU.ActiveCfg = Release|Any CPU {1FE385D8-8F8B-4EC9-A1A9-AFCC38B8546C}.Release|Any CPU.Build.0 = Release|Any CPU + {8F722A02-71A4-4787-ACD8-FB7D5B7AE648}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F722A02-71A4-4787-ACD8-FB7D5B7AE648}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F722A02-71A4-4787-ACD8-FB7D5B7AE648}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F722A02-71A4-4787-ACD8-FB7D5B7AE648}.Release|Any CPU.Build.0 = Release|Any CPU + {01F86E65-6448-424C-AAB5-9C6427EF6FD4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {01F86E65-6448-424C-AAB5-9C6427EF6FD4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {01F86E65-6448-424C-AAB5-9C6427EF6FD4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {01F86E65-6448-424C-AAB5-9C6427EF6FD4}.Release|Any CPU.Build.0 = Release|Any CPU + {3D935B7D-80BD-49AD-BDC9-E1B0C9D9494F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3D935B7D-80BD-49AD-BDC9-E1B0C9D9494F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3D935B7D-80BD-49AD-BDC9-E1B0C9D9494F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3D935B7D-80BD-49AD-BDC9-E1B0C9D9494F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -386,6 +404,9 @@ Global {F1B2E5A0-8C74-414A-B262-353FEE325E9F} = {258D5057-81B9-40EC-A872-D21E27452749} {933C8662-817C-4F45-B98B-6557E28F7BB1} = {258D5057-81B9-40EC-A872-D21E27452749} {1FE385D8-8F8B-4EC9-A1A9-AFCC38B8546C} = {CE6B50B2-34AE-44C9-940A-4E48C3E1B3BC} + {8F722A02-71A4-4787-ACD8-FB7D5B7AE648} = {CE6B50B2-34AE-44C9-940A-4E48C3E1B3BC} + {01F86E65-6448-424C-AAB5-9C6427EF6FD4} = {258D5057-81B9-40EC-A872-D21E27452749} + {3D935B7D-80BD-49AD-BDC9-E1B0C9D9494F} = {CE6B50B2-34AE-44C9-940A-4E48C3E1B3BC} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {285A5EB4-BCF4-40EB-B9E1-DF6DBCB5E705} diff --git a/src/EFCore.SqlServer.Abstractions/EFCore.SqlServer.Abstractions.csproj b/src/EFCore.SqlServer.Abstractions/EFCore.SqlServer.Abstractions.csproj new file mode 100644 index 00000000000..ae4835d4b17 --- /dev/null +++ b/src/EFCore.SqlServer.Abstractions/EFCore.SqlServer.Abstractions.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.0 + EntityFrameworkCore.SqlServer.HierarchyId.Abstractions + Microsoft.EntityFrameworkCore + Common abstractions for using hierarchyid with EF Core + true + true + + + + + + + diff --git a/src/EFCore.SqlServer.Abstractions/HierarchyId.cs b/src/EFCore.SqlServer.Abstractions/HierarchyId.cs new file mode 100644 index 00000000000..67b4084361c --- /dev/null +++ b/src/EFCore.SqlServer.Abstractions/HierarchyId.cs @@ -0,0 +1,217 @@ +using System; +using System.IO; +using Microsoft.SqlServer.Types; + +namespace Microsoft.EntityFrameworkCore +{ + /// + /// Represents a position in a hierarchical structure, specifying depth and breadth. + /// + public class HierarchyId : IComparable + { + private SqlHierarchyId _value; + + private HierarchyId(SqlHierarchyId value) + { + if (value.IsNull) + throw new ArgumentNullException(nameof(value)); + + _value = value; + } + + /// + /// Gets the root node of the hierarchy. + /// + /// The root node of the hierarchy. + public static HierarchyId GetRoot() + => new HierarchyId(SqlHierarchyId.GetRoot()); + + /// + /// Converts the canonical string representation of a node to a value. + /// + /// The string representation of a node. + /// A value. + public static HierarchyId Parse(string input) + => Wrap(SqlHierarchyId.Parse(input)); + + /// + /// Reads a value from the specified reader. + /// + /// The reader. + /// A value. + public static HierarchyId Read(BinaryReader reader) + { + var hid = new SqlHierarchyId(); + hid.Read(reader); + return Wrap(hid); + } + + /// + /// Writes this value to the specified writer. + /// + /// The writer. + public void Write(BinaryWriter writer) + { + _value.Write(writer); + } + + /// + public int CompareTo(object obj) + => _value.CompareTo( + obj is HierarchyId other + ? other._value + : obj); + + /// + public override bool Equals(object obj) + => _value.Equals( + obj is HierarchyId other + ? other._value + : obj); + + /// + /// Gets the node levels up the hierarchical tree. + /// + /// The number of levels to ascend in the hierarchy. + /// A value representing the th ancestor of this node or null if is greater than . + /// is negative. + public HierarchyId GetAncestor(int n) + => Wrap(_value.GetAncestor(n)); + + /// + /// Gets the value of a descendant node that is greater than and less than . + /// + /// The lower bound. + /// The upper bound. + /// A value. + public HierarchyId GetDescendant(HierarchyId child1, HierarchyId child2) + => Wrap(_value.GetDescendant(Unwrap(child1), Unwrap(child2))); + + /// + public override int GetHashCode() + => _value.GetHashCode(); + + /// + /// Gets the level of this node in the hierarchical tree. + /// + /// The depth of this node. The root node is level 0. + public short GetLevel() + => _value.GetLevel().Value; + + /// + /// Gets a value representing the location of a new node that has a path from equal to the path from to this, effectively moving this to the new location. + /// + /// An ancestor of this node specifying the endpoint of the path segment to be moved. + /// The node that represents the new ancestor. + /// A value or null if or is null. + public HierarchyId GetReparentedValue(HierarchyId oldRoot, HierarchyId newRoot) + => Wrap(_value.GetReparentedValue(Unwrap(oldRoot), Unwrap(newRoot))); + + /// + /// Gets a value indicating whether this node is a descendant of . + /// + /// The parent to test against. + /// True if this node is in the sub-tree rooted at ; otherwise false. + public bool IsDescendantOf(HierarchyId parent) + { + if (parent == null) + return false; + + return _value.IsDescendantOf(parent._value).Value; + } + + /// + public override string ToString() + => _value.ToString(); + + /// + /// Evaluates whether two nodes are equal. + /// + /// The first node to compare. + /// The second node to compare. + /// True if and are equal; otherwise, false. + public static bool operator ==(HierarchyId hid1, HierarchyId hid2) + { + var sh1 = Unwrap(hid1); + var sh2 = Unwrap(hid2); + + return sh1.IsNull == sh2.IsNull && sh1.CompareTo(sh2) == 0; + } + + /// + /// Evaluates whether two nodes are unequal. + /// + /// The first node to compare. + /// The second node to compare. + /// True if and are unequal; otherwise, false. + public static bool operator !=(HierarchyId hid1, HierarchyId hid2) + { + var sh1 = Unwrap(hid1); + var sh2 = Unwrap(hid2); + + return sh1.IsNull != sh2.IsNull || sh1.CompareTo(sh2) != 0; + } + + /// + /// Evaluates whether one node is less than another. + /// + /// The first node to compare. + /// The second node to compare. + /// True if is less than ; otherwise, false. + public static bool operator <(HierarchyId hid1, HierarchyId hid2) + { + var sh1 = Unwrap(hid1); + var sh2 = Unwrap(hid2); + + return !sh1.IsNull && !sh2.IsNull && sh1.CompareTo(sh2) < 0; + } + + /// + /// Evaluates whether one node is greater than another. + /// + /// The first node to compare. + /// The second node to compare. + /// True if is greater than ; otherwise, false. + public static bool operator >(HierarchyId hid1, HierarchyId hid2) + { + var sh1 = Unwrap(hid1); + var sh2 = Unwrap(hid2); + + return !sh1.IsNull && !sh2.IsNull && sh1.CompareTo(sh2) > 0; + } + + /// + /// Evaluates whether one node is less than or equal to another. + /// + /// The first node to compare. + /// The second node to compare. + /// True if is less than or equal to ; otherwise, false. + public static bool operator <=(HierarchyId hid1, HierarchyId hid2) + { + var sh1 = Unwrap(hid1); + var sh2 = Unwrap(hid2); + + return !sh1.IsNull && !sh2.IsNull && sh1.CompareTo(sh2) <= 0; + } + + /// + /// Evaluates whether one node is greater than or equal to another. + /// + /// The first node to compare. + /// The second node to compare. + /// True if is greater than or equal to ; otherwise, false. + public static bool operator >=(HierarchyId hid1, HierarchyId hid2) + { + var sh1 = Unwrap(hid1); + var sh2 = Unwrap(hid2); + + return !sh1.IsNull && !sh2.IsNull && sh1.CompareTo(sh2) >= 0; + } + + private static SqlHierarchyId Unwrap(HierarchyId value) + => value?._value ?? SqlHierarchyId.Null; + + private static HierarchyId Wrap(SqlHierarchyId value) + => value.IsNull ? null : new HierarchyId(value); + } +} \ No newline at end of file diff --git a/src/EFCore.SqlServer.HierarchyId/Design/SqlServerHierarchyIdDesignTimeServices.cs b/src/EFCore.SqlServer.HierarchyId/Design/SqlServerHierarchyIdDesignTimeServices.cs new file mode 100644 index 00000000000..9cfe4fbb710 --- /dev/null +++ b/src/EFCore.SqlServer.HierarchyId/Design/SqlServerHierarchyIdDesignTimeServices.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.EntityFrameworkCore.SqlServer.Scaffolding; +using Microsoft.EntityFrameworkCore.SqlServer.Storage; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Design +{ + /// + /// Enables configuring design-time services. Tools will automatically discover implementations of this + /// interface that are in the startup assembly. + /// + public class SqlServerHierarchyIdDesignTimeServices : IDesignTimeServices + { + /// + /// Configures design-time services. Use this method to override the default design-time services with your + /// own implementations. + /// + /// The design-time service collection. + public virtual void ConfigureDesignTimeServices(IServiceCollection serviceCollection) + { + serviceCollection + .AddSingleton() + .AddSingleton(); + } + } +} diff --git a/src/EFCore.SqlServer.HierarchyId/EFCore.SqlServer.HierarchyId.csproj b/src/EFCore.SqlServer.HierarchyId/EFCore.SqlServer.HierarchyId.csproj new file mode 100644 index 00000000000..5fe27346456 --- /dev/null +++ b/src/EFCore.SqlServer.HierarchyId/EFCore.SqlServer.HierarchyId.csproj @@ -0,0 +1,42 @@ + + + + net6.0 + EntityFrameworkCore.SqlServer.HierarchyId + Microsoft.EntityFrameworkCore.SqlServer + Adds hierarchyid support to the SQL Server EF Core provider + true + true + + + + + True + build + + + + + + + + + + + + + + True + True + Resources.resx + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + diff --git a/src/EFCore.SqlServer.HierarchyId/Extensions/SqlServerHierarchyIdDbContextOptionsBuilderExtensions.cs b/src/EFCore.SqlServer.HierarchyId/Extensions/SqlServerHierarchyIdDbContextOptionsBuilderExtensions.cs new file mode 100644 index 00000000000..d4f926e8623 --- /dev/null +++ b/src/EFCore.SqlServer.HierarchyId/Extensions/SqlServerHierarchyIdDbContextOptionsBuilderExtensions.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.SqlServer.Infrastructure; + +namespace Microsoft.EntityFrameworkCore +{ + /// + /// HierarchyId specific extension methods for . + /// + public static class SqlServerHierarchyIdDbContextOptionsBuilderExtensions + { + /// + /// Enable HierarchyId mappings. + /// + /// The builder being used to configure SQL Server. + /// The options builder so that further configuration can be chained. + public static SqlServerDbContextOptionsBuilder UseHierarchyId( + this SqlServerDbContextOptionsBuilder optionsBuilder) + { + var coreOptionsBuilder = ((IRelationalDbContextOptionsBuilderInfrastructure)optionsBuilder).OptionsBuilder; + + var extension = coreOptionsBuilder.Options.FindExtension() + ?? new SqlServerHierarchyIdOptionsExtension(); + + ((IDbContextOptionsBuilderInfrastructure)coreOptionsBuilder).AddOrUpdateExtension(extension); + + return optionsBuilder; + } + } +} diff --git a/src/EFCore.SqlServer.HierarchyId/Extensions/SqlServerHierarchyIdServiceCollectionExtensions.cs b/src/EFCore.SqlServer.HierarchyId/Extensions/SqlServerHierarchyIdServiceCollectionExtensions.cs new file mode 100644 index 00000000000..77df6399117 --- /dev/null +++ b/src/EFCore.SqlServer.HierarchyId/Extensions/SqlServerHierarchyIdServiceCollectionExtensions.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.SqlServer.Query.ExpressionTranslators; +using Microsoft.EntityFrameworkCore.SqlServer.Storage; +using Microsoft.EntityFrameworkCore.Storage; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// EntityFrameworkCore.SqlServer.HierarchyId extension methods for . + /// + public static class SqlServerHierarchyIdServiceCollectionExtensions + { + /// + /// Adds the services required for HierarchyId support in the SQL Server provider for Entity Framework. + /// + /// The to add services to. + /// The same service collection so that multiple calls can be chained. + public static IServiceCollection AddEntityFrameworkSqlServerHierarchyId( + this IServiceCollection serviceCollection) + { + new EntityFrameworkRelationalServicesBuilder(serviceCollection) + .TryAdd() + .TryAdd(); + + return serviceCollection; + } + } +} diff --git a/src/EFCore.SqlServer.HierarchyId/Infrastructure/SqlServerHierarchyIdOptionsExtension.cs b/src/EFCore.SqlServer.HierarchyId/Infrastructure/SqlServerHierarchyIdOptionsExtension.cs new file mode 100644 index 00000000000..1fdbd5265cc --- /dev/null +++ b/src/EFCore.SqlServer.HierarchyId/Infrastructure/SqlServerHierarchyIdOptionsExtension.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.SqlServer.Properties; +using Microsoft.EntityFrameworkCore.SqlServer.Query.ExpressionTranslators; +using Microsoft.EntityFrameworkCore.SqlServer.Storage; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Infrastructure +{ + internal class SqlServerHierarchyIdOptionsExtension : IDbContextOptionsExtension + { + private DbContextOptionsExtensionInfo _info; + + public DbContextOptionsExtensionInfo Info => _info ??= new ExtensionInfo(this); + + public virtual void ApplyServices(IServiceCollection services) + { + services.AddEntityFrameworkSqlServerHierarchyId(); + } + + public virtual void Validate(IDbContextOptions options) + { + var internalServiceProvider = options.FindExtension()?.InternalServiceProvider; + if (internalServiceProvider != null) + { + using (var scope = internalServiceProvider.CreateScope()) + { + if (scope.ServiceProvider.GetService>() + ?.Any(s => s is SqlServerHierarchyIdMethodCallTranslatorPlugin) != true || + scope.ServiceProvider.GetService>() + ?.Any(s => s is SqlServerHierarchyIdTypeMappingSourcePlugin) != true) + { + throw new InvalidOperationException(Resources.ServicesMissing); + } + } + } + } + + private sealed class ExtensionInfo : DbContextOptionsExtensionInfo + { + public ExtensionInfo(IDbContextOptionsExtension extension) + : base(extension) + { + } + + private new SqlServerHierarchyIdOptionsExtension Extension + => (SqlServerHierarchyIdOptionsExtension)base.Extension; + + public override bool IsDatabaseProvider => false; + + public override int GetServiceProviderHashCode() => 0; + + public override bool ShouldUseSameServiceProvider(DbContextOptionsExtensionInfo other) + => other is ExtensionInfo; + + public override void PopulateDebugInfo(IDictionary debugInfo) + => debugInfo["SqlServer:" + nameof(SqlServerHierarchyIdDbContextOptionsBuilderExtensions.UseHierarchyId)] = "1"; + + public override string LogFragment => "using HierarchyId "; + } + } +} diff --git a/src/EFCore.SqlServer.HierarchyId/Properties/InternalsVisibleTo.cs b/src/EFCore.SqlServer.HierarchyId/Properties/InternalsVisibleTo.cs new file mode 100644 index 00000000000..5fd03cebc42 --- /dev/null +++ b/src/EFCore.SqlServer.HierarchyId/Properties/InternalsVisibleTo.cs @@ -0,0 +1,4 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo( + "EntityFrameworkCore.SqlServer.HierarchyId.Test, PublicKey=00240000048000009400000006020000002400005253413100040000010001006d92138307b5e251bf4918cf751dc83489f4b2d70ac15b04110fd1f78491fe93719d0cd464d103a95fb1c3b1cb21ce0033c94c6f52b325d36360736dea7571bd1074cb2c937cf4fc54526ceb44271c4f44753dbeb5d9b364e4dc57a8988542d3a7edb6575bc35ce7670612bd8f00f2c6899f3e74bd563810fa45f4c5c8b51cd3")] diff --git a/src/EFCore.SqlServer.HierarchyId/Properties/Resources.Designer.cs b/src/EFCore.SqlServer.HierarchyId/Properties/Resources.Designer.cs new file mode 100644 index 00000000000..2dd653ff2b1 --- /dev/null +++ b/src/EFCore.SqlServer.HierarchyId/Properties/Resources.Designer.cs @@ -0,0 +1,72 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.EntityFrameworkCore.SqlServer.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.EntityFrameworkCore.SqlServer.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to UseHierarchyId requires AddEntityFrameworkSqlServerHierarchyId to be called on the internal service provider used.. + /// + internal static string ServicesMissing { + get { + return ResourceManager.GetString("ServicesMissing", resourceCulture); + } + } + } +} diff --git a/src/EFCore.SqlServer.HierarchyId/Properties/Resources.resx b/src/EFCore.SqlServer.HierarchyId/Properties/Resources.resx new file mode 100644 index 00000000000..3f088ff9ecd --- /dev/null +++ b/src/EFCore.SqlServer.HierarchyId/Properties/Resources.resx @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + UseHierarchyId requires AddEntityFrameworkSqlServerHierarchyId to be called on the internal service provider used. + + \ No newline at end of file diff --git a/src/EFCore.SqlServer.HierarchyId/Query/ExpressionTranslators/SqlServerHierarchyIdMethodCallTranslatorPlugin.cs b/src/EFCore.SqlServer.HierarchyId/Query/ExpressionTranslators/SqlServerHierarchyIdMethodCallTranslatorPlugin.cs new file mode 100644 index 00000000000..92ff81eaefa --- /dev/null +++ b/src/EFCore.SqlServer.HierarchyId/Query/ExpressionTranslators/SqlServerHierarchyIdMethodCallTranslatorPlugin.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Storage; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Query.ExpressionTranslators +{ + internal class SqlServerHierarchyIdMethodCallTranslatorPlugin : IMethodCallTranslatorPlugin + { + public SqlServerHierarchyIdMethodCallTranslatorPlugin( + IRelationalTypeMappingSource typeMappingSource, + ISqlExpressionFactory sqlExpressionFactory) + { + Translators = new IMethodCallTranslator[] + { + new SqlServerHierarchyIdMethodTranslator(typeMappingSource, sqlExpressionFactory) + }; + } + + public virtual IEnumerable Translators { get; } + } +} diff --git a/src/EFCore.SqlServer.HierarchyId/Query/ExpressionTranslators/SqlServerHierarchyIdMethodTranslator.cs b/src/EFCore.SqlServer.HierarchyId/Query/ExpressionTranslators/SqlServerHierarchyIdMethodTranslator.cs new file mode 100644 index 00000000000..0b2af7439a6 --- /dev/null +++ b/src/EFCore.SqlServer.HierarchyId/Query/ExpressionTranslators/SqlServerHierarchyIdMethodTranslator.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using Microsoft.EntityFrameworkCore.SqlServer.Storage; +using Microsoft.EntityFrameworkCore.Storage; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Query.ExpressionTranslators +{ + internal class SqlServerHierarchyIdMethodTranslator : IMethodCallTranslator + { + private static readonly IDictionary _methodToFunctionName = new Dictionary + { + // instance methods + { typeof(HierarchyId).GetRuntimeMethod(nameof(HierarchyId.GetAncestor), new[] { typeof(int) }), "GetAncestor" }, + { typeof(HierarchyId).GetRuntimeMethod(nameof(HierarchyId.GetDescendant), new[] { typeof(HierarchyId), typeof(HierarchyId) }), "GetDescendant" }, + { typeof(HierarchyId).GetRuntimeMethod(nameof(HierarchyId.GetLevel), Type.EmptyTypes), "GetLevel" }, + { typeof(HierarchyId).GetRuntimeMethod(nameof(HierarchyId.GetReparentedValue), new[] { typeof(HierarchyId), typeof(HierarchyId) }), "GetReparentedValue" }, + { typeof(HierarchyId).GetRuntimeMethod(nameof(HierarchyId.IsDescendantOf), new[] { typeof(HierarchyId) }), "IsDescendantOf" }, + { typeof(object).GetRuntimeMethod(nameof(HierarchyId.ToString), Type.EmptyTypes), "ToString" }, + + // static methods + { typeof(HierarchyId).GetRuntimeMethod(nameof(HierarchyId.GetRoot), Type.EmptyTypes), "hierarchyid::GetRoot" }, + { typeof(HierarchyId).GetRuntimeMethod(nameof(HierarchyId.Parse), new[] { typeof(string) }), "hierarchyid::Parse" }, + }; + + private readonly IRelationalTypeMappingSource _typeMappingSource; + private readonly ISqlExpressionFactory _sqlExpressionFactory; + + public SqlServerHierarchyIdMethodTranslator( + IRelationalTypeMappingSource typeMappingSource, + ISqlExpressionFactory sqlExpressionFactory) + { + _typeMappingSource = typeMappingSource; + _sqlExpressionFactory = sqlExpressionFactory; + } + + public SqlExpression Translate( + SqlExpression instance, + MethodInfo method, + IReadOnlyList arguments, + IDiagnosticsLogger logger) + { + // instance is null for static methods like Parse + const string storeType = SqlServerHierarchyIdTypeMappingSourcePlugin.SqlServerTypeName; + var callingType = instance?.Type ?? method.DeclaringType; + if (typeof(HierarchyId).IsAssignableFrom(callingType) + && _methodToFunctionName.TryGetValue(method, out var functionName)) + { + var typeMappedArguments = new List(); + foreach (var argument in arguments) + { + var argumentTypeMapping = typeof(HierarchyId).IsAssignableFrom(argument.Type) + ? _typeMappingSource.FindMapping(argument.Type, storeType) + : _typeMappingSource.FindMapping(argument.Type); + var mappedArgument = _sqlExpressionFactory.ApplyTypeMapping(argument, argumentTypeMapping); + typeMappedArguments.Add(mappedArgument); + } + + var resultTypeMapping = typeof(HierarchyId).IsAssignableFrom(method.ReturnType) + ? _typeMappingSource.FindMapping(method.ReturnType, storeType) + : _typeMappingSource.FindMapping(method.ReturnType); + + + if (instance != null) + { + var instanceMapping = _typeMappingSource.FindMapping(instance.Type, storeType); + instance = _sqlExpressionFactory.ApplyTypeMapping(instance, instanceMapping); + + return _sqlExpressionFactory.Function( + instance, + functionName, + simplify(arguments), + nullable: true, + instancePropagatesNullability: true, + argumentsPropagateNullability: arguments.Select(a => true), + method.ReturnType, + resultTypeMapping); + } + + return _sqlExpressionFactory.Function( + functionName, + simplify(arguments), + nullable: true, + argumentsPropagateNullability: arguments.Select(a => true), + method.ReturnType, + resultTypeMapping); + } + + return null; + } + + private IEnumerable simplify(IEnumerable arguments) + { + foreach (var argument in arguments) + { + if (argument is SqlConstantExpression constant + && constant.Value is HierarchyId hierarchyId) + { + yield return _sqlExpressionFactory.Fragment($"'{hierarchyId}'"); + } + else + { + yield return argument; + } + } + } + } +} diff --git a/src/EFCore.SqlServer.HierarchyId/Scaffolding/SqlServerHierarchyIdCodeGeneratorPlugin.cs b/src/EFCore.SqlServer.HierarchyId/Scaffolding/SqlServerHierarchyIdCodeGeneratorPlugin.cs new file mode 100644 index 00000000000..899f13290b5 --- /dev/null +++ b/src/EFCore.SqlServer.HierarchyId/Scaffolding/SqlServerHierarchyIdCodeGeneratorPlugin.cs @@ -0,0 +1,18 @@ +using System.Reflection; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Scaffolding; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Scaffolding +{ + internal class SqlServerHierarchyIdCodeGeneratorPlugin : ProviderCodeGeneratorPlugin + { + public override MethodCallCodeFragment GenerateProviderOptions() + { + return new MethodCallCodeFragment( + typeof(SqlServerHierarchyIdDbContextOptionsBuilderExtensions).GetRuntimeMethod( + nameof(SqlServerHierarchyIdDbContextOptionsBuilderExtensions.UseHierarchyId), + new[] { typeof(SqlServerDbContextOptionsBuilder) })); + } + } +} diff --git a/src/EFCore.SqlServer.HierarchyId/Storage/SqlServerHierarchyIdTypeMapping.cs b/src/EFCore.SqlServer.HierarchyId/Storage/SqlServerHierarchyIdTypeMapping.cs new file mode 100644 index 00000000000..4c08f5d2264 --- /dev/null +++ b/src/EFCore.SqlServer.HierarchyId/Storage/SqlServerHierarchyIdTypeMapping.cs @@ -0,0 +1,155 @@ +using System; +using System.Data; +using System.Data.Common; +using System.Data.SqlTypes; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Threading; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Storage; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Storage +{ + internal class SqlServerHierarchyIdTypeMapping : RelationalTypeMapping + { + private static readonly MethodInfo _getSqlBytes + = typeof(SqlDataReader).GetRuntimeMethod(nameof(SqlDataReader.GetSqlBytes), new[] { typeof(int) }); + + private static readonly MethodInfo _parseHierarchyId + = typeof(HierarchyId).GetRuntimeMethod(nameof(HierarchyId.Parse), new[] { typeof(string) }); + + private static readonly SqlServerHierarchyIdValueConverter _valueConverter = new SqlServerHierarchyIdValueConverter(); + + private static Action _sqlDbTypeSetter; + private static Action _udtTypeNameSetter; + + public SqlServerHierarchyIdTypeMapping(string storeType, Type clrType) + : base(CreateRelationalTypeMappingParameters(storeType, clrType)) + { + } + + private static RelationalTypeMappingParameters CreateRelationalTypeMappingParameters(string storeType, Type clrType) + { + return new RelationalTypeMappingParameters( + new CoreTypeMappingParameters( + clrType: clrType, + converter: null //this gets the generatecodeliteral to run + ), + storeType); + } + + // needed to implement Clone + protected SqlServerHierarchyIdTypeMapping(RelationalTypeMappingParameters parameters) + : base(parameters) + { + } + + protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) + { + return new SqlServerHierarchyIdTypeMapping(parameters); + } + + protected override void ConfigureParameter(DbParameter parameter) + { + var type = parameter.GetType(); + LazyInitializer.EnsureInitialized(ref _sqlDbTypeSetter, () => CreateSqlDbTypeAccessor(type)); + LazyInitializer.EnsureInitialized(ref _udtTypeNameSetter, () => CreateUdtTypeNameAccessor(type)); + + if (parameter.Value == DBNull.Value) + { + parameter.Value = SqlBytes.Null; + } + + _sqlDbTypeSetter(parameter, SqlDbType.Udt); + _udtTypeNameSetter(parameter, StoreType); + } + + public override MethodInfo GetDataReaderMethod() + { + return _getSqlBytes; + } + + public override Expression GenerateCodeLiteral(object value) + { + return Expression.Call( + _parseHierarchyId, + Expression.Constant(value.ToString(), typeof(string)) + ); + } + + protected override string GenerateNonNullSqlLiteral(object value) + { + //this appears to only be called when using the update-database + //command, and the value is already a hierarchyid + return $"'{value}'"; + } + + public override DbParameter CreateParameter(DbCommand command, string name, object value, bool? nullable = null, ParameterDirection direction = ParameterDirection.Input) + { + var parameter = command.CreateParameter(); + parameter.Direction = ParameterDirection.Input; + parameter.ParameterName = name; + + if (Converter != null) + { + value = Converter.ConvertToProvider(value); + } + + parameter.Value = value is null + ? DBNull.Value + : _valueConverter.ConvertToProvider(value); + + if (nullable.HasValue) + { + parameter.IsNullable = nullable.Value; + } + + ConfigureParameter(parameter); + + return parameter; + } + + public override Expression CustomizeDataReaderExpression(Expression expression) + { + if (expression.Type != _valueConverter.ProviderClrType) + { + expression = Expression.Convert(expression, _valueConverter.ProviderClrType); + } + + return ReplacingExpressionVisitor.Replace( + _valueConverter.ConvertFromProviderExpression.Parameters.Single(), + expression, + _valueConverter.ConvertFromProviderExpression.Body); + } + + private static Action CreateSqlDbTypeAccessor(Type paramType) + { + var paramParam = Expression.Parameter(typeof(DbParameter), "parameter"); + var valueParam = Expression.Parameter(typeof(SqlDbType), "value"); + + return Expression.Lambda>( + Expression.Call( + Expression.Convert(paramParam, paramType), + paramType.GetProperty("SqlDbType").SetMethod, + valueParam), + paramParam, + valueParam).Compile(); + } + + private static Action CreateUdtTypeNameAccessor(Type paramType) + { + var paramParam = Expression.Parameter(typeof(DbParameter), "parameter"); + var valueParam = Expression.Parameter(typeof(string), "value"); + + return Expression.Lambda>( + Expression.Call( + Expression.Convert(paramParam, paramType), + paramType.GetProperty("UdtTypeName").SetMethod, + valueParam), + paramParam, + valueParam).Compile(); + } + } +} diff --git a/src/EFCore.SqlServer.HierarchyId/Storage/SqlServerHierarchyIdTypeMappingSourcePlugin.cs b/src/EFCore.SqlServer.HierarchyId/Storage/SqlServerHierarchyIdTypeMappingSourcePlugin.cs new file mode 100644 index 00000000000..10e54dd2189 --- /dev/null +++ b/src/EFCore.SqlServer.HierarchyId/Storage/SqlServerHierarchyIdTypeMappingSourcePlugin.cs @@ -0,0 +1,21 @@ +using System; +using Microsoft.EntityFrameworkCore.Storage; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Storage +{ + internal class SqlServerHierarchyIdTypeMappingSourcePlugin : IRelationalTypeMappingSourcePlugin + { + public const string SqlServerTypeName = "hierarchyid"; + + public virtual RelationalTypeMapping FindMapping(in RelationalTypeMappingInfo mappingInfo) + { + var clrType = mappingInfo.ClrType; + var storeTypeName = mappingInfo.StoreTypeName; + + return typeof(HierarchyId).IsAssignableFrom(clrType) + || SqlServerTypeName.Equals(storeTypeName, StringComparison.OrdinalIgnoreCase) + ? new SqlServerHierarchyIdTypeMapping(SqlServerTypeName, clrType ?? typeof(HierarchyId)) + : null; + } + } +} diff --git a/src/EFCore.SqlServer.HierarchyId/Storage/SqlServerHierarchyIdValueConverter.cs b/src/EFCore.SqlServer.HierarchyId/Storage/SqlServerHierarchyIdValueConverter.cs new file mode 100644 index 00000000000..cd5593c4f72 --- /dev/null +++ b/src/EFCore.SqlServer.HierarchyId/Storage/SqlServerHierarchyIdValueConverter.cs @@ -0,0 +1,33 @@ +using System.Data.SqlTypes; +using System.IO; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Storage +{ + internal class SqlServerHierarchyIdValueConverter : ValueConverter + { + public SqlServerHierarchyIdValueConverter() + : base(h => toProvider(h), b => fromProvider(b)) + { + } + + private static SqlBytes toProvider(HierarchyId hid) + { + using (var memory = new MemoryStream()) + using (var writer = new BinaryWriter(memory)) + { + hid.Write(writer); + return new SqlBytes(memory.ToArray()); + } + } + + private static HierarchyId fromProvider(SqlBytes bytes) + { + using (var memory = new MemoryStream(bytes.Value)) + using (var reader = new BinaryReader(memory)) + { + return HierarchyId.Read(reader); + } + } + } +} diff --git a/src/EFCore.SqlServer.HierarchyId/build/net6.0/EntityFrameworkCore.SqlServer.HierarchyId.targets b/src/EFCore.SqlServer.HierarchyId/build/net6.0/EntityFrameworkCore.SqlServer.HierarchyId.targets new file mode 100644 index 00000000000..09f8c908850 --- /dev/null +++ b/src/EFCore.SqlServer.HierarchyId/build/net6.0/EntityFrameworkCore.SqlServer.HierarchyId.targets @@ -0,0 +1,46 @@ + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + $(IntermediateOutputPath)EFCoreSqlServerHierarchyId$(DefaultLanguageSourceExtension) + + + + + + + CompileBefore + + + + + CompileAfter + + + + + + + Compile + + + + + + + <_Parameter1>Microsoft.EntityFrameworkCore.SqlServer.Design.SqlServerHierarchyIdDesignTimeServices, EntityFrameworkCore.SqlServer.HierarchyId + <_Parameter2>Microsoft.EntityFrameworkCore.SqlServer + + + + + + + + \ No newline at end of file diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/CSharpDbContextGeneratorTest.cs b/test/EFCore.SqlServer.HierarchyId.Tests/CSharpDbContextGeneratorTest.cs new file mode 100644 index 00000000000..dbb660d1f41 --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/CSharpDbContextGeneratorTest.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.EntityFrameworkCore.SqlServer.Internal; +using Xunit; + +namespace Microsoft.EntityFrameworkCore.SqlServer; + +public class CSharpDbContextGeneratorTest : ModelCodeGeneratorTestBase +{ + [ConditionalFact] + public void Generates_context_with_UseHierarchyId() + => Test( + modelBuilder => + { + modelBuilder.Entity( + "Patriarch", + b => + { + b.Property("Id"); + b.HasKey("Id"); + b.Property("Name"); + }); + }, + new ModelCodeGenerationOptions {UseDataAnnotations = false}, + code => + { + AssertFileContents( + @"using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; + +namespace TestNamespace; + +public partial class TestDbContext : DbContext +{ + public TestDbContext() + { + } + + public TestDbContext(DbContextOptions options) + : base(options) + { + } + + public virtual DbSet Patriarch { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) +#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148. For more guidance on storing connection strings, see http://go.microsoft.com/fwlink/?LinkId=723263. + => optionsBuilder.UseSqlServer(""Initial Catalog=TestDatabase"", x => x.UseHierarchyId()); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + OnModelCreatingPartial(modelBuilder); + } + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); +} +", + code.ContextFile); + }); +} diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/CSharpEntityTypeGeneratorTest.cs b/test/EFCore.SqlServer.HierarchyId.Tests/CSharpEntityTypeGeneratorTest.cs new file mode 100644 index 00000000000..4dd7005a6ca --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/CSharpEntityTypeGeneratorTest.cs @@ -0,0 +1,125 @@ +using System.Linq; +using Microsoft.EntityFrameworkCore.Scaffolding; +using Xunit; + +namespace Microsoft.EntityFrameworkCore.SqlServer; + +public class CSharpEntityTypeGeneratorTest : ModelCodeGeneratorTestBase +{ + [ConditionalFact] + public void Class_with_HierarchyId_key_is_generated() + => Test( + modelBuilder => + { + modelBuilder.Entity( + "Patriarch", + b => + { + b.Property("Id"); + b.HasKey("Id"); + b.Property("Name"); + }); + }, + new ModelCodeGenerationOptions { UseDataAnnotations = true }, + code => + { + AssertFileContents( + @"using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace TestNamespace; + +public partial class Patriarch +{ + [Key] + public HierarchyId Id { get; set; } + + public string Name { get; set; } +} +", + code.AdditionalFiles.Single(f => f.Path == "Patriarch.cs")); + }); + + [ConditionalFact] + public void Class_with_HierarchyId_property_is_generated() + => Test( + modelBuilder => + { + modelBuilder.Entity( + "Patriarch", + b => + { + b.Property("Id"); + b.HasKey("Id"); + b.Property("Name"); + b.Property("Hierarchy"); + }); + }, + new ModelCodeGenerationOptions { UseDataAnnotations = true }, + code => + { + AssertFileContents( + @"using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace TestNamespace; + +public partial class Patriarch +{ + [Key] + public int Id { get; set; } + + public HierarchyId Hierarchy { get; set; } + + public string Name { get; set; } +} +", + code.AdditionalFiles.Single(f => f.Path == "Patriarch.cs")); + }); + + [ConditionalFact] + public void Class_with_multiple_HierarchyId_properties_are_generated() + => Test( + modelBuilder => + { + modelBuilder.Entity( + "Patriarch", + b => + { + b.Property("Id"); + b.HasKey("Id"); + b.Property("Name"); + b.Property("Hierarchy"); + }); + }, + new ModelCodeGenerationOptions { UseDataAnnotations = true }, + code => + { + AssertFileContents( + @"using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace TestNamespace; + +public partial class Patriarch +{ + [Key] + public HierarchyId Id { get; set; } + + public HierarchyId Hierarchy { get; set; } + + public string Name { get; set; } +} +", + code.AdditionalFiles.Single(f => f.Path == "Patriarch.cs")); + }); +} diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/DesignTimeServicesTests.cs b/test/EFCore.SqlServer.HierarchyId.Tests/DesignTimeServicesTests.cs new file mode 100644 index 00000000000..65cd1c3d3a2 --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/DesignTimeServicesTests.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.EntityFrameworkCore.SqlServer.Design; +using Microsoft.EntityFrameworkCore.SqlServer.Scaffolding; +using Microsoft.EntityFrameworkCore.SqlServer.Storage; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.EntityFrameworkCore.SqlServer +{ + public class DesignTimeServicesTests + { + [Fact] + public void ConfigureDesignTimeServices_works() + { + var serviceCollection = new ServiceCollection(); + new SqlServerHierarchyIdDesignTimeServices().ConfigureDesignTimeServices(serviceCollection); + var serviceProvider = serviceCollection.BuildServiceProvider(); + + Assert.IsType(serviceProvider.GetService()); + Assert.IsType(serviceProvider.GetService()); + } + } +} diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/EFCore.SqlServer.HierarchyId.Tests.csproj b/test/EFCore.SqlServer.HierarchyId.Tests/EFCore.SqlServer.HierarchyId.Tests.csproj new file mode 100644 index 00000000000..9ac69ed5adb --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/EFCore.SqlServer.HierarchyId.Tests.csproj @@ -0,0 +1,24 @@ + + + + net7.0 + false + EntityFrameworkCore.SqlServer.HierarchyId.Test + Microsoft.EntityFrameworkCore.SqlServer + + + + + + + + + + + + + + + + + diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/MigrationTests.cs b/test/EFCore.SqlServer.HierarchyId.Tests/MigrationTests.cs new file mode 100644 index 00000000000..deddd3596e0 --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/MigrationTests.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Design.Internal; +using Microsoft.EntityFrameworkCore.Migrations.Design; +using Microsoft.EntityFrameworkCore.SqlServer.Test.Models.Migrations; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.EntityFrameworkCore.SqlServer +{ + public class MigrationTests + { + private delegate string MigrationCodeGetter(string migrationName, string rootNamespace); + private delegate string SnapshotCodeGetter(string rootNamespace); + + [Fact] + public void Migration_and_snapshot_generate_with_typed_array() + { + using var db = new TypedArraySeedContext(); + ValidateMigrationAndSnapshotCode(db, db.GetExpectedMigrationCode, db.GetExpectedSnapshotCode); + } + + [Fact] + public void Migration_and_snapshot_generate_with_anonymous_array() + { + using var db = new AnonymousArraySeedContext(); + ValidateMigrationAndSnapshotCode(db, db.GetExpectedMigrationCode, db.GetExpectedSnapshotCode); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Uses internal efcore apis")] + private static void ValidateMigrationAndSnapshotCode( + DbContext context, + MigrationCodeGetter migrationCodeGetter, + SnapshotCodeGetter snapshotCodeGetter) + { + const string migrationName = "MyMigration"; + const string rootNamespace = "MyApp.Data"; + + var expectedMigration = migrationCodeGetter(migrationName, rootNamespace); + var expectedSnapshot = snapshotCodeGetter(rootNamespace); + + var reporter = new OperationReporter( + new OperationReportHandler( + m => Console.WriteLine($" error: {m}"), + m => Console.WriteLine($" warn: {m}"), + m => Console.WriteLine($" info: {m}"), + m => Console.WriteLine($"verbose: {m}"))); + + var assembly = System.Reflection.Assembly.GetExecutingAssembly(); + + //this works because we have placed the DesignTimeServicesReferenceAttribute + //in the test project's properties, which simulates + //the nuget package's build target + var migration = new DesignTimeServicesBuilder(assembly, assembly, reporter, Array.Empty()) + .Build(context) + .GetRequiredService() + .ScaffoldMigration(migrationName, rootNamespace); + + Assert.Equal(expectedMigration, migration.MigrationCode); + Assert.Equal(expectedSnapshot, migration.SnapshotCode); + } + } +} diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/ModelCodeGeneratorTestBase.cs b/test/EFCore.SqlServer.HierarchyId.Tests/ModelCodeGeneratorTestBase.cs new file mode 100644 index 00000000000..c1d87785da7 --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/ModelCodeGeneratorTestBase.cs @@ -0,0 +1,66 @@ +using System; +using Microsoft.EntityFrameworkCore.Design.Internal; +using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.EntityFrameworkCore.TestUtilities; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.EntityFrameworkCore.SqlServer; + +#pragma warning disable EF1001 + +public abstract class ModelCodeGeneratorTestBase +{ + protected void Test( + Action buildModel, + ModelCodeGenerationOptions options, + Action assertScaffold) + { + var designServices = new ServiceCollection(); + AddModelServices(designServices); + + var modelBuilder = SqlServerTestHelpers.Instance.CreateConventionBuilder(customServices: designServices); + modelBuilder.Model.RemoveAnnotation(CoreAnnotationNames.ProductVersion); + buildModel(modelBuilder); + + var model = modelBuilder.FinalizeModel(designTime: true, skipValidation: true); + + var services = CreateServices(); + AddScaffoldingServices(services); + + var generator = services.BuildServiceProvider(validateScopes: true) + .GetRequiredService(); + + options.ModelNamespace ??= "TestNamespace"; + options.ContextName = "TestDbContext"; + options.ConnectionString = "Initial Catalog=TestDatabase"; + + var scaffoldedModel = generator.GenerateModel( + model, + options); + assertScaffold(scaffoldedModel); + } + + private static IServiceCollection CreateServices() + { + var testAssembly = typeof(ModelCodeGeneratorTestBase).Assembly; + var reporter = new TestOperationReporter(); + var services = new DesignTimeServicesBuilder(testAssembly, testAssembly, reporter, new string[0]) + .CreateServiceCollection("Microsoft.EntityFrameworkCore.SqlServer"); + return services; + } + + protected virtual void AddModelServices(IServiceCollection services) + { + } + + protected virtual void AddScaffoldingServices(IServiceCollection services) + { + } + + protected static void AssertFileContents( + string expectedCode, + ScaffoldedFile file) + => Assert.Equal(expectedCode, file.Code, ignoreLineEndingDifferences: true); +} diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/NullabilityTests.cs b/test/EFCore.SqlServer.HierarchyId.Tests/NullabilityTests.cs new file mode 100644 index 00000000000..dc717930a68 --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/NullabilityTests.cs @@ -0,0 +1,80 @@ +using System.Linq; +using Xunit; + +namespace Microsoft.EntityFrameworkCore.SqlServer +{ + public class NullabilityTests + { + [Fact] + public void Null_against_null() + { + Assert.True((HierarchyId)null == (HierarchyId)null); + Assert.False((HierarchyId)null != (HierarchyId)null); + Assert.False((HierarchyId)null > (HierarchyId)null); + Assert.False((HierarchyId)null >= (HierarchyId)null); + Assert.False((HierarchyId)null < (HierarchyId)null); + Assert.False((HierarchyId)null <= (HierarchyId)null); + } + + [Fact] + public void Null_against_nonNull() + { + var hid = HierarchyId.GetRoot(); + Assert.False(hid == (HierarchyId)null); + Assert.False((HierarchyId)null == hid); + + Assert.True(hid != (HierarchyId)null); + Assert.True((HierarchyId)null != hid); + + Assert.False(hid > (HierarchyId)null); + Assert.False((HierarchyId)null > hid); + + Assert.False(hid >= (HierarchyId)null); + Assert.False((HierarchyId)null >= hid); + + Assert.False(hid < (HierarchyId)null); + Assert.False((HierarchyId)null < hid); + + Assert.False(hid <= (HierarchyId)null); + Assert.False((HierarchyId)null <= hid); + } + + [Fact] + public void NullOnly_aggregates_equalTo_null() + { + var hid = (HierarchyId)null; + var collection = new[] { (HierarchyId)null, (HierarchyId)null, }; + var min = collection.Min(); + var max = collection.Max(); + + Assert.True(hid == min); + Assert.True(min == hid); + Assert.False(hid != min); + Assert.False(min != hid); + + Assert.True(hid == max); + Assert.True(max == hid); + Assert.False(hid != max); + Assert.False(max != hid); + } + + [Fact] + public void Aggregates_including_nulls_equalTo_nonNull() + { + var hid = HierarchyId.GetRoot(); + var collection = new[] { (HierarchyId)null, (HierarchyId)null, HierarchyId.GetRoot(), HierarchyId.GetRoot(), }; + var min = collection.Min(); + var max = collection.Max(); + + Assert.True(hid == min); + Assert.True(min == hid); + Assert.False(hid != min); + Assert.False(min != hid); + + Assert.True(hid == max); + Assert.True(max == hid); + Assert.False(hid != max); + Assert.False(max != hid); + } + } +} diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/Properties/EFCoreSqlServerHierarchyId.cs b/test/EFCore.SqlServer.HierarchyId.Tests/Properties/EFCoreSqlServerHierarchyId.cs new file mode 100644 index 00000000000..4b04f36c8db --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/Properties/EFCoreSqlServerHierarchyId.cs @@ -0,0 +1,18 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +using System; +using System.Reflection; + +[assembly: Microsoft.EntityFrameworkCore.Design.DesignTimeServicesReferenceAttribute("Microsoft.EntityFrameworkCore.SqlServer.Design.SqlServerHierarchyIdDesignTimeServ" + + "ices, EntityFrameworkCore.SqlServer.HierarchyId", "Microsoft.EntityFrameworkCore.SqlServer")] + +// Generated by the MSBuild WriteCodeFragment class. + diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/QueryTests.cs b/test/EFCore.SqlServer.HierarchyId.Tests/QueryTests.cs new file mode 100644 index 00000000000..e07e3fa6676 --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/QueryTests.cs @@ -0,0 +1,343 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore.SqlServer.Test.Models; +using Xunit; + +namespace Microsoft.EntityFrameworkCore.SqlServer +{ + public class QueryTests : IDisposable + { + private readonly AbrahamicContext _db; + + public QueryTests() + { + _db = new AbrahamicContext(); + _db.Database.EnsureDeleted(); + _db.Database.EnsureCreated(); + } + + [Fact] + public void GetLevel_can_translate() + { + var results = Enumerable.ToList( + from p in _db.Patriarchy + where p.Id.GetLevel() == 0 + select p.Name); + + Assert.Equal( + condense(@"SELECT [p].[Name] FROM [Patriarchy] AS [p] WHERE [p].[Id].GetLevel() = CAST(0 AS smallint)"), + condense(_db.Sql)); + + Assert.Equal(new[] { "Abraham" }, results); + } + + [Fact] + public void IsDescendantOf_can_translate() + { + var results = Enumerable.ToList( + from p in _db.Patriarchy + where p.Id.GetLevel() == 3 + select p.Id.IsDescendantOf(p.Id.GetAncestor(1))); + + Assert.Equal( + condense(@"SELECT [p].[Id].IsDescendantOf([p].[Id].GetAncestor(1)) FROM [Patriarchy] AS [p] WHERE [p].[Id].GetLevel() = CAST(3 AS smallint)"), + condense(_db.Sql)); + + Assert.All(results, b => Assert.True(b)); + } + + [Fact] + public void GetAncestor_0_can_translate() + { + var results = Enumerable.ToList( + from p in _db.Patriarchy + where p.Id.GetLevel() == 0 + select p.Id.GetAncestor(0)); + + Assert.Equal( + condense(@"SELECT [p].[Id].GetAncestor(0) FROM [Patriarchy] AS [p] WHERE [p].[Id].GetLevel() = CAST(0 AS smallint)"), + condense(_db.Sql)); + + Assert.All(results, h => Assert.Equal(HierarchyId.GetRoot(), h)); + } + + [Fact] + public void GetAncestor_1_can_translate() + { + var results = Enumerable.ToList( + from p in _db.Patriarchy + where p.Id.GetLevel() == 1 + select p.Id.GetAncestor(1)); + + Assert.Equal( + condense(@"SELECT [p].[Id].GetAncestor(1) FROM [Patriarchy] AS [p] WHERE [p].[Id].GetLevel() = CAST(1 AS smallint)"), + condense(_db.Sql)); + + Assert.All(results, h => Assert.Equal(HierarchyId.GetRoot(), h)); + } + + [Fact] + public void GetAncestor_2_can_translate() + { + var results = Enumerable.ToList( + from p in _db.Patriarchy + where p.Id.GetLevel() == 2 + select p.Id.GetAncestor(2)); + + Assert.Equal( + condense(@"SELECT [p].[Id].GetAncestor(2) FROM [Patriarchy] AS [p] WHERE [p].[Id].GetLevel() = CAST(2 AS smallint)"), + condense(_db.Sql)); + + Assert.All(results, h => Assert.Equal(HierarchyId.GetRoot(), h)); + } + + [Fact] + public void GetAncestor_3_can_translate() + { + var results = Enumerable.ToList( + from p in _db.Patriarchy + where p.Id.GetLevel() == 3 + select p.Id.GetAncestor(3)); + + Assert.Equal( + condense(@"SELECT [p].[Id].GetAncestor(3) FROM [Patriarchy] AS [p] WHERE [p].[Id].GetLevel() = CAST(3 AS smallint)"), + condense(_db.Sql)); + + Assert.All(results, h => Assert.Equal(HierarchyId.GetRoot(), h)); + } + + [Fact] + public void GetAncestor_of_root_returns_null() + { + var results = Enumerable.ToList( + from p in _db.Patriarchy + where p.Id.GetLevel() == 0 + select p.Id.GetAncestor(1)); + + Assert.Equal( + condense(@"SELECT [p].[Id].GetAncestor(1) FROM [Patriarchy] AS [p] WHERE [p].[Id].GetLevel() = CAST(0 AS smallint)"), + condense(_db.Sql)); + + Assert.Equal(new HierarchyId[] { null }, results); + } + + [Fact] + public void GetDescendent_can_translate() + { + var results = Enumerable.ToList( + from p in _db.Patriarchy + where p.Id.GetLevel() == 0 + select p.Id.GetDescendant(null, null)); + + Assert.Equal( + condense(@"SELECT [p].[Id].GetDescendant(NULL, NULL) FROM [Patriarchy] AS [p] WHERE [p].[Id].GetLevel() = CAST(0 AS smallint)"), + condense(_db.Sql)); + + Assert.Equal(new[] { HierarchyId.Parse("/1/") }, results); + } + + [Fact] + public void HierarchyId_can_be_sent_as_parameter() + { + var results = Enumerable.ToList( + from p in _db.Patriarchy + where p.Id == HierarchyId.Parse("/1/") + select p.Name); + + Assert.Equal( + condense(@"SELECT [p].[Name] FROM [Patriarchy] AS [p] WHERE [p].[Id] = '/1/'"), + condense(_db.Sql)); + + Assert.Equal(new[] { "Isaac" }, results); + } + + [Fact] + public void Converted_HierarchyId_can_be_sent_as_parameter() + { + var results = Enumerable.ToList( + from p in _db.ConvertedPatriarchy + where p.HierarchyId == HierarchyId.Parse("/1/").ToString() + select p.Name); + + Assert.Equal( + condense(@"SELECT [c].[Name] FROM [ConvertedPatriarchy] AS [c] WHERE [c].[HierarchyId] = '/1/'"), + condense(_db.Sql)); + + Assert.Equal(new[] { "Isaac" }, results); + } + + [Fact] + public void Can_insert_HierarchyId() + { + using (_db.Database.BeginTransaction()) + { + var entities = new List + { + new() { Id = HierarchyId.Parse("/2/1/"), Name = "Thrór" }, + new() { Id = HierarchyId.Parse("/2/2/"), Name = "Thráin II" }, + new() { Id = HierarchyId.Parse("/3/"), Name = "Thorin Oakenshield" } + }; + + _db.AddRange(entities); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + + var queried = _db.Patriarchy.Where(e => e.Name.StartsWith("Th")).OrderBy(e => e.Id).ToList(); + + Assert.Equal(3, queried.Count); + + Assert.Equal(HierarchyId.Parse("/2/1/"), queried[0].Id); + Assert.Equal("Thrór", queried[0].Name); + + Assert.Equal(HierarchyId.Parse("/2/2/"), queried[1].Id); + Assert.Equal("Thráin II", queried[1].Name); + + Assert.Equal(HierarchyId.Parse("/3/"), queried[2].Id); + Assert.Equal("Thorin Oakenshield", queried[2].Name); + } + } + + [Fact] + public void Can_insert_and_update_converted_HierarchyId() + { + using (_db.Database.BeginTransaction()) + { + var entities = new List + { + new() { HierarchyId = HierarchyId.Parse("/2/1/").ToString(), Name = "Thrór" }, + new() { HierarchyId = HierarchyId.Parse("/2/2/").ToString(), Name = "Thráin II" }, + new() { HierarchyId = HierarchyId.Parse("/3/").ToString(), Name = "Thorin Oakenshield" } + }; + + _db.AddRange(entities); + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + + var queried = _db.ConvertedPatriarchy.Where(e => e.Name.StartsWith("Th")).OrderBy(e => e.Id).ToList(); + + Assert.Equal(3, queried.Count); + + Assert.Equal(HierarchyId.Parse("/2/1/").ToString(), queried[0].HierarchyId); + Assert.Equal("Thrór", queried[0].Name); + + Assert.Equal(HierarchyId.Parse("/2/2/").ToString(), queried[1].HierarchyId); + Assert.Equal("Thráin II", queried[1].Name); + + Assert.Equal(HierarchyId.Parse("/3/").ToString(), queried[2].HierarchyId); + Assert.Equal("Thorin Oakenshield", queried[2].Name); + + queried[2].HierarchyId = "/3/1/"; + + _db.SaveChanges(); + _db.ChangeTracker.Clear(); + + queried = _db.ConvertedPatriarchy.Where(e => e.Name.StartsWith("Th")).OrderBy(e => e.Id).ToList(); + + Assert.Equal(3, queried.Count); + + Assert.Equal(HierarchyId.Parse("/2/1/").ToString(), queried[0].HierarchyId); + Assert.Equal("Thrór", queried[0].Name); + + Assert.Equal(HierarchyId.Parse("/2/2/").ToString(), queried[1].HierarchyId); + Assert.Equal("Thráin II", queried[1].Name); + + Assert.Equal(HierarchyId.Parse("/3/1/").ToString(), queried[2].HierarchyId); + Assert.Equal("Thorin Oakenshield", queried[2].Name); + } + } + + [Fact] + public void HierarchyId_get_ancestor_of_level_is_root() + { + var results = Enumerable.ToList( + from p in _db.Patriarchy + where p.Id.GetAncestor(p.Id.GetLevel()) == HierarchyId.GetRoot() // HierarchyId.Parse("/1/") // HierarchyId.Parse(p.Id.ToString()).GetAncestor(HierarchyId.Parse(p.Id.ToString()).GetLevel()) + select p.Name); + + Assert.Equal( + condense(@"SELECT [p].[Name] FROM [Patriarchy] AS [p] WHERE [p].[Id].GetAncestor(CAST([p].[Id].GetLevel() AS int)) = '/'"), + condense(_db.Sql)); + + var all = Enumerable.ToList( + from p in _db.Patriarchy + select p.Name); + + Assert.Equal(all, results); + } + + [Fact] + public void HierarchyId_can_call_method_on_parameter() + { + var isaac = HierarchyId.Parse("/1/"); + + var results = Enumerable.ToList( + from p in _db.Patriarchy + where isaac.IsDescendantOf(p.Id) + select p.Name); + + Assert.Equal( + condense(@"SELECT [p].[Name] FROM [Patriarchy] AS [p] WHERE @__isaac_0.IsDescendantOf([p].[Id]) = CAST(1 AS bit)"), + condense(_db.Sql)); + + Assert.Equal(new[] { "Abraham", "Isaac" }, results); + } + + [Fact] + public void ToString_can_translate() + { + var results = Enumerable.ToList( + from p in _db.Patriarchy + where p.Id.GetLevel() == 1 + select p.Id.ToString()); + + Assert.Equal( + condense(@"SELECT [p].[Id].ToString() FROM [Patriarchy] AS [p] WHERE [p].[Id].GetLevel() = CAST(1 AS smallint)"), + condense(_db.Sql)); + + Assert.Equal(new[] { "/1/" }, results); + } + + [Fact] + public void ToString_can_translate_redux() + { + var results = Enumerable.ToList( + from p in _db.Patriarchy + where EF.Functions.Like(p.Id.ToString(), "%/1/") + select p.Name); + + Assert.Equal( + condense(@"SELECT [p].[Name] FROM [Patriarchy] AS [p] WHERE [p].[Id].ToString() LIKE N'%/1/'"), + condense(_db.Sql)); + + Assert.Equal(new[] { "Isaac", "Jacob", "Reuben" }, results); + } + + [Fact] + public void Parse_can_translate() + { + var results = Enumerable.ToList( + from p in _db.Patriarchy + where p.Id == HierarchyId.GetRoot() + select HierarchyId.Parse(p.Id.ToString())); + + Assert.Equal( + condense(@"SELECT hierarchyid::Parse([p].[Id].ToString()) FROM [Patriarchy] AS [p] WHERE [p].[Id] = '/'"), + condense(_db.Sql)); + + Assert.Equal(new[] { HierarchyId.Parse("/") }, results); + } + + public void Dispose() + { + _db.Dispose(); + } + + // replace whitespace with a single space + private static string condense(string str) + { + var split = str.Split((char[])null, StringSplitOptions.RemoveEmptyEntries); + return string.Join(" ", split); + } + } +} diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/RelationalScaffoldingModelFactoryTest.cs b/test/EFCore.SqlServer.HierarchyId.Tests/RelationalScaffoldingModelFactoryTest.cs new file mode 100644 index 00000000000..987d6865a1e --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/RelationalScaffoldingModelFactoryTest.cs @@ -0,0 +1,121 @@ +using Microsoft.EntityFrameworkCore.Design.Internal; +using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; +using Microsoft.EntityFrameworkCore.TestUtilities; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.EntityFrameworkCore.SqlServer; + +#pragma warning disable EF1001 + +public class RelationalScaffoldingModelFactoryTest +{ + private readonly IScaffoldingModelFactory _factory; + private readonly TestOperationReporter _reporter; + + private static readonly DatabaseModel Database; + private static readonly DatabaseTable Table; + private static readonly DatabaseColumn IdColumn; + private static readonly DatabasePrimaryKey IdPrimaryKey; + + static RelationalScaffoldingModelFactoryTest() + { + Database = new DatabaseModel(); + Table = new DatabaseTable { Database = Database, Name = "Foo" }; + IdColumn = new DatabaseColumn + { + Table = Table, + Name = "Id", + StoreType = "int" + }; + IdPrimaryKey = new DatabasePrimaryKey + { + Table = Table, + Name = "IdPrimaryKey", + Columns = { IdColumn } + }; + } + + public RelationalScaffoldingModelFactoryTest() + { + _reporter = new TestOperationReporter(); + + var assembly = typeof(RelationalScaffoldingModelFactoryTest).Assembly; + _factory = new DesignTimeServicesBuilder(assembly, assembly, _reporter, new string[0]) + .CreateServiceCollection("Microsoft.EntityFrameworkCore.SqlServer") + .AddSingleton() + .BuildServiceProvider(validateScopes: true) + .GetRequiredService(); + + _reporter.Clear(); + } + + [ConditionalFact] + public void Loads_HierarchyId_columns() + { + var info = new DatabaseModel + { + Tables = + { + new DatabaseTable + { + Database = Database, + Name = "Jobs", + Columns = + { + IdColumn, + new DatabaseColumn + { + Table = Table, + Name = "occupation", + StoreType = "nvarchar(max)", + DefaultValueSql = "\"dev\"" + }, + new DatabaseColumn + { + Table = Table, + Name = "salary", + StoreType = "int", + IsNullable = true + }, + new DatabaseColumn + { + Table = Table, + Name = "hierarchy", + StoreType = "HierarchyId" + } + }, + PrimaryKey = IdPrimaryKey + } + } + }; + + var entityType = + (EntityType)_factory.Create(info, new ModelReverseEngineerOptions { NoPluralize = true }).FindEntityType("Jobs"); + + Assert.Collection( + entityType.GetProperties(), + pk => + { + Assert.Equal("Id", pk.Name); + Assert.Equal(typeof(int), pk.ClrType); + }, + column => + { + Assert.Equal("hierarchy", column.GetColumnName()); + Assert.Equal(typeof(HierarchyId), column.ClrType); + }, + column => + { + Assert.Equal("occupation", column.GetColumnName()); + Assert.Equal(typeof(string), column.ClrType); + }, + column => + { + Assert.Equal("salary", column.GetColumnName()); + Assert.Equal(typeof(int?), column.ClrType); + }); + } +} diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/Test/Logging/TestLogger.cs b/test/EFCore.SqlServer.HierarchyId.Tests/Test/Logging/TestLogger.cs new file mode 100644 index 00000000000..3906af28440 --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/Test/Logging/TestLogger.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Test.Logging +{ + public class TestLogger : ILogger + { + public string Sql { get; set; } + + public IDisposable BeginScope(TState state) + => null; + + public bool IsEnabled(LogLevel logLevel) + => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) + { + if (eventId != RelationalEventId.CommandExecuting) + return; + + var structure = (IReadOnlyList>)state; + var commandText = (string)structure.FirstOrDefault(i => i.Key == "commandText").Value; + + Sql = commandText; + } + } +} diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/Test/Logging/TestLoggerFactory.cs b/test/EFCore.SqlServer.HierarchyId.Tests/Test/Logging/TestLoggerFactory.cs new file mode 100644 index 00000000000..0d2f8efd9bb --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/Test/Logging/TestLoggerFactory.cs @@ -0,0 +1,21 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Test.Logging +{ + class TestLoggerFactory : ILoggerFactory + { + public TestLogger Logger { get; } + = new TestLogger(); + + public void AddProvider(ILoggerProvider provider) + => throw new NotImplementedException(); + + public ILogger CreateLogger(string categoryName) + => Logger; + + public void Dispose() + { + } + } +} diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/Test/Models/AbrahamicContext.cs b/test/EFCore.SqlServer.HierarchyId.Tests/Test/Models/AbrahamicContext.cs new file mode 100644 index 00000000000..8b3f94d9aa6 --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/Test/Models/AbrahamicContext.cs @@ -0,0 +1,69 @@ +using Microsoft.EntityFrameworkCore.SqlServer.Test.Logging; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Test.Models +{ + class AbrahamicContext : DbContext + { + readonly TestLoggerFactory _loggerFactory + = new TestLoggerFactory(); + + public DbSet Patriarchy { get; set; } + public DbSet ConvertedPatriarchy { get; set; } + + public string Sql + => _loggerFactory.Logger.Sql; + + protected override void OnConfiguring(DbContextOptionsBuilder options) + => options + .UseSqlServer( + @"Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=HierarchyIdTests", + x => x.UseHierarchyId()) + .UseLoggerFactory(_loggerFactory); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasData( + new Patriarch { Id = HierarchyId.GetRoot(), Name = "Abraham" }, + new Patriarch { Id = HierarchyId.Parse("/1/"), Name = "Isaac" }, + new Patriarch { Id = HierarchyId.Parse("/1/1/"), Name = "Jacob" }, + new Patriarch { Id = HierarchyId.Parse("/1/1/1/"), Name = "Reuben" }, + new Patriarch { Id = HierarchyId.Parse("/1/1/2/"), Name = "Simeon" }, + new Patriarch { Id = HierarchyId.Parse("/1/1/3/"), Name = "Levi" }, + new Patriarch { Id = HierarchyId.Parse("/1/1/4/"), Name = "Judah" }, + new Patriarch { Id = HierarchyId.Parse("/1/1/5/"), Name = "Issachar" }, + new Patriarch { Id = HierarchyId.Parse("/1/1/6/"), Name = "Zebulun" }, + new Patriarch { Id = HierarchyId.Parse("/1/1/7/"), Name = "Dan" }, + new Patriarch { Id = HierarchyId.Parse("/1/1/8/"), Name = "Naphtali" }, + new Patriarch { Id = HierarchyId.Parse("/1/1/9/"), Name = "Gad" }, + new Patriarch { Id = HierarchyId.Parse("/1/1/10/"), Name = "Asher" }, + new Patriarch { Id = HierarchyId.Parse("/1/1/11.1/"), Name = "Ephraim" }, + new Patriarch { Id = HierarchyId.Parse("/1/1/11.2/"), Name = "Manasseh" }, + new Patriarch { Id = HierarchyId.Parse("/1/1/12/"), Name = "Benjamin" }); + + modelBuilder.Entity(b => + { + b.Property(e => e.HierarchyId) + .HasConversion(v => HierarchyId.Parse(v), v => v.ToString()); + + b.HasData( + new ConvertedPatriarch { Id = 1, HierarchyId = HierarchyId.GetRoot().ToString(), Name = "Abraham" }, + new ConvertedPatriarch { Id = 2, HierarchyId = HierarchyId.Parse("/1/").ToString(), Name = "Isaac" }, + new ConvertedPatriarch { Id = 3, HierarchyId = HierarchyId.Parse("/1/1/").ToString(), Name = "Jacob" }, + new ConvertedPatriarch { Id = 4, HierarchyId = HierarchyId.Parse("/1/1/1/").ToString(), Name = "Reuben" }, + new ConvertedPatriarch { Id = 5, HierarchyId = HierarchyId.Parse("/1/1/2/").ToString(), Name = "Simeon" }, + new ConvertedPatriarch { Id = 6, HierarchyId = HierarchyId.Parse("/1/1/3/").ToString(), Name = "Levi" }, + new ConvertedPatriarch { Id = 7, HierarchyId = HierarchyId.Parse("/1/1/4/").ToString(), Name = "Judah" }, + new ConvertedPatriarch { Id = 8, HierarchyId = HierarchyId.Parse("/1/1/5/").ToString(), Name = "Issachar" }, + new ConvertedPatriarch { Id = 9, HierarchyId = HierarchyId.Parse("/1/1/6/").ToString(), Name = "Zebulun" }, + new ConvertedPatriarch { Id = 10, HierarchyId = HierarchyId.Parse("/1/1/7/").ToString(), Name = "Dan" }, + new ConvertedPatriarch { Id = 11, HierarchyId = HierarchyId.Parse("/1/1/8/").ToString(), Name = "Naphtali" }, + new ConvertedPatriarch { Id = 12, HierarchyId = HierarchyId.Parse("/1/1/9/").ToString(), Name = "Gad" }, + new ConvertedPatriarch { Id = 13, HierarchyId = HierarchyId.Parse("/1/1/10/").ToString(), Name = "Asher" }, + new ConvertedPatriarch { Id = 14, HierarchyId = HierarchyId.Parse("/1/1/11.1/").ToString(), Name = "Ephraim" }, + new ConvertedPatriarch { Id = 15, HierarchyId = HierarchyId.Parse("/1/1/11.2/").ToString(), Name = "Manasseh" }, + new ConvertedPatriarch { Id = 16, HierarchyId = HierarchyId.Parse("/1/1/12/").ToString(), Name = "Benjamin" }); + }); + } + } +} diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/Test/Models/ConvertedPatriarch.cs b/test/EFCore.SqlServer.HierarchyId.Tests/Test/Models/ConvertedPatriarch.cs new file mode 100644 index 00000000000..a5ca81ce2b1 --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/Test/Models/ConvertedPatriarch.cs @@ -0,0 +1,9 @@ +namespace Microsoft.EntityFrameworkCore.SqlServer.Test.Models +{ + class ConvertedPatriarch + { + public int Id { get; set; } + public string HierarchyId { get; set; } + public string Name { get; set; } + } +} \ No newline at end of file diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/Test/Models/Migrations/AnonymousArraySeedContext.cs b/test/EFCore.SqlServer.HierarchyId.Tests/Test/Models/Migrations/AnonymousArraySeedContext.cs new file mode 100644 index 00000000000..b448b23ca89 --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/Test/Models/Migrations/AnonymousArraySeedContext.cs @@ -0,0 +1,197 @@ +using Microsoft.EntityFrameworkCore.SqlServer.Storage; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Test.Models.Migrations +{ + internal sealed class AnonymousArraySeedContext : MigrationContext + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + RemoveVariableModelAnnotations(modelBuilder); + + modelBuilder.Entity().HasData( + new { Id = HierarchyId.GetRoot(), Name = "Eddard Stark" }, + new { Id = HierarchyId.Parse("/1/"), Name = "Robb Stark" }, + new { Id = HierarchyId.Parse("/2/"), Name = "Jon Snow" }); + + modelBuilder.Entity(b => + { + b.Property(e => e.HierarchyId) + .HasConversion(v => HierarchyId.Parse(v), v => v.ToString()); + + b.HasData( + new ConvertedPatriarch { Id = 1, HierarchyId = HierarchyId.GetRoot().ToString(), Name = "Eddard Stark" }, + new ConvertedPatriarch { Id = 2, HierarchyId = HierarchyId.Parse("/1/").ToString(), Name = "Robb Stark" }, + new ConvertedPatriarch { Id = 3, HierarchyId = HierarchyId.Parse("/2/").ToString(), Name = "Jon Snow" }); + }); + } + + public override string GetExpectedMigrationCode(string migrationName, string rootNamespace) + { + return $@"using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace {rootNamespace}.Migrations +{{ + /// + public partial class {migrationName} : Migration + {{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + {{ + migrationBuilder.CreateTable( + name: ""{nameof(ConvertedTestModels)}"", + columns: table => new + {{ + Id = table.Column(type: ""int"", nullable: false), + HierarchyId = table.Column<{nameof(HierarchyId)}>(type: ""hierarchyid"", nullable: true), + Name = table.Column(type: ""nvarchar(max)"", nullable: true) + }}, + constraints: table => + {{ + table.PrimaryKey(""PK_ConvertedTestModels"", x => x.Id); + }}); + + migrationBuilder.CreateTable( + name: ""{nameof(TestModels)}"", + columns: table => new + {{ + {nameof(Patriarch.Id)} = table.Column<{nameof(HierarchyId)}>(type: ""hierarchyid"", nullable: false), + {nameof(Patriarch.Name)} = table.Column(type: ""nvarchar(max)"", nullable: true) + }}, + constraints: table => + {{ + table.PrimaryKey(""PK_{nameof(TestModels)}"", x => x.{nameof(Patriarch.Id)}); + }}); + + migrationBuilder.InsertData( + table: ""ConvertedTestModels"", + columns: new[] {{ ""Id"", ""HierarchyId"", ""Name"" }}, + values: new object[,] + {{ + {{ 1, Microsoft.EntityFrameworkCore.HierarchyId.Parse(""/""), ""Eddard Stark"" }}, + {{ 2, Microsoft.EntityFrameworkCore.HierarchyId.Parse(""/1/""), ""Robb Stark"" }}, + {{ 3, Microsoft.EntityFrameworkCore.HierarchyId.Parse(""/2/""), ""Jon Snow"" }} + }}); + + migrationBuilder.InsertData( + table: ""TestModels"", + columns: new[] {{ ""Id"", ""Name"" }}, + values: new object[,] + {{ + {{ Microsoft.EntityFrameworkCore.HierarchyId.Parse(""/""), ""Eddard Stark"" }}, + {{ Microsoft.EntityFrameworkCore.HierarchyId.Parse(""/1/""), ""Robb Stark"" }}, + {{ Microsoft.EntityFrameworkCore.HierarchyId.Parse(""/2/""), ""Jon Snow"" }} + }}); + }} + + /// + protected override void Down(MigrationBuilder migrationBuilder) + {{ + migrationBuilder.DropTable( + name: ""ConvertedTestModels""); + + migrationBuilder.DropTable( + name: ""{nameof(TestModels)}""); + }} + }} +}} +"; + } + + public override string GetExpectedSnapshotCode(string rootNamespace) + { + return $@"// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using {ThisType.Namespace}; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace {rootNamespace}.Migrations +{{ + [DbContext(typeof({ThisType.Name}))] + partial class {ThisType.Name}ModelSnapshot : ModelSnapshot + {{ + protected override void BuildModel(ModelBuilder modelBuilder) + {{ +#pragma warning disable 612, 618 + + modelBuilder.Entity(""{ModelType2.FullName}"", b => + {{ + b.Property(""Id"") + .ValueGeneratedOnAdd() + .HasColumnType(""int""); + + b.Property(""HierarchyId"") + .HasColumnType(""hierarchyid""); + + b.Property(""Name"") + .HasColumnType(""nvarchar(max)""); + + b.HasKey(""Id""); + + b.ToTable(""ConvertedTestModels""); + + b.HasData( + new + {{ + Id = 1, + HierarchyId = Microsoft.EntityFrameworkCore.HierarchyId.Parse(""/""), + Name = ""Eddard Stark"" + }}, + new + {{ + Id = 2, + HierarchyId = Microsoft.EntityFrameworkCore.HierarchyId.Parse(""/1/""), + Name = ""Robb Stark"" + }}, + new + {{ + Id = 3, + HierarchyId = Microsoft.EntityFrameworkCore.HierarchyId.Parse(""/2/""), + Name = ""Jon Snow"" + }}); + }}); + + modelBuilder.Entity(""{ModelType1.FullName}"", b => + {{ + b.Property<{nameof(HierarchyId)}>(""{nameof(Patriarch.Id)}"") + .HasColumnType(""{SqlServerHierarchyIdTypeMappingSourcePlugin.SqlServerTypeName}""); + + b.Property(""{nameof(Patriarch.Name)}"") + .HasColumnType(""nvarchar(max)""); + + b.HasKey(""{nameof(Patriarch.Id)}""); + + b.ToTable(""{nameof(TestModels)}""); + + b.HasData( + new + {{ + {nameof(Patriarch.Id)} = Microsoft.EntityFrameworkCore.HierarchyId.Parse(""/""), + {nameof(Patriarch.Name)} = ""Eddard Stark"" + }}, + new + {{ + {nameof(Patriarch.Id)} = Microsoft.EntityFrameworkCore.HierarchyId.Parse(""/1/""), + {nameof(Patriarch.Name)} = ""Robb Stark"" + }}, + new + {{ + {nameof(Patriarch.Id)} = Microsoft.EntityFrameworkCore.HierarchyId.Parse(""/2/""), + {nameof(Patriarch.Name)} = ""Jon Snow"" + }}); + }}); +#pragma warning restore 612, 618 + }} + }} +}} +"; + } + } +} diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/Test/Models/Migrations/MigrationContext.cs b/test/EFCore.SqlServer.HierarchyId.Tests/Test/Models/Migrations/MigrationContext.cs new file mode 100644 index 00000000000..940d136502f --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/Test/Models/Migrations/MigrationContext.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Microsoft.EntityFrameworkCore.SqlServer.Metadata.Internal; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Test.Models.Migrations +{ + internal abstract class MigrationContext : DbContext + where TEntity1 : class + where TEntity2 : class + { + protected Type ModelType1 { get; } = typeof(TEntity1); + protected Type ModelType2 { get; } = typeof(TEntity2); + + private Type _thisType; + protected Type ThisType => _thisType ??= GetType(); + + public DbSet TestModels { get; set; } + public DbSet ConvertedTestModels { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder options) + => options + .UseSqlServer( + @"Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=HierarchyIdMigrationTests", + x => x.UseHierarchyId()); + + /// + /// Removes annotations from the model that can + /// change between versions of ef. + /// This should be called during OnModelCreating + /// + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Uses internal efcore apis.")] + protected void RemoveVariableModelAnnotations(ModelBuilder modelBuilder) + { + var model = modelBuilder.Model; + + //the values of these could change between versions + //so get rid of them for the tests + model.RemoveAnnotation(CoreAnnotationNames.ProductVersion); + model.RemoveAnnotation(RelationalAnnotationNames.MaxIdentifierLength); + model.RemoveAnnotation(SqlServerAnnotationNames.ValueGenerationStrategy); + } + + public abstract string GetExpectedMigrationCode(string migrationName, string rootNamespace); + public abstract string GetExpectedSnapshotCode(string rootNamespace); + } +} diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/Test/Models/Migrations/TypedArraySeedContext.cs b/test/EFCore.SqlServer.HierarchyId.Tests/Test/Models/Migrations/TypedArraySeedContext.cs new file mode 100644 index 00000000000..6b04e71c517 --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/Test/Models/Migrations/TypedArraySeedContext.cs @@ -0,0 +1,197 @@ +using Microsoft.EntityFrameworkCore.SqlServer.Storage; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Test.Models.Migrations +{ + internal sealed class TypedArraySeedContext : MigrationContext + { + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + RemoveVariableModelAnnotations(modelBuilder); + + modelBuilder.Entity().HasData( + new Patriarch { Id = HierarchyId.GetRoot(), Name = "Eddard Stark" }, + new Patriarch { Id = HierarchyId.Parse("/1/"), Name = "Robb Stark" }, + new Patriarch { Id = HierarchyId.Parse("/2/"), Name = "Jon Snow" }); + + modelBuilder.Entity(b => + { + b.Property(e => e.HierarchyId) + .HasConversion(v => HierarchyId.Parse(v), v => v.ToString()); + + b.HasData( + new ConvertedPatriarch { Id = 1, HierarchyId = HierarchyId.GetRoot().ToString(), Name = "Eddard Stark" }, + new ConvertedPatriarch { Id = 2, HierarchyId = HierarchyId.Parse("/1/").ToString(), Name = "Robb Stark" }, + new ConvertedPatriarch { Id = 3, HierarchyId = HierarchyId.Parse("/2/").ToString(), Name = "Jon Snow" }); + }); + } + + public override string GetExpectedMigrationCode(string migrationName, string rootNamespace) + { + return $@"using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace {rootNamespace}.Migrations +{{ + /// + public partial class {migrationName} : Migration + {{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + {{ + migrationBuilder.CreateTable( + name: ""{nameof(ConvertedTestModels)}"", + columns: table => new + {{ + {nameof(ConvertedPatriarch.Id)} = table.Column(type: ""int"", nullable: false), + {nameof(ConvertedPatriarch.HierarchyId)} = table.Column<{nameof(HierarchyId)}>(type: ""hierarchyid"", nullable: true), + {nameof(ConvertedPatriarch.Name)} = table.Column(type: ""nvarchar(max)"", nullable: true) + }}, + constraints: table => + {{ + table.PrimaryKey(""PK_{nameof(ConvertedTestModels)}"", x => x.{nameof(ConvertedPatriarch.Id)}); + }}); + + migrationBuilder.CreateTable( + name: ""{nameof(TestModels)}"", + columns: table => new + {{ + {nameof(Patriarch.Id)} = table.Column<{nameof(HierarchyId)}>(type: ""hierarchyid"", nullable: false), + {nameof(Patriarch.Name)} = table.Column(type: ""nvarchar(max)"", nullable: true) + }}, + constraints: table => + {{ + table.PrimaryKey(""PK_{nameof(TestModels)}"", x => x.{nameof(Patriarch.Id)}); + }}); + + migrationBuilder.InsertData( + table: ""ConvertedTestModels"", + columns: new[] {{ ""Id"", ""HierarchyId"", ""Name"" }}, + values: new object[,] + {{ + {{ 1, {typeof(HierarchyId).FullName}.Parse(""/""), ""Eddard Stark"" }}, + {{ 2, {typeof(HierarchyId).FullName}.Parse(""/1/""), ""Robb Stark"" }}, + {{ 3, {typeof(HierarchyId).FullName}.Parse(""/2/""), ""Jon Snow"" }} + }}); + + migrationBuilder.InsertData( + table: ""TestModels"", + columns: new[] {{ ""Id"", ""Name"" }}, + values: new object[,] + {{ + {{ {typeof(HierarchyId).FullName}.Parse(""/""), ""Eddard Stark"" }}, + {{ {typeof(HierarchyId).FullName}.Parse(""/1/""), ""Robb Stark"" }}, + {{ {typeof(HierarchyId).FullName}.Parse(""/2/""), ""Jon Snow"" }} + }}); + }} + + /// + protected override void Down(MigrationBuilder migrationBuilder) + {{ + migrationBuilder.DropTable( + name: ""ConvertedTestModels""); + + migrationBuilder.DropTable( + name: ""{nameof(TestModels)}""); + }} + }} +}} +"; + } + + public override string GetExpectedSnapshotCode(string rootNamespace) + { + return $@"// +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using {ThisType.Namespace}; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace {rootNamespace}.Migrations +{{ + [DbContext(typeof({ThisType.Name}))] + partial class {ThisType.Name}ModelSnapshot : ModelSnapshot + {{ + protected override void BuildModel(ModelBuilder modelBuilder) + {{ +#pragma warning disable 612, 618 + + modelBuilder.Entity(""{ModelType2.FullName}"", b => + {{ + b.Property(""Id"") + .ValueGeneratedOnAdd() + .HasColumnType(""int""); + + b.Property(""HierarchyId"") + .HasColumnType(""hierarchyid""); + + b.Property(""Name"") + .HasColumnType(""nvarchar(max)""); + + b.HasKey(""Id""); + + b.ToTable(""ConvertedTestModels""); + + b.HasData( + new + {{ + Id = 1, + HierarchyId = Microsoft.EntityFrameworkCore.HierarchyId.Parse(""/""), + Name = ""Eddard Stark"" + }}, + new + {{ + Id = 2, + HierarchyId = Microsoft.EntityFrameworkCore.HierarchyId.Parse(""/1/""), + Name = ""Robb Stark"" + }}, + new + {{ + Id = 3, + HierarchyId = Microsoft.EntityFrameworkCore.HierarchyId.Parse(""/2/""), + Name = ""Jon Snow"" + }}); + }}); + + modelBuilder.Entity(""{ModelType1.FullName}"", b => + {{ + b.Property<{nameof(HierarchyId)}>(""{nameof(Patriarch.Id)}"") + .HasColumnType(""{SqlServerHierarchyIdTypeMappingSourcePlugin.SqlServerTypeName}""); + + b.Property(""{nameof(Patriarch.Name)}"") + .HasColumnType(""nvarchar(max)""); + + b.HasKey(""{nameof(Patriarch.Id)}""); + + b.ToTable(""{nameof(TestModels)}""); + + b.HasData( + new + {{ + {nameof(Patriarch.Id)} = Microsoft.EntityFrameworkCore.HierarchyId.Parse(""/""), + {nameof(Patriarch.Name)} = ""Eddard Stark"" + }}, + new + {{ + {nameof(Patriarch.Id)} = Microsoft.EntityFrameworkCore.HierarchyId.Parse(""/1/""), + {nameof(Patriarch.Name)} = ""Robb Stark"" + }}, + new + {{ + {nameof(Patriarch.Id)} = Microsoft.EntityFrameworkCore.HierarchyId.Parse(""/2/""), + {nameof(Patriarch.Name)} = ""Jon Snow"" + }}); + }}); +#pragma warning restore 612, 618 + }} + }} +}} +"; + } + } +} diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/Test/Models/Patriarch.cs b/test/EFCore.SqlServer.HierarchyId.Tests/Test/Models/Patriarch.cs new file mode 100644 index 00000000000..2e7712a4c61 --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/Test/Models/Patriarch.cs @@ -0,0 +1,8 @@ +namespace Microsoft.EntityFrameworkCore.SqlServer.Test.Models +{ + class Patriarch + { + public HierarchyId Id { get; set; } + public string Name { get; set; } + } +} diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/Test/Utilities/FakeScaffoldingModelFactory.cs b/test/EFCore.SqlServer.HierarchyId.Tests/Test/Utilities/FakeScaffoldingModelFactory.cs new file mode 100644 index 00000000000..e73193686fe --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/Test/Utilities/FakeScaffoldingModelFactory.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.EntityFrameworkCore.Design.Internal; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Scaffolding; +using Microsoft.EntityFrameworkCore.Scaffolding.Internal; +using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; + +namespace Microsoft.EntityFrameworkCore.TestUtilities; + +#pragma warning disable EF1001 + +public class FakeScaffoldingModelFactory : RelationalScaffoldingModelFactory +{ + public FakeScaffoldingModelFactory( + IOperationReporter reporter, + ICandidateNamingService candidateNamingService, + IPluralizer pluralizer, + ICSharpUtilities cSharpUtilities, + IScaffoldingTypeMapper scaffoldingTypeMapper, + IModelRuntimeInitializer modelRuntimeInitializer) + : base(reporter, candidateNamingService, pluralizer, cSharpUtilities, scaffoldingTypeMapper, + modelRuntimeInitializer) + { + } + + public override IModel Create(DatabaseModel databaseModel, ModelReverseEngineerOptions options) + { + foreach (var sequence in databaseModel.Sequences) + { + sequence.Database = databaseModel; + } + + foreach (var table in databaseModel.Tables) + { + table.Database = databaseModel; + + foreach (var column in table.Columns) + { + column.Table = table; + } + + if (table.PrimaryKey != null) + { + table.PrimaryKey.Table = table; + FixupColumns(table, table.PrimaryKey.Columns); + } + + foreach (var index in table.Indexes) + { + index.Table = table; + FixupColumns(table, index.Columns); + } + + foreach (var uniqueConstraints in table.UniqueConstraints) + { + uniqueConstraints.Table = table; + FixupColumns(table, uniqueConstraints.Columns); + } + + foreach (var foreignKey in table.ForeignKeys) + { + foreignKey.Table = table; + FixupColumns(table, foreignKey.Columns); + + if (foreignKey.PrincipalTable is DatabaseTableRef tableRef) + { + foreignKey.PrincipalTable = databaseModel.Tables + .First(t => t.Name == tableRef.Name && t.Schema == tableRef.Schema); + } + + FixupColumns(foreignKey.PrincipalTable, foreignKey.PrincipalColumns); + } + } + + return base.Create(databaseModel, options); + } + + private static void FixupColumns(DatabaseTable table, IList columns) + { + for (var i = 0; i < columns.Count; i++) + { + if (columns[i] is DatabaseColumnRef columnRef) + { + columns[i] = table.Columns.First(c => c.Name == columnRef.Name); + } + + columns[i].Table = table; + } + } +} + +internal class DatabaseTableRef : DatabaseTable +{ + public DatabaseTableRef(string name, string schema = null) + { + Name = name; + Schema = schema; + } + + public override DatabaseModel Database + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + public override string Comment + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + public override DatabasePrimaryKey PrimaryKey + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + public override IList Columns + => throw new NotImplementedException(); + + public override IList UniqueConstraints + => throw new NotImplementedException(); + + public override IList Indexes + => throw new NotImplementedException(); + + public override IList ForeignKeys + => throw new NotImplementedException(); +} + +internal class DatabaseColumnRef : DatabaseColumn +{ + public DatabaseColumnRef(string name) + { + Name = name; + } + + public override DatabaseTable Table + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + public override bool IsNullable + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + public override string StoreType + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + public override string DefaultValueSql + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + public override string ComputedColumnSql + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + public override string Comment + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + public override ValueGenerated? ValueGenerated + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } +} diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/Test/Utilities/SqlServerTestHelpers.cs b/test/EFCore.SqlServer.HierarchyId.Tests/Test/Utilities/SqlServerTestHelpers.cs new file mode 100644 index 00000000000..f74e93f88ad --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/Test/Utilities/SqlServerTestHelpers.cs @@ -0,0 +1,25 @@ +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.SqlServer.Diagnostics.Internal; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.EntityFrameworkCore.TestUtilities; + +#pragma warning disable EF1001 + +public class SqlServerTestHelpers : TestHelpers +{ + private SqlServerTestHelpers() + { + } + + public static SqlServerTestHelpers Instance { get; } = new(); + + public override IServiceCollection AddProviderServices(IServiceCollection services) + => services.AddEntityFrameworkSqlServer(); + + public override DbContextOptionsBuilder UseProviderOptions(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseSqlServer(new SqlConnection("Database=DummyDatabase")); + + public override LoggingDefinitions LoggingDefinitions { get; } = new SqlServerLoggingDefinitions(); +} diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/TypeMappingTests.cs b/test/EFCore.SqlServer.HierarchyId.Tests/TypeMappingTests.cs new file mode 100644 index 00000000000..79bfa933a51 --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/TypeMappingTests.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.SqlServer.Storage; +using Microsoft.EntityFrameworkCore.Storage; +using Xunit; + +namespace Microsoft.EntityFrameworkCore.SqlServer +{ + public class TypeMappingTests + { + [Fact] + public void Maps_int_column() + { + var mapping = CreateMapper().FindMapping( + new RelationalTypeMappingInfo( + storeTypeName: "int", + storeTypeNameBase: "int", + unicode: null, + size: null, + precision: null, + scale: null)); + + Assert.Null(mapping); + } + + [Fact] + public void Maps_hierarchyid_column() + { + var mapping = CreateMapper().FindMapping( + new RelationalTypeMappingInfo( + storeTypeName: SqlServerHierarchyIdTypeMappingSourcePlugin.SqlServerTypeName, + storeTypeNameBase: SqlServerHierarchyIdTypeMappingSourcePlugin.SqlServerTypeName, + unicode: null, + size: null, + precision: null, + scale: null)); + + AssertMapping(mapping); + } + + private static void AssertMapping( + RelationalTypeMapping mapping) + { + AssertMapping(typeof(T), mapping); + } + + private static void AssertMapping( + Type type, + RelationalTypeMapping mapping) + { + Assert.Same(type, mapping.ClrType); + } + + private static IRelationalTypeMappingSourcePlugin CreateMapper() + => new SqlServerHierarchyIdTypeMappingSourcePlugin(); + } +} diff --git a/test/EFCore.SqlServer.HierarchyId.Tests/WrapperTests.cs b/test/EFCore.SqlServer.HierarchyId.Tests/WrapperTests.cs new file mode 100644 index 00000000000..c6a7c62a304 --- /dev/null +++ b/test/EFCore.SqlServer.HierarchyId.Tests/WrapperTests.cs @@ -0,0 +1,15 @@ +using Xunit; + +namespace Microsoft.EntityFrameworkCore.SqlServer +{ + public class WrapperTests + { + [Fact] + public void GetAncestor_returns_null_when_too_high() + => Assert.Null(HierarchyId.Parse("/1/").GetAncestor(2)); + + [Fact] + public void GetReparentedValue_returns_null_when_newRoot_is_null() + => Assert.Null(HierarchyId.Parse("/1/").GetReparentedValue(HierarchyId.GetRoot(), newRoot: null)); + } +}