From d5982a1e11764c9c69c66f32b6fa10c2cfb2c4c0 Mon Sep 17 00:00:00 2001 From: Alexander Zabluda Date: Tue, 8 Sep 2026 12:56:32 +0200 Subject: [PATCH 1/2] Run the correlated queryable-function tests, and create what they need The fourteen QF_*Correlated*/QF_*Apply* tests were skipped as "Not supported on Firebird". Twelve of them need nothing Firebird-specific at all, so their overrides go away and the base tests run on every version; the two that reach the store as a lateral derived table keep a Firebird 4 gate. The fixture was missing AddValues, GetCustomerOrderCountByYear and GetCustomerOrderCountByYearOnlyFrom2000, and nothing noticed because every test that uses them was skipped. They are ported from EF Core's own UdfDbFunctionSqlServerTests fixture, in Firebird form: the two table-valued ones become selectable procedures, as the two already here are, and year(...) becomes extract(year from ...). EF Core names a queryable function after its method unless told otherwise, and GetCustomerOrderCountByYearOnlyFrom2000 is 39 characters, which Firebird 3 will not create. It is mapped to a 31-character name instead, the way GetCustWithMostOrdersAfterDate already is here. This commit is red: the twelve tests fail on a malformed statement. Co-Authored-By: Claude Opus 5 --- .../Query/UdfDbFunctionFbTests.cs | 138 ++++++++---------- 1 file changed, 64 insertions(+), 74 deletions(-) diff --git a/src/FirebirdSql.EntityFrameworkCore.Firebird.FunctionalTests/Query/UdfDbFunctionFbTests.cs b/src/FirebirdSql.EntityFrameworkCore.Firebird.FunctionalTests/Query/UdfDbFunctionFbTests.cs index c524c8b30..bc4c0cc2b 100644 --- a/src/FirebirdSql.EntityFrameworkCore.Firebird.FunctionalTests/Query/UdfDbFunctionFbTests.cs +++ b/src/FirebirdSql.EntityFrameworkCore.Firebird.FunctionalTests/Query/UdfDbFunctionFbTests.cs @@ -33,96 +33,32 @@ public UdfDbFunctionFbTests(Fb fixture) : base(fixture) { } - [NotSupportedOnFirebirdFact] - public override void QF_CrossApply_Correlated_Select_Anonymous() - { - base.QF_CrossApply_Correlated_Select_Anonymous(); - } - - [NotSupportedOnFirebirdFact] - public override void QF_OuterApply_Correlated_Select_QF() - { - base.QF_OuterApply_Correlated_Select_QF(); - } - - [NotSupportedOnFirebirdFact] - public override void Udf_with_argument_being_comparison_of_nullable_columns() - { - base.Udf_with_argument_being_comparison_of_nullable_columns(); - } - [Fact] public override void QF_Select_Correlated_Subquery_In_Anonymous_MultipleCollections() { base.QF_Select_Correlated_Subquery_In_Anonymous_MultipleCollections(); } - [NotSupportedOnFirebirdFact] - public override void QF_CrossApply_Correlated_Select_Result() - { - base.QF_CrossApply_Correlated_Select_Result(); - } - - [NotSupportedOnFirebirdFact] + [Fact] public override void QF_Select_Correlated_Subquery_In_Anonymous() { + // that needs LATERAL which is Firebird 4+ + var fbTestStore = (FbTestStore)Fixture.TestStore; + if (fbTestStore.ServerLessThan4()) + return; base.QF_Select_Correlated_Subquery_In_Anonymous(); } - [NotSupportedOnFirebirdFact] + [Fact] public override void QF_Correlated_Func_Call_With_Navigation() { + // that needs LATERAL which is Firebird 4+ + var fbTestStore = (FbTestStore)Fixture.TestStore; + if (fbTestStore.ServerLessThan4()) + return; base.QF_Correlated_Func_Call_With_Navigation(); } - [NotSupportedOnFirebirdFact] - public override void QF_Select_Correlated_Direct_With_Function_Query_Parameter_Correlated_In_Anonymous() - { - base.QF_Select_Correlated_Direct_With_Function_Query_Parameter_Correlated_In_Anonymous(); - } - - [NotSupportedOnFirebirdFact] - public override void QF_OuterApply_Correlated_Select_Entity() - { - base.QF_OuterApply_Correlated_Select_Entity(); - } - - [NotSupportedOnFirebirdFact] - public override void QF_Correlated_Nested_Func_Call() - { - base.QF_Correlated_Nested_Func_Call(); - } - - [NotSupportedOnFirebirdFact] - public override void QF_OuterApply_Correlated_Select_Anonymous() - { - base.QF_OuterApply_Correlated_Select_Anonymous(); - } - - [NotSupportedOnFirebirdFact] - public override void QF_Select_Correlated_Subquery_In_Anonymous_Nested_With_QF() - { - base.QF_Select_Correlated_Subquery_In_Anonymous_Nested_With_QF(); - } - - [NotSupportedOnFirebirdFact] - public override void QF_Correlated_Select_In_Anonymous() - { - base.QF_Correlated_Select_In_Anonymous(); - } - - [NotSupportedOnFirebirdFact] - public override void QF_CrossApply_Correlated_Select_QF_Type() - { - base.QF_CrossApply_Correlated_Select_QF_Type(); - } - - [NotSupportedOnFirebirdFact] - public override void Udf_with_argument_being_comparison_to_null_parameter() - { - base.Udf_with_argument_being_comparison_to_null_parameter(); - } - [DoesNotHaveTheDataFact] public override void QF_CrossJoin_Not_Correlated() { @@ -208,6 +144,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasName("GetCustWithMostOrdersAfterDate"); modelBuilder.HasDbFunction(typeof(UDFSqlContext).GetMethod(nameof(GetCustomerWithMostOrdersAfterDateInstance))) .HasName("GetCustWithMostOrdersAfterDate"); + modelBuilder.HasDbFunction(typeof(UDFSqlContext).GetMethod(nameof(GetCustomerOrderCountByYearOnlyFrom2000))) + .HasName("GetCustOrderCountByYearFrom2000"); modelBuilder.HasDbFunction(typeof(UDFSqlContext).GetMethod(nameof(IdentityString))) .HasSchema(null); @@ -349,6 +287,58 @@ having count(""ProductId"") > 1 end end"); + await context.Database.ExecuteSqlRawAsync( + @"create function ""AddValues"" (a int, b int) + returns int + as + begin + return :a + :b; + end"); + + await context.Database.ExecuteSqlRawAsync( + @"create procedure ""GetCustomerOrderCountByYear"" (customerId int) + returns + ( + ""CustomerId"" int not null, + ""Count"" int not null, + ""Year"" int not null + ) + as + begin + for select :customerId, count(""Id""), extract(year from ""OrderDate"") + from ""Orders"" + where ""CustomerId"" = :customerId + group by ""CustomerId"", extract(year from ""OrderDate"") + order by extract(year from ""OrderDate"") + into :""CustomerId"", :""Count"", :""Year"" do + begin + suspend; + end + end"); + + await context.Database.ExecuteSqlRawAsync( + @"create procedure ""GetCustOrderCountByYearFrom2000"" (customerId int, onlyFrom2000 boolean) + returns + ( + ""CustomerId"" int not null, + ""Count"" int not null, + ""Year"" int not null + ) + as + begin + for select :customerId, count(""Id""), extract(year from ""OrderDate"") + from ""Orders"" + where ""CustomerId"" = 1 + and (:onlyFrom2000 = false or :onlyFrom2000 is null + or (:onlyFrom2000 = true and extract(year from ""OrderDate"") = 2000)) + group by ""CustomerId"", extract(year from ""OrderDate"") + order by extract(year from ""OrderDate"") + into :""CustomerId"", :""Count"", :""Year"" do + begin + suspend; + end + end"); + await context.SaveChangesAsync(); } } From 9512e45119e2b334afd5562e7acb5a75f2682827 Mon Sep 17 00:00:00 2001 From: Alexander Zabluda Date: Tue, 8 Sep 2026 12:57:35 +0200 Subject: [PATCH 2/2] Emit a table-valued function without LATERAL. Fix #1277 VisitCrossApply and VisitOuterApply put LATERAL in front of whatever EF Core hands them. In Firebird's grammar LATERAL is a prefix on a derived table only: ::= [[AS] correlation-name] | [LATERAL] [] | ::= table-name | query-name | [package-name.]procedure-name [()] A selectable procedure is the other alternative, and its arguments may already reference streams declared earlier in the FROM clause. So a correlated queryable function needs no LATERAL - and cannot have one, which is why it did not parse. JOIN LATERAL "GetCustomerOrderCountByYear"("c"."Id") AS "g" ON TRUE -- Dynamic SQL Error, SQL error code = -104, Token unknown JOIN "GetCustomerOrderCountByYear"("c"."Id") AS "g" ON TRUE -- works, on Firebird 3 as well The two visitors now share one GenerateApplySource, which emits the call as it is emitted anywhere else and keeps LATERAL for the sources that need it. Co-Authored-By: Claude Opus 5 --- .../Query/Internal/FbQuerySqlGenerator.cs | 46 +++++++++---------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/src/FirebirdSql.EntityFrameworkCore.Firebird/Query/Internal/FbQuerySqlGenerator.cs b/src/FirebirdSql.EntityFrameworkCore.Firebird/Query/Internal/FbQuerySqlGenerator.cs index 2e899cea6..bb2f29f17 100644 --- a/src/FirebirdSql.EntityFrameworkCore.Firebird/Query/Internal/FbQuerySqlGenerator.cs +++ b/src/FirebirdSql.EntityFrameworkCore.Firebird/Query/Internal/FbQuerySqlGenerator.cs @@ -285,24 +285,8 @@ protected override void GenerateOrderings(SelectExpression selectExpression) // Copyright (c) 2002-2021, Npgsql protected override Expression VisitCrossApply(CrossApplyExpression crossApplyExpression) { - Sql.Append("JOIN LATERAL "); - - if (crossApplyExpression.Table is TableExpression table) - { - // Firebird doesn't support LATERAL JOIN over table, and it doesn't really make sense to do it - but EF Core - // will sometimes generate that. - Sql - .Append("(SELECT * FROM ") - .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(table.Name, table.Schema)) - .Append(")") - .Append(AliasSeparator) - .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(table.Alias)); - } - else - { - Visit(crossApplyExpression.Table); - } - + Sql.Append("JOIN "); + GenerateApplySource(crossApplyExpression.Table); Sql.Append(" ON TRUE"); return crossApplyExpression; } @@ -312,14 +296,28 @@ protected override Expression VisitCrossApply(CrossApplyExpression crossApplyExp // Copyright (c) 2002-2021, Npgsql protected override Expression VisitOuterApply(OuterApplyExpression outerApplyExpression) { - Sql.Append("LEFT JOIN LATERAL "); + Sql.Append("LEFT JOIN "); + GenerateApplySource(outerApplyExpression.Table); + Sql.Append(" ON TRUE"); + return outerApplyExpression; + } - if (outerApplyExpression.Table is TableExpression table) + // LATERAL is only allowed in front of a derived table, so anything that is not one has to + // become one - except a selectable procedure, which is implicitly lateral already. + void GenerateApplySource(TableExpressionBase source) + { + if (source is TableValuedFunctionExpression function) + { + // A selectable procedure takes its arguments from streams earlier in the FROM clause + // without LATERAL, so the call is emitted as it is anywhere else. + Visit(function); + } + else if (source is TableExpression table) { // Firebird doesn't support LATERAL JOIN over table, and it doesn't really make sense to do it - but EF Core // will sometimes generate that. Sql - .Append("(SELECT * FROM ") + .Append("LATERAL (SELECT * FROM ") .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(table.Name, table.Schema)) .Append(")") .Append(AliasSeparator) @@ -327,11 +325,9 @@ protected override Expression VisitOuterApply(OuterApplyExpression outerApplyExp } else { - Visit(outerApplyExpression.Table); + Sql.Append("LATERAL "); + Visit(source); } - - Sql.Append(" ON TRUE"); - return outerApplyExpression; } protected override void GeneratePseudoFromClause()