diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/Expressions.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/Expressions.java index be72e03..a9c937a 100644 --- a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/Expressions.java +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/Expressions.java @@ -17,7 +17,7 @@ import java.util.Optional; import org.eclipse.daanse.jdbc.db.dialect.api.generator.KnownFunction; -import org.eclipse.daanse.jdbc.db.dialect.api.type.Datatype; +import org.eclipse.daanse.jdbc.db.api.type.Datatype; import org.eclipse.daanse.sql.statement.api.expression.ArithmeticOperator; import org.eclipse.daanse.sql.statement.api.expression.Predicate; import org.eclipse.daanse.sql.statement.api.expression.SqlExpression; diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/Predicates.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/Predicates.java index 1082e3f..51c2f88 100644 --- a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/Predicates.java +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/Predicates.java @@ -53,8 +53,9 @@ public static Predicate in(SqlExpression expression, SqlExpression... values) { /** * Row-value / tuple {@code IN}: * {@code (c1, c2, ...) IN ((v11, ...), (v21, ...), ...)}. Every row must match - * {@code columns} in arity. Gate at the call site on - * {@code dialect.supportsMultiValueInExpr()}. + * {@code columns} in arity. Build tuples child-first (most specific column + * first); dialects without multi-value IN render the parent-first OR-of-ANDs + * expansion instead (see {@code Predicate.InTuple}). */ public static Predicate inTuple(List columns, List> rows) { List> copy = new java.util.ArrayList<>(rows.size()); diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/SelectStatementBuilder.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/SelectStatementBuilder.java index d2dfee2..2404485 100644 --- a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/SelectStatementBuilder.java +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/SelectStatementBuilder.java @@ -19,7 +19,7 @@ import java.util.Optional; import org.eclipse.daanse.jdbc.db.dialect.api.generator.StatementHint; -import org.eclipse.daanse.jdbc.db.dialect.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; import org.eclipse.daanse.sql.statement.api.expression.Predicate; import org.eclipse.daanse.sql.statement.api.expression.SqlExpression; import org.eclipse.daanse.sql.statement.api.model.ColumnAlias; @@ -60,6 +60,8 @@ public final class SelectStatementBuilder { private final List groupKeys = new ArrayList<>(); private final List groupingSets = new ArrayList<>(); private final List groupingFunctions = new ArrayList<>(); + private boolean completeNonAggregatesGroupBy; + private final java.util.Set groupByCompletionExempt = new java.util.HashSet<>(); private final List having = new ArrayList<>(); private final List orderKeys = new ArrayList<>(); private RowLimit rowLimit; @@ -152,6 +154,31 @@ public SelectStatementBuilder groupOn(SqlExpression expression) { return this; } + /** + * Marks the group by as needing per-dialect completion: on dialects that do not allow + * non-aggregate select columns outside {@code GROUP BY}, the renderer appends every + * non-aggregate projection not already a key. Set this when the query genuinely groups + * (i.e. a member-enumeration read that placed keys/ordinals as group keys); leave it off + * for a query whose GROUP BY is incidental (e.g. a single non-level-dependent property on a + * non-grouping read), so the renderer does not over-group. + */ + public SelectStatementBuilder completeNonAggregatesGroupBy() { + this.completeNonAggregatesGroupBy = true; + return this; + } + + /** + * Exempts one projection from the dialect GROUP-BY completion (see + * {@link #completeNonAggregatesGroupBy()}). Use for a projection that is semantically an + * aggregate the renderer cannot recognise structurally — an arithmetic expression WRAPPING + * aggregates (e.g. a native TopCount/Order measure {@code (sum(a)-sum(b))/sum(b)}) — which + * must not be added to GROUP BY on restrictive dialects. No effect on permissive dialects. + */ + public SelectStatementBuilder excludeFromGroupByCompletion(ProjectionRef ref) { + groupByCompletionExempt.add(ref.ordinal()); + return this; + } + public SelectStatementBuilder addGroupingSet(List keys) { groupingSets.add(new GroupBy.GroupingSet(List.copyOf(keys))); return this; @@ -267,8 +294,18 @@ public int projectionCount() { /** Produces the immutable {@link SelectStatement}. */ public SelectStatement build() { GroupBy groupBy = new GroupBy(List.copyOf(groupKeys), List.copyOf(groupingSets), - List.copyOf(groupingFunctions)); - return new SelectStatement(distinct, List.copyOf(projections), Optional.ofNullable(from), List.copyOf(filters), + List.copyOf(groupingFunctions), completeNonAggregatesGroupBy); + List finalProjections = projections; + if (!groupByCompletionExempt.isEmpty()) { + finalProjections = new java.util.ArrayList<>(projections.size()); + for (int i = 0; i < projections.size(); i++) { + Projection p = projections.get(i); + finalProjections.add(groupByCompletionExempt.contains(i) + ? new Projection(p.expression(), p.columnType(), p.alias(), p.comment(), true) + : p); + } + } + return new SelectStatement(distinct, List.copyOf(finalProjections), Optional.ofNullable(from), List.copyOf(filters), groupBy, List.copyOf(having), List.copyOf(orderKeys), Optional.ofNullable(rowLimit), Optional.ofNullable(headerComment), new java.util.IdentityHashMap<>(filterComments), Optional.ofNullable(footerComment), List.copyOf(statementHints)); diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/Predicate.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/Predicate.java index fb2f622..c3f6c80 100644 --- a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/Predicate.java +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/Predicate.java @@ -38,9 +38,11 @@ record In(SqlExpression expression, List values) implements Predi /** * Row-value / tuple {@code IN}: * {@code (c1, c2, ...) IN ((v11, v12, ...), (v21, v22, ...), ...)}. Each row in - * {@code rows} must have the same arity as {@code columns}. Gate construction - * at the call site on {@code dialect.supportsMultiValueInExpr()}; dialects - * without it need an OR-of-ANDs expansion instead. + * {@code rows} must have the same arity as {@code columns}. Build tuples + * child-first (most specific column first, e.g. {@code (quarter, the_year)}). + * On a dialect without {@code supportsMultiValueInExpr()} the renderer degrades + * this to an OR-of-ANDs expansion whose pairs read parent-first (reverse tuple + * order). */ record InTuple(List columns, List> rows) implements Predicate { } diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/SqlExpression.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/SqlExpression.java index b514bc4..e2358f9 100644 --- a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/SqlExpression.java +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/SqlExpression.java @@ -16,9 +16,9 @@ import java.util.List; import java.util.Optional; -import org.eclipse.daanse.jdbc.db.dialect.api.generator.BitOperation; -import org.eclipse.daanse.jdbc.db.dialect.api.sql.OrderedColumn; -import org.eclipse.daanse.jdbc.db.dialect.api.type.Datatype; +import org.eclipse.daanse.jdbc.db.api.sql.BitOperation; +import org.eclipse.daanse.jdbc.db.api.sql.OrderedColumn; +import org.eclipse.daanse.jdbc.db.api.type.Datatype; import org.eclipse.daanse.sql.statement.api.model.SelectStatement; /** diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/GroupBy.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/GroupBy.java index 19e6f2b..2beb4ff 100644 --- a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/GroupBy.java +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/GroupBy.java @@ -27,8 +27,20 @@ * supports them) * @param groupingFunctions {@code GROUPING(expr)} columns added to the select * list + * @param completeNonAggregates when {@code true}, dialects that do NOT allow non-aggregate + * select columns outside {@code GROUP BY} + * ({@code !allowsSelectNotInGroupBy()}) have every non-aggregate + * projection not already a key appended to the group by at render time. + * The canonical (permissive-dialect) form groups only the keys the + * builder placed; the renderer completes it per dialect. */ -public record GroupBy(List keys, List groupingSets, List groupingFunctions) { +public record GroupBy(List keys, List groupingSets, List groupingFunctions, + boolean completeNonAggregates) { + + /** Backward-compatible: no dialect-completed grouping. */ + public GroupBy(List keys, List groupingSets, List groupingFunctions) { + this(keys, groupingSets, groupingFunctions, false); + } /** True if there is nothing to group by. */ public boolean isEmpty() { diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/Projection.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/Projection.java index ac09032..af4236e 100644 --- a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/Projection.java +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/Projection.java @@ -15,7 +15,7 @@ import java.util.Optional; -import org.eclipse.daanse.jdbc.db.dialect.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; import org.eclipse.daanse.sql.statement.api.expression.SqlExpression; /** @@ -34,12 +34,26 @@ * @param comment an optional explanatory comment (rollup provenance), * emitted only when the renderer is asked to emit comments; * never part of the executed SQL + * @param groupByCompletionExempt when {@code true}, the dialect GROUP-BY + * completion (which adds every non-aggregate projection to + * GROUP BY for engines that require it) skips this projection. + * Set for a projection that is semantically an aggregate the + * renderer cannot recognise structurally — e.g. an arithmetic + * expression WRAPPING aggregates (a native TopCount/Order + * measure like {@code (sum(a)-sum(b))/sum(b)}), which must not + * be grouped. Never affects permissive dialects. */ public record Projection(SqlExpression expression, BestFitColumnType columnType, Optional alias, - Optional comment) { + Optional comment, boolean groupByCompletionExempt) { + + /** Backwards-compatible form without the completion-exempt flag (defaults to {@code false}). */ + public Projection(SqlExpression expression, BestFitColumnType columnType, Optional alias, + Optional comment) { + this(expression, columnType, alias, comment, false); + } /** Backwards-compatible form without a comment. */ public Projection(SqlExpression expression, BestFitColumnType columnType, Optional alias) { - this(expression, columnType, alias, Optional.empty()); + this(expression, columnType, alias, Optional.empty(), false); } } diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/SortSpec.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/SortSpec.java index cfaa698..7f515ad 100644 --- a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/SortSpec.java +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/SortSpec.java @@ -39,12 +39,12 @@ * {@code "de_DE"}). */ public record SortSpec(SortDirection direction, boolean nullable, NullOrder nullOrder, boolean prepend, - String nullSortValue, org.eclipse.daanse.jdbc.db.dialect.api.type.Datatype nullSortDatatype, + String nullSortValue, org.eclipse.daanse.jdbc.db.api.type.Datatype nullSortDatatype, Optional collation) { /** Compatibility constructor without a collation. */ public SortSpec(SortDirection direction, boolean nullable, NullOrder nullOrder, boolean prepend, - String nullSortValue, org.eclipse.daanse.jdbc.db.dialect.api.type.Datatype nullSortDatatype) { + String nullSortValue, org.eclipse.daanse.jdbc.db.api.type.Datatype nullSortDatatype) { this(direction, nullable, nullOrder, prepend, nullSortValue, nullSortDatatype, Optional.empty()); } @@ -75,7 +75,7 @@ public SortSpec prepended() { * @return a copy ordering nulls as if they held {@code value} (of type * {@code datatype}), via the dialect's order-value generator. */ - public SortSpec withNullSortValue(String value, org.eclipse.daanse.jdbc.db.dialect.api.type.Datatype datatype) { + public SortSpec withNullSortValue(String value, org.eclipse.daanse.jdbc.db.api.type.Datatype datatype) { return new SortSpec(direction, nullable, nullOrder, prepend, value, datatype, collation); } diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/BoundParameter.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/BoundParameter.java index b54ef32..257ea7d 100644 --- a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/BoundParameter.java +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/BoundParameter.java @@ -13,7 +13,7 @@ */ package org.eclipse.daanse.sql.statement.api.render; -import org.eclipse.daanse.jdbc.db.dialect.api.type.Datatype; +import org.eclipse.daanse.jdbc.db.api.type.Datatype; /** * One bind parameter of a rendered statement, in placeholder order. diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/RenderedSql.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/RenderedSql.java index af99832..9d1edf2 100644 --- a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/RenderedSql.java +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/RenderedSql.java @@ -15,7 +15,7 @@ import java.util.List; -import org.eclipse.daanse.jdbc.db.dialect.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; /** * The result of rendering a statement: the SQL text, the column types to read diff --git a/statement/demo/pom.xml b/statement/demo/pom.xml new file mode 100644 index 0000000..9b70361 --- /dev/null +++ b/statement/demo/pom.xml @@ -0,0 +1,66 @@ + + + + 4.0.0 + + org.eclipse.daanse + org.eclipse.daanse.sql.statement + ${revision} + + org.eclipse.daanse.sql.statement.demo + Daanse SQL Query Builder Demo + Runnable demonstration of the SQL query builder: assembles + queries with the + dialect-free API and renders them with the ANSI dialect. Shows, in comments, + how a + future adapter (e.g. ROLAP or ORM) would translate its own model into the + builder + primitives. Not a production module. + + + + org.eclipse.daanse + org.eclipse.daanse.sql.statement.api + ${project.version} + + + org.eclipse.daanse + org.eclipse.daanse.sql.statement.impl + ${project.version} + + + com.h2database + h2 + 2.2.224 + + + org.eclipse.daanse + org.eclipse.daanse.jdbc.db.dialect.db.common + 0.0.1-SNAPSHOT + + + org.eclipse.daanse + org.eclipse.daanse.jdbc.db.dialect.db.mssqlserver + 0.0.1-SNAPSHOT + test + + + org.eclipse.daanse + org.eclipse.daanse.jdbc.db.dialect.db.mysql + 0.0.1-SNAPSHOT + test + + + diff --git a/statement/demo/src/main/java/org/eclipse/daanse/sql/statement/demo/ResultReaderDemo.java b/statement/demo/src/main/java/org/eclipse/daanse/sql/statement/demo/ResultReaderDemo.java new file mode 100644 index 0000000..0b62b2a --- /dev/null +++ b/statement/demo/src/main/java/org/eclipse/daanse/sql/statement/demo/ResultReaderDemo.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.daanse.jdbc.db.dialect.api.Dialect; +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.Datatype; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.InsertStatementBuilder; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.ProjectionRef; +import org.eclipse.daanse.sql.statement.api.model.SortSpec; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.api.render.RenderedSql; +import org.eclipse.daanse.sql.statement.exec.JdbcStatementExecutor; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; + +/** + * End-to-end demonstration of the read path: build statements with the dialect-free API, + * render them, execute against a real (in-memory H2) database, and map result rows back into + * targets via {@link org.eclipse.daanse.sql.statement.api.result.RowMapper}. + *

+ * Two mapping styles are shown, both keyed by the build-time {@link ProjectionRef} handles: + *

    + *
  1. {@link #loadProducts} maps each row into a {@link Product} record;
  2. + *
  3. {@link #loadAsAttributes} maps each row into a generic {@link AttributeBinder} — the + * direct analogue of an ORM's {@code eObject.eSet(feature, value)} or a ROLAP member setter.
  4. + *
+ */ +public final class ResultReaderDemo { + + private static final Dialect DIALECT = new AnsiDialect(); + private static final DialectSqlRenderer RENDERER = new DialectSqlRenderer(DIALECT); + + private ResultReaderDemo() { + } + + /** A demo target record. */ + public record Product(Integer id, String name) { + } + + /** A generic sink for column values — mirrors {@code EObject.eSet(feature, value)}. */ + public interface AttributeBinder { + void set(String attribute, Object value); + } + + /** A {@link Map}-backed {@link AttributeBinder} standing in for an EObject. */ + public static final class MapAttributeBinder implements AttributeBinder { + private final Map values = new LinkedHashMap<>(); + + @Override + public void set(String attribute, Object value) { + values.put(attribute, value); + } + + public Map values() { + return values; + } + } + + /** DDL for the demo table (quoted lowercase identifiers, matching the renderer's quoting). */ + public static String createTableSql() { + return "create table \"product\" (\"id\" int primary key, \"name\" varchar(50))"; + } + + /** Inserts two rows and reads them back as {@link Product} records. */ + public static List loadProducts(Connection connection) { + JdbcStatementExecutor executor = new JdbcStatementExecutor(connection); + + executor.update(RENDERER.render(InsertStatementBuilder.create().into("product").columns("id", "name") + .addRow(Expressions.literal(1, Datatype.INTEGER), Expressions.literal("Widget", Datatype.VARCHAR)) + .addRow(Expressions.literal(2, Datatype.INTEGER), Expressions.literal("Gadget", Datatype.VARCHAR)) + .build())); + + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias p = TableAlias.of("p"); + q.from(From.table("product", p)); + ProjectionRef idRef = q.project(Expressions.column(p, "id"), BestFitColumnType.INT); + ProjectionRef nameRef = q.project(Expressions.column(p, "name"), BestFitColumnType.STRING); + q.orderOn(idRef, SortSpec.asc()); + RenderedSql sql = RENDERER.render(q.build()); + + // The same handles used to build the query read it back, already typed. + return executor.query(sql, row -> new Product(row.getInt(idRef), row.getString(nameRef))); + } + + /** Reads the rows into generic attribute maps (the EObject/ROLAP correspondence). */ + public static List> loadAsAttributes(Connection connection) { + JdbcStatementExecutor executor = new JdbcStatementExecutor(connection); + + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias p = TableAlias.of("p"); + q.from(From.table("product", p)); + ProjectionRef idRef = q.project(Expressions.column(p, "id"), BestFitColumnType.INT); + ProjectionRef nameRef = q.project(Expressions.column(p, "name"), BestFitColumnType.STRING); + q.orderOn(idRef, SortSpec.asc()); + RenderedSql sql = RENDERER.render(q.build()); + + return executor.query(sql, row -> { + MapAttributeBinder target = new MapAttributeBinder(); + // In an ORM this is eObject.eSet(idFeature, value); in ROLAP a member setter. + target.set("id", row.get(idRef)); + target.set("name", row.get(nameRef)); + return target.values(); + }); + } + + public static void main(String[] args) throws Exception { + try (Connection c = DriverManager.getConnection("jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1", "sa", "")) { + try (java.sql.Statement s = c.createStatement()) { + s.execute(createTableSql()); + } + System.out.println("-- as records"); + loadProducts(c).forEach(System.out::println); + System.out.println("-- as attribute maps (EObject/ROLAP correspondence)"); + loadAsAttributes(c).forEach(System.out::println); + } + } +} diff --git a/statement/demo/src/main/java/org/eclipse/daanse/sql/statement/demo/StatementBuilderDemo.java b/statement/demo/src/main/java/org/eclipse/daanse/sql/statement/demo/StatementBuilderDemo.java new file mode 100644 index 0000000..e512b79 --- /dev/null +++ b/statement/demo/src/main/java/org/eclipse/daanse/sql/statement/demo/StatementBuilderDemo.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import java.util.List; + +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.Datatype; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.Predicates; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.ColumnAlias; +import org.eclipse.daanse.sql.statement.api.model.SelectStatement; +import org.eclipse.daanse.sql.statement.api.model.SetOperation; +import org.eclipse.daanse.sql.statement.api.model.SortSpec; +import org.eclipse.daanse.sql.statement.api.model.Statement; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; + +public final class StatementBuilderDemo { + + private StatementBuilderDemo() { + } + + /** + * {@code sales} joined to {@code product}, counted per product name, filtered + * and ordered. Demonstrates projections, a join, a WHERE (comparison), GROUP BY + * by handle, and ORDER BY. + */ + public static SelectStatement salesByProduct() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias s = TableAlias.of("s"); + TableAlias p = TableAlias.of("p"); + + q.from(From.table("sales", s)); + q.innerJoin(From.table("product", p), + Predicates.eq(Expressions.column(s, "product_id"), Expressions.column(p, "id"))); + + var nameRef = q.project(Expressions.column(p, "name"), BestFitColumnType.STRING); + q.project(Expressions.countStar(), BestFitColumnType.LONG, ColumnAlias.of("cnt")); + + q.where(Predicates.gt(Expressions.column(s, "amount"), Expressions.literal(100, Datatype.INTEGER))); + q.groupOn(nameRef); + q.orderOn(nameRef, SortSpec.asc()); + return q.build(); + } + + /** + * Top-N product names. Demonstrates a row limit, which renders as a trailing + * {@code FETCH NEXT} on ANSI dialects but as a leading {@code TOP} on SQL + * Server. + */ + public static SelectStatement topProducts() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias p = TableAlias.of("p"); + q.from(From.table("product", p)); + var nameRef = q.project(Expressions.column(p, "name"), BestFitColumnType.STRING); + q.orderOn(nameRef, SortSpec.asc()); + q.rowLimit(10); + return q.build(); + } + + /** A {@code UNION ALL} of two simple selects (e.g. table-per-class style). */ + public static Statement productNameUnion() { + SelectStatementBuilder a = SelectStatementBuilder.create(); + TableAlias ta = TableAlias.of("a"); + a.from(From.table("product_a", ta)); + a.project(Expressions.column(ta, "name"), BestFitColumnType.STRING); + + SelectStatementBuilder b = SelectStatementBuilder.create(); + TableAlias tb = TableAlias.of("b"); + b.from(From.table("product_b", tb)); + b.project(Expressions.column(tb, "name"), BestFitColumnType.STRING); + + return SetOperation.unionAll(List.of(a.build(), b.build())); + } + + public static void main(String[] args) { + DialectSqlRenderer renderer = new DialectSqlRenderer(new AnsiDialect()); + print(renderer, "salesByProduct", salesByProduct()); + print(renderer, "topProducts", topProducts()); + print(renderer, "productNameUnion", productNameUnion()); + } + + private static void print(DialectSqlRenderer renderer, String label, Statement statement) { + // Using System.out is intentional for a runnable demo. + System.out.println("-- " + label); + System.out.println(renderer.render(statement).sql()); + System.out.println(); + } +} diff --git a/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/compare/package-info.java b/statement/demo/src/main/java/org/eclipse/daanse/sql/statement/demo/package-info.java similarity index 71% rename from statement/impl/src/main/java/org/eclipse/daanse/sql/statement/compare/package-info.java rename to statement/demo/src/main/java/org/eclipse/daanse/sql/statement/demo/package-info.java index 2ec6c47..b54fe92 100644 --- a/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/compare/package-info.java +++ b/statement/demo/src/main/java/org/eclipse/daanse/sql/statement/demo/package-info.java @@ -12,10 +12,6 @@ * Stefan Bischof (bipolis.org) - initial */ -/** - * Comparison of statements and SQL texts up to defined equivalences — the - * promotion/verification gate consumers build on. - */ @org.osgi.annotation.bundle.Export @org.osgi.annotation.versioning.Version("0.0.1") -package org.eclipse.daanse.sql.statement.compare; +package org.eclipse.daanse.sql.statement.demo; diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/AggregateTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/AggregateTest.java new file mode 100644 index 0000000..df45d85 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/AggregateTest.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.eclipse.daanse.jdbc.db.dialect.api.Dialect; +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.ProjectionRef; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** + * Covers the {@code Aggregate(name, distinct, args)} expression node: plain aggregate, single + * {@code DISTINCT}, and compound {@code COUNT(DISTINCT a, b)} — the shapes ROLAP's measure and + * statistics queries need. + */ +class AggregateTest { + + private final Dialect ansi = new AnsiDialect(); + + @Test + void plainAggregateAndGroupBy() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias f = TableAlias.of("f"); + q.from(From.table("sales", f)); + ProjectionRef region = q.project(Expressions.column(f, "region"), BestFitColumnType.STRING); + q.groupOn(region); + q.project(Expressions.aggregate("SUM", Expressions.column(f, "amount")), BestFitColumnType.DECIMAL); + String sql = new DialectSqlRenderer(ansi).render(q.build()).sql(); + assertEquals(""" + select "f"."region" as "c0", SUM("f"."amount") as "c1" \ + from "sales" as "f" group by "f"."region"\ + """, sql); + } + + @Test + void singleColumnCountDistinct() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias f = TableAlias.of("f"); + q.from(From.table("sales", f)); + q.project(Expressions.countDistinct(Expressions.column(f, "customer_id")), BestFitColumnType.INT); + String sql = new DialectSqlRenderer(ansi).render(q.build()).sql(); + assertEquals(""" + select COUNT(distinct "f"."customer_id") as "c0" from "sales" as "f"\ + """, sql); + } + + @Test + void compoundCountDistinct() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias f = TableAlias.of("f"); + q.from(From.table("sales", f)); + q.project(Expressions.countDistinct(Expressions.column(f, "a"), Expressions.column(f, "b")), + BestFitColumnType.INT); + String sql = new DialectSqlRenderer(ansi).render(q.build()).sql(); + assertEquals(""" + select COUNT(distinct "f"."a", "f"."b") as "c0" from "sales" as "f"\ + """, sql); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/CollationOrderByTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/CollationOrderByTest.java new file mode 100644 index 0000000..aa789f4 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/CollationOrderByTest.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.NullOrder; +import org.eclipse.daanse.sql.statement.api.model.SortSpec; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** + * {@code ORDER BY expr COLLATE name}: the collation binds to the key expression (before the + * dialect's direction/null-order machinery) and the name is emitted verbatim. + */ +class CollationOrderByTest { + + private final DialectSqlRenderer renderer = new DialectSqlRenderer(new AnsiDialect()); + + private static SelectStatementBuilder oneColumn() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias t = TableAlias.of("t"); + q.from(From.table("fact", t)); + q.project(Expressions.column(t, "name"), BestFitColumnType.STRING); + return q; + } + + @Test + void orderBy_withCollation() { + SelectStatementBuilder q = oneColumn(); + TableAlias t = TableAlias.of("t"); + q.orderOn(Expressions.column(t, "name"), SortSpec.asc().withCollation("utf8mb4_bin")); + assertEquals(""" + select "t"."name" as "c0" from "fact" as "t" \ + order by "t"."name" collate utf8mb4_bin ASC\ + """, renderer.render(q.build()).sql()); + } + + /** Collation composes with null-ordering: it wraps the expression, not the direction. */ + @Test + void orderBy_collationWithNullOrder() { + SelectStatementBuilder q = oneColumn(); + TableAlias t = TableAlias.of("t"); + q.orderOn(Expressions.column(t, "name"), + SortSpec.desc().withNulls(NullOrder.LAST).withCollation("Latin1_General_CS_AS")); + String sql = renderer.render(q.build()).sql(); + assertTrue(sql.contains(""" + "t"."name" collate Latin1_General_CS_AS\ + """), sql); + assertTrue(sql.endsWith("DESC"), sql); + } + + /** Without a collation the output is unchanged (compat constructors default to empty). */ + @Test + void orderBy_noCollation_unchanged() { + SelectStatementBuilder q = oneColumn(); + TableAlias t = TableAlias.of("t"); + q.orderOn(Expressions.column(t, "name"), SortSpec.asc()); + assertEquals(""" + select "t"."name" as "c0" from "fact" as "t" \ + order by "t"."name" ASC\ + """, renderer.render(q.build()).sql()); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/ConstantPredicateTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/ConstantPredicateTest.java new file mode 100644 index 0000000..24deaa4 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/ConstantPredicateTest.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.Datatype; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.Predicates; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** + * The constant predicates {@code alwaysTrue()} / {@code alwaysFalse()}: they render with the same + * {@code 1 = 1} / {@code 1 = 0} spelling as an empty connective, without parentheses. + */ +class ConstantPredicateTest { + + private final DialectSqlRenderer renderer = new DialectSqlRenderer(new AnsiDialect()); + + @Test + void constants_renderWithoutParens() { + assertEquals("1 = 1", renderer.renderPredicate(Predicates.alwaysTrue())); + assertEquals("1 = 0", renderer.renderPredicate(Predicates.alwaysFalse())); + } + + @Test + void factories_returnSingletons() { + assertSame(Predicates.alwaysTrue(), Predicates.alwaysTrue()); + assertSame(Predicates.alwaysFalse(), Predicates.alwaysFalse()); + } + + @Test + void constant_insideAnd() { + String sql = renderer.renderPredicate(Predicates.and(Predicates.alwaysTrue(), + Predicates.eq(Expressions.column("id"), Expressions.literal(1, Datatype.INTEGER)))); + assertEquals("(1 = 1 and \"id\" = 1)", sql); + } + + @Test + void notAlwaysFalse() { + assertEquals("not (1 = 0)", renderer.renderPredicate(Predicates.not(Predicates.alwaysFalse()))); + } + + @Test + void emptyConnectives_useTheSameSpelling() { + // The empty-connective case and Constant share one helper — pin both spellings. + assertEquals("1 = 1", renderer.renderPredicate(Predicates.and())); + assertEquals("1 = 0", renderer.renderPredicate(Predicates.or())); + } + + @Test + void constant_inWhereOfFullStatement() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias t = TableAlias.of("t"); + q.from(From.table("fact", t)); + q.project(Expressions.column(t, "id"), BestFitColumnType.INT); + q.where(Predicates.alwaysFalse()); + assertEquals(""" + select "t"."id" as "c0" from "fact" as "t" where 1 = 0\ + """, + renderer.render(q.build()).sql()); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/CteTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/CteTest.java new file mode 100644 index 0000000..6b15169 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/CteTest.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; +import java.util.List; + +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.Datatype; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.InsertStatementBuilder; +import org.eclipse.daanse.sql.statement.api.Predicates; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.WithStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.ColumnAlias; +import org.eclipse.daanse.sql.statement.api.model.ProjectionRef; +import org.eclipse.daanse.sql.statement.api.model.SortSpec; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.api.model.WithStatement; +import org.eclipse.daanse.sql.statement.exec.JdbcStatementExecutor; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** Common-table expression ({@code WITH}) rendering and a real H2 round-trip. */ +class CteTest { + + private final DialectSqlRenderer renderer = new DialectSqlRenderer(new AnsiDialect()); + + private record Built(WithStatement statement, ProjectionRef nameRef) { + } + + /** + * {@code WITH expensive AS (SELECT id, name FROM product WHERE price > 100) + * SELECT name FROM expensive ORDER BY name}. The CTE columns are aliased so the body can + * reference them by name (a CTE's column names come from its inner projection aliases). + */ + private Built expensive() { + SelectStatementBuilder cte = SelectStatementBuilder.create(); + TableAlias pr = TableAlias.of("pr"); + cte.from(From.table("product", pr)); + cte.project(Expressions.column(pr, "id"), BestFitColumnType.INT, ColumnAlias.of("id")); + cte.project(Expressions.column(pr, "name"), BestFitColumnType.STRING, ColumnAlias.of("name")); + cte.where(Predicates.gt(Expressions.column(pr, "price"), Expressions.literal(100, Datatype.INTEGER))); + + SelectStatementBuilder body = SelectStatementBuilder.create(); + TableAlias e = TableAlias.of("e"); + body.from(From.table("expensive", e)); + ProjectionRef nameRef = body.project(Expressions.column(e, "name"), BestFitColumnType.STRING); + body.orderOn(nameRef, SortSpec.asc()); + + WithStatement w = WithStatementBuilder.create().cte("expensive", cte.build()).body(body.build()).build(); + return new Built(w, nameRef); + } + + @Test + void rendersWithClause() { + String sql = renderer.render(expensive().statement()).sql(); + assertEquals(""" + WITH "expensive" AS (\ + select "pr"."id" as "id", "pr"."name" as "name" from "product" as "pr" \ + where "pr"."price" > 100) \ + select "e"."name" as "c0" from "expensive" as "e" order by "e"."name" ASC\ + """, sql); + } + + @Test + void cteRoundTrip_onH2() throws Exception { + try (Connection c = DriverManager.getConnection("jdbc:h2:mem:cte;DB_CLOSE_DELAY=-1", "sa", "")) { + try (Statement s = c.createStatement()) { + s.execute(""" + create table "product" ("id" int primary key, "name" varchar(50), "price" int)\ + """); + } + JdbcStatementExecutor exec = new JdbcStatementExecutor(c); + exec.update(renderer.render(InsertStatementBuilder.create().into("product").columns("id", "name", "price") + .addRow(Expressions.literal(1, Datatype.INTEGER), Expressions.literal("Cheap", Datatype.VARCHAR), + Expressions.literal(50, Datatype.INTEGER)) + .addRow(Expressions.literal(2, Datatype.INTEGER), Expressions.literal("Mid", Datatype.VARCHAR), + Expressions.literal(150, Datatype.INTEGER)) + .addRow(Expressions.literal(3, Datatype.INTEGER), Expressions.literal("Lux", Datatype.VARCHAR), + Expressions.literal(300, Datatype.INTEGER)) + .build())); + + Built built = expensive(); + List names = exec.query(renderer.render(built.statement()), row -> row.getString(built.nameRef())); + assertEquals(List.of("Lux", "Mid"), names); + } + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/DataSourceExecutorTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/DataSourceExecutorTest.java new file mode 100644 index 0000000..79a955c --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/DataSourceExecutorTest.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; + +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.Datatype; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.InsertStatementBuilder; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.exec.StatementExecutor; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.api.render.RenderedSql; +import org.eclipse.daanse.sql.statement.exec.DataSourceStatementExecutor; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; + +/** {@link DataSourceStatementExecutor}: per-call connections, plus transactional commit/rollback. */ +class DataSourceExecutorTest { + + private final DialectSqlRenderer renderer = new DialectSqlRenderer(new AnsiDialect()); + + private JdbcDataSource h2() { + JdbcDataSource ds = new JdbcDataSource(); + // DB_CLOSE_DELAY=-1 keeps the in-memory DB alive across the per-call connections. + ds.setURL("jdbc:h2:mem:dse;DB_CLOSE_DELAY=-1"); + ds.setUser("sa"); + ds.setPassword(""); + return ds; + } + + private RenderedSql insert(int id, String name) { + return renderer.render(InsertStatementBuilder.create().into("product").columns("id", "name") + .addRow(Expressions.literal(id, Datatype.INTEGER), Expressions.literal(name, Datatype.VARCHAR)) + .build()); + } + + private long count(StatementExecutor exec) { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias p = TableAlias.of("p"); + q.from(From.table("product", p)); + q.project(Expressions.countStar(), BestFitColumnType.LONG); + return exec.query(renderer.render(q.build()), row -> row.getLong(0)).get(0); + } + + @Test + void perCall_and_transaction_commit_and_rollback() { + DataSourceStatementExecutor exec = new DataSourceStatementExecutor(h2()); + + // DDL and a one-shot insert, each on its own borrowed connection. + exec.update(RenderedSql.of(""" + create table "product" ("id" int primary key, "name" varchar(50))\ + """, + List.of())); + exec.update(insert(1, "Widget")); + assertEquals(1L, count(exec)); + + // Two inserts in one transaction; the count seen inside reflects them, and they persist. + long insideCount = exec.inTransaction(tx -> { + tx.update(insert(2, "Gadget")); + tx.update(insert(3, "Gizmo")); + return count(tx); + }); + assertEquals(3L, insideCount); + assertEquals(3L, count(exec)); + + // A failing transaction is rolled back: row 4 must not persist. + RuntimeException boom = assertThrows(RuntimeException.class, () -> exec.inTransaction(tx -> { + tx.update(insert(4, "Doomed")); + throw new IllegalStateException("boom"); + })); + assertEquals("boom", boom.getMessage()); + assertEquals(3L, count(exec)); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/DmlTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/DmlTest.java new file mode 100644 index 0000000..b5348a1 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/DmlTest.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.eclipse.daanse.jdbc.db.dialect.api.Dialect; +import org.eclipse.daanse.jdbc.db.api.type.Datatype; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.DeleteStatementBuilder; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.InsertStatementBuilder; +import org.eclipse.daanse.sql.statement.api.Predicates; +import org.eclipse.daanse.sql.statement.api.UpdateStatementBuilder; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** Golden tests for the INSERT/UPDATE/DELETE statements (rendered with the ANSI dialect). */ +class DmlTest { + + private final DialectSqlRenderer renderer = new DialectSqlRenderer((Dialect) new AnsiDialect()); + + @Test + void insertValues() { + String sql = renderer.render(InsertStatementBuilder.create().into("product").columns("id", "name") + .addRow(Expressions.literal(1, Datatype.INTEGER), Expressions.literal("Widget", Datatype.VARCHAR)) + .addRow(Expressions.literal(2, Datatype.INTEGER), Expressions.literal("Gadget", Datatype.VARCHAR)) + .build()).sql(); + assertEquals(""" + insert into "product" ("id", "name") values (1, 'Widget'), (2, 'Gadget')\ + """, sql); + } + + @Test + void update() { + String sql = renderer.render(UpdateStatementBuilder.create().table("product") + .set("name", Expressions.literal("Gizmo", Datatype.VARCHAR)) + .set("price", Expressions.literal(10, Datatype.INTEGER)) + .where(Predicates.eq(Expressions.column("id"), Expressions.literal(1, Datatype.INTEGER))) + .build()).sql(); + assertEquals(""" + update "product" set "name" = 'Gizmo', "price" = 10 where "id" = 1\ + """, sql); + } + + @Test + void delete() { + String sql = renderer.render(DeleteStatementBuilder.create().from("product") + .where(Predicates.eq(Expressions.column("id"), Expressions.literal(1, Datatype.INTEGER))) + .build()).sql(); + assertEquals(""" + delete from "product" where "id" = 1\ + """, sql); + } + + @Test + void insertFromSelect() { + org.eclipse.daanse.sql.statement.api.SelectStatementBuilder src = org.eclipse.daanse.sql.statement.api.SelectStatementBuilder + .create(); + org.eclipse.daanse.sql.statement.api.model.TableAlias p = org.eclipse.daanse.sql.statement.api.model.TableAlias + .of("p"); + src.from(org.eclipse.daanse.sql.statement.api.From.table("product", p)); + src.project(Expressions.column(p, "id"), null); + src.project(Expressions.column(p, "name"), null); + String sql = renderer.render(InsertStatementBuilder.create().into("product_archive").columns("id", "name") + .fromSelect(src.build()).build()).sql(); + assertEquals(""" + insert into "product_archive" ("id", "name") \ + select "p"."id" as "c0", "p"."name" as "c1" from "product" as "p"\ + """, sql); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/ExistsPredicateTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/ExistsPredicateTest.java new file mode 100644 index 0000000..1cdbb22 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/ExistsPredicateTest.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.Datatype; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.Predicates; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.SelectStatement; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.api.render.RenderedSql; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** + * {@code [NOT] EXISTS (subquery)}: plain, negated, correlated, and bind-parameter accumulation in + * placeholder order across the outer statement and the subquery. + */ +class ExistsPredicateTest { + + private final DialectSqlRenderer renderer = new DialectSqlRenderer(new AnsiDialect()); + + /** {@code select l.order_id from line l} (optionally correlated to the outer alias). */ + private static SelectStatement lines(org.eclipse.daanse.sql.statement.api.expression.Predicate... filters) { + SelectStatementBuilder sub = SelectStatementBuilder.create(); + TableAlias l = TableAlias.of("l"); + sub.from(From.table("line", l)); + sub.project(Expressions.column(l, "order_id"), BestFitColumnType.INT); + for (org.eclipse.daanse.sql.statement.api.expression.Predicate f : filters) { + sub.where(f); + } + return sub.build(); + } + + private static SelectStatementBuilder ordersQuery(TableAlias o) { + SelectStatementBuilder q = SelectStatementBuilder.create(); + q.from(From.table("orders", o)); + q.project(Expressions.column(o, "id"), BestFitColumnType.INT); + return q; + } + + @Test + void exists_plain() { + TableAlias o = TableAlias.of("o"); + SelectStatementBuilder q = ordersQuery(o); + q.where(Predicates.exists(lines())); + String sql = renderer.render(q.build()).sql(); + assertEquals(""" + select "o"."id" as "c0" from "orders" as "o" \ + where exists (select "l"."order_id" as "c0" from "line" as "l")\ + """, sql); + } + + @Test + void notExists_negated() { + TableAlias o = TableAlias.of("o"); + SelectStatementBuilder q = ordersQuery(o); + q.where(Predicates.notExists(lines())); + String sql = renderer.render(q.build()).sql(); + assertEquals(""" + select "o"."id" as "c0" from "orders" as "o" \ + where not exists (select "l"."order_id" as "c0" from "line" as "l")\ + """, sql); + } + + @Test + void exists_correlated_referencesOuterAlias() { + TableAlias o = TableAlias.of("o"); + TableAlias l = TableAlias.of("l"); + SelectStatementBuilder q = ordersQuery(o); + q.where(Predicates.exists( + lines(Predicates.eq(Expressions.column(l, "order_id"), Expressions.column(o, "id"))))); + String sql = renderer.render(q.build()).sql(); + assertEquals(""" + select "o"."id" as "c0" from "orders" as "o" \ + where exists (select "l"."order_id" as "c0" from "line" as "l" \ + where "l"."order_id" = "o"."id")\ + """, sql); + } + + @Test + void exists_parametersKeepPlaceholderOrder_outerBeforeAndAfter() { + TableAlias o = TableAlias.of("o"); + TableAlias l = TableAlias.of("l"); + SelectStatementBuilder q = ordersQuery(o); + // outer param BEFORE the subquery, one param INSIDE it, and an outer param AFTER it + q.where(Predicates.eq(Expressions.column(o, "region"), Expressions.param("EMEA", Datatype.VARCHAR))); + q.where(Predicates.exists(lines( + Predicates.eq(Expressions.column(l, "qty"), Expressions.param(5, Datatype.INTEGER))))); + q.where(Predicates.eq(Expressions.column(o, "year"), Expressions.param(1997, Datatype.INTEGER))); + RenderedSql sql = renderer.render(q.build()); + + assertEquals(""" + select "o"."id" as "c0" from "orders" as "o" \ + where "o"."region" = ? \ + and exists (select "l"."order_id" as "c0" from "line" as "l" where "l"."qty" = ?) \ + and "o"."year" = ?\ + """, sql.sql()); + assertEquals(List.of("EMEA", 5, 1997), + sql.parameters().stream().map(bp -> bp.value()).toList()); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/ExtraAggregateTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/ExtraAggregateTest.java new file mode 100644 index 0000000..6080991 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/ExtraAggregateTest.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Optional; + +import org.eclipse.daanse.jdbc.db.dialect.api.Dialect; +import org.eclipse.daanse.jdbc.db.api.sql.BitOperation; +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.dialect.db.mysql.MySqlDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.expression.SqlExpression; +import org.eclipse.daanse.sql.statement.api.model.ColumnAlias; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** + * {@link SqlExpression.ExtraAggregate} carries dialect-free parameters and the renderer generates the SQL + * at render time via {@code dialect.aggregationGenerator()} — the same call the (now node-producing) extra + * aggregators used to make at build time, so the rendered SQL is byte-identical. This checks the renderer + * dispatches to the right generator method with the rendered operand. + */ +class ExtraAggregateTest { + + private final Dialect mysql = new MySqlDialect(); + + private String render(SqlExpression projection) { + TableAlias t = TableAlias.of("t"); + SelectStatementBuilder q = SelectStatementBuilder.create(); + q.from(From.table("t", t)); + q.project(projection, BestFitColumnType.LONG, ColumnAlias.of("m0")); + return new DialectSqlRenderer(mysql).render(q.build()).sql(); + } + + @Test + void bitAggregation_dispatchesToGeneratorWithRenderedOperand() { + TableAlias t = TableAlias.of("t"); + SqlExpression operand = Expressions.column(t, "flags"); + // the operand SQL exactly as the renderer produces it + String operandSql = render(operand); + operandSql = operandSql.substring(operandSql.indexOf(' ') + 1, operandSql.indexOf(" as ")); + + String sql = render(new SqlExpression.ExtraAggregate(Optional.of(operand), + new SqlExpression.ExtraAggregate.Spec.BitAggregation(BitOperation.AND))); + + String expected = mysql.aggregationGenerator().generateBitAggregation(BitOperation.AND, operandSql) + .orElseThrow(() -> new AssertionError("MySql does not support bit aggregation")); + assertTrue(sql.contains(expected), "rendered=" + sql + " expected fragment=" + expected); + } + + @Test + void caseFold_rendersViaDialectUpperCaseFunction() { + TableAlias t = TableAlias.of("t"); + SqlExpression col = Expressions.column(t, "name"); + String colSql = render(col); + colSql = colSql.substring(colSql.indexOf(' ') + 1, colSql.indexOf(" as ")); + + String sql = render(new SqlExpression.CaseFold(col)); + + String expected = mysql.functionGenerator().wrapIntoSqlUpperCaseFunction(colSql).toString(); + assertTrue(sql.contains(expected), "rendered=" + sql + " expected fragment=" + expected); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/FooterCommentTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/FooterCommentTest.java new file mode 100644 index 0000000..858ff69 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/FooterCommentTest.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Optional; + +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.Datatype; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.InsertStatementBuilder; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.SelectStatement; +import org.eclipse.daanse.sql.statement.api.model.SetOperation; +import org.eclipse.daanse.sql.statement.api.model.Statement; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.api.render.RenderOptions; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** + * The footer comment: appended as a {@code /* ... *}{@code /} block on its own line at the very end + * of the rendered statement (compact mode included), only when comments are on; multi-line text + * stays multi-line; {@code *}{@code /} is neutralized; comments-off output is byte-identical. + */ +class FooterCommentTest { + + private static final String NL = System.lineSeparator(); + + private final DialectSqlRenderer renderer = new DialectSqlRenderer(new AnsiDialect()); + + private static SelectStatement plainRead(String footer) { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias t = TableAlias.of("t"); + q.from(From.table("fact", t)); + q.project(Expressions.column(t, "id"), BestFitColumnType.INT); + if (footer != null) { + q.footerComment(footer); + } + return q.build(); + } + + @Test + void compact_footerOnItsOwnLine_atTheVeryEnd() { + String sql = renderer.render(plainRead("emitted-by=TupleSqlMapper"), + RenderOptions.compact().withComments(true)).sql(); + assertEquals("select \"t\".\"id\" as \"c0\" from \"fact\" as \"t\"" + + NL + "/* emitted-by=TupleSqlMapper */", sql); + } + + @Test + void formatted_footerOnItsOwnLine_atTheVeryEnd() { + String sql = renderer.render(plainRead("emitted-by=TupleSqlMapper"), + RenderOptions.multiLine().withComments(true)).sql(); + assertTrue(sql.endsWith(NL + "/* emitted-by=TupleSqlMapper */"), sql); + } + + @Test + void multiLineFooter_staysMultiLineInsideTheBlock() { + String sql = renderer.render(plainRead("request=[Sales]\nslicer=[Time].[1997]"), + RenderOptions.compact().withComments(true)).sql(); + assertTrue(sql.endsWith(NL + "/* request=[Sales]\nslicer=[Time].[1997] */"), sql); + } + + @Test + void closingMarkerInFooter_isNeutralized() { + String sql = renderer.render(plainRead("evil */ drop table x"), + RenderOptions.compact().withComments(true)).sql(); + assertTrue(sql.endsWith(NL + "/* evil * / drop table x */"), sql); + assertFalse(sql.contains("*/ drop"), sql); + } + + @Test + void commentsOff_isByteIdentical_withAndWithoutFooter() { + String withFooter = renderer.render(plainRead("any footer"), RenderOptions.compact()).sql(); + String withoutFooter = renderer.render(plainRead(null), RenderOptions.compact()).sql(); + assertEquals(withoutFooter, withFooter); + String formattedWith = renderer.render(plainRead("any footer"), RenderOptions.multiLine()).sql(); + String formattedWithout = renderer.render(plainRead(null), RenderOptions.multiLine()).sql(); + assertEquals(formattedWithout, formattedWith); + } + + @Test + void footer_onSetOperation() { + Statement union = new SetOperation(SetOperation.SetOp.UNION_ALL, + List.of(plainRead(null), plainRead(null)), List.of(), Optional.empty(), + Optional.of("set-footer")); + String sql = renderer.render(union, RenderOptions.compact().withComments(true)).sql(); + assertTrue(sql.endsWith(" union all select \"t\".\"id\" as \"c0\" from \"fact\" as \"t\"" + + NL + "/* set-footer */"), sql); + // the inputs' own footers never leak into a nested render + Statement unionOfFootered = new SetOperation(SetOperation.SetOp.UNION_ALL, + List.of(plainRead("inner"), plainRead("inner")), List.of(), Optional.empty(), + Optional.empty()); + assertFalse(renderer.render(unionOfFootered, RenderOptions.compact().withComments(true)).sql() + .contains("inner")); + } + + @Test + void footer_onInsert() { + String sql = renderer.render(InsertStatementBuilder.create().into("fact").columns("id") + .addRow(Expressions.literal(1, Datatype.INTEGER)) + .footerComment("load-batch=42").build(), + RenderOptions.compact().withComments(true)).sql(); + assertEquals("insert into \"fact\" (\"id\") values (1)" + NL + "/* load-batch=42 */", sql); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/FromInlineTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/FromInlineTest.java new file mode 100644 index 0000000..c32d237 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/FromInlineTest.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.eclipse.daanse.jdbc.db.dialect.api.Dialect; +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.jdbc.db.dialect.db.mysql.MySqlDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** + * {@link org.eclipse.daanse.sql.statement.api.model.FromClause.FromInline} carries inline-{@code VALUES} + * data and the renderer generates the dialect-specific SQL at render time via + * {@code dialect.sqlGenerator().generateInline(...)}. This is the per-dialect byte-check the TCK cannot + * provide (no test schema uses inline tables): the rendered FROM must equal the same + * {@code generateInline} output wrapped as a derived table — byte-identical to the legacy + * {@code convertInlineTableToRelation} embedding. + */ +class FromInlineTest { + + private static final List NAMES = List.of("id", "label"); + private static final List TYPES = List.of("INTEGER", "VARCHAR"); + private static final List ROWS = List.of(new String[] { "1", "Mon" }, new String[] { "2", "Tue" }); + + @Test + void fromInline_rendersDialectGeneratedValuesWrappedAsDerivedTable() { + TableAlias alias = TableAlias.of("days"); + SelectStatementBuilder q = SelectStatementBuilder.create(); + q.from(From.inline(NAMES, TYPES, ROWS, alias)); + q.project(Expressions.column(alias, "label"), BestFitColumnType.STRING); + var statement = q.build(); + + for (Dialect dialect : List.of(new MySqlDialect(), new AnsiDialect())) { + String sql = new DialectSqlRenderer(dialect).render(statement).sql(); + String inline = dialect.sqlGenerator().generateInline(NAMES, TYPES, ROWS).toString(); + String expectedFrom = "(" + inline + ")" + + (dialect.allowsFromAlias() ? " as " : " ") + dialect.quoteIdentifier("days"); + assertTrue(sql.contains(expectedFrom), + dialect.getClass().getSimpleName() + " FROM mismatch:\n" + sql + "\nexpected FROM:\n" + expectedFrom); + } + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/FromSetTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/FromSetTest.java new file mode 100644 index 0000000..faacd15 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/FromSetTest.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Optional; + +import org.eclipse.daanse.jdbc.db.dialect.api.Dialect; +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.Datatype; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.Predicates; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.SelectStatement; +import org.eclipse.daanse.sql.statement.api.model.SetOperation; +import org.eclipse.daanse.sql.statement.api.model.SortSpec; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.api.render.RenderedSql; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** + * A {@link SetOperation} as a derived table ({@code FromClause.FromSet}): the FROM item renders as + * the parenthesized concatenation of its inputs' individual renders — byte-identical to + * {@code "(" + inputA + " union " + inputB + ") as alias"} — with nested bind parameters + * accumulating in placeholder order. + */ +class FromSetTest { + + private final Dialect dialect = new AnsiDialect(); + private final DialectSqlRenderer renderer = new DialectSqlRenderer(dialect); + + /** {@code select t.id from tbl as t}, optionally filtered on {@code id = ?}. */ + private static SelectStatement input(String table, String aliasName, Integer paramValue) { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias t = TableAlias.of(aliasName); + q.from(From.table(table, t)); + q.project(Expressions.column(t, "id"), BestFitColumnType.INT); + if (paramValue != null) { + q.where(Predicates.eq(Expressions.column(t, "id"), + Expressions.param(paramValue, Datatype.INTEGER))); + } + return q.build(); + } + + /** {@code select * from () as u}, optionally {@code order by 1 ASC}. */ + private SelectStatement outer(SetOperation set, boolean orderBy) { + SelectStatementBuilder q = SelectStatementBuilder.create(); + q.from(From.set(set, TableAlias.of("u"))); + q.project(Expressions.star(), null); + if (orderBy) { + q.orderOn(Expressions.ordinal(1), SortSpec.asc()); + } + return q.build(); + } + + /** The byte identity R5 relies on: the FROM item IS the inputs' own renders joined by the keyword. */ + @Test + void unionInFrom_byteIdenticalToConcatenatedInputRenders() { + SelectStatement inA = input("t1", "a", null); + SelectStatement inB = input("t2", "b", null); + String a = renderer.render(inA).sql(); + String b = renderer.render(inB).sql(); + + SetOperation union = new SetOperation(SetOperation.SetOp.UNION, List.of(inA, inB), List.of(), + Optional.empty()); + String sql = renderer.render(outer(union, true)).sql(); + + assertEquals("select * from (" + a + " union " + b + ") as \"u\" order by 1 ASC", sql); + } + + @Test + void unionAllInFrom_byteIdenticalToConcatenatedInputRenders() { + SelectStatement inA = input("t1", "a", null); + SelectStatement inB = input("t2", "b", null); + String a = renderer.render(inA).sql(); + String b = renderer.render(inB).sql(); + + String sql = renderer.render(outer(SetOperation.unionAll(List.of(inA, inB)), false)).sql(); + + assertEquals("select * from (" + a + " union all " + b + ") as \"u\"", sql); + } + + /** Bind parameters of both union inputs accumulate once each, in placeholder (input) order. */ + @Test + void unionInFrom_bindParametersAccumulateInPlaceholderOrder() { + SelectStatement inA = input("t1", "a", 7); + SelectStatement inB = input("t2", "b", 11); + String a = renderer.render(inA).sql(); + String b = renderer.render(inB).sql(); + + SetOperation union = new SetOperation(SetOperation.SetOp.UNION, List.of(inA, inB), List.of(), + Optional.empty()); + RenderedSql sql = renderer.render(outer(union, true)); + + assertEquals("select * from (" + a + " union " + b + ") as \"u\" order by 1 ASC", sql.sql()); + assertEquals(2, sql.parameters().size()); + assertEquals(7, sql.parameters().get(0).value()); + assertEquals(11, sql.parameters().get(1).value()); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/FromVariantTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/FromVariantTest.java new file mode 100644 index 0000000..d8396b5 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/FromVariantTest.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Map; + +import org.eclipse.daanse.jdbc.db.dialect.api.Dialect; +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.jdbc.db.dialect.db.mysql.MySqlDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.FromClause; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** + * {@link FromClause.FromVariant} must render identically to a {@link FromClause.FromRaw} built from the + * dialect-resolved fragment — i.e. the renderer's {@code chooseVariant} pick reproduces the legacy + * {@code ViewCodeSet.chooseQuery} selection (live dialect, else {@code "generic"}) byte-for-byte. This is the + * primary byte guard for the doc-04 FROM-side variant node (there is no integration view fixture). + */ +class FromVariantTest { + + private static final TableAlias V = TableAlias.of("v"); + private static final String GENERIC = "select 1 as x"; + private static final String MYSQL = "select 2 as x"; + private static final Map VARIANTS = Map.of("generic", GENERIC, "mysql", MYSQL); + + private String renderVariant(Dialect d) { + SelectStatementBuilder q = SelectStatementBuilder.create(); + q.from(new FromClause.FromVariant(VARIANTS, V)); + q.project(Expressions.column(V, "x"), BestFitColumnType.STRING); + return new DialectSqlRenderer(d).render(q.build()).sql(); + } + + private String renderRaw(Dialect d, String sql) { + SelectStatementBuilder q = SelectStatementBuilder.create(); + q.from(From.raw(sql, V)); + q.project(Expressions.column(V, "x"), BestFitColumnType.STRING); + return new DialectSqlRenderer(d).render(q.build()).sql(); + } + + @Test + void variantFallsBackToGenericForNonMatchingDialect() { + Dialect ansi = new AnsiDialect(); + assertEquals(renderRaw(ansi, GENERIC), renderVariant(ansi)); + } + + @Test + void variantPicksDialectSpecificForMysql() { + Dialect mysql = new MySqlDialect(); + assertEquals(renderRaw(mysql, MYSQL), renderVariant(mysql)); + } + + // ---- RawVariant (SELECT-side computed-column expression) ----------------------------------------- + + private String renderProjVariant(Dialect d) { + SelectStatementBuilder q = SelectStatementBuilder.create(); + q.from(From.table("t", V)); + q.project(Expressions.rawVariant(VARIANTS), BestFitColumnType.STRING); + return new DialectSqlRenderer(d).render(q.build()).sql(); + } + + private String renderProjRaw(Dialect d, String sql) { + SelectStatementBuilder q = SelectStatementBuilder.create(); + q.from(From.table("t", V)); + q.project(Expressions.raw(sql), BestFitColumnType.STRING); + return new DialectSqlRenderer(d).render(q.build()).sql(); + } + + @Test + void rawVariantFallsBackToGenericForNonMatchingDialect() { + Dialect ansi = new AnsiDialect(); + assertEquals(renderProjRaw(ansi, GENERIC), renderProjVariant(ansi)); + } + + @Test + void rawVariantPicksDialectSpecificForMysql() { + Dialect mysql = new MySqlDialect(); + assertEquals(renderProjRaw(mysql, MYSQL), renderProjVariant(mysql)); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/InlineCommentHardeningTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/InlineCommentHardeningTest.java new file mode 100644 index 0000000..fbe0fd4 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/InlineCommentHardeningTest.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.Datatype; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.Predicates; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.ColumnAlias; +import org.eclipse.daanse.sql.statement.api.model.JoinKind; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.api.render.RenderOptions; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** + * Hardening for inline BLOCK comments in COMPACT mode at every existing attachment point (header, + * projection, filter, having, join): the SQL around the comment must stay structurally intact, an + * embedded {@code *}{@code /} cannot close the comment early, and newlines are folded so the inline + * comment stays on one line. + */ +class InlineCommentHardeningTest { + + private final DialectSqlRenderer renderer = new DialectSqlRenderer(new AnsiDialect()); + + private static final RenderOptions COMPACT_COMMENTS = RenderOptions.compact().withComments(true); + + @Test + void compactBlock_allAttachmentPoints() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias a = TableAlias.of("a"); + TableAlias b = TableAlias.of("b"); + q.header("hdr"); + q.from(From.table("alpha", a)); + q.join(JoinKind.INNER, From.table("beta", b), + Predicates.eq(Expressions.column(a, "id"), Expressions.column(b, "alpha_id")), "join-cmt"); + q.project(Expressions.column(a, "name"), BestFitColumnType.STRING, ColumnAlias.of("c0"), "proj-cmt"); + q.where(Predicates.gt(Expressions.column(a, "qty"), Expressions.literal(1, Datatype.INTEGER)), "where-cmt"); + q.having(Predicates.gt(Expressions.countStar(), Expressions.literal(0, Datatype.INTEGER)), "having-cmt"); + String sql = renderer.render(q.build(), COMPACT_COMMENTS).sql(); + + // header lines come first, as -- lines, even in compact mode (existing behavior) + assertTrue(sql.startsWith("-- hdr" + System.lineSeparator() + "select "), sql); + // projection comment inline after the aliased column + assertTrue(sql.contains("\"a\".\"name\" as \"c0\" /* proj-cmt */"), sql); + // join comment inline between the keyword and the joined table + assertTrue(sql.contains(""" + join /* join-cmt */ "beta" as "b" on \ + """), sql); + // filter and having comments inline after their conjunct + assertTrue(sql.contains(""" + where "a"."qty" > 1 /* where-cmt */\ + """), sql); + assertTrue(sql.contains(" having COUNT(*) > 0 /* having-cmt */"), sql); + } + + @Test + void compactBlock_embeddedClosingMarker_isNeutralizedEverywhere() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias a = TableAlias.of("a"); + q.from(From.table("alpha", a)); + q.project(Expressions.column(a, "name"), BestFitColumnType.STRING, ColumnAlias.of("c0"), "p */ q"); + q.where(Predicates.gt(Expressions.column(a, "qty"), Expressions.literal(1, Datatype.INTEGER)), "w */ x"); + String sql = renderer.render(q.build(), COMPACT_COMMENTS).sql(); + assertTrue(sql.contains("/* p * / q */"), sql); + assertTrue(sql.contains("/* w * / x */"), sql); + assertFalse(sql.contains("*/ q"), sql); + assertFalse(sql.contains("*/ x"), sql); + } + + @Test + void compactBlock_newlinesInInlineComments_areFolded() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias a = TableAlias.of("a"); + q.from(From.table("alpha", a)); + q.project(Expressions.column(a, "name"), BestFitColumnType.STRING, ColumnAlias.of("c0"), + "line1\nline2\r\nline3"); + String sql = renderer.render(q.build(), COMPACT_COMMENTS).sql(); + assertTrue(sql.contains("/* line1 line2 line3 */"), sql); + } + + /** + * PIN of current (latent) behavior: a {@code Raw("t.*")} projection is NOT recognized as a + * whole-row projection (only bare {@code Raw("*")} and the structured {@code Star} are), so the + * generated {@code as c0} alias IS appended — producing invalid SQL if ever built. Documented + * here so any change is deliberate; use {@code Expressions.star(alias)} for a qualified star. + */ + @Test + void rawQualifiedStar_currentBehavior_getsAliasAppended() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias t = TableAlias.of("t"); + q.from(From.table("fact", t)); + q.project(Expressions.raw("t.*"), null); + assertEquals(""" + select t.* as "c0" from "fact" as "t"\ + """, renderer.render(q.build()).sql()); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/KnownCallTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/KnownCallTest.java new file mode 100644 index 0000000..c1ef1ee --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/KnownCallTest.java @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; + +import org.eclipse.daanse.jdbc.db.dialect.api.generator.KnownFunction; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.jdbc.db.dialect.db.mssqlserver.MicrosoftSqlServerDialect; +import org.eclipse.daanse.jdbc.db.dialect.db.mysql.MySqlDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.expression.SqlExpression; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** + * {@code SqlExpression.KnownCall}: a portable well-known function intent whose spelling the + * dialect's {@code FunctionGenerator} chooses at render time. Exercises the OData {@code $filter} + * builtin set (substring, length, concat, indexof, trim, year..second, round, floor, ceiling) + * against the ANSI, MySQL and SQL Server dialects, plus the arity checks of the + * {@code Expressions} factories. + */ +class KnownCallTest { + + private final DialectSqlRenderer ansi = new DialectSqlRenderer(new AnsiDialect()); + private final DialectSqlRenderer mysql = new DialectSqlRenderer(new MySqlDialect()); + private final DialectSqlRenderer mssql = new DialectSqlRenderer(new MicrosoftSqlServerDialect()); + + private static final SqlExpression X = Expressions.raw("x"); + private static final SqlExpression A = Expressions.raw("a"); + private static final SqlExpression B = Expressions.raw("b"); + private static final SqlExpression TWO = Expressions.raw("2"); + private static final SqlExpression THREE = Expressions.raw("3"); + + // ---- string builtins --------------------------------------------------------- + + @Test + void substring_perDialect() { + SqlExpression two = Expressions.substring(X, TWO); + SqlExpression three = Expressions.substring(X, TWO, THREE); + assertEquals("SUBSTRING(x, 2)", ansi.renderExpression(two)); + assertEquals("SUBSTRING(x, 2, 3)", ansi.renderExpression(three)); + assertEquals("SUBSTRING(x, 2)", mysql.renderExpression(two)); + assertEquals("SUBSTRING(x, 2, 3)", mssql.renderExpression(three)); + } + + @Test + void length_perDialect() { + SqlExpression e = Expressions.length(X); + assertEquals("CHAR_LENGTH(x)", ansi.renderExpression(e)); + // MySQL's LENGTH() counts bytes; CHAR_LENGTH stays. + assertEquals("CHAR_LENGTH(x)", mysql.renderExpression(e)); + assertEquals("LEN(x)", mssql.renderExpression(e)); + } + + @Test + void concat_perDialect() { + SqlExpression e = Expressions.concat(A, B, X); + assertEquals("(a || b || x)", ansi.renderExpression(e)); + assertEquals("CONCAT(a, b, x)", mysql.renderExpression(e)); + assertEquals("CONCAT(a, b, x)", mssql.renderExpression(e)); + } + + @Test + void indexOf_needleFirst_perDialect() { + // indexOf(needle, haystack) — needle first, like the OData indexof builtin. + SqlExpression e = Expressions.indexOf(A, B); + assertEquals("POSITION(a IN b)", ansi.renderExpression(e)); + assertEquals("LOCATE(a, b)", mysql.renderExpression(e)); + assertEquals("CHARINDEX(a, b)", mssql.renderExpression(e)); + } + + @Test + void trim_family_perDialect() { + assertEquals("TRIM(x)", ansi.renderExpression(Expressions.trim(X))); + assertEquals("TRIM(LEADING FROM x)", ansi.renderExpression(Expressions.ltrim(X))); + assertEquals("TRIM(TRAILING FROM x)", ansi.renderExpression(Expressions.rtrim(X))); + assertEquals("TRIM(x)", mysql.renderExpression(Expressions.trim(X))); + assertEquals("TRIM(x)", mssql.renderExpression(Expressions.trim(X))); + } + + // ---- date/time builtins ------------------------------------------------------ + + @Test + void dateParts_perDialect() { + assertEquals("EXTRACT(YEAR FROM x)", ansi.renderExpression(Expressions.year(X))); + assertEquals("EXTRACT(MONTH FROM x)", ansi.renderExpression(Expressions.month(X))); + assertEquals("EXTRACT(DAY FROM x)", ansi.renderExpression(Expressions.day(X))); + assertEquals("EXTRACT(HOUR FROM x)", ansi.renderExpression(Expressions.hour(X))); + assertEquals("EXTRACT(MINUTE FROM x)", ansi.renderExpression(Expressions.minute(X))); + assertEquals("EXTRACT(SECOND FROM x)", ansi.renderExpression(Expressions.second(X))); + + assertEquals("EXTRACT(YEAR FROM x)", mysql.renderExpression(Expressions.year(X))); + assertEquals("EXTRACT(SECOND FROM x)", mysql.renderExpression(Expressions.second(X))); + + // T-SQL has no EXTRACT; the whole family renders via DATEPART. + assertEquals("DATEPART(year, x)", mssql.renderExpression(Expressions.year(X))); + assertEquals("DATEPART(month, x)", mssql.renderExpression(Expressions.month(X))); + assertEquals("DATEPART(day, x)", mssql.renderExpression(Expressions.day(X))); + assertEquals("DATEPART(hour, x)", mssql.renderExpression(Expressions.hour(X))); + assertEquals("DATEPART(minute, x)", mssql.renderExpression(Expressions.minute(X))); + assertEquals("DATEPART(second, x)", mssql.renderExpression(Expressions.second(X))); + } + + @Test + void dateAndTime_castSpelling() { + assertEquals("CAST(x AS DATE)", ansi.renderExpression(Expressions.date(X))); + assertEquals("CAST(x AS TIME)", ansi.renderExpression(Expressions.time(X))); + assertEquals("CAST(x AS DATE)", mssql.renderExpression(Expressions.date(X))); + } + + @Test + void now_perDialect() { + assertEquals("CURRENT_TIMESTAMP", ansi.renderExpression(Expressions.now())); + assertEquals("CURRENT_TIMESTAMP", mysql.renderExpression(Expressions.now())); + assertEquals("GETDATE()", mssql.renderExpression(Expressions.now())); + } + + // ---- math builtins ----------------------------------------------------------- + + @Test + void round_floor_ceiling_perDialect() { + assertEquals("ROUND(x)", ansi.renderExpression(Expressions.round(X))); + assertEquals("ROUND(x, 2)", ansi.renderExpression(Expressions.round(X, TWO))); + assertEquals("FLOOR(x)", ansi.renderExpression(Expressions.floor(X))); + assertEquals("CEILING(x)", ansi.renderExpression(Expressions.ceiling(X))); + assertEquals("ROUND(x)", mysql.renderExpression(Expressions.round(X))); + assertEquals("CEILING(x)", mysql.renderExpression(Expressions.ceiling(X))); + assertEquals("ROUND(x, 2)", mssql.renderExpression(Expressions.round(X, TWO))); + assertEquals("FLOOR(x)", mssql.renderExpression(Expressions.floor(X))); + assertEquals("CEILING(x)", mssql.renderExpression(Expressions.ceiling(X))); + } + + @Test + void abs_mod_power_sqrt_defaultSpellings() { + assertEquals("ABS(x)", ansi.renderExpression(Expressions.abs(X))); + assertEquals("MOD(a, b)", ansi.renderExpression(Expressions.mod(A, B))); + assertEquals("POWER(a, b)", ansi.renderExpression(Expressions.power(A, B))); + assertEquals("SQRT(x)", ansi.renderExpression(Expressions.sqrt(X))); + } + + // ---- nested arguments render through renderExpression ------------------------ + + @Test + void arguments_areRenderedExpressions_notRawText() { + SqlExpression e = Expressions.length(Expressions.trim(Expressions.column("name"))); + assertEquals("CHAR_LENGTH(TRIM(\"name\"))", ansi.renderExpression(e)); + assertEquals("LEN(TRIM(\"name\"))", mssql.renderExpression(e)); + } + + // ---- factory arity checks ---------------------------------------------------- + + @Test + void concat_factoryRequiresAtLeastTwoArguments() { + assertThrows(IllegalArgumentException.class, () -> Expressions.concat(A)); + assertThrows(IllegalArgumentException.class, () -> Expressions.concat()); + } + + @Test + void handBuiltKnownCall_arityFailsAtRender() { + // The factories are arity-safe by signature; a hand-built node still fails in the + // dialect generator's arity check at render time. + SqlExpression tooMany = new SqlExpression.KnownCall(KnownFunction.LENGTH, List.of(A, B)); + assertThrows(IllegalArgumentException.class, () -> ansi.renderExpression(tooMany)); + } + + @Test + void knownCall_copiesItsArgumentList() { + java.util.List args = new java.util.ArrayList<>(List.of(A)); + SqlExpression.KnownCall call = new SqlExpression.KnownCall(KnownFunction.ABS, args); + args.add(B); + assertEquals(1, call.arguments().size()); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/OrdinalOrderByTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/OrdinalOrderByTest.java new file mode 100644 index 0000000..1e3814b --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/OrdinalOrderByTest.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.expression.SqlExpression; +import org.eclipse.daanse.sql.statement.api.model.NullOrder; +import org.eclipse.daanse.sql.statement.api.model.SortSpec; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** + * Ordinal {@code ORDER BY} positions ({@code order by 1, 2}): rendered as the bare 1-based number, + * passing unchanged through the dialect's null-ordering machinery. + */ +class OrdinalOrderByTest { + + private final DialectSqlRenderer renderer = new DialectSqlRenderer(new AnsiDialect()); + + private static SelectStatementBuilder twoColumns() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias t = TableAlias.of("t"); + q.from(From.table("fact", t)); + q.project(Expressions.column(t, "name"), BestFitColumnType.STRING); + q.project(Expressions.column(t, "amount"), BestFitColumnType.DECIMAL); + return q; + } + + @Test + void orderByOrdinals_ascAndDesc() { + SelectStatementBuilder q = twoColumns(); + q.orderOn(Expressions.ordinal(1), SortSpec.asc()); + q.orderOn(Expressions.ordinal(2), SortSpec.desc()); + assertEquals(""" + select "t"."name" as "c0", "t"."amount" as "c1" from "fact" as "t" \ + order by 1 ASC, 2 DESC\ + """, renderer.render(q.build()).sql()); + } + + /** + * A requested null order is dropped on an ordinal: the dialect's CASE emulation would test the + * literal {@code 1} rather than the column it designates. Order on the expression instead. + */ + @Test + void orderByOrdinal_nullOrderDropped() { + SelectStatementBuilder q = twoColumns(); + q.orderOn(Expressions.ordinal(1), SortSpec.asc().withNulls(NullOrder.LAST)); + String sql = renderer.render(q.build()).sql(); + assertTrue(sql.endsWith(" order by 1 ASC"), sql); + } + + @Test + void ordinal_isOneBased() { + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> Expressions.ordinal(0)); + assertTrue(e.getMessage().contains("1-based"), e.getMessage()); + assertThrows(IllegalArgumentException.class, () -> new SqlExpression.Ordinal(-3)); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/OuterJoinRenderingTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/OuterJoinRenderingTest.java new file mode 100644 index 0000000..c4834cd --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/OuterJoinRenderingTest.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.eclipse.daanse.jdbc.db.dialect.api.Dialect; +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.Predicates; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.JoinKind; +import org.eclipse.daanse.sql.statement.api.model.SelectStatement; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** + * RIGHT/FULL outer joins always render the ANSI keyword — there is no comma fallback (that remains + * INNER-only) and no emulation: a dialect without support fails at the database. + */ +class OuterJoinRenderingTest { + + private final Dialect ansi = new AnsiDialect(); + + /** An ANSI dialect that disallows {@code JOIN ... ON}, forcing the INNER comma fallback. */ + private final Dialect commaOnly = new AnsiDialect() { + @Override + public boolean allowsJoinOn() { + return false; + } + }; + + private static SelectStatement joined(JoinKind kind) { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias a = TableAlias.of("a"); + TableAlias b = TableAlias.of("b"); + q.from(From.table("alpha", a)); + q.join(kind, From.table("beta", b), + Predicates.eq(Expressions.column(a, "id"), Expressions.column(b, "alpha_id"))); + q.project(Expressions.column(a, "id"), BestFitColumnType.INT); + return q.build(); + } + + @Test + void rightJoin_ansiKeyword() { + assertEquals(""" + select "a"."id" as "c0" from "alpha" as "a" \ + right join "beta" as "b" on "a"."id" = "b"."alpha_id"\ + """, + new DialectSqlRenderer(ansi).render(joined(JoinKind.RIGHT)).sql()); + } + + @Test + void fullJoin_ansiKeyword() { + assertEquals(""" + select "a"."id" as "c0" from "alpha" as "a" \ + full join "beta" as "b" on "a"."id" = "b"."alpha_id"\ + """, + new DialectSqlRenderer(ansi).render(joined(JoinKind.FULL)).sql()); + } + + @Test + void innerJoin_stillCommaFallsBack_whenJoinOnDisallowed() { + assertEquals(""" + select "a"."id" as "c0" from "alpha" as "a", "beta" as "b" \ + where "a"."id" = "b"."alpha_id"\ + """, + new DialectSqlRenderer(commaOnly).render(joined(JoinKind.INNER)).sql()); + } + + @Test + void rightAndFullJoin_neverCommaFallBack() { + assertEquals(""" + select "a"."id" as "c0" from "alpha" as "a" \ + right join "beta" as "b" on "a"."id" = "b"."alpha_id"\ + """, + new DialectSqlRenderer(commaOnly).render(joined(JoinKind.RIGHT)).sql()); + assertEquals(""" + select "a"."id" as "c0" from "alpha" as "a" \ + full join "beta" as "b" on "a"."id" = "b"."alpha_id"\ + """, + new DialectSqlRenderer(commaOnly).render(joined(JoinKind.FULL)).sql()); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/ParameterAndBatchTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/ParameterAndBatchTest.java new file mode 100644 index 0000000..44e87d3 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/ParameterAndBatchTest.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; +import java.util.List; + +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.Datatype; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.InsertStatementBuilder; +import org.eclipse.daanse.sql.statement.api.Predicates; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.ProjectionRef; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.api.render.RenderedSql; +import org.eclipse.daanse.sql.statement.exec.JdbcStatementExecutor; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** Bind parameters: placeholder rendering, a parameterized query on H2, and a batch insert. */ +class ParameterAndBatchTest { + + private final DialectSqlRenderer renderer = new DialectSqlRenderer(new AnsiDialect()); + + private static final String DDL = """ + create table "product" ("id" int primary key, "name" varchar(50))\ + """; + + @Test + void param_rendersPlaceholderAndCarriesValue() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias p = TableAlias.of("p"); + q.from(From.table("product", p)); + q.project(Expressions.column(p, "name"), BestFitColumnType.STRING); + q.where(Predicates.eq(Expressions.column(p, "id"), Expressions.param(7, Datatype.INTEGER))); + RenderedSql sql = renderer.render(q.build()); + + assertEquals(""" + select "p"."name" as "c0" from "product" as "p" where "p"."id" = ?\ + """, sql.sql()); + assertEquals(1, sql.parameters().size()); + assertEquals(7, sql.parameters().get(0).value()); + assertTrue(sql.parameters().get(0).bound()); + } + + @Test + void parameterizedQuery_onH2() throws Exception { + try (Connection c = DriverManager.getConnection("jdbc:h2:mem:param;DB_CLOSE_DELAY=-1", "sa", "")) { + try (Statement s = c.createStatement()) { + s.execute(DDL); + } + JdbcStatementExecutor exec = new JdbcStatementExecutor(c); + exec.update(renderer.render(InsertStatementBuilder.create().into("product").columns("id", "name") + .addRow(Expressions.literal(1, Datatype.INTEGER), Expressions.literal("Widget", Datatype.VARCHAR)) + .addRow(Expressions.literal(2, Datatype.INTEGER), Expressions.literal("Gadget", Datatype.VARCHAR)) + .build())); + + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias p = TableAlias.of("p"); + q.from(From.table("product", p)); + ProjectionRef nameRef = q.project(Expressions.column(p, "name"), BestFitColumnType.STRING); + q.where(Predicates.eq(Expressions.column(p, "id"), Expressions.param(2, Datatype.INTEGER))); + + List names = exec.query(renderer.render(q.build()), row -> row.getString(nameRef)); + assertEquals(List.of("Gadget"), names); + } + } + + @Test + void batchInsert_onH2() throws Exception { + try (Connection c = DriverManager.getConnection("jdbc:h2:mem:batch;DB_CLOSE_DELAY=-1", "sa", "")) { + try (Statement s = c.createStatement()) { + s.execute(DDL); + } + JdbcStatementExecutor exec = new JdbcStatementExecutor(c); + + RenderedSql insert = renderer.render(InsertStatementBuilder.create().into("product").columns("id", "name") + .addRow(Expressions.paramMarker(Datatype.INTEGER), Expressions.paramMarker(Datatype.VARCHAR)) + .build()); + assertEquals(""" + insert into "product" ("id", "name") values (?, ?)\ + """, insert.sql()); + + int[] counts = exec.batch(insert, + List.of(new Object[] { 1, "A" }, new Object[] { 2, "B" }, new Object[] { 3, "C" })); + assertEquals(3, counts.length); + + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias p = TableAlias.of("p"); + q.from(From.table("product", p)); + ProjectionRef cnt = q.project(Expressions.countStar(), BestFitColumnType.LONG); + Long total = exec.query(renderer.render(q.build()), row -> row.getLong(cnt)).get(0); + assertEquals(3L, total); + } + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/RecursiveCteTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/RecursiveCteTest.java new file mode 100644 index 0000000..188c413 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/RecursiveCteTest.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.util.List; + +import org.eclipse.daanse.jdbc.db.dialect.api.Dialect; +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.Datatype; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.InsertStatementBuilder; +import org.eclipse.daanse.sql.statement.api.Predicates; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.WithStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.ColumnAlias; +import org.eclipse.daanse.sql.statement.api.model.ProjectionRef; +import org.eclipse.daanse.sql.statement.api.model.SetOperation; +import org.eclipse.daanse.sql.statement.api.model.SortSpec; +import org.eclipse.daanse.sql.statement.api.model.Statement; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.api.model.WithStatement; +import org.eclipse.daanse.sql.statement.exec.JdbcStatementExecutor; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** + * {@code WITH RECURSIVE}: walk a parent-child hierarchy (all descendants of a node) and run + * it for real on H2. Uses an ANSI {@code JOIN ... ON} dialect for the recursive member (the + * canonical recursive-CTE shape). + */ +class RecursiveCteTest { + + /** ANSI dialect that emits {@code JOIN ... ON} (rather than the comma-join fallback). */ + private final Dialect dialect = new AnsiDialect() { + @Override + public boolean allowsJoinOn() { + return true; + } + }; + private final DialectSqlRenderer renderer = new DialectSqlRenderer(dialect); + + private record Built(WithStatement statement, ProjectionRef nameRef) { + } + + /** + * {@code WITH RECURSIVE descendants AS ( + * SELECT id, parent_id, name FROM category WHERE id = 1 + * UNION ALL + * SELECT c.id, c.parent_id, c.name FROM category c JOIN descendants d ON c.parent_id = d.id) + * SELECT name FROM descendants ORDER BY name}. + */ + private Built descendantsOfRoot() { + // Anchor — column aliases define the CTE's column names (id, parent_id, name). + SelectStatementBuilder anchor = SelectStatementBuilder.create(); + TableAlias ca = TableAlias.of("ca"); + anchor.from(From.table("category", ca)); + anchor.project(Expressions.column(ca, "id"), BestFitColumnType.INT, ColumnAlias.of("id")); + anchor.project(Expressions.column(ca, "parent_id"), BestFitColumnType.INT, ColumnAlias.of("parent_id")); + anchor.project(Expressions.column(ca, "name"), BestFitColumnType.STRING, ColumnAlias.of("name")); + anchor.where(Predicates.eq(Expressions.column(ca, "id"), Expressions.literal(1, Datatype.INTEGER))); + + // Recursive member — joins the table back to the CTE. + SelectStatementBuilder rec = SelectStatementBuilder.create(); + TableAlias c = TableAlias.of("c"); + TableAlias d = TableAlias.of("d"); + rec.from(From.table("category", c)); + rec.innerJoin(From.table("descendants", d), + Predicates.eq(Expressions.column(c, "parent_id"), Expressions.column(d, "id"))); + rec.project(Expressions.column(c, "id"), BestFitColumnType.INT); + rec.project(Expressions.column(c, "parent_id"), BestFitColumnType.INT); + rec.project(Expressions.column(c, "name"), BestFitColumnType.STRING); + + Statement union = SetOperation.unionAll(List.of(anchor.build(), rec.build())); + + SelectStatementBuilder body = SelectStatementBuilder.create(); + TableAlias x = TableAlias.of("x"); + body.from(From.table("descendants", x)); + ProjectionRef nameRef = body.project(Expressions.column(x, "name"), BestFitColumnType.STRING); + body.orderOn(nameRef, SortSpec.asc()); + + WithStatement w = WithStatementBuilder.create().recursive(true) + .cte("descendants", List.of("id", "parent_id", "name"), union).body(body.build()).build(); + return new Built(w, nameRef); + } + + @Test + void rendersWithRecursive() { + String sql = renderer.render(descendantsOfRoot().statement()).sql(); + assertTrue(sql.startsWith(""" + WITH RECURSIVE "descendants"("id", "parent_id", "name") AS (\ + """), sql); + assertTrue(sql.contains(" union all "), sql); + assertTrue(sql.contains(""" + join "descendants" as "d" on "c"."parent_id" = "d"."id"\ + """), sql); + assertTrue(sql.endsWith(""" + select "x"."name" as "c0" from "descendants" as "x" \ + order by "x"."name" ASC\ + """), sql); + } + + @Test + void recursiveCte_onH2() throws Exception { + try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:rcte;DB_CLOSE_DELAY=-1", "sa", "")) { + try (java.sql.Statement s = conn.createStatement()) { + s.execute(""" + create table "category" \ + ("id" int primary key, "parent_id" int, "name" varchar(50))\ + """); + } + JdbcStatementExecutor exec = new JdbcStatementExecutor(conn); + exec.update(renderer.render(InsertStatementBuilder.create().into("category") + .columns("id", "parent_id", "name") + .addRow(i(1), nul(), str("Electronics")) + .addRow(i(2), i(1), str("Computers")) + .addRow(i(3), i(2), str("Laptops")) + .addRow(i(4), i(1), str("Phones")) + .addRow(i(5), nul(), str("Food")) + .build())); + + Built built = descendantsOfRoot(); + List names = exec.query(renderer.render(built.statement()), + row -> row.getString(built.nameRef())); + // All descendants of Electronics(1): itself + Computers, Laptops, Phones (not Food). + assertEquals(List.of("Computers", "Electronics", "Laptops", "Phones"), names); + } + } + + private static org.eclipse.daanse.sql.statement.api.expression.SqlExpression i(int v) { + return Expressions.literal(v, Datatype.INTEGER); + } + + private static org.eclipse.daanse.sql.statement.api.expression.SqlExpression nul() { + return Expressions.literal(null, Datatype.INTEGER); + } + + private static org.eclipse.daanse.sql.statement.api.expression.SqlExpression str(String v) { + return Expressions.literal(v, Datatype.VARCHAR); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/RendererHardeningTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/RendererHardeningTest.java new file mode 100644 index 0000000..3e20476 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/RendererHardeningTest.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.eclipse.daanse.jdbc.db.dialect.api.Dialect; +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.Datatype; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.jdbc.db.dialect.db.mssqlserver.MicrosoftSqlServerDialect; +import org.eclipse.daanse.jdbc.db.dialect.db.mysql.MySqlDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.Predicates; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.SelectStatement; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.api.render.RenderOptions; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** Hardening tests: richer predicates, formatted output, and dialect-driven pagination. */ +class RendererHardeningTest { + + private final Dialect ansi = new AnsiDialect(); + + /** A query exercising LIKE (on UPPER), BETWEEN, and NOT(IS NULL). */ + private static SelectStatement predicateShowcase() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias c = TableAlias.of("c"); + q.from(From.table("customer", c)); + q.project(Expressions.column(c, "name"), BestFitColumnType.STRING); + q.where(Predicates.like(Expressions.upper(Expressions.column(c, "name")), + Expressions.literal("A%", Datatype.VARCHAR))); + q.where(Predicates.between(Expressions.column(c, "age"), Expressions.literal(18, Datatype.INTEGER), + Expressions.literal(65, Datatype.INTEGER))); + q.where(Predicates.not(Predicates.isNull(Expressions.column(c, "name")))); + return q.build(); + } + + @Test + void predicates_like_between_not() { + String sql = new DialectSqlRenderer(ansi).render(predicateShowcase()).sql(); + assertEquals(""" + select "c"."name" as "c0" from "customer" as "c" \ + where UPPER("c"."name") like 'A%' \ + and "c"."age" between 18 and 65 \ + and not ("c"."name" is null)\ + """, sql); + } + + @Test + void inTuple_rendersRowValueIn() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias c = TableAlias.of("c"); + q.from(From.table("customer", c)); + q.project(Expressions.column(c, "id"), BestFitColumnType.INT); + // Tuples are built child-first: most specific column first. + q.where(Predicates.inTuple( + java.util.List.of(Expressions.column(c, "state"), Expressions.column(c, "country")), + java.util.List.of( + java.util.List.of(Expressions.literal("CA", Datatype.VARCHAR), + Expressions.literal("USA", Datatype.VARCHAR)), + java.util.List.of(Expressions.literal("WA", Datatype.VARCHAR), + Expressions.literal("USA", Datatype.VARCHAR))))); + // A dialect that supports multi-value IN (MySQL) renders the row-value IN in tuple order ... + String mysqlSql = new DialectSqlRenderer(new MySqlDialect()).render(q.build()).sql(); + assertEquals(""" + select `c`.`id` as `c0` from `customer` as `c` \ + where (`c`.`state`, `c`.`country`) in (('CA', 'USA'), ('WA', 'USA'))\ + """, mysqlSql); + // ... while one that does not (the ANSI default; e.g. H2 mis-unifies row-value IN types at + // execution) degrades to the parent-first OR-of-ANDs chain. As the sole conjunct the chain + // carries no outer parenthesis — an enclosing connective or an and-join site supplies it. + String sql = new DialectSqlRenderer(ansi).render(q.build()).sql(); + assertEquals(""" + select "c"."id" as "c0" from "customer" as "c" \ + where ("c"."country" = 'USA' and "c"."state" = 'CA') \ + or ("c"."country" = 'USA' and "c"."state" = 'WA')\ + """, sql); + } + + @Test + void formatted_putsClausesOnSeparateLines() { + String sql = new DialectSqlRenderer(ansi).render(predicateShowcase(), RenderOptions.multiLine()).sql(); + assertTrue(sql.startsWith("select\n"), sql); + assertTrue(sql.contains("\nfrom\n"), sql); + assertTrue(sql.contains("\"customer\" as \"c\""), sql); + assertTrue(sql.contains("\nwhere\n"), sql); + assertTrue(sql.contains("UPPER(\"c\".\"name\")"), sql); + } + + @Test + void caseAndArithmetic() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias o = TableAlias.of("o"); + q.from(From.table("orders", o)); + // CASE WHEN qty > 100 THEN 'bulk' ELSE 'normal' END + q.project(Expressions.caseExpr( + java.util.List.of(Expressions.when( + Predicates.gt(Expressions.column(o, "qty"), Expressions.literal(100, Datatype.INTEGER)), + Expressions.literal("bulk", Datatype.VARCHAR))), + Expressions.literal("normal", Datatype.VARCHAR)), BestFitColumnType.STRING); + // price * qty + q.project(Expressions.multiply(Expressions.column(o, "price"), Expressions.column(o, "qty")), + BestFitColumnType.DECIMAL); + String sql = new DialectSqlRenderer(ansi).render(q.build()).sql(); + assertEquals(""" + select CASE WHEN "o"."qty" > 100 THEN 'bulk' ELSE 'normal' END as "c0", \ + ("o"."price" * "o"."qty") as "c1" from "orders" as "o"\ + """, sql); + } + + @Test + void pagination_isDialectDriven() { + SelectStatement top = StatementBuilderDemo.topProducts(); // rowLimit(10) + String ansiSql = new DialectSqlRenderer(ansi).render(top).sql(); + String mysqlSql = new DialectSqlRenderer(new MySqlDialect()).render(top).sql(); + String mssqlSql = new DialectSqlRenderer(new MicrosoftSqlServerDialect()).render(top).sql(); + + assertTrue(ansiSql.endsWith(" FETCH NEXT 10 ROWS ONLY"), ansiSql); + assertTrue(mysqlSql.endsWith(" LIMIT 10"), mysqlSql); + assertTrue(mssqlSql.startsWith("select TOP 10 "), mssqlSql); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/ResultReaderDemoTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/ResultReaderDemoTest.java new file mode 100644 index 0000000..ee96986 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/ResultReaderDemoTest.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; +import java.util.List; +import java.util.Map; + +import org.eclipse.daanse.sql.statement.demo.ResultReaderDemo.Product; +import org.junit.jupiter.api.Test; + +/** + * Real in-memory H2 round-trip: build + render + execute (INSERT and SELECT) + map rows back + * into a record and into a generic attribute map via the result-reading layer. + */ +class ResultReaderDemoTest { + + @Test + void roundTrip_intoRecordsAndAttributeMaps() throws Exception { + try (Connection c = DriverManager.getConnection("jdbc:h2:mem:rrt;DB_CLOSE_DELAY=-1", "sa", "")) { + try (Statement s = c.createStatement()) { + s.execute(ResultReaderDemo.createTableSql()); + } + + List products = ResultReaderDemo.loadProducts(c); + assertEquals(2, products.size()); + assertEquals(new Product(1, "Widget"), products.get(0)); + assertEquals(new Product(2, "Gadget"), products.get(1)); + + List> attributes = ResultReaderDemo.loadAsAttributes(c); + assertEquals(2, attributes.size()); + assertEquals(1, attributes.get(0).get("id")); + assertEquals("Widget", attributes.get(0).get("name")); + assertEquals(2, attributes.get(1).get("id")); + assertEquals("Gadget", attributes.get(1).get("name")); + } + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/ScalarSubqueryTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/ScalarSubqueryTest.java new file mode 100644 index 0000000..4bfa360 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/ScalarSubqueryTest.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.Datatype; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.Predicates; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.expression.ComparisonOperator; +import org.eclipse.daanse.sql.statement.api.model.SelectStatement; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.api.render.RenderedSql; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** + * Scalar subqueries {@code (select ...)}: in the {@code SELECT} list, as a comparison operand, and + * bind-parameter accumulation in placeholder order. + */ +class ScalarSubqueryTest { + + private final DialectSqlRenderer renderer = new DialectSqlRenderer(new AnsiDialect()); + + /** {@code select AVG(t.total) from orders t [where t.year = ?param]}. */ + private static SelectStatement avgTotal(Object param) { + SelectStatementBuilder sub = SelectStatementBuilder.create(); + TableAlias t = TableAlias.of("t"); + sub.from(From.table("orders", t)); + sub.project(Expressions.aggregate("AVG", Expressions.column(t, "total")), BestFitColumnType.DECIMAL); + if (param != null) { + sub.where(Predicates.eq(Expressions.column(t, "year"), Expressions.param(param, Datatype.INTEGER))); + } + return sub.build(); + } + + @Test + void scalarSubquery_inSelectList_isParenthesizedAndAliased() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias o = TableAlias.of("o"); + q.from(From.table("orders", o)); + q.project(Expressions.column(o, "id"), BestFitColumnType.INT); + q.project(Expressions.scalarSubquery(avgTotal(null)), BestFitColumnType.DECIMAL); + String sql = renderer.render(q.build()).sql(); + assertEquals(""" + select "o"."id" as "c0", \ + (select AVG("t"."total") as "c0" from "orders" as "t") as "c1" \ + from "orders" as "o"\ + """, sql); + } + + @Test + void scalarSubquery_asComparisonRightSide() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias o = TableAlias.of("o"); + q.from(From.table("orders", o)); + q.project(Expressions.column(o, "id"), BestFitColumnType.INT); + q.where(Predicates.comparison(Expressions.column(o, "total"), ComparisonOperator.GT, + Expressions.scalarSubquery(avgTotal(null)))); + String sql = renderer.render(q.build()).sql(); + assertEquals(""" + select "o"."id" as "c0" from "orders" as "o" \ + where "o"."total" > (select AVG("t"."total") as "c0" from "orders" as "t")\ + """, sql); + } + + @Test + void scalarSubquery_parametersKeepPlaceholderOrder() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias o = TableAlias.of("o"); + q.from(From.table("orders", o)); + q.project(Expressions.column(o, "id"), BestFitColumnType.INT); + // subquery param renders first (projection list precedes WHERE), then the outer WHERE param + q.project(Expressions.scalarSubquery(avgTotal(1997)), BestFitColumnType.DECIMAL); + q.where(Predicates.eq(Expressions.column(o, "region"), Expressions.param("EMEA", Datatype.VARCHAR))); + RenderedSql sql = renderer.render(q.build()); + + assertEquals(""" + select "o"."id" as "c0", \ + (select AVG("t"."total") as "c0" from "orders" as "t" where "t"."year" = ?) \ + as "c1" from "orders" as "o" where "o"."region" = ?\ + """, sql.sql()); + assertEquals(List.of(1997, "EMEA"), + sql.parameters().stream().map(bp -> bp.value()).toList()); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/SqlCommentRenderingTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/SqlCommentRenderingTest.java new file mode 100644 index 0000000..1d2ef35 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/SqlCommentRenderingTest.java @@ -0,0 +1,269 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.eclipse.daanse.jdbc.db.dialect.api.Dialect; +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.Predicates; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.ColumnAlias; +import org.eclipse.daanse.sql.statement.api.model.JoinKind; +import org.eclipse.daanse.sql.statement.api.model.ProjectionRef; +import org.eclipse.daanse.sql.statement.api.model.SelectStatement; +import org.eclipse.daanse.sql.statement.api.model.SortSpec; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.api.render.RenderOptions; +import org.eclipse.daanse.sql.statement.api.render.RenderOptions.CommentStyle; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** + * Validates the optional explanatory-comment rendering: a statement-level header, per-column and + * per-join comments are emitted only when {@link RenderOptions#comments()} is on, the executed + * (comments-off) SQL is unchanged, and an embedded {@code *}{@code /} cannot terminate a block comment early. + */ +class SqlCommentRenderingTest { + + private final Dialect ansi = new AnsiDialect(); + + /** The Gender level-members read: header + a commented join + a commented column (with a tricky {@code *}{@code /}). */ + private static SelectStatement genderRead() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias customer = TableAlias.of("customer"); + TableAlias fact = TableAlias.of("sales_fact_1997"); + q.header("request=[Customer].[Gender].MEMBERS\ncube=[Sales]"); + q.from(From.table("customer", customer)); + q.join(JoinKind.INNER, From.table("sales_fact_1997", fact), + Predicates.eq(Expressions.column(fact, "customer_id"), Expressions.column(customer, "customer_id")), + "dim=[Customer] reason=NonEmpty */ injection"); + q.project(Expressions.column(customer, "gender"), BestFitColumnType.STRING, ColumnAlias.of("c0"), + "lvl=[Customer].[Gender] role=key"); + return q.build(); + } + + @Test + void commentsOff_isPlainExecutableSql() { + String sql = new DialectSqlRenderer(ansi).render(genderRead(), RenderOptions.multiLine()).sql(); + assertFalse(sql.contains("/*"), sql); + assertFalse(sql.contains("--"), sql); + assertFalse(sql.contains("[Customer]"), sql); + // compact form is byte-exact (no comment artifacts at all) + String compact = new DialectSqlRenderer(ansi).render(genderRead(), RenderOptions.compact()).sql(); + assertEquals(""" + select "customer"."gender" as "c0" from "customer" as "customer" \ + join "sales_fact_1997" as "sales_fact_1997" \ + on "sales_fact_1997"."customer_id" = "customer"."customer_id"\ + """, compact); + } + + @Test + void commentsOn_emitsHeaderColumnAndJoin_andNeutralizesClosingMarker() { + String sql = new DialectSqlRenderer(ansi) + .render(genderRead(), RenderOptions.multiLine().withComments(true)).sql(); + // header rendered as -- lines (own lines, safe) + assertTrue(sql.contains("-- request=[Customer].[Gender].MEMBERS"), sql); + assertTrue(sql.contains("-- cube=[Sales]"), sql); + // per-column comment + assertTrue(sql.contains("/* lvl=[Customer].[Gender] role=key */"), sql); + // per-join comment with the embedded */ neutralized to "* /" so it cannot close the comment early + assertTrue(sql.contains("/* dim=[Customer] reason=NonEmpty * / injection */"), sql); + assertFalse(sql.contains("*/ injection"), sql); + // and the actual SQL structure is still intact after the comments + assertTrue(sql.contains("join "), sql); + assertTrue(sql.contains("\"customer\".\"gender\""), sql); + } + + /** Gender read with GROUP BY + ORDER BY referencing the commented column. */ + private static SelectStatement genderReadGrouped() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias customer = TableAlias.of("customer"); + TableAlias fact = TableAlias.of("sales_fact_1997"); + q.header("request=[Customer].[Gender].MEMBERS\ncube=[Sales]"); + q.from(From.table("customer", customer)); + q.join(JoinKind.INNER, From.table("sales_fact_1997", fact), + Predicates.eq(Expressions.column(fact, "customer_id"), Expressions.column(customer, "customer_id")), + "dim=[Customer] reason=NonEmpty"); + ProjectionRef gender = q.project(Expressions.column(customer, "gender"), BestFitColumnType.STRING, + ColumnAlias.of("c0"), "lvl=[Customer].[Gender] role=key"); + q.groupOn(gender); + q.orderOn(gender, SortSpec.asc()); + return q.build(); + } + + @Test + void lineStyle_commentsBeforeEachElement_reusedInGroupAndOrder() { + String sql = new DialectSqlRenderer(ansi) + .render(genderReadGrouped(), RenderOptions.multiLine().withComments(true, CommentStyle.LINE)).sql(); + // LINE style uses -- and never /* */ + assertFalse(sql.contains("/*"), sql); + // header + assertTrue(sql.contains("-- request=[Customer].[Gender].MEMBERS"), sql); + // join rendered on its own line, with its comment on the line above + assertTrue(java.util.regex.Pattern.compile("(?m)^\\s*-- dim=\\[Customer\\] reason=NonEmpty$").matcher(sql) + .find(), sql); + assertTrue(java.util.regex.Pattern.compile("(?m)^\\s*join \"sales_fact_1997\"").matcher(sql).find(), sql); + // the level comment is reused in SELECT, GROUP BY and ORDER BY (>=3 occurrences) + int n = sql.split(java.util.regex.Pattern.quote("-- lvl=[Customer].[Gender] role=key"), -1).length - 1; + assertTrue(n >= 3, "expected the lvl comment in select+group+order, got " + n + "\n" + sql); + } + + /** Gender read whose BASE FROM table carries a provenance comment (From.commentBase). */ + private static SelectStatement genderReadWithBaseComment() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias customer = TableAlias.of("customer"); + TableAlias fact = TableAlias.of("sales_fact_1997"); + q.from(From.commentBase(From.table("customer", customer), + "dimension [Customer] level table [Customer].[Gender]")); + q.join(JoinKind.INNER, From.table("sales_fact_1997", fact), + Predicates.eq(Expressions.column(fact, "customer_id"), Expressions.column(customer, "customer_id")), + "fact join (nonempty)"); + q.project(Expressions.column(customer, "gender"), BestFitColumnType.STRING, ColumnAlias.of("c0")); + return q.build(); + } + + @Test + void baseTableComment_lineStyle_ownLineBeforeBaseTable() { + String sql = new DialectSqlRenderer(ansi) + .render(genderReadWithBaseComment(), RenderOptions.multiLine().withComments(true, CommentStyle.LINE)) + .sql(); + // the base-table comment sits on its own line directly above the base table + assertTrue(java.util.regex.Pattern.compile( + """ + (?m)^\\s*-- dimension \\[Customer\\] level table \\[Customer\\]\\.\\[Gender\\]$\ + \\R\\s*"customer" as "customer"\ + """).matcher(sql).find(), sql); + // the join comment still sits above the join line (both provenances coexist) + assertTrue(java.util.regex.Pattern.compile("(?m)^\\s*-- fact join \\(nonempty\\)$").matcher(sql).find(), sql); + } + + @Test + void baseTableComment_blockStyle_inlineBeforeBaseTable() { + String sql = new DialectSqlRenderer(ansi) + .render(genderReadWithBaseComment(), RenderOptions.compact().withComments(true)).sql(); + assertTrue(sql.contains( + """ + from /* dimension [Customer] level table [Customer].[Gender] */ "customer" as "customer"\ + """), sql); + } + + @Test + void baseTableComment_commentsOff_isByteExact() { + // With comments off, a base-table comment leaves the SQL byte-identical to a comment-free build. + String off = new DialectSqlRenderer(ansi).render(genderReadWithBaseComment(), RenderOptions.compact()).sql(); + assertEquals(""" + select "customer"."gender" as "c0" from "customer" as "customer" \ + join "sales_fact_1997" as "sales_fact_1997" \ + on "sales_fact_1997"."customer_id" = "customer"."customer_id"\ + """, off); + } + + /** + * The cardinality probe ({@code select count(*) from (select distinct ) init}) — a + * commented statement with a DERIVED TABLE: outer header/footer, inner header, inner level-key + * projection comment and inner base-table comment. Exercises the diagnostic nested-formatted + * contract: formatted + comments render the inner SELECT formatted too (LINE comments, indent + * stacking); compact renders stay byte-identical / comment-free in the nested context. + */ + private static SelectStatement cardinalityProbe() { + TableAlias store = TableAlias.of("store"); + SelectStatementBuilder inner = SelectStatementBuilder.create(); + inner.header("distinct level keys"); + inner.distinct(true); + inner.from(From.commentBase(From.table("store", store), + "dimension [Store] level table [Store].[Store State]")); + inner.project(Expressions.column(store, "store_state"), BestFitColumnType.STRING, null, + "level key [Store].[Store State]"); + + SelectStatementBuilder outer = SelectStatementBuilder.create(); + outer.header("level cardinality [Store].[Store].[Store State]"); + outer.footerComment("cardinality probe (count distinct keys)"); + outer.project(Expressions.aggregate("count", Expressions.star()), null); + outer.from(From.subquery(inner.build(), TableAlias.of("init"))); + return outer.build(); + } + + @Test + void nestedSubquery_commentsOff_compact_isByteExact() { + String sql = new DialectSqlRenderer(ansi).render(cardinalityProbe(), RenderOptions.compact()).sql(); + assertEquals(""" + select count(*) as "c0" from (select distinct "store"."store_state" as "c0" \ + from "store" as "store") as "init"\ + """, sql); + } + + @Test + void nestedSubquery_diagnosticMode_rendersInnerFormatted_withIndentStacking() { + String nl = System.lineSeparator(); + String sql = new DialectSqlRenderer(ansi) + .render(cardinalityProbe(), RenderOptions.multiLine().withComments(true, CommentStyle.LINE)).sql(); + assertEquals("-- level cardinality [Store].[Store].[Store State]" + nl + + "select" + nl + + " count(*) as \"c0\"" + nl + + "from" + nl + + " (" + nl + + " -- distinct level keys" + nl + + " select distinct" + nl + + " -- level key [Store].[Store State]" + nl + + " \"store\".\"store_state\" as \"c0\"" + nl + + " from" + nl + + " -- dimension [Store] level table [Store].[Store State]" + nl + + " \"store\" as \"store\"" + nl + + " ) as \"init\"" + nl + + "/* cardinality probe (count distinct keys) */", sql); + } + + @Test + void nestedSubquery_compactWithComments_staysCompactAndCommentFree() { + // Compact + comments (BLOCK) is NOT the diagnostic combination: the derived table renders + // compact without comments, exactly as before; outer header/footer render as always. + String sql = new DialectSqlRenderer(ansi) + .render(cardinalityProbe(), RenderOptions.compact().withComments(true)).sql(); + assertTrue(sql.contains(""" + (select distinct "store"."store_state" as "c0" \ + from "store" as "store") as "init"\ + """), sql); + assertFalse(sql.contains("level key"), sql); + assertFalse(sql.contains("distinct level keys"), sql); + assertTrue(sql.startsWith("-- level cardinality [Store].[Store].[Store State]"), sql); + assertTrue(sql.endsWith("/* cardinality probe (count distinct keys) */"), sql); + } + + @Test + void nestedSubquery_formattedCommentsOff_isUnchangedNestedCompact() { + String sql = new DialectSqlRenderer(ansi).render(cardinalityProbe(), RenderOptions.multiLine()).sql(); + assertTrue(sql.contains(""" + (select distinct "store"."store_state" as "c0" \ + from "store" as "store") as "init"\ + """), sql); + assertFalse(sql.contains("--"), sql); + assertFalse(sql.contains("/*"), sql); + } + + @Test + void lineStyle_commentsOff_isByteExact() { + // LINE style with comments OFF must be identical to a plain multi-line render. + String off = new DialectSqlRenderer(ansi) + .render(genderReadGrouped(), RenderOptions.multiLine().withComments(false, CommentStyle.LINE)).sql(); + String plain = new DialectSqlRenderer(ansi).render(genderReadGrouped(), RenderOptions.multiLine()).sql(); + assertEquals(plain, off); + assertFalse(off.contains("--"), off); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/StarProjectionTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/StarProjectionTest.java new file mode 100644 index 0000000..4882539 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/StarProjectionTest.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.ColumnAlias; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** + * The structured whole-row projection {@code Star}: bare {@code *}, qualified {@code t.*} (neither + * ever gets an {@code as cN} alias), and the {@code countStar()} text pin after its internals moved + * from {@code Raw("*")} to {@code Star}. + */ +class StarProjectionTest { + + private final DialectSqlRenderer renderer = new DialectSqlRenderer(new AnsiDialect()); + + @Test + void selectStar_noAliasAppended() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias t = TableAlias.of("t"); + q.from(From.table("fact", t)); + q.project(Expressions.star(), null); + assertEquals("select * from \"fact\" as \"t\"", renderer.render(q.build()).sql()); + } + + @Test + void selectQualifiedStar_noAliasAppended() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias t = TableAlias.of("t"); + q.from(From.table("fact", t)); + q.project(Expressions.star(t), null); + assertEquals("select \"t\".* from \"fact\" as \"t\"", renderer.render(q.build()).sql()); + } + + @Test + void qualifiedStar_notAliased_evenWithExplicitAlias() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias t = TableAlias.of("t"); + q.from(From.table("fact", t)); + q.project(Expressions.star(t), null, ColumnAlias.of("x")); + // Star suppresses ANY alias — "select t.* as x" would be invalid SQL. + assertEquals("select \"t\".* from \"fact\" as \"t\"", renderer.render(q.build()).sql()); + } + + /** {@code countStar()} must render exactly as before the internal Raw("*") -> Star retarget. */ + @Test + void countStar_textPin() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias t = TableAlias.of("fact"); + q.from(From.table("fact", t)); + q.project(Expressions.countStar(), BestFitColumnType.LONG, ColumnAlias.of("cnt")); + assertEquals(""" + select COUNT(*) as "cnt" from "fact" as "fact"\ + """, renderer.render(q.build()).sql()); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/StatementBuilderDemoTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/StatementBuilderDemoTest.java new file mode 100644 index 0000000..c21cf6c --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/StatementBuilderDemoTest.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.eclipse.daanse.jdbc.db.dialect.api.Dialect; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.jdbc.db.dialect.db.mssqlserver.MicrosoftSqlServerDialect; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** + * Golden-SQL tests: the same dialect-free query model renders to dialect-specific SQL. These + * are the behavioural proof that the assembler + renderer work end-to-end, and that the new + * dialect pagination placement ({@code TOP} vs {@code FETCH}) is honoured. + */ +class StatementBuilderDemoTest { + + private final Dialect ansi = new AnsiDialect(); + + @Test + void salesByProduct_ansi() { + String sql = new DialectSqlRenderer(ansi).render(StatementBuilderDemo.salesByProduct()).sql(); + assertEquals(""" + select "p"."name" as "c0", COUNT(*) as "cnt" \ + from "sales" as "s" join "product" as "p" on "s"."product_id" = "p"."id" \ + where "s"."amount" > 100 \ + group by "p"."name" order by "p"."name" ASC\ + """, sql); + } + + @Test + void topProducts_ansi_usesTrailingFetch() { + String sql = new DialectSqlRenderer(ansi).render(StatementBuilderDemo.topProducts()).sql(); + assertEquals(""" + select "p"."name" as "c0" from "product" as "p" \ + order by "p"."name" ASC FETCH NEXT 10 ROWS ONLY\ + """, sql); + } + + @Test + void topProducts_sqlServer_usesLeadingTop() { + String sql = new DialectSqlRenderer(new MicrosoftSqlServerDialect()) + .render(StatementBuilderDemo.topProducts()).sql(); + assertEquals(""" + select TOP 10 "p"."name" as "c0" from "product" as "p" \ + order by "p"."name" ASC\ + """, sql); + } + + @Test + void productNameUnion_ansi() { + String sql = new DialectSqlRenderer(ansi).render(StatementBuilderDemo.productNameUnion()).sql(); + assertEquals(""" + select "a"."name" as "c0" from "product_a" as "a" \ + union all select "b"."name" as "c0" from "product_b" as "b"\ + """, sql); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/StatementHintTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/StatementHintTest.java new file mode 100644 index 0000000..02dcc31 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/StatementHintTest.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.eclipse.daanse.jdbc.db.dialect.api.generator.StatementHint; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.jdbc.db.dialect.db.mssqlserver.MicrosoftSqlServerDialect; +import org.eclipse.daanse.jdbc.db.dialect.db.mysql.MySqlDialect; +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.SelectStatement; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.api.render.RenderOptions; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** + * Statement-level optimizer hints: dialect-free {@code StatementHint} intents on the + * {@code SelectStatement}, spelled (or silently ignored) by the dialect's {@code HintGenerator}. + * Unlike comments they are always emitted — never gated by {@code RenderOptions.comments()}. + */ +class StatementHintTest { + + private static final String NL = System.lineSeparator(); + + private static SelectStatementBuilder read(StatementHint... hints) { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias t = TableAlias.of("t"); + q.from(From.table("fact", t)); + q.project(Expressions.column(t, "id"), BestFitColumnType.INT); + for (StatementHint hint : hints) { + q.statementHint(hint); + } + return q; + } + + @Test + void ansi_ignoresHints_byteIdenticalToHintFree() { + DialectSqlRenderer renderer = new DialectSqlRenderer(new AnsiDialect()); + String without = renderer.render(read().build()).sql(); + String with = renderer + .render(read(new StatementHint("MAX_EXECUTION_TIME", List.of("1000"))).build()).sql(); + assertEquals(without, with); + String formattedWithout = renderer.render(read().build(), RenderOptions.multiLine()).sql(); + String formattedWith = renderer.render( + read(new StatementHint("MAX_EXECUTION_TIME", List.of("1000"))).build(), + RenderOptions.multiLine()).sql(); + assertEquals(formattedWithout, formattedWith); + } + + @Test + void mysql_optimizerBlockAfterSelectKeyword() { + DialectSqlRenderer renderer = new DialectSqlRenderer(new MySqlDialect()); + String hintFree = renderer.render(read().build()).sql(); + String sql = renderer + .render(read(new StatementHint("MAX_EXECUTION_TIME", List.of("1000"))).build()).sql(); + assertTrue(sql.startsWith("select /*+ MAX_EXECUTION_TIME(1000) */ "), sql); + // everything after the hint block is byte-identical to the hint-free statement + assertEquals("select /*+ MAX_EXECUTION_TIME(1000) */ " + hintFree.substring("select ".length()), sql); + } + + @Test + void mysql_hintPrecedesDistinct() { + SelectStatement s = read(new StatementHint("NO_INDEX_MERGE", List.of())).distinct(true).build(); + String sql = new DialectSqlRenderer(new MySqlDialect()).render(s).sql(); + assertTrue(sql.startsWith("select /*+ NO_INDEX_MERGE */ distinct "), sql); + } + + @Test + void sqlServer_trailingOptionClause() { + DialectSqlRenderer renderer = new DialectSqlRenderer(new MicrosoftSqlServerDialect()); + String hintFree = renderer.render(read().build()).sql(); + String sql = renderer.render(read(new StatementHint("RECOMPILE", List.of())).build()).sql(); + assertEquals(hintFree + " OPTION (RECOMPILE)", sql); + } + + @Test + void hintsAreEmitted_evenWithCommentsOff_andWithCommentsOn() { + // Hints are semantic for the DBMS: not gated by RenderOptions.comments(). + SelectStatement s = read(new StatementHint("RECOMPILE", List.of())).build(); + DialectSqlRenderer renderer = new DialectSqlRenderer(new MicrosoftSqlServerDialect()); + assertTrue(renderer.render(s, RenderOptions.compact()).sql().endsWith(" OPTION (RECOMPILE)")); + assertTrue(renderer.render(s, RenderOptions.compact().withComments(true)).sql() + .endsWith(" OPTION (RECOMPILE)")); + } + + @Test + void endOfStatementOrder_pagination_thenOption_thenFooter() { + SelectStatementBuilder q = read(new StatementHint("MAXDOP", List.of("2"))); + q.rowLimit(10, 20); + q.footerComment("emitted-by=StatementHintTest"); + String sql = new DialectSqlRenderer(new MicrosoftSqlServerDialect()) + .render(q.build(), RenderOptions.compact().withComments(true)).sql(); + assertTrue(sql.endsWith(" OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY OPTION (MAXDOP 2)" + + NL + "/* emitted-by=StatementHintTest */"), sql); + } +} diff --git a/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/TableRefTest.java b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/TableRefTest.java new file mode 100644 index 0000000..12d5336 --- /dev/null +++ b/statement/demo/src/test/java/org/eclipse/daanse/sql/statement/demo/TableRefTest.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.demo; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Optional; + +import org.eclipse.daanse.jdbc.db.api.schema.ColumnReference; +import org.eclipse.daanse.jdbc.db.api.schema.SchemaReference; +import org.eclipse.daanse.jdbc.db.api.schema.TableReference; +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.Datatype; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.DeleteStatementBuilder; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.Predicates; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; +import org.junit.jupiter.api.Test; + +/** Uses the shared jdbc.db identifier types ({@code TableReference}/{@code ColumnReference}). */ +class TableRefTest { + + private final DialectSqlRenderer renderer = new DialectSqlRenderer(new AnsiDialect()); + + private static final TableReference SALES_ORDER = new TableReference(Optional.of(new SchemaReference("sales")), + "order"); + + @Test + void selectWithTableReferenceAndColumnReference() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias o = TableAlias.of("o"); + q.from(From.table(SALES_ORDER, o)); + // ColumnReference carries metadata (its table); only its name() is used, qualified by alias o. + q.project(Expressions.column(o, new ColumnReference(Optional.of(SALES_ORDER), "amount")), + BestFitColumnType.DECIMAL); + + String sql = renderer.render(q.build()).sql(); + assertEquals(""" + select "o"."amount" as "c0" from "sales"."order" as "o"\ + """, sql); + } + + @Test + void deleteWithTableReference() { + String sql = renderer.render(DeleteStatementBuilder.create().from(SALES_ORDER) + .where(Predicates.eq(Expressions.column("id"), Expressions.literal(1, Datatype.INTEGER))) + .build()).sql(); + assertEquals(""" + delete from "sales"."order" where "id" = 1\ + """, sql); + } +} diff --git a/statement/impl/pom.xml b/statement/impl/pom.xml index 8854796..b6c26cd 100644 --- a/statement/impl/pom.xml +++ b/statement/impl/pom.xml @@ -37,5 +37,17 @@ org.eclipse.daanse.jdbc.db.dialect.api 0.0.1-SNAPSHOT + + org.eclipse.daanse + org.eclipse.daanse.jdbc.db.dialect.db.common + 0.0.1-SNAPSHOT + test + + + org.eclipse.daanse + org.eclipse.daanse.jdbc.db.dialect.db.mysql + 0.0.1-SNAPSHOT + test + diff --git a/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/compare/SqlTextNormalizer.java b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/compare/SqlTextNormalizer.java deleted file mode 100644 index d25b786..0000000 --- a/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/compare/SqlTextNormalizer.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (c) 2026 Contributors to the Eclipse Foundation. - * - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * SmartCity Jena - initial - * Stefan Bischof (bipolis.org) - initial - */ -package org.eclipse.daanse.sql.statement.compare; - -/** Small SQL-text utilities shared by the query-mapping layer and its dual-run tests. */ -public final class SqlTextNormalizer { - - private SqlTextNormalizer() { - } - - /** - * Collapse every run of whitespace to a single space and trim. Used to compare two SQL - * strings for equivalence when one may be single-line and the other formatted onto multiple - * indented lines. - */ - public static String normalizeWhitespace(String sql) { - return sql.replaceAll("\\s+", " ").trim(); - } -} diff --git a/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/compare/StatementEquivalence.java b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/compare/StatementEquivalence.java deleted file mode 100644 index 2e3819c..0000000 --- a/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/compare/StatementEquivalence.java +++ /dev/null @@ -1,365 +0,0 @@ -/* - * Copyright (c) 2026 Contributors to the Eclipse Foundation. - * - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * SmartCity Jena - initial - * Stefan Bischof (bipolis.org) - initial - */ -package org.eclipse.daanse.sql.statement.compare; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; - -import org.eclipse.daanse.jdbc.db.dialect.api.Dialect; -import org.eclipse.daanse.sql.statement.api.expression.Predicate; -import org.eclipse.daanse.sql.statement.api.expression.SqlExpression; -import org.eclipse.daanse.sql.statement.api.model.FromClause; -import org.eclipse.daanse.sql.statement.api.model.GroupBy; -import org.eclipse.daanse.sql.statement.api.model.OrderKey; -import org.eclipse.daanse.sql.statement.api.model.Projection; -import org.eclipse.daanse.sql.statement.api.model.SelectStatement; -import org.eclipse.daanse.sql.statement.render.DialectSqlRenderer; - -/** - * Structural equivalence of two {@link SelectStatement} trees, up to the differences that do NOT - * change the rows a query returns. This is the relaxation a byte-equal comparison cannot offer: - * a byte-level gate rejects any whitespace difference, including ones where statement B - * is merely cosmetically different from (or better than) statement A — so correct paths - * stall on clause-order / comma-vs-ANSI-join noise. This comparator treats those as equivalent - * while still catching a genuinely different query. - * - *

Why it renders fragments rather than comparing AST nodes. One statement may be - * Raw-string based (built from pre-rendered SQL fragments) while the other is - * structured ({@code Column}, {@code Comparison}, …). Their trees never match even when - * they render identically, so each comparable fragment is rendered to its dialect SQL - * (whitespace-normalised) and the SQL strings are compared. That makes - * {@code Raw("`c`.`x` = 1")} ≡ {@code Comparison(Column, EQ, Literal)}. - * - *

Defined equivalences (the benign classes): - *

    - *
  • WHERE / JOIN conjunct order — the top-level {@code WHERE} predicates and every - * {@code FromJoin} {@code ON} are rendered and compared as a set, so order is - * irrelevant and a comma-product join-in-{@code WHERE} ≡ an ANSI join-in-{@code ON}.
  • - *
  • table-filter placement — a {@link FromClause.FromTable}'s own filter joins the set.
  • - *
- * - *

Structural (NOT equivalent): a table present on one side only (over-join / missing - * non-empty fact join); a conjunct present on one side only (dropped/extra restriction); - * {@code UPPER(x)=UPPER(y)} vs {@code x=y} (different rendered fragments); any projection / - * {@code GROUP BY} / {@code ORDER BY} difference. - * - *

Out of scope (conservatively NOT equivalent): a redundant all-member {@code IN} that - * one side drops as a no-op — proving it redundant needs the level's member count, not in the - * tree; and a query where the two sides group the {@code WHERE} conjuncts into different - * parenthesised {@code AND} groups (rendered as different fragments) — conservatively treated as - * structural. - */ -public final class StatementEquivalence { - - private StatementEquivalence() { - } - - /** A named, human-readable first structural difference (empty {@link #describe} ⇒ equivalent). */ - public record Diff(String reason) { - @Override - public String toString() { - return reason; - } - } - - /** True when {@code a} and {@code b} return the same rows up to the defined equivalences. */ - public static boolean equivalent(SelectStatement a, SelectStatement b, Dialect dialect) { - return describeAll(a, b, dialect).isEmpty(); - } - - /** The first structural difference between {@code a} and {@code b}, or empty when equivalent. */ - public static Optional describe(SelectStatement a, SelectStatement b, Dialect dialect) { - List all = describeAll(a, b, dialect); - return all.isEmpty() ? Optional.empty() : Optional.of(all.get(0)); - } - - /** - * ALL structural differences between {@code a} and {@code b} (empty ⇒ equivalent). One entry per - * differing clause — a first-diff-only report misattributes multi-clause divergences (a duplicated - * FROM table used to surface as a spurious ORDER-BY diff), so verify-mode logging reports every - * clause that differs. - */ - public static List describeAll(SelectStatement a, SelectStatement b, Dialect dialect) { - List out = new ArrayList<>(); - if (a.distinct() != b.distinct()) { - out.add(new Diff("DISTINCT: " + a.distinct() + " vs " + b.distinct())); - } - List pa = projections(a, dialect); - List pb = projections(b, dialect); - if (!pa.equals(pb)) { - out.add(new Diff("projections: " + pa + " vs " + pb)); - } - // FROM tables are a MULTISET: a duplicate table (`from store, store`) is a real structural - // difference a set-compare hides (it produces a cross-product of the table with itself). - List ta = new ArrayList<>(); - List tb = new ArrayList<>(); - Set ca = new HashSet<>(); - Set cb = new HashSet<>(); - a.from().ifPresent(f -> collect(f, ta, ca, dialect)); - b.from().ifPresent(f -> collect(f, tb, cb, dialect)); - for (Predicate p : a.filters()) { - ca.add(render(p, dialect)); - } - for (Predicate p : b.filters()) { - cb.add(render(p, dialect)); - } - List sta = new ArrayList<>(ta); - List stb = new ArrayList<>(tb); - java.util.Collections.sort(sta); - java.util.Collections.sort(stb); - if (!sta.equals(stb)) { - out.add(new Diff("FROM tables: " + symmetricMultiset(ta, tb))); - } - // WHERE/HAVING conjuncts and GROUP keys compare as SETS: AND is idempotent and group keys - // dedupe, so a duplicate conjunct/key is result-neutral (unlike a duplicate FROM table). - if (!ca.equals(cb)) { - out.add(new Diff("WHERE/JOIN conjuncts: " + symmetric(ca, cb))); - } - Set ga = groupKeys(a, dialect); - Set gb = groupKeys(b, dialect); - if (!ga.equals(gb)) { - out.add(new Diff("GROUP BY: " + symmetric(ga, gb))); - } - Set ha = new HashSet<>(); - Set hb = new HashSet<>(); - for (Predicate p : a.having()) { - ha.add(render(p, dialect)); - } - for (Predicate p : b.having()) { - hb.add(render(p, dialect)); - } - if (!ha.equals(hb)) { - out.add(new Diff("HAVING: " + symmetric(ha, hb))); - } - List oa = orderKeys(a, dialect); - List ob = orderKeys(b, dialect); - if (!oa.equals(ob)) { - out.add(new Diff("ORDER BY: " + oa + " vs " + ob)); - } - if (!a.rowLimit().equals(b.rowLimit())) { - out.add(new Diff("rowLimit differ")); - } - return out; - } - - private static String render(Predicate p, Dialect dialect) { - return SqlTextNormalizer.normalizeWhitespace(new DialectSqlRenderer(dialect).renderPredicate(p)); - } - - private static String render(SqlExpression e, Dialect dialect) { - return SqlTextNormalizer.normalizeWhitespace(new DialectSqlRenderer(dialect).renderExpression(e)); - } - - /** Ordered, rendered SELECT-list expressions (projection ORDER is referenced by ordinals). */ - private static List projections(SelectStatement s, Dialect dialect) { - List out = new ArrayList<>(); - for (Projection p : s.projections()) { - out.add(render(p.expression(), dialect)); - } - return out; - } - - /** The GROUP BY keys as a rendered set (group order is not row-significant). */ - private static Set groupKeys(SelectStatement s, Dialect dialect) { - Set out = new HashSet<>(); - for (GroupBy.GroupKey k : s.groupBy().keys()) { - SqlExpression e; - if (k instanceof GroupBy.GroupKey.Ref r) { - e = s.projections().get(r.projection().ordinal()).expression(); - } else if (k instanceof GroupBy.GroupKey.Expr ex) { - e = ex.expression(); - } else { - throw new IllegalStateException("unknown GROUP BY key: " + k); - } - out.add(render(e, dialect)); - } - return out; - } - - /** Ordered, rendered ORDER BY keys (with sort spec — order IS row-significant). */ - private static List orderKeys(SelectStatement s, Dialect dialect) { - List out = new ArrayList<>(); - for (OrderKey k : s.orderKeys()) { - out.add(render(k.expression(), dialect) + " " + k.sort()); - } - return out; - } - - /** - * The NAMED accepted-equivalence classes a divergence needed, or empty when the two statements - * are NOT equivalent. Empty SET = byte-level-identical structure (no relaxation needed). - * - *

Stable contract: the class-name strings below are a closed catalog referenced - * verbatim by consumer allowlists (promotion gates) and their ledger documentation — they MUST - * NOT be renamed. A consumer promotes a divergence only when EVERY needed class is allowed. - *

    - *
  • {@code join-order} — the FROM table SEQUENCE differs (multiset equal; inner-join order - * is result-neutral).
  • - *
  • {@code where-conjunct-set} — the WHERE/ON conjunct ORDER differs (set equal; AND is - * commutative).
  • - *
  • {@code join-style} — a conjunct moved between ON and WHERE (comma-product ↔ ANSI join; - * equivalent for inner joins).
  • - *
- */ - public static Optional> equivalenceClasses(SelectStatement a, SelectStatement b, Dialect dialect) { - if (!describeAll(a, b, dialect).isEmpty()) { - return Optional.empty(); - } - Set classes = new java.util.LinkedHashSet<>(); - List ta = new ArrayList<>(); - List tb = new ArrayList<>(); - List onA = new ArrayList<>(); - List onB = new ArrayList<>(); - a.from().ifPresent(f -> collectOrdered(f, ta, onA, dialect)); - b.from().ifPresent(f -> collectOrdered(f, tb, onB, dialect)); - if (!ta.equals(tb)) { - classes.add("join-order"); - } - // WHERE-only conjunct SETS differing (while the ON+WHERE union is equal — guaranteed by - // describeAll) = a conjunct moved between ON and WHERE. - Set wa = new HashSet<>(); - Set wb = new HashSet<>(); - for (Predicate p : a.filters()) { - wa.add(render(p, dialect)); - } - for (Predicate p : b.filters()) { - wb.add(render(p, dialect)); - } - if (!wa.equals(wb)) { - classes.add("join-style"); - } - // Conjunct ORDER: only the WHERE filters' own sequence. The ON sequence follows the join - // order (an ON is attached to its join), so an ON reorder is ALREADY named by join-order — - // counting it here would over-name a pure join reorder and block its promotion. - List fseqA = new ArrayList<>(); - List fseqB = new ArrayList<>(); - for (Predicate p : a.filters()) { - fseqA.add(render(p, dialect)); - } - for (Predicate p : b.filters()) { - fseqB.add(render(p, dialect)); - } - if (!fseqA.equals(fseqB) && wa.equals(wb)) { - classes.add("where-conjunct-set"); - } - return Optional.of(classes); - } - - /** In-order table aliases + in-order JOIN-ON/table-filter fragments (sequence-preserving twin - * of {@link #collect}). */ - private static void collectOrdered(FromClause f, List tables, List conjuncts, Dialect dialect) { - if (f instanceof FromClause.FromJoin j) { - collectOrdered(j.left(), tables, conjuncts, dialect); - collectOrdered(j.right(), tables, conjuncts, dialect); - conjuncts.add(render(j.on(), dialect)); - } else if (f instanceof FromClause.FromProduct p) { - for (FromClause item : p.items()) { - collectOrdered(item, tables, conjuncts, dialect); - } - } else if (f instanceof FromClause.FromTable t) { - tables.add("t:" + t.alias()); - t.filter().ifPresent(pr -> conjuncts.add(render(pr, dialect))); - } else if (f instanceof FromClause.FromSubquery sq) { - tables.add("sub:" + sq.alias()); - } else if (f instanceof FromClause.FromSet fs) { - // Opaque leaf by alias, like FromSubquery (structured bodies compare by alias; only the - // pre-rendered FromRaw carries its text). - tables.add("set:" + fs.alias()); - } else if (f instanceof FromClause.FromRaw r) { - tables.add("raw:" + SqlTextNormalizer.normalizeWhitespace(r.sql()) + "#" + r.alias()); - } else if (f instanceof FromClause.FromVariant v) { - tables.add("var:" + v.alias()); - } else if (f instanceof FromClause.FromInline in) { - tables.add("inline:" + in.alias()); - } - } - - /** Hoists every FROM-tree table (by alias) into {@code tables} (a multiset — duplicates are - * meaningful), every JOIN-ON / table-filter into {@code conjuncts}. */ - private static void collect(FromClause f, List tables, Set conjuncts, Dialect dialect) { - if (f instanceof FromClause.FromJoin j) { - collect(j.left(), tables, conjuncts, dialect); - collect(j.right(), tables, conjuncts, dialect); - conjuncts.add(render(j.on(), dialect)); - } else if (f instanceof FromClause.FromProduct p) { - for (FromClause item : p.items()) { - collect(item, tables, conjuncts, dialect); - } - } else if (f instanceof FromClause.FromTable t) { - tables.add("t:" + t.alias()); - t.filter().ifPresent(pr -> conjuncts.add(render(pr, dialect))); - } else if (f instanceof FromClause.FromSubquery sq) { - tables.add("sub:" + sq.alias()); - } else if (f instanceof FromClause.FromSet fs) { - // Opaque leaf by alias, like FromSubquery (structured bodies compare by alias; only the - // pre-rendered FromRaw carries its text). - tables.add("set:" + fs.alias()); - } else if (f instanceof FromClause.FromRaw r) { - tables.add("raw:" + SqlTextNormalizer.normalizeWhitespace(r.sql()) + "#" + r.alias()); - } else if (f instanceof FromClause.FromVariant v) { - tables.add("var:" + v.alias()); - } else if (f instanceof FromClause.FromInline in) { - tables.add("inline:" + in.alias()); - } - } - - /** The elements in exactly one of the two sets (for a readable diff message; {@code left} is - * the first statement argument, {@code right} the second). */ - private static String symmetric(Set a, Set b) { - List onlyA = new ArrayList<>(); - for (String x : a) { - if (!b.contains(x)) { - onlyA.add(x); - } - } - List onlyB = new ArrayList<>(); - for (String x : b) { - if (!a.contains(x)) { - onlyB.add(x); - } - } - return "onlyLeft=" + onlyA + " onlyRight=" + onlyB; - } - - /** Multiset difference by occurrence count (a duplicated table reports as {@code table×2 vs ×1}). */ - private static String symmetricMultiset(List a, List b) { - java.util.Map countA = counts(a); - java.util.Map countB = counts(b); - List onlyA = new ArrayList<>(); - List onlyB = new ArrayList<>(); - Set keys = new HashSet<>(countA.keySet()); - keys.addAll(countB.keySet()); - for (String k : keys) { - int na = countA.getOrDefault(k, 0); - int nb = countB.getOrDefault(k, 0); - if (na > nb) { - onlyA.add(nb == 0 ? k : k + "×" + na + " vs ×" + nb); - } else if (nb > na) { - onlyB.add(na == 0 ? k : k + "×" + nb + " vs ×" + na); - } - } - return "onlyLeft=" + onlyA + " onlyRight=" + onlyB; - } - - private static java.util.Map counts(List xs) { - java.util.Map out = new java.util.LinkedHashMap<>(); - for (String x : xs) { - out.merge(x, 1, Integer::sum); - } - return out; - } -} diff --git a/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/ColumnAccessors.java b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/ColumnAccessors.java index 79d86af..e50ff22 100644 --- a/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/ColumnAccessors.java +++ b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/ColumnAccessors.java @@ -16,7 +16,7 @@ import java.sql.ResultSet; import java.sql.SQLException; -import org.eclipse.daanse.jdbc.db.dialect.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; /** * Reads a single column from a {@link ResultSet} as the right Java type for a diff --git a/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/JdbcRow.java b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/JdbcRow.java index d756da8..dce6e49 100644 --- a/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/JdbcRow.java +++ b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/JdbcRow.java @@ -17,7 +17,7 @@ import java.sql.SQLException; import java.util.List; -import org.eclipse.daanse.jdbc.db.dialect.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; import org.eclipse.daanse.sql.statement.api.exec.StatementExecutionException; import org.eclipse.daanse.sql.statement.api.result.Row; diff --git a/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/render/DialectSqlRenderer.java b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/render/DialectSqlRenderer.java index fe457b3..9b6db68 100644 --- a/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/render/DialectSqlRenderer.java +++ b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/render/DialectSqlRenderer.java @@ -24,7 +24,7 @@ import org.eclipse.daanse.jdbc.db.api.schema.SchemaReference; import org.eclipse.daanse.jdbc.db.api.schema.TableReference; import org.eclipse.daanse.jdbc.db.dialect.api.Dialect; -import org.eclipse.daanse.jdbc.db.dialect.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; import org.eclipse.daanse.sql.statement.api.expression.ComparisonOperator; import org.eclipse.daanse.sql.statement.api.expression.Predicate; import org.eclipse.daanse.sql.statement.api.expression.SqlExpression; @@ -35,6 +35,7 @@ import org.eclipse.daanse.sql.statement.api.model.JoinKind; import org.eclipse.daanse.sql.statement.api.model.OrderKey; import org.eclipse.daanse.sql.statement.api.model.Projection; +import org.eclipse.daanse.sql.statement.api.model.ProjectionRef; import org.eclipse.daanse.sql.statement.api.model.SelectStatement; import org.eclipse.daanse.sql.statement.api.model.SetOperation; import org.eclipse.daanse.sql.statement.api.model.SortSpec; @@ -189,7 +190,7 @@ private RenderedSql renderUpdate(UpdateStatement upd) { .map(a -> dialect.quoteIdentifier(a.column()) + " = " + renderExpression(a.value())) .collect(Collectors.joining(", "))); if (!upd.filters().isEmpty()) { - sb.append(" where ").append(renderPredicateList(upd.filters(), " and ")); + sb.append(" where ").append(renderPredicateList(upd.filters(), " and ", true)); } return RenderedSql.of(sb.toString(), List.of()); } @@ -198,7 +199,7 @@ private RenderedSql renderDelete(DeleteStatement del) { StringBuilder sb = new StringBuilder("delete from "); appendQualifiedTable(sb, del.table()); if (!del.filters().isEmpty()) { - sb.append(" where ").append(renderPredicateList(del.filters(), " and ")); + sb.append(" where ").append(renderPredicateList(del.filters(), " and ", true)); } return RenderedSql.of(sb.toString(), List.of()); } @@ -272,7 +273,129 @@ private String blockCommentAfter(java.util.Optional c, RenderOptions o) return (blockComments(o) && c.isPresent()) ? " /* " + safeBlockComment(c.get()) + " */" : ""; } + /** + * The dialect-free count-distinct SUBQUERY rewrite — the render-time relocation of the core + * {@code AbstractQuerySpec.distinctGenerateSql}. A flat aggregate carrying one or more + * {@code count(distinct col)} projections the live dialect cannot execute inline + * ({@code !allowsCountDistinct}; or {@code !allowsMultipleCountDistinct} for two or more distinct + * measures) is rewritten into the nested form + * + *
+     *   select "d0".., <agg>("m0").. from (
+     *       select [distinct] <dimExpr> as "d0".., <measArg> as "m0"..
+     *       from <from> where <where>
+     *       [group by every projection, when !allowsInnerDistinct]) as "dummyname"
+     *   group by "d0"..
+     * 
+ * + * The builder ALWAYS emits the canonical flat {@code count(distinct col)}; only a restrictive + * dialect triggers this pass, so on every permissive dialect (the whole test corpus) this returns + * {@code null} and the output is byte-identical to a no-op. Each measure re-aggregates with the SAME + * function name minus {@code DISTINCT} ({@code count(distinct x) -> count("m0")}), which equals the + * legacy {@code aggregator.getNonDistinctAggregator()} because a distinct aggregator shares its SQL + * function name with its non-distinct sibling. + *

+ * Returns {@code null} (render the statement as-is) when no rewrite is needed, or when the statement + * is not the flat aggregate shape this pass reproduces — grouping sets / grouping functions, an + * ORDER BY / HAVING / row limit, an already-DISTINCT select, an {@link SqlExpression.ExtraAggregate} + * measure, or a {@code count(*)} / multi-argument distinct measure. Core keeps those shapes on the + * recorder path, whose output already IS the nested form (it never presents a distinct aggregate + * projection here), so the two paths never collide. + */ + private SelectStatement rewriteDistinctCountToSubquery(SelectStatement s) { + int k = 0; + for (Projection p : s.projections()) { + if (p.expression() instanceof SqlExpression.Aggregate a && a.distinct()) { + k++; + } + } + boolean needsRewrite = (k >= 1 && !dialect.allowsCountDistinct()) + || (k >= 2 && !dialect.allowsMultipleCountDistinct()); + if (!needsRewrite) { + return null; + } + // Only the flat aggregate shape is reproducible; anything richer stays flat (core routes those + // shapes to the recorder, which produces the nested form directly). + if (s.from().isEmpty() || s.distinct() || !s.groupBy().groupingSets().isEmpty() + || !s.groupBy().groupingFunctions().isEmpty() || !s.orderKeys().isEmpty() + || !s.having().isEmpty() || s.rowLimit().isPresent()) { + return null; + } + // Partition the flat projections: an aggregate projection is a measure (unwrapped to its sole + // argument inside the subquery); every other projection is a group-by dimension. + List dims = new ArrayList<>(); + List measures = new ArrayList<>(); + List measureProjs = new ArrayList<>(); + for (Projection p : s.projections()) { + if (p.expression() instanceof SqlExpression.Aggregate a) { + if (a.arguments().size() != 1 || a.arguments().get(0) instanceof SqlExpression.Star) { + return null; // count(*) / multi-argument aggregate — not reproducible; leave flat + } + measures.add(a); + measureProjs.add(p); + } else if (isAggregateProjection(p.expression())) { + return null; // ExtraAggregate — not reproducible; leave flat + } else { + dims.add(p); + } + } + + boolean innerDistinct = dialect.allowsInnerDistinct(); + + // Inner subquery: dimension expressions aliased d{j}, then each measure's argument aliased m{i}; + // DISTINCT when the dialect allows inner DISTINCT, else grouped by every projection (Greenplum). + List innerProjections = new ArrayList<>(); + for (int j = 0; j < dims.size(); j++) { + innerProjections.add(new Projection(dims.get(j).expression(), null, + java.util.Optional.of(new org.eclipse.daanse.sql.statement.api.model.ColumnAlias("d" + j)), + java.util.Optional.empty())); + } + for (int i = 0; i < measures.size(); i++) { + innerProjections.add(new Projection(measures.get(i).arguments().get(0), measureProjs.get(i).columnType(), + java.util.Optional.of(new org.eclipse.daanse.sql.statement.api.model.ColumnAlias("m" + i)), + java.util.Optional.empty())); + } + List innerKeys = new ArrayList<>(); + if (!innerDistinct) { + for (int idx = 0; idx < innerProjections.size(); idx++) { + innerKeys.add(new GroupBy.GroupKey.Ref(new ProjectionRef(idx, java.util.Optional.empty()))); + } + } + SelectStatement inner = new SelectStatement(innerDistinct, innerProjections, s.from(), s.filters(), + new GroupBy(innerKeys, List.of(), List.of()), List.of(), List.of(), java.util.Optional.empty(), + java.util.Optional.empty(), s.filterComments()); + + // Outer wrapper: reference the subquery aliases (bare "d0" / "m0"), re-aggregate each measure with + // the SAME function WITHOUT distinct, group by the d{j} refs; FROM the "dummyname" derived table. + FromClause dummy = new FromClause.FromSubquery(inner, + new org.eclipse.daanse.sql.statement.api.model.TableAlias("dummyname")); + List outerProjections = new ArrayList<>(); + List outerKeys = new ArrayList<>(); + for (int j = 0; j < dims.size(); j++) { + outerProjections.add(new Projection(new SqlExpression.Column(java.util.Optional.empty(), "d" + j), null, + java.util.Optional.empty(), java.util.Optional.empty())); + outerKeys.add(new GroupBy.GroupKey.Ref(new ProjectionRef(j, java.util.Optional.empty()))); + } + for (int i = 0; i < measures.size(); i++) { + SqlExpression outerAgg = new SqlExpression.Aggregate(measures.get(i).name(), false, + List.of(new SqlExpression.Column(java.util.Optional.empty(), "m" + i))); + outerProjections.add(new Projection(outerAgg, measureProjs.get(i).columnType(), + java.util.Optional.empty(), java.util.Optional.empty())); + } + return new SelectStatement(false, outerProjections, java.util.Optional.of(dummy), List.of(), + new GroupBy(outerKeys, List.of(), List.of()), List.of(), List.of(), java.util.Optional.empty(), + s.headerComment(), java.util.Map.of(), s.footerComment(), s.statementHints()); + } + private RenderedSql renderSelect(SelectStatement s, RenderOptions options) { + // Count-distinct SUBQUERY rewrite (P2b): a flat count(distinct col) the live dialect cannot + // execute inline is degraded into the nested dummyname form here, once, before the normal + // render. Returns null (no rewrite) on every permissive dialect, so corpus output is unchanged; + // the rewritten statement carries no distinct aggregate, so the recursive render never re-triggers. + SelectStatement distinctRewrite = rewriteDistinctCountToSubquery(s); + if (distinctRewrite != null) { + return renderSelect(distinctRewrite, options); + } final String itemSep = fmtKw(", ", options); StringBuilder sb = new StringBuilder(); List types = new ArrayList<>(); @@ -553,6 +676,32 @@ private void renderGroupBy(GroupBy gb, List projections, StringBuild keyed.putIfAbsent(renderExpression(e), java.util.Optional.empty()); } } + // Dialect completion: an engine that does not allow non-aggregate select columns outside + // GROUP BY needs EVERY non-aggregate projection grouped. The builder emits the canonical + // (permissive) form — only the keys it placed — and the completion rebuilds the group by in + // PROJECTION order here (every non-aggregate projection, then any non-projection keys the + // builder added). Projection order is exactly the order a restrictive-dialect builder used + // to place its keys, so this renders byte-identically to the pre-relocation SQL. On + // permissive dialects this branch is skipped, so the canonical form stands unchanged. + if (gb.completeNonAggregates() && !dialect.allowsSelectNotInGroupBy()) { + java.util.LinkedHashMap> full = new java.util.LinkedHashMap<>(); + for (int i = 0; i < projections.size(); i++) { + Projection p = projections.get(i); + // Skip true aggregates AND projections the builder explicitly exempted (an arithmetic + // expression wrapping aggregates the structural check below cannot see — e.g. a native + // TopCount/Order measure), which must never become a GROUP-BY key. + if (isAggregateProjection(p.expression()) || p.groupByCompletionExempt()) { + continue; + } + GroupBy.GroupKey.Ref ref = + new GroupBy.GroupKey.Ref(new ProjectionRef(i, java.util.Optional.empty())); + full.putIfAbsent(renderGroupKey(ref, projections), p.comment()); + } + // Preserve any explicit keys that are not plain projections (standalone expressions, + // grouping-set keys), appended after the projection-order columns. + keyed.forEach(full::putIfAbsent); + keyed = full; + } if (!keyed.isEmpty()) { sb.append(fmtKw(" group by ", options)); String sep = fmtKw(", ", options); @@ -569,6 +718,11 @@ private void renderGroupBy(GroupBy gb, List projections, StringBuild } } + /** An aggregate/window projection — excluded from GROUP-BY completion (only non-aggregates group). */ + private static boolean isAggregateProjection(SqlExpression e) { + return e instanceof SqlExpression.Aggregate || e instanceof SqlExpression.ExtraAggregate; + } + private java.util.Optional groupKeyComment(GroupBy.GroupKey key, List projections) { if (key instanceof GroupBy.GroupKey.Ref ref) { return projections.get(ref.projection().ordinal()).comment(); @@ -616,6 +770,19 @@ private String renderOrderKey(OrderKey key, List projections) { } boolean ascending = spec.direction() == SortDirection.ASC; boolean collateNullsLast = spec.nullOrder() != NullOrder.FIRST; + if (key.expression() instanceof SqlExpression.Ordinal) { + // Ordinal ORDER keys (set-operation wrappers order by 1, 2, ...) never carry a null + // collation. A dialect that emulates it wraps the key in CASE WHEN IS NULL, which + // on an ordinal tests the literal rather than the column it designates - a constant + // leading sort key, rejected in a UNION by the very requiresUnionOrderByOrdinal() + // dialects that force ordinals. Dialect.supportsNullsLast() cannot gate this: it defaults + // to true while OrderByGenerator.generateOrderByNulls() defaults to the CASE emulation, + // so the renderer cannot tell native spelling from emulation. Producers wanting a null + // collation must order on the expression, not on its ordinal; SortSpec.nullable is + // ignored here. Degrading HERE keeps producers capability-free (rolap doc 21). + return dialect.orderByGenerator().generateOrderItem(exprSql, false, ascending, collateNullsLast) + .toString(); + } if (spec.nullSortValue() != null) { // Order nulls as if they held nullSortValue (e.g. a parent-child hierarchy nullParentValue). return dialect.orderByGenerator().generateOrderItemForOrderValue( @@ -737,8 +904,26 @@ private String renderExtraAggregate(SqlExpression.ExtraAggregate ea) { "dialect '" + dialect.name() + "' does not support extra aggregate: " + spec)); } - private String renderPredicateList(List predicates, String separator) { - return predicates.stream().map(this::renderPredicate).collect(Collectors.joining(separator)); + private String renderPredicateList(List predicates, String separator, boolean andJoined) { + boolean guard = andJoined && predicates.size() > 1; + return predicates.stream() + .map(p -> guard ? renderAndConjunct(p) : renderPredicate(p)) + .collect(Collectors.joining(separator)); + } + + /** + * One member of an AND list that has siblings: identical to {@link #renderPredicate} except that an + * InTuple degraded to a bare OR chain (see there) gets its grouping parenthesis here — without it the + * chain's OR would capture the sibling conjuncts. Byte-neutral for every other predicate (they are + * self-delimiting) and never used for a sole conjunct, whose bare chain is wrapped by the enclosing + * connective instead. + */ + private String renderAndConjunct(Predicate p) { + String sql = renderPredicate(p); + if (p instanceof Predicate.InTuple && !dialect.supportsMultiValueInExpr()) { + return "(" + sql + ")"; + } + return sql; } /** @@ -748,7 +933,7 @@ private String renderPredicateList(List predicates, String separator) */ private String renderWhere(List preds, RenderOptions options, java.util.Map comments) { if (!options.comments() || comments.isEmpty()) { - return renderPredicateList(preds, fmtKw(" and ", options)); + return renderPredicateList(preds, fmtKw(" and ", options), true); } String sep = fmtKw(" and ", options); StringBuilder b = new StringBuilder(); @@ -760,7 +945,7 @@ private String renderWhere(List preds, RenderOptions options, java.ut first = false; java.util.Optional c = java.util.Optional.ofNullable(comments.get(p)); b.append(lineCommentBefore(c, options, options.indent())); - b.append(renderPredicate(p)); + b.append(preds.size() > 1 ? renderAndConjunct(p) : renderPredicate(p)); b.append(blockCommentAfter(c, options)); } return b.toString(); @@ -781,15 +966,20 @@ public String renderPredicate(Predicate p) { if (p instanceof Predicate.InTuple it) { if (!dialect.supportsMultiValueInExpr()) { // The dialect cannot evaluate a multi-column row-value IN (e.g. H2 mis-unifies the row - // types). Degrade to the equivalent OR-of-ANDs — the same shape the legacy - // AndPredicate.checkInList fallback produced when it cleared the IN-list bit key: - // ((c1 = v11 and c2 = v12) or (c1 = v21 and c2 = v22) or ...). + // types). Degrade to the equivalent OR-of-ANDs. Two shape rules: + // * Column/value pairs render in REVERSE tuple order. Tuples are built child-first + // (leaf level first, e.g. (quarter, the_year)); the expansion reads parent-first: + // (the_year = 1997 and quarter = 'Q1') or (the_year = 1998 and quarter = 'Q2'). + // * Exactly one parenthesis per AND group, and NO outer parenthesis of its own: the + // enclosing connective (an InTuple in a WHERE always arrives inside an And/Or, which + // parenthesizes) supplies the single grouping layer around the OR chain. And-join + // sites with sibling conjuncts add a guard parenthesis instead (renderPredicateList). List colSql = it.columns().stream().map(this::renderExpression).toList(); - String ors = it.rows().stream() + return it.rows().stream() .map(row -> { StringBuilder b = new StringBuilder("("); - for (int i = 0; i < colSql.size(); i++) { - if (i > 0) { + for (int i = colSql.size() - 1; i >= 0; i--) { + if (i < colSql.size() - 1) { b.append(" and "); } b.append(colSql.get(i)).append(" = ").append(renderExpression(row.get(i))); @@ -797,7 +987,6 @@ public String renderPredicate(Predicate p) { return b.append(")").toString(); }) .collect(Collectors.joining(" or ")); - return "(" + ors + ")"; } String cols = it.columns().stream().map(this::renderExpression).collect(Collectors.joining(", ")); String rows = it.rows().stream() @@ -833,7 +1022,7 @@ public String renderPredicate(Predicate p) { if (conn.operands().isEmpty()) { return constantPredicate(and); } - return "(" + renderPredicateList(conn.operands(), and ? " and " : " or ") + ")"; + return "(" + renderPredicateList(conn.operands(), and ? " and " : " or ", and) + ")"; } if (p instanceof Predicate.Raw r) { return r.sql(); diff --git a/statement/impl/src/test/java/org/eclipse/daanse/sql/statement/render/DistinctCountSubqueryRewriteTest.java b/statement/impl/src/test/java/org/eclipse/daanse/sql/statement/render/DistinctCountSubqueryRewriteTest.java new file mode 100644 index 0000000..dc33814 --- /dev/null +++ b/statement/impl/src/test/java/org/eclipse/daanse/sql/statement/render/DistinctCountSubqueryRewriteTest.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.render; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.eclipse.daanse.jdbc.db.dialect.api.Dialect; +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.model.ColumnAlias; +import org.eclipse.daanse.sql.statement.api.model.ProjectionRef; +import org.eclipse.daanse.sql.statement.api.model.SelectStatement; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.junit.jupiter.api.Test; + +/** + * Pins the count-distinct SUBQUERY rewrite the {@link DialectSqlRenderer} performs (P2b — relocated + * from the core {@code AbstractQuerySpec.distinctGenerateSql}). The builder always emits the + * canonical flat {@code count(distinct col)}; a dialect that cannot execute it inline + * ({@code !allowsCountDistinct}, or {@code !allowsMultipleCountDistinct} for two or more) has it + * degraded into the nested {@code dummyname} subquery form. Because the degraded branch never fires + * on any corpus dialect, these pins ARE the contract; the byte-for-byte equivalence with the legacy + * recorder output is proved separately by the rolap-side oracle test. + */ +class DistinctCountSubqueryRewriteTest { + + private static final String FROM = + """ + from "fact" as "fact" join "dim" as "dim" on "fact"."dim_id" = "dim"."id"\ + """; + + /** A dialect without count-distinct support (else identical to ANSI). */ + private static Dialect noCountDistinct() { + return new AnsiDialect() { + @Override + public boolean allowsCountDistinct() { + return false; + } + }; + } + + /** As {@link #noCountDistinct()} but also forbidding DISTINCT in the inner subquery (Greenplum). */ + private static Dialect noCountDistinctNoInnerDistinct() { + return new AnsiDialect() { + @Override + public boolean allowsCountDistinct() { + return false; + } + + @Override + public boolean allowsInnerDistinct() { + return false; + } + }; + } + + /** A flat aggregate: one grouped dim column + one {@code count(distinct measCol)} measure (m0). */ + private static SelectStatement flatDistinctCount() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias fact = TableAlias.of("fact"); + TableAlias dim = TableAlias.of("dim"); + q.from(From.table("fact", fact)); + q.innerJoin(From.table("dim", dim), org.eclipse.daanse.sql.statement.api.Predicates.comparison( + Expressions.column(fact, "dim_id"), + org.eclipse.daanse.sql.statement.api.expression.ComparisonOperator.EQ, + Expressions.column(dim, "id"))); + ProjectionRef ref = q.project(Expressions.column(dim, "name"), BestFitColumnType.STRING); + q.groupOn(ref); + q.project(Expressions.aggregateDistinct("count", Expressions.column(fact, "sales")), + BestFitColumnType.INT, ColumnAlias.of("m0")); + return q.build(); + } + + @Test + void permissiveDialect_rendersFlatCountDistinct() { + String sql = new DialectSqlRenderer(new AnsiDialect()).render(flatDistinctCount()).sql(); + assertEquals("select \"dim\".\"name\" as \"c0\", count(distinct \"fact\".\"sales\") as \"m0\"" + + FROM + " group by \"dim\".\"name\"", sql); + } + + @Test + void restrictiveDialect_rewritesToDistinctSubquery() { + String sql = new DialectSqlRenderer(noCountDistinct()).render(flatDistinctCount()).sql(); + assertEquals( + "select \"d0\" as \"c0\", count(\"m0\") as \"c1\" from (" + + "select distinct \"dim\".\"name\" as \"d0\", \"fact\".\"sales\" as \"m0\"" + + FROM + ") as \"dummyname\" group by \"d0\"", + sql); + } + + @Test + void restrictiveNoInnerDistinct_rewritesWithInnerGroupBy() { + String sql = new DialectSqlRenderer(noCountDistinctNoInnerDistinct()).render(flatDistinctCount()).sql(); + assertEquals( + "select \"d0\" as \"c0\", count(\"m0\") as \"c1\" from (" + + "select \"dim\".\"name\" as \"d0\", \"fact\".\"sales\" as \"m0\"" + + FROM + " group by \"dim\".\"name\", \"fact\".\"sales\") as \"dummyname\" group by \"d0\"", + sql); + } +} diff --git a/statement/impl/src/test/java/org/eclipse/daanse/sql/statement/render/InTupleRenderingTest.java b/statement/impl/src/test/java/org/eclipse/daanse/sql/statement/render/InTupleRenderingTest.java new file mode 100644 index 0000000..b64b1bd --- /dev/null +++ b/statement/impl/src/test/java/org/eclipse/daanse/sql/statement/render/InTupleRenderingTest.java @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.render; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import org.eclipse.daanse.jdbc.db.dialect.api.Dialect; +import org.eclipse.daanse.jdbc.db.api.type.BestFitColumnType; +import org.eclipse.daanse.jdbc.db.api.type.Datatype; +import org.eclipse.daanse.jdbc.db.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.jdbc.db.dialect.db.mysql.MySqlDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.Predicates; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.expression.Predicate; +import org.eclipse.daanse.sql.statement.api.model.SelectStatement; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.junit.jupiter.api.Test; + +/** + * Pins the two dialect shapes of {@link Predicate.InTuple} to exact SQL strings. + * + * The tuple is built child-first (most specific column first, e.g. {@code (quarter, the_year)}). + * On a dialect with multi-value IN (MySQL) it renders as a row-value IN in tuple order. On a + * dialect without (ANSI/H2) it degrades to OR-of-ANDs whose pairs read parent-first (reverse + * tuple order), with exactly one parenthesis per AND group and no outer parenthesis of its own — + * the enclosing connective (here the single-operand And wrap used by callers) supplies the one + * grouping layer around the OR chain. + */ +class InTupleRenderingTest { + + private final Dialect ansi = new AnsiDialect(); + private final Dialect mysql = new MySqlDialect(); + + /** Child-first tuple over time_by_day: (quarter, the_year) in (('Q1', 1997), ('Q2', 1998)). */ + private static SelectStatement timeQuery() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias t = TableAlias.of("time_by_day"); + q.from(From.table("time_by_day", t)); + q.project(Expressions.column(t, "the_year"), BestFitColumnType.INT); + Predicate tupleIn = Predicates.inTuple( + List.of(Expressions.column(t, "quarter"), Expressions.column(t, "the_year")), + List.of( + List.of(Expressions.literal("Q1", Datatype.VARCHAR), + Expressions.literal(1997, Datatype.INTEGER)), + List.of(Expressions.literal("Q2", Datatype.VARCHAR), + Expressions.literal(1998, Datatype.INTEGER)))); + // Callers wrap the tuple IN in a single-operand And; the connective's parenthesis is the + // one grouping layer around both dialect shapes. + q.where(Predicates.and(List.of(tupleIn))); + return q.build(); + } + + @Test + void multiValueInDialect_rendersRowValueInInTupleOrder() { + String sql = new DialectSqlRenderer(mysql).render(timeQuery()).sql(); + assertEquals(""" + select `time_by_day`.`the_year` as `c0` from `time_by_day` as `time_by_day` \ + where ((`time_by_day`.`quarter`, `time_by_day`.`the_year`) \ + in (('Q1', 1997), ('Q2', 1998)))\ + """, sql); + } + + @Test + void degradingDialect_rendersParentFirstOrOfAnds() { + String sql = new DialectSqlRenderer(ansi).render(timeQuery()).sql(); + assertEquals(""" + select "time_by_day"."the_year" as "c0" from "time_by_day" as "time_by_day" \ + where (("time_by_day"."the_year" = 1997 and "time_by_day"."quarter" = 'Q1') \ + or ("time_by_day"."the_year" = 1998 and "time_by_day"."quarter" = 'Q2'))\ + """, sql); + } + + @Test + void degradingDialect_parentFirstAcrossStringColumns() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias p = TableAlias.of("product_class"); + q.from(From.table("product_class", p)); + q.project(Expressions.column(p, "product_family"), BestFitColumnType.STRING); + Predicate tupleIn = Predicates.inTuple( + List.of(Expressions.column(p, "product_department"), Expressions.column(p, "product_family")), + List.of( + List.of(Expressions.literal("Alcoholic Beverages", Datatype.VARCHAR), + Expressions.literal("Drink", Datatype.VARCHAR)), + List.of(Expressions.literal("Baking Goods", Datatype.VARCHAR), + Expressions.literal("Food", Datatype.VARCHAR)))); + q.where(Predicates.and(List.of(tupleIn))); + String sql = new DialectSqlRenderer(ansi).render(q.build()).sql(); + assertEquals(""" + select "product_class"."product_family" as "c0" \ + from "product_class" as "product_class" \ + where (("product_class"."product_family" = 'Drink' \ + and "product_class"."product_department" = 'Alcoholic Beverages') \ + or ("product_class"."product_family" = 'Food' \ + and "product_class"."product_department" = 'Baking Goods'))\ + """, sql); + } + + @Test + void degradedChainNextToSiblingConjunct_getsGuardParenthesis() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias t = TableAlias.of("t"); + q.from(From.table("t", t)); + q.project(Expressions.column(t, "a"), BestFitColumnType.INT); + q.where(Predicates.eq(Expressions.column(t, "x"), Expressions.literal(1, Datatype.INTEGER))); + q.where(Predicates.inTuple( + List.of(Expressions.column(t, "b"), Expressions.column(t, "a")), + List.of( + List.of(Expressions.literal("p", Datatype.VARCHAR), + Expressions.literal(2, Datatype.INTEGER)), + List.of(Expressions.literal("q", Datatype.VARCHAR), + Expressions.literal(3, Datatype.INTEGER))))); + String sql = new DialectSqlRenderer(ansi).render(q.build()).sql(); + // The bare (unwrapped) chain sits next to an and-joined sibling: the join site must add + // the grouping parenthesis, or the OR would capture the sibling. + assertEquals(""" + select "t"."a" as "c0" from "t" as "t" \ + where "t"."x" = 1 \ + and (("t"."a" = 2 and "t"."b" = 'p') or ("t"."a" = 3 and "t"."b" = 'q'))\ + """, sql); + } + + @Test + void degradedChainAsSoleConjunct_hasNoOuterParenthesis() { + SelectStatementBuilder q = SelectStatementBuilder.create(); + TableAlias t = TableAlias.of("t"); + q.from(From.table("t", t)); + q.project(Expressions.column(t, "a"), BestFitColumnType.INT); + q.where(Predicates.inTuple( + List.of(Expressions.column(t, "b"), Expressions.column(t, "a")), + List.of( + List.of(Expressions.literal("p", Datatype.VARCHAR), + Expressions.literal(2, Datatype.INTEGER)), + List.of(Expressions.literal("q", Datatype.VARCHAR), + Expressions.literal(3, Datatype.INTEGER))))); + String sql = new DialectSqlRenderer(ansi).render(q.build()).sql(); + assertEquals(""" + select "t"."a" as "c0" from "t" as "t" \ + where ("t"."a" = 2 and "t"."b" = 'p') or ("t"."a" = 3 and "t"."b" = 'q')\ + """, sql); + } +} diff --git a/statement/pom.xml b/statement/pom.xml index c6d656a..b00dee1 100644 --- a/statement/pom.xml +++ b/statement/pom.xml @@ -30,5 +30,6 @@ api impl + demo