Repository navigation
GROOVY-12724: GINQ: support static compilation and a fluent query API - #3259
daniellansun wants to merge 11 commits into
Conversation
e0efe3b to
e104389
Compare
8eaec54 to
e4287ea
Compare
blackdrag
left a comment
There was a problem hiding this comment.
Not a full review yet. I find it difficult to approach, because I miss something explaining the strategy for this implementation in more detail and concentrated. Of course I can conclude a lot from the code, but that does not make the intend clear in detail, only the implementation. In general I spotted a view things that look questionable at first glance. Like that adding of a phase operation, the local variables determination... in general most is tied to the lazy variant I would say. And it is not clear to me why you need lazy in this way either.
Also unclear is when you actually get static compilation. Or why some of the work done here cannot be left to the general infrastructure. Then there is the issue that this kind of feels like you try to extend the static compiler without extending the static compiler.
e4287ea to
da5895d
Compare
daniellansun
left a comment
There was a problem hiding this comment.
Thanks for the early notes. I added a concentrated strategy write-up at subprojects/groovy-ginq/STRATEGY.md (and aligned the user-guide CompileStatic section with it). Short answers to the points you raised:
Phase operation / local-variable walk. Both are gone on the current tip. The lowered query is no longer a synthetic ginqQuery$ method appended via CompilationUnit.addNewPhaseOperation. It stays a closure in the enclosing method. Capture of assigned locals is the normal closure-shared rule; read-only locals that must be snapshotted for a deferred call() are copied into an array when the expression runs (documented in STRATEGY.md). The hand-written local walk is removed.
Why lazy this way. Three separate deferrals, not one “lazy variant”:
- Element pull was already deferred on
Queryable(pipeline until a terminal). - Fluent
Ginq.fromrecords operators until a terminal (GinqQuery) — LINQ-to-Objects shape, not an expression tree. - Under
@CompileStatic/@TypeChecked, staticGQwraps the lowered IIFE inLazyQueryable.deferso the pipeline’scall()runs on first read (closure still created at the expression to capture locals then). DynamicGQis unchanged.LazyQueryableis only that thunk.
When you get static compilation. Same as elsewhere: StaticCompileTransformation at instruction selection. @CompileStatic type-checks and statically compiles the enclosing method; the lowered query closure is marked DYNAMIC_RESOLUTION and emitted with the dynamic writer (StaticTypesClosureWriter already has that switch). @TypeChecked type-checks without static compile. No annotation / @CompileDynamic / SKIP leave the lowered tree fully dynamic. Row/property/aggregate bodies stay on Groovy’s object protocol on purpose (maps, beans, records).
General infrastructure vs a parallel mini-compiler. Method checking, bytecode, and written-local capture stay with the existing compiler. The only compiler delta is honouring DYNAMIC_RESOLUTION on a closure so the existing dynamic writer can compile it. Type rules for rows go through GinqRowChecks registered on the annotation’s extensions member — the normal STC extension point — not a subclass of StaticTypeCheckingVisitor / StaticCompilationVisitor, and not a new compile phase.
Happy to walk any remaining spot in the STRATEGY note or the tip.
|
@daniellansun This branch does not target main? Disclaimer: These are conclusions based on the source, I did not actually execute any code. So maybe some points are incorrect. (1) @TypeChecked
class Example {
@CompileDynamic
def query(List<Map> rows) {
GQ {
from r in rows
where r.value > 10
select r.value
}
}
}The method is still type-checked. Nevertheless, GinqStaticBridge.checked() returns false, so the bridge does not install its row-checking extension or wrap the lowered query closure. The consequence is that the macro-generated code is presented to the checker without the adaptations required for dynamic GINQ row access. @TypeChecked
class Example {
@CompileStatic(TypeCheckingMode.SKIP)
def query(List<Map> rows) {
GQ {
from r in rows
select r.value
}
}
}Is a similar case. @CompileDynamic and @CompileStatic(SKIP) must not be treated as equivalent to @TypeChecked(SKIP). (2) @CompileStatic
class Example {
Object value
def query(List<Map> rows) {
GQ {
from r in rows
select r.value
}
}
String unrelated() {
value.mispelledProperty
}
}If I see right, then r.vlue would be accepted. GinqStaticBridge.attachRowChecks() attaches to the class level, which means it participates outside the GQ block. Most dynamic resolution is restricted to the marked GQ closure, but fallback handling for Object receivers is not restricted to that closure. Object getValue(Object value) {
value[0]
}instead of the method (3) int threshold = 10
GQ {
from row in rows
select {
int threshold = 5
row.value > threshold
}
}A name-based analysis has to distinguish the local threshold from the enclosing one. But the current global declared-name collection cannot represent that distinction. Similar for more complicated cases involving closure parameters, nested closures and declarations. (4) @GQ
def query(List rows) {
from r in rows
select r
}If I understand right this undergoes return type widening and changes to Queryable. Also (5) (6) (7) (8) def numbers = [1, 2, 3]
def query = Ginq.from(numbers).where { it > 1 }
def first = query.toList()
numbers.add(4)
def second = query.toList()
assert first != second // will fail?While documented, this might still be surprising, but that is how the materialization idea works. def query = GQ { ... }
def s1 = query.stream()
def s2 = query.stream()
assert s1 == s2
s1.count()
s2.count() // <- stream already consumed?
query.toList()
def s3 = query.stream()
assert s3 != s1
s3.count()
def s4 = query.stream()
assert s4 != s3
s4.count()Without a materialization stream() will return the same object, while after materialization it based off the list and a new stream is created every time. |
6e536c4 to
17381a1
Compare
Follow the type checker's own skip rules, keep row checks inside the query, snapshot read-only locals from VariableScopeVisitor, and make stream() return a fresh unconsumed stream.
17381a1 to
40f3087
Compare
|
The PR now targets master. I ran the cases from the review. Notes on each, in the same order. (1) Confirmed.
(2) Confirmed for the two methods in the review. The extension has to be registered on the class annotation, because that is the member the checker reads, but the hooks no longer treat the rest of the class as a query.
(3) The snippet with What the bridge does copy is decided by that visitor, not by a set of names. The lowered query is spliced into the method, the visitor links each reference to its declaration, and the original call is put back. A read-only local declared outside the query is snapshotted when the expression runs. A local the query assigns is shared, so the write is visible to the caller. A The snapshot is taken only when the result is deferred. (4) Confirmed. Widening was not limited to a real override. Only a (5) The whole lowered query closure is still compiled dynamically. I tried not to do that.
So the query closure is marked (6) (7) Confirmed. A member that is not a string, a class literal, or a list or array of those was left unchanged and the extension was dropped. That is now a compile error, and the member is left as written. A class literal is stored as the class name, because the checker loads strings. A list that itself contains a call is the error case, not a supported form. (8)
|
I think there is a misunderstanding. That was not supposed to be two local variables, that was supposed to be a field/property and a local variable.
I think you misunderstood the point was not that the closure is dynamic, the point was that it forces anything withing to be dynamic. It may change what another transform intended |
|
(3) The example I answered last time really was two locals, and The lowered query is spliced into the method, I ran that shape under (5) You are right that I answered a different question. The problem is not that the GINQ operators are dynamic. A concrete case: an extension's I tried not to mark the closure. Clause parameters stayed dynamic, so a map row is not inferred as a bean. A call the checker could resolve kept its target, and only an operator whose target was a stand-in became a dynamic call. The read-only snapshot was passed into the result lambda as a parameter, because a static lambda still cannot capture a local of an enclosing closure: the bytecode loads the outer class and casts it to The rest of the dynamic query did not. On the groovy-ginq suite, 71 tests failed. So the closure stays dynamic. The enclosing method does not. I have written that limitation into |
The lowered query closure stays on the static writer. A call the checker selects, including one another extension replaced, is emitted as that target. Unprovable operators, unresolved calls, and stream pipelines go through ScriptBytecodeAdapter. Parallel clause bodies use supplyClause so the row is not inferred as CompletableFuture.
A well-typed Stream call in a clause is the static method the checker selects, including a legal method-reference SAM. Comparisons are still rewritten before type checking; arithmetic stays an operator unless it cannot be proved. Primitive type arguments are boxed in the type, and a class that declares no type parameters is not given any. SourceRecord remains public and is @internal.
A number comparison stays the operator, so static compilation emits the primitive compare. A comparison the checker cannot prove still reaches ScriptBytecodeAdapter. The row type of navigate comes from the query, not from an enclosing closure parameter, and fullyResolveType stores only legal type arguments.
|
(5) Follow-up to #3259 (comment). The lowered query closure is no longer marked The A well-typed Only precise holes stay dynamic: an unresolved call, an operator the checker cannot prove, and a comparison it cannot type. A list of maps is a normal GINQ source, so
A few existing tests used typings javac would reject, for example
|
A generics placeholder is not a class that declares no type parameters, so correcting a signature must not replace T with Object. Java stubs for List.toArray keep T[] and IntFunction<T[]>.
|
|
||
| @Override | ||
| public boolean tryAdvance(Consumer<? super T> action) { | ||
| writeLock.lock(); |
There was a problem hiding this comment.
that will potentially mean a lot of lock calls
There was a problem hiding this comment.
Elements already in the replay buffer are served under the read lock. The callback runs after that lock is released. The write lock is taken only when the cursor is at the end of the buffer and the next element has to be pulled from upstream. Two concurrent readers of one stream see the same sequence, including nulls, with no lost or duplicated elements.
A class that declares no type parameters is decided from its declaration: a primary class from the formals on that node, a loaded class from Class.getTypeParameters. ResolveVisitor applies that once a use site is redirected. The signature writer no longer loads unresolved names; it omits arguments only when the declaration is already known. Primitive type arguments are not boxed in the bytecode writer. Queryable coercion materializes List directly and lets DefaultGroovyMethods coerce other collection types from that list. A one-shot replay serves buffered elements under the read lock and takes the write lock only when pulling from upstream.
A virtual bound is written under the name of its compile-time class. Copying every interface's type arguments onto that node gave Serializable the arguments of Comparable. The bound keeps arguments only for the class it is written as, and only when that count matches the declaration.
| } | ||
| if (legal == null || legal.length == 0) { | ||
| type.setGenericsTypes(null); | ||
| ClassNode outer = type.getOuterClassType(); |
There was a problem hiding this comment.
what happens for String<Integer>?
There was a problem hiding this comment.
Thanks for the question. On the current tip (c56d922), a source use site such as String<Integer> is a compile error again, the same as in 6.0.0:
The class java.lang.String<java.lang.Integer> (supplied with 1 type parameter) refers to the class java.lang.String which takes no parameters
The revision you commented on dropped type arguments in ResolveVisitor when the class declares no type parameters, so String<Integer> would have compiled as a raw String. That was a mistake on my side, and c56d922 removes the rewriting. The arguments now reach GenericsVisitor, which rejects a use site that is not a well-formed parameterized type (JLS 4.5: it is an error if the class is not generic or the number of type arguments differs from its type parameters). ClosureReturnTypeSignatureTest.nonGenericUseSiteIsRejected covers this for java.io.Serializable and for a source class.
…ed type ResolveVisitor was rewriting a resolved use site before the generics check. It dropped arguments on a class that declares none and boxed a primitive argument, so Date<String>, new Date<Calendar>() and List<int> compiled. Those arguments stay as written for GenericsVisitor. Inference and substitution still store reference arguments, and a signature still omits a count the declaration does not have.
✅ All tests passed ✅🏷️ Commit: c56d922 Learn more about TestLens at testlens.app/docs. |
|
@blackdrag @paulk-asert Thank you both for the earlier reviews. The current tip is c56d922, and CI is green. The notes so far have replies on this tip, including the |
JIRA
GROOVY-12724 — GINQ: support static compilation and a fluent query API
Summary
GQ,GQL, and@GQcan be used from a@CompileStaticor@TypeCheckedscope. The SQL-like syntax is still lowered to the collection runtime. Under static checking that lowered tree is not run when the expression is evaluated: aLazyQueryablebuilds it on the first read (toList,count, and the other terminals). Locals are copied at the expression site (same binding as dynamic GQ); sources stay closed until a terminal.Ginq.fromadds a deferred, generic fluent chain on the same in-memory runtime. Fluent closure overloads carry the element type intowhere/select/ joins / aggregates via@ClosureParamswhere inference is sound; remaining sites keep explicit parameter types.Stacked on GEP-26 SQL window work (#3167 /
gep26-sql-window).Known limits
Closurevariables still need a SAM target type for parameter inference (same as Java/C#).Queryparameters/fields cannot recover a safe element type.frominsideGQ/GQL/@GQstay dynamic by design; callers that need checked element types useGinq.from.Test
:groovy-ginq:testand:groovy-ginq-sql:testgreen locally