EF Core (Entity Framework Core) 9 adalah ORM framework versi terbaru yang disempurnakan yang ditujukan untuk pengembangan aplikasi berbasis .NET sehingga pengembang dapat bekerja dan mengembangkan aplikasi dalam lingkungan object/class berbasis .NET. EF Core 9 memang dirancang untuk menggunakan versi .NET 9 terbaru dan memanfaatkan peningkatan performa yang didapat dari SQL Server versi terbaru, terutama fitur terbaru SQL SERVER 2022. Akan tetapi , beberapa use case penggunaan EF Core terkadang tidak sesuai ekspektasi yang kita bayangkan, karena ternyata masih banyak perusahaan kecil terutama sektor Usaha Kecil Menengah (UMKM) / Small to Medium Enterprise (SME) yang menggunakan Sql Server 2008. Apabila dilansir dari website microsoft itu sendiri , lifecycle dari SQL Server yang masih disupport di tahun 2025 ini adalah SQL Server 2016 ke atas, karena diharapkan para pengguna SQL Server telah mengupgrade atau menggunakan SQL Server terbaru untuk dapat memanfaatkan peningkatan dan fitur baru yang ada.

Gambar 1 : Lifecycle SQL Server Release – Support Year

Jika kita menggunakan EF Core 9 dengan database Sql Server 2008, besar kemungkinan terjadi error ketika dilakukan LINQ dan select data ke database. Seperti yang kita ketahui, framework ORM seperti entity framework ini bekerja dengan mentranslasikan fungsi fungsi pada .NET dan object entity .NET ke dalam perintah SELECT ke tabel database yang sudah dimappingkan dengan class entity terkait. Translasi ke dialek SQL inilah yang menjadi akar permasalahan ketika kita menggunakan SQL Server 2008 ke bawah yang sudah tidak disupport. Beberapa error terjadi antara lain

System.Data.SqlClient.SqlException (0x80131904): Incorrect syntax near 'OFFSET'. Invalid usage of the option NEXT in the FETCH statement.
Incorrect syntax near 'OFFSET'. Invalid usage of the option NEXT in the FETCH statement

Contoh tersebut error karena driver yang bertugas berinteraksi dengan SQL Server dan men-generate perintah query SQL ke database menghasilkan perintah query yang belum didukung oleh SQL Server 2008 namun justru merupakan query paling optimal jika dijalankan di SQL Server 2022. Query yang dihasilkan untuk select dan paging oleh EF Core 9, ternyata menggunakan OFFSET dan FETCH, sementara penggunakan OFFSET dan FETCH baru dikenalkan mulai SQL Server 2012.

Solusi

Opsi pertama, Langkah paling mudah adalah mengganti database server menjadi SQL Server 2022 atau yang terbaru. Tentu hal ini tidak semua bisa dilakukan , terutama jika memang belum ada opsi upgrade server di perusahaan sementara data yang masih dipakai juga lumayan besar, kecil kemungkinan menggunakan opsi SQL Server terbaru.

Opsi kedua adalah menggunakan opsi optionbuilder dengan UseRowNumberForPaging. Namun sayangnya UseRowNumberForPaging ini sudah obsolete dan tidak bisa digunakan sejak .NET Core 3.x, yang artinya opsi tersebut sudah tidak tersedia di EF Core 9 kecuali jika kita menggunakan .NET Core 2 dan EF Core versi lama.

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        var coonectionString = "Data Source=localhost\\MSSQL2008;Initial Catalog=AdventureWorks;Integrated Security=True";
        optionsBuilder.UseSqlServer(coonectionString, builder => builder.UseRowNumberForPaging());
    }

Opsi ketiga adalah mengganti class postprocessor yang digunakan untuk mentranslasikan Linq dari entity framework ke dialek SQL, kita membuat fungsi tiruan dari UseRowNumberForPaging sehingga query yang dihasilkan untuk paging akan menggunakan fungsi SQL RowNumber() alih alih menggunakan sintaks OFFSET FETCH … NEXT.

Pertama kita buat class SqlServer2008QueryTranslationPostprocessorFactory yang bertugas mengubah query SQL agar menggunakan RowNumber.

// File :SqlServer2008QueryTranslationPostprocessorFactory.cs
using System.Collections.Generic;
using System.Linq;
using System;
using System.Linq.Expressions;
using System.Reflection;
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;

namespace EF
{
    public class SqlServer2008QueryTranslationPostprocessorFactory : IQueryTranslationPostprocessorFactory
    {
        private readonly QueryTranslationPostprocessorDependencies _dependencies;
        private readonly RelationalQueryTranslationPostprocessorDependencies _relationalDependencies;
        public SqlServer2008QueryTranslationPostprocessorFactory(QueryTranslationPostprocessorDependencies dependencies, RelationalQueryTranslationPostprocessorDependencies relationalDependencies)
        {
            _dependencies = dependencies;
            _relationalDependencies = relationalDependencies;
        }

        public virtual QueryTranslationPostprocessor Create(QueryCompilationContext queryCompilationContext)
            => new SqlServer2008QueryTranslationPostprocessor(
                _dependencies,
                _relationalDependencies,
#if NET9_0_OR_GREATER
                (RelationalQueryCompilationContext)queryCompilationContext
#else
                queryCompilationContext
#endif
            );


        public class SqlServer2008QueryTranslationPostprocessor : RelationalQueryTranslationPostprocessor
        {
            public SqlServer2008QueryTranslationPostprocessor(
                QueryTranslationPostprocessorDependencies dependencies, 
                RelationalQueryTranslationPostprocessorDependencies relationalDependencies,
#if NET9_0_OR_GREATER
                RelationalQueryCompilationContext queryCompilationContext
#else
                QueryCompilationContext queryCompilationContext
#endif
                )
                : base(dependencies, relationalDependencies, queryCompilationContext)
            {
            }
            
            public override Expression Process(Expression query)
            {
                query = base.Process(query);               
#if NET9_0_OR_GREATER
                query = new Offset2RowNumberConvertVisitor(query, RelationalDependencies.SqlExpressionFactory, RelationalQueryCompilationContext.SqlAliasManager).Visit(query);
#else
                query = new Offset2RowNumberConvertVisitor(query, RelationalDependencies.SqlExpressionFactory).Visit(query);
#endif
                return query;
            }


#if NET9_0_OR_GREATER
            // new class for patch on EFCore9 .net9
            internal class Offset2RowNumberConvertVisitor(
                Expression root,
                ISqlExpressionFactory sqlExpressionFactory,
                SqlAliasManager sqlAliasManager
            ) : ExpressionVisitor
            {
                private readonly Expression root = root;
                private readonly ISqlExpressionFactory sqlExpressionFactory = sqlExpressionFactory;
                private readonly SqlAliasManager sqlAliasManager = sqlAliasManager;

                protected override Expression VisitExtension(Expression node) => node switch
                {
                    ShapedQueryExpression shapedQueryExpression => shapedQueryExpression.Update(Visit(shapedQueryExpression.QueryExpression), Visit(shapedQueryExpression.ShaperExpression)),
                    SelectExpression se => VisitSelect(se),
                    _ => base.VisitExtension(node),
                };

                private SelectExpression VisitSelect(SelectExpression selectExpression)
                {
                    // if we have no offset, we do not need to use ROW_NUMBER for offset calculations
                    if (selectExpression.Offset == null)
                    {
                        return selectExpression;
                    }
                    var isRootQuery = selectExpression == root;

                    // store offset, limit and orderings
                    var oldOffset = selectExpression.Offset;
                    var oldLimit = selectExpression.Limit;
                    var oldOrderings = selectExpression.Orderings;
                    var newOrderings = oldOrderings.Count > 0 && (oldLimit != null || selectExpression == root)
                        ? oldOrderings.ToList()
                        : new List<OrderingExpression>();

                    // remove offset and limit by creating new select expression from old one
                    // we can't use SelectExpression.Update because that breaks PushDownIntoSubquery
                    var enhancedSelect = new SelectExpression(
                        alias: null,
                        tables: new(selectExpression.Tables),
                        predicate: selectExpression.Predicate,
                        groupBy: new(selectExpression.GroupBy),
                        having: selectExpression.Having,
                        projections: new(selectExpression.Projection),
                        distinct: selectExpression.IsDistinct,
                        //orderings: isRootQuery ? new(selectExpression.Orderings) : [],
                        orderings: newOrderings,
                        offset: null,
                        limit: null,
                        tags: selectExpression.Tags,
                        annotations: null,
                        sqlAliasManager: sqlAliasManager,
                        isMutable: true
                    );
                    // set up row_number expression
                    var rowOrderings = isRootQuery || oldOrderings.Count != 0 ? 
                                        oldOrderings
                                        : [new(new SqlFragmentExpression("(SELECT 1)"), true)];
                    //var rowNumber = new RowNumberExpression([], !isRootQuery ? [new(new SqlFragmentExpression("(SELECT 1)"), true)] : oldOrderings, oldOffset.TypeMapping);
                    var rowNumber = new RowNumberExpression([], rowOrderings, oldOffset.TypeMapping);
                    enhancedSelect.AddToProjection(rowNumber);
                    enhancedSelect.PushdownIntoSubquery();

                    // restore ordering to outer select after earlier removal
                    if (isRootQuery)
                    {
                        foreach (var orderingClause in oldOrderings)
                        {
                            selectExpression.AppendOrdering(orderingClause);
                        }
                    }

                    // generate subselect rownumber access expression
                    var innerTable = enhancedSelect.Tables[0];
                    var rowNumberColname = enhancedSelect.Projection[enhancedSelect.Projection.Count - 1].Alias;
                    var rowNumberAlias = enhancedSelect.CreateColumnExpression(innerTable, rowNumberColname, typeof(int), null, false);

                    // apply offset and limit
                    var rowNumberGtOffset = sqlExpressionFactory.GreaterThan(rowNumberAlias, oldOffset);
                    enhancedSelect.ApplyPredicate(rowNumberGtOffset);
                    if (oldLimit != null)
                    {
                        if (oldOrderings.Count == 0)
                        {
                            var rowNumberLimiting = sqlExpressionFactory.LessThanOrEqual(rowNumberAlias, sqlExpressionFactory.Add(oldOffset, oldLimit));
                            enhancedSelect.ApplyPredicate(rowNumberLimiting);
                        }
                        else
                        {
                            enhancedSelect.ApplyLimit(oldLimit);
                        }
                    }

                    enhancedSelect.ApplyProjection(); // to make immutable
                    var restoredProjections = enhancedSelect.Projection
                        .Where(p => p.Alias != rowNumberColname)
                        .ToList();
                    var result = enhancedSelect.Update(
                        enhancedSelect.Tables,
                        enhancedSelect.Predicate,
                        enhancedSelect.GroupBy,
                        enhancedSelect.Having,
                        restoredProjections,
                        enhancedSelect.Orderings,
                        enhancedSelect.Offset,
                        enhancedSelect.Limit
                    );

                    // restore projection member binding lookup capabilities via reflection magic
                    var clientProjections = typeof(SelectExpression).GetField("_clientProjections", BindingFlags.NonPublic | BindingFlags.Instance);
                    clientProjections.SetValue(result, clientProjections.GetValue(selectExpression));
                    var projectionMapping = typeof(SelectExpression).GetField("_projectionMapping", BindingFlags.NonPublic | BindingFlags.Instance);
                    projectionMapping.SetValue(result, projectionMapping.GetValue(selectExpression));
                    return result;
                }
            }
#endif

#if !NET9_0_OR_GREATER
            internal class Offset2RowNumberConvertVisitor : ExpressionVisitor
            {
#if !NET5_0
                private static readonly MethodInfo GenerateOuterColumnAccessor;
                private static readonly Type TableReferenceExpressionType;
#else
                private static readonly Func<SelectExpression, SqlExpression, string, ColumnExpression> GenerateOuterColumnAccessor;
#endif
                private readonly Expression root;
                private readonly ISqlExpressionFactory sqlExpressionFactory;
                static Offset2RowNumberConvertVisitor()
                {
                    var method = typeof(SelectExpression).GetMethod("GenerateOuterColumn", BindingFlags.NonPublic | BindingFlags.Instance);

                    if (!typeof(ColumnExpression).IsAssignableFrom(method?.ReturnType))
                        throw new InvalidOperationException("SelectExpression.GenerateOuterColumn() is not found.");

#if !NET5_0
                    TableReferenceExpressionType = method.GetParameters().First().ParameterType;
                    GenerateOuterColumnAccessor = method;
#else
                     GenerateOuterColumnAccessor = (Func<SelectExpression, SqlExpression, string, ColumnExpression>)method.CreateDelegate(typeof(Func<SelectExpression, SqlExpression, string, ColumnExpression>));
#endif
                }
                public Offset2RowNumberConvertVisitor(Expression root, ISqlExpressionFactory sqlExpressionFactory)
                {
                    this.root = root;
                    this.sqlExpressionFactory = sqlExpressionFactory;
                }
                protected override Expression VisitExtension(Expression node)
                {
                    if (node is ShapedQueryExpression shapedQueryExpression)
                    {
                        return shapedQueryExpression.Update(Visit(shapedQueryExpression.QueryExpression), shapedQueryExpression.ShaperExpression);
                    }
                    if (node is SelectExpression se)
                        node = VisitSelect(se);
                    return base.VisitExtension(node);
                }
                private Expression VisitSelect(SelectExpression selectExpression)
                {
                    var oldOffset = selectExpression.Offset;
                    if (oldOffset == null)
                        return selectExpression;
                    var oldLimit = selectExpression.Limit;
                    var oldOrderings = selectExpression.Orderings;
                    var newOrderings = oldOrderings.Count > 0 && (oldLimit != null || selectExpression == root)
                        ? oldOrderings.ToList()
                        : new List<OrderingExpression>();
                     
                    selectExpression = selectExpression.Update(selectExpression.Projection.ToList(),
                                                               selectExpression.Tables.ToList(),
                                                               selectExpression.Predicate,
                                                               selectExpression.GroupBy.ToList(),
                                                               selectExpression.Having,
                                                               orderings: newOrderings,
                                                               limit: null,
                                                               offset: null);

                    
                    var rowOrderings = oldOrderings.Count != 0 ? oldOrderings
                        : new[] { new OrderingExpression(new SqlFragmentExpression("(SELECT 1)"), true) };

                    selectExpression.PushdownIntoSubquery();

                    var subQuery = (SelectExpression)selectExpression.Tables[0];
                    var projection = new RowNumberExpression(Array.Empty<SqlExpression>(), rowOrderings, oldOffset.TypeMapping);
#if !NET5_0
                    var left = GenerateOuterColumnAccessor.Invoke(subQuery
                        , new object[]
                        {
                            Activator.CreateInstance(TableReferenceExpressionType, new object[] { subQuery,subQuery.Alias! })!,
                            projection,
                            "row",
                            true
                        }) as ColumnExpression;
#else
                    var left = GenerateOuterColumnAccessor(subQuery, projection, "row");
#endif
                    selectExpression.ApplyPredicate(sqlExpressionFactory.GreaterThan(left!, oldOffset));

                    if (oldLimit != null)
                    {
                        if (oldOrderings.Count == 0)
                        {
                            selectExpression.ApplyPredicate(sqlExpressionFactory.LessThanOrEqual(left, sqlExpressionFactory.Add(oldOffset, oldLimit)));
                        }
                        else
                        {
                            selectExpression.ApplyLimit(oldLimit);
                        }
                    }
                    return selectExpression;
                }
            }
        
        
#endif
        }
    }
}

Selanjutnya, kita buat class SqlServerDbContextOptionsBuilderExtensions agar supaya bisa menggunakan UseRowNumberForPaging saat mengatur DB Options. Berikut kode SqlServerDbContextOptionsBuilderExtensions

// File: SqlServerDbContextOptionsBuilderExtensions.cs
using System;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Query; 
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace EF
{
    public static class SqlServerDbContextOptionsBuilderExtensions
    {

        public static SqlServerDbContextOptionsBuilder UseRowNumberForPaging(this SqlServerDbContextOptionsBuilder optionsBuilder)
        {
            ((IRelationalDbContextOptionsBuilderInfrastructure)optionsBuilder).OptionsBuilder
                .ReplaceService<IQueryTranslationPostprocessorFactory, SqlServer2008QueryTranslationPostprocessorFactory>();
            return optionsBuilder;
        }
    }
}

Apabila dilihat di solution view, maka di project kita seharusnya sudah ada dua file tambahan yang telah kita buat seperti pada gambar 2 berikut

Gambar 2: Penambahan class untuk mendukung SQL Server 2008 di EF Core 9

Langkah terakhir adalah memanggil dbcontext dengan parameter tambahan option yang menggunakan UseRowNumberForPaging. Perhatikan kode berikut ini

using EF;
using EF.AppDbContext;
using EF.Entities;
using EF.Models;
using Microsoft.EntityFrameworkCore;

namespace ConsoleApp123
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello, World!");

            // persiapkan connection string bisa dari membaca appsettings.json
            var connectionstring = $"Data Source=.\\MSSQL2008;Initial Catalog=AdventureWork2008;Persist Security Info=True;Integrated Security=true;MultipleActiveResultSets=False;Encrypt=False;TrustServerCertificate=False;Connection Timeout=30;";
            // create new option object
            var options = new DbContextOptionsBuilder<AppDbContext>();
            options.UseSqlServer(connectionstring, opt => opt.UseCompatibilityLevel(100).UseRowNumberForPaging());
            
            // new db context dengan parameter tambahan option
            var dbContext = new AppDbContext(options.Options);

            var salesGendut = dbContext.SalesPeople.Where(s => s.Bonus > 2000).ToList();
            Console.WriteLine("Sales dengan bonus diatas 2000 USD :");
            for (int i = 0; i < salesGendut.Count; i++)
            {
                int nomer = i + 1;
                Console.WriteLine($"{nomer}. ID {salesGendut[i].BusinessEntityID} has bonus USD {salesGendut[i].Bonus:#,##0.00}");
            }
        }
    }
}

Jika kita periksa, perubahan kode tersebut nampak jelas dimulai dari pembuatan options yang mendukung UseRowNumberForPaging dan membuat new object dari AppDbContext dengan parameter option tersebut.

 // create new option object
 var options = new DbContextOptionsBuilder<AppDbContext>();
 options.UseSqlServer(connectionstring, opt => opt.UseCompatibilityLevel(100).UseRowNumberForPaging());
 
 // new db context dengan parameter tambahan option
 var dbContext = new AppDbContext(options.Options);
,


Leave a Reply

Your email address will not be published. Required fields are marked *

Search

Welcome

Bayu Pratama R N is a lonely programmer who is very enthusiastic about .NET. He just try to live a life of a programmer life and write a blog post when he is so sick about love.

Gallery