diff --git a/Application/EdFi.Ods.Api/Controllers/DataManagementControllerBase.cs b/Application/EdFi.Ods.Api/Controllers/DataManagementControllerBase.cs index fd8faf7c3..6b7d3f645 100644 --- a/Application/EdFi.Ods.Api/Controllers/DataManagementControllerBase.cs +++ b/Application/EdFi.Ods.Api/Controllers/DataManagementControllerBase.cs @@ -72,6 +72,8 @@ public abstract class DataManagementControllerBase> GetManyPipeline; protected Lazy> PutPipeline; + private static readonly IContextStorage _contextStorage = new CallContextStorage(); + protected DataManagementControllerBase( IPipelineFactory pipelineFactory, IEdFiProblemDetailsProvider problemDetailsProvider, @@ -148,7 +150,13 @@ public virtual async Task GetAll( [FromQuery] Dictionary additionalParameters = default) { //respond quickly to DOS style requests (should we catch these earlier? e.g. attribute filter?) - + + // Store alternative auth approach decision into call context + if (additionalParameters?.TryGetValue("useJoinAuth", out string useJoinAuth) == true) + { + _contextStorage.SetValue("UseJoinAuth", Convert.ToBoolean(useJoinAuth)); + } + var queryParameters = new QueryParameters(urlQueryParametersRequest); if (!QueryParametersValidator.IsValid(queryParameters, _defaultPageLimitSize, out string errorMessage)) diff --git a/Application/EdFi.Ods.Api/Controllers/Partitions/Controllers/PartitionsController.cs b/Application/EdFi.Ods.Api/Controllers/Partitions/Controllers/PartitionsController.cs index a58a223d3..fb135e7a1 100644 --- a/Application/EdFi.Ods.Api/Controllers/Partitions/Controllers/PartitionsController.cs +++ b/Application/EdFi.Ods.Api/Controllers/Partitions/Controllers/PartitionsController.cs @@ -48,6 +48,8 @@ public class PartitionsController : ControllerBase private readonly IOdsDatabaseConnectionStringProvider _odsDatabaseConnectionStringProvider; private readonly IPartitionsQueryBuilderProvider _partitionsQueryBuilderProvider; + private static readonly IContextStorage _contextStorage = new CallContextStorage(); + public PartitionsController( DbProviderFactory dbProviderFactory, IContextProvider dataManagementResourceContextProvider, @@ -67,6 +69,12 @@ public async Task Get( [FromQuery] int number = 1, [FromQuery] Dictionary additionalParameters = default) { + // Store alternative auth approach decision into call context + if (additionalParameters?.TryGetValue("useJoinAuth", out string useJoinAuth) == true) + { + _contextStorage.SetValue("UseJoinAuth", Convert.ToBoolean(useJoinAuth)); + } + if (number is < 1 or > 200) { var problemDetails = new BadRequestParameterException( diff --git a/Application/EdFi.Ods.Api/Controllers/Partitions/Controllers/PartitionsQueryBuilderProvider.cs b/Application/EdFi.Ods.Api/Controllers/Partitions/Controllers/PartitionsQueryBuilderProvider.cs index fbbdc98e7..84537b8a9 100644 --- a/Application/EdFi.Ods.Api/Controllers/Partitions/Controllers/PartitionsQueryBuilderProvider.cs +++ b/Application/EdFi.Ods.Api/Controllers/Partitions/Controllers/PartitionsQueryBuilderProvider.cs @@ -43,9 +43,11 @@ public QueryBuilder GetQueryBuilder( var cteCountQueryBuilder = cteQueryBuilder.Clone(); bool hasDistinct = cteCountQueryBuilder.HasDistinct(); cteCountQueryBuilder.ClearSelect(); + cteCountQueryBuilder.ClearWith(); cteCountQueryBuilder.SelectRaw($"COUNT({(hasDistinct ? "DISTINCT " : null)}AggregateId) AS CountOfRows"); - var queryBuilder = new QueryBuilder(_dialect).With("Numbered", cteQueryBuilder) + var queryBuilder = new QueryBuilder(_dialect) + .With("Numbered", cteQueryBuilder) .With("Counts", cteCountQueryBuilder) .From("Numbered, Counts") .Select("AggregateId") diff --git a/Application/EdFi.Ods.Api/Security/Authorization/Repositories/AggregateRootQueryBuilderProviderCteAuthorizationDecorator.cs b/Application/EdFi.Ods.Api/Security/Authorization/Repositories/AggregateRootQueryBuilderProviderCteAuthorizationDecorator.cs new file mode 100644 index 000000000..1e69519c7 --- /dev/null +++ b/Application/EdFi.Ods.Api/Security/Authorization/Repositories/AggregateRootQueryBuilderProviderCteAuthorizationDecorator.cs @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: Apache-2.0 +// Licensed to the Ed-Fi Alliance under one or more agreements. +// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. +// See the LICENSE and NOTICES files in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using EdFi.Ods.Common; +using EdFi.Ods.Api.Security.Authorization.Filtering; +using EdFi.Ods.Api.Security.AuthorizationStrategies.Relationships.Filters; +using EdFi.Ods.Common.Context; +using EdFi.Ods.Common.Database.Querying; +using EdFi.Ods.Common.Infrastructure.Filtering; +using EdFi.Ods.Common.Models.Domain; +using EdFi.Ods.Common.Providers.Queries; +using EdFi.Ods.Common.Security.Authorization; + +namespace EdFi.Ods.Api.Security.Authorization.Repositories +{ + /// + /// Provides an abstract implementation for applying authorization filters to queries on aggregate roots built using the . + /// + public class AggregateRootQueryBuilderProviderCteAuthorizationDecorator : IAggregateRootQueryBuilderProvider + { + private readonly IAggregateRootQueryBuilderProvider _decoratedInstance; + private readonly IContextProvider _authorizationPlanContextProvider; + private readonly IAuthorizationFilterDefinitionProvider _authorizationFilterDefinitionProvider; + + public AggregateRootQueryBuilderProviderCteAuthorizationDecorator( + IAggregateRootQueryBuilderProvider decoratedInstance, + IContextProvider authorizationPlanContextProvider, + IAuthorizationFilterDefinitionProvider authorizationFilterDefinitionProvider) + { + _decoratedInstance = decoratedInstance; + _authorizationPlanContextProvider = authorizationPlanContextProvider; + _authorizationFilterDefinitionProvider = authorizationFilterDefinitionProvider; + } + + /// + /// Applies the authorization filtering criteria to the query created by the decorated instance. + /// + /// + /// An instance of the entity representing the parameters to the query. + /// The parameter values to apply to the query. + /// Additional parameters supplied by the API client that are resource-level properties or common parameters. + /// The criteria created by the decorated instance. + public QueryBuilder GetQueryBuilder( + Entity aggregateRootEntity, + AggregateRootWithCompositeKey specification, + IQueryParameters queryParameters, + IDictionary additionalQueryParameters) + { + var queryBuilder = _decoratedInstance.GetQueryBuilder( + aggregateRootEntity, + specification, + queryParameters, + additionalQueryParameters); + + var authorizationPlan = _authorizationPlanContextProvider.Get(); + + // Process unless join-based auth has been indicated + bool shouldUseJoinAuth = additionalQueryParameters?.TryGetValue("UseJoinAuth", out string useJoinAuth) == true + && Convert.ToBoolean(useJoinAuth); + + if (shouldUseJoinAuth) + { + return queryBuilder; + } + + var unsupportedAuthorizationFilters = new HashSet(); + + // If there are multiple relationship-based authorization strategies with views (that are combined with OR), we must use left outer joins and null/not null checks + var relationshipBasedAuthViewJoinType = DetermineRelationshipBasedAuthViewJoinType(); + + ApplyAuthorizationStrategiesCombinedWithAndLogic(); + ApplyAuthorizationStrategiesCombinedWithOrLogic(); + + return queryBuilder; + + JoinType? DetermineRelationshipBasedAuthViewJoinType() + { + // NOTE: Relationship-based authorization filters are combined using OR, while custom auth-view filters are combined using AND + var countOfRelationshipBasedAuthorizationFilters = authorizationPlan.Filtering.Count( + af => af.Operator == FilterOperator.Or && af.Filters.Select(afd => + { + if (_authorizationFilterDefinitionProvider.TryGetAuthorizationFilterDefinition(afd.FilterName, out var filterDetails)) + { + return filterDetails; + }; + + unsupportedAuthorizationFilters.Add(afd.FilterName); + + return null; + }) + .Where(x => x != null) + .OfType() + .Any()); + + return countOfRelationshipBasedAuthorizationFilters switch + { + 0 => null, + 1 => JoinType.InnerJoin, + _ => JoinType.LeftOuterJoin + }; + } + + void ApplyAuthorizationStrategiesCombinedWithAndLogic() + { + var andStrategies = authorizationPlan.Filtering.Where(x => x.Operator == FilterOperator.And).ToArray(); + + // Combine 'AND' strategies + foreach (var andStrategy in andStrategies) + { + if (!TryApplyFilters(queryBuilder, andStrategy.Filters, andStrategy.AuthorizationStrategy, JoinType.InnerJoin)) + { + // All filters for AND strategies must be applied, and if not, this is an error condition + throw new Exception($"The following authorization filters are not recognized: {string.Join(" ", unsupportedAuthorizationFilters)}"); + } + } + } + + void ApplyAuthorizationStrategiesCombinedWithOrLogic() + { + var orStrategies = authorizationPlan.Filtering + .Where(x => x.Operator == FilterOperator.Or) + .ToArray(); + + bool disjunctionFiltersApplied = false; + + // Combine 'OR' strategies + queryBuilder.Where( + disjunctionBuilder => + { + foreach (var orStrategy in orStrategies) + { + disjunctionBuilder.OrWhere( + filtersConjunctionBuilder => + { + // Combine filters with 'AND' + if (TryApplyFilters( + filtersConjunctionBuilder, + orStrategy.Filters, + orStrategy.AuthorizationStrategy, + relationshipBasedAuthViewJoinType ?? JoinType.InnerJoin)) + { + disjunctionFiltersApplied = true; + } + + return filtersConjunctionBuilder; + }); + } + + return disjunctionBuilder; + }); + + // If we have some OR strategies with filters defined, but no filters were applied, this is an error condition + if (orStrategies.SelectMany(s => s.Filters).Any() && !disjunctionFiltersApplied) + { + throw new Exception($"The following authorization filters are not recognized: {string.Join(" ", unsupportedAuthorizationFilters)}"); + } + } + + bool TryApplyFilters( + QueryBuilder conjunctionQueryBuilder, + IReadOnlyList filters, + IAuthorizationStrategy authorizationStrategy, + JoinType joinType) + { + bool allFiltersCanBeApplied = true; + + foreach (var filterDetails in filters) + { + if (!_authorizationFilterDefinitionProvider.TryGetAuthorizationFilterDefinition( + filterDetails.FilterName, + out var ignored)) + { + unsupportedAuthorizationFilters.Add(filterDetails.FilterName); + + allFiltersCanBeApplied = false; + } + } + + if (!allFiltersCanBeApplied) + { + return false; + } + + bool filtersApplied = false; + + foreach (var filterContext in filters) + { + _authorizationFilterDefinitionProvider.TryGetAuthorizationFilterDefinition( + filterContext.FilterName, + out var filterDefinition); + + var parameterValues = filterContext.ClaimParameterName == null + ? new Dictionary() + : new Dictionary + { + { filterContext.ClaimParameterName, filterContext.ClaimParameterValues } + }; + + // Apply the authorization strategy filter + filterDefinition.CriteriaApplicator( + queryBuilder, + conjunctionQueryBuilder, + filterContext.SubjectEndpointNames, + parameterValues, + joinType, + authorizationStrategy); + + filtersApplied = true; + } + + return filtersApplied; + } + } + } +} diff --git a/Application/EdFi.Ods.Api/Security/Authorization/Repositories/AggregateRootQueryBuilderProviderAuthorizationDecorator.cs b/Application/EdFi.Ods.Api/Security/Authorization/Repositories/AggregateRootQueryBuilderProviderJoinAuthorizationDecorator.cs similarity index 95% rename from Application/EdFi.Ods.Api/Security/Authorization/Repositories/AggregateRootQueryBuilderProviderAuthorizationDecorator.cs rename to Application/EdFi.Ods.Api/Security/Authorization/Repositories/AggregateRootQueryBuilderProviderJoinAuthorizationDecorator.cs index d177ee310..1c8048852 100644 --- a/Application/EdFi.Ods.Api/Security/Authorization/Repositories/AggregateRootQueryBuilderProviderAuthorizationDecorator.cs +++ b/Application/EdFi.Ods.Api/Security/Authorization/Repositories/AggregateRootQueryBuilderProviderJoinAuthorizationDecorator.cs @@ -21,13 +21,13 @@ namespace EdFi.Ods.Api.Security.Authorization.Repositories /// /// Provides an abstract implementation for applying authorization filters to queries on aggregate roots built using the . /// - public class AggregateRootQueryBuilderProviderAuthorizationDecorator : IAggregateRootQueryBuilderProvider + public class AggregateRootQueryBuilderProviderJoinAuthorizationDecorator : IAggregateRootQueryBuilderProvider { private readonly IAggregateRootQueryBuilderProvider _decoratedInstance; private readonly IContextProvider _authorizationPlanContextProvider; private readonly IAuthorizationFilterDefinitionProvider _authorizationFilterDefinitionProvider; - public AggregateRootQueryBuilderProviderAuthorizationDecorator( + public AggregateRootQueryBuilderProviderJoinAuthorizationDecorator( IAggregateRootQueryBuilderProvider decoratedInstance, IContextProvider authorizationPlanContextProvider, IAuthorizationFilterDefinitionProvider authorizationFilterDefinitionProvider) @@ -59,6 +59,15 @@ public QueryBuilder GetQueryBuilder( var authorizationPlan = _authorizationPlanContextProvider.Get(); + // Process if join-based auth has been indicated + bool shouldUseJoinAuth = additionalQueryParameters?.TryGetValue("UseJoinAuth", out string useJoinAuth) == true + && Convert.ToBoolean(useJoinAuth); + + if (!shouldUseJoinAuth) + { + return queryBuilder; + } + var unsupportedAuthorizationFilters = new HashSet(); // If there are multiple relationship-based authorization strategies with views (that are combined with OR), we must use left outer joins and null/not null checks diff --git a/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/QueryBuilderExtensions.cs b/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/QueryBuilderExtensions.cs index d055bee99..833ea4e4c 100644 --- a/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/QueryBuilderExtensions.cs +++ b/Application/EdFi.Ods.Api/Security/AuthorizationStrategies/Relationships/Filters/QueryBuilderExtensions.cs @@ -6,7 +6,7 @@ using System; using System.Collections.Generic; using System.Linq; -using EdFi.Ods.Common; +using EdFi.Ods.Common.Context; using EdFi.Ods.Common.Database.Querying; using EdFi.Ods.Common.Security.Authorization; using EdFi.Ods.Common.Security.CustomViewBased; @@ -15,10 +15,12 @@ namespace EdFi.Ods.Api.Security.AuthorizationStrategies.Relationships.Filters { public static class QueryBuilderExtensions { + private static readonly CallContextStorage _callContextStorage = new(); + /// /// Applies a join-based filter to the criteria for the specified authorization view. /// - /// + /// The to which criteria should be applied. /// The named parameters to be used to satisfy additional filtering requirements. /// The name of the view to be filtered. /// The name of the property to be joined for the entity being queried. @@ -36,32 +38,115 @@ public static void ApplySingleColumnJoinFilter( JoinType joinType, string authViewAlias = null) { + // Temporary logic to opt-in to join-based authorization approach + bool useJoinAuth = _callContextStorage.GetValue("UseJoinAuth"); + + if (useJoinAuth) + { + ApplySingleColumnJoinFilterUsingJoins(queryBuilder, parameters, viewName, subjectEndpointName, viewSourceEndpointName, viewTargetEndpointName, joinType, authViewAlias); + return; + } + + ApplySingleColumnJoinFilterUsingCtes(queryBuilder, parameters, viewName, subjectEndpointName, viewSourceEndpointName, + viewTargetEndpointName, + joinType, + authViewAlias); + } + + private static void ApplySingleColumnJoinFilterUsingCtes( + this QueryBuilder queryBuilder, + IDictionary parameters, + string viewName, + string subjectEndpointName, + string viewSourceEndpointName, + string viewTargetEndpointName, + JoinType joinType, + string authViewAlias = null) + { + // Defensive check to ensure required parameter is present + if (!parameters.TryGetValue(RelationshipAuthorizationConventions.ClaimsParameterName, out object value)) + { + throw new Exception($"Unable to find parameter for filtering '{RelationshipAuthorizationConventions.ClaimsParameterName}' on view '{viewName}'. Available parameters: '{string.Join("', '", parameters.Keys)}'"); + } + authViewAlias = string.IsNullOrWhiteSpace(authViewAlias) ? $"authView{viewName}" : $"authView{authViewAlias}"; - // Apply authorization join using ICriteria + // Create a CTE query for the authorization view + var cte = new QueryBuilder(queryBuilder.Dialect, queryBuilder.ParameterIndexer); + cte.From($"auth.{viewName} AS av"); + cte.Select($"av.{viewTargetEndpointName}"); + cte.Distinct(); + + // Apply claims to the CTE query + if (value is object[] arrayOfValues) + { + cte.WhereIn($"av.{viewSourceEndpointName}", arrayOfValues, $"@{RelationshipAuthorizationConventions.ClaimsParameterName}"); + } + else + { + cte.Where($"av.{viewSourceEndpointName}", value, $"@{RelationshipAuthorizationConventions.ClaimsParameterName}"); + } + + // Add the CTE to the main query, with alias + queryBuilder.With(authViewAlias, cte); + + // Apply join to the authorization CTE if (joinType == JoinType.InnerJoin) { queryBuilder.Join( - $"auth.{viewName} AS {authViewAlias}", + authViewAlias, j => j.On($"r.{subjectEndpointName}", $"{authViewAlias}.{viewTargetEndpointName}")); } else if (joinType == JoinType.LeftOuterJoin) { queryBuilder.LeftJoin( - $"auth.{viewName} AS {authViewAlias}", + authViewAlias, j => j.On($"r.{subjectEndpointName}", $"{authViewAlias}.{viewTargetEndpointName}")); + + queryBuilder.Where(qb => qb.WhereNotNull($"{authViewAlias}.{viewTargetEndpointName}")); } else { throw new NotSupportedException("Unsupported authorization view join type."); } - + } + + private static void ApplySingleColumnJoinFilterUsingJoins( + QueryBuilder queryBuilder, + IDictionary parameters, + string viewName, + string subjectEndpointName, + string viewSourceEndpointName, + string viewTargetEndpointName, + JoinType joinType, + string authViewAlias) + { // Defensive check to ensure required parameter is present if (!parameters.TryGetValue(RelationshipAuthorizationConventions.ClaimsParameterName, out object value)) { throw new Exception($"Unable to find parameter for filtering '{RelationshipAuthorizationConventions.ClaimsParameterName}' on view '{viewName}'. Available parameters: '{string.Join("', '", parameters.Keys)}'"); } + authViewAlias = string.IsNullOrWhiteSpace(authViewAlias) ? $"authView{viewName}" : $"authView{authViewAlias}"; + + // Apply authorization join using ICriteria + if (joinType == JoinType.InnerJoin) + { + queryBuilder.Join( + $"auth.{viewName} AS {authViewAlias}", + j => j.On($"r.{subjectEndpointName}", $"{authViewAlias}.{viewTargetEndpointName}")); + } + else if (joinType == JoinType.LeftOuterJoin) + { + queryBuilder.LeftJoin( + $"auth.{viewName} AS {authViewAlias}", + j => j.On($"r.{subjectEndpointName}", $"{authViewAlias}.{viewTargetEndpointName}")); + } + else + { + throw new NotSupportedException("Unsupported authorization view join type."); + } + if (value is object[] arrayOfValues) { if (joinType == JoinType.InnerJoin) @@ -90,15 +175,10 @@ public static void ApplySingleColumnJoinFilter( } } - private static string GetFullNameForView(this string viewName) - { - return Namespaces.Entities.NHibernate.QueryModels.GetViewNamespace(viewName); - } - /// - /// Applies a join-based filter to the criteria for the specified custom authorization view. + /// Applies a join-based filter to the for the specified custom authorization view. /// - /// The criteria to which filters should be applied. + /// The to which filters should be applied. /// The name of the view to be filtered. /// The name of the property to be joined for the entity being queried. /// The name of the property to be joined for the other property as authorization view. diff --git a/Application/EdFi.Ods.Api/Security/Container/Modules/SecurityPersistenceModule.cs b/Application/EdFi.Ods.Api/Security/Container/Modules/SecurityPersistenceModule.cs index 84e16ec9b..9a537e4bd 100644 --- a/Application/EdFi.Ods.Api/Security/Container/Modules/SecurityPersistenceModule.cs +++ b/Application/EdFi.Ods.Api/Security/Container/Modules/SecurityPersistenceModule.cs @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: Apache-2.0 // Licensed to the Ed-Fi Alliance under one or more agreements. // The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. // See the LICENSE and NOTICES files in the project root for more information. @@ -49,7 +49,8 @@ public class SecurityPersistenceModule : Module {typeof(AuthorizationContextUpsertPipelineStepsProviderDecorator), typeof(IUpsertPipelineStepsProvider)}, {typeof(AuthorizationContextDeletePipelineStepsProviderDecorator), typeof(IDeletePipelineStepsProvider)}, - {typeof(AggregateRootQueryBuilderProviderAuthorizationDecorator), typeof(IAggregateRootQueryBuilderProvider)}, + {typeof(AggregateRootQueryBuilderProviderJoinAuthorizationDecorator), typeof(IAggregateRootQueryBuilderProvider)}, + {typeof(AggregateRootQueryBuilderProviderCteAuthorizationDecorator), typeof(IAggregateRootQueryBuilderProvider)}, }; protected override void Load(ContainerBuilder builder) diff --git a/Application/EdFi.Ods.Common/Database/Querying/DynamicParametersExtensions.cs b/Application/EdFi.Ods.Common/Database/Querying/DynamicParametersExtensions.cs index 69fa72676..0f18b4fa5 100644 --- a/Application/EdFi.Ods.Common/Database/Querying/DynamicParametersExtensions.cs +++ b/Application/EdFi.Ods.Common/Database/Querying/DynamicParametersExtensions.cs @@ -31,6 +31,8 @@ public static void MergeParameters(this DynamicParameters target, DynamicParamet // Merge source parameters into target parameters, preserving the target values when they're already present. foreach (string key in sourceParameters.Keys) { + // NOTE: This can lead to quiet loss of conflicting parameter names. Recommendation is to apply explicit + // names to CTE-based queries to avoid ordinal-based parameter name conflicts. if (!targetParameters.Contains(key)) { targetParameters.Add(key, sourceParameters[key]); diff --git a/Application/EdFi.Ods.Common/Database/Querying/QueryBuilder.cs b/Application/EdFi.Ods.Common/Database/Querying/QueryBuilder.cs index 3d0d88a45..ba9761ddf 100644 --- a/Application/EdFi.Ods.Common/Database/Querying/QueryBuilder.cs +++ b/Application/EdFi.Ods.Common/Database/Querying/QueryBuilder.cs @@ -11,6 +11,7 @@ using Dapper; using EdFi.Common.Utils.Extensions; using EdFi.Ods.Common.Database.Querying.Dialects; +using NHibernate.SqlCommand; namespace EdFi.Ods.Common.Database.Querying { @@ -52,6 +53,11 @@ private QueryBuilder(Dialect dialect, SqlBuilder sqlBuilder, string tableName, P TableName = tableName; } + public Dialect Dialect + { + get => _dialect; + } + public string TableName { get; private set; } public IDictionary Parameters { get; } = new Dictionary(); @@ -77,14 +83,14 @@ public QueryBuilder From(string tableName) return this; } - public QueryBuilder Where(string column, object value) + public QueryBuilder Where(string column, object value, string parameterNameDisposition = null) { - return Where(column, "=", value); + return Where(column, "=", value, parameterNameDisposition); } - public QueryBuilder Where(string column, string op, object value) + public QueryBuilder Where(string column, string op, object value, string parameterNameDisposition = null) { - (DynamicParameters parameters, string parameterName) = GetParametersFromObject(value); + (DynamicParameters parameters, string parameterName) = GetParametersFromObject(value, parameterNameDisposition); _sqlBuilder.Where($"{column} {op} {parameterName}", parameters); @@ -106,7 +112,7 @@ public QueryBuilder Where(string column, string op, object value) else if (value is IList values) { parameters = new DynamicParameters(); - parameterName = parameterNameDisposition ?? $"@p{_parameterIndexer.Increment()}"; + parameterName = parameterNameDisposition ?? _parameterIndexer.NextParameterName(); parameters.AddDynamicParams(new[] { new KeyValuePair(parameterName, values) }); } else if (value is DynamicParameters dynamicParameters) @@ -119,7 +125,7 @@ public QueryBuilder Where(string column, string op, object value) { // Inline parameter, automatically named parameters = new DynamicParameters(); - parameterName = parameterNameDisposition ?? $"@p{_parameterIndexer.Increment()}"; + parameterName = parameterNameDisposition ?? _parameterIndexer.NextParameterName(); parameters.Add(parameterName, value); } @@ -156,7 +162,7 @@ public QueryBuilder Where(Func nestedWhereApplicator } // Incorporate any JOINs added into this builder - _sqlBuilder.CopyDataFrom(childScopeSqlBuilder, "innerjoin", "leftjoin", "rightjoin", "join"); + _sqlBuilder.CopyDataFrom(childScopeSqlBuilder, "with", "innerjoin", "leftjoin", "rightjoin", "join"); return this; } @@ -176,17 +182,27 @@ public QueryBuilder OrWhere(Func nestedWhereApplicat return this; } - var template = childScopeSqlBuilder.AddTemplate( - "/**where**/", - childScope.Parameters.Any() - ? new DynamicParameters(childScope.Parameters) - : null); + if (childScopeSqlBuilder.HasWhereClause()) + { + var template = childScopeSqlBuilder.AddTemplate( + "/**where**/", + childScope.Parameters.Any() + ? new DynamicParameters(childScope.Parameters) + : null); - // SqlBuilder warps 'OR' where clauses when building the template SQL - _sqlBuilder.OrWhere($"({template.RawSql.Replace("WHERE ", string.Empty)})", template.Parameters); + // SqlBuilder wraps 'OR' where clauses when building the template SQL + _sqlBuilder.OrWhere($"({template.RawSql.Replace("WHERE ", string.Empty)})", template.Parameters); + } + else + { + if (childScope.Parameters.Any()) + { + _sqlBuilder.AddParameters(childScope.Parameters); + } + } // Incorporate the JOINs into this builder - _sqlBuilder.CopyDataFrom(childScopeSqlBuilder, "innerjoin", "leftjoin", "rightjoin", "join"); + _sqlBuilder.CopyDataFrom(childScopeSqlBuilder, "with", "innerjoin", "leftjoin", "rightjoin", "join"); return this; } @@ -304,19 +320,19 @@ private QueryBuilder WhereBetween(string columnName, object minValueInclusive, o return this; } - public QueryBuilder WhereIn(string columnName, IList values) + public QueryBuilder WhereIn(string columnName, IList values, string parameterNameDisposition = null) { - return WhereIn(columnName, values, useOrWhere: false); + return WhereIn(columnName, values, parameterNameDisposition, useOrWhere: false); } - public QueryBuilder OrWhereIn(string columnName, IList values) + public QueryBuilder OrWhereIn(string columnName, IList values, string parameterNameDisposition = null) { - return WhereIn(columnName, values, useOrWhere: true); + return WhereIn(columnName, values, parameterNameDisposition, useOrWhere: true); } - private QueryBuilder WhereIn(string columnName, IList values, bool useOrWhere) + private QueryBuilder WhereIn(string columnName, IList values, string parameterNameDisposition, bool useOrWhere) { - string parameterName = _parameterIndexer.NextParameterName(); + string parameterName = parameterNameDisposition ?? _parameterIndexer.NextParameterName(); var (sql, parameters) = _dialect.GetInClause(columnName, parameterName, values); @@ -496,6 +512,21 @@ public void ClearSelect() _sqlBuilder.ClearClause(ClauseKey.Select); _sqlBuilder.ClearClause(ClauseKey.Distinct); } + + /// + /// Clears the CTE queries (e.g. for building a COUNT query with a cloned QueryBuilder, since they will already be present on the final query). + /// + public void ClearWith() + { + _sqlBuilder.ClearClause(ClauseKey.With); + } + + public QueryBuilder AddParameters(DynamicParameters parameters) + { + _sqlBuilder.AddParameters(parameters); + + return this; + } } public class ParameterIndexer diff --git a/Application/EdFi.Ods.Common/Database/Querying/SqlBuilder_customizations.cs b/Application/EdFi.Ods.Common/Database/Querying/SqlBuilder_customizations.cs index b0522c79e..4b1fbe50b 100644 --- a/Application/EdFi.Ods.Common/Database/Querying/SqlBuilder_customizations.cs +++ b/Application/EdFi.Ods.Common/Database/Querying/SqlBuilder_customizations.cs @@ -190,5 +190,6 @@ public static class ClauseKey public static string Having = "having"; public static string Set = "set"; public static string Distinct = "distinct"; + public static string With = "with"; } } diff --git a/Postman Test Suite/Ed-Fi ODS-API Integration Test Suite.postman_collection.json b/Postman Test Suite/Ed-Fi ODS-API Integration Test Suite.postman_collection.json index 618a65fe7..f082086f8 100644 --- a/Postman Test Suite/Ed-Fi ODS-API Integration Test Suite.postman_collection.json +++ b/Postman Test Suite/Ed-Fi ODS-API Integration Test Suite.postman_collection.json @@ -11477,7 +11477,7 @@ ], "body": { "mode": "raw", - "raw": "{\r\n \"schoolReference\": {\r\n \"schoolId\": 255901001\r\n },\r\n \"sessionReference\": {\r\n \"schoolId\": 255901001,\r\n \"schoolYear\": 2022,\r\n \"sessionName\": \"2021-2022 Fall Semester\"\r\n },\r\n \"localCourseCode\": \"ALG-1\",\r\n \"instructionalTimePlanned\": {{$randomInt}},\r\n \"courseLevelCharacteristics\": [],\r\n \"curriculumUseds\": [],\r\n \"offeredGradeLevels\": []\r\n}", + "raw": "{\r\n \"schoolReference\": {\r\n \"schoolId\": 255901001\r\n },\r\n \"sessionReference\": {\r\n \"schoolId\": 255901001,\r\n \"schoolYear\": 2022,\r\n \"sessionName\": \"2021-2022 Fall Semester\"\r\n },\r\n \"localCourseCode\": \"ALG-1\",\r\n \"instructionalTimePlanned\": 1{{$randomInt}},\r\n \"courseLevelCharacteristics\": [],\r\n \"curriculumUseds\": [],\r\n \"offeredGradeLevels\": []\r\n}", "options": { "raw": { "language": "json"