├── src └── EFCore.OpenEdge │ ├── efcore.openedge.extended.png │ ├── Properties │ └── AssemblyInfo.cs │ ├── Storage │ ├── IOpenEdgeRelationalConnection.cs │ ├── Internal │ │ ├── IOpenEdgeSqlGenerationHelper.cs │ │ ├── OpenEdgeSqlGenerationHelper.cs │ │ ├── OpenEdgeDatabaseCreator.cs │ │ └── Mapping │ │ │ ├── OpenEdgeBoolTypeMapping.cs │ │ │ ├── OpenEdgeDateOnlyTypeMapping.cs │ │ │ └── OpenEdgeTimestampTimezoneTypeMapping.cs │ └── OpenEdgeRelationalConnection.cs │ ├── Diagnostics │ └── Internal │ │ └── OpenEdgeLoggingDefinitions.cs │ ├── Design │ └── Internal │ │ ├── OpenEdgeAnnotationCodeGenerator.cs │ │ └── OpenEdgeDesignTimeServices.cs │ ├── Metadata │ └── Conventions │ │ └── Internal │ │ └── OpenEdgeRelationalConventionSetBuilder.cs │ ├── Update │ ├── Internal │ │ ├── OpenEdgeModificationCommandBatchFactory.cs │ │ └── OpenEdgeModificationCommandBatch.cs │ └── OpenEdgeUpdateSqlGenerator.cs │ ├── Query │ ├── ExpressionTranslators │ │ └── Internal │ │ │ ├── OpenEdgeMemberTranslatorProvider.cs │ │ │ ├── OpenEdgeMethodCallTranslatorProvider.cs │ │ │ ├── OpenEdgeStringLengthTranslator.cs │ │ │ ├── OpenEdgeDateOnlyMemberTranslator.cs │ │ │ ├── OpenEdgeStringMethodCallTranslator.cs │ │ │ └── OpenEdgeDateOnlyMethodCallTranslator.cs │ ├── Sql │ │ └── Internal │ │ │ ├── OpenEdgeSqlGeneratorFactory.cs │ │ │ └── OpenEdgeSqlGenerator.cs │ ├── ExpressionVisitors │ │ └── Internal │ │ │ ├── OpenEdgeQueryTranslationPostprocessorFactory.cs │ │ │ ├── OpenEdgeParameterBasedSqlProcessorFactory.cs │ │ │ ├── OpenEdgeSqlTranslatingExpressionVisitorFactory.cs │ │ │ ├── OpenEdgeQueryableMethodTranslatingExpressionVisitorFactory.cs │ │ │ ├── OpenEdgeQueryExpressionVisitor.cs │ │ │ ├── OpenEdgeQueryTranslationPostprocessor.cs │ │ │ ├── OpenEdgeQueryableMethodTranslatingExpressionVisitor.cs │ │ │ └── OpenEdgeSqlTranslatingExpressionVisitor.cs │ ├── Internal │ │ ├── OpenEdgeResultOperatorHandler.cs │ │ └── __delete_OpenEdgeQueryModelGenerator.cs │ └── pipeline.md │ ├── Extensions │ ├── OpenEdgeStringExtensions.cs │ ├── OpenEdgeSharedTypeExtensions.cs │ ├── OpenEdgeDataReaderExtensions.cs │ ├── OpenEdgeServiceCollectionExtensions.cs │ └── OpenEdgeDbContextOptionsBuilderExtensions.cs │ ├── Scaffolding │ └── Internal │ │ ├── OpenEdgeCodeGenerator.cs │ │ └── OpenEdgeDatabaseModelFactory.cs │ ├── Infrastructure │ └── Internal │ │ ├── OpenEdgeModelCustomizer.cs │ │ └── OpenEdgeOptionsExtension.cs │ └── EFCore.OpenEdge.csproj ├── test └── EFCore.OpenEdge.FunctionalTests │ ├── appsettings.example.json │ ├── Shared │ ├── Models │ │ ├── Category.cs │ │ ├── OrderItem.cs │ │ ├── Customer.cs │ │ ├── Order.cs │ │ └── Product.cs │ ├── ECommerceTestBase.cs │ └── ECommerceTestContext.cs │ ├── EFCore.OpenEdge.FunctionalTests.csproj │ ├── Query │ ├── BasicQueryTests.cs │ ├── BooleanParameterTest.cs │ ├── JoinQueryTests.cs │ ├── DateOnlyTranslationTests.cs │ ├── DateOnlyMethodTranslationTests.cs │ ├── SqlGenerationTests.cs │ └── AdvancedQueryTests.cs │ ├── TestUtilities │ ├── OpenEdgeTestBase.cs │ └── SqlCapturingInterceptor.cs │ ├── Update │ ├── TransactionTests.cs │ └── BulkUpdateTests.cs │ └── Unit │ └── TypeMapping │ └── OpenEdgeTypeMappingSourceTests.cs ├── CHANGELOG.md ├── EFCore.OpenEdge.sln ├── README.md └── .gitignore /src/EFCore.OpenEdge/efcore.openedge.extended.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexwiese/EntityFrameworkCore.OpenEdge/HEAD/src/EFCore.OpenEdge/efcore.openedge.extended.png -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/appsettings.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "OpenEdgeConnection": "Your OpenEdge Connection String" 4 | } 5 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Design; 2 | 3 | [assembly: DesignTimeProviderServices("EntityFrameworkCore.OpenEdge.Design.Internal.OpenEdgeDesignTimeServices")] 4 | -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Storage/IOpenEdgeRelationalConnection.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Storage; 2 | 3 | namespace EntityFrameworkCore.OpenEdge.Storage 4 | { 5 | public interface IOpenEdgeRelationalConnection : IRelationalConnection 6 | { 7 | } 8 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Storage/Internal/IOpenEdgeSqlGenerationHelper.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Storage; 2 | 3 | namespace EntityFrameworkCore.OpenEdge.Storage.Internal 4 | { 5 | public interface IOpenEdgeSqlGenerationHelper : ISqlGenerationHelper 6 | { 7 | } 8 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Diagnostics/Internal/OpenEdgeLoggingDefinitions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Diagnostics; 2 | 3 | namespace EntityFrameworkCore.OpenEdge.Diagnostics.Internal 4 | { 5 | public class OpenEdgeLoggingDefinitions : RelationalLoggingDefinitions 6 | { 7 | } 8 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Design/Internal/OpenEdgeAnnotationCodeGenerator.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Design; 2 | 3 | namespace EntityFrameworkCore.OpenEdge.Design.Internal 4 | { 5 | public class OpenEdgeAnnotationCodeGenerator : AnnotationCodeGenerator 6 | { 7 | public OpenEdgeAnnotationCodeGenerator(AnnotationCodeGeneratorDependencies dependencies) : base(dependencies) 8 | { 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Storage/OpenEdgeRelationalConnection.cs: -------------------------------------------------------------------------------- 1 | using System.Data.Common; 2 | using System.Data.Odbc; 3 | using Microsoft.EntityFrameworkCore.Storage; 4 | 5 | namespace EntityFrameworkCore.OpenEdge.Storage 6 | { 7 | public class OpenEdgeRelationalConnection : RelationalConnection, IOpenEdgeRelationalConnection 8 | { 9 | public OpenEdgeRelationalConnection(RelationalConnectionDependencies dependencies) 10 | : base(dependencies) 11 | { 12 | } 13 | 14 | protected override DbConnection CreateDbConnection() 15 | => new OdbcConnection(ConnectionString); 16 | } 17 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Metadata/Conventions/Internal/OpenEdgeRelationalConventionSetBuilder.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; 2 | 3 | namespace EntityFrameworkCore.OpenEdge.Metadata.Conventions.Internal 4 | { 5 | public class OpenEdgeRelationalConventionSetBuilder : RelationalConventionSetBuilder, IConventionSetBuilder 6 | { 7 | public OpenEdgeRelationalConventionSetBuilder( 8 | ProviderConventionSetBuilderDependencies dependencies, 9 | RelationalConventionSetBuilderDependencies relationalDependencies) 10 | : base(dependencies, relationalDependencies) 11 | { 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/Shared/Models/Category.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.ComponentModel.DataAnnotations; 3 | using System.ComponentModel.DataAnnotations.Schema; 4 | 5 | namespace EFCore.OpenEdge.FunctionalTests.Shared.Models 6 | { 7 | [Table("CATEGORIES_TEST_PROVIDER", Schema = "PUB")] 8 | public class Category 9 | { 10 | [Key] 11 | public int Id { get; set; } 12 | 13 | [Required] 14 | [MaxLength(100)] 15 | public string Name { get; set; } 16 | 17 | [MaxLength(500)] 18 | public string Description { get; set; } 19 | 20 | // Navigation property for products 21 | public virtual ICollection Products { get; set; } = new List(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [V9.0.7] - 09-19-2025 6 | 7 | ### Fixed 8 | - Fixed inline fetch/offset statements being cached incorrectly 9 | 10 | ### Added 11 | - Extended support for datetimezone type 12 | 13 | ## [V9.0.6] - 08-21-2025 14 | 15 | ### Fixed 16 | - Fixed issue with boolean comparison not being translated correctly in some cases 17 | 18 | ### Added 19 | - Added support for DateOnly type in queries 20 | 21 | ## [V9.0.4] - 08-07-2025 22 | 23 | ### Fixed 24 | 25 | - Fixed issue with OFFSET/FETCH parameters not being inlined correctly in complex queries 26 | - Added support for nested queries with Skip/Take 27 | 28 | ### Changed 29 | 30 | - Commented out functionality for casting COUNT in generated queries to INT -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Update/Internal/OpenEdgeModificationCommandBatchFactory.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using Microsoft.EntityFrameworkCore.Update; 3 | using Microsoft.EntityFrameworkCore.Update.Internal; 4 | 5 | namespace EntityFrameworkCore.OpenEdge.Update.Internal 6 | { 7 | public class OpenEdgeModificationCommandBatchFactory : IModificationCommandBatchFactory 8 | { 9 | private readonly ModificationCommandBatchFactoryDependencies _dependencies; 10 | 11 | public OpenEdgeModificationCommandBatchFactory(ModificationCommandBatchFactoryDependencies dependencies) 12 | { 13 | _dependencies = dependencies; 14 | } 15 | 16 | public virtual ModificationCommandBatch Create() 17 | => new OpenEdgeModificationCommandBatch(_dependencies); 18 | } 19 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Query/ExpressionTranslators/Internal/OpenEdgeMemberTranslatorProvider.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Query; 2 | 3 | namespace EntityFrameworkCore.OpenEdge.Query.ExpressionTranslators.Internal 4 | { 5 | /// 6 | /// Provides member translators for OpenEdge. 7 | /// 8 | public class OpenEdgeMemberTranslatorProvider : RelationalMemberTranslatorProvider 9 | { 10 | public OpenEdgeMemberTranslatorProvider(RelationalMemberTranslatorProviderDependencies dependencies) 11 | : base(dependencies) 12 | { 13 | AddTranslators([ 14 | new OpenEdgeStringLengthTranslator(dependencies.SqlExpressionFactory), 15 | new OpenEdgeDateOnlyMemberTranslator(dependencies.SqlExpressionFactory) 16 | ]); 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Query/ExpressionTranslators/Internal/OpenEdgeMethodCallTranslatorProvider.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Query; 2 | 3 | namespace EntityFrameworkCore.OpenEdge.Query.ExpressionTranslators.Internal 4 | { 5 | /// 6 | /// Provides method call translators for OpenEdge. 7 | /// 8 | public class OpenEdgeMethodCallTranslatorProvider : RelationalMethodCallTranslatorProvider 9 | { 10 | public OpenEdgeMethodCallTranslatorProvider(RelationalMethodCallTranslatorProviderDependencies dependencies) 11 | : base(dependencies) 12 | { 13 | AddTranslators([ 14 | new OpenEdgeStringMethodCallTranslator(dependencies.SqlExpressionFactory), 15 | new OpenEdgeDateOnlyMethodCallTranslator(dependencies.SqlExpressionFactory) 16 | ]); 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/Shared/Models/OrderItem.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.ComponentModel.DataAnnotations.Schema; 3 | 4 | namespace EFCore.OpenEdge.FunctionalTests.Shared.Models 5 | { 6 | [Table("ORDER_ITEMS_TEST_PROVIDER", Schema = "PUB")] 7 | public class OrderItem 8 | { 9 | [Key] 10 | public int Id { get; set; } 11 | 12 | [Required] 13 | public int OrderId { get; set; } 14 | 15 | [Required] 16 | public int ProductId { get; set; } 17 | 18 | [Required] 19 | public int Quantity { get; set; } 20 | 21 | [Column(TypeName = "decimal(10,2)")] 22 | public decimal UnitPrice { get; set; } 23 | 24 | // Navigation properties 25 | public virtual Order Order { get; set; } 26 | public virtual Product Product { get; set; } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Query/Sql/Internal/OpenEdgeSqlGeneratorFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Query; 2 | using Microsoft.EntityFrameworkCore.Storage; 3 | 4 | namespace EntityFrameworkCore.OpenEdge.Query.Sql.Internal 5 | { 6 | public class OpenEdgeSqlGeneratorFactory : IQuerySqlGeneratorFactory 7 | { 8 | private readonly QuerySqlGeneratorDependencies _dependencies; 9 | private readonly IRelationalTypeMappingSource _typeMappingSource; 10 | 11 | public OpenEdgeSqlGeneratorFactory( 12 | QuerySqlGeneratorDependencies dependencies, 13 | IRelationalTypeMappingSource typeMappingSource) 14 | { 15 | _dependencies = dependencies; 16 | _typeMappingSource = typeMappingSource; 17 | } 18 | 19 | public QuerySqlGenerator Create() 20 | { 21 | var result = new OpenEdgeSqlGenerator(_dependencies, _typeMappingSource); 22 | 23 | return result; 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Query/ExpressionVisitors/Internal/OpenEdgeQueryTranslationPostprocessorFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Query; 2 | 3 | namespace EntityFrameworkCore.OpenEdge.Query.ExpressionVisitors.Internal 4 | { 5 | /// 6 | /// Factory for creating OpenEdgeQueryTranslationPostprocessor instances. 7 | /// Replaces the old QueryModelGenerator pattern. 8 | /// 9 | public class OpenEdgeQueryTranslationPostprocessorFactory( 10 | QueryTranslationPostprocessorDependencies dependencies, 11 | RelationalQueryTranslationPostprocessorDependencies relationalDependencies) : IQueryTranslationPostprocessorFactory 12 | { 13 | public virtual QueryTranslationPostprocessor Create(QueryCompilationContext queryCompilationContext) 14 | => new OpenEdgeQueryTranslationPostprocessor( 15 | dependencies, 16 | relationalDependencies, 17 | (RelationalQueryCompilationContext) queryCompilationContext); 18 | } 19 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Extensions/OpenEdgeStringExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace EntityFrameworkCore.OpenEdge.Extensions 6 | { 7 | public static class OpenEdgeStringExtensions 8 | { 9 | public static StringBuilder AppendJoin( 10 | this StringBuilder stringBuilder, 11 | IEnumerable values, 12 | TParam param, 13 | Action joinAction, 14 | string separator = ", ") 15 | { 16 | var appended = false; 17 | 18 | foreach (var value in values) 19 | { 20 | joinAction(stringBuilder, value, param); 21 | stringBuilder.Append(separator); 22 | appended = true; 23 | } 24 | 25 | if (appended) 26 | { 27 | stringBuilder.Length -= separator.Length; 28 | } 29 | 30 | return stringBuilder; 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Design/Internal/OpenEdgeDesignTimeServices.cs: -------------------------------------------------------------------------------- 1 | using EntityFrameworkCore.OpenEdge.Scaffolding.Internal; 2 | using EntityFrameworkCore.OpenEdge.Storage.Internal.Mapping; 3 | using Microsoft.EntityFrameworkCore.Design; 4 | using Microsoft.EntityFrameworkCore.Scaffolding; 5 | using Microsoft.EntityFrameworkCore.Storage; 6 | using Microsoft.Extensions.DependencyInjection; 7 | 8 | namespace EntityFrameworkCore.OpenEdge.Design.Internal 9 | { 10 | public class OpenEdgeDesignTimeServices : IDesignTimeServices 11 | { 12 | public void ConfigureDesignTimeServices(IServiceCollection serviceCollection) 13 | => serviceCollection 14 | .AddSingleton() 15 | .AddSingleton() 16 | .AddSingleton() 17 | .AddSingleton() 18 | ; 19 | } 20 | } -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/Shared/ECommerceTestBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using EFCore.OpenEdge.FunctionalTests.TestUtilities; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | namespace EFCore.OpenEdge.FunctionalTests.Shared 6 | { 7 | public abstract class ECommerceTestBase : OpenEdgeTestBase, IDisposable 8 | { 9 | protected ECommerceTestBase() 10 | { 11 | // Ensure database is seeded with test data 12 | TestDataSeeder.EnsureSeeded(ConnectionString); 13 | } 14 | 15 | protected ECommerceTestContext CreateContext() 16 | { 17 | var options = CreateOptionsBuilder().Options; 18 | var context = new ECommerceTestContext(options); 19 | 20 | // Disable savepoints for OpenEdge compatibility 21 | context.Database.AutoSavepointsEnabled = false; 22 | 23 | return context; 24 | } 25 | 26 | public override void Dispose() 27 | { 28 | base.Dispose(); 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Query/ExpressionVisitors/Internal/OpenEdgeParameterBasedSqlProcessorFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Query; 2 | 3 | namespace EntityFrameworkCore.OpenEdge.Query.ExpressionVisitors.Internal 4 | { 5 | /// 6 | /// Factory for creating OpenEdgeParameterBasedSqlProcessor instances. 7 | /// Follows the standard EF Core factory pattern for dependency injection. 8 | /// 9 | public class OpenEdgeParameterBasedSqlProcessorFactory : IRelationalParameterBasedSqlProcessorFactory 10 | { 11 | private readonly RelationalParameterBasedSqlProcessorDependencies _dependencies; 12 | 13 | public OpenEdgeParameterBasedSqlProcessorFactory(RelationalParameterBasedSqlProcessorDependencies dependencies) 14 | => _dependencies = dependencies; 15 | 16 | /// 17 | /// Creates a new OpenEdgeParameterBasedSqlProcessor instance 18 | /// 19 | public RelationalParameterBasedSqlProcessor Create(RelationalParameterBasedSqlProcessorParameters parameters) 20 | => new OpenEdgeParameterBasedSqlProcessor(_dependencies, parameters); 21 | } 22 | } -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/Shared/Models/Customer.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.ComponentModel.DataAnnotations; 3 | using System.ComponentModel.DataAnnotations.Schema; 4 | 5 | namespace EFCore.OpenEdge.FunctionalTests.Shared.Models 6 | { 7 | // [Table("CUSTOMERS_TEST_PROVIDER", Schema = "PUB")] 8 | [Table("CUSTOMERS_TEST_PROVIDER")] 9 | public class Customer 10 | { 11 | [Key] 12 | public int Id { get; set; } 13 | 14 | [Required] 15 | [MaxLength(100)] 16 | public string Name { get; set; } 17 | 18 | [MaxLength(100)] 19 | public string Email { get; set; } 20 | 21 | public int Age { get; set; } 22 | 23 | [MaxLength(50)] 24 | public string City { get; set; } 25 | 26 | public bool IsActive { get; set; } 27 | 28 | // Navigation property for orders 29 | public virtual ICollection Orders { get; set; } = new List(); 30 | 31 | public override string ToString() 32 | { 33 | return $"Customer {{ Id: {Id}, Name: {Name}, Email: {Email}, Age: {Age}, City: {City}, IsActive: {IsActive} }}"; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/Shared/Models/Order.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.ComponentModel.DataAnnotations; 4 | using System.ComponentModel.DataAnnotations.Schema; 5 | 6 | namespace EFCore.OpenEdge.FunctionalTests.Shared.Models 7 | { 8 | [Table("ORDERS_TEST_PROVIDER", Schema = "PUB")] 9 | public class Order 10 | { 11 | [Key] 12 | public int Id { get; set; } 13 | 14 | [Required] 15 | public int CustomerId { get; set; } 16 | 17 | [Required] 18 | public DateOnly? OrderDate { get; set; } 19 | 20 | [Column(TypeName = "decimal(10,2)")] 21 | public decimal TotalAmount { get; set; } 22 | 23 | [MaxLength(50)] 24 | public string Status { get; set; } 25 | 26 | // Navigation properties 27 | public virtual Customer Customer { get; set; } 28 | public virtual ICollection OrderItems { get; set; } = new List(); 29 | 30 | public override string ToString() 31 | { 32 | return $"Order {{ Id: {Id}, CustomerId: {CustomerId}, OrderDate: {OrderDate}, TotalAmount: {TotalAmount}, Status: {Status} }}"; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Extensions/OpenEdgeSharedTypeExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | 4 | namespace EntityFrameworkCore.OpenEdge.Extensions 5 | { 6 | public static class OpenEdgeSharedTypeExtensions 7 | { 8 | public static Type UnwrapNullableType(this Type type) => Nullable.GetUnderlyingType(type) ?? type; 9 | 10 | public static bool IsInteger(this Type type) 11 | { 12 | type = type.UnwrapNullableType(); 13 | 14 | return type == typeof(int) 15 | || type == typeof(long) 16 | || type == typeof(short) 17 | || type == typeof(byte) 18 | || type == typeof(uint) 19 | || type == typeof(ulong) 20 | || type == typeof(ushort) 21 | || type == typeof(sbyte) 22 | || type == typeof(char); 23 | } 24 | 25 | public static bool IsNullableType(this Type type) 26 | { 27 | var typeInfo = type.GetTypeInfo(); 28 | 29 | return !typeInfo.IsValueType 30 | || typeInfo.IsGenericType 31 | && typeInfo.GetGenericTypeDefinition() == typeof(Nullable<>); 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/Shared/Models/Product.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.ComponentModel.DataAnnotations; 3 | using System.ComponentModel.DataAnnotations.Schema; 4 | 5 | namespace EFCore.OpenEdge.FunctionalTests.Shared.Models 6 | { 7 | [Table("PRODUCTS_TEST_PROVIDER", Schema = "PUB")] 8 | public class Product 9 | { 10 | [Key] 11 | public int Id { get; set; } 12 | 13 | [Required] 14 | [MaxLength(100)] 15 | public string Name { get; set; } 16 | 17 | [Column(TypeName = "decimal(10,2)")] 18 | public decimal Price { get; set; } 19 | 20 | public int CategoryId { get; set; } 21 | 22 | [MaxLength(500)] 23 | public string Description { get; set; } 24 | 25 | public bool InStock { get; set; } 26 | 27 | // Navigation property for category 28 | public virtual Category Category { get; set; } 29 | 30 | // Navigation property for order items 31 | public virtual ICollection OrderItems { get; set; } = new List(); 32 | 33 | public override string ToString() 34 | { 35 | return $"Product: {Name}, Price: {Price}, Category: {Category?.Name}"; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Query/Internal/OpenEdgeResultOperatorHandler.cs: -------------------------------------------------------------------------------- 1 | // using Microsoft.EntityFrameworkCore.Metadata; 2 | // using Microsoft.EntityFrameworkCore.Query; 3 | // using Microsoft.EntityFrameworkCore.Query.Expressions; 4 | // using Microsoft.EntityFrameworkCore.Query.ExpressionVisitors; 5 | // using Microsoft.EntityFrameworkCore.Query.Internal; 6 | // 7 | // namespace EntityFrameworkCore.OpenEdge.Query.Internal 8 | // { 9 | // /* 10 | // * This class is responsible for handling aggregations and result operations. 11 | // * For example: 12 | // * 13 | // * // Handles converting .Count() to SQL: 14 | // * SELECT COUNT(*) FROM ... 15 | // */ 16 | // public class OpenEdgeResultOperatorHandler : RelationalResultOperatorHandler 17 | // { 18 | // public OpenEdgeResultOperatorHandler(IModel model, 19 | // ISqlTranslatingExpressionVisitorFactory sqlTranslatingExpressionVisitorFactory, 20 | // ISelectExpressionFactory selectExpressionFactory, 21 | // IResultOperatorHandler resultOperatorHandler) 22 | // : base(model, sqlTranslatingExpressionVisitorFactory, 23 | // selectExpressionFactory, resultOperatorHandler) 24 | // { 25 | // } 26 | // } 27 | // } 28 | -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Query/ExpressionVisitors/Internal/OpenEdgeSqlTranslatingExpressionVisitorFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Query; 2 | 3 | namespace EntityFrameworkCore.OpenEdge.Query.ExpressionVisitors.Internal 4 | { 5 | /// 6 | /// Factory for creating SQL translating expression visitors for OpenEdge. 7 | /// Handles translation of LINQ expressions to SQL expressions. 8 | /// 9 | public class OpenEdgeSqlTranslatingExpressionVisitorFactory : IRelationalSqlTranslatingExpressionVisitorFactory 10 | { 11 | private readonly RelationalSqlTranslatingExpressionVisitorDependencies _dependencies; 12 | 13 | public OpenEdgeSqlTranslatingExpressionVisitorFactory( 14 | RelationalSqlTranslatingExpressionVisitorDependencies dependencies) 15 | { 16 | _dependencies = dependencies; 17 | } 18 | 19 | public virtual RelationalSqlTranslatingExpressionVisitor Create( 20 | QueryCompilationContext queryCompilationContext, 21 | QueryableMethodTranslatingExpressionVisitor queryableMethodTranslatingExpressionVisitor) 22 | => new OpenEdgeSqlTranslatingExpressionVisitor( 23 | _dependencies, 24 | queryCompilationContext, 25 | queryableMethodTranslatingExpressionVisitor); 26 | } 27 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Query/ExpressionVisitors/Internal/OpenEdgeQueryableMethodTranslatingExpressionVisitorFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Query; 2 | 3 | namespace EntityFrameworkCore.OpenEdge.Query.ExpressionVisitors.Internal 4 | { 5 | public class OpenEdgeQueryableMethodTranslatingExpressionVisitorFactory : IQueryableMethodTranslatingExpressionVisitorFactory 6 | { 7 | private readonly QueryableMethodTranslatingExpressionVisitorDependencies _dependencies; 8 | private readonly RelationalQueryableMethodTranslatingExpressionVisitorDependencies _relationalDependencies; 9 | 10 | public OpenEdgeQueryableMethodTranslatingExpressionVisitorFactory( 11 | QueryableMethodTranslatingExpressionVisitorDependencies dependencies, 12 | RelationalQueryableMethodTranslatingExpressionVisitorDependencies relationalDependencies) 13 | { 14 | _dependencies = dependencies; 15 | _relationalDependencies = relationalDependencies; 16 | } 17 | 18 | public QueryableMethodTranslatingExpressionVisitor Create(QueryCompilationContext queryCompilationContext) 19 | { 20 | return new OpenEdgeQueryableMethodTranslatingExpressionVisitor( 21 | _dependencies, 22 | _relationalDependencies, 23 | (RelationalQueryCompilationContext)queryCompilationContext); 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/EFCore.OpenEdge.FunctionalTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | EFCore.OpenEdge.FunctionalTests 6 | EFCore.OpenEdge.FunctionalTests 7 | false 8 | latest 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | Always 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Storage/Internal/OpenEdgeSqlGenerationHelper.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using Microsoft.EntityFrameworkCore.Storage; 3 | 4 | namespace EntityFrameworkCore.OpenEdge.Storage.Internal 5 | { 6 | public class OpenEdgeSqlGenerationHelper : RelationalSqlGenerationHelper, IOpenEdgeSqlGenerationHelper 7 | { 8 | public OpenEdgeSqlGenerationHelper(RelationalSqlGenerationHelperDependencies dependencies) 9 | : base(dependencies) 10 | { 11 | 12 | } 13 | 14 | public override string StatementTerminator { get; } = ""; 15 | 16 | public override string GenerateParameterName(string name) 17 | { 18 | return "?"; // Always return ? for positional parameters 19 | } 20 | 21 | public override void DelimitIdentifier(StringBuilder builder, string identifier) 22 | { 23 | // Row ID cannot be delimited in OpenEdge 24 | if (identifier == "rowid") 25 | { 26 | EscapeIdentifier(builder, identifier); 27 | return; 28 | } 29 | 30 | base.DelimitIdentifier(builder, identifier); 31 | } 32 | 33 | public override string DelimitIdentifier(string identifier) 34 | { 35 | // Row ID cannot be delimited in OpenEdge 36 | if (identifier == "rowid") 37 | { 38 | return EscapeIdentifier(identifier); 39 | } 40 | 41 | return base.DelimitIdentifier(identifier); 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Scaffolding/Internal/OpenEdgeCodeGenerator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Design; 4 | using Microsoft.EntityFrameworkCore.Scaffolding; 5 | 6 | namespace EntityFrameworkCore.OpenEdge.Scaffolding.Internal 7 | { 8 | /// 9 | /// Code generator for OpenEdge database provider scaffolding. 10 | /// 11 | public class OpenEdgeCodeGenerator : ProviderCodeGenerator 12 | { 13 | public OpenEdgeCodeGenerator(ProviderCodeGeneratorDependencies dependencies) 14 | : base(dependencies) 15 | { 16 | } 17 | 18 | public override MethodCallCodeFragment GenerateUseProvider( 19 | string connectionString, 20 | MethodCallCodeFragment providerOptions) 21 | { 22 | // Get the MethodInfo for the UseOpenEdge extension method 23 | var useOpenEdgeMethod = typeof(OpenEdgeDbContextOptionsBuilderExtensions) 24 | .GetMethod(nameof(OpenEdgeDbContextOptionsBuilderExtensions.UseOpenEdge), 25 | new[] { typeof(DbContextOptionsBuilder), typeof(string), typeof(System.Action<>).MakeGenericType(typeof(object)) }); 26 | 27 | if (useOpenEdgeMethod == null) 28 | { 29 | throw new InvalidOperationException("Could not find UseOpenEdge method"); 30 | } 31 | 32 | return new MethodCallCodeFragment( 33 | useOpenEdgeMethod, 34 | connectionString, 35 | providerOptions); 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/Shared/ECommerceTestContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using EFCore.OpenEdge.FunctionalTests.Shared.Models; 3 | 4 | namespace EFCore.OpenEdge.FunctionalTests.Shared 5 | { 6 | public class ECommerceTestContext : DbContext 7 | { 8 | public DbSet Customers { get; set; } 9 | public DbSet Products { get; set; } 10 | public DbSet Categories { get; set; } 11 | public DbSet Orders { get; set; } 12 | public DbSet OrderItems { get; set; } 13 | 14 | public ECommerceTestContext(DbContextOptions options) : base(options) 15 | { 16 | } 17 | 18 | protected override void OnModelCreating(ModelBuilder modelBuilder) 19 | { 20 | base.OnModelCreating(modelBuilder); 21 | 22 | // Configure relationships 23 | modelBuilder.Entity() 24 | .HasOne(o => o.Customer) 25 | .WithMany(c => c.Orders) 26 | .HasForeignKey(o => o.CustomerId); 27 | 28 | modelBuilder.Entity() 29 | .HasOne(p => p.Category) 30 | .WithMany(c => c.Products) 31 | .HasForeignKey(p => p.CategoryId); 32 | 33 | modelBuilder.Entity() 34 | .HasOne(oi => oi.Order) 35 | .WithMany(o => o.OrderItems) 36 | .HasForeignKey(oi => oi.OrderId); 37 | 38 | modelBuilder.Entity() 39 | .HasOne(oi => oi.Product) 40 | .WithMany(p => p.OrderItems) 41 | .HasForeignKey(oi => oi.ProductId); 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Query/ExpressionTranslators/Internal/OpenEdgeStringLengthTranslator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | using Microsoft.EntityFrameworkCore.Diagnostics; 4 | using Microsoft.EntityFrameworkCore.Query; 5 | using Microsoft.EntityFrameworkCore.Query.SqlExpressions; 6 | 7 | namespace EntityFrameworkCore.OpenEdge.Query.ExpressionTranslators.Internal 8 | { 9 | /// 10 | /// Translates string.Length property access to OpenEdge SQL length() function. 11 | /// 12 | public class OpenEdgeStringLengthTranslator : IMemberTranslator 13 | { 14 | private readonly ISqlExpressionFactory _sqlExpressionFactory; 15 | 16 | private static readonly PropertyInfo _stringLengthProperty = typeof(string).GetRuntimeProperty( 17 | nameof(string.Length))!; 18 | 19 | public OpenEdgeStringLengthTranslator(ISqlExpressionFactory sqlExpressionFactory) 20 | { 21 | _sqlExpressionFactory = sqlExpressionFactory; 22 | } 23 | 24 | #nullable enable 25 | public virtual SqlExpression? Translate( 26 | SqlExpression? instance, 27 | MemberInfo member, 28 | Type returnType, 29 | IDiagnosticsLogger logger) 30 | { 31 | // Check if this is a string.Length property access 32 | if (member.Equals(_stringLengthProperty)) 33 | { 34 | // Translate to OpenEdge length() function 35 | return _sqlExpressionFactory.Function( 36 | "LENGTH", 37 | [instance!], 38 | nullable: true, 39 | argumentsPropagateNullability: [true], 40 | returnType); 41 | } 42 | 43 | return null; 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Infrastructure/Internal/OpenEdgeModelCustomizer.cs: -------------------------------------------------------------------------------- 1 | using EntityFrameworkCore.OpenEdge.Infrastructure.Internal; 2 | using Microsoft.EntityFrameworkCore; 3 | using Microsoft.EntityFrameworkCore.Infrastructure; 4 | 5 | namespace EntityFrameworkCore.OpenEdge.Infrastructure.Internal 6 | { 7 | /// 8 | /// OpenEdge-specific model customizer that applies provider-specific configurations during model building. 9 | /// 10 | public class OpenEdgeModelCustomizer : RelationalModelCustomizer 11 | { 12 | public OpenEdgeModelCustomizer(ModelCustomizerDependencies dependencies) 13 | : base(dependencies) 14 | { 15 | } 16 | 17 | /// 18 | /// Customizes the model by applying OpenEdge-specific configurations, including the default schema. 19 | /// 20 | /// The model builder to customize. 21 | /// The context for which the model is being built. 22 | public override void Customize(ModelBuilder modelBuilder, DbContext context) 23 | { 24 | // Apply the default schema from options if configured 25 | var openEdgeOptionsExtension = context.GetService() 26 | .FindExtension(); 27 | 28 | if (openEdgeOptionsExtension != null) 29 | { 30 | var defaultSchema = openEdgeOptionsExtension.DefaultSchema; 31 | if (!string.IsNullOrEmpty(defaultSchema)) 32 | { 33 | modelBuilder.HasDefaultSchema(defaultSchema); 34 | } 35 | } 36 | 37 | // Call base implementation to ensure all relational customizations are applied 38 | base.Customize(modelBuilder, context); 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Extensions/OpenEdgeDataReaderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Data.Common; 3 | 4 | namespace EntityFrameworkCore.OpenEdge.Extensions 5 | { 6 | public static class OpenEdgeDataReaderExtensions 7 | { 8 | /* 9 | * GetValueOrDefault is NOT a native method on DbDataReader. 10 | * The compiler searches for extension methods in scope that match 11 | * a) First parameter type: DbDataReader (matches reader.Object) 12 | * b) Method name: GetValueOrDefault 13 | * 14 | * The compiler transforms this call: 15 | * // How it is called: 16 | * reader.Object.GetValueOrDefault("TestColumn") 17 | * 18 | * // What the compiler actually calls: 19 | * OpenEdgeDataReaderExtensions.GetValueOrDefault(reader.Object, "TestColumn") 20 | */ 21 | public static T GetValueOrDefault(this DbDataReader reader, string name) 22 | { 23 | var idx = reader.GetOrdinal(name); 24 | return reader.IsDBNull(idx) 25 | ? default 26 | : (T)GetValue(reader.GetValue(idx)); 27 | } 28 | 29 | public static T GetValueOrDefault(this DbDataRecord record, string name) 30 | { 31 | var idx = record.GetOrdinal(name); 32 | return record.IsDBNull(idx) 33 | ? default 34 | : (T)GetValue(record.GetValue(idx)); 35 | } 36 | 37 | private static object GetValue(object valueRecord) 38 | { 39 | switch (typeof(T).Name) 40 | { 41 | case nameof(Int32): 42 | return Convert.ToInt32(valueRecord); 43 | case nameof(Boolean): 44 | return Convert.ToBoolean(valueRecord); 45 | default: 46 | return valueRecord; 47 | } 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/Query/BasicQueryTests.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using EFCore.OpenEdge.FunctionalTests.Shared; 3 | using FluentAssertions; 4 | using Xunit; 5 | using Xunit.Abstractions; 6 | 7 | namespace EFCore.OpenEdge.FunctionalTests.Query 8 | { 9 | public class BasicQueryTests : ECommerceTestBase 10 | { 11 | 12 | private readonly ITestOutputHelper _output; 13 | 14 | public BasicQueryTests(ITestOutputHelper output) 15 | { 16 | _output = output; 17 | } 18 | 19 | 20 | [Fact] 21 | public void CanExecuteBasicSelect() 22 | { 23 | using var context = CreateContext(); 24 | 25 | var customers = context.Customers.ToList(); 26 | 27 | customers.Should().NotBeEmpty(); 28 | } 29 | 30 | [Fact] 31 | public void CanExecuteBasicWhere() 32 | { 33 | using var context = CreateContext(); 34 | 35 | var customer = context.Customers.Where(c => c.Name == "John Doe").Single(); 36 | 37 | customer.Should().NotBeNull(); 38 | } 39 | 40 | [Fact] 41 | public void CanExecuteBasicOrderBy() 42 | { 43 | using var context = CreateContext(); 44 | 45 | var customers = context.Customers.OrderBy(c => c.Age).ToList(); 46 | 47 | customers.Should().NotBeEmpty(); 48 | customers[0].Age.Should().Be(25); 49 | customers[9].Age.Should().Be(55); 50 | } 51 | 52 | [Fact] 53 | public void CanExecuteBasicCount() 54 | { 55 | using var context = CreateContext(); 56 | 57 | var count = context.Customers.Count(); 58 | 59 | count.Should().BeGreaterThan(0); 60 | } 61 | 62 | [Fact] 63 | public void CanExecuteBasicFirst() 64 | { 65 | using var context = CreateContext(); 66 | 67 | var customer = context.Customers.First(); 68 | 69 | customer.Should().NotBeNull(); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Query/Internal/__delete_OpenEdgeQueryModelGenerator.cs: -------------------------------------------------------------------------------- 1 | // using System.Linq.Expressions; 2 | // using EntityFrameworkCore.OpenEdge.Query.ExpressionVisitors.Internal; 3 | // using Microsoft.EntityFrameworkCore; 4 | // using Microsoft.EntityFrameworkCore.Diagnostics; 5 | // using Microsoft.EntityFrameworkCore.Internal; 6 | // using Microsoft.EntityFrameworkCore.Query.Internal; 7 | // using Remotion.Linq.Parsing.ExpressionVisitors.TreeEvaluation; 8 | // 9 | // namespace EntityFrameworkCore.OpenEdge.Query.Internal 10 | // { 11 | // /* 12 | // * This class is responsible for coordinating the parameter extraction phase of query compilation. 13 | // * Orchestrates parameter extraction using OpenEdgeParameterExtractingExpressionVisitor visitor. 14 | // */ 15 | // public class OpenEdgeQueryModelGenerator : QueryModelGenerator 16 | // { 17 | // private readonly IEvaluatableExpressionFilter _evaluatableExpressionFilter; 18 | // private readonly ICurrentDbContext _currentDbContext; 19 | // 20 | // public OpenEdgeQueryModelGenerator(INodeTypeProviderFactory nodeTypeProviderFactory, 21 | // IEvaluatableExpressionFilter evaluatableExpressionFilter, 22 | // ICurrentDbContext currentDbContext) 23 | // : base(nodeTypeProviderFactory, evaluatableExpressionFilter, currentDbContext) 24 | // { 25 | // _evaluatableExpressionFilter = evaluatableExpressionFilter; 26 | // _currentDbContext = currentDbContext; 27 | // } 28 | // 29 | // public override Expression ExtractParameters(IDiagnosticsLogger logger, Expression query, IParameterValues parameterValues, 30 | // bool parameterize = true, bool generateContextAccessors = false) 31 | // { 32 | // return new OpenEdgeParameterExtractingExpressionVisitor(_evaluatableExpressionFilter, parameterValues, logger, 33 | // _currentDbContext.Context, 34 | // parameterize, generateContextAccessors).ExtractParameters(query); 35 | // } 36 | // } 37 | // } -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/TestUtilities/OpenEdgeTestBase.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.Configuration; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Logging; 5 | using System; 6 | using Xunit; 7 | 8 | namespace EFCore.OpenEdge.FunctionalTests.TestUtilities 9 | { 10 | public abstract class OpenEdgeTestBase : IDisposable 11 | { 12 | protected IConfiguration Configuration { get; } 13 | protected ServiceProvider ServiceProvider { get; } 14 | protected string ConnectionString { get; } 15 | 16 | private readonly ILoggerFactory _loggerFactory; 17 | 18 | protected OpenEdgeTestBase() 19 | { 20 | Configuration = new ConfigurationBuilder() 21 | .AddJsonFile("appsettings.json") 22 | .Build(); 23 | 24 | ConnectionString = Configuration.GetConnectionString("OpenEdgeConnection"); 25 | 26 | _loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); 27 | 28 | var services = new ServiceCollection(); 29 | ConfigureServices(services); 30 | ServiceProvider = services.BuildServiceProvider(); 31 | } 32 | 33 | protected void ConfigureServices(IServiceCollection services) 34 | { 35 | services.AddSingleton(Configuration); 36 | } 37 | 38 | protected DbContextOptionsBuilder CreateOptionsBuilder() where T : DbContext 39 | { 40 | return new DbContextOptionsBuilder() 41 | .UseOpenEdge(ConnectionString, "PUB") 42 | .EnableSensitiveDataLogging() 43 | .UseLoggerFactory(_loggerFactory); 44 | } 45 | 46 | protected DbContextOptions CreateOptions() 47 | { 48 | return new DbContextOptionsBuilder() 49 | .UseOpenEdge(ConnectionString, "PUB") 50 | .EnableSensitiveDataLogging() 51 | .UseLoggerFactory(_loggerFactory) 52 | .Options; 53 | } 54 | 55 | public virtual void Dispose() 56 | { 57 | ServiceProvider?.Dispose(); 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Update/Internal/OpenEdgeModificationCommandBatch.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using Microsoft.EntityFrameworkCore.Update; 3 | using Microsoft.EntityFrameworkCore.Metadata; 4 | 5 | namespace EntityFrameworkCore.OpenEdge.Update.Internal 6 | { 7 | /// 8 | /// OpenEdge-specific modification command batch that ensures parameters are ordered correctly 9 | /// to match the SQL generation order (write operations first, then condition operations). 10 | /// 11 | public class OpenEdgeModificationCommandBatch : SingularModificationCommandBatch 12 | { 13 | public OpenEdgeModificationCommandBatch(ModificationCommandBatchFactoryDependencies dependencies) 14 | : base(dependencies) 15 | { 16 | } 17 | 18 | /// 19 | /// Adds parameters for all column modifications in the given modification command to the relational command 20 | /// being built for this batch. Parameters are ordered to match OpenEdge SQL generation order: 21 | /// 1. Write operations (SET clause parameters) 22 | /// 2. Condition operations (WHERE clause parameters) 23 | /// 24 | /// The modification command for which to add parameters. 25 | protected override void AddParameters(IReadOnlyModificationCommand modificationCommand) 26 | { 27 | var modifications = modificationCommand.StoreStoredProcedure is null 28 | ? modificationCommand.ColumnModifications 29 | : modificationCommand.ColumnModifications.Where( 30 | c => c.Column is IStoreStoredProcedureParameter or IStoreStoredProcedureReturnValue); 31 | 32 | // Order parameters to match OpenEdge SQL generation: 33 | // 1. Write operations first (IsCondition = false), these go in SET clause 34 | // 2. Condition operations second (IsCondition = true), these go in WHERE clause 35 | var orderedModifications = modifications 36 | .OrderBy(cm => cm.IsCondition) // false (write operations) come before true (condition operations) 37 | .ToList(); 38 | 39 | foreach (var columnModification in orderedModifications) 40 | { 41 | AddParameter(columnModification); 42 | } 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Storage/Internal/OpenEdgeDatabaseCreator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.EntityFrameworkCore.Storage; 3 | 4 | namespace EntityFrameworkCore.OpenEdge.Storage.Internal 5 | { 6 | /// 7 | /// Performs database/schema creation, and other related operations. 8 | /// 9 | public class OpenEdgeDatabaseCreator : RelationalDatabaseCreator 10 | { 11 | // TODO: Double check what all of this is about 12 | public OpenEdgeDatabaseCreator(RelationalDatabaseCreatorDependencies dependencies) : base(dependencies) 13 | { 14 | } 15 | 16 | public override bool Exists() 17 | { 18 | try 19 | { 20 | // Try to open a connection to check if database exists 21 | using var connection = Dependencies.Connection.DbConnection; 22 | connection.Open(); 23 | connection.Close(); 24 | return true; 25 | } 26 | catch 27 | { 28 | // If connection fails, assume database doesn't exist 29 | return false; 30 | } 31 | } 32 | 33 | public override void Create() 34 | { 35 | // For the moment being, enforce database creation using file-system based tools 36 | throw new NotSupportedException("OpenEdge databases must be created externally using OpenEdge tools."); 37 | } 38 | 39 | public override void Delete() 40 | { 41 | // For the moment being, enforced to be handled externally 42 | throw new NotSupportedException("OpenEdge databases should be deleted using OpenEdge tools."); 43 | } 44 | 45 | public override bool HasTables() 46 | { 47 | try 48 | { 49 | using var connection = Dependencies.Connection.DbConnection; 50 | connection.Open(); 51 | 52 | using var command = connection.CreateCommand(); 53 | command.CommandText = @" 54 | SELECT COUNT(*) 55 | FROM SYSPROGRESS.SYSTABLES"; 56 | 57 | var result = command.ExecuteScalar(); 58 | connection.Close(); 59 | 60 | return Convert.ToInt32(result) > 0; 61 | } 62 | catch 63 | { 64 | // If we can't determine, assume no tables 65 | return false; 66 | } 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Query/ExpressionVisitors/Internal/OpenEdgeQueryExpressionVisitor.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Linq.Expressions; 3 | using System.Reflection; 4 | 5 | namespace EntityFrameworkCore.OpenEdge.Query.ExpressionVisitors.Internal 6 | { 7 | /// 8 | /// Handles OpenEdge-specific expression transformations. 9 | /// Migrated from OpenEdgeParameterExtractingExpressionVisitor. 10 | /// 11 | public class OpenEdgeQueryExpressionVisitor : ExpressionVisitor 12 | { 13 | // Existing VisitNewMember logic 14 | protected Expression VisitNewMember(MemberExpression memberExpression) 15 | { 16 | if (memberExpression.Expression is ConstantExpression constant 17 | && constant.Value != null) 18 | { 19 | switch (memberExpression.Member.MemberType) 20 | { 21 | case MemberTypes.Field: 22 | return Expression.Constant(constant.Value.GetType().GetField(memberExpression.Member.Name).GetValue(constant.Value)); 23 | 24 | case MemberTypes.Property: 25 | var propertyInfo = constant.Value.GetType().GetProperty(memberExpression.Member.Name); 26 | if (propertyInfo == null) 27 | { 28 | break; 29 | } 30 | return Expression.Constant(propertyInfo.GetValue(constant.Value)); 31 | } 32 | } 33 | return base.VisitMember(memberExpression); 34 | } 35 | 36 | // Existing VisitNew logic 37 | protected override Expression VisitNew(NewExpression node) 38 | { 39 | var memberArguments = node.Arguments 40 | .Select(m => m is MemberExpression mem ? VisitNewMember(mem) : Visit(m)) 41 | .ToList(); 42 | 43 | var newNode = node.Update(memberArguments); 44 | return newNode; 45 | } 46 | 47 | // Existing VisitMethodCall logic 48 | protected override Expression VisitMethodCall(MethodCallExpression methodCallExpression) 49 | { 50 | // Prevents Take and Skip from being parameterized for OpenEdge 51 | if (methodCallExpression.Method.Name == "Take" || 52 | methodCallExpression.Method.Name == "Skip") 53 | { 54 | return methodCallExpression; 55 | } 56 | 57 | return base.VisitMethodCall(methodCallExpression); 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/EFCore.OpenEdge.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | EntityFrameworkCore.OpenEdge 6 | EntityFrameworkCore.OpenEdge 7 | latest 8 | Alex Wiese, Ernests Rudzitis 9 | Entity Framework Core provider for Progress OpenEdge databases 10 | https://github.com/alexwiese/EntityFrameworkCore.OpenEdge 11 | https://github.com/alexwiese/EntityFrameworkCore.OpenEdge 12 | git 13 | openedge;progress;Entity Framework Core;entity-framework-core;ef;efcore;orm;sql 14 | 15 | 16 | 17 | true 18 | 19 | true 20 | 9.0.7 21 | 22 | - Version 9.0.7 23 | - Extended support for datetimezone type 24 | - Fixed inline fetch/offset statements being cached incorrectly 25 | 26 | - Version 9.0.6 27 | - Fixed issue with boolean comparison not being translated correctly in some cases 28 | - Added support for DateOnly type in queries 29 | 30 | - Version 9.0.5 31 | - Added support for DateOnly type. This ensures that OpenEdge DATE columns are mapped to DateOnly type in EF Core. 32 | 33 | - Version 9.0.4 34 | - Fixed issue with OFFSET/FETCH parameters not being inlined correctly in complex queries 35 | - Added support for nested queries with Skip/Take 36 | 37 | MIT 38 | 39 | 40 | 41 | README.md 42 | efcore.openedge.extended.png 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Infrastructure/Internal/OpenEdgeOptionsExtension.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using EntityFrameworkCore.OpenEdge.Extensions; 3 | using Microsoft.EntityFrameworkCore.Infrastructure; 4 | using Microsoft.Extensions.DependencyInjection; 5 | 6 | using EntityFrameworkCore.OpenEdge.Update.Internal; 7 | using Microsoft.EntityFrameworkCore.Update; 8 | 9 | namespace EntityFrameworkCore.OpenEdge.Infrastructure.Internal 10 | { 11 | /// 12 | /// This instance gets added to the DbContextOptions internal collection. 13 | /// When DbContext is created, EF Core calls ApplyServices() and all the services are added to the service collection. 14 | /// 15 | public class OpenEdgeOptionsExtension : RelationalOptionsExtension 16 | { 17 | private DbContextOptionsExtensionInfo _info; 18 | private string _defaultSchema; 19 | 20 | public OpenEdgeOptionsExtension() 21 | { 22 | } 23 | 24 | protected OpenEdgeOptionsExtension(OpenEdgeOptionsExtension copyFrom) : base(copyFrom) 25 | { 26 | _defaultSchema = copyFrom._defaultSchema; 27 | } 28 | 29 | /// 30 | /// The default schema to use for tables when not explicitly specified. 31 | /// Defaults to "pub" if not set. 32 | /// 33 | public virtual string DefaultSchema => _defaultSchema ?? "pub"; 34 | 35 | public override DbContextOptionsExtensionInfo Info 36 | => _info ?? (_info = new OpenExtensionInfo(this)); 37 | 38 | protected override RelationalOptionsExtension Clone() 39 | => new OpenEdgeOptionsExtension(this); 40 | 41 | /// 42 | /// Returns a new instance with the specified default schema. 43 | /// 44 | /// The default schema name to use. 45 | /// A new OpenEdgeOptionsExtension instance with the specified default schema. 46 | public virtual OpenEdgeOptionsExtension WithDefaultSchema(string defaultSchema) 47 | { 48 | var clone = new OpenEdgeOptionsExtension(this); 49 | clone._defaultSchema = defaultSchema; 50 | return clone; 51 | } 52 | 53 | public override void ApplyServices(IServiceCollection services) 54 | { 55 | services.AddEntityFrameworkOpenEdge(); 56 | services.AddScoped(); 57 | } 58 | 59 | // ✅ Required nested class for EF Core 3.0+ 60 | private sealed class OpenExtensionInfo : RelationalExtensionInfo 61 | { 62 | public OpenExtensionInfo(IDbContextOptionsExtension extension) 63 | : base(extension) 64 | { 65 | } 66 | 67 | public override bool IsDatabaseProvider => true; 68 | 69 | public override string LogFragment => "using OpenEdge"; 70 | 71 | public override void PopulateDebugInfo(IDictionary debugInfo) 72 | { 73 | debugInfo["OpenEdge"] = "1"; 74 | 75 | var openEdgeExtension = (OpenEdgeOptionsExtension)Extension; 76 | debugInfo["OpenEdge:DefaultSchema"] = openEdgeExtension.DefaultSchema; 77 | } 78 | 79 | } 80 | } 81 | } -------------------------------------------------------------------------------- /EFCore.OpenEdge.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.28307.136 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EFCore.OpenEdge", "src\EFCore.OpenEdge\EFCore.OpenEdge.csproj", "{21A6CF0F-EBB8-48CA-A843-ECFF81F94E0D}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}" 9 | ProjectSection(SolutionItems) = preProject 10 | test\appsettings.json'.json = test\appsettings.json'.json 11 | EndProjectSection 12 | EndProject 13 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EFCore.OpenEdge.FunctionalTests", "test\EFCore.OpenEdge.FunctionalTests\EFCore.OpenEdge.FunctionalTests.csproj", "{C2C2216F-6748-4CEC-84A5-2147C7C2E23F}" 14 | EndProject 15 | Global 16 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 17 | Debug|Any CPU = Debug|Any CPU 18 | Debug|x64 = Debug|x64 19 | Debug|x86 = Debug|x86 20 | Release|Any CPU = Release|Any CPU 21 | Release|x64 = Release|x64 22 | Release|x86 = Release|x86 23 | EndGlobalSection 24 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 25 | {21A6CF0F-EBB8-48CA-A843-ECFF81F94E0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 26 | {21A6CF0F-EBB8-48CA-A843-ECFF81F94E0D}.Debug|Any CPU.Build.0 = Debug|Any CPU 27 | {21A6CF0F-EBB8-48CA-A843-ECFF81F94E0D}.Debug|x64.ActiveCfg = Debug|Any CPU 28 | {21A6CF0F-EBB8-48CA-A843-ECFF81F94E0D}.Debug|x64.Build.0 = Debug|Any CPU 29 | {21A6CF0F-EBB8-48CA-A843-ECFF81F94E0D}.Debug|x86.ActiveCfg = Debug|Any CPU 30 | {21A6CF0F-EBB8-48CA-A843-ECFF81F94E0D}.Debug|x86.Build.0 = Debug|Any CPU 31 | {21A6CF0F-EBB8-48CA-A843-ECFF81F94E0D}.Release|Any CPU.ActiveCfg = Release|Any CPU 32 | {21A6CF0F-EBB8-48CA-A843-ECFF81F94E0D}.Release|Any CPU.Build.0 = Release|Any CPU 33 | {21A6CF0F-EBB8-48CA-A843-ECFF81F94E0D}.Release|x64.ActiveCfg = Release|Any CPU 34 | {21A6CF0F-EBB8-48CA-A843-ECFF81F94E0D}.Release|x64.Build.0 = Release|Any CPU 35 | {21A6CF0F-EBB8-48CA-A843-ECFF81F94E0D}.Release|x86.ActiveCfg = Release|Any CPU 36 | {21A6CF0F-EBB8-48CA-A843-ECFF81F94E0D}.Release|x86.Build.0 = Release|Any CPU 37 | {C2C2216F-6748-4CEC-84A5-2147C7C2E23F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 38 | {C2C2216F-6748-4CEC-84A5-2147C7C2E23F}.Debug|Any CPU.Build.0 = Debug|Any CPU 39 | {C2C2216F-6748-4CEC-84A5-2147C7C2E23F}.Debug|x64.ActiveCfg = Debug|Any CPU 40 | {C2C2216F-6748-4CEC-84A5-2147C7C2E23F}.Debug|x64.Build.0 = Debug|Any CPU 41 | {C2C2216F-6748-4CEC-84A5-2147C7C2E23F}.Debug|x86.ActiveCfg = Debug|Any CPU 42 | {C2C2216F-6748-4CEC-84A5-2147C7C2E23F}.Debug|x86.Build.0 = Debug|Any CPU 43 | {C2C2216F-6748-4CEC-84A5-2147C7C2E23F}.Release|Any CPU.ActiveCfg = Release|Any CPU 44 | {C2C2216F-6748-4CEC-84A5-2147C7C2E23F}.Release|Any CPU.Build.0 = Release|Any CPU 45 | {C2C2216F-6748-4CEC-84A5-2147C7C2E23F}.Release|x64.ActiveCfg = Release|Any CPU 46 | {C2C2216F-6748-4CEC-84A5-2147C7C2E23F}.Release|x64.Build.0 = Release|Any CPU 47 | {C2C2216F-6748-4CEC-84A5-2147C7C2E23F}.Release|x86.ActiveCfg = Release|Any CPU 48 | {C2C2216F-6748-4CEC-84A5-2147C7C2E23F}.Release|x86.Build.0 = Release|Any CPU 49 | EndGlobalSection 50 | GlobalSection(SolutionProperties) = preSolution 51 | HideSolutionNode = FALSE 52 | EndGlobalSection 53 | GlobalSection(NestedProjects) = preSolution 54 | {C2C2216F-6748-4CEC-84A5-2147C7C2E23F} = {0C88DD14-F956-CE84-757C-A364CCF449FC} 55 | EndGlobalSection 56 | GlobalSection(ExtensibilityGlobals) = postSolution 57 | SolutionGuid = {36C294ED-9ECE-42AA-8273-31E008749AF3} 58 | EndGlobalSection 59 | EndGlobal 60 | -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Storage/Internal/Mapping/OpenEdgeBoolTypeMapping.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Data; 3 | using System.Data.Common; 4 | using System.Linq.Expressions; 5 | using System.Reflection; 6 | using Microsoft.EntityFrameworkCore.Storage; 7 | 8 | namespace EntityFrameworkCore.OpenEdge.Storage.Internal.Mapping 9 | { 10 | /// 11 | /// Custom boolean type mapping for OpenEdge databases. 12 | /// 13 | /// OpenEdge stores boolean values as integers (0 = false, 1 = true) rather than 14 | /// native boolean types. This causes issues when EF Core tries to read boolean 15 | /// values using DbDataReader.GetBoolean(), which expects actual boolean values 16 | /// and throws InvalidCastException when encountering integers. 17 | /// 18 | /// This mapping overrides the default behavior to: 19 | /// 1. Use GetInt32() to read the integer value from the database 20 | /// 2. Convert the integer (0/1) to boolean (false/true) in the generated expression 21 | /// 22 | public class OpenEdgeBoolTypeMapping : BoolTypeMapping 23 | { 24 | /// 25 | /// Method info for DbDataReader.GetInt32() used to read integer values 26 | /// instead of the default GetBoolean() method. 27 | /// 28 | private static readonly MethodInfo GetInt32Method 29 | = typeof(DbDataReader).GetRuntimeMethod(nameof(DbDataReader.GetInt32), [typeof(int)])!; 30 | 31 | public OpenEdgeBoolTypeMapping() 32 | : base("bit") 33 | { 34 | } 35 | 36 | protected OpenEdgeBoolTypeMapping(RelationalTypeMappingParameters parameters) 37 | : base(parameters) 38 | { 39 | } 40 | 41 | /// 42 | /// Overrides the default data reader method to use GetInt32() instead of GetBoolean(). 43 | /// This is necessary because OpenEdge returns integer values (0/1) for boolean fields. 44 | /// 45 | /// MethodInfo for DbDataReader.GetInt32() 46 | public override MethodInfo GetDataReaderMethod() 47 | => GetInt32Method; 48 | 49 | /// 50 | /// Customizes the expression used to read boolean values from the database. 51 | /// Converts the integer value (0/1) returned by OpenEdge to a boolean value. 52 | /// 53 | /// Generated expression: (int_value != 0) 54 | /// - 0 becomes false 55 | /// - Any non-zero value becomes true (following C convention) 56 | /// 57 | /// The expression that reads the integer value 58 | /// Expression that converts integer to boolean 59 | public override Expression CustomizeDataReaderExpression(Expression expression) 60 | { 61 | // Convert integer (0/1) to boolean (false/true) 62 | // Expression: (int_value != 0) 63 | return Expression.NotEqual( 64 | expression, 65 | Expression.Constant(0, typeof(int))); 66 | } 67 | 68 | /// 69 | /// Overrides the Clone method to ensure that cloned instances maintain the custom 70 | /// OpenEdge boolean behavior. Without this, EF Core would create a base BoolTypeMapping 71 | /// instance during cloning, losing the custom GetDataReaderMethod and CustomizeDataReaderExpression overrides. 72 | /// 73 | /// The mapping parameters for the cloned instance 74 | /// A new OpenEdgeBoolTypeMapping instance with the same configuration 75 | protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) 76 | => new OpenEdgeBoolTypeMapping(parameters); 77 | } 78 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Extensions/OpenEdgeServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using EntityFrameworkCore.OpenEdge.Diagnostics.Internal; 2 | using EntityFrameworkCore.OpenEdge.Infrastructure.Internal; 3 | using EntityFrameworkCore.OpenEdge.Metadata.Conventions.Internal; 4 | using EntityFrameworkCore.OpenEdge.Query.ExpressionTranslators.Internal; 5 | using EntityFrameworkCore.OpenEdge.Query.ExpressionVisitors.Internal; 6 | using EntityFrameworkCore.OpenEdge.Query.Sql.Internal; 7 | using EntityFrameworkCore.OpenEdge.Storage; 8 | using EntityFrameworkCore.OpenEdge.Storage.Internal; 9 | using EntityFrameworkCore.OpenEdge.Storage.Internal.Mapping; 10 | using EntityFrameworkCore.OpenEdge.Update; 11 | using EntityFrameworkCore.OpenEdge.Update.Internal; 12 | using Microsoft.EntityFrameworkCore.Diagnostics; 13 | using Microsoft.EntityFrameworkCore.Infrastructure; 14 | using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; 15 | using Microsoft.EntityFrameworkCore.Query; 16 | using Microsoft.EntityFrameworkCore.Storage; 17 | using Microsoft.EntityFrameworkCore.Update; 18 | using Microsoft.Extensions.DependencyInjection; 19 | 20 | namespace EntityFrameworkCore.OpenEdge.Extensions 21 | { 22 | /* 23 | * Contains configurations that make OpenEdge provider work with Entity Framework Core's dependency injection system. 24 | * Essentially, when someone uses .UseOpenEdge(), this file describes all the OpenEdge-specific implementations 25 | * that should be used instead of the default ones. 26 | */ 27 | public static class OpenEdgeServiceCollectionExtensions 28 | { 29 | public static IServiceCollection AddEntityFrameworkOpenEdge(this IServiceCollection serviceCollection) 30 | { 31 | var builder = new EntityFrameworkRelationalServicesBuilder(serviceCollection) 32 | .TryAdd() 33 | .TryAdd>() 34 | .TryAdd() 35 | .TryAdd() 36 | .TryAdd() 37 | .TryAdd() 38 | .TryAdd() 39 | .TryAdd(p => p.GetService()) 40 | .TryAdd() 41 | 42 | .TryAdd() 43 | .TryAdd() 44 | .TryAdd() 45 | .TryAdd() 46 | .TryAdd() 48 | 49 | .TryAdd() 50 | .TryAdd() 51 | 52 | .TryAddProviderSpecificServices(b => b 53 | .TryAddScoped() 54 | ); 55 | 56 | builder.TryAddCoreServices(); 57 | 58 | // Force registration of our UpdateSqlGenerator after all other services 59 | serviceCollection.AddScoped(); 60 | 61 | return serviceCollection; 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Storage/Internal/Mapping/OpenEdgeDateOnlyTypeMapping.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Data; 3 | using System.Data.Common; 4 | using System.Linq.Expressions; 5 | using System.Reflection; 6 | using Microsoft.EntityFrameworkCore.ChangeTracking; 7 | using Microsoft.EntityFrameworkCore.Storage; 8 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 9 | 10 | namespace EntityFrameworkCore.OpenEdge.Storage.Internal.Mapping 11 | { 12 | /// 13 | /// Custom DateOnly type mapping for OpenEdge databases. 14 | /// 15 | /// OpenEdge returns DATE columns as DateTime objects via ODBC, but EF Core's 16 | /// default DateOnly mapping may try to read them as strings, causing InvalidCastException. 17 | /// 18 | /// This mapping overrides the default behavior to: 19 | /// 1. Use GetDateTime() to read the DateTime value from the database 20 | /// 2. Convert the DateTime to DateOnly using DateOnly.FromDateTime() 21 | /// 3. Convert DateOnly parameters to DateTime for ODBC compatibility 22 | /// 23 | public class OpenEdgeDateOnlyTypeMapping : DateOnlyTypeMapping 24 | { 25 | private static readonly MethodInfo GetDateTimeMethod 26 | = typeof(DbDataReader).GetRuntimeMethod(nameof(DbDataReader.GetDateTime), [typeof(int)])!; 27 | 28 | public OpenEdgeDateOnlyTypeMapping(string storeType, DbType? dbType = null) 29 | : base(storeType, dbType) 30 | { 31 | } 32 | 33 | protected OpenEdgeDateOnlyTypeMapping(RelationalTypeMappingParameters parameters) 34 | : base(parameters) 35 | { 36 | } 37 | 38 | public override MethodInfo GetDataReaderMethod() 39 | => GetDateTimeMethod; 40 | 41 | public override Expression CustomizeDataReaderExpression(Expression expression) 42 | { 43 | // OpenEdge returns DATE columns as DateTime objects via ODBC, 44 | // we read this as a DateTime using 'GetDateTimeMethod' and convert it to a DateOnly using DateOnly.FromDateTime() 45 | var fromDateTimeMethod = typeof(DateOnly).GetMethod( 46 | nameof(DateOnly.FromDateTime), 47 | [typeof(DateTime)])!; 48 | 49 | return Expression.Call( 50 | null, 51 | fromDateTimeMethod, 52 | expression); 53 | } 54 | 55 | protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) 56 | => new OpenEdgeDateOnlyTypeMapping(parameters); 57 | 58 | protected override string GenerateNonNullSqlLiteral(object value) 59 | { 60 | // When EF Core needs to embed a literal value in a SQL query, it calls this method. 61 | // When DateOnly is used as a parameter, we need to handle the conversion. 62 | // We convert to DateTime for ODBC compatibility and format as ISO date string that ODBC can understand and process 63 | if (value is DateOnly dateOnly) 64 | { 65 | var dateTime = dateOnly.ToDateTime(TimeOnly.MinValue); 66 | return $"{{ ts '{dateTime:yyyy-MM-dd HH:mm:ss}' }}"; 67 | } 68 | 69 | return base.GenerateNonNullSqlLiteral(value); 70 | } 71 | 72 | protected override void ConfigureParameter(DbParameter parameter) 73 | { 74 | base.ConfigureParameter(parameter); 75 | 76 | // When EF Core needs to prepare a parameter for execution against a database, it calls this method. 77 | // When DateOnly is used as a parameter, we need to handle the conversion. 78 | // We convert to DateTime for ODBC compatibility and format as ISO date string that ODBC can understand and process 79 | if (parameter.Value is DateOnly dateOnly) 80 | { 81 | parameter.Value = dateOnly.ToDateTime(TimeOnly.MinValue); 82 | parameter.DbType = System.Data.DbType.Date; 83 | } 84 | } 85 | } 86 | } -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/Update/TransactionTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using EFCore.OpenEdge.FunctionalTests.Shared; 6 | using EFCore.OpenEdge.FunctionalTests.Shared.Models; 7 | using FluentAssertions; 8 | using Microsoft.EntityFrameworkCore; 9 | using Xunit; 10 | using Xunit.Abstractions; 11 | 12 | namespace EFCore.OpenEdge.FunctionalTests.Update 13 | { 14 | public class TransactionTests : ECommerceTestBase 15 | { 16 | private readonly ITestOutputHelper _output; 17 | 18 | public TransactionTests(ITestOutputHelper output) 19 | { 20 | _output = output; 21 | } 22 | 23 | #region ROLLBACK SCENARIOS 24 | 25 | [Fact] 26 | public void CanRollback_Simple_Transaction() 27 | { 28 | using var context = CreateContext(); 29 | using var transaction = context.Database.BeginTransaction(System.Data.IsolationLevel.ReadCommitted); 30 | 31 | try 32 | { 33 | var customer = new Customer 34 | { 35 | Id = 1202, 36 | Name = "Rollback Customer", 37 | Email = "rollback@example.com", 38 | Age = 30, 39 | City = "Rollback City", 40 | IsActive = true 41 | }; 42 | 43 | context.Customers.Add(customer); 44 | context.SaveChanges(); 45 | 46 | // Explicitly rollback instead of commit 47 | transaction.Rollback(); 48 | 49 | _output.WriteLine("Successfully rolled back simple transaction"); 50 | 51 | // Verify the customer was not inserted 52 | using var verifyContext = CreateContext(); 53 | var notInsertedCustomer = verifyContext.Customers.Find(1202); 54 | notInsertedCustomer.Should().BeNull(); 55 | } 56 | catch 57 | { 58 | transaction.Rollback(); 59 | throw; 60 | } 61 | } 62 | 63 | [Fact] 64 | public void CanRollback_Multiple_Operations_In_Transaction() 65 | { 66 | using var context = CreateContext(); 67 | using var transaction = context.Database.BeginTransaction(System.Data.IsolationLevel.ReadCommitted); 68 | 69 | try 70 | { 71 | // Get original state 72 | var originalCustomer = context.Customers.Find(1); 73 | var originalAge = originalCustomer.Age; 74 | 75 | // Insert a customer 76 | var customer = new Customer 77 | { 78 | Id = 1203, 79 | Name = "Rollback Multi-Op Customer", 80 | Email = "rollbackmultiop@example.com", 81 | Age = 40, 82 | City = "Rollback Multi-Op City", 83 | IsActive = true 84 | }; 85 | 86 | context.Customers.Add(customer); 87 | 88 | // Update an existing customer 89 | originalCustomer.Age = 99; 90 | 91 | context.SaveChanges(); 92 | transaction.Rollback(); 93 | 94 | _output.WriteLine("Successfully rolled back transaction with multiple operations"); 95 | 96 | // Verify all operations were rolled back 97 | using var verifyContext = CreateContext(); 98 | var notInsertedCustomer = verifyContext.Customers.Find(1203); 99 | var notUpdatedCustomer = verifyContext.Customers.Find(1); 100 | 101 | notInsertedCustomer.Should().BeNull(); 102 | notUpdatedCustomer.Age.Should().Be(originalAge); 103 | } 104 | catch 105 | { 106 | transaction.Rollback(); 107 | throw; 108 | } 109 | } 110 | 111 | #endregion 112 | } 113 | } -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/Query/BooleanParameterTest.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using EFCore.OpenEdge.FunctionalTests.Shared; 3 | using FluentAssertions; 4 | using Microsoft.EntityFrameworkCore; 5 | using Xunit; 6 | using Xunit.Abstractions; 7 | 8 | namespace EFCore.OpenEdge.FunctionalTests.Query 9 | { 10 | /// 11 | /// Tests for boolean parameter conversion functionality. 12 | /// Ensures that boolean parameters are correctly converted to integer values for OpenEdge. 13 | /// 14 | public class BooleanParameterTest(ITestOutputHelper output) : ECommerceTestBase 15 | { 16 | private readonly ITestOutputHelper _output = output; 17 | 18 | [Fact] 19 | public void BooleanParameter_False_GeneratesCorrectSQL() 20 | { 21 | // Arrange 22 | using var context = CreateContext(); 23 | bool isActiveFilter = false; 24 | 25 | // Act - This should generate WHERE column = 0 26 | var inactiveCustomers = context.Customers 27 | .Where(c => c.IsActive == isActiveFilter) 28 | .ToList(); 29 | 30 | // Assert 31 | inactiveCustomers.Should().NotBeEmpty(); 32 | inactiveCustomers.Should().OnlyContain(c => !c.IsActive); 33 | } 34 | 35 | [Fact] 36 | public void BooleanParameter_True_GeneratesCorrectSQL() 37 | { 38 | // Arrange 39 | using var context = CreateContext(); 40 | bool isActiveFilter = true; 41 | 42 | // Act - This should generate WHERE column = 1 43 | var activeCustomers = context.Customers 44 | .Where(c => c.IsActive == isActiveFilter) 45 | .ToList(); 46 | 47 | // Assert 48 | activeCustomers.Should().NotBeEmpty(); 49 | activeCustomers.Should().OnlyContain(c => c.IsActive); 50 | } 51 | 52 | [Fact] 53 | public void BooleanParameter_NotEqual_False_GeneratesCorrectSQL() 54 | { 55 | // Arrange 56 | using var context = CreateContext(); 57 | bool isActiveFilter = false; 58 | 59 | // Act - This should generate WHERE column <> 0 60 | var activeCustomers = context.Customers 61 | .Where(c => c.IsActive != isActiveFilter) 62 | .ToList(); 63 | 64 | // Assert 65 | activeCustomers.Should().NotBeEmpty(); 66 | activeCustomers.Should().OnlyContain(c => c.IsActive); 67 | } 68 | 69 | [Fact] 70 | public void BooleanParameter_NotEqual_True_GeneratesCorrectSQL() 71 | { 72 | // Arrange 73 | using var context = CreateContext(); 74 | bool isActiveFilter = true; 75 | 76 | // Act - This should generate WHERE column <> 1 77 | var inactiveCustomers = context.Customers 78 | .Where(c => c.IsActive != isActiveFilter) 79 | .ToList(); 80 | 81 | // Assert 82 | inactiveCustomers.Should().NotBeEmpty(); 83 | inactiveCustomers.Should().OnlyContain(c => !c.IsActive); 84 | } 85 | 86 | [Fact] 87 | public void BooleanParameter_InComplexQuery_WorksCorrectly() 88 | { 89 | // Arrange 90 | using var context = CreateContext(); 91 | bool isActiveFilter = false; 92 | int ageThreshold = 30; 93 | 94 | // Act - Complex query with boolean parameter 95 | var customers = context.Customers 96 | .Where(c => c.IsActive == isActiveFilter && c.Age > ageThreshold) 97 | .OrderBy(c => c.Name) 98 | .Take(5) 99 | .ToList(); 100 | 101 | // Assert 102 | customers.Should().HaveCountLessOrEqualTo(5); 103 | customers.Should().OnlyContain(c => !c.IsActive && c.Age > ageThreshold); 104 | customers.Should().BeInAscendingOrder(c => c.Name); 105 | } 106 | } 107 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Storage/Internal/Mapping/OpenEdgeTimestampTimezoneTypeMapping.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Data; 3 | using System.Data.Common; 4 | using System.Globalization; 5 | using System.Linq.Expressions; 6 | using System.Reflection; 7 | using Microsoft.EntityFrameworkCore.Storage; 8 | 9 | namespace EntityFrameworkCore.OpenEdge.Storage.Internal.Mapping 10 | { 11 | /// 12 | /// Custom DateTimeOffset type mapping for OpenEdge timestamp_timezone columns. 13 | /// 14 | /// OpenEdge returns timestamp_timezone columns as strings via ODBC (e.g., "2025-07-22 05:29:48.197-07:00"), 15 | /// but EF Core's default DateTimeOffset mapping expects a DateTimeOffset object, causing InvalidCastException. 16 | /// 17 | /// This mapping overrides the default behavior to: 18 | /// 1. Use GetString() to read the string value from the database 19 | /// 2. Parse the string to DateTimeOffset using DateTimeOffset.Parse() 20 | /// 3. Handle null values appropriately 21 | /// 22 | public class OpenEdgeTimestampTimezoneTypeMapping : DateTimeOffsetTypeMapping 23 | { 24 | private static readonly MethodInfo GetStringMethod 25 | = typeof(DbDataReader).GetRuntimeMethod(nameof(DbDataReader.GetString), [typeof(int)])!; 26 | 27 | public OpenEdgeTimestampTimezoneTypeMapping(string storeType, DbType? dbType = null) 28 | : base(storeType, dbType) 29 | { 30 | } 31 | 32 | protected OpenEdgeTimestampTimezoneTypeMapping(RelationalTypeMappingParameters parameters) 33 | : base(parameters) 34 | { 35 | } 36 | 37 | public override MethodInfo GetDataReaderMethod() 38 | => GetStringMethod; 39 | 40 | public override Expression CustomizeDataReaderExpression(Expression expression) 41 | { 42 | // OpenEdge returns timestamp_timezone columns as strings via ODBC 43 | // We read this as a string using 'GetStringMethod' and parse it to DateTimeOffset 44 | 45 | // Create the parsing method call: DateTimeOffset.Parse(string, IFormatProvider) 46 | var parseMethod = typeof(DateTimeOffset).GetMethod( 47 | nameof(DateTimeOffset.Parse), 48 | [typeof(string), typeof(IFormatProvider)])!; 49 | 50 | // Create the CultureInfo.InvariantCulture property access. 51 | // This simply tells us to not take into account different cultures for parsing the date and time. 52 | var invariantCulture = Expression.Property( 53 | null, 54 | typeof(CultureInfo).GetProperty(nameof(CultureInfo.InvariantCulture))!); 55 | 56 | // Return: DateTimeOffset.Parse(reader.GetString(ordinal), CultureInfo.InvariantCulture) 57 | return Expression.Call( 58 | null, 59 | parseMethod, 60 | expression, 61 | invariantCulture); 62 | } 63 | 64 | protected override RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) 65 | => new OpenEdgeTimestampTimezoneTypeMapping(parameters); 66 | 67 | protected override string GenerateNonNullSqlLiteral(object value) 68 | { 69 | // When EF Core needs to embed a literal value in a SQL query 70 | if (value is DateTimeOffset dateTimeOffset) 71 | { 72 | // Format as ISO 8601 with timezone offset that OpenEdge can understand 73 | return $"'{dateTimeOffset:yyyy-MM-dd HH:mm:ss.fffzzz}'"; 74 | } 75 | 76 | return base.GenerateNonNullSqlLiteral(value); 77 | } 78 | 79 | protected override void ConfigureParameter(DbParameter parameter) 80 | { 81 | base.ConfigureParameter(parameter); 82 | 83 | // When DateTimeOffset is used as a parameter, format it as a string for OpenEdge 84 | if (parameter.Value is DateTimeOffset dateTimeOffset) 85 | { 86 | // Convert to ISO 8601 string format that OpenEdge expects 87 | parameter.Value = dateTimeOffset.ToString("yyyy-MM-dd HH:mm:ss.fffzzz", CultureInfo.InvariantCulture); 88 | parameter.DbType = System.Data.DbType.String; 89 | } 90 | } 91 | } 92 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Query/ExpressionTranslators/Internal/OpenEdgeDateOnlyMemberTranslator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | using Microsoft.EntityFrameworkCore.Diagnostics; 4 | using Microsoft.EntityFrameworkCore.Query; 5 | using Microsoft.EntityFrameworkCore.Query.SqlExpressions; 6 | 7 | namespace EntityFrameworkCore.OpenEdge.Query.ExpressionTranslators.Internal 8 | { 9 | /// 10 | /// Translates DateOnly member access to OpenEdge SQL functions. 11 | /// Handles properties like Year, Month, Day, DayOfYear, and DayOfWeek. 12 | /// 13 | public class OpenEdgeDateOnlyMemberTranslator : IMemberTranslator 14 | { 15 | private readonly ISqlExpressionFactory _sqlExpressionFactory; 16 | 17 | private static readonly PropertyInfo _yearProperty = typeof(DateOnly).GetRuntimeProperty(nameof(DateOnly.Year))!; 18 | private static readonly PropertyInfo _monthProperty = typeof(DateOnly).GetRuntimeProperty(nameof(DateOnly.Month))!; 19 | private static readonly PropertyInfo _dayProperty = typeof(DateOnly).GetRuntimeProperty(nameof(DateOnly.Day))!; 20 | private static readonly PropertyInfo _dayOfYearProperty = typeof(DateOnly).GetRuntimeProperty(nameof(DateOnly.DayOfYear))!; 21 | private static readonly PropertyInfo _dayOfWeekProperty = typeof(DateOnly).GetRuntimeProperty(nameof(DateOnly.DayOfWeek))!; 22 | 23 | public OpenEdgeDateOnlyMemberTranslator(ISqlExpressionFactory sqlExpressionFactory) 24 | { 25 | _sqlExpressionFactory = sqlExpressionFactory; 26 | } 27 | 28 | #nullable enable 29 | public virtual SqlExpression? Translate( 30 | SqlExpression? instance, 31 | MemberInfo member, 32 | Type returnType, 33 | IDiagnosticsLogger logger) 34 | { 35 | if (instance?.Type != typeof(DateOnly) && instance?.Type != typeof(DateOnly?)) 36 | { 37 | return null; 38 | } 39 | 40 | // Year property 41 | if (member.Equals(_yearProperty)) 42 | { 43 | return _sqlExpressionFactory.Function( 44 | "YEAR", 45 | [instance], 46 | nullable: true, 47 | argumentsPropagateNullability: [true], 48 | returnType); 49 | } 50 | 51 | // Month property 52 | if (member.Equals(_monthProperty)) 53 | { 54 | return _sqlExpressionFactory.Function( 55 | "MONTH", 56 | [instance], 57 | nullable: true, 58 | argumentsPropagateNullability: [true], 59 | returnType); 60 | } 61 | 62 | // Day property - OpenEdge uses DAYOFMONTH instead of DAY 63 | if (member.Equals(_dayProperty)) 64 | { 65 | return _sqlExpressionFactory.Function( 66 | "DAYOFMONTH", 67 | [instance], 68 | nullable: true, 69 | argumentsPropagateNullability: [true], 70 | returnType); 71 | } 72 | 73 | // DayOfYear property 74 | if (member.Equals(_dayOfYearProperty)) 75 | { 76 | return _sqlExpressionFactory.Function( 77 | "DAYOFYEAR", 78 | [instance], 79 | nullable: true, 80 | argumentsPropagateNullability: [true], 81 | returnType); 82 | } 83 | 84 | // DayOfWeek property - OpenEdge uses DAYOFWEEK which returns 1-7 (Sunday=1) 85 | // .NET DayOfWeek enum is 0-6 (Sunday=0), so we need to subtract 1 86 | if (member.Equals(_dayOfWeekProperty)) 87 | { 88 | var dayOfWeekFunc = _sqlExpressionFactory.Function( 89 | "DAYOFWEEK", 90 | [instance], 91 | nullable: true, 92 | argumentsPropagateNullability: [true], 93 | typeof(int)); 94 | 95 | // Subtract 1 to match .NET DayOfWeek enum values 96 | return _sqlExpressionFactory.Subtract( 97 | dayOfWeekFunc, 98 | _sqlExpressionFactory.Constant(1)); 99 | } 100 | 101 | return null; 102 | } 103 | } 104 | } -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/Query/JoinQueryTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using EFCore.OpenEdge.FunctionalTests.Shared; 4 | using FluentAssertions; 5 | using Microsoft.EntityFrameworkCore; 6 | using Xunit; 7 | using Xunit.Abstractions; 8 | 9 | namespace EFCore.OpenEdge.FunctionalTests.Query 10 | { 11 | public class JoinQueryTests : ECommerceTestBase 12 | { 13 | private readonly ITestOutputHelper _output; 14 | 15 | public JoinQueryTests(ITestOutputHelper output) 16 | { 17 | _output = output; 18 | } 19 | 20 | [Fact] 21 | public void CanExecute_SimpleInnerJoin() 22 | { 23 | using var context = CreateContext(); 24 | 25 | var query = from customer in context.Customers 26 | join order in context.Orders on customer.Id equals order.CustomerId 27 | select new { customer.Name, order.OrderDate }; 28 | 29 | var results = query.ToList(); 30 | _output.WriteLine($"Found {results.Count} customer-order combinations"); 31 | 32 | // Test should pass if joins are working 33 | results.Should().NotBeNull(); 34 | } 35 | 36 | [Fact] 37 | public void CanExecute_NavigationPropertyInclude() 38 | { 39 | using var context = CreateContext(); 40 | 41 | var customersWithOrders = context.Customers 42 | .Include(c => c.Orders.Where(o => o.TotalAmount > 1000)) 43 | .ToList(); 44 | 45 | // Test navigation property loading 46 | customersWithOrders.Should().NotBeNull(); 47 | } 48 | 49 | [Fact] 50 | public void CanExecute_MultiLevelInclude() 51 | { 52 | using var context = CreateContext(); 53 | 54 | var customersWithOrderDetails = context.Customers 55 | .Include(c => c.Orders) 56 | .ThenInclude(o => o.OrderItems) 57 | .ThenInclude(oi => oi.Product) 58 | .ToList(); 59 | 60 | _output.WriteLine($"Found {customersWithOrderDetails.Count} customers with full order details"); 61 | 62 | customersWithOrderDetails.Should().NotBeNull(); 63 | } 64 | 65 | [Fact] 66 | public void CanExecute_ComplexQueryWithJoins() 67 | { 68 | using var context = CreateContext(); 69 | 70 | var orderSummaries = from order in context.Orders 71 | join customer in context.Customers on order.CustomerId equals customer.Id 72 | join orderItem in context.OrderItems on order.Id equals orderItem.OrderId 73 | join product in context.Products on orderItem.ProductId equals product.Id 74 | select new 75 | { 76 | CustomerName = customer.Name, 77 | OrderDate = order.OrderDate, 78 | ProductName = product.Name, 79 | Quantity = orderItem.Quantity, 80 | TotalPrice = orderItem.Quantity * orderItem.UnitPrice 81 | }; 82 | 83 | var results = orderSummaries.ToList(); 84 | _output.WriteLine($"Found {results.Count} order line items"); 85 | 86 | results.Should().NotBeNull(); 87 | } 88 | 89 | [Fact] 90 | public void CanExecute_LeftOuterJoin() 91 | { 92 | using var context = CreateContext(); 93 | 94 | var customersWithOptionalOrders = from customer in context.Customers 95 | join order in context.Orders on customer.Id equals order.CustomerId into orderGroup 96 | from order in orderGroup.DefaultIfEmpty() 97 | select new 98 | { 99 | CustomerName = customer.Name, 100 | OrderDate = order != null ? order.OrderDate : (DateOnly?)null 101 | }; 102 | 103 | var results = customersWithOptionalOrders.ToList(); 104 | _output.WriteLine($"Found {results.Count} customers (including those without orders)"); 105 | 106 | results.Should().NotBeNull(); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EntityFrameworkCore.OpenEdge 2 | [![NuGet Version](https://img.shields.io/nuget/v/EntityFrameworkCore.OpenEdge)](https://www.nuget.org/packages/EntityFrameworkCore.OpenEdge) 3 | [![License: Apache-2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) 4 | 5 | EntityFrameworkCore.OpenEdge is an **Entity Framework Core 9 provider** that lets you target Progress OpenEdge databases via **ODBC**. 6 | 7 | > ⚠️ **Status:** This library is under active development. While it is already used in production scenarios, you may still encounter bugs or missing edge-cases. Please [open an issue](https://github.com/alexwiese/EntityFrameworkCore.OpenEdge/issues) if you run into problems. 8 | 9 | --- 10 | 11 | ## Quick Start 12 | 13 | ### Install 14 | ```bash 15 | dotnet add package EntityFrameworkCore.OpenEdge --version 9.0.7 16 | ``` 17 | 18 | ### DSN-less connection 19 | ```csharp 20 | optionsBuilder.UseOpenEdge( 21 | "Driver=Progress OpenEdge 11.7 Driver;" + 22 | "HOST=localhost;PORT=10000;UID=;PWD=;DIL=1;Database="); 23 | ``` 24 | 25 | ### Using a DSN 26 | ```csharp 27 | optionsBuilder.UseOpenEdge("dsn=MyDb;password=mypassword"); 28 | ``` 29 | 30 | ### Custom schema (defaults to "pub") 31 | ```csharp 32 | optionsBuilder.UseOpenEdge( 33 | connectionString: "dsn=MyDb;password=mypassword", 34 | defaultSchema: "myschema"); 35 | ``` 36 | 37 | ### Reverse-engineer an existing database 38 | ```powershell 39 | Scaffold-DbContext "dsn=MyDb;password=mypassword" EntityFrameworkCore.OpenEdge -OutputDir Models 40 | ``` 41 | 42 | --- 43 | 44 | ## Feature Matrix (EF Core 9) 45 | 46 | | Area | Status | Notes | 47 | |--------------------------|:------:|---------------------------------------------------------------------------| 48 | | Queries | ✅ | `SELECT`, `WHERE`, `ORDER BY`, `GROUP BY`, paging (`Skip`/`Take`), aggregates | 49 | | Joins / `Include` | ✅ | `INNER JOIN`, `LEFT JOIN`, filtered `Include`s | 50 | | String operations | ✅ | `Contains`, `StartsWith`, `EndsWith`, `Length` | 51 | | CRUD | ✅ | `INSERT`, `UPDATE`, `DELETE` – one command per operation (OpenEdge limitation) | 52 | | Scaffolding | ✅ | `Scaffold-DbContext` | 53 | | Nested queries | ✅ | `Skip`/`Take` inside sub-queries (new in 9.0.4) | 54 | | DateTime literal support | ✅ | `{ ts 'yyyy-MM-dd HH:mm:ss' }` formatting | 55 | | Date/Time operations | ✅ | `DateOnly` properties (`Year`, `Month`, `Day`, `DayOfYear`, `DayOfWeek`); methods (`FromDateTime`, `AddDays`, `AddMonths`, `AddYears`) | 56 | 57 | --- 58 | 59 | ## OpenEdge Gotchas 60 | 61 | * **Primary keys** – OpenEdge doesn’t enforce uniqueness. Use `rowid` or define composite keys 62 | * **No batching / `RETURNING`** – each modification executes individually; concurrency detection is limited. 63 | 64 | See the [OpenEdge SQL Reference](https://docs.progress.com/bundle/openedge-sql-reference/) for database specifics. 65 | 66 | ## Legacy 1.x Line (netstandard2.0 / EF Core 2.1) 67 | 68 | The original **1.x** versions target **netstandard 2.0** and EF Core 2.1. 69 | They remain on NuGet for applications that cannot yet migrate to .NET 8/9. 70 | 71 | | Package | Framework | EF Core | Install | 72 | |---------|-----------|---------|---------| 73 | | **1.0.11** (latest stable) | netstandard2.0 | 2.1.x | `dotnet add package EntityFrameworkCore.OpenEdge --version 1.0.11` | 74 | | 1.0.12-rc3 | netstandard2.0 | 2.1.x | `dotnet add package EntityFrameworkCore.OpenEdge --version 1.0.12-rc3` | 75 | 76 | The 1.x branch is **feature-frozen**. New development happens in the 9.x line. 77 | 78 | ## Contributing & Development 79 | 80 | We welcome pull requests — especially **back-ports to older EF Core branches** and **additional translator implementations**. 81 | 82 | * **Testing requirements:** EF Core providers are expected to pass the [EF Core provider specification tests](https://learn.microsoft.com/ef/core/providers/writing-a-provider). Our current test harness is **minimal** and may not cover all cases. Contributions that add missing tests (or implement the snadard testing flow for database providers) are highly appreciated. 83 | * **Bug reports:** Please include a runnable repro or failing test where possible. 84 | 85 | For a deeper dive into the architecture (query / update pipelines, type mapping, etc.) browse the source under `src/EFCore.OpenEdge`. 86 | 87 | ## License 88 | 89 | Apache-2.0 – see [LICENSE](LICENSE). 90 | -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/TestUtilities/SqlCapturingInterceptor.cs: -------------------------------------------------------------------------------- 1 | using System.Data; 2 | using System.Collections.Generic; 3 | using System.Data.Common; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Microsoft.EntityFrameworkCore.Diagnostics; 7 | 8 | namespace EFCore.OpenEdge.FunctionalTests.TestUtilities 9 | { 10 | public class SqlCapturingInterceptor : DbCommandInterceptor 11 | { 12 | private readonly List _capturedSql = new(); 13 | private readonly List _capturedParameters = new(); 14 | 15 | public IReadOnlyList CapturedSql => _capturedSql; 16 | public IReadOnlyList CapturedParameters => _capturedParameters; 17 | 18 | public void Clear() 19 | { 20 | _capturedSql.Clear(); 21 | _capturedParameters.Clear(); 22 | } 23 | 24 | public override InterceptionResult ReaderExecuting( 25 | DbCommand command, 26 | CommandEventData eventData, 27 | InterceptionResult result) 28 | { 29 | CaptureCommand(command); 30 | return base.ReaderExecuting(command, eventData, result); 31 | } 32 | 33 | public override ValueTask> ReaderExecutingAsync( 34 | DbCommand command, 35 | CommandEventData eventData, 36 | InterceptionResult result, 37 | CancellationToken cancellationToken = default) 38 | { 39 | CaptureCommand(command); 40 | return base.ReaderExecutingAsync(command, eventData, result, cancellationToken); 41 | } 42 | 43 | public override InterceptionResult NonQueryExecuting( 44 | DbCommand command, 45 | CommandEventData eventData, 46 | InterceptionResult result) 47 | { 48 | CaptureCommand(command); 49 | return base.NonQueryExecuting(command, eventData, result); 50 | } 51 | 52 | public override ValueTask> NonQueryExecutingAsync( 53 | DbCommand command, 54 | CommandEventData eventData, 55 | InterceptionResult result, 56 | CancellationToken cancellationToken = default) 57 | { 58 | CaptureCommand(command); 59 | return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken); 60 | } 61 | 62 | public override InterceptionResult ScalarExecuting( 63 | DbCommand command, 64 | CommandEventData eventData, 65 | InterceptionResult result) 66 | { 67 | CaptureCommand(command); 68 | return base.ScalarExecuting(command, eventData, result); 69 | } 70 | 71 | public override ValueTask> ScalarExecutingAsync( 72 | DbCommand command, 73 | CommandEventData eventData, 74 | InterceptionResult result, 75 | CancellationToken cancellationToken = default) 76 | { 77 | CaptureCommand(command); 78 | return base.ScalarExecutingAsync(command, eventData, result, cancellationToken); 79 | } 80 | 81 | private void CaptureCommand(DbCommand command) 82 | { 83 | _capturedSql.Add(command.CommandText); 84 | 85 | var parameters = new DbParameter[command.Parameters.Count]; 86 | for (int i = 0; i < command.Parameters.Count; i++) 87 | { 88 | var param = command.Parameters[i]; 89 | parameters[i] = new CapturedParameter 90 | { 91 | ParameterName = param.ParameterName, 92 | Value = param.Value, 93 | DbType = param.DbType, 94 | Direction = param.Direction 95 | }; 96 | } 97 | _capturedParameters.Add(parameters); 98 | } 99 | } 100 | 101 | public class CapturedParameter : DbParameter 102 | { 103 | public override DbType DbType { get; set; } 104 | public override ParameterDirection Direction { get; set; } 105 | public override bool IsNullable { get; set; } 106 | public override string ParameterName { get; set; } 107 | public override int Size { get; set; } 108 | public override string SourceColumn { get; set; } 109 | public override bool SourceColumnNullMapping { get; set; } 110 | public override object Value { get; set; } 111 | 112 | public override void ResetDbType() {} 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/Query/DateOnlyTranslationTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using EFCore.OpenEdge.FunctionalTests.Shared; 4 | using FluentAssertions; 5 | using Xunit; 6 | using Xunit.Abstractions; 7 | 8 | namespace EFCore.OpenEdge.FunctionalTests.Query 9 | { 10 | public class DateOnlyTranslationTests(ITestOutputHelper output) : ECommerceTestBase 11 | { 12 | private readonly ITestOutputHelper _output = output; 13 | 14 | [Fact] 15 | public void CanFilterByExactDate() 16 | { 17 | using var context = CreateContext(); 18 | var targetDate = new DateOnly(2024, 1, 15); 19 | 20 | var orders = context.Orders 21 | .Where(o => o.OrderDate == targetDate) 22 | .ToList(); 23 | 24 | // Assert query executes without translation errors 25 | orders.Should().NotBeNull(); 26 | } 27 | 28 | [Fact] 29 | public void CanFilterByDateComparison_GreaterThan() 30 | { 31 | using var context = CreateContext(); 32 | var cutoffDate = new DateOnly(2024, 1, 1); 33 | 34 | var orders = context.Orders 35 | .Where(o => o.OrderDate > cutoffDate) 36 | .ToList(); 37 | 38 | orders.Should().NotBeNull(); 39 | orders.Where(o => o.OrderDate.HasValue) 40 | .Should().OnlyContain(o => o.OrderDate.Value > cutoffDate); 41 | } 42 | 43 | [Fact] 44 | public void CanFilterByDateComparison_LessThanOrEqual() 45 | { 46 | using var context = CreateContext(); 47 | var cutoffDate = new DateOnly(2024, 12, 31); 48 | 49 | var orders = context.Orders 50 | .Where(o => o.OrderDate <= cutoffDate) 51 | .ToList(); 52 | 53 | orders.Should().NotBeNull(); 54 | orders.Where(o => o.OrderDate.HasValue) 55 | .Should().OnlyContain(o => o.OrderDate.Value <= cutoffDate); 56 | } 57 | 58 | [Fact] 59 | public void CanFilterByYearOnly() 60 | { 61 | using var context = CreateContext(); 62 | 63 | var orders = context.Orders 64 | .Where(o => o.OrderDate.Value.Year == 2024) 65 | .ToList(); 66 | 67 | orders.Should().NotBeNull(); 68 | orders.Where(o => o.OrderDate.HasValue) 69 | .Should().OnlyContain(o => o.OrderDate.Value.Year == 2024); 70 | } 71 | 72 | [Fact] 73 | public void CanFilterByMonth() 74 | { 75 | using var context = CreateContext(); 76 | 77 | var orders = context.Orders 78 | .Where(o => o.OrderDate.Value.Month == 1) 79 | .ToList(); 80 | 81 | orders.Should().NotBeNull(); 82 | orders.Where(o => o.OrderDate.HasValue) 83 | .Should().OnlyContain(o => o.OrderDate.Value.Month == 1); 84 | } 85 | 86 | [Fact] 87 | public void CanFilterByDay() 88 | { 89 | using var context = CreateContext(); 90 | 91 | var orders = context.Orders 92 | .Where(o => o.OrderDate.Value.Day == 15) 93 | .ToList(); 94 | 95 | orders.Should().NotBeNull(); 96 | orders.Where(o => o.OrderDate.HasValue) 97 | .Should().OnlyContain(o => o.OrderDate.Value.Day == 15); 98 | } 99 | 100 | [Fact] 101 | public void CanFilterByYearAndMonth() 102 | { 103 | using var context = CreateContext(); 104 | 105 | var orders = context.Orders 106 | .Where(o => o.OrderDate.Value.Year == 2024 && o.OrderDate.Value.Month == 1) 107 | .ToList(); 108 | 109 | orders.Should().NotBeNull(); 110 | orders.Where(o => o.OrderDate.HasValue) 111 | .Should().OnlyContain(o => o.OrderDate.Value.Year == 2024 && o.OrderDate.Value.Month == 1); 112 | } 113 | 114 | [Fact] 115 | public void CanFilterByDateRange() 116 | { 117 | using var context = CreateContext(); 118 | var startDate = new DateOnly(2024, 1, 1); 119 | var endDate = new DateOnly(2024, 6, 30); 120 | 121 | var orders = context.Orders 122 | .Where(o => o.OrderDate >= startDate && o.OrderDate <= endDate) 123 | .ToList(); 124 | 125 | orders.Should().NotBeNull(); 126 | orders.Where(o => o.OrderDate.HasValue) 127 | .Should().OnlyContain(o => o.OrderDate.Value >= startDate && o.OrderDate.Value <= endDate); 128 | } 129 | } 130 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Query/ExpressionTranslators/Internal/OpenEdgeStringMethodCallTranslator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Reflection; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.EntityFrameworkCore.Diagnostics; 6 | using Microsoft.EntityFrameworkCore.Query; 7 | using Microsoft.EntityFrameworkCore.Query.SqlExpressions; 8 | 9 | namespace EntityFrameworkCore.OpenEdge.Query.ExpressionTranslators.Internal 10 | { 11 | /// 12 | /// Translates string method calls to OpenEdge SQL equivalents. 13 | /// 14 | public class OpenEdgeStringMethodCallTranslator : IMethodCallTranslator 15 | { 16 | private readonly ISqlExpressionFactory _sqlExpressionFactory; 17 | 18 | private static readonly MethodInfo _stringContainsMethod = typeof(string).GetRuntimeMethod( 19 | nameof(string.Contains), [typeof(string)])!; 20 | 21 | private static readonly MethodInfo _stringStartsWithMethod = typeof(string).GetRuntimeMethod( 22 | nameof(string.StartsWith), [typeof(string)])!; 23 | 24 | private static readonly MethodInfo _stringEndsWithMethod = typeof(string).GetRuntimeMethod( 25 | nameof(string.EndsWith), [typeof(string)])!; 26 | 27 | public OpenEdgeStringMethodCallTranslator(ISqlExpressionFactory sqlExpressionFactory) 28 | { 29 | _sqlExpressionFactory = sqlExpressionFactory; 30 | } 31 | 32 | #nullable enable 33 | public virtual SqlExpression? Translate( 34 | SqlExpression? instance, 35 | MethodInfo method, 36 | IReadOnlyList arguments, 37 | IDiagnosticsLogger logger) 38 | { 39 | if (instance == null) 40 | { 41 | return null; 42 | } 43 | 44 | // Handle string.Contains(string) 45 | if (method.Equals(_stringContainsMethod)) 46 | { 47 | return TranslateContains(instance, arguments[0]); 48 | } 49 | 50 | // Handle string.StartsWith(string) 51 | if (method.Equals(_stringStartsWithMethod)) 52 | { 53 | return TranslateStartsWith(instance, arguments[0]); 54 | } 55 | 56 | // Handle string.EndsWith(string) 57 | if (method.Equals(_stringEndsWithMethod)) 58 | { 59 | return TranslateEndsWith(instance, arguments[0]); 60 | } 61 | 62 | return null; 63 | } 64 | 65 | private SqlExpression TranslateContains(SqlExpression instance, SqlExpression argument) 66 | { 67 | // OpenEdge CONCAT only accepts 2 arguments, so we need to chain them 68 | // CONCAT('%', CONCAT(argument, '%')) to get '%argument%' 69 | // Potentially, advisable to migrate to this approach instead: https://docs.progress.com/bundle/openedge-sql-reference/page/Concatenation-operator.html 70 | var innerConcat = _sqlExpressionFactory.Function( 71 | "CONCAT", 72 | [argument, _sqlExpressionFactory.Constant("%")], 73 | nullable: true, 74 | argumentsPropagateNullability: [true, false], 75 | typeof(string)); 76 | 77 | var pattern = _sqlExpressionFactory.Function( 78 | "CONCAT", 79 | [_sqlExpressionFactory.Constant("%"), innerConcat], 80 | nullable: true, 81 | argumentsPropagateNullability: [false, true], 82 | typeof(string)); 83 | 84 | return _sqlExpressionFactory.Like(instance, pattern); 85 | } 86 | 87 | private SqlExpression TranslateStartsWith(SqlExpression instance, SqlExpression argument) 88 | { 89 | // For StartsWith, we only need one CONCAT: argument + '%' 90 | var pattern = _sqlExpressionFactory.Function( 91 | "CONCAT", 92 | [argument, _sqlExpressionFactory.Constant("%")], 93 | nullable: true, 94 | argumentsPropagateNullability: [true, false], 95 | typeof(string)); 96 | 97 | return _sqlExpressionFactory.Like(instance, pattern); 98 | } 99 | 100 | private SqlExpression TranslateEndsWith(SqlExpression instance, SqlExpression argument) 101 | { 102 | // For EndsWith, we only need one CONCAT: '%' + argument 103 | var pattern = _sqlExpressionFactory.Function( 104 | "CONCAT", 105 | [_sqlExpressionFactory.Constant("%"), argument], 106 | nullable: true, 107 | argumentsPropagateNullability: [false, true], 108 | typeof(string)); 109 | 110 | return _sqlExpressionFactory.Like(instance, pattern); 111 | } 112 | } 113 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Query/ExpressionVisitors/Internal/OpenEdgeQueryTranslationPostprocessor.cs: -------------------------------------------------------------------------------- 1 | using System.Linq.Expressions; 2 | using Microsoft.EntityFrameworkCore.Query; 3 | using Microsoft.EntityFrameworkCore.Query.SqlExpressions; 4 | 5 | namespace EntityFrameworkCore.OpenEdge.Query.ExpressionVisitors.Internal 6 | { 7 | public class OpenEdgeQueryTranslationPostprocessor( 8 | QueryTranslationPostprocessorDependencies dependencies, 9 | RelationalQueryTranslationPostprocessorDependencies relationalDependencies, 10 | RelationalQueryCompilationContext queryCompilationContext) : RelationalQueryTranslationPostprocessor(dependencies, relationalDependencies, queryCompilationContext) 11 | { 12 | public override Expression Process(Expression query) 13 | { 14 | // First, let the base class do its processing 15 | query = base.Process(query); 16 | 17 | // Then apply OpenEdge-specific boolean comparison simplification 18 | query = new BooleanComparisonSimplificationVisitor(RelationalDependencies.SqlExpressionFactory).Visit(query); 19 | 20 | return query; 21 | } 22 | 23 | /// 24 | /// Visitor that simplifies redundant boolean comparisons for OpenEdge compatibility. 25 | /// 26 | /// OpenEdge doesn't support comparing boolean expressions with parameters like: 27 | /// WHERE (column = 1) = ? 28 | /// 29 | /// This visitor simplifies such patterns to: 30 | /// - (bool_expr) = true → bool_expr 31 | /// - (bool_expr) = false → NOT (bool_expr) 32 | /// 33 | private sealed class BooleanComparisonSimplificationVisitor(ISqlExpressionFactory sqlExpressionFactory) : ExpressionVisitor 34 | { 35 | private readonly ISqlExpressionFactory _sqlExpressionFactory = sqlExpressionFactory; 36 | 37 | protected override Expression VisitExtension(Expression extensionExpression) 38 | { 39 | // Handle ShapedQueryExpression - must be done manually 40 | if (extensionExpression is ShapedQueryExpression shapedQueryExpression) 41 | { 42 | var queryExpression = Visit(shapedQueryExpression.QueryExpression); 43 | 44 | return queryExpression != shapedQueryExpression.QueryExpression 45 | ? shapedQueryExpression.Update(queryExpression, shapedQueryExpression.ShaperExpression) 46 | : shapedQueryExpression; 47 | } 48 | 49 | // Handle SqlBinaryExpression for equality comparisons 50 | if (extensionExpression is SqlBinaryExpression sqlBinary && 51 | sqlBinary.OperatorType == ExpressionType.Equal) 52 | { 53 | var left = sqlBinary.Left; 54 | var right = sqlBinary.Right; 55 | 56 | // Check if we have a boolean expression compared with a boolean constant/parameter 57 | if (left.Type == typeof(bool) && right.Type == typeof(bool)) 58 | { 59 | // Case 1: (bool_expr) = true constant 60 | if (right is SqlConstantExpression { Value: true }) 61 | { 62 | // Simplify to just the left expression 63 | return Visit(left); 64 | } 65 | 66 | // Case 2: (bool_expr) = false constant 67 | if (right is SqlConstantExpression { Value: false }) 68 | { 69 | // Simplify to NOT (bool_expr) 70 | return Visit(_sqlExpressionFactory.Not(left)); 71 | } 72 | 73 | // Handle reverse cases: constant/parameter on left side 74 | if (left is SqlConstantExpression { Value: true }) 75 | { 76 | return Visit(right); 77 | } 78 | 79 | if (left is SqlConstantExpression { Value: false }) 80 | { 81 | return Visit(_sqlExpressionFactory.Not(right)); 82 | } 83 | } 84 | } 85 | 86 | return base.VisitExtension(extensionExpression); 87 | } 88 | 89 | /// 90 | /// Checks if the expression is a boolean comparison (e.g., column = 1) 91 | /// 92 | private static bool IsBooleanComparison(SqlExpression expression) 93 | { 94 | return expression is SqlBinaryExpression binary && 95 | binary.Type == typeof(bool) && 96 | (binary.OperatorType == ExpressionType.Equal || 97 | binary.OperatorType == ExpressionType.NotEqual || 98 | binary.OperatorType == ExpressionType.GreaterThan || 99 | binary.OperatorType == ExpressionType.GreaterThanOrEqual || 100 | binary.OperatorType == ExpressionType.LessThan || 101 | binary.OperatorType == ExpressionType.LessThanOrEqual || 102 | binary.OperatorType == ExpressionType.AndAlso || 103 | binary.OperatorType == ExpressionType.OrElse); 104 | } 105 | } 106 | } 107 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Query/ExpressionVisitors/Internal/OpenEdgeQueryableMethodTranslatingExpressionVisitor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq.Expressions; 3 | using Microsoft.EntityFrameworkCore.Query; 4 | using Microsoft.EntityFrameworkCore.Query.SqlExpressions; 5 | 6 | namespace EntityFrameworkCore.OpenEdge.Query.ExpressionVisitors.Internal 7 | { 8 | /// 9 | /// OpenEdge-specific implementation of the queryable method translating expression visitor. 10 | /// This visitor translates LINQ queryable methods (OrderBy, Where, Select, etc.) into SQL expressions. 11 | /// 12 | /// Key responsibility: Tracks ORDER BY context to coordinate with SqlTranslatingExpressionVisitor 13 | /// for proper boolean handling in different SQL clauses. 14 | /// 15 | public class OpenEdgeQueryableMethodTranslatingExpressionVisitor : RelationalQueryableMethodTranslatingExpressionVisitor 16 | { 17 | /// 18 | /// Tracks whether we're currently translating an ORDER BY expression. 19 | /// 20 | /// Why this is needed: 21 | /// OpenEdge has specific requirements for boolean columns in different SQL contexts: 22 | /// - In WHERE/HAVING/JOIN: Boolean columns must be compared explicitly (e.g., "column = 1") 23 | /// - In ORDER BY: Boolean columns must be used as-is without comparison (e.g., just "column") 24 | /// - In SELECT: Boolean columns need CASE WHEN wrapping (handled in SqlGenerator) 25 | /// 26 | /// This flag allows the SqlTranslatingExpressionVisitor to know when it's processing 27 | /// an ORDER BY expression and should NOT add the "= 1" comparison to boolean columns. 28 | /// 29 | /// Thread safety: This is an instance field (not static) to ensure thread safety. 30 | /// Each query execution creates its own visitor instance, preventing race conditions 31 | /// between concurrent requests. 32 | /// 33 | /// Communication: The SqlTranslatingExpressionVisitor receives a reference to this 34 | /// visitor instance through its constructor, allowing it to check this flag. 35 | /// 36 | internal bool IsTranslatingOrderBy { get; private set; } 37 | 38 | public OpenEdgeQueryableMethodTranslatingExpressionVisitor( 39 | QueryableMethodTranslatingExpressionVisitorDependencies dependencies, 40 | RelationalQueryableMethodTranslatingExpressionVisitorDependencies relationalDependencies, 41 | RelationalQueryCompilationContext queryCompilationContext) 42 | : base(dependencies, relationalDependencies, queryCompilationContext) 43 | { 44 | } 45 | 46 | /// 47 | /// Overrides TranslateOrderBy to set context flag for ORDER BY processing. 48 | /// 49 | /// This method is called when translating .OrderBy() or .OrderByDescending() LINQ methods. 50 | /// It sets the IsTranslatingOrderBy flag to true before delegating to the base implementation, 51 | /// ensuring that any boolean columns in the ORDER BY expression are not transformed. 52 | /// 53 | /// Example LINQ: query.OrderBy(p => p.IsActive) 54 | /// With flag: ORDER BY p.IsActive (correct for OpenEdge) 55 | /// 56 | #nullable enable 57 | protected override ShapedQueryExpression? TranslateOrderBy( 58 | ShapedQueryExpression source, 59 | LambdaExpression keySelector, 60 | bool ascending) 61 | { 62 | // Save current state to support nested expressions (though rare in ORDER BY) 63 | var previousValue = IsTranslatingOrderBy; 64 | IsTranslatingOrderBy = true; 65 | 66 | try 67 | { 68 | // Delegate to base implementation which will eventually call 69 | // SqlTranslatingExpressionVisitor to translate the keySelector 70 | return base.TranslateOrderBy(source, keySelector, ascending); 71 | } 72 | finally 73 | { 74 | // Always restore previous state to ensure proper cleanup 75 | IsTranslatingOrderBy = previousValue; 76 | } 77 | } 78 | 79 | /// 80 | /// Overrides TranslateThenBy to set context flag for additional ORDER BY expressions. 81 | /// 82 | /// This method is called when translating .ThenBy() or .ThenByDescending() LINQ methods. 83 | /// These methods add additional sorting criteria after an initial OrderBy. 84 | /// 85 | /// Example LINQ: query.OrderBy(p => p.Name).ThenBy(p => p.IsActive) 86 | /// The ThenBy portion needs the same boolean handling as OrderBy. 87 | /// 88 | #nullable enable 89 | protected override ShapedQueryExpression? TranslateThenBy( 90 | ShapedQueryExpression source, 91 | LambdaExpression keySelector, 92 | bool ascending) 93 | { 94 | // Save current state (should already be true if called after OrderBy, but be safe) 95 | var previousValue = IsTranslatingOrderBy; 96 | IsTranslatingOrderBy = true; 97 | 98 | try 99 | { 100 | // Delegate to base implementation for the actual translation 101 | return base.TranslateThenBy(source, keySelector, ascending); 102 | } 103 | finally 104 | { 105 | // Always restore previous state to ensure proper cleanup 106 | IsTranslatingOrderBy = previousValue; 107 | } 108 | } 109 | } 110 | } -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/Query/DateOnlyMethodTranslationTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using EFCore.OpenEdge.FunctionalTests.Shared; 4 | using FluentAssertions; 5 | using Xunit; 6 | using Xunit.Abstractions; 7 | 8 | namespace EFCore.OpenEdge.FunctionalTests.Query 9 | { 10 | public class DateOnlyMethodTranslationTests(ITestOutputHelper output) : ECommerceTestBase 11 | { 12 | private readonly ITestOutputHelper _output = output; 13 | 14 | [Fact] 15 | public void CanUseAddDays() 16 | { 17 | using var context = CreateContext(); 18 | var baseDate = new DateOnly(2024, 1, 1); 19 | 20 | // Find orders that are 30 days after a base date 21 | var orders = context.Orders 22 | .Where(o => o.OrderDate == baseDate.AddDays(30)) 23 | .ToList(); 24 | 25 | // Query should execute without translation errors 26 | orders.Should().NotBeNull(); 27 | } 28 | 29 | [Fact] 30 | public void CanUseAddDaysInComparison() 31 | { 32 | using var context = CreateContext(); 33 | var baseDate = new DateOnly(2024, 1, 1); 34 | 35 | // Find orders that are within 7 days of base date 36 | var orders = context.Orders 37 | .Where(o => o.OrderDate > baseDate && o.OrderDate <= baseDate.AddDays(7)) 38 | .ToList(); 39 | 40 | orders.Should().NotBeNull(); 41 | } 42 | 43 | [Fact] 44 | public void CanUseAddMonths() 45 | { 46 | using var context = CreateContext(); 47 | var baseDate = new DateOnly(2024, 1, 1); 48 | 49 | // Find orders that are 3 months after base date 50 | var orders = context.Orders 51 | .Where(o => o.OrderDate == baseDate.AddMonths(3)) 52 | .ToList(); 53 | 54 | orders.Should().NotBeNull(); 55 | } 56 | 57 | [Fact] 58 | public void CanUseAddMonthsInRange() 59 | { 60 | using var context = CreateContext(); 61 | var baseDate = new DateOnly(2024, 1, 1); 62 | 63 | // Find orders in a 6-month window 64 | var orders = context.Orders 65 | .Where(o => o.OrderDate >= baseDate && o.OrderDate < baseDate.AddMonths(6)) 66 | .ToList(); 67 | 68 | orders.Should().NotBeNull(); 69 | } 70 | 71 | [Fact] 72 | public void CanUseAddYears() 73 | { 74 | using var context = CreateContext(); 75 | var baseDate = new DateOnly(2023, 1, 1); 76 | 77 | // Find orders that are 1 year after base date 78 | var orders = context.Orders 79 | .Where(o => o.OrderDate == baseDate.AddYears(1)) 80 | .ToList(); 81 | 82 | orders.Should().NotBeNull(); 83 | } 84 | 85 | [Fact] 86 | public void CanUseAddYearsInComparison() 87 | { 88 | using var context = CreateContext(); 89 | var baseDate = new DateOnly(2023, 1, 1); 90 | 91 | // Find orders within a year 92 | var orders = context.Orders 93 | .Where(o => o.OrderDate >= baseDate && o.OrderDate < baseDate.AddYears(1)) 94 | .ToList(); 95 | 96 | orders.Should().NotBeNull(); 97 | } 98 | 99 | [Fact] 100 | public void CanCombineAddMethods() 101 | { 102 | using var context = CreateContext(); 103 | var baseDate = new DateOnly(2024, 1, 1); 104 | 105 | // Find orders that are 1 year and 3 months after base date 106 | var targetDate = baseDate.AddYears(1).AddMonths(3); 107 | var orders = context.Orders 108 | .Where(o => o.OrderDate == targetDate) 109 | .ToList(); 110 | 111 | orders.Should().NotBeNull(); 112 | } 113 | 114 | [Fact] 115 | public void CanUseNegativeValuesInAddMethods() 116 | { 117 | using var context = CreateContext(); 118 | var baseDate = new DateOnly(2024, 6, 15); 119 | 120 | // Find orders from 30 days before base date 121 | var orders = context.Orders 122 | .Where(o => o.OrderDate == baseDate.AddDays(-30)) 123 | .ToList(); 124 | 125 | orders.Should().NotBeNull(); 126 | } 127 | 128 | [Fact] 129 | public void CanFilterByDayOfYear() 130 | { 131 | using var context = CreateContext(); 132 | 133 | // Find orders on the 100th day of the year 134 | var orders = context.Orders 135 | .Where(o => o.OrderDate.Value.DayOfYear == 100) 136 | .ToList(); 137 | 138 | orders.Should().NotBeNull(); 139 | } 140 | 141 | [Fact] 142 | public void ComplexDateCalculation() 143 | { 144 | using var context = CreateContext(); 145 | var referenceDate = new DateOnly(2024, 1, 15); 146 | 147 | // Complex query combining multiple date operations 148 | var orders = context.Orders 149 | .Where(o => o.OrderDate != null && 150 | o.OrderDate.Value.Year == referenceDate.Year && 151 | o.OrderDate.Value >= referenceDate.AddMonths(-1) && 152 | o.OrderDate.Value <= referenceDate.AddMonths(1).AddDays(15)) 153 | .OrderBy(o => o.OrderDate) 154 | .ToList(); 155 | 156 | orders.Should().NotBeNull(); 157 | } 158 | } 159 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Query/ExpressionVisitors/Internal/OpenEdgeSqlTranslatingExpressionVisitor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq.Expressions; 3 | using Microsoft.EntityFrameworkCore.Query; 4 | using Microsoft.EntityFrameworkCore.Query.SqlExpressions; 5 | using Microsoft.EntityFrameworkCore.Storage; 6 | 7 | namespace EntityFrameworkCore.OpenEdge.Query.ExpressionVisitors.Internal 8 | { 9 | /// 10 | /// Translates LINQ expressions to SQL expressions for OpenEdge. 11 | /// Handles OpenEdge-specific translation requirements. 12 | /// 13 | public class OpenEdgeSqlTranslatingExpressionVisitor( 14 | RelationalSqlTranslatingExpressionVisitorDependencies dependencies, 15 | QueryCompilationContext queryCompilationContext, 16 | QueryableMethodTranslatingExpressionVisitor queryableMethodTranslatingExpressionVisitor) : RelationalSqlTranslatingExpressionVisitor(dependencies, queryCompilationContext, queryableMethodTranslatingExpressionVisitor) 17 | { 18 | private bool _isInPredicateContext = false; 19 | private bool _isInComparisonContext = false; 20 | 21 | #nullable enable 22 | private readonly OpenEdgeQueryableMethodTranslatingExpressionVisitor? _openEdgeQueryableVisitor = queryableMethodTranslatingExpressionVisitor as OpenEdgeQueryableMethodTranslatingExpressionVisitor; 23 | 24 | /// 25 | /// Overrides the Translate method to track when we're in a predicate context. 26 | /// The allowOptimizedExpansion parameter is typically true for WHERE/HAVING predicates. 27 | /// 28 | #nullable enable 29 | public override SqlExpression? Translate(Expression expression, bool allowOptimizedExpansion = false) 30 | { 31 | var oldIsInPredicateContext = _isInPredicateContext; 32 | 33 | // When allowOptimizedExpansion is true AND we're not in ORDER BY context, 34 | // we're in a WHERE/HAVING/JOIN predicate (presumably?) 35 | if (allowOptimizedExpansion && !(_openEdgeQueryableVisitor?.IsTranslatingOrderBy ?? false)) 36 | { 37 | _isInPredicateContext = true; 38 | } 39 | 40 | try 41 | { 42 | return base.Translate(expression, allowOptimizedExpansion); 43 | } 44 | finally 45 | { 46 | _isInPredicateContext = oldIsInPredicateContext; 47 | } 48 | } 49 | 50 | /// 51 | /// Handles member access expressions and ensures OpenEdge-compatible boolean comparisons. 52 | /// Only applies comparison transformation in WHERE/HAVING/JOIN contexts. 53 | /// Never applies transformation in ORDER BY or SELECT projection contexts. 54 | /// 55 | protected override Expression VisitMember(MemberExpression memberExpression) 56 | { 57 | var result = base.VisitMember(memberExpression); 58 | 59 | // Never transform boolean members in ORDER BY context (double-check) 60 | if (_openEdgeQueryableVisitor?.IsTranslatingOrderBy ?? false) 61 | { 62 | return result; 63 | } 64 | 65 | // Only transform boolean members to comparisons when: 66 | // 1. We're definitively in a predicate context (WHERE/HAVING/JOIN) 67 | // 2. NOT already in a comparison context (to avoid double comparisons) 68 | // 3. The result is a boolean SQL expression 69 | if (_isInPredicateContext && 70 | !_isInComparisonContext && 71 | result is SqlExpression sqlResult && 72 | sqlResult.Type == typeof(bool)) 73 | { 74 | // Transform boolean member to explicit comparison with 1 75 | // This ensures OpenEdge gets "boolean_column = 1" instead of just "boolean_column" 76 | var intTypeMapping = Dependencies.TypeMappingSource.FindMapping(typeof(int)); 77 | 78 | return Dependencies.SqlExpressionFactory.Equal( 79 | sqlResult, 80 | Dependencies.SqlExpressionFactory.Constant(1, intTypeMapping)); 81 | } 82 | 83 | return result; 84 | } 85 | 86 | /// 87 | /// Handles binary expressions to set comparison context. 88 | /// 89 | protected override Expression VisitBinary(BinaryExpression binaryExpression) 90 | { 91 | // If this is a comparison operation, set the context flag to prevent 92 | // boolean members from being transformed to comparisons 93 | var oldIsInComparisonContext = _isInComparisonContext; 94 | 95 | if (IsBooleanComparison(binaryExpression)) 96 | { 97 | _isInComparisonContext = true; 98 | } 99 | 100 | try 101 | { 102 | return base.VisitBinary(binaryExpression); 103 | } 104 | finally 105 | { 106 | _isInComparisonContext = oldIsInComparisonContext; 107 | } 108 | } 109 | 110 | /// 111 | /// Checks if the expression is a comparison operation. 112 | /// 113 | private static bool IsBooleanComparison(BinaryExpression expression) 114 | { 115 | return expression.NodeType == ExpressionType.Equal || 116 | expression.NodeType == ExpressionType.NotEqual || 117 | expression.NodeType == ExpressionType.GreaterThan || 118 | expression.NodeType == ExpressionType.GreaterThanOrEqual || 119 | expression.NodeType == ExpressionType.LessThan || 120 | expression.NodeType == ExpressionType.LessThanOrEqual || 121 | expression.NodeType == ExpressionType.AndAlso || 122 | expression.NodeType == ExpressionType.OrElse; 123 | } 124 | } 125 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Extensions/OpenEdgeDbContextOptionsBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using EntityFrameworkCore.OpenEdge.Infrastructure.Internal; 3 | using Microsoft.EntityFrameworkCore.Infrastructure; 4 | 5 | // Note!: Namespace intentionally matches EF Core provider convention 6 | // rather than file location for better user experience 7 | #pragma warning disable IDE0130 8 | namespace Microsoft.EntityFrameworkCore 9 | { 10 | public static class OpenEdgeDbContextOptionsBuilderExtensions 11 | { 12 | /// 13 | /// Configures the context to connect to an OpenEdge database. 14 | /// 15 | /// The builder being used to configure the context. 16 | /// The connection string of the database to connect to. 17 | /// An optional action to allow additional configuration. 18 | /// The options builder so that further configuration can be chained. 19 | public static DbContextOptionsBuilder UseOpenEdge( 20 | this DbContextOptionsBuilder optionsBuilder, 21 | string connectionString, 22 | Action optionsAction = null) 23 | { 24 | return UseOpenEdge(optionsBuilder, connectionString, defaultSchema: null, optionsAction); 25 | } 26 | 27 | /// 28 | /// Configures the context to connect to an OpenEdge database with a specific default schema. 29 | /// 30 | /// The builder being used to configure the context. 31 | /// The connection string of the database to connect to. 32 | /// The default schema to use for tables when not explicitly specified. Defaults to "pub" if null. 33 | /// An optional action to allow additional configuration. 34 | /// The options builder so that further configuration can be chained. 35 | public static DbContextOptionsBuilder UseOpenEdge( 36 | this DbContextOptionsBuilder optionsBuilder, 37 | string connectionString, 38 | string defaultSchema, 39 | Action optionsAction = null) 40 | { 41 | /* 42 | * Adds the OpenEdgeOptionsExtension extension to the internal collection. 43 | * 44 | * // Without this pattern, users would need to do: 45 | * services.AddEntityFrameworkOpenEdge(); // Manual registration 46 | * services.AddDbContext(options => 47 | * options.UseOpenEdge("connection")); 48 | * 49 | * // With this pattern, users only need: 50 | * services.AddDbContext(options => 51 | * options.UseOpenEdge("connection")); // Automatic registration 52 | */ 53 | var extension = GetOrCreateExtension(optionsBuilder).WithConnectionString(connectionString); 54 | 55 | if (defaultSchema != null) 56 | { 57 | extension = ((OpenEdgeOptionsExtension) extension).WithDefaultSchema(defaultSchema); 58 | } 59 | 60 | ((IDbContextOptionsBuilderInfrastructure)optionsBuilder).AddOrUpdateExtension(extension); 61 | 62 | optionsAction?.Invoke(optionsBuilder); 63 | 64 | return optionsBuilder; 65 | } 66 | 67 | /// 68 | /// Configures the context to connect to an OpenEdge database. 69 | /// 70 | /// The type of context to be configured. 71 | /// The builder being used to configure the context. 72 | /// The connection string of the database to connect to. 73 | /// An optional action to allow additional configuration. 74 | /// The options builder so that further configuration can be chained. 75 | public static DbContextOptionsBuilder UseOpenEdge( 76 | this DbContextOptionsBuilder optionsBuilder, 77 | string connectionString, 78 | Action optionsAction = null) 79 | where TContext : DbContext 80 | => UseOpenEdge(optionsBuilder, connectionString, defaultSchema: null, optionsAction); 81 | 82 | /// 83 | /// Configures the context to connect to an OpenEdge database with a specific default schema. 84 | /// 85 | /// The type of context to be configured. 86 | /// The builder being used to configure the context. 87 | /// The connection string of the database to connect to. 88 | /// The default schema to use for tables when not explicitly specified. Defaults to "pub" if null. 89 | /// An optional action to allow additional configuration. 90 | /// The options builder so that further configuration can be chained. 91 | public static DbContextOptionsBuilder UseOpenEdge( 92 | this DbContextOptionsBuilder optionsBuilder, 93 | string connectionString, 94 | string defaultSchema, 95 | Action optionsAction = null) 96 | where TContext : DbContext 97 | => (DbContextOptionsBuilder)UseOpenEdge( 98 | (DbContextOptionsBuilder)optionsBuilder, connectionString, defaultSchema, optionsAction); 99 | 100 | private static OpenEdgeOptionsExtension GetOrCreateExtension(DbContextOptionsBuilder optionsBuilder) 101 | => optionsBuilder.Options.FindExtension() ?? new OpenEdgeOptionsExtension(); 102 | } 103 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Query/ExpressionTranslators/Internal/OpenEdgeDateOnlyMethodCallTranslator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Reflection; 4 | using Microsoft.EntityFrameworkCore.Diagnostics; 5 | using Microsoft.EntityFrameworkCore.Query; 6 | using Microsoft.EntityFrameworkCore.Query.SqlExpressions; 7 | 8 | namespace EntityFrameworkCore.OpenEdge.Query.ExpressionTranslators.Internal 9 | { 10 | /// 11 | /// Translates DateOnly method calls to OpenEdge SQL functions. 12 | /// Handles methods like FromDateTime, ToDateTime, AddDays, AddMonths, AddYears, and comparisons. 13 | /// 14 | public class OpenEdgeDateOnlyMethodCallTranslator(ISqlExpressionFactory sqlExpressionFactory) : IMethodCallTranslator 15 | { 16 | private readonly ISqlExpressionFactory _sqlExpressionFactory = sqlExpressionFactory; 17 | 18 | private static readonly MethodInfo _fromDateTimeMethod = typeof(DateOnly).GetRuntimeMethod( 19 | nameof(DateOnly.FromDateTime), [typeof(DateTime)])!; 20 | 21 | private static readonly MethodInfo _toDateTimeMethod = typeof(DateOnly).GetRuntimeMethod( 22 | nameof(DateOnly.ToDateTime), [typeof(TimeOnly)])!; 23 | 24 | private static readonly MethodInfo _toDateTimeMethodWithKind = typeof(DateOnly).GetRuntimeMethod( 25 | nameof(DateOnly.ToDateTime), [typeof(TimeOnly), typeof(DateTimeKind)])!; 26 | 27 | private static readonly MethodInfo _addDaysMethod = typeof(DateOnly).GetRuntimeMethod( 28 | nameof(DateOnly.AddDays), [typeof(int)])!; 29 | 30 | private static readonly MethodInfo _addMonthsMethod = typeof(DateOnly).GetRuntimeMethod( 31 | nameof(DateOnly.AddMonths), [typeof(int)])!; 32 | 33 | private static readonly MethodInfo _addYearsMethod = typeof(DateOnly).GetRuntimeMethod( 34 | nameof(DateOnly.AddYears), [typeof(int)])!; 35 | 36 | private static readonly MethodInfo _compareToMethod = typeof(DateOnly).GetRuntimeMethod( 37 | nameof(DateOnly.CompareTo), [typeof(DateOnly)])!; 38 | 39 | #nullable enable 40 | public virtual SqlExpression? Translate( 41 | SqlExpression? instance, 42 | MethodInfo method, 43 | IReadOnlyList arguments, 44 | IDiagnosticsLogger logger) 45 | { 46 | // FromDateTime static method - extract date part from DateTime 47 | if (method.Equals(_fromDateTimeMethod)) 48 | { 49 | // In OpenEdge, we can use DATE function to extract date part 50 | return _sqlExpressionFactory.Function( 51 | "DATE", 52 | [arguments[0]], 53 | nullable: true, 54 | argumentsPropagateNullability: [true], 55 | typeof(DateOnly)); 56 | } 57 | 58 | // Instance methods - check if instance is DateOnly 59 | if (instance?.Type != typeof(DateOnly) && instance?.Type != typeof(DateOnly?)) 60 | { 61 | return null; 62 | } 63 | 64 | // ToDateTime method - combine date with time 65 | if (method.Equals(_toDateTimeMethod) || method.Equals(_toDateTimeMethodWithKind)) 66 | { 67 | // For simplicity, cast DateOnly to DateTime (adds 00:00:00 time) 68 | // If a TimeOnly is provided in arguments[0], we'd need to combine them 69 | // For now, just cast the date to datetime 70 | return _sqlExpressionFactory.Convert(instance, typeof(DateTime)); 71 | } 72 | 73 | // AddDays method 74 | // OpenEdge uses date arithmetic: date + integer (where integer represents days) 75 | // https://docs.progress.com/bundle/openedge-sql-reference/page/Date-arithmetic-expressions.html 76 | if (method.Equals(_addDaysMethod)) 77 | { 78 | return _sqlExpressionFactory.Add( 79 | instance, 80 | arguments[0]); 81 | } 82 | 83 | // AddMonths method 84 | // OpenEdge has ADD_MONTHS function for adding months to a date 85 | // https://docs.progress.com/bundle/openedge-sql-reference/page/ADD_MONTHS.html 86 | if (method.Equals(_addMonthsMethod)) 87 | { 88 | return _sqlExpressionFactory.Function( 89 | "ADD_MONTHS", 90 | [ 91 | instance, 92 | arguments[0] 93 | ], 94 | nullable: true, 95 | argumentsPropagateNullability: [true, true], 96 | typeof(DateOnly)); 97 | } 98 | 99 | // AddYears method 100 | // OpenEdge doesn't have ADD_YEARS, so we use ADD_MONTHS with years * 12 101 | if (method.Equals(_addYearsMethod)) 102 | { 103 | // Multiply years by 12 to get months 104 | var monthsToAdd = _sqlExpressionFactory.Multiply( 105 | arguments[0], 106 | _sqlExpressionFactory.Constant(12)); 107 | 108 | return _sqlExpressionFactory.Function( 109 | "ADD_MONTHS", 110 | [ 111 | instance, 112 | monthsToAdd 113 | ], 114 | nullable: true, 115 | argumentsPropagateNullability: [true, true], 116 | typeof(DateOnly)); 117 | } 118 | 119 | // CompareTo method - convert to standard comparison 120 | if (method.Equals(_compareToMethod)) 121 | { 122 | // DateOnly.CompareTo returns -1, 0, or 1 123 | // We can use CASE WHEN to simulate this 124 | var comparison = _sqlExpressionFactory.Case( 125 | [ 126 | new CaseWhenClause( 127 | _sqlExpressionFactory.LessThan(instance, arguments[0]), 128 | _sqlExpressionFactory.Constant(-1)), 129 | new CaseWhenClause( 130 | _sqlExpressionFactory.Equal(instance, arguments[0]), 131 | _sqlExpressionFactory.Constant(0)) 132 | ], 133 | _sqlExpressionFactory.Constant(1)); 134 | 135 | return comparison; 136 | } 137 | 138 | return null; 139 | } 140 | } 141 | } -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/Query/SqlGenerationTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using EFCore.OpenEdge.FunctionalTests.Shared; 4 | using EFCore.OpenEdge.FunctionalTests.Shared.Models; 5 | using EFCore.OpenEdge.FunctionalTests.TestUtilities; 6 | using FluentAssertions; 7 | using Microsoft.EntityFrameworkCore; 8 | using Xunit; 9 | using Xunit.Abstractions; 10 | using System.Text.RegularExpressions; 11 | 12 | namespace EFCore.OpenEdge.FunctionalTests.Query 13 | { 14 | public class SqlGenerationTests : ECommerceTestBase 15 | { 16 | private readonly ITestOutputHelper _output; 17 | 18 | public SqlGenerationTests(ITestOutputHelper output) 19 | { 20 | _output = output; 21 | } 22 | 23 | private (ECommerceTestContext context, SqlCapturingInterceptor interceptor) CreateContextWithSqlCapturing() 24 | { 25 | var interceptor = new SqlCapturingInterceptor(); 26 | 27 | var options = CreateOptionsBuilder() 28 | .AddInterceptors(interceptor) 29 | .EnableSensitiveDataLogging() 30 | .Options; 31 | 32 | var context = new ECommerceTestContext(options); 33 | return (context, interceptor); 34 | } 35 | 36 | #region PAGINATION TESTS 37 | 38 | [Fact] 39 | public void Skip_Take_Should_Generate_OFFSET_FETCH_SQL() 40 | { 41 | var (context, interceptor) = CreateContextWithSqlCapturing(); 42 | 43 | var pagedCustomers = context.Customers 44 | .OrderBy(c => c.Id) 45 | .Skip(5) 46 | .Take(10) 47 | .ToList(); 48 | 49 | interceptor.CapturedSql.Should().NotBeEmpty("SQL should be captured for Skip/Take"); 50 | var sql = interceptor.CapturedSql.First(); 51 | 52 | _output.WriteLine($"Skip(5).Take(10) generated SQL: {sql}"); 53 | 54 | var expectedSql = @"SELECT ""c"".""Id"", ""c"".""Age"", ""c"".""City"", ""c"".""Email"", ""c"".""IsActive"", ""c"".""Name"" 55 | FROM ""PUB"".""CUSTOMERS_TEST_PROVIDER"" AS ""c"" 56 | ORDER BY ""c"".""Id"" 57 | OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY"; 58 | 59 | // Normalize both strings to handle line ending differences 60 | var normalizedSql = sql.Replace("\r\n", "\n").Replace("\r", "\n"); 61 | var normalizedExpected = expectedSql.Replace("\r\n", "\n").Replace("\r", "\n"); 62 | 63 | normalizedSql.Should().Be(normalizedExpected, "Should generate proper OFFSET/FETCH SQL for pagination"); 64 | } 65 | 66 | [Fact] 67 | public void Take_Only_Should_Generate_TOP_SQL() 68 | { 69 | var (context, interceptor) = CreateContextWithSqlCapturing(); 70 | 71 | var topCustomers = context.Customers 72 | .OrderBy(c => c.Id) 73 | .Take(10) 74 | .ToList(); 75 | 76 | interceptor.CapturedSql.Should().NotBeEmpty("SQL should be captured for Take"); 77 | var sql = interceptor.CapturedSql.First(); 78 | 79 | _output.WriteLine($"Take(10) generated SQL: {sql}"); 80 | 81 | var expectedSql = @"SELECT ""c"".""Id"", ""c"".""Age"", ""c"".""City"", ""c"".""Email"", ""c"".""IsActive"", ""c"".""Name"" 82 | FROM ""PUB"".""CUSTOMERS_TEST_PROVIDER"" AS ""c"" 83 | ORDER BY ""c"".""Id"" 84 | FETCH FIRST 10 ROWS ONLY"; 85 | 86 | // Normalize both strings to handle line ending differences 87 | var normalizedSql = sql.Replace("\r\n", "\n").Replace("\r", "\n"); 88 | var normalizedExpected = expectedSql.Replace("\r\n", "\n").Replace("\r", "\n"); 89 | 90 | normalizedSql.Should().Be(normalizedExpected, "Should generate proper TOP SQL for Take"); 91 | } 92 | 93 | [Fact] 94 | public void Skip_Only_Should_Generate_OFFSET_SQL() 95 | { 96 | var (context, interceptor) = CreateContextWithSqlCapturing(); 97 | 98 | var skippedCustomers = context.Customers 99 | .OrderBy(c => c.Id) 100 | .Skip(5) 101 | .ToList(); 102 | 103 | interceptor.CapturedSql.Should().NotBeEmpty("SQL should be captured for Skip"); 104 | var sql = interceptor.CapturedSql.First(); 105 | 106 | _output.WriteLine($"Skip(5) generated SQL: {sql}"); 107 | 108 | var expectedSql = @"SELECT ""c"".""Id"", ""c"".""Age"", ""c"".""City"", ""c"".""Email"", ""c"".""IsActive"", ""c"".""Name"" 109 | FROM ""PUB"".""CUSTOMERS_TEST_PROVIDER"" AS ""c"" 110 | ORDER BY ""c"".""Id"" 111 | OFFSET 5 ROWS"; 112 | 113 | // Normalize both strings to handle line ending differences 114 | var normalizedSql = sql.Replace("\r\n", "\n").Replace("\r", "\n"); 115 | var normalizedExpected = expectedSql.Replace("\r\n", "\n").Replace("\r", "\n"); 116 | 117 | normalizedSql.Should().Be(normalizedExpected, "Should generate proper OFFSET SQL for Skip"); 118 | } 119 | 120 | [Fact] 121 | public void Nested_Subquery_With_Take_Should_Generate_Inlined_FETCH_At_All_Levels() 122 | { 123 | var (context, interceptor) = CreateContextWithSqlCapturing(); 124 | 125 | // Create a query with nested subquery similar to what OData might generate 126 | // This simulates a scenario where there's a subquery with its own FETCH clause 127 | var query = context.Customers 128 | .Where(c => context.Orders 129 | .OrderBy(o => o.Id) 130 | .Take(2) // This inner Take should generate an inlined FETCH 131 | .Any(o => o.CustomerId == c.Id)) 132 | .OrderBy(c => c.Id) 133 | .Take(5) // This outer Take should also generate an inlined FETCH 134 | .ToList(); 135 | 136 | interceptor.CapturedSql.Should().NotBeEmpty("SQL should be captured for nested query"); 137 | var sql = interceptor.CapturedSql.First(); 138 | 139 | _output.WriteLine($"Nested query with Take generated SQL: {sql}"); 140 | 141 | // The SQL should NOT contain any '?' parameters in FETCH clauses 142 | sql.Should().NotContain("FETCH FIRST ? ROWS", "All FETCH clauses should use inlined literal values, not parameters"); 143 | sql.Should().NotContain("FETCH NEXT ? ROWS", "All FETCH clauses should use inlined literal values, not parameters"); 144 | 145 | // The SQL SHOULD contain inlined FETCH clauses with literal numbers 146 | sql.Should().Match("*FETCH*2*ROWS*", "Inner Take(2) should generate inlined FETCH with literal 2"); 147 | sql.Should().Match("*FETCH*5*ROWS*", "Outer Take(5) should generate inlined FETCH with literal 5"); 148 | } 149 | 150 | #endregion 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015/2017 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # Visual Studio 2017 auto generated files 33 | Generated\ Files/ 34 | 35 | # MSTest test Results 36 | [Tt]est[Rr]esult*/ 37 | [Bb]uild[Ll]og.* 38 | 39 | # NUNIT 40 | *.VisualState.xml 41 | TestResult.xml 42 | 43 | # Build Results of an ATL Project 44 | [Dd]ebugPS/ 45 | [Rr]eleasePS/ 46 | dlldata.c 47 | 48 | # Benchmark Results 49 | BenchmarkDotNet.Artifacts/ 50 | 51 | # .NET Core 52 | project.lock.json 53 | project.fragment.lock.json 54 | artifacts/ 55 | **/Properties/launchSettings.json 56 | 57 | # StyleCop 58 | StyleCopReport.xml 59 | 60 | # Files built by Visual Studio 61 | *_i.c 62 | *_p.c 63 | *_i.h 64 | *.ilk 65 | *.meta 66 | *.obj 67 | *.iobj 68 | *.pch 69 | *.pdb 70 | *.ipdb 71 | *.pgc 72 | *.pgd 73 | *.rsp 74 | *.sbr 75 | *.tlb 76 | *.tli 77 | *.tlh 78 | *.tmp 79 | *.tmp_proj 80 | *.log 81 | *.vspscc 82 | *.vssscc 83 | .builds 84 | *.pidb 85 | *.svclog 86 | *.scc 87 | 88 | # Chutzpah Test files 89 | _Chutzpah* 90 | 91 | # Visual C++ cache files 92 | ipch/ 93 | *.aps 94 | *.ncb 95 | *.opendb 96 | *.opensdf 97 | *.sdf 98 | *.cachefile 99 | *.VC.db 100 | *.VC.VC.opendb 101 | 102 | # Visual Studio profiler 103 | *.psess 104 | *.vsp 105 | *.vspx 106 | *.sap 107 | 108 | # Visual Studio Trace Files 109 | *.e2e 110 | 111 | # TFS 2012 Local Workspace 112 | $tf/ 113 | 114 | # Guidance Automation Toolkit 115 | *.gpState 116 | 117 | # ReSharper is a .NET coding add-in 118 | _ReSharper*/ 119 | *.[Rr]e[Ss]harper 120 | *.DotSettings.user 121 | 122 | # JustCode is a .NET coding add-in 123 | .JustCode 124 | 125 | # TeamCity is a build add-in 126 | _TeamCity* 127 | 128 | # DotCover is a Code Coverage Tool 129 | *.dotCover 130 | 131 | # AxoCover is a Code Coverage Tool 132 | .axoCover/* 133 | !.axoCover/settings.json 134 | 135 | # Visual Studio code coverage results 136 | *.coverage 137 | *.coveragexml 138 | 139 | # NCrunch 140 | _NCrunch_* 141 | .*crunch*.local.xml 142 | nCrunchTemp_* 143 | 144 | # MightyMoose 145 | *.mm.* 146 | AutoTest.Net/ 147 | 148 | # Web workbench (sass) 149 | .sass-cache/ 150 | 151 | # Installshield output folder 152 | [Ee]xpress/ 153 | 154 | # DocProject is a documentation generator add-in 155 | DocProject/buildhelp/ 156 | DocProject/Help/*.HxT 157 | DocProject/Help/*.HxC 158 | DocProject/Help/*.hhc 159 | DocProject/Help/*.hhk 160 | DocProject/Help/*.hhp 161 | DocProject/Help/Html2 162 | DocProject/Help/html 163 | 164 | # Click-Once directory 165 | publish/ 166 | 167 | # Publish Web Output 168 | *.[Pp]ublish.xml 169 | *.azurePubxml 170 | # Note: Comment the next line if you want to checkin your web deploy settings, 171 | # but database connection strings (with potential passwords) will be unencrypted 172 | *.pubxml 173 | *.publishproj 174 | 175 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 176 | # checkin your Azure Web App publish settings, but sensitive information contained 177 | # in these scripts will be unencrypted 178 | PublishScripts/ 179 | 180 | # NuGet Packages 181 | *.nupkg 182 | nupkgs/ 183 | # The packages folder can be ignored because of Package Restore 184 | **/[Pp]ackages/* 185 | # except build/, which is used as an MSBuild target. 186 | !**/[Pp]ackages/build/ 187 | # Uncomment if necessary however generally it will be regenerated when needed 188 | #!**/[Pp]ackages/repositories.config 189 | # NuGet v3's project.json files produces more ignorable files 190 | *.nuget.props 191 | *.nuget.targets 192 | 193 | # Microsoft Azure Build Output 194 | csx/ 195 | *.build.csdef 196 | 197 | # Microsoft Azure Emulator 198 | ecf/ 199 | rcf/ 200 | 201 | # Windows Store app package directories and files 202 | AppPackages/ 203 | BundleArtifacts/ 204 | Package.StoreAssociation.xml 205 | _pkginfo.txt 206 | *.appx 207 | 208 | # Visual Studio cache files 209 | # files ending in .cache can be ignored 210 | *.[Cc]ache 211 | # but keep track of directories ending in .cache 212 | !*.[Cc]ache/ 213 | 214 | # Others 215 | ClientBin/ 216 | ~$* 217 | *~ 218 | *.dbmdl 219 | *.dbproj.schemaview 220 | *.jfm 221 | *.pfx 222 | *.publishsettings 223 | orleans.codegen.cs 224 | 225 | # Including strong name files can present a security risk 226 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 227 | #*.snk 228 | 229 | # Since there are multiple workflows, uncomment next line to ignore bower_components 230 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 231 | #bower_components/ 232 | 233 | # RIA/Silverlight projects 234 | Generated_Code/ 235 | 236 | # Backup & report files from converting an old project file 237 | # to a newer Visual Studio version. Backup files are not needed, 238 | # because we have git ;-) 239 | _UpgradeReport_Files/ 240 | Backup*/ 241 | UpgradeLog*.XML 242 | UpgradeLog*.htm 243 | ServiceFabricBackup/ 244 | *.rptproj.bak 245 | 246 | # SQL Server files 247 | *.mdf 248 | *.ldf 249 | *.ndf 250 | 251 | # Business Intelligence projects 252 | *.rdl.data 253 | *.bim.layout 254 | *.bim_*.settings 255 | *.rptproj.rsuser 256 | 257 | # Microsoft Fakes 258 | FakesAssemblies/ 259 | 260 | # GhostDoc plugin setting file 261 | *.GhostDoc.xml 262 | 263 | # Node.js Tools for Visual Studio 264 | .ntvs_analysis.dat 265 | node_modules/ 266 | 267 | # Visual Studio 6 build log 268 | *.plg 269 | 270 | # Visual Studio 6 workspace options file 271 | *.opt 272 | 273 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 274 | *.vbw 275 | 276 | # Visual Studio LightSwitch build output 277 | **/*.HTMLClient/GeneratedArtifacts 278 | **/*.DesktopClient/GeneratedArtifacts 279 | **/*.DesktopClient/ModelManifest.xml 280 | **/*.Server/GeneratedArtifacts 281 | **/*.Server/ModelManifest.xml 282 | _Pvt_Extensions 283 | 284 | # Paket dependency manager 285 | .paket/paket.exe 286 | paket-files/ 287 | 288 | # FAKE - F# Make 289 | .fake/ 290 | 291 | # JetBrains Rider 292 | .idea/ 293 | *.sln.iml 294 | 295 | # CodeRush 296 | .cr/ 297 | 298 | # Python Tools for Visual Studio (PTVS) 299 | __pycache__/ 300 | *.pyc 301 | 302 | # Cake - Uncomment if you are using it 303 | # tools/** 304 | # !tools/packages.config 305 | 306 | # Tabs Studio 307 | *.tss 308 | 309 | # Telerik's JustMock configuration file 310 | *.jmconfig 311 | 312 | # BizTalk build output 313 | *.btp.cs 314 | *.btm.cs 315 | *.odx.cs 316 | *.xsd.cs 317 | 318 | # OpenCover UI analysis results 319 | OpenCover/ 320 | 321 | # Azure Stream Analytics local run output 322 | ASALocalRun/ 323 | 324 | # MSBuild Binary and Structured Log 325 | *.binlog 326 | 327 | # NVidia Nsight GPU debugger configuration file 328 | *.nvuser 329 | 330 | # MFractors (Xamarin productivity tool) working folder 331 | .mfractor/ 332 | 333 | /test/EFCore.OpenEdge.FunctionalTests/appsettings.json -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/Update/BulkUpdateTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using EFCore.OpenEdge.FunctionalTests.Shared; 5 | using EFCore.OpenEdge.FunctionalTests.Shared.Models; 6 | using FluentAssertions; 7 | using Microsoft.EntityFrameworkCore; 8 | using Xunit; 9 | using Xunit.Abstractions; 10 | 11 | namespace EFCore.OpenEdge.FunctionalTests.Update 12 | { 13 | public class BulkUpdateTests : ECommerceTestBase 14 | { 15 | private readonly ITestOutputHelper _output; 16 | 17 | public BulkUpdateTests(ITestOutputHelper output) 18 | { 19 | _output = output; 20 | } 21 | 22 | #region BULK INSERT TESTS 23 | 24 | [Fact] 25 | public void CanInsert_Multiple_Customers() 26 | { 27 | using var context = CreateContext(); 28 | 29 | var customers = new List 30 | { 31 | new Customer { Id = 300, Name = "Bulk Customer 1", Email = "bulk1@example.com", Age = 25, City = "Bulk City 1", IsActive = true }, 32 | new Customer { Id = 301, Name = "Bulk Customer 2", Email = "bulk2@example.com", Age = 30, City = "Bulk City 2", IsActive = true }, 33 | new Customer { Id = 302, Name = "Bulk Customer 3", Email = "bulk3@example.com", Age = 35, City = "Bulk City 3", IsActive = false }, 34 | new Customer { Id = 303, Name = "Bulk Customer 4", Email = "bulk4@example.com", Age = 40, City = "Bulk City 4", IsActive = true }, 35 | new Customer { Id = 304, Name = "Bulk Customer 5", Email = "bulk5@example.com", Age = 45, City = "Bulk City 5", IsActive = false } 36 | }; 37 | 38 | context.Customers.AddRange(customers); 39 | var result = context.SaveChanges(); 40 | 41 | result.Should().Be(5); 42 | _output.WriteLine($"Bulk inserted {result} customers"); 43 | 44 | // Verify all customers were inserted 45 | var insertedCustomers = context.Customers.Where(c => c.Id >= 300 && c.Id <= 304).ToList(); 46 | insertedCustomers.Should().HaveCount(5); 47 | } 48 | 49 | #endregion 50 | 51 | #region BULK UPDATE TESTS 52 | 53 | [Fact] 54 | public void CanUpdate_Multiple_Customers_Status() 55 | { 56 | using var context = CreateContext(); 57 | 58 | // First, insert some customers to update 59 | var customers = new List 60 | { 61 | new Customer { Id = 400, Name = "Update Customer 1", Email = "update1@example.com", Age = 25, City = "Update City", IsActive = true }, 62 | new Customer { Id = 401, Name = "Update Customer 2", Email = "update2@example.com", Age = 30, City = "Update City", IsActive = true }, 63 | new Customer { Id = 402, Name = "Update Customer 3", Email = "update3@example.com", Age = 35, City = "Update City", IsActive = true } 64 | }; 65 | 66 | context.Customers.AddRange(customers); 67 | context.SaveChanges(); 68 | 69 | // Now update all customers in "Update City" to inactive 70 | var customersToUpdate = context.Customers.Where(c => c.City == "Update City").ToList(); 71 | foreach (var customer in customersToUpdate) 72 | { 73 | customer.IsActive = false; 74 | } 75 | 76 | var result = context.SaveChanges(); 77 | 78 | result.Should().Be(3); 79 | _output.WriteLine($"Bulk updated {result} customers to inactive"); 80 | 81 | // Verify all customers were updated 82 | var updatedCustomers = context.Customers.Where(c => c.City == "Update City").ToList(); 83 | updatedCustomers.Should().OnlyContain(c => c.IsActive == false); 84 | } 85 | 86 | #endregion 87 | 88 | #region BULK DELETE TESTS 89 | 90 | [Fact] 91 | public void CanDelete_Multiple_Customers() 92 | { 93 | using var context = CreateContext(); 94 | 95 | // First, insert some customers to delete 96 | var customers = new List 97 | { 98 | new Customer { Id = 500, Name = "Delete Customer 1", Email = "delete1@example.com", Age = 25, City = "Delete City", IsActive = true }, 99 | new Customer { Id = 501, Name = "Delete Customer 2", Email = "delete2@example.com", Age = 30, City = "Delete City", IsActive = true }, 100 | new Customer { Id = 502, Name = "Delete Customer 3", Email = "delete3@example.com", Age = 35, City = "Delete City", IsActive = true } 101 | }; 102 | 103 | context.Customers.AddRange(customers); 104 | context.SaveChanges(); 105 | 106 | // Now delete all customers from "Delete City" 107 | var customersToDelete = context.Customers.Where(c => c.City == "Delete City").ToList(); 108 | context.Customers.RemoveRange(customersToDelete); 109 | 110 | var result = context.SaveChanges(); 111 | 112 | result.Should().Be(3); 113 | _output.WriteLine($"Bulk deleted {result} customers"); 114 | 115 | // Verify all customers were deleted 116 | var remainingCustomers = context.Customers.Where(c => c.City == "Delete City").ToList(); 117 | remainingCustomers.Should().BeEmpty(); 118 | } 119 | 120 | #endregion 121 | 122 | #region MIXED BULK OPERATIONS 123 | 124 | [Fact] 125 | public void CanPerform_Mixed_Bulk_Operations() 126 | { 127 | using var context = CreateContext(); 128 | 129 | // Add some new customers 130 | var newCustomers = new List 131 | { 132 | new Customer { Id = 600, Name = "Mixed Customer 1", Email = "mixed1@example.com", Age = 25, City = "Mixed City", IsActive = true }, 133 | new Customer { Id = 601, Name = "Mixed Customer 2", Email = "mixed2@example.com", Age = 30, City = "Mixed City", IsActive = true } 134 | }; 135 | 136 | context.Customers.AddRange(newCustomers); 137 | 138 | // Update existing customers 139 | var existingCustomers = context.Customers.Where(c => c.Id >= 1 && c.Id <= 2).ToList(); 140 | foreach (var customer in existingCustomers) 141 | { 142 | customer.Age += 1; // Age everyone by 1 year 143 | } 144 | 145 | // Add new products 146 | var newProducts = new List 147 | { 148 | new Product { Id = 600, Name = "Mixed Product 1", Price = 100.00m, CategoryId = 1, Description = "Mixed product 1", InStock = true }, 149 | new Product { Id = 601, Name = "Mixed Product 2", Price = 200.00m, CategoryId = 2, Description = "Mixed product 2", InStock = true } 150 | }; 151 | 152 | context.Products.AddRange(newProducts); 153 | 154 | var result = context.SaveChanges(); 155 | 156 | result.Should().Be(6); // 2 new customers + 2 updated customers + 2 new products 157 | _output.WriteLine($"Mixed bulk operations completed with {result} total changes"); 158 | 159 | // Verify the operations 160 | var insertedCustomers = context.Customers.Where(c => c.Id >= 600 && c.Id <= 601).ToList(); 161 | insertedCustomers.Should().HaveCount(2); 162 | 163 | var insertedProducts = context.Products.Where(p => p.Id >= 600 && p.Id <= 601).ToList(); 164 | insertedProducts.Should().HaveCount(2); 165 | } 166 | 167 | #endregion 168 | } 169 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Scaffolding/Internal/OpenEdgeDatabaseModelFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Data; 4 | using System.Data.Common; 5 | using System.Data.Odbc; 6 | using System.Linq; 7 | using System.Text; 8 | using EntityFrameworkCore.OpenEdge.Extensions; 9 | using Microsoft.EntityFrameworkCore; 10 | using Microsoft.EntityFrameworkCore.Diagnostics; 11 | using Microsoft.EntityFrameworkCore.Migrations; 12 | using Microsoft.EntityFrameworkCore.Scaffolding; 13 | using Microsoft.EntityFrameworkCore.Scaffolding.Metadata; 14 | 15 | namespace EntityFrameworkCore.OpenEdge.Scaffolding.Internal 16 | { 17 | public class OpenEdgeDatabaseModelFactory : IDatabaseModelFactory 18 | { 19 | private const string DatabaseModelDefaultSchema = "pub"; 20 | private readonly IDiagnosticsLogger _logger; 21 | 22 | public OpenEdgeDatabaseModelFactory(IDiagnosticsLogger logger) 23 | { 24 | _logger = logger; 25 | } 26 | 27 | public DatabaseModel Create(string connectionString, DatabaseModelFactoryOptions options) 28 | { 29 | using (var connection = new OdbcConnection(connectionString)) 30 | { 31 | return Create(connection, options); 32 | } 33 | } 34 | 35 | public DatabaseModel Create(DbConnection connection, DatabaseModelFactoryOptions options) 36 | { 37 | var databaseModel = new DatabaseModel(); 38 | 39 | var connectionStartedOpen = connection.State == ConnectionState.Open; 40 | if (!connectionStartedOpen) 41 | { 42 | connection.Open(); 43 | } 44 | 45 | try 46 | { 47 | databaseModel.DefaultSchema = DatabaseModelDefaultSchema; 48 | 49 | GetTables(connection, null, databaseModel); 50 | 51 | return databaseModel; 52 | } 53 | finally 54 | { 55 | if (!connectionStartedOpen) 56 | { 57 | connection.Close(); 58 | } 59 | } 60 | } 61 | 62 | private void GetTables( 63 | DbConnection connection, 64 | Func tableFilter, 65 | DatabaseModel databaseModel) 66 | { 67 | using (var command = connection.CreateCommand()) 68 | { 69 | var commandText = @" 70 | SELECT 71 | t.""_File-Name"" AS 'name' 72 | FROM ""pub"".""_File"" t "; 73 | 74 | var filter = 75 | $"WHERE t.\"_File-Name\" <> '{HistoryRepository.DefaultTableName}' {(tableFilter != null ? $" AND {tableFilter("t.\"_file-name\"")}" : "")}"; 76 | 77 | command.CommandText = commandText + filter; 78 | 79 | using (var reader = command.ExecuteReader()) 80 | { 81 | while (reader.Read()) 82 | { 83 | var name = reader.GetValueOrDefault("name"); 84 | 85 | var table = new DatabaseTable 86 | { 87 | Schema = DatabaseModelDefaultSchema, 88 | Name = name 89 | }; 90 | 91 | databaseModel.Tables.Add(table); 92 | } 93 | } 94 | 95 | GetColumns(connection, filter, databaseModel); 96 | } 97 | } 98 | 99 | private void GetColumns( 100 | DbConnection connection, 101 | string tableFilter, 102 | DatabaseModel databaseModel) 103 | { 104 | using (var command = connection.CreateCommand()) 105 | { 106 | command.CommandText = new StringBuilder() 107 | .AppendLine("SELECT") 108 | .AppendLine(" f.\"_Field-Name\",") 109 | .AppendLine(" f.\"_Data-Type\",") 110 | .AppendLine(" t.\"_File-Name\" as name,") 111 | .AppendLine(" t.\"_Prime-Index\" as primeindex,") 112 | .AppendLine(" f.\"_Mandatory\",") 113 | .AppendLine(" if.\"_index-recid\" as identity,") 114 | .AppendLine(" f.\"_initial\"") 115 | .AppendLine("FROM pub.\"_field\" f") 116 | .AppendLine("INNER JOIN pub.\"_File\" t ") 117 | .AppendLine("ON t.rowid = f.\"_File-recid\"") 118 | .AppendLine("LEFT JOIN pub.\"_index-field\" if ") 119 | .AppendLine("ON if.\"_index-recid\" = t.\"_Prime-Index\" AND if.\"_field-recid\" = f.rowid") 120 | .AppendLine(tableFilter) 121 | .AppendLine("ORDER BY f.\"_Order\"") 122 | .ToString(); 123 | 124 | using (var reader = command.ExecuteReader()) 125 | { 126 | var tableColumnGroups = reader.Cast() 127 | .GroupBy( 128 | ddr => ddr.GetValueOrDefault("name")); 129 | 130 | foreach (var tableColumnGroup in tableColumnGroups) 131 | { 132 | var tableName = tableColumnGroup.Key; 133 | var table = databaseModel.Tables.Single(t => t.Schema == DatabaseModelDefaultSchema && t.Name == tableName); 134 | 135 | var primaryKey = new DatabasePrimaryKey 136 | { 137 | Table = table, 138 | Name = table + "_PK" 139 | }; 140 | 141 | table.PrimaryKey = primaryKey; 142 | 143 | foreach (var dataRecord in tableColumnGroup) 144 | { 145 | var columnName = dataRecord.GetValueOrDefault("_Field-Name"); 146 | var dataTypeName = dataRecord.GetValueOrDefault("_Data-Type"); 147 | var isNullable = !dataRecord.GetValueOrDefault("_Mandatory"); 148 | var isIdentity = dataRecord.GetValueOrDefault("identity") != null; 149 | var defaultValue = !isIdentity ? dataRecord.GetValueOrDefault("_initial") : null; 150 | 151 | var storeType = dataTypeName; 152 | if (string.IsNullOrWhiteSpace(defaultValue?.ToString())) 153 | { 154 | defaultValue = null; 155 | } 156 | 157 | table.Columns.Add(new DatabaseColumn 158 | { 159 | Table = table, 160 | Name = columnName, 161 | StoreType = storeType, 162 | IsNullable = isNullable, 163 | DefaultValueSql = defaultValue?.ToString(), 164 | ValueGenerated = default 165 | }); 166 | 167 | 168 | if (isIdentity) 169 | { 170 | var column = table.Columns.FirstOrDefault(c => c.Name == columnName) 171 | ?? table.Columns.FirstOrDefault(c => c.Name.Equals(columnName, StringComparison.OrdinalIgnoreCase)); 172 | primaryKey.Columns.Add(column); 173 | } 174 | } 175 | 176 | // OpenEdge supports having tables with no primary key 177 | if (!primaryKey.Columns.Any()) 178 | { 179 | databaseModel.Tables.Remove(table); 180 | } 181 | } 182 | } 183 | } 184 | } 185 | 186 | 187 | private static string DisplayName(string schema, string name) 188 | => (!string.IsNullOrEmpty(schema) ? schema + "." : "") + name; 189 | } 190 | } -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/Unit/TypeMapping/OpenEdgeTypeMappingSourceTests.cs: -------------------------------------------------------------------------------- 1 | using EntityFrameworkCore.OpenEdge.Storage.Internal.Mapping; 2 | using FluentAssertions; 3 | using Microsoft.EntityFrameworkCore.Storage; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Data; 7 | using System.Linq; 8 | using Microsoft.EntityFrameworkCore.Storage.Json; 9 | using Microsoft.EntityFrameworkCore.Storage.ValueConversion; 10 | using Moq; 11 | using Xunit; 12 | 13 | namespace EFCore.OpenEdge.FunctionalTests.Unit.TypeMapping 14 | { 15 | public class OpenEdgeTypeMappingSourceTests 16 | { 17 | private readonly OpenEdgeTypeMappingSource _typeMappingSource; 18 | 19 | public OpenEdgeTypeMappingSourceTests() 20 | { 21 | var valueConverterSelector = CreateValueConverterSelector(); 22 | var plugins = Enumerable.Empty(); 23 | var relationalPlugins = Enumerable.Empty(); 24 | var jsonValueReaderWriterSource = new Mock().Object; 25 | 26 | var dependencies = new TypeMappingSourceDependencies(valueConverterSelector, jsonValueReaderWriterSource, plugins); 27 | var relationalDependencies = new RelationalTypeMappingSourceDependencies(relationalPlugins); 28 | 29 | _typeMappingSource = new OpenEdgeTypeMappingSource(dependencies, relationalDependencies); 30 | } 31 | 32 | private static IValueConverterSelector CreateValueConverterSelector() 33 | { 34 | var mock = new Mock(); 35 | mock.Setup(x => x.Select(It.IsAny(), It.IsAny())) 36 | .Returns(Enumerable.Empty()); 37 | return mock.Object; 38 | } 39 | 40 | public static IEnumerable ClrTypeMappingData => 41 | new List 42 | { 43 | new object[] { typeof(int), "integer", DbType.Int32 }, 44 | new object[] { typeof(long), "bigint", null }, 45 | new object[] { typeof(short), "smallint", DbType.Int16 }, 46 | new object[] { typeof(byte), "tinyint", DbType.Byte }, 47 | new object[] { typeof(bool), "bit", null }, 48 | new object[] { typeof(DateTime), "datetime", DbType.DateTime }, 49 | new object[] { typeof(DateTimeOffset), "datetime-tz", DbType.DateTimeOffset }, 50 | new object[] { typeof(TimeSpan), "time", DbType.Time }, 51 | new object[] { typeof(decimal), "decimal", null }, 52 | new object[] { typeof(double), "double precision", null }, 53 | new object[] { typeof(float), "real", null }, 54 | new object[] { typeof(byte[]), "binary", DbType.Binary } 55 | }; 56 | 57 | [Theory] 58 | [MemberData(nameof(ClrTypeMappingData))] 59 | public void FindMapping_WithClrType_ShouldReturnCorrectMapping(Type clrType, string expectedStoreType, DbType? expectedDbType) 60 | { 61 | // Act 62 | var result = (RelationalTypeMapping) _typeMappingSource.FindMapping(clrType); 63 | 64 | // Assert 65 | result.Should().NotBeNull(); 66 | result.ClrType.Should().Be(clrType); 67 | result.StoreType.Should().Be(expectedStoreType); 68 | 69 | if (expectedDbType.HasValue) 70 | { 71 | result.DbType.Should().Be(expectedDbType.Value); 72 | } 73 | } 74 | 75 | public static IEnumerable StoreTypeMappingData => 76 | new List 77 | { 78 | // Integer types 79 | new object[] { "bigint", typeof(long) }, 80 | new object[] { "int64", typeof(long) }, 81 | new object[] { "integer", typeof(int) }, 82 | new object[] { "int", typeof(int) }, 83 | new object[] { "smallint", typeof(short) }, 84 | new object[] { "short", typeof(short) }, 85 | new object[] { "tinyint", typeof(byte) }, 86 | 87 | // Boolean types 88 | new object[] { "bit", typeof(bool) }, 89 | new object[] { "logical", typeof(bool) }, 90 | 91 | // String types 92 | new object[] { "char", typeof(string) }, 93 | new object[] { "character", typeof(string) }, 94 | new object[] { "varchar", typeof(string) }, 95 | new object[] { "char varying", typeof(string) }, 96 | new object[] { "character varying", typeof(string) }, 97 | new object[] { "text", typeof(string) }, 98 | new object[] { "clob", typeof(string) }, 99 | new object[] { "recid", typeof(string) }, 100 | 101 | // Date/Time types 102 | new object[] { "date", typeof(DateTime) }, 103 | new object[] { "datetime", typeof(DateTime) }, 104 | new object[] { "datetime2", typeof(DateTime) }, 105 | new object[] { "smalldatetime", typeof(DateTime) }, 106 | new object[] { "timestamp", typeof(DateTime) }, 107 | new object[] { "time", typeof(TimeSpan) }, 108 | new object[] { "datetime-tz", typeof(DateTimeOffset) }, 109 | new object[] { "datetimeoffset", typeof(DateTimeOffset) }, 110 | 111 | // Numeric types 112 | new object[] { "decimal", typeof(decimal) }, 113 | new object[] { "dec", typeof(decimal) }, 114 | new object[] { "numeric", typeof(decimal) }, 115 | new object[] { "money", typeof(decimal) }, 116 | new object[] { "smallmoney", typeof(decimal) }, 117 | new object[] { "real", typeof(float) }, 118 | new object[] { "float", typeof(double) }, 119 | new object[] { "double", typeof(double) }, 120 | new object[] { "double precision", typeof(double) }, 121 | 122 | // Binary types 123 | new object[] { "binary", typeof(byte[]) }, 124 | new object[] { "varbinary", typeof(byte[]) }, 125 | new object[] { "binary varying", typeof(byte[]) }, 126 | new object[] { "raw", typeof(byte[]) }, 127 | new object[] { "blob", typeof(byte[]) }, 128 | new object[] { "image", typeof(byte[]) } 129 | }; 130 | 131 | [Theory] 132 | [MemberData(nameof(StoreTypeMappingData))] 133 | public void FindMapping_WithStoreTypeName_ShouldReturnCorrectClrType(string storeTypeName, Type expectedClrType) 134 | { 135 | // Act 136 | var result = _typeMappingSource.FindMapping(storeTypeName); 137 | 138 | // Assert 139 | result.Should().NotBeNull(); 140 | result.ClrType.Should().Be(expectedClrType); 141 | } 142 | 143 | [Theory] 144 | [InlineData("BIGINT")] // Test case insensitivity 145 | [InlineData("VARCHAR")] 146 | [InlineData("Datetime")] 147 | public void FindMapping_WithStoreTypeName_ShouldBeCaseInsensitive(string storeTypeName) 148 | { 149 | // Act 150 | var result = _typeMappingSource.FindMapping(storeTypeName); 151 | 152 | // Assert 153 | result.Should().NotBeNull("mapping should be case insensitive"); 154 | } 155 | 156 | [Theory] 157 | [InlineData("float(15)", typeof(double))] // Should map to double by default 158 | [InlineData("real", typeof(float))] // Real should map to float 159 | [InlineData("double precision", typeof(double))] // Should map to double 160 | public void FindMapping_WithFloatTypes_ShouldSelectCorrectType(string storeType, Type expectedClrType) 161 | { 162 | // Act 163 | var result = _typeMappingSource.FindMapping(storeType); 164 | 165 | // Assert 166 | result.Should().NotBeNull(); 167 | result.ClrType.Should().Be(expectedClrType); 168 | } 169 | 170 | [Fact] 171 | public void FindMapping_WithUnknownStoreType_ShouldReturnNull() 172 | { 173 | // Act 174 | var result = _typeMappingSource.FindMapping("unknowntype"); 175 | 176 | // Assert 177 | result.Should().BeNull(); 178 | } 179 | } 180 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Update/OpenEdgeUpdateSqlGenerator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using EntityFrameworkCore.OpenEdge.Extensions; 6 | using Microsoft.EntityFrameworkCore.Metadata; 7 | using Microsoft.EntityFrameworkCore.Storage; 8 | using Microsoft.EntityFrameworkCore.Update; 9 | 10 | namespace EntityFrameworkCore.OpenEdge.Update 11 | { 12 | public class OpenEdgeUpdateSqlGenerator : UpdateSqlGenerator 13 | { 14 | public OpenEdgeUpdateSqlGenerator(UpdateSqlGeneratorDependencies dependencies) : base(dependencies) 15 | { 16 | } 17 | 18 | private bool ShouldSkipConcurrencyCheck(IColumnModification columnModification) 19 | { 20 | // Add logic here to determine when to skip concurrency checks 21 | // This might depend on column type, table configuration, etc. 22 | // For now returning this placeholder value 23 | return false; 24 | } 25 | 26 | 27 | // VALUES Clause Generation 28 | protected override void AppendValues(StringBuilder commandStringBuilder, string name, string schema, IReadOnlyList operations) 29 | { 30 | // OpenEdge preference for literals over parameters 31 | bool useLiterals = true; 32 | 33 | if (operations.Count > 0) 34 | { 35 | commandStringBuilder 36 | .Append("(") 37 | .AppendJoin( 38 | operations, 39 | SqlGenerationHelper, 40 | 41 | (sb, o, helper) => 42 | { 43 | if (useLiterals) 44 | { 45 | // Direct value embedding 46 | AppendSqlLiteral(sb, o.Value, o.Property); 47 | } 48 | else 49 | { 50 | // Use '?' rather than named parameters 51 | AppendParameter(sb, o); 52 | } 53 | }) 54 | .Append(")"); 55 | } 56 | } 57 | 58 | private void AppendParameter(StringBuilder commandStringBuilder, IColumnModification modification) 59 | { 60 | commandStringBuilder.Append(modification.IsWrite ? "?" : "DEFAULT"); 61 | } 62 | 63 | private void AppendSqlLiteral(StringBuilder commandStringBuilder, object value, IProperty property) 64 | { 65 | // Handle DateTime values with OpenEdge-specific format 66 | if (value is DateTime dateTime) 67 | { 68 | commandStringBuilder.Append($"{{ ts '{dateTime:yyyy-MM-dd HH:mm:ss}' }}"); 69 | return; 70 | } 71 | 72 | var mapping = property != null 73 | ? Dependencies.TypeMappingSource.FindMapping(property) 74 | : null; 75 | 76 | mapping ??= Dependencies.TypeMappingSource.GetMappingForValue(value); 77 | commandStringBuilder.Append(mapping.GenerateProviderValueSqlLiteral(value)); 78 | } 79 | 80 | 81 | protected override void AppendUpdateCommandHeader(StringBuilder commandStringBuilder, string name, string schema, 82 | IReadOnlyList operations) 83 | { 84 | commandStringBuilder.Append("UPDATE "); 85 | SqlGenerationHelper.DelimitIdentifier(commandStringBuilder, name, schema); 86 | commandStringBuilder.Append(" SET ") 87 | .AppendJoin( 88 | operations, 89 | SqlGenerationHelper, 90 | (sb, o, helper) => 91 | { 92 | helper.DelimitIdentifier(sb, o.ColumnName); 93 | sb.Append(" = "); 94 | if (!o.UseCurrentValueParameter) 95 | { 96 | AppendSqlLiteral(sb, o.Value, o.Property); 97 | } 98 | else 99 | { 100 | sb.Append("?"); 101 | } 102 | }); 103 | } 104 | 105 | // WHERE Clause Generation 106 | protected override void AppendWhereCondition(StringBuilder commandStringBuilder, IColumnModification columnModification, 107 | bool useOriginalValue) 108 | { 109 | // OpenEdge workaround for limited concurrency support 110 | // TODO: Check if this condition should be disabled (replaces the old AppendRowsAffectedWhereCondition and AppendIdentityWhereCondition logic) 111 | if (ShouldSkipConcurrencyCheck(columnModification)) 112 | { 113 | commandStringBuilder.Append("1 = 1"); 114 | return; 115 | } 116 | 117 | SqlGenerationHelper.DelimitIdentifier(commandStringBuilder, columnModification.ColumnName); 118 | 119 | var parameterValue = useOriginalValue 120 | ? columnModification.OriginalValue 121 | : columnModification.Value; 122 | 123 | if (parameterValue == null) 124 | { 125 | base.AppendWhereCondition(commandStringBuilder, columnModification, useOriginalValue); 126 | } 127 | else 128 | { 129 | commandStringBuilder.Append(" = "); 130 | if (!columnModification.UseCurrentValueParameter 131 | && !columnModification.UseOriginalValueParameter) 132 | { 133 | base.AppendWhereCondition(commandStringBuilder, columnModification, useOriginalValue); 134 | } 135 | else 136 | { 137 | commandStringBuilder.Append("?"); 138 | } 139 | } 140 | } 141 | 142 | // Insert SQL Generation 143 | public override ResultSetMapping AppendInsertOperation( 144 | StringBuilder commandStringBuilder, 145 | IReadOnlyModificationCommand command, 146 | int commandPosition, 147 | out bool requiresTransaction) 148 | { 149 | // TODO: Double check this?! 150 | requiresTransaction = false; 151 | 152 | var name = command.TableName; 153 | var schema = command.Schema; 154 | var operations = command.ColumnModifications; 155 | 156 | var writeOperations = operations.Where(o => o.IsWrite) 157 | .Where(o => o.ColumnName != "rowid") 158 | .ToList(); 159 | 160 | AppendInsertCommand(commandStringBuilder, name, schema, writeOperations, new List()); 161 | return ResultSetMapping.NoResults; 162 | } 163 | 164 | // Update SQL Generation 165 | public override ResultSetMapping AppendUpdateOperation( 166 | StringBuilder commandStringBuilder, 167 | IReadOnlyModificationCommand command, 168 | int commandPosition, 169 | out bool requiresTransaction) 170 | { 171 | // TODO: Double check this?! 172 | requiresTransaction = false; 173 | 174 | var name = command.TableName; 175 | var schema = command.Schema; 176 | var operations = command.ColumnModifications; 177 | 178 | var writeOperations = operations.Where(o => o.IsWrite).ToList(); 179 | var conditionOperations = operations.Where(o => o.IsCondition).ToList(); 180 | 181 | // Generate UPDATE command without RETURNING clause. EF Core internally uses sql statement like 'RETURNING 1' to verify that such operation succeeds, for example, 182 | // a query would look like: 'UPDATE products SET name = 'New Name' WHERE id = 123 RETURNING 1', however, OpenEdge does not support RETURNING clause, so we need to use a workaround to omit it 183 | AppendUpdateCommandHeader(commandStringBuilder, name, schema, writeOperations); // "UPDATE table SET column = ?" 184 | AppendWhereClause(commandStringBuilder, conditionOperations); // "WHERE condition" 185 | 186 | return ResultSetMapping.NoResults; 187 | } 188 | 189 | // Delete SQL Generation 190 | public override ResultSetMapping AppendDeleteOperation( 191 | StringBuilder commandStringBuilder, 192 | IReadOnlyModificationCommand command, 193 | int commandPosition, 194 | out bool requiresTransaction) 195 | { 196 | // TODO: Double check this?! 197 | requiresTransaction = false; 198 | 199 | var name = command.TableName; 200 | var schema = command.Schema; 201 | var conditionOperations = command.ColumnModifications.Where(o => o.IsCondition).ToList(); 202 | 203 | // Generate DELETE command without RETURNING clause. EF Core internally uses sql statement like 'RETURNING 1' to verify that such operation succeeds, for example, 204 | // a query would look like: 'UPDATE products SET name = 'New Name' WHERE id = 123 RETURNING 1', however, OpenEdge does not support RETURNING clause, so we need to use a workaround to omit it 205 | commandStringBuilder.Append("DELETE FROM "); 206 | SqlGenerationHelper.DelimitIdentifier(commandStringBuilder, name, schema); 207 | AppendWhereClause(commandStringBuilder, conditionOperations); 208 | 209 | return ResultSetMapping.NoResults; 210 | } 211 | } 212 | } -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Query/Sql/Internal/OpenEdgeSqlGenerator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Linq.Expressions; 4 | using EntityFrameworkCore.OpenEdge.Extensions; 5 | using Microsoft.EntityFrameworkCore.Query; 6 | using Microsoft.EntityFrameworkCore.Query.SqlExpressions; 7 | using Microsoft.EntityFrameworkCore.Storage; 8 | 9 | namespace EntityFrameworkCore.OpenEdge.Query.Sql.Internal 10 | { 11 | public class OpenEdgeSqlGenerator : QuerySqlGenerator 12 | { 13 | private bool _existsConditional; 14 | private readonly IRelationalTypeMappingSource _typeMappingSource; 15 | 16 | public OpenEdgeSqlGenerator( 17 | QuerySqlGeneratorDependencies dependencies, 18 | IRelationalTypeMappingSource typeMappingSource 19 | ) : base(dependencies) 20 | { 21 | _typeMappingSource = typeMappingSource; 22 | } 23 | 24 | protected override Expression VisitParameter(ParameterExpression parameterExpression) 25 | { 26 | var parameterName = Dependencies.SqlGenerationHelper.GenerateParameterName(parameterExpression.Name); 27 | 28 | // Register the parameter for later binding 29 | if (Sql.Parameters 30 | .All(p => p.InvariantName != parameterExpression.Name)) 31 | { 32 | var typeMapping 33 | = _typeMappingSource.GetMapping(parameterExpression.Type); 34 | 35 | /* 36 | * What this essentially means is that a standard SQL query like this: 37 | * WHERE Name = @p0 AND Age = @p1 38 | * 39 | * Needs to be converted to this (for OpenEdge): 40 | * WHERE Name = ? AND Age = ? 41 | * 42 | * The parameters are still tracked internally, but the SQL uses positional placeholders. 43 | */ 44 | Sql.AddParameter( 45 | parameterExpression.Name, 46 | parameterName, 47 | typeMapping, 48 | parameterExpression.Type.IsNullableType()); 49 | } 50 | 51 | // Named parameters not supported in the command text 52 | // Need to use '?' instead 53 | Sql.Append("?"); // This appears to be OpenEdge specific! 54 | 55 | return parameterExpression; 56 | } 57 | 58 | protected override Expression VisitConditional(ConditionalExpression conditionalExpression) 59 | { 60 | var visitConditional = base.VisitConditional(conditionalExpression); 61 | 62 | // OpenEdge requires that SELECT statements always include a table, 63 | // so we SELECT from the _File metaschema table that always exists, 64 | // selecting a single row that we know will always exist; the metaschema 65 | // record for the _File metaschema table itself. 66 | if (_existsConditional) 67 | Sql.Append(@" FROM pub.""_File"" f WHERE f.""_File-Name"" = '_File'"); 68 | 69 | _existsConditional = false; 70 | 71 | return visitConditional; 72 | } 73 | 74 | // TODO: Double check that this is still needed and create this functionality in an appropriate location 75 | // protected override Expression VisitExists(ExistsExpression existsExpression) 76 | // { 77 | // // Your OpenEdge-specific EXISTS logic here 78 | // // OpenEdge does not support WHEN EXISTS, only WHERE EXISTS 79 | // // We need to SELECT 1 using WHERE EXISTS, then compare 80 | // // the result to 1 to satisfy the conditional. 81 | // 82 | // // OpenEdge requires that SELECT statements always include a table, 83 | // // so we SELECT from the _File metaschema table that always exists, 84 | // // selecting a single row that we know will always exist; the metaschema 85 | // // record for the _File metaschema table itself. 86 | // Sql.AppendLine(@"(SELECT 1 FROM pub.""_File"" f WHERE f.""_File-Name"" = '_File' AND EXISTS ("); 87 | // 88 | // using (Sql.Indent()) 89 | // { 90 | // Visit(existsExpression.Subquery); 91 | // } 92 | // 93 | // Sql.Append(")) = 1"); 94 | // 95 | // _existsConditional = true; 96 | // 97 | // return existsExpression; 98 | // } 99 | 100 | protected override void GenerateTop(SelectExpression selectExpression) 101 | { 102 | // OpenEdge: TOP clause cannot be combined with OFFSET/FETCH clauses 103 | // Only use TOP if there's no limit/offset that will be handled by GenerateLimitOffset 104 | // TOP is only used when there's a limit but no offset, and we're not using OFFSET/FETCH 105 | 106 | // Don't generate TOP - let GenerateLimitOffset handle all limit/offset cases 107 | // This avoids the conflict between TOP and FETCH clauses 108 | } 109 | 110 | protected override void GenerateLimitOffset(SelectExpression selectExpression) 111 | { 112 | // https://docs.progress.com/bundle/openedge-sql-reference/page/OFFSET-and-FETCH-clauses.html 113 | if (selectExpression.Offset != null || selectExpression.Limit != null) 114 | { 115 | if (selectExpression.Offset != null) 116 | { 117 | Sql.AppendLine() 118 | .Append("OFFSET "); 119 | 120 | // OpenEdge requires literal values in OFFSET/FETCH, not parameters 121 | Visit(selectExpression.Offset); 122 | 123 | Sql.Append(" ROWS"); 124 | } 125 | 126 | if (selectExpression.Limit != null) 127 | { 128 | if (selectExpression.Offset == null) 129 | { 130 | Sql.AppendLine(); 131 | } 132 | else 133 | { 134 | Sql.Append(" "); 135 | } 136 | 137 | // Use FETCH FIRST when no offset, FETCH NEXT when there is an offset 138 | if (selectExpression.Offset == null) 139 | { 140 | Sql.Append("FETCH FIRST "); 141 | } 142 | else 143 | { 144 | Sql.Append("FETCH NEXT "); 145 | } 146 | 147 | // OpenEdge requires literal values in OFFSET/FETCH, not parameters 148 | Visit(selectExpression.Limit); 149 | 150 | Sql.Append(" ROWS ONLY"); 151 | } 152 | } 153 | } 154 | 155 | protected override Expression VisitSqlFunction(SqlFunctionExpression sqlFunctionExpression) 156 | { 157 | // Handle COUNT(*) to cast result to INT to match EF Core expectations. This ensures that 'COUNT(*)' function is wrapped inside 'CAST (... AS INT)'. 158 | // The generated SQL will now be 'CAST(COUNT(*) AS INT)' 159 | // if (string.Equals(sqlFunctionExpression.Name, "COUNT", StringComparison.OrdinalIgnoreCase)) 160 | // { 161 | // Sql.Append("CAST("); 162 | // base.VisitSqlFunction(sqlFunctionExpression); 163 | // Sql.Append(" AS INT)"); 164 | // return sqlFunctionExpression; 165 | // } 166 | 167 | return base.VisitSqlFunction(sqlFunctionExpression); 168 | } 169 | 170 | protected override Expression VisitConstant(ConstantExpression constantExpression) 171 | { 172 | // Handle DateTime values with OpenEdge-specific format 173 | if ((constantExpression.Type == typeof(DateTime) || constantExpression.Type == typeof(DateTime?)) 174 | && constantExpression.Value != null) 175 | { 176 | var dateTime = (DateTime)constantExpression.Value; 177 | Sql.Append($"{{ ts '{dateTime:yyyy-MM-dd HH:mm:ss}' }}"); 178 | } 179 | else 180 | base.VisitConstant(constantExpression); 181 | 182 | return constantExpression; 183 | } 184 | 185 | protected override Expression VisitProjection(ProjectionExpression projectionExpression) 186 | { 187 | // OpenEdge doesn't support boolean expressions directly in SELECT clauses. 188 | // They must be wrapped in CASE statements: CASE WHEN condition THEN 1 ELSE 0 END 189 | if (projectionExpression.Expression.Type == typeof(bool)) 190 | { 191 | Sql.Append("CASE WHEN "); 192 | 193 | // If it's already a comparison expression, use it as-is 194 | // Otherwise, we need to compare it with 1 (for boolean columns stored as integers) 195 | if (projectionExpression.Expression is SqlBinaryExpression) 196 | { 197 | Visit(projectionExpression.Expression); 198 | } 199 | else 200 | { 201 | Visit(projectionExpression.Expression); 202 | Sql.Append(" = 1"); 203 | } 204 | 205 | Sql.Append(" THEN 1 ELSE 0 END"); 206 | 207 | // Handle alias if present 208 | if (!string.IsNullOrEmpty(projectionExpression.Alias)) 209 | { 210 | Sql.Append(" AS "); 211 | Sql.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(projectionExpression.Alias)); 212 | } 213 | 214 | return projectionExpression; 215 | } 216 | 217 | // For non-boolean expressions, use the base implementation 218 | return base.VisitProjection(projectionExpression); 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/EFCore.OpenEdge/Query/pipeline.md: -------------------------------------------------------------------------------- 1 | EF CORE QUERY PIPELINE FOR OPENEDGE 2 | ======================================== 3 | 4 | This document outlines the journey of a LINQ query from C# code to executable OpenEdge SQL within the Entity Framework Core provider. 5 | 6 | --- 7 | 8 | ### **Phase 1: LINQ Expression Tree Creation** 9 | * **Input:** C# LINQ query. 10 | * **Output:** .NET Expression Tree. 11 | * **Process:** The C# compiler converts LINQ query syntax into a standard `Expression` tree representation. 12 | 13 | **Example:** 14 | ```csharp 15 | var customers = context.Customers 16 | .Where(c => c.Name.StartsWith("A")) 17 | .OrderBy(c => c.Id) 18 | .Skip(5) 19 | .Take(10) 20 | .ToList(); 21 | ``` 22 | 23 | --- 24 | 25 | ### **Phase 2: Query Translation (LINQ to Relational Representation)** 26 | This phase translates the generic LINQ expression tree into a relational-specific `SelectExpression` tree. It involves several components working in sequence. 27 | 28 | 1. **Query Preprocessing (`IQueryTranslationPreprocessor`)** 29 | * **Component:** EF Core's built-in preprocessor. 30 | * **Responsibility:** Normalizes the expression tree, resolves closures, and performs initial optimizations. 31 | 32 | 2. **Queryable Method Translation (`IQueryableMethodTranslatingExpressionVisitor`)** 33 | * **Component:** `OpenEdgeQueryableMethodTranslatingExpressionVisitor`. 34 | * **Responsibility:** Translates top-level LINQ methods like `Where`, `OrderBy`, `Select`, `Skip`, and `Take` into the corresponding parts of a `SelectExpression` (e.g., `Predicate`, `Orderings`, `Limit`, `Offset`). 35 | * **OpenEdge Customization:** Tracks ORDER BY context via the `IsTranslatingOrderBy` flag. This flag is crucial for coordinating with `SqlTranslatingExpressionVisitor` to prevent boolean transformations in ORDER BY clauses. 36 | 37 | 3. **Member and Method Call Translation (`IMemberTranslator` / `IMethodCallTranslator`)** 38 | * **Components:** 39 | * `OpenEdgeMemberTranslatorProvider` -> `OpenEdgeStringLengthTranslator` 40 | * `OpenEdgeMethodCallTranslatorProvider` -> `OpenEdgeStringMethodCallTranslator` 41 | * **Responsibility:** Translates specific .NET property accesses and method calls into their SQL equivalents. 42 | * `string.Length` is translated to the `LENGTH()` SQL function. 43 | * `string.StartsWith()`, `string.EndsWith()`, and `string.Contains()` are translated to SQL `LIKE` expressions. 44 | 45 | 4. **General Expression to SQL Translation (`RelationalSqlTranslatingExpressionVisitor`)** 46 | * **Component:** `OpenEdgeSqlTranslatingExpressionVisitor`. 47 | * **Responsibility:** Translates the remaining C# expressions inside the LINQ query (e.g., boolean logic, member access) into `SqlExpression` nodes. 48 | * **OpenEdge Customizations:** 49 | * **VisitMember:** Context-aware boolean handling based on SQL clause type: 50 | * In WHERE/HAVING/JOIN (predicates): Transforms `c.IsActive` → `c.IsActive = 1` 51 | * In ORDER BY: Leaves as `c.IsActive` (checks `IsTranslatingOrderBy` flag) 52 | * In SELECT: Leaves as `c.IsActive` (handled later by SqlGenerator) 53 | * **Context Tracking:** Maintains `_isInPredicateContext` and `_isInComparisonContext` flags to determine when boolean transformations are needed. 54 | 55 | 5. **Query Post-processing (`IQueryTranslationPostprocessor`)** 56 | * **Component:** `OpenEdgeQueryTranslationPostprocessor`. 57 | * **Responsibility:** Performs final optimizations on the generated `SelectExpression` tree. While the current implementation is minimal, this is the stage where a visitor like `OpenEdgeQueryExpressionVisitor` would be invoked to handle provider-specific tree manipulations, such as preventing parameterization for `Take()` and `Skip()`. 58 | 59 | --- 60 | 61 | ### **Phase 3: Parameter-Based SQL Processing** 62 | * **Input:** `SelectExpression` tree with parameter placeholders. 63 | * **Output:** `SelectExpression` tree with literal values for `OFFSET`/`FETCH` and converted boolean parameters. 64 | * **Component:** `OpenEdgeParameterBasedSqlProcessor`. 65 | * **Responsibility:** This is an intermediate stage that has access to both the query expression and the actual parameter values. 66 | * **OpenEdge Customizations:** 67 | * **OffsetValueInliningExpressionVisitor:** OpenEdge SQL requires `OFFSET` and `FETCH` clauses to use literal integer values, not parameters. This visitor finds `SqlParameterExpression` nodes for `Offset` and `Limit` and replaces them with `SqlConstantExpression` nodes containing the actual integer values. 68 | * **BooleanParameterConversionVisitor:** Converts boolean parameters to integer constants (0/1) for comparisons, since OpenEdge stores booleans as integers. 69 | 70 | --- 71 | 72 | ### **Phase 4: SQL Generation** 73 | * **Input:** The final, provider-specific `SelectExpression` tree. 74 | * **Output:** A SQL string and its associated database parameters. 75 | * **Component:** `OpenEdgeSqlGenerator`. 76 | * **Responsibility:** Traverses the `SelectExpression` tree and generates the final, executable OpenEdge SQL text. 77 | * **OpenEdge Customizations:** 78 | * **Parameters:** Replaces EF Core's named parameters (`@p0`) with positional `?` placeholders required by the OpenEdge ODBC driver (`VisitParameter`). 79 | * **Paging:** Generates the `OFFSET ROWS FETCH NEXT ROWS ONLY` clause. It intentionally skips the `TOP` clause to avoid conflicts (`GenerateLimitOffset`, `GenerateTop`). 80 | * **Boolean Projections:** Wraps boolean expressions in `SELECT` clauses with `CASE WHEN ... THEN 1 ELSE 0 END` (`VisitProjection`). 81 | * **Functions:** Casts the result of `COUNT(*)` to `INT` to align with EF Core's expectations (`VisitSqlFunction`). 82 | * **Literals:** Formats `DateTime` constants into the OpenEdge-specific `{ ts '...' }` syntax (`VisitConstant`). 83 | * **EXISTS:** Implements a workaround for `EXISTS` conditionals by selecting from the `pub."_File"` metaschema table (`VisitConditional`). 84 | 85 | --- 86 | 87 | ### **Boolean Handling Flow** 88 | 89 | OpenEdge stores boolean values as integers (0/1) and has specific requirements for how they appear in different SQL contexts. Here's how boolean expressions are handled throughout the pipeline: 90 | 91 | #### **Context-Specific Boolean Transformations** 92 | 93 | | SQL Context | Input Expression | Transformation | Component | Result | 94 | |------------|------------------|----------------|-----------|---------| 95 | | WHERE/HAVING | `c.IsActive` | Add `= 1` comparison | `SqlTranslatingExpressionVisitor.VisitMember()` | `WHERE c.IsActive = 1` | 96 | | ORDER BY | `c.IsActive` | No transformation | `SqlTranslatingExpressionVisitor.VisitMember()` (checks `IsTranslatingOrderBy`) | `ORDER BY c.IsActive` | 97 | | SELECT | `c.IsActive` | Wrap in CASE WHEN | `SqlGenerator.VisitProjection()` | `SELECT CASE WHEN c.IsActive = 1 THEN 1 ELSE 0 END` | 98 | | JOIN ON | `a.IsActive` | Add `= 1` comparison | `SqlTranslatingExpressionVisitor.VisitMember()` | `ON a.IsActive = 1` | 99 | 100 | #### **The Coordination Mechanism** 101 | 102 | 1. **QueryableMethodTranslatingExpressionVisitor** sets `IsTranslatingOrderBy = true` when processing ORDER BY 103 | 2. **SqlTranslatingExpressionVisitor** receives a reference to the QueryableMethodTranslatingExpressionVisitor instance 104 | 3. **VisitMember()** checks this flag before applying transformations 105 | 4. **SqlGenerator** handles final formatting for SELECT projections 106 | 107 | This design ensures thread safety through instance-based state (not static) and proper SQL generation for OpenEdge's specific boolean requirements. 108 | 109 | --- 110 | 111 | ### **Visualized Flow** 112 | 113 | ``` 114 | ┌──────────────────┐ 115 | │ 1. LINQ Query │ e.g., .Skip(5).Take(10) 116 | └─────────┬────────┘ 117 | │ 118 | ▼ 119 | ┌─────────────────────────────────┐ 120 | │ 2. Query Translation Phase │ 121 | │ (LINQ → SelectExpression) │ 122 | │ │ 123 | │ ┌─────────────────────────────┐ │ 124 | │ │ QueryableMethodTranslator │ │ ← .Skip(5) -> Offset: 5 125 | │ │ │ │ .Take(10) -> Limit: 10 126 | │ │ │ │ .OrderBy() -> Sets IsTranslatingOrderBy 127 | │ └─────────────────────────────┘ │ 128 | │ ┌─────────────────────────────┐ │ 129 | │ │ MethodCallTranslator │ │ ← .StartsWith("A") -> LIKE 'A%' 130 | │ └─────────────────────────────┘ │ 131 | │ ┌─────────────────────────────┐ │ 132 | │ │ SqlTranslatingVisitor │ │ ← WHERE: c.IsActive -> c.IsActive = 1 133 | │ │ │ │ ORDER BY: c.IsActive -> c.IsActive 134 | │ └─────────────────────────────┘ │ 135 | └─────────┬───────────────────────┘ 136 | │ 137 | ▼ 138 | ┌─────────────────────────────────┐ 139 | │ 3. Parameter-Based SQL Phase │ 140 | │ (Parameter Value Inlining) │ 141 | │ │ 142 | │ ┌─────────────────────────────┐ │ 143 | │ │ OpenEdgeParameterBasedSql- │ │ ← Has access to parameter values. 144 | │ │ Processor │ │ ← Finds OFFSET/FETCH parameters. 145 | │ │ └─ OffsetValueInlining- │ │ ← Replaces parameter with literal. 146 | │ │ ExpressionVisitor │ │ ← Offset: (param p0) -> Offset: (const 5) 147 | │ └─────────────────────────────┘ │ 148 | └─────────┬───────────────────────┘ 149 | │ 150 | ▼ 151 | ┌─────────────────────────────────┐ 152 | │ 4. SQL Generation Phase │ 153 | │ (SelectExpression → SQL Text) │ 154 | │ │ 155 | │ ┌─────────────────────────────┐ │ 156 | │ │ OpenEdgeSqlGenerator │ │ ← Main SQL generator 157 | │ │ └─ GenerateLimitOffset() │ │ ← Generates "OFFSET 5 ROWS..." 158 | │ │ └─ VisitParameter() │ │ ← Generates "?" for other params 159 | │ │ └─ VisitProjection() │ │ ← Generates "CASE WHEN..." 160 | │ └─────────────────────────────┘ │ 161 | └─────────┬───────────────────────┘ 162 | │ 163 | ▼ 164 | ┌─────────────────────────────────┐ 165 | │ 5. Final SQL String │ ← SELECT ... FROM ... OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY 166 | │ Ready for OpenEdge ODBC │ 167 | └─────────────────────────────────┘ 168 | ``` -------------------------------------------------------------------------------- /test/EFCore.OpenEdge.FunctionalTests/Query/AdvancedQueryTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using EFCore.OpenEdge.FunctionalTests.Shared; 4 | using FluentAssertions; 5 | using Xunit; 6 | using Xunit.Abstractions; 7 | 8 | namespace EFCore.OpenEdge.FunctionalTests.Query 9 | { 10 | public class AdvancedQueryTests : ECommerceTestBase 11 | { 12 | private readonly ITestOutputHelper _output; 13 | 14 | public AdvancedQueryTests(ITestOutputHelper output) 15 | { 16 | _output = output; 17 | } 18 | 19 | #region AGGREGATION TESTS 20 | 21 | [Fact] 22 | public void CanExecute_GroupBy_WithCount() 23 | { 24 | using var context = CreateContext(); 25 | 26 | var cityGroups = context.Customers 27 | .GroupBy(c => c.City) 28 | .Select(g => new { City = g.Key, Count = (long)g.Count() }) 29 | .ToList(); 30 | 31 | cityGroups.Should().NotBeNull(); 32 | } 33 | 34 | [Fact] 35 | public void CanExecute_GroupBy_WithSum() 36 | { 37 | using var context = CreateContext(); 38 | 39 | var categoryTotals = context.Products 40 | .GroupBy(p => p.CategoryId) 41 | .Select(g => new 42 | { 43 | CategoryId = g.Key, 44 | TotalValue = g.Sum(p => p.Price), 45 | ProductCount = (long)g.Count() 46 | }) 47 | .ToList(); 48 | 49 | categoryTotals.Should().NotBeNull(); 50 | } 51 | 52 | [Fact] 53 | public void CanExecute_GroupBy_WithAverage() 54 | { 55 | using var context = CreateContext(); 56 | 57 | var ageByCity = context.Customers 58 | .Where(c => !c.IsActive) 59 | .GroupBy(c => c.City) 60 | .Select(g => new 61 | { 62 | City = g.Key, 63 | AverageAge = g.Average(c => c.Age), 64 | MinAge = g.Min(c => c.Age), 65 | MaxAge = g.Max(c => c.Age) 66 | }) 67 | .ToList(); 68 | 69 | ageByCity.Should().NotBeNull(); 70 | } 71 | 72 | #endregion 73 | 74 | #region SUBQUERY TESTS 75 | 76 | [Fact] 77 | public void CanExecute_SubqueryInWhere() 78 | { 79 | using var context = CreateContext(); 80 | 81 | var averageAge = context.Customers.Average(c => c.Age); 82 | 83 | var aboveAverageCustomers = context.Customers 84 | .Where(c => c.Age > context.Customers.Average(x => x.Age)) 85 | .ToList(); 86 | 87 | _output.WriteLine($"Average age: {averageAge:F1}"); 88 | _output.WriteLine($"Customers above average: {aboveAverageCustomers.Count}"); 89 | 90 | aboveAverageCustomers.Should().NotBeNull(); 91 | } 92 | 93 | [Fact] 94 | public void CanExecute_ExistsSubquery() 95 | { 96 | using var context = CreateContext(); 97 | 98 | var customersWithOrders = context.Customers 99 | .Where(c => context.Orders.Any(o => o.CustomerId == c.Id)) 100 | .ToList(); 101 | 102 | _output.WriteLine($"Customers with orders: {customersWithOrders.Count}"); 103 | 104 | customersWithOrders.Should().NotBeNull(); 105 | } 106 | 107 | [Fact] 108 | public void CanExecute_NotExistsSubquery() 109 | { 110 | using var context = CreateContext(); 111 | 112 | var customersWithoutOrders = context.Customers 113 | .Where(c => !context.Orders.Any(o => o.CustomerId == c.Id)) 114 | .ToList(); 115 | 116 | _output.WriteLine($"Customers without orders: {customersWithoutOrders.Count}"); 117 | 118 | customersWithoutOrders.Should().NotBeNull(); 119 | } 120 | 121 | #endregion 122 | 123 | #region STRING OPERATIONS 124 | 125 | [Fact] 126 | public void CanExecute_StringContains() 127 | { 128 | using var context = CreateContext(); 129 | 130 | var customersWithJohnInName = context.Customers 131 | .Where(c => c.Name.Contains("John")) 132 | .ToList(); 133 | 134 | _output.WriteLine($"Customers with 'John' in name: {customersWithJohnInName.Count}"); 135 | 136 | customersWithJohnInName.Should().NotBeNull(); 137 | } 138 | 139 | 140 | [Fact] 141 | public void CanExecute_StringStartsWith() 142 | { 143 | using var context = CreateContext(); 144 | 145 | var customersStartingWithJ = context.Customers 146 | .Where(c => c.Name.StartsWith("J")) 147 | .ToList(); 148 | 149 | _output.WriteLine($"Customers with names starting with 'J': {customersStartingWithJ.Count}"); 150 | 151 | customersStartingWithJ.Should().NotBeNull(); 152 | } 153 | 154 | [Fact] 155 | public void CanExecute_StringEndsWith() 156 | { 157 | using var context = CreateContext(); 158 | 159 | var emailsEndingWithCom = context.Customers 160 | .Where(c => c.Email.EndsWith(".com")) 161 | .ToList(); 162 | 163 | _output.WriteLine($"Customers with .com emails: {emailsEndingWithCom.Count}"); 164 | 165 | emailsEndingWithCom.Should().NotBeNull(); 166 | } 167 | 168 | [Fact] 169 | public void CanExecute_StringLength() 170 | { 171 | using var context = CreateContext(); 172 | 173 | var customersWithLongNames = context.Customers 174 | .Where(c => c.Name.Length > 10) 175 | .ToList(); 176 | 177 | _output.WriteLine($"Customers with names longer than 10 characters: {customersWithLongNames.Count}"); 178 | 179 | customersWithLongNames.Should().NotBeNull(); 180 | } 181 | 182 | #endregion 183 | 184 | #region MATH OPERATIONS 185 | 186 | [Fact] 187 | public void CanExecute_MathOperations() 188 | { 189 | using var context = CreateContext(); 190 | 191 | var priceCalculations = context.Products 192 | .Select(p => new 193 | { 194 | p.Name, 195 | OriginalPrice = p.Price, 196 | DiscountedPrice = p.Price * 0.9m, 197 | RoundedPrice = Math.Round(p.Price, 0), 198 | AbsolutePrice = Math.Abs(p.Price - 100) 199 | }) 200 | .ToList(); 201 | 202 | priceCalculations.Should().NotBeNull(); 203 | } 204 | 205 | #endregion 206 | 207 | #region DATE OPERATIONS 208 | 209 | [Fact] 210 | public void CanExecute_DateOperations() 211 | { 212 | using var context = CreateContext(); 213 | 214 | // Calculate the date threshold on the client side 215 | var thirtyDaysAgo = DateOnly.FromDateTime(DateTime.Now).AddDays(-30); 216 | 217 | var recentOrders = context.Orders 218 | .Where(o => o.OrderDate >= thirtyDaysAgo) 219 | .Select(o => new 220 | { 221 | o.Id, 222 | o.OrderDate 223 | }) 224 | .ToList(); 225 | 226 | _output.WriteLine($"Recent orders (last 30 days): {recentOrders.Count}"); 227 | 228 | recentOrders.Should().NotBeNull(); 229 | } 230 | 231 | #endregion 232 | 233 | #region PAGING AND SORTING 234 | 235 | [Fact] 236 | public void CanExecute_Skip_Take() 237 | { 238 | using var context = CreateContext(); 239 | 240 | var pagedCustomers = context.Customers 241 | .OrderBy(c => c.Name) 242 | .Skip(5) 243 | .Take(10) 244 | .ToList(); 245 | 246 | _output.WriteLine($"Retrieved page of {pagedCustomers.Count} customers (skipped 5, took 10)"); 247 | 248 | pagedCustomers.Should().NotBeNull(); 249 | pagedCustomers.Count.Should().BeLessOrEqualTo(10); 250 | } 251 | 252 | [Fact] 253 | public void CanExecute_Top_Without_Skip() 254 | { 255 | using var context = CreateContext(); 256 | 257 | var topCustomers = context.Customers 258 | .OrderByDescending(c => c.Age) 259 | .Take(5) 260 | .ToList(); 261 | 262 | _output.WriteLine($"Top 5 oldest customers retrieved"); 263 | 264 | topCustomers.Should().NotBeNull(); 265 | topCustomers.Count.Should().BeLessOrEqualTo(5); 266 | } 267 | 268 | [Fact] 269 | public void CanExecute_Multiple_OrderBy() 270 | { 271 | using var context = CreateContext(); 272 | 273 | var sortedCustomers = context.Customers 274 | .OrderBy(c => c.City) 275 | .ThenByDescending(c => c.Age) 276 | .ThenBy(c => c.Name) 277 | .ToList(); 278 | 279 | _output.WriteLine($"Customers sorted by City (asc), Age (desc), Name (asc): {sortedCustomers.Count}"); 280 | 281 | sortedCustomers.Should().NotBeNull(); 282 | } 283 | 284 | #endregion 285 | 286 | #region CASE/CONDITIONAL OPERATIONS 287 | 288 | [Fact] 289 | public void CanExecute_ConditionalSelect() 290 | { 291 | using var context = CreateContext(); 292 | 293 | var customerCategories = context.Customers 294 | .Select(c => new 295 | { 296 | c.Name, 297 | c.Age, 298 | AgeCategory = c.Age < 30 ? "Young" : c.Age < 50 ? "Middle-aged" : "Senior", 299 | IsAdult = c.Age >= 18 300 | }) 301 | .ToList(); 302 | 303 | _output.WriteLine($"Customer age categories for {customerCategories.Count} customers"); 304 | 305 | customerCategories.Should().NotBeNull(); 306 | } 307 | 308 | #endregion 309 | 310 | #region NULL HANDLING 311 | 312 | [Fact] 313 | public void CanExecute_NullChecks() 314 | { 315 | using var context = CreateContext(); 316 | 317 | var customersWithEmail = context.Customers 318 | .Where(c => c.Email != null && c.Email != "") 319 | .ToList(); 320 | 321 | var customersWithoutEmail = context.Customers 322 | .Where(c => c.Email == null || c.Email == "") 323 | .ToList(); 324 | 325 | _output.WriteLine($"Customers with email: {customersWithEmail.Count}"); 326 | _output.WriteLine($"Customers without email: {customersWithoutEmail.Count}"); 327 | 328 | customersWithEmail.Should().NotBeNull(); 329 | customersWithoutEmail.Should().NotBeNull(); 330 | } 331 | 332 | #endregion 333 | } 334 | } 335 | --------------------------------------------------------------------------------