diff --git a/docs/limitations.md b/docs/limitations.md index e51aa5e..ccdefa1 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -34,19 +34,19 @@ request is sent. See [Supported Operators](querying/operators.md) for the full list of what does translate. -| Category | Operators | Why | -| --------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| Aggregation | `Count`, `LongCount`, `Sum`, `Average`, `Min`, `Max` | DynamoDB PartiQL has no aggregate functions | -| Grouping | `GroupBy` | `GROUP BY` is not supported in DynamoDB PartiQL | -| Joins | `Join`, `GroupJoin`, `LeftJoin`, `RightJoin`, `SelectMany`, `DefaultIfEmpty` | DynamoDB does not support cross-item joins | -| Set operations | `Union`, `Concat`, `Except`, `Intersect` | Not supported in DynamoDB PartiQL | -| Offset / paging | `Skip`, `Take`, `ElementAt`, `ElementAtOrDefault` | DynamoDB has no offset semantics — use `Limit(n)` for an evaluation budget | -| Element operators | `Any`, `All` | Not supported server-side | -| Reverse traversal | `Last`, `LastOrDefault`, `Reverse` | Requires reverse index traversal, not implemented | -| Deduplication | `Distinct` | `SELECT DISTINCT` is not supported in DynamoDB PartiQL | -| Type filtering | `OfType`, `Cast` | Not supported | -| Conditional skipping | `SkipWhile`, `TakeWhile` | Not supported | -| Queryable `Contains` over query sources | `Queryable.Contains(dbSet, item)` | Not supported; in-memory membership translates to `IN`, native collection membership to `contains` | +| Category | Operators | Why | +| --------------------------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Aggregation | `Count`, `LongCount`, `Sum`, `Average`, `Min`, `Max` | DynamoDB PartiQL has no aggregate functions | +| Grouping | `GroupBy` | `GROUP BY` is not supported in DynamoDB PartiQL | +| Joins | `Join`, `GroupJoin`, `LeftJoin`, `RightJoin`, `SelectMany`, `DefaultIfEmpty` | DynamoDB does not support cross-item joins | +| Set operations | `Union`, `Concat`, `Except`, `Intersect` | Not supported in DynamoDB PartiQL | +| Offset / paging | `Skip`, `Take`, `ElementAt`, `ElementAtOrDefault` | DynamoDB has no offset semantics — use `Limit(n)` for an evaluation budget | +| Element operators | `Any`, `All` | Not supported server-side | +| Reverse traversal | `Last`, `LastOrDefault`, `Reverse` | Requires reverse index traversal, not implemented | +| Deduplication | `Distinct` | `SELECT DISTINCT` is not supported in DynamoDB PartiQL | +| Type casting | `Cast` | Not supported; TPH discriminator filtering via `OfType()` and `is` requires active discriminator metadata; `GetType()` checks require active discriminator metadata and are limited to exact concrete mapped entity types | +| Conditional skipping | `SkipWhile`, `TakeWhile` | Not supported | +| Queryable `Contains` over query sources | `Queryable.Contains(dbSet, item)` | Not supported; in-memory membership translates to `IN`, native collection membership to `contains` | Value-converted enum numeric casts are also rejected when compared to parameters. For example, `(int)entity.Status == value` is not translated if `Status` uses `.HasConversion()`, because DynamoDB stores the converted string value. Compare `entity.Status` to an enum value directly, or map the enum numerically. @@ -396,8 +396,13 @@ provider cannot guarantee auto-increment semantics on DynamoDB item writes. ### Shared-Table Discriminator Constraints -When multiple entity types share the same DynamoDB table, a discriminator is required. The -following constraints are validated at startup: +When multiple entity types share the same DynamoDB table, discriminator metadata is required only +when the provider needs server-side type discrimination or type filtering. `HasNoDiscriminator()` +can disable discriminator metadata for a shared-table group when your key design already isolates +entity types. With discriminator metadata disabled, derived `OfType()`, `is`, and `GetType()` +type filters cannot be translated. + +When discriminator metadata is enabled, the following constraints are validated at startup: - Discriminator values must be unique within the table group. - All entity types in the group must use the same discriminator attribute name. diff --git a/docs/modeling/single-table-design.md b/docs/modeling/single-table-design.md index fee1176..cba75c4 100644 --- a/docs/modeling/single-table-design.md +++ b/docs/modeling/single-table-design.md @@ -248,10 +248,34 @@ For key-shape examples, continue with [Practical single-table pattern](#practica Base queries materialize polymorphically (`DbSet` can return `Employee` and `Manager`). When discrimination is active, the discriminator attribute is included in projection. -### `OfType()` limitation +### Type filtering -`Queryable.OfType()` is not currently translated by the provider. -Query derived sets directly (for example `context.Employees`) instead. +`Queryable.OfType()` translates to a discriminator predicate for TPH hierarchies: + +```csharp +context.People + .OfType() + .Where(x => x.Pk == "TENANT#1") + .ToListAsync(); +``` + +Type tests in predicates also translate when they can be expressed as discriminator checks: + +```csharp +context.People.Where(x => x is Manager); +context.People.Where(x => x.GetType() == typeof(Employee)); +``` + +`GetType()` comparisons are exact-type checks. Use them for concrete mapped leaf types, such +as `Employee` in the example above. For base or intermediate type matching, use `is` or +`OfType()`. + +When `HasNoDiscriminator()` disables discrimination for a shared-table group, derived +`OfType()`, `is`, and `GetType()` type filters cannot be translated to server-side type +predicates. Use key predicates or query the concrete `DbSet` directly when your PK/SK pattern +already isolates types. + +`Cast()` is not translated. !!! tip "Index selection with inheritance/shared-table queries" diff --git a/docs/querying/operators.md b/docs/querying/operators.md index faf2169..7661d05 100644 --- a/docs/querying/operators.md +++ b/docs/querying/operators.md @@ -32,6 +32,7 @@ _This page is the authoritative reference for which LINQ operators translate to | `string.Length` | `size(attr)` | DynamoDB `size` semantics; for strings, use with care when non-ASCII text matters | | `string.IsNullOrEmpty(s)` | `attr IS NULL OR attr IS MISSING OR attr = ''` | Matches DynamoDB `NULL`, missing attributes, or empty string | | `collection.Contains(prop)` | `prop IN [?, ...]` | In-memory collection membership; max 50 PK values, 100 non-key values | +| `OfType()` | discriminator predicate | TPH inheritance only; filters by concrete discriminator values for the requested derived type. `x is TDerived` translates when the target type can be expressed as discriminator values. `x.GetType() == typeof(TConcrete)` translates only for exact concrete mapped entity types. | ## Projection Operators @@ -105,7 +106,7 @@ The following operators are not supported and throw `InvalidOperationException` | Joins | `Join`, `GroupJoin`, `SelectMany`, `LeftJoin`, `RightJoin`, `DefaultIfEmpty` | DynamoDB does not support cross-item joins | | Set operations | `Union`, `Concat`, `Except`, `Intersect` | Not supported | | Conditional filtering | `SkipWhile`, `TakeWhile` | Not supported | -| Type filtering | `OfType`, `Cast` | Not supported | +| Type casting | `Cast` | Not supported; use TPH inheritance queries with `OfType()` for type filtering | ## Scalar Type Support diff --git a/docs/spec-test-coverage.md b/docs/spec-test-coverage.md index d9448ca..5b541e7 100644 --- a/docs/spec-test-coverage.md +++ b/docs/spec-test-coverage.md @@ -166,16 +166,16 @@ These tests use non-Northwind models and fixtures. ### Implemented -| Test Class | Methods | Cosmos | MongoDB | Notes | -| -------------------------- | ------: | :----: | :-----: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ComplexTypeQueryTestBase` | 74 | ✓ | ✗ | All inherited methods are registered/overridden with explicit outcomes; supported projection/filter, nested struct projection, and complex equality subsets execute, while navigation, set-operation, GroupBy, subquery/Contains, and pushdown cases are explicitly skipped | +| Test Class | Methods | Cosmos | MongoDB | Notes | +| -------------------------- | ------: | :----: | :-----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ComplexTypeQueryTestBase` | 74 | ✓ | ✗ | All inherited methods are registered/overridden with explicit outcomes; supported projection/filter, nested struct projection, and complex equality subsets execute, while navigation, set-operation, GroupBy, subquery/Contains, and pushdown cases are explicitly skipped | +| `InheritanceQueryTestBase` | 52 | ✓ | ✗ | Single-table inheritance with discriminator predicates is covered, including `OfType`, `is`, `GetType()` leaf checks, derived-property filters, and discriminator projections. Skips remain for keyless views, navigations/includes, transactions, set operations, non-key ordered result assumptions, scan-like `Single`, and a few unsupported projection/query shapes. | ### Future | Test Class | Methods | Cosmos | MongoDB | Feasibility | Rationale | | -------------------------------------------- | ------: | :----: | :-----: | ----------: | ----------------------------------------------------------------------------------------------------------------------- | | `AdHocComplexTypeQueryTestBase` | 13 | ✓ | ✗ | ~65% | Ad-hoc complex type query scenarios; same fixture dependency | -| `InheritanceQueryTestBase` | 52 | ✓ | ✗ | ~60% | Single-table inheritance with discriminator; DynamoDB has discriminator support | | `FiltersInheritanceQueryTestBase` | 11 | ✗ | ✗ | ~55% | Query filters on inherited types | | `FunkyDataQueryTestBase` | 19 | ✗ | ✗ | ~60% | Edge-case strings (null chars, Unicode, SQL injection chars); WHERE translation should handle these | | `PrimitiveCollectionsQueryTestBase` | 156 | ✓ | ✗ | ~50% | DynamoDB LIST/SET attribute querying; PartiQL supports `CONTAINS` on lists; complex collection operations not supported | @@ -316,7 +316,7 @@ ______________________________________________________________________ | Non-Query (top-level) | 18 classes / 356 methods | — | 4 classes / 410 methods | 21 classes / 1,058 methods | | BulkUpdates | — | — | 5 classes / 135+ methods | 1 class / 33 methods | | Northwind Query | 8 classes / 458 methods | — | 1 class / 469 methods | 13 classes / 929+ methods | -| Other Query | 1 class / 74 methods | — | 10 classes / 381 methods | 18 classes / 1,691 methods | +| Other Query | 2 classes / 126 methods | — | 9 classes / 329 methods | 18 classes / 1,691 methods | | Associations | 3 classes / 42 methods | — | 2 classes / 4 methods | 13+ classes / 123+ methods | | Translations | 5 classes / 134 methods | — | 8 classes / 162 methods | 3 classes / 25 methods | @@ -351,6 +351,7 @@ This list records recently completed additions; authoritative implemented/not-im 21. `StringTranslationsDynamoTest` — 100 methods 22. `ComplexPropertiesStructuralEqualityDynamoTest` — 16 methods 23. `ComplexPropertiesProjectionDynamoTest` — 20 methods +24. `InheritanceQueryDynamoTest` — 52 methods ### Near-term (small, high confidence) @@ -362,16 +363,15 @@ No medium-term specification test classes are currently queued here. ### Long-term (after core coverage is stable) -1. `InheritanceQueryDynamoTest` — blocked on `OfType`, `is`/`GetType()` discriminator translation, and fixture work -2. `PrimitiveCollectionsQueryDynamoTest` -3. `BulkUpdates` family — blocked on `ExecuteUpdate`/`ExecuteDelete` -4. Remaining translation tests (Math, ByteArray) +1. `PrimitiveCollectionsQueryDynamoTest` +2. `BulkUpdates` family — blocked on `ExecuteUpdate`/`ExecuteDelete` +3. Remaining translation tests (Math, ByteArray) ### Current totals | Status | Classes | Methods | | -------------- | ------: | ------: | -| Implemented | 35 | 1,064 | +| Implemented | 36 | 1,116 | | Implement Next | 0 | 0 | -| Future | 30 | 1,561+ | +| Future | 29 | 1,509+ | | Skip | 69+ | 3,859+ | diff --git a/src/EntityFrameworkCore.DynamoDb/Query/Internal/DynamoQueryableMethodTranslatingExpressionVisitor.cs b/src/EntityFrameworkCore.DynamoDb/Query/Internal/DynamoQueryableMethodTranslatingExpressionVisitor.cs index 105db01..0be3b59 100644 --- a/src/EntityFrameworkCore.DynamoDb/Query/Internal/DynamoQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EntityFrameworkCore.DynamoDb/Query/Internal/DynamoQueryableMethodTranslatingExpressionVisitor.cs @@ -520,6 +520,52 @@ [new ProjectionMember()] = entityProjection }; } + private SqlExpression? CreateDiscriminatorPredicate(IEntityType entityType) + { + var discriminatorProperty = entityType.FindDiscriminatorProperty(); + if (discriminatorProperty is null) + return null; + + var discriminatorColumn = _sqlExpressionFactory.ApplyTypeMapping( + _sqlExpressionFactory.Property( + discriminatorProperty.GetAttributeName(), + discriminatorProperty.ClrType), + discriminatorProperty.GetTypeMapping()); + + SqlExpression? predicate = null; + foreach (var concreteType in entityType.GetConcreteDerivedTypesInclusive()) + { + if (concreteType.ClrType.IsAbstract) + continue; + + var discriminatorValue = concreteType.GetDiscriminatorValue(); + if (discriminatorValue is null) + continue; + + var equals = _sqlExpressionFactory.Binary( + ExpressionType.Equal, + discriminatorColumn, + _sqlExpressionFactory.Constant(discriminatorValue, discriminatorProperty.ClrType)); + + if (equals is null) + throw new InvalidOperationException( + $"Failed to create discriminator predicate for entity type '{entityType.DisplayName()}'."); + + predicate = predicate is null + ? equals + : _sqlExpressionFactory.Binary(ExpressionType.OrElse, predicate, equals) + ?? throw new InvalidOperationException( + $"Failed to compose discriminator predicate for entity type '{entityType.DisplayName()}'."); + } + + return predicate switch + { + SqlBinaryExpression { OperatorType: ExpressionType.OrElse } => + new SqlParenthesizedExpression(predicate), + _ => predicate + }; + } + /// /// Determines whether discriminator filtering is required for the root entity type's table /// group. @@ -749,7 +795,63 @@ private bool RequiresDiscriminatorPredicate(IEntityType entityType) protected override ShapedQueryExpression? TranslateOfType( ShapedQueryExpression source, Type resultType) - => UnsupportedOperator(nameof(Queryable.OfType), DynamoStrings.OfTypeNotSupportedYet); + { + if (source.ShaperExpression is not StructuralTypeShaperExpression + { + StructuralType: IEntityType sourceEntityType + }) + return UnsupportedOperator( + nameof(Queryable.OfType), + DynamoStrings.OfTypeNotSupportedYet); + + var targetEntityType = QueryCompilationContext.Model.FindEntityType(resultType); + if (targetEntityType is null + || !sourceEntityType.GetRootType().IsAssignableFrom(targetEntityType) + || !targetEntityType.GetRootType().IsAssignableFrom(sourceEntityType)) + return UnsupportedOperator( + nameof(Queryable.OfType), + DynamoStrings.OfTypeNotSupportedYet); + + var discriminatorPredicate = CreateDiscriminatorPredicate(targetEntityType); + if (discriminatorPredicate is null) + { + if (IsOfTypeIdentityOrBaseType(sourceEntityType, targetEntityType)) + return source; + + return UnsupportedOperator( + nameof(Queryable.OfType), + DynamoStrings.OfTypeRequiresDiscriminatorPredicate); + } + + var selectExpression = (SelectExpression)source.QueryExpression; + selectExpression.ApplyPredicate( + new SqlDiscriminatorPredicateExpression(discriminatorPredicate)); + + var entityProjection = + new DynamoEntityProjectionExpression(targetEntityType, _sqlExpressionFactory); + selectExpression.ReplaceProjectionMapping( + new Dictionary + { + [new ProjectionMember()] = entityProjection + }); + + var projectionBindingExpression = new ProjectionBindingExpression( + selectExpression, + new ProjectionMember(), + typeof(ValueBuffer)); + + return source.UpdateShaperExpression( + new StructuralTypeShaperExpression( + targetEntityType, + projectionBindingExpression, + false)); + } + + private static bool IsOfTypeIdentityOrBaseType( + IEntityType sourceEntityType, + IEntityType targetEntityType) + => sourceEntityType == targetEntityType + || targetEntityType.IsAssignableFrom(sourceEntityType); /// Provides functionality for this member. protected override ShapedQueryExpression? TranslateOrderBy( diff --git a/src/EntityFrameworkCore.DynamoDb/Query/Internal/DynamoSqlTranslatingExpressionVisitor.cs b/src/EntityFrameworkCore.DynamoDb/Query/Internal/DynamoSqlTranslatingExpressionVisitor.cs index 8ab8eeb..e6ebac3 100644 --- a/src/EntityFrameworkCore.DynamoDb/Query/Internal/DynamoSqlTranslatingExpressionVisitor.cs +++ b/src/EntityFrameworkCore.DynamoDb/Query/Internal/DynamoSqlTranslatingExpressionVisitor.cs @@ -136,6 +136,10 @@ private void AddTranslationErrorDetails(string details) /// protected override Expression VisitBinary(BinaryExpression node) { + if (node.NodeType is ExpressionType.Equal or ExpressionType.NotEqual + && TryTranslateGetTypeComparison(node) is { } getTypeComparison) + return getTypeComparison; + // Translate string.Compare(a, b) OP 0 → a OP b if (TryTranslateStringCompare(node) is { } stringCompareResult) return stringCompareResult; @@ -241,8 +245,8 @@ private static bool IsLiteralShape(Expression expression) { ConstantExpression => true, // Only allow field access — property getters may execute arbitrary user code. - MemberExpression { Member: System.Reflection.FieldInfo } memberExpression => - IsLiteralShape(memberExpression.Expression!), + MemberExpression { Member: FieldInfo } memberExpression => IsLiteralShape( + memberExpression.Expression!), UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked @@ -278,6 +282,142 @@ or QueryParameterExpression } } + /// + protected override Expression VisitTypeBinary(TypeBinaryExpression node) + => node.NodeType == ExpressionType.TypeIs + ? TryCreateDiscriminatorPredicate(node.Expression, node.TypeOperand, false) is + { } predicate + ? new SqlDiscriminatorPredicateExpression(predicate) + : QueryCompilationContext.NotTranslatedExpression + : QueryCompilationContext.NotTranslatedExpression; + + private SqlExpression? TryTranslateGetTypeComparison(BinaryExpression node) + { + if (TryUnwrapGetTypeComparison(node.Left, node.Right, out var instance, out var type) + || TryUnwrapGetTypeComparison(node.Right, node.Left, out instance, out type)) + { + var predicate = TryCreateDiscriminatorPredicate(instance, type, true); + if (predicate is null) + return null; + + var discriminatorPredicate = new SqlDiscriminatorPredicateExpression(predicate); + + return node.NodeType == ExpressionType.NotEqual + ? sqlExpressionFactory.Not(discriminatorPredicate) + : discriminatorPredicate; + } + + return null; + } + + private static bool TryUnwrapGetTypeComparison( + Expression getTypeSide, + Expression typeSide, + out Expression instance, + out Type type) + { + instance = null!; + type = null!; + + getTypeSide = UnwrapConvert(getTypeSide); + typeSide = UnwrapConvert(typeSide); + + if (getTypeSide is MethodCallExpression + { + Object: { } source, + Method.Name: nameof(GetType), + Method.DeclaringType: var declaringType, + Method.ReturnType: var returnType, + Arguments.Count: 0 + } + && declaringType == typeof(object) + && returnType == typeof(Type) + && typeSide is ConstantExpression { Value: Type comparedType }) + { + instance = source; + type = comparedType; + return true; + } + + return false; + } + + private static Expression UnwrapConvert(Expression expression) + { + while (expression is UnaryExpression + { + NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked + } unaryExpression) + expression = unaryExpression.Operand; + + return expression; + } + + private SqlExpression? TryCreateDiscriminatorPredicate( + Expression source, + Type targetClrType, + bool exact) + { + var sourceEntityType = ResolveRootEntityType(source); + var targetEntityType = model.FindEntityType(targetClrType); + if (sourceEntityType is null || targetEntityType is null) + return null; + + if (!sourceEntityType.GetRootType().IsAssignableFrom(targetEntityType) + || !targetEntityType.GetRootType().IsAssignableFrom(sourceEntityType)) + return exact ? CreateFalsePredicate() : null; + + var discriminatorProperty = targetEntityType.FindDiscriminatorProperty(); + if (discriminatorProperty is null) + return null; + + var discriminatorColumn = sqlExpressionFactory.ApplyTypeMapping( + sqlExpressionFactory.Property( + discriminatorProperty.GetAttributeName(), + discriminatorProperty.ClrType), + discriminatorProperty.GetTypeMapping()); + + var concreteTypes = exact + ? targetEntityType.ClrType.IsAbstract ? [] : [targetEntityType] + : targetEntityType + .GetConcreteDerivedTypesInclusive() + .Where(static entityType => !entityType.ClrType.IsAbstract); + + SqlExpression? predicate = null; + foreach (var concreteType in concreteTypes) + { + var discriminatorValue = concreteType.GetDiscriminatorValue(); + if (discriminatorValue is null) + continue; + + var equals = sqlExpressionFactory.Binary( + ExpressionType.Equal, + discriminatorColumn, + sqlExpressionFactory.Constant(discriminatorValue, discriminatorProperty.ClrType)); + + if (equals is null) + return null; + + predicate = predicate is null + ? equals + : sqlExpressionFactory.Binary(ExpressionType.OrElse, predicate, equals); + } + + return predicate switch + { + null => CreateFalsePredicate(), + SqlBinaryExpression { OperatorType: ExpressionType.OrElse } => + new SqlParenthesizedExpression(predicate), + _ => predicate + }; + } + + private SqlExpression CreateFalsePredicate() + => sqlExpressionFactory.Binary( + ExpressionType.Equal, + sqlExpressionFactory.Constant(1, typeof(int)), + sqlExpressionFactory.Constant(0, typeof(int)))!; + /// protected override Expression VisitConditional(ConditionalExpression node) => QueryCompilationContext.NotTranslatedExpression; diff --git a/src/EntityFrameworkCore.DynamoDb/Query/Internal/DynamoStrings.cs b/src/EntityFrameworkCore.DynamoDb/Query/Internal/DynamoStrings.cs index ae4271e..02f30cc 100644 --- a/src/EntityFrameworkCore.DynamoDb/Query/Internal/DynamoStrings.cs +++ b/src/EntityFrameworkCore.DynamoDb/Query/Internal/DynamoStrings.cs @@ -123,6 +123,10 @@ public static string SingleOrDefaultRequiresKeyOnlyPath(string queryShape) public const string OfTypeNotSupportedYet = "OfType translation is not currently supported by this provider."; + /// Error message for missing discriminator predicate during OfType translation. + public const string OfTypeRequiresDiscriminatorPredicate = + "The target entity type requires discriminator filtering, but the model has no discriminator predicate to apply."; + /// Error message for predicates that cannot be translated to PartiQL. public const string PredicateNotTranslatable = "The predicate could not be translated to DynamoDB PartiQL."; diff --git a/tests/EntityFrameworkCore.DynamoDb.SpecificationTests/ComplianceDynamoTest.cs b/tests/EntityFrameworkCore.DynamoDb.SpecificationTests/ComplianceDynamoTest.cs index 66b2035..8705c62 100644 --- a/tests/EntityFrameworkCore.DynamoDb.SpecificationTests/ComplianceDynamoTest.cs +++ b/tests/EntityFrameworkCore.DynamoDb.SpecificationTests/ComplianceDynamoTest.cs @@ -26,6 +26,7 @@ protected override IEnumerable GetBaseTestClasses() yield return typeof(FindTestBase<>); yield return typeof(EnumTranslationsTestBase<>); yield return typeof(GuidTranslationsTestBase<>); + yield return typeof(InheritanceQueryTestBase<>); yield return typeof(StringTranslationsTestBase<>); yield return typeof(KeysWithConvertersTestBase<>); yield return typeof(LoggingTestBase); diff --git a/tests/EntityFrameworkCore.DynamoDb.SpecificationTests/Query/InheritanceQueryDynamoTest.cs b/tests/EntityFrameworkCore.DynamoDb.SpecificationTests/Query/InheritanceQueryDynamoTest.cs new file mode 100644 index 0000000..3d0d6e2 --- /dev/null +++ b/tests/EntityFrameworkCore.DynamoDb.SpecificationTests/Query/InheritanceQueryDynamoTest.cs @@ -0,0 +1,577 @@ +using EntityFrameworkCore.DynamoDb.Diagnostics; +using EntityFrameworkCore.DynamoDb.SpecificationTests.TestUtilities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.TestModels.InheritanceModel; +using Microsoft.EntityFrameworkCore.TestUtilities; + +namespace EntityFrameworkCore.DynamoDb.SpecificationTests.Query; + +/// Inheritance query specification tests for the DynamoDB provider. +public abstract class InheritanceQueryDynamoTest + : InheritanceQueryTestBase +{ + protected InheritanceQueryDynamoTest(InheritanceQueryDynamoFixture fixture) : base(fixture) + => fixture.ClearSql(); + + [ConditionalFact] + public virtual void Check_all_tests_overridden() + => DynamoTestHelpers.AssertAllTestMethodsOverridden(typeof(InheritanceQueryDynamoTest)); + + [ConditionalTheory(Skip = SkipReason.QueryShapeNotSupported)] + public override Task Can_query_when_shared_column(bool async) + => DynamoTestHelpers.Instance.NoSyncTest(async, a => base.Can_query_when_shared_column(a)); + + public override Task Can_query_all_types_when_shared_column(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.Can_query_all_types_when_shared_column(a); + AssertSql( + """ + SELECT "id", "discriminator", "sortIndex", "caffeineGrams", "carbonation", "sugarGrams", "hasMilk" + FROM "Drinks" + WHERE ("discriminator" = 0 OR "discriminator" = 1 OR "discriminator" = 2 OR "discriminator" = 3) + """); + }); + + [ConditionalTheory(Skip = SkipReason.OrderedResultSetNotSupported)] + public override Task Can_use_of_type_animal(bool async) + => DynamoTestHelpers.Instance.NoSyncTest(async, a => base.Can_use_of_type_animal(a)); + + public override Task Can_use_is_kiwi(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.Can_use_is_kiwi(a); + AssertSql( + """ + SELECT "id", "countryId", "discriminator", "name", "species", "isFlightless", "group", "foundOn" + FROM "Animals" + WHERE "discriminator" = 'Kiwi' AND ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') + """); + }); + + public override Task Can_use_is_kiwi_with_cast(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.Can_use_is_kiwi_with_cast(a); + AssertSql( + """ + SELECT "id", "countryId", "discriminator", "name", "species", "isFlightless", "group", "foundOn" + FROM "Animals" + WHERE ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') + """); + }); + + public override Task Can_use_backwards_is_animal(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.Can_use_backwards_is_animal(a); + AssertSql( + """ + SELECT "id", "countryId", "discriminator", "name", "species", "isFlightless", "foundOn" + FROM "Animals" + WHERE ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') AND "discriminator" = 'Kiwi' + """); + }); + + public override Task Can_use_is_kiwi_with_other_predicate(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.Can_use_is_kiwi_with_other_predicate(a); + AssertSql( + """ + SELECT "id", "countryId", "discriminator", "name", "species", "isFlightless", "group", "foundOn" + FROM "Animals" + WHERE "discriminator" = 'Kiwi' AND "countryId" = 1 AND ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') + """); + }); + + public override Task Can_use_is_kiwi_in_projection(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.Can_use_is_kiwi_in_projection(a); + AssertSql( + """ + SELECT "id", "countryId", "discriminator", "name", "species", "isFlightless", "group", "foundOn" + FROM "Animals" + WHERE ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') + """); + }); + + [ConditionalTheory(Skip = SkipReason.OrderedResultSetNotSupported)] + public override Task Can_use_of_type_bird(bool async) + => DynamoTestHelpers.Instance.NoSyncTest(async, a => base.Can_use_of_type_bird(a)); + + [ConditionalTheory(Skip = SkipReason.OrderedResultSetNotSupported)] + public override Task Can_use_of_type_bird_predicate(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + a => base.Can_use_of_type_bird_predicate(a)); + + public override Task Can_use_of_type_bird_with_projection(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.Can_use_of_type_bird_with_projection(a); + AssertSql( + """ + SELECT "EagleId" + FROM "Animals" + WHERE ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') AND ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') + """); + }); + + [ConditionalTheory(Skip = SkipReason.OrderedResultSetNotSupported)] + public override Task Can_use_of_type_bird_first(bool async) + => DynamoTestHelpers.Instance.NoSyncTest(async, a => base.Can_use_of_type_bird_first(a)); + + public override Task Can_use_of_type_kiwi(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.Can_use_of_type_kiwi(a); + AssertSql( + """ + SELECT "id", "countryId", "discriminator", "name", "species", "isFlightless", "foundOn" + FROM "Animals" + WHERE "discriminator" = 'Kiwi' AND ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') + """); + }); + + public override Task Can_use_backwards_of_type_animal(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.Can_use_backwards_of_type_animal(a); + AssertSql( + """ + SELECT "id", "countryId", "discriminator", "name", "species", "isFlightless", "foundOn" + FROM "Animals" + WHERE "discriminator" = 'Kiwi' + """); + }); + + public override Task Can_use_of_type_rose(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.Can_use_of_type_rose(a); + AssertSql( + """ + SELECT "species", "$type", "genus", "name", "hasThorns" + FROM "Plants" + WHERE "$type" = 'Rose' AND ("$type" = 'Daisy' OR "$type" = 'Rose') + """); + }); + + [ConditionalTheory(Skip = SkipReason.OrderedResultSetNotSupported)] + public override Task Can_query_all_animals(bool async) + => DynamoTestHelpers.Instance.NoSyncTest(async, a => base.Can_query_all_animals(a)); + + [ConditionalTheory(Skip = SkipReason.PartitionKeyRequiredOnAllEntities)] + public override Task Can_query_all_animal_views(bool async) + => base.Can_query_all_animal_views(async); + + [ConditionalTheory(Skip = SkipReason.OrderedResultSetNotSupported)] + public override Task Can_query_all_plants(bool async) + => DynamoTestHelpers.Instance.NoSyncTest(async, a => base.Can_query_all_plants(a)); + + [ConditionalTheory(Skip = SkipReason.OrderedResultSetNotSupported)] + public override Task Can_filter_all_animals(bool async) + => DynamoTestHelpers.Instance.NoSyncTest(async, a => base.Can_filter_all_animals(a)); + + [ConditionalTheory(Skip = SkipReason.OrderedResultSetNotSupported)] + public override Task Can_query_all_birds(bool async) + => DynamoTestHelpers.Instance.NoSyncTest(async, a => base.Can_query_all_birds(a)); + + [ConditionalTheory(Skip = SkipReason.QueryShapeNotSupported)] + public override Task Can_query_just_kiwis(bool async) + => DynamoTestHelpers.Instance.NoSyncTest(async, a => base.Can_query_just_kiwis(a)); + + [ConditionalTheory(Skip = SkipReason.QueryShapeNotSupported)] + public override Task Can_query_just_roses(bool async) + => DynamoTestHelpers.Instance.NoSyncTest(async, a => base.Can_query_just_roses(a)); + + [ConditionalTheory(Skip = SkipReason.NavigationPropertiesNotSupported)] + public override Task Can_include_animals(bool async) => base.Can_include_animals(async); + + [ConditionalTheory(Skip = SkipReason.NavigationPropertiesNotSupported)] + public override Task Can_include_prey(bool async) => base.Can_include_prey(async); + + public override Task Can_use_of_type_kiwi_where_south_on_derived_property(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.Can_use_of_type_kiwi_where_south_on_derived_property(a); + AssertSql( + """ + SELECT "id", "countryId", "discriminator", "name", "species", "isFlightless", "foundOn" + FROM "Animals" + WHERE "discriminator" = 'Kiwi' AND "foundOn" = 1 AND ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') + """); + }); + + public override Task Can_use_of_type_kiwi_where_north_on_derived_property(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.Can_use_of_type_kiwi_where_north_on_derived_property(a); + AssertSql( + """ + SELECT "id", "countryId", "discriminator", "name", "species", "isFlightless", "foundOn" + FROM "Animals" + WHERE "discriminator" = 'Kiwi' AND "foundOn" = 0 AND ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') + """); + }); + + public override Task Discriminator_used_when_projection_over_derived_type(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.Discriminator_used_when_projection_over_derived_type(a); + AssertSql( + """ + SELECT "foundOn" + FROM "Animals" + WHERE "discriminator" = 'Kiwi' + """); + }); + + public override Task Discriminator_used_when_projection_over_derived_type2(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.Discriminator_used_when_projection_over_derived_type2(a); + AssertSql( + """ + SELECT "isFlightless", "discriminator" + FROM "Animals" + WHERE ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') + """); + }); + + [ConditionalTheory(Skip = SkipReason.QueryShapeNotSupported)] + public override Task Discriminator_with_cast_in_shadow_property(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + a => base.Discriminator_with_cast_in_shadow_property(a)); + + public override Task Discriminator_used_when_projection_over_of_type(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.Discriminator_used_when_projection_over_of_type(a); + AssertSql( + """ + SELECT "foundOn" + FROM "Animals" + WHERE "discriminator" = 'Kiwi' AND ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') + """); + }); + + [ConditionalFact(Skip = SkipReason.TransactionsNotSupported)] + public override Task Can_insert_update_delete() => base.Can_insert_update_delete(); + + [ConditionalTheory(Skip = SkipReason.SetOperationsNotSupported)] + public override Task Union_siblings_with_duplicate_property_in_subquery(bool async) + => base.Union_siblings_with_duplicate_property_in_subquery(async); + + [ConditionalTheory(Skip = SkipReason.SetOperationsNotSupported)] + public override Task OfType_Union_subquery(bool async) => base.OfType_Union_subquery(async); + + [ConditionalTheory(Skip = SkipReason.SetOperationsNotSupported)] + public override Task OfType_Union_OfType(bool async) => base.OfType_Union_OfType(async); + + [ConditionalTheory(Skip = SkipReason.SubqueryPushdownNotSupported)] + public override Task Subquery_OfType(bool async) => base.Subquery_OfType(async); + + [ConditionalTheory(Skip = SkipReason.SetOperationsNotSupported)] + public override Task Union_entity_equality(bool async) => base.Union_entity_equality(async); + + [ConditionalFact(Skip = SkipReason.NavigationPropertiesNotSupported)] + public override Task Setting_foreign_key_to_a_different_type_throws() + => base.Setting_foreign_key_to_a_different_type_throws(); + + public override Task Byte_enum_value_constant_used_in_projection(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.Byte_enum_value_constant_used_in_projection(a); + AssertSql( + """ + SELECT "isFlightless" + FROM "Animals" + WHERE "discriminator" = 'Kiwi' + """); + }); + + [ConditionalFact(Skip = SkipReason.OrderedResultSetNotSupported)] + public override Task Member_access_on_intermediate_type_works() + => base.Member_access_on_intermediate_type_works(); + + [ConditionalTheory(Skip = SkipReason.SubqueryPushdownNotSupported)] + public override Task Is_operator_on_result_of_FirstOrDefault(bool async) + => base.Is_operator_on_result_of_FirstOrDefault(async); + + public override Task Selecting_only_base_properties_on_base_type(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.Selecting_only_base_properties_on_base_type(a); + AssertSql( + """ + SELECT "name" + FROM "Animals" + WHERE ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') + """); + }); + + public override Task Selecting_only_base_properties_on_derived_type(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.Selecting_only_base_properties_on_derived_type(a); + AssertSql( + """ + SELECT "name" + FROM "Animals" + WHERE ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') + """); + }); + + public override Task Using_is_operator_on_multiple_type_with_no_result(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.Using_is_operator_on_multiple_type_with_no_result(a); + AssertSql( + """ + SELECT "id", "countryId", "discriminator", "name", "species", "isFlightless", "group", "foundOn" + FROM "Animals" + WHERE "discriminator" = 'Kiwi' AND "discriminator" = 'Eagle' AND ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') + """); + }); + + public override Task Using_is_operator_with_of_type_on_multiple_type_with_no_result(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.Using_is_operator_with_of_type_on_multiple_type_with_no_result(a); + AssertSql( + """ + SELECT "id", "countryId", "discriminator", "name", "species", "isFlightless", "group" + FROM "Animals" + WHERE "discriminator" = 'Kiwi' AND "discriminator" = 'Eagle' AND ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') + """); + }); + + [ConditionalTheory(Skip = SkipReason.QueryShapeNotSupported)] + public override Task Using_OfType_on_multiple_type_with_no_result(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + a => base.Using_OfType_on_multiple_type_with_no_result(a)); + + public override Task GetType_in_hierarchy_in_abstract_base_type(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.GetType_in_hierarchy_in_abstract_base_type(a); + AssertSql( + """ + SELECT "id", "countryId", "discriminator", "name", "species", "isFlightless", "group", "foundOn" + FROM "Animals" + WHERE 1 = 0 AND ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') + """); + }); + + public override Task GetType_in_hierarchy_in_intermediate_type(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.GetType_in_hierarchy_in_intermediate_type(a); + AssertSql( + """ + SELECT "id", "countryId", "discriminator", "name", "species", "isFlightless", "group", "foundOn" + FROM "Animals" + WHERE 1 = 0 AND ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') + """); + }); + + public override Task GetType_in_hierarchy_in_leaf_type_with_sibling(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.GetType_in_hierarchy_in_leaf_type_with_sibling(a); + AssertSql( + """ + SELECT "id", "countryId", "discriminator", "name", "species", "isFlightless", "group", "foundOn" + FROM "Animals" + WHERE "discriminator" = 'Eagle' AND ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') + """); + }); + + public override Task GetType_in_hierarchy_in_leaf_type_with_sibling2(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.GetType_in_hierarchy_in_leaf_type_with_sibling2(a); + AssertSql( + """ + SELECT "id", "countryId", "discriminator", "name", "species", "isFlightless", "group", "foundOn" + FROM "Animals" + WHERE "discriminator" = 'Kiwi' AND ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') + """); + }); + + public override Task GetType_in_hierarchy_in_leaf_type_with_sibling2_reverse(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.GetType_in_hierarchy_in_leaf_type_with_sibling2_reverse(a); + AssertSql( + """ + SELECT "id", "countryId", "discriminator", "name", "species", "isFlightless", "group", "foundOn" + FROM "Animals" + WHERE "discriminator" = 'Kiwi' AND ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') + """); + }); + + public override Task GetType_in_hierarchy_in_leaf_type_with_sibling2_not_equal(bool async) + => DynamoTestHelpers.Instance.NoSyncTest( + async, + async a => + { + await base.GetType_in_hierarchy_in_leaf_type_with_sibling2_not_equal(a); + AssertSql( + """ + SELECT "id", "countryId", "discriminator", "name", "species", "isFlightless", "group", "foundOn" + FROM "Animals" + WHERE NOT ("discriminator" = 'Kiwi') AND ("discriminator" = 'Eagle' OR "discriminator" = 'Kiwi') + """); + }); + + [ConditionalTheory(Skip = SkipReason.QueryShapeNotSupported)] + public override Task Filter_on_property_inside_complex_type_on_derived_type(bool async) + => base.Filter_on_property_inside_complex_type_on_derived_type(async); + + protected override void ClearLog() => Fixture.ClearSql(); + + private void AssertSql(params string[] expected) => Fixture.AssertSql(expected); + + public class InheritanceQueryDynamoFixture + : InheritanceQueryFixtureBase, IDynamoSpecificationFixture + { + public TestSqlLoggerFactory TestSqlLoggerFactory => (TestSqlLoggerFactory)ListLoggerFactory; + + public override bool UseGeneratedKeys => false; + + protected override ITestStoreFactory TestStoreFactory => DynamoTestStoreFactory.Instance; + + protected override bool ShouldLogCategory(string logCategory) + => DynamoSpecificationFixtureExtensions.ShouldLogDynamoSql(logCategory); + + public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder) + => base + .AddOptions(builder) + .ConfigureWarnings(warnings => warnings.Ignore(DynamoEventId.ScanLikeQueryDetected)) + .UseDynamo(o => o.DynamoDbClient(DynamoTestStoreFactory.Instance.Client)); + + protected override async Task CleanAsync(DbContext context) + { + await context.Database.EnsureDeletedAsync(); + await context.Database.EnsureCreatedAsync(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context) + { + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity().HasKey(e => e.Species); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + modelBuilder.Entity(); + + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + + modelBuilder + .Entity() + .HasDiscriminator("Discriminator") + .IsComplete(IsDiscriminatorMappingComplete); + modelBuilder + .Entity() + .HasDiscriminator(e => e.Discriminator) + .HasValue(DrinkType.Drink) + .HasValue(DrinkType.Coke) + .HasValue(DrinkType.Lilt) + .HasValue(DrinkType.Tea) + .IsComplete(IsDiscriminatorMappingComplete); + + modelBuilder.Entity().ToTable("Animals").HasPartitionKey(e => e.Id); + modelBuilder.Entity(entity => + { + entity.ToTable("Countries").HasPartitionKey(e => e.Id); + entity.Ignore(e => e.Animals); + entity.Ignore(e => e.Plants); + }); + modelBuilder.Entity().ToTable("Plants").HasPartitionKey(e => e.Species); + modelBuilder.Entity().ToTable("Drinks").HasPartitionKey(e => e.Id); + + modelBuilder.Entity().Ignore(e => e.Prey); + modelBuilder.Entity().Ignore(e => e.EagleId); + + modelBuilder.Entity().Property(e => e.Id).ValueGeneratedNever(); + modelBuilder.Entity().Property(e => e.Id).ValueGeneratedNever(); + modelBuilder.Entity().Property(e => e.Id).ValueGeneratedNever(); + } + } + + [Collection(DynamoSpecificationCollection.Name)] + public sealed class InheritanceQueryDynamoTestDefault : InheritanceQueryDynamoTest + { + public InheritanceQueryDynamoTestDefault( + InheritanceQueryDynamoFixture fixture, + DynamoSpecificationContainerFixture containerFixture) : base(fixture) + => _ = containerFixture; + } +} diff --git a/tests/EntityFrameworkCore.DynamoDb.SpecificationTests/TestUtilities/DynamoTestHelpers.cs b/tests/EntityFrameworkCore.DynamoDb.SpecificationTests/TestUtilities/DynamoTestHelpers.cs index 99286b2..f9518da 100644 --- a/tests/EntityFrameworkCore.DynamoDb.SpecificationTests/TestUtilities/DynamoTestHelpers.cs +++ b/tests/EntityFrameworkCore.DynamoDb.SpecificationTests/TestUtilities/DynamoTestHelpers.cs @@ -48,7 +48,13 @@ public async Task NoSyncTest(bool async, Func testCode) await testCode(async); } catch (InvalidOperationException exception) when (!async - && IsExpectedSyncQueryFailure(exception)) { } + && IsExpectedSyncQueryFailure(exception)) + { + return; + } + + if (!async) + Assert.Fail("Expected DynamoDB sync query failure."); } private static bool IsExpectedSyncQueryFailure(InvalidOperationException exception)