Skip to content

GEP-26: continue the GINQ SQL/jOOQ reference implementation (from #2658) - #3155

Open
daniellansun wants to merge 8 commits into
apache:masterfrom
daniellansun:gep26-sql
Open

daniellansun wants to merge 8 commits into
apache:masterfrom
daniellansun:gep26-sql

Conversation

@daniellansun

@daniellansun daniellansun commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Continues #2658 .

Summary

Continues / Relates to #2658 (Paul King's GEP-26 reference implementation: GINQ SQL / jOOQ).

This branch rebases Paul's three reference commits onto current master, resolves drift (including verification-metadata.xml), pins checksums for the SQL provider dependencies, adds opt-in mixed-mode, finishes Phase 5 polish, pins H2 2.5.252 for ginq-sql tests, and applies a faithfulness audit so SQL results match Groovy/collection semantics where the GEP requires it.

Credit: Paul King for the GEP-26 reference implementation (#2658).

Phase 5 / mixed mode / audit highlights

  • Default remains strict; opt-in mixedMode: true.
  • Function push-down covers the GEP initial vocabulary plus concat, isEmpty, abs/floor/ceil/round, constant ranges on in.
  • Native dialect: ansi (default), h2, postgres, mysql, mariadb, sqlite.
  • Audit fixes: real division for /, null-safe contains/LIKE, empty in/!in, COALESCE(SUM(...),0), jOOQ schema.table split.
  • Tests use H2 2.5.252 only (test-only dependency).

Tests (local, last tip)

  • :groovy-ginq-sql:test — 137 passed
  • :groovy-ginq:test — 665 passed
  • Module checkstyleGate / codenarcGate for ginq + ginq-sql — passed

Out of scope / deferred (per GEP)

  • Window functions, type-driven table inference, ORM, full native dialect breadth, table(…) helper, jOOQ Record results / runtime DSLContext, async/streaming.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Several moderate active findings remain in SQL rendering and provider selection, including unsafe table-name handling, incorrect NULL membership semantics, unsupported SQLite functions, and unreliable jOOQ auto-detection.

4 open issues

Review effort: Lite

private Expression selectAll(String tableName) {
Expression dataSourceExpr = configurationExpressions.get(GinqGroovyMethods.CONF_DATA_SOURCE)
return callX(classX(SQL_GINQ_RUNTIME_TYPE), 'execute',
args(dataSourceExpr, constX('SELECT * FROM ' + tableName), new ListExpression()))
// `x in []` is false and `x !in []` is true, including when x is null
return new SqlBinary('=', new SqlLiteral(1), new SqlLiteral('!in' == op ? 1 : 0))
}
List<SqlExpr> values = elements.collect { Expression e -> translateExpr(e) }
Comment on lines +151 to +153
return "MIN";
default:
return ansiName;

String astWalkerClassName
if (CONF_PROVIDER_AUTO_SQL == provider) {
astWalkerClassName = PREFERRED_SQL_PROVIDERS.findResult { providers.get(it) }
@paulk-asert

Copy link
Copy Markdown
Contributor

I've been through this with 7.0.0-alpha-1 in mind and I think it can go in as is, as an incubating optional module, with the smaller items handled in later alphas.

Checked: :groovy-ginq:test (665) and :groovy-ginq-sql:test (207, on the #3167 tip which includes this) are green locally; the Copilot findings and Jochen's two comments are addressed at the tip; jOOQ stays compileOnly, H2 test-only, nothing new is bundled. The additive groovy-ginq changes (provider/dataSource, GinqProvider, the two GinqAstVisitor defaults) are all under the incubating provider extension point, which is the right place for them.

CI: the macOS red is the known 60-minute stall. Please rebase onto master before merging: the branch is ~40 commits behind and lacks 7155266 (the cache-scrub fix), which is exactly what failed on Windows for #3167 after a successful Gradle run.

For later alphas (none blocking):

  • MixedPlan and Untranslatable are the only public types without @Incubating.
  • A note in the guide that SQL result types can differ from in-memory ones (e.g. avg is double from H2, BigDecimal in memory).
  • GEP-26 should be updated to say mixed mode is implemented.

While comparing providers I also hit two pre-existing collection-provider bugs (denseRank() over(orderby e.prop) without partitionby NPEs, and a range frame NPEs on a null key); I'll raise those separately, they're unrelated to this PR.

@daniellansun

Copy link
Copy Markdown
Contributor Author

Thanks Paul — landed the later-alpha items in this PR and fixed the two collection-provider NPEs you hit while comparing providers:

Tip is e8244ce; CI green. Happy to rebase onto master (incl. the cache-scrub) whenever you’re ready to merge.

@blackdrag

Copy link
Copy Markdown
Contributor

(1)

from e in 'employees'
join d in 'departments' on e.deptId == d.id
select e.name.reverse(), d.name.reverse() // double use of name

This should force mixed mode. The source records are reconstructed independently, but their column labels originate from the SQL projection. If the database returns duplicate column labels within a source projection, some source information could be lost. The design assumes that JDBC metadata provides a stable representation of the original source schema. That assumption is not universally safe. JDBC drivers and database systems can differ in their treatment of labels, duplicate names, case folding and quoting. naming the columns could help, even though it requires remapping later. Also in that map __ginq_mixed_key could collide with a database column of the same name.

There is also a lot about the mapping of the operations... I think a documented list of intentional differences would help to understand not only what is mapped, but also how semantics may differ (exact semantics depend on the db of course if mapped to a db operation. Maybe something like:

Groovy operation: String.length()
SQL mapping: CHAR_LENGTH
Constraints:
  - compatible character-counting semantics
  - compatible treatment of null
  - compatible type conversion

(2)
I think there are also a lot of issues around null-handling

def wanted = null

GQL(provider: 'native-sql', dataSource: db) {
    from e in 'employees'
    where e.deptId == wanted
    select e.name
}

This will most likely result in WHERE e.deptId = ?, but it will not select employees where deptId is null in the table. isNullLiteral fails to recognize this, since this is not a null literal to begin with.
where e.deptId !in 1..3 is translated to NOT (e.deptId >= 1 AND e.deptId <= 3). For a null department ID, the comparisons evaluate to UNKNOWN, and negating UNKNOWN remains UNKNOWN. e.deptId IS NULL OR (e.deptId < 1 OR e.deptId > 3) would represent Groovy semantics better.

I am not saying this must work according to Groovy semantics, but it needs documentation when it does and when not.

(3)

def suffix = '!'

from e in 'employees'
select e.name + suffix

isStringy() does not recognize the captured string variable and silently generates arithmetic addition. The implementation depends a lot on recognizes cases like this, but cannot always do so. Maybe we should consider rejecting some constructions. Having clear limits is better than unpredictable behavior

(4)
Currently it works like this: Groovy AST -> GinqToSqlTranslator -> SQL IR -> Dialect-specific renderer -> SQL text

While reasonable, if a translation succeeds in producing a SqlFunction, a particular dialect might still not support the corresponding function. FlOOR and CEILING have been fixed, but I think the mixed-mode planner needs general mechanism for considering dialect capabilities when determining which clauses can be pushed down. Or there must be a way for the dialect renderer to fall back to non-SQL implementation.

@daniellansun

Copy link
Copy Markdown
Contributor Author

Jochen, thanks for the review. I agree with the direction: a mapping that cannot be decided should be rejected or left in memory, not emitted as a guess. The four points are handled as follows.

Mixed-mode rows. A pushed join is no longer reconstructed from the labels of alias.* on the joined result. Each source is probed, the prefix is re-run with those columns named explicitly, and the values are read back by ordinal onto that source's own labels. e.name.reverse(), d.name.reverse() therefore keeps both names. Duplicate labels within one source are rejected rather than dropped, because JDBC labels, case folding and quoting are not a stable copy of the table. The ordinal that re-aligns the join is not stored as a column, so a real column named __ginq_mixed_key stays readable.

Nulls. == and != against a null literal are still IS NULL / IS NOT NULL. Every other comparison is null-safe (IS NOT DISTINCT FROM, MySQL <=>, SQLite IS), so where e.deptId == wanted matches a null deptId when wanted is null. !in on a list or a constant range keeps null rows (IS NULL OR outside the bounds / the list). Comparisons < <= > >=, and AND / OR / NOT, stay SQL three-valued logic: a null is not ordered below every value the way Groovy would order it. The user guide now lists each mapping and where the semantics differ. The exact result of a mapped function still depends on the database.

+ and text methods. e.name + suffix is no longer arithmetic. + is concatenation only when the left side is text, or a column stands beside a string literal, and addition only when a number anchors it (e.salary + 1) or both sides are numbers. A captured variable is pushed only when its declared type is text or numeric, or it is cast ((suffix as String), (bonus as int)). Two columns are not pushed. length() and the other text methods are pushed only for a column or a value known to be text. Otherwise strict mode rejects the construct and mixed mode evaluates it in memory.

Dialects. Whether a function can be rendered is one table, shared by the translator and both renderers. SQLite does not include FLOOR or CEILING. Strict mode rejects those calls. Mixed mode leaves the clause that uses them in memory and can still push the rest. A function that is only spelled differently, such as CHAR_LENGTH as LENGTH, is still pushed. Adding another gap is an entry in that table, not a special case in the planner. ntile uses integer division, so it does not depend on FLOOR.

@testlens-app

This comment has been minimized.

paulk-asert and others added 6 commits November 15, 2026 13:22
…additional functions)

(cherry picked from commit 8a0d584)
Pin SQL provider dependency checksums and H2 2.5.252 for the ginq-sql
tests. Add opt-in mixed-mode execution and Phase 5 function/dialect
polish, and keep SQL translation faithful to Groovy results. Map
coalesce, count(distinct), and Math max/min to SQL COALESCE,
COUNT(DISTINCT), and GREATEST/LEAST (SQLite scalar MAX/MIN).

cast(expr, 'TYPE') renders CAST(expr AS TYPE) for a whitelist of SQL
types; select expr as alias stays a column alias. Scalar method
mappings, including coalesce and Math.max/min, live in
SqlFunctionRegistry; indexOf renders as a 0-based POSITION, LOCATE or
INSTR depending on the dialect. Import types so the lint gate accepts
simple names.

Mixed-mode SELECT * loads use the same plain-identifier check and
qualified-name rendering as a translated FROM. Null members of an
IN/NOT IN list literal become IS NULL / IS NOT NULL plus any non-null
values. The sqlite dialect rejects FLOOR and CEILING at render time.
provider: 'sql' prefers jooq-sql only when jOOQ loads, otherwise
native-sql; explicit jooq-sql is unchanged.

provider: 'sql' asks each candidate whether its classes load, and an
explicit provider name fails with that provider's message when they
do not. @SInCE on the GEP-26 SQL API is 7.0.0; these types were not
in the 6.0.0 release.
Both types stay package-private. Use the same incubating annotation as the other groovy-ginq-sql types.
Aggregate values keep the JDBC type the database returns. H2's AVG is a Double, while the collection provider returns BigDecimal.
denseRank() counted distinct preceding keys by comparing a null sentinel row, which dereferenced orderby e.prop. A range frame also passed a null order key to toBigDecimal. Treat a missing previous row as a new key, and resolve null keys as their own peer group in a range frame.
Name mixed-mode source columns and read them back by ordinal, and keep
the join ordinal off the row so it cannot collide with a real column.
Map == and != with null-safe comparison, and keep null rows for !in on
a list or a constant range. Push + and text methods only when the
operand kind is known. A dialect that cannot render a function leaves
that clause in memory instead of emitting SQL it cannot run.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants