From 30196af8743b2d39fe52a7f793c69b2a466b9fb7 Mon Sep 17 00:00:00 2001 From: raymondhuy177 Date: Sun, 8 Aug 2021 00:08:28 +0700 Subject: [PATCH] add FromRawSql api --- .../Extensions/CosmosQueryableExtensions.cs | 66 +++ ...yableMethodTranslatingExpressionVisitor.cs | 22 + .../FromSqlCosmosEntityShaperExpression.cs | 56 +++ .../Query/Internal/FromSqlExpression.cs | 106 +++++ .../Internal/FromSqlQueryRootExpression.cs | 141 +++++++ .../Query/Internal/QuerySqlGenerator.cs | 102 ++++- .../Query/Internal/SelectExpression.cs | 13 + .../Query/Internal/SqlExpressionVisitor.cs | 10 + .../Internal/ParameterNameGenerator.cs | 32 ++ .../EFCore.Cosmos.FunctionalTests.csproj | 5 + .../Query/FromSqlQueryCosmosTest.cs | 389 ++++++++++++++++++ 11 files changed, 940 insertions(+), 2 deletions(-) create mode 100644 src/EFCore.Cosmos/Query/Internal/FromSqlCosmosEntityShaperExpression.cs create mode 100644 src/EFCore.Cosmos/Query/Internal/FromSqlExpression.cs create mode 100644 src/EFCore.Cosmos/Query/Internal/FromSqlQueryRootExpression.cs create mode 100644 src/EFCore.Cosmos/Storage/Internal/ParameterNameGenerator.cs create mode 100644 test/EFCore.Cosmos.FunctionalTests/Query/FromSqlQueryCosmosTest.cs diff --git a/src/EFCore.Cosmos/Extensions/CosmosQueryableExtensions.cs b/src/EFCore.Cosmos/Extensions/CosmosQueryableExtensions.cs index b83dc622604..e27ef64783b 100644 --- a/src/EFCore.Cosmos/Extensions/CosmosQueryableExtensions.cs +++ b/src/EFCore.Cosmos/Extensions/CosmosQueryableExtensions.cs @@ -5,6 +5,9 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; +using System.Runtime.CompilerServices; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore.Cosmos.Query.Internal; using Microsoft.EntityFrameworkCore.Query; using Microsoft.EntityFrameworkCore.Query.Internal; using Microsoft.EntityFrameworkCore.Utilities; @@ -46,5 +49,68 @@ source.Provider is EntityQueryProvider Expression.Constant(partitionKey))) : source; } + + /// + /// + /// Creates a LINQ query based on a raw SQL query. + /// + /// + /// If the database provider supports composing on the supplied SQL, you can compose on top of the raw SQL query using + /// LINQ operators: context.Blogs.FromSqlRaw("SELECT * FROM root c WHERE c["Discriminator"] = "Blog").OrderBy(b => b.Name). + /// + /// + /// As with any API that accepts SQL it is important to parameterize any user input to protect against a SQL injection + /// attack. You can include parameter place holders in the SQL query string and then supply parameter values as additional + /// arguments. Any parameter values you supply will automatically be converted to a DbParameter: + /// + /// context.Blogs.FromSqlRaw(""SELECT * FROM root c WHERE c["Discriminator"] = {0})", userSuppliedSearchTerm) + /// + /// + /// This overload also accepts DbParameter instances as parameter values. This allows you to use named + /// parameters in the SQL query string: + /// + /// context.Blogs.FromSqlRaw(""SELECT * FROM root c WHERE c["Discriminator"] = @searchTerm)", new SqlParameter("@searchTerm", userSuppliedSearchTerm)) + /// + /// The type of the elements of . + /// + /// An to use as the base of the raw SQL query (typically a ). + /// + /// The raw SQL query. + /// The values to be assigned to parameters. + /// An representing the raw SQL query. + [StringFormatMethod("sql")] + public static IQueryable FromSqlRaw( + this IQueryable source, + [NotParameterized] string sql, + params object[] parameters) + where TEntity : class + { + Check.NotNull(source, nameof(source)); + Check.NotEmpty(sql, nameof(sql)); + Check.NotNull(parameters, nameof(parameters)); + + return source.Provider.CreateQuery( + GenerateFromSqlQueryRoot( + source, + sql, + parameters)); + } + + private static FromSqlQueryRootExpression GenerateFromSqlQueryRoot( + IQueryable source, + string sql, + object?[] arguments, + [CallerMemberName] string memberName = null!) + { + var queryRootExpression = (QueryRootExpression)source.Expression; + + var entityType = queryRootExpression.EntityType; + + return new FromSqlQueryRootExpression( + queryRootExpression.QueryProvider!, + entityType, + sql, + Expression.Constant(arguments)); + } } } diff --git a/src/EFCore.Cosmos/Query/Internal/CosmosQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.Cosmos/Query/Internal/CosmosQueryableMethodTranslatingExpressionVisitor.cs index 6977c168f13..b036cc4cb8f 100644 --- a/src/EFCore.Cosmos/Query/Internal/CosmosQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.Cosmos/Query/Internal/CosmosQueryableMethodTranslatingExpressionVisitor.cs @@ -187,6 +187,28 @@ static bool TryGetPartitionKeyProperty(IEntityType entityType, out IProperty par } } + /// + protected override Expression VisitExtension(Expression extensionExpression) + { + switch (extensionExpression) + { + case FromSqlQueryRootExpression fromSqlQueryRootExpression: + var queryExpression = new SelectExpression( + fromSqlQueryRootExpression.EntityType, + fromSqlQueryRootExpression.Sql, + fromSqlQueryRootExpression.Argument); + + return new ShapedQueryExpression( + queryExpression, + new FromSqlCosmosEntityShaperExpression( + fromSqlQueryRootExpression.EntityType, + new ProjectionBindingExpression(queryExpression, new ProjectionMember(), typeof(ValueBuffer)), + false)); + default: + return base.VisitExtension(extensionExpression); + } + } + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore.Cosmos/Query/Internal/FromSqlCosmosEntityShaperExpression.cs b/src/EFCore.Cosmos/Query/Internal/FromSqlCosmosEntityShaperExpression.cs new file mode 100644 index 00000000000..e29f2f6d4f2 --- /dev/null +++ b/src/EFCore.Cosmos/Query/Internal/FromSqlCosmosEntityShaperExpression.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.EntityFrameworkCore.Utilities; + +namespace Microsoft.EntityFrameworkCore.Cosmos.Query.Internal +{ + /// + /// + /// An expression that represents creation of an entity instance for Cosmos provider in + /// . + /// + /// + /// This type is typically used by database providers (and other extensions). It is generally + /// not used in application code. + /// + /// + public class FromSqlCosmosEntityShaperExpression : EntityShaperExpression + { + /// + /// Creates a new instance of the class. + /// + /// The entity type to shape. + /// An expression of ValueBuffer to get values for properties of the entity. + /// A bool value indicating whether this entity instance can be null. + public FromSqlCosmosEntityShaperExpression(IEntityType entityType, Expression valueBufferExpression, bool nullable) + : base(entityType, valueBufferExpression, nullable, null) + { + } + + /// + protected override LambdaExpression GenerateMaterializationCondition(IEntityType entityType, bool nullable) + { + Check.NotNull(entityType, nameof(EntityType)); + + var valueBufferParameter = Parameter(typeof(ValueBuffer)); + Expression body; + var concreteEntityTypes = entityType.GetConcreteDerivedTypesInclusive().ToArray(); + + body = Constant(concreteEntityTypes.Length == 1 ? concreteEntityTypes[0] : entityType, typeof(IEntityType)); + + return Lambda(body, valueBufferParameter); + } + } +} diff --git a/src/EFCore.Cosmos/Query/Internal/FromSqlExpression.cs b/src/EFCore.Cosmos/Query/Internal/FromSqlExpression.cs new file mode 100644 index 00000000000..9c46113fd9a --- /dev/null +++ b/src/EFCore.Cosmos/Query/Internal/FromSqlExpression.cs @@ -0,0 +1,106 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Utilities; + +namespace Microsoft.EntityFrameworkCore.Cosmos.Query.Internal +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public class FromSqlExpression : RootReferenceExpression, ICloneable, IPrintableExpression + { + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public FromSqlExpression(IEntityType entityType, string alias, string sql, Expression arguments) : base(entityType, alias) + { + Check.NotEmpty(sql, nameof(sql)); + Check.NotNull(arguments, nameof(arguments)); + + Sql = sql; + Arguments = arguments; + } + + /// + /// The alias assigned to this table source. + /// + [NotNull] + public override string? Alias => base.Alias!; + + /// + /// The user-provided custom SQL for the table source. + /// + public virtual string Sql { get; } + + /// + /// The user-provided parameters passed to the custom SQL. + /// + public virtual Expression Arguments { get; } + + /// + /// Creates a new expression that is like this one, but using the supplied children. If all of the children are the same, it will + /// return this expression. + /// + /// The property of the result. + /// This expression if no children changed, or an expression with the updated children. + public virtual FromSqlExpression Update(Expression arguments) + { + Check.NotNull(arguments, nameof(arguments)); + + return arguments != Arguments + ? new FromSqlExpression(EntityType, Alias, Sql, arguments) + : this; + } + + /// + protected override Expression VisitChildren(ExpressionVisitor visitor) + { + Check.NotNull(visitor, nameof(visitor)); + + return this; + } + + /// + public override Type Type + => typeof(object); + + /// + public virtual object Clone() => new FromSqlExpression(EntityType, Alias, Sql, Arguments); + + /// + void IPrintableExpression.Print(ExpressionPrinter expressionPrinter) + { + Check.NotNull(expressionPrinter, nameof(expressionPrinter)); + + expressionPrinter.Append(Sql); + } + + /// + public override bool Equals(object? obj) + => obj != null + && (ReferenceEquals(this, obj) + || obj is FromSqlExpression fromSqlExpression + && Equals(fromSqlExpression)); + + private bool Equals(FromSqlExpression fromSqlExpression) + => base.Equals(fromSqlExpression) + && Sql == fromSqlExpression.Sql + && ExpressionEqualityComparer.Instance.Equals(Arguments, fromSqlExpression.Arguments); + + /// + public override int GetHashCode() + => HashCode.Combine(base.GetHashCode(), Sql); + } +} diff --git a/src/EFCore.Cosmos/Query/Internal/FromSqlQueryRootExpression.cs b/src/EFCore.Cosmos/Query/Internal/FromSqlQueryRootExpression.cs new file mode 100644 index 00000000000..935358d2006 --- /dev/null +++ b/src/EFCore.Cosmos/Query/Internal/FromSqlQueryRootExpression.cs @@ -0,0 +1,141 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Utilities; + +namespace Microsoft.EntityFrameworkCore.Cosmos.Query.Internal +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public class FromSqlQueryRootExpression : QueryRootExpression + { + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public FromSqlQueryRootExpression( + IAsyncQueryProvider queryProvider, + IEntityType entityType, + string sql, + Expression argument) + : base(queryProvider, entityType) + { + Check.NotEmpty(sql, nameof(sql)); + Check.NotNull(argument, nameof(argument)); + + Sql = sql; + Argument = argument; + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public FromSqlQueryRootExpression( + IEntityType entityType, + string sql, + Expression argument) + : base(entityType) + { + Check.NotEmpty(sql, nameof(sql)); + Check.NotNull(argument, nameof(argument)); + + Sql = sql; + Argument = argument; + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual string Sql { get; } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public virtual Expression Argument { get; } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public override Expression DetachQueryProvider() + => new FromSqlQueryRootExpression(EntityType, Sql, Argument); + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override Expression VisitChildren(ExpressionVisitor visitor) + { + var argument = visitor.Visit(Argument); + + return argument != Argument + ? new FromSqlQueryRootExpression(EntityType, Sql, argument) + : this; + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override void Print(ExpressionPrinter expressionPrinter) + { + Check.NotNull(expressionPrinter, nameof(expressionPrinter)); + + base.Print(expressionPrinter); + expressionPrinter.Append($".FromSql({Sql}, "); + expressionPrinter.Visit(Argument); + expressionPrinter.AppendLine(")"); + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public override bool Equals(object? obj) + => obj != null + && (ReferenceEquals(this, obj) + || obj is FromSqlQueryRootExpression queryRootExpression + && Equals(queryRootExpression)); + + private bool Equals(FromSqlQueryRootExpression queryRootExpression) + => base.Equals(queryRootExpression) + && string.Equals(Sql, queryRootExpression.Sql, StringComparison.OrdinalIgnoreCase) + && ExpressionEqualityComparer.Instance.Equals(Argument, queryRootExpression.Argument); + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public override int GetHashCode() + => HashCode.Combine(base.GetHashCode(), Sql, ExpressionEqualityComparer.Instance.GetHashCode(Argument)); + } +} diff --git a/src/EFCore.Cosmos/Query/Internal/QuerySqlGenerator.cs b/src/EFCore.Cosmos/Query/Internal/QuerySqlGenerator.cs index 1a75b103766..357a88389ad 100644 --- a/src/EFCore.Cosmos/Query/Internal/QuerySqlGenerator.cs +++ b/src/EFCore.Cosmos/Query/Internal/QuerySqlGenerator.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Data.Common; using System.Linq; using System.Linq.Expressions; using System.Text; @@ -29,6 +30,7 @@ public class QuerySqlGenerator : SqlExpressionVisitor private IReadOnlyDictionary _parameterValues; private List _sqlParameters; private bool _useValueProjection; + private ParameterNameGenerator _parameterNameGenerator; private readonly IDictionary _operatorMap = new Dictionary { @@ -77,9 +79,9 @@ public virtual CosmosSqlQuery GetSqlQuery( _sqlBuilder.Clear(); _parameterValues = parameterValues; _sqlParameters = new List(); + _parameterNameGenerator = new ParameterNameGenerator(); Visit(selectExpression); - return new CosmosSqlQuery(_sqlBuilder.ToString(), _sqlParameters); } @@ -225,7 +227,14 @@ protected override Expression VisitSelect(SelectExpression selectExpression) _sqlBuilder.AppendLine(); - _sqlBuilder.Append("FROM root "); + if (selectExpression.FromExpression is FromSqlExpression) + { + _sqlBuilder.Append("FROM "); + } + else + { + _sqlBuilder.Append("FROM root "); + } Visit(selectExpression.FromExpression); _sqlBuilder.AppendLine(); @@ -272,6 +281,95 @@ protected override Expression VisitSelect(SelectExpression selectExpression) return selectExpression; } + /// + protected override Expression VisitFromSql(FromSqlExpression fromSqlExpression) + { + Check.NotNull(fromSqlExpression, nameof(fromSqlExpression)); + + string[] substitutions = null; + var sql = fromSqlExpression.Sql; + + switch (fromSqlExpression.Arguments) + { + case ConstantExpression constantExpression: + var existingValues = constantExpression.GetConstantValue(); + substitutions = new string[existingValues.Length]; + for (int i = 0; i < existingValues.Length; i++) + { + var value = existingValues[i]; + + if (value is DbParameter dbParameter) + { + if (string.IsNullOrEmpty(dbParameter.ParameterName)) + { + string parameterName = _parameterNameGenerator.GenerateNext(); + _sqlParameters.Add(new SqlParameter(parameterName, dbParameter.Value)); + substitutions[i] = parameterName; + } + else + { + _sqlParameters.Add(new SqlParameter(dbParameter.ParameterName, dbParameter.Value)); + } + } + else + { + string parameterName = _parameterNameGenerator.GenerateNext(); + _sqlParameters.Add(new SqlParameter(parameterName, value)); + substitutions[i] = parameterName; + } + } + break; + case ParameterExpression parameterExpression: + if (_parameterValues.ContainsKey(parameterExpression.Name)) + { + var parameterValues = (object[])_parameterValues[parameterExpression.Name]; + substitutions = new string[parameterValues.Length]; + for (int i = 0; i < parameterValues.Length; i++) + { + var value = parameterValues[i]; + + if (value is DbParameter dbParameter) + { + if (string.IsNullOrEmpty(dbParameter.ParameterName)) + { + string parameterName = _parameterNameGenerator.GenerateNext(); + _sqlParameters.Add(new SqlParameter(parameterName, dbParameter.Value)); + substitutions[i] = parameterName; + } + else + { + _sqlParameters.Add(new SqlParameter(dbParameter.ParameterName, dbParameter.Value)); + } + } + else + { + string parameterName = _parameterNameGenerator.GenerateNext(); + _sqlParameters.Add(new SqlParameter(parameterName, value)); + substitutions[i] = parameterName; + } + } + } + break; + default: + break; + } + + _sqlBuilder.AppendLine("("); + + if (substitutions != null) + { + sql = string.Format(sql, substitutions); + } + + _sqlBuilder.Append(sql); + + _sqlBuilder.Append(")"); + + _sqlBuilder.Append($" {fromSqlExpression.Alias}"); + + return fromSqlExpression; + } + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore.Cosmos/Query/Internal/SelectExpression.cs b/src/EFCore.Cosmos/Query/Internal/SelectExpression.cs index cae1cbbed00..943513d2ea3 100644 --- a/src/EFCore.Cosmos/Query/Internal/SelectExpression.cs +++ b/src/EFCore.Cosmos/Query/Internal/SelectExpression.cs @@ -45,6 +45,19 @@ public SelectExpression(IEntityType entityType) _projectionMapping[new ProjectionMember()] = new EntityProjectionExpression(entityType, FromExpression); } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + public SelectExpression(IEntityType entityType, string sql, Expression argument) + { + Container = entityType.GetContainer(); + FromExpression = new FromSqlExpression(entityType, RootAlias, sql, argument); + _projectionMapping[new ProjectionMember()] = new EntityProjectionExpression(entityType, new RootReferenceExpression(entityType, RootAlias)); + } + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore.Cosmos/Query/Internal/SqlExpressionVisitor.cs b/src/EFCore.Cosmos/Query/Internal/SqlExpressionVisitor.cs index 151de23dc5f..188acef3f09 100644 --- a/src/EFCore.Cosmos/Query/Internal/SqlExpressionVisitor.cs +++ b/src/EFCore.Cosmos/Query/Internal/SqlExpressionVisitor.cs @@ -46,6 +46,9 @@ protected override Expression VisitExtension(Expression extensionExpression) case ObjectArrayProjectionExpression arrayProjectionExpression: return VisitObjectArrayProjection(arrayProjectionExpression); + case FromSqlExpression fromSqlExpression: + return VisitFromSql(fromSqlExpression); + case RootReferenceExpression rootReferenceExpression: return VisitRootReference(rootReferenceExpression); @@ -83,6 +86,13 @@ protected override Expression VisitExtension(Expression extensionExpression) return base.VisitExtension(extensionExpression); } + /// + /// Visits the children of the from sql expression. + /// + /// The expression to visit. + /// The modified expression, if it or any subexpression was modified; otherwise, returns the original expression. + protected abstract Expression VisitFromSql(FromSqlExpression fromSqlExpression); + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/src/EFCore.Cosmos/Storage/Internal/ParameterNameGenerator.cs b/src/EFCore.Cosmos/Storage/Internal/ParameterNameGenerator.cs new file mode 100644 index 00000000000..83d5e58e38a --- /dev/null +++ b/src/EFCore.Cosmos/Storage/Internal/ParameterNameGenerator.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Cosmos.Storage.Internal +{ + /// + /// + /// Generates unique names for parameters. + /// + /// + /// This type is typically used by database providers (and other extensions). It is generally + /// not used in application code. + /// + /// + public class ParameterNameGenerator + { + private int _count; + + /// + /// Generates the next unique parameter name. + /// + /// The generated name. + public virtual string GenerateNext() + => "@p" + _count++; + + /// + /// Resets the generator, meaning it can reuse previously generated names. + /// + public virtual void Reset() + => _count = 0; + } +} diff --git a/test/EFCore.Cosmos.FunctionalTests/EFCore.Cosmos.FunctionalTests.csproj b/test/EFCore.Cosmos.FunctionalTests/EFCore.Cosmos.FunctionalTests.csproj index ca49776d0ea..a77553be351 100644 --- a/test/EFCore.Cosmos.FunctionalTests/EFCore.Cosmos.FunctionalTests.csproj +++ b/test/EFCore.Cosmos.FunctionalTests/EFCore.Cosmos.FunctionalTests.csproj @@ -14,6 +14,10 @@ PreserveNewest + + + + @@ -21,6 +25,7 @@ + diff --git a/test/EFCore.Cosmos.FunctionalTests/Query/FromSqlQueryCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/Query/FromSqlQueryCosmosTest.cs new file mode 100644 index 00000000000..a7059fe9495 --- /dev/null +++ b/test/EFCore.Cosmos.FunctionalTests/Query/FromSqlQueryCosmosTest.cs @@ -0,0 +1,389 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Linq.Expressions; +using System.Threading.Tasks; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore.TestModels.Northwind; +using Microsoft.EntityFrameworkCore.TestUtilities; +using Microsoft.EntityFrameworkCore.Utilities; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.EntityFrameworkCore.Query +{ + public class FromSqlQueryCosmosTest : QueryTestBase> + { + public FromSqlQueryCosmosTest( + NorthwindQueryCosmosFixture fixture, + ITestOutputHelper testOutputHelper) + : base(fixture) + { + ClearLog(); + } + + protected NorthwindContext CreateContext() + => Fixture.CreateContext(); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task FromSqlRaw_queryable_simple(bool async) + { + using var context = CreateContext(); + var query = context.Set() + .FromSqlRaw(@"SELECT * + FROM root c + WHERE c[""Discriminator""] = ""Customer"" AND c[""ContactName""] LIKE '%z%'"); + + var actual = async + ? await query.ToArrayAsync() + : query.ToArray(); + + Assert.Equal(14, actual.Length); + Assert.Equal(14, context.ChangeTracker.Entries().Count()); + + AssertSql(@"SELECT c +FROM ( +SELECT * + FROM root c + WHERE c[""Discriminator""] = ""Customer"" AND c[""ContactName""] LIKE '%z%') c +"); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task FromSqlRaw_queryable_simple_columns_out_of_order(bool async) + { + using var context = CreateContext(); + var query = context.Set().FromSqlRaw( + NormalizeDelimitersInRawString( + @"SELECT c[id], c[Region], c[PostalCode], c[Phone], c[Fax], c[CustomerID], c[Country], c[ContactTitle], c[ContactName], c[CompanyName], c[City], c[Address] + FROM root c + WHERE c[Discriminator] = ""Customer""")); + + var actual = async + ? await query.ToArrayAsync() + : query.ToArray(); + + Assert.Equal(91, actual.Length); + Assert.Equal(91, context.ChangeTracker.Entries().Count()); + AssertSql(@"SELECT c +FROM ( +SELECT c[""id""], c[""Region""], c[""PostalCode""], c[""Phone""], c[""Fax""], c[""CustomerID""], c[""Country""], c[""ContactTitle""], c[""ContactName""], c[""CompanyName""], c[""City""], c[""Address""] + FROM root c + WHERE c[""Discriminator""] = ""Customer"") c +"); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task FromSqlRaw_queryable_simple_columns_out_of_order_and_extra_columns(bool async) + { + using var context = CreateContext(); + var query = context.Set().FromSqlRaw( + NormalizeDelimitersInRawString( + @"SELECT c[id], c[Region], c[PostalCode], c[PostalCode] AS Foo, c[Phone], c[Fax], c[CustomerID], c[Country], c[ContactTitle], c[ContactName], c[CompanyName], c[City], c[Address] + FROM root c + WHERE c[Discriminator] = ""Customer""")); + + var actual = async + ? await query.ToArrayAsync() + : query.ToArray(); + + Assert.Equal(91, actual.Length); + Assert.Equal(91, context.ChangeTracker.Entries().Count()); + + AssertSql(@"SELECT c +FROM ( +SELECT c[""id""], c[""Region""], c[""PostalCode""], c[""PostalCode""] AS Foo, c[""Phone""], c[""Fax""], c[""CustomerID""], c[""Country""], c[""ContactTitle""], c[""ContactName""], c[""CompanyName""], c[""City""], c[""Address""] + FROM root c + WHERE c[""Discriminator""] = ""Customer"") c +"); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task FromSqlRaw_queryable_composed(bool async) + { + using var context = CreateContext(); + var query = context.Set().FromSqlRaw( + NormalizeDelimitersInRawString(@"SELECT * FROM root c WHERE c[Discriminator] = ""Customer""")) + .Where(c => c.ContactName.Contains("z")); + + var sql = query.ToQueryString(); + + var actual = async + ? await query.ToArrayAsync() + : query.ToArray(); + + Assert.Equal(14, actual.Length); + Assert.Equal(14, context.ChangeTracker.Entries().Count()); + + AssertSql(@"SELECT c +FROM ( +SELECT * FROM root c WHERE c[""Discriminator""] = ""Customer"") c +WHERE CONTAINS(c[""ContactName""], ""z"")"); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task FromSqlRaw_queryable_composed_compiled(bool async) + { + if (async) + { + var query = EF.CompileAsyncQuery( + (NorthwindContext context) => context.Set() + .FromSqlRaw(NormalizeDelimitersInRawString(@"SELECT * FROM root c WHERE c[Discriminator] = ""Customer""")) + .Where(c => c.ContactName.Contains("z"))); + + using (var context = CreateContext()) + { + var actual = await query(context).ToListAsync(); + + Assert.Equal(14, actual.Count); + } + } + else + { + var query = EF.CompileQuery( + (NorthwindContext context) => context.Set() + .FromSqlRaw(NormalizeDelimitersInRawString(@"SELECT * FROM root c WHERE c[Discriminator] = ""Customer""")) + .Where(c => c.ContactName.Contains("z"))); + + using (var context = CreateContext()) + { + var actual = query(context).ToArray(); + + Assert.Equal(14, actual.Length); + } + } + + AssertSql(@"SELECT c +FROM ( +SELECT * FROM root c WHERE c[""Discriminator""] = ""Customer"") c +WHERE CONTAINS(c[""ContactName""], ""z"")"); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task FromSqlRaw_queryable_composed_compiled_with_DbParameter(bool async) + { + if (async) + { + var query = EF.CompileAsyncQuery( + (NorthwindContext context) => context.Set() + .FromSqlRaw( + NormalizeDelimitersInRawString(@"SELECT * FROM root c WHERE c[Discriminator] = ""Customer"" AND c[CustomerID] = @customer"), + CreateDbParameter("@customer", "CONSH")) + .Where(c => c.ContactName.Contains("z"))); + + using (var context = CreateContext()) + { + var actual = await query(context).ToListAsync(); + + Assert.Single(actual); + } + } + else + { + var query = EF.CompileQuery( + (NorthwindContext context) => context.Set() + .FromSqlRaw( + NormalizeDelimitersInRawString(@"SELECT * FROM root c WHERE c[Discriminator] = ""Customer"" AND c[CustomerID] = @customer"), + CreateDbParameter("@customer", "CONSH")) + .Where(c => c.ContactName.Contains("z"))); + + using (var context = CreateContext()) + { + var actual = query(context).ToArray(); + + Assert.Single(actual); + } + } + + AssertSql(@"@customer='CONSH' + +SELECT c +FROM ( +SELECT * FROM root c WHERE c[""Discriminator""] = ""Customer"" AND c[""CustomerID""] = @customer) c +WHERE CONTAINS(c[""ContactName""], ""z"")"); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task FromSqlRaw_queryable_composed_compiled_with_nameless_DbParameter(bool async) + { + if (async) + { + var query = EF.CompileAsyncQuery( + (NorthwindContext context) => context.Set() + .FromSqlRaw( + NormalizeDelimitersInRawString(@"SELECT * FROM root c WHERE c[Discriminator] = ""Customer"" AND c[CustomerID] = {0}"), + CreateDbParameter(null, "CONSH")) + .Where(c => c.ContactName.Contains("z"))); + + using (var context = CreateContext()) + { + var actual = await query(context).ToListAsync(); + + Assert.Single(actual); + } + } + else + { + var query = EF.CompileQuery( + (NorthwindContext context) => context.Set() + .FromSqlRaw( + NormalizeDelimitersInRawString(@"SELECT * FROM root c WHERE c[Discriminator] = ""Customer"" AND c[CustomerID] = {0}"), + CreateDbParameter(null, "CONSH")) + .Where(c => c.ContactName.Contains("z"))); + + using (var context = CreateContext()) + { + var actual = query(context).ToArray(); + + Assert.Single(actual); + } + } + + AssertSql(@"@p0='CONSH' + +SELECT c +FROM ( +SELECT * FROM root c WHERE c[""Discriminator""] = ""Customer"" AND c[""CustomerID""] = @p0) c +WHERE CONTAINS(c[""ContactName""], ""z"")"); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task FromSqlRaw_queryable_with_parameters(bool async) + { + var city = "London"; + var contactTitle = "Sales Representative"; + + using var context = CreateContext(); + var query = context.Set().FromSqlRaw( + NormalizeDelimitersInRawString(@"SELECT * FROM root c WHERE c[Discriminator] = ""Customer"" AND c[City] = {0} AND c[ContactTitle] = {1}"), city, + contactTitle); + + var actual = async + ? await query.ToArrayAsync() + : query.ToArray(); + + Assert.Equal(3, actual.Length); + Assert.True(actual.All(c => c.City == "London")); + Assert.True(actual.All(c => c.ContactTitle == "Sales Representative")); + + AssertSql(@"@p0='London' +@p1='Sales Representative' + +SELECT c +FROM ( +SELECT * FROM root c WHERE c[""Discriminator""] = ""Customer"" AND c[""City""] = @p0 AND c[""ContactTitle""] = @p1) c +"); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task FromSqlRaw_queryable_with_parameters_inline(bool async) + { + using var context = CreateContext(); + var query = context.Set().FromSqlRaw( + NormalizeDelimitersInRawString(@"SELECT * FROM root c WHERE c[Discriminator] = ""Customer"" AND c[City] = {0} AND c[ContactTitle] = {1}"), "London", + "Sales Representative"); + + var actual = async + ? await query.ToArrayAsync() + : query.ToArray(); + + Assert.Equal(3, actual.Length); + Assert.True(actual.All(c => c.City == "London")); + Assert.True(actual.All(c => c.ContactTitle == "Sales Representative")); + + AssertSql(@"@p0='London' +@p1='Sales Representative' + +SELECT c +FROM ( +SELECT * FROM root c WHERE c[""Discriminator""] = ""Customer"" AND c[""City""] = @p0 AND c[""ContactTitle""] = @p1) c +"); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task FromSqlRaw_queryable_with_null_parameter(bool async) + { + uint? reportsTo = null; + + using var context = CreateContext(); + var query = context.Set().FromSqlRaw( + NormalizeDelimitersInRawString( + @"SELECT * FROM root c WHERE c[Discriminator] = ""Employee"" AND c[ReportsTo] = {0} OR (IS_NULL(c[ReportsTo]) AND IS_NULL({0}))"), reportsTo); + + var actual = async + ? await query.ToArrayAsync() + : query.ToArray(); + + Assert.Single(actual); + + AssertSql(@"@p0=null + +SELECT c +FROM ( +SELECT * FROM root c WHERE c[""Discriminator""] = ""Employee"" AND c[""ReportsTo""] = @p0 OR (IS_NULL(c[""ReportsTo""]) AND IS_NULL(@p0))) c +"); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task FromSqlRaw_queryable_with_parameters_and_closure(bool async) + { + var city = "London"; + var contactTitle = "Sales Representative"; + + using var context = CreateContext(); + var query = context.Set().FromSqlRaw( + NormalizeDelimitersInRawString(@"SELECT * FROM root c WHERE c[Discriminator] = ""Customer"" AND c[City] = {0}"), city) + .Where(c => c.ContactTitle == contactTitle); + var queryString = query.ToQueryString(); + + var actual = async + ? await query.ToArrayAsync() + : query.ToArray(); + + Assert.Equal(3, actual.Length); + Assert.True(actual.All(c => c.City == "London")); + Assert.True(actual.All(c => c.ContactTitle == "Sales Representative")); + + AssertSql(@"@p0='London' +@__contactTitle_1='Sales Representative' + +SELECT c +FROM ( +SELECT * FROM root c WHERE c[""Discriminator""] = ""Customer"" AND c[""City""] = @p0) c +WHERE (c[""ContactTitle""] = @__contactTitle_1)"); + } + + protected DbParameter CreateDbParameter(string name, object value) + => new SqlParameter { ParameterName = name, Value = value }; + + private void AssertSql(params string[] expected) + => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); + + protected void ClearLog() + => Fixture.TestSqlLoggerFactory.Clear(); + + public virtual string NormalizeDelimitersInRawString(string sql) + => sql.Replace("[", OpenDelimiter).Replace("]", CloseDelimiter); + + protected virtual string OpenDelimiter + => "[\""; + + protected virtual string CloseDelimiter + => "\"]"; + } +}