diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index e0573d7..e7c99e8 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -10,9 +10,11 @@ jobs: uses: actions/checkout@v1 - name: Setup .NET SDK - uses: actions/setup-dotnet@v1 + uses: actions/setup-dotnet@v4 with: - dotnet-version: 7.0.x + dotnet-version: | + 8.0.x + 10.0.x - name: Install dependencies run: dotnet restore @@ -21,4 +23,4 @@ jobs: run: dotnet build ./Mindbox.Data.Linq.sln --configuration Release --no-restore - name: Test - run: dotnet test --no-restore + run: dotnet test --no-restore diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..d1c34ed --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,9 @@ + + + 12 + Mindbox + Mindbox + Copyright 2019 (c) Mindbox + MIT + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..61397e9 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,17 @@ + + + true + $(NoWarn);NU1507 + + + + + + + + + + + + + diff --git a/Mindbox.Data.Linq.Tests/Mindbox.Data.Linq.Tests.csproj b/Mindbox.Data.Linq.Tests/Mindbox.Data.Linq.Tests.csproj index 940b847..1a8bf8e 100644 --- a/Mindbox.Data.Linq.Tests/Mindbox.Data.Linq.Tests.csproj +++ b/Mindbox.Data.Linq.Tests/Mindbox.Data.Linq.Tests.csproj @@ -1,23 +1,21 @@ - + + net8.0;net10.0 Mindbox.Data.Linq.Tests Mindbox.Data.Linq.Tests - net7.0 false false - mindbox.ru - Itc.Nexus - ru - false + true true true + CS0169 - - - - + + + + - \ No newline at end of file + diff --git a/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs b/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs new file mode 100644 index 0000000..a147c8f --- /dev/null +++ b/Mindbox.Data.Linq.Tests/SqlGeneration/ArrayContainsTranslationTests.cs @@ -0,0 +1,87 @@ +using System; +using System.Data.Linq; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Mindbox.Data.Linq.Tests +{ + /// + /// Tests that array.Contains(value) in LINQ queries is correctly translated to SQL IN clause. + /// In .NET 10, the C# compiler generates MemoryExtensions.Contains(ReadOnlySpan, value) + /// for this pattern instead of Enumerable.Contains — this must produce the same SQL. + /// Two compiler patterns exist: + /// 1. MethodCallExpression(op_Implicit, array) — simple closure capture + /// 2. InvocationExpression(ConstantExpression(delegate), []) — pre-compiled closure + /// Both must be handled. + /// + [TestClass] + public class ArrayContainsTranslationTests + { + [TestMethod] + public void ArrayContains_TranslatesToSqlIn() + { + var ids = new[] { 1, 2, 3 }; + + using var connection = new DbConnectionStub(); + using var context = new DataContext(connection); + + var query = context.GetTable().Where(t => ids.Contains(t.Id)); + + using var command = context.GetCommand(query); + + Assert.AreEqual( + "SELECT [t0].[Id], [t0].[Discriminator], [t0].[X]" + Environment.NewLine + + "FROM [SimpleTable] AS [t0]" + Environment.NewLine + + "WHERE [t0].[Id] IN (@p0, @p1, @p2)", + command.CommandText); + } + + /// + /// Reproduces the InvocationExpression pattern: when array is passed as a parameter + /// to a helper method and used in a Where clause, .NET 10 compiler may generate + /// InvocationExpression(ConstantExpression(pre_compiled_delegate), []) instead of + /// MethodCallExpression(op_Implicit, array_expr) for the implicit T[]→ReadOnlySpan conversion. + /// + [TestMethod] + public void ArrayContains_PassedAsParameter_TranslatesToSqlIn() + { + TranslateContainsQuery(new[] { 1, 2, 3 }); + } + + private static void TranslateContainsQuery(int[] ids) + { + using var connection = new DbConnectionStub(); + using var context = new DataContext(connection); + + var query = context.GetTable() + .Where(t => ids.Contains(t.Id)); + + using var command = context.GetCommand(query); + + Assert.AreEqual( + "SELECT [t0].[Id], [t0].[Discriminator], [t0].[X]" + Environment.NewLine + + "FROM [SimpleTable] AS [t0]" + Environment.NewLine + + "WHERE [t0].[Id] IN (@p0, @p1, @p2)", + command.CommandText); + } + + [TestMethod] + public void ArrayContains_EmptyArray_TranslatesToFalse() + { + var ids = Array.Empty(); + + using var connection = new DbConnectionStub(); + using var context = new DataContext(connection); + + var query = context.GetTable().Where(t => ids.Contains(t.Id)); + + using var command = context.GetCommand(query); + + Assert.AreEqual( + "SELECT [t0].[Id], [t0].[Discriminator], [t0].[X]" + Environment.NewLine + + "FROM [SimpleTable] AS [t0]" + Environment.NewLine + + "WHERE 0 = 1", + command.CommandText); + } + } +} diff --git a/Mindbox.Data.Linq/Mindbox.Data.Linq.csproj b/Mindbox.Data.Linq/Mindbox.Data.Linq.csproj index a773987..fc5788e 100644 --- a/Mindbox.Data.Linq/Mindbox.Data.Linq.csproj +++ b/Mindbox.Data.Linq/Mindbox.Data.Linq.csproj @@ -1,30 +1,22 @@ - + - netstandard2.0 - Mindbox.Data.Linq - Mindbox + net8.0 + Mindbox.Data.Linq 3.2.0 A clone of Microsoft System.Data.Linq to allow multi-DLL extensibility and EF compatibility. false - Copyright 2019 (c) Mindbox - Mindbox - latest true true snupkg - 10.7.2$(VersionTag) + 10.8.1$(VersionTag) + SYSLIB0003;SYSLIB0011 - - - - - - - - - - + + + + + diff --git a/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs b/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs index bf2f79d..5064bd5 100644 --- a/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs +++ b/Mindbox.Data.Linq/SqlClient/Query/QueryConverter.cs @@ -1774,6 +1774,38 @@ private SqlExpression GetAggregate(SqlNodeType aggType, Type clrType, SqlExpress return new SqlUnary(aggType, clrType, sqlType, exp, this.dominatingExpression); } + /// + /// Extracts the underlying array/collection expression from a ReadOnlySpan expression. + /// In .NET 10, array.Contains() may generate two patterns for the implicit T[]→ReadOnlySpan conversion: + /// 1. MethodCallExpression(op_Implicit, [array_expr]) — extract array_expr directly + /// 2. InvocationExpression(ConstantExpression(pre_compiled_delegate), []) — extract array + /// from the delegate's closure target via reflection + /// + private static Expression TryExtractArrayFromSpanExpression(Expression spanExpr) { + // Pattern 1: MethodCallExpression — op_Implicit(array) + if (spanExpr is MethodCallExpression mc && mc.Arguments.Count == 1) + return mc.Arguments[0]; + + // Pattern 2: InvocationExpression(ConstantExpression(delegate), []) + // The compiler pre-compiled the implicit conversion to a zero-arg delegate. + // Extract the array from the delegate's closure. + if (spanExpr is InvocationExpression { Arguments.Count: 0 } invoke + && invoke.Expression is ConstantExpression constExpr + && constExpr.Value is Delegate del + && del.Target != null) { + var target = del.Target; + var elementType = spanExpr.Type.GetGenericArguments()[0]; + var arrayType = elementType.MakeArrayType(); + var arrayField = target.GetType() + .GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .FirstOrDefault(f => f.FieldType == arrayType); + if (arrayField != null) + return Expression.Constant(arrayField.GetValue(target), arrayType); + } + + return null; + } + private SqlNode VisitContains(Expression sequence, Expression value) { Type elemType = TypeSystem.GetElementType(sequence.Type); SqlNode seqNode = this.Visit(sequence); @@ -1879,6 +1911,21 @@ private SqlNode VisitMethodCall(MethodCallExpression mc) { if (this.IsSequenceOperatorCall(mc)) { return this.VisitSequenceOperatorCall(mc); } + // In .NET 10, array.Contains(value) in expression trees compiles to + // MemoryExtensions.Contains(ReadOnlySpan span, value). + // Two compiler patterns exist for the span argument: + // 1. MethodCallExpression(op_Implicit, [array]) — simple local capture + // 2. InvocationExpression(ConstantExpression(pre_compiled_delegate), []) — nested closure + // Both must be unwrapped to extract the underlying array for SQL IN translation. + else if (mc.Method.DeclaringType == typeof(MemoryExtensions) + && mc.Method.Name == "Contains" + && mc.Arguments[0].Type is { IsGenericType: true } spanType + && spanType.GetGenericTypeDefinition() == typeof(ReadOnlySpan<>)) { + var arrayExpr = TryExtractArrayFromSpanExpression(mc.Arguments[0]); + if (arrayExpr != null) { + return this.VisitContains(arrayExpr, mc.Arguments[1]); + } + } else if (IsDataManipulationCall(mc)) { return this.VisitDataManipulationCall(mc); } diff --git a/README.md b/README.md new file mode 100644 index 0000000..e855fb2 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# Mindbox.Data.Linq + +A fork of Microsoft's `System.Data.Linq` (LINQ to SQL) that enables multi-assembly extensibility and compatibility with modern .NET. + +## Why this fork? + +The original `System.Data.Linq` ships as a single sealed assembly. This fork splits the internals across multiple DLLs, making it possible to extend the query pipeline — custom SQL translators, mapping providers, and diagnostics hooks — without reflection hacks. + +## Target framework + +`net8.0` + +## Installation + +``` +dotnet add package Mindbox.Data.Linq +``` + +## Usage + +Drop-in replacement for `System.Data.Linq`. Replace the namespace import and use `DataContext` as usual: + +```csharp +using System.Data.Linq; + +using var context = new DataContext(connectionString, mappingSource); +var results = context.GetTable() + .Where(o => o.CustomerId == customerId) + .ToList(); +``` + +## Key differences from System.Data.Linq + +| Feature | System.Data.Linq | Mindbox.Data.Linq | +|---|---|---| +| Multi-assembly extensibility | ✗ | ✓ | +| .NET 10 `array.Contains()` in queries | ✗ | ✓ | +| Target framework | netstandard2.0 | net8.0 | + +## Building + +```bash +dotnet restore +dotnet build --configuration Release +dotnet test +``` + +## License + +MIT — see [LICENSE.txt](LICENSE.txt).