From 869554d7d2873b2abe302f3351ef6a7d0aea1213 Mon Sep 17 00:00:00 2001 From: Alexander Zabluda Date: Tue, 8 Sep 2026 00:41:41 +0200 Subject: [PATCH 1/2] Un-skip the 14 correlated queryable-function tests, and create what they need Committed red on purpose, so the fix that follows is measured against a failing suite rather than asserted. All 14 carry [NotSupportedOnFirebirdFact]. The store supports every one of them; only the generated SQL is malformed, which is #1277. A correlated queryable function reaches the store as a LATERAL derived table, and LATERAL is Firebird 4 and later, so both the tests and the objects they need are gated on ServerLessThan4(). The gate is the same early return the Associations and ComplexType tests here already use, behind one helper rather than repeated fourteen times. The fixture guard is required rather than tidy. "GetCustomerOrderCountByYearOnlyFrom2000" is 39 characters and Firebird 3 caps identifiers at 31, so creating it there fails the seed with "Name longer than database column size" and takes every test in the class down with it, including ones that pass today. That second gap was hidden behind the first. The fixture never created "AddValues", "GetCustomerOrderCountByYear" or "GetCustomerOrderCountByYearOnlyFrom2000", and nothing noticed, because every test that uses them was skipped. They are ported from the definitions in EF Core's own UdfDbFunctionSqlServerTests fixture, in Firebird form: the two table-valued ones become selectable procedures, as the two already in this fixture are, and year(...) becomes extract(year from ...). UdfDbFunctionFbTests on Firebird 5.0.3: Passed 82 -> 84, Failed 0 -> 12, Skipped 24 -> 10, Total 106. Twelve fail at the LATERAL source with the store's own parse error: FbException: Dynamic SQL Error Token unknown - line 3, column 14 Two pass with no product change at all, so their skips were already stale: QF_Select_Correlated_Subquery_In_Anonymous QF_Correlated_Func_Call_With_Navigation Co-Authored-By: Claude Opus 5 --- .../Query/UdfDbFunctionFbTests.cs | 149 +++++++++++------- 1 file changed, 93 insertions(+), 56 deletions(-) diff --git a/src/FirebirdSql.EntityFrameworkCore.Firebird.FunctionalTests/Query/UdfDbFunctionFbTests.cs b/src/FirebirdSql.EntityFrameworkCore.Firebird.FunctionalTests/Query/UdfDbFunctionFbTests.cs index c524c8b30..c0afc2179 100644 --- a/src/FirebirdSql.EntityFrameworkCore.Firebird.FunctionalTests/Query/UdfDbFunctionFbTests.cs +++ b/src/FirebirdSql.EntityFrameworkCore.Firebird.FunctionalTests/Query/UdfDbFunctionFbTests.cs @@ -33,23 +33,17 @@ public UdfDbFunctionFbTests(Fb fixture) : base(fixture) { } - [NotSupportedOnFirebirdFact] + [Fact] public override void QF_CrossApply_Correlated_Select_Anonymous() - { - base.QF_CrossApply_Correlated_Select_Anonymous(); - } + => RunIfLateralIsSupported(base.QF_CrossApply_Correlated_Select_Anonymous); - [NotSupportedOnFirebirdFact] + [Fact] public override void QF_OuterApply_Correlated_Select_QF() - { - base.QF_OuterApply_Correlated_Select_QF(); - } + => RunIfLateralIsSupported(base.QF_OuterApply_Correlated_Select_QF); - [NotSupportedOnFirebirdFact] + [Fact] public override void Udf_with_argument_being_comparison_of_nullable_columns() - { - base.Udf_with_argument_being_comparison_of_nullable_columns(); - } + => RunIfLateralIsSupported(base.Udf_with_argument_being_comparison_of_nullable_columns); [Fact] public override void QF_Select_Correlated_Subquery_In_Anonymous_MultipleCollections() @@ -57,71 +51,49 @@ public override void QF_Select_Correlated_Subquery_In_Anonymous_MultipleCollecti base.QF_Select_Correlated_Subquery_In_Anonymous_MultipleCollections(); } - [NotSupportedOnFirebirdFact] + [Fact] public override void QF_CrossApply_Correlated_Select_Result() - { - base.QF_CrossApply_Correlated_Select_Result(); - } + => RunIfLateralIsSupported(base.QF_CrossApply_Correlated_Select_Result); - [NotSupportedOnFirebirdFact] + [Fact] public override void QF_Select_Correlated_Subquery_In_Anonymous() - { - base.QF_Select_Correlated_Subquery_In_Anonymous(); - } + => RunIfLateralIsSupported(base.QF_Select_Correlated_Subquery_In_Anonymous); - [NotSupportedOnFirebirdFact] + [Fact] public override void QF_Correlated_Func_Call_With_Navigation() - { - base.QF_Correlated_Func_Call_With_Navigation(); - } + => RunIfLateralIsSupported(base.QF_Correlated_Func_Call_With_Navigation); - [NotSupportedOnFirebirdFact] + [Fact] 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(); - } + => RunIfLateralIsSupported(base.QF_Select_Correlated_Direct_With_Function_Query_Parameter_Correlated_In_Anonymous); - [NotSupportedOnFirebirdFact] + [Fact] public override void QF_OuterApply_Correlated_Select_Entity() - { - base.QF_OuterApply_Correlated_Select_Entity(); - } + => RunIfLateralIsSupported(base.QF_OuterApply_Correlated_Select_Entity); - [NotSupportedOnFirebirdFact] + [Fact] public override void QF_Correlated_Nested_Func_Call() - { - base.QF_Correlated_Nested_Func_Call(); - } + => RunIfLateralIsSupported(base.QF_Correlated_Nested_Func_Call); - [NotSupportedOnFirebirdFact] + [Fact] public override void QF_OuterApply_Correlated_Select_Anonymous() - { - base.QF_OuterApply_Correlated_Select_Anonymous(); - } + => RunIfLateralIsSupported(base.QF_OuterApply_Correlated_Select_Anonymous); - [NotSupportedOnFirebirdFact] + [Fact] public override void QF_Select_Correlated_Subquery_In_Anonymous_Nested_With_QF() - { - base.QF_Select_Correlated_Subquery_In_Anonymous_Nested_With_QF(); - } + => RunIfLateralIsSupported(base.QF_Select_Correlated_Subquery_In_Anonymous_Nested_With_QF); - [NotSupportedOnFirebirdFact] + [Fact] public override void QF_Correlated_Select_In_Anonymous() - { - base.QF_Correlated_Select_In_Anonymous(); - } + => RunIfLateralIsSupported(base.QF_Correlated_Select_In_Anonymous); - [NotSupportedOnFirebirdFact] + [Fact] public override void QF_CrossApply_Correlated_Select_QF_Type() - { - base.QF_CrossApply_Correlated_Select_QF_Type(); - } + => RunIfLateralIsSupported(base.QF_CrossApply_Correlated_Select_QF_Type); - [NotSupportedOnFirebirdFact] + [Fact] public override void Udf_with_argument_being_comparison_to_null_parameter() - { - base.Udf_with_argument_being_comparison_to_null_parameter(); - } + => RunIfLateralIsSupported(base.Udf_with_argument_being_comparison_to_null_parameter); [DoesNotHaveTheDataFact] public override void QF_CrossJoin_Not_Correlated() @@ -217,6 +189,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) } + // A correlated queryable function needs LATERAL, which is Firebird 4 and later. + void RunIfLateralIsSupported(Action test) + { + var fbTestStore = (FbTestStore)Fixture.TestStore; + if (fbTestStore.ServerLessThan4()) + return; + test(); + } + public class Fb : UdfFixtureBase { protected override string StoreName { get; } = nameof(UdfDbFunctionFbTests); @@ -349,6 +330,62 @@ having count(""ProductId"") > 1 end end"); + // Used only by the FB4+ tests, and the last name is 39 characters, which FB3 rejects. + if (!((FbTestStore)TestStore).ServerLessThan4()) + { + 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 ""GetCustomerOrderCountByYearOnlyFrom2000"" (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 23c3cf59aa3f6f6450b4a9ff54c290cdbfe31f67 Mon Sep 17 00:00:00 2001 From: Alexander Zabluda Date: Mon, 7 Sep 2026 23:59:31 +0200 Subject: [PATCH 2/2] Wrap a table-valued function in a derived table after LATERAL. Fix #1277 VisitCrossApply and VisitOuterApply already know that Firebird will not take a bare source after LATERAL: both special-case a TableExpression and emit (SELECT * FROM "T") AS "t". The same branch was missing for a TableValuedFunctionExpression, so a correlated queryable function was emitted as a bare call and the statement did not parse: JOIN LATERAL "GetCustomerOrderCountByYear"("c"."Id") AS "g" ON TRUE -> Dynamic SQL Error, Token unknown It now emits the wrapped form, with the alias on the derived table so the rest of the statement keeps referring to it unchanged: JOIN LATERAL (SELECT * FROM "GetCustomerOrderCountByYear"("c"."Id")) AS "g" ON TRUE Argument rendering goes through the existing GenerateList helper rather than a new loop. UdfDbFunctionFbTests: Failed 12 -> 0, Passed 84 -> 96, Skipped 10, Total 106. No other test moved. The whole functional suite is Passed 14167, Failed 0, Skipped 1113, Total 15280, and FirebirdSql.EntityFrameworkCore.Firebird.Tests is Passed 102, Failed 0. Verified against Firebird 5.0.3. Co-Authored-By: Claude Opus 5 --- .../Query/Internal/FbQuerySqlGenerator.cs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/FirebirdSql.EntityFrameworkCore.Firebird/Query/Internal/FbQuerySqlGenerator.cs b/src/FirebirdSql.EntityFrameworkCore.Firebird/Query/Internal/FbQuerySqlGenerator.cs index 2e899cea6..8ca68fcf5 100644 --- a/src/FirebirdSql.EntityFrameworkCore.Firebird/Query/Internal/FbQuerySqlGenerator.cs +++ b/src/FirebirdSql.EntityFrameworkCore.Firebird/Query/Internal/FbQuerySqlGenerator.cs @@ -298,6 +298,12 @@ protected override Expression VisitCrossApply(CrossApplyExpression crossApplyExp .Append(AliasSeparator) .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(table.Alias)); } + else if (crossApplyExpression.Table is TableValuedFunctionExpression function) + { + // Same for a table-valued function. The alias goes on the derived table rather than + // on the call, so the rest of the statement keeps referring to it unchanged. + GenerateLateralFunction(function); + } else { Visit(crossApplyExpression.Table); @@ -325,6 +331,12 @@ protected override Expression VisitOuterApply(OuterApplyExpression outerApplyExp .Append(AliasSeparator) .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(table.Alias)); } + else if (outerApplyExpression.Table is TableValuedFunctionExpression function) + { + // Same for a table-valued function. The alias goes on the derived table rather than + // on the call, so the rest of the statement keeps referring to it unchanged. + GenerateLateralFunction(function); + } else { Visit(outerApplyExpression.Table); @@ -334,6 +346,23 @@ protected override Expression VisitOuterApply(OuterApplyExpression outerApplyExp return outerApplyExpression; } + // Firebird will not take a bare table-valued function after LATERAL either, so it is + // wrapped the same way a table is: (SELECT * FROM "Func"(args)) AS "alias". + void GenerateLateralFunction(TableValuedFunctionExpression function) + { + Sql + .Append("(SELECT * FROM ") + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(function.Name, function.Schema)) + .Append("("); + + GenerateList(function.Arguments, e => Visit(e)); + + Sql + .Append("))") + .Append(AliasSeparator) + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(function.Alias)); + } + protected override void GeneratePseudoFromClause() { Sql.Append(" FROM RDB$DATABASE");