From ad49892cd81382d31f2bd2e33f43f98e60c8538e Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Fri, 2 Oct 2026 20:34:15 +0200 Subject: [PATCH 01/36] feat: make CompilationPipeline methods public for LSP accessibility --- .../java/org/rumbledb/compiler/CompilationPipeline.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/rumbledb/compiler/CompilationPipeline.java b/src/main/java/org/rumbledb/compiler/CompilationPipeline.java index 7c96296510..5a80e13ea4 100644 --- a/src/main/java/org/rumbledb/compiler/CompilationPipeline.java +++ b/src/main/java/org/rumbledb/compiler/CompilationPipeline.java @@ -33,7 +33,7 @@ import static org.rumbledb.compiler.CompilationDiagnostics.debugPrintTree; /** Orders analysis and rewrite passes. Visitors mutate annotations and may replace the module tree. */ -final class CompilationPipeline { +public final class CompilationPipeline { private CompilationPipeline() {} @@ -47,7 +47,8 @@ static LibraryModule prepareLibraryModuleFromLocation( return prepareLibraryModule(source.query(), source.systemId(), importingContext, configuration); } - static MainModule compileMainModule( + /** Compiles main-module text with the supplied source URI and annotates the returned tree. */ + public static MainModule compileMainModule( String query, URI uri, CompilationConfiguration compilationConfiguration, @@ -75,7 +76,7 @@ static LibraryModule prepareLibraryModule( } /** Standalone library analysis for language-server callers; deliberately does not run the main-module pipeline. */ - static LibraryModule analyzeLibraryModule(String query, URI uri, RumbleConfiguration configuration) { + public static LibraryModule analyzeLibraryModule(String query, URI uri, RumbleConfiguration configuration) { StaticContext importingContext = ModuleParser.createModuleContext(uri, configuration); LibraryModule module = prepareLibraryModule(query, uri, importingContext, new CompilationConfiguration(configuration)); From e3746897ae035bfc0a5bef1e24509a3efddd09f8 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Fri, 2 Oct 2026 21:15:50 +0200 Subject: [PATCH 02/36] refactor: drop VisitorHelper class --- src/main/java/org/rumbledb/api/Rumble.java | 24 ++-- .../compiler/CompilationPipeline.java | 16 +++ .../compiler/DynamicContextVisitor.java | 25 +--- .../compiler/RuntimeIteratorVisitor.java | 14 ++ .../org/rumbledb/compiler/VisitorHelpers.java | 130 ------------------ src/test/java/iq/FrontendTests.java | 42 +++--- .../iq/SequentialClassificationTests.java | 6 +- .../schema/XmlSchemaCatalogLoaderTest.java | 4 +- 8 files changed, 78 insertions(+), 183 deletions(-) delete mode 100644 src/main/java/org/rumbledb/compiler/VisitorHelpers.java diff --git a/src/main/java/org/rumbledb/api/Rumble.java b/src/main/java/org/rumbledb/api/Rumble.java index 28fab94e75..3549f99262 100644 --- a/src/main/java/org/rumbledb/api/Rumble.java +++ b/src/main/java/org/rumbledb/api/Rumble.java @@ -20,7 +20,10 @@ import org.apache.spark.sql.SparkSession; -import org.rumbledb.compiler.VisitorHelpers; +import org.rumbledb.compiler.CompilationPipeline; +import org.rumbledb.compiler.DynamicContextVisitor; +import org.rumbledb.compiler.EffectiveConfigurationVisitor; +import org.rumbledb.compiler.RuntimeIteratorVisitor; import org.rumbledb.config.CompilationConfiguration; import org.rumbledb.context.DynamicContext; import org.rumbledb.expressions.module.MainModule; @@ -109,7 +112,8 @@ public SequenceOfItems runQuery(String query) { */ public SequenceOfItems runQuery(String query, URI baseUri) { org.rumbledb.bindings.ExternalBindings bindings = org.rumbledb.bindings.ExternalBindings.empty(); - MainModule mainModule = VisitorHelpers.parseMainModule(query, baseUri, this.compilationConfiguration, bindings); + MainModule mainModule = + CompilationPipeline.compileMainModule(query, baseUri, this.compilationConfiguration, bindings); return createSequence(mainModule, bindings); } @@ -133,7 +137,8 @@ public SequenceOfItems runQuery(String query, ExternalBindings bindings) { */ public SequenceOfItems runQuery(String query, org.rumbledb.bindings.ExternalBindings bindings) { org.rumbledb.bindings.ExternalBindings snapshot = bindings.snapshot(); - MainModule mainModule = VisitorHelpers.parseMainModuleFromQuery(query, this.compilationConfiguration, snapshot); + MainModule mainModule = + CompilationPipeline.compileMainModuleFromQuery(query, this.compilationConfiguration, snapshot); return createSequence(mainModule, snapshot); } @@ -186,16 +191,17 @@ public SequenceOfItems runQuery(URI location, ExternalBindings bindings) throws public SequenceOfItems runQuery(URI location, org.rumbledb.bindings.ExternalBindings bindings) throws IOException { org.rumbledb.bindings.ExternalBindings snapshot = bindings.snapshot(); MainModule mainModule = - VisitorHelpers.parseMainModuleFromLocation(location, this.compilationConfiguration, snapshot); + CompilationPipeline.compileMainModuleFromLocation(location, this.compilationConfiguration, snapshot); return createSequence(mainModule, snapshot); } private SequenceOfItems createSequence(MainModule mainModule, org.rumbledb.bindings.ExternalBindings bindings) { - var effectiveConfiguration = VisitorHelpers.getEffectiveConfiguration( - mainModule, this.compilationConfiguration.runtimeConfiguration().toBuilder()); + var effectiveConfiguration = new EffectiveConfigurationVisitor() + .getEffectiveConfiguration( + mainModule, this.compilationConfiguration.runtimeConfiguration().toBuilder()); DynamicContext dynamicContext = - VisitorHelpers.createDynamicContext(mainModule, effectiveConfiguration, bindings); - ItemRuntimePlan plan = VisitorHelpers.generateRuntimeIterator(mainModule, effectiveConfiguration); + new DynamicContextVisitor(effectiveConfiguration, bindings).visit(mainModule, null); + ItemRuntimePlan plan = new RuntimeIteratorVisitor(effectiveConfiguration).generateRuntimePlan(mainModule); return new SequenceOfItems(plan, dynamicContext, effectiveConfiguration); } @@ -223,7 +229,7 @@ public String runQueryToString(URI location, ExternalBindings bindings) throws I * @return serialization of the JSONiq Expression Tree. */ public String serializeToJSONiq(String query) { - MainModule mainModule = VisitorHelpers.parseMainModuleFromQuery( + MainModule mainModule = CompilationPipeline.compileMainModuleFromQuery( query, this.compilationConfiguration, ExternalBindings.empty().getInternalBindings()); StringBuilder sb = new StringBuilder(); mainModule.serializeToJSONiq(sb, 0); diff --git a/src/main/java/org/rumbledb/compiler/CompilationPipeline.java b/src/main/java/org/rumbledb/compiler/CompilationPipeline.java index 5a80e13ea4..faf1926eda 100644 --- a/src/main/java/org/rumbledb/compiler/CompilationPipeline.java +++ b/src/main/java/org/rumbledb/compiler/CompilationPipeline.java @@ -37,6 +37,22 @@ public final class CompilationPipeline { private CompilationPipeline() {} + /** Loads and compiles a main module using its resolved source URI. */ + public static MainModule compileMainModuleFromLocation( + URI location, CompilationConfiguration configuration, ExternalBindings externalBindings) + throws IOException { + ModuleSourceLoader.ModuleSource source = + ModuleSourceLoader.readModuleSource(location, configuration, ExceptionMetadata.EMPTY_METADATA); + return compileMainModule(source.query(), source.systemId(), configuration, externalBindings); + } + + /** Compiles query text using the configured static base URI. */ + public static MainModule compileMainModuleFromQuery( + String query, CompilationConfiguration configuration, ExternalBindings externalBindings) { + URI location = ModuleSourceLoader.queryLocation(configuration.runtimeConfiguration()); + return compileMainModule(query, location, configuration, externalBindings); + } + static LibraryModule prepareLibraryModuleFromLocation( URI location, StaticContext importingContext, diff --git a/src/main/java/org/rumbledb/compiler/DynamicContextVisitor.java b/src/main/java/org/rumbledb/compiler/DynamicContextVisitor.java index 47a8659f3b..994cf548c9 100644 --- a/src/main/java/org/rumbledb/compiler/DynamicContextVisitor.java +++ b/src/main/java/org/rumbledb/compiler/DynamicContextVisitor.java @@ -77,7 +77,7 @@ public class DynamicContextVisitor extends AbstractNodeVisitor { private ExternalBindings externalBindings; private Map importedModuleContexts; - DynamicContextVisitor(RumbleConfiguration configuration, ExternalBindings externalBindings) { + public DynamicContextVisitor(RumbleConfiguration configuration, ExternalBindings externalBindings) { this.configuration = configuration; this.externalBindings = externalBindings; this.importedModuleContexts = new HashMap<>(); @@ -108,7 +108,7 @@ public DynamicContext visitFunctionDeclaration(FunctionDeclaration declaration, if (!expression.getReturnType().isResolved()) { expression.getReturnType().resolve(argument, expression.getMetadata()); } - ItemRuntimePlan bodyIterator = VisitorHelpers.generateRuntimeIterator(expression, this.configuration); + ItemRuntimePlan bodyIterator = new RuntimeIteratorVisitor(this.configuration).generateRuntimePlan(expression); List functionInList = bodyIterator.materialize(argument); if (functionInList.size() != 1) { throw new OurBadException("A function declaration should produce exactly one function"); @@ -123,23 +123,6 @@ public DynamicContext visitFunctionDeclaration(FunctionDeclaration declaration, return defaultAction(expression, argument); } - // @Override - // public DynamicContext visitTransformExpression(TransformExpression expression, DynamicContext argument) { - // - // for (CopyDeclaration copyDecl : expression.getCopyDeclarations()) { - // Expression child = copyDecl.getSourceExpression(); - // this.visit(child, argument); - // ItemRuntimePlan iterator = VisitorHelpers.generateRuntimeIterator(child, this.configuration); - // iterator.bindToVariableInDynamicContext(argument, copyDecl.getVariableName(), argument); - // } - // - // this.visit(expression.getModifyExpression(), argument); - // - // this.visit(expression.getReturnExpression(), argument); - // - // return argument; - // } - @Override public DynamicContext visitVariableDeclaration(VariableDeclaration variableDeclaration, DynamicContext argument) { Name name = variableDeclaration.getVariableName(); @@ -147,7 +130,7 @@ public DynamicContext visitVariableDeclaration(VariableDeclaration variableDecla // Variable is not external: we use the expression. if (!variableDeclaration.external()) { Expression expression = variableDeclaration.getExpression(); - ItemRuntimePlan iterator = VisitorHelpers.generateRuntimeIterator(expression, this.configuration); + ItemRuntimePlan iterator = new RuntimeIteratorVisitor(this.configuration).generateRuntimePlan(expression); RuntimePlanBindings.bind(iterator, argument, name, argument); return argument; } @@ -296,7 +279,7 @@ public DynamicContext visitVariableDeclaration(VariableDeclaration variableDecla // Variable is external and we do not have any supplied value: we fall back to expression, if any. Expression expression = variableDeclaration.getExpression(); if (expression != null) { - ItemRuntimePlan iterator = VisitorHelpers.generateRuntimeIterator(expression, this.configuration); + ItemRuntimePlan iterator = new RuntimeIteratorVisitor(this.configuration).generateRuntimePlan(expression); RuntimePlanBindings.bind(iterator, argument, name, argument); return argument; } diff --git a/src/main/java/org/rumbledb/compiler/RuntimeIteratorVisitor.java b/src/main/java/org/rumbledb/compiler/RuntimeIteratorVisitor.java index 3292de1050..80b368fbb3 100644 --- a/src/main/java/org/rumbledb/compiler/RuntimeIteratorVisitor.java +++ b/src/main/java/org/rumbledb/compiler/RuntimeIteratorVisitor.java @@ -23,6 +23,8 @@ import java.util.Map; import java.util.stream.Collectors; +import lombok.extern.log4j.Log4j2; + import org.rumbledb.config.RumbleConfiguration; import org.rumbledb.context.BuiltinFunctionCatalogue; import org.rumbledb.context.FunctionIdentifier; @@ -269,6 +271,7 @@ import org.rumbledb.types.SequenceType; import org.rumbledb.xml.schema.XmlSchemaCatalog; +@Log4j2 public class RuntimeIteratorVisitor extends AbstractNodeVisitor { private final VisitorConfig visitorConfig; @@ -279,6 +282,17 @@ public RuntimeIteratorVisitor(RumbleConfiguration config) { this.config = config; } + /** Generates a runtime plan and renders it when debug output is enabled. */ + public ItemRuntimePlan generateRuntimePlan(Node node) { + ItemRuntimePlan result = visit(node, null); + if (this.config.debug().printIteratorTree() || this.config.debug().logging()) { + StringBuilder sb = new StringBuilder(); + result.print(sb, 0); + log.debug(sb); + } + return result; + } + @Override public ItemRuntimePlan visit(Node node, ItemRuntimePlan argument) { return node.accept(this, argument); diff --git a/src/main/java/org/rumbledb/compiler/VisitorHelpers.java b/src/main/java/org/rumbledb/compiler/VisitorHelpers.java deleted file mode 100644 index 14dc588fbc..0000000000 --- a/src/main/java/org/rumbledb/compiler/VisitorHelpers.java +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * Contributor acknowledgements are maintained in the CONTRIBUTORS file at the project root. - */ -package org.rumbledb.compiler; - -import java.io.IOException; -import java.net.URI; - -import lombok.extern.log4j.Log4j2; - -import org.rumbledb.bindings.ExternalBindings; -import org.rumbledb.config.CompilationConfiguration; -import org.rumbledb.config.RumbleConfiguration; -import org.rumbledb.context.DynamicContext; -import org.rumbledb.context.StaticContext; -import org.rumbledb.exceptions.ExceptionMetadata; -import org.rumbledb.expressions.Node; -import org.rumbledb.expressions.module.LibraryModule; -import org.rumbledb.expressions.module.MainModule; -import org.rumbledb.runtime.plan.ItemRuntimePlan; - -/** Compatibility entry points for compilation, runtime planning, and context initialization. */ -@Log4j2 -public class VisitorHelpers { - - public static ItemRuntimePlan generateRuntimeIterator(Node node, RumbleConfiguration conf) { - ItemRuntimePlan result = new RuntimeIteratorVisitor(conf).visit(node, null); - if (conf.debug().printIteratorTree() || conf.debug().logging()) { - StringBuilder sb = new StringBuilder(); - result.print(sb, 0); - log.debug(sb); - } - return result; - } - - public static RumbleConfiguration getEffectiveConfiguration( - Node node, RumbleConfiguration.RumbleConfigurationBuilder builder) { - return new EffectiveConfigurationVisitor().getEffectiveConfiguration(node, builder); - } - - public static MainModule parseMainModuleFromLocation(URI location, RumbleConfiguration configuration) - throws IOException { - return parseMainModuleFromLocation( - location, new CompilationConfiguration(configuration), ExternalBindings.empty()); - } - - public static MainModule parseMainModuleFromLocation( - URI location, RumbleConfiguration configuration, ExternalBindings externalBindings) throws IOException { - return parseMainModuleFromLocation(location, new CompilationConfiguration(configuration), externalBindings); - } - - public static MainModule parseMainModuleFromLocation( - URI location, CompilationConfiguration compilationConfiguration) throws IOException { - return parseMainModuleFromLocation(location, compilationConfiguration, ExternalBindings.empty()); - } - - public static MainModule parseMainModuleFromLocation( - URI location, CompilationConfiguration compilationConfiguration, ExternalBindings externalBindings) - throws IOException { - ModuleSourceLoader.ModuleSource source = ModuleSourceLoader.readModuleSource( - location, compilationConfiguration, ExceptionMetadata.EMPTY_METADATA); - return parseMainModule(source.query(), source.systemId(), compilationConfiguration, externalBindings); - } - - static LibraryModule parseLibraryModuleFromLocation( - URI location, - StaticContext importingModuleContext, - CompilationConfiguration compilationConfiguration, - ExceptionMetadata metadata) - throws IOException { - return CompilationPipeline.prepareLibraryModuleFromLocation( - location, importingModuleContext, compilationConfiguration, metadata); - } - - public static MainModule parseMainModuleFromQuery( - String query, RumbleConfiguration configuration, ExternalBindings externalBindings) { - return parseMainModuleFromQuery(query, new CompilationConfiguration(configuration), externalBindings); - } - - public static MainModule parseMainModuleFromQuery(String query, CompilationConfiguration compilationConfiguration) { - return parseMainModuleFromQuery(query, compilationConfiguration, ExternalBindings.empty()); - } - - public static MainModule parseMainModuleFromQuery( - String query, CompilationConfiguration compilationConfiguration, ExternalBindings externalBindings) { - URI location = ModuleSourceLoader.queryLocation(compilationConfiguration.runtimeConfiguration()); - return parseMainModule(query, location, compilationConfiguration, externalBindings); - } - - public static MainModule parseMainModule( - String query, URI uri, RumbleConfiguration configuration, ExternalBindings externalBindings) { - return parseMainModule(query, uri, new CompilationConfiguration(configuration), externalBindings); - } - - public static MainModule parseMainModule( - String query, - URI uri, - CompilationConfiguration compilationConfiguration, - ExternalBindings externalBindings) { - return CompilationPipeline.compileMainModule(query, uri, compilationConfiguration, externalBindings); - } - - /** Parses and analyzes a standalone library module for language-server callers. */ - public static LibraryModule parseLibraryModuleFromQueryWithStaticContextAndInference( - String query, URI uri, RumbleConfiguration configuration) { - return CompilationPipeline.analyzeLibraryModule(query, uri, configuration); - } - - public static DynamicContext createDynamicContext(Node node, RumbleConfiguration configuration) { - return createDynamicContext(node, configuration, ExternalBindings.empty()); - } - - public static DynamicContext createDynamicContext( - Node node, RumbleConfiguration configuration, ExternalBindings externalBindings) { - DynamicContextVisitor visitor = new DynamicContextVisitor(configuration, externalBindings); - return visitor.visit(node, null); - } -} diff --git a/src/test/java/iq/FrontendTests.java b/src/test/java/iq/FrontendTests.java index 302a83e842..e95747738e 100644 --- a/src/test/java/iq/FrontendTests.java +++ b/src/test/java/iq/FrontendTests.java @@ -29,7 +29,9 @@ import iq.base.TestFileDiscovery; import org.rumbledb.bindings.ExternalBindings; -import org.rumbledb.compiler.VisitorHelpers; +import org.rumbledb.compiler.CompilationPipeline; +import org.rumbledb.compiler.EffectiveConfigurationVisitor; +import org.rumbledb.config.CompilationConfiguration; import org.rumbledb.config.RumbleConfiguration; import org.rumbledb.context.Name; import org.rumbledb.exceptions.ExceptionMetadata; @@ -97,8 +99,8 @@ public void testSematicChecks() throws Throwable { if (Arrays.asList(manualSemanticChecksFiles).contains(testFile.getName())) { URI uri = FileSystemUtil.resolveURIAgainstWorkingDirectory( testFile.getAbsolutePath(), ExceptionMetadata.EMPTY_METADATA); - MainModule mainModule = - VisitorHelpers.parseMainModuleFromLocation(uri, configuration, ExternalBindings.empty()); + MainModule mainModule = CompilationPipeline.compileMainModuleFromLocation( + uri, new CompilationConfiguration(configuration), ExternalBindings.empty()); testVariableTypes(mainModule); } @@ -111,13 +113,13 @@ public void testEffectiveConfigurationDisablesParentPointerOptimizationForRevers RumbleConfiguration initialConfiguration = RumbleConfiguration.builder() .configureOptimization(optimization -> optimization.optimizeParentPointers(true)) .build(); - MainModule mainModule = VisitorHelpers.parseMainModuleFromQuery( + MainModule mainModule = CompilationPipeline.compileMainModuleFromQuery( "jsoniq version \"1.0\"; doc(\"books.xml\")/child::book/parent::node()", - initialConfiguration, + new CompilationConfiguration(initialConfiguration), ExternalBindings.empty()); - RumbleConfiguration effectiveConfiguration = - VisitorHelpers.getEffectiveConfiguration(mainModule, initialConfiguration.toBuilder()); + RumbleConfiguration effectiveConfiguration = new EffectiveConfigurationVisitor() + .getEffectiveConfiguration(mainModule, initialConfiguration.toBuilder()); Assertions.assertFalse(effectiveConfiguration.optimization().optimizeParentPointers()); Assertions.assertTrue(initialConfiguration.optimization().optimizeParentPointers()); @@ -129,13 +131,13 @@ public void testEffectiveConfigurationKeepsParentPointerOptimizationForForwardCh RumbleConfiguration initialConfiguration = RumbleConfiguration.builder() .configureOptimization(optimization -> optimization.optimizeParentPointers(true)) .build(); - MainModule mainModule = VisitorHelpers.parseMainModuleFromQuery( + MainModule mainModule = CompilationPipeline.compileMainModuleFromQuery( "jsoniq version \"1.0\"; doc(\"books.xml\")/child::book/child::title", - initialConfiguration, + new CompilationConfiguration(initialConfiguration), ExternalBindings.empty()); - RumbleConfiguration effectiveConfiguration = - VisitorHelpers.getEffectiveConfiguration(mainModule, initialConfiguration.toBuilder()); + RumbleConfiguration effectiveConfiguration = new EffectiveConfigurationVisitor() + .getEffectiveConfiguration(mainModule, initialConfiguration.toBuilder()); Assertions.assertTrue(effectiveConfiguration.optimization().optimizeParentPointers()); } @@ -146,13 +148,13 @@ public void testEffectiveConfigurationDisablesParentPointerOptimizationForFollow RumbleConfiguration initialConfiguration = RumbleConfiguration.builder() .configureOptimization(optimization -> optimization.optimizeParentPointers(true)) .build(); - MainModule mainModule = VisitorHelpers.parseMainModuleFromQuery( + MainModule mainModule = CompilationPipeline.compileMainModuleFromQuery( "jsoniq version \"1.0\"; doc(\"books.xml\")/child::book/following::node()", - initialConfiguration, + new CompilationConfiguration(initialConfiguration), ExternalBindings.empty()); - RumbleConfiguration effectiveConfiguration = - VisitorHelpers.getEffectiveConfiguration(mainModule, initialConfiguration.toBuilder()); + RumbleConfiguration effectiveConfiguration = new EffectiveConfigurationVisitor() + .getEffectiveConfiguration(mainModule, initialConfiguration.toBuilder()); Assertions.assertFalse(effectiveConfiguration.optimization().optimizeParentPointers()); } @@ -163,11 +165,13 @@ public void testEffectiveConfigurationDisablesParentPointerOptimizationForNested RumbleConfiguration initialConfiguration = RumbleConfiguration.builder() .configureOptimization(optimization -> optimization.optimizeParentPointers(true)) .build(); - MainModule mainModule = VisitorHelpers.parseMainModuleFromQuery( - "jsoniq version \"1.0\"; (1, serialize(1))", initialConfiguration, ExternalBindings.empty()); + MainModule mainModule = CompilationPipeline.compileMainModuleFromQuery( + "jsoniq version \"1.0\"; (1, serialize(1))", + new CompilationConfiguration(initialConfiguration), + ExternalBindings.empty()); - RumbleConfiguration effectiveConfiguration = - VisitorHelpers.getEffectiveConfiguration(mainModule, initialConfiguration.toBuilder()); + RumbleConfiguration effectiveConfiguration = new EffectiveConfigurationVisitor() + .getEffectiveConfiguration(mainModule, initialConfiguration.toBuilder()); Assertions.assertFalse(effectiveConfiguration.optimization().optimizeParentPointers()); Assertions.assertTrue(initialConfiguration.optimization().optimizeParentPointers()); diff --git a/src/test/java/iq/SequentialClassificationTests.java b/src/test/java/iq/SequentialClassificationTests.java index 4c647acc83..0733f0792a 100644 --- a/src/test/java/iq/SequentialClassificationTests.java +++ b/src/test/java/iq/SequentialClassificationTests.java @@ -28,7 +28,8 @@ import lombok.extern.log4j.Log4j2; import org.rumbledb.bindings.ExternalBindings; -import org.rumbledb.compiler.VisitorHelpers; +import org.rumbledb.compiler.CompilationPipeline; +import org.rumbledb.config.CompilationConfiguration; import org.rumbledb.config.RumbleConfiguration; import org.rumbledb.exceptions.ExceptionMetadata; import org.rumbledb.expressions.Expression; @@ -61,7 +62,8 @@ public class SequentialClassificationTests { private MainModule parseAndCompile(String filePath) throws IOException { URI uri = FileSystemUtil.resolveURIAgainstWorkingDirectory(filePath, ExceptionMetadata.EMPTY_METADATA); - return VisitorHelpers.parseMainModuleFromLocation(uri, configuration, ExternalBindings.empty()); + return CompilationPipeline.compileMainModuleFromLocation( + uri, new CompilationConfiguration(configuration), ExternalBindings.empty()); } @Test diff --git a/src/test/java/org/rumbledb/xml/schema/XmlSchemaCatalogLoaderTest.java b/src/test/java/org/rumbledb/xml/schema/XmlSchemaCatalogLoaderTest.java index 0157521147..0fb7ce28f3 100644 --- a/src/test/java/org/rumbledb/xml/schema/XmlSchemaCatalogLoaderTest.java +++ b/src/test/java/org/rumbledb/xml/schema/XmlSchemaCatalogLoaderTest.java @@ -28,7 +28,7 @@ import org.junit.jupiter.api.io.TempDir; import org.rumbledb.bindings.ExternalBindings; -import org.rumbledb.compiler.VisitorHelpers; +import org.rumbledb.compiler.CompilationPipeline; import org.rumbledb.config.CompilationConfiguration; import org.rumbledb.config.RumbleConfiguration; import org.rumbledb.context.InScopeSchemaTypes; @@ -101,7 +101,7 @@ private static MainModule compile(String query, URI queryUri, ResourceResolver r RumbleConfiguration configuration = RumbleConfiguration.builder() .configureSemantics(semantics -> semantics.queryLanguage("xquery31")) .build(); - return VisitorHelpers.parseMainModule( + return CompilationPipeline.compileMainModule( query, queryUri, new CompilationConfiguration(configuration, resolver), ExternalBindings.empty()); } From b90f8cdac27b9d413dda880e3d0becd5700f4ffb Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Sun, 4 Oct 2026 00:16:14 +0200 Subject: [PATCH 03/36] feat: include ExceptionMetadata for FLWOR variables --- .../org/rumbledb/compiler/CloneVisitor.java | 17 ++- .../rumbledb/compiler/ComparisonVisitor.java | 8 +- .../compiler/FunctionInliningVisitor.java | 11 +- .../rumbledb/compiler/InferTypeVisitor.java | 2 + .../compiler/ProjectionPushdownVisitor.java | 18 +-- .../compiler/StaticContextVisitor.java | 20 +++- .../translation/ComparisonTranslation.java | 18 ++- .../translation/FlworTranslation.java | 16 ++- .../translation/QuantifiedTranslation.java | 4 +- .../rumbledb/expressions/flowr/ForClause.java | 24 +++- .../flowr/GroupByVariableDeclaration.java | 17 ++- .../rumbledb/expressions/flowr/LetClause.java | 13 ++- .../compiler/FlworBindingMetadataTest.java | 109 ++++++++++++++++++ 13 files changed, 235 insertions(+), 42 deletions(-) create mode 100644 src/test/java/org/rumbledb/compiler/FlworBindingMetadataTest.java diff --git a/src/main/java/org/rumbledb/compiler/CloneVisitor.java b/src/main/java/org/rumbledb/compiler/CloneVisitor.java index 521109d95b..fda78373bc 100644 --- a/src/main/java/org/rumbledb/compiler/CloneVisitor.java +++ b/src/main/java/org/rumbledb/compiler/CloneVisitor.java @@ -271,13 +271,16 @@ public Node visitVariableReference(VariableReferenceExpression expression, Node @Override public Node visitForClause(ForClause clause, Node argument) { - Clause result = new ForClause( + ForClause result = new ForClause( clause.getVariableName(), clause.isAllowEmpty(), clause.getActualSequenceType(), clause.getPositionalVariableName(), (Expression) visit(clause.getExpression(), argument), - clause.getMetadata()); + clause.getMetadata(), + clause.getVariableMetadata(), + clause.getPositionalVariableMetadata()); + result.setVariableSequenceType(clause.getVariableSequenceType()); result.setStaticContext(clause.getStaticContext()); return result; } @@ -307,7 +310,8 @@ public Node visitLetClause(LetClause clause, Node argument) { clause.getVariableName(), clause.getActualSequenceType(), (Expression) visit(clause.getExpression(), argument), - clause.getMetadata()); + clause.getMetadata(), + clause.getVariableMetadata()); result.setStaticType(clause.getStaticType()); result.setStaticContext(clause.getStaticContext()); return result; @@ -317,13 +321,16 @@ public Node visitLetClause(LetClause clause, Node argument) { public Node visitGroupByClause(GroupByClause clause, Node argument) { List groupByVariableDeclarations = new ArrayList<>(); for (GroupByVariableDeclaration variable : clause.getGroupVariables()) { - groupByVariableDeclarations.add(new GroupByVariableDeclaration( + GroupByVariableDeclaration copy = new GroupByVariableDeclaration( variable.getVariableName(), variable.getActualSequenceType(), (variable.getExpression() == null) ? variable.getExpression() : (Expression) visit(variable.getExpression(), argument), - variable.getCollationURI())); + variable.getCollationURI(), + variable.getVariableMetadata()); + copy.setVariableSequenceType(variable.getVariableSequenceType()); + groupByVariableDeclarations.add(copy); } Clause result = new GroupByClause(groupByVariableDeclarations, clause.getMetadata()); result.setStaticContext(clause.getStaticContext()); diff --git a/src/main/java/org/rumbledb/compiler/ComparisonVisitor.java b/src/main/java/org/rumbledb/compiler/ComparisonVisitor.java index eee661b3b3..c0249ca017 100644 --- a/src/main/java/org/rumbledb/compiler/ComparisonVisitor.java +++ b/src/main/java/org/rumbledb/compiler/ComparisonVisitor.java @@ -120,7 +120,9 @@ public Node visitComparisonExpr(ComparisonExpression expression, Node argument) leftChild.getStaticSequenceType(), null, leftChild, - expression.getMetadata()); + expression.getMetadata(), + null, + null); firstClause.setStaticContext(leftContext); StaticContext rightContext = new StaticContext(leftContext); rightContext.addVariable(variableNameRight, rightChild.getStaticSequenceType(), expression.getMetadata()); @@ -130,7 +132,9 @@ public Node visitComparisonExpr(ComparisonExpression expression, Node argument) rightChild.getStaticSequenceType(), null, rightChild, - expression.getMetadata()); + expression.getMetadata(), + null, + null); secondClause.setStaticContext(rightContext); firstClause.chainWith(secondClause); Expression leftReference = new VariableReferenceExpression(variableNameLeft, expression.getMetadata()); diff --git a/src/main/java/org/rumbledb/compiler/FunctionInliningVisitor.java b/src/main/java/org/rumbledb/compiler/FunctionInliningVisitor.java index 7093ddeba6..2c56d6e1e6 100644 --- a/src/main/java/org/rumbledb/compiler/FunctionInliningVisitor.java +++ b/src/main/java/org/rumbledb/compiler/FunctionInliningVisitor.java @@ -126,7 +126,9 @@ private void addCastCase( new SequenceType(testType.getItemType(), Arity.One), null, expression, - expression.getMetadata()); + expression.getMetadata(), + null, + null); Expression castExpression = new CastExpression( new VariableReferenceExpression(variableName, expression.getMetadata()), new SequenceType(targetType.getItemType(), Arity.One), @@ -474,11 +476,12 @@ public Node visitFunctionCall(FunctionCallExpression expression, Node argument) if (isVariableReferenced(expression.getArguments(), paramName, i)) { Name columnName = Name.createVariableInNoNamespace( String.format("param%s", UUID.randomUUID().toString().replaceAll("-", ""))); - Clause expressionClause = new LetClause(columnName, null, argumentExpression, expression.getMetadata()); + Clause expressionClause = + new LetClause(columnName, null, argumentExpression, expression.getMetadata(), null); Expression assignmentExpression = createTypePromotion( new VariableReferenceExpression(columnName, expression.getMetadata()), paramType); Clause assignmentClause = - new LetClause(paramName, null, assignmentExpression, expression.getMetadata()); + new LetClause(paramName, null, assignmentExpression, expression.getMetadata(), null); if (assignmentClauses != null) { assignmentClause.chainWith(assignmentClauses); } @@ -490,7 +493,7 @@ public Node visitFunctionCall(FunctionCallExpression expression, Node argument) } else { Expression assignmentExpression = createTypePromotion(argumentExpression, paramType); Clause expressionClause = - new LetClause(paramName, null, assignmentExpression, expression.getMetadata()); + new LetClause(paramName, null, assignmentExpression, expression.getMetadata(), null); if (expressionClauses != null) { expressionClause.chainWith(expressionClauses); } diff --git a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java index 3b495e3deb..1232b73fd5 100644 --- a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java +++ b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java @@ -2373,6 +2373,7 @@ public StaticContext visitForClause(ForClause expression, StaticContext argument expression.getVariableName(), expression.getMetadata()); + expression.setVariableSequenceType(declaredType == null ? inferredType : declaredType); return argument; } @@ -2490,6 +2491,7 @@ public StaticContext visitGroupByClause(GroupByClause expression, StaticContext + expectedType, expression.getMetadata()); } + groupByVar.setVariableSequenceType(expectedType); groupingVars.add(groupByVar.getVariableName()); } diff --git a/src/main/java/org/rumbledb/compiler/ProjectionPushdownVisitor.java b/src/main/java/org/rumbledb/compiler/ProjectionPushdownVisitor.java index 2fb0fb4ebc..537362bbf9 100644 --- a/src/main/java/org/rumbledb/compiler/ProjectionPushdownVisitor.java +++ b/src/main/java/org/rumbledb/compiler/ProjectionPushdownVisitor.java @@ -72,23 +72,7 @@ public Node visitFlowrExpression(FlworExpression expression, Node argument) { @Override public Node visitLetClause(LetClause clause, Node argument) { - // return new LetClause( - // clause.getVariableName(), - // clause.getActualSequenceType(), - // (Expression) visit(clause.getExpression(), argument), - // clause.getMetadata() - // ); - - // code below from dominik - // problem is that it removes the last let clause (returns null for that) - if (clause.getReferenced()) { - return new LetClause( - clause.getVariableName(), - clause.getActualSequenceType(), - (Expression) visit(clause.getExpression(), argument), - clause.getMetadata()); - } - return null; + return clause.getReferenced() ? super.visitLetClause(clause, argument) : null; } @Override diff --git a/src/main/java/org/rumbledb/compiler/StaticContextVisitor.java b/src/main/java/org/rumbledb/compiler/StaticContextVisitor.java index aaf6d8bdb1..179950643f 100644 --- a/src/main/java/org/rumbledb/compiler/StaticContextVisitor.java +++ b/src/main/java/org/rumbledb/compiler/StaticContextVisitor.java @@ -19,6 +19,7 @@ import java.util.HashMap; import java.util.Map; import java.util.Map.Entry; +import java.util.Objects; import org.rumbledb.context.ConstructorFunctionResolver; import org.rumbledb.context.Name; @@ -227,14 +228,17 @@ public StaticContext visitForClause(ForClause clause, StaticContext argument) { this.visit(clause.getExpression(), argument); StaticContext result = new StaticContext(argument); // add a block level to function declaration body - result.addVariable(clause.getVariableName(), clause.getActualSequenceType(), clause.getMetadata()); + result.addVariable( + clause.getVariableName(), + clause.getActualSequenceType(), + Objects.requireNonNullElse(clause.getVariableMetadata(), clause.getMetadata())); clause.getSequenceType().resolve(result, clause.getMetadata()); if (clause.getPositionalVariableName() != null) { result.addVariable( clause.getPositionalVariableName(), - new SequenceType(BuiltinTypesCatalogue.integerItem), - clause.getMetadata()); + clause.getPositionalVariableSequenceType(), + Objects.requireNonNullElse(clause.getPositionalVariableMetadata(), clause.getMetadata())); } this.visit(clause.getNextClause(), result); return argument; @@ -288,7 +292,10 @@ public StaticContext visitLetClause(LetClause clause, StaticContext argument) { this.visit(clause.getExpression(), argument); StaticContext result = new StaticContext(argument); - result.addVariable(clause.getVariableName(), clause.getActualSequenceType(), clause.getMetadata()); + result.addVariable( + clause.getVariableName(), + clause.getActualSequenceType(), + Objects.requireNonNullElse(clause.getVariableMetadata(), clause.getMetadata())); clause.getSequenceType().resolve(result, clause.getMetadata()); this.visit(clause.getNextClause(), result); return argument; @@ -310,7 +317,10 @@ public StaticContext visitGroupByClause(GroupByClause clause, StaticContext argu if (variable.getExpression() != null) { // if a variable declaration takes place this.visit(variable.getExpression(), argument); - result.addVariable(variable.getVariableName(), variable.getActualSequenceType(), clause.getMetadata()); + result.addVariable( + variable.getVariableName(), + variable.getActualSequenceType(), + Objects.requireNonNullElse(variable.getVariableMetadata(), clause.getMetadata())); } else if (!argument.isInScope(variable.getVariableName())) { throw new UndeclaredVariableException( "Uninitialized variable reference: " + variable.getVariableName(), clause.getMetadata()); diff --git a/src/main/java/org/rumbledb/compiler/translation/ComparisonTranslation.java b/src/main/java/org/rumbledb/compiler/translation/ComparisonTranslation.java index b45a03cd02..908eb42b5a 100644 --- a/src/main/java/org/rumbledb/compiler/translation/ComparisonTranslation.java +++ b/src/main/java/org/rumbledb/compiler/translation/ComparisonTranslation.java @@ -68,9 +68,23 @@ public static Expression comparisonExpr Name variableNameRight = Name.TEMP_VAR2; Clause firstClause = new ForClause( - variableNameLeft, false, null, null, mainExpression, translationContext.metadata(ctx.context())); + variableNameLeft, + false, + null, + null, + mainExpression, + translationContext.metadata(ctx.context()), + null, + null); Clause secondClause = new ForClause( - variableNameRight, false, null, null, childExpression, translationContext.metadata(ctx.context())); + variableNameRight, + false, + null, + null, + childExpression, + translationContext.metadata(ctx.context()), + null, + null); firstClause.chainWith(secondClause); Expression valueComparison = new ComparisonExpression( new VariableReferenceExpression(variableNameLeft, translationContext.metadata(ctx.context())), diff --git a/src/main/java/org/rumbledb/compiler/translation/FlworTranslation.java b/src/main/java/org/rumbledb/compiler/translation/FlworTranslation.java index e6ad6dd590..29d769eb06 100644 --- a/src/main/java/org/rumbledb/compiler/translation/FlworTranslation.java +++ b/src/main/java/org/rumbledb/compiler/translation/FlworTranslation.java @@ -150,7 +150,15 @@ ForClause forVar( expr = new TreatExpression(expr, expressionType, ErrorCode.UnexpectedTypeErrorCode, expr.getMetadata()); } - return new ForClause(var, emptyFlag, seq, atVar, expr, translationContext.metadata(ctx.context())); + return new ForClause( + var, + emptyFlag, + seq, + atVar, + expr, + translationContext.metadata(ctx.context()), + translationContext.metadata(ctx.varRef()), + ctx.at() == null ? null : translationContext.metadata(ctx.at())); } public static < @@ -196,7 +204,8 @@ LetClause letVar( expr = new TreatExpression(expr, seq, ErrorCode.UnexpectedTypeErrorCode, expr.getMetadata()); } - return new LetClause(var, seq, expr, translationContext.metadata(ctx.context())); + return new LetClause( + var, seq, expr, translationContext.metadata(ctx.context()), translationContext.metadata(ctx.varRef())); } public static WhereClause whereClause( @@ -241,7 +250,8 @@ GroupByClause groupByClause( SequenceType seq = varCtx.seq() != null ? processSequenceType.apply(varCtx.seq()) : null; Expression expr = varCtx.exprSingle() != null ? visitExprSingle.apply(varCtx.exprSingle()) : null; Name var = parseVariableBinding.apply(varCtx.varRef()); - vars.add(new GroupByVariableDeclaration(var, seq, expr, collationUri)); + vars.add(new GroupByVariableDeclaration( + var, seq, expr, collationUri, translationContext.metadata(varCtx.varRef()))); } return new GroupByClause(vars, translationContext.metadata(ctx.context())); } diff --git a/src/main/java/org/rumbledb/compiler/translation/QuantifiedTranslation.java b/src/main/java/org/rumbledb/compiler/translation/QuantifiedTranslation.java index 885c66c158..63a8d78702 100644 --- a/src/main/java/org/rumbledb/compiler/translation/QuantifiedTranslation.java +++ b/src/main/java/org/rumbledb/compiler/translation/QuantifiedTranslation.java @@ -66,7 +66,9 @@ FunctionCallExpression quantifiedExpr( sequenceType, null, varExpression, - translationContext.metadata(currentVariable.context())); + translationContext.metadata(currentVariable.context()), + null, + null); if (lastClause != null) { lastClause.chainWith(newClause); } diff --git a/src/main/java/org/rumbledb/expressions/flowr/ForClause.java b/src/main/java/org/rumbledb/expressions/flowr/ForClause.java index 245cc78319..2b47c4761a 100644 --- a/src/main/java/org/rumbledb/expressions/flowr/ForClause.java +++ b/src/main/java/org/rumbledb/expressions/flowr/ForClause.java @@ -30,6 +30,7 @@ import org.rumbledb.expressions.ExecutionMode; import org.rumbledb.expressions.Expression; import org.rumbledb.expressions.Node; +import org.rumbledb.types.BuiltinTypesCatalogue; import org.rumbledb.types.SequenceType; public class ForClause extends Clause { @@ -42,8 +43,21 @@ public class ForClause extends Clause { @Getter private final Name positionalVariableName; + /** Variable token metadata, or null for compiler-generated clauses. */ + @Getter + private final ExceptionMetadata variableMetadata; + + /** Positional variable token metadata, or null when there is no source token. */ + @Getter + private final ExceptionMetadata positionalVariableMetadata; + protected SequenceType sequenceType; + // The type visible immediately after this binding, before later clauses regroup or shadow it. + @Getter + @Setter + private SequenceType variableSequenceType; + @Getter protected Expression expression; @@ -57,7 +71,9 @@ public ForClause( SequenceType sequenceType, Name positionalVariableName, Expression expression, - ExceptionMetadata metadata) { + ExceptionMetadata metadata, + ExceptionMetadata variableMetadata, + ExceptionMetadata positionalVariableMetadata) { super(FLWOR_CLAUSES.FOR, metadata); if (variableName == null) { throw new SemanticException("For clause must have a variable", metadata); @@ -65,6 +81,8 @@ public ForClause( this.variableName = variableName; this.allowingEmpty = allowEmpty; this.positionalVariableName = positionalVariableName; + this.variableMetadata = variableMetadata; + this.positionalVariableMetadata = positionalVariableMetadata; this.sequenceType = sequenceType; this.expression = expression; } @@ -81,6 +99,10 @@ public SequenceType getActualSequenceType() { return this.sequenceType; } + public SequenceType getPositionalVariableSequenceType() { + return this.positionalVariableName == null ? null : new SequenceType(BuiltinTypesCatalogue.integerItem); + } + public ExecutionMode getVariableHighestStorageMode(VisitorConfig visitorConfig) { if (!visitorConfig.suppressErrorsForAccessingUnsetExecutionModes() && this.variableHighestStorageMode == ExecutionMode.UNSET) { diff --git a/src/main/java/org/rumbledb/expressions/flowr/GroupByVariableDeclaration.java b/src/main/java/org/rumbledb/expressions/flowr/GroupByVariableDeclaration.java index beab69816a..315d20ee25 100644 --- a/src/main/java/org/rumbledb/expressions/flowr/GroupByVariableDeclaration.java +++ b/src/main/java/org/rumbledb/expressions/flowr/GroupByVariableDeclaration.java @@ -16,8 +16,10 @@ package org.rumbledb.expressions.flowr; import lombok.Getter; +import lombok.Setter; import org.rumbledb.context.Name; +import org.rumbledb.exceptions.ExceptionMetadata; import org.rumbledb.expressions.Expression; import org.rumbledb.types.SequenceType; @@ -31,15 +33,28 @@ public class GroupByVariableDeclaration { protected SequenceType sequenceType; + @Getter + private final ExceptionMetadata variableMetadata; + + // The type visible immediately after this grouping binding, independent of later clauses. + @Getter + @Setter + private SequenceType variableSequenceType; + @Getter protected String collationURI; public GroupByVariableDeclaration( - Name variableName, SequenceType sequenceType, Expression expression, String collationURI) { + Name variableName, + SequenceType sequenceType, + Expression expression, + String collationURI, + ExceptionMetadata variableMetadata) { if (variableName == null) { throw new IllegalArgumentException("Flowr var decls cannot be empty"); } this.variableName = variableName; + this.variableMetadata = variableMetadata; this.sequenceType = sequenceType; this.expression = expression; this.collationURI = collationURI; diff --git a/src/main/java/org/rumbledb/expressions/flowr/LetClause.java b/src/main/java/org/rumbledb/expressions/flowr/LetClause.java index 6c4ca2a927..444cb572f8 100644 --- a/src/main/java/org/rumbledb/expressions/flowr/LetClause.java +++ b/src/main/java/org/rumbledb/expressions/flowr/LetClause.java @@ -37,6 +37,10 @@ public class LetClause extends Clause { @Getter private final Name variableName; + /** Variable token metadata, or null for compiler-generated clauses. */ + @Getter + private final ExceptionMetadata variableMetadata; + protected SequenceType sequenceType; @Setter @@ -56,12 +60,14 @@ public LetClause( Name variableName, SequenceType sequenceType, Expression expression, - ExceptionMetadata metadataFromContext) { + ExceptionMetadata metadataFromContext, + ExceptionMetadata variableMetadata) { super(FLWOR_CLAUSES.LET, metadataFromContext); if (variableName == null) { throw new SemanticException("Let clause must have at least one variable", metadataFromContext); } this.variableName = variableName; + this.variableMetadata = variableMetadata; this.sequenceType = sequenceType; this.expression = expression; this.isReferenced = true; @@ -75,6 +81,11 @@ public SequenceType getActualSequenceType() { return this.sequenceType; } + /** Type visible after this binding; staticType retains the initializer's type for execution. */ + public SequenceType getVariableSequenceType() { + return this.sequenceType == null ? this.staticType : this.sequenceType; + } + public ExecutionMode getVariableHighestStorageMode(VisitorConfig visitorConfig) { if (!visitorConfig.suppressErrorsForAccessingUnsetExecutionModes() && this.variableHighestStorageMode == ExecutionMode.UNSET) { diff --git a/src/test/java/org/rumbledb/compiler/FlworBindingMetadataTest.java b/src/test/java/org/rumbledb/compiler/FlworBindingMetadataTest.java new file mode 100644 index 0000000000..589f784794 --- /dev/null +++ b/src/test/java/org/rumbledb/compiler/FlworBindingMetadataTest.java @@ -0,0 +1,109 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contributor acknowledgements are maintained in the CONTRIBUTORS file at the project root. + */ +package org.rumbledb.compiler; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.rumbledb.bindings.ExternalBindings; +import org.rumbledb.config.CompilationConfiguration; +import org.rumbledb.config.RumbleConfiguration; +import org.rumbledb.exceptions.ExceptionMetadata; +import org.rumbledb.exceptions.SourcePosition; +import org.rumbledb.exceptions.SourceRange; +import org.rumbledb.expressions.AbstractNodeVisitor; +import org.rumbledb.expressions.Node; +import org.rumbledb.expressions.flowr.Clause; +import org.rumbledb.expressions.flowr.ForClause; +import org.rumbledb.expressions.flowr.GroupByClause; +import org.rumbledb.expressions.flowr.LetClause; + +class FlworBindingMetadataTest { + private static final String QUERY = + """ + for $i at $pos in 1 to 10 + let $value := $i + 1 + group by $key := $value mod 2 + return ($key, $i, $pos, $value) + """; + + @ParameterizedTest + @ValueSource(strings = {"jq", "xq"}) + void recordsVariableRangesAndTypes(String extension) { + List nodes = compile(QUERY, extension); + ForClause forClause = find(nodes, ForClause.class); + assertRange(QUERY, "$i", forClause.getVariableMetadata()); + assertRange(QUERY, "$pos", forClause.getPositionalVariableMetadata()); + assertEquals("xs:integer", forClause.getVariableSequenceType().toString()); + assertEquals("xs:integer", forClause.getPositionalVariableSequenceType().toString()); + assertEquals( + forClause.getVariableMetadata(), + forClause + .getNextClause() + .getStaticContext() + .getInScopeVariables() + .get(forClause.getVariableName()) + .getMetadata()); + + LetClause letClause = find(nodes, LetClause.class); + assertRange(QUERY, "$value", letClause.getVariableMetadata()); + assertEquals("xs:integer", letClause.getVariableSequenceType().toString()); + var groupVariable = find(nodes, GroupByClause.class).getGroupVariables().get(0); + assertRange(QUERY, "$key", groupVariable.getVariableMetadata()); + assertEquals("xs:integer", groupVariable.getVariableSequenceType().toString()); + } + + private static List compile(String query, String extension) { + var module = CompilationPipeline.compileMainModule( + query, + URI.create("file:///binding-metadata." + extension), + new CompilationConfiguration(RumbleConfiguration.defaultConfiguration()), + ExternalBindings.empty()); + return new AbstractNodeVisitor>() { + @Override + protected List defaultAction(Node node, List nodes) { + nodes.add(node); + return visitDescendants(node, nodes); + } + }.visit(module, new ArrayList<>()); + } + + private static T find(List nodes, Class type) { + return nodes.stream() + .filter(type::isInstance) + .map(type::cast) + .findFirst() + .orElseThrow(); + } + + private static void assertRange(String query, String name, ExceptionMetadata metadata) { + assertNotNull(metadata); + int offset = query.indexOf(name); + String before = query.substring(0, offset); + int line = (int) before.chars().filter(c -> c == '\n').count() + 1; + int column = before.length() - before.lastIndexOf('\n') - 1; + assertEquals( + new SourceRange(new SourcePosition(line, column), new SourcePosition(line, column + name.length())), + metadata.getRange()); + } +} From dd824427c8e199eccfd6c26db0e8ec8ce85c1abc Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Sun, 4 Oct 2026 00:44:38 +0200 Subject: [PATCH 04/36] Add ExceptionMetadata to more variable bindings Window and start/end variables, count, some/every, function parameters, expression and statement typeswitch, copy bindings, and scripting declarations. --- .../org/rumbledb/compiler/CloneVisitor.java | 85 +++++++++---------- .../compiler/FunctionInliningVisitor.java | 26 +++--- .../rumbledb/compiler/InferTypeVisitor.java | 4 + .../compiler/StaticContextVisitor.java | 75 +++++++++++----- .../translation/ControlTranslation.java | 11 ++- .../translation/DeclarationTranslation.java | 5 ++ .../translation/FlworTranslation.java | 60 ++++++++++--- .../translation/PrimaryTranslation.java | 7 +- .../translation/QuantifiedTranslation.java | 2 +- .../ControlStatementTranslation.java | 11 ++- .../DeclarationStatementTranslation.java | 7 +- .../translation/update/UpdateTranslation.java | 2 +- .../expressions/control/TypeswitchCase.java | 17 +++- .../expressions/flowr/CountClause.java | 14 ++- .../expressions/flowr/WindowClause.java | 23 ++++- .../primary/InlineFunctionExpression.java | 5 ++ .../control/TypeSwitchStatementCase.java | 17 +++- .../declaration/VariableDeclStatement.java | 7 +- .../expressions/update/CopyDeclaration.java | 5 +- 19 files changed, 268 insertions(+), 115 deletions(-) diff --git a/src/main/java/org/rumbledb/compiler/CloneVisitor.java b/src/main/java/org/rumbledb/compiler/CloneVisitor.java index fda78373bc..91a767ea67 100644 --- a/src/main/java/org/rumbledb/compiler/CloneVisitor.java +++ b/src/main/java/org/rumbledb/compiler/CloneVisitor.java @@ -294,7 +294,8 @@ public Node visitWindowClause(WindowClause clause, Node argument) { (Expression) visit(clause.getExpression(), argument), cloneWindowCondition(clause.getStartCondition(), argument), clause.getEndCondition() == null ? null : cloneWindowCondition(clause.getEndCondition(), argument), - clause.getMetadata()); + clause.getMetadata(), + clause.getVariableMetadata()); result.setStaticContext(clause.getStaticContext()); return result; } @@ -354,7 +355,8 @@ public Node visitOrderByClause(OrderByClause clause, Node argument) { @Override public Node visitCountClause(CountClause expression, Node argument) { - Clause result = new CountClause(expression.getCountVariableName(), expression.getMetadata()); + Clause result = new CountClause( + expression.getCountVariableName(), expression.getMetadata(), expression.getVariableMetadata()); result.setStaticContext(expression.getStaticContext()); return result; } @@ -795,6 +797,7 @@ public Node visitInlineFunctionExpr(InlineFunctionExpression expression, Node ar expression.isExternal(), expression.getMetadata(), expression.getNameMetadata()); + result.setParameterMetadata(expression.getParameterMetadata()); result.setStaticSequenceType(expression.getStaticSequenceType()); result.setStaticContext(expression.getStaticContext()); result.setSequential(expression.isSequential()); @@ -1111,25 +1114,33 @@ public Node visitSwitchExpression(SwitchExpression expression, Node argument) { return result; } + private TypeswitchCase cloneTypeswitchCase(TypeswitchCase switchCase, Node argument) { + Expression body = (Expression) visit(switchCase.getReturnExpression(), argument); + TypeswitchCase copy = switchCase.getUnion() == null + ? new TypeswitchCase(switchCase.getVariableName(), body, switchCase.getVariableMetadata()) + : new TypeswitchCase( + switchCase.getVariableName(), switchCase.getUnion(), body, switchCase.getVariableMetadata()); + copy.setVariableSequenceType(switchCase.getVariableSequenceType()); + return copy; + } + + private TypeSwitchStatementCase cloneTypeswitchCase(TypeSwitchStatementCase switchCase, Node argument) { + Statement body = (Statement) visit(switchCase.getReturnStatement(), argument); + TypeSwitchStatementCase copy = switchCase.getUnion() == null + ? new TypeSwitchStatementCase(switchCase.getVariableName(), body, switchCase.getVariableMetadata()) + : new TypeSwitchStatementCase( + switchCase.getVariableName(), switchCase.getUnion(), body, switchCase.getVariableMetadata()); + copy.setVariableSequenceType(switchCase.getVariableSequenceType()); + return copy; + } + @Override public Node visitTypeSwitchExpression(TypeSwitchExpression expression, Node argument) { List resultCases = new ArrayList<>(); for (TypeswitchCase switchCase : expression.getCases()) { - if (switchCase.getUnion() == null) { - resultCases.add(new TypeswitchCase( - switchCase.getVariableName(), (Expression) visit(switchCase.getReturnExpression(), argument))); - } else { - resultCases.add(new TypeswitchCase(switchCase.getVariableName(), switchCase.getUnion(), (Expression) - visit(switchCase.getReturnExpression(), argument))); - } + resultCases.add(cloneTypeswitchCase(switchCase, argument)); } - TypeswitchCase defaultCase = (expression.getDefaultCase().getUnion() == null) - ? new TypeswitchCase(expression.getDefaultCase().getVariableName(), (Expression) - visit(expression.getDefaultCase().getReturnExpression(), argument)) - : new TypeswitchCase( - expression.getDefaultCase().getVariableName(), - expression.getDefaultCase().getUnion(), - (Expression) visit(expression.getDefaultCase().getReturnExpression(), argument)); + TypeswitchCase defaultCase = cloneTypeswitchCase(expression.getDefaultCase(), argument); TypeSwitchExpression result = new TypeSwitchExpression( (Expression) visit(expression.getTestCondition(), argument), @@ -1322,22 +1333,10 @@ public Node visitSwitchStatement(SwitchStatement statement, Node argument) { @Override public Node visitTypeSwitchStatement(TypeSwitchStatement statement, Node argument) { List resultCases = new ArrayList<>(); - statement.getCases().forEach(twsCase -> { - if (twsCase.getUnion() == null) { - resultCases.add(new TypeSwitchStatementCase( - twsCase.getVariableName(), (Statement) visit(twsCase.getReturnStatement(), argument))); - } else { - resultCases.add(new TypeSwitchStatementCase(twsCase.getVariableName(), twsCase.getUnion(), (Statement) - visit(twsCase.getReturnStatement(), argument))); - } - }); - TypeSwitchStatementCase defaultCase = (statement.getDefaultCase().getUnion() == null) - ? new TypeSwitchStatementCase(statement.getDefaultCase().getVariableName(), (Statement) - visit(statement.getDefaultCase().getReturnStatement(), argument)) - : new TypeSwitchStatementCase( - statement.getDefaultCase().getVariableName(), - statement.getDefaultCase().getUnion(), - (Statement) visit(statement.getDefaultCase().getReturnStatement(), argument)); + for (TypeSwitchStatementCase switchCase : statement.getCases()) { + resultCases.add(cloneTypeswitchCase(switchCase, argument)); + } + TypeSwitchStatementCase defaultCase = cloneTypeswitchCase(statement.getDefaultCase(), argument); TypeSwitchStatement result = new TypeSwitchStatement( (Expression) visit(statement.getTestCondition(), argument), @@ -1376,26 +1375,18 @@ public Node visitCommaVariableDeclStatement(CommaVariableDeclStatement statement @Override public Node visitVariableDeclStatement(VariableDeclStatement statement, Node argument) { - if (statement.getVariableExpression() != null) { - VariableDeclStatement result = new VariableDeclStatement( - statement.getAnnotations(), - statement.getVariableName(), - statement.getActualSequenceType(), - (Expression) visit(statement.getVariableExpression(), argument), - statement.getMetadata()); - result.setStaticContext(statement.getStaticContext()); - result.setStaticSequenceType(statement.getSequenceType()); - result.setSequential(statement.isSequential()); - return result; - } + Expression initializer = statement.getVariableExpression() == null + ? null + : (Expression) visit(statement.getVariableExpression(), argument); VariableDeclStatement result = new VariableDeclStatement( statement.getAnnotations(), statement.getVariableName(), statement.getActualSequenceType(), - null, - statement.getMetadata()); + initializer, + statement.getMetadata(), + statement.getVariableMetadata()); result.setStaticContext(statement.getStaticContext()); - result.setStaticSequenceType(statement.getSequenceType()); + result.setStaticSequenceType(statement.getStaticSequenceType()); result.setSequential(statement.isSequential()); return result; } diff --git a/src/main/java/org/rumbledb/compiler/FunctionInliningVisitor.java b/src/main/java/org/rumbledb/compiler/FunctionInliningVisitor.java index 2c56d6e1e6..98706cc597 100644 --- a/src/main/java/org/rumbledb/compiler/FunctionInliningVisitor.java +++ b/src/main/java/org/rumbledb/compiler/FunctionInliningVisitor.java @@ -111,12 +111,13 @@ private static boolean allArgumentsMatch(FunctionCallExpression expression, List private void addCastCase( List cases, Expression expression, SequenceType testType, SequenceType targetType) { if (testType.equals(targetType)) { - cases.add(new TypeswitchCase(null, Collections.singletonList(testType), expression)); + cases.add(new TypeswitchCase(null, Collections.singletonList(testType), expression, null)); } else if (testType.getArity().isSubtypeOf(Arity.OneOrZero)) { cases.add(new TypeswitchCase( null, Collections.singletonList(testType), - new CastExpression(expression, targetType, expression.getMetadata()))); + new CastExpression(expression, targetType, expression.getMetadata()), + null)); } else { Name variableName = Name.createVariableInNoNamespace( String.format("param%s", UUID.randomUUID().toString().replaceAll("-", ""))); @@ -138,7 +139,8 @@ private void addCastCase( cases.add(new TypeswitchCase( null, Collections.singletonList(testType), - new FlworExpression(returnClause, expression.getMetadata()))); + new FlworExpression(returnClause, expression.getMetadata()), + null)); } } @@ -236,10 +238,8 @@ && usesQNameCoercionErrorSemantics(expression) new TypeswitchCase( null, new TreatExpression( - expression, - paramType, - ErrorCode.UnexpectedTypeErrorCode, - expression.getMetadata())), + expression, paramType, ErrorCode.UnexpectedTypeErrorCode, expression.getMetadata()), + null), expression.getMetadata()); typeSwitchExpression.setStaticSequenceType(paramType); return typeSwitchExpression; @@ -303,10 +303,8 @@ && usesQNameCoercionErrorSemantics(expression) new TypeswitchCase( null, new TreatExpression( - expression, - paramType, - ErrorCode.UnexpectedTypeErrorCode, - expression.getMetadata())), + expression, paramType, ErrorCode.UnexpectedTypeErrorCode, expression.getMetadata()), + null), expression.getMetadata()); typeSwitchExpression.setStaticSequenceType(paramType); return typeSwitchExpression; @@ -371,10 +369,8 @@ && usesQNameCoercionErrorSemantics(expression) new TypeswitchCase( null, new TreatExpression( - expression, - paramType, - ErrorCode.UnexpectedTypeErrorCode, - expression.getMetadata())), + expression, paramType, ErrorCode.UnexpectedTypeErrorCode, expression.getMetadata()), + null), expression.getMetadata()); typeSwitchExpression.setStaticSequenceType(paramType); return typeSwitchExpression; diff --git a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java index 1232b73fd5..2abca4456d 100644 --- a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java +++ b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java @@ -1860,6 +1860,7 @@ public StaticContext visitTypeSwitchExpression(TypeSwitchExpression expression, variableType = variableType == null ? st : variableType.leastCommonSupertypeWith(st); } returnExpression.getStaticContext().replaceVariableSequenceType(variableName, variableType); + typeswitchCase.setVariableSequenceType(variableType); } visit(returnExpression, argument); @@ -1873,6 +1874,7 @@ public StaticContext visitTypeSwitchExpression(TypeSwitchExpression expression, // if we bind a variable in the default case, we infer testCondition type if (variableName != null) { returnExpression.getStaticContext().replaceVariableSequenceType(variableName, conditionType); + expression.getDefaultCase().setVariableSequenceType(conditionType); } visit(returnExpression, argument); SequenceType defaultType = returnExpression.getStaticSequenceType(); @@ -2955,6 +2957,7 @@ public StaticContext visitTypeSwitchStatement(TypeSwitchStatement statement, Sta variableType = variableType == null ? st : variableType.leastCommonSupertypeWith(st); } returnStatement.getStaticContext().replaceVariableSequenceType(variableName, variableType); + typeswitchCase.setVariableSequenceType(variableType); } visit(returnStatement, argument); @@ -2968,6 +2971,7 @@ public StaticContext visitTypeSwitchStatement(TypeSwitchStatement statement, Sta // if we bind a variable in the default case, we infer testCondition type if (variableName != null) { returnStatement.getStaticContext().replaceVariableSequenceType(variableName, conditionType); + statement.getDefaultCase().setVariableSequenceType(conditionType); } visit(returnStatement, argument); SequenceType defaultType = returnStatement.getStaticSequenceType(); diff --git a/src/main/java/org/rumbledb/compiler/StaticContextVisitor.java b/src/main/java/org/rumbledb/compiler/StaticContextVisitor.java index 179950643f..5e51abf77e 100644 --- a/src/main/java/org/rumbledb/compiler/StaticContextVisitor.java +++ b/src/main/java/org/rumbledb/compiler/StaticContextVisitor.java @@ -167,7 +167,10 @@ public StaticContext visitVariableReference(VariableReferenceExpression expressi private void populateFunctionDeclarationStaticContext( StaticContext functionDeclarationContext, InlineFunctionExpression expression) { for (Name name : expression.getParams().keySet()) { - functionDeclarationContext.addVariable(name, expression.getParams().get(name), expression.getMetadata()); + functionDeclarationContext.addVariable( + name, + expression.getParams().get(name), + Objects.requireNonNullElse(expression.getParameterMetadata().get(name), expression.getMetadata())); } } @@ -204,7 +207,11 @@ public StaticContext visitInlineFunctionExpr(InlineFunctionExpression expression // define a static context for the function body, add params to the context and visit the body expression StaticContext functionDeclarationContext = new StaticContext(argument); for (Entry entry : expression.getParams().entrySet()) { - functionDeclarationContext.addVariable(entry.getKey(), entry.getValue(), expression.getMetadata()); + functionDeclarationContext.addVariable( + entry.getKey(), + entry.getValue(), + Objects.requireNonNullElse( + expression.getParameterMetadata().get(entry.getKey()), expression.getMetadata())); } // visit the body first to make its execution mode available while adding the function to the catalog this.visit(expression.getBody(), functionDeclarationContext); @@ -256,35 +263,37 @@ public StaticContext visitWindowClause(WindowClause clause, StaticContext argume // Three different static contexts are created to avoid situations like start condition seeing end condition // variables. StaticContext startContext = new StaticContext(argument); - addWindowVars(start, clause.getSequenceType(), startContext, clause); + addWindowVars(start, startContext, clause); this.visit(clause.getStartCondition().expression(), startContext); if (end != null) { StaticContext endContext = new StaticContext(startContext); - addWindowVars(end, clause.getSequenceType(), endContext, clause); + addWindowVars(end, endContext, clause); this.visit(clause.getEndCondition().expression(), endContext); } StaticContext followingClausesContext = new StaticContext(argument); - followingClausesContext.addVariable(clause.getWindowVariable(), clause.getSequenceType(), clause.getMetadata()); - addWindowVars(start, clause.getSequenceType(), followingClausesContext, clause); + followingClausesContext.addVariable( + clause.getWindowVariable(), + clause.getSequenceType(), + Objects.requireNonNullElse( + clause.getVariableMetadata(clause.getWindowVariable()), clause.getMetadata())); + addWindowVars(start, followingClausesContext, clause); if (end != null) { - addWindowVars(end, clause.getSequenceType(), followingClausesContext, clause); + addWindowVars(end, followingClausesContext, clause); } this.visit(clause.getNextClause(), followingClausesContext); return argument; } - private static void addWindowVars( - WindowClause.WindowVars vars, SequenceType itemType, StaticContext context, WindowClause clause) { - SequenceType optionalItem = new SequenceType(itemType.getItemType(), SequenceType.Arity.OneOrZero); - if (vars.currentItem() != null) context.addVariable(vars.currentItem(), optionalItem, clause.getMetadata()); - if (vars.position() != null) + private static void addWindowVars(WindowClause.WindowVars vars, StaticContext context, WindowClause clause) { + for (Name name : vars.names()) { context.addVariable( - vars.position(), new SequenceType(BuiltinTypesCatalogue.integerItem), clause.getMetadata()); - if (vars.previousItem() != null) context.addVariable(vars.previousItem(), optionalItem, clause.getMetadata()); - if (vars.nextItem() != null) context.addVariable(vars.nextItem(), optionalItem, clause.getMetadata()); + name, + clause.getConditionVariableSequenceType(name), + Objects.requireNonNullElse(clause.getVariableMetadata(name), clause.getMetadata())); + } } @Override @@ -345,7 +354,9 @@ public StaticContext visitOrderByClause(OrderByClause clause, StaticContext argu public StaticContext visitCountClause(CountClause clause, StaticContext argument) { StaticContext result = new StaticContext(argument); result.addVariable( - clause.getCountVariableName(), SequenceType.createSequenceType("integer"), clause.getMetadata()); + clause.getCountVariableName(), + clause.getVariableSequenceType(), + Objects.requireNonNullElse(clause.getVariableMetadata(), clause.getMetadata())); this.visit(clause.getNextClause(), result); return argument; } @@ -366,7 +377,10 @@ public StaticContext visitTypeSwitchExpression(TypeSwitchExpression expression, StaticContext caseContext = new StaticContext(argument); Name variableName = c.getVariableName(); if (variableName != null) { - caseContext.addVariable(variableName, null, expression.getMetadata()); + caseContext.addVariable( + variableName, + null, + Objects.requireNonNullElse(c.getVariableMetadata(), expression.getMetadata())); } this.visit(c.getReturnExpression(), caseContext); for (SequenceType sequenceType : c.getUnion()) { @@ -380,7 +394,11 @@ public StaticContext visitTypeSwitchExpression(TypeSwitchExpression expression, } else { // add variable to child context to visit default return expression StaticContext defaultCaseStaticContext = new StaticContext(argument); - defaultCaseStaticContext.addVariable(defaultCaseVariableName, null, expression.getMetadata()); + defaultCaseStaticContext.addVariable( + defaultCaseVariableName, + null, + Objects.requireNonNullElse( + expression.getDefaultCase().getVariableMetadata(), expression.getMetadata())); this.visit(expression.getDefaultCase().getReturnExpression(), defaultCaseStaticContext); } // return the given context unchanged as defined variables go out of scope @@ -397,7 +415,8 @@ public StaticContext visitVariableDeclaration(VariableDeclaration variableDeclar argument.addVariable( variableDeclaration.getVariableName(), variableDeclaration.getActualSequenceType(), - variableDeclaration.getMetadata(), + Objects.requireNonNullElse( + variableDeclaration.getVariableMetadata(), variableDeclaration.getMetadata()), variableDeclaration.isAssignable()); return argument; } @@ -438,7 +457,9 @@ private StaticContext visitCopyDecl( result.addVariable( copyDeclaration.getVariableName(), copyDeclaration.getSourceSequenceType(), - copyDeclaration.getSourceExpression().getMetadata()); + Objects.requireNonNullElse( + copyDeclaration.getVariableMetadata(), + copyDeclaration.getSourceExpression().getMetadata())); copyDeclaration .getSourceSequenceType() .resolve(copyContext, copyDeclaration.getSourceExpression().getMetadata()); @@ -577,7 +598,8 @@ public StaticContext visitVariableDeclStatement( result.addVariable( variableDeclStatement.getVariableName(), variableDeclStatement.getActualSequenceType(), - variableDeclStatement.getMetadata(), + Objects.requireNonNullElse( + variableDeclStatement.getVariableMetadata(), variableDeclStatement.getMetadata()), variableDeclStatement.isAssignable()); variableDeclStatement.setStaticContext(result); return result; @@ -631,7 +653,10 @@ public StaticContext visitTypeSwitchStatement(TypeSwitchStatement statement, Sta StaticContext caseContext = new StaticContext(argument); Name variableName = tssc.getVariableName(); if (variableName != null) { - caseContext.addVariable(variableName, null, statement.getMetadata()); + caseContext.addVariable( + variableName, + null, + Objects.requireNonNullElse(tssc.getVariableMetadata(), statement.getMetadata())); } this.visit(tssc.getReturnStatement(), caseContext); for (SequenceType sequenceType : tssc.getUnion()) { @@ -643,7 +668,11 @@ public StaticContext visitTypeSwitchStatement(TypeSwitchStatement statement, Sta this.visit(statement.getDefaultCase().getReturnStatement(), argument); } else { StaticContext defaultCaseStaticContext = new StaticContext(argument); - defaultCaseStaticContext.addVariable(defaultCaseVariableName, null, statement.getMetadata()); + defaultCaseStaticContext.addVariable( + defaultCaseVariableName, + null, + Objects.requireNonNullElse( + statement.getDefaultCase().getVariableMetadata(), statement.getMetadata())); this.visit(statement.getDefaultCase().getReturnStatement(), defaultCaseStaticContext); } return argument; diff --git a/src/main/java/org/rumbledb/compiler/translation/ControlTranslation.java b/src/main/java/org/rumbledb/compiler/translation/ControlTranslation.java index bbac85afda..4b3c39c2d3 100644 --- a/src/main/java/org/rumbledb/compiler/translation/ControlTranslation.java +++ b/src/main/java/org/rumbledb/compiler/translation/ControlTranslation.java @@ -107,7 +107,11 @@ TypeSwitchExpression typeswitchExpr( } } Expression expression = visitExprSingle.apply(expr.ret()); - cases.add(new TypeswitchCase(variableName, union, expression)); + cases.add(new TypeswitchCase( + variableName, + union, + expression, + expr.varRef() == null ? null : translationContext.metadata(expr.varRef()))); } Name defaultVariableName = null; if (ctx.defaultVar() != null) { @@ -117,7 +121,10 @@ TypeSwitchExpression typeswitchExpr( return new TypeSwitchExpression( condition, cases, - new TypeswitchCase(defaultVariableName, defaultCase), + new TypeswitchCase( + defaultVariableName, + defaultCase, + ctx.defaultVar() == null ? null : translationContext.metadata(ctx.defaultVar())), translationContext.metadata(ctx.context())); } diff --git a/src/main/java/org/rumbledb/compiler/translation/DeclarationTranslation.java b/src/main/java/org/rumbledb/compiler/translation/DeclarationTranslation.java index f102d66c27..f10e8226d1 100644 --- a/src/main/java/org/rumbledb/compiler/translation/DeclarationTranslation.java +++ b/src/main/java/org/rumbledb/compiler/translation/DeclarationTranslation.java @@ -17,6 +17,7 @@ import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.function.BiFunction; import java.util.function.Function; @@ -32,6 +33,7 @@ import org.rumbledb.context.Name; import org.rumbledb.errorcodes.ErrorCode; import org.rumbledb.exceptions.DuplicateParamNameException; +import org.rumbledb.exceptions.ExceptionMetadata; import org.rumbledb.expressions.Expression; import org.rumbledb.expressions.module.FunctionDeclaration; import org.rumbledb.expressions.module.OptionDeclaration; @@ -145,6 +147,7 @@ FunctionDeclaration functionDecl( Name name = parseFunctionName.apply(ctx.functionName()); FunctionDeclarationValidator.validateFunctionName(name, translationContext.metadata(ctx.functionName())); LinkedHashMap fnParams = new LinkedHashMap<>(); + Map parameterMetadata = new LinkedHashMap<>(); SequenceType fnReturnType = null; for (FunctionDeclContext.FunctionParam param : ctx.params()) { Name paramName = parseVariableBinding.apply(param.name()); @@ -155,6 +158,7 @@ FunctionDeclaration functionDecl( ? SequenceType.createSequenceType("item*") : processSequenceType.apply(param.sequenceType()); fnParams.put(paramName, paramType); + parameterMetadata.put(paramName, translationContext.metadata(param.name())); } if (ctx.returnType() != null) { fnReturnType = processReturnType.apply(ctx.returnType()); @@ -169,6 +173,7 @@ FunctionDeclaration functionDecl( ctx.isExternal(), translationContext.metadata(ctx.context()), translationContext.metadata(ctx.functionName())); + inlineFunction.setParameterMetadata(Map.copyOf(parameterMetadata)); return new FunctionDeclaration(inlineFunction, translationContext.metadata(ctx.context())); } } diff --git a/src/main/java/org/rumbledb/compiler/translation/FlworTranslation.java b/src/main/java/org/rumbledb/compiler/translation/FlworTranslation.java index 29d769eb06..ff02d8081e 100644 --- a/src/main/java/org/rumbledb/compiler/translation/FlworTranslation.java +++ b/src/main/java/org/rumbledb/compiler/translation/FlworTranslation.java @@ -16,7 +16,9 @@ package org.rumbledb.compiler.translation; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.function.Function; import org.antlr.v4.runtime.ParserRuleContext; @@ -221,7 +223,9 @@ public static CountClause countClause( TranslationContext translationContext, Function parseVariableBinding) { return new CountClause( - parseVariableBinding.apply(ctx.varBinding()), translationContext.metadata(ctx.context())); + parseVariableBinding.apply(ctx.varBinding()), + translationContext.metadata(ctx.context()), + translationContext.metadata(ctx.varBinding())); } public static < @@ -289,14 +293,21 @@ WindowClause windowClause( Function parseVariableBinding, Function processSequenceType, Function visitExprSingle) { - Name windowVariable = parseVariableBinding.apply(ctx.windowVariable()); + Map variableMetadata = new LinkedHashMap<>(); + Name windowVariable = + parseWindowVariable(ctx.windowVariable(), parseVariableBinding, translationContext, variableMetadata); SequenceType sequenceType = ctx.seqType() == null ? null : processSequenceType.apply(ctx.seqType()); Expression expression = visitExprSingle.apply(ctx.expression()); - WindowClause.WindowCondition start = - buildWindowCondition(ctx.startCondition(), parseVariableBinding, visitExprSingle); + WindowClause.WindowCondition start = buildWindowCondition( + ctx.startCondition(), parseVariableBinding, visitExprSingle, translationContext, variableMetadata); WindowClause.WindowCondition end = ctx.endCondition() == null ? null - : buildWindowCondition(ctx.endCondition(), parseVariableBinding, visitExprSingle); + : buildWindowCondition( + ctx.endCondition(), + parseVariableBinding, + visitExprSingle, + translationContext, + variableMetadata); validateWindowVariables(windowVariable, start, end, translationContext.metadata(ctx.context())); return new WindowClause( ctx.windowType(), @@ -305,25 +316,46 @@ WindowClause windowClause( expression, start, end, - translationContext.metadata(ctx.context())); + translationContext.metadata(ctx.context()), + variableMetadata); } private static WindowClause.WindowCondition buildWindowCondition( WindowConditionContext ctx, Function parseVariableBinding, - Function visitExprSingle) { + Function visitExprSingle, + TranslationContext translationContext, + Map variableMetadata) { return new WindowClause.WindowCondition( - buildWindowVars(ctx.vars(), parseVariableBinding), visitExprSingle.apply(ctx.exprSingle()), ctx.only()); + buildWindowVars(ctx.vars(), parseVariableBinding, translationContext, variableMetadata), + visitExprSingle.apply(ctx.exprSingle()), + ctx.only()); } private static WindowClause.WindowVars buildWindowVars( - WindowVarsContext ctx, Function parseVariableBinding) { - Name current = ctx.currentItem() == null ? null : parseVariableBinding.apply(ctx.currentItem()); - Name position = ctx.positionalVar() == null ? null : parseVariableBinding.apply(ctx.positionalVar()); - Name previous = ctx.previousItem() == null ? null : parseVariableBinding.apply(ctx.previousItem()); - Name next = ctx.nextItem() == null ? null : parseVariableBinding.apply(ctx.nextItem()); - return new WindowClause.WindowVars(current, position, previous, next); + WindowVarsContext ctx, + Function parseVariableBinding, + TranslationContext translationContext, + Map variableMetadata) { + return new WindowClause.WindowVars( + parseWindowVariable(ctx.currentItem(), parseVariableBinding, translationContext, variableMetadata), + parseWindowVariable(ctx.positionalVar(), parseVariableBinding, translationContext, variableMetadata), + parseWindowVariable(ctx.previousItem(), parseVariableBinding, translationContext, variableMetadata), + parseWindowVariable(ctx.nextItem(), parseVariableBinding, translationContext, variableMetadata)); + } + + private static Name parseWindowVariable( + VarBindingCtx ctx, + Function parseVariableBinding, + TranslationContext translationContext, + Map variableMetadata) { + if (ctx == null) { + return null; + } + Name name = parseVariableBinding.apply(ctx); + variableMetadata.put(name, translationContext.metadata(ctx)); + return name; } private static void validateWindowVariables( diff --git a/src/main/java/org/rumbledb/compiler/translation/PrimaryTranslation.java b/src/main/java/org/rumbledb/compiler/translation/PrimaryTranslation.java index 4543bdda61..c41109eced 100644 --- a/src/main/java/org/rumbledb/compiler/translation/PrimaryTranslation.java +++ b/src/main/java/org/rumbledb/compiler/translation/PrimaryTranslation.java @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.function.BiFunction; import java.util.function.Function; @@ -221,6 +222,7 @@ InlineFunctionExpression inlineFunctionExpr( List annotations = processAnnotations.apply(ctx.annotations()); AnnotationValidator.validateInlineFunctionAnnotations(annotations); LinkedHashMap fnParams = new LinkedHashMap<>(); + Map parameterMetadata = new LinkedHashMap<>(); SequenceType fnReturnType = SequenceType.createSequenceType("item*"); if (ctx.params() != null) { for (InlineFunctionExprContext.InlineFunctionParam param : ctx.params()) { @@ -236,6 +238,7 @@ InlineFunctionExpression inlineFunctionExpr( ? processSequenceType.apply(param.sequenceType()) : SequenceType.createSequenceType("item*"); fnParams.put(paramName, paramType); + parameterMetadata.put(paramName, translationContext.metadata(param.name())); } } @@ -245,8 +248,10 @@ InlineFunctionExpression inlineFunctionExpr( StatementsAndOptionalExpr funcBody = visitStatementsAndOptionalExpr.apply(ctx.fnBody()); - return new InlineFunctionExpression( + InlineFunctionExpression function = new InlineFunctionExpression( annotations, null, fnParams, fnReturnType, funcBody, translationContext.metadata(ctx.context())); + function.setParameterMetadata(Map.copyOf(parameterMetadata)); + return function; } public static ArrayConstructorExpression squareArrayConstructor( diff --git a/src/main/java/org/rumbledb/compiler/translation/QuantifiedTranslation.java b/src/main/java/org/rumbledb/compiler/translation/QuantifiedTranslation.java index 63a8d78702..2fed230888 100644 --- a/src/main/java/org/rumbledb/compiler/translation/QuantifiedTranslation.java +++ b/src/main/java/org/rumbledb/compiler/translation/QuantifiedTranslation.java @@ -67,7 +67,7 @@ FunctionCallExpression quantifiedExpr( null, varExpression, translationContext.metadata(currentVariable.context()), - null, + translationContext.metadata(currentVariable.varBinding()), null); if (lastClause != null) { lastClause.chainWith(newClause); diff --git a/src/main/java/org/rumbledb/compiler/translation/scripting/ControlStatementTranslation.java b/src/main/java/org/rumbledb/compiler/translation/scripting/ControlStatementTranslation.java index 93f6d2fad8..bf259620e6 100644 --- a/src/main/java/org/rumbledb/compiler/translation/scripting/ControlStatementTranslation.java +++ b/src/main/java/org/rumbledb/compiler/translation/scripting/ControlStatementTranslation.java @@ -118,7 +118,11 @@ TypeSwitchStatement typeSwitchStatement( } } Statement returnStatement = visitStatement.apply(stmt.ret()); - cases.add(new TypeSwitchStatementCase(variableName, union, returnStatement)); + cases.add(new TypeSwitchStatementCase( + variableName, + union, + returnStatement, + stmt.varRef() == null ? null : translationContext.metadata(stmt.varRef()))); } Name defaultVariableName = null; if (ctx.defaultVar() != null) { @@ -128,7 +132,10 @@ TypeSwitchStatement typeSwitchStatement( return new TypeSwitchStatement( condition, cases, - new TypeSwitchStatementCase(defaultVariableName, defaultStatement), + new TypeSwitchStatementCase( + defaultVariableName, + defaultStatement, + ctx.defaultVar() == null ? null : translationContext.metadata(ctx.defaultVar())), translationContext.metadata(ctx.context())); } diff --git a/src/main/java/org/rumbledb/compiler/translation/scripting/DeclarationStatementTranslation.java b/src/main/java/org/rumbledb/compiler/translation/scripting/DeclarationStatementTranslation.java index abd804fa19..f1a1a9c6c8 100644 --- a/src/main/java/org/rumbledb/compiler/translation/scripting/DeclarationStatementTranslation.java +++ b/src/main/java/org/rumbledb/compiler/translation/scripting/DeclarationStatementTranslation.java @@ -67,7 +67,12 @@ Statement varDeclStatement( } } variables.add(new VariableDeclStatement( - annotations, var, seq, exprSingle, translationContext.metadata(varDecl.context()))); + annotations, + var, + seq, + exprSingle, + translationContext.metadata(varDecl.context()), + translationContext.metadata(varDecl.varRef()))); } if (variables.size() == 1) { return variables.get(0); diff --git a/src/main/java/org/rumbledb/compiler/translation/update/UpdateTranslation.java b/src/main/java/org/rumbledb/compiler/translation/update/UpdateTranslation.java index 3d205efb5c..9704144c3e 100644 --- a/src/main/java/org/rumbledb/compiler/translation/update/UpdateTranslation.java +++ b/src/main/java/org/rumbledb/compiler/translation/update/UpdateTranslation.java @@ -141,7 +141,7 @@ TransformExpression transformExpr( .map(copyDeclCtx -> { Name var = parseVariableBinding.apply(copyDeclCtx.varRef()); Expression expr = visitExprSingle.apply(copyDeclCtx.srcExpr()); - return new CopyDeclaration(var, expr); + return new CopyDeclaration(var, expr, translationContext.metadata(copyDeclCtx.varRef())); }) .collect(Collectors.toList()); Expression modifyExpression = visitExprSingle.apply(ctx.modExpr()); diff --git a/src/main/java/org/rumbledb/expressions/control/TypeswitchCase.java b/src/main/java/org/rumbledb/expressions/control/TypeswitchCase.java index 952dfa7bad..7473dd4dbe 100644 --- a/src/main/java/org/rumbledb/expressions/control/TypeswitchCase.java +++ b/src/main/java/org/rumbledb/expressions/control/TypeswitchCase.java @@ -19,8 +19,10 @@ import java.util.List; import lombok.Getter; +import lombok.Setter; import org.rumbledb.context.Name; +import org.rumbledb.exceptions.ExceptionMetadata; import org.rumbledb.expressions.Expression; import org.rumbledb.types.SequenceType; @@ -33,17 +35,28 @@ public class TypeswitchCase { private final Name variableName; + private final ExceptionMetadata variableMetadata; + + @Setter + private SequenceType variableSequenceType; + private final List union; private final Expression returnExpression; - public TypeswitchCase(Name variableName, List union, Expression returnExpression) { + public TypeswitchCase( + Name variableName, + List union, + Expression returnExpression, + ExceptionMetadata variableMetadata) { this.variableName = variableName; + this.variableMetadata = variableMetadata; this.union = new ArrayList<>(union); this.returnExpression = returnExpression; } - public TypeswitchCase(Name variableName, Expression returnExpression) { + public TypeswitchCase(Name variableName, Expression returnExpression, ExceptionMetadata variableMetadata) { this.variableName = variableName; + this.variableMetadata = variableMetadata; this.union = null; this.returnExpression = returnExpression; } diff --git a/src/main/java/org/rumbledb/expressions/flowr/CountClause.java b/src/main/java/org/rumbledb/expressions/flowr/CountClause.java index 256b65c31f..ed98f92c42 100644 --- a/src/main/java/org/rumbledb/expressions/flowr/CountClause.java +++ b/src/main/java/org/rumbledb/expressions/flowr/CountClause.java @@ -18,17 +18,25 @@ import java.util.Collections; import java.util.List; +import lombok.Getter; + import org.rumbledb.context.Name; import org.rumbledb.exceptions.ExceptionMetadata; import org.rumbledb.expressions.AbstractNodeVisitor; import org.rumbledb.expressions.Node; +import org.rumbledb.types.BuiltinTypesCatalogue; +import org.rumbledb.types.SequenceType; public class CountClause extends Clause { private final Name variableName; - public CountClause(Name variableName, ExceptionMetadata metadata) { + @Getter + private final ExceptionMetadata variableMetadata; + + public CountClause(Name variableName, ExceptionMetadata metadata, ExceptionMetadata variableMetadata) { super(FLWOR_CLAUSES.COUNT, metadata); this.variableName = variableName; + this.variableMetadata = variableMetadata; } @Override @@ -45,6 +53,10 @@ public Name getCountVariableName() { return this.variableName; } + public SequenceType getVariableSequenceType() { + return new SequenceType(BuiltinTypesCatalogue.integerItem); + } + @Override public void print(StringBuilder buffer, int indent) { for (int i = 0; i < indent; ++i) { diff --git a/src/main/java/org/rumbledb/expressions/flowr/WindowClause.java b/src/main/java/org/rumbledb/expressions/flowr/WindowClause.java index 3243706518..3752f6a01a 100644 --- a/src/main/java/org/rumbledb/expressions/flowr/WindowClause.java +++ b/src/main/java/org/rumbledb/expressions/flowr/WindowClause.java @@ -18,6 +18,7 @@ import java.io.Serializable; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.stream.Stream; import lombok.Getter; @@ -27,6 +28,7 @@ import org.rumbledb.expressions.AbstractNodeVisitor; import org.rumbledb.expressions.Expression; import org.rumbledb.expressions.Node; +import org.rumbledb.types.BuiltinTypesCatalogue; import org.rumbledb.types.SequenceType; public class WindowClause extends Clause { @@ -76,6 +78,10 @@ public record WindowCondition(WindowVars variables, Expression expression, boole private final SequenceType sequenceType; + /** Token metadata for each variable declared by the window, whose names are distinct. */ + @Getter + private final Map variableMetadata; + @Getter private final Expression expression; @@ -92,9 +98,11 @@ public WindowClause( Expression expression, WindowCondition startCondition, WindowCondition endCondition, - ExceptionMetadata metadata) { + ExceptionMetadata metadata, + Map variableMetadata) { super(FLWOR_CLAUSES.WINDOW, metadata); this.windowType = windowType; + this.variableMetadata = Map.copyOf(variableMetadata); this.windowVariable = windowVariable; this.sequenceType = sequenceType; this.expression = expression; @@ -110,6 +118,19 @@ public SequenceType getSequenceType() { return this.sequenceType == null ? SequenceType.createSequenceType("item*") : this.sequenceType; } + public ExceptionMetadata getVariableMetadata(Name name) { + return this.variableMetadata.get(name); + } + + public SequenceType getConditionVariableSequenceType(Name name) { + if (name.equals(this.startCondition.variables().position()) + || this.endCondition != null + && name.equals(this.endCondition.variables().position())) { + return new SequenceType(BuiltinTypesCatalogue.integerItem); + } + return new SequenceType(getSequenceType().getItemType(), SequenceType.Arity.OneOrZero); + } + @Override public List getChildren() { List result = new ArrayList<>(); diff --git a/src/main/java/org/rumbledb/expressions/primary/InlineFunctionExpression.java b/src/main/java/org/rumbledb/expressions/primary/InlineFunctionExpression.java index 19b2bec04e..679e93f36e 100644 --- a/src/main/java/org/rumbledb/expressions/primary/InlineFunctionExpression.java +++ b/src/main/java/org/rumbledb/expressions/primary/InlineFunctionExpression.java @@ -53,6 +53,11 @@ public class InlineFunctionExpression extends Expression { @Getter private final Map params; + /** Synthetic parameters have no source token and are absent from this map. */ + @Getter + @Setter + private Map parameterMetadata = Map.of(); + private final SequenceType returnType; @Getter diff --git a/src/main/java/org/rumbledb/expressions/scripting/control/TypeSwitchStatementCase.java b/src/main/java/org/rumbledb/expressions/scripting/control/TypeSwitchStatementCase.java index a1c99ce8cc..e56e0f3d1e 100644 --- a/src/main/java/org/rumbledb/expressions/scripting/control/TypeSwitchStatementCase.java +++ b/src/main/java/org/rumbledb/expressions/scripting/control/TypeSwitchStatementCase.java @@ -18,8 +18,10 @@ import java.util.List; import lombok.Getter; +import lombok.Setter; import org.rumbledb.context.Name; +import org.rumbledb.exceptions.ExceptionMetadata; import org.rumbledb.expressions.scripting.statement.Statement; import org.rumbledb.types.SequenceType; @@ -31,17 +33,28 @@ @Getter public class TypeSwitchStatementCase { private final Name variableName; + private final ExceptionMetadata variableMetadata; + + @Setter + private SequenceType variableSequenceType; + private final List union; private final Statement returnStatement; - public TypeSwitchStatementCase(Name variableName, List union, Statement returnStatement) { + public TypeSwitchStatementCase( + Name variableName, + List union, + Statement returnStatement, + ExceptionMetadata variableMetadata) { this.variableName = variableName; + this.variableMetadata = variableMetadata; this.union = union; this.returnStatement = returnStatement; } - public TypeSwitchStatementCase(Name variableName, Statement returnStatement) { + public TypeSwitchStatementCase(Name variableName, Statement returnStatement, ExceptionMetadata variableMetadata) { this.variableName = variableName; + this.variableMetadata = variableMetadata; this.union = null; this.returnStatement = returnStatement; } diff --git a/src/main/java/org/rumbledb/expressions/scripting/declaration/VariableDeclStatement.java b/src/main/java/org/rumbledb/expressions/scripting/declaration/VariableDeclStatement.java index 913ea36864..e97f834fed 100644 --- a/src/main/java/org/rumbledb/expressions/scripting/declaration/VariableDeclStatement.java +++ b/src/main/java/org/rumbledb/expressions/scripting/declaration/VariableDeclStatement.java @@ -44,6 +44,9 @@ public class VariableDeclStatement extends Statement { @Getter private final Name variableName; + @Getter + private final ExceptionMetadata variableMetadata; + private final SequenceType variableSequenceType; @Getter @@ -57,10 +60,12 @@ public VariableDeclStatement( Name variableName, SequenceType variableSequenceType, Expression variableExpression, - ExceptionMetadata metadata) { + ExceptionMetadata metadata, + ExceptionMetadata variableMetadata) { super(metadata); this.annotations = annotations; this.variableName = variableName; + this.variableMetadata = variableMetadata; this.variableSequenceType = variableSequenceType; this.variableExpression = variableExpression; if (this.annotations != null) { diff --git a/src/main/java/org/rumbledb/expressions/update/CopyDeclaration.java b/src/main/java/org/rumbledb/expressions/update/CopyDeclaration.java index 92e8b33896..d495fc08e4 100644 --- a/src/main/java/org/rumbledb/expressions/update/CopyDeclaration.java +++ b/src/main/java/org/rumbledb/expressions/update/CopyDeclaration.java @@ -18,6 +18,7 @@ import lombok.Getter; import org.rumbledb.context.Name; +import org.rumbledb.exceptions.ExceptionMetadata; import org.rumbledb.expressions.Expression; import org.rumbledb.types.SequenceType; @@ -25,13 +26,15 @@ public class CopyDeclaration { private Name variableName; + private final ExceptionMetadata variableMetadata; private Expression sourceExpression; - public CopyDeclaration(Name variableName, Expression sourceExpression) { + public CopyDeclaration(Name variableName, Expression sourceExpression, ExceptionMetadata variableMetadata) { if (variableName == null) { throw new IllegalArgumentException("Copy clause var decls cannot be empty"); } this.variableName = variableName; + this.variableMetadata = variableMetadata; this.sourceExpression = sourceExpression; } From ddd297a67af2fed1db4f1c28635c2bbdf3deebfa Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Tue, 6 Oct 2026 20:36:20 +0200 Subject: [PATCH 05/36] refactor: add constructors for compiler-generated bindings Generated for/let clauses and typeswitch cases bind no source variable, so they have no variable metadata. Dedicated constructors keep the rewrite visitors free of trailing null arguments. --- .../rumbledb/compiler/ComparisonVisitor.java | 8 +--- .../compiler/FunctionInliningVisitor.java | 43 ++++++------------- .../translation/ComparisonTranslation.java | 18 +------- .../expressions/control/TypeswitchCase.java | 10 +++++ .../rumbledb/expressions/flowr/ForClause.java | 13 +++++- .../rumbledb/expressions/flowr/LetClause.java | 7 ++- 6 files changed, 45 insertions(+), 54 deletions(-) diff --git a/src/main/java/org/rumbledb/compiler/ComparisonVisitor.java b/src/main/java/org/rumbledb/compiler/ComparisonVisitor.java index c0249ca017..eee661b3b3 100644 --- a/src/main/java/org/rumbledb/compiler/ComparisonVisitor.java +++ b/src/main/java/org/rumbledb/compiler/ComparisonVisitor.java @@ -120,9 +120,7 @@ public Node visitComparisonExpr(ComparisonExpression expression, Node argument) leftChild.getStaticSequenceType(), null, leftChild, - expression.getMetadata(), - null, - null); + expression.getMetadata()); firstClause.setStaticContext(leftContext); StaticContext rightContext = new StaticContext(leftContext); rightContext.addVariable(variableNameRight, rightChild.getStaticSequenceType(), expression.getMetadata()); @@ -132,9 +130,7 @@ public Node visitComparisonExpr(ComparisonExpression expression, Node argument) rightChild.getStaticSequenceType(), null, rightChild, - expression.getMetadata(), - null, - null); + expression.getMetadata()); secondClause.setStaticContext(rightContext); firstClause.chainWith(secondClause); Expression leftReference = new VariableReferenceExpression(variableNameLeft, expression.getMetadata()); diff --git a/src/main/java/org/rumbledb/compiler/FunctionInliningVisitor.java b/src/main/java/org/rumbledb/compiler/FunctionInliningVisitor.java index 98706cc597..d6446310f9 100644 --- a/src/main/java/org/rumbledb/compiler/FunctionInliningVisitor.java +++ b/src/main/java/org/rumbledb/compiler/FunctionInliningVisitor.java @@ -111,13 +111,11 @@ private static boolean allArgumentsMatch(FunctionCallExpression expression, List private void addCastCase( List cases, Expression expression, SequenceType testType, SequenceType targetType) { if (testType.equals(targetType)) { - cases.add(new TypeswitchCase(null, Collections.singletonList(testType), expression, null)); + cases.add(new TypeswitchCase(Collections.singletonList(testType), expression)); } else if (testType.getArity().isSubtypeOf(Arity.OneOrZero)) { cases.add(new TypeswitchCase( - null, Collections.singletonList(testType), - new CastExpression(expression, targetType, expression.getMetadata()), - null)); + new CastExpression(expression, targetType, expression.getMetadata()))); } else { Name variableName = Name.createVariableInNoNamespace( String.format("param%s", UUID.randomUUID().toString().replaceAll("-", ""))); @@ -127,9 +125,7 @@ private void addCastCase( new SequenceType(testType.getItemType(), Arity.One), null, expression, - expression.getMetadata(), - null, - null); + expression.getMetadata()); Expression castExpression = new CastExpression( new VariableReferenceExpression(variableName, expression.getMetadata()), new SequenceType(targetType.getItemType(), Arity.One), @@ -137,10 +133,7 @@ private void addCastCase( ReturnClause returnClause = new ReturnClause(castExpression, expression.getMetadata()); forClause.chainWith(returnClause); cases.add(new TypeswitchCase( - null, - Collections.singletonList(testType), - new FlworExpression(returnClause, expression.getMetadata()), - null)); + Collections.singletonList(testType), new FlworExpression(returnClause, expression.getMetadata()))); } } @@ -235,11 +228,8 @@ && usesQNameCoercionErrorSemantics(expression) TypeSwitchExpression typeSwitchExpression = new TypeSwitchExpression( expression, cases, - new TypeswitchCase( - null, - new TreatExpression( - expression, paramType, ErrorCode.UnexpectedTypeErrorCode, expression.getMetadata()), - null), + new TypeswitchCase(new TreatExpression( + expression, paramType, ErrorCode.UnexpectedTypeErrorCode, expression.getMetadata())), expression.getMetadata()); typeSwitchExpression.setStaticSequenceType(paramType); return typeSwitchExpression; @@ -300,11 +290,8 @@ && usesQNameCoercionErrorSemantics(expression) TypeSwitchExpression typeSwitchExpression = new TypeSwitchExpression( expression, cases, - new TypeswitchCase( - null, - new TreatExpression( - expression, paramType, ErrorCode.UnexpectedTypeErrorCode, expression.getMetadata()), - null), + new TypeswitchCase(new TreatExpression( + expression, paramType, ErrorCode.UnexpectedTypeErrorCode, expression.getMetadata())), expression.getMetadata()); typeSwitchExpression.setStaticSequenceType(paramType); return typeSwitchExpression; @@ -366,11 +353,8 @@ && usesQNameCoercionErrorSemantics(expression) TypeSwitchExpression typeSwitchExpression = new TypeSwitchExpression( expression, cases, - new TypeswitchCase( - null, - new TreatExpression( - expression, paramType, ErrorCode.UnexpectedTypeErrorCode, expression.getMetadata()), - null), + new TypeswitchCase(new TreatExpression( + expression, paramType, ErrorCode.UnexpectedTypeErrorCode, expression.getMetadata())), expression.getMetadata()); typeSwitchExpression.setStaticSequenceType(paramType); return typeSwitchExpression; @@ -472,12 +456,11 @@ public Node visitFunctionCall(FunctionCallExpression expression, Node argument) if (isVariableReferenced(expression.getArguments(), paramName, i)) { Name columnName = Name.createVariableInNoNamespace( String.format("param%s", UUID.randomUUID().toString().replaceAll("-", ""))); - Clause expressionClause = - new LetClause(columnName, null, argumentExpression, expression.getMetadata(), null); + Clause expressionClause = new LetClause(columnName, null, argumentExpression, expression.getMetadata()); Expression assignmentExpression = createTypePromotion( new VariableReferenceExpression(columnName, expression.getMetadata()), paramType); Clause assignmentClause = - new LetClause(paramName, null, assignmentExpression, expression.getMetadata(), null); + new LetClause(paramName, null, assignmentExpression, expression.getMetadata()); if (assignmentClauses != null) { assignmentClause.chainWith(assignmentClauses); } @@ -489,7 +472,7 @@ public Node visitFunctionCall(FunctionCallExpression expression, Node argument) } else { Expression assignmentExpression = createTypePromotion(argumentExpression, paramType); Clause expressionClause = - new LetClause(paramName, null, assignmentExpression, expression.getMetadata(), null); + new LetClause(paramName, null, assignmentExpression, expression.getMetadata()); if (expressionClauses != null) { expressionClause.chainWith(expressionClauses); } diff --git a/src/main/java/org/rumbledb/compiler/translation/ComparisonTranslation.java b/src/main/java/org/rumbledb/compiler/translation/ComparisonTranslation.java index 908eb42b5a..b45a03cd02 100644 --- a/src/main/java/org/rumbledb/compiler/translation/ComparisonTranslation.java +++ b/src/main/java/org/rumbledb/compiler/translation/ComparisonTranslation.java @@ -68,23 +68,9 @@ public static Expression comparisonExpr Name variableNameRight = Name.TEMP_VAR2; Clause firstClause = new ForClause( - variableNameLeft, - false, - null, - null, - mainExpression, - translationContext.metadata(ctx.context()), - null, - null); + variableNameLeft, false, null, null, mainExpression, translationContext.metadata(ctx.context())); Clause secondClause = new ForClause( - variableNameRight, - false, - null, - null, - childExpression, - translationContext.metadata(ctx.context()), - null, - null); + variableNameRight, false, null, null, childExpression, translationContext.metadata(ctx.context())); firstClause.chainWith(secondClause); Expression valueComparison = new ComparisonExpression( new VariableReferenceExpression(variableNameLeft, translationContext.metadata(ctx.context())), diff --git a/src/main/java/org/rumbledb/expressions/control/TypeswitchCase.java b/src/main/java/org/rumbledb/expressions/control/TypeswitchCase.java index 7473dd4dbe..9fcdeb5585 100644 --- a/src/main/java/org/rumbledb/expressions/control/TypeswitchCase.java +++ b/src/main/java/org/rumbledb/expressions/control/TypeswitchCase.java @@ -43,6 +43,16 @@ public class TypeswitchCase { private final List union; private final Expression returnExpression; + /** Creates a compiler-generated case that binds no variable. */ + public TypeswitchCase(List union, Expression returnExpression) { + this(null, union, returnExpression, null); + } + + /** Creates a compiler-generated default case that binds no variable. */ + public TypeswitchCase(Expression returnExpression) { + this(null, returnExpression, null); + } + public TypeswitchCase( Name variableName, List union, diff --git a/src/main/java/org/rumbledb/expressions/flowr/ForClause.java b/src/main/java/org/rumbledb/expressions/flowr/ForClause.java index 2b47c4761a..705ba99c99 100644 --- a/src/main/java/org/rumbledb/expressions/flowr/ForClause.java +++ b/src/main/java/org/rumbledb/expressions/flowr/ForClause.java @@ -43,7 +43,7 @@ public class ForClause extends Clause { @Getter private final Name positionalVariableName; - /** Variable token metadata, or null for compiler-generated clauses. */ + /** Variable token metadata, or null for compiler-generated clauses, which bind no source variable. */ @Getter private final ExceptionMetadata variableMetadata; @@ -65,6 +65,17 @@ public class ForClause extends Clause { @Setter protected ExecutionMode variableHighestStorageMode = ExecutionMode.UNSET; + /** Creates a compiler-generated clause, whose variables have no source tokens. */ + public ForClause( + Name variableName, + boolean allowEmpty, + SequenceType sequenceType, + Name positionalVariableName, + Expression expression, + ExceptionMetadata metadata) { + this(variableName, allowEmpty, sequenceType, positionalVariableName, expression, metadata, null, null); + } + public ForClause( Name variableName, boolean allowEmpty, diff --git a/src/main/java/org/rumbledb/expressions/flowr/LetClause.java b/src/main/java/org/rumbledb/expressions/flowr/LetClause.java index 444cb572f8..85ef428eb6 100644 --- a/src/main/java/org/rumbledb/expressions/flowr/LetClause.java +++ b/src/main/java/org/rumbledb/expressions/flowr/LetClause.java @@ -37,7 +37,7 @@ public class LetClause extends Clause { @Getter private final Name variableName; - /** Variable token metadata, or null for compiler-generated clauses. */ + /** Variable token metadata, or null for compiler-generated clauses, which bind no source variable. */ @Getter private final ExceptionMetadata variableMetadata; @@ -56,6 +56,11 @@ public class LetClause extends Clause { // Holds whether the let variable will be stored in materialized(local) or native/spark(RDD or DF) format in a tuple protected ExecutionMode variableHighestStorageMode = ExecutionMode.UNSET; + /** Creates a compiler-generated clause, whose variable has no source token. */ + public LetClause(Name variableName, SequenceType sequenceType, Expression expression, ExceptionMetadata metadata) { + this(variableName, sequenceType, expression, metadata, null); + } + public LetClause( Name variableName, SequenceType sequenceType, From d0cb0d7fce1e710a1906745d97aa09cc883d1956 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Tue, 6 Oct 2026 20:49:47 +0200 Subject: [PATCH 06/36] refactor: keep binding types in the static context Type inference already records each variable's type in the scope where the variable is visible. Read binding types from there instead of keeping a second copy on the AST nodes, which every rewrite had to preserve. Window clauses now infer their variables' types from the input: the window variable has the input's item type with at least one item, the current item is always present, and the previous and next items are optional. --- .../org/rumbledb/compiler/CloneVisitor.java | 4 -- .../rumbledb/compiler/InferTypeVisitor.java | 55 +++++++++++++------ .../compiler/StaticContextVisitor.java | 9 ++- .../expressions/control/TypeswitchCase.java | 4 -- .../expressions/flowr/CountClause.java | 6 -- .../rumbledb/expressions/flowr/ForClause.java | 10 ---- .../flowr/GroupByVariableDeclaration.java | 6 -- .../rumbledb/expressions/flowr/LetClause.java | 5 -- .../expressions/flowr/WindowClause.java | 10 ---- .../control/TypeSwitchStatementCase.java | 4 -- .../compiler/FlworBindingMetadataTest.java | 46 ++++++++++++++-- 11 files changed, 86 insertions(+), 73 deletions(-) diff --git a/src/main/java/org/rumbledb/compiler/CloneVisitor.java b/src/main/java/org/rumbledb/compiler/CloneVisitor.java index 91a767ea67..f3802d7f66 100644 --- a/src/main/java/org/rumbledb/compiler/CloneVisitor.java +++ b/src/main/java/org/rumbledb/compiler/CloneVisitor.java @@ -280,7 +280,6 @@ public Node visitForClause(ForClause clause, Node argument) { clause.getMetadata(), clause.getVariableMetadata(), clause.getPositionalVariableMetadata()); - result.setVariableSequenceType(clause.getVariableSequenceType()); result.setStaticContext(clause.getStaticContext()); return result; } @@ -330,7 +329,6 @@ public Node visitGroupByClause(GroupByClause clause, Node argument) { : (Expression) visit(variable.getExpression(), argument), variable.getCollationURI(), variable.getVariableMetadata()); - copy.setVariableSequenceType(variable.getVariableSequenceType()); groupByVariableDeclarations.add(copy); } Clause result = new GroupByClause(groupByVariableDeclarations, clause.getMetadata()); @@ -1120,7 +1118,6 @@ private TypeswitchCase cloneTypeswitchCase(TypeswitchCase switchCase, Node argum ? new TypeswitchCase(switchCase.getVariableName(), body, switchCase.getVariableMetadata()) : new TypeswitchCase( switchCase.getVariableName(), switchCase.getUnion(), body, switchCase.getVariableMetadata()); - copy.setVariableSequenceType(switchCase.getVariableSequenceType()); return copy; } @@ -1130,7 +1127,6 @@ private TypeSwitchStatementCase cloneTypeswitchCase(TypeSwitchStatementCase swit ? new TypeSwitchStatementCase(switchCase.getVariableName(), body, switchCase.getVariableMetadata()) : new TypeSwitchStatementCase( switchCase.getVariableName(), switchCase.getUnion(), body, switchCase.getVariableMetadata()); - copy.setVariableSequenceType(switchCase.getVariableSequenceType()); return copy; } diff --git a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java index 2abca4456d..d7e9f64d1c 100644 --- a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java +++ b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java @@ -1860,7 +1860,6 @@ public StaticContext visitTypeSwitchExpression(TypeSwitchExpression expression, variableType = variableType == null ? st : variableType.leastCommonSupertypeWith(st); } returnExpression.getStaticContext().replaceVariableSequenceType(variableName, variableType); - typeswitchCase.setVariableSequenceType(variableType); } visit(returnExpression, argument); @@ -1874,7 +1873,6 @@ public StaticContext visitTypeSwitchExpression(TypeSwitchExpression expression, // if we bind a variable in the default case, we infer testCondition type if (variableName != null) { returnExpression.getStaticContext().replaceVariableSequenceType(variableName, conditionType); - expression.getDefaultCase().setVariableSequenceType(conditionType); } visit(returnExpression, argument); SequenceType defaultType = returnExpression.getStaticSequenceType(); @@ -2374,8 +2372,6 @@ public StaticContext visitForClause(ForClause expression, StaticContext argument expression.getClass().getSimpleName(), expression.getVariableName(), expression.getMetadata()); - - expression.setVariableSequenceType(declaredType == null ? inferredType : declaredType); return argument; } @@ -2386,19 +2382,49 @@ public StaticContext visitWindowClause(WindowClause expression, StaticContext ar ? expression.getExpression().getStaticSequenceType() : expression.getActualSequenceType(); basicChecks(sourceType, expression.getClass().getSimpleName(), true, false, expression.getMetadata()); - visit( - expression.getStartCondition().expression(), - expression.getStartCondition().expression().getStaticContext()); - checkWindowConditionType(expression.getStartCondition().expression(), expression); - if (expression.getEndCondition() != null) { - visit( - expression.getEndCondition().expression(), - expression.getEndCondition().expression().getStaticContext()); - checkWindowConditionType(expression.getEndCondition().expression(), expression); + // Condition variables bind items of the input, while a declared type applies to the window variable. + ItemType inputItemType = + expression.getExpression().getStaticSequenceType().getItemType(); + WindowClause.WindowCondition start = expression.getStartCondition(); + WindowClause.WindowCondition end = expression.getEndCondition(); + refineWindowConditionVariables( + start.variables(), inputItemType, start.expression().getStaticContext()); + visit(start.expression(), start.expression().getStaticContext()); + checkWindowConditionType(start.expression(), expression); + if (end != null) { + refineWindowConditionVariables( + end.variables(), inputItemType, end.expression().getStaticContext()); + visit(end.expression(), end.expression().getStaticContext()); + checkWindowConditionType(end.expression(), expression); + } + + StaticContext followingContext = expression.getNextClause().getStaticContext(); + if (expression.getActualSequenceType() == null) { + // A window always contains at least one item. + followingContext.replaceVariableSequenceType( + expression.getWindowVariable(), new SequenceType(inputItemType, SequenceType.Arity.OneOrMore)); + } + refineWindowConditionVariables(start.variables(), inputItemType, followingContext); + if (end != null) { + refineWindowConditionVariables(end.variables(), inputItemType, followingContext); } return argument; } + // The current item always exists, while the previous and next items may not. + private static void refineWindowConditionVariables( + WindowClause.WindowVars variables, ItemType inputItemType, StaticContext context) { + if (variables.currentItem() != null) { + context.replaceVariableSequenceType(variables.currentItem(), new SequenceType(inputItemType)); + } + for (Name item : Arrays.asList(variables.previousItem(), variables.nextItem())) { + if (item != null) { + context.replaceVariableSequenceType( + item, new SequenceType(inputItemType, SequenceType.Arity.OneOrZero)); + } + } + } + private void checkWindowConditionType(Expression condition, WindowClause clause) { SequenceType conditionType = condition.getStaticSequenceType(); basicChecks(conditionType, clause.getClass().getSimpleName(), true, false, clause.getMetadata()); @@ -2493,7 +2519,6 @@ public StaticContext visitGroupByClause(GroupByClause expression, StaticContext + expectedType, expression.getMetadata()); } - groupByVar.setVariableSequenceType(expectedType); groupingVars.add(groupByVar.getVariableName()); } @@ -2957,7 +2982,6 @@ public StaticContext visitTypeSwitchStatement(TypeSwitchStatement statement, Sta variableType = variableType == null ? st : variableType.leastCommonSupertypeWith(st); } returnStatement.getStaticContext().replaceVariableSequenceType(variableName, variableType); - typeswitchCase.setVariableSequenceType(variableType); } visit(returnStatement, argument); @@ -2971,7 +2995,6 @@ public StaticContext visitTypeSwitchStatement(TypeSwitchStatement statement, Sta // if we bind a variable in the default case, we infer testCondition type if (variableName != null) { returnStatement.getStaticContext().replaceVariableSequenceType(variableName, conditionType); - statement.getDefaultCase().setVariableSequenceType(conditionType); } visit(returnStatement, argument); SequenceType defaultType = returnStatement.getStaticSequenceType(); diff --git a/src/main/java/org/rumbledb/compiler/StaticContextVisitor.java b/src/main/java/org/rumbledb/compiler/StaticContextVisitor.java index 5e51abf77e..addc451600 100644 --- a/src/main/java/org/rumbledb/compiler/StaticContextVisitor.java +++ b/src/main/java/org/rumbledb/compiler/StaticContextVisitor.java @@ -244,7 +244,7 @@ public StaticContext visitForClause(ForClause clause, StaticContext argument) { if (clause.getPositionalVariableName() != null) { result.addVariable( clause.getPositionalVariableName(), - clause.getPositionalVariableSequenceType(), + new SequenceType(BuiltinTypesCatalogue.integerItem), Objects.requireNonNullElse(clause.getPositionalVariableMetadata(), clause.getMetadata())); } this.visit(clause.getNextClause(), result); @@ -288,10 +288,13 @@ public StaticContext visitWindowClause(WindowClause clause, StaticContext argume } private static void addWindowVars(WindowClause.WindowVars vars, StaticContext context, WindowClause clause) { + // Type inference later refines these types from the window's input. + SequenceType optionalItem = + new SequenceType(clause.getSequenceType().getItemType(), SequenceType.Arity.OneOrZero); for (Name name : vars.names()) { context.addVariable( name, - clause.getConditionVariableSequenceType(name), + name.equals(vars.position()) ? new SequenceType(BuiltinTypesCatalogue.integerItem) : optionalItem, Objects.requireNonNullElse(clause.getVariableMetadata(name), clause.getMetadata())); } } @@ -355,7 +358,7 @@ public StaticContext visitCountClause(CountClause clause, StaticContext argument StaticContext result = new StaticContext(argument); result.addVariable( clause.getCountVariableName(), - clause.getVariableSequenceType(), + SequenceType.createSequenceType("integer"), Objects.requireNonNullElse(clause.getVariableMetadata(), clause.getMetadata())); this.visit(clause.getNextClause(), result); return argument; diff --git a/src/main/java/org/rumbledb/expressions/control/TypeswitchCase.java b/src/main/java/org/rumbledb/expressions/control/TypeswitchCase.java index 9fcdeb5585..6235249186 100644 --- a/src/main/java/org/rumbledb/expressions/control/TypeswitchCase.java +++ b/src/main/java/org/rumbledb/expressions/control/TypeswitchCase.java @@ -19,7 +19,6 @@ import java.util.List; import lombok.Getter; -import lombok.Setter; import org.rumbledb.context.Name; import org.rumbledb.exceptions.ExceptionMetadata; @@ -37,9 +36,6 @@ public class TypeswitchCase { private final Name variableName; private final ExceptionMetadata variableMetadata; - @Setter - private SequenceType variableSequenceType; - private final List union; private final Expression returnExpression; diff --git a/src/main/java/org/rumbledb/expressions/flowr/CountClause.java b/src/main/java/org/rumbledb/expressions/flowr/CountClause.java index ed98f92c42..9fc5d683bd 100644 --- a/src/main/java/org/rumbledb/expressions/flowr/CountClause.java +++ b/src/main/java/org/rumbledb/expressions/flowr/CountClause.java @@ -24,8 +24,6 @@ import org.rumbledb.exceptions.ExceptionMetadata; import org.rumbledb.expressions.AbstractNodeVisitor; import org.rumbledb.expressions.Node; -import org.rumbledb.types.BuiltinTypesCatalogue; -import org.rumbledb.types.SequenceType; public class CountClause extends Clause { private final Name variableName; @@ -53,10 +51,6 @@ public Name getCountVariableName() { return this.variableName; } - public SequenceType getVariableSequenceType() { - return new SequenceType(BuiltinTypesCatalogue.integerItem); - } - @Override public void print(StringBuilder buffer, int indent) { for (int i = 0; i < indent; ++i) { diff --git a/src/main/java/org/rumbledb/expressions/flowr/ForClause.java b/src/main/java/org/rumbledb/expressions/flowr/ForClause.java index 705ba99c99..fbb162a267 100644 --- a/src/main/java/org/rumbledb/expressions/flowr/ForClause.java +++ b/src/main/java/org/rumbledb/expressions/flowr/ForClause.java @@ -30,7 +30,6 @@ import org.rumbledb.expressions.ExecutionMode; import org.rumbledb.expressions.Expression; import org.rumbledb.expressions.Node; -import org.rumbledb.types.BuiltinTypesCatalogue; import org.rumbledb.types.SequenceType; public class ForClause extends Clause { @@ -53,11 +52,6 @@ public class ForClause extends Clause { protected SequenceType sequenceType; - // The type visible immediately after this binding, before later clauses regroup or shadow it. - @Getter - @Setter - private SequenceType variableSequenceType; - @Getter protected Expression expression; @@ -110,10 +104,6 @@ public SequenceType getActualSequenceType() { return this.sequenceType; } - public SequenceType getPositionalVariableSequenceType() { - return this.positionalVariableName == null ? null : new SequenceType(BuiltinTypesCatalogue.integerItem); - } - public ExecutionMode getVariableHighestStorageMode(VisitorConfig visitorConfig) { if (!visitorConfig.suppressErrorsForAccessingUnsetExecutionModes() && this.variableHighestStorageMode == ExecutionMode.UNSET) { diff --git a/src/main/java/org/rumbledb/expressions/flowr/GroupByVariableDeclaration.java b/src/main/java/org/rumbledb/expressions/flowr/GroupByVariableDeclaration.java index 315d20ee25..bee11d9e82 100644 --- a/src/main/java/org/rumbledb/expressions/flowr/GroupByVariableDeclaration.java +++ b/src/main/java/org/rumbledb/expressions/flowr/GroupByVariableDeclaration.java @@ -16,7 +16,6 @@ package org.rumbledb.expressions.flowr; import lombok.Getter; -import lombok.Setter; import org.rumbledb.context.Name; import org.rumbledb.exceptions.ExceptionMetadata; @@ -36,11 +35,6 @@ public class GroupByVariableDeclaration { @Getter private final ExceptionMetadata variableMetadata; - // The type visible immediately after this grouping binding, independent of later clauses. - @Getter - @Setter - private SequenceType variableSequenceType; - @Getter protected String collationURI; diff --git a/src/main/java/org/rumbledb/expressions/flowr/LetClause.java b/src/main/java/org/rumbledb/expressions/flowr/LetClause.java index 85ef428eb6..a00809c42d 100644 --- a/src/main/java/org/rumbledb/expressions/flowr/LetClause.java +++ b/src/main/java/org/rumbledb/expressions/flowr/LetClause.java @@ -86,11 +86,6 @@ public SequenceType getActualSequenceType() { return this.sequenceType; } - /** Type visible after this binding; staticType retains the initializer's type for execution. */ - public SequenceType getVariableSequenceType() { - return this.sequenceType == null ? this.staticType : this.sequenceType; - } - public ExecutionMode getVariableHighestStorageMode(VisitorConfig visitorConfig) { if (!visitorConfig.suppressErrorsForAccessingUnsetExecutionModes() && this.variableHighestStorageMode == ExecutionMode.UNSET) { diff --git a/src/main/java/org/rumbledb/expressions/flowr/WindowClause.java b/src/main/java/org/rumbledb/expressions/flowr/WindowClause.java index 3752f6a01a..0eb1d055f7 100644 --- a/src/main/java/org/rumbledb/expressions/flowr/WindowClause.java +++ b/src/main/java/org/rumbledb/expressions/flowr/WindowClause.java @@ -28,7 +28,6 @@ import org.rumbledb.expressions.AbstractNodeVisitor; import org.rumbledb.expressions.Expression; import org.rumbledb.expressions.Node; -import org.rumbledb.types.BuiltinTypesCatalogue; import org.rumbledb.types.SequenceType; public class WindowClause extends Clause { @@ -122,15 +121,6 @@ public ExceptionMetadata getVariableMetadata(Name name) { return this.variableMetadata.get(name); } - public SequenceType getConditionVariableSequenceType(Name name) { - if (name.equals(this.startCondition.variables().position()) - || this.endCondition != null - && name.equals(this.endCondition.variables().position())) { - return new SequenceType(BuiltinTypesCatalogue.integerItem); - } - return new SequenceType(getSequenceType().getItemType(), SequenceType.Arity.OneOrZero); - } - @Override public List getChildren() { List result = new ArrayList<>(); diff --git a/src/main/java/org/rumbledb/expressions/scripting/control/TypeSwitchStatementCase.java b/src/main/java/org/rumbledb/expressions/scripting/control/TypeSwitchStatementCase.java index e56e0f3d1e..1a1e3e75f5 100644 --- a/src/main/java/org/rumbledb/expressions/scripting/control/TypeSwitchStatementCase.java +++ b/src/main/java/org/rumbledb/expressions/scripting/control/TypeSwitchStatementCase.java @@ -18,7 +18,6 @@ import java.util.List; import lombok.Getter; -import lombok.Setter; import org.rumbledb.context.Name; import org.rumbledb.exceptions.ExceptionMetadata; @@ -35,9 +34,6 @@ public class TypeSwitchStatementCase { private final Name variableName; private final ExceptionMetadata variableMetadata; - @Setter - private SequenceType variableSequenceType; - private final List union; private final Statement returnStatement; diff --git a/src/test/java/org/rumbledb/compiler/FlworBindingMetadataTest.java b/src/test/java/org/rumbledb/compiler/FlworBindingMetadataTest.java index 589f784794..9f56dc635e 100644 --- a/src/test/java/org/rumbledb/compiler/FlworBindingMetadataTest.java +++ b/src/test/java/org/rumbledb/compiler/FlworBindingMetadataTest.java @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.List; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -28,6 +29,7 @@ import org.rumbledb.bindings.ExternalBindings; import org.rumbledb.config.CompilationConfiguration; import org.rumbledb.config.RumbleConfiguration; +import org.rumbledb.context.Name; import org.rumbledb.exceptions.ExceptionMetadata; import org.rumbledb.exceptions.SourcePosition; import org.rumbledb.exceptions.SourceRange; @@ -37,6 +39,7 @@ import org.rumbledb.expressions.flowr.ForClause; import org.rumbledb.expressions.flowr.GroupByClause; import org.rumbledb.expressions.flowr.LetClause; +import org.rumbledb.expressions.flowr.WindowClause; class FlworBindingMetadataTest { private static final String QUERY = @@ -54,8 +57,8 @@ void recordsVariableRangesAndTypes(String extension) { ForClause forClause = find(nodes, ForClause.class); assertRange(QUERY, "$i", forClause.getVariableMetadata()); assertRange(QUERY, "$pos", forClause.getPositionalVariableMetadata()); - assertEquals("xs:integer", forClause.getVariableSequenceType().toString()); - assertEquals("xs:integer", forClause.getPositionalVariableSequenceType().toString()); + assertType("xs:integer", forClause, forClause.getVariableName()); + assertType("xs:integer", forClause, forClause.getPositionalVariableName()); assertEquals( forClause.getVariableMetadata(), forClause @@ -67,10 +70,43 @@ void recordsVariableRangesAndTypes(String extension) { LetClause letClause = find(nodes, LetClause.class); assertRange(QUERY, "$value", letClause.getVariableMetadata()); - assertEquals("xs:integer", letClause.getVariableSequenceType().toString()); - var groupVariable = find(nodes, GroupByClause.class).getGroupVariables().get(0); + assertType("xs:integer", letClause, letClause.getVariableName()); + GroupByClause groupBy = find(nodes, GroupByClause.class); + var groupVariable = groupBy.getGroupVariables().get(0); assertRange(QUERY, "$key", groupVariable.getVariableMetadata()); - assertEquals("xs:integer", groupVariable.getVariableSequenceType().toString()); + assertType("xs:integer", groupBy, groupVariable.getVariableName()); + } + + @Test + void infersWindowVariableTypesFromTheInput() { + String query = + """ + for tumbling window $w in (1, 2, 3) + start $s previous $p when $s gt 0 + return $w + """; + WindowClause window = find(compile(query, "jq"), WindowClause.class); + var start = window.getStartCondition(); + // The condition sees the refined type of its own variables. + assertEquals( + "xs:integer", + start.expression() + .getStaticContext() + .getVariableSequenceType(start.variables().currentItem()) + .toString()); + assertType("xs:integer+", window, window.getWindowVariable()); + assertType("xs:integer", window, start.variables().currentItem()); + assertType("xs:integer?", window, start.variables().previousItem()); + } + + /** A binding's type is the one visible to the clauses after it. */ + private static void assertType(String expected, Clause clause, Name variable) { + assertEquals( + expected, + clause.getNextClause() + .getStaticContext() + .getVariableSequenceType(variable) + .toString()); } private static List compile(String query, String extension) { From 3c94a1d22fd8e8763665307cf29b328d0cd84f90 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Tue, 6 Oct 2026 20:57:33 +0200 Subject: [PATCH 07/36] refactor: pass function parameter metadata through the constructor Keeps the metadata immutable and avoids a setter that every copy of the function had to remember to call. --- .../java/org/rumbledb/compiler/CloneVisitor.java | 4 ++-- .../translation/DeclarationTranslation.java | 4 ++-- .../compiler/translation/PrimaryTranslation.java | 15 +++++++++++---- .../primary/InlineFunctionExpression.java | 9 +++++---- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/rumbledb/compiler/CloneVisitor.java b/src/main/java/org/rumbledb/compiler/CloneVisitor.java index f3802d7f66..3577afd875 100644 --- a/src/main/java/org/rumbledb/compiler/CloneVisitor.java +++ b/src/main/java/org/rumbledb/compiler/CloneVisitor.java @@ -794,8 +794,8 @@ public Node visitInlineFunctionExpr(InlineFunctionExpression expression, Node ar (StatementsAndOptionalExpr) visit(expression.getBody(), argument), expression.isExternal(), expression.getMetadata(), - expression.getNameMetadata()); - result.setParameterMetadata(expression.getParameterMetadata()); + expression.getNameMetadata(), + expression.getParameterMetadata()); result.setStaticSequenceType(expression.getStaticSequenceType()); result.setStaticContext(expression.getStaticContext()); result.setSequential(expression.isSequential()); diff --git a/src/main/java/org/rumbledb/compiler/translation/DeclarationTranslation.java b/src/main/java/org/rumbledb/compiler/translation/DeclarationTranslation.java index f10e8226d1..4dc968961d 100644 --- a/src/main/java/org/rumbledb/compiler/translation/DeclarationTranslation.java +++ b/src/main/java/org/rumbledb/compiler/translation/DeclarationTranslation.java @@ -172,8 +172,8 @@ FunctionDeclaration functionDecl( funcBody, ctx.isExternal(), translationContext.metadata(ctx.context()), - translationContext.metadata(ctx.functionName())); - inlineFunction.setParameterMetadata(Map.copyOf(parameterMetadata)); + translationContext.metadata(ctx.functionName()), + parameterMetadata); return new FunctionDeclaration(inlineFunction, translationContext.metadata(ctx.context())); } } diff --git a/src/main/java/org/rumbledb/compiler/translation/PrimaryTranslation.java b/src/main/java/org/rumbledb/compiler/translation/PrimaryTranslation.java index c41109eced..f2033c75db 100644 --- a/src/main/java/org/rumbledb/compiler/translation/PrimaryTranslation.java +++ b/src/main/java/org/rumbledb/compiler/translation/PrimaryTranslation.java @@ -248,10 +248,17 @@ InlineFunctionExpression inlineFunctionExpr( StatementsAndOptionalExpr funcBody = visitStatementsAndOptionalExpr.apply(ctx.fnBody()); - InlineFunctionExpression function = new InlineFunctionExpression( - annotations, null, fnParams, fnReturnType, funcBody, translationContext.metadata(ctx.context())); - function.setParameterMetadata(Map.copyOf(parameterMetadata)); - return function; + // An anonymous function has no name token. + return new InlineFunctionExpression( + annotations, + null, + fnParams, + fnReturnType, + funcBody, + false, + translationContext.metadata(ctx.context()), + null, + parameterMetadata); } public static ArrayConstructorExpression squareArrayConstructor( diff --git a/src/main/java/org/rumbledb/expressions/primary/InlineFunctionExpression.java b/src/main/java/org/rumbledb/expressions/primary/InlineFunctionExpression.java index 679e93f36e..aa2320bd19 100644 --- a/src/main/java/org/rumbledb/expressions/primary/InlineFunctionExpression.java +++ b/src/main/java/org/rumbledb/expressions/primary/InlineFunctionExpression.java @@ -55,8 +55,7 @@ public class InlineFunctionExpression extends Expression { /** Synthetic parameters have no source token and are absent from this map. */ @Getter - @Setter - private Map parameterMetadata = Map.of(); + private final Map parameterMetadata; private final SequenceType returnType; @@ -80,7 +79,7 @@ public InlineFunctionExpression( StatementsAndOptionalExpr body, boolean isExternal, ExceptionMetadata metadata) { - this(annotations, name, params, returnType, body, isExternal, metadata, metadata); + this(annotations, name, params, returnType, body, isExternal, metadata, metadata, Map.of()); } public InlineFunctionExpression( @@ -91,11 +90,13 @@ public InlineFunctionExpression( StatementsAndOptionalExpr body, boolean isExternal, ExceptionMetadata metadata, - ExceptionMetadata nameMetadata) { + ExceptionMetadata nameMetadata, + Map parameterMetadata) { super(metadata); this.name = name; this.nameMetadata = nameMetadata; this.params = params; + this.parameterMetadata = Map.copyOf(parameterMetadata); this.returnType = returnType; this.body = body; this.annotations = annotations; From 33a6d95d99a2f6640a13027e65f6f8e356db77ab Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Tue, 6 Oct 2026 21:01:06 +0200 Subject: [PATCH 08/36] refactor: expose window variable metadata only as a map Matches how function parameter metadata is read. --- src/main/java/org/rumbledb/compiler/StaticContextVisitor.java | 4 ++-- .../java/org/rumbledb/expressions/flowr/WindowClause.java | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/rumbledb/compiler/StaticContextVisitor.java b/src/main/java/org/rumbledb/compiler/StaticContextVisitor.java index addc451600..028bf32d4d 100644 --- a/src/main/java/org/rumbledb/compiler/StaticContextVisitor.java +++ b/src/main/java/org/rumbledb/compiler/StaticContextVisitor.java @@ -277,7 +277,7 @@ public StaticContext visitWindowClause(WindowClause clause, StaticContext argume clause.getWindowVariable(), clause.getSequenceType(), Objects.requireNonNullElse( - clause.getVariableMetadata(clause.getWindowVariable()), clause.getMetadata())); + clause.getVariableMetadata().get(clause.getWindowVariable()), clause.getMetadata())); addWindowVars(start, followingClausesContext, clause); if (end != null) { addWindowVars(end, followingClausesContext, clause); @@ -295,7 +295,7 @@ private static void addWindowVars(WindowClause.WindowVars vars, StaticContext co context.addVariable( name, name.equals(vars.position()) ? new SequenceType(BuiltinTypesCatalogue.integerItem) : optionalItem, - Objects.requireNonNullElse(clause.getVariableMetadata(name), clause.getMetadata())); + Objects.requireNonNullElse(clause.getVariableMetadata().get(name), clause.getMetadata())); } } diff --git a/src/main/java/org/rumbledb/expressions/flowr/WindowClause.java b/src/main/java/org/rumbledb/expressions/flowr/WindowClause.java index 0eb1d055f7..cc64a9ebfb 100644 --- a/src/main/java/org/rumbledb/expressions/flowr/WindowClause.java +++ b/src/main/java/org/rumbledb/expressions/flowr/WindowClause.java @@ -117,10 +117,6 @@ public SequenceType getSequenceType() { return this.sequenceType == null ? SequenceType.createSequenceType("item*") : this.sequenceType; } - public ExceptionMetadata getVariableMetadata(Name name) { - return this.variableMetadata.get(name); - } - @Override public List getChildren() { List result = new ArrayList<>(); From 5b1026fc9c03ec7b770b16b7337ebd95ce32b740 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Wed, 7 Oct 2026 00:46:41 +0200 Subject: [PATCH 09/36] fix: let references to window variables see their inferred types The static context recorded placeholder types for window variables without a declared type (item* for the window, item? for the condition items). Variable references copy their type from the static context before type inference runs, so they kept the placeholder instead of the inferred type. Leave these types unset, as for other undeclared bindings, so references read the type that inference assigns. --- .../compiler/StaticContextVisitor.java | 8 +++---- .../compiler/FlworBindingMetadataTest.java | 23 +++++++++++++------ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/rumbledb/compiler/StaticContextVisitor.java b/src/main/java/org/rumbledb/compiler/StaticContextVisitor.java index 028bf32d4d..dd773a72ea 100644 --- a/src/main/java/org/rumbledb/compiler/StaticContextVisitor.java +++ b/src/main/java/org/rumbledb/compiler/StaticContextVisitor.java @@ -275,7 +275,7 @@ public StaticContext visitWindowClause(WindowClause clause, StaticContext argume StaticContext followingClausesContext = new StaticContext(argument); followingClausesContext.addVariable( clause.getWindowVariable(), - clause.getSequenceType(), + clause.getActualSequenceType(), Objects.requireNonNullElse( clause.getVariableMetadata().get(clause.getWindowVariable()), clause.getMetadata())); addWindowVars(start, followingClausesContext, clause); @@ -288,13 +288,11 @@ public StaticContext visitWindowClause(WindowClause clause, StaticContext argume } private static void addWindowVars(WindowClause.WindowVars vars, StaticContext context, WindowClause clause) { - // Type inference later refines these types from the window's input. - SequenceType optionalItem = - new SequenceType(clause.getSequenceType().getItemType(), SequenceType.Arity.OneOrZero); + // Type inference sets the item variables' types from the window's input. for (Name name : vars.names()) { context.addVariable( name, - name.equals(vars.position()) ? new SequenceType(BuiltinTypesCatalogue.integerItem) : optionalItem, + name.equals(vars.position()) ? new SequenceType(BuiltinTypesCatalogue.integerItem) : null, Objects.requireNonNullElse(clause.getVariableMetadata().get(name), clause.getMetadata())); } } diff --git a/src/test/java/org/rumbledb/compiler/FlworBindingMetadataTest.java b/src/test/java/org/rumbledb/compiler/FlworBindingMetadataTest.java index 9f56dc635e..53f41a2442 100644 --- a/src/test/java/org/rumbledb/compiler/FlworBindingMetadataTest.java +++ b/src/test/java/org/rumbledb/compiler/FlworBindingMetadataTest.java @@ -40,6 +40,7 @@ import org.rumbledb.expressions.flowr.GroupByClause; import org.rumbledb.expressions.flowr.LetClause; import org.rumbledb.expressions.flowr.WindowClause; +import org.rumbledb.expressions.primary.VariableReferenceExpression; class FlworBindingMetadataTest { private static final String QUERY = @@ -83,9 +84,10 @@ void infersWindowVariableTypesFromTheInput() { """ for tumbling window $w in (1, 2, 3) start $s previous $p when $s gt 0 - return $w + return ($w, $s, $p) """; - WindowClause window = find(compile(query, "jq"), WindowClause.class); + List nodes = compile(query, "jq"); + WindowClause window = find(nodes, WindowClause.class); var start = window.getStartCondition(); // The condition sees the refined type of its own variables. assertEquals( @@ -97,6 +99,13 @@ void infersWindowVariableTypesFromTheInput() { assertType("xs:integer+", window, window.getWindowVariable()); assertType("xs:integer", window, start.variables().currentItem()); assertType("xs:integer?", window, start.variables().previousItem()); + // References see the refined types too, not placeholders from before type inference. + for (VariableReferenceExpression reference : findAll(nodes, VariableReferenceExpression.class)) { + assertEquals( + reference.getStaticContext().getVariableSequenceType(reference.getVariableName()), + reference.getStaticSequenceType(), + reference.getVariableName().toString()); + } } /** A binding's type is the one visible to the clauses after it. */ @@ -125,11 +134,11 @@ protected List defaultAction(Node node, List nodes) { } private static T find(List nodes, Class type) { - return nodes.stream() - .filter(type::isInstance) - .map(type::cast) - .findFirst() - .orElseThrow(); + return findAll(nodes, type).get(0); + } + + private static List findAll(List nodes, Class type) { + return nodes.stream().filter(type::isInstance).map(type::cast).toList(); } private static void assertRange(String query, String name, ExceptionMetadata metadata) { From a15d149cc15ef86c55935df5fbf14b80084d21af Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Sun, 4 Oct 2026 11:23:45 +0200 Subject: [PATCH 10/36] feat: enhance object field type inference Currently, if a value type does not have arity one, the whole object type is not infered. This change introduce fix for this, and uses union type to represent the resulting value type. --- .../rumbledb/compiler/InferTypeVisitor.java | 8 +- .../org/rumbledb/types/ItemTypeFactory.java | 46 ++++- .../org/rumbledb/types/UnionItemType.java | 19 ++ .../ObjectConstructorTypeInferenceTest.java | 182 ++++++++++++++++++ 4 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 src/test/java/org/rumbledb/compiler/ObjectConstructorTypeInferenceTest.java diff --git a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java index d7e9f64d1c..810589d7c2 100644 --- a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java +++ b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java @@ -467,17 +467,13 @@ public StaticContext visitObjectConstructor(ObjectConstructorExpression expressi stringLiteralKeys.add(stringLiteralKey); } } - if (expression.getKeys() != null - && stringLiteralKeys.size() == expression.getKeys().size() - && expression.getValues().stream() - .map(Expression::getStaticSequenceType) - .allMatch(type -> type.getArity() == SequenceType.Arity.One)) { + if (expression.getKeys() != null && stringLiteralKeys.size() == expression.getKeys().size()) { expression.setStaticSequenceType(new SequenceType(ItemTypeFactory.createAnonymousObjectType( stringLiteralKeys.stream() .map(StringLiteralExpression::getValue) .collect(Collectors.toList()), expression.getValues().stream() - .map(value -> value.getStaticSequenceType().getItemType()) + .map(value -> ItemTypeFactory.createObjectFieldType(value.getStaticSequenceType())) .collect(Collectors.toList())))); } else { expression.setStaticSequenceType(new SequenceType(BuiltinTypesCatalogue.objectItem)); diff --git a/src/main/java/org/rumbledb/types/ItemTypeFactory.java b/src/main/java/org/rumbledb/types/ItemTypeFactory.java index 23881d8919..8d9bd2af76 100644 --- a/src/main/java/org/rumbledb/types/ItemTypeFactory.java +++ b/src/main/java/org/rumbledb/types/ItemTypeFactory.java @@ -154,7 +154,7 @@ public static ItemType createItemTypeFromJSoundCompactItem(Name name, Item item, * Create an anonymous object type from keys and values. * * @param keys a list of String representing the keys of the object - * @param values a list of ItemType of the values, all with arity == Arity.One + * @param values the item types of the stored field values, after sequence-to-value conversion * @return an anonymous object type based on the provided keys and values */ public static ItemType createAnonymousObjectType(List keys, List values) { @@ -182,6 +182,50 @@ public static ItemType createAnonymousObjectType(List keys, List alternatives = new ArrayList<>(); + if (itemType.isUnionType()) { + alternatives.addAll(itemType.getTypes()); + } else { + alternatives.add(itemType); + } + if (valueType.getArity() == SequenceType.Arity.OneOrMore + || valueType.getArity() == SequenceType.Arity.ZeroOrMore) { + alternatives.add(createAnonymousArrayType(itemType)); + } + if ((valueType.getArity() == SequenceType.Arity.OneOrZero + || valueType.getArity() == SequenceType.Arity.ZeroOrMore) + && alternatives.stream().noneMatch(type -> BuiltinTypesCatalogue.nullItem.isSubtypeOf(type))) { + alternatives.add(BuiltinTypesCatalogue.nullItem); + } + if (alternatives.size() == 1) { + return alternatives.get(0); + } + ItemType baseType = alternatives.stream().allMatch(type -> type.isSubtypeOf(BuiltinTypesCatalogue.atomicItem)) + ? BuiltinTypesCatalogue.atomicItem + : BuiltinTypesCatalogue.item; + return new UnionItemType(null, baseType, alternatives, false); + } + /** * Create an anonymous array type from keys and values. * diff --git a/src/main/java/org/rumbledb/types/UnionItemType.java b/src/main/java/org/rumbledb/types/UnionItemType.java index ace379ef06..a63b65d338 100644 --- a/src/main/java/org/rumbledb/types/UnionItemType.java +++ b/src/main/java/org/rumbledb/types/UnionItemType.java @@ -138,6 +138,14 @@ public List getTypes() { return this.types; } + @Override + public boolean isSubtypeOf(ItemType superType) { + if (this.equals(superType) || super.isSubtypeOf(superType)) { + return true; + } + return this.types.stream().allMatch(type -> type.isSubtypeOf(superType)); + } + @Override public String getIdentifierString() { if (this.hasName()) { @@ -254,6 +262,12 @@ public boolean isCompatibleWithDataFrames(RumbleConfiguration configuration) { @Override public ItemType findLeastCommonSuperTypeWith(ItemType other) { + if (this.isSubtypeOf(other)) { + return other; + } + if (other.isSubtypeOf(this)) { + return this; + } ItemType otherBaseType = other.getBaseType(); List otherTypes; if (other.isUnionType()) { @@ -266,6 +280,11 @@ public ItemType findLeastCommonSuperTypeWith(ItemType other) { boolean hasNull = false; Set resultTypes = new HashSet<>(this.types); resultTypes.addAll(otherTypes); + // Scalar/array unions inferred for object values must not be widened to an + // atomic type, which would exclude the possible array values. + if (resultTypes.stream().anyMatch(type -> !type.isSubtypeOf(BuiltinTypesCatalogue.atomicItem))) { + return BuiltinTypesCatalogue.item; + } for (ItemType member : resultTypes) { if (member.equals(BuiltinTypesCatalogue.nullItem)) { hasNull = true; diff --git a/src/test/java/org/rumbledb/compiler/ObjectConstructorTypeInferenceTest.java b/src/test/java/org/rumbledb/compiler/ObjectConstructorTypeInferenceTest.java new file mode 100644 index 0000000000..3321ae9994 --- /dev/null +++ b/src/test/java/org/rumbledb/compiler/ObjectConstructorTypeInferenceTest.java @@ -0,0 +1,182 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contributor acknowledgements are maintained in the CONTRIBUTORS file at the project root. + */ +package org.rumbledb.compiler; + +import java.net.URI; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.*; + +import org.rumbledb.api.Item; +import org.rumbledb.api.Rumble; +import org.rumbledb.bindings.ExternalBindings; +import org.rumbledb.config.CompilationConfiguration; +import org.rumbledb.config.RumbleConfiguration; +import org.rumbledb.types.BuiltinTypesCatalogue; +import org.rumbledb.types.FieldDescriptor; +import org.rumbledb.types.ItemType; +import org.rumbledb.types.ItemTypeFactory; +import org.rumbledb.types.SequenceType; + +class ObjectConstructorTypeInferenceTest { + private static final RumbleConfiguration CONFIGURATION = RumbleConfiguration.builder() + .configureAnalysis(analysis -> analysis.enableStaticTyping(true)) + .build(); + + @Test + void preservesFieldsInOriginalFlworExample() { + String query = "for $i in 1 to 10 return {\"i\": $i, \"power_of_two\": math:pow($i, 2)}"; + SequenceType result = infer(query, "jq"); + ItemType objectType = result.getItemType(); + assertEquals(List.of("i", "power_of_two"), objectType.getObjectKeysFacet()); + assertEquals(BuiltinTypesCatalogue.integerItem, field(objectType, "i")); + ItemType powerType = field(objectType, "power_of_two"); + assertTrue(powerType.isUnionType()); + assertEquals(List.of(BuiltinTypesCatalogue.doubleItem, BuiltinTypesCatalogue.nullItem), powerType.getTypes()); + assertTrue(objectType.getClosedFacet()); + assertEquals(SequenceType.Arity.ZeroOrMore, result.getArity()); + List results = new Rumble(CONFIGURATION).runQuery(query).getAsList(); + assertEquals(10, results.size()); + for (Item object : results) { + assertTrue(object.getItemByKey("i").getDynamicType().isSubtypeOf(field(objectType, "i"))); + assertTrue(object.getItemByKey("power_of_two").getDynamicType().isSubtypeOf(powerType)); + } + } + + @Test + void storesEmptySequenceAsRequiredNullField() { + ItemType objectType = infer("{\"empty\": (), \"known\": 1}", "jq").getItemType(); + assertEquals(BuiltinTypesCatalogue.nullItem, field(objectType, "empty")); + assertEquals(BuiltinTypesCatalogue.integerItem, field(objectType, "known")); + assertEquals(SequenceType.Arity.One, infer("{\"empty\": ()}.empty", "jq").getArity()); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 2}) + void sequenceValuesCoverNullSingletonAndWrappedArray(int length) { + String query = "let $values := (for $i in 1 to 2 where $i le " + length + + " return $i) return {\"value\": $values, \"known\": 1}"; + ItemType objectType = infer(query, "jq").getItemType(); + ItemType valueType = field(objectType, "value"); + assertEquals(BuiltinTypesCatalogue.integerItem, field(objectType, "known")); + assertTrue(valueType.isUnionType()); + assertEquals(3, valueType.getTypes().size()); + assertEquals(BuiltinTypesCatalogue.integerItem, valueType.getTypes().get(0)); + ItemType arrayType = valueType.getTypes().get(1); + assertTrue(arrayType.isArrayItemType()); + assertEquals(BuiltinTypesCatalogue.integerItem, arrayType.getArrayContentFacet()); + assertEquals(BuiltinTypesCatalogue.nullItem, valueType.getTypes().get(2)); + Item value = new Rumble(CONFIGURATION).runQuery(query).getAsList().get(0).getItemByKey("value"); + // Dynamic arrays carry a generic type; check their members against the inferred content. + if (length > 1) { + assertTrue(value.isArray()); + assertEquals(length, value.getSize()); + for (Item member : value.getItemMembers()) { + assertTrue(member.getDynamicType().isSubtypeOf(arrayType.getArrayContentFacet())); + } + } else { + assertTrue(value.getDynamicType().isSubtypeOf(valueType)); + assertEquals(length == 0, value.isNull()); + } + } + + @Test + void nonemptySequenceIncludesScalarAndArrayButNoIntroducedNull() { + ItemType objectType = infer( + "declare variable $values as xs:integer+ := (1, 2); {\"value\": $values}", "jq").getItemType(); + ItemType valueType = field(objectType, "value"); + assertTrue(valueType.isUnionType()); + assertEquals(2, valueType.getTypes().size()); + assertFalse(valueType.canBeNull()); + } + + @Test + void preservesNestedObjectFields() { + ItemType objectType = infer("{\"nested\": {\"value\": math:pow(2, 3)}, \"known\": 1}", "jq") + .getItemType(); + ItemType nestedType = field(objectType, "nested"); + assertTrue(nestedType.isObjectItemType()); + assertTrue(field(nestedType, "value").isUnionType()); + assertEquals(BuiltinTypesCatalogue.integerItem, infer( + "{\"nested\": {\"value\": math:pow(2, 3)}, \"known\": 1}.known", "jq").getItemType()); + } + + @Test + void optionalObjectIncludesOriginalShapeAndNull() { + ItemType objectType = infer( + "declare variable $value as object? := (); {\"value\": $value, \"known\": 1}", "jq") + .getItemType(); + ItemType valueType = field(objectType, "value"); + assertTrue(valueType.isUnionType()); + assertTrue(valueType.getTypes().get(0).isObjectItemType()); + assertEquals(BuiltinTypesCatalogue.nullItem, valueType.getTypes().get(1)); + } + + @Test + void keepsGenericTypeForDynamicKeysAndMergedConstructors() { + assertEquals(BuiltinTypesCatalogue.objectItem, + infer("{string(1): math:pow(2, 3)}", "jq").getItemType()); + assertEquals(BuiltinTypesCatalogue.objectItem, + infer("{| {\"value\": 1} |}", "jq").getItemType()); + assertTrue(infer("({})", "jq").getItemType().getObjectKeysFacet().isEmpty()); + } + + @Test + void nullableUnionAlreadyContainingNullDoesNotDuplicateIt() { + ItemType type = field(infer("{\"value\": if (true) then null else ()}", "jq").getItemType(), "value"); + assertEquals(BuiltinTypesCatalogue.nullItem, type); + } + + @Test + void scalarArrayUnionsRemainSoundWhenMergedWithAtomicTypes() { + ItemType valueType = ItemTypeFactory.createObjectFieldType( + new SequenceType(BuiltinTypesCatalogue.integerItem, SequenceType.Arity.OneOrMore)); + assertFalse(valueType.isSubtypeOf(BuiltinTypesCatalogue.atomicItem)); + assertFalse(valueType.isSubtypeOf(BuiltinTypesCatalogue.integerItem)); + assertTrue(valueType.isSubtypeOf(BuiltinTypesCatalogue.item)); + ItemType merged = valueType.findLeastCommonSuperTypeWith(BuiltinTypesCatalogue.stringItem); + assertTrue(valueType.getTypes().stream().allMatch(type -> type.isSubtypeOf(merged))); + assertTrue(BuiltinTypesCatalogue.stringItem.isSubtypeOf(merged)); + } + + @Test + void inferredNullableUnionCanBeComparedWithEquivalentUnion() { + SequenceType inputType = new SequenceType(BuiltinTypesCatalogue.doubleItem, SequenceType.Arity.OneOrZero); + ItemType left = ItemTypeFactory.createObjectFieldType(inputType); + ItemType right = ItemTypeFactory.createObjectFieldType(inputType); + assertTrue(left.isSubtypeOf(right)); + assertTrue(right.isSubtypeOf(left)); + assertTrue(left.isSubtypeOf(BuiltinTypesCatalogue.atomicItem)); + } + + private static ItemType field(ItemType objectType, String key) { + assertTrue(objectType.isObjectItemType()); + FieldDescriptor field = objectType.getObjectContentFacet(key); + assertNotNull(field); + assertTrue(field.isRequired(), "Literal-key fields are present even when their value is null"); + return field.getType(); + } + + private static SequenceType infer(String query, String extension) { + return CompilationPipeline.compileMainModule(query, URI.create("file:///object-inference." + extension), + new CompilationConfiguration(CONFIGURATION), ExternalBindings.empty()) + .getExpression().getStaticSequenceType(); + } +} From 624115e2b62fec0fa4da151f2c7b7602f54f676f Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Sun, 4 Oct 2026 12:05:33 +0200 Subject: [PATCH 11/36] feat: enhance type inference for JSONiq object fields and unions --- .../rumbledb/compiler/InferTypeVisitor.java | 112 +++++++++---- .../org/rumbledb/types/ItemTypeFactory.java | 5 + .../java/org/rumbledb/types/SequenceType.java | 9 ++ .../java/org/rumbledb/types/TypeMappings.java | 6 +- .../org/rumbledb/types/UnionItemType.java | 1 + .../ObjectConstructorTypeInferenceTest.java | 153 ++++++++++++++++-- 6 files changed, 233 insertions(+), 53 deletions(-) diff --git a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java index 810589d7c2..bf9b64d1b9 100644 --- a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java +++ b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java @@ -467,7 +467,10 @@ public StaticContext visitObjectConstructor(ObjectConstructorExpression expressi stringLiteralKeys.add(stringLiteralKey); } } - if (expression.getKeys() != null && stringLiteralKeys.size() == expression.getKeys().size()) { + if (expression.getKeys() != null + && stringLiteralKeys.size() == expression.getKeys().size()) { + // Literal keys define the shape even when a value expression can return zero or many items. + // Infer each stored item after the constructor's null/array conversion. expression.setStaticSequenceType(new SequenceType(ItemTypeFactory.createAnonymousObjectType( stringLiteralKeys.stream() .map(StringLiteralExpression::getValue) @@ -1987,8 +1990,9 @@ public StaticContext visitArrayLookupExpression(ArrayLookupExpression expression : SequenceType.Arity.ZeroOrMore; ItemType resultItemType = BuiltinTypesCatalogue.item; ItemType itemType = mainType.getItemType(); - if (itemType.isArrayItemType()) { - resultItemType = itemType.getArrayContentFacet(); + ItemType arrayContentType = inferArrayContentType(itemType); + if (arrayContentType != null) { + resultItemType = arrayContentType; } expression.setStaticSequenceType(new SequenceType(resultItemType, inferredArity)); return argument; @@ -2024,41 +2028,79 @@ public StaticContext visitObjectLookupExpression(ObjectLookupExpression expressi expression.getMetadata()); } - SequenceType.Arity inferredArity = mainType.isAritySubtypeOf(SequenceType.Arity.OneOrZero) + String key = + expression.getLookupExpression() instanceof StringLiteralExpression literal ? literal.getValue() : null; + SequenceType result = inferObjectLookupType(mainType.getItemType(), mainType.getArity(), key); + if (result.isEmptySequence()) { + throwStaticTypeException( + "Inferred type is empty sequence and this is not a CommaExpression", + ErrorCode.StaticallyInferredEmptySequenceNotFromCommaExpression, + expression.getMetadata()); + } + expression.setStaticSequenceType(result); + return argument; + } + + /** + * Infers JSONiq dot selection separately for each possible input type. + * For example, selecting n from ({n: integer} | null) yields integer?: + * the object branch yields an integer, while the null branch yields the empty sequence. + * A null key here denotes an expression whose string value is not known statically. + */ + private SequenceType inferObjectLookupType(ItemType itemType, SequenceType.Arity inputArity, String key) { + if (itemType.isUnionType()) { + SequenceType result = null; + for (ItemType member : itemType.getTypes()) { + SequenceType memberResult = inferObjectLookupType(member, inputArity, key); + // Include empty branches: they make a required field's lookup result optional. + result = result == null ? memberResult : result.leastCommonSupertypeWith(memberResult); + } + return result == null ? SequenceType.createSequenceType("()") : result; + } + if (itemType.isMapItemType()) { + return itemType.getMapValueSequenceType(); + } + SequenceType.Arity outputArity = inputArity.isSubtypeOf(SequenceType.Arity.OneOrZero) ? SequenceType.Arity.OneOrZero : SequenceType.Arity.ZeroOrMore; - - ItemType inferredType = BuiltinTypesCatalogue.item; - if (mainType.getItemType().isMapItemType()) { - SequenceType mapValueType = mainType.getItemType().getMapValueSequenceType(); - inferredType = mapValueType.getItemType(); - inferredArity = mapValueType.getArity(); + if (itemType.isObjectItemType() && key != null) { + FieldDescriptor field = itemType.getObjectContentFacet(key); + if (field != null) { + return new SequenceType(field.getType(), field.isRequired() ? inputArity : outputArity); + } + if (itemType.getClosedFacet()) { + return SequenceType.createSequenceType("()"); + } + } + SequenceType type = new SequenceType(itemType, inputArity); + if (!type.hasOverlapWith(SequenceType.createSequenceType("object*")) + && !type.hasOverlapWith(SequenceType.createSequenceType("map*"))) { + // JSONiq object selection ignores nonobjects, including null. + return SequenceType.createSequenceType("()"); } - // if we have a specific object type and a string literal as key try to perform better inference - if (mainType.getItemType().isObjectItemType() - && (expression.getLookupExpression() instanceof StringLiteralExpression stringLiteralExpr)) { - String key = stringLiteralExpr.getValue(); - boolean isObjectClosed = mainType.getItemType().getClosedFacet(); - List objectKeys = mainType.getItemType().getObjectKeysFacet(); - if (objectKeys.contains(key)) { - FieldDescriptor field = mainType.getItemType().getObjectContentFacet(key); - inferredType = field.getType(); - if (field.isRequired()) { - // if the field is required then any object will have it, so no need to include '0' arity if not - // present - inferredArity = mainType.getArity(); + return new SequenceType(BuiltinTypesCatalogue.item, outputArity); + } + + /** + * Combines content types of possible arrays for JSONiq lookup and unboxing. + * Nonarray alternatives contribute no results. Returns Java null when no array is possible; + * a broad type that could contain arrays instead contributes item. + */ + private ItemType inferArrayContentType(ItemType itemType) { + if (itemType.isUnionType()) { + ItemType result = null; + for (ItemType member : itemType.getTypes()) { + ItemType memberContent = inferArrayContentType(member); + if (memberContent != null) { + result = result == null ? memberContent : result.findLeastCommonSuperTypeWith(memberContent); } - } else if (isObjectClosed) { - // if object is closed and key is not found then for sure we will return the empty sequence - throwStaticTypeException( - "Inferred type is empty sequence and this is not a CommaExpression", - ErrorCode.StaticallyInferredEmptySequenceNotFromCommaExpression, - expression.getMetadata()); } + return result; } - - expression.setStaticSequenceType(new SequenceType(inferredType, inferredArity)); - return argument; + if (itemType.isArrayItemType()) { + return itemType.getArrayContentFacet(); + } + return BuiltinTypesCatalogue.arrayItem.isSubtypeOf(itemType) ? BuiltinTypesCatalogue.item : null; } @Override @@ -2121,9 +2163,9 @@ public StaticContext visitArrayUnboxingExpression(ArrayUnboxingExpression expres ErrorCode.StaticallyInferredEmptySequenceNotFromCommaExpression, expression.getMetadata()); } - if (mainType.getItemType().isArrayItemType()) { - SequenceType sequenceType = - new SequenceType(mainType.getItemType().getArrayContentFacet(), SequenceType.Arity.ZeroOrMore); + ItemType contentType = inferArrayContentType(mainType.getItemType()); + if (contentType != null) { + SequenceType sequenceType = new SequenceType(contentType, SequenceType.Arity.ZeroOrMore); expression.setStaticSequenceType(sequenceType); return argument; } diff --git a/src/main/java/org/rumbledb/types/ItemTypeFactory.java b/src/main/java/org/rumbledb/types/ItemTypeFactory.java index 8d9bd2af76..ab135ef6e6 100644 --- a/src/main/java/org/rumbledb/types/ItemTypeFactory.java +++ b/src/main/java/org/rumbledb/types/ItemTypeFactory.java @@ -186,6 +186,8 @@ public static ItemType createAnonymousObjectType(List keys, List type.isSubtypeOf(BuiltinTypesCatalogue.atomicItem)) ? BuiltinTypesCatalogue.atomicItem : BuiltinTypesCatalogue.item; diff --git a/src/main/java/org/rumbledb/types/SequenceType.java b/src/main/java/org/rumbledb/types/SequenceType.java index 7b6467cae1..31505204e1 100644 --- a/src/main/java/org/rumbledb/types/SequenceType.java +++ b/src/main/java/org/rumbledb/types/SequenceType.java @@ -171,6 +171,15 @@ public boolean hasOverlapWith(SequenceType other) { if (other.isEmptySequence()) { return this.getArity() == Arity.OneOrZero || this.getArity() == Arity.ZeroOrMore; } + // A union overlaps another type when at least one member overlaps it. + // Subtyping alone would reject, for example, (object | null) against object. + if (this.itemType.isUnionType()) { + return this.itemType.getTypes().stream() + .anyMatch(member -> new SequenceType(member, this.arity).hasOverlapWith(other)); + } + if (other.itemType.isUnionType()) { + return other.hasOverlapWith(this); + } // All arities overlap between each other return this.getItemType().isSubtypeOf(other.getItemType()) || other.getItemType().isSubtypeOf(this.getItemType()); diff --git a/src/main/java/org/rumbledb/types/TypeMappings.java b/src/main/java/org/rumbledb/types/TypeMappings.java index 851d0096e9..bc9ecfab46 100644 --- a/src/main/java/org/rumbledb/types/TypeMappings.java +++ b/src/main/java/org/rumbledb/types/TypeMappings.java @@ -84,7 +84,9 @@ public static DataType getDataFrameDataTypeFromItemType(ItemType itemType, Runti if (itemType.isSubtypeOf(BuiltinTypesCatalogue.hexBinaryItem)) { return DataTypes.BinaryType; } - if (itemType.isSubtypeOf(BuiltinTypesCatalogue.objectItem)) { + // A union can be a subtype of object or array without exposing their facets. + // Leave these unions to the union mapping below. + if (!itemType.isUnionType() && itemType.isSubtypeOf(BuiltinTypesCatalogue.objectItem)) { List fields = new ArrayList<>(); itemType.getObjectKeysFacet() .forEach(key -> fields.add(DataTypes.createStructField( @@ -97,7 +99,7 @@ public static DataType getDataFrameDataTypeFromItemType(ItemType itemType, Runti } return DataTypes.BinaryType; } - if (itemType.isSubtypeOf(BuiltinTypesCatalogue.arrayItem)) { + if (!itemType.isUnionType() && itemType.isSubtypeOf(BuiltinTypesCatalogue.arrayItem)) { return DataTypes.createArrayType( getDataFrameDataTypeFromItemType(itemType.getArrayContentFacet(), staticContext)); } diff --git a/src/main/java/org/rumbledb/types/UnionItemType.java b/src/main/java/org/rumbledb/types/UnionItemType.java index a63b65d338..e44fcf486b 100644 --- a/src/main/java/org/rumbledb/types/UnionItemType.java +++ b/src/main/java/org/rumbledb/types/UnionItemType.java @@ -143,6 +143,7 @@ public boolean isSubtypeOf(ItemType superType) { if (this.equals(superType) || super.isSubtypeOf(superType)) { return true; } + // Every possible member must fit the target type; overlap only requires one member to overlap it. return this.types.stream().allMatch(type -> type.isSubtypeOf(superType)); } diff --git a/src/test/java/org/rumbledb/compiler/ObjectConstructorTypeInferenceTest.java b/src/test/java/org/rumbledb/compiler/ObjectConstructorTypeInferenceTest.java index 3321ae9994..1ede49dbfb 100644 --- a/src/test/java/org/rumbledb/compiler/ObjectConstructorTypeInferenceTest.java +++ b/src/test/java/org/rumbledb/compiler/ObjectConstructorTypeInferenceTest.java @@ -18,6 +18,7 @@ import java.net.URI; import java.util.List; +import org.apache.spark.sql.types.DataTypes; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -29,11 +30,16 @@ import org.rumbledb.bindings.ExternalBindings; import org.rumbledb.config.CompilationConfiguration; import org.rumbledb.config.RumbleConfiguration; +import org.rumbledb.context.RuntimeStaticContext; +import org.rumbledb.exceptions.ExceptionMetadata; +import org.rumbledb.exceptions.UnexpectedStaticTypeException; +import org.rumbledb.expressions.ExecutionMode; import org.rumbledb.types.BuiltinTypesCatalogue; import org.rumbledb.types.FieldDescriptor; import org.rumbledb.types.ItemType; import org.rumbledb.types.ItemTypeFactory; import org.rumbledb.types.SequenceType; +import org.rumbledb.types.TypeMappings; class ObjectConstructorTypeInferenceTest { private static final RumbleConfiguration CONFIGURATION = RumbleConfiguration.builder() @@ -65,13 +71,15 @@ void storesEmptySequenceAsRequiredNullField() { ItemType objectType = infer("{\"empty\": (), \"known\": 1}", "jq").getItemType(); assertEquals(BuiltinTypesCatalogue.nullItem, field(objectType, "empty")); assertEquals(BuiltinTypesCatalogue.integerItem, field(objectType, "known")); - assertEquals(SequenceType.Arity.One, infer("{\"empty\": ()}.empty", "jq").getArity()); + assertEquals( + SequenceType.Arity.One, infer("{\"empty\": ()}.empty", "jq").getArity()); } @ParameterizedTest @ValueSource(ints = {0, 1, 2}) void sequenceValuesCoverNullSingletonAndWrappedArray(int length) { - String query = "let $values := (for $i in 1 to 2 where $i le " + length + String query = "let $values := (for $i in 1 to 2 where $i le " + + length + " return $i) return {\"value\": $values, \"known\": 1}"; ItemType objectType = infer(query, "jq").getItemType(); ItemType valueType = field(objectType, "value"); @@ -83,7 +91,8 @@ void sequenceValuesCoverNullSingletonAndWrappedArray(int length) { assertTrue(arrayType.isArrayItemType()); assertEquals(BuiltinTypesCatalogue.integerItem, arrayType.getArrayContentFacet()); assertEquals(BuiltinTypesCatalogue.nullItem, valueType.getTypes().get(2)); - Item value = new Rumble(CONFIGURATION).runQuery(query).getAsList().get(0).getItemByKey("value"); + Item value = + new Rumble(CONFIGURATION).runQuery(query).getAsList().get(0).getItemByKey("value"); // Dynamic arrays carry a generic type; check their members against the inferred content. if (length > 1) { assertTrue(value.isArray()); @@ -99,8 +108,8 @@ void sequenceValuesCoverNullSingletonAndWrappedArray(int length) { @Test void nonemptySequenceIncludesScalarAndArrayButNoIntroducedNull() { - ItemType objectType = infer( - "declare variable $values as xs:integer+ := (1, 2); {\"value\": $values}", "jq").getItemType(); + ItemType objectType = infer("declare variable $values as xs:integer+ := (1, 2); {\"value\": $values}", "jq") + .getItemType(); ItemType valueType = field(objectType, "value"); assertTrue(valueType.isUnionType()); assertEquals(2, valueType.getTypes().size()); @@ -114,14 +123,15 @@ void preservesNestedObjectFields() { ItemType nestedType = field(objectType, "nested"); assertTrue(nestedType.isObjectItemType()); assertTrue(field(nestedType, "value").isUnionType()); - assertEquals(BuiltinTypesCatalogue.integerItem, infer( - "{\"nested\": {\"value\": math:pow(2, 3)}, \"known\": 1}.known", "jq").getItemType()); + assertEquals( + BuiltinTypesCatalogue.integerItem, + infer("{\"nested\": {\"value\": math:pow(2, 3)}, \"known\": 1}.known", "jq") + .getItemType()); } @Test void optionalObjectIncludesOriginalShapeAndNull() { - ItemType objectType = infer( - "declare variable $value as object? := (); {\"value\": $value, \"known\": 1}", "jq") + ItemType objectType = infer("declare variable $value as object? := (); {\"value\": $value, \"known\": 1}", "jq") .getItemType(); ItemType valueType = field(objectType, "value"); assertTrue(valueType.isUnionType()); @@ -131,19 +141,57 @@ void optionalObjectIncludesOriginalShapeAndNull() { @Test void keepsGenericTypeForDynamicKeysAndMergedConstructors() { - assertEquals(BuiltinTypesCatalogue.objectItem, + assertEquals( + BuiltinTypesCatalogue.objectItem, infer("{string(1): math:pow(2, 3)}", "jq").getItemType()); - assertEquals(BuiltinTypesCatalogue.objectItem, + assertEquals( + BuiltinTypesCatalogue.objectItem, infer("{| {\"value\": 1} |}", "jq").getItemType()); assertTrue(infer("({})", "jq").getItemType().getObjectKeysFacet().isEmpty()); } @Test - void nullableUnionAlreadyContainingNullDoesNotDuplicateIt() { - ItemType type = field(infer("{\"value\": if (true) then null else ()}", "jq").getItemType(), "value"); + void optionalNullRemainsNull() { + ItemType type = + field(infer("{\"value\": if (true) then null else ()}", "jq").getItemType(), "value"); assertEquals(BuiltinTypesCatalogue.nullItem, type); } + @Test + void nullableUnionAlreadyContainingNullDoesNotDuplicateIt() { + ItemType nullable = ItemTypeFactory.createObjectFieldType( + new SequenceType(BuiltinTypesCatalogue.doubleItem, SequenceType.Arity.OneOrZero)); + ItemType result = + ItemTypeFactory.createObjectFieldType(new SequenceType(nullable, SequenceType.Arity.OneOrZero)); + assertEquals(List.of(BuiltinTypesCatalogue.doubleItem, BuiltinTypesCatalogue.nullItem), result.getTypes()); + } + + @Test + void arrayAlternativesUseTheUnionDataFrameMapping() { + String query = "{\"value\": ([1], [2])}"; + ItemType arrayAlternatives = field(infer(query, "jq").getItemType(), "value"); + assertTrue(arrayAlternatives.isUnionType()); + assertTrue(arrayAlternatives.isSubtypeOf(BuiltinTypesCatalogue.arrayItem)); + assertFalse(arrayAlternatives.isCompatibleWithDataFrames(CONFIGURATION)); + RuntimeStaticContext context = RuntimeStaticContext.builder() + .configuration(CONFIGURATION) + .executionMode(ExecutionMode.LOCAL) + .metadata(ExceptionMetadata.EMPTY_METADATA) + .build(); + // The union has no array content facet, even though every alternative is an array. + assertEquals(DataTypes.StringType, TypeMappings.getDataFrameDataTypeFromItemType(arrayAlternatives, context)); + Item value = + new Rumble(CONFIGURATION).runQuery(query).getAsList().get(0).getItemByKey("value"); + assertTrue(value.isArray()); + assertEquals(2, value.getSize()); + for (int index = 0; index < 2; index++) { + Item member = value.getItemAt(index); + assertTrue(member.isArray()); + assertEquals(1, member.getSize()); + assertEquals(index + 1, member.getItemAt(0).getIntValue()); + } + } + @Test void scalarArrayUnionsRemainSoundWhenMergedWithAtomicTypes() { ItemType valueType = ItemTypeFactory.createObjectFieldType( @@ -166,6 +214,75 @@ void inferredNullableUnionCanBeComparedWithEquivalentUnion() { assertTrue(left.isSubtypeOf(BuiltinTypesCatalogue.atomicItem)); } + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void nullableNestedObjectSupportsChainedLookup(boolean present) { + String query = "let $v := if (" + present + ") then {\"n\": 1} else () return {\"v\": $v}.v.n"; + SequenceType result = infer(query, "jq"); + assertEquals(BuiltinTypesCatalogue.integerItem, result.getItemType()); + assertEquals(SequenceType.Arity.OneOrZero, result.getArity()); + List values = new Rumble(CONFIGURATION).runQuery(query).getAsList(); + assertEquals(present ? 1 : 0, values.size()); + if (present) { + assertEquals(1, values.get(0).getIntValue()); + } + } + + @Test + void lookupMissingFromEveryAlternativeStillReportsAnEmptyResult() { + String query = "let $v := if (true) then {\"n\": 1} else () return {\"v\": $v}.v.missing"; + assertThrows(UnexpectedStaticTypeException.class, () -> infer(query, "jq")); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 2}) + void sequenceFieldSupportsArrayLookupAndUnboxing(int length) { + String header = "let $v := (for $i in 1 to 2 where $i le " + length + " return $i) return {\"v\": $v}.v"; + SequenceType lookup = infer(header + "[[1]]", "jq"); + assertEquals(BuiltinTypesCatalogue.integerItem, lookup.getItemType()); + assertEquals(SequenceType.Arity.OneOrZero, lookup.getArity()); + SequenceType unboxing = infer(header + "[]", "jq"); + assertEquals(BuiltinTypesCatalogue.integerItem, unboxing.getItemType()); + assertEquals(SequenceType.Arity.ZeroOrMore, unboxing.getArity()); + List selected = + new Rumble(CONFIGURATION).runQuery(header + "[[1]]").getAsList(); + assertEquals(length > 1 ? 1 : 0, selected.size()); + List unboxed = new Rumble(CONFIGURATION).runQuery(header + "[]").getAsList(); + assertEquals(length > 1 ? length : 0, unboxed.size()); + } + + @Test + void unionOverlapIsExistentialButSubtypingIsUniversal() { + ItemType union = ItemTypeFactory.createObjectFieldType( + new SequenceType(BuiltinTypesCatalogue.integerItem, SequenceType.Arity.ZeroOrMore)); + SequenceType sequence = new SequenceType(union); + SequenceType arrays = SequenceType.createSequenceType("array*"); + SequenceType objects = SequenceType.createSequenceType("object*"); + assertTrue(sequence.hasOverlapWith(arrays)); + assertTrue(arrays.hasOverlapWith(sequence)); + assertFalse(sequence.isSubtypeOf(arrays)); + assertFalse(sequence.hasOverlapWith(objects)); + assertFalse(objects.hasOverlapWith(sequence)); + } + + @Test + void xqueryMapValuesRetainEmptyAndMultipleItemSequences() { + String query = "let $m := map {\"empty\": (), \"many\": (1, 2)} " + + "return (map:contains($m, \"empty\"), count($m?empty), count($m?many), $m?many)"; + assertTrue(infer("map {\"empty\": (), \"many\": (1, 2)}", "xq") + .getItemType() + .isMapItemType()); + List values = new Rumble(CONFIGURATION) + .runQuery(query, URI.create("file:///map-values.xq")) + .getAsList(); + assertEquals(5, values.size()); + assertTrue(values.get(0).getBooleanValue()); + assertEquals(0, values.get(1).getIntValue()); + assertEquals(2, values.get(2).getIntValue()); + assertEquals(1, values.get(3).getIntValue()); + assertEquals(2, values.get(4).getIntValue()); + } + private static ItemType field(ItemType objectType, String key) { assertTrue(objectType.isObjectItemType()); FieldDescriptor field = objectType.getObjectContentFacet(key); @@ -175,8 +292,12 @@ private static ItemType field(ItemType objectType, String key) { } private static SequenceType infer(String query, String extension) { - return CompilationPipeline.compileMainModule(query, URI.create("file:///object-inference." + extension), - new CompilationConfiguration(CONFIGURATION), ExternalBindings.empty()) - .getExpression().getStaticSequenceType(); + return CompilationPipeline.compileMainModule( + query, + URI.create("file:///object-inference." + extension), + new CompilationConfiguration(CONFIGURATION), + ExternalBindings.empty()) + .getExpression() + .getStaticSequenceType(); } } From 9a93186fd96eb4f4643f65713b22404a0550d225 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Sun, 4 Oct 2026 12:36:08 +0200 Subject: [PATCH 12/36] Fix CI --- src/main/java/org/rumbledb/items/parsing/ItemParser.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/rumbledb/items/parsing/ItemParser.java b/src/main/java/org/rumbledb/items/parsing/ItemParser.java index a80e9452d6..0bfc8dd808 100644 --- a/src/main/java/org/rumbledb/items/parsing/ItemParser.java +++ b/src/main/java/org/rumbledb/items/parsing/ItemParser.java @@ -466,6 +466,7 @@ public static Item getItemFromRow(Row row, ExceptionMetadata metadata, ItemType DataType fieldType = field.dataType(); String fieldName = field.name(); ItemType fieldItemType = null; + boolean requiredField = false; if (fieldName.equals(SparkSessionManager.mutabilityLevelColumnName)) { mutabilityLevel = row.getInt(i); @@ -494,6 +495,7 @@ public static Item getItemFromRow(Row row, ExceptionMetadata metadata, ItemType FieldDescriptor descriptor = itemType.getObjectContentFacet(fieldName); if (descriptor != null) { fieldItemType = descriptor.getType(); + requiredField = descriptor.isRequired(); if (fieldItemType == null) { throw new OurBadException("Type for field " + fieldName @@ -504,10 +506,11 @@ public static Item getItemFromRow(Row row, ExceptionMetadata metadata, ItemType } } Item newItem = convertValueToItem(row, i, null, fieldType, metadata, fieldItemType); - // NULL values in DataFrames are mapped to absent in JSONiq. + // SQL NULL represents absence for optional fields, but required constructor fields + // must retain their key and store JSON null (e.g., an empty value expression). if (!newItem.isNull() || (!fieldName.equals(SparkSessionManager.emptyObjectJSONiqItemColumnName) - && fieldType.equals(DataTypes.NullType))) { + && (requiredField || fieldType.equals(DataTypes.NullType)))) { // don't return array for single sequence item if (fieldName.endsWith(SparkSessionManager.sequenceColumnName)) { if (newItem.getSize() == 0) { From a6be58f3292adb60ee20ece4dbae881929bbfdf8 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Tue, 6 Oct 2026 20:38:29 +0200 Subject: [PATCH 13/36] fix: type constructors with duplicate literal keys as error Such a constructor always fails at runtime, so like fn:error() it returns no value; the inferred object type listed the key twice. Also clarify why union types fall back to item when a member is not atomic. --- .../org/rumbledb/compiler/InferTypeVisitor.java | 15 ++++++++++----- .../java/org/rumbledb/types/ItemTypeFactory.java | 3 ++- .../java/org/rumbledb/types/UnionItemType.java | 4 ++-- .../ObjectConstructorTypeInferenceTest.java | 7 +++++++ 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java index bf9b64d1b9..00d23436f7 100644 --- a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java +++ b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java @@ -467,14 +467,19 @@ public StaticContext visitObjectConstructor(ObjectConstructorExpression expressi stringLiteralKeys.add(stringLiteralKey); } } - if (expression.getKeys() != null - && stringLiteralKeys.size() == expression.getKeys().size()) { + List literalKeys = stringLiteralKeys.stream() + .map(StringLiteralExpression::getValue) + .toList(); + boolean literalKeysOnly = expression.getKeys() != null + && literalKeys.size() == expression.getKeys().size(); + if (literalKeysOnly && new HashSet<>(literalKeys).size() < literalKeys.size()) { + // Duplicate keys always make the constructor fail at runtime, so it returns no value, like fn:error(). + expression.setStaticSequenceType(new SequenceType(BuiltinTypesCatalogue.errorItem)); + } else if (literalKeysOnly) { // Literal keys define the shape even when a value expression can return zero or many items. // Infer each stored item after the constructor's null/array conversion. expression.setStaticSequenceType(new SequenceType(ItemTypeFactory.createAnonymousObjectType( - stringLiteralKeys.stream() - .map(StringLiteralExpression::getValue) - .collect(Collectors.toList()), + literalKeys, expression.getValues().stream() .map(value -> ItemTypeFactory.createObjectFieldType(value.getStaticSequenceType())) .collect(Collectors.toList())))); diff --git a/src/main/java/org/rumbledb/types/ItemTypeFactory.java b/src/main/java/org/rumbledb/types/ItemTypeFactory.java index ab135ef6e6..d918140160 100644 --- a/src/main/java/org/rumbledb/types/ItemTypeFactory.java +++ b/src/main/java/org/rumbledb/types/ItemTypeFactory.java @@ -224,7 +224,8 @@ public static ItemType createObjectFieldType(SequenceType valueType) { if (alternatives.size() == 1) { return alternatives.get(0); } - // A scalar/array union cannot have an atomic base: that would exclude valid array results. + // isSubtypeOf checks the base type before the members, so the base must cover every member: + // xs:anyAtomicType only if all members are atomic, otherwise item (e.g. when one is an array). ItemType baseType = alternatives.stream().allMatch(type -> type.isSubtypeOf(BuiltinTypesCatalogue.atomicItem)) ? BuiltinTypesCatalogue.atomicItem : BuiltinTypesCatalogue.item; diff --git a/src/main/java/org/rumbledb/types/UnionItemType.java b/src/main/java/org/rumbledb/types/UnionItemType.java index e44fcf486b..7e5c823d9f 100644 --- a/src/main/java/org/rumbledb/types/UnionItemType.java +++ b/src/main/java/org/rumbledb/types/UnionItemType.java @@ -281,8 +281,8 @@ public ItemType findLeastCommonSuperTypeWith(ItemType other) { boolean hasNull = false; Set resultTypes = new HashSet<>(this.types); resultTypes.addAll(otherTypes); - // Scalar/array unions inferred for object values must not be widened to an - // atomic type, which would exclude the possible array values. + // The atomic join below widens to an atomic base type, which cannot cover non-atomic members + // (for example, the array alternative of an inferred object field). Fall back to item instead. if (resultTypes.stream().anyMatch(type -> !type.isSubtypeOf(BuiltinTypesCatalogue.atomicItem))) { return BuiltinTypesCatalogue.item; } diff --git a/src/test/java/org/rumbledb/compiler/ObjectConstructorTypeInferenceTest.java b/src/test/java/org/rumbledb/compiler/ObjectConstructorTypeInferenceTest.java index 1ede49dbfb..b066364fe0 100644 --- a/src/test/java/org/rumbledb/compiler/ObjectConstructorTypeInferenceTest.java +++ b/src/test/java/org/rumbledb/compiler/ObjectConstructorTypeInferenceTest.java @@ -150,6 +150,13 @@ void keepsGenericTypeForDynamicKeysAndMergedConstructors() { assertTrue(infer("({})", "jq").getItemType().getObjectKeysFacet().isEmpty()); } + @Test + void duplicateLiteralKeysNeverProduceAnObject() { + assertEquals( + BuiltinTypesCatalogue.errorItem, + infer("{\"a\": 1, \"a\": 2}", "jq").getItemType()); + } + @Test void optionalNullRemainsNull() { ItemType type = From a1e33c95185ba5168140271a6c84ec36bad83308 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Sun, 4 Oct 2026 14:22:34 +0200 Subject: [PATCH 14/36] Add SequenceCardinality to enhance sequence type handling - Introduced SequenceCardinality enum to represent possible sequence sizes, allowing for more flexible type inference. - Updated SequenceType to utilize SequenceCardinality instead of Arity for better clarity and functionality. - Refactored ItemTypeFactory to accommodate the new cardinality system, ensuring correct type inference and union handling. - Enhanced tests to validate the behavior of the new cardinality system, including inferred unions and type compatibility. - Updated Kryo serialization to preserve inferred sequence cardinalities during object round-tripping. --- .../rumbledb/compiler/InferTypeVisitor.java | 122 +++------ .../runtime/flwor/FlworDataFrameUtils.java | 2 + .../rumbledb/spark/SparkSessionManager.java | 2 + .../org/rumbledb/types/ItemTypeFactory.java | 62 +++-- .../rumbledb/types/SequenceCardinality.java | 112 ++++++++ .../java/org/rumbledb/types/SequenceType.java | 248 ++++++++---------- .../ObjectConstructorTypeInferenceTest.java | 117 ++++++++- .../flwor/udfs/DataFrameContextKryoTest.java | 13 + .../rumbledb/types/ItemTypeFactoryTest.java | 52 ++++ .../types/SequenceCardinalityTest.java | 79 ++++++ .../Atomic/Atomiccomma.jq | 16 +- 11 files changed, 578 insertions(+), 247 deletions(-) create mode 100644 src/main/java/org/rumbledb/types/SequenceCardinality.java create mode 100644 src/test/java/org/rumbledb/types/ItemTypeFactoryTest.java create mode 100644 src/test/java/org/rumbledb/types/SequenceCardinalityTest.java diff --git a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java index 00d23436f7..2e42b0cb22 100644 --- a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java +++ b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java @@ -183,6 +183,7 @@ import org.rumbledb.types.ItemType; import org.rumbledb.types.ItemTypeFactory; import org.rumbledb.types.SchemaElementNodeItemType; +import org.rumbledb.types.SequenceCardinality; import org.rumbledb.types.SequenceType; import org.rumbledb.xml.schema.XmlSchemaCatalog; @@ -304,43 +305,7 @@ public StaticContext visitCommaExpression(CommaExpression expression, StaticCont SequenceType childExpressionInferredType = requireInferredType(childExpression.getStaticSequenceType(), "CommaExpression"); - // if the child expression is an EMPTY_SEQUENCE it does not affect the comma expression type - if (!childExpressionInferredType.isEmptySequence()) { - if (inferredType.isEmptySequence()) { - inferredType = childExpressionInferredType; - } else { - ItemType resultingItemType; - if (inferredType.getItemType().isObjectItemType() - && childExpressionInferredType.getItemType().isObjectItemType()) { - ItemType currentItemType = inferredType.getItemType(); - ItemType childItemType = childExpressionInferredType.getItemType(); - final List currentKeys = currentItemType.getObjectKeysFacet(); - List childKeys = childItemType.getObjectKeysFacet(); - resultingItemType = (currentKeys.size() == childKeys.size() - && currentKeys.stream() - .allMatch(key -> childKeys.contains(key) - && currentItemType - .getObjectContentFacet(key) - .getType() - .equals(childItemType - .getObjectContentFacet(key) - .getType()))) - ? currentItemType - : BuiltinTypesCatalogue.objectItem; - } else { - resultingItemType = inferredType - .getItemType() - .findLeastCommonSuperTypeWith(childExpressionInferredType.getItemType()); - } - SequenceType.Arity resultingArity = ((inferredType.getArity() == SequenceType.Arity.OneOrZero - || inferredType.getArity() == SequenceType.Arity.ZeroOrMore) - && (childExpressionInferredType.getArity() == SequenceType.Arity.OneOrZero - || childExpressionInferredType.getArity() == SequenceType.Arity.ZeroOrMore)) - ? SequenceType.Arity.ZeroOrMore - : SequenceType.Arity.OneOrMore; - inferredType = new SequenceType(resultingItemType, resultingArity); - } - } + inferredType = inferredType.concatenateWith(childExpressionInferredType); } expression.setStaticSequenceType(inferredType); @@ -2317,8 +2282,8 @@ public StaticContext visitSimpleMapExpr(SimpleMapExpression expression, StaticCo SequenceType rightType = rightExpression.getStaticSequenceType(); basicChecks(rightType, expression.getClass().getSimpleName(), true, true, expression.getMetadata()); - SequenceType.Arity resultingArity = leftType.getArity().multiplyWith(rightType.getArity()); - expression.setStaticSequenceType(new SequenceType(rightType.getItemType(), resultingArity)); + SequenceCardinality resultingCardinality = leftType.getCardinality().multiply(rightType.getCardinality()); + expression.setStaticSequenceType(new SequenceType(rightType.getItemType(), resultingCardinality)); return argument; } @@ -2329,14 +2294,14 @@ public StaticContext visitSimpleMapExpr(SimpleMapExpression expression, StaticCo @Override public StaticContext visitFlowrExpression(FlworExpression expression, StaticContext argument) { Clause clause = expression.getReturnClause().getFirstClause(); - SequenceType.Arity forArities = SequenceType.Arity.One; // One is arity multiplication's neutral element + SequenceCardinality forCardinality = SequenceCardinality.ONE; SequenceType forType; while (clause != null) { try { this.visit(clause, clause.getStaticContext()); } catch (UnexpectedStaticTypeException e) { - if (forArities.equals(SequenceType.Arity.Zero) + if (forCardinality == SequenceCardinality.EMPTY && clause.getClauseType().equals(FLWOR_CLAUSES.WHERE)) { clause = clause.getNextClause(); continue; @@ -2351,27 +2316,32 @@ public StaticContext visitFlowrExpression(FlworExpression expression, StaticCont // therefore this for loop will generate one tuple binding the empty sequence, so as for the arities // count as arity.One if (!forType.isEmptySequence()) { - forArities = forType.getArity().multiplyWith(forArities); + SequenceCardinality sourceCardinality = forType.getCardinality(); + if (((ForClause) clause).isAllowEmpty()) { + // An empty source still emits one tuple with an empty binding. + sourceCardinality = sourceCardinality.replaceZeroWithOne(); + } + forCardinality = sourceCardinality.multiply(forCardinality); } else if (!((ForClause) clause).isAllowEmpty()) { - forArities = SequenceType.Arity.Zero; + forCardinality = SequenceCardinality.EMPTY; } + } else if (clause.getClauseType() == FLWOR_CLAUSES.GROUP_BY) { + // Multiple input tuples can collapse into a single group. + forCardinality = SequenceCardinality.fromArity(forCardinality.toArity()); } else if (clause.getClauseType() == FLWOR_CLAUSES.WHERE) { - // where clause could reject all tuples so arity change from + => * and 1 => ? - if (forArities == SequenceType.Arity.One) { - forArities = SequenceType.Arity.OneOrZero; - } else if (forArities == SequenceType.Arity.OneOrMore) { - forArities = SequenceType.Arity.ZeroOrMore; - } + // Filtering tuples can leave zero, one, or multiple tuples. + forCardinality = + SequenceCardinality.fromArity(forCardinality.toArity()).union(SequenceCardinality.EMPTY); } else if (clause.getClauseType() == FLWOR_CLAUSES.WINDOW) { - forArities = SequenceType.Arity.ZeroOrMore; + forCardinality = SequenceCardinality.ANY; } clause = clause.getNextClause(); } SequenceType returnType = expression.getReturnClause().getReturnExpr().getStaticSequenceType(); basicChecks(returnType, expression.getClass().getSimpleName(), true, true, expression.getMetadata()); - returnType = - new SequenceType(returnType.getItemType(), returnType.getArity().multiplyWith(forArities)); + returnType = new SequenceType( + returnType.getItemType(), returnType.getCardinality().multiply(forCardinality)); expression.setStaticSequenceType(returnType); return argument; } @@ -2721,7 +2691,8 @@ public StaticContext visitProlog(Prolog prolog, StaticContext argument) { public StaticContext visitValidateTypeExpression(ValidateTypeExpression expression, StaticContext argument) { visitDescendants(expression, expression.getStaticContext()); SequenceType sourceType = expression.getMainExpression().getStaticSequenceType(); - expression.setStaticSequenceType(expression.getSequenceType().refineArityIfSubtype(sourceType.getArity())); + expression.setStaticSequenceType( + expression.getSequenceType().refineCardinalityIfSubtype(sourceType.getCardinality())); return argument; } @@ -2774,25 +2745,10 @@ private SequenceType getSequenceTypeFromChildren( // if a child expression has no inferred type throw an error if (childSequenceType == null) { throwStaticTypeException("A child expression of a BlockStatement has no inferred type", childMetadata); + return inferredType; } - // if the child expression is an EMPTY_SEQUENCE it does not affect the comma expression type - if (childSequenceType != null && !childSequenceType.isEmptySequence()) { - if (inferredType.isEmptySequence()) { - inferredType = childSequenceType; - } else { - ItemType resultingItemType = - inferredType.getItemType().findLeastCommonSuperTypeWith(childSequenceType.getItemType()); - SequenceType.Arity resultingArity = ((inferredType.getArity() == SequenceType.Arity.OneOrZero - || inferredType.getArity() == SequenceType.Arity.ZeroOrMore) - && (childSequenceType.getArity() == SequenceType.Arity.OneOrZero - || childSequenceType.getArity() == SequenceType.Arity.ZeroOrMore)) - ? SequenceType.Arity.ZeroOrMore - : SequenceType.Arity.OneOrMore; - inferredType = new SequenceType(resultingItemType, resultingArity); - } - } - return inferredType; + return inferredType.concatenateWith(childSequenceType); } @Override @@ -2866,14 +2822,14 @@ public StaticContext visitWhileStatement(WhileStatement statement, StaticContext @Override public StaticContext visitFlowrStatement(FlowrStatement statement, StaticContext argument) { Clause clause = statement.getReturnStatementClause().getFirstClause(); - SequenceType.Arity forArities = SequenceType.Arity.One; // One is arity multiplication's neutral element + SequenceCardinality forCardinality = SequenceCardinality.ONE; SequenceType forType; while (clause != null) { try { this.visit(clause, clause.getStaticContext()); } catch (UnexpectedStaticTypeException e) { - if (forArities.equals(SequenceType.Arity.Zero) + if (forCardinality == SequenceCardinality.EMPTY && clause.getClauseType().equals(FLWOR_CLAUSES.WHERE)) { clause = clause.getNextClause(); continue; @@ -2888,17 +2844,22 @@ public StaticContext visitFlowrStatement(FlowrStatement statement, StaticContext // therefore this for loop will generate one tuple binding the empty sequence, so as for the arities // count as arity.One if (!forType.isEmptySequence()) { - forArities = forType.getArity().multiplyWith(forArities); + SequenceCardinality sourceCardinality = forType.getCardinality(); + if (((ForClause) clause).isAllowEmpty()) { + // An empty source still emits one tuple with an empty binding. + sourceCardinality = sourceCardinality.replaceZeroWithOne(); + } + forCardinality = sourceCardinality.multiply(forCardinality); } else if (!((ForClause) clause).isAllowEmpty()) { - forArities = SequenceType.Arity.Zero; + forCardinality = SequenceCardinality.EMPTY; } + } else if (clause.getClauseType() == FLWOR_CLAUSES.GROUP_BY) { + // Multiple input tuples can collapse into a single group. + forCardinality = SequenceCardinality.fromArity(forCardinality.toArity()); } else if (clause.getClauseType() == FLWOR_CLAUSES.WHERE) { - // where clause could reject all tuples so arity change from + => * and 1 => ? - if (forArities == SequenceType.Arity.One) { - forArities = SequenceType.Arity.OneOrZero; - } else if (forArities == SequenceType.Arity.OneOrMore) { - forArities = SequenceType.Arity.ZeroOrMore; - } + // Filtering tuples can leave zero, one, or multiple tuples. + forCardinality = + SequenceCardinality.fromArity(forCardinality.toArity()).union(SequenceCardinality.EMPTY); } clause = clause.getNextClause(); } @@ -3158,6 +3119,7 @@ public StaticContext visitSlashExpr(SlashExpr slashExpr, StaticContext argument) SequenceType rightType = requireInferredType(rightExpression.getStaticSequenceType(), "SlashExpr"); basicChecks(rightType, slashExpr.getClass().getSimpleName(), true, false, slashExpr.getMetadata()); + // XPath removes duplicate nodes, so multiple inputs need not yield multiple results. SequenceType.Arity resultingArity = leftType.getArity().multiplyWith(rightType.getArity()); slashExpr.setStaticSequenceType(new SequenceType(rightType.getItemType(), resultingArity)); return argument; diff --git a/src/main/java/org/rumbledb/runtime/flwor/FlworDataFrameUtils.java b/src/main/java/org/rumbledb/runtime/flwor/FlworDataFrameUtils.java index aff0565428..8c5e9d0b7d 100644 --- a/src/main/java/org/rumbledb/runtime/flwor/FlworDataFrameUtils.java +++ b/src/main/java/org/rumbledb/runtime/flwor/FlworDataFrameUtils.java @@ -94,6 +94,7 @@ import org.rumbledb.runtime.flwor.FlworDataFrameColumn.ColumnFormat; import org.rumbledb.spark.SparkSessionManager; import org.rumbledb.types.ItemType; +import org.rumbledb.types.SequenceCardinality; import org.rumbledb.types.SequenceType; public class FlworDataFrameUtils { @@ -140,6 +141,7 @@ public static void registerKryoClassesKryo(Kryo kryo) { kryo.register(Name.class); kryo.register(SequenceType.class); kryo.register(SequenceType.Arity.class); + kryo.register(SequenceCardinality.class); kryo.register(ItemType.class); kryo.register(ArrayList.class); diff --git a/src/main/java/org/rumbledb/spark/SparkSessionManager.java b/src/main/java/org/rumbledb/spark/SparkSessionManager.java index fe6b13b38d..7178ac1826 100644 --- a/src/main/java/org/rumbledb/spark/SparkSessionManager.java +++ b/src/main/java/org/rumbledb/spark/SparkSessionManager.java @@ -68,6 +68,7 @@ import org.rumbledb.runtime.flwor.tuple.FlworTuple; import org.rumbledb.runtime.plan.RuntimePlan; import org.rumbledb.types.ItemType; +import org.rumbledb.types.SequenceCardinality; import org.rumbledb.types.SequenceType; @Log4j2 @@ -235,6 +236,7 @@ private void initializeKryoSerialization() { Name.class, SequenceType.class, SequenceType.Arity.class, + SequenceCardinality.class, ItemType.class, DynamicContext.class, FlworTuple.class, diff --git a/src/main/java/org/rumbledb/types/ItemTypeFactory.java b/src/main/java/org/rumbledb/types/ItemTypeFactory.java index d918140160..7509682daf 100644 --- a/src/main/java/org/rumbledb/types/ItemTypeFactory.java +++ b/src/main/java/org/rumbledb/types/ItemTypeFactory.java @@ -197,39 +197,59 @@ public static ItemType createObjectFieldType(SequenceType valueType) { return BuiltinTypesCatalogue.nullItem; } ItemType itemType = valueType.getItemType(); - if (valueType.getArity() == SequenceType.Arity.One) { - return itemType; - } - if (itemType.isTopmostItemType()) { - // item already covers the singleton, null, and wrapped-array possibilities. - return itemType; - } - + SequenceCardinality cardinality = valueType.getCardinality(); List alternatives = new ArrayList<>(); - if (itemType.isUnionType()) { - alternatives.addAll(itemType.getTypes()); - } else { + if (cardinality.allowsOne()) { alternatives.add(itemType); } - if (valueType.getArity() == SequenceType.Arity.OneOrMore - || valueType.getArity() == SequenceType.Arity.ZeroOrMore) { - // Both arities allow a singleton as well as multiple items, so retain both alternatives. + if (cardinality.allowsMany()) { alternatives.add(createAnonymousArrayType(itemType)); } - if ((valueType.getArity() == SequenceType.Arity.OneOrZero - || valueType.getArity() == SequenceType.Arity.ZeroOrMore) - && alternatives.stream().noneMatch(type -> BuiltinTypesCatalogue.nullItem.isSubtypeOf(type))) { + if (cardinality.allowsZero()) { alternatives.add(BuiltinTypesCatalogue.nullItem); } - if (alternatives.size() == 1) { - return alternatives.get(0); + return createInferredUnionType(alternatives); + } + + /** + * Preserves alternatives inferred from expressions, removing duplicates and subsumed types. + * Named schema unions retain their identity; only anonymous inference unions are flattened. + */ + public static ItemType createInferredUnionType(List alternatives) { + List flattened = new ArrayList<>(); + for (ItemType alternative : alternatives) { + collectInferredUnionMembers(alternative, flattened); + } + List members = new ArrayList<>(); + for (ItemType candidate : flattened) { + if (members.stream().anyMatch(candidate::isSubtypeOf)) { + continue; + } + members.removeIf(member -> member.isSubtypeOf(candidate)); + members.add(candidate); + } + if (members.isEmpty()) { + throw new OurBadException("An inferred union must contain at least one item type."); + } + if (members.size() == 1) { + return members.get(0); } // isSubtypeOf checks the base type before the members, so the base must cover every member: // xs:anyAtomicType only if all members are atomic, otherwise item (e.g. when one is an array). - ItemType baseType = alternatives.stream().allMatch(type -> type.isSubtypeOf(BuiltinTypesCatalogue.atomicItem)) + ItemType baseType = members.stream().allMatch(type -> type.isSubtypeOf(BuiltinTypesCatalogue.atomicItem)) ? BuiltinTypesCatalogue.atomicItem : BuiltinTypesCatalogue.item; - return new UnionItemType(null, baseType, alternatives, false); + return new UnionItemType(null, baseType, members, false); + } + + private static void collectInferredUnionMembers(ItemType type, List members) { + if (type.isUnionType() && !type.hasName() && !type.isUserDefined()) { + for (ItemType member : type.getTypes()) { + collectInferredUnionMembers(member, members); + } + } else { + members.add(type); + } } /** diff --git a/src/main/java/org/rumbledb/types/SequenceCardinality.java b/src/main/java/org/rumbledb/types/SequenceCardinality.java new file mode 100644 index 0000000000..b8baedcc54 --- /dev/null +++ b/src/main/java/org/rumbledb/types/SequenceCardinality.java @@ -0,0 +1,112 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contributor acknowledgements are maintained in the CONTRIBUTORS file at the project root. + */ +package org.rumbledb.types; + +/** + * Possible sequence sizes, with all lengths of two or more represented by MANY. + * Unlike the declared occurrence indicators, this can express multiple-only and empty-or-multiple. + */ +public enum SequenceCardinality { + EMPTY(1), + ONE(2), + MANY(4), + ZERO_OR_ONE(3), + ONE_OR_MANY(6), + ZERO_OR_MANY(5), + ANY(7); + + private final int mask; + + SequenceCardinality(int mask) { + this.mask = mask; + } + + public boolean allowsZero() { + return (this.mask & 1) != 0; + } + + public boolean allowsOne() { + return (this.mask & 2) != 0; + } + + public boolean allowsMany() { + return (this.mask & 4) != 0; + } + + public boolean isSubtypeOf(SequenceCardinality other) { + return (this.mask & other.mask) == this.mask; + } + + public boolean overlaps(SequenceCardinality other) { + return (this.mask & other.mask) != 0; + } + + public SequenceCardinality union(SequenceCardinality other) { + return fromMask(this.mask | other.mask); + } + + public SequenceCardinality concatenate(SequenceCardinality other) { + boolean zero = allowsZero() && other.allowsZero(); + boolean one = (allowsOne() && other.allowsZero()) || (allowsZero() && other.allowsOne()); + boolean many = allowsMany() || other.allowsMany() || (allowsOne() && other.allowsOne()); + return fromPossibilities(zero, one, many); + } + + public SequenceCardinality multiply(SequenceCardinality other) { + boolean zero = allowsZero() || other.allowsZero(); + boolean one = allowsOne() && other.allowsOne(); + boolean many = (allowsMany() && (other.allowsOne() || other.allowsMany())) + || (other.allowsMany() && (allowsOne() || allowsMany())); + return fromPossibilities(zero, one, many); + } + + public SequenceCardinality replaceZeroWithOne() { + return fromPossibilities(false, allowsZero() || allowsOne(), allowsMany()); + } + + public SequenceType.Arity toArity() { + return switch (this) { + case EMPTY -> SequenceType.Arity.Zero; + case ONE -> SequenceType.Arity.One; + case ZERO_OR_ONE -> SequenceType.Arity.OneOrZero; + case MANY, ONE_OR_MANY -> SequenceType.Arity.OneOrMore; + case ZERO_OR_MANY, ANY -> SequenceType.Arity.ZeroOrMore; + }; + } + + public static SequenceCardinality fromArity(SequenceType.Arity arity) { + return switch (arity) { + case Zero -> EMPTY; + case One -> ONE; + case OneOrZero -> ZERO_OR_ONE; + case OneOrMore -> ONE_OR_MANY; + case ZeroOrMore -> ANY; + }; + } + + private static SequenceCardinality fromPossibilities(boolean zero, boolean one, boolean many) { + return fromMask((zero ? 1 : 0) | (one ? 2 : 0) | (many ? 4 : 0)); + } + + private static SequenceCardinality fromMask(int mask) { + for (SequenceCardinality cardinality : values()) { + if (cardinality.mask == mask) { + return cardinality; + } + } + throw new IllegalArgumentException("A sequence cardinality must allow at least one size."); + } +} diff --git a/src/main/java/org/rumbledb/types/SequenceType.java b/src/main/java/org/rumbledb/types/SequenceType.java index 31505204e1..035c599ae5 100644 --- a/src/main/java/org/rumbledb/types/SequenceType.java +++ b/src/main/java/org/rumbledb/types/SequenceType.java @@ -20,7 +20,9 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Objects; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -42,106 +44,83 @@ public class SequenceType implements Serializable { private static final long serialVersionUID = 1L; private ItemType itemType; - private Arity arity; + private SequenceCardinality cardinality; public SequenceType(ItemType itemType, Arity arity) { - if (arity == Arity.Zero) { - this.itemType = BuiltinTypesCatalogue.item; - this.arity = Arity.Zero; - return; - } - this.itemType = itemType; - this.arity = arity; + this(itemType, SequenceCardinality.fromArity(arity == null ? Arity.ZeroOrMore : arity)); + } + + public SequenceType(ItemType itemType, SequenceCardinality cardinality) { + this.cardinality = cardinality; + this.itemType = cardinality == SequenceCardinality.EMPTY ? BuiltinTypesCatalogue.item : itemType; if (this.itemType == null) { - log.warn("Missing item type in incomplete sequence type " - + this.arity - + ", defaulting to item. Please let us know as we would like to look into this!"); + log.warn("Missing item type in incomplete sequence type {}, defaulting to item.", getArity()); this.itemType = BuiltinTypesCatalogue.item; } - if (this.arity == null) { - log.warn("Missing arity in incomplete sequence type " - + this.itemType - + ", defaulting to *. Please let us know as we would like to look into this!"); - this.arity = Arity.ZeroOrMore; - } } public SequenceType(ItemType itemType) { - this.itemType = itemType; - this.arity = Arity.One; - if (this.itemType == null) { - throw new OurBadException("Missing item type in incomplete sequence type " + this.arity); + this(itemType, SequenceCardinality.ONE); + if (itemType == null) { + throw new OurBadException("Missing item type in incomplete sequence type " + getArity()); } } private SequenceType() { - this.itemType = BuiltinTypesCatalogue.item; - this.arity = Arity.Zero; + this(BuiltinTypesCatalogue.item, SequenceCardinality.EMPTY); + } + + // Declared occurrence indicators remain a conservative view for existing runtime consumers. + public Arity getArity() { + return this.cardinality.toArity(); } public boolean isResolved() { - if (isEmptySequence()) { - return true; - } - return this.itemType.isResolved(); + return isEmptySequence() || this.itemType.isResolved(); } public void resolve(DynamicContext context, ExceptionMetadata metadata) { - if (isEmptySequence()) { - return; + if (!isEmptySequence()) { + this.itemType.resolve(context, metadata); } - this.itemType.resolve(context, metadata); } public void resolve(StaticContext context, ExceptionMetadata metadata) { - if (isEmptySequence()) { - return; + if (!isEmptySequence()) { + this.itemType.resolve(context, metadata); } - this.itemType.resolve(context, metadata); } public boolean isEmptySequence() { - return this.arity == Arity.Zero; + return this.cardinality == SequenceCardinality.EMPTY; } public boolean isSubtypeOf(SequenceType superType) { if (isEmptySequence()) { - return superType.isEmptySequence() - || superType.arity == Arity.OneOrZero - || superType.arity == Arity.ZeroOrMore; + return superType.cardinality.allowsZero(); } if (this.itemType.equals(BuiltinTypesCatalogue.errorItem)) { - return hasOnlyEmptySequenceAsValue() ? emptySequenceIsSubtypeOf(superType) : true; + return !this.cardinality.allowsZero() || superType.cardinality.allowsZero(); } - return this.itemType.isSubtypeOf(superType.getItemType()) && this.isAritySubtypeOf(superType.arity); + return this.itemType.isSubtypeOf(superType.itemType) && this.cardinality.isSubtypeOf(superType.cardinality); } - // keep in consideration also automatic promotion of integer > decimal > double and anyURI > string + // Includes automatic promotions and function coercion used for declared parameter types. public boolean isSubtypeOfOrCanBePromotedTo(SequenceType superType) { if (isEmptySequence()) { - return superType.arity == Arity.OneOrZero || superType.arity == Arity.ZeroOrMore; + return superType.cardinality.allowsZero(); } if (this.itemType.equals(BuiltinTypesCatalogue.errorItem)) { - return hasOnlyEmptySequenceAsValue() ? emptySequenceIsSubtypeOf(superType) : true; + return !this.cardinality.allowsZero() || superType.cardinality.allowsZero(); } - return this.isAritySubtypeOf(superType.arity) - && (this.itemType.isSubtypeOf(superType.getItemType()) - || (this.itemType.canBePromotedTo(superType.itemType)) + return this.cardinality.isSubtypeOf(superType.cardinality) + && (this.itemType.isSubtypeOf(superType.itemType) + || this.itemType.canBePromotedTo(superType.itemType) || FunctionCoercion.canItemTypeBeFunctionCoercedTo(this.itemType, superType.itemType)); } - // check if the arity of a sequence type is subtype of another arity, assume [this] is a non-empty sequence - // TODO: consider removing it public boolean isAritySubtypeOf(Arity superArity) { - return this.arity.isSubtypeOf(superArity); - } - - private boolean hasOnlyEmptySequenceAsValue() { - return this.arity == Arity.Zero || this.arity == Arity.OneOrZero || this.arity == Arity.ZeroOrMore; - } - - private boolean emptySequenceIsSubtypeOf(SequenceType superType) { - return superType.isEmptySequence() || superType.arity == Arity.OneOrZero || superType.arity == Arity.ZeroOrMore; + return this.cardinality.isSubtypeOf(SequenceCardinality.fromArity(superArity)); } public boolean hasEffectiveBooleanValue() { @@ -149,99 +128,100 @@ public boolean hasEffectiveBooleanValue() { return true; } else if (this.itemType.isSubtypeOf(BuiltinTypesCatalogue.JSONItem)) { return true; - } else if ((this.arity == Arity.One || this.arity == Arity.OneOrZero) - && (this.itemType.isNumeric() - || this.itemType.equals(BuiltinTypesCatalogue.stringItem) - || this.itemType.equals(BuiltinTypesCatalogue.anyURIItem) - || this.itemType.equals(BuiltinTypesCatalogue.nullItem) - || this.itemType.equals(BuiltinTypesCatalogue.booleanItem))) { - return true; } else { - return false; + return !this.cardinality.allowsMany() + && (this.itemType.isNumeric() + || this.itemType.equals(BuiltinTypesCatalogue.stringItem) + || this.itemType.equals(BuiltinTypesCatalogue.anyURIItem) + || this.itemType.equals(BuiltinTypesCatalogue.nullItem) + || this.itemType.equals(BuiltinTypesCatalogue.booleanItem)); } } public boolean hasOverlapWith(SequenceType other) { - // types overlap if both itemType and Arity overlap, we also need to take care of empty sequence - if (isEmptySequence()) { - return other.isEmptySequence() - || other.getArity() == Arity.OneOrZero - || other.getArity() == Arity.ZeroOrMore; + if (!this.cardinality.overlaps(other.cardinality)) { + return false; } - if (other.isEmptySequence()) { - return this.getArity() == Arity.OneOrZero || this.getArity() == Arity.ZeroOrMore; + if (isEmptySequence() || other.isEmptySequence()) { + return true; } - // A union overlaps another type when at least one member overlaps it. - // Subtyping alone would reject, for example, (object | null) against object. + // A union overlaps another item type when at least one member overlaps it. if (this.itemType.isUnionType()) { return this.itemType.getTypes().stream() - .anyMatch(member -> new SequenceType(member, this.arity).hasOverlapWith(other)); + .anyMatch(member -> new SequenceType(member, this.cardinality).hasOverlapWith(other)); } if (other.itemType.isUnionType()) { return other.hasOverlapWith(this); } - // All arities overlap between each other - return this.getItemType().isSubtypeOf(other.getItemType()) - || other.getItemType().isSubtypeOf(this.getItemType()); + return this.itemType.isSubtypeOf(other.itemType) || other.itemType.isSubtypeOf(this.itemType); } public SequenceType leastCommonSupertypeWith(SequenceType other) { + ItemType itemSupertype = isEmptySequence() + ? other.itemType + : other.isEmptySequence() ? this.itemType : this.itemType.findLeastCommonSuperTypeWith(other.itemType); + return new SequenceType(itemSupertype, this.cardinality.union(other.cardinality)); + } + + public SequenceType concatenateWith(SequenceType other) { if (isEmptySequence()) { - if (other.isEmptySequence()) { - return this; - } else { - Arity resultingArity = other.getArity(); - if (resultingArity == Arity.One) { - resultingArity = Arity.OneOrZero; - } else if (resultingArity == Arity.OneOrMore) { - resultingArity = Arity.ZeroOrMore; - } - return new SequenceType(other.itemType, resultingArity); - } + return other; } if (other.isEmptySequence()) { - Arity resultingArity = this.getArity(); - if (resultingArity == Arity.One) { - resultingArity = Arity.OneOrZero; - } else if (resultingArity == Arity.OneOrMore) { - resultingArity = Arity.ZeroOrMore; - } - return new SequenceType(this.itemType, resultingArity); + return this; } + ItemType contentType = concatenateItemTypes(other.itemType); + return new SequenceType(contentType, this.cardinality.concatenate(other.cardinality)); + } - ItemType itemSupertype = this.getItemType().findLeastCommonSuperTypeWith(other.getItemType()); - Arity aritySuperType = Arity.ZeroOrMore; - if (this.isAritySubtypeOf(other.getArity())) { - aritySuperType = other.getArity(); - } else if (other.isAritySubtypeOf(this.getArity())) { - aritySuperType = this.getArity(); + private ItemType concatenateItemTypes(ItemType other) { + if (this.itemType.equals(other)) { + return this.itemType; } - // no need additional check because the only disjointed arity are ? and +, which least common supertype is * - return new SequenceType(itemSupertype, aritySuperType); + if (this.itemType.isObjectItemType() && other.isObjectItemType()) { + boolean sameShape = !this.itemType.hasName() + && !other.hasName() + && this.itemType.getBaseType().equals(BuiltinTypesCatalogue.objectItem) + && other.getBaseType().equals(BuiltinTypesCatalogue.objectItem) + && haveSameObjectFields(this.itemType, other); + // Keep equivalent object schemas usable as DataFrames; merge field presence conservatively. + return sameShape + ? this.itemType.findLeastCommonSuperTypeLax(other) + : this.itemType.findLeastCommonSuperTypeWith(other); + } + if (this.itemType.isSubtypeOf(BuiltinTypesCatalogue.atomicItem) + && other.isSubtypeOf(BuiltinTypesCatalogue.atomicItem)) { + return ItemTypeFactory.createInferredUnionType(List.of(this.itemType, other)); + } + // Structured types have existing joins used by navigation and native execution. + return this.itemType.findLeastCommonSuperTypeWith(other); } - // increment arity of a sequence type from ? to * and from 1 to +, leave others arity or sequence types untouched - public SequenceType incrementArity() { - if (!isEmptySequence()) { - if (this.arity == Arity.One) { - return new SequenceType(this.getItemType(), Arity.OneOrMore); - } else if (this.arity == Arity.OneOrZero) { - return new SequenceType(this.getItemType(), Arity.ZeroOrMore); + private static boolean haveSameObjectFields(ItemType left, ItemType right) { + if (left.getObjectKeysFacet().size() != right.getObjectKeysFacet().size()) { + return false; + } + for (String key : left.getObjectKeysFacet()) { + FieldDescriptor leftField = left.getObjectContentFacet(key); + FieldDescriptor rightField = right.getObjectContentFacet(key); + if (rightField == null + || !leftField.getType().equals(rightField.getType()) + || leftField.isRequired() != rightField.isRequired() + || !Objects.equals(leftField.isUnique(), rightField.isUnique()) + || !Objects.equals(leftField.getDefaultValue(), rightField.getDefaultValue())) { + return false; } } - return this; + return true; } - // increment arity of a sequence type from ? to * and from 1 to +, leave others arity or sequence types untouched - public SequenceType refineArityIfSubtype(Arity otherArity) { - if (!isEmptySequence()) { - if (otherArity.isSubtypeOf(this.arity)) { - return new SequenceType(this.itemType, otherArity); - } else { - return this; - } - } - return this; + // Grouping concatenates one or more sequences for each group. + public SequenceType incrementArity() { + return new SequenceType(this.itemType, this.cardinality.multiply(SequenceCardinality.ONE_OR_MANY)); + } + + public SequenceType refineCardinalityIfSubtype(SequenceCardinality other) { + return other.isSubtypeOf(this.cardinality) ? new SequenceType(this.itemType, other) : this; } public enum Arity { @@ -279,29 +259,13 @@ public String getSymbol() { public abstract String getSymbol(); public boolean isSubtypeOf(Arity superArity) { - if (superArity == Zero) { - return this == Arity.Zero; - } - if (this == Zero) { - return superArity == Arity.ZeroOrMore || superArity == Arity.OneOrZero; - } - if (superArity == Arity.ZeroOrMore || superArity == this) return true; - else return this == Arity.One; + return SequenceCardinality.fromArity(this).isSubtypeOf(SequenceCardinality.fromArity(superArity)); } public Arity multiplyWith(Arity other) { - if (this == Zero || other == Zero) { - return Zero; - } - if (this == One && other == One) { - return One; - } else if (this.isSubtypeOf(OneOrZero) && other.isSubtypeOf(OneOrZero)) { - return OneOrZero; - } else if (this.isSubtypeOf(OneOrMore) && other.isSubtypeOf(OneOrMore)) { - return OneOrMore; - } else { - return ZeroOrMore; - } + return SequenceCardinality.fromArity(this) + .multiply(SequenceCardinality.fromArity(other)) + .toArity(); } } @@ -322,7 +286,7 @@ public String toString() { } else { result.append(itemType); } - result.append(this.arity.getSymbol()); + result.append(getArity().getSymbol()); return result.toString(); } diff --git a/src/test/java/org/rumbledb/compiler/ObjectConstructorTypeInferenceTest.java b/src/test/java/org/rumbledb/compiler/ObjectConstructorTypeInferenceTest.java index b066364fe0..2ed43da448 100644 --- a/src/test/java/org/rumbledb/compiler/ObjectConstructorTypeInferenceTest.java +++ b/src/test/java/org/rumbledb/compiler/ObjectConstructorTypeInferenceTest.java @@ -38,6 +38,7 @@ import org.rumbledb.types.FieldDescriptor; import org.rumbledb.types.ItemType; import org.rumbledb.types.ItemTypeFactory; +import org.rumbledb.types.SequenceCardinality; import org.rumbledb.types.SequenceType; import org.rumbledb.types.TypeMappings; @@ -46,6 +47,117 @@ class ObjectConstructorTypeInferenceTest { .configureAnalysis(analysis -> analysis.enableStaticTyping(true)) .build(); + @ParameterizedTest + @ValueSource( + strings = { + "let $a := {\"b\": (false, 1)} return $a.b", + "let $v := (false, 1) return {\"b\": $v}.b", + "{\"b\": (false, (1, ()))}.b", + "{\"b\": (let $v := (false, 1) return $v)}.b" + }) + void knownMultipleValuesProduceOnlyAnArray(String query) { + SequenceType result = infer(query, "jq"); + assertEquals(SequenceType.Arity.One, result.getArity()); + assertTrue(result.getItemType().isArrayItemType()); + Item value = new Rumble(CONFIGURATION).runQuery(query).getAsList().get(0); + assertTrue(value.isArray()); + assertEquals(2, value.getSize()); + assertFalse(value.getItemAt(0).getBooleanValue()); + assertEquals(1, value.getItemAt(1).getIntValue()); + } + + @Test + void emptyOrMultipleValuesExcludeSingletonAlternative() { + String query = + "declare variable $flag as xs:boolean external; " + "{\"b\": if ($flag) then (false, 1) else ()}.b"; + ItemType type = infer(query, "jq").getItemType(); + assertTrue(type.isUnionType()); + assertEquals(2, type.getTypes().size()); + assertTrue(type.getTypes().stream().anyMatch(ItemType::isArrayItemType)); + assertTrue(type.getTypes().contains(BuiltinTypesCatalogue.nullItem)); + } + + @Test + void multipleGenericItemsStillProduceAnArray() { + ItemType type = infer("declare variable $v as item external; {\"b\": ($v, $v)}.b", "jq") + .getItemType(); + assertTrue(type.isArrayItemType()); + assertEquals(BuiltinTypesCatalogue.item, type.getArrayContentFacet()); + } + + @Test + void heterogeneousArrayKeepsBooleanAndIntegerMemberTypes() { + ItemType type = infer("let $a := {\"b\": (false, 1)} return $a.b", "jq").getItemType(); + ItemType content = type.getArrayContentFacet(); + assertTrue(content.isUnionType()); + assertEquals(List.of(BuiltinTypesCatalogue.booleanItem, BuiltinTypesCatalogue.integerItem), content.getTypes()); + assertTrue(content.isSubtypeOf(BuiltinTypesCatalogue.atomicItem)); + assertFalse(content.isSubtypeOf(BuiltinTypesCatalogue.numericItem)); + } + + @Test + void identicalObjectShapesKeepTheirDataFrameCompatibleType() { + SequenceType type = infer("({\"id\": 1, \"v\": \"A\"}, {\"id\": 2, \"v\": \"B\"})", "jq"); + assertEquals(SequenceCardinality.MANY, type.getCardinality()); + assertTrue(type.getItemType().isObjectItemType()); + assertTrue(type.getItemType().isCompatibleWithDataFrames(CONFIGURATION)); + assertEquals(BuiltinTypesCatalogue.integerItem, field(type.getItemType(), "id")); + } + + @Test + void filteringMultipleItemsCanProduceASingleton() { + ItemType type = + infer("let $v := (false, 1) return {\"b\": $v[1]}.b", "jq").getItemType(); + assertTrue(type.isUnionType()); + assertFalse(type.getTypes().stream().anyMatch(ItemType::isArrayItemType)); + assertTrue(type.getTypes().contains(BuiltinTypesCatalogue.booleanItem)); + assertTrue(type.getTypes().contains(BuiltinTypesCatalogue.integerItem)); + assertTrue(type.getTypes().contains(BuiltinTypesCatalogue.nullItem)); + } + + @Test + void xqueryCommaExpressionRetainsSequenceValues() { + String query = "let $v := (false(), 1) return $v"; + SequenceType type = infer(query, "xq"); + assertEquals(SequenceCardinality.MANY, type.getCardinality()); + assertTrue(type.getItemType().isUnionType()); + List values = new Rumble(CONFIGURATION) + .runQuery(query, URI.create("file:///comma-values.xq")) + .getAsList(); + assertEquals(2, values.size()); + assertFalse(values.get(0).getBooleanValue()); + assertEquals(1, values.get(1).getIntValue()); + } + + @Test + void allowingEmptyCanProduceOneTupleForAnEmptyOrMultipleSource() { + String query = "declare variable $flag as xs:boolean external; " + + "for $v allowing empty in (if ($flag) then (1, 2) else ()) return 1"; + SequenceType type = infer(query, "jq"); + assertEquals(SequenceCardinality.ONE_OR_MANY, type.getCardinality()); + } + + @Test + void groupingMultipleTuplesCanProduceOneResult() { + String query = "for $v in (1, 2) group by $key := 1 return 1"; + SequenceType type = infer(query, "jq"); + assertEquals(SequenceCardinality.ONE_OR_MANY, type.getCardinality()); + assertEquals(1, new Rumble(CONFIGURATION).runQuery(query).getAsList().size()); + } + + @Test + void xpathDuplicateEliminationCanProduceOneNode() { + String query = "let $n := return ($n, $n)/self::node()"; + SequenceType type = infer(query, "xq"); + assertTrue(type.getCardinality().allowsOne()); + assertEquals( + 1, + new Rumble(CONFIGURATION) + .runQuery(query, URI.create("file:///duplicate-nodes.xq")) + .getAsList() + .size()); + } + @Test void preservesFieldsInOriginalFlworExample() { String query = "for $i in 1 to 10 return {\"i\": $i, \"power_of_two\": math:pow($i, 2)}"; @@ -176,7 +288,10 @@ void nullableUnionAlreadyContainingNullDoesNotDuplicateIt() { @Test void arrayAlternativesUseTheUnionDataFrameMapping() { String query = "{\"value\": ([1], [2])}"; - ItemType arrayAlternatives = field(infer(query, "jq").getItemType(), "value"); + // An unknown nonempty sequence of arrays can be stored as an array or a wrapped array of arrays. + ItemType arrayAlternatives = ItemTypeFactory.createObjectFieldType(new SequenceType( + ItemTypeFactory.createAnonymousArrayType(BuiltinTypesCatalogue.integerItem), + SequenceType.Arity.OneOrMore)); assertTrue(arrayAlternatives.isUnionType()); assertTrue(arrayAlternatives.isSubtypeOf(BuiltinTypesCatalogue.arrayItem)); assertFalse(arrayAlternatives.isCompatibleWithDataFrames(CONFIGURATION)); diff --git a/src/test/java/org/rumbledb/runtime/flwor/udfs/DataFrameContextKryoTest.java b/src/test/java/org/rumbledb/runtime/flwor/udfs/DataFrameContextKryoTest.java index 7f825af8ef..142d781b2b 100644 --- a/src/test/java/org/rumbledb/runtime/flwor/udfs/DataFrameContextKryoTest.java +++ b/src/test/java/org/rumbledb/runtime/flwor/udfs/DataFrameContextKryoTest.java @@ -30,9 +30,22 @@ import org.rumbledb.items.StringItem; import org.rumbledb.items.xml.TextItem; import org.rumbledb.types.BuiltinTypesCatalogue; +import org.rumbledb.types.SequenceCardinality; +import org.rumbledb.types.SequenceType; public class DataFrameContextKryoTest { + @Test + public void kryoPreservesInferredSequenceCardinalities() { + for (SequenceCardinality cardinality : SequenceCardinality.values()) { + SequenceType type = new SequenceType(BuiltinTypesCatalogue.integerItem, cardinality); + SequenceType copy = (SequenceType) roundTripObject(type); + Assertions.assertEquals(type, copy); + Assertions.assertEquals(cardinality, copy.getCardinality()); + Assertions.assertEquals(type.getArity(), copy.getArity()); + } + } + @Test public void kryoRoundTripsItemsWithoutNoArgumentConstructors() { Assertions.assertThrows(NoSuchMethodException.class, StringItem.class::getDeclaredConstructor); diff --git a/src/test/java/org/rumbledb/types/ItemTypeFactoryTest.java b/src/test/java/org/rumbledb/types/ItemTypeFactoryTest.java new file mode 100644 index 0000000000..af11196c6f --- /dev/null +++ b/src/test/java/org/rumbledb/types/ItemTypeFactoryTest.java @@ -0,0 +1,52 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contributor acknowledgements are maintained in the CONTRIBUTORS file at the project root. + */ +package org.rumbledb.types; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +import org.rumbledb.context.Name; + +class ItemTypeFactoryTest { + @Test + void inferredUnionsFlattenAndRemoveSubsumedAlternatives() { + ItemType numbers = ItemTypeFactory.createInferredUnionType( + List.of(BuiltinTypesCatalogue.integerItem, BuiltinTypesCatalogue.decimalItem)); + assertSame(BuiltinTypesCatalogue.decimalItem, numbers); + ItemType union = ItemTypeFactory.createInferredUnionType(List.of(BuiltinTypesCatalogue.booleanItem, numbers)); + ItemType normalized = ItemTypeFactory.createInferredUnionType( + List.of(union, BuiltinTypesCatalogue.booleanItem, BuiltinTypesCatalogue.intItem)); + assertEquals( + List.of(BuiltinTypesCatalogue.booleanItem, BuiltinTypesCatalogue.decimalItem), normalized.getTypes()); + assertSame( + BuiltinTypesCatalogue.item, + ItemTypeFactory.createInferredUnionType(List.of(union, BuiltinTypesCatalogue.item))); + } + + @Test + void namedNullableSchemaUnionRetainsItsIdentity() { + ItemType named = new UnionItemType( + Name.createVariableInDefaultTypeNamespace("namedNullable"), + BuiltinTypesCatalogue.atomicItem, + List.of(BuiltinTypesCatalogue.doubleItem, BuiltinTypesCatalogue.nullItem), + true); + assertSame(named, ItemTypeFactory.createInferredUnionType(List.of(named, BuiltinTypesCatalogue.nullItem))); + assertSame(named, ItemTypeFactory.createObjectFieldType(new SequenceType(named, SequenceType.Arity.OneOrZero))); + } +} diff --git a/src/test/java/org/rumbledb/types/SequenceCardinalityTest.java b/src/test/java/org/rumbledb/types/SequenceCardinalityTest.java new file mode 100644 index 0000000000..eacae4cd59 --- /dev/null +++ b/src/test/java/org/rumbledb/types/SequenceCardinalityTest.java @@ -0,0 +1,79 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contributor acknowledgements are maintained in the CONTRIBUTORS file at the project root. + */ +package org.rumbledb.types; + +import java.util.HashSet; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class SequenceCardinalityTest { + @Test + void operationsAgreeWithConcreteSequenceLengths() { + for (SequenceCardinality left : SequenceCardinality.values()) { + for (SequenceCardinality right : SequenceCardinality.values()) { + Set sums = new HashSet<>(); + Set products = new HashSet<>(); + Set choices = new HashSet<>(sizes(left)); + choices.addAll(sizes(right)); + for (int a : sizes(left)) { + for (int b : sizes(right)) { + sums.add(Math.min(2, a + b)); + products.add(Math.min(2, a * b)); + } + } + assertEquals(sums, sizes(left.concatenate(right)), left + " concatenated with " + right); + assertEquals(products, sizes(left.multiply(right)), left + " repeated " + right + " times"); + assertEquals(choices, sizes(left.union(right))); + assertEquals(sizes(right).containsAll(sizes(left)), left.isSubtypeOf(right)); + assertEquals(sizes(left).stream().anyMatch(sizes(right)::contains), left.overlaps(right)); + } + assertTrue(left.isSubtypeOf(SequenceCardinality.fromArity(left.toArity()))); + Set allowingEmptySizes = new HashSet<>(); + for (int size : sizes(left)) { + allowingEmptySizes.add(Math.max(1, size)); + } + assertEquals(allowingEmptySizes, sizes(left.replaceZeroWithOne())); + } + } + + @Test + void sequenceTypesKeepMultipleOnlyThroughJoiningAndGrouping() { + SequenceType multiple = new SequenceType(BuiltinTypesCatalogue.integerItem, SequenceCardinality.MANY); + SequenceType empty = SequenceType.createSequenceType("()"); + SequenceType singleton = new SequenceType(BuiltinTypesCatalogue.integerItem); + assertEquals(SequenceType.Arity.OneOrMore, multiple.getArity()); + assertTrue(multiple.isSubtypeOf(SequenceType.createSequenceType("integer+"))); + assertFalse(SequenceType.createSequenceType("integer+").isSubtypeOf(multiple)); + assertFalse(multiple.hasOverlapWith(singleton)); + assertEquals( + SequenceCardinality.ZERO_OR_MANY, + multiple.leastCommonSupertypeWith(empty).getCardinality()); + assertEquals(SequenceCardinality.MANY, multiple.incrementArity().getCardinality()); + assertEquals( + SequenceCardinality.MANY, singleton.concatenateWith(singleton).getCardinality()); + } + + private static Set sizes(SequenceCardinality cardinality) { + Set sizes = new HashSet<>(); + if (cardinality.allowsZero()) sizes.add(0); + if (cardinality.allowsOne()) sizes.add(1); + if (cardinality.allowsMany()) sizes.add(2); + return sizes; + } +} diff --git a/src/test/resources/test_files/backwards-compatibility/Atomic/Atomiccomma.jq b/src/test/resources/test_files/backwards-compatibility/Atomic/Atomiccomma.jq index 8f1bd2160d..32606aef73 100644 --- a/src/test/resources/test_files/backwards-compatibility/Atomic/Atomiccomma.jq +++ b/src/test/resources/test_files/backwards-compatibility/Atomic/Atomiccomma.jq @@ -1,5 +1,15 @@ jsoniq version "1.0"; (:JIQS: ShouldRun; Output="(1, 2, 3, 1, s, 12, 1, 1, 2, 1, 1, 2, 3, null, null, 1, 1, 2)" :) -(1,2,3) is statically integer+ , (1) is statically integer, ("s",12) is statically atomic+, () is statically (), ((),()) is statically (), (1, (1,2)) is statically integer+, (1, (( )), ()) is statically integer, ((1,2,3)) is statically integer+, (null, null) is statically null+, (1 treat as integer?) is statically integer?, (1 treat as integer?, 2 treat as integer?) is statically integer* - -(: anyAtomicType was atomic in jsoniq1.0 :) \ No newline at end of file +(1,2,3) is statically integer+, +(1) is statically integer, +(: "atomic" is the JSONiq 1.0 alias for anyAtomicType. Declare it explicitly + before asserting the exact type of this mixed sequence. :) +(("s",12) treat as atomic+) is statically atomic+, +() is statically (), +((),()) is statically (), +(1, (1,2)) is statically integer+, +(1, (( )), ()) is statically integer, +((1,2,3)) is statically integer+, +(null, null) is statically null+, +(1 treat as integer?) is statically integer?, +(1 treat as integer?, 2 treat as integer?) is statically integer* From fb86dea65d7385700af888627064d62df5992a5f Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Sun, 4 Oct 2026 15:17:37 +0200 Subject: [PATCH 15/36] Define the scope of is statically --- docs/Types.md | 27 ++++ .../rumbledb/compiler/InferTypeVisitor.java | 7 +- .../typing/IsStaticallyExpression.java | 10 ++ .../IsStaticallyTypeInferenceTest.java | 133 ++++++++++++++++++ .../test_files/static-typing/comma.jq | 13 +- .../static-typing/primary/literals.jq | 3 +- .../test_files/static-typing/typing/cast9.jq | 4 +- 7 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java diff --git a/docs/Types.md b/docs/Types.md index aab1bb9432..c0314e74d1 100644 --- a/docs/Types.md +++ b/docs/Types.md @@ -276,6 +276,33 @@ complete specification of both syntaxes is available on the [JSound website](htt In the feature, RumbleDB will support user-defined atomic types and union types via the verbose syntax. +## Static type assertions (`is statically`) + +`expression is statically T` is a RumbleDB extension that asserts the exact type inferred by the compiler. +It compares the inferred item type with the item type in `T` using equality, and compares their declared occurrence +indicators: exactly one, `?`, `+`, `*`, or the empty sequence. A mismatch raises `RBTY0001` during type inference. + +```xquery +1 is statically xs:integer (: succeeds :) +(1, 2) is statically xs:integer+ (: succeeds :) +1 is statically xs:decimal (: fails: different item type :) +1 is statically xs:integer+ (: fails: different occurrence indicator :) +("s", 12) is statically xs:anyAtomicType+ (: fails: inferred item type is a union :) +``` + +This asserts exact inference, rather than compatibility with a supertype. For example, `("s", 12)` matches +`xs:anyAtomicType+` in an `instance of` or `treat as` expression, but its inferred item type is the more precise +anonymous union `xs:string | xs:integer`. + +Inference can distinguish multiple-only cardinality (`MANY`) from one-or-more cardinality (`ONE_OR_MANY`). Both +have the declared occurrence indicator `+`, so `(1, 2) is statically xs:integer+` succeeds. Internal cardinality +refinements are not compared directly. A successful assertion returns the expression's value unchanged and +preserves its precise inferred type, including those refinements, for subsequent inference. + +Query type syntax cannot express anonymous unions such as `xs:string | xs:integer`; this notation describes +the inferred type, not executable type syntax. Tests for their exact members therefore use Java compiler tests. +The `is statically` comparison does not widen inferred unions to their common supertype. + ## What's next? Once you have validated your data as a dataframe with a user-defined type, you are all set to use the RumbleDB ML Machine Learning library and feed it through ML pipelines! diff --git a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java index 2e42b0cb22..57063dd35a 100644 --- a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java +++ b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java @@ -1068,13 +1068,16 @@ public StaticContext visitIsStaticallyExpr(IsStaticallyExpression expression, St SequenceType inferred = expression.getMainExpression().getStaticSequenceType(); SequenceType expected = expression.getSequenceType(); - if (!inferred.equals(expected)) { + // Assertions use declared occurrence indicators, which cannot express MANY or ZERO_OR_MANY. + if (inferred.getArity() != expected.getArity() + || !inferred.getItemType().equals(expected.getItemType())) { throw new IsStaticallyUnexpectedTypeException( "expected static type is " + expected + " instead " + inferred + " was inferred", expression.getMetadata()); } - expression.setStaticSequenceType(expected); + // An assertion checks the type without discarding its inference refinements. + expression.setStaticSequenceType(inferred); return argument; } diff --git a/src/main/java/org/rumbledb/expressions/typing/IsStaticallyExpression.java b/src/main/java/org/rumbledb/expressions/typing/IsStaticallyExpression.java index b2d0a4f93c..c1c168525a 100644 --- a/src/main/java/org/rumbledb/expressions/typing/IsStaticallyExpression.java +++ b/src/main/java/org/rumbledb/expressions/typing/IsStaticallyExpression.java @@ -27,6 +27,16 @@ import org.rumbledb.expressions.Node; import org.rumbledb.types.SequenceType; +/** + * A RumbleDB assertion of the exact inferred item type and declared occurrence indicator. + * The comparison uses item-type equality and {@link SequenceType#getArity()}, not subtyping + * or equality of internal cardinality refinements. For example, multiple-only cardinality + * matches a declared {@code +}, but an inferred anonymous union does not match its wider + * common supertype. + * + * A successful assertion returns the operand unchanged and preserves its precise inferred + * sequence type. A mismatch raises an IsStaticallyUnexpectedTypeException during inference. + */ @Getter public class IsStaticallyExpression extends Expression { private Expression mainExpression; diff --git a/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java b/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java new file mode 100644 index 0000000000..596a81ce40 --- /dev/null +++ b/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java @@ -0,0 +1,133 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contributor acknowledgements are maintained in the CONTRIBUTORS file at the project root. + */ +package org.rumbledb.compiler; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.*; + +import org.rumbledb.bindings.ExternalBindings; +import org.rumbledb.config.CompilationConfiguration; +import org.rumbledb.config.RumbleConfiguration; +import org.rumbledb.exceptions.IsStaticallyUnexpectedTypeException; +import org.rumbledb.types.BuiltinTypesCatalogue; +import org.rumbledb.types.ItemType; +import org.rumbledb.types.SequenceCardinality; +import org.rumbledb.types.SequenceType; + +class IsStaticallyTypeInferenceTest { + private static final RumbleConfiguration CONFIGURATION = RumbleConfiguration.builder() + .configureAnalysis(analysis -> analysis.enableStaticTyping(true)) + .build(); + + @ParameterizedTest + @ValueSource(strings = {"jq", "xq"}) + void occurrenceAssertionPreservesMultipleOnlyCardinality(String extension) { + SequenceType type = infer("(1, 2) is statically xs:integer+", extension); + assertEquals(SequenceCardinality.MANY, type.getCardinality()); + } + + @Test + void occurrenceAssertionPreservesEmptyOrMultipleCardinality() { + SequenceType type = infer( + "declare variable $flag as xs:boolean external; " + + "(if ($flag) then (1, 2) else ()) is statically xs:integer*", + "jq"); + assertEquals(SequenceCardinality.ZERO_OR_MANY, type.getCardinality()); + } + + @ParameterizedTest + @ValueSource(strings = {"jq", "xq"}) + void inferredUnionDoesNotMatchItsCommonSupertype(String extension) { + assertThrows( + IsStaticallyUnexpectedTypeException.class, + () -> infer("(\"s\", 12) is statically xs:anyAtomicType+", extension)); + } + + @ParameterizedTest + @ValueSource(strings = {"comma.jq", "primary/literals.jq", "typing/cast9.jq"}) + void mixedAtomicFixturesKeepTheirExactUnionMembers(String fixture) throws IOException { + // Query syntax cannot declare anonymous unions, so check these fixtures' item types here. + Path path = Path.of("src/test/resources/test_files/static-typing", fixture); + SequenceType type = infer(Files.readString(path), "jq"); + assertEquals(SequenceCardinality.MANY, type.getCardinality()); + assertTrue(type.getItemType().isUnionType()); + Set expectedMembers = + switch (fixture) { + case "comma.jq" -> Set.of( + BuiltinTypesCatalogue.integerItem, + BuiltinTypesCatalogue.stringItem, + BuiltinTypesCatalogue.nullItem); + case "primary/literals.jq" -> Set.of( + BuiltinTypesCatalogue.decimalItem, + BuiltinTypesCatalogue.doubleItem, + BuiltinTypesCatalogue.booleanItem, + BuiltinTypesCatalogue.nullItem, + BuiltinTypesCatalogue.stringItem); + case "typing/cast9.jq" -> Set.of( + BuiltinTypesCatalogue.floatItem, + BuiltinTypesCatalogue.doubleItem, + BuiltinTypesCatalogue.decimalItem, + BuiltinTypesCatalogue.dateTimeItem, + BuiltinTypesCatalogue.dateItem, + BuiltinTypesCatalogue.durationItem, + BuiltinTypesCatalogue.hexBinaryItem, + BuiltinTypesCatalogue.base64BinaryItem, + BuiltinTypesCatalogue.booleanItem); + default -> throw new IllegalArgumentException(fixture); + }; + assertEquals(expectedMembers, Set.copyOf(type.getItemType().getTypes())); + assertEquals(expectedMembers.size(), type.getItemType().getTypes().size()); + } + + @Test + void assertedMultipleValuesStillBecomeOnlyAnArrayInObjectFields() { + SequenceType type = infer("{\"b\": ((1, 2) is statically xs:integer+)}.b", "jq"); + assertTrue(type.getItemType().isArrayItemType()); + assertEquals(BuiltinTypesCatalogue.integerItem, type.getItemType().getArrayContentFacet()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "1 is statically xs:integer+", + "(1, 2) is statically xs:integer*", + "1 is statically xs:decimal", + "\"s\" is statically xs:anyAtomicType", + "(false, 1) is statically xs:integer+" + }) + void assertionStillRequiresAnExactDeclaredType(String query) { + assertThrows(IsStaticallyUnexpectedTypeException.class, () -> infer(query, "jq")); + } + + private static SequenceType infer(String query, String extension) { + return CompilationPipeline.compileMainModule( + query, + URI.create("file:///static-assertion." + extension), + new CompilationConfiguration(CONFIGURATION), + ExternalBindings.empty()) + .getExpression() + .getStaticSequenceType(); + } +} diff --git a/src/test/resources/test_files/static-typing/comma.jq b/src/test/resources/test_files/static-typing/comma.jq index 8ddee7dc47..eac3a3ed34 100644 --- a/src/test/resources/test_files/static-typing/comma.jq +++ b/src/test/resources/test_files/static-typing/comma.jq @@ -1,3 +1,14 @@ jsoniq version "3.1"; (:JIQS: ShouldRun :) -(1,2,3) is statically integer+ , (1) is statically integer, ("s",12) is statically anyAtomicType+, () is statically (), ((),()) is statically (), (1, (1,2)) is statically integer+, (1, (( )), ()) is statically integer, ((1,2,3)) is statically integer+, (null, null) is statically null+, (1 treat as integer?) is statically integer?, (1 treat as integer?, 2 treat as integer?) is statically integer* \ No newline at end of file +(: Exact inferred union members are checked in IsStaticallyTypeInferenceTest. :) +(1,2,3) is statically integer+, +(1) is statically integer, +("s",12), +() is statically (), +((),()) is statically (), +(1, (1,2)) is statically integer+, +(1, (( )), ()) is statically integer, +((1,2,3)) is statically integer+, +(null, null) is statically null+, +(1 treat as integer?) is statically integer?, +(1 treat as integer?, 2 treat as integer?) is statically integer* diff --git a/src/test/resources/test_files/static-typing/primary/literals.jq b/src/test/resources/test_files/static-typing/primary/literals.jq index aef5c75f04..21e58cae3e 100644 --- a/src/test/resources/test_files/static-typing/primary/literals.jq +++ b/src/test/resources/test_files/static-typing/primary/literals.jq @@ -1,3 +1,4 @@ jsoniq version "3.1"; (:JIQS: ShouldRun :) -(12 is statically integer, 12.33 is statically decimal, 12e4 is statically double, true is statically boolean, false is statically boolean, null is statically null, "qwerty" is statically string) is statically anyAtomicType+ \ No newline at end of file +(: Exact inferred union members are checked in IsStaticallyTypeInferenceTest. :) +(12 is statically integer, 12.33 is statically decimal, 12e4 is statically double, true is statically boolean, false is statically boolean, null is statically null, "qwerty" is statically string) diff --git a/src/test/resources/test_files/static-typing/typing/cast9.jq b/src/test/resources/test_files/static-typing/typing/cast9.jq index be27c77850..000b3da20c 100644 --- a/src/test/resources/test_files/static-typing/typing/cast9.jq +++ b/src/test/resources/test_files/static-typing/typing/cast9.jq @@ -1,6 +1,7 @@ jsoniq version "3.1"; (:JIQS: ShouldRun :) (: Y cases in the primitive cast matrix :) +(: Exact inferred union members are checked in IsStaticallyTypeInferenceTest. :) ( 3.0e0 cast as float, 3.0e0 cast as double, @@ -21,5 +22,4 @@ jsoniq version "3.1"; "13" cast as double, "P2Y" cast as yearMonthDuration, "P1D" cast as dayTimeDuration -) is statically xs:anyAtomicType+ - +) From 947f26eb5cdf582e14ac3ad5db35c791f36b36a3 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Sun, 4 Oct 2026 15:33:08 +0200 Subject: [PATCH 16/36] feat: enhance effective boolean value handling for union types in SequenceType --- src/main/java/org/rumbledb/types/SequenceType.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/org/rumbledb/types/SequenceType.java b/src/main/java/org/rumbledb/types/SequenceType.java index 421382bd7b..eb023c868c 100644 --- a/src/main/java/org/rumbledb/types/SequenceType.java +++ b/src/main/java/org/rumbledb/types/SequenceType.java @@ -124,6 +124,10 @@ public boolean isAritySubtypeOf(Arity superArity) { public boolean hasEffectiveBooleanValue() { if (isEmptySequence()) { return true; + } else if (this.itemType.isUnionType()) { + // Every possible member must allow EBV at this sequence's cardinality. + return this.itemType.getTypes().stream() + .allMatch(member -> new SequenceType(member, this.cardinality).hasEffectiveBooleanValue()); } else if (this.itemType.isSubtypeOf(BuiltinTypesCatalogue.JSONItem)) { return true; } else { From 2d9e9a446fb5ddf0a770d524473db0cb9ce308e0 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Tue, 6 Oct 2026 22:01:22 +0200 Subject: [PATCH 17/36] fix: let independent iterations produce exactly one item SequenceCardinality.multiply assumed every iteration has the same size, so two iterations of an optional sequence could never total one item. A switch over such a FLWOR then dropped the type of a case that does match. Replace it with repeated(iterations), whose operand order is explicit, and make the simple map pass its operands in that order. --- .../rumbledb/compiler/InferTypeVisitor.java | 9 ++-- .../rumbledb/types/SequenceCardinality.java | 15 ++++-- .../java/org/rumbledb/types/SequenceType.java | 5 +- .../types/SequenceCardinalityTest.java | 48 +++++++++++++++++-- 4 files changed, 63 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java index 57063dd35a..ce2748bb95 100644 --- a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java +++ b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java @@ -2285,7 +2285,8 @@ public StaticContext visitSimpleMapExpr(SimpleMapExpression expression, StaticCo SequenceType rightType = rightExpression.getStaticSequenceType(); basicChecks(rightType, expression.getClass().getSimpleName(), true, true, expression.getMetadata()); - SequenceCardinality resultingCardinality = leftType.getCardinality().multiply(rightType.getCardinality()); + // The right expression is evaluated once per item of the left expression. + SequenceCardinality resultingCardinality = rightType.getCardinality().repeated(leftType.getCardinality()); expression.setStaticSequenceType(new SequenceType(rightType.getItemType(), resultingCardinality)); return argument; } @@ -2324,7 +2325,7 @@ public StaticContext visitFlowrExpression(FlworExpression expression, StaticCont // An empty source still emits one tuple with an empty binding. sourceCardinality = sourceCardinality.replaceZeroWithOne(); } - forCardinality = sourceCardinality.multiply(forCardinality); + forCardinality = sourceCardinality.repeated(forCardinality); } else if (!((ForClause) clause).isAllowEmpty()) { forCardinality = SequenceCardinality.EMPTY; } @@ -2344,7 +2345,7 @@ public StaticContext visitFlowrExpression(FlworExpression expression, StaticCont SequenceType returnType = expression.getReturnClause().getReturnExpr().getStaticSequenceType(); basicChecks(returnType, expression.getClass().getSimpleName(), true, true, expression.getMetadata()); returnType = new SequenceType( - returnType.getItemType(), returnType.getCardinality().multiply(forCardinality)); + returnType.getItemType(), returnType.getCardinality().repeated(forCardinality)); expression.setStaticSequenceType(returnType); return argument; } @@ -2852,7 +2853,7 @@ public StaticContext visitFlowrStatement(FlowrStatement statement, StaticContext // An empty source still emits one tuple with an empty binding. sourceCardinality = sourceCardinality.replaceZeroWithOne(); } - forCardinality = sourceCardinality.multiply(forCardinality); + forCardinality = sourceCardinality.repeated(forCardinality); } else if (!((ForClause) clause).isAllowEmpty()) { forCardinality = SequenceCardinality.EMPTY; } diff --git a/src/main/java/org/rumbledb/types/SequenceCardinality.java b/src/main/java/org/rumbledb/types/SequenceCardinality.java index b8baedcc54..650642baac 100644 --- a/src/main/java/org/rumbledb/types/SequenceCardinality.java +++ b/src/main/java/org/rumbledb/types/SequenceCardinality.java @@ -65,11 +65,16 @@ public SequenceCardinality concatenate(SequenceCardinality other) { return fromPossibilities(zero, one, many); } - public SequenceCardinality multiply(SequenceCardinality other) { - boolean zero = allowsZero() || other.allowsZero(); - boolean one = allowsOne() && other.allowsOne(); - boolean many = (allowsMany() && (other.allowsOne() || other.allowsMany())) - || (other.allowsMany() && (allowsOne() || allowsMany())); + /** + * The cardinality of concatenating one sequence of this cardinality per iteration, for a number of + * iterations with the given cardinality. Each iteration can produce a different size: for example, + * two iterations of an optional sequence can produce exactly one item. + */ + public SequenceCardinality repeated(SequenceCardinality iterations) { + boolean someIteration = iterations.allowsOne() || iterations.allowsMany(); + boolean zero = iterations.allowsZero() || allowsZero(); + boolean one = allowsOne() && (iterations.allowsOne() || (iterations.allowsMany() && allowsZero())); + boolean many = (allowsMany() && someIteration) || (iterations.allowsMany() && (allowsOne() || allowsMany())); return fromPossibilities(zero, one, many); } diff --git a/src/main/java/org/rumbledb/types/SequenceType.java b/src/main/java/org/rumbledb/types/SequenceType.java index 035c599ae5..c122974e53 100644 --- a/src/main/java/org/rumbledb/types/SequenceType.java +++ b/src/main/java/org/rumbledb/types/SequenceType.java @@ -217,7 +217,7 @@ private static boolean haveSameObjectFields(ItemType left, ItemType right) { // Grouping concatenates one or more sequences for each group. public SequenceType incrementArity() { - return new SequenceType(this.itemType, this.cardinality.multiply(SequenceCardinality.ONE_OR_MANY)); + return new SequenceType(this.itemType, this.cardinality.repeated(SequenceCardinality.ONE_OR_MANY)); } public SequenceType refineCardinalityIfSubtype(SequenceCardinality other) { @@ -262,9 +262,10 @@ public boolean isSubtypeOf(Arity superArity) { return SequenceCardinality.fromArity(this).isSubtypeOf(SequenceCardinality.fromArity(superArity)); } + // Declared arities that allow many items also allow one, so the operand order does not matter here. public Arity multiplyWith(Arity other) { return SequenceCardinality.fromArity(this) - .multiply(SequenceCardinality.fromArity(other)) + .repeated(SequenceCardinality.fromArity(other)) .toArity(); } } diff --git a/src/test/java/org/rumbledb/types/SequenceCardinalityTest.java b/src/test/java/org/rumbledb/types/SequenceCardinalityTest.java index eacae4cd59..c972b2d127 100644 --- a/src/test/java/org/rumbledb/types/SequenceCardinalityTest.java +++ b/src/test/java/org/rumbledb/types/SequenceCardinalityTest.java @@ -15,6 +15,7 @@ */ package org.rumbledb.types; +import java.net.URI; import java.util.HashSet; import java.util.Set; @@ -22,23 +23,32 @@ import static org.junit.jupiter.api.Assertions.*; +import org.rumbledb.api.Item; +import org.rumbledb.api.Rumble; +import org.rumbledb.bindings.ExternalBindings; +import org.rumbledb.compiler.CompilationPipeline; +import org.rumbledb.config.CompilationConfiguration; +import org.rumbledb.config.RumbleConfiguration; + class SequenceCardinalityTest { @Test void operationsAgreeWithConcreteSequenceLengths() { for (SequenceCardinality left : SequenceCardinality.values()) { for (SequenceCardinality right : SequenceCardinality.values()) { Set sums = new HashSet<>(); - Set products = new HashSet<>(); + Set repeated = new HashSet<>(); Set choices = new HashSet<>(sizes(left)); choices.addAll(sizes(right)); for (int a : sizes(left)) { for (int b : sizes(right)) { sums.add(Math.min(2, a + b)); - products.add(Math.min(2, a * b)); } } + for (int iterations : sizes(right)) { + repeated.addAll(totals(sizes(left), iterations)); + } assertEquals(sums, sizes(left.concatenate(right)), left + " concatenated with " + right); - assertEquals(products, sizes(left.multiply(right)), left + " repeated " + right + " times"); + assertEquals(repeated, sizes(left.repeated(right)), left + " repeated " + right + " times"); assertEquals(choices, sizes(left.union(right))); assertEquals(sizes(right).containsAll(sizes(left)), left.isSubtypeOf(right)); assertEquals(sizes(left).stream().anyMatch(sizes(right)::contains), left.overlaps(right)); @@ -69,6 +79,38 @@ void sequenceTypesKeepMultipleOnlyThroughJoiningAndGrouping() { SequenceCardinality.MANY, singleton.concatenateWith(singleton).getCardinality()); } + @Test + void independentIterationsCanProduceExactlyOneItem() { + // Only the first of the two iterations returns an item, so the case below matches and returns a string. + String query = "switch (for $x in (1, 2) return if ($x eq 1) then $x else ()) " + + "case 1 return \"one\" default return 2"; + URI uri = URI.create("file:///iterations.jq"); + RumbleConfiguration configuration = RumbleConfiguration.defaultConfiguration(); + ItemType inferred = CompilationPipeline.compileMainModule( + query, uri, new CompilationConfiguration(configuration), ExternalBindings.empty()) + .getExpression() + .getStaticSequenceType() + .getItemType(); + Item value = new Rumble(configuration).runQuery(query, uri).getAsList().get(0); + assertTrue( + value.getDynamicType().isSubtypeOf(inferred), () -> value.serialize() + " is excluded by " + inferred); + } + + /** Totals of independently sized sequences, capped at 2 like sizes(); two iterations reach every total. */ + private static Set totals(Set sizes, int iterations) { + Set totals = Set.of(0); + for (int i = 0; i < iterations; i++) { + Set next = new HashSet<>(); + for (int total : totals) { + for (int size : sizes) { + next.add(Math.min(2, total + size)); + } + } + totals = next; + } + return totals; + } + private static Set sizes(SequenceCardinality cardinality) { Set sizes = new HashSet<>(); if (cardinality.allowsZero()) sizes.add(0); From b24ae5d5ed52eb16355251d1cbfb1d8fcc666b97 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Sun, 4 Oct 2026 17:03:18 +0200 Subject: [PATCH 18/36] feat: enhance union type handling and inference in static typing --- .../rumbledb/compiler/InferTypeVisitor.java | 264 ++++++++++++------ .../runtime/functions/FunctionCoercion.java | 4 + .../org/rumbledb/types/FunctionItemType.java | 3 + .../java/org/rumbledb/types/SequenceType.java | 5 + .../org/rumbledb/types/UnionItemType.java | 21 +- .../compiler/UnionTypeInferenceTest.java | 180 ++++++++++++ .../org/rumbledb/types/SequenceTypeTest.java | 94 +++++++ .../static-typing/flwor/where-atomic-union.jq | 4 + .../flwor/where-max-mixed-types.jq | 4 + .../flwor/where-min-mixed-types.jq | 4 + 10 files changed, 478 insertions(+), 105 deletions(-) create mode 100644 src/test/java/org/rumbledb/compiler/UnionTypeInferenceTest.java create mode 100644 src/test/java/org/rumbledb/types/SequenceTypeTest.java create mode 100644 src/test/resources/test_files/static-typing/flwor/where-atomic-union.jq create mode 100644 src/test/resources/test_files/static-typing/flwor/where-max-mixed-types.jq create mode 100644 src/test/resources/test_files/static-typing/flwor/where-min-mixed-types.jq diff --git a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java index 01e1bbd278..d6100497c8 100644 --- a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java +++ b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java @@ -21,6 +21,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.function.BiFunction; import java.util.stream.Collectors; import org.apache.spark.sql.SparkSession; @@ -622,7 +623,7 @@ private SequenceType validateStrictAggregateInputType( return inputType; } - ItemType inputItemType = inputType.getItemType(); + ItemType inputItemType = normalizeAggregateItemType(inputType.getItemType()); if (!inputItemType.isSubtypeOf(BuiltinTypesCatalogue.numericItem) && !inputItemType.isSubtypeOf(BuiltinTypesCatalogue.yearMonthDurationItem) && !inputItemType.isSubtypeOf(BuiltinTypesCatalogue.dayTimeDurationItem)) { @@ -634,7 +635,47 @@ private SequenceType validateStrictAggregateInputType( expression.getMetadata()); } - return inputType; + return new SequenceType(inputItemType, inputType.getCardinality()); + } + + // Aggregates can convert untyped values and produce values outside a derived type's restrictions. + private ItemType normalizeAggregateItemType(ItemType type) { + if (type.isUnionType()) { + return ItemTypeFactory.createInferredUnionType(type.getTypes().stream() + .map(this::normalizeAggregateItemType) + .collect(Collectors.toList())); + } + if (type.isSubtypeOf(BuiltinTypesCatalogue.untypedAtomicItem)) { + return BuiltinTypesCatalogue.doubleItem; + } + if (type.isNumeric() + || type.isSubtypeOf(BuiltinTypesCatalogue.yearMonthDurationItem) + || type.isSubtypeOf(BuiltinTypesCatalogue.dayTimeDurationItem)) { + return type.getCastingPrimitiveType(); + } + if (type.isSubtypeOf(BuiltinTypesCatalogue.stringItem)) { + return BuiltinTypesCatalogue.stringItem; + } + return type; + } + + private SequenceType inferSumReturnType(FunctionCallExpression expression) { + SequenceType inputType = validateStrictAggregateInputType( + expression, expression.getArguments().get(0), "fn:sum"); + SequenceType zeroType = expression.getArguments().size() > 1 + ? expression.getArguments().get(1).getStaticSequenceType() + : new SequenceType(BuiltinTypesCatalogue.integerItem); + if (inputType.isEmptySequence()) { + return zeroType; + } + SequenceType result = new SequenceType(inputType.getItemType()); + if (!inputType.getCardinality().allowsZero()) { + return result; + } + ItemType itemType = zeroType.isEmptySequence() + ? result.getItemType() + : ItemTypeFactory.createInferredUnionType(List.of(result.getItemType(), zeroType.getItemType())); + return new SequenceType(itemType, result.getCardinality().union(zeroType.getCardinality())); } private SequenceType inferStrictAggregateReturnType(FunctionCallExpression expression, Expression inputExpression) { @@ -678,7 +719,7 @@ private SequenceType inferStrictMinMaxReturnType( (inputType.getArity() == SequenceType.Arity.One || inputType.getArity() == SequenceType.Arity.OneOrMore) ? SequenceType.Arity.One : SequenceType.Arity.OneOrZero; - return new SequenceType(inputItemType, returnArity); + return new SequenceType(normalizeAggregateItemType(inputItemType), returnArity); } private boolean isBuiltinFunctionName(Name functionName, String localName) { @@ -776,12 +817,7 @@ private boolean tryAnnotateSpecificFunctions(FunctionCallExpression expression, } if (isBuiltinFunctionName(functionName, "sum")) { - SequenceType inputType = validateStrictAggregateInputType(expression, args.get(0), "fn:sum"); - expression.setStaticSequenceType(new SequenceType( - inputType.getItemType(), - inputType.getArity() == SequenceType.Arity.OneOrMore - ? SequenceType.Arity.One - : SequenceType.Arity.OneOrZero)); + expression.setStaticSequenceType(inferSumReturnType(expression)); return true; } @@ -1020,11 +1056,10 @@ public StaticContext visitCastExpression(CastExpression expression, StaticContex : ErrorCode.AtomizationError, expression.getMetadata()); } - if (!expressionSequenceType.getItemType().isAtomicItemType() - || (!castedSequenceType.getItemType().equals(BuiltinTypesCatalogue.errorItem) - && !expressionSequenceType - .getItemType() - .isStaticallyCastableAs(castedSequenceType.getItemType()))) { + // Non-atomic operands were reported above; with static typing disabled, leave them to runtime. + if (expressionSequenceType.getItemType().isSubtypeOf(BuiltinTypesCatalogue.atomicItem) + && !castedSequenceType.getItemType().equals(BuiltinTypesCatalogue.errorItem) + && !expressionSequenceType.getItemType().isStaticallyCastableAs(castedSequenceType.getItemType())) { throwStaticTypeException( "It is never possible to cast a " + expressionSequenceType + " as " + castedSequenceType, ErrorCode.UnexpectedTypeErrorCode, @@ -1259,67 +1294,133 @@ public StaticContext visitAdditiveExpr(AdditiveExpression expression, StaticCont ItemType leftItemType = leftInferredType.getItemType(); ItemType rightItemType = rightInferredType.getItemType(); + inferredType = inferBinaryOperationType( + leftItemType, rightItemType, (left, right) -> inferAdditiveItemType(left, right, expression.isMinus())); + + if (inferredType == null) { + if (inferredArity == SequenceType.Arity.OneOrZero) { + // Only possible resulting type is empty sequence so throw error XPST0005 + throwStaticTypeException( + "Inferred type is empty sequence and this is not a CommaExpression", + ErrorCode.StaticallyInferredEmptySequenceNotFromCommaExpression, + expression.getMetadata()); + } else { + throwStaticTypeException( + "The following types operation is not possible: " + + leftInferredType + + (expression.isMinus() ? " - " : " + ") + + rightInferredType, + expression.getMetadata()); + } + inferredType = BuiltinTypesCatalogue.atomicItem; + } + + expression.setStaticSequenceType(new SequenceType(inferredType, inferredArity)); + return argument; + } + + // Evaluate each possible operand pair. A null result means an unsupported pair. + private ItemType inferBinaryOperationType( + ItemType left, ItemType right, BiFunction operation) { + if (!left.isUnionType() && !right.isUnionType()) { + return operation.apply(left, right); + } + List results = new ArrayList<>(); + List leftTypes = left.isUnionType() ? left.getTypes() : List.of(left); + List rightTypes = right.isUnionType() ? right.getTypes() : List.of(right); + for (ItemType leftMember : leftTypes) { + for (ItemType rightMember : rightTypes) { + ItemType result = inferBinaryOperationType(leftMember, rightMember, operation); + if (result == null) { + return null; + } + results.add(result); + } + } + return ItemTypeFactory.createInferredUnionType(results); + } + + private ItemType inferAdditiveItemType(ItemType leftItemType, ItemType rightItemType, boolean minus) { + leftItemType = leftItemType.getCastingPrimitiveType(); + rightItemType = rightItemType.getCastingPrimitiveType(); + ItemType result = null; // check item type combination if (leftItemType.isNumeric()) { if (rightItemType.isNumeric()) { - inferredType = resolveNumericType(leftItemType, rightItemType); + result = resolveNumericType(leftItemType, rightItemType); } } else if (leftItemType.equals(BuiltinTypesCatalogue.dateItem) || leftItemType.equals(BuiltinTypesCatalogue.dateTimeItem)) { if (rightItemType.equals(BuiltinTypesCatalogue.dayTimeDurationItem) || rightItemType.equals(BuiltinTypesCatalogue.yearMonthDurationItem)) { - inferredType = leftItemType; - } else if (expression.isMinus() && rightItemType.equals(leftItemType)) { - inferredType = BuiltinTypesCatalogue.dayTimeDurationItem; + result = leftItemType; + } else if (minus && rightItemType.equals(leftItemType)) { + result = BuiltinTypesCatalogue.dayTimeDurationItem; } } else if (leftItemType.equals(BuiltinTypesCatalogue.timeItem)) { if (rightItemType.equals(BuiltinTypesCatalogue.dayTimeDurationItem)) { - inferredType = leftItemType; - } else if (expression.isMinus() && rightItemType.equals(leftItemType)) { - inferredType = BuiltinTypesCatalogue.dayTimeDurationItem; + result = leftItemType; + } else if (minus && rightItemType.equals(leftItemType)) { + result = BuiltinTypesCatalogue.dayTimeDurationItem; } } else if (leftItemType.equals(BuiltinTypesCatalogue.dayTimeDurationItem)) { if (rightItemType.equals(leftItemType)) { - inferredType = leftItemType; - } else if (!expression.isMinus() + result = leftItemType; + } else if (!minus && (rightItemType.equals(BuiltinTypesCatalogue.dateTimeItem) || rightItemType.equals(BuiltinTypesCatalogue.dateItem) || rightItemType.equals(BuiltinTypesCatalogue.timeItem))) { - inferredType = rightItemType; + result = rightItemType; } } else if (leftItemType.equals(BuiltinTypesCatalogue.yearMonthDurationItem)) { if (rightItemType.equals(leftItemType)) { - inferredType = leftItemType; - } else if (!expression.isMinus() + result = leftItemType; + } else if (!minus && (rightItemType.equals(BuiltinTypesCatalogue.dateTimeItem) || rightItemType.equals(BuiltinTypesCatalogue.dateItem))) { - inferredType = rightItemType; + result = rightItemType; } } - if (inferredType == null) { - if (inferredArity == SequenceType.Arity.OneOrZero) { - // Only possible resulting type is empty sequence so throw error XPST0005 - throwStaticTypeException( - "Inferred type is empty sequence and this is not a CommaExpression", - ErrorCode.StaticallyInferredEmptySequenceNotFromCommaExpression, - expression.getMetadata()); - } else { - throwStaticTypeException( - "The following types operation is not possible: " - + leftInferredType - + (expression.isMinus() ? " - " : " + ") - + rightInferredType, - expression.getMetadata()); + return result; + } + + private ItemType inferMultiplicativeItemType( + ItemType leftItemType, ItemType rightItemType, MultiplicativeExpression.MultiplicativeOperator operator) { + leftItemType = leftItemType.getCastingPrimitiveType(); + rightItemType = rightItemType.getCastingPrimitiveType(); + ItemType result = null; + // check resulting item for each operation + if (leftItemType.isNumeric()) { + if (rightItemType.isNumeric()) { + if (operator == MultiplicativeExpression.MultiplicativeOperator.IDIV) { + result = BuiltinTypesCatalogue.integerItem; + } else if (operator == MultiplicativeExpression.MultiplicativeOperator.DIV) { + result = resolveNumericType( + BuiltinTypesCatalogue.decimalItem, resolveNumericType(leftItemType, rightItemType)); + } else { + result = resolveNumericType(leftItemType, rightItemType); + } + } else if (rightItemType.isSubtypeOf(BuiltinTypesCatalogue.durationItem) + && !rightItemType.equals(BuiltinTypesCatalogue.durationItem) + && operator == MultiplicativeExpression.MultiplicativeOperator.MUL) { + result = rightItemType; + } + } else if (leftItemType.isSubtypeOf(BuiltinTypesCatalogue.durationItem) + && !leftItemType.equals(BuiltinTypesCatalogue.durationItem)) { + if (rightItemType.isNumeric() + && (operator == MultiplicativeExpression.MultiplicativeOperator.MUL + || operator == MultiplicativeExpression.MultiplicativeOperator.DIV)) { + result = leftItemType; + } else if (rightItemType.equals(leftItemType)) { + result = BuiltinTypesCatalogue.decimalItem; } - inferredType = BuiltinTypesCatalogue.atomicItem; } - expression.setStaticSequenceType(new SequenceType(inferredType, inferredArity)); - return argument; + return result; } - // This function assume 2 numeric ItemType + // Both operands are concrete numeric types. private ItemType resolveNumericType(ItemType left, ItemType right) { if (left.equals(BuiltinTypesCatalogue.doubleItem) || right.equals(BuiltinTypesCatalogue.doubleItem)) { return BuiltinTypesCatalogue.doubleItem; @@ -1384,34 +1485,10 @@ public StaticContext visitMultiplicativeExpr(MultiplicativeExpression expression ItemType leftItemType = leftInferredType.getItemType(); ItemType rightItemType = rightInferredType.getItemType(); - // check resulting item for each operation - if (leftItemType.isNumeric()) { - if (rightItemType.isNumeric()) { - if (expression.getMultiplicativeOperator() == MultiplicativeExpression.MultiplicativeOperator.IDIV) { - inferredType = BuiltinTypesCatalogue.integerItem; - } else if (expression.getMultiplicativeOperator() - == MultiplicativeExpression.MultiplicativeOperator.DIV) { - inferredType = resolveNumericType( - BuiltinTypesCatalogue.decimalItem, resolveNumericType(leftItemType, rightItemType)); - } else { - inferredType = resolveNumericType(leftItemType, rightItemType); - } - } else if (rightItemType.isSubtypeOf(BuiltinTypesCatalogue.durationItem) - && !rightItemType.equals(BuiltinTypesCatalogue.durationItem) - && expression.getMultiplicativeOperator() == MultiplicativeExpression.MultiplicativeOperator.MUL) { - inferredType = rightItemType; - } - } else if (leftItemType.isSubtypeOf(BuiltinTypesCatalogue.durationItem) - && !leftItemType.equals(BuiltinTypesCatalogue.durationItem)) { - if (rightItemType.isNumeric() - && (expression.getMultiplicativeOperator() == MultiplicativeExpression.MultiplicativeOperator.MUL - || expression.getMultiplicativeOperator() - == MultiplicativeExpression.MultiplicativeOperator.DIV)) { - inferredType = leftItemType; - } else if (rightItemType.equals(leftItemType)) { - inferredType = BuiltinTypesCatalogue.decimalItem; - } - } + inferredType = inferBinaryOperationType( + leftItemType, + rightItemType, + (left, right) -> inferMultiplicativeItemType(left, right, expression.getMultiplicativeOperator())); if (inferredType == null) { if (inferredArity == SequenceType.Arity.OneOrZero) { @@ -2209,28 +2286,32 @@ public StaticContext visitDynamicFunctionCallExpression( return argument; } - ItemType type = mainType.getItemType(); - if (type.isArrayItemType()) { - expression.setStaticSequenceType(SequenceType.createSequenceType("item*")); - return argument; + expression.setStaticSequenceType(inferDynamicFunctionCallType(expression, mainType.getItemType())); + return argument; + } + + private SequenceType inferDynamicFunctionCallType(DynamicFunctionCallExpression expression, ItemType type) { + if (type.isUnionType()) { + SequenceType result = null; + for (ItemType member : type.getTypes()) { + SequenceType memberResult = inferDynamicFunctionCallType(expression, member); + result = result == null ? memberResult : result.leastCommonSupertypeWith(memberResult); + } + return result; } - if (type.isMapItemType()) { - expression.setStaticSequenceType(SequenceType.createSequenceType("item*")); - return argument; + if (type.isArrayItemType() || type.isMapItemType()) { + return SequenceType.createSequenceType("item*"); } if (!type.isFunctionItemType()) { - expression.setStaticSequenceType(SequenceType.createSequenceType("item*")); - throwStaticTypeException( - "the type of a dynamic function call main expression must be function or array, instead inferred " - + mainType, + "the type of a dynamic function call main expression must be function, array, or map, instead inferred " + + type, expression.getMetadata()); - return argument; + return SequenceType.createSequenceType("item*"); } if (type.equals(BuiltinTypesCatalogue.anyFunctionItem)) { - expression.setStaticSequenceType(SequenceType.createSequenceType("item*")); - return argument; + return SequenceType.createSequenceType("item*"); } FunctionSignature signature = type.getSignature(); @@ -2252,18 +2333,15 @@ public StaticContext visitDynamicFunctionCallExpression( if (isPartialApplication) { FunctionSignature newSignature = new FunctionSignature( partialFormalParameterTypes, signature.getReturnType(), expression.isUpdating()); - expression.setStaticSequenceType(new SequenceType(ItemTypeFactory.createFunctionItemType(newSignature))); - return argument; + return new SequenceType(ItemTypeFactory.createFunctionItemType(newSignature)); } if (!checkArguments(formalParameterTypes, actualParameterTypes)) { throwStaticTypeException( - "the type of a dynamic function call main expression must be function, instead inferred " - + mainType, + "the type of a dynamic function call main expression must be function, instead inferred " + type, expression.getMetadata()); } - expression.setStaticSequenceType(signature.getReturnType()); - return argument; + return signature.getReturnType(); } @Override diff --git a/src/main/java/org/rumbledb/runtime/functions/FunctionCoercion.java b/src/main/java/org/rumbledb/runtime/functions/FunctionCoercion.java index 339083a1dc..2482407f3e 100644 --- a/src/main/java/org/rumbledb/runtime/functions/FunctionCoercion.java +++ b/src/main/java/org/rumbledb/runtime/functions/FunctionCoercion.java @@ -47,6 +47,10 @@ public static boolean canBeFunctionCoercedTo(SequenceType sourceType, SequenceTy } public static boolean canItemTypeBeFunctionCoercedTo(ItemType sourceItemType, ItemType targetItemType) { + if (sourceItemType.isUnionType()) { + return sourceItemType.getTypes().stream() + .allMatch(member -> canItemTypeBeFunctionCoercedTo(member, targetItemType)); + } if (!targetItemType.isFunctionItemType() || targetItemType.getSignature() == null) { return false; } diff --git a/src/main/java/org/rumbledb/types/FunctionItemType.java b/src/main/java/org/rumbledb/types/FunctionItemType.java index 5b31834ce0..3be85dc356 100644 --- a/src/main/java/org/rumbledb/types/FunctionItemType.java +++ b/src/main/java/org/rumbledb/types/FunctionItemType.java @@ -108,6 +108,9 @@ public void resolve(DynamicContext context, ExceptionMetadata metadata) { @Override public boolean isSubtypeOf(ItemType superType) { + if (superType.isUnionType()) { + return superType.getTypes().stream().anyMatch(this::isSubtypeOf); + } if (this.equals(superType) || superType.equals(anyFunctionItem) || superType.equals(BuiltinTypesCatalogue.item)) { diff --git a/src/main/java/org/rumbledb/types/SequenceType.java b/src/main/java/org/rumbledb/types/SequenceType.java index eb023c868c..9e2d41533e 100644 --- a/src/main/java/org/rumbledb/types/SequenceType.java +++ b/src/main/java/org/rumbledb/types/SequenceType.java @@ -111,6 +111,11 @@ public boolean isSubtypeOfOrCanBePromotedTo(SequenceType superType) { if (this.itemType.equals(BuiltinTypesCatalogue.errorItem)) { return !this.cardinality.allowsZero() || superType.cardinality.allowsZero(); } + if (this.itemType.isUnionType()) { + // A member can fit directly while another needs promotion or function coercion. + return this.itemType.getTypes().stream().allMatch(member -> new SequenceType(member, this.cardinality) + .isSubtypeOfOrCanBePromotedTo(superType)); + } return this.cardinality.isSubtypeOf(superType.cardinality) && (this.itemType.isSubtypeOf(superType.itemType) || this.itemType.canBePromotedTo(superType.itemType) diff --git a/src/main/java/org/rumbledb/types/UnionItemType.java b/src/main/java/org/rumbledb/types/UnionItemType.java index 7e5c823d9f..6798747a11 100644 --- a/src/main/java/org/rumbledb/types/UnionItemType.java +++ b/src/main/java/org/rumbledb/types/UnionItemType.java @@ -104,18 +104,15 @@ public boolean isNumeric() { @Override public boolean isStaticallyCastableAs(ItemType other) { - if (other.equals(this)) { - return true; - } - if (other.isNumeric()) { - return true; - } - for (ItemType member : this.types) { - if (other.isSubtypeOf(member)) { - return true; - } - } - return false; + return other.equals(this) + || this.types.stream() + .allMatch(member -> member.isSubtypeOf(BuiltinTypesCatalogue.atomicItem) + && member.isStaticallyCastableAs(other)); + } + + @Override + public boolean canBePromotedTo(ItemType other) { + return this.types.stream().allMatch(member -> member.isSubtypeOf(other) || member.canBePromotedTo(other)); } @Override diff --git a/src/test/java/org/rumbledb/compiler/UnionTypeInferenceTest.java b/src/test/java/org/rumbledb/compiler/UnionTypeInferenceTest.java new file mode 100644 index 0000000000..5fb722691d --- /dev/null +++ b/src/test/java/org/rumbledb/compiler/UnionTypeInferenceTest.java @@ -0,0 +1,180 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contributor acknowledgements are maintained in the CONTRIBUTORS file at the project root. + */ +package org.rumbledb.compiler; + +import java.net.URI; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.*; + +import org.rumbledb.api.Item; +import org.rumbledb.api.Rumble; +import org.rumbledb.bindings.ExternalBindings; +import org.rumbledb.config.CompilationConfiguration; +import org.rumbledb.config.RumbleConfiguration; +import org.rumbledb.exceptions.UnexpectedStaticTypeException; +import org.rumbledb.types.BuiltinTypesCatalogue; +import org.rumbledb.types.ItemType; +import org.rumbledb.types.ItemTypeFactory; +import org.rumbledb.types.SequenceCardinality; +import org.rumbledb.types.SequenceType; + +class UnionTypeInferenceTest { + private static final RumbleConfiguration CONFIGURATION = RumbleConfiguration.builder() + .configureAnalysis(analysis -> analysis.enableStaticTyping(true)) + .build(); + + private record Case(String query, List expectedTypes) {} + + static Stream unionQueries() { + List floating = List.of(BuiltinTypesCatalogue.floatItem, BuiltinTypesCatalogue.doubleItem); + List integers = List.of(BuiltinTypesCatalogue.integerItem); + List strings = List.of(BuiltinTypesCatalogue.stringItem); + List cases = new java.util.ArrayList<>(); + for (String operator : List.of("+", "-", "*", "div", "mod")) { + cases.add(new Case("for $x in (xs:float(1), xs:double(2)) return $x " + operator + " 1", floating)); + } + cases.addAll(List.of( + new Case("for $x in (xs:float(1), xs:double(2)) for $y in (xs:decimal(3), 4) return $x + $y", floating), + new Case("for $x in (xs:float(1), xs:double(2)) return $x idiv 1", integers), + new Case( + "for $x in (xs:date(\"2000-01-01\"), xs:dateTime(\"2000-01-01T00:00:00\")) return $x + xs:dayTimeDuration(\"P1D\")", + List.of(BuiltinTypesCatalogue.dateItem, BuiltinTypesCatalogue.dateTimeItem)), + new Case( + "for $x in (xs:float(1), xs:decimal(2)) return math:pow($x, 2)", + List.of(BuiltinTypesCatalogue.doubleItem)), + new Case("for $x in (xs:anyURI(\"a\"), \"b\") return string-length($x)", integers), + new Case( + "for $x in (xs:anyURI(\"a\"), \"b\") return $x eq \"a\"", + List.of(BuiltinTypesCatalogue.booleanItem)), + new Case("for $x in (1, \"2\") return $x cast as xs:string", strings), + new Case("for $x in (xs:boolean(\"true\"), 1) return $x cast as xs:string", strings), + new Case("sum((xs:positiveInteger(1), xs:negativeInteger(-1)))", integers), + new Case( + "sum((xs:untypedAtomic(\"1\"), 2))", + List.of(BuiltinTypesCatalogue.integerItem, BuiltinTypesCatalogue.doubleItem)), + new Case("avg((xs:untypedAtomic(\"1\"), 2))", List.of(BuiltinTypesCatalogue.numericItem)), + new Case( + "min((xs:untypedAtomic(\"1\"), 2))", + List.of(BuiltinTypesCatalogue.integerItem, BuiltinTypesCatalogue.doubleItem)), + new Case( + "max((xs:untypedAtomic(\"1\"), 2))", + List.of(BuiltinTypesCatalogue.integerItem, BuiltinTypesCatalogue.doubleItem)), + new Case( + "min((xs:int(1), xs:float(2)))", + List.of(BuiltinTypesCatalogue.integerItem, BuiltinTypesCatalogue.floatItem)), + new Case( + "max((xs:anyURI(\"a\"), \"b\"))", + List.of(BuiltinTypesCatalogue.anyURIItem, BuiltinTypesCatalogue.stringItem)), + new Case( + "sum((1, 2)[xs:boolean(\"false\")], \"empty\")", + List.of(BuiltinTypesCatalogue.integerItem, BuiltinTypesCatalogue.stringItem)))); + return cases.stream().flatMap(test -> Stream.of("jq", "xq").map(extension -> Arguments.of(test, extension))); + } + + @ParameterizedTest + @MethodSource("unionQueries") + void inferredTypesCoverRuntimeResults(Case test, String extension) { + URI uri = URI.create("file:///union-inference." + extension); + SequenceType inferred = infer(test.query(), uri); + ItemType expected = ItemTypeFactory.createInferredUnionType(test.expectedTypes()); + assertTrue(inferred.getItemType().isSubtypeOf(expected), () -> "Unexpected inferred type: " + inferred); + List values = + new Rumble(CONFIGURATION).runQuery(test.query(), uri).getAsList(); + assertFalse(values.isEmpty()); + for (Item value : values) { + assertTrue( + value.getDynamicType().isSubtypeOf(inferred.getItemType()), + () -> value.getDynamicType() + " is excluded by " + inferred + " for " + test.query()); + } + } + + @Test + void sumEmptyInputUsesItsZeroArgument() { + URI uri = URI.create("file:///sum-empty.xq"); + assertEquals(SequenceCardinality.ONE, infer("sum(())", uri).getCardinality()); + assertEquals(BuiltinTypesCatalogue.integerItem, infer("sum(())", uri).getItemType()); + assertEquals( + BuiltinTypesCatalogue.stringItem, + infer("sum((), \"zero\")", uri).getItemType()); + assertTrue(infer("sum((), ())", uri).isEmptySequence()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void callableFieldAlternativesCanBeInvoked(boolean singleton) { + String query = "let $f := function($x as xs:integer) as xs:integer {$x} " + + "let $o := {\"f\": if (" + + singleton + + ") then $f else ($f, $f)} return ($o.f)(1)"; + List values = new Rumble(CONFIGURATION).runQuery(query).getAsList(); + assertEquals(1, values.size()); + if (singleton) { + assertEquals(1, values.get(0).getIntValue()); + } else { + assertTrue(values.get(0).isFunction()); + } + } + + @ParameterizedTest + @ValueSource( + strings = { + "for $x in (1, \"a\") return $x + 1", + "for $x in (xs:float(1), \"a\") return math:pow($x, 2)", + "for $x in (xs:date(\"2000-01-01\"), xs:dateTime(\"2000-01-01T00:00:00\")) return $x cast as xs:double" + }) + void incompatibleUnionMembersAreStillRejected(String query) { + for (String extension : List.of("jq", "xq")) { + assertThrows( + UnexpectedStaticTypeException.class, + () -> infer(query, URI.create("file:///invalid-union." + extension))); + } + } + + @Test + void nullableSumWithEmptyZeroRemainsOptional() { + URI uri = URI.create("file:///sum-optional.xq"); + assertEquals( + SequenceCardinality.ZERO_OR_ONE, + infer("sum((1, 2)[xs:boolean(\"false\")], ())", uri).getCardinality()); + } + + @Test + void nonAtomicCastOperandsRemainRuntimeChecksWhenStaticTypingIsDisabled() { + RumbleConfiguration configuration = RumbleConfiguration.builder() + .configureAnalysis(analysis -> analysis.enableStaticTyping(false)) + .build(); + assertDoesNotThrow(() -> CompilationPipeline.compileMainModule( + "[1] cast as xs:string", + URI.create("file:///array-cast.jq"), + new CompilationConfiguration(configuration), + ExternalBindings.empty())); + } + + private static SequenceType infer(String query, URI uri) { + return CompilationPipeline.compileMainModule( + query, uri, new CompilationConfiguration(CONFIGURATION), ExternalBindings.empty()) + .getExpression() + .getStaticSequenceType(); + } +} diff --git a/src/test/java/org/rumbledb/types/SequenceTypeTest.java b/src/test/java/org/rumbledb/types/SequenceTypeTest.java new file mode 100644 index 0000000000..d38e99fd4c --- /dev/null +++ b/src/test/java/org/rumbledb/types/SequenceTypeTest.java @@ -0,0 +1,94 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contributor acknowledgements are maintained in the CONTRIBUTORS file at the project root. + */ +package org.rumbledb.types; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.junit.jupiter.api.Assertions.*; + +import org.rumbledb.context.Name; + +class SequenceTypeTest { + @ParameterizedTest + @EnumSource(SequenceCardinality.class) + void atomicUnionEffectiveBooleanValueRespectsCardinality(SequenceCardinality cardinality) { + ItemType union = ItemTypeFactory.createInferredUnionType( + List.of(BuiltinTypesCatalogue.stringItem, BuiltinTypesCatalogue.integerItem)); + Set allowed = + Set.of(SequenceCardinality.EMPTY, SequenceCardinality.ONE, SequenceCardinality.ZERO_OR_ONE); + assertEquals(allowed.contains(cardinality), new SequenceType(union, cardinality).hasEffectiveBooleanValue()); + } + + @Test + void namedAndNestedUnionsUseTheirMembers() { + ItemType named = ItemTypeFactory.createXmlSchemaUnionType( + new Name("urn:test", "test", "string-or-integer"), + List.of(BuiltinTypesCatalogue.stringItem, BuiltinTypesCatalogue.integerItem)); + assertTrue(new SequenceType(named).hasEffectiveBooleanValue()); + ItemType nested = ItemTypeFactory.createInferredUnionType(List.of(named, BuiltinTypesCatalogue.booleanItem)); + assertTrue(new SequenceType(nested).hasEffectiveBooleanValue()); + } + + @Test + void everyUnionMemberMustHaveAnEffectiveBooleanValue() { + ItemType union = ItemTypeFactory.createInferredUnionType( + List.of(BuiltinTypesCatalogue.stringItem, BuiltinTypesCatalogue.dateItem)); + assertFalse(new SequenceType(union).hasEffectiveBooleanValue()); + assertFalse(new SequenceType(union, SequenceCardinality.ZERO_OR_ONE).hasEffectiveBooleanValue()); + } + + @Test + void unionCastabilityUsesMemberRules() { + ItemType union = ItemTypeFactory.createInferredUnionType( + List.of(BuiltinTypesCatalogue.booleanItem, BuiltinTypesCatalogue.integerItem)); + assertTrue(union.isStaticallyCastableAs(BuiltinTypesCatalogue.stringItem)); + ItemType invalid = ItemTypeFactory.createInferredUnionType( + List.of(BuiltinTypesCatalogue.dateItem, BuiltinTypesCatalogue.dateTimeItem)); + assertFalse(invalid.isStaticallyCastableAs(BuiltinTypesCatalogue.doubleItem)); + ItemType structured = ItemTypeFactory.createInferredUnionType( + List.of(BuiltinTypesCatalogue.arrayItem, BuiltinTypesCatalogue.integerItem)); + assertFalse(structured.isStaticallyCastableAs(BuiltinTypesCatalogue.stringItem)); + } + + @Test + void unionArgumentConversionChecksEachAlternative() { + ItemType union = ItemTypeFactory.createInferredUnionType( + List.of(BuiltinTypesCatalogue.doubleItem, BuiltinTypesCatalogue.integerItem)); + assertTrue(new SequenceType(union) + .isSubtypeOfOrCanBePromotedTo(new SequenceType(BuiltinTypesCatalogue.doubleItem))); + ItemType invalid = ItemTypeFactory.createInferredUnionType( + List.of(BuiltinTypesCatalogue.stringItem, BuiltinTypesCatalogue.integerItem)); + assertFalse(new SequenceType(invalid) + .isSubtypeOfOrCanBePromotedTo(new SequenceType(BuiltinTypesCatalogue.doubleItem))); + } + + @Test + void functionSubtypingAndCoercionSupportUnions() { + ItemType function = ItemTypeFactory.createFunctionItemType(new FunctionSignature( + List.of(new SequenceType(BuiltinTypesCatalogue.integerItem)), + new SequenceType(BuiltinTypesCatalogue.integerItem))); + ItemType target = ItemTypeFactory.createInferredUnionType(List.of(function, BuiltinTypesCatalogue.integerItem)); + assertTrue(function.isSubtypeOf(target)); + ItemType callable = ItemTypeFactory.createInferredUnionType(List.of(function, BuiltinTypesCatalogue.arrayItem)); + assertTrue(new SequenceType(callable).isSubtypeOfOrCanBePromotedTo(new SequenceType(function))); + assertFalse(new SequenceType(target).isSubtypeOfOrCanBePromotedTo(new SequenceType(function))); + } +} diff --git a/src/test/resources/test_files/static-typing/flwor/where-atomic-union.jq b/src/test/resources/test_files/static-typing/flwor/where-atomic-union.jq new file mode 100644 index 0000000000..04507379c3 --- /dev/null +++ b/src/test/resources/test_files/static-typing/flwor/where-atomic-union.jq @@ -0,0 +1,4 @@ +(:JIQS: ShouldRun :) +for $value in ("a", 0) +where $value +return $value diff --git a/src/test/resources/test_files/static-typing/flwor/where-max-mixed-types.jq b/src/test/resources/test_files/static-typing/flwor/where-max-mixed-types.jq new file mode 100644 index 0000000000..59d9ac1777 --- /dev/null +++ b/src/test/resources/test_files/static-typing/flwor/where-max-mixed-types.jq @@ -0,0 +1,4 @@ +(:JIQS: ShouldCrash; ErrorCode="FORG0006" :) +for $var in ("a", "b", "c") +where fn:max(($var, 1)) +return $var diff --git a/src/test/resources/test_files/static-typing/flwor/where-min-mixed-types.jq b/src/test/resources/test_files/static-typing/flwor/where-min-mixed-types.jq new file mode 100644 index 0000000000..b573719957 --- /dev/null +++ b/src/test/resources/test_files/static-typing/flwor/where-min-mixed-types.jq @@ -0,0 +1,4 @@ +(:JIQS: ShouldCrash; ErrorCode="FORG0006" :) +for $var in ("a", "b", "c") +where fn:min(($var, 1)) +return $var From cb32b459f5393e0c5c5fffd71e76007fbf5b14fd Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Tue, 6 Oct 2026 22:18:08 +0200 Subject: [PATCH 19/36] test: cover how DataFrame rows restore null object fields Required constructor fields keep their key with a JSON null, while optional fields and the empty-object sentinel are omitted. --- .../items/parsing/ItemParserTest.java | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/test/java/org/rumbledb/items/parsing/ItemParserTest.java diff --git a/src/test/java/org/rumbledb/items/parsing/ItemParserTest.java b/src/test/java/org/rumbledb/items/parsing/ItemParserTest.java new file mode 100644 index 0000000000..7370f8fb6c --- /dev/null +++ b/src/test/java/org/rumbledb/items/parsing/ItemParserTest.java @@ -0,0 +1,95 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contributor acknowledgements are maintained in the CONTRIBUTORS file at the project root. + */ +package org.rumbledb.items.parsing; + +import java.util.List; + +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.expressions.GenericRowWithSchema; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.StructType; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +import org.rumbledb.api.Item; +import org.rumbledb.exceptions.ExceptionMetadata; +import org.rumbledb.spark.SparkSessionManager; +import org.rumbledb.types.BuiltinTypesCatalogue; +import org.rumbledb.types.ItemType; +import org.rumbledb.types.ItemTypeFactory; +import org.rumbledb.types.SequenceType; + +class ItemParserTest { + private static final StructType BOOLEAN_FIELDS = DataTypes.createStructType(List.of( + DataTypes.createStructField("is_1", DataTypes.BooleanType, true), + DataTypes.createStructField("is_not_1", DataTypes.BooleanType, true))); + + @Test + void retainsNullForRequiredConstructorFields() { + Item object = parse(new GenericRowWithSchema(new Object[] {true, null}, BOOLEAN_FIELDS), constructorType()); + assertEquals(List.of("is_1", "is_not_1"), object.getStringKeys()); + assertTrue(object.getItemByKey("is_1").getBooleanValue()); + assertTrue(object.getItemByKey("is_not_1").isNull()); + } + + @Test + void omitsNullForOptionalSourceFields() { + ItemType sourceType = ItemTypeFactory.createItemType(BOOLEAN_FIELDS); + Item object = parse(new GenericRowWithSchema(new Object[] {true, null}, BOOLEAN_FIELDS), sourceType); + assertEquals(List.of("is_1"), object.getStringKeys()); + } + + @Test + void omitsNullWithoutFieldTypeInformation() { + Item object = parse(new GenericRowWithSchema(new Object[] {true, null}, BOOLEAN_FIELDS), null); + assertEquals(List.of("is_1"), object.getStringKeys()); + } + + @Test + void retainsRequiredNullInsideNestedObjects() { + StructType schema = + DataTypes.createStructType(List.of(DataTypes.createStructField("nested", BOOLEAN_FIELDS, false))); + ItemType type = ItemTypeFactory.createAnonymousObjectType(List.of("nested"), List.of(constructorType())); + Row nested = new GenericRowWithSchema(new Object[] {null, true}, BOOLEAN_FIELDS); + Item object = parse(new GenericRowWithSchema(new Object[] {nested}, schema), type); + Item nestedObject = object.getItemByKey("nested"); + assertEquals(List.of("is_1", "is_not_1"), nestedObject.getStringKeys()); + assertTrue(nestedObject.getItemByKey("is_1").isNull()); + assertTrue(nestedObject.getItemByKey("is_not_1").getBooleanValue()); + } + + @Test + void retainsExplicitNullColumnsButOmitsEmptyObjectSentinel() { + StructType schema = DataTypes.createStructType(List.of( + DataTypes.createStructField("explicit_null", DataTypes.NullType, true), + DataTypes.createStructField( + SparkSessionManager.emptyObjectJSONiqItemColumnName, DataTypes.NullType, true))); + Item object = parse(new GenericRowWithSchema(new Object[] {null, null}, schema), null); + assertEquals(List.of("explicit_null"), object.getStringKeys()); + assertTrue(object.getItemByKey("explicit_null").isNull()); + } + + private static ItemType constructorType() { + ItemType fieldType = ItemTypeFactory.createObjectFieldType( + new SequenceType(BuiltinTypesCatalogue.booleanItem, SequenceType.Arity.OneOrZero)); + return ItemTypeFactory.createAnonymousObjectType(List.of("is_1", "is_not_1"), List.of(fieldType, fieldType)); + } + + private static Item parse(Row row, ItemType type) { + return ItemParser.getItemFromRow(row, ExceptionMetadata.EMPTY_METADATA, type); + } +} From e79e7c98bd3a1d71c1dd3e21f0752ef8fd213155 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Tue, 6 Oct 2026 22:02:28 +0200 Subject: [PATCH 20/36] fix: compare sequence types by their declared occurrence indicator Inferred refinements such as MANY made types unequal although they print the same, e.g. an inferred xs:integer+ and the declared xs:integer+, and checks against item* compare inferred types. is statically can now use equals again. --- .../rumbledb/compiler/InferTypeVisitor.java | 5 ++--- .../java/org/rumbledb/types/SequenceType.java | 18 ++++++++++++++++-- .../types/SequenceCardinalityTest.java | 3 +++ 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java index ce2748bb95..01e1bbd278 100644 --- a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java +++ b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java @@ -1068,9 +1068,8 @@ public StaticContext visitIsStaticallyExpr(IsStaticallyExpression expression, St SequenceType inferred = expression.getMainExpression().getStaticSequenceType(); SequenceType expected = expression.getSequenceType(); - // Assertions use declared occurrence indicators, which cannot express MANY or ZERO_OR_MANY. - if (inferred.getArity() != expected.getArity() - || !inferred.getItemType().equals(expected.getItemType())) { + // Equality compares declared occurrence indicators, which is all an assertion's type can express. + if (!inferred.equals(expected)) { throw new IsStaticallyUnexpectedTypeException( "expected static type is " + expected + " instead " + inferred + " was inferred", expression.getMetadata()); diff --git a/src/main/java/org/rumbledb/types/SequenceType.java b/src/main/java/org/rumbledb/types/SequenceType.java index c122974e53..421382bd7b 100644 --- a/src/main/java/org/rumbledb/types/SequenceType.java +++ b/src/main/java/org/rumbledb/types/SequenceType.java @@ -24,7 +24,6 @@ import java.util.Map; import java.util.Objects; -import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.extern.log4j.Log4j2; @@ -37,7 +36,6 @@ @Log4j2 @Getter -@EqualsAndHashCode public class SequenceType implements Serializable { @Serial @@ -270,6 +268,22 @@ public Arity multiplyWith(Arity other) { } } + /** + * Equal sequence types have the same item type and declared occurrence indicator, as their notation shows. + * Inferred refinements such as MANY are not part of equality; compare getCardinality() where they matter. + */ + @Override + public boolean equals(Object other) { + return other instanceof SequenceType that + && this.itemType.equals(that.itemType) + && getArity() == that.getArity(); + } + + @Override + public int hashCode() { + return 31 * this.itemType.hashCode() + getArity().hashCode(); + } + @Override public String toString() { if (isEmptySequence()) { diff --git a/src/test/java/org/rumbledb/types/SequenceCardinalityTest.java b/src/test/java/org/rumbledb/types/SequenceCardinalityTest.java index c972b2d127..8de62a14d2 100644 --- a/src/test/java/org/rumbledb/types/SequenceCardinalityTest.java +++ b/src/test/java/org/rumbledb/types/SequenceCardinalityTest.java @@ -68,6 +68,9 @@ void sequenceTypesKeepMultipleOnlyThroughJoiningAndGrouping() { SequenceType empty = SequenceType.createSequenceType("()"); SequenceType singleton = new SequenceType(BuiltinTypesCatalogue.integerItem); assertEquals(SequenceType.Arity.OneOrMore, multiple.getArity()); + // Equality follows the declared notation, so the refinement is invisible to it. + assertEquals(SequenceType.createSequenceType("integer+"), multiple); + assertEquals(SequenceType.createSequenceType("integer+").hashCode(), multiple.hashCode()); assertTrue(multiple.isSubtypeOf(SequenceType.createSequenceType("integer+"))); assertFalse(SequenceType.createSequenceType("integer+").isSubtypeOf(multiple)); assertFalse(multiple.hasOverlapWith(singleton)); From 27c5d7c5337d1daa01526d67631671e8d5944b56 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Sun, 4 Oct 2026 18:18:39 +0200 Subject: [PATCH 21/36] feat: refactor comparison type checks and enhance static sequence type handling in NodeComparisonExpression --- .../rumbledb/compiler/InferTypeVisitor.java | 94 ++++++++++++------- 1 file changed, 59 insertions(+), 35 deletions(-) diff --git a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java index d6100497c8..afafd66d5f 100644 --- a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java +++ b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java @@ -1672,33 +1672,7 @@ public StaticContext visitComparisonExpr(ComparisonExpression expression, Static expression.getMetadata()); } - // Type must match exactly or be both numeric or both promotable to string or both durations or one must be - // null - if (!leftItemType.equals(rightItemType) - && !(leftItemType.isNumeric() && rightItemType.isNumeric()) - && !(leftItemType.isSubtypeOf(BuiltinTypesCatalogue.durationItem) - && rightItemType.isSubtypeOf(BuiltinTypesCatalogue.durationItem)) - && !(leftItemType.canBePromotedTo(BuiltinTypesCatalogue.stringItem) - && rightItemType.canBePromotedTo(BuiltinTypesCatalogue.stringItem)) - && !(leftItemType.equals(BuiltinTypesCatalogue.nullItem) - || rightItemType.equals(BuiltinTypesCatalogue.nullItem))) { - throwStaticTypeException( - "It is not possible to compare these types: " + leftItemType + " and " + rightItemType, - expression.getMetadata()); - } - - // Inequality is not defined for hexBinary and base64binary or for duration of different types - if ((operator != ComparisonExpression.ComparisonOperator.VC_EQ - && operator != ComparisonExpression.ComparisonOperator.VC_NE - && operator != ComparisonExpression.ComparisonOperator.GC_EQ - && operator != ComparisonExpression.ComparisonOperator.GC_NE) - && (leftItemType.equals(BuiltinTypesCatalogue.hexBinaryItem) - || leftItemType.equals(BuiltinTypesCatalogue.base64BinaryItem) - || leftItemType.equals(BuiltinTypesCatalogue.durationItem) - || rightItemType.equals(BuiltinTypesCatalogue.durationItem) - || ((leftItemType.equals(BuiltinTypesCatalogue.dayTimeDurationItem) - || leftItemType.equals(BuiltinTypesCatalogue.yearMonthDurationItem)) - && !rightItemType.equals(leftItemType)))) { + if (!areComparisonTypesCompatible(leftItemType, rightItemType, operator)) { throwStaticTypeException( "It is not possible to compare these types: " + leftItemType @@ -1714,17 +1688,67 @@ public StaticContext visitComparisonExpr(ComparisonExpression expression, Static return argument; } + private boolean areComparisonTypesCompatible( + ItemType left, ItemType right, ComparisonExpression.ComparisonOperator operator) { + // JSONiq null is comparable with every atomic value, including for ordering. + if (left.equals(BuiltinTypesCatalogue.nullItem) || right.equals(BuiltinTypesCatalogue.nullItem)) { + return true; + } + // Keep the existing whole-type rules, including comparisons of the same inferred union. + boolean compatible = left.equals(right) + || (left.isNumeric() && right.isNumeric()) + || (left.isSubtypeOf(BuiltinTypesCatalogue.durationItem) + && right.isSubtypeOf(BuiltinTypesCatalogue.durationItem)) + || (left.canBePromotedTo(BuiltinTypesCatalogue.stringItem) + && right.canBePromotedTo(BuiltinTypesCatalogue.stringItem)); + // Null is an atomic value, not an empty sequence. A nullable union is comparable when + // its non-null alternatives are compatible too; one valid alternative cannot hide another. + if (!compatible && left.isUnionType()) { + return left.getTypes().stream().allMatch(member -> areComparisonTypesCompatible(member, right, operator)); + } + if (!compatible && right.isUnionType()) { + return right.getTypes().stream().allMatch(member -> areComparisonTypesCompatible(left, member, operator)); + } + if (!compatible) { + return false; + } + boolean ordered = operator != ComparisonExpression.ComparisonOperator.VC_EQ + && operator != ComparisonExpression.ComparisonOperator.VC_NE + && operator != ComparisonExpression.ComparisonOperator.GC_EQ + && operator != ComparisonExpression.ComparisonOperator.GC_NE; + // Apply ordering restrictions to the members as well when a nullable union is expanded. + return !ordered + || !(left.equals(BuiltinTypesCatalogue.hexBinaryItem) + || left.equals(BuiltinTypesCatalogue.base64BinaryItem) + || left.equals(BuiltinTypesCatalogue.durationItem) + || right.equals(BuiltinTypesCatalogue.durationItem) + || ((left.equals(BuiltinTypesCatalogue.dayTimeDurationItem) + || left.equals(BuiltinTypesCatalogue.yearMonthDurationItem)) + && !right.equals(left))); + } + @Override public StaticContext visitNodeComparisonExpr(NodeComparisonExpression expression, StaticContext argument) { visitDescendants(expression, argument); - // According to XQuery 3.1 specification section 3.7.3: - // Each operand must be either a single node or an empty sequence; otherwise a type error is raised - // [err:XPTY0004]. - // TODO: implement static type checking, to exclude evaluation of operands that we can - // infer statically that are not empty sequences or nodes (e.g. strings, etc.) - - // Node comparisons always return a boolean - expression.setStaticSequenceType(new SequenceType(BuiltinTypesCatalogue.booleanItem, SequenceType.Arity.One)); + // TODO: statically check that each operand is a single node or empty; runtime already checks this. + + SequenceType leftType = + requireInferredType(expression.getLeftExpression().getStaticSequenceType(), "NodeComparisonExpression"); + SequenceType rightType = requireInferredType( + expression.getRightExpression().getStaticSequenceType(), "NodeComparisonExpression"); + + // XQuery 3.1 section 3.7.3: an empty operand produces an empty result. Otherwise, + // a successful comparison produces one boolean, even when operand checks are deferred to runtime. + SequenceCardinality cardinality; + if (leftType.isEmptySequence() || rightType.isEmptySequence()) { + cardinality = SequenceCardinality.EMPTY; + } else if (leftType.getCardinality().allowsZero() + || rightType.getCardinality().allowsZero()) { + cardinality = SequenceCardinality.ZERO_OR_ONE; + } else { + cardinality = SequenceCardinality.ONE; + } + expression.setStaticSequenceType(new SequenceType(BuiltinTypesCatalogue.booleanItem, cardinality)); return argument; } From 2c0f9c33e023504c7d9742a6efcf0cac6f3cf29a Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Sun, 4 Oct 2026 20:04:07 +0200 Subject: [PATCH 22/36] feat: refactor casting checks and enhance node comparison operand validation in InferTypeVisitor --- .../rumbledb/compiler/InferTypeVisitor.java | 89 +++++++++++-------- src/test/java/iq/TypeInferenceTests.java | 51 +++++++++++ .../java/iq/base/AnnotationsTestsBase.java | 6 +- .../static-typing/comparison/comp11.jq | 5 +- .../static-typing/comparison/comp12.jq | 5 +- .../comparisons/NodeEmptyComparisons.xq | 13 +++ .../strict/casts/NodeAtomization.xq | 6 ++ .../casts/NodeCastInvalidLexicalValue.xq | 7 ++ .../strict/casts/NodeCastMultipleValues.xq | 7 ++ .../strict/casts/NodeUnionCast.xq | 3 + .../strict/casts/OptionalNodeCast.xq | 3 + .../comparisons/IndependentDurationUnions.xq | 5 ++ .../strict/comparisons/NodeEmptyBothAfter.xq | 3 + .../strict/comparisons/NodeEmptyBothBefore.xq | 3 + .../comparisons/NodeEmptyBothIdentity.xq | 3 + .../strict/comparisons/NodeEmptyLeftAfter.xq | 3 + .../strict/comparisons/NodeEmptyLeftBefore.xq | 3 + .../comparisons/NodeEmptyLeftIdentity.xq | 3 + .../strict/comparisons/NodeEmptyRightAfter.xq | 3 + .../comparisons/NodeEmptyRightBefore.xq | 3 + .../comparisons/NodeEmptyRightIdentity.xq | 3 + .../comparisons/NodeMultipleLeftOperand.xq | 2 + .../comparisons/NodeMultipleRightOperand.xq | 2 + .../strict/comparisons/NodeNonNodeOperand.xq | 2 + .../comparisons/NodeOptionalComparisons.xq | 12 +++ .../comparisons/NodeSingletonComparisons.xq | 7 ++ .../comparisons/NullableGeneralComparisons.jq | 25 ++++++ .../NullableUnionIncompatibleMember.jq | 4 + .../NullableUnionIncompatibleOperand.jq | 3 + .../comparisons/NullableUnionsOnBothSides.jq | 10 +++ .../comparisons/NullableValueComparisons.jq | 25 ++++++ .../comparisons/SameSingletonBinaryUnion.xq | 3 + .../comparisons/SameSingletonDurationUnion.xq | 3 + .../strict/comparisons/SameSingletonUnion.xq | 4 + .../UnorderedDurationAlternative.xq | 4 + 35 files changed, 292 insertions(+), 41 deletions(-) create mode 100644 src/test/java/iq/TypeInferenceTests.java create mode 100644 src/test/resources/test_files/type-inference/runtime/comparisons/NodeEmptyComparisons.xq create mode 100644 src/test/resources/test_files/type-inference/strict/casts/NodeAtomization.xq create mode 100644 src/test/resources/test_files/type-inference/strict/casts/NodeCastInvalidLexicalValue.xq create mode 100644 src/test/resources/test_files/type-inference/strict/casts/NodeCastMultipleValues.xq create mode 100644 src/test/resources/test_files/type-inference/strict/casts/NodeUnionCast.xq create mode 100644 src/test/resources/test_files/type-inference/strict/casts/OptionalNodeCast.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/IndependentDurationUnions.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothAfter.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothBefore.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothIdentity.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftAfter.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftBefore.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftIdentity.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightAfter.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightBefore.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightIdentity.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeMultipleLeftOperand.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeMultipleRightOperand.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeNonNodeOperand.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeOptionalComparisons.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeSingletonComparisons.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NullableGeneralComparisons.jq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NullableUnionIncompatibleMember.jq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NullableUnionIncompatibleOperand.jq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NullableUnionsOnBothSides.jq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NullableValueComparisons.jq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonBinaryUnion.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonDurationUnion.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonUnion.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/UnorderedDurationAlternative.xq diff --git a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java index afafd66d5f..97615b8aea 100644 --- a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java +++ b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java @@ -940,7 +940,7 @@ public StaticContext visitCastableExpression(CastableExpression expression, Stat visitDescendants(expression, argument); XmlSchemaCatalog schemaCatalog = argument.getInScopeSchemaTypes().getXmlSchemaCatalog(); if (isSchemaCastTarget(expression.getSequenceType(), schemaCatalog)) { - checkSchemaCastOperand(expression.getMainExpression().getStaticSequenceType(), expression); + checkCastOperand(expression.getMainExpression().getStaticSequenceType(), expression); expression.setStaticSequenceType(new SequenceType(BuiltinTypesCatalogue.booleanItem)); return argument; } @@ -974,7 +974,7 @@ public StaticContext visitCastExpression(CastExpression expression, StaticContex XmlSchemaCatalog schemaCatalog = argument.getInScopeSchemaTypes().getXmlSchemaCatalog(); if (isSchemaCastTarget(expression.getSequenceType(), schemaCatalog)) { SequenceType expressionType = expression.getMainExpression().getStaticSequenceType(); - checkSchemaCastOperand(expressionType, expression); + checkCastOperand(expressionType, expression); if (expressionType.isEmptySequence()) { if (expression.getSequenceType().getArity() != SequenceType.Arity.OneOrZero) { @@ -1038,7 +1038,10 @@ public StaticContext visitCastExpression(CastExpression expression, StaticContex } } - if (!expressionSequenceType.isAritySubtypeOf(castedSequenceType.getArity())) { + boolean nodeOperand = expressionSequenceType.getItemType().isSubtypeOf(BuiltinTypesCatalogue.nodeItem); + // Cast cardinality applies after atomization. A source node can have zero, one, + // or multiple typed values, so its typed-value checks remain at runtime. + if (!nodeOperand && !expressionSequenceType.isAritySubtypeOf(castedSequenceType.getArity())) { throwStaticTypeException( "with static type feature it is not possible to cast a " + expressionSequenceType @@ -1047,15 +1050,7 @@ public StaticContext visitCastExpression(CastExpression expression, StaticContex expression.getMetadata()); } - // ItemType static castability check - if (!expressionSequenceType.getItemType().isSubtypeOf(BuiltinTypesCatalogue.atomicItem)) { - throwStaticTypeException( - "It is never possible to cast a non-atomic sequence type: " + expressionSequenceType, - expressionSequenceType.getItemType().isSubtypeOf(BuiltinTypesCatalogue.JSONItem) - ? ErrorCode.NonAtomicElementErrorCode - : ErrorCode.AtomizationError, - expression.getMetadata()); - } + checkCastOperand(expressionSequenceType, expression); // Non-atomic operands were reported above; with static typing disabled, leave them to runtime. if (expressionSequenceType.getItemType().isSubtypeOf(BuiltinTypesCatalogue.atomicItem) && !castedSequenceType.getItemType().equals(BuiltinTypesCatalogue.errorItem) @@ -1065,7 +1060,7 @@ public StaticContext visitCastExpression(CastExpression expression, StaticContex ErrorCode.UnexpectedTypeErrorCode, expression.getMetadata()); } - if (expressionSequenceType.getArity() == SequenceType.Arity.One) { + if (!nodeOperand && expressionSequenceType.getArity() == SequenceType.Arity.One) { castedSequenceType = new SequenceType(castedSequenceType.getItemType(), SequenceType.Arity.One); } expression.setStaticSequenceType(castedSequenceType); @@ -1083,13 +1078,13 @@ private boolean isSchemaCastTarget(SequenceType sequenceType, XmlSchemaCatalog s * The static node types used here do not say whether atomization succeeds or how many * atomic values it produces; the runtime checks those properties after atomization. */ - private void checkSchemaCastOperand(SequenceType operandType, Expression expression) { + private void checkCastOperand(SequenceType operandType, Expression expression) { basicChecks(operandType, expression.getClass().getSimpleName(), true, false, expression.getMetadata()); if (!operandType.isEmptySequence() && !operandType.getItemType().isSubtypeOf(BuiltinTypesCatalogue.atomicItem) - && !operandType.getItemType().isNodeItemType()) { + && !operandType.getItemType().isSubtypeOf(BuiltinTypesCatalogue.nodeItem)) { throwStaticTypeException( - "An XML Schema cast operand must be atomic after atomization, found " + operandType, + "A cast operand must be atomic after atomization, found " + operandType, operandType.getItemType().isSubtypeOf(BuiltinTypesCatalogue.JSONItem) ? ErrorCode.NonAtomicElementErrorCode : ErrorCode.AtomizationError, @@ -1672,7 +1667,11 @@ public StaticContext visitComparisonExpr(ComparisonExpression expression, Static expression.getMetadata()); } - if (!areComparisonTypesCompatible(leftItemType, rightItemType, operator)) { + boolean sameValue = leftInferredType.getCardinality() == SequenceCardinality.ONE + && childrenExpressions.get(0) instanceof VariableReferenceExpression leftVariable + && childrenExpressions.get(1) instanceof VariableReferenceExpression rightVariable + && leftVariable.getVariableName().equals(rightVariable.getVariableName()); + if (!areComparisonTypesCompatible(leftItemType, rightItemType, operator, sameValue)) { throwStaticTypeException( "It is not possible to compare these types: " + leftItemType @@ -1689,26 +1688,31 @@ public StaticContext visitComparisonExpr(ComparisonExpression expression, Static } private boolean areComparisonTypesCompatible( - ItemType left, ItemType right, ComparisonExpression.ComparisonOperator operator) { + ItemType left, ItemType right, ComparisonExpression.ComparisonOperator operator, boolean sameValue) { + // Equal union types do not imply equal runtime values. Only references to the same + // singleton variable let us check matching alternatives instead of every possible pair. + if (left.isUnionType()) { + if (sameValue && left.equals(right)) { + return left.getTypes().stream() + .allMatch(member -> areComparisonTypesCompatible(member, member, operator, true)); + } + return left.getTypes().stream() + .allMatch(member -> areComparisonTypesCompatible(member, right, operator, false)); + } + if (right.isUnionType()) { + return right.getTypes().stream() + .allMatch(member -> areComparisonTypesCompatible(left, member, operator, false)); + } // JSONiq null is comparable with every atomic value, including for ordering. if (left.equals(BuiltinTypesCatalogue.nullItem) || right.equals(BuiltinTypesCatalogue.nullItem)) { return true; } - // Keep the existing whole-type rules, including comparisons of the same inferred union. boolean compatible = left.equals(right) || (left.isNumeric() && right.isNumeric()) || (left.isSubtypeOf(BuiltinTypesCatalogue.durationItem) && right.isSubtypeOf(BuiltinTypesCatalogue.durationItem)) || (left.canBePromotedTo(BuiltinTypesCatalogue.stringItem) && right.canBePromotedTo(BuiltinTypesCatalogue.stringItem)); - // Null is an atomic value, not an empty sequence. A nullable union is comparable when - // its non-null alternatives are compatible too; one valid alternative cannot hide another. - if (!compatible && left.isUnionType()) { - return left.getTypes().stream().allMatch(member -> areComparisonTypesCompatible(member, right, operator)); - } - if (!compatible && right.isUnionType()) { - return right.getTypes().stream().allMatch(member -> areComparisonTypesCompatible(left, member, operator)); - } if (!compatible) { return false; } @@ -1716,31 +1720,44 @@ private boolean areComparisonTypesCompatible( && operator != ComparisonExpression.ComparisonOperator.VC_NE && operator != ComparisonExpression.ComparisonOperator.GC_EQ && operator != ComparisonExpression.ComparisonOperator.GC_NE; - // Apply ordering restrictions to the members as well when a nullable union is expanded. + // XQuery 3.1 supports binary ordering. Durations are ordered only within + // the day-time subtype or within the year-month subtype. return !ordered - || !(left.equals(BuiltinTypesCatalogue.hexBinaryItem) - || left.equals(BuiltinTypesCatalogue.base64BinaryItem) - || left.equals(BuiltinTypesCatalogue.durationItem) - || right.equals(BuiltinTypesCatalogue.durationItem) - || ((left.equals(BuiltinTypesCatalogue.dayTimeDurationItem) - || left.equals(BuiltinTypesCatalogue.yearMonthDurationItem)) - && !right.equals(left))); + || !(left.isSubtypeOf(BuiltinTypesCatalogue.durationItem) + || right.isSubtypeOf(BuiltinTypesCatalogue.durationItem)) + || (left.isSubtypeOf(BuiltinTypesCatalogue.dayTimeDurationItem) + && right.isSubtypeOf(BuiltinTypesCatalogue.dayTimeDurationItem)) + || (left.isSubtypeOf(BuiltinTypesCatalogue.yearMonthDurationItem) + && right.isSubtypeOf(BuiltinTypesCatalogue.yearMonthDurationItem)); } @Override public StaticContext visitNodeComparisonExpr(NodeComparisonExpression expression, StaticContext argument) { visitDescendants(expression, argument); - // TODO: statically check that each operand is a single node or empty; runtime already checks this. SequenceType leftType = requireInferredType(expression.getLeftExpression().getStaticSequenceType(), "NodeComparisonExpression"); SequenceType rightType = requireInferredType( expression.getRightExpression().getStaticSequenceType(), "NodeComparisonExpression"); + SequenceType operandType = new SequenceType(BuiltinTypesCatalogue.nodeItem, SequenceCardinality.ZERO_OR_ONE); + for (SequenceType type : List.of(leftType, rightType)) { + if (!type.isSubtypeOf(operandType)) { + throwStaticTypeException( + "A node comparison operand must be a single node or empty, found " + type, + expression.getMetadata()); + } + } + // XQuery 3.1 section 3.7.3: an empty operand produces an empty result. Otherwise, // a successful comparison produces one boolean, even when operand checks are deferred to runtime. SequenceCardinality cardinality; if (leftType.isEmptySequence() || rightType.isEmptySequence()) { + // The strict Static Typing Feature rejects known-empty expressions other than () and data(()). + throwStaticTypeException( + "The node comparison is statically inferred to return an empty sequence", + ErrorCode.StaticallyInferredEmptySequenceNotFromCommaExpression, + expression.getMetadata()); cardinality = SequenceCardinality.EMPTY; } else if (leftType.getCardinality().allowsZero() || rightType.getCardinality().allowsZero()) { diff --git a/src/test/java/iq/TypeInferenceTests.java b/src/test/java/iq/TypeInferenceTests.java new file mode 100644 index 0000000000..92f3481e91 --- /dev/null +++ b/src/test/java/iq/TypeInferenceTests.java @@ -0,0 +1,51 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Contributor acknowledgements are maintained in the CONTRIBUTORS file at the project root. + */ +package iq; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; + +import iq.base.SparkAnnotationsTestsBase; +import iq.base.TestFileDiscovery; + +import org.rumbledb.config.RumbleConfiguration; + +/** Annotation regressions that check both inferred types and materialized results. */ +public class TypeInferenceTests extends SparkAnnotationsTestsBase { + private static final Path ROOT = + Path.of(System.getProperty("user.dir"), "src/test/resources/test_files/type-inference"); + + @Override + protected File testDirectory() { + return ROOT.toFile(); + } + + @Override + protected List testFiles() throws IOException { + return TestFileDiscovery.files(testDirectory(), ".jq", ".xq"); + } + + @Override + protected RumbleConfiguration getConfiguration(File testFile) { + boolean strictTyping = testFile.toPath().startsWith(ROOT.resolve("strict")); + return RumbleConfiguration.builder() + .configureAnalysis(analysis -> analysis.enableStaticTyping(strictTyping)) + .configureRuntime(runtime -> runtime.resultsSizeCap(200)) + .build(); + } +} diff --git a/src/test/java/iq/base/AnnotationsTestsBase.java b/src/test/java/iq/base/AnnotationsTestsBase.java index 76bc64f4dc..168560cd5b 100644 --- a/src/test/java/iq/base/AnnotationsTestsBase.java +++ b/src/test/java/iq/base/AnnotationsTestsBase.java @@ -35,6 +35,10 @@ public RumbleConfiguration getConfiguration() { return TestConfigurations.defaultConfiguration(); } + protected RumbleConfiguration getConfiguration(File testFile) { + return getConfiguration(); + } + public ExternalBindings getExternalBindings() { return TestConfigurations.defaultExternalBindings(); } @@ -54,6 +58,6 @@ protected boolean checkOutput() { @MethodSource("testFiles") @Timeout(1000) final void testAnnotation(File testFile) throws IOException { - AnnotationTestExecutor.run(testFile, getConfiguration(), getExternalBindings(), checkOutput()); + AnnotationTestExecutor.run(testFile, getConfiguration(testFile), getExternalBindings(), checkOutput()); } } diff --git a/src/test/resources/test_files/static-typing/comparison/comp11.jq b/src/test/resources/test_files/static-typing/comparison/comp11.jq index c21afca4da..57998ae4ca 100644 --- a/src/test/resources/test_files/static-typing/comparison/comp11.jq +++ b/src/test/resources/test_files/static-typing/comparison/comp11.jq @@ -1,3 +1,4 @@ -(:JIQS: ShouldCrash; ErrorCode="XPTY0004" :) +(:JIQS: ShouldRun; Output="false" :) +(: Binary values support ordering in Functions and Operators 3.1. :) declare variable $hexb := "0cd7" cast as hexBinary; -$hexb lt $hexb \ No newline at end of file +$hexb lt $hexb diff --git a/src/test/resources/test_files/static-typing/comparison/comp12.jq b/src/test/resources/test_files/static-typing/comparison/comp12.jq index 22496cd589..6041a105fd 100644 --- a/src/test/resources/test_files/static-typing/comparison/comp12.jq +++ b/src/test/resources/test_files/static-typing/comparison/comp12.jq @@ -1,3 +1,4 @@ -(:JIQS: ShouldCrash; ErrorCode="XPTY0004" :) +(:JIQS: ShouldRun; Output="false" :) +(: Binary values support ordering in Functions and Operators 3.1. :) declare variable $base64b := "DNc=" cast as base64Binary; -$base64b lt $base64b \ No newline at end of file +$base64b lt $base64b diff --git a/src/test/resources/test_files/type-inference/runtime/comparisons/NodeEmptyComparisons.xq b/src/test/resources/test_files/type-inference/runtime/comparisons/NodeEmptyComparisons.xq new file mode 100644 index 0000000000..1a1c328680 --- /dev/null +++ b/src/test/resources/test_files/type-inference/runtime/comparisons/NodeEmptyComparisons.xq @@ -0,0 +1,13 @@ +(:JIQS: ShouldRun; Output="" :) +(: With strict static typing disabled, every empty operand combination remains empty. :) +( + (() is ) is statically empty-sequence(), + ( is ()) is statically empty-sequence(), + (() is ()) is statically empty-sequence(), + (() << ) is statically empty-sequence(), + ( << ()) is statically empty-sequence(), + (() << ()) is statically empty-sequence(), + (() >> ) is statically empty-sequence(), + ( >> ()) is statically empty-sequence(), + (() >> ()) is statically empty-sequence() +) diff --git a/src/test/resources/test_files/type-inference/strict/casts/NodeAtomization.xq b/src/test/resources/test_files/type-inference/strict/casts/NodeAtomization.xq new file mode 100644 index 0000000000..2cfbfa3e97 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/casts/NodeAtomization.xq @@ -0,0 +1,6 @@ +(:JIQS: ShouldRun; Output="(2, 2, 2)" :) +( + (text {"2"} cast as xs:integer) is statically xs:integer, + (2 cast as xs:integer) is statically xs:integer, + (attribute a {"2"} cast as xs:integer) is statically xs:integer +) diff --git a/src/test/resources/test_files/type-inference/strict/casts/NodeCastInvalidLexicalValue.xq b/src/test/resources/test_files/type-inference/strict/casts/NodeCastInvalidLexicalValue.xq new file mode 100644 index 0000000000..9af8bbcd9d --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/casts/NodeCastInvalidLexicalValue.xq @@ -0,0 +1,7 @@ +(:JIQS: ShouldRun; Output="true" :) +(: Catching the error requires successful compilation and a failed runtime conversion. :) +try { + text {"abc"} cast as xs:integer +} catch err:FORG0001 { + true() +} diff --git a/src/test/resources/test_files/type-inference/strict/casts/NodeCastMultipleValues.xq b/src/test/resources/test_files/type-inference/strict/casts/NodeCastMultipleValues.xq new file mode 100644 index 0000000000..8da068c8b1 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/casts/NodeCastMultipleValues.xq @@ -0,0 +1,7 @@ +(:JIQS: ShouldRun; Output="true" :) +(: The cast's cardinality check is performed during evaluation. :) +try { + (1, 2) cast as xs:integer +} catch err:XPTY0004 { + true() +} diff --git a/src/test/resources/test_files/type-inference/strict/casts/NodeUnionCast.xq b/src/test/resources/test_files/type-inference/strict/casts/NodeUnionCast.xq new file mode 100644 index 0000000000..a2e794f914 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/casts/NodeUnionCast.xq @@ -0,0 +1,3 @@ +(:JIQS: ShouldRun; Output="(1, 2)" :) +for $x in (1, text {"2"}) +return ($x cast as xs:integer) is statically xs:integer diff --git a/src/test/resources/test_files/type-inference/strict/casts/OptionalNodeCast.xq b/src/test/resources/test_files/type-inference/strict/casts/OptionalNodeCast.xq new file mode 100644 index 0000000000..5c0e775a07 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/casts/OptionalNodeCast.xq @@ -0,0 +1,3 @@ +(:JIQS: ShouldRun; Output="2" :) +(: Node atomization may be empty, so the optional target stays optional in inference. :) +(text {"2"} cast as xs:integer?) is statically xs:integer? diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/IndependentDurationUnions.xq b/src/test/resources/test_files/type-inference/strict/comparisons/IndependentDurationUnions.xq new file mode 100644 index 0000000000..7c791451f9 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/IndependentDurationUnions.xq @@ -0,0 +1,5 @@ +(:JIQS: ShouldCrash; ErrorCode="XPTY0004" :) +(: Equal union types do not make independently bound values comparable. :) +for $x in (xs:dayTimeDuration("P1D"), xs:yearMonthDuration("P1M")) +for $y in (xs:dayTimeDuration("P1D"), xs:yearMonthDuration("P1M")) +return $x lt $y diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothAfter.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothAfter.xq new file mode 100644 index 0000000000..b64179879c --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothAfter.xq @@ -0,0 +1,3 @@ +(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) +(: Strict static typing rejects a known-empty comparison. :) +() >> () diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothBefore.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothBefore.xq new file mode 100644 index 0000000000..a35f259f27 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothBefore.xq @@ -0,0 +1,3 @@ +(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) +(: Strict static typing rejects a known-empty comparison. :) +() << () diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothIdentity.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothIdentity.xq new file mode 100644 index 0000000000..b035edce2e --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothIdentity.xq @@ -0,0 +1,3 @@ +(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) +(: Strict static typing rejects a known-empty comparison. :) +() is () diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftAfter.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftAfter.xq new file mode 100644 index 0000000000..16f459b8a2 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftAfter.xq @@ -0,0 +1,3 @@ +(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) +(: Strict static typing rejects a known-empty comparison. :) +() >> diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftBefore.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftBefore.xq new file mode 100644 index 0000000000..7b116aa796 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftBefore.xq @@ -0,0 +1,3 @@ +(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) +(: Strict static typing rejects a known-empty comparison. :) +() << diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftIdentity.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftIdentity.xq new file mode 100644 index 0000000000..dc662d31bc --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftIdentity.xq @@ -0,0 +1,3 @@ +(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) +(: Strict static typing rejects a known-empty comparison. :) +() is diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightAfter.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightAfter.xq new file mode 100644 index 0000000000..705e5d8718 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightAfter.xq @@ -0,0 +1,3 @@ +(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) +(: Strict static typing rejects a known-empty comparison. :) + >> () diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightBefore.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightBefore.xq new file mode 100644 index 0000000000..71e6940e0f --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightBefore.xq @@ -0,0 +1,3 @@ +(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) +(: Strict static typing rejects a known-empty comparison. :) + << () diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightIdentity.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightIdentity.xq new file mode 100644 index 0000000000..f6cf24db54 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightIdentity.xq @@ -0,0 +1,3 @@ +(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) +(: Strict static typing rejects a known-empty comparison. :) + is () diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeMultipleLeftOperand.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeMultipleLeftOperand.xq new file mode 100644 index 0000000000..b372120b24 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NodeMultipleLeftOperand.xq @@ -0,0 +1,2 @@ +(:JIQS: ShouldCrash; ErrorCode="XPTY0004" :) +(, ) is diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeMultipleRightOperand.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeMultipleRightOperand.xq new file mode 100644 index 0000000000..ac1b0698bd --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NodeMultipleRightOperand.xq @@ -0,0 +1,2 @@ +(:JIQS: ShouldCrash; ErrorCode="XPTY0004" :) + is (, ) diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeNonNodeOperand.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeNonNodeOperand.xq new file mode 100644 index 0000000000..a082f95144 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NodeNonNodeOperand.xq @@ -0,0 +1,2 @@ +(:JIQS: ShouldCrash; ErrorCode="XPTY0004" :) +1 is 2 diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeOptionalComparisons.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeOptionalComparisons.xq new file mode 100644 index 0000000000..57874cf1dd --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NodeOptionalComparisons.xq @@ -0,0 +1,12 @@ +(:JIQS: ShouldRun; Output="(1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0)" :) +for $present in (true(), false()) +let $a := +let $b := if ($present) then $a else () +return ( + count(($a is $b) is statically xs:boolean?), + count(($b is $a) is statically xs:boolean?), + count(($a << $b) is statically xs:boolean?), + count(($b << $a) is statically xs:boolean?), + count(($a >> $b) is statically xs:boolean?), + count(($b >> $a) is statically xs:boolean?) +) diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeSingletonComparisons.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeSingletonComparisons.xq new file mode 100644 index 0000000000..14fecab819 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NodeSingletonComparisons.xq @@ -0,0 +1,7 @@ +(:JIQS: ShouldRun; Output="(true, false, false)" :) +let $a := +return ( + ($a is $a) is statically xs:boolean, + ($a << $a) is statically xs:boolean, + ($a >> $a) is statically xs:boolean +) diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NullableGeneralComparisons.jq b/src/test/resources/test_files/type-inference/strict/comparisons/NullableGeneralComparisons.jq new file mode 100644 index 0000000000..5e11514389 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NullableGeneralComparisons.jq @@ -0,0 +1,25 @@ +(:JIQS: ShouldRun; Output="(24, true, true, false)" :) +(: Each union alternative yields one boolean, on either side of every general operator. :) +let $results := ( + for $x in (1, null) + return ( + ($x = 1) is statically xs:boolean, + (1 = $x) is statically xs:boolean, + ($x != 1) is statically xs:boolean, + (1 != $x) is statically xs:boolean, + ($x < 1) is statically xs:boolean, + (1 < $x) is statically xs:boolean, + ($x <= 1) is statically xs:boolean, + (1 <= $x) is statically xs:boolean, + ($x > 1) is statically xs:boolean, + (1 > $x) is statically xs:boolean, + ($x >= 1) is statically xs:boolean, + (1 >= $x) is statically xs:boolean + ) +) is statically xs:boolean+ +return ( + count($results), + every $result in $results satisfies $result instance of xs:boolean, + $results[1], + $results[13] +) diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NullableUnionIncompatibleMember.jq b/src/test/resources/test_files/type-inference/strict/comparisons/NullableUnionIncompatibleMember.jq new file mode 100644 index 0000000000..4e9bea7866 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NullableUnionIncompatibleMember.jq @@ -0,0 +1,4 @@ +(:JIQS: ShouldCrash; ErrorCode="XPTY0004" :) +(: Null cannot hide the string alternative, which cannot be compared with an integer. :) +for $x in (1, null, "a") +return $x eq 1 diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NullableUnionIncompatibleOperand.jq b/src/test/resources/test_files/type-inference/strict/comparisons/NullableUnionIncompatibleOperand.jq new file mode 100644 index 0000000000..34be1a5f4a --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NullableUnionIncompatibleOperand.jq @@ -0,0 +1,3 @@ +(:JIQS: ShouldCrash; ErrorCode="XPTY0004" :) +for $x in (1, null) +return $x eq "a" diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NullableUnionsOnBothSides.jq b/src/test/resources/test_files/type-inference/strict/comparisons/NullableUnionsOnBothSides.jq new file mode 100644 index 0000000000..e1277327b9 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NullableUnionsOnBothSides.jq @@ -0,0 +1,10 @@ +(:JIQS: ShouldRun; Output="(8, true)" :) +let $results := ( + for $x in (1, null) + for $y in (xs:double(1), null) + return ( + ($x eq $y) is statically xs:boolean, + ($x lt $y) is statically xs:boolean + ) +) is statically xs:boolean+ +return (count($results), every $result in $results satisfies $result instance of xs:boolean) diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NullableValueComparisons.jq b/src/test/resources/test_files/type-inference/strict/comparisons/NullableValueComparisons.jq new file mode 100644 index 0000000000..7393211189 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NullableValueComparisons.jq @@ -0,0 +1,25 @@ +(:JIQS: ShouldRun; Output="(24, true, true, false)" :) +(: Each union alternative yields one boolean, on either side of every value operator. :) +let $results := ( + for $x in (1, null) + return ( + ($x eq 1) is statically xs:boolean, + (1 eq $x) is statically xs:boolean, + ($x ne 1) is statically xs:boolean, + (1 ne $x) is statically xs:boolean, + ($x lt 1) is statically xs:boolean, + (1 lt $x) is statically xs:boolean, + ($x le 1) is statically xs:boolean, + (1 le $x) is statically xs:boolean, + ($x gt 1) is statically xs:boolean, + (1 gt $x) is statically xs:boolean, + ($x ge 1) is statically xs:boolean, + (1 ge $x) is statically xs:boolean + ) +) is statically xs:boolean+ +return ( + count($results), + every $result in $results satisfies $result instance of xs:boolean, + $results[1], + $results[13] +) diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonBinaryUnion.xq b/src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonBinaryUnion.xq new file mode 100644 index 0000000000..599b5c7a27 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonBinaryUnion.xq @@ -0,0 +1,3 @@ +(:JIQS: ShouldRun; Output="(false, false)" :) +for $x in (xs:hexBinary("00"), xs:base64Binary("AA==")) +return ($x lt $x) is statically xs:boolean diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonDurationUnion.xq b/src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonDurationUnion.xq new file mode 100644 index 0000000000..240e20f695 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonDurationUnion.xq @@ -0,0 +1,3 @@ +(:JIQS: ShouldRun; Output="(false, false)" :) +for $x in (xs:dayTimeDuration("P1D"), xs:yearMonthDuration("P1M")) +return ($x lt $x) is statically xs:boolean diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonUnion.xq b/src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonUnion.xq new file mode 100644 index 0000000000..e3b91964af --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonUnion.xq @@ -0,0 +1,4 @@ +(:JIQS: ShouldRun; Output="(true, true)" :) +(: Both references denote the same singleton, so each type is compared only with itself. :) +for $x in (1, "a") +return ($x eq $x) is statically xs:boolean diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/UnorderedDurationAlternative.xq b/src/test/resources/test_files/type-inference/strict/comparisons/UnorderedDurationAlternative.xq new file mode 100644 index 0000000000..de0cf8a286 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/UnorderedDurationAlternative.xq @@ -0,0 +1,4 @@ +(:JIQS: ShouldCrash; ErrorCode="XPTY0004" :) +(: General xs:duration is not ordered, even when both operands are the same variable. :) +for $x in (xs:duration("P1D"), 1) +return $x lt $x From 6b6138992c1b63045c88f093672ab095f0db8fc3 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Sun, 4 Oct 2026 20:14:40 +0200 Subject: [PATCH 23/36] fix: make sure that RDD iterators return correct type for max, min and sum --- ...valuation.java => ExtremumEvaluation.java} | 153 ++++++++++++++---- .../aggregate/MaxFunctionIterator.java | 16 +- .../aggregate/MinFunctionIterator.java | 16 +- .../sequences/aggregate/SumClosure.java | 14 +- .../aggregate/SumFunctionIterator.java | 5 +- .../aggregates/AnyURIStringPromotionRDD.xq | 6 + .../aggregates/DerivedStringPromotionRDD.xq | 6 + .../strict/aggregates/AnyURITypeRDD.xq | 5 + .../strict/aggregates/DecimalMaxRDD.xq | 5 + .../strict/aggregates/DerivedStringType.xq | 7 + .../strict/aggregates/EmptyRDD.xq | 5 + .../strict/aggregates/FloatMaxRDD.xq | 5 + .../strict/aggregates/MixedDoubleMinRDD.xq | 5 + .../strict/aggregates/MixedFloatMaxRDD.xq | 5 + .../strict/aggregates/MixedFloatMinRDD.xq | 5 + .../strict/aggregates/NaNPromotionRDD.xq | 9 ++ .../strict/aggregates/UntypedSingletonRDD.xq | 10 ++ .../strict/aggregates/UntypedSumRDD.xq | 6 + 18 files changed, 209 insertions(+), 74 deletions(-) rename src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/{ExtremumLocalEvaluation.java => ExtremumEvaluation.java} (50%) create mode 100644 src/test/resources/test_files/type-inference/runtime/aggregates/AnyURIStringPromotionRDD.xq create mode 100644 src/test/resources/test_files/type-inference/runtime/aggregates/DerivedStringPromotionRDD.xq create mode 100644 src/test/resources/test_files/type-inference/strict/aggregates/AnyURITypeRDD.xq create mode 100644 src/test/resources/test_files/type-inference/strict/aggregates/DecimalMaxRDD.xq create mode 100644 src/test/resources/test_files/type-inference/strict/aggregates/DerivedStringType.xq create mode 100644 src/test/resources/test_files/type-inference/strict/aggregates/EmptyRDD.xq create mode 100644 src/test/resources/test_files/type-inference/strict/aggregates/FloatMaxRDD.xq create mode 100644 src/test/resources/test_files/type-inference/strict/aggregates/MixedDoubleMinRDD.xq create mode 100644 src/test/resources/test_files/type-inference/strict/aggregates/MixedFloatMaxRDD.xq create mode 100644 src/test/resources/test_files/type-inference/strict/aggregates/MixedFloatMinRDD.xq create mode 100644 src/test/resources/test_files/type-inference/strict/aggregates/NaNPromotionRDD.xq create mode 100644 src/test/resources/test_files/type-inference/strict/aggregates/UntypedSingletonRDD.xq create mode 100644 src/test/resources/test_files/type-inference/strict/aggregates/UntypedSumRDD.xq diff --git a/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/ExtremumLocalEvaluation.java b/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/ExtremumEvaluation.java similarity index 50% rename from src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/ExtremumLocalEvaluation.java rename to src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/ExtremumEvaluation.java index 151c8539f2..1c93d2c1c5 100644 --- a/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/ExtremumLocalEvaluation.java +++ b/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/ExtremumEvaluation.java @@ -15,6 +15,11 @@ */ package org.rumbledb.runtime.functions.sequences.aggregate; +import java.io.Serializable; + +import org.apache.spark.api.java.JavaRDD; +import org.apache.spark.storage.StorageLevel; + import lombok.NonNull; import org.rumbledb.api.Item; @@ -30,9 +35,9 @@ import org.rumbledb.types.ItemType; /** - * Direct local evaluation shared by the {@code min()} and {@code max()} aggregate plans. + * Local and RDD evaluation shared by the {@code min()} and {@code max()} aggregate plans. */ -final class ExtremumLocalEvaluation { +final class ExtremumEvaluation { private enum Kind { MIN, @@ -41,7 +46,34 @@ private enum Kind { private static final String CODEPOINT_COLLATION = "http://www.w3.org/2005/xpath-functions/collation/codepoint"; - private ExtremumLocalEvaluation() {} + private ExtremumEvaluation() {} + + public static Item minRDD(JavaRDD input, ExceptionMetadata metadata) { + return evaluateRDD(input, metadata, Kind.MIN); + } + + public static Item maxRDD(JavaRDD input, ExceptionMetadata metadata) { + return evaluateRDD(input, metadata, Kind.MAX); + } + + private static Item evaluateRDD(JavaRDD input, ExceptionMetadata metadata, Kind kind) { + // Both passes must see the same converted input, including for non-deterministic source plans. + JavaRDD converted = + input.map(item -> normalizeCandidate(item, kind, metadata)).persist(StorageLevel.MEMORY_AND_DISK()); + try { + Promotion promotion = converted.map(Promotion::of).fold(Promotion.EMPTY, Promotion::merge); + if (!promotion.hasItems()) { + return null; + } + // The result's type depends on every input, including values that are not the extremum. + // Convert to the common type before comparing, rather than returning an original RDD item. + JavaRDD promoted = converted.map(promotion::apply); + ItemComparator comparator = new ItemComparator(kind == Kind.MIN, inputError(kind, metadata)); + return kind == Kind.MIN ? promoted.min(comparator) : promoted.max(comparator); + } finally { + converted.unpersist(); + } + } public static Item min( ItemRuntimePlan childPlan, @@ -69,15 +101,8 @@ private static Item evaluate( Item selected = null; boolean sawNull = false; - boolean sawFloat = false; - boolean sawDouble = false; - boolean sawString = false; - ItemComparator comparator = new ItemComparator( - kind == Kind.MIN, - new InvalidArgumentTypeException( - functionName(kind) - + " expression input error. Input has to be non-null atomics of matching types", - metadata)); + Promotion promotion = Promotion.EMPTY; + ItemComparator comparator = new ItemComparator(kind == Kind.MIN, inputError(kind, metadata)); try (Cursor childCursor = childPlan.getCursor(context)) { while (childCursor.hasNext()) { @@ -89,13 +114,8 @@ private static Item evaluate( sawNull = true; continue; } - if (candidate.isUntypedAtomic()) { - candidate = ItemFactory.getInstance().createDoubleItem(candidate.castToDoubleValue()); - } - ensureSupported(candidate, kind, metadata); - sawDouble |= candidate.isDouble(); - sawFloat |= candidate.isFloat(); - sawString |= candidate.isString(); + candidate = normalizeCandidate(candidate, kind, metadata); + promotion = promotion.merge(Promotion.of(candidate)); if (selected == null) { selected = candidate; @@ -114,18 +134,91 @@ private static Item evaluate( if (selected == null) { return sawNull ? ItemFactory.getInstance().createNullItem() : null; } - if (selected.isNumeric()) { - if (sawDouble) { - return ItemFactory.getInstance().createDoubleItem(selected.castToDoubleValue()); + return promotion.apply(selected); + } + + private static Item normalizeCandidate(Item item, Kind kind, ExceptionMetadata metadata) { + if (item.isNull()) { + return item; + } + if (item.isUntypedAtomic()) { + item = ItemFactory.getInstance().createDoubleItem(item.castToDoubleValue()); + } + ensureSupported(item, kind, metadata); + return item; + } + + private static final class Promotion implements Serializable { + private static final long serialVersionUID = 1L; + private static final Promotion EMPTY = new Promotion(false, false, false, false, false, false); + private final boolean hasItems; + private final boolean sawDouble; + private final boolean sawFloat; + private final boolean sawDecimal; + private final boolean sawString; + private final boolean sawAnyURI; + + // Spark's Kryo serializer needs an ordinary class rather than a Java record. + private Promotion() { + this(false, false, false, false, false, false); + } + + private Promotion( + boolean hasItems, + boolean sawDouble, + boolean sawFloat, + boolean sawDecimal, + boolean sawString, + boolean sawAnyURI) { + this.hasItems = hasItems; + this.sawDouble = sawDouble; + this.sawFloat = sawFloat; + this.sawDecimal = sawDecimal; + this.sawString = sawString; + this.sawAnyURI = sawAnyURI; + } + + boolean hasItems() { + return this.hasItems; + } + + static Promotion of(Item item) { + return new Promotion( + true, item.isDouble(), item.isFloat(), item.isDecimal(), item.isString(), item.isAnyURI()); + } + + Promotion merge(Promotion other) { + return new Promotion( + this.hasItems || other.hasItems, + this.sawDouble || other.sawDouble, + this.sawFloat || other.sawFloat, + this.sawDecimal || other.sawDecimal, + this.sawString || other.sawString, + this.sawAnyURI || other.sawAnyURI); + } + + Item apply(Item item) { + // With one primitive type, retain the original item and its derived type. + // Only mixtures of primitive types require conversion to their common type. + if (item.isNumeric()) { + if (this.sawDouble && (this.sawFloat || this.sawDecimal)) { + return ItemFactory.getInstance().createDoubleItem(item.castToDoubleValue()); + } + if (this.sawFloat && this.sawDecimal) { + return ItemFactory.getInstance().createFloatItem(item.castToFloatValue()); + } } - if (sawFloat) { - return ItemFactory.getInstance().createFloatItem(selected.castToFloatValue()); + if (this.sawString && this.sawAnyURI && (item.isString() || item.isAnyURI())) { + return ItemFactory.getInstance().createStringItem(item.getStringValue()); } + return item; } - if (sawString && (selected.isString() || selected.isAnyURI())) { - return ItemFactory.getInstance().createStringItem(selected.getStringValue()); - } - return selected; + } + + private static InvalidArgumentTypeException inputError(Kind kind, ExceptionMetadata metadata) { + return new InvalidArgumentTypeException( + functionName(kind) + " expression input error. Input has to be non-null atomics of matching types", + metadata); } private static boolean shouldSelectCandidate(int comparison, Kind kind) { @@ -162,9 +255,7 @@ private static void ensureSupported(Item item, Kind kind, ExceptionMetadata meta || type.equals(BuiltinTypesCatalogue.base64BinaryItem)) { return; } - throw new InvalidArgumentTypeException( - functionName(kind) + " expression input error. Input has to be non-null atomics of matching types", - metadata); + throw inputError(kind, metadata); } private static String functionName(Kind kind) { diff --git a/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/MaxFunctionIterator.java b/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/MaxFunctionIterator.java index 0ffed0c413..30aa0cf73d 100644 --- a/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/MaxFunctionIterator.java +++ b/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/MaxFunctionIterator.java @@ -20,15 +20,11 @@ import java.util.Map; import java.util.TreeMap; -import org.apache.spark.api.java.JavaRDD; - import org.rumbledb.api.Item; import org.rumbledb.context.DynamicContext; import org.rumbledb.context.Name; import org.rumbledb.context.RuntimeStaticContext; -import org.rumbledb.exceptions.InvalidArgumentTypeException; import org.rumbledb.exceptions.UnsupportedCollationException; -import org.rumbledb.items.ItemComparator; import org.rumbledb.items.ItemFactory; import org.rumbledb.items.structured.HomogeneousItemDataFrame; import org.rumbledb.runtime.AbstractAtMostOneItemRuntimePlan; @@ -59,7 +55,7 @@ public MaxFunctionIterator(List arguments, RuntimeStaticContext @Override public Item evaluateAtMostOne(DynamicContext context) { if (!this.iterator.getRuntimeStaticContext().getExecutionMode().isRDDOrDataFrame()) { - return ExtremumLocalEvaluation.max(this.iterator, getCollationPlan(), context, getMetadata()); + return ExtremumEvaluation.max(this.iterator, getCollationPlan(), context, getMetadata()); } validateCollation(context); @@ -88,15 +84,7 @@ public Item evaluateAtMostOne(DynamicContext context) { return itemTypePromotion(maxDF.getExactlyOneItem()); } - JavaRDD rdd = this.iterator.getRDD(context); - if (rdd.isEmpty()) { - return null; - } - return rdd.max(new ItemComparator( - false, - new InvalidArgumentTypeException( - "Max expression input error. Input has to be non-null atomics of matching types", - getMetadata()))); + return ExtremumEvaluation.maxRDD(this.iterator.getRDD(context), getMetadata()); } private ItemRuntimePlan getCollationPlan() { diff --git a/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/MinFunctionIterator.java b/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/MinFunctionIterator.java index f986453414..5bf1cf0a93 100644 --- a/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/MinFunctionIterator.java +++ b/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/MinFunctionIterator.java @@ -20,15 +20,11 @@ import java.util.Map; import java.util.TreeMap; -import org.apache.spark.api.java.JavaRDD; - import org.rumbledb.api.Item; import org.rumbledb.context.DynamicContext; import org.rumbledb.context.Name; import org.rumbledb.context.RuntimeStaticContext; -import org.rumbledb.exceptions.InvalidArgumentTypeException; import org.rumbledb.exceptions.UnsupportedCollationException; -import org.rumbledb.items.ItemComparator; import org.rumbledb.items.structured.HomogeneousItemDataFrame; import org.rumbledb.runtime.AbstractAtMostOneItemRuntimePlan; import org.rumbledb.runtime.dataframe.ItemRuntimeDataFrameFactory; @@ -57,7 +53,7 @@ public MinFunctionIterator(List arguments, RuntimeStaticContext @Override public Item evaluateAtMostOne(DynamicContext context) { if (!this.iterator.getRuntimeStaticContext().getExecutionMode().isRDDOrDataFrame()) { - return ExtremumLocalEvaluation.min(this.iterator, getCollationPlan(), context, getMetadata()); + return ExtremumEvaluation.min(this.iterator, getCollationPlan(), context, getMetadata()); } validateCollation(context); @@ -77,15 +73,7 @@ public Item evaluateAtMostOne(DynamicContext context) { return minDF.getExactlyOneItem(); } - JavaRDD rdd = this.iterator.getRDD(context); - if (rdd.isEmpty()) { - return null; - } - return rdd.min(new ItemComparator( - true, - new InvalidArgumentTypeException( - "Min expression input error. Input has to be non-null atomics of matching types", - getMetadata()))); + return ExtremumEvaluation.minRDD(this.iterator.getRDD(context), getMetadata()); } private ItemRuntimePlan getCollationPlan() { diff --git a/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/SumClosure.java b/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/SumClosure.java index b04ac80af8..48ec321157 100644 --- a/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/SumClosure.java +++ b/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/SumClosure.java @@ -21,8 +21,6 @@ import org.rumbledb.api.Item; import org.rumbledb.exceptions.ExceptionMetadata; -import org.rumbledb.exceptions.InvalidArgumentTypeException; -import org.rumbledb.runtime.arithmetics.AdditiveOperationIterator; public class SumClosure implements Function2 { @@ -37,16 +35,6 @@ public SumClosure(ExceptionMetadata metadata) { @Override public Item call(Item v1, Item v2) throws Exception { - Item result = AdditiveOperationIterator.processItem(v1, v2, false); - if (result == null) { - throw new InvalidArgumentTypeException( - " \"+\": operation not possible with parameters of type \"" - + v1.getDynamicType().toString() - + "\" and \"" - + v2.getDynamicType().toString() - + "\"", - this.metadata); - } - return result; + return SumFunctionIterator.addToSum(v1, v2, this.metadata); } } diff --git a/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/SumFunctionIterator.java b/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/SumFunctionIterator.java index 556a9b305f..89f4ddd6f0 100644 --- a/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/SumFunctionIterator.java +++ b/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/SumFunctionIterator.java @@ -112,10 +112,11 @@ static Item addToSum(Item currentSum, Item nextValue, ExceptionMetadata metadata private static Item computeRDD( Item zeroElement, ItemRuntimePlan iterator, DynamicContext context, ExceptionMetadata metadata) { JavaRDD rdd = iterator.getRDD(context); - if (rdd.count() == 0) { + if (rdd.isEmpty()) { return zeroElement; } - return rdd.reduce(new SumClosure(metadata)); + // Reduction does not call its combiner for a singleton, so convert each input first. + return rdd.map(item -> addToSum(null, item, metadata)).reduce(new SumClosure(metadata)); } private static Item computeDataFrame( diff --git a/src/test/resources/test_files/type-inference/runtime/aggregates/AnyURIStringPromotionRDD.xq b/src/test/resources/test_files/type-inference/runtime/aggregates/AnyURIStringPromotionRDD.xq new file mode 100644 index 0000000000..626b719aab --- /dev/null +++ b/src/test/resources/test_files/type-inference/runtime/aggregates/AnyURIStringPromotionRDD.xq @@ -0,0 +1,6 @@ +(:JIQS: ShouldRun; Output="(xs:string, true)" :) +(: A generic atomic input cannot guarantee comparability under strict static typing. :) +let $result := max( + parallelize((xs:anyURI("z"), "a"), 2) treat as xs:anyAtomicType+ +) +return (fn:item-type($result), $result eq "z") diff --git a/src/test/resources/test_files/type-inference/runtime/aggregates/DerivedStringPromotionRDD.xq b/src/test/resources/test_files/type-inference/runtime/aggregates/DerivedStringPromotionRDD.xq new file mode 100644 index 0000000000..69f810779e --- /dev/null +++ b/src/test/resources/test_files/type-inference/runtime/aggregates/DerivedStringPromotionRDD.xq @@ -0,0 +1,6 @@ +(:JIQS: ShouldRun; Output="(xs:string, true)" :) +(: Mixing primitive string and URI types also converts a winning xs:token to xs:string. :) +let $result := max( + parallelize((xs:token("z"), xs:anyURI("a")), 2) treat as xs:anyAtomicType+ +) +return (fn:item-type($result), $result eq "z") diff --git a/src/test/resources/test_files/type-inference/strict/aggregates/AnyURITypeRDD.xq b/src/test/resources/test_files/type-inference/strict/aggregates/AnyURITypeRDD.xq new file mode 100644 index 0000000000..20d7c287be --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/aggregates/AnyURITypeRDD.xq @@ -0,0 +1,5 @@ +(:JIQS: ShouldRun; Output="(xs:anyURI, true)" :) +let $result := max( + parallelize((xs:anyURI("z"), xs:anyURI("a")), 2) treat as xs:anyURI+ +) is statically xs:anyURI +return (fn:item-type($result), $result eq xs:anyURI("z")) diff --git a/src/test/resources/test_files/type-inference/strict/aggregates/DecimalMaxRDD.xq b/src/test/resources/test_files/type-inference/strict/aggregates/DecimalMaxRDD.xq new file mode 100644 index 0000000000..29a914766c --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/aggregates/DecimalMaxRDD.xq @@ -0,0 +1,5 @@ +(:JIQS: ShouldRun; Output="(xs:decimal, true)" :) +let $result := max( + parallelize((xs:decimal(1), xs:decimal(2)), 2) treat as xs:decimal+ +) is statically xs:decimal +return (fn:item-type($result), $result eq xs:decimal(2)) diff --git a/src/test/resources/test_files/type-inference/strict/aggregates/DerivedStringType.xq b/src/test/resources/test_files/type-inference/strict/aggregates/DerivedStringType.xq new file mode 100644 index 0000000000..df73cd3459 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/aggregates/DerivedStringType.xq @@ -0,0 +1,7 @@ +(:JIQS: ShouldRun; Output="(xs:token, true, xs:token, true)" :) +(: With one primitive type, local and RDD max retain the selected derived type. :) +let $local := max((xs:token("a"), xs:token("b"))) is statically xs:string +let $distributed := max( + parallelize((xs:token("a"), xs:token("b")), 2) treat as xs:token+ +) is statically xs:string +return (fn:item-type($local), $local eq "b", fn:item-type($distributed), $distributed eq "b") diff --git a/src/test/resources/test_files/type-inference/strict/aggregates/EmptyRDD.xq b/src/test/resources/test_files/type-inference/strict/aggregates/EmptyRDD.xq new file mode 100644 index 0000000000..61bb565d68 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/aggregates/EmptyRDD.xq @@ -0,0 +1,5 @@ +(:JIQS: ShouldRun; Output="(0, true, true)" :) +let $sum := sum(parallelize((), 2) treat as xs:integer*) is statically xs:integer +let $min := min(parallelize((), 2) treat as xs:integer*) is statically xs:integer? +let $max := max(parallelize((), 2) treat as xs:integer*) is statically xs:integer? +return ($sum, empty($min), empty($max)) diff --git a/src/test/resources/test_files/type-inference/strict/aggregates/FloatMaxRDD.xq b/src/test/resources/test_files/type-inference/strict/aggregates/FloatMaxRDD.xq new file mode 100644 index 0000000000..7ebc23a4b6 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/aggregates/FloatMaxRDD.xq @@ -0,0 +1,5 @@ +(:JIQS: ShouldRun; Output="(xs:float, true)" :) +let $result := max( + parallelize((xs:float(1), xs:float(2)), 2) treat as xs:float+ +) is statically xs:float +return (fn:item-type($result), $result eq xs:float(2)) diff --git a/src/test/resources/test_files/type-inference/strict/aggregates/MixedDoubleMinRDD.xq b/src/test/resources/test_files/type-inference/strict/aggregates/MixedDoubleMinRDD.xq new file mode 100644 index 0000000000..2bc0c008e5 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/aggregates/MixedDoubleMinRDD.xq @@ -0,0 +1,5 @@ +(:JIQS: ShouldRun; Output="(xs:double, true)" :) +let $result := min( + parallelize((xs:float(1), xs:double(2)), 2) treat as xs:numeric+ +) +return (fn:item-type($result), $result eq xs:double(1)) diff --git a/src/test/resources/test_files/type-inference/strict/aggregates/MixedFloatMaxRDD.xq b/src/test/resources/test_files/type-inference/strict/aggregates/MixedFloatMaxRDD.xq new file mode 100644 index 0000000000..bf6f643f0b --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/aggregates/MixedFloatMaxRDD.xq @@ -0,0 +1,5 @@ +(:JIQS: ShouldRun; Output="(xs:float, true)" :) +let $result := max( + parallelize((1, xs:float(0)), 2) treat as xs:numeric+ +) +return (fn:item-type($result), $result eq xs:float(1)) diff --git a/src/test/resources/test_files/type-inference/strict/aggregates/MixedFloatMinRDD.xq b/src/test/resources/test_files/type-inference/strict/aggregates/MixedFloatMinRDD.xq new file mode 100644 index 0000000000..a5f55316ed --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/aggregates/MixedFloatMinRDD.xq @@ -0,0 +1,5 @@ +(:JIQS: ShouldRun; Output="(xs:float, true)" :) +let $result := min( + parallelize((1, xs:float(2)), 2) treat as xs:numeric+ +) +return (fn:item-type($result), $result eq xs:float(1)) diff --git a/src/test/resources/test_files/type-inference/strict/aggregates/NaNPromotionRDD.xq b/src/test/resources/test_files/type-inference/strict/aggregates/NaNPromotionRDD.xq new file mode 100644 index 0000000000..1ebfc257b9 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/aggregates/NaNPromotionRDD.xq @@ -0,0 +1,9 @@ +(:JIQS: ShouldRun; Output="(xs:double, true, xs:double, true)" :) +(: Even a NaN result uses the common numeric type of every input. :) +let $min := min( + parallelize((xs:float("NaN"), xs:double(2)), 2) treat as xs:numeric+ +) +let $max := max( + parallelize((xs:float("NaN"), xs:double(2)), 2) treat as xs:numeric+ +) +return (fn:item-type($min), $min ne $min, fn:item-type($max), $max ne $max) diff --git a/src/test/resources/test_files/type-inference/strict/aggregates/UntypedSingletonRDD.xq b/src/test/resources/test_files/type-inference/strict/aggregates/UntypedSingletonRDD.xq new file mode 100644 index 0000000000..52089ed7f4 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/aggregates/UntypedSingletonRDD.xq @@ -0,0 +1,10 @@ +(:JIQS: ShouldRun; Output="(xs:double, true, xs:double, true, xs:double, true)" :) +(: A singleton reduction still needs the untyped-atomic conversion. :) +let $sum := sum(parallelize(xs:untypedAtomic("1"), 2) treat as xs:untypedAtomic+) is statically xs:double +let $min := min(parallelize(xs:untypedAtomic("1"), 2) treat as xs:untypedAtomic+) is statically xs:double +let $max := max(parallelize(xs:untypedAtomic("1"), 2) treat as xs:untypedAtomic+) is statically xs:double +return ( + fn:item-type($sum), $sum eq xs:double(1), + fn:item-type($min), $min eq xs:double(1), + fn:item-type($max), $max eq xs:double(1) +) diff --git a/src/test/resources/test_files/type-inference/strict/aggregates/UntypedSumRDD.xq b/src/test/resources/test_files/type-inference/strict/aggregates/UntypedSumRDD.xq new file mode 100644 index 0000000000..59a046d675 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/aggregates/UntypedSumRDD.xq @@ -0,0 +1,6 @@ +(:JIQS: ShouldRun; Output="(xs:double, true)" :) +let $result := sum( + parallelize((xs:untypedAtomic("1"), xs:untypedAtomic("2")), 2) + treat as xs:untypedAtomic+ +) is statically xs:double +return (fn:item-type($result), $result eq xs:double(3)) From 863ac6e28c21f2993470e33957ed59d8b8c5841b Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Tue, 6 Oct 2026 22:20:01 +0200 Subject: [PATCH 24/36] test: trim redundant type inference fixtures Node comparison operators share one code path, as do min and max, so keep one case per rule instead of every operator and operand position. Merge the value and general nullable comparisons into one fixture. --- .../flwor/where-max-mixed-types.jq | 4 --- .../comparisons/NodeEmptyComparisons.xq | 11 ++------ .../strict/casts/NodeAtomization.xq | 8 ++---- .../strict/comparisons/NodeEmptyBothAfter.xq | 3 --- .../strict/comparisons/NodeEmptyBothBefore.xq | 3 --- .../comparisons/NodeEmptyBothIdentity.xq | 3 --- .../strict/comparisons/NodeEmptyLeftAfter.xq | 3 --- .../strict/comparisons/NodeEmptyLeftBefore.xq | 3 --- .../comparisons/NodeEmptyLeftIdentity.xq | 3 --- .../strict/comparisons/NodeEmptyOperand.xq | 3 +++ .../strict/comparisons/NodeEmptyRightAfter.xq | 3 --- .../comparisons/NodeEmptyRightBefore.xq | 3 --- .../comparisons/NodeEmptyRightIdentity.xq | 3 --- ...eLeftOperand.xq => NodeMultipleOperand.xq} | 0 .../comparisons/NodeMultipleRightOperand.xq | 2 -- .../comparisons/NodeOptionalComparisons.xq | 8 ++---- .../comparisons/NodeSingletonComparisons.xq | 8 ++---- .../strict/comparisons/NullableComparisons.jq | 9 +++++++ .../comparisons/NullableGeneralComparisons.jq | 25 ------------------- .../NullableUnionIncompatibleOperand.jq | 3 --- .../comparisons/NullableValueComparisons.jq | 25 ------------------- 21 files changed, 20 insertions(+), 113 deletions(-) delete mode 100644 src/test/resources/test_files/static-typing/flwor/where-max-mixed-types.jq delete mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothAfter.xq delete mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothBefore.xq delete mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothIdentity.xq delete mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftAfter.xq delete mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftBefore.xq delete mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftIdentity.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyOperand.xq delete mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightAfter.xq delete mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightBefore.xq delete mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightIdentity.xq rename src/test/resources/test_files/type-inference/strict/comparisons/{NodeMultipleLeftOperand.xq => NodeMultipleOperand.xq} (100%) delete mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NodeMultipleRightOperand.xq create mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NullableComparisons.jq delete mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NullableGeneralComparisons.jq delete mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NullableUnionIncompatibleOperand.jq delete mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/NullableValueComparisons.jq diff --git a/src/test/resources/test_files/static-typing/flwor/where-max-mixed-types.jq b/src/test/resources/test_files/static-typing/flwor/where-max-mixed-types.jq deleted file mode 100644 index 59d9ac1777..0000000000 --- a/src/test/resources/test_files/static-typing/flwor/where-max-mixed-types.jq +++ /dev/null @@ -1,4 +0,0 @@ -(:JIQS: ShouldCrash; ErrorCode="FORG0006" :) -for $var in ("a", "b", "c") -where fn:max(($var, 1)) -return $var diff --git a/src/test/resources/test_files/type-inference/runtime/comparisons/NodeEmptyComparisons.xq b/src/test/resources/test_files/type-inference/runtime/comparisons/NodeEmptyComparisons.xq index 1a1c328680..c35f6570fc 100644 --- a/src/test/resources/test_files/type-inference/runtime/comparisons/NodeEmptyComparisons.xq +++ b/src/test/resources/test_files/type-inference/runtime/comparisons/NodeEmptyComparisons.xq @@ -1,13 +1,6 @@ (:JIQS: ShouldRun; Output="" :) -(: With strict static typing disabled, every empty operand combination remains empty. :) +(: With strict static typing disabled, an empty operand on either side makes the comparison empty. :) ( (() is ) is statically empty-sequence(), - ( is ()) is statically empty-sequence(), - (() is ()) is statically empty-sequence(), - (() << ) is statically empty-sequence(), - ( << ()) is statically empty-sequence(), - (() << ()) is statically empty-sequence(), - (() >> ) is statically empty-sequence(), - ( >> ()) is statically empty-sequence(), - (() >> ()) is statically empty-sequence() + ( is ()) is statically empty-sequence() ) diff --git a/src/test/resources/test_files/type-inference/strict/casts/NodeAtomization.xq b/src/test/resources/test_files/type-inference/strict/casts/NodeAtomization.xq index 2cfbfa3e97..bc80a20206 100644 --- a/src/test/resources/test_files/type-inference/strict/casts/NodeAtomization.xq +++ b/src/test/resources/test_files/type-inference/strict/casts/NodeAtomization.xq @@ -1,6 +1,2 @@ -(:JIQS: ShouldRun; Output="(2, 2, 2)" :) -( - (text {"2"} cast as xs:integer) is statically xs:integer, - (2 cast as xs:integer) is statically xs:integer, - (attribute a {"2"} cast as xs:integer) is statically xs:integer -) +(:JIQS: ShouldRun; Output="2" :) +(2 cast as xs:integer) is statically xs:integer diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothAfter.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothAfter.xq deleted file mode 100644 index b64179879c..0000000000 --- a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothAfter.xq +++ /dev/null @@ -1,3 +0,0 @@ -(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) -(: Strict static typing rejects a known-empty comparison. :) -() >> () diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothBefore.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothBefore.xq deleted file mode 100644 index a35f259f27..0000000000 --- a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothBefore.xq +++ /dev/null @@ -1,3 +0,0 @@ -(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) -(: Strict static typing rejects a known-empty comparison. :) -() << () diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothIdentity.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothIdentity.xq deleted file mode 100644 index b035edce2e..0000000000 --- a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyBothIdentity.xq +++ /dev/null @@ -1,3 +0,0 @@ -(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) -(: Strict static typing rejects a known-empty comparison. :) -() is () diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftAfter.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftAfter.xq deleted file mode 100644 index 16f459b8a2..0000000000 --- a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftAfter.xq +++ /dev/null @@ -1,3 +0,0 @@ -(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) -(: Strict static typing rejects a known-empty comparison. :) -() >> diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftBefore.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftBefore.xq deleted file mode 100644 index 7b116aa796..0000000000 --- a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftBefore.xq +++ /dev/null @@ -1,3 +0,0 @@ -(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) -(: Strict static typing rejects a known-empty comparison. :) -() << diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftIdentity.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftIdentity.xq deleted file mode 100644 index dc662d31bc..0000000000 --- a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyLeftIdentity.xq +++ /dev/null @@ -1,3 +0,0 @@ -(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) -(: Strict static typing rejects a known-empty comparison. :) -() is diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyOperand.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyOperand.xq new file mode 100644 index 0000000000..dfc13c9d57 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyOperand.xq @@ -0,0 +1,3 @@ +(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) +(: Strict static typing rejects a node comparison with a known-empty operand. :) +() is diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightAfter.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightAfter.xq deleted file mode 100644 index 705e5d8718..0000000000 --- a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightAfter.xq +++ /dev/null @@ -1,3 +0,0 @@ -(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) -(: Strict static typing rejects a known-empty comparison. :) - >> () diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightBefore.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightBefore.xq deleted file mode 100644 index 71e6940e0f..0000000000 --- a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightBefore.xq +++ /dev/null @@ -1,3 +0,0 @@ -(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) -(: Strict static typing rejects a known-empty comparison. :) - << () diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightIdentity.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightIdentity.xq deleted file mode 100644 index f6cf24db54..0000000000 --- a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyRightIdentity.xq +++ /dev/null @@ -1,3 +0,0 @@ -(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) -(: Strict static typing rejects a known-empty comparison. :) - is () diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeMultipleLeftOperand.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeMultipleOperand.xq similarity index 100% rename from src/test/resources/test_files/type-inference/strict/comparisons/NodeMultipleLeftOperand.xq rename to src/test/resources/test_files/type-inference/strict/comparisons/NodeMultipleOperand.xq diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeMultipleRightOperand.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeMultipleRightOperand.xq deleted file mode 100644 index ac1b0698bd..0000000000 --- a/src/test/resources/test_files/type-inference/strict/comparisons/NodeMultipleRightOperand.xq +++ /dev/null @@ -1,2 +0,0 @@ -(:JIQS: ShouldCrash; ErrorCode="XPTY0004" :) - is (, ) diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeOptionalComparisons.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeOptionalComparisons.xq index 57874cf1dd..42e363079d 100644 --- a/src/test/resources/test_files/type-inference/strict/comparisons/NodeOptionalComparisons.xq +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NodeOptionalComparisons.xq @@ -1,12 +1,8 @@ -(:JIQS: ShouldRun; Output="(1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0)" :) +(:JIQS: ShouldRun; Output="(1, 1, 0, 0)" :) for $present in (true(), false()) let $a := let $b := if ($present) then $a else () return ( count(($a is $b) is statically xs:boolean?), - count(($b is $a) is statically xs:boolean?), - count(($a << $b) is statically xs:boolean?), - count(($b << $a) is statically xs:boolean?), - count(($a >> $b) is statically xs:boolean?), - count(($b >> $a) is statically xs:boolean?) + count(($b is $a) is statically xs:boolean?) ) diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeSingletonComparisons.xq b/src/test/resources/test_files/type-inference/strict/comparisons/NodeSingletonComparisons.xq index 14fecab819..215d6bf906 100644 --- a/src/test/resources/test_files/type-inference/strict/comparisons/NodeSingletonComparisons.xq +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NodeSingletonComparisons.xq @@ -1,7 +1,3 @@ -(:JIQS: ShouldRun; Output="(true, false, false)" :) +(:JIQS: ShouldRun; Output="true" :) let $a := -return ( - ($a is $a) is statically xs:boolean, - ($a << $a) is statically xs:boolean, - ($a >> $a) is statically xs:boolean -) +return ($a is $a) is statically xs:boolean diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NullableComparisons.jq b/src/test/resources/test_files/type-inference/strict/comparisons/NullableComparisons.jq new file mode 100644 index 0000000000..573222b7e0 --- /dev/null +++ b/src/test/resources/test_files/type-inference/strict/comparisons/NullableComparisons.jq @@ -0,0 +1,9 @@ +(:JIQS: ShouldRun; Output="(true, false, true, false, false, false, false, false)" :) +(: null is comparable with every atomic value, for equality and ordering, on either side. :) +for $x in (1, null) +return ( + ($x eq 1) is statically xs:boolean, + (1 lt $x) is statically xs:boolean, + ($x = 1) is statically xs:boolean, + (1 < $x) is statically xs:boolean +) diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NullableGeneralComparisons.jq b/src/test/resources/test_files/type-inference/strict/comparisons/NullableGeneralComparisons.jq deleted file mode 100644 index 5e11514389..0000000000 --- a/src/test/resources/test_files/type-inference/strict/comparisons/NullableGeneralComparisons.jq +++ /dev/null @@ -1,25 +0,0 @@ -(:JIQS: ShouldRun; Output="(24, true, true, false)" :) -(: Each union alternative yields one boolean, on either side of every general operator. :) -let $results := ( - for $x in (1, null) - return ( - ($x = 1) is statically xs:boolean, - (1 = $x) is statically xs:boolean, - ($x != 1) is statically xs:boolean, - (1 != $x) is statically xs:boolean, - ($x < 1) is statically xs:boolean, - (1 < $x) is statically xs:boolean, - ($x <= 1) is statically xs:boolean, - (1 <= $x) is statically xs:boolean, - ($x > 1) is statically xs:boolean, - (1 > $x) is statically xs:boolean, - ($x >= 1) is statically xs:boolean, - (1 >= $x) is statically xs:boolean - ) -) is statically xs:boolean+ -return ( - count($results), - every $result in $results satisfies $result instance of xs:boolean, - $results[1], - $results[13] -) diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NullableUnionIncompatibleOperand.jq b/src/test/resources/test_files/type-inference/strict/comparisons/NullableUnionIncompatibleOperand.jq deleted file mode 100644 index 34be1a5f4a..0000000000 --- a/src/test/resources/test_files/type-inference/strict/comparisons/NullableUnionIncompatibleOperand.jq +++ /dev/null @@ -1,3 +0,0 @@ -(:JIQS: ShouldCrash; ErrorCode="XPTY0004" :) -for $x in (1, null) -return $x eq "a" diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NullableValueComparisons.jq b/src/test/resources/test_files/type-inference/strict/comparisons/NullableValueComparisons.jq deleted file mode 100644 index 7393211189..0000000000 --- a/src/test/resources/test_files/type-inference/strict/comparisons/NullableValueComparisons.jq +++ /dev/null @@ -1,25 +0,0 @@ -(:JIQS: ShouldRun; Output="(24, true, true, false)" :) -(: Each union alternative yields one boolean, on either side of every value operator. :) -let $results := ( - for $x in (1, null) - return ( - ($x eq 1) is statically xs:boolean, - (1 eq $x) is statically xs:boolean, - ($x ne 1) is statically xs:boolean, - (1 ne $x) is statically xs:boolean, - ($x lt 1) is statically xs:boolean, - (1 lt $x) is statically xs:boolean, - ($x le 1) is statically xs:boolean, - (1 le $x) is statically xs:boolean, - ($x gt 1) is statically xs:boolean, - (1 gt $x) is statically xs:boolean, - ($x ge 1) is statically xs:boolean, - (1 ge $x) is statically xs:boolean - ) -) is statically xs:boolean+ -return ( - count($results), - every $result in $results satisfies $result instance of xs:boolean, - $results[1], - $results[13] -) From c4b6e1ed763a1ab5844c24cf202458290d3d9e0d Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Tue, 6 Oct 2026 22:52:05 +0200 Subject: [PATCH 25/36] test: keep one aggregate fixture per promotion rule min and max share the promotion code, and inputs with one primitive type all keep the selected item, so the removed fixtures repeated covered rules. --- .../runtime/aggregates/AnyURIStringPromotionRDD.xq | 6 ------ .../type-inference/strict/aggregates/AnyURITypeRDD.xq | 5 ----- .../type-inference/strict/aggregates/DecimalMaxRDD.xq | 5 ----- .../type-inference/strict/aggregates/FloatMaxRDD.xq | 5 ----- .../type-inference/strict/aggregates/MixedFloatMinRDD.xq | 5 ----- .../type-inference/strict/aggregates/NaNPromotionRDD.xq | 7 ++----- .../type-inference/strict/aggregates/UntypedSumRDD.xq | 6 ------ 7 files changed, 2 insertions(+), 37 deletions(-) delete mode 100644 src/test/resources/test_files/type-inference/runtime/aggregates/AnyURIStringPromotionRDD.xq delete mode 100644 src/test/resources/test_files/type-inference/strict/aggregates/AnyURITypeRDD.xq delete mode 100644 src/test/resources/test_files/type-inference/strict/aggregates/DecimalMaxRDD.xq delete mode 100644 src/test/resources/test_files/type-inference/strict/aggregates/FloatMaxRDD.xq delete mode 100644 src/test/resources/test_files/type-inference/strict/aggregates/MixedFloatMinRDD.xq delete mode 100644 src/test/resources/test_files/type-inference/strict/aggregates/UntypedSumRDD.xq diff --git a/src/test/resources/test_files/type-inference/runtime/aggregates/AnyURIStringPromotionRDD.xq b/src/test/resources/test_files/type-inference/runtime/aggregates/AnyURIStringPromotionRDD.xq deleted file mode 100644 index 626b719aab..0000000000 --- a/src/test/resources/test_files/type-inference/runtime/aggregates/AnyURIStringPromotionRDD.xq +++ /dev/null @@ -1,6 +0,0 @@ -(:JIQS: ShouldRun; Output="(xs:string, true)" :) -(: A generic atomic input cannot guarantee comparability under strict static typing. :) -let $result := max( - parallelize((xs:anyURI("z"), "a"), 2) treat as xs:anyAtomicType+ -) -return (fn:item-type($result), $result eq "z") diff --git a/src/test/resources/test_files/type-inference/strict/aggregates/AnyURITypeRDD.xq b/src/test/resources/test_files/type-inference/strict/aggregates/AnyURITypeRDD.xq deleted file mode 100644 index 20d7c287be..0000000000 --- a/src/test/resources/test_files/type-inference/strict/aggregates/AnyURITypeRDD.xq +++ /dev/null @@ -1,5 +0,0 @@ -(:JIQS: ShouldRun; Output="(xs:anyURI, true)" :) -let $result := max( - parallelize((xs:anyURI("z"), xs:anyURI("a")), 2) treat as xs:anyURI+ -) is statically xs:anyURI -return (fn:item-type($result), $result eq xs:anyURI("z")) diff --git a/src/test/resources/test_files/type-inference/strict/aggregates/DecimalMaxRDD.xq b/src/test/resources/test_files/type-inference/strict/aggregates/DecimalMaxRDD.xq deleted file mode 100644 index 29a914766c..0000000000 --- a/src/test/resources/test_files/type-inference/strict/aggregates/DecimalMaxRDD.xq +++ /dev/null @@ -1,5 +0,0 @@ -(:JIQS: ShouldRun; Output="(xs:decimal, true)" :) -let $result := max( - parallelize((xs:decimal(1), xs:decimal(2)), 2) treat as xs:decimal+ -) is statically xs:decimal -return (fn:item-type($result), $result eq xs:decimal(2)) diff --git a/src/test/resources/test_files/type-inference/strict/aggregates/FloatMaxRDD.xq b/src/test/resources/test_files/type-inference/strict/aggregates/FloatMaxRDD.xq deleted file mode 100644 index 7ebc23a4b6..0000000000 --- a/src/test/resources/test_files/type-inference/strict/aggregates/FloatMaxRDD.xq +++ /dev/null @@ -1,5 +0,0 @@ -(:JIQS: ShouldRun; Output="(xs:float, true)" :) -let $result := max( - parallelize((xs:float(1), xs:float(2)), 2) treat as xs:float+ -) is statically xs:float -return (fn:item-type($result), $result eq xs:float(2)) diff --git a/src/test/resources/test_files/type-inference/strict/aggregates/MixedFloatMinRDD.xq b/src/test/resources/test_files/type-inference/strict/aggregates/MixedFloatMinRDD.xq deleted file mode 100644 index a5f55316ed..0000000000 --- a/src/test/resources/test_files/type-inference/strict/aggregates/MixedFloatMinRDD.xq +++ /dev/null @@ -1,5 +0,0 @@ -(:JIQS: ShouldRun; Output="(xs:float, true)" :) -let $result := min( - parallelize((1, xs:float(2)), 2) treat as xs:numeric+ -) -return (fn:item-type($result), $result eq xs:float(1)) diff --git a/src/test/resources/test_files/type-inference/strict/aggregates/NaNPromotionRDD.xq b/src/test/resources/test_files/type-inference/strict/aggregates/NaNPromotionRDD.xq index 1ebfc257b9..a8866effa6 100644 --- a/src/test/resources/test_files/type-inference/strict/aggregates/NaNPromotionRDD.xq +++ b/src/test/resources/test_files/type-inference/strict/aggregates/NaNPromotionRDD.xq @@ -1,9 +1,6 @@ -(:JIQS: ShouldRun; Output="(xs:double, true, xs:double, true)" :) +(:JIQS: ShouldRun; Output="(xs:double, true)" :) (: Even a NaN result uses the common numeric type of every input. :) let $min := min( parallelize((xs:float("NaN"), xs:double(2)), 2) treat as xs:numeric+ ) -let $max := max( - parallelize((xs:float("NaN"), xs:double(2)), 2) treat as xs:numeric+ -) -return (fn:item-type($min), $min ne $min, fn:item-type($max), $max ne $max) +return (fn:item-type($min), $min ne $min) diff --git a/src/test/resources/test_files/type-inference/strict/aggregates/UntypedSumRDD.xq b/src/test/resources/test_files/type-inference/strict/aggregates/UntypedSumRDD.xq deleted file mode 100644 index 59a046d675..0000000000 --- a/src/test/resources/test_files/type-inference/strict/aggregates/UntypedSumRDD.xq +++ /dev/null @@ -1,6 +0,0 @@ -(:JIQS: ShouldRun; Output="(xs:double, true)" :) -let $result := sum( - parallelize((xs:untypedAtomic("1"), xs:untypedAtomic("2")), 2) - treat as xs:untypedAtomic+ -) is statically xs:double -return (fn:item-type($result), $result eq xs:double(3)) From 04f9b54b0da3bae27e08d218bcb51141910aa4d3 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Thu, 8 Oct 2026 16:32:53 +0200 Subject: [PATCH 26/36] fix: ensure union types retain all members and handle nullable fields correctly in DataFrame mappings --- .../java/org/rumbledb/types/TypeMappings.java | 14 +++++----- .../org/rumbledb/types/UnionItemType.java | 4 +-- .../ObjectConstructorTypeInferenceTest.java | 27 +++++++++++++++++++ 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/rumbledb/types/TypeMappings.java b/src/main/java/org/rumbledb/types/TypeMappings.java index bc9ecfab46..04114b06b4 100644 --- a/src/main/java/org/rumbledb/types/TypeMappings.java +++ b/src/main/java/org/rumbledb/types/TypeMappings.java @@ -88,12 +88,14 @@ public static DataType getDataFrameDataTypeFromItemType(ItemType itemType, Runti // Leave these unions to the union mapping below. if (!itemType.isUnionType() && itemType.isSubtypeOf(BuiltinTypesCatalogue.objectItem)) { List fields = new ArrayList<>(); - itemType.getObjectKeysFacet() - .forEach(key -> fields.add(DataTypes.createStructField( - key, - getDataFrameDataTypeFromItemType( - itemType.getObjectContentFacet(key).getType(), staticContext), - !itemType.getObjectContentFacet(key).isRequired()))); + itemType.getObjectKeysFacet().forEach(key -> { + FieldDescriptor field = itemType.getObjectContentFacet(key); + // A required field can still hold null, e.g. an object constructor field of type (xs:double | js:null). + fields.add(DataTypes.createStructField( + key, + getDataFrameDataTypeFromItemType(field.getType(), staticContext), + !field.isRequired() || field.getType().canBeNull())); + }); if (fields.size() > 0) { return DataTypes.createStructType(fields); } diff --git a/src/main/java/org/rumbledb/types/UnionItemType.java b/src/main/java/org/rumbledb/types/UnionItemType.java index 7e5c823d9f..e10f57f2bb 100644 --- a/src/main/java/org/rumbledb/types/UnionItemType.java +++ b/src/main/java/org/rumbledb/types/UnionItemType.java @@ -282,9 +282,9 @@ public ItemType findLeastCommonSuperTypeWith(ItemType other) { Set resultTypes = new HashSet<>(this.types); resultTypes.addAll(otherTypes); // The atomic join below widens to an atomic base type, which cannot cover non-atomic members - // (for example, the array alternative of an inferred object field). Fall back to item instead. + // (for example, the array alternative of an inferred object field). Keep every member instead. if (resultTypes.stream().anyMatch(type -> !type.isSubtypeOf(BuiltinTypesCatalogue.atomicItem))) { - return BuiltinTypesCatalogue.item; + return new UnionItemType(null, BuiltinTypesCatalogue.item, new ArrayList<>(resultTypes)); } for (ItemType member : resultTypes) { if (member.equals(BuiltinTypesCatalogue.nullItem)) { diff --git a/src/test/java/org/rumbledb/compiler/ObjectConstructorTypeInferenceTest.java b/src/test/java/org/rumbledb/compiler/ObjectConstructorTypeInferenceTest.java index b066364fe0..158333623d 100644 --- a/src/test/java/org/rumbledb/compiler/ObjectConstructorTypeInferenceTest.java +++ b/src/test/java/org/rumbledb/compiler/ObjectConstructorTypeInferenceTest.java @@ -19,6 +19,7 @@ import java.util.List; import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.StructType; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -173,6 +174,32 @@ void nullableUnionAlreadyContainingNullDoesNotDuplicateIt() { assertEquals(List.of(BuiltinTypesCatalogue.doubleItem, BuiltinTypesCatalogue.nullItem), result.getTypes()); } + @Test + void joiningUnionWithNonAtomicMemberKeepsEveryMember() { + ItemType sequenceField = ItemTypeFactory.createObjectFieldType( + new SequenceType(BuiltinTypesCatalogue.integerItem, SequenceType.Arity.ZeroOrMore)); + ItemType joined = sequenceField.findLeastCommonSuperTypeWith(BuiltinTypesCatalogue.stringItem); + assertFalse(joined.isTopmostItemType()); + assertTrue(joined.isUnionType()); + assertEquals(4, joined.getTypes().size()); + assertTrue(BuiltinTypesCatalogue.stringItem.isSubtypeOf(joined)); + assertTrue(sequenceField.isSubtypeOf(joined)); + } + + @Test + void requiredFieldThatCanBeNullMapsToNullableColumn() { + ItemType objectType = infer("declare variable $d as xs:double? := 1e0; {\"value\": $d, \"known\": 1}", "jq") + .getItemType(); + RuntimeStaticContext context = RuntimeStaticContext.builder() + .configuration(CONFIGURATION) + .executionMode(ExecutionMode.LOCAL) + .metadata(ExceptionMetadata.EMPTY_METADATA) + .build(); + StructType schema = (StructType) TypeMappings.getDataFrameDataTypeFromItemType(objectType, context); + assertTrue(schema.apply("value").nullable()); + assertFalse(schema.apply("known").nullable()); + } + @Test void arrayAlternativesUseTheUnionDataFrameMapping() { String query = "{\"value\": ([1], [2])}"; From 9538b235f18567054e66445f16e6523b408912ce Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Thu, 8 Oct 2026 17:01:16 +0200 Subject: [PATCH 27/36] refactor: extract FLWORclause infer type processing into a separate method to avoid code duplicate --- .../rumbledb/compiler/InferTypeVisitor.java | 59 ++++--------------- 1 file changed, 13 insertions(+), 46 deletions(-) diff --git a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java index 01e1bbd278..663957b599 100644 --- a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java +++ b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java @@ -2294,9 +2294,11 @@ public StaticContext visitSimpleMapExpr(SimpleMapExpression expression, StaticCo // region FLOWR - @Override - public StaticContext visitFlowrExpression(FlworExpression expression, StaticContext argument) { - Clause clause = expression.getReturnClause().getFirstClause(); + /** + * Visits the clauses of a FLWOR expression or statement, starting with the given clause, + * and returns how many tuples can reach the return clause. + */ + private SequenceCardinality visitFlworClauses(Clause clause) { SequenceCardinality forCardinality = SequenceCardinality.ONE; SequenceType forType; @@ -2341,6 +2343,13 @@ public StaticContext visitFlowrExpression(FlworExpression expression, StaticCont clause = clause.getNextClause(); } + return forCardinality; + } + + @Override + public StaticContext visitFlowrExpression(FlworExpression expression, StaticContext argument) { + SequenceCardinality forCardinality = + visitFlworClauses(expression.getReturnClause().getFirstClause()); SequenceType returnType = expression.getReturnClause().getReturnExpr().getStaticSequenceType(); basicChecks(returnType, expression.getClass().getSimpleName(), true, true, expression.getMetadata()); returnType = new SequenceType( @@ -2824,49 +2833,7 @@ public StaticContext visitWhileStatement(WhileStatement statement, StaticContext @Override public StaticContext visitFlowrStatement(FlowrStatement statement, StaticContext argument) { - Clause clause = statement.getReturnStatementClause().getFirstClause(); - SequenceCardinality forCardinality = SequenceCardinality.ONE; - SequenceType forType; - - while (clause != null) { - try { - this.visit(clause, clause.getStaticContext()); - } catch (UnexpectedStaticTypeException e) { - if (forCardinality == SequenceCardinality.EMPTY - && clause.getClauseType().equals(FLWOR_CLAUSES.WHERE)) { - clause = clause.getNextClause(); - continue; - } - throw e; - } - // if there are for clauses we need to consider their arities for the returning expression - if (clause.getClauseType() == FLWOR_CLAUSES.FOR) { - forType = ((ForClause) clause).getExpression().getStaticSequenceType(); - // if forType is the empty sequence that means that allowing empty is set otherwise we would have thrown - // an error - // therefore this for loop will generate one tuple binding the empty sequence, so as for the arities - // count as arity.One - if (!forType.isEmptySequence()) { - SequenceCardinality sourceCardinality = forType.getCardinality(); - if (((ForClause) clause).isAllowEmpty()) { - // An empty source still emits one tuple with an empty binding. - sourceCardinality = sourceCardinality.replaceZeroWithOne(); - } - forCardinality = sourceCardinality.repeated(forCardinality); - } else if (!((ForClause) clause).isAllowEmpty()) { - forCardinality = SequenceCardinality.EMPTY; - } - } else if (clause.getClauseType() == FLWOR_CLAUSES.GROUP_BY) { - // Multiple input tuples can collapse into a single group. - forCardinality = SequenceCardinality.fromArity(forCardinality.toArity()); - } else if (clause.getClauseType() == FLWOR_CLAUSES.WHERE) { - // Filtering tuples can leave zero, one, or multiple tuples. - forCardinality = - SequenceCardinality.fromArity(forCardinality.toArity()).union(SequenceCardinality.EMPTY); - } - clause = clause.getNextClause(); - } - + visitFlworClauses(statement.getReturnStatementClause().getFirstClause()); SequenceType returnType = statement.getReturnStatementClause().getReturnStatement().getStaticSequenceType(); basicChecks(returnType, statement.getClass().getSimpleName(), true, true, statement.getMetadata()); From feb06db6c783520c1a7bc4392917a0e08c99584b Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Thu, 8 Oct 2026 17:02:15 +0200 Subject: [PATCH 28/36] feat: enhance type inference and maintain operand order in union types --- .../java/org/rumbledb/types/SequenceType.java | 10 +++--- .../org/rumbledb/types/UnionItemType.java | 4 ++- .../IsStaticallyTypeInferenceTest.java | 32 +++++++++++++++++++ .../static-typing/postfix/arraylookup1.jq | 2 +- .../static-typing/postfix/arraylookup5.jq | 2 ++ .../static-typing/postfix/arrayunbox1.jq | 3 +- .../static-typing/postfix/arrayunbox5.jq | 2 ++ .../static-typing/postfix/objectlookup1.jq | 2 +- .../static-typing/postfix/objectlookup8.jq | 2 ++ .../static-typing/postfix/objectlookup9.jq | 2 ++ 10 files changed, 52 insertions(+), 9 deletions(-) create mode 100644 src/test/resources/test_files/static-typing/postfix/arraylookup5.jq create mode 100644 src/test/resources/test_files/static-typing/postfix/arrayunbox5.jq create mode 100644 src/test/resources/test_files/static-typing/postfix/objectlookup8.jq create mode 100644 src/test/resources/test_files/static-typing/postfix/objectlookup9.jq diff --git a/src/main/java/org/rumbledb/types/SequenceType.java b/src/main/java/org/rumbledb/types/SequenceType.java index 421382bd7b..59ba380400 100644 --- a/src/main/java/org/rumbledb/types/SequenceType.java +++ b/src/main/java/org/rumbledb/types/SequenceType.java @@ -187,12 +187,12 @@ private ItemType concatenateItemTypes(ItemType other) { ? this.itemType.findLeastCommonSuperTypeLax(other) : this.itemType.findLeastCommonSuperTypeWith(other); } - if (this.itemType.isSubtypeOf(BuiltinTypesCatalogue.atomicItem) - && other.isSubtypeOf(BuiltinTypesCatalogue.atomicItem)) { - return ItemTypeFactory.createInferredUnionType(List.of(this.itemType, other)); + if (this.itemType.isArrayItemType() && other.isArrayItemType()) { + // Arrays have an existing join used by navigation and native execution. + return this.itemType.findLeastCommonSuperTypeWith(other); } - // Structured types have existing joins used by navigation and native execution. - return this.itemType.findLeastCommonSuperTypeWith(other); + // Atomic values, or values of different kinds such as (1, {"a": 1}), keep their members in operand order. + return ItemTypeFactory.createInferredUnionType(List.of(this.itemType, other)); } private static boolean haveSameObjectFields(ItemType left, ItemType right) { diff --git a/src/main/java/org/rumbledb/types/UnionItemType.java b/src/main/java/org/rumbledb/types/UnionItemType.java index e10f57f2bb..145452dddc 100644 --- a/src/main/java/org/rumbledb/types/UnionItemType.java +++ b/src/main/java/org/rumbledb/types/UnionItemType.java @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Set; @@ -279,7 +280,8 @@ public ItemType findLeastCommonSuperTypeWith(ItemType other) { boolean hasNumeric = false; boolean hasNonNumeric = false; boolean hasNull = false; - Set resultTypes = new HashSet<>(this.types); + // Keep operand order so that the same join always displays its members in the same order. + Set resultTypes = new LinkedHashSet<>(this.types); resultTypes.addAll(otherTypes); // The atomic join below widens to an atomic base type, which cannot cover non-atomic members // (for example, the array alternative of an inferred object field). Keep every member instead. diff --git a/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java b/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java index 596a81ce40..a70c935c93 100644 --- a/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java +++ b/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java @@ -19,6 +19,7 @@ import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import java.util.Set; import org.junit.jupiter.api.Test; @@ -101,6 +102,37 @@ void mixedAtomicFixturesKeepTheirExactUnionMembers(String fixture) throws IOExce assertEquals(expectedMembers.size(), type.getItemType().getTypes().size()); } + @ParameterizedTest + @ValueSource(strings = {"jq", "xq"}) + void mixedAtomicCommaKeepsMembersInOperandOrder(String extension) { + SequenceType type = infer("(\"s\", 12)", extension); + assertEquals(SequenceCardinality.MANY, type.getCardinality()); + assertEquals( + List.of(BuiltinTypesCatalogue.stringItem, BuiltinTypesCatalogue.integerItem), + type.getItemType().getTypes()); + } + + @Test + void atomicAndStructuredValuesKeepEveryMemberInOperandOrder() { + // Unboxing ignores the members that are not arrays; the array content keeps the comma's union. + SequenceType type = infer("[1,2,\"a\",\"b\",{\"a\":12}][]", "jq"); + assertEquals(SequenceType.Arity.ZeroOrMore, type.getArity()); + List members = type.getItemType().getTypes(); + assertEquals(3, members.size()); + assertEquals(BuiltinTypesCatalogue.integerItem, members.get(0)); + assertEquals(BuiltinTypesCatalogue.stringItem, members.get(1)); + assertTrue(members.get(2).isObjectItemType()); + assertEquals( + BuiltinTypesCatalogue.integerItem, + members.get(2).getObjectContentFacet("a").getType()); + } + + @Test + void lookupOnMixedSequenceKeepsTheMatchingMember() { + SequenceType type = infer("(1, {\"a\": 12}).a", "jq"); + assertEquals(BuiltinTypesCatalogue.integerItem, type.getItemType()); + } + @Test void assertedMultipleValuesStillBecomeOnlyAnArrayInObjectFields() { SequenceType type = infer("{\"b\": ((1, 2) is statically xs:integer+)}.b", "jq"); diff --git a/src/test/resources/test_files/static-typing/postfix/arraylookup1.jq b/src/test/resources/test_files/static-typing/postfix/arraylookup1.jq index 8c584eb83d..c21b2ba180 100644 --- a/src/test/resources/test_files/static-typing/postfix/arraylookup1.jq +++ b/src/test/resources/test_files/static-typing/postfix/arraylookup1.jq @@ -1,2 +1,2 @@ (:JIQS: ShouldRun :) -([1,2,3][[2]]) is statically integer?, (([1,2,3], [1,2,3])[[1]]) is statically item*, ([1,2,3][[12]]) is statically integer?, (({"a":12}, 3)[[4]]) is statically item* \ No newline at end of file +([1,2,3][[2]]) is statically integer?, (([1,2,3], [1,2,3])[[1]]) is statically item*, ([1,2,3][[12]]) is statically integer? \ No newline at end of file diff --git a/src/test/resources/test_files/static-typing/postfix/arraylookup5.jq b/src/test/resources/test_files/static-typing/postfix/arraylookup5.jq new file mode 100644 index 0000000000..b844f567ef --- /dev/null +++ b/src/test/resources/test_files/static-typing/postfix/arraylookup5.jq @@ -0,0 +1,2 @@ +(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) +({"a":12}, 3)[[4]] \ No newline at end of file diff --git a/src/test/resources/test_files/static-typing/postfix/arrayunbox1.jq b/src/test/resources/test_files/static-typing/postfix/arrayunbox1.jq index aedae45f49..14b55ae6b1 100644 --- a/src/test/resources/test_files/static-typing/postfix/arrayunbox1.jq +++ b/src/test/resources/test_files/static-typing/postfix/arrayunbox1.jq @@ -1,2 +1,3 @@ (:JIQS: ShouldRun :) -([1,2,3][]) is statically integer*, (([1,2],[3])[]) is statically item*, (({"a":12}, 3)[]) is statically item*, ([1,2,"a","b",{"a":12}][]) is statically item* \ No newline at end of file +(: The exact union of the last expression is checked in IsStaticallyTypeInferenceTest. :) +([1,2,3][]) is statically integer*, (([1,2],[3])[]) is statically item*, ([1,2,"a","b",{"a":12}][]) \ No newline at end of file diff --git a/src/test/resources/test_files/static-typing/postfix/arrayunbox5.jq b/src/test/resources/test_files/static-typing/postfix/arrayunbox5.jq new file mode 100644 index 0000000000..f597f004a9 --- /dev/null +++ b/src/test/resources/test_files/static-typing/postfix/arrayunbox5.jq @@ -0,0 +1,2 @@ +(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) +({"a":12}, 3)[] \ No newline at end of file diff --git a/src/test/resources/test_files/static-typing/postfix/objectlookup1.jq b/src/test/resources/test_files/static-typing/postfix/objectlookup1.jq index 4d5e615b49..813432d519 100644 --- a/src/test/resources/test_files/static-typing/postfix/objectlookup1.jq +++ b/src/test/resources/test_files/static-typing/postfix/objectlookup1.jq @@ -1,2 +1,2 @@ (:JIQS: ShouldRun :) -({"a":12}.a) is statically integer, (({"a": 15}, {"b":"qwe"}, {"a":12}).a) is statically item*, ({"a":12}.(12)) is statically item?, ({"a":12}.("a")) is statically integer, ((1, [12]).a) is statically item*, ((1, 2, {"a":12}).b) is statically item* \ No newline at end of file +({"a":12}.a) is statically integer, (({"a": 15}, {"b":"qwe"}, {"a":12}).a) is statically item*, ({"a":12}.(12)) is statically item?, ({"a":12}.("a")) is statically integer \ No newline at end of file diff --git a/src/test/resources/test_files/static-typing/postfix/objectlookup8.jq b/src/test/resources/test_files/static-typing/postfix/objectlookup8.jq new file mode 100644 index 0000000000..f8def1df17 --- /dev/null +++ b/src/test/resources/test_files/static-typing/postfix/objectlookup8.jq @@ -0,0 +1,2 @@ +(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) +(1, [12]).a \ No newline at end of file diff --git a/src/test/resources/test_files/static-typing/postfix/objectlookup9.jq b/src/test/resources/test_files/static-typing/postfix/objectlookup9.jq new file mode 100644 index 0000000000..d98ac1342f --- /dev/null +++ b/src/test/resources/test_files/static-typing/postfix/objectlookup9.jq @@ -0,0 +1,2 @@ +(:JIQS: ShouldCrash; ErrorCode="XPST0005" :) +(1, 2, {"a":12}).b \ No newline at end of file From b60b973395bdd489fc56ba169ed280791f6b46d3 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Thu, 8 Oct 2026 17:13:42 +0200 Subject: [PATCH 29/36] feat: enhance type inference and cardinality handling in JSONiq --- docs/Types.md | 13 ++++--- .../rumbledb/compiler/InferTypeVisitor.java | 38 +++++++++++++------ .../rumbledb/types/SequenceCardinality.java | 11 +++++- .../java/org/rumbledb/types/SequenceType.java | 23 ++++++++--- .../IsStaticallyTypeInferenceTest.java | 32 +++++++++++++++- .../types/SequenceCardinalityTest.java | 2 +- .../test_files/static-typing/control/if2.jq | 3 +- .../test_files/static-typing/control/if5.jq | 3 +- .../test_files/static-typing/control/if6.jq | 3 +- .../static-typing/control/switch12.jq | 3 +- .../static-typing/control/switch2.jq | 3 +- .../test_files/static-typing/control/try1.jq | 3 +- .../test_files/static-typing/control/try2.jq | 3 +- .../test_files/static-typing/control/try5.jq | 3 +- .../static-typing/control/typeswitch2.jq | 3 +- .../static-typing/control/typeswitch6.jq | 3 +- .../test_files/static-typing/misc/minmax1.jq | 2 + .../test_files/static-typing/misc/minmax2.jq | 2 + .../test_files/static-typing/misc/minmax3.jq | 2 + 19 files changed, 118 insertions(+), 37 deletions(-) create mode 100644 src/test/resources/test_files/static-typing/misc/minmax1.jq create mode 100644 src/test/resources/test_files/static-typing/misc/minmax2.jq create mode 100644 src/test/resources/test_files/static-typing/misc/minmax3.jq diff --git a/docs/Types.md b/docs/Types.md index c0314e74d1..cb752be0fc 100644 --- a/docs/Types.md +++ b/docs/Types.md @@ -294,14 +294,15 @@ This asserts exact inference, rather than compatibility with a supertype. For ex `xs:anyAtomicType+` in an `instance of` or `treat as` expression, but its inferred item type is the more precise anonymous union `xs:string | xs:integer`. -Inference can distinguish multiple-only cardinality (`MANY`) from one-or-more cardinality (`ONE_OR_MANY`). Both -have the declared occurrence indicator `+`, so `(1, 2) is statically xs:integer+` succeeds. Internal cardinality -refinements are not compared directly. A successful assertion returns the expression's value unchanged and -preserves its precise inferred type, including those refinements, for subsequent inference. +Inference can know more about the number of items than an occurrence indicator shows: for example, that `(1, 2)` +has at least two items. Such an expression is still written `xs:integer+`, so `(1, 2) is statically xs:integer+` +succeeds. A successful assertion returns the expression's value unchanged, and inference continues with its +precise type. Query type syntax cannot express anonymous unions such as `xs:string | xs:integer`; this notation describes -the inferred type, not executable type syntax. Tests for their exact members therefore use Java compiler tests. -The `is statically` comparison does not widen inferred unions to their common supertype. +the inferred type, not executable type syntax. The `is statically` comparison does not widen inferred unions to +their common supertype, so an assertion cannot match the type of an expression such as `("s", 12)` or +`if ($c) then "s" else 12`. ## What's next? diff --git a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java index 663957b599..d94520f266 100644 --- a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java +++ b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java @@ -664,11 +664,11 @@ private SequenceType inferStrictMinMaxReturnType( } ItemType inputItemType = inputType.getItemType(); - if (!inputItemType.isSubtypeOf(BuiltinTypesCatalogue.atomicItem) - || inputItemType.equals(BuiltinTypesCatalogue.atomicItem)) { + if (!hasMutuallyComparableItems(inputItemType)) { throwStaticTypeException( functionName - + " requires its inferred input item type to be an atomic type other than xs:anyAtomicType, found " + + " requires its inferred input item type to be an atomic type other than xs:anyAtomicType," + + " or a union of numeric types or of xs:string and xs:anyURI, found " + inputType, ErrorCode.InvalidArgumentType, expression.getMetadata()); @@ -681,6 +681,26 @@ private SequenceType inferStrictMinMaxReturnType( return new SequenceType(inputItemType, returnArity); } + /** + * fn:min and fn:max compare every input item with the others, so the input must not mix types that cannot be + * compared, such as (xs:string | xs:integer). Numeric types are compared after promotion, and so are xs:string + * and xs:anyURI. + */ + private static boolean hasMutuallyComparableItems(ItemType itemType) { + if (!itemType.isSubtypeOf(BuiltinTypesCatalogue.atomicItem) + || itemType.equals(BuiltinTypesCatalogue.atomicItem)) { + return false; + } + if (!itemType.isUnionType()) { + return true; + } + List members = itemType.getTypes(); + return members.stream().allMatch(ItemType::isNumeric) + || members.stream() + .allMatch(member -> member.isSubtypeOf(BuiltinTypesCatalogue.stringItem) + || member.isSubtypeOf(BuiltinTypesCatalogue.anyURIItem)); + } + private boolean isBuiltinFunctionName(Name functionName, String localName) { return functionName.getLocalName().equals(localName) && (functionName.getNamespace().equals(Name.JSONIQ_DEFAULT_FUNCTION_NS) @@ -2300,8 +2320,6 @@ public StaticContext visitSimpleMapExpr(SimpleMapExpression expression, StaticCo */ private SequenceCardinality visitFlworClauses(Clause clause) { SequenceCardinality forCardinality = SequenceCardinality.ONE; - SequenceType forType; - while (clause != null) { try { this.visit(clause, clause.getStaticContext()); @@ -2313,13 +2331,9 @@ private SequenceCardinality visitFlworClauses(Clause clause) { } throw e; } - // if there are for clauses we need to consider their arities for the returning expression if (clause.getClauseType() == FLWOR_CLAUSES.FOR) { - forType = ((ForClause) clause).getExpression().getStaticSequenceType(); - // if forType is the empty sequence that means that allowing empty is set otherwise we would have thrown - // an error - // therefore this for loop will generate one tuple binding the empty sequence, so as for the arities - // count as arity.One + // Each tuple so far is repeated once per item of the for clause's sequence. + SequenceType forType = ((ForClause) clause).getExpression().getStaticSequenceType(); if (!forType.isEmptySequence()) { SequenceCardinality sourceCardinality = forType.getCardinality(); if (((ForClause) clause).isAllowEmpty()) { @@ -2328,6 +2342,8 @@ private SequenceCardinality visitFlworClauses(Clause clause) { } forCardinality = sourceCardinality.repeated(forCardinality); } else if (!((ForClause) clause).isAllowEmpty()) { + // Without allowing empty, an empty source produces no tuples; + // with it, each tuple continues once with an empty binding. forCardinality = SequenceCardinality.EMPTY; } } else if (clause.getClauseType() == FLWOR_CLAUSES.GROUP_BY) { diff --git a/src/main/java/org/rumbledb/types/SequenceCardinality.java b/src/main/java/org/rumbledb/types/SequenceCardinality.java index 650642baac..a0cd5d9831 100644 --- a/src/main/java/org/rumbledb/types/SequenceCardinality.java +++ b/src/main/java/org/rumbledb/types/SequenceCardinality.java @@ -20,12 +20,19 @@ * Unlike the declared occurrence indicators, this can express multiple-only and empty-or-multiple. */ public enum SequenceCardinality { + /** The empty sequence, (). */ EMPTY(1), + /** Exactly one item. */ ONE(2), + /** Two or more items. */ MANY(4), + /** The empty sequence or one item, ?. */ ZERO_OR_ONE(3), + /** One or more items, +. */ ONE_OR_MANY(6), - ZERO_OR_MANY(5), + /** The empty sequence or two or more items, but never exactly one. Displayed as *. */ + EMPTY_OR_MANY(5), + /** Any number of items, *. */ ANY(7); private final int mask; @@ -88,7 +95,7 @@ public SequenceType.Arity toArity() { case ONE -> SequenceType.Arity.One; case ZERO_OR_ONE -> SequenceType.Arity.OneOrZero; case MANY, ONE_OR_MANY -> SequenceType.Arity.OneOrMore; - case ZERO_OR_MANY, ANY -> SequenceType.Arity.ZeroOrMore; + case EMPTY_OR_MANY, ANY -> SequenceType.Arity.ZeroOrMore; }; } diff --git a/src/main/java/org/rumbledb/types/SequenceType.java b/src/main/java/org/rumbledb/types/SequenceType.java index 59ba380400..eb70d3fda7 100644 --- a/src/main/java/org/rumbledb/types/SequenceType.java +++ b/src/main/java/org/rumbledb/types/SequenceType.java @@ -157,7 +157,7 @@ public boolean hasOverlapWith(SequenceType other) { public SequenceType leastCommonSupertypeWith(SequenceType other) { ItemType itemSupertype = isEmptySequence() ? other.itemType - : other.isEmptySequence() ? this.itemType : this.itemType.findLeastCommonSuperTypeWith(other.itemType); + : other.isEmptySequence() ? this.itemType : joinItemTypes(other.itemType); return new SequenceType(itemSupertype, this.cardinality.union(other.cardinality)); } @@ -168,11 +168,17 @@ public SequenceType concatenateWith(SequenceType other) { if (other.isEmptySequence()) { return this; } - ItemType contentType = concatenateItemTypes(other.itemType); + ItemType contentType = joinItemTypes(other.itemType); return new SequenceType(contentType, this.cardinality.concatenate(other.cardinality)); } - private ItemType concatenateItemTypes(ItemType other) { + /** + * Joins the item types of two sequences that are concatenated or that are alternatives, e.g. the branches of a + * conditional. Atomic types, and types of different kinds such as xs:integer and an object, become an inferred + * union that keeps its members in operand order. Two types of the same structured kind keep their existing join, + * which navigation and native execution rely on. + */ + private ItemType joinItemTypes(ItemType other) { if (this.itemType.equals(other)) { return this.itemType; } @@ -187,14 +193,19 @@ private ItemType concatenateItemTypes(ItemType other) { ? this.itemType.findLeastCommonSuperTypeLax(other) : this.itemType.findLeastCommonSuperTypeWith(other); } - if (this.itemType.isArrayItemType() && other.isArrayItemType()) { - // Arrays have an existing join used by navigation and native execution. + if (haveSameStructuredKind(this.itemType, other)) { return this.itemType.findLeastCommonSuperTypeWith(other); } - // Atomic values, or values of different kinds such as (1, {"a": 1}), keep their members in operand order. return ItemTypeFactory.createInferredUnionType(List.of(this.itemType, other)); } + private static boolean haveSameStructuredKind(ItemType left, ItemType right) { + return (left.isArrayItemType() && right.isArrayItemType()) + || (left.isMapItemType() && right.isMapItemType()) + || (left.isFunctionItemType() && right.isFunctionItemType()) + || (left.isNodeItemType() && right.isNodeItemType()); + } + private static boolean haveSameObjectFields(ItemType left, ItemType right) { if (left.getObjectKeysFacet().size() != right.getObjectKeysFacet().size()) { return false; diff --git a/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java b/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java index a70c935c93..d481187e0f 100644 --- a/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java +++ b/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java @@ -55,7 +55,7 @@ void occurrenceAssertionPreservesEmptyOrMultipleCardinality() { "declare variable $flag as xs:boolean external; " + "(if ($flag) then (1, 2) else ()) is statically xs:integer*", "jq"); - assertEquals(SequenceCardinality.ZERO_OR_MANY, type.getCardinality()); + assertEquals(SequenceCardinality.EMPTY_OR_MANY, type.getCardinality()); } @ParameterizedTest @@ -112,6 +112,36 @@ void mixedAtomicCommaKeepsMembersInOperandOrder(String extension) { type.getItemType().getTypes()); } + @ParameterizedTest + @ValueSource( + strings = {"if2", "if5", "if6", "switch2", "switch12", "try1", "try2", "try5", "typeswitch2", "typeswitch6" + }) + void alternativesJoinLikeACommaExpression(String fixture) throws IOException { + // Conditional branches, switch cases and catch clauses use the same join as a comma expression. + Path path = Path.of("src/test/resources/test_files/static-typing/control", fixture + ".jq"); + SequenceType type = infer(Files.readString(path), "jq"); + List expectedMembers = + switch (fixture) { + case "if2", "try1", "typeswitch2" -> List.of( + BuiltinTypesCatalogue.integerItem, BuiltinTypesCatalogue.stringItem); + case "if5", "switch2" -> List.of( + BuiltinTypesCatalogue.stringItem, BuiltinTypesCatalogue.integerItem); + case "try2" -> List.of(BuiltinTypesCatalogue.integerItem, BuiltinTypesCatalogue.stringItem); + case "if6" -> List.of(BuiltinTypesCatalogue.arrayItem, BuiltinTypesCatalogue.integerItem); + case "switch12", "try5" -> List.of( + BuiltinTypesCatalogue.decimalItem, BuiltinTypesCatalogue.stringItem); + case "typeswitch6" -> List.of(BuiltinTypesCatalogue.stringItem, BuiltinTypesCatalogue.decimalItem); + default -> throw new IllegalArgumentException(fixture); + }; + assertEquals(expectedMembers, type.getItemType().getTypes()); + SequenceType.Arity expectedArity = + switch (fixture) { + case "if5", "if6", "try2" -> SequenceType.Arity.OneOrMore; + default -> SequenceType.Arity.One; + }; + assertEquals(expectedArity, type.getArity()); + } + @Test void atomicAndStructuredValuesKeepEveryMemberInOperandOrder() { // Unboxing ignores the members that are not arrays; the array content keeps the comma's union. diff --git a/src/test/java/org/rumbledb/types/SequenceCardinalityTest.java b/src/test/java/org/rumbledb/types/SequenceCardinalityTest.java index 8de62a14d2..89abba4a33 100644 --- a/src/test/java/org/rumbledb/types/SequenceCardinalityTest.java +++ b/src/test/java/org/rumbledb/types/SequenceCardinalityTest.java @@ -75,7 +75,7 @@ void sequenceTypesKeepMultipleOnlyThroughJoiningAndGrouping() { assertFalse(SequenceType.createSequenceType("integer+").isSubtypeOf(multiple)); assertFalse(multiple.hasOverlapWith(singleton)); assertEquals( - SequenceCardinality.ZERO_OR_MANY, + SequenceCardinality.EMPTY_OR_MANY, multiple.leastCommonSupertypeWith(empty).getCardinality()); assertEquals(SequenceCardinality.MANY, multiple.incrementArity().getCardinality()); assertEquals( diff --git a/src/test/resources/test_files/static-typing/control/if2.jq b/src/test/resources/test_files/static-typing/control/if2.jq index 20ed092485..669a081049 100644 --- a/src/test/resources/test_files/static-typing/control/if2.jq +++ b/src/test/resources/test_files/static-typing/control/if2.jq @@ -1,5 +1,6 @@ jsoniq version "3.1"; (:JIQS: ShouldRun :) +(: The exact inferred union is checked in IsStaticallyTypeInferenceTest. :) (if(true) then 3 -else "str") is statically anyAtomicType \ No newline at end of file +else "str") \ No newline at end of file diff --git a/src/test/resources/test_files/static-typing/control/if5.jq b/src/test/resources/test_files/static-typing/control/if5.jq index 2575806f87..0e3a4a7651 100644 --- a/src/test/resources/test_files/static-typing/control/if5.jq +++ b/src/test/resources/test_files/static-typing/control/if5.jq @@ -1,5 +1,6 @@ jsoniq version "3.1"; (:JIQS: ShouldRun :) +(: The exact inferred union is checked in IsStaticallyTypeInferenceTest. :) (if(true) then "qwr" -else (1,2,3)) is statically anyAtomicType+ \ No newline at end of file +else (1,2,3)) \ No newline at end of file diff --git a/src/test/resources/test_files/static-typing/control/if6.jq b/src/test/resources/test_files/static-typing/control/if6.jq index 426d503d9c..3c79819513 100644 --- a/src/test/resources/test_files/static-typing/control/if6.jq +++ b/src/test/resources/test_files/static-typing/control/if6.jq @@ -1,4 +1,5 @@ (:JIQS: ShouldRun :) +(: The exact inferred union is checked in IsStaticallyTypeInferenceTest. :) (if(true) then [] -else (1,2,3)) is statically item+ \ No newline at end of file +else (1,2,3)) \ No newline at end of file diff --git a/src/test/resources/test_files/static-typing/control/switch12.jq b/src/test/resources/test_files/static-typing/control/switch12.jq index de2b0c9ba9..2c8b2a6ce9 100644 --- a/src/test/resources/test_files/static-typing/control/switch12.jq +++ b/src/test/resources/test_files/static-typing/control/switch12.jq @@ -1,6 +1,7 @@ jsoniq version "3.1"; (:JIQS: ShouldRun :) +(: The exact inferred union is checked in IsStaticallyTypeInferenceTest. :) (switch(3) case 2 case 3 return "a" case 4 return 12 -default return 12.4) is statically anyAtomicType \ No newline at end of file +default return 12.4) \ No newline at end of file diff --git a/src/test/resources/test_files/static-typing/control/switch2.jq b/src/test/resources/test_files/static-typing/control/switch2.jq index 553819e0e8..edbdfff492 100644 --- a/src/test/resources/test_files/static-typing/control/switch2.jq +++ b/src/test/resources/test_files/static-typing/control/switch2.jq @@ -1,6 +1,7 @@ jsoniq version "3.1"; (:JIQS: ShouldRun :) +(: The exact inferred union is checked in IsStaticallyTypeInferenceTest. :) (switch(3) case 12 return 12 default return "not 12" -) is statically anyAtomicType \ No newline at end of file +) \ No newline at end of file diff --git a/src/test/resources/test_files/static-typing/control/try1.jq b/src/test/resources/test_files/static-typing/control/try1.jq index 3b5dd447c2..26531561d4 100644 --- a/src/test/resources/test_files/static-typing/control/try1.jq +++ b/src/test/resources/test_files/static-typing/control/try1.jq @@ -1,4 +1,5 @@ jsoniq version "3.1"; (:JIQS: ShouldRun :) +(: The exact inferred union is checked in IsStaticallyTypeInferenceTest. :) (try { 3 } -catch * { "str" }) is statically anyAtomicType \ No newline at end of file +catch * { "str" }) \ No newline at end of file diff --git a/src/test/resources/test_files/static-typing/control/try2.jq b/src/test/resources/test_files/static-typing/control/try2.jq index fa52839029..05413d01ec 100644 --- a/src/test/resources/test_files/static-typing/control/try2.jq +++ b/src/test/resources/test_files/static-typing/control/try2.jq @@ -1,4 +1,5 @@ jsoniq version "3.1"; (:JIQS: ShouldRun :) +(: The exact inferred union is checked in IsStaticallyTypeInferenceTest. :) (try { (1,2) } -catch * { "str" }) is statically anyAtomicType+ \ No newline at end of file +catch * { "str" }) \ No newline at end of file diff --git a/src/test/resources/test_files/static-typing/control/try5.jq b/src/test/resources/test_files/static-typing/control/try5.jq index b6fcb5cb56..a3ea064b65 100644 --- a/src/test/resources/test_files/static-typing/control/try5.jq +++ b/src/test/resources/test_files/static-typing/control/try5.jq @@ -1,5 +1,6 @@ jsoniq version "3.1"; (:JIQS: ShouldRun :) +(: The exact inferred union is checked in IsStaticallyTypeInferenceTest. :) (try{ 3 } catch err:XPTY0004 { 12.3 } -catch * { "asd" }) is statically anyAtomicType \ No newline at end of file +catch * { "asd" }) \ No newline at end of file diff --git a/src/test/resources/test_files/static-typing/control/typeswitch2.jq b/src/test/resources/test_files/static-typing/control/typeswitch2.jq index 53bf3e55cf..c6fa20cef5 100644 --- a/src/test/resources/test_files/static-typing/control/typeswitch2.jq +++ b/src/test/resources/test_files/static-typing/control/typeswitch2.jq @@ -1,5 +1,6 @@ jsoniq version "3.1"; (:JIQS: ShouldRun :) +(: The exact inferred union is checked in IsStaticallyTypeInferenceTest. :) (typeswitch(3) case integer return 1 -default return "asd") is statically anyAtomicType \ No newline at end of file +default return "asd") \ No newline at end of file diff --git a/src/test/resources/test_files/static-typing/control/typeswitch6.jq b/src/test/resources/test_files/static-typing/control/typeswitch6.jq index 612dc547fb..5a32a09625 100644 --- a/src/test/resources/test_files/static-typing/control/typeswitch6.jq +++ b/src/test/resources/test_files/static-typing/control/typeswitch6.jq @@ -1,6 +1,7 @@ jsoniq version "3.1"; (:JIQS: ShouldRun :) +(: The exact inferred union is checked in IsStaticallyTypeInferenceTest. :) (typeswitch(3) case decimal return "asd" case integer return 1 -default return 12.2) is statically anyAtomicType +default return 12.2) \ No newline at end of file diff --git a/src/test/resources/test_files/static-typing/misc/minmax1.jq b/src/test/resources/test_files/static-typing/misc/minmax1.jq new file mode 100644 index 0000000000..b7d59c7c90 --- /dev/null +++ b/src/test/resources/test_files/static-typing/misc/minmax1.jq @@ -0,0 +1,2 @@ +(:JIQS: ShouldRun; Output="(3, b)" :) +max((1, 2.5e0, 3 cast as float)), min(("b", "c" cast as anyURI)) \ No newline at end of file diff --git a/src/test/resources/test_files/static-typing/misc/minmax2.jq b/src/test/resources/test_files/static-typing/misc/minmax2.jq new file mode 100644 index 0000000000..8905efc15b --- /dev/null +++ b/src/test/resources/test_files/static-typing/misc/minmax2.jq @@ -0,0 +1,2 @@ +(:JIQS: ShouldCrash; ErrorCode="FORG0006" :) +for $var in ("a","b","c") where max(($var,1)) return $var \ No newline at end of file diff --git a/src/test/resources/test_files/static-typing/misc/minmax3.jq b/src/test/resources/test_files/static-typing/misc/minmax3.jq new file mode 100644 index 0000000000..eae8537b6a --- /dev/null +++ b/src/test/resources/test_files/static-typing/misc/minmax3.jq @@ -0,0 +1,2 @@ +(:JIQS: ShouldCrash; ErrorCode="FORG0006" :) +for $var in ("a","b","c") where min(($var,1)) return $var \ No newline at end of file From 9fbdcaf9427317c208e426455bd391dbb62932fb Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Thu, 8 Oct 2026 20:22:12 +0200 Subject: [PATCH 30/36] fix: make inferred unions comparable and promote their members in arithmetic Comparisons require every pair of union members to be comparable, as static typing is pessimistic in XQuery 3.1 (2.2.3.1). Arithmetic promotes each member separately, and a union has an effective boolean value if every member has one. Anonymous unions from the least common supertype are no longer marked user-defined, so inferred unions flatten them. Objects with the same fields merge even if their field presence differs. Group by and where use named cardinality operations. --- .../rumbledb/compiler/InferTypeVisitor.java | 85 +++++++++++++------ .../java/org/rumbledb/types/ItemType.java | 7 +- .../org/rumbledb/types/ItemTypeFactory.java | 9 ++ .../rumbledb/types/SequenceCardinality.java | 30 +++++-- .../java/org/rumbledb/types/SequenceType.java | 57 ++++++++----- .../org/rumbledb/types/UnionItemType.java | 6 +- .../IsStaticallyTypeInferenceTest.java | 30 +++++++ .../rumbledb/types/ItemTypeFactoryTest.java | 14 +++ .../rumbledb/types/ObjectItemTypeTest.java | 13 +++ 9 files changed, 193 insertions(+), 58 deletions(-) diff --git a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java index d94520f266..88f9c802fc 100644 --- a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java +++ b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java @@ -1339,8 +1339,51 @@ public StaticContext visitAdditiveExpr(AdditiveExpression expression, StaticCont return argument; } + // Types must match exactly or be both numeric or both promotable to string or both durations or one must be null + private static boolean areComparable(ItemType left, ItemType right) { + return left.equals(right) + || (left.isNumeric() && right.isNumeric()) + || (left.isSubtypeOf(BuiltinTypesCatalogue.durationItem) + && right.isSubtypeOf(BuiltinTypesCatalogue.durationItem)) + || (left.canBePromotedTo(BuiltinTypesCatalogue.stringItem) + && right.canBePromotedTo(BuiltinTypesCatalogue.stringItem)) + || left.equals(BuiltinTypesCatalogue.nullItem) + || right.equals(BuiltinTypesCatalogue.nullItem); + } + + // Inequality is not defined for hexBinary and base64binary or for duration of different types + private static boolean isOperatorDefined( + ItemType left, ItemType right, ComparisonExpression.ComparisonOperator operator) { + if (operator == ComparisonExpression.ComparisonOperator.VC_EQ + || operator == ComparisonExpression.ComparisonOperator.VC_NE + || operator == ComparisonExpression.ComparisonOperator.GC_EQ + || operator == ComparisonExpression.ComparisonOperator.GC_NE) { + return true; + } + return !(left.equals(BuiltinTypesCatalogue.hexBinaryItem) + || left.equals(BuiltinTypesCatalogue.base64BinaryItem) + || left.equals(BuiltinTypesCatalogue.durationItem) + || right.equals(BuiltinTypesCatalogue.durationItem) + || ((left.equals(BuiltinTypesCatalogue.dayTimeDurationItem) + || left.equals(BuiltinTypesCatalogue.yearMonthDurationItem)) + && !right.equals(left))); + } + // This function assume 2 numeric ItemType private ItemType resolveNumericType(ItemType left, ItemType right) { + List leftMembers = ItemTypeFactory.getInferredUnionMembers(left); + List rightMembers = ItemTypeFactory.getInferredUnionMembers(right); + if (leftMembers.size() > 1 || rightMembers.size() > 1) { + // Each member pair is promoted separately, e.g. (xs:integer | xs:double) + xs:integer + // is (xs:integer | xs:double). + List results = new ArrayList<>(); + for (ItemType leftMember : leftMembers) { + for (ItemType rightMember : rightMembers) { + results.add(resolveNumericType(leftMember, rightMember)); + } + } + return ItemTypeFactory.createInferredUnionType(results); + } if (left.equals(BuiltinTypesCatalogue.doubleItem) || right.equals(BuiltinTypesCatalogue.doubleItem)) { return BuiltinTypesCatalogue.doubleItem; } else if (left.equals(BuiltinTypesCatalogue.floatItem) || right.equals(BuiltinTypesCatalogue.floatItem)) { @@ -1615,33 +1658,22 @@ public StaticContext visitComparisonExpr(ComparisonExpression expression, Static expression.getMetadata()); } - // Type must match exactly or be both numeric or both promotable to string or both durations or one must be - // null - if (!leftItemType.equals(rightItemType) - && !(leftItemType.isNumeric() && rightItemType.isNumeric()) - && !(leftItemType.isSubtypeOf(BuiltinTypesCatalogue.durationItem) - && rightItemType.isSubtypeOf(BuiltinTypesCatalogue.durationItem)) - && !(leftItemType.canBePromotedTo(BuiltinTypesCatalogue.stringItem) - && rightItemType.canBePromotedTo(BuiltinTypesCatalogue.stringItem)) - && !(leftItemType.equals(BuiltinTypesCatalogue.nullItem) - || rightItemType.equals(BuiltinTypesCatalogue.nullItem))) { + List leftMembers = ItemTypeFactory.getInferredUnionMembers(leftItemType); + List rightMembers = ItemTypeFactory.getInferredUnionMembers(rightItemType); + if (leftMembers.size() > 1 || rightMembers.size() > 1) { + // Static typing is pessimistic (XQuery 3.1, 2.2.3.1): every pair of members must be comparable. + boolean comparable = leftMembers.stream().allMatch(left -> rightMembers.stream() + .allMatch(right -> areComparable(left, right) && isOperatorDefined(left, right, operator))); + if (!comparable) { + throwStaticTypeException( + "It is not possible to compare these types: " + leftItemType + " and " + rightItemType, + expression.getMetadata()); + } + } else if (!areComparable(leftItemType, rightItemType)) { throwStaticTypeException( "It is not possible to compare these types: " + leftItemType + " and " + rightItemType, expression.getMetadata()); - } - - // Inequality is not defined for hexBinary and base64binary or for duration of different types - if ((operator != ComparisonExpression.ComparisonOperator.VC_EQ - && operator != ComparisonExpression.ComparisonOperator.VC_NE - && operator != ComparisonExpression.ComparisonOperator.GC_EQ - && operator != ComparisonExpression.ComparisonOperator.GC_NE) - && (leftItemType.equals(BuiltinTypesCatalogue.hexBinaryItem) - || leftItemType.equals(BuiltinTypesCatalogue.base64BinaryItem) - || leftItemType.equals(BuiltinTypesCatalogue.durationItem) - || rightItemType.equals(BuiltinTypesCatalogue.durationItem) - || ((leftItemType.equals(BuiltinTypesCatalogue.dayTimeDurationItem) - || leftItemType.equals(BuiltinTypesCatalogue.yearMonthDurationItem)) - && !rightItemType.equals(leftItemType)))) { + } else if (!isOperatorDefined(leftItemType, rightItemType, operator)) { throwStaticTypeException( "It is not possible to compare these types: " + leftItemType @@ -2348,11 +2380,10 @@ private SequenceCardinality visitFlworClauses(Clause clause) { } } else if (clause.getClauseType() == FLWOR_CLAUSES.GROUP_BY) { // Multiple input tuples can collapse into a single group. - forCardinality = SequenceCardinality.fromArity(forCardinality.toArity()); + forCardinality = forCardinality.grouped(); } else if (clause.getClauseType() == FLWOR_CLAUSES.WHERE) { // Filtering tuples can leave zero, one, or multiple tuples. - forCardinality = - SequenceCardinality.fromArity(forCardinality.toArity()).union(SequenceCardinality.EMPTY); + forCardinality = forCardinality.filtered(); } else if (clause.getClauseType() == FLWOR_CLAUSES.WINDOW) { forCardinality = SequenceCardinality.ANY; } diff --git a/src/main/java/org/rumbledb/types/ItemType.java b/src/main/java/org/rumbledb/types/ItemType.java index 49e9545378..b050359875 100644 --- a/src/main/java/org/rumbledb/types/ItemType.java +++ b/src/main/java/org/rumbledb/types/ItemType.java @@ -224,14 +224,17 @@ default ItemType findLeastCommonSuperTypeWith(ItemType other) { return BuiltinTypesCatalogue.atomicItem; } return new UnionItemType( - null, BuiltinTypesCatalogue.atomicItem, Arrays.asList(this, BuiltinTypesCatalogue.nullItem)); + null, BuiltinTypesCatalogue.atomicItem, Arrays.asList(this, BuiltinTypesCatalogue.nullItem), false); } if (other.isAtomicItemType() && this.equals(BuiltinTypesCatalogue.nullItem)) { if (other.equals(BuiltinTypesCatalogue.atomicItem)) { return BuiltinTypesCatalogue.atomicItem; } return new UnionItemType( - null, BuiltinTypesCatalogue.atomicItem, Arrays.asList(other, BuiltinTypesCatalogue.nullItem)); + null, + BuiltinTypesCatalogue.atomicItem, + Arrays.asList(other, BuiltinTypesCatalogue.nullItem), + false); } ItemType current = this; while (other.getTypeTreeDepth() > current.getTypeTreeDepth()) { diff --git a/src/main/java/org/rumbledb/types/ItemTypeFactory.java b/src/main/java/org/rumbledb/types/ItemTypeFactory.java index 7509682daf..3820baa5bf 100644 --- a/src/main/java/org/rumbledb/types/ItemTypeFactory.java +++ b/src/main/java/org/rumbledb/types/ItemTypeFactory.java @@ -242,6 +242,15 @@ public static ItemType createInferredUnionType(List alternatives) { return new UnionItemType(null, baseType, members, false); } + /** + * Returns the members of an inferred union, or the type itself for any other type, including named unions. + */ + public static List getInferredUnionMembers(ItemType type) { + List members = new ArrayList<>(); + collectInferredUnionMembers(type, members); + return members; + } + private static void collectInferredUnionMembers(ItemType type, List members) { if (type.isUnionType() && !type.hasName() && !type.isUserDefined()) { for (ItemType member : type.getTypes()) { diff --git a/src/main/java/org/rumbledb/types/SequenceCardinality.java b/src/main/java/org/rumbledb/types/SequenceCardinality.java index a0cd5d9831..00c7ed4207 100644 --- a/src/main/java/org/rumbledb/types/SequenceCardinality.java +++ b/src/main/java/org/rumbledb/types/SequenceCardinality.java @@ -35,6 +35,14 @@ public enum SequenceCardinality { /** Any number of items, *. */ ANY(7); + private static final SequenceCardinality[] BY_MASK = new SequenceCardinality[8]; + + static { + for (SequenceCardinality cardinality : values()) { + BY_MASK[cardinality.mask] = cardinality; + } + } + private final int mask; SequenceCardinality(int mask) { @@ -89,6 +97,20 @@ public SequenceCardinality replaceZeroWithOne() { return fromPossibilities(false, allowsZero() || allowsOne(), allowsMany()); } + /** + * The cardinality after grouping, which can merge any number of items into one but never removes all of them. + */ + public SequenceCardinality grouped() { + return fromPossibilities(allowsZero(), allowsOne() || allowsMany(), allowsMany()); + } + + /** + * The cardinality after filtering, which can keep any number of items up to the original size. + */ + public SequenceCardinality filtered() { + return fromPossibilities(true, allowsOne() || allowsMany(), allowsMany()); + } + public SequenceType.Arity toArity() { return switch (this) { case EMPTY -> SequenceType.Arity.Zero; @@ -114,11 +136,9 @@ private static SequenceCardinality fromPossibilities(boolean zero, boolean one, } private static SequenceCardinality fromMask(int mask) { - for (SequenceCardinality cardinality : values()) { - if (cardinality.mask == mask) { - return cardinality; - } + if (BY_MASK[mask] == null) { + throw new IllegalArgumentException("A sequence cardinality must allow at least one size."); } - throw new IllegalArgumentException("A sequence cardinality must allow at least one size."); + return BY_MASK[mask]; } } diff --git a/src/main/java/org/rumbledb/types/SequenceType.java b/src/main/java/org/rumbledb/types/SequenceType.java index eb70d3fda7..63bdbbe4a4 100644 --- a/src/main/java/org/rumbledb/types/SequenceType.java +++ b/src/main/java/org/rumbledb/types/SequenceType.java @@ -22,9 +22,9 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; import lombok.Getter; +import lombok.NonNull; import lombok.extern.log4j.Log4j2; import org.rumbledb.context.DynamicContext; @@ -45,23 +45,33 @@ public class SequenceType implements Serializable { private SequenceCardinality cardinality; public SequenceType(ItemType itemType, Arity arity) { - this(itemType, SequenceCardinality.fromArity(arity == null ? Arity.ZeroOrMore : arity)); + this(itemType, cardinalityOf(itemType, arity)); } public SequenceType(ItemType itemType, SequenceCardinality cardinality) { this.cardinality = cardinality; this.itemType = cardinality == SequenceCardinality.EMPTY ? BuiltinTypesCatalogue.item : itemType; if (this.itemType == null) { - log.warn("Missing item type in incomplete sequence type {}, defaulting to item.", getArity()); + log.warn("Missing item type in incomplete sequence type " + + getArity() + + ", defaulting to item. Please let us know as we would like to look into this!"); this.itemType = BuiltinTypesCatalogue.item; } } - public SequenceType(ItemType itemType) { - this(itemType, SequenceCardinality.ONE); - if (itemType == null) { - throw new OurBadException("Missing item type in incomplete sequence type " + getArity()); + public SequenceType(@NonNull ItemType itemType) { + this.itemType = itemType; + this.cardinality = SequenceCardinality.ONE; + } + + private static SequenceCardinality cardinalityOf(ItemType itemType, Arity arity) { + if (arity == null) { + log.warn("Missing arity in incomplete sequence type " + + itemType + + ", defaulting to *. Please let us know as we would like to look into this!"); + return SequenceCardinality.ANY; } + return SequenceCardinality.fromArity(arity); } private SequenceType() { @@ -124,16 +134,22 @@ public boolean isAritySubtypeOf(Arity superArity) { public boolean hasEffectiveBooleanValue() { if (isEmptySequence()) { return true; - } else if (this.itemType.isSubtypeOf(BuiltinTypesCatalogue.JSONItem)) { + } + // An inferred union has an effective boolean value only if every member has one. + return ItemTypeFactory.getInferredUnionMembers(this.itemType).stream() + .allMatch(this::memberHasEffectiveBooleanValue); + } + + private boolean memberHasEffectiveBooleanValue(ItemType member) { + if (member.isSubtypeOf(BuiltinTypesCatalogue.JSONItem)) { return true; - } else { - return !this.cardinality.allowsMany() - && (this.itemType.isNumeric() - || this.itemType.equals(BuiltinTypesCatalogue.stringItem) - || this.itemType.equals(BuiltinTypesCatalogue.anyURIItem) - || this.itemType.equals(BuiltinTypesCatalogue.nullItem) - || this.itemType.equals(BuiltinTypesCatalogue.booleanItem)); } + return !this.cardinality.allowsMany() + && (member.isNumeric() + || member.equals(BuiltinTypesCatalogue.stringItem) + || member.equals(BuiltinTypesCatalogue.anyURIItem) + || member.equals(BuiltinTypesCatalogue.nullItem) + || member.equals(BuiltinTypesCatalogue.booleanItem)); } public boolean hasOverlapWith(SequenceType other) { @@ -182,13 +198,16 @@ private ItemType joinItemTypes(ItemType other) { if (this.itemType.equals(other)) { return this.itemType; } + if (other.isSubtypeOf(this.itemType)) { + return this.itemType; + } if (this.itemType.isObjectItemType() && other.isObjectItemType()) { boolean sameShape = !this.itemType.hasName() && !other.hasName() && this.itemType.getBaseType().equals(BuiltinTypesCatalogue.objectItem) && other.getBaseType().equals(BuiltinTypesCatalogue.objectItem) && haveSameObjectFields(this.itemType, other); - // Keep equivalent object schemas usable as DataFrames; merge field presence conservatively. + // Keep objects with the same fields usable as DataFrames; a field is required only if both require it. return sameShape ? this.itemType.findLeastCommonSuperTypeLax(other) : this.itemType.findLeastCommonSuperTypeWith(other); @@ -213,11 +232,7 @@ private static boolean haveSameObjectFields(ItemType left, ItemType right) { for (String key : left.getObjectKeysFacet()) { FieldDescriptor leftField = left.getObjectContentFacet(key); FieldDescriptor rightField = right.getObjectContentFacet(key); - if (rightField == null - || !leftField.getType().equals(rightField.getType()) - || leftField.isRequired() != rightField.isRequired() - || !Objects.equals(leftField.isUnique(), rightField.isUnique()) - || !Objects.equals(leftField.getDefaultValue(), rightField.getDefaultValue())) { + if (rightField == null || !leftField.getType().equals(rightField.getType())) { return false; } } diff --git a/src/main/java/org/rumbledb/types/UnionItemType.java b/src/main/java/org/rumbledb/types/UnionItemType.java index 145452dddc..b61c0a3b02 100644 --- a/src/main/java/org/rumbledb/types/UnionItemType.java +++ b/src/main/java/org/rumbledb/types/UnionItemType.java @@ -286,7 +286,7 @@ public ItemType findLeastCommonSuperTypeWith(ItemType other) { // The atomic join below widens to an atomic base type, which cannot cover non-atomic members // (for example, the array alternative of an inferred object field). Keep every member instead. if (resultTypes.stream().anyMatch(type -> !type.isSubtypeOf(BuiltinTypesCatalogue.atomicItem))) { - return new UnionItemType(null, BuiltinTypesCatalogue.item, new ArrayList<>(resultTypes)); + return new UnionItemType(null, BuiltinTypesCatalogue.item, new ArrayList<>(resultTypes), false); } for (ItemType member : resultTypes) { if (member.equals(BuiltinTypesCatalogue.nullItem)) { @@ -311,9 +311,9 @@ public ItemType findLeastCommonSuperTypeWith(ItemType other) { return BuiltinTypesCatalogue.numericItem; } if (this.baseType.isAtomicItemType() && otherBaseType.isAtomicItemType()) { - return new UnionItemType(null, BuiltinTypesCatalogue.atomicItem, new ArrayList<>(resultTypes)); + return new UnionItemType(null, BuiltinTypesCatalogue.atomicItem, new ArrayList<>(resultTypes), false); } - return new UnionItemType(null, BuiltinTypesCatalogue.item, new ArrayList<>(resultTypes)); + return new UnionItemType(null, BuiltinTypesCatalogue.item, new ArrayList<>(resultTypes), false); } @Override diff --git a/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java b/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java index d481187e0f..e05c4ea92a 100644 --- a/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java +++ b/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java @@ -32,6 +32,7 @@ import org.rumbledb.config.CompilationConfiguration; import org.rumbledb.config.RumbleConfiguration; import org.rumbledb.exceptions.IsStaticallyUnexpectedTypeException; +import org.rumbledb.exceptions.UnexpectedStaticTypeException; import org.rumbledb.types.BuiltinTypesCatalogue; import org.rumbledb.types.ItemType; import org.rumbledb.types.SequenceCardinality; @@ -170,6 +171,35 @@ void assertedMultipleValuesStillBecomeOnlyAnArrayInObjectFields() { assertEquals(BuiltinTypesCatalogue.integerItem, type.getItemType().getArrayContentFacet()); } + @Test + void arithmeticPromotesEachUnionMember() { + SequenceType type = infer("for $x in (1, 2.5e0) return $x + 1", "jq"); + assertEquals( + List.of(BuiltinTypesCatalogue.integerItem, BuiltinTypesCatalogue.doubleItem), + type.getItemType().getTypes()); + } + + @Test + void unionsAreComparableIfEveryMemberPairIs() { + String flags = "declare variable $c as xs:boolean external; declare variable $d as xs:boolean external; "; + assertEquals( + BuiltinTypesCatalogue.booleanItem, + infer(flags + "(if ($c) then \"a\" else xs:anyURI(\"b\")) eq \"a\"", "jq") + .getItemType()); + assertThrows( + UnexpectedStaticTypeException.class, + () -> infer(flags + "(if ($c) then 1 else \"a\") eq (if ($d) then 2 else \"b\")", "jq")); + } + + @Test + void unionHasAnEffectiveBooleanValueIfEveryMemberHasOne() { + String flag = "declare variable $c as xs:boolean external; "; + assertDoesNotThrow(() -> infer(flag + "if (if ($c) then 1 else \"a\") then 1 else 2", "jq")); + assertThrows( + UnexpectedStaticTypeException.class, + () -> infer(flag + "if (if ($c) then 1 else current-date()) then 1 else 2", "jq")); + } + @ParameterizedTest @ValueSource( strings = { diff --git a/src/test/java/org/rumbledb/types/ItemTypeFactoryTest.java b/src/test/java/org/rumbledb/types/ItemTypeFactoryTest.java index af11196c6f..b0d3acb13e 100644 --- a/src/test/java/org/rumbledb/types/ItemTypeFactoryTest.java +++ b/src/test/java/org/rumbledb/types/ItemTypeFactoryTest.java @@ -39,6 +39,20 @@ void inferredUnionsFlattenAndRemoveSubsumedAlternatives() { ItemTypeFactory.createInferredUnionType(List.of(union, BuiltinTypesCatalogue.item))); } + @Test + void inferredUnionsFlattenNullableJoins() { + ItemType nullableInteger = + BuiltinTypesCatalogue.integerItem.findLeastCommonSuperTypeWith(BuiltinTypesCatalogue.nullItem); + ItemType union = + ItemTypeFactory.createInferredUnionType(List.of(nullableInteger, BuiltinTypesCatalogue.stringItem)); + assertEquals( + List.of( + BuiltinTypesCatalogue.integerItem, + BuiltinTypesCatalogue.nullItem, + BuiltinTypesCatalogue.stringItem), + union.getTypes()); + } + @Test void namedNullableSchemaUnionRetainsItsIdentity() { ItemType named = new UnionItemType( diff --git a/src/test/java/org/rumbledb/types/ObjectItemTypeTest.java b/src/test/java/org/rumbledb/types/ObjectItemTypeTest.java index 32d2311ae8..1febd5201b 100644 --- a/src/test/java/org/rumbledb/types/ObjectItemTypeTest.java +++ b/src/test/java/org/rumbledb/types/ObjectItemTypeTest.java @@ -129,6 +129,19 @@ public void defaultValuesArePreservedOnlyWhenCompatible() { * @param descriptors the field descriptors * @return the created object type */ + @Test + public void joiningObjectsThatDifferOnlyInFieldPresenceKeepsTheirFields() { + ObjectItemType required = createObjectType(true, field("a", BuiltinTypesCatalogue.intItem, true, false, null)); + ObjectItemType optional = createObjectType(true, field("a", BuiltinTypesCatalogue.intItem, false, false, null)); + + ItemType joined = new SequenceType(required) + .concatenateWith(new SequenceType(optional)) + .getItemType(); + Assertions.assertEquals( + BuiltinTypesCatalogue.intItem, joined.getObjectContentFacet("a").getType()); + Assertions.assertFalse(joined.getObjectContentFacet("a").isRequired()); + } + private ObjectItemType createObjectType(boolean closed, FieldDescriptor... descriptors) { List keys = new ArrayList<>(); List content = new ArrayList<>(); From ee2758bd61ddf85cbff11ce04e4209c7896602a5 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Thu, 8 Oct 2026 20:24:02 +0200 Subject: [PATCH 31/36] refactor: restore the linear fromMask lookup --- .../org/rumbledb/types/SequenceCardinality.java | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/rumbledb/types/SequenceCardinality.java b/src/main/java/org/rumbledb/types/SequenceCardinality.java index 00c7ed4207..4165a21d35 100644 --- a/src/main/java/org/rumbledb/types/SequenceCardinality.java +++ b/src/main/java/org/rumbledb/types/SequenceCardinality.java @@ -35,14 +35,6 @@ public enum SequenceCardinality { /** Any number of items, *. */ ANY(7); - private static final SequenceCardinality[] BY_MASK = new SequenceCardinality[8]; - - static { - for (SequenceCardinality cardinality : values()) { - BY_MASK[cardinality.mask] = cardinality; - } - } - private final int mask; SequenceCardinality(int mask) { @@ -136,9 +128,11 @@ private static SequenceCardinality fromPossibilities(boolean zero, boolean one, } private static SequenceCardinality fromMask(int mask) { - if (BY_MASK[mask] == null) { - throw new IllegalArgumentException("A sequence cardinality must allow at least one size."); + for (SequenceCardinality cardinality : values()) { + if (cardinality.mask == mask) { + return cardinality; + } } - return BY_MASK[mask]; + throw new IllegalArgumentException("A sequence cardinality must allow at least one size."); } } From 40eedab996cd903d4cc2b4a2b46b6014b50f146c Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Thu, 8 Oct 2026 20:42:14 +0200 Subject: [PATCH 32/36] test: drop union tests covered by UnionTypeInferenceTest --- .../IsStaticallyTypeInferenceTest.java | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java b/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java index e05c4ea92a..6087269744 100644 --- a/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java +++ b/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java @@ -171,26 +171,6 @@ void assertedMultipleValuesStillBecomeOnlyAnArrayInObjectFields() { assertEquals(BuiltinTypesCatalogue.integerItem, type.getItemType().getArrayContentFacet()); } - @Test - void arithmeticPromotesEachUnionMember() { - SequenceType type = infer("for $x in (1, 2.5e0) return $x + 1", "jq"); - assertEquals( - List.of(BuiltinTypesCatalogue.integerItem, BuiltinTypesCatalogue.doubleItem), - type.getItemType().getTypes()); - } - - @Test - void unionsAreComparableIfEveryMemberPairIs() { - String flags = "declare variable $c as xs:boolean external; declare variable $d as xs:boolean external; "; - assertEquals( - BuiltinTypesCatalogue.booleanItem, - infer(flags + "(if ($c) then \"a\" else xs:anyURI(\"b\")) eq \"a\"", "jq") - .getItemType()); - assertThrows( - UnexpectedStaticTypeException.class, - () -> infer(flags + "(if ($c) then 1 else \"a\") eq (if ($d) then 2 else \"b\")", "jq")); - } - @Test void unionHasAnEffectiveBooleanValueIfEveryMemberHasOne() { String flag = "declare variable $c as xs:boolean external; "; From b9e6dfc36c4d2d5da1893603edf110086074d1bb Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Thu, 8 Oct 2026 21:00:19 +0200 Subject: [PATCH 33/36] refactor: check union member types through one helper ItemType.getMemberTypes and allMemberTypesMatch replace the inferred-union member lists used by comparisons, arithmetic and effective boolean values. fn:min and fn:max accept null alongside comparable values, as at runtime. --- .../rumbledb/compiler/InferTypeVisitor.java | 34 ++++++------------- .../java/org/rumbledb/types/ItemType.java | 24 +++++++++++++ .../org/rumbledb/types/ItemTypeFactory.java | 9 ----- .../java/org/rumbledb/types/SequenceType.java | 4 +-- .../test_files/static-typing/misc/minmax4.jq | 3 ++ 5 files changed, 39 insertions(+), 35 deletions(-) create mode 100644 src/test/resources/test_files/static-typing/misc/minmax4.jq diff --git a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java index 88f9c802fc..62bcafec6f 100644 --- a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java +++ b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java @@ -691,11 +691,12 @@ private static boolean hasMutuallyComparableItems(ItemType itemType) { || itemType.equals(BuiltinTypesCatalogue.atomicItem)) { return false; } - if (!itemType.isUnionType()) { - return true; - } - List members = itemType.getTypes(); - return members.stream().allMatch(ItemType::isNumeric) + // JSONiq null is comparable with every atomic value. + List members = itemType.getMemberTypes().stream() + .filter(member -> !member.equals(BuiltinTypesCatalogue.nullItem)) + .toList(); + return members.size() <= 1 + || members.stream().allMatch(ItemType::isNumeric) || members.stream() .allMatch(member -> member.isSubtypeOf(BuiltinTypesCatalogue.stringItem) || member.isSubtypeOf(BuiltinTypesCatalogue.anyURIItem)); @@ -1371,8 +1372,8 @@ private static boolean isOperatorDefined( // This function assume 2 numeric ItemType private ItemType resolveNumericType(ItemType left, ItemType right) { - List leftMembers = ItemTypeFactory.getInferredUnionMembers(left); - List rightMembers = ItemTypeFactory.getInferredUnionMembers(right); + List leftMembers = left.getMemberTypes(); + List rightMembers = right.getMemberTypes(); if (leftMembers.size() > 1 || rightMembers.size() > 1) { // Each member pair is promoted separately, e.g. (xs:integer | xs:double) + xs:integer // is (xs:integer | xs:double). @@ -1658,22 +1659,9 @@ public StaticContext visitComparisonExpr(ComparisonExpression expression, Static expression.getMetadata()); } - List leftMembers = ItemTypeFactory.getInferredUnionMembers(leftItemType); - List rightMembers = ItemTypeFactory.getInferredUnionMembers(rightItemType); - if (leftMembers.size() > 1 || rightMembers.size() > 1) { - // Static typing is pessimistic (XQuery 3.1, 2.2.3.1): every pair of members must be comparable. - boolean comparable = leftMembers.stream().allMatch(left -> rightMembers.stream() - .allMatch(right -> areComparable(left, right) && isOperatorDefined(left, right, operator))); - if (!comparable) { - throwStaticTypeException( - "It is not possible to compare these types: " + leftItemType + " and " + rightItemType, - expression.getMetadata()); - } - } else if (!areComparable(leftItemType, rightItemType)) { - throwStaticTypeException( - "It is not possible to compare these types: " + leftItemType + " and " + rightItemType, - expression.getMetadata()); - } else if (!isOperatorDefined(leftItemType, rightItemType, operator)) { + // Static typing is pessimistic (XQuery 3.1, 2.2.3.1): every pair of member types must be comparable. + if (!leftItemType.allMemberTypesMatch(left -> rightItemType.allMemberTypesMatch( + right -> areComparable(left, right) && isOperatorDefined(left, right, operator)))) { throwStaticTypeException( "It is not possible to compare these types: " + leftItemType diff --git a/src/main/java/org/rumbledb/types/ItemType.java b/src/main/java/org/rumbledb/types/ItemType.java index b050359875..ac9d992a7d 100644 --- a/src/main/java/org/rumbledb/types/ItemType.java +++ b/src/main/java/org/rumbledb/types/ItemType.java @@ -17,10 +17,12 @@ import java.io.Serial; import java.io.Serializable; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Predicate; import org.rumbledb.api.Item; import org.rumbledb.config.RumbleConfiguration; @@ -634,6 +636,28 @@ default List getTypes() { throw new UnsupportedOperationException("union content facet is allowed only for union item types"); } + /** + * Returns the types a value of this type can have: the members of a union, with nested unions expanded, or this + * type itself. + */ + default List getMemberTypes() { + if (!isUnionType()) { + return List.of(this); + } + List members = new ArrayList<>(); + for (ItemType member : getTypes()) { + members.addAll(member.getMemberTypes()); + } + return members; + } + + /** + * Static typing is pessimistic: a check passes for a union only if it passes for every member type. + */ + default boolean allMemberTypesMatch(Predicate check) { + return getMemberTypes().stream().allMatch(check); + } + // endregion /** diff --git a/src/main/java/org/rumbledb/types/ItemTypeFactory.java b/src/main/java/org/rumbledb/types/ItemTypeFactory.java index 3820baa5bf..7509682daf 100644 --- a/src/main/java/org/rumbledb/types/ItemTypeFactory.java +++ b/src/main/java/org/rumbledb/types/ItemTypeFactory.java @@ -242,15 +242,6 @@ public static ItemType createInferredUnionType(List alternatives) { return new UnionItemType(null, baseType, members, false); } - /** - * Returns the members of an inferred union, or the type itself for any other type, including named unions. - */ - public static List getInferredUnionMembers(ItemType type) { - List members = new ArrayList<>(); - collectInferredUnionMembers(type, members); - return members; - } - private static void collectInferredUnionMembers(ItemType type, List members) { if (type.isUnionType() && !type.hasName() && !type.isUserDefined()) { for (ItemType member : type.getTypes()) { diff --git a/src/main/java/org/rumbledb/types/SequenceType.java b/src/main/java/org/rumbledb/types/SequenceType.java index 63bdbbe4a4..03def08bba 100644 --- a/src/main/java/org/rumbledb/types/SequenceType.java +++ b/src/main/java/org/rumbledb/types/SequenceType.java @@ -135,9 +135,7 @@ public boolean hasEffectiveBooleanValue() { if (isEmptySequence()) { return true; } - // An inferred union has an effective boolean value only if every member has one. - return ItemTypeFactory.getInferredUnionMembers(this.itemType).stream() - .allMatch(this::memberHasEffectiveBooleanValue); + return this.itemType.allMemberTypesMatch(this::memberHasEffectiveBooleanValue); } private boolean memberHasEffectiveBooleanValue(ItemType member) { diff --git a/src/test/resources/test_files/static-typing/misc/minmax4.jq b/src/test/resources/test_files/static-typing/misc/minmax4.jq new file mode 100644 index 0000000000..5307734e21 --- /dev/null +++ b/src/test/resources/test_files/static-typing/misc/minmax4.jq @@ -0,0 +1,3 @@ +(:JIQS: ShouldRun :) +(: Null is comparable with every atomic value, so it does not make the input incomparable. :) +min((1, null)), max((1, null)) From 26de5df6d6c0c46f0f7213a560de445c9a0eb0b9 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Thu, 8 Oct 2026 21:06:12 +0200 Subject: [PATCH 34/36] refactor: check union member types through ItemType.allMemberTypesMatch Arithmetic, comparisons, promotion, function coercion, castability and effective boolean values share the base branch's member type helper. fn:min and fn:max reuse the comparison rule with lt, which accepts null and binary values. Simplify the fn:sum return type and the dynamic call argument error. --- .../rumbledb/compiler/InferTypeVisitor.java | 65 +++++++------------ .../runtime/functions/FunctionCoercion.java | 3 +- .../java/org/rumbledb/types/SequenceType.java | 28 ++++---- .../org/rumbledb/types/UnionItemType.java | 7 +- .../IsStaticallyTypeInferenceTest.java | 10 --- .../compiler/UnionTypeInferenceTest.java | 3 +- 6 files changed, 44 insertions(+), 72 deletions(-) diff --git a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java index b0dd5cd287..da0533d5a8 100644 --- a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java +++ b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java @@ -641,7 +641,7 @@ private SequenceType validateStrictAggregateInputType( // Aggregates can convert untyped values and produce values outside a derived type's restrictions. private ItemType normalizeAggregateItemType(ItemType type) { if (type.isUnionType()) { - return ItemTypeFactory.createInferredUnionType(type.getTypes().stream() + return ItemTypeFactory.createInferredUnionType(type.getMemberTypes().stream() .map(this::normalizeAggregateItemType) .collect(Collectors.toList())); } @@ -668,14 +668,12 @@ private SequenceType inferSumReturnType(FunctionCallExpression expression) { if (inputType.isEmptySequence()) { return zeroType; } - SequenceType result = new SequenceType(inputType.getItemType()); + // A non-empty input sums to one item; an empty input returns the zero argument instead. + SequenceType sumType = new SequenceType(inputType.getItemType()); if (!inputType.getCardinality().allowsZero()) { - return result; + return sumType; } - ItemType itemType = zeroType.isEmptySequence() - ? result.getItemType() - : ItemTypeFactory.createInferredUnionType(List.of(result.getItemType(), zeroType.getItemType())); - return new SequenceType(itemType, result.getCardinality().union(zeroType.getCardinality())); + return sumType.leastCommonSupertypeWith(zeroType); } private SequenceType inferStrictAggregateReturnType(FunctionCallExpression expression, Expression inputExpression) { @@ -709,8 +707,8 @@ private SequenceType inferStrictMinMaxReturnType( if (!hasMutuallyComparableItems(inputItemType)) { throwStaticTypeException( functionName - + " requires its inferred input item type to be an atomic type other than xs:anyAtomicType," - + " or a union of numeric types or of xs:string and xs:anyURI, found " + + " requires an atomic input type other than xs:anyAtomicType whose member types can be" + + " compared with each other, found " + inputType, ErrorCode.InvalidArgumentType, expression.getMetadata()); @@ -724,23 +722,16 @@ private SequenceType inferStrictMinMaxReturnType( } /** - * fn:min and fn:max compare every input item with the others, so the input must not mix types that cannot be - * compared, such as (xs:string | xs:integer). Numeric types are compared after promotion, and so are xs:string - * and xs:anyURI. + * fn:min and fn:max compare every input item with the others, so every pair of member types must support + * ordering, e.g. (xs:string | xs:integer) is rejected. */ private static boolean hasMutuallyComparableItems(ItemType itemType) { if (!itemType.isSubtypeOf(BuiltinTypesCatalogue.atomicItem) || itemType.equals(BuiltinTypesCatalogue.atomicItem)) { return false; } - if (!itemType.isUnionType()) { - return true; - } - List members = itemType.getTypes(); - return members.stream().allMatch(ItemType::isNumeric) - || members.stream() - .allMatch(member -> member.isSubtypeOf(BuiltinTypesCatalogue.stringItem) - || member.isSubtypeOf(BuiltinTypesCatalogue.anyURIItem)); + return itemType.allMemberTypesMatch(left -> itemType.allMemberTypesMatch( + right -> areMemberTypesComparable(left, right, ComparisonExpression.ComparisonOperator.VC_LT))); } private boolean isBuiltinFunctionName(Name functionName, String localName) { @@ -1338,15 +1329,10 @@ public StaticContext visitAdditiveExpr(AdditiveExpression expression, StaticCont // Evaluate each possible operand pair. A null result means an unsupported pair. private ItemType inferBinaryOperationType( ItemType left, ItemType right, BiFunction operation) { - if (!left.isUnionType() && !right.isUnionType()) { - return operation.apply(left, right); - } List results = new ArrayList<>(); - List leftTypes = left.isUnionType() ? left.getTypes() : List.of(left); - List rightTypes = right.isUnionType() ? right.getTypes() : List.of(right); - for (ItemType leftMember : leftTypes) { - for (ItemType rightMember : rightTypes) { - ItemType result = inferBinaryOperationType(leftMember, rightMember, operation); + for (ItemType leftMember : left.getMemberTypes()) { + for (ItemType rightMember : right.getMemberTypes()) { + ItemType result = operation.apply(leftMember, rightMember); if (result == null) { return null; } @@ -1711,19 +1697,16 @@ public StaticContext visitComparisonExpr(ComparisonExpression expression, Static private boolean areComparisonTypesCompatible( ItemType left, ItemType right, ComparisonExpression.ComparisonOperator operator, boolean sameValue) { // Equal union types do not imply equal runtime values. Only references to the same - // singleton variable let us check matching alternatives instead of every possible pair. - if (left.isUnionType()) { - if (sameValue && left.equals(right)) { - return left.getTypes().stream() - .allMatch(member -> areComparisonTypesCompatible(member, member, operator, true)); - } - return left.getTypes().stream() - .allMatch(member -> areComparisonTypesCompatible(member, right, operator, false)); - } - if (right.isUnionType()) { - return right.getTypes().stream() - .allMatch(member -> areComparisonTypesCompatible(left, member, operator, false)); + // singleton variable let us check matching member types instead of every possible pair. + if (sameValue && left.isUnionType() && left.equals(right)) { + return left.allMemberTypesMatch(member -> areMemberTypesComparable(member, member, operator)); } + return left.allMemberTypesMatch(leftMember -> + right.allMemberTypesMatch(rightMember -> areMemberTypesComparable(leftMember, rightMember, operator))); + } + + private static boolean areMemberTypesComparable( + ItemType left, ItemType right, ComparisonExpression.ComparisonOperator operator) { // JSONiq null is comparable with every atomic value, including for ordering. if (left.equals(BuiltinTypesCatalogue.nullItem) || right.equals(BuiltinTypesCatalogue.nullItem)) { return true; @@ -2399,7 +2382,7 @@ private SequenceType inferDynamicFunctionCallType(DynamicFunctionCallExpression } if (!checkArguments(formalParameterTypes, actualParameterTypes)) { throwStaticTypeException( - "the type of a dynamic function call main expression must be function, instead inferred " + type, + "the arguments of a dynamic function call do not match the signature of " + type, expression.getMetadata()); } diff --git a/src/main/java/org/rumbledb/runtime/functions/FunctionCoercion.java b/src/main/java/org/rumbledb/runtime/functions/FunctionCoercion.java index 2482407f3e..cf6392dc2d 100644 --- a/src/main/java/org/rumbledb/runtime/functions/FunctionCoercion.java +++ b/src/main/java/org/rumbledb/runtime/functions/FunctionCoercion.java @@ -48,8 +48,7 @@ public static boolean canBeFunctionCoercedTo(SequenceType sourceType, SequenceTy public static boolean canItemTypeBeFunctionCoercedTo(ItemType sourceItemType, ItemType targetItemType) { if (sourceItemType.isUnionType()) { - return sourceItemType.getTypes().stream() - .allMatch(member -> canItemTypeBeFunctionCoercedTo(member, targetItemType)); + return sourceItemType.allMemberTypesMatch(member -> canItemTypeBeFunctionCoercedTo(member, targetItemType)); } if (!targetItemType.isFunctionItemType() || targetItemType.getSignature() == null) { return false; diff --git a/src/main/java/org/rumbledb/types/SequenceType.java b/src/main/java/org/rumbledb/types/SequenceType.java index b2eb8c0360..fc9a0d95a9 100644 --- a/src/main/java/org/rumbledb/types/SequenceType.java +++ b/src/main/java/org/rumbledb/types/SequenceType.java @@ -123,8 +123,8 @@ public boolean isSubtypeOfOrCanBePromotedTo(SequenceType superType) { } if (this.itemType.isUnionType()) { // A member can fit directly while another needs promotion or function coercion. - return this.itemType.getTypes().stream().allMatch(member -> new SequenceType(member, this.cardinality) - .isSubtypeOfOrCanBePromotedTo(superType)); + return this.itemType.allMemberTypesMatch( + member -> new SequenceType(member, this.cardinality).isSubtypeOfOrCanBePromotedTo(superType)); } return this.cardinality.isSubtypeOf(superType.cardinality) && (this.itemType.isSubtypeOf(superType.itemType) @@ -139,20 +139,20 @@ public boolean isAritySubtypeOf(Arity superArity) { public boolean hasEffectiveBooleanValue() { if (isEmptySequence()) { return true; - } else if (this.itemType.isUnionType()) { - // Every possible member must allow EBV at this sequence's cardinality. - return this.itemType.getTypes().stream() - .allMatch(member -> new SequenceType(member, this.cardinality).hasEffectiveBooleanValue()); - } else if (this.itemType.isSubtypeOf(BuiltinTypesCatalogue.JSONItem)) { + } + return this.itemType.allMemberTypesMatch(this::memberHasEffectiveBooleanValue); + } + + private boolean memberHasEffectiveBooleanValue(ItemType member) { + if (member.isSubtypeOf(BuiltinTypesCatalogue.JSONItem)) { return true; - } else { - return !this.cardinality.allowsMany() - && (this.itemType.isNumeric() - || this.itemType.equals(BuiltinTypesCatalogue.stringItem) - || this.itemType.equals(BuiltinTypesCatalogue.anyURIItem) - || this.itemType.equals(BuiltinTypesCatalogue.nullItem) - || this.itemType.equals(BuiltinTypesCatalogue.booleanItem)); } + return !this.cardinality.allowsMany() + && (member.isNumeric() + || member.equals(BuiltinTypesCatalogue.stringItem) + || member.equals(BuiltinTypesCatalogue.anyURIItem) + || member.equals(BuiltinTypesCatalogue.nullItem) + || member.equals(BuiltinTypesCatalogue.booleanItem)); } public boolean hasOverlapWith(SequenceType other) { diff --git a/src/main/java/org/rumbledb/types/UnionItemType.java b/src/main/java/org/rumbledb/types/UnionItemType.java index e9303684f9..7910723027 100644 --- a/src/main/java/org/rumbledb/types/UnionItemType.java +++ b/src/main/java/org/rumbledb/types/UnionItemType.java @@ -106,14 +106,13 @@ public boolean isNumeric() { @Override public boolean isStaticallyCastableAs(ItemType other) { return other.equals(this) - || this.types.stream() - .allMatch(member -> member.isSubtypeOf(BuiltinTypesCatalogue.atomicItem) - && member.isStaticallyCastableAs(other)); + || allMemberTypesMatch(member -> + member.isSubtypeOf(BuiltinTypesCatalogue.atomicItem) && member.isStaticallyCastableAs(other)); } @Override public boolean canBePromotedTo(ItemType other) { - return this.types.stream().allMatch(member -> member.isSubtypeOf(other) || member.canBePromotedTo(other)); + return allMemberTypesMatch(member -> member.isSubtypeOf(other) || member.canBePromotedTo(other)); } @Override diff --git a/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java b/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java index 6087269744..d481187e0f 100644 --- a/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java +++ b/src/test/java/org/rumbledb/compiler/IsStaticallyTypeInferenceTest.java @@ -32,7 +32,6 @@ import org.rumbledb.config.CompilationConfiguration; import org.rumbledb.config.RumbleConfiguration; import org.rumbledb.exceptions.IsStaticallyUnexpectedTypeException; -import org.rumbledb.exceptions.UnexpectedStaticTypeException; import org.rumbledb.types.BuiltinTypesCatalogue; import org.rumbledb.types.ItemType; import org.rumbledb.types.SequenceCardinality; @@ -171,15 +170,6 @@ void assertedMultipleValuesStillBecomeOnlyAnArrayInObjectFields() { assertEquals(BuiltinTypesCatalogue.integerItem, type.getItemType().getArrayContentFacet()); } - @Test - void unionHasAnEffectiveBooleanValueIfEveryMemberHasOne() { - String flag = "declare variable $c as xs:boolean external; "; - assertDoesNotThrow(() -> infer(flag + "if (if ($c) then 1 else \"a\") then 1 else 2", "jq")); - assertThrows( - UnexpectedStaticTypeException.class, - () -> infer(flag + "if (if ($c) then 1 else current-date()) then 1 else 2", "jq")); - } - @ParameterizedTest @ValueSource( strings = { diff --git a/src/test/java/org/rumbledb/compiler/UnionTypeInferenceTest.java b/src/test/java/org/rumbledb/compiler/UnionTypeInferenceTest.java index 5fb722691d..a08c35af58 100644 --- a/src/test/java/org/rumbledb/compiler/UnionTypeInferenceTest.java +++ b/src/test/java/org/rumbledb/compiler/UnionTypeInferenceTest.java @@ -16,6 +16,7 @@ package org.rumbledb.compiler; import java.net.URI; +import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; @@ -50,7 +51,7 @@ static Stream unionQueries() { List floating = List.of(BuiltinTypesCatalogue.floatItem, BuiltinTypesCatalogue.doubleItem); List integers = List.of(BuiltinTypesCatalogue.integerItem); List strings = List.of(BuiltinTypesCatalogue.stringItem); - List cases = new java.util.ArrayList<>(); + List cases = new ArrayList<>(); for (String operator : List.of("+", "-", "*", "div", "mod")) { cases.add(new Case("for $x in (xs:float(1), xs:double(2)) return $x " + operator + " 1", floating)); } From 8754952cec7836fc0be640542988b64f11ac7034 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Thu, 8 Oct 2026 21:13:16 +0200 Subject: [PATCH 35/36] test: fold TypeInferenceTests into StaticTypeTests and drop the same-variable comparison case StaticTypeTests runs .jq and .xq fixtures and checks output when a fixture declares one, so the type-inference fixtures move into static-typing/ and TypeInferenceTests, which CI never ran, is removed. Comparing a union-typed variable with itself now follows the every-pair rule like any other comparison. --- .../rumbledb/compiler/InferTypeVisitor.java | 16 ++---- src/test/java/iq/StaticTypeTests.java | 7 ++- src/test/java/iq/TypeInferenceTests.java | 51 ------------------- .../java/iq/base/AnnotationTestExecutor.java | 3 +- .../java/iq/base/AnnotationsTestsBase.java | 6 +-- .../comparison}/IndependentDurationUnions.xq | 0 .../comparison}/NodeEmptyOperand.xq | 0 .../comparison}/NodeMultipleOperand.xq | 0 .../comparison}/NodeNonNodeOperand.xq | 0 .../comparison}/NodeOptionalComparisons.xq | 0 .../comparison}/NodeSingletonComparisons.xq | 0 .../comparison}/NullableComparisons.jq | 0 .../NullableUnionIncompatibleMember.jq | 0 .../comparison}/NullableUnionsOnBothSides.jq | 0 .../UnorderedDurationAlternative.xq | 0 .../typing}/NodeAtomization.xq | 0 .../typing}/NodeCastInvalidLexicalValue.xq | 0 .../typing}/NodeCastMultipleValues.xq | 0 .../typing}/NodeUnionCast.xq | 0 .../typing}/OptionalNodeCast.xq | 0 .../comparisons/SameSingletonBinaryUnion.xq | 3 -- .../comparisons/SameSingletonDurationUnion.xq | 3 -- .../strict/comparisons/SameSingletonUnion.xq | 4 -- .../NodeEmptyComparisons.xq | 0 24 files changed, 12 insertions(+), 81 deletions(-) delete mode 100644 src/test/java/iq/TypeInferenceTests.java rename src/test/resources/test_files/{type-inference/strict/comparisons => static-typing/comparison}/IndependentDurationUnions.xq (100%) rename src/test/resources/test_files/{type-inference/strict/comparisons => static-typing/comparison}/NodeEmptyOperand.xq (100%) rename src/test/resources/test_files/{type-inference/strict/comparisons => static-typing/comparison}/NodeMultipleOperand.xq (100%) rename src/test/resources/test_files/{type-inference/strict/comparisons => static-typing/comparison}/NodeNonNodeOperand.xq (100%) rename src/test/resources/test_files/{type-inference/strict/comparisons => static-typing/comparison}/NodeOptionalComparisons.xq (100%) rename src/test/resources/test_files/{type-inference/strict/comparisons => static-typing/comparison}/NodeSingletonComparisons.xq (100%) rename src/test/resources/test_files/{type-inference/strict/comparisons => static-typing/comparison}/NullableComparisons.jq (100%) rename src/test/resources/test_files/{type-inference/strict/comparisons => static-typing/comparison}/NullableUnionIncompatibleMember.jq (100%) rename src/test/resources/test_files/{type-inference/strict/comparisons => static-typing/comparison}/NullableUnionsOnBothSides.jq (100%) rename src/test/resources/test_files/{type-inference/strict/comparisons => static-typing/comparison}/UnorderedDurationAlternative.xq (100%) rename src/test/resources/test_files/{type-inference/strict/casts => static-typing/typing}/NodeAtomization.xq (100%) rename src/test/resources/test_files/{type-inference/strict/casts => static-typing/typing}/NodeCastInvalidLexicalValue.xq (100%) rename src/test/resources/test_files/{type-inference/strict/casts => static-typing/typing}/NodeCastMultipleValues.xq (100%) rename src/test/resources/test_files/{type-inference/strict/casts => static-typing/typing}/NodeUnionCast.xq (100%) rename src/test/resources/test_files/{type-inference/strict/casts => static-typing/typing}/OptionalNodeCast.xq (100%) delete mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonBinaryUnion.xq delete mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonDurationUnion.xq delete mode 100644 src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonUnion.xq rename src/test/resources/test_files/{type-inference/runtime/comparisons => xquery-parser}/NodeEmptyComparisons.xq (100%) diff --git a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java index da0533d5a8..0eb0748054 100644 --- a/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java +++ b/src/main/java/org/rumbledb/compiler/InferTypeVisitor.java @@ -1674,11 +1674,7 @@ public StaticContext visitComparisonExpr(ComparisonExpression expression, Static expression.getMetadata()); } - boolean sameValue = leftInferredType.getCardinality() == SequenceCardinality.ONE - && childrenExpressions.get(0) instanceof VariableReferenceExpression leftVariable - && childrenExpressions.get(1) instanceof VariableReferenceExpression rightVariable - && leftVariable.getVariableName().equals(rightVariable.getVariableName()); - if (!areComparisonTypesCompatible(leftItemType, rightItemType, operator, sameValue)) { + if (!areComparisonTypesCompatible(leftItemType, rightItemType, operator)) { throwStaticTypeException( "It is not possible to compare these types: " + leftItemType @@ -1694,13 +1690,9 @@ public StaticContext visitComparisonExpr(ComparisonExpression expression, Static return argument; } - private boolean areComparisonTypesCompatible( - ItemType left, ItemType right, ComparisonExpression.ComparisonOperator operator, boolean sameValue) { - // Equal union types do not imply equal runtime values. Only references to the same - // singleton variable let us check matching member types instead of every possible pair. - if (sameValue && left.isUnionType() && left.equals(right)) { - return left.allMemberTypesMatch(member -> areMemberTypesComparable(member, member, operator)); - } + // Static typing is pessimistic (XQuery 3.1, 2.2.3.1): every pair of member types must be comparable. + private static boolean areComparisonTypesCompatible( + ItemType left, ItemType right, ComparisonExpression.ComparisonOperator operator) { return left.allMemberTypesMatch(leftMember -> right.allMemberTypesMatch(rightMember -> areMemberTypesComparable(leftMember, rightMember, operator))); } diff --git a/src/test/java/iq/StaticTypeTests.java b/src/test/java/iq/StaticTypeTests.java index 598fd81124..2f5b6efdb0 100644 --- a/src/test/java/iq/StaticTypeTests.java +++ b/src/test/java/iq/StaticTypeTests.java @@ -16,8 +16,11 @@ package iq; import java.io.File; +import java.io.IOException; +import java.util.List; import iq.base.SparkAnnotationsTestsBase; +import iq.base.TestFileDiscovery; import org.rumbledb.config.RumbleConfiguration; @@ -41,7 +44,7 @@ protected File testDirectory() { } @Override - protected boolean checkOutput() { - return false; + protected List testFiles() throws IOException { + return TestFileDiscovery.files(testDirectory(), ".jq", ".xq"); } } diff --git a/src/test/java/iq/TypeInferenceTests.java b/src/test/java/iq/TypeInferenceTests.java deleted file mode 100644 index 92f3481e91..0000000000 --- a/src/test/java/iq/TypeInferenceTests.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * Contributor acknowledgements are maintained in the CONTRIBUTORS file at the project root. - */ -package iq; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Path; -import java.util.List; - -import iq.base.SparkAnnotationsTestsBase; -import iq.base.TestFileDiscovery; - -import org.rumbledb.config.RumbleConfiguration; - -/** Annotation regressions that check both inferred types and materialized results. */ -public class TypeInferenceTests extends SparkAnnotationsTestsBase { - private static final Path ROOT = - Path.of(System.getProperty("user.dir"), "src/test/resources/test_files/type-inference"); - - @Override - protected File testDirectory() { - return ROOT.toFile(); - } - - @Override - protected List testFiles() throws IOException { - return TestFileDiscovery.files(testDirectory(), ".jq", ".xq"); - } - - @Override - protected RumbleConfiguration getConfiguration(File testFile) { - boolean strictTyping = testFile.toPath().startsWith(ROOT.resolve("strict")); - return RumbleConfiguration.builder() - .configureAnalysis(analysis -> analysis.enableStaticTyping(strictTyping)) - .configureRuntime(runtime -> runtime.resultsSizeCap(200)) - .build(); - } -} diff --git a/src/test/java/iq/base/AnnotationTestExecutor.java b/src/test/java/iq/base/AnnotationTestExecutor.java index 7b793a1d8c..e8094882d5 100644 --- a/src/test/java/iq/base/AnnotationTestExecutor.java +++ b/src/test/java/iq/base/AnnotationTestExecutor.java @@ -211,7 +211,8 @@ private static void checkExpectedOutput( boolean checkOutput, boolean applyUpdates, int resultSizeCap) { - if (!checkOutput) { + // A fixture without an Output annotation only checks that the query runs. + if (!checkOutput || expectedOutput == null) { if (applyUpdates && sequence.availableAsPUL()) { sequence.applyPUL(); } diff --git a/src/test/java/iq/base/AnnotationsTestsBase.java b/src/test/java/iq/base/AnnotationsTestsBase.java index 168560cd5b..76bc64f4dc 100644 --- a/src/test/java/iq/base/AnnotationsTestsBase.java +++ b/src/test/java/iq/base/AnnotationsTestsBase.java @@ -35,10 +35,6 @@ public RumbleConfiguration getConfiguration() { return TestConfigurations.defaultConfiguration(); } - protected RumbleConfiguration getConfiguration(File testFile) { - return getConfiguration(); - } - public ExternalBindings getExternalBindings() { return TestConfigurations.defaultExternalBindings(); } @@ -58,6 +54,6 @@ protected boolean checkOutput() { @MethodSource("testFiles") @Timeout(1000) final void testAnnotation(File testFile) throws IOException { - AnnotationTestExecutor.run(testFile, getConfiguration(testFile), getExternalBindings(), checkOutput()); + AnnotationTestExecutor.run(testFile, getConfiguration(), getExternalBindings(), checkOutput()); } } diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/IndependentDurationUnions.xq b/src/test/resources/test_files/static-typing/comparison/IndependentDurationUnions.xq similarity index 100% rename from src/test/resources/test_files/type-inference/strict/comparisons/IndependentDurationUnions.xq rename to src/test/resources/test_files/static-typing/comparison/IndependentDurationUnions.xq diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyOperand.xq b/src/test/resources/test_files/static-typing/comparison/NodeEmptyOperand.xq similarity index 100% rename from src/test/resources/test_files/type-inference/strict/comparisons/NodeEmptyOperand.xq rename to src/test/resources/test_files/static-typing/comparison/NodeEmptyOperand.xq diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeMultipleOperand.xq b/src/test/resources/test_files/static-typing/comparison/NodeMultipleOperand.xq similarity index 100% rename from src/test/resources/test_files/type-inference/strict/comparisons/NodeMultipleOperand.xq rename to src/test/resources/test_files/static-typing/comparison/NodeMultipleOperand.xq diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeNonNodeOperand.xq b/src/test/resources/test_files/static-typing/comparison/NodeNonNodeOperand.xq similarity index 100% rename from src/test/resources/test_files/type-inference/strict/comparisons/NodeNonNodeOperand.xq rename to src/test/resources/test_files/static-typing/comparison/NodeNonNodeOperand.xq diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeOptionalComparisons.xq b/src/test/resources/test_files/static-typing/comparison/NodeOptionalComparisons.xq similarity index 100% rename from src/test/resources/test_files/type-inference/strict/comparisons/NodeOptionalComparisons.xq rename to src/test/resources/test_files/static-typing/comparison/NodeOptionalComparisons.xq diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NodeSingletonComparisons.xq b/src/test/resources/test_files/static-typing/comparison/NodeSingletonComparisons.xq similarity index 100% rename from src/test/resources/test_files/type-inference/strict/comparisons/NodeSingletonComparisons.xq rename to src/test/resources/test_files/static-typing/comparison/NodeSingletonComparisons.xq diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NullableComparisons.jq b/src/test/resources/test_files/static-typing/comparison/NullableComparisons.jq similarity index 100% rename from src/test/resources/test_files/type-inference/strict/comparisons/NullableComparisons.jq rename to src/test/resources/test_files/static-typing/comparison/NullableComparisons.jq diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NullableUnionIncompatibleMember.jq b/src/test/resources/test_files/static-typing/comparison/NullableUnionIncompatibleMember.jq similarity index 100% rename from src/test/resources/test_files/type-inference/strict/comparisons/NullableUnionIncompatibleMember.jq rename to src/test/resources/test_files/static-typing/comparison/NullableUnionIncompatibleMember.jq diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/NullableUnionsOnBothSides.jq b/src/test/resources/test_files/static-typing/comparison/NullableUnionsOnBothSides.jq similarity index 100% rename from src/test/resources/test_files/type-inference/strict/comparisons/NullableUnionsOnBothSides.jq rename to src/test/resources/test_files/static-typing/comparison/NullableUnionsOnBothSides.jq diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/UnorderedDurationAlternative.xq b/src/test/resources/test_files/static-typing/comparison/UnorderedDurationAlternative.xq similarity index 100% rename from src/test/resources/test_files/type-inference/strict/comparisons/UnorderedDurationAlternative.xq rename to src/test/resources/test_files/static-typing/comparison/UnorderedDurationAlternative.xq diff --git a/src/test/resources/test_files/type-inference/strict/casts/NodeAtomization.xq b/src/test/resources/test_files/static-typing/typing/NodeAtomization.xq similarity index 100% rename from src/test/resources/test_files/type-inference/strict/casts/NodeAtomization.xq rename to src/test/resources/test_files/static-typing/typing/NodeAtomization.xq diff --git a/src/test/resources/test_files/type-inference/strict/casts/NodeCastInvalidLexicalValue.xq b/src/test/resources/test_files/static-typing/typing/NodeCastInvalidLexicalValue.xq similarity index 100% rename from src/test/resources/test_files/type-inference/strict/casts/NodeCastInvalidLexicalValue.xq rename to src/test/resources/test_files/static-typing/typing/NodeCastInvalidLexicalValue.xq diff --git a/src/test/resources/test_files/type-inference/strict/casts/NodeCastMultipleValues.xq b/src/test/resources/test_files/static-typing/typing/NodeCastMultipleValues.xq similarity index 100% rename from src/test/resources/test_files/type-inference/strict/casts/NodeCastMultipleValues.xq rename to src/test/resources/test_files/static-typing/typing/NodeCastMultipleValues.xq diff --git a/src/test/resources/test_files/type-inference/strict/casts/NodeUnionCast.xq b/src/test/resources/test_files/static-typing/typing/NodeUnionCast.xq similarity index 100% rename from src/test/resources/test_files/type-inference/strict/casts/NodeUnionCast.xq rename to src/test/resources/test_files/static-typing/typing/NodeUnionCast.xq diff --git a/src/test/resources/test_files/type-inference/strict/casts/OptionalNodeCast.xq b/src/test/resources/test_files/static-typing/typing/OptionalNodeCast.xq similarity index 100% rename from src/test/resources/test_files/type-inference/strict/casts/OptionalNodeCast.xq rename to src/test/resources/test_files/static-typing/typing/OptionalNodeCast.xq diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonBinaryUnion.xq b/src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonBinaryUnion.xq deleted file mode 100644 index 599b5c7a27..0000000000 --- a/src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonBinaryUnion.xq +++ /dev/null @@ -1,3 +0,0 @@ -(:JIQS: ShouldRun; Output="(false, false)" :) -for $x in (xs:hexBinary("00"), xs:base64Binary("AA==")) -return ($x lt $x) is statically xs:boolean diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonDurationUnion.xq b/src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonDurationUnion.xq deleted file mode 100644 index 240e20f695..0000000000 --- a/src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonDurationUnion.xq +++ /dev/null @@ -1,3 +0,0 @@ -(:JIQS: ShouldRun; Output="(false, false)" :) -for $x in (xs:dayTimeDuration("P1D"), xs:yearMonthDuration("P1M")) -return ($x lt $x) is statically xs:boolean diff --git a/src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonUnion.xq b/src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonUnion.xq deleted file mode 100644 index e3b91964af..0000000000 --- a/src/test/resources/test_files/type-inference/strict/comparisons/SameSingletonUnion.xq +++ /dev/null @@ -1,4 +0,0 @@ -(:JIQS: ShouldRun; Output="(true, true)" :) -(: Both references denote the same singleton, so each type is compared only with itself. :) -for $x in (1, "a") -return ($x eq $x) is statically xs:boolean diff --git a/src/test/resources/test_files/type-inference/runtime/comparisons/NodeEmptyComparisons.xq b/src/test/resources/test_files/xquery-parser/NodeEmptyComparisons.xq similarity index 100% rename from src/test/resources/test_files/type-inference/runtime/comparisons/NodeEmptyComparisons.xq rename to src/test/resources/test_files/xquery-parser/NodeEmptyComparisons.xq From 960093885289bb094e965083ad16ad31bc508004 Mon Sep 17 00:00:00 2001 From: Jimmy Cai Date: Thu, 8 Oct 2026 21:40:49 +0200 Subject: [PATCH 36/36] refactor: simplify aggregate evaluation helpers Track the primitive types seen by min and max in an EnumSet instead of six positional booleans, share collation validation through ExtremumEvaluation, replace SumClosure with a lambda, and drop a conversion of the running sum, which is never untyped. --- .../aggregate/ExtremumEvaluation.java | 90 +++++++++++-------- .../aggregate/MaxFunctionIterator.java | 16 +--- .../aggregate/MinFunctionIterator.java | 16 +--- .../sequences/aggregate/SumClosure.java | 40 --------- .../aggregate/SumFunctionIterator.java | 6 +- 5 files changed, 55 insertions(+), 113 deletions(-) delete mode 100644 src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/SumClosure.java diff --git a/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/ExtremumEvaluation.java b/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/ExtremumEvaluation.java index 1c93d2c1c5..82e61b5739 100644 --- a/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/ExtremumEvaluation.java +++ b/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/ExtremumEvaluation.java @@ -16,6 +16,7 @@ package org.rumbledb.runtime.functions.sequences.aggregate; import java.io.Serializable; +import java.util.EnumSet; import org.apache.spark.api.java.JavaRDD; import org.apache.spark.storage.StorageLevel; @@ -148,71 +149,83 @@ private static Item normalizeCandidate(Item item, Kind kind, ExceptionMetadata m return item; } + /** + * The primitive types seen in the input, which decide the common type of the result. + */ private static final class Promotion implements Serializable { private static final long serialVersionUID = 1L; - private static final Promotion EMPTY = new Promotion(false, false, false, false, false, false); - private final boolean hasItems; - private final boolean sawDouble; - private final boolean sawFloat; - private final boolean sawDecimal; - private final boolean sawString; - private final boolean sawAnyURI; - // Spark's Kryo serializer needs an ordinary class rather than a Java record. - private Promotion() { - this(false, false, false, false, false, false); + private enum Seen { + ANY_ITEM, + DOUBLE, + FLOAT, + DECIMAL, + STRING, + ANY_URI } - private Promotion( - boolean hasItems, - boolean sawDouble, - boolean sawFloat, - boolean sawDecimal, - boolean sawString, - boolean sawAnyURI) { - this.hasItems = hasItems; - this.sawDouble = sawDouble; - this.sawFloat = sawFloat; - this.sawDecimal = sawDecimal; - this.sawString = sawString; - this.sawAnyURI = sawAnyURI; + private static final Promotion EMPTY = new Promotion(EnumSet.noneOf(Seen.class)); + private final EnumSet seen; + + // Spark's Kryo serializer needs an ordinary class rather than a Java record. + private Promotion() { + this(EnumSet.noneOf(Seen.class)); } - boolean hasItems() { - return this.hasItems; + private Promotion(EnumSet seen) { + this.seen = seen; } static Promotion of(Item item) { - return new Promotion( - true, item.isDouble(), item.isFloat(), item.isDecimal(), item.isString(), item.isAnyURI()); + EnumSet seen = EnumSet.of(Seen.ANY_ITEM); + if (item.isDouble()) { + seen.add(Seen.DOUBLE); + } + if (item.isFloat()) { + seen.add(Seen.FLOAT); + } + if (item.isDecimal()) { + seen.add(Seen.DECIMAL); + } + if (item.isString()) { + seen.add(Seen.STRING); + } + if (item.isAnyURI()) { + seen.add(Seen.ANY_URI); + } + return new Promotion(seen); } Promotion merge(Promotion other) { - return new Promotion( - this.hasItems || other.hasItems, - this.sawDouble || other.sawDouble, - this.sawFloat || other.sawFloat, - this.sawDecimal || other.sawDecimal, - this.sawString || other.sawString, - this.sawAnyURI || other.sawAnyURI); + EnumSet merged = EnumSet.copyOf(this.seen); + merged.addAll(other.seen); + return new Promotion(merged); + } + + boolean hasItems() { + return this.seen.contains(Seen.ANY_ITEM); } Item apply(Item item) { // With one primitive type, retain the original item and its derived type. // Only mixtures of primitive types require conversion to their common type. if (item.isNumeric()) { - if (this.sawDouble && (this.sawFloat || this.sawDecimal)) { + if (saw(Seen.DOUBLE) && (saw(Seen.FLOAT) || saw(Seen.DECIMAL))) { return ItemFactory.getInstance().createDoubleItem(item.castToDoubleValue()); } - if (this.sawFloat && this.sawDecimal) { + if (saw(Seen.FLOAT) && saw(Seen.DECIMAL)) { return ItemFactory.getInstance().createFloatItem(item.castToFloatValue()); } } - if (this.sawString && this.sawAnyURI && (item.isString() || item.isAnyURI())) { + if (saw(Seen.STRING) && saw(Seen.ANY_URI) && (item.isString() || item.isAnyURI())) { return ItemFactory.getInstance().createStringItem(item.getStringValue()); } return item; } + + private boolean saw(Seen kind) { + return this.seen.contains(kind); + } } private static InvalidArgumentTypeException inputError(Kind kind, ExceptionMetadata metadata) { @@ -229,8 +242,7 @@ private static boolean isNaN(Item item) { return (item.isFloat() || item.isDouble()) && item.isNaN(); } - private static void validateCollation( - ItemRuntimePlan collationPlan, DynamicContext context, ExceptionMetadata metadata) { + static void validateCollation(ItemRuntimePlan collationPlan, DynamicContext context, ExceptionMetadata metadata) { if (collationPlan == null) { return; } diff --git a/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/MaxFunctionIterator.java b/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/MaxFunctionIterator.java index 30aa0cf73d..b536ba9e06 100644 --- a/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/MaxFunctionIterator.java +++ b/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/MaxFunctionIterator.java @@ -24,7 +24,6 @@ import org.rumbledb.context.DynamicContext; import org.rumbledb.context.Name; import org.rumbledb.context.RuntimeStaticContext; -import org.rumbledb.exceptions.UnsupportedCollationException; import org.rumbledb.items.ItemFactory; import org.rumbledb.items.structured.HomogeneousItemDataFrame; import org.rumbledb.runtime.AbstractAtMostOneItemRuntimePlan; @@ -43,8 +42,6 @@ public class MaxFunctionIterator extends AbstractAtMostOneItemRuntimePlan implem @Serial private static final long serialVersionUID = 1L; - private static final String CODEPOINT_COLLATION = "http://www.w3.org/2005/xpath-functions/collation/codepoint"; - private final ItemRuntimePlan iterator; public MaxFunctionIterator(List arguments, RuntimeStaticContext staticContext) { @@ -57,7 +54,7 @@ public Item evaluateAtMostOne(DynamicContext context) { if (!this.iterator.getRuntimeStaticContext().getExecutionMode().isRDDOrDataFrame()) { return ExtremumEvaluation.max(this.iterator, getCollationPlan(), context, getMetadata()); } - validateCollation(context); + ExtremumEvaluation.validateCollation(getCollationPlan(), context, getMetadata()); if (this.iterator.getRuntimeStaticContext().getExecutionMode().isDataFrame()) { HomogeneousItemDataFrame df = ItemRuntimeDataFrameFactory.INSTANCE.fromPlan(this.iterator, context); @@ -91,17 +88,6 @@ private ItemRuntimePlan getCollationPlan() { return this.getChildren().size() > 1 ? this.getChild(1) : null; } - private void validateCollation(DynamicContext context) { - ItemRuntimePlan collationPlan = getCollationPlan(); - if (collationPlan == null) { - return; - } - Item collation = collationPlan.materializeFirstOrNull(context); - if (!CODEPOINT_COLLATION.equals(collation.getStringValue())) { - throw new UnsupportedCollationException("Wrong collation parameter", getMetadata()); - } - } - @Override public Map getVariableDependencies() { if (this.getChild(0) instanceof VariableReferenceIterator expression) { diff --git a/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/MinFunctionIterator.java b/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/MinFunctionIterator.java index 5bf1cf0a93..7fcaffd5bd 100644 --- a/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/MinFunctionIterator.java +++ b/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/MinFunctionIterator.java @@ -24,7 +24,6 @@ import org.rumbledb.context.DynamicContext; import org.rumbledb.context.Name; import org.rumbledb.context.RuntimeStaticContext; -import org.rumbledb.exceptions.UnsupportedCollationException; import org.rumbledb.items.structured.HomogeneousItemDataFrame; import org.rumbledb.runtime.AbstractAtMostOneItemRuntimePlan; import org.rumbledb.runtime.dataframe.ItemRuntimeDataFrameFactory; @@ -41,8 +40,6 @@ public class MinFunctionIterator extends AbstractAtMostOneItemRuntimePlan implem @Serial private static final long serialVersionUID = 1L; - private static final String CODEPOINT_COLLATION = "http://www.w3.org/2005/xpath-functions/collation/codepoint"; - private final ItemRuntimePlan iterator; public MinFunctionIterator(List arguments, RuntimeStaticContext staticContext) { @@ -55,7 +52,7 @@ public Item evaluateAtMostOne(DynamicContext context) { if (!this.iterator.getRuntimeStaticContext().getExecutionMode().isRDDOrDataFrame()) { return ExtremumEvaluation.min(this.iterator, getCollationPlan(), context, getMetadata()); } - validateCollation(context); + ExtremumEvaluation.validateCollation(getCollationPlan(), context, getMetadata()); if (this.iterator.getRuntimeStaticContext().getExecutionMode().isDataFrame()) { HomogeneousItemDataFrame df = ItemRuntimeDataFrameFactory.INSTANCE.fromPlan(this.iterator, context); @@ -80,17 +77,6 @@ private ItemRuntimePlan getCollationPlan() { return this.getChildren().size() > 1 ? this.getChild(1) : null; } - private void validateCollation(DynamicContext context) { - ItemRuntimePlan collationPlan = getCollationPlan(); - if (collationPlan == null) { - return; - } - Item collation = collationPlan.materializeFirstOrNull(context); - if (!CODEPOINT_COLLATION.equals(collation.getStringValue())) { - throw new UnsupportedCollationException("Wrong collation parameter", getMetadata()); - } - } - @Override public Map getVariableDependencies() { if (this.getChild(0) instanceof VariableReferenceIterator expression) { diff --git a/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/SumClosure.java b/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/SumClosure.java deleted file mode 100644 index 48ec321157..0000000000 --- a/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/SumClosure.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - * Contributor acknowledgements are maintained in the CONTRIBUTORS file at the project root. - */ -package org.rumbledb.runtime.functions.sequences.aggregate; - -import java.io.Serial; - -import org.apache.spark.api.java.function.Function2; - -import org.rumbledb.api.Item; -import org.rumbledb.exceptions.ExceptionMetadata; - -public class SumClosure implements Function2 { - - @Serial - private static final long serialVersionUID = 1L; - - private final ExceptionMetadata metadata; - - public SumClosure(ExceptionMetadata metadata) { - this.metadata = metadata; - } - - @Override - public Item call(Item v1, Item v2) throws Exception { - return SumFunctionIterator.addToSum(v1, v2, this.metadata); - } -} diff --git a/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/SumFunctionIterator.java b/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/SumFunctionIterator.java index 89f4ddd6f0..e1b231180b 100644 --- a/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/SumFunctionIterator.java +++ b/src/main/java/org/rumbledb/runtime/functions/sequences/aggregate/SumFunctionIterator.java @@ -90,12 +90,10 @@ static Item addToSum(Item currentSum, Item nextValue, ExceptionMetadata metadata if (nextValue.isUntypedAtomic()) { nextValue = ItemFactory.getInstance().createDoubleItem(nextValue.castToDoubleValue()); } + // The running sum is always a converted input or an addition result, so it is never untyped. if (currentSum == null) { return nextValue; } - if (currentSum.isUntypedAtomic()) { - currentSum = ItemFactory.getInstance().createDoubleItem(currentSum.castToDoubleValue()); - } Item result = AdditiveOperationIterator.processItem(currentSum, nextValue, false); if (result == null) { throw new InvalidArgumentTypeException( @@ -116,7 +114,7 @@ private static Item computeRDD( return zeroElement; } // Reduction does not call its combiner for a singleton, so convert each input first. - return rdd.map(item -> addToSum(null, item, metadata)).reduce(new SumClosure(metadata)); + return rdd.map(item -> addToSum(null, item, metadata)).reduce((sum, value) -> addToSum(sum, value, metadata)); } private static Item computeDataFrame(