From 9fc4f1083d98d47a5c3c4948a75839b3361c2639 Mon Sep 17 00:00:00 2001 From: Stefan Bischof Date: Sat, 4 Jul 2026 13:36:35 +0200 Subject: [PATCH 1/2] bump version to 0.0.7 and add statement module Signed-off-by: Stefan Bischof --- pom.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 9f81523..1fb2acc 100644 --- a/pom.xml +++ b/pom.xml @@ -19,7 +19,7 @@ org.eclipse.daanse org.eclipse.daanse.pom.parent - 0.0.6 + 0.0.7 org.eclipse.daanse.sql ${revision} @@ -81,5 +81,6 @@ guard deparser + statement From 604e414bc7f6d5c35edfe532634e38f9c1468237 Mon Sep 17 00:00:00 2001 From: Stefan Bischof Date: Sat, 4 Jul 2026 13:48:52 +0200 Subject: [PATCH 2/2] statement: dialect-neutral SQL statement model, renderer and executor The statement module lets engines build SQL as a typed statement tree instead of concatenating text. The api bundle holds the immutable model (select/insert/update/delete/with, expressions, predicates, joins, hints) plus builder, render and execution interfaces; the impl bundle renders a statement through a Dialect into SQL text and executes it over a DataSource, mapping results via RowMapper without exposing java.sql. Signed-off-by: Stefan Bischof --- statement/api/pom.xml | 37 + .../statement/api/DeleteStatementBuilder.java | 71 ++ .../daanse/sql/statement/api/Expressions.java | 384 ++++++++ .../daanse/sql/statement/api/From.java | 154 +++ .../statement/api/InsertStatementBuilder.java | 95 ++ .../daanse/sql/statement/api/Predicates.java | 152 +++ .../statement/api/SelectStatementBuilder.java | 276 ++++++ .../statement/api/UpdateStatementBuilder.java | 80 ++ .../statement/api/WithStatementBuilder.java | 91 ++ .../api/exec/StatementExecutionException.java | 27 + .../statement/api/exec/StatementExecutor.java | 60 ++ .../sql/statement/api/exec/package-info.java | 17 + .../api/expression/ArithmeticOperator.java | 31 + .../api/expression/ComparisonOperator.java | 31 + .../statement/api/expression/Predicate.java | 131 +++ .../api/expression/SqlExpression.java | 293 ++++++ .../api/expression/package-info.java | 17 + .../sql/statement/api/model/ColumnAlias.java | 26 + .../api/model/CommonTableExpression.java | 33 + .../statement/api/model/DeleteStatement.java | 40 + .../sql/statement/api/model/FromClause.java | 184 ++++ .../sql/statement/api/model/GroupBy.java | 62 ++ .../statement/api/model/InsertStatement.java | 46 + .../sql/statement/api/model/JoinKind.java | 37 + .../sql/statement/api/model/NullOrder.java | 22 + .../sql/statement/api/model/OrderKey.java | 34 + .../sql/statement/api/model/Projection.java | 45 + .../statement/api/model/ProjectionRef.java | 35 + .../sql/statement/api/model/RowLimit.java | 35 + .../statement/api/model/SelectStatement.java | 94 ++ .../sql/statement/api/model/SetOperation.java | 62 ++ .../statement/api/model/SortDirection.java | 19 + .../sql/statement/api/model/SortSpec.java | 90 ++ .../sql/statement/api/model/Statement.java | 35 + .../sql/statement/api/model/TableAlias.java | 27 + .../statement/api/model/UpdateStatement.java | 51 + .../statement/api/model/WithStatement.java | 40 + .../sql/statement/api/model/package-info.java | 17 + .../sql/statement/api/package-info.java | 17 + .../statement/api/render/BoundParameter.java | 29 + .../statement/api/render/RenderOptions.java | 76 ++ .../sql/statement/api/render/RenderedSql.java | 37 + .../sql/statement/api/render/SqlRenderer.java | 32 + .../statement/api/render/package-info.java | 17 + .../daanse/sql/statement/api/result/Row.java | 99 ++ .../sql/statement/api/result/RowMapper.java | 28 + .../statement/api/result/package-info.java | 17 + statement/impl/pom.xml | 41 + .../statement/compare/SqlTextNormalizer.java | 30 + .../compare/StatementEquivalence.java | 365 +++++++ .../sql/statement/compare/package-info.java | 21 + .../sql/statement/exec/ColumnAccessors.java | 58 ++ .../exec/DataSourceStatementExecutor.java | 98 ++ .../daanse/sql/statement/exec/JdbcRow.java | 67 ++ .../statement/exec/JdbcStatementExecutor.java | 100 ++ .../sql/statement/exec/package-info.java | 17 + .../statement/render/DialectSqlRenderer.java | 924 ++++++++++++++++++ .../sql/statement/render/package-info.java | 17 + statement/pom.xml | 34 + 59 files changed, 5105 insertions(+) create mode 100644 statement/api/pom.xml create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/DeleteStatementBuilder.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/Expressions.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/From.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/InsertStatementBuilder.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/Predicates.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/SelectStatementBuilder.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/UpdateStatementBuilder.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/WithStatementBuilder.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/exec/StatementExecutionException.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/exec/StatementExecutor.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/exec/package-info.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/ArithmeticOperator.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/ComparisonOperator.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/Predicate.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/SqlExpression.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/package-info.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/ColumnAlias.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/CommonTableExpression.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/DeleteStatement.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/FromClause.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/GroupBy.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/InsertStatement.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/JoinKind.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/NullOrder.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/OrderKey.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/Projection.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/ProjectionRef.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/RowLimit.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/SelectStatement.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/SetOperation.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/SortDirection.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/SortSpec.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/Statement.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/TableAlias.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/UpdateStatement.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/WithStatement.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/package-info.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/package-info.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/BoundParameter.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/RenderOptions.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/RenderedSql.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/SqlRenderer.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/package-info.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/result/Row.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/result/RowMapper.java create mode 100644 statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/result/package-info.java create mode 100644 statement/impl/pom.xml create mode 100644 statement/impl/src/main/java/org/eclipse/daanse/sql/statement/compare/SqlTextNormalizer.java create mode 100644 statement/impl/src/main/java/org/eclipse/daanse/sql/statement/compare/StatementEquivalence.java create mode 100644 statement/impl/src/main/java/org/eclipse/daanse/sql/statement/compare/package-info.java create mode 100644 statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/ColumnAccessors.java create mode 100644 statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/DataSourceStatementExecutor.java create mode 100644 statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/JdbcRow.java create mode 100644 statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/JdbcStatementExecutor.java create mode 100644 statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/package-info.java create mode 100644 statement/impl/src/main/java/org/eclipse/daanse/sql/statement/render/DialectSqlRenderer.java create mode 100644 statement/impl/src/main/java/org/eclipse/daanse/sql/statement/render/package-info.java create mode 100644 statement/pom.xml diff --git a/statement/api/pom.xml b/statement/api/pom.xml new file mode 100644 index 0000000..c54c330 --- /dev/null +++ b/statement/api/pom.xml @@ -0,0 +1,37 @@ + + + + 4.0.0 + + org.eclipse.daanse + org.eclipse.daanse.sql.statement + ${revision} + + org.eclipse.daanse.sql.statement.api + Daanse SQL Query Builder API + Immutable query model, fluent assembler and renderer contract for + the + dialect-agnostic SQL query builder. Depends on the dialect API only for the + shared + value types (Datatype, BestFitColumnType); contains no dialect behaviour. + + + + org.eclipse.daanse + org.eclipse.daanse.jdbc.db.dialect.api + 0.0.1-SNAPSHOT + + + diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/DeleteStatementBuilder.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/DeleteStatementBuilder.java new file mode 100644 index 0000000..0ee40b0 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/DeleteStatementBuilder.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.api; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.eclipse.daanse.jdbc.db.api.schema.TableReference; +import org.eclipse.daanse.sql.statement.api.expression.Predicate; +import org.eclipse.daanse.sql.statement.api.model.DeleteStatement; + +/** Mutable, dialect-free builder for a {@link DeleteStatement}. */ +public final class DeleteStatementBuilder { + + private TableReference table; + private final List filters = new ArrayList<>(); + private String footerComment; + + private DeleteStatementBuilder() { + } + + public static DeleteStatementBuilder create() { + return new DeleteStatementBuilder(); + } + + public DeleteStatementBuilder from(String table) { + this.table = new TableReference(table); + return this; + } + + public DeleteStatementBuilder from(String schema, String table) { + this.table = From.tableRef(schema, table); + return this; + } + + public DeleteStatementBuilder from(TableReference table) { + this.table = table; + return this; + } + + public DeleteStatementBuilder where(Predicate predicate) { + filters.add(predicate); + return this; + } + + /** + * Sets an optional trailing explanatory comment, appended at the very end + * (emitted only when comments are on). + */ + public DeleteStatementBuilder footerComment(String comment) { + this.footerComment = comment; + return this; + } + + public DeleteStatement build() { + return new DeleteStatement(Objects.requireNonNull(table, "table"), List.copyOf(filters), + java.util.Optional.ofNullable(footerComment)); + } +} 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 new file mode 100644 index 0000000..be72e03 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/Expressions.java @@ -0,0 +1,384 @@ +/* + * 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.api; + +import java.util.List; +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.sql.statement.api.expression.ArithmeticOperator; +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.SelectStatement; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; + +/** + * Factory methods for {@link SqlExpression}s (house style: static helpers over + * values). + */ +public final class Expressions { + + private Expressions() { + } + + /** An unqualified column reference. */ + public static SqlExpression column(String name) { + return new SqlExpression.Column(Optional.empty(), name); + } + + /** A column reference qualified by a table alias. */ + public static SqlExpression column(TableAlias table, String name) { + return new SqlExpression.Column(Optional.of(table.name()), name); + } + + /** + * A column from a jdbc.db + * {@link org.eclipse.daanse.jdbc.db.api.schema.ColumnReference}, qualified by + * the given query alias. Only the column's {@code name()} is used; the + * reference's own table is metadata and is not the in-query qualifier. + */ + public static SqlExpression column(TableAlias table, org.eclipse.daanse.jdbc.db.api.schema.ColumnReference column) { + return new SqlExpression.Column(Optional.of(table.name()), column.name()); + } + + /** + * An unqualified column from a jdbc.db + * {@link org.eclipse.daanse.jdbc.db.api.schema.ColumnReference} (uses + * {@code name()} only). + */ + public static SqlExpression column(org.eclipse.daanse.jdbc.db.api.schema.ColumnReference column) { + return new SqlExpression.Column(Optional.empty(), column.name()); + } + + /** A typed literal value. */ + public static SqlExpression literal(Object value, Datatype datatype) { + return new SqlExpression.Literal(value, datatype); + } + + /** A verbatim SQL fragment. */ + public static SqlExpression raw(String sql) { + return new SqlExpression.Raw(sql); + } + + /** + * A per-dialect SQL fragment chosen at render time — the multi-variant sibling + * of {@link #raw(String)} for computed columns / measure expressions whose SQL + * differs per engine. The renderer resolves the {@code dialect-name -> SQL} map + * (live dialect else {@code "generic"}). + */ + public static SqlExpression rawVariant(java.util.Map byDialectName) { + return new SqlExpression.RawVariant(byDialectName); + } + + /** + * A bind parameter carrying an immediate value (rendered as a placeholder, + * bound by the executor). + */ + public static SqlExpression param(Object value, Datatype datatype) { + return new SqlExpression.Param(value, true, datatype); + } + + /** + * A positional parameter marker whose value is supplied at execute time (e.g. + * per batch row). + */ + public static SqlExpression paramMarker(Datatype datatype) { + return new SqlExpression.Param(null, false, datatype); + } + + /** A function call, e.g. {@code function("UPPER", column("name"))}. */ + public static SqlExpression function(String name, SqlExpression... arguments) { + return new SqlExpression.Function(name, List.of(arguments)); + } + + /** + * The whole-row projection {@code *} (never given a column alias by the + * renderer). + */ + public static SqlExpression star() { + return new SqlExpression.Star(Optional.empty()); + } + + /** + * The qualified whole-row projection {@code t.*} (never given a column alias by + * the renderer). + */ + public static SqlExpression star(TableAlias table) { + return new SqlExpression.Star(Optional.of(table.name())); + } + + /** + * A scalar subquery {@code (select ...)} usable as a value — in the + * {@code SELECT} list or as one side of a comparison. Rendered compact; nested + * bind parameters keep placeholder order. + */ + public static SqlExpression scalarSubquery(SelectStatement query) { + return new SqlExpression.ScalarSubquery(query); + } + + /** + * A 1-based ordinal position reference, e.g. {@code ordinal(1)} for + * {@code ORDER BY 1}. + */ + public static SqlExpression ordinal(int position) { + return new SqlExpression.Ordinal(position); + } + + /** The {@code COUNT(*)} aggregate. */ + public static SqlExpression countStar() { + return new SqlExpression.Function("COUNT", List.of(new SqlExpression.Star(Optional.empty()))); + } + + /** + * A non-distinct aggregate call, e.g. + * {@code aggregate("SUM", column("amount"))}. + */ + public static SqlExpression aggregate(String name, SqlExpression... arguments) { + requireArgs(arguments, "aggregate"); + return new SqlExpression.Aggregate(name, false, List.of(arguments)); + } + + /** + * A {@code DISTINCT} aggregate call, e.g. {@code SUM(DISTINCT amount)} or + * {@code COUNT(DISTINCT a, b)} (the multi-argument form is compound + * count-distinct, which not every dialect supports — gate at the call site on + * {@code allowsCompoundCountDistinct}). + */ + public static SqlExpression aggregateDistinct(String name, SqlExpression... arguments) { + requireArgs(arguments, "aggregateDistinct"); + return new SqlExpression.Aggregate(name, true, List.of(arguments)); + } + + /** {@code COUNT(DISTINCT arg0, arg1, ...)}. */ + public static SqlExpression countDistinct(SqlExpression... arguments) { + requireArgs(arguments, "countDistinct"); + return new SqlExpression.Aggregate("COUNT", true, List.of(arguments)); + } + + private static void requireArgs(SqlExpression[] arguments, String method) { + if (arguments == null || arguments.length == 0) { + throw new IllegalArgumentException(method + " requires at least one argument"); + } + } + + /** {@code UPPER(expression)}. */ + public static SqlExpression upper(SqlExpression expression) { + return new SqlExpression.Function("UPPER", List.of(expression)); + } + + /** {@code LOWER(expression)}. */ + public static SqlExpression lower(SqlExpression expression) { + return new SqlExpression.Function("LOWER", List.of(expression)); + } + + /** {@code COALESCE(arg0, arg1, ...)}. */ + public static SqlExpression coalesce(SqlExpression... arguments) { + return new SqlExpression.Function("COALESCE", List.of(arguments)); + } + + /** A binary arithmetic expression {@code left right}. */ + public static SqlExpression arithmetic(SqlExpression left, ArithmeticOperator operator, SqlExpression right) { + return new SqlExpression.Binary(left, operator, right); + } + + /** + * A binary arithmetic expression that renders WITHOUT enclosing parentheses — + * for infix fragments already inside an enclosing context (e.g. the + * {@code a / b} inside {@code sum(a / b)}), where the standalone + * {@link #arithmetic} form's outer parens would be incorrect. + */ + public static SqlExpression infix(SqlExpression left, ArithmeticOperator operator, SqlExpression right) { + return new SqlExpression.Binary(left, operator, right, false); + } + + public static SqlExpression add(SqlExpression left, SqlExpression right) { + return arithmetic(left, ArithmeticOperator.ADD, right); + } + + public static SqlExpression subtract(SqlExpression left, SqlExpression right) { + return arithmetic(left, ArithmeticOperator.SUBTRACT, right); + } + + public static SqlExpression multiply(SqlExpression left, SqlExpression right) { + return arithmetic(left, ArithmeticOperator.MULTIPLY, right); + } + + public static SqlExpression divide(SqlExpression left, SqlExpression right) { + return arithmetic(left, ArithmeticOperator.DIVIDE, right); + } + + public static SqlExpression modulo(SqlExpression left, SqlExpression right) { + return arithmetic(left, ArithmeticOperator.MODULO, right); + } + + // ---- portable well-known functions (KnownCall: the dialect spells them at + // render time) ---- + + /** + * Substring from a 1-based start position to the end: + * {@code SUBSTRING(e, start)}. + */ + public static SqlExpression substring(SqlExpression expression, SqlExpression start) { + return new SqlExpression.KnownCall(KnownFunction.SUBSTRING, List.of(expression, start)); + } + + /** + * Substring of {@code length} characters from a 1-based start position: + * {@code SUBSTRING(e, start, length)}. + */ + public static SqlExpression substring(SqlExpression expression, SqlExpression start, SqlExpression length) { + return new SqlExpression.KnownCall(KnownFunction.SUBSTRING, List.of(expression, start, length)); + } + + /** Character length of a string. */ + public static SqlExpression length(SqlExpression expression) { + return new SqlExpression.KnownCall(KnownFunction.LENGTH, List.of(expression)); + } + + /** String concatenation of two or more expressions. */ + public static SqlExpression concat(SqlExpression... arguments) { + if (arguments == null || arguments.length < 2) { + throw new IllegalArgumentException("concat requires at least two arguments"); + } + return new SqlExpression.KnownCall(KnownFunction.CONCAT, List.of(arguments)); + } + + /** + * 1-based position of {@code needle} inside {@code haystack} — needle + * first, matching the OData {@code indexof} builtin; dialects whose native + * function takes the haystack first (Oracle {@code INSTR}) swap the arguments + * at render time. + */ + public static SqlExpression indexOf(SqlExpression needle, SqlExpression haystack) { + return new SqlExpression.KnownCall(KnownFunction.INDEX_OF, List.of(needle, haystack)); + } + + /** Strip leading and trailing whitespace. */ + public static SqlExpression trim(SqlExpression expression) { + return new SqlExpression.KnownCall(KnownFunction.TRIM, List.of(expression)); + } + + /** Strip leading whitespace. */ + public static SqlExpression ltrim(SqlExpression expression) { + return new SqlExpression.KnownCall(KnownFunction.LTRIM, List.of(expression)); + } + + /** Strip trailing whitespace. */ + public static SqlExpression rtrim(SqlExpression expression) { + return new SqlExpression.KnownCall(KnownFunction.RTRIM, List.of(expression)); + } + + /** Year part of a date/time value. */ + public static SqlExpression year(SqlExpression expression) { + return new SqlExpression.KnownCall(KnownFunction.YEAR, List.of(expression)); + } + + /** Month part of a date/time value. */ + public static SqlExpression month(SqlExpression expression) { + return new SqlExpression.KnownCall(KnownFunction.MONTH, List.of(expression)); + } + + /** Day-of-month part of a date/time value. */ + public static SqlExpression day(SqlExpression expression) { + return new SqlExpression.KnownCall(KnownFunction.DAY, List.of(expression)); + } + + /** Hour part of a time value. */ + public static SqlExpression hour(SqlExpression expression) { + return new SqlExpression.KnownCall(KnownFunction.HOUR, List.of(expression)); + } + + /** Minute part of a time value. */ + public static SqlExpression minute(SqlExpression expression) { + return new SqlExpression.KnownCall(KnownFunction.MINUTE, List.of(expression)); + } + + /** Second part of a time value. */ + public static SqlExpression second(SqlExpression expression) { + return new SqlExpression.KnownCall(KnownFunction.SECOND, List.of(expression)); + } + + /** Date part of a date/time value. */ + public static SqlExpression date(SqlExpression expression) { + return new SqlExpression.KnownCall(KnownFunction.DATE, List.of(expression)); + } + + /** Time part of a date/time value. */ + public static SqlExpression time(SqlExpression expression) { + return new SqlExpression.KnownCall(KnownFunction.TIME, List.of(expression)); + } + + /** Numeric rounding to an integer: {@code ROUND(e)}. */ + public static SqlExpression round(SqlExpression expression) { + return new SqlExpression.KnownCall(KnownFunction.ROUND, List.of(expression)); + } + + /** + * Numeric rounding to {@code digits} decimal places: {@code ROUND(e, digits)}. + */ + public static SqlExpression round(SqlExpression expression, SqlExpression digits) { + return new SqlExpression.KnownCall(KnownFunction.ROUND, List.of(expression, digits)); + } + + /** Largest integer not greater than the value. */ + public static SqlExpression floor(SqlExpression expression) { + return new SqlExpression.KnownCall(KnownFunction.FLOOR, List.of(expression)); + } + + /** Smallest integer not less than the value. */ + public static SqlExpression ceiling(SqlExpression expression) { + return new SqlExpression.KnownCall(KnownFunction.CEILING, List.of(expression)); + } + + /** Absolute value. */ + public static SqlExpression abs(SqlExpression expression) { + return new SqlExpression.KnownCall(KnownFunction.ABS, List.of(expression)); + } + + /** Remainder of an integer division: {@code MOD(dividend, divisor)}. */ + public static SqlExpression mod(SqlExpression dividend, SqlExpression divisor) { + return new SqlExpression.KnownCall(KnownFunction.MOD, List.of(dividend, divisor)); + } + + /** Exponentiation: {@code POWER(base, exponent)}. */ + public static SqlExpression power(SqlExpression base, SqlExpression exponent) { + return new SqlExpression.KnownCall(KnownFunction.POWER, List.of(base, exponent)); + } + + /** Square root. */ + public static SqlExpression sqrt(SqlExpression expression) { + return new SqlExpression.KnownCall(KnownFunction.SQRT, List.of(expression)); + } + + /** Current date and time. */ + public static SqlExpression now() { + return new SqlExpression.KnownCall(KnownFunction.NOW, List.of()); + } + + /** A single {@code WHEN condition THEN result} branch for {@link #caseExpr}. */ + public static SqlExpression.Case.WhenClause when(Predicate condition, SqlExpression result) { + return new SqlExpression.Case.WhenClause(condition, result); + } + + /** A {@code CASE WHEN ... THEN ... END} with no ELSE. */ + public static SqlExpression caseExpr(List whens) { + return new SqlExpression.Case(List.copyOf(whens), Optional.empty()); + } + + /** A {@code CASE WHEN ... THEN ... ELSE elseResult END}. */ + public static SqlExpression caseExpr(List whens, SqlExpression elseResult) { + return new SqlExpression.Case(List.copyOf(whens), Optional.ofNullable(elseResult)); + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/From.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/From.java new file mode 100644 index 0000000..71d2530 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/From.java @@ -0,0 +1,154 @@ +/* + * 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.api; + +import java.util.Map; +import java.util.Optional; + +import org.eclipse.daanse.jdbc.db.api.schema.SchemaReference; +import org.eclipse.daanse.jdbc.db.api.schema.TableReference; +import org.eclipse.daanse.sql.statement.api.expression.Predicate; +import org.eclipse.daanse.sql.statement.api.model.FromClause; +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.TableAlias; + +/** Factory methods for {@link FromClause} nodes. */ +public final class From { + + private From() { + } + + /** Builds a (optionally schema-qualified) {@link TableReference}. */ + public static TableReference tableRef(String schema, String table) { + return schema == null ? new TableReference(table) + : new TableReference(Optional.of(new SchemaReference(schema)), table); + } + + public static FromClause.FromTable table(String table, TableAlias alias) { + return table(new TableReference(table), alias); + } + + public static FromClause.FromTable table(String schema, String table, TableAlias alias) { + return table(tableRef(schema, table), alias); + } + + public static FromClause.FromTable table(TableReference table, TableAlias alias) { + return new FromClause.FromTable(table, alias, Optional.empty(), Map.of()); + } + + public static FromClause.FromTable table(String schema, String table, TableAlias alias, Predicate filter, + Map hints) { + return table(tableRef(schema, table), alias, filter, hints); + } + + public static FromClause.FromTable table(TableReference table, TableAlias alias, Predicate filter, + Map hints) { + return new FromClause.FromTable(table, alias, Optional.ofNullable(filter), Map.copyOf(hints)); + } + + public static FromClause.FromSubquery subquery(SelectStatement query, TableAlias alias) { + return new FromClause.FromSubquery(query, alias); + } + + /** + * A parenthesized set operation (e.g. {@code UNION}) as a derived table (see + * {@link FromClause.FromSet}). + */ + public static FromClause.FromSet set(SetOperation set, TableAlias alias) { + return new FromClause.FromSet(set, alias); + } + + /** + * A derived table from a pre-rendered SQL fragment (view / inline + * {@code VALUES}). + */ + public static FromClause.FromRaw raw(String sql, TableAlias alias) { + return new FromClause.FromRaw(sql, alias); + } + + /** + * A derived table from an inline {@code VALUES} table — the structured data is + * carried as-is and the dialect-specific SQL is generated at render time (see + * {@link FromClause.FromInline}). + */ + public static FromClause.FromInline inline(java.util.List columnNames, java.util.List columnTypes, + java.util.List rows, TableAlias alias) { + return new FromClause.FromInline(java.util.List.copyOf(columnNames), java.util.List.copyOf(columnTypes), + java.util.List.copyOf(rows), alias); + } + + /** + * The alias of the BASE table of a FROM tree — the leftmost aliased leaf, i.e. + * the item the renderer emits first (a join tree's leftmost input; a product's + * first item) — or {@code null} when the tree has no aliased base + * (empty/unsupported shapes). + */ + public static TableAlias baseAlias(FromClause from) { + if (from instanceof FromClause.Aliased a) { + return a.alias(); + } + if (from instanceof FromClause.FromJoin j) { + return baseAlias(j.left()); + } + if (from instanceof FromClause.FromProduct p) { + return baseAlias(p.items().get(0)); + } + return null; + } + + /** + * A copy of {@code from} whose BASE table (the leftmost + * {@link FromClause.FromTable}) carries the given provenance comment (see + * {@link FromClause.FromTable#comment()}). Joined tables keep their provenance + * on the {@link FromClause.FromJoin} comment; this reaches the one item no join + * comment can. No-op (returns {@code from} unchanged) when the base is not a + * plain table or the comment is {@code null}/blank. + */ + public static FromClause commentBase(FromClause from, String comment) { + if (comment == null || comment.isBlank()) { + return from; + } + if (from instanceof FromClause.FromTable t) { + return t.withComment(comment); + } + if (from instanceof FromClause.FromJoin j) { + FromClause left = commentBase(j.left(), comment); + return left == j.left() ? from : new FromClause.FromJoin(left, j.kind(), j.right(), j.on(), j.comment()); + } + if (from instanceof FromClause.FromProduct p) { + FromClause first = commentBase(p.items().get(0), comment); + if (first == p.items().get(0)) { + return from; + } + java.util.List items = new java.util.ArrayList<>(p.items()); + items.set(0, first); + return new FromClause.FromProduct(items); + } + return from; + } + + /** + * A comma product of two-or-more from-clauses with no join predicate of its own + * — the caller adds the join conditions to {@code WHERE} (see + * {@link FromClause.FromProduct}). + */ + public static FromClause.FromProduct product(FromClause first, FromClause second, FromClause... rest) { + java.util.List items = new java.util.ArrayList<>(); + items.add(first); + items.add(second); + java.util.Collections.addAll(items, rest); + return new FromClause.FromProduct(items); + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/InsertStatementBuilder.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/InsertStatementBuilder.java new file mode 100644 index 0000000..77716e4 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/InsertStatementBuilder.java @@ -0,0 +1,95 @@ +/* + * 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.api; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.eclipse.daanse.jdbc.db.api.schema.TableReference; +import org.eclipse.daanse.sql.statement.api.expression.SqlExpression; +import org.eclipse.daanse.sql.statement.api.model.InsertStatement; +import org.eclipse.daanse.sql.statement.api.model.Statement; + +/** + * Mutable, dialect-free builder for an {@link InsertStatement} — either + * {@code INSERT ... + * VALUES} (via {@link #addRow}) or {@code INSERT ... SELECT} (via + * {@link #fromSelect}). + */ +public final class InsertStatementBuilder { + + private TableReference table; + private final List columns = new ArrayList<>(); + private final List> rows = new ArrayList<>(); + private Statement source; + private String footerComment; + + private InsertStatementBuilder() { + } + + public static InsertStatementBuilder create() { + return new InsertStatementBuilder(); + } + + public InsertStatementBuilder into(String table) { + this.table = new TableReference(table); + return this; + } + + public InsertStatementBuilder into(String schema, String table) { + this.table = From.tableRef(schema, table); + return this; + } + + public InsertStatementBuilder into(TableReference table) { + this.table = table; + return this; + } + + public InsertStatementBuilder columns(String... names) { + columns.addAll(List.of(names)); + return this; + } + + /** Adds one VALUES row; its length should match {@link #columns(String...)}. */ + public InsertStatementBuilder addRow(SqlExpression... values) { + rows.add(List.of(values)); + return this; + } + + /** Sources the rows from a sub-query ({@code INSERT ... SELECT}). */ + public InsertStatementBuilder fromSelect(Statement source) { + this.source = source; + return this; + } + + /** + * Sets an optional trailing explanatory comment, appended at the very end + * (emitted only when comments are on). + */ + public InsertStatementBuilder footerComment(String comment) { + this.footerComment = comment; + return this; + } + + public InsertStatement build() { + if (source != null && !rows.isEmpty()) { + throw new IllegalStateException("insert has both VALUES rows and a SELECT source"); + } + return new InsertStatement(Objects.requireNonNull(table, "table"), List.copyOf(columns), List.copyOf(rows), + Optional.ofNullable(source), Optional.ofNullable(footerComment)); + } +} 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 new file mode 100644 index 0000000..1082e3f --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/Predicates.java @@ -0,0 +1,152 @@ +/* + * 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.api; + +import java.util.List; +import java.util.Optional; + +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; +import org.eclipse.daanse.sql.statement.api.model.SelectStatement; + +/** Factory methods for {@link Predicate}s. */ +public final class Predicates { + + private static final Predicate ALWAYS_TRUE = new Predicate.Constant(true); + private static final Predicate ALWAYS_FALSE = new Predicate.Constant(false); + + private Predicates() { + } + + public static Predicate comparison(SqlExpression left, ComparisonOperator operator, SqlExpression right) { + return new Predicate.Comparison(left, operator, right); + } + + public static Predicate eq(SqlExpression left, SqlExpression right) { + return comparison(left, ComparisonOperator.EQ, right); + } + + public static Predicate gt(SqlExpression left, SqlExpression right) { + return comparison(left, ComparisonOperator.GT, right); + } + + public static Predicate in(SqlExpression expression, List values) { + return new Predicate.In(expression, List.copyOf(values)); + } + + public static Predicate in(SqlExpression expression, SqlExpression... values) { + return new Predicate.In(expression, List.of(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()}. + */ + public static Predicate inTuple(List columns, List> rows) { + List> copy = new java.util.ArrayList<>(rows.size()); + for (List row : rows) { + if (row.size() != columns.size()) { + throw new IllegalArgumentException( + "inTuple row arity " + row.size() + " != column count " + columns.size()); + } + copy.add(List.copyOf(row)); + } + return new Predicate.InTuple(List.copyOf(columns), List.copyOf(copy)); + } + + public static Predicate isNull(SqlExpression expression) { + return new Predicate.IsNull(expression, false); + } + + public static Predicate isNotNull(SqlExpression expression) { + return new Predicate.IsNull(expression, true); + } + + public static Predicate like(SqlExpression expression, SqlExpression pattern) { + return new Predicate.Like(expression, pattern, false, Optional.empty()); + } + + public static Predicate like(SqlExpression expression, SqlExpression pattern, char escape) { + return new Predicate.Like(expression, pattern, false, Optional.of(escape)); + } + + public static Predicate notLike(SqlExpression expression, SqlExpression pattern) { + return new Predicate.Like(expression, pattern, true, Optional.empty()); + } + + public static Predicate between(SqlExpression expression, SqlExpression low, SqlExpression high) { + return new Predicate.Between(expression, low, high, false); + } + + public static Predicate notBetween(SqlExpression expression, SqlExpression low, SqlExpression high) { + return new Predicate.Between(expression, low, high, true); + } + + public static Predicate not(Predicate operand) { + return new Predicate.Not(operand); + } + + public static Predicate and(List operands) { + return new Predicate.And(List.copyOf(operands)); + } + + public static Predicate and(Predicate... operands) { + return new Predicate.And(List.of(operands)); + } + + public static Predicate or(List operands) { + return new Predicate.Or(List.copyOf(operands)); + } + + public static Predicate or(Predicate... operands) { + return new Predicate.Or(List.of(operands)); + } + + /** + * {@code EXISTS (query)}. The subquery may be correlated (reference outer + * aliases). + */ + public static Predicate exists(SelectStatement query) { + return new Predicate.Exists(query, false); + } + + /** + * {@code NOT EXISTS (query)}. The subquery may be correlated (reference outer + * aliases). + */ + public static Predicate notExists(SelectStatement query) { + return new Predicate.Exists(query, true); + } + + /** + * The always-true constant predicate, rendered {@code 1 = 1} (no parentheses). + */ + public static Predicate alwaysTrue() { + return ALWAYS_TRUE; + } + + /** + * The always-false constant predicate, rendered {@code 1 = 0} (no parentheses). + */ + public static Predicate alwaysFalse() { + return ALWAYS_FALSE; + } + + public static Predicate raw(String sql) { + return new Predicate.Raw(sql); + } +} 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 new file mode 100644 index 0000000..d2dfee2 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/SelectStatementBuilder.java @@ -0,0 +1,276 @@ +/* + * 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.api; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +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.sql.statement.api.expression.Predicate; +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.FromClause; +import org.eclipse.daanse.sql.statement.api.model.GroupBy; +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.RowLimit; +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; + +/** + * Mutable, dialect-free builder for a {@link SelectStatement}. + *

+ * Consumers accumulate clauses with intent methods ({@code project}, + * {@code where}, {@code groupOn}, {@code orderOn}, {@code from}/{@code join}, + * {@code rowLimit}) and call {@link #build()} to obtain the immutable model. + * There is deliberately no {@code Dialect} here: alias generation and + * alias-vs-expression decisions are made later by the renderer, which is the + * only dialect-aware component. + *

+ * {@link #project(SqlExpression, BestFitColumnType)} returns a + * {@link ProjectionRef} handle; pass it to {@link #groupOn(ProjectionRef)} / + * {@link #orderOn(ProjectionRef, SortSpec)} instead of capturing and threading + * an alias string. + *

+ * Not thread-safe; use one instance per query being built. + */ +public final class SelectStatementBuilder { + + private boolean distinct; + private final List projections = new ArrayList<>(); + private FromClause from; + private final List filters = new ArrayList<>(); + private final List groupKeys = new ArrayList<>(); + private final List groupingSets = new ArrayList<>(); + private final List groupingFunctions = new ArrayList<>(); + private final List having = new ArrayList<>(); + private final List orderKeys = new ArrayList<>(); + private RowLimit rowLimit; + private String headerComment; + private String footerComment; + private final List statementHints = new ArrayList<>(); + /** + * Identity-keyed so structurally-equal predicates (and the pushed-down join + * ONs) don't collide. + */ + private final java.util.Map filterComments = new java.util.IdentityHashMap<>(); + + private SelectStatementBuilder() { + } + + /** + * Sets an optional statement-level explanatory comment (emitted only when + * comments are on). + */ + public SelectStatementBuilder header(String comment) { + this.headerComment = comment; + return this; + } + + /** + * Sets an optional trailing explanatory comment, appended at the very end + * (emitted only when comments are on). + */ + public SelectStatementBuilder footerComment(String comment) { + this.footerComment = comment; + return this; + } + + /** + * Adds a statement-level optimizer hint, spelled (or silently ignored) by the + * dialect at render time. Unlike comments, hints are semantic for the DBMS and + * are always emitted. + */ + public SelectStatementBuilder statementHint(StatementHint hint) { + this.statementHints.add(Objects.requireNonNull(hint, "hint")); + return this; + } + + /** Creates an empty assembler. */ + public static SelectStatementBuilder create() { + return new SelectStatementBuilder(); + } + + public SelectStatementBuilder distinct(boolean value) { + this.distinct = value; + return this; + } + + /** Adds an expression to the {@code SELECT} list and returns a handle to it. */ + public ProjectionRef project(SqlExpression expression, BestFitColumnType columnType) { + return project(expression, columnType, null); + } + + /** Adds an expression to the {@code SELECT} list with an explicit alias. */ + public ProjectionRef project(SqlExpression expression, BestFitColumnType columnType, ColumnAlias alias) { + return project(expression, columnType, alias, null); + } + + /** + * Adds an expression to the {@code SELECT} list with an explicit alias and an + * explanatory comment. + */ + public ProjectionRef project(SqlExpression expression, BestFitColumnType columnType, ColumnAlias alias, + String comment) { + int ordinal = projections.size(); + Optional a = Optional.ofNullable(alias); + projections.add(new Projection(expression, columnType, a, Optional.ofNullable(comment))); + return new ProjectionRef(ordinal, a); + } + + /** Convenience: project an expression and group by it. */ + public ProjectionRef projectAndGroup(SqlExpression expression, BestFitColumnType columnType) { + ProjectionRef ref = project(expression, columnType); + groupOn(ref); + return ref; + } + + public SelectStatementBuilder groupOn(ProjectionRef ref) { + groupKeys.add(new GroupBy.GroupKey.Ref(ref)); + return this; + } + + public SelectStatementBuilder groupOn(SqlExpression expression) { + groupKeys.add(new GroupBy.GroupKey.Expr(expression)); + return this; + } + + public SelectStatementBuilder addGroupingSet(List keys) { + groupingSets.add(new GroupBy.GroupingSet(List.copyOf(keys))); + return this; + } + + public SelectStatementBuilder addGroupingFunction(SqlExpression argument) { + groupingFunctions.add(new GroupBy.GroupingFunction(argument)); + return this; + } + + public SelectStatementBuilder where(Predicate predicate) { + filters.add(predicate); + return this; + } + + /** + * Adds a {@code WHERE} predicate carrying an explanatory comment (emitted only + * when comments are on). + */ + public SelectStatementBuilder where(Predicate predicate, String comment) { + filters.add(predicate); + if (comment != null) { + filterComments.put(predicate, comment); + } + return this; + } + + public SelectStatementBuilder having(Predicate predicate) { + having.add(predicate); + return this; + } + + /** + * Adds a {@code HAVING} predicate carrying an explanatory comment (emitted only + * when comments are on). + */ + public SelectStatementBuilder having(Predicate predicate, String comment) { + having.add(predicate); + if (comment != null) { + filterComments.put(predicate, comment); + } + return this; + } + + public SelectStatementBuilder orderOn(SqlExpression expression, SortSpec spec) { + addOrder(new OrderKey(expression, Optional.empty(), spec), spec); + return this; + } + + public SelectStatementBuilder orderOn(ProjectionRef ref, SortSpec spec) { + SqlExpression expression = projections.get(ref.ordinal()).expression(); + addOrder(new OrderKey(expression, Optional.of(ref), spec), spec); + return this; + } + + private void addOrder(OrderKey key, SortSpec spec) { + if (spec.prepend()) { + orderKeys.add(0, key); + } else { + orderKeys.add(key); + } + } + + /** Sets the {@code FROM} clause (table, sub-query, or join tree). */ + public SelectStatementBuilder from(FromClause clause) { + this.from = clause; + return this; + } + + /** Sets the {@code FROM} to a sub-query built by another assembler. */ + public SelectStatementBuilder fromSubquery(SelectStatementBuilder inner, TableAlias alias) { + this.from = From.subquery(inner.build(), alias); + return this; + } + + /** Joins the current {@code FROM} with {@code right} on {@code on}. */ + public SelectStatementBuilder join(JoinKind kind, FromClause right, Predicate on) { + return join(kind, right, on, null); + } + + /** + * Joins the current {@code FROM} with {@code right} on {@code on}, carrying an + * explanatory comment. + */ + public SelectStatementBuilder join(JoinKind kind, FromClause right, Predicate on, String comment) { + Objects.requireNonNull(from, "set a FROM before joining"); + this.from = new FromClause.FromJoin(from, kind, right, on, Optional.ofNullable(comment)); + return this; + } + + public SelectStatementBuilder innerJoin(FromClause right, Predicate on) { + return join(JoinKind.INNER, right, on); + } + + public SelectStatementBuilder leftJoin(FromClause right, Predicate on) { + return join(JoinKind.LEFT, right, on); + } + + public SelectStatementBuilder rowLimit(long maxRows) { + this.rowLimit = RowLimit.of(maxRows); + return this; + } + + public SelectStatementBuilder rowLimit(long maxRows, long offset) { + this.rowLimit = RowLimit.of(maxRows, offset); + return this; + } + + public int projectionCount() { + return projections.size(); + } + + /** 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), + 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/UpdateStatementBuilder.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/UpdateStatementBuilder.java new file mode 100644 index 0000000..d7300ad --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/UpdateStatementBuilder.java @@ -0,0 +1,80 @@ +/* + * 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.api; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.eclipse.daanse.jdbc.db.api.schema.TableReference; +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.UpdateStatement; +import org.eclipse.daanse.sql.statement.api.model.UpdateStatement.Assignment; + +/** Mutable, dialect-free builder for an {@link UpdateStatement}. */ +public final class UpdateStatementBuilder { + + private TableReference table; + private final List assignments = new ArrayList<>(); + private final List filters = new ArrayList<>(); + private String footerComment; + + private UpdateStatementBuilder() { + } + + public static UpdateStatementBuilder create() { + return new UpdateStatementBuilder(); + } + + public UpdateStatementBuilder table(String table) { + this.table = new TableReference(table); + return this; + } + + public UpdateStatementBuilder table(String schema, String table) { + this.table = From.tableRef(schema, table); + return this; + } + + public UpdateStatementBuilder table(TableReference table) { + this.table = table; + return this; + } + + /** Adds a {@code SET column = value} assignment. */ + public UpdateStatementBuilder set(String column, SqlExpression value) { + assignments.add(new Assignment(column, value)); + return this; + } + + public UpdateStatementBuilder where(Predicate predicate) { + filters.add(predicate); + return this; + } + + /** + * Sets an optional trailing explanatory comment, appended at the very end + * (emitted only when comments are on). + */ + public UpdateStatementBuilder footerComment(String comment) { + this.footerComment = comment; + return this; + } + + public UpdateStatement build() { + return new UpdateStatement(Objects.requireNonNull(table, "table"), List.copyOf(assignments), + List.copyOf(filters), java.util.Optional.ofNullable(footerComment)); + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/WithStatementBuilder.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/WithStatementBuilder.java new file mode 100644 index 0000000..11dd903 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/WithStatementBuilder.java @@ -0,0 +1,91 @@ +/* + * 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.api; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.eclipse.daanse.sql.statement.api.model.CommonTableExpression; +import org.eclipse.daanse.sql.statement.api.model.Statement; +import org.eclipse.daanse.sql.statement.api.model.WithStatement; + +/** + * Mutable, dialect-free builder for a {@link WithStatement} + * ({@code WITH ... }). + */ +public final class WithStatementBuilder { + + private final List ctes = new ArrayList<>(); + private boolean recursive; + private Statement body; + private String footerComment; + + private WithStatementBuilder() { + } + + public static WithStatementBuilder create() { + return new WithStatementBuilder(); + } + + /** + * Adds a common-table expression. Reference it from the body via + * {@code From.table(name, alias)}. + */ + public WithStatementBuilder cte(String name, Statement query) { + ctes.add(new CommonTableExpression(name, List.of(), query)); + return this; + } + + /** + * Adds a CTE with an explicit column list (recommended/required for recursive + * CTEs). + */ + public WithStatementBuilder cte(String name, List columns, Statement query) { + ctes.add(new CommonTableExpression(name, List.copyOf(columns), query)); + return this; + } + + /** + * Marks the CTEs as recursive (emitted as {@code WITH RECURSIVE} where the + * dialect supports it). + */ + public WithStatementBuilder recursive(boolean value) { + this.recursive = value; + return this; + } + + /** Sets the main statement that may reference the CTEs. */ + public WithStatementBuilder body(Statement body) { + this.body = body; + return this; + } + + /** + * Sets an optional trailing explanatory comment, appended at the very end + * (emitted only when comments are on). + */ + public WithStatementBuilder footerComment(String comment) { + this.footerComment = comment; + return this; + } + + public WithStatement build() { + if (ctes.isEmpty()) { + throw new IllegalStateException("WITH requires at least one common-table expression"); + } + return new WithStatement(List.copyOf(ctes), recursive, Objects.requireNonNull(body, "body"), + java.util.Optional.ofNullable(footerComment)); + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/exec/StatementExecutionException.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/exec/StatementExecutionException.java new file mode 100644 index 0000000..aa84208 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/exec/StatementExecutionException.java @@ -0,0 +1,27 @@ +/* + * 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.api.exec; + +/** + * Unchecked wrapper for {@link java.sql.SQLException}s raised during statement + * execution. + */ +public class StatementExecutionException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public StatementExecutionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/exec/StatementExecutor.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/exec/StatementExecutor.java new file mode 100644 index 0000000..137f956 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/exec/StatementExecutor.java @@ -0,0 +1,60 @@ +/* + * 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.api.exec; + +import java.util.List; + +import org.eclipse.daanse.sql.statement.api.render.RenderedSql; +import org.eclipse.daanse.sql.statement.api.result.RowMapper; + +/** + * Executes already-rendered SQL and maps result rows. This is the adaptable + * seam: the JDBC implementation is {@link JdbcStatementExecutor}, but other + * backends (a test double, a remote service, a cached source) can implement the + * same contract. + */ +public interface StatementExecutor { + + /** + * Runs a query and maps each row. + * + * @param sql the rendered query (carries the SQL text and column types) + * @param mapper maps each {@link org.eclipse.daanse.sql.statement.api.result.Row} + * to a {@code T} + * @return the mapped rows, in result order + */ + List query(RenderedSql sql, RowMapper mapper); + + /** + * Runs a write statement (INSERT/UPDATE/DELETE/DDL). + * + * @param sql the rendered statement (its bound parameters, if any, are applied) + * @return the affected-row count + */ + int update(RenderedSql sql); + + /** + * Runs a parameterized write statement once per value-row as a JDBC batch. The + * statement should be rendered with parameter markers (see + * {@code Expressions.paramMarker}); each {@code Object[]} supplies the values + * for one row, in placeholder order. + * + * @param sql the rendered statement (its placeholder count must match each + * row's length) + * @param rows the per-row parameter values + * @return the per-row affected-row counts (as from + * {@link java.sql.PreparedStatement#executeBatch()}) + */ + int[] batch(RenderedSql sql, List rows); +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/exec/package-info.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/exec/package-info.java new file mode 100644 index 0000000..aa439a2 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/exec/package-info.java @@ -0,0 +1,17 @@ +/* + * 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 + */ + +@org.osgi.annotation.bundle.Export +@org.osgi.annotation.versioning.Version("0.0.1") +package org.eclipse.daanse.sql.statement.api.exec; diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/ArithmeticOperator.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/ArithmeticOperator.java new file mode 100644 index 0000000..5f70928 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/ArithmeticOperator.java @@ -0,0 +1,31 @@ +/* + * 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.api.expression; + +/** Binary arithmetic operators for {@link SqlExpression.Binary}. */ +public enum ArithmeticOperator { + + ADD("+"), SUBTRACT("-"), MULTIPLY("*"), DIVIDE("/"), MODULO("%"); + + private final String symbol; + + ArithmeticOperator(String symbol) { + this.symbol = symbol; + } + + /** @return the SQL symbol, e.g. {@code "+"} */ + public String symbol() { + return symbol; + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/ComparisonOperator.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/ComparisonOperator.java new file mode 100644 index 0000000..c2e02a4 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/ComparisonOperator.java @@ -0,0 +1,31 @@ +/* + * 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.api.expression; + +/** Binary comparison operators for {@link Predicate.Comparison}. */ +public enum ComparisonOperator { + + EQ("="), NE("<>"), LT("<"), LE("<="), GT(">"), GE(">="); + + private final String symbol; + + ComparisonOperator(String symbol) { + this.symbol = symbol; + } + + /** @return the SQL symbol, e.g. {@code "="} or {@code "<>"} */ + public String symbol() { + return symbol; + } +} 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 new file mode 100644 index 0000000..fb2f622 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/Predicate.java @@ -0,0 +1,131 @@ +/* + * 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.api.expression; + +import java.util.List; +import java.util.Optional; + +import org.eclipse.daanse.sql.statement.api.model.SelectStatement; + +/** + * A boolean condition usable in {@code WHERE}/{@code HAVING}/join-{@code ON} + * positions. + *

+ * Like {@link SqlExpression}, predicates carry structure only; the renderer + * performs all quoting and spelling. {@link Raw} is the verbatim escape hatch. + */ +public sealed interface Predicate { + + /** {@code left right}. */ + record Comparison(SqlExpression left, ComparisonOperator operator, SqlExpression right) implements Predicate { + } + + /** {@code expression IN (values...)}. */ + record In(SqlExpression expression, List values) implements Predicate { + } + + /** + * 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. + */ + record InTuple(List columns, List> rows) implements Predicate { + } + + /** {@code expression IS [NOT] NULL}. */ + record IsNull(SqlExpression expression, boolean negated) implements Predicate { + } + + /** {@code expression [NOT] LIKE pattern [ESCAPE c]}. */ + record Like(SqlExpression expression, SqlExpression pattern, boolean negated, Optional escape) + implements Predicate { + } + + /** {@code expression [NOT] BETWEEN low AND high}. */ + record Between(SqlExpression expression, SqlExpression low, SqlExpression high, boolean negated) + implements Predicate { + } + + /** Negation: {@code NOT (operand)}. */ + record Not(Predicate operand) implements Predicate { + } + + /** + * An n-ary boolean connective over operands — {@link And} or {@link Or}. Lets + * the renderer (and any predicate normalizer) treat both uniformly via one + * {@code instanceof Connective}. + */ + sealed interface Connective extends Predicate permits And, Or { + List operands(); + } + + /** + * Conjunction of operands ({@code AND}). An empty list is treated as + * always-true. + */ + record And(List operands) implements Connective { + } + + /** + * Disjunction of operands ({@code OR}). An empty list is treated as + * always-false. + */ + record Or(List operands) implements Connective { + } + + /** + * An {@code [NOT] EXISTS (subquery)} test. The subquery renders compact (like a + * CTE body or a derived table) and may be correlated — it can reference outer + * table aliases freely. Any bind parameters inside the subquery accumulate with + * the enclosing statement's, in placeholder order. + * + * @param query the subquery whose row existence is tested + * @param negated {@code true} for {@code NOT EXISTS} + */ + record Exists(SelectStatement query, boolean negated) implements Predicate { + } + + /** + * A constant truth value, rendered with the dialect-neutral spelling + * {@code 1 = 1} / {@code 1 = 0} (the same spelling an empty + * {@link And}/{@link Or} renders to), without parentheses. Useful as a neutral + * seed when folding predicates. Obtain via {@code Predicates.alwaysTrue()} / + * {@code Predicates.alwaysFalse()}. + * + * @param value the truth value + */ + record Constant(boolean value) implements Predicate { + } + + /** A pre-rendered predicate fragment, emitted verbatim. */ + record Raw(String sql) implements Predicate { + } + + /** + * A dialect-rendered regular-expression match + * ({@code MATCHES }). The whole SQL fragment — null-guard, + * optional case-fold, and the dialect's regex operator — is produced at render + * time by + * {@code dialect.regexGenerator().generateRegularExpression(renderedSource, pattern)}, + * so the node stays dialect-free (the regex spelling differs per database: + * {@code REGEXP}, {@code RLIKE}, {@code ~}, {@code regexp_like(...)}). + * {@code pattern} is the Java/MDX regex string. When the dialect + * {@code requiresHavingAlias()} the renderer resolves {@code source} to the + * matching SELECT alias. + */ + record Regexp(SqlExpression source, String pattern, boolean negated) 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 new file mode 100644 index 0000000..99970c0 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/SqlExpression.java @@ -0,0 +1,293 @@ +/* + * 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.api.expression; + +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.sql.statement.api.model.SelectStatement; + +/** + * A scalar SQL expression usable in {@code SELECT}, {@code WHERE}, + * {@code GROUP BY} and {@code ORDER BY} positions. + *

+ * The model is intentionally dialect-free: it stores structure only. + * All quoting and spelling happens later, in the renderer. The {@link Raw} + * variant is an escape hatch for SQL fragments that are already final (e.g. + * opaque view SQL) and is rendered verbatim. + */ +public sealed interface SqlExpression { + + /** + * A function or aggregate invocation — a name plus argument expressions. Shared + * by {@link Function} and {@link Aggregate} (which additionally carries a + * {@code distinct} flag); lets a caller match any call with one + * {@code instanceof Call} (e.g. "find every {@code COUNT(...)}"). + */ + sealed interface Call extends SqlExpression permits Function, Aggregate { + String name(); + + List arguments(); + } + + /** + * A column reference, optionally qualified by a table alias. + * + * @param tableQualifier the table alias (its raw name), or empty for an + * unqualified column + * @param name the column name (unquoted; the renderer quotes it) + */ + record Column(Optional tableQualifier, String name) implements SqlExpression { + } + + /** + * A typed literal value. The {@link Datatype} tells the renderer how to quote + * it. + * + * @param value the raw value (may be {@code null}) + * @param datatype the value's SQL datatype + */ + record Literal(Object value, Datatype datatype) implements SqlExpression { + } + + /** + * A function or operator call, e.g. {@code COUNT(...)} or {@code UPPER(...)}. + * + * @param name the function name (rendered verbatim, upper-case by + * convention) + * @param arguments the argument expressions + */ + record Function(String name, List arguments) implements Call { + public Function { + arguments = List.copyOf(arguments); + } + } + + /** + * An aggregate-function call that may carry a {@code DISTINCT} qualifier, e.g. + * {@code COUNT(DISTINCT a, b)} or {@code SUM(amount)}. Distinguished from + * {@link Function} because the renderer must place {@code DISTINCT} + * inside the parentheses, and because a multi-argument + * {@code COUNT(DISTINCT ...)} (compound count-distinct) is its own + * dialect-gated shape. + * + * @param name the aggregate name (rendered verbatim, upper-case by + * convention) + * @param distinct whether the {@code DISTINCT} qualifier precedes the + * arguments + * @param arguments the argument expressions (at least one) + */ + record Aggregate(String name, boolean distinct, List arguments) implements Call { + public Aggregate { + if (arguments == null || arguments.isEmpty()) { + throw new IllegalArgumentException("Aggregate requires at least one argument"); + } + arguments = List.copyOf(arguments); + } + } + + /** + * A portable well-known function call, identified by {@link KnownFunction} + * intent rather than a verbatim function name — the dialect's + * {@code FunctionGenerator} chooses the spelling at render time (e.g. + * {@code LENGTH} renders as {@code CHAR_LENGTH(x)} on ANSI but {@code LEN(x)} + * on SQL Server). + *

+ * Deliberately NOT a {@link Call}: {@code Call.name()} promises a verbatim name + * that is rendered as-is, but a {@code KnownCall} has no name until render + * time. + * + * @param function the portable function intent + * @param arguments the argument expressions (arity is validated by the + * renderer's dialect generator; the {@code Expressions} + * factories validate it eagerly) + */ + record KnownCall(org.eclipse.daanse.jdbc.db.dialect.api.generator.KnownFunction function, + List arguments) implements SqlExpression { + public KnownCall { + arguments = List.copyOf(arguments); + } + } + + /** + * A binary arithmetic expression, e.g. {@code price * quantity}. + * + * @param left left operand + * @param operator the arithmetic operator + * @param right right operand + * @param parenthesized whether the rendered expression is wrapped in + * parentheses; {@code true} for a standalone binary (the + * safe default), {@code false} for an infix fragment that + * is already inside an enclosing context (e.g. + * {@code sum(a / b)}) and must not gain an extra paren + * pair + */ + record Binary(SqlExpression left, ArithmeticOperator operator, SqlExpression right, boolean parenthesized) + implements SqlExpression { + /** Backward-compatible constructor: parenthesized by default. */ + public Binary(SqlExpression left, ArithmeticOperator operator, SqlExpression right) { + this(left, operator, right, true); + } + } + + /** + * A searched {@code CASE WHEN condition THEN result ... [ELSE result] END} + * expression. + * + * @param whens the ordered WHEN/THEN branches (at least one) + * @param elseResult the ELSE result, if any + */ + record Case(List whens, Optional elseResult) implements SqlExpression { + + /** One {@code WHEN condition THEN result} branch. */ + public record WhenClause(Predicate condition, SqlExpression result) { + } + } + + /** + * A bind parameter. Renders as a dialect placeholder ({@code ?}, {@code $n}, …) + * and its value is carried alongside the SQL for the executor to bind — + * avoiding literal string interpolation. + * + * @param value the value to bind when {@code bound} is true (may be + * {@code null}) + * @param bound {@code true} for an immediate value; {@code false} for a + * positional marker whose value is supplied later (e.g. per + * batch row) + * @param datatype the parameter's SQL datatype + */ + record Param(Object value, boolean bound, Datatype datatype) implements SqlExpression { + } + + /** + * A scalar subquery: a parenthesized {@code SELECT} used where a single value + * is expected (a projection, or one side of a comparison). The subquery renders + * compact (like a CTE body or a derived table); any bind parameters inside + * accumulate with the enclosing statement's, in placeholder order. + * + * @param query the subquery producing the scalar value + */ + record ScalarSubquery(SelectStatement query) implements SqlExpression { + } + + /** + * The whole-row projection {@code *} or {@code alias.*}. Never receives a + * column alias — the renderer suppresses the generated {@code as cN} for it + * ({@code select * as c0} is invalid SQL). + * + * @param tableQualifier the table alias to qualify with ({@code t.*}), or empty + * for bare {@code *} + */ + record Star(Optional tableQualifier) implements SqlExpression { + } + + /** + * A 1-based ordinal position reference, e.g. the {@code 1} in + * {@code ORDER BY 1}. Rendered as the bare number. + * + * @param position the position (1-based, as in SQL) + */ + record Ordinal(int position) implements SqlExpression { + public Ordinal { + if (position < 1) { + throw new IllegalArgumentException("Ordinal position is 1-based (SQL), got " + position); + } + } + } + + /** + * A pre-rendered SQL fragment, emitted verbatim by the renderer. + * + * @param sql the literal SQL text + */ + record Raw(String sql) implements SqlExpression { + } + + /** + * The multi-variant sibling of {@link Raw}: a pre-rendered SQL fragment chosen + * per dialect at render time. Carries the whole {@code dialect-name -> SQL} map + * (author-written per-engine fragments — e.g. a computed column / measure + * expression with several {@code } entries) instead of a + * single already-picked string, so the expression stays dialect-free and the + * {@code Statement} stays cache-safe. The renderer resolves the map + * ({@code dialect.name()} else {@code "generic"}) and emits it verbatim like + * {@link Raw}. + * + * @param byDialectName the {@code dialect-name -> SQL} variants (must contain + * the live dialect or {@code "generic"}) + */ + record RawVariant(java.util.Map byDialectName) implements SqlExpression { + } + + /** + * A dialect-generated extra aggregate (PERCENTILE / LISTAGG / bitwise / + * NTH_VALUE) whose SQL is produced at RENDER time by + * {@code dialect.aggregationGenerator()} — each engine spells these very + * differently, so (like + * {@link org.eclipse.daanse.sql.statement.api.model.FromClause.FromInline}) the + * node carries only the dialect-free parameters and the renderer dispatches on + * {@link Spec}. + * + * @param operand the aggregated operand expression when the kind uses one + * (LISTAGG / bitwise / NTH_VALUE); empty for PERCENTILE, which + * takes its ordered column directly + * @param spec the per-kind dialect-free parameters + */ + record ExtraAggregate(Optional operand, Spec spec) implements SqlExpression { + + /** The per-kind parameters of an {@link ExtraAggregate}. */ + public sealed interface Spec { + + /** + * {@code PERCENTILE_DISC|CONT(fraction) WITHIN GROUP (ORDER BY column [DESC])}. + */ + record Percentile(double fraction, boolean continuous, boolean descending, String tableName, + String columnName) implements Spec { + } + + /** + * {@code LISTAGG([DISTINCT] operand, separator) WITHIN GROUP (ORDER BY columns)}. + */ + record ListAgg(boolean distinct, String separator, String coalesce, String onOverflowTruncate, + List columns) implements Spec { + } + + /** + * A bitwise aggregation (AND/OR/XOR, optionally negated) of {@code operand}. + */ + record BitAggregation(BitOperation operation) implements Spec { + } + + /** {@code NTH_VALUE(operand, n) [IGNORE NULLS] OVER (ORDER BY columns)}. */ + record NthValue(boolean ignoreNulls, int n, List columns) implements Spec { + } + } + } + + /** + * A dialect-specific case fold (upper-casing) of an expression, e.g. + * {@code UPPER(x)} / {@code UCASE(x)}. Rendered at render time via + * {@code dialect.functionGenerator().wrapIntoSqlUpperCaseFunction(...)} — each + * engine spells the function differently, so (like {@link ExtraAggregate}) the + * node stays dialect-free and the renderer supplies the spelling. Used for + * case-insensitive name-column comparisons. + * + * @param inner the expression to upper-case + */ + record CaseFold(SqlExpression inner) implements SqlExpression { + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/package-info.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/package-info.java new file mode 100644 index 0000000..08447ba --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/package-info.java @@ -0,0 +1,17 @@ +/* + * 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 + */ + +@org.osgi.annotation.bundle.Export +@org.osgi.annotation.versioning.Version("0.0.1") +package org.eclipse.daanse.sql.statement.api.expression; diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/ColumnAlias.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/ColumnAlias.java new file mode 100644 index 0000000..b5d25a9 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/ColumnAlias.java @@ -0,0 +1,26 @@ +/* + * 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.api.model; + +/** + * A column alias used in the {@code SELECT} list (its raw, unquoted name). + * + * @param name the alias name + */ +public record ColumnAlias(String name) { + + public static ColumnAlias of(String name) { + return new ColumnAlias(name); + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/CommonTableExpression.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/CommonTableExpression.java new file mode 100644 index 0000000..d6d2180 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/CommonTableExpression.java @@ -0,0 +1,33 @@ +/* + * 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.api.model; + +import java.util.List; + +/** + * One named common-table expression in a {@link WithStatement}: a name, an + * optional explicit column list, and the statement that produces its rows. + * Reference it from the body by using its {@code name} as a table (e.g. + * {@code From.table(name, alias)}). + *

+ * An explicit {@code columns} list renders as {@code name(col1, col2, …)} and + * is recommended for recursive CTEs (where databases like H2 require it). + * + * @param name the CTE name (unquoted; the renderer quotes it consistently) + * @param columns explicit column names (unquoted); empty means "derive from the + * body" + * @param query the statement producing the CTE's rows + */ +public record CommonTableExpression(String name, List columns, Statement query) { +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/DeleteStatement.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/DeleteStatement.java new file mode 100644 index 0000000..19d2a4d --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/DeleteStatement.java @@ -0,0 +1,40 @@ +/* + * 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.api.model; + +import java.util.List; +import java.util.Optional; + +import org.eclipse.daanse.jdbc.db.api.schema.TableReference; +import org.eclipse.daanse.sql.statement.api.expression.Predicate; + +/** + * A {@code DELETE FROM table [WHERE ...]} statement. + * + * @param table the target table (shared jdbc.db identifier) + * @param filters {@code WHERE} predicates, combined with {@code AND} + * (empty = delete all) + * @param footerComment an optional trailing explanatory comment, appended on + * its own line at the very end of the rendered statement — + * only when the renderer is asked to emit comments; never + * part of the executed SQL + */ +public record DeleteStatement(TableReference table, List filters, Optional footerComment) + implements Statement { + + /** Backwards-compatible form without a footer comment. */ + public DeleteStatement(TableReference table, List filters) { + this(table, filters, Optional.empty()); + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/FromClause.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/FromClause.java new file mode 100644 index 0000000..3d43d21 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/FromClause.java @@ -0,0 +1,184 @@ +/* + * 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.api.model; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.eclipse.daanse.jdbc.db.api.schema.TableReference; +import org.eclipse.daanse.sql.statement.api.expression.Predicate; + +/** + * The {@code FROM} clause as a tree of table references, sub-queries and joins. + *

+ * Whether a {@link FromJoin} renders as an ANSI {@code JOIN ... ON} or as an + * old-style comma join with the condition pushed into {@code WHERE} is decided + * by the renderer from the dialect's capabilities — there is a single + * representation here. + */ +public sealed interface FromClause { + + /** + * A FROM item that carries a single query-local table {@link TableAlias} — a + * leaf table, sub-query, or pre-rendered/variant fragment — as opposed to + * {@link FromJoin}/{@link FromProduct}, which compose other items and have no + * single alias of their own. Lets callers resolve a from-item's alias with one + * {@code instanceof Aliased} rather than enumerating every leaf variant. + */ + sealed interface Aliased extends FromClause + permits FromTable, FromSubquery, FromSet, FromRaw, FromVariant, FromInline { + TableAlias alias(); + } + + /** + * A base table reference. + * + * @param table the (optionally schema-qualified) table, as the shared jdbc.db + * identifier + * @param alias the query-local table alias + * @param filter an optional per-table filter to add to {@code WHERE} + * @param hints optimizer hints (dialect-specific; may be empty) + * @param comment an optional explanatory comment (rollup provenance — why this + * table anchors the FROM, e.g. the level relation or the fact + * table), emitted only when the renderer is asked to emit + * comments; never part of the executed SQL. The {@link FromJoin} + * comment covers joined tables; this slot covers the BASE table, + * which no join comment can reach. + */ + record FromTable(TableReference table, TableAlias alias, Optional filter, Map hints, + Optional comment) implements Aliased { + + /** Backwards-compatible form without a comment. */ + public FromTable(TableReference table, TableAlias alias, Optional filter, + Map hints) { + this(table, alias, filter, hints, Optional.empty()); + } + + /** A copy of this table reference carrying the given provenance comment. */ + public FromTable withComment(String comment) { + return new FromTable(table, alias, filter, hints, Optional.ofNullable(comment)); + } + } + + /** + * A derived table (sub-query) reference. + * + * @param query the sub-query + * @param alias the derived-table alias + */ + record FromSubquery(SelectStatement query, TableAlias alias) implements Aliased { + } + + /** + * A parenthesized set operation (e.g. {@code UNION}) as a derived table — the + * {@link SetOperation} sibling of {@link FromSubquery}, rendered as + * {@code (input1 union input2 ...) as alias}. + * + * @param set the set operation forming the derived-table body + * @param alias the derived-table alias + */ + record FromSet(SetOperation set, TableAlias alias) implements Aliased { + } + + /** + * A derived table from a pre-rendered, dialect-specific SQL fragment (e.g. a + * mapping view's chosen SQL, or an inline {@code VALUES} table). Unlike + * {@link FromSubquery} the body is not a structured {@link SelectStatement} — + * it is the SQL the source already produces for the target dialect, wrapped as + * {@code (sql) as alias}. + * + * @param sql the derived-table body SQL (without surrounding parentheses) + * @param alias the derived-table alias + */ + record FromRaw(String sql, TableAlias alias) implements Aliased { + } + + /** + * The multi-variant sibling of {@link FromRaw}: a derived table whose body SQL + * is chosen per dialect at render time. Carries the whole + * {@code dialect-name -> pre-rendered SQL} map (author-written per-engine + * fragments — e.g. a mapping view with several {@code } + * entries) instead of a single already-picked string, so the {@code Statement} + * no longer encodes a dialect choice and stays cache-safe. The renderer + * resolves the map ({@code dialect.name()} else {@code "generic"}) and renders + * it exactly like {@link FromRaw}. + * + * @param byDialectName the {@code dialect-name -> SQL} variants (must contain + * the live dialect or {@code "generic"}) + * @param alias the derived-table alias + */ + record FromVariant(Map byDialectName, TableAlias alias) implements Aliased { + } + + /** + * A derived table from an inline {@code VALUES} table: the structured data + * (column names, column type names, and rows of raw string values) carried + * as-is, with the dialect-specific {@code VALUES} / {@code SELECT … UNION ALL} + * SQL generated at RENDER time by + * {@code dialect.sqlGenerator().generateInline(...)}. Unlike + * {@link FromVariant} (a schema-carried per-dialect map), inline-table SQL is + * dialect-GENERATED, so the single dialect touch is deferred to the renderer + * rather than resolved while building. + * + * @param columnNames the inline column names, in order + * @param columnTypes the inline column type names (matching + * {@code columnNames}) + * @param rows the rows of raw string cell values (each {@code String[]} + * matching {@code columnNames}) + * @param alias the derived-table alias + */ + record FromInline(List columnNames, List columnTypes, List rows, TableAlias alias) + implements Aliased { + } + + /** + * A join of two from-clauses. + * + * @param left the left input + * @param kind the join kind + * @param right the right input + * @param on the join condition (ignored for {@link JoinKind#CROSS}) + * @param comment an optional explanatory comment (rollup provenance — why this + * join exists), emitted only when the renderer is asked to emit + * comments; never part of the executed SQL + */ + record FromJoin(FromClause left, JoinKind kind, FromClause right, Predicate on, Optional comment) + implements FromClause { + + /** Backwards-compatible form without a comment. */ + public FromJoin(FromClause left, JoinKind kind, FromClause right, Predicate on) { + this(left, kind, right, on, Optional.empty()); + } + } + + /** + * A comma-separated product of from-clauses with no join predicate of + * its own — the caller is responsible for adding the join conditions to + * {@code WHERE} (via the builder's {@code where(...)}). This lets a mapper + * reproduce the exact {@code WHERE}-conjunct order of a legacy query (where + * join and constraint predicates were interleaved by call order), which a + * {@link FromJoin}'s deferred predicate-push cannot. + * + * @param items the comma-joined inputs (at least two) + */ + record FromProduct(List items) implements FromClause { + public FromProduct { + if (items == null || items.size() < 2) { + throw new IllegalArgumentException("FromProduct requires at least two items"); + } + items = List.copyOf(items); + } + } +} 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 new file mode 100644 index 0000000..19e6f2b --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/GroupBy.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.api.model; + +import java.util.List; + +import org.eclipse.daanse.sql.statement.api.expression.SqlExpression; + +/** + * The {@code GROUP BY} part of a select: plain grouping keys, optional grouping + * sets, and optional {@code GROUPING(...)} super-aggregate columns. + * + * @param keys the grouping keys (each either a projection handle + * or an expression) + * @param groupingSets grouping sets (rendered only when the dialect + * supports them) + * @param groupingFunctions {@code GROUPING(expr)} columns added to the select + * list + */ +public record GroupBy(List keys, List groupingSets, List groupingFunctions) { + + /** True if there is nothing to group by. */ + public boolean isEmpty() { + return keys.isEmpty() && groupingSets.isEmpty() && groupingFunctions.isEmpty(); + } + + /** + * A grouping key: either a reference to a projection, or a standalone + * expression. + */ + public sealed interface GroupKey { + + /** + * Group by a projection (the renderer decides alias-vs-expression per dialect). + */ + record Ref(ProjectionRef projection) implements GroupKey { + } + + /** Group by a standalone expression. */ + record Expr(SqlExpression expression) implements GroupKey { + } + } + + /** One grouping set: a list of grouping-key expressions. */ + public record GroupingSet(List keys) { + } + + /** A {@code GROUPING(expr)} super-aggregate column. */ + public record GroupingFunction(SqlExpression argument) { + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/InsertStatement.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/InsertStatement.java new file mode 100644 index 0000000..44c0b88 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/InsertStatement.java @@ -0,0 +1,46 @@ +/* + * 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.api.model; + +import java.util.List; +import java.util.Optional; + +import org.eclipse.daanse.jdbc.db.api.schema.TableReference; +import org.eclipse.daanse.sql.statement.api.expression.SqlExpression; + +/** + * An {@code INSERT INTO table (cols...)} statement, sourcing rows either from + * inline {@code VALUES} or from a sub-query ({@code INSERT ... SELECT}). + * + * @param table the target table (shared jdbc.db identifier) + * @param columns target column names (unquoted); empty means positional + * insert + * @param rows inline value rows (each row's size should match + * {@code columns}); used when {@code source} is empty + * @param source a sub-query to insert from; when present, {@code rows} + * must be empty + * @param footerComment an optional trailing explanatory comment, appended on + * its own line at the very end of the rendered statement — + * only when the renderer is asked to emit comments; never + * part of the executed SQL + */ +public record InsertStatement(TableReference table, List columns, List> rows, + Optional source, Optional footerComment) implements Statement { + + /** Backwards-compatible form without a footer comment. */ + public InsertStatement(TableReference table, List columns, List> rows, + Optional source) { + this(table, columns, rows, source, Optional.empty()); + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/JoinKind.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/JoinKind.java new file mode 100644 index 0000000..4d24945 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/JoinKind.java @@ -0,0 +1,37 @@ +/* + * 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.api.model; + +/** + * The kind of join in a {@link FromClause.FromJoin}. + *

+ * Only {@link #INNER} may fall back to an old-style comma join (condition + * pushed into {@code WHERE}) when the dialect does not allow + * {@code JOIN ... ON}; every outer kind always renders the ANSI keyword. + */ +public enum JoinKind { + INNER, LEFT, + /** + * Right outer join, always rendered as ANSI {@code right join ... on}. There is + * no comma fallback and no emulation: a dialect that does not support RIGHT + * JOIN fails loudly at the database. + */ + RIGHT, + /** + * Full outer join, always rendered as ANSI {@code full join ... on}. There is + * no comma fallback and no emulation: a dialect that does not support FULL JOIN + * fails loudly at the database. + */ + FULL, CROSS +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/NullOrder.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/NullOrder.java new file mode 100644 index 0000000..a0427e7 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/NullOrder.java @@ -0,0 +1,22 @@ +/* + * 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.api.model; + +/** + * Where {@code NULL}s sort relative to non-null values for an {@link OrderKey}. + * {@link #DEFAULT} leaves it to the database's natural ordering. + */ +public enum NullOrder { + FIRST, LAST, DEFAULT +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/OrderKey.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/OrderKey.java new file mode 100644 index 0000000..5199af5 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/OrderKey.java @@ -0,0 +1,34 @@ +/* + * 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.api.model; + +import java.util.Optional; + +import org.eclipse.daanse.sql.statement.api.expression.SqlExpression; + +/** + * One {@code ORDER BY} item. + *

+ * The {@link #expression()} is always present (so a dialect that orders by + * expression can render it directly). When {@link #projectionRef()} is also + * present, a dialect that requires/uses order-by aliases may render the + * projection's alias instead. + * + * @param expression the expression to order by + * @param projectionRef the projection this key refers to, if it was added by + * handle + * @param sort the sort specification + */ +public record OrderKey(SqlExpression expression, Optional projectionRef, SortSpec sort) { +} 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 new file mode 100644 index 0000000..ac09032 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/Projection.java @@ -0,0 +1,45 @@ +/* + * 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.api.model; + +import java.util.Optional; + +import org.eclipse.daanse.jdbc.db.dialect.api.type.BestFitColumnType; +import org.eclipse.daanse.sql.statement.api.expression.SqlExpression; + +/** + * One item of the {@code SELECT} list: an expression, the JDBC column type to + * read it back as, and an optional explicit alias. + *

+ * The projection carries its own column type, so the rendered SQL and the + * column-type list stay in lock-step by construction (no separate parallel type + * list). + * + * @param expression the projected expression + * @param columnType the type to read the result column as (may be {@code null} + * if unknown) + * @param alias an explicit column alias, or empty to let the renderer + * decide + * @param comment an optional explanatory comment (rollup provenance), + * emitted only when the renderer is asked to emit comments; + * never part of the executed SQL + */ +public record Projection(SqlExpression expression, BestFitColumnType columnType, Optional alias, + Optional comment) { + + /** Backwards-compatible form without a comment. */ + public Projection(SqlExpression expression, BestFitColumnType columnType, Optional alias) { + this(expression, columnType, alias, Optional.empty()); + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/ProjectionRef.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/ProjectionRef.java new file mode 100644 index 0000000..97c601e --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/ProjectionRef.java @@ -0,0 +1,35 @@ +/* + * 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.api.model; + +import java.util.Optional; + +/** + * A stable handle to a projection returned by the assembler when an expression + * is added to the {@code SELECT} list. + *

+ * Callers pass this handle to {@code groupOn}/{@code orderOn} instead of + * re-deriving an alias string. The renderer resolves the handle's + * {@link #ordinal()} to the effective alias (or back to the expression) per the + * dialect's capabilities at render time — which is what removes the + * alias-return coupling of the old builder. + * + * @param ordinal zero-based position of the projection in the + * {@code SELECT} list + * @param explicitAlias the alias requested by the caller, if any (else the + * renderer may auto-generate one when the dialect allows + * column aliases) + */ +public record ProjectionRef(int ordinal, Optional explicitAlias) { +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/RowLimit.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/RowLimit.java new file mode 100644 index 0000000..ff47c8c --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/RowLimit.java @@ -0,0 +1,35 @@ +/* + * 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.api.model; + +import java.util.OptionalLong; + +/** + * A row-count limit and/or offset. The renderer turns this into the dialect's + * pagination syntax (leading {@code TOP} or trailing + * {@code OFFSET ... FETCH}/{@code LIMIT}). + * + * @param maxRows maximum rows to return, if limited + * @param offset rows to skip, if any + */ +public record RowLimit(OptionalLong maxRows, OptionalLong offset) { + + public static RowLimit of(long maxRows) { + return new RowLimit(OptionalLong.of(maxRows), OptionalLong.empty()); + } + + public static RowLimit of(long maxRows, long offset) { + return new RowLimit(OptionalLong.of(maxRows), OptionalLong.of(offset)); + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/SelectStatement.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/SelectStatement.java new file mode 100644 index 0000000..cf75ac6 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/SelectStatement.java @@ -0,0 +1,94 @@ +/* + * 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.api.model; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.eclipse.daanse.jdbc.db.dialect.api.generator.StatementHint; +import org.eclipse.daanse.sql.statement.api.expression.Predicate; + +/** + * An immutable {@code SELECT} statement. Holds structure only — no + * dialect, no pre-quoted SQL — so it has value semantics and is safe to use as + * a cache key. Render it with a {@code SqlRenderer} to obtain dialect-specific + * SQL. + * + * @param distinct whether {@code DISTINCT} is set + * @param projections the {@code SELECT} list (also carries the column types, + * in order) + * @param from the {@code FROM} clause, if any + * @param filters {@code WHERE} predicates, combined with {@code AND} + * @param groupBy the {@code GROUP BY} part (may be empty) + * @param having {@code HAVING} predicates, combined with {@code AND} + * @param orderKeys the {@code ORDER BY} items + * @param rowLimit the row limit/offset, if any + * @param headerComment an optional statement-level explanatory comment (e.g. + * the originating MDX request / cube), emitted only when + * the renderer is asked to emit comments; never part of + * the executed SQL + * @param footerComment an optional trailing explanatory comment, appended on + * its own line at the very end of the rendered statement + * — only when the renderer is asked to emit comments; + * never part of the executed SQL + * @param statementHints statement-level optimizer hint intents, spelled (or + * silently ignored) by the dialect at render time — + * always emitted, they are semantic for the DBMS. A + * {@code SELECT}-only concern for now; extending hints to + * DML or set operations is a possible future extension + */ +public record SelectStatement(boolean distinct, List projections, Optional from, + List filters, GroupBy groupBy, List having, List orderKeys, + Optional rowLimit, Optional headerComment, Map filterComments, + Optional footerComment, List statementHints) implements Statement { + + /** + * Backwards-compatible form without a header comment, filter comments, a footer + * comment or hints. + */ + public SelectStatement(boolean distinct, List projections, Optional from, + List filters, GroupBy groupBy, List having, List orderKeys, + Optional rowLimit) { + this(distinct, projections, from, filters, groupBy, having, orderKeys, rowLimit, Optional.empty(), Map.of(), + Optional.empty(), List.of()); + } + + /** + * Backwards-compatible form without filter comments, a footer comment or hints. + */ + public SelectStatement(boolean distinct, List projections, Optional from, + List filters, GroupBy groupBy, List having, List orderKeys, + Optional rowLimit, Optional headerComment) { + this(distinct, projections, from, filters, groupBy, having, orderKeys, rowLimit, headerComment, Map.of(), + Optional.empty(), List.of()); + } + + /** Backwards-compatible form without a footer comment or hints. */ + public SelectStatement(boolean distinct, List projections, Optional from, + List filters, GroupBy groupBy, List having, List orderKeys, + Optional rowLimit, Optional headerComment, Map filterComments) { + this(distinct, projections, from, filters, groupBy, having, orderKeys, rowLimit, headerComment, filterComments, + Optional.empty(), List.of()); + } + + /** Backwards-compatible form without hints. */ + public SelectStatement(boolean distinct, List projections, Optional from, + List filters, GroupBy groupBy, List having, List orderKeys, + Optional rowLimit, Optional headerComment, Map filterComments, + Optional footerComment) { + this(distinct, projections, from, filters, groupBy, having, orderKeys, rowLimit, headerComment, filterComments, + footerComment, List.of()); + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/SetOperation.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/SetOperation.java new file mode 100644 index 0000000..9688782 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/SetOperation.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.api.model; + +import java.util.List; +import java.util.Optional; + +/** + * A set operation ({@code UNION ALL}, {@code UNION}, {@code INTERSECT}, + * {@code EXCEPT}) over two or more input statements, with optional trailing + * {@code ORDER BY} / row limit. Useful e.g. for ORM table-per-class inheritance + * (a {@code UNION ALL} of per-subclass selects). + * + * @param op the set operator + * @param inputs the input statements (size ≥ 2) + * @param orderKeys trailing {@code ORDER BY} applied to the whole set, if + * any + * @param rowLimit trailing row limit applied to the whole set, if any + * @param footerComment an optional trailing explanatory comment, appended on + * its own line at the very end of the rendered statement — + * only when the renderer is asked to emit comments; never + * part of the executed SQL + */ +public record SetOperation(SetOp op, List inputs, List orderKeys, Optional rowLimit, + Optional footerComment) implements Statement { + + /** Backwards-compatible form without a footer comment. */ + public SetOperation(SetOp op, List inputs, List orderKeys, Optional rowLimit) { + this(op, inputs, orderKeys, rowLimit, Optional.empty()); + } + + public static SetOperation unionAll(List inputs) { + return new SetOperation(SetOp.UNION_ALL, inputs, List.of(), Optional.empty()); + } + + /** The set operator. */ + public enum SetOp { + UNION_ALL("union all"), UNION("union"), INTERSECT("intersect"), EXCEPT("except"); + + private final String keyword; + + SetOp(String keyword) { + this.keyword = keyword; + } + + /** @return the SQL keyword, e.g. {@code "union all"} */ + public String keyword() { + return keyword; + } + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/SortDirection.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/SortDirection.java new file mode 100644 index 0000000..0819133 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/SortDirection.java @@ -0,0 +1,19 @@ +/* + * 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.api.model; + +/** Sort direction for an {@link OrderKey}. */ +public enum SortDirection { + ASC, DESC +} 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 new file mode 100644 index 0000000..cfaa698 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/SortSpec.java @@ -0,0 +1,90 @@ +/* + * 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.api.model; + +import java.util.Optional; + +/** + * How a single {@link OrderKey} is sorted. + * + * @param direction ascending or descending + * @param nullable whether the expression may be {@code null} (enables + * null-ordering logic) + * @param nullOrder where nulls sort + * @param prepend whether this key is prepended (vs appended) to the + * order list + * @param nullSortValue when non-null, nulls are ordered as if they + * held this value (the renderer uses the dialect's + * order-value generator instead of plain null-ordering) + * — e.g. a parent-child hierarchy's + * {@code nullParentValue} + * @param nullSortDatatype the datatype of {@code nullSortValue} (required when + * it is non-null) + * @param collation when present, the key expression is rendered with + * {@code COLLATE }. The name is emitted + * verbatim — it is a dialect-specific + * identifier the caller chooses per engine (e.g. + * {@code utf8mb4_bin}, {@code Latin1_General_CS_AS}, + * {@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, + 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) { + this(direction, nullable, nullOrder, prepend, nullSortValue, nullSortDatatype, Optional.empty()); + } + + /** Convenience constructor for the common case with no null-sort-value. */ + public SortSpec(SortDirection direction, boolean nullable, NullOrder nullOrder, boolean prepend) { + this(direction, nullable, nullOrder, prepend, null, null); + } + + public static SortSpec asc() { + return new SortSpec(SortDirection.ASC, false, NullOrder.DEFAULT, false); + } + + public static SortSpec desc() { + return new SortSpec(SortDirection.DESC, false, NullOrder.DEFAULT, false); + } + + /** @return a copy of this spec marked nullable with the given null ordering */ + public SortSpec withNulls(NullOrder order) { + return new SortSpec(direction, true, order, prepend, nullSortValue, nullSortDatatype, collation); + } + + /** @return a copy of this spec marked as prepended */ + public SortSpec prepended() { + return new SortSpec(direction, nullable, nullOrder, true, nullSortValue, nullSortDatatype, collation); + } + + /** + * @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) { + return new SortSpec(direction, nullable, nullOrder, prepend, value, datatype, collation); + } + + /** + * @return a copy whose key expression renders with {@code COLLATE name} (name + * emitted verbatim). + */ + public SortSpec withCollation(String name) { + return new SortSpec(direction, nullable, nullOrder, prepend, nullSortValue, nullSortDatatype, + Optional.of(name)); + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/Statement.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/Statement.java new file mode 100644 index 0000000..e30d094 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/Statement.java @@ -0,0 +1,35 @@ +/* + * 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.api.model; + +import java.util.Optional; + +/** + * A renderable, immutable SQL statement. May be a read + * ({@link SelectStatement}, or a {@link SetOperation} such as + * {@code UNION ALL}) or a write + * ({@link InsertStatement}/{@link UpdateStatement}/{@link DeleteStatement}). + */ +public sealed interface Statement + permits SelectStatement, SetOperation, InsertStatement, UpdateStatement, DeleteStatement, WithStatement { + + /** + * An optional trailing explanatory comment. The renderer appends it as a + * {@code /* ... *}{@code /} block on its own line at the very end of the + * rendered statement — only when comment emission is on; with comments off the + * rendered SQL is byte-identical to a statement without a footer (cache-key + * stability). Multi-line text stays multi-line inside the block. + */ + Optional footerComment(); +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/TableAlias.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/TableAlias.java new file mode 100644 index 0000000..f132edb --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/TableAlias.java @@ -0,0 +1,27 @@ +/* + * 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.api.model; + +/** + * A table/relation alias used in the {@code FROM} clause (its raw, unquoted + * name). + * + * @param name the alias name + */ +public record TableAlias(String name) { + + public static TableAlias of(String name) { + return new TableAlias(name); + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/UpdateStatement.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/UpdateStatement.java new file mode 100644 index 0000000..add4f69 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/UpdateStatement.java @@ -0,0 +1,51 @@ +/* + * 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.api.model; + +import java.util.List; +import java.util.Optional; + +import org.eclipse.daanse.jdbc.db.api.schema.TableReference; +import org.eclipse.daanse.sql.statement.api.expression.Predicate; +import org.eclipse.daanse.sql.statement.api.expression.SqlExpression; + +/** + * An {@code UPDATE table SET col = expr, ... [WHERE ...]} statement. + * + * @param table the target table (shared jdbc.db identifier) + * @param assignments the {@code SET} assignments (order preserved) + * @param filters {@code WHERE} predicates, combined with {@code AND} + * (empty = unfiltered) + * @param footerComment an optional trailing explanatory comment, appended on + * its own line at the very end of the rendered statement — + * only when the renderer is asked to emit comments; never + * part of the executed SQL + */ +public record UpdateStatement(TableReference table, List assignments, List filters, + Optional footerComment) implements Statement { + + /** Backwards-compatible form without a footer comment. */ + public UpdateStatement(TableReference table, List assignments, List filters) { + this(table, assignments, filters, Optional.empty()); + } + + /** + * One {@code SET} assignment: {@code column = value}. + * + * @param column the target column name (unquoted) + * @param value the value expression + */ + public record Assignment(String column, SqlExpression value) { + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/WithStatement.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/WithStatement.java new file mode 100644 index 0000000..bdc976c --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/WithStatement.java @@ -0,0 +1,40 @@ +/* + * 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.api.model; + +import java.util.List; +import java.util.Optional; + +/** + * A {@code WITH [RECURSIVE] cte1 AS (...), ... } statement: one or more + * {@link CommonTableExpression}s prefixed onto a body statement. The + * {@code recursive} flag is honoured by the dialect (it emits the + * {@code RECURSIVE} keyword only where supported). + * + * @param ctes the common-table expressions (at least one) + * @param recursive whether the CTEs are recursive + * @param body the main statement that may reference the CTEs by name + * @param footerComment an optional trailing explanatory comment, appended on + * its own line at the very end of the rendered statement — + * only when the renderer is asked to emit comments; never + * part of the executed SQL + */ +public record WithStatement(List ctes, boolean recursive, Statement body, + Optional footerComment) implements Statement { + + /** Backwards-compatible form without a footer comment. */ + public WithStatement(List ctes, boolean recursive, Statement body) { + this(ctes, recursive, body, Optional.empty()); + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/package-info.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/package-info.java new file mode 100644 index 0000000..0b90e20 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/model/package-info.java @@ -0,0 +1,17 @@ +/* + * 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 + */ + +@org.osgi.annotation.bundle.Export +@org.osgi.annotation.versioning.Version("0.0.1") +package org.eclipse.daanse.sql.statement.api.model; diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/package-info.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/package-info.java new file mode 100644 index 0000000..157ad23 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/package-info.java @@ -0,0 +1,17 @@ +/* + * 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 + */ + +@org.osgi.annotation.bundle.Export +@org.osgi.annotation.versioning.Version("0.0.1") +package org.eclipse.daanse.sql.statement.api; 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 new file mode 100644 index 0000000..b54ef32 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/BoundParameter.java @@ -0,0 +1,29 @@ +/* + * 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.api.render; + +import org.eclipse.daanse.jdbc.db.dialect.api.type.Datatype; + +/** + * One bind parameter of a rendered statement, in placeholder order. + * + * @param value the value to bind ({@code null} allowed) when {@code bound} + * is true + * @param bound {@code true} if {@code value} is to be bound; {@code false} + * for a marker whose value is supplied at execute time (e.g. + * per batch row) + * @param datatype the parameter's SQL datatype + */ +public record BoundParameter(Object value, boolean bound, Datatype datatype) { +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/RenderOptions.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/RenderOptions.java new file mode 100644 index 0000000..9ea7782 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/RenderOptions.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.api.render; + +/** + * Options controlling SQL output. + * + * @param formatted whether to format the SQL onto multiple indented lines + * @param indent the indent unit used when {@code formatted} is true + * @param comments whether to emit explanatory comments carried by the + * statement nodes (a header comment, per-column and per-join + * comments). Defaults to {@code false}; the executed SQL is + * rendered with comments off so it stays byte-identical. + */ +public record RenderOptions(boolean formatted, String indent, boolean comments, CommentStyle commentStyle) { + + /** + * How comments are rendered. {@code BLOCK} = inline {@code /* … *}{@code /} + * (safe anywhere, used for compact output); {@code LINE} = end-of-line + * {@code -- …} on its own line before each element (only meaningful with + * {@code formatted}, much more readable). + */ + public enum CommentStyle { + BLOCK, LINE + } + + /** Backwards-compatible 2-arg form (comments off). */ + public RenderOptions(boolean formatted, String indent) { + this(formatted, indent, false, CommentStyle.BLOCK); + } + + /** Backwards-compatible 3-arg form (BLOCK style). */ + public RenderOptions(boolean formatted, String indent, boolean comments) { + this(formatted, indent, comments, CommentStyle.BLOCK); + } + + public static RenderOptions compact() { + return new RenderOptions(false, " ", false, CommentStyle.BLOCK); + } + + public static RenderOptions multiLine() { + return new RenderOptions(true, " ", false, CommentStyle.BLOCK); + } + + /** + * A copy of these options with comment emission turned on/off (keeps the + * current style). + */ + public RenderOptions withComments(boolean emit) { + return new RenderOptions(formatted, indent, emit, commentStyle); + } + + /** A copy of these options with comment emission and an explicit style. */ + public RenderOptions withComments(boolean emit, CommentStyle style) { + return new RenderOptions(formatted, indent, emit, style); + } + + /** + * The style actually used: {@code LINE} only when {@code formatted}; otherwise + * {@code BLOCK}. + */ + public CommentStyle effectiveCommentStyle() { + return (commentStyle == CommentStyle.LINE && formatted) ? CommentStyle.LINE : CommentStyle.BLOCK; + } +} 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 new file mode 100644 index 0000000..af99832 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/RenderedSql.java @@ -0,0 +1,37 @@ +/* + * 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.api.render; + +import java.util.List; + +import org.eclipse.daanse.jdbc.db.dialect.api.type.BestFitColumnType; + +/** + * The result of rendering a statement: the SQL text, the column types to read + * each {@code SELECT} item back as (in order; an entry may be {@code null} if + * the type is unknown), and the bind parameters in placeholder order. All three + * are produced together, so they cannot drift out of sync. + * + * @param sql the rendered SQL + * @param columnTypes the per-column read types, in {@code SELECT} order + * @param parameters the bind parameters, in placeholder order (empty if the + * SQL has none) + */ +public record RenderedSql(String sql, List columnTypes, List parameters) { + + /** Convenience for SQL without bind parameters. */ + public static RenderedSql of(String sql, List columnTypes) { + return new RenderedSql(sql, columnTypes, List.of()); + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/SqlRenderer.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/SqlRenderer.java new file mode 100644 index 0000000..c520b06 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/SqlRenderer.java @@ -0,0 +1,32 @@ +/* + * 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.api.render; + +import org.eclipse.daanse.sql.statement.api.model.Statement; + +/** + * Turns an immutable {@link Statement} into dialect-specific SQL. + * Implementations are the only dialect-aware part of the query builder; the + * model and assembler stay dialect-free. + */ +public interface SqlRenderer { + + /** Renders the statement with the given options. */ + RenderedSql render(Statement statement, RenderOptions options); + + /** Renders the statement with {@link RenderOptions#compact()}. */ + default RenderedSql render(Statement statement) { + return render(statement, RenderOptions.compact()); + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/package-info.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/package-info.java new file mode 100644 index 0000000..d2b4581 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/render/package-info.java @@ -0,0 +1,17 @@ +/* + * 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 + */ + +@org.osgi.annotation.bundle.Export +@org.osgi.annotation.versioning.Version("0.0.1") +package org.eclipse.daanse.sql.statement.api.render; diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/result/Row.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/result/Row.java new file mode 100644 index 0000000..79b40b8 --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/result/Row.java @@ -0,0 +1,99 @@ +/* + * 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.api.result; + +import java.math.BigDecimal; + +import org.eclipse.daanse.sql.statement.api.model.ProjectionRef; + +/** + * A read-only view of one result row, decoupled from {@code java.sql}. + *

+ * Columns are addressed positionally — most naturally by the + * {@link ProjectionRef} handle returned by the builder at projection time, so + * the same handle used to build the query is used to read it back. Values are + * already converted according to the projection's column type. The typed + * convenience getters return boxed values that are {@code null} for SQL + * {@code NULL}. + *

+ * A {@code Row} is only valid during a single {@link RowMapper#map(Row)} call; + * do not retain it. + */ +public interface Row { + + /** @return the number of columns */ + int columnCount(); + + /** + * @param columnIndex zero-based column position (aligns with + * {@link ProjectionRef#ordinal()}) + */ + Object get(int columnIndex); + + /** @param columnLabel the column label/alias as exposed by the result */ + Object get(String columnLabel); + + /** Reads the column the handle refers to. */ + default Object get(ProjectionRef ref) { + return get(ref.ordinal()); + } + + default String getString(int columnIndex) { + Object v = get(columnIndex); + return v == null ? null : v.toString(); + } + + default String getString(ProjectionRef ref) { + return getString(ref.ordinal()); + } + + default Integer getInt(int columnIndex) { + Object v = get(columnIndex); + return v == null ? null : ((Number) v).intValue(); + } + + default Integer getInt(ProjectionRef ref) { + return getInt(ref.ordinal()); + } + + default Long getLong(int columnIndex) { + Object v = get(columnIndex); + return v == null ? null : ((Number) v).longValue(); + } + + default Long getLong(ProjectionRef ref) { + return getLong(ref.ordinal()); + } + + default Double getDouble(int columnIndex) { + Object v = get(columnIndex); + return v == null ? null : ((Number) v).doubleValue(); + } + + default Double getDouble(ProjectionRef ref) { + return getDouble(ref.ordinal()); + } + + default BigDecimal getDecimal(int columnIndex) { + Object v = get(columnIndex); + if (v == null) { + return null; + } + return v instanceof BigDecimal b ? b : new BigDecimal(v.toString()); + } + + default BigDecimal getDecimal(ProjectionRef ref) { + return getDecimal(ref.ordinal()); + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/result/RowMapper.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/result/RowMapper.java new file mode 100644 index 0000000..11ca68a --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/result/RowMapper.java @@ -0,0 +1,28 @@ +/* + * 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.api.result; + +/** + * Maps one result {@link Row} to a target value. This is the single seam a + * consumer implements to turn rows into domain objects — e.g. setting EObject + * attributes via {@code eSet}, or populating a ROLAP member — without touching + * {@code java.sql}. + * + * @param the produced type + */ +@FunctionalInterface +public interface RowMapper { + + T map(Row row); +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/result/package-info.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/result/package-info.java new file mode 100644 index 0000000..8b2b1bd --- /dev/null +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/result/package-info.java @@ -0,0 +1,17 @@ +/* + * 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 + */ + +@org.osgi.annotation.bundle.Export +@org.osgi.annotation.versioning.Version("0.0.1") +package org.eclipse.daanse.sql.statement.api.result; diff --git a/statement/impl/pom.xml b/statement/impl/pom.xml new file mode 100644 index 0000000..8854796 --- /dev/null +++ b/statement/impl/pom.xml @@ -0,0 +1,41 @@ + + + + 4.0.0 + + org.eclipse.daanse + org.eclipse.daanse.sql.statement + ${revision} + + org.eclipse.daanse.sql.statement.impl + Daanse SQL Statement Implementation + Dialect-/JDBC-bound implementations: the DialectSqlRenderer + (model -> SQL via + the JDBC dialect API) and the JDBC StatementExecutor (run RenderedSql, map + rows). + + + + org.eclipse.daanse + org.eclipse.daanse.sql.statement.api + ${project.version} + + + org.eclipse.daanse + org.eclipse.daanse.jdbc.db.dialect.api + 0.0.1-SNAPSHOT + + + 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 new file mode 100644 index 0000000..d25b786 --- /dev/null +++ b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/compare/SqlTextNormalizer.java @@ -0,0 +1,30 @@ +/* + * 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 new file mode 100644 index 0000000..2e3819c --- /dev/null +++ b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/compare/StatementEquivalence.java @@ -0,0 +1,365 @@ +/* + * 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/compare/package-info.java b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/compare/package-info.java new file mode 100644 index 0000000..2ec6c47 --- /dev/null +++ b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/compare/package-info.java @@ -0,0 +1,21 @@ +/* + * 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 + */ + +/** + * 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; 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 new file mode 100644 index 0000000..79d86af --- /dev/null +++ b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/ColumnAccessors.java @@ -0,0 +1,58 @@ +/* + * 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.exec; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import org.eclipse.daanse.jdbc.db.dialect.api.type.BestFitColumnType; + +/** + * Reads a single column from a {@link ResultSet} as the right Java type for a + * {@link BestFitColumnType}, returning {@code null} for SQL {@code NULL}. Mirrors the + * type→getter logic used by the ROLAP {@code SqlStatement} accessors. + */ +final class ColumnAccessors { + + private ColumnAccessors() { + } + + /** + * @param rs the result set, positioned on a row + * @param oneBasedCol the 1-based JDBC column index + * @param type the expected type, or {@code null} to fall back to {@code getObject} + */ + static Object read(ResultSet rs, int oneBasedCol, BestFitColumnType type) throws SQLException { + if (type == null) { + return rs.getObject(oneBasedCol); + } + return switch (type) { + case STRING -> rs.getString(oneBasedCol); + case INT -> { + int v = rs.getInt(oneBasedCol); + yield rs.wasNull() ? null : v; + } + case LONG -> { + long v = rs.getLong(oneBasedCol); + yield rs.wasNull() ? null : v; + } + case DOUBLE -> { + double v = rs.getDouble(oneBasedCol); + yield rs.wasNull() ? null : v; + } + case DECIMAL -> rs.getBigDecimal(oneBasedCol); + case OBJECT -> rs.getObject(oneBasedCol); + }; + } +} diff --git a/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/DataSourceStatementExecutor.java b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/DataSourceStatementExecutor.java new file mode 100644 index 0000000..f112c84 --- /dev/null +++ b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/DataSourceStatementExecutor.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.exec; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +import javax.sql.DataSource; + +import org.eclipse.daanse.sql.statement.api.exec.StatementExecutionException; +import org.eclipse.daanse.sql.statement.api.exec.StatementExecutor; +import org.eclipse.daanse.sql.statement.api.render.RenderedSql; +import org.eclipse.daanse.sql.statement.api.result.RowMapper; + +/** + * A {@link StatementExecutor} backed by a {@link DataSource}: each call borrows a fresh + * {@link Connection} and closes it afterwards (auto-commit — one transaction per call). + *

+ * To run several statements in a single transaction, use {@link #inTransaction(Function)}: + * it borrows one connection, disables auto-commit, hands a connection-bound executor to the + * unit of work, then commits on success or rolls back on any exception. + */ +public final class DataSourceStatementExecutor implements StatementExecutor { + + private final DataSource dataSource; + + public DataSourceStatementExecutor(DataSource dataSource) { + this.dataSource = Objects.requireNonNull(dataSource, "dataSource"); + } + + @Override + public List query(RenderedSql sql, RowMapper mapper) { + try (Connection connection = dataSource.getConnection()) { + return new JdbcStatementExecutor(connection).query(sql, mapper); + } catch (SQLException e) { + throw new StatementExecutionException("query failed: " + sql.sql(), e); + } + } + + @Override + public int update(RenderedSql sql) { + try (Connection connection = dataSource.getConnection()) { + return new JdbcStatementExecutor(connection).update(sql); + } catch (SQLException e) { + throw new StatementExecutionException("update failed: " + sql.sql(), e); + } + } + + @Override + public int[] batch(RenderedSql sql, List rows) { + try (Connection connection = dataSource.getConnection()) { + return new JdbcStatementExecutor(connection).batch(sql, rows); + } catch (SQLException e) { + throw new StatementExecutionException("batch failed: " + sql.sql(), e); + } + } + + /** + * Runs a unit of work in a single transaction. The {@code work} receives a + * {@link StatementExecutor} bound to one connection; its result is returned after + * {@code commit}. Any exception triggers a {@code rollback} and is rethrown. + * + * @param work the transactional unit of work (may return {@code null} for void work) + * @return the work's result + */ + public T inTransaction(Function work) { + try (Connection connection = dataSource.getConnection()) { + boolean previousAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + T result = work.apply(new JdbcStatementExecutor(connection)); + connection.commit(); + return result; + } catch (RuntimeException e) { + connection.rollback(); + throw e; + } finally { + connection.setAutoCommit(previousAutoCommit); + } + } catch (SQLException e) { + throw new StatementExecutionException("transaction failed", e); + } + } +} 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 new file mode 100644 index 0000000..d756da8 --- /dev/null +++ b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/JdbcRow.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.exec; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; + +import org.eclipse.daanse.jdbc.db.dialect.api.type.BestFitColumnType; +import org.eclipse.daanse.sql.statement.api.exec.StatementExecutionException; +import org.eclipse.daanse.sql.statement.api.result.Row; + +/** + * A {@link Row} backed by a {@link ResultSet} cursor. One instance is reused per result and + * always reflects the result set's current row; the type list (from the rendered query) + * drives typed reads, with {@code java.sql} metadata as the fallback. + */ +final class JdbcRow implements Row { + + private final ResultSet resultSet; + private final List columnTypes; + + JdbcRow(ResultSet resultSet, List columnTypes) { + this.resultSet = resultSet; + this.columnTypes = columnTypes; + } + + @Override + public int columnCount() { + try { + return resultSet.getMetaData().getColumnCount(); + } catch (SQLException e) { + throw new StatementExecutionException("could not read result metadata", e); + } + } + + @Override + public Object get(int columnIndex) { + BestFitColumnType type = columnIndex >= 0 && columnIndex < columnTypes.size() ? columnTypes.get(columnIndex) + : null; + try { + return ColumnAccessors.read(resultSet, columnIndex + 1, type); + } catch (SQLException e) { + throw new StatementExecutionException("could not read column " + columnIndex, e); + } + } + + @Override + public Object get(String columnLabel) { + try { + return ColumnAccessors.read(resultSet, resultSet.findColumn(columnLabel), null); + } catch (SQLException e) { + throw new StatementExecutionException("could not read column '" + columnLabel + "'", e); + } + } +} diff --git a/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/JdbcStatementExecutor.java b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/JdbcStatementExecutor.java new file mode 100644 index 0000000..efdf431 --- /dev/null +++ b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/JdbcStatementExecutor.java @@ -0,0 +1,100 @@ +/* + * 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.exec; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.eclipse.daanse.sql.statement.api.render.BoundParameter; +import org.eclipse.daanse.sql.statement.api.render.RenderedSql; +import org.eclipse.daanse.sql.statement.api.exec.StatementExecutionException; +import org.eclipse.daanse.sql.statement.api.exec.StatementExecutor; +import org.eclipse.daanse.sql.statement.api.result.RowMapper; + +/** + * JDBC-backed {@link StatementExecutor} over a single {@link Connection}. The connection is + * not owned (callers manage its lifecycle); statements and result sets are closed per call. + */ +public final class JdbcStatementExecutor implements StatementExecutor { + + private final Connection connection; + + public JdbcStatementExecutor(Connection connection) { + this.connection = Objects.requireNonNull(connection, "connection"); + } + + @Override + public List query(RenderedSql sql, RowMapper mapper) { + try (PreparedStatement ps = connection.prepareStatement(sql.sql())) { + bind(ps, sql.parameters()); + try (ResultSet rs = ps.executeQuery()) { + JdbcRow row = new JdbcRow(rs, sql.columnTypes()); + List results = new ArrayList<>(); + while (rs.next()) { + results.add(mapper.map(row)); + } + return results; + } + } catch (SQLException e) { + throw new StatementExecutionException("query failed: " + sql.sql(), e); + } + } + + @Override + public int update(RenderedSql sql) { + try (PreparedStatement ps = connection.prepareStatement(sql.sql())) { + bind(ps, sql.parameters()); + return ps.executeUpdate(); + } catch (SQLException e) { + throw new StatementExecutionException("update failed: " + sql.sql(), e); + } + } + + @Override + public int[] batch(RenderedSql sql, List rows) { + int n = sql.parameters().size(); + try (PreparedStatement ps = connection.prepareStatement(sql.sql())) { + for (Object[] row : rows) { + if (row.length != n) { + throw new StatementExecutionException( + "batch row has " + row.length + " values but statement has " + n + " parameters", null); + } + for (int i = 0; i < n; i++) { + ps.setObject(i + 1, row[i]); + } + ps.addBatch(); + } + return ps.executeBatch(); + } catch (SQLException e) { + throw new StatementExecutionException("batch failed: " + sql.sql(), e); + } + } + + /** Binds immediate parameter values; unbound markers must be supplied via {@link #batch}. */ + private static void bind(PreparedStatement ps, List parameters) throws SQLException { + for (int i = 0; i < parameters.size(); i++) { + BoundParameter p = parameters.get(i); + if (!p.bound()) { + throw new StatementExecutionException( + "parameter " + (i + 1) + " is an unbound marker; use batch(...) instead", null); + } + ps.setObject(i + 1, p.value()); + } + } +} diff --git a/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/package-info.java b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/package-info.java new file mode 100644 index 0000000..65400ce --- /dev/null +++ b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/exec/package-info.java @@ -0,0 +1,17 @@ +/* + * 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 + */ + +@org.osgi.annotation.bundle.Export +@org.osgi.annotation.versioning.Version("0.0.1") +package org.eclipse.daanse.sql.statement.exec; 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 new file mode 100644 index 0000000..fe457b3 --- /dev/null +++ b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/render/DialectSqlRenderer.java @@ -0,0 +1,924 @@ +/* + * 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 java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +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.sql.statement.api.expression.ComparisonOperator; +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.DeleteStatement; +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.InsertStatement; +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.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.UpdateStatement; +import org.eclipse.daanse.sql.statement.api.model.WithStatement; +import org.eclipse.daanse.sql.statement.api.model.NullOrder; +import org.eclipse.daanse.sql.statement.api.model.SortDirection; +import org.eclipse.daanse.sql.statement.api.render.BoundParameter; +import org.eclipse.daanse.sql.statement.api.render.RenderOptions; +import org.eclipse.daanse.sql.statement.api.render.RenderedSql; +import org.eclipse.daanse.sql.statement.api.render.SqlRenderer; + +/** + * The {@link SqlRenderer} implementation. The single dialect-aware component: it turns the + * dialect-free query model into SQL, taking every spelling decision (identifier/literal + * quoting, the {@code AS} keyword, join style, group-by alias-vs-expression, grouping-set + * support, pagination placement, null ordering) from the {@link Dialect}. + *

+ * Output layout follows {@link RenderOptions}: compact renders the statement on one line; + * formatted ({@link RenderOptions#multiLine()}) breaks the top-level {@code SELECT} onto + * indented clause lines (see {@link #fmtKw}). Nested contexts — CTE bodies, derived tables + * ({@code FromSubquery} / {@code FromSet}), set-operation inputs, {@code EXISTS} subqueries and + * scalar subqueries — render compact (and without comments), whatever the top-level options + * say, with ONE exception: in the diagnostic combination (formatted AND comments on), derived + * tables in FROM ({@code FromSubquery} / {@code FromSet}, including the set's inputs) render + * FORMATTED too, with the enclosing comment style and their whole block indented one level + * deeper (indent stacking), so the nesting reads clearly; the inner statement's header renders + * inside the parentheses, its footer stays suppressed (footers are top-of-statement-only, see + * {@link #render}). {@code EXISTS} / scalar subqueries and CTE bodies have no options in scope + * and stay always-compact. Executed SQL (comments off) is byte-identical either way. Nested + * bind parameters still accumulate in placeholder order with the enclosing statement's. + *

+ * Statement-level hints ({@code SelectStatement.statementHints()}) are ALWAYS emitted — they are + * not gated by {@link RenderOptions#comments()}: unlike explanatory comments they are + * semantic for the DBMS (its optimizer reads them). The dialect's {@code HintGenerator} chooses + * the placement/spelling: an optimizer block directly after the {@code SELECT} keyword + * ({@code selectHint}, MySQL/Oracle) and/or a trailing clause ({@code statementOption}, SQL + * Server) — the trailing clause is placed after the pagination suffix and before the footer + * comment, which stays last. + */ +public final class DialectSqlRenderer implements SqlRenderer { + + private final Dialect dialect; + + /** + * Bind parameters accumulated during a single {@link #render} call, in placeholder order. + * Not thread-safe: render one statement at a time per instance (renderers are cheap). + */ + private List parameters = new ArrayList<>(); + + /** + * The current SELECT's projections while rendering its {@code HAVING}, or {@code null} elsewhere. Lets a + * {@link Predicate.Regexp} resolve its source to the matching SELECT alias when the dialect + * {@code requiresHavingAlias()} (e.g. MySQL renders {@code UPPER(c5) REGEXP …}, not the column). Scoped + * (saved/restored) around the HAVING render so nested selects don't clobber it. + */ + private List havingAliasProjections; + + public DialectSqlRenderer(Dialect dialect) { + this.dialect = Objects.requireNonNull(dialect, "dialect"); + } + + @Override + public RenderedSql render(Statement statement, RenderOptions options) { + this.parameters = new ArrayList<>(); + RenderedSql body = renderInternal(statement, options); + String sql = body.sql(); + // The footer comment goes at the very end of the whole rendered statement, always on its own + // line (compact mode included). Comments off => byte-identical output (cache-key stability). + if (options.comments() && statement.footerComment().isPresent()) { + sql = sql + System.lineSeparator() + "/* " + escapeBlockCommentEnd(statement.footerComment().get()) + + " */"; + } + return new RenderedSql(sql, body.columnTypes(), List.copyOf(parameters)); + } + + /** Dispatch without resetting the parameter accumulator (used for nested statements). */ + private RenderedSql renderInternal(Statement statement, RenderOptions options) { + if (statement instanceof SelectStatement select) { + return renderSelect(select, options); + } + if (statement instanceof SetOperation set) { + return renderSet(set, options); + } + if (statement instanceof InsertStatement insert) { + return renderInsert(insert); + } + if (statement instanceof UpdateStatement update) { + return renderUpdate(update); + } + if (statement instanceof DeleteStatement delete) { + return renderDelete(delete); + } + if (statement instanceof WithStatement with) { + return renderWith(with); + } + throw new IllegalArgumentException("unsupported statement: " + statement); + } + + private RenderedSql renderWith(WithStatement with) { + // CTE bodies render first, so their bind parameters precede the body's, in order. + List ctes = new ArrayList<>(); + for (org.eclipse.daanse.sql.statement.api.model.CommonTableExpression cte : with.ctes()) { + String body = renderInternal(cte.query(), RenderOptions.compact()).sql(); + // Quote the CTE name (so body references via From.table(name, ...) match the casing); + // append an explicit quoted column list when given (required for recursive CTEs on H2). + String name = dialect.quoteIdentifier(cte.name()); + if (!cte.columns().isEmpty()) { + name += "(" + cte.columns().stream().map(dialect::quoteIdentifier).collect(Collectors.joining(", ")) + + ")"; + } + ctes.add(new org.eclipse.daanse.jdbc.db.dialect.api.generator.CteGenerator.Cte(name, body)); + } + String withClause = dialect.cteGenerator().withClause(ctes, with.recursive()); + RenderedSql body = renderInternal(with.body(), RenderOptions.compact()); + return RenderedSql.of(withClause + body.sql(), body.columnTypes()); + } + + // ---- DML ------------------------------------------------------------------- + + private void appendQualifiedTable(StringBuilder sb, TableReference table) { + String schema = table.schema().map(SchemaReference::name).orElse(null); + sb.append(dialect.quoteIdentifier(schema, table.name())); + } + + private RenderedSql renderInsert(InsertStatement ins) { + StringBuilder sb = new StringBuilder("insert into "); + appendQualifiedTable(sb, ins.table()); + if (!ins.columns().isEmpty()) { + sb.append(" (") + .append(ins.columns().stream().map(c -> dialect.quoteIdentifier(c)) + .collect(Collectors.joining(", "))) + .append(")"); + } + if (ins.source().isPresent()) { + sb.append(' ').append(renderInternal(ins.source().get(), RenderOptions.compact()).sql()); + } else { + sb.append(" values "); + sb.append(ins.rows().stream() + .map(row -> "(" + row.stream().map(this::renderExpression).collect(Collectors.joining(", ")) + ")") + .collect(Collectors.joining(", "))); + } + return RenderedSql.of(sb.toString(), List.of()); + } + + private RenderedSql renderUpdate(UpdateStatement upd) { + StringBuilder sb = new StringBuilder("update "); + appendQualifiedTable(sb, upd.table()); + sb.append(" set "); + sb.append(upd.assignments().stream() + .map(a -> dialect.quoteIdentifier(a.column()) + " = " + renderExpression(a.value())) + .collect(Collectors.joining(", "))); + if (!upd.filters().isEmpty()) { + sb.append(" where ").append(renderPredicateList(upd.filters(), " and ")); + } + return RenderedSql.of(sb.toString(), List.of()); + } + + 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 ")); + } + return RenderedSql.of(sb.toString(), List.of()); + } + + // ---- SELECT ---------------------------------------------------------------- + + /** + * Formats a clause keyword/separator for the requested mode, byte-compatible with the legacy + * {@code SqlQuery.ClauseList.formatClauseKeyword}: compact returns {@code s} unchanged; formatted maps a + * leading-space keyword (e.g. {@code " from "}, {@code " and "}) to {@code NL + keyword.trimLeading} and a + * trailing-space separator (e.g. {@code ", "}) to {@code keyword.trimTrailing + NL + indent} (a keyword + * with both, like {@code " from "}, gets both → {@code "\nfrom\n"}); a keyword ending in + * {@code "("} gets {@code "(" + NL + indent}. Top-level prefix is empty. + */ + private static String fmtKw(String s, RenderOptions options) { + if (!options.formatted()) { + return s; + } + String nl = System.lineSeparator(); + String r = s; + if (r.startsWith(" ")) { + r = nl + r.substring(1); + } + if (r.endsWith(" ")) { + r = r.substring(0, r.length() - 1) + nl + options.indent(); + } else if (r.endsWith("(")) { + r = r + nl + options.indent(); + } + return r; + } + + /** + * The one {@code *}{@code /}-escape implementation: neutralize any {@code *}{@code /} in arbitrary + * text so it cannot terminate an enclosing block comment early. Newlines are kept — used for the + * footer comment, where multi-line text stays multi-line inside the block. + */ + private static String escapeBlockCommentEnd(String text) { + return text.replace("*/", "* /"); + } + + /** + * Make arbitrary text safe to embed inside an inline {@code /* ... *}{@code /} block + * comment: {@link #escapeBlockCommentEnd} plus folding newlines to spaces so the comment stays on + * one line. + */ + private static String safeBlockComment(String text) { + return escapeBlockCommentEnd(text).replace('\n', ' ').replace('\r', ' '); + } + + /** A {@code --} line comment cannot span lines: fold any newline to a space. */ + private static String safeLineComment(String text) { + return text.replace('\n', ' ').replace('\r', ' '); + } + + private boolean lineComments(RenderOptions o) { + return o.comments() && o.effectiveCommentStyle() == RenderOptions.CommentStyle.LINE; + } + + private boolean blockComments(RenderOptions o) { + return o.comments() && o.effectiveCommentStyle() == RenderOptions.CommentStyle.BLOCK; + } + + /** LINE style: the comment on its own line BEFORE an element — {@code "-- text\n"} — or "". */ + private String lineCommentBefore(java.util.Optional c, RenderOptions o, String indent) { + return (lineComments(o) && c.isPresent()) + ? "-- " + safeLineComment(c.get()) + System.lineSeparator() + indent : ""; + } + + /** BLOCK style: the comment inline AFTER an element — {@code " /* text *}{@code /"} — or "". */ + private String blockCommentAfter(java.util.Optional c, RenderOptions o) { + return (blockComments(o) && c.isPresent()) ? " /* " + safeBlockComment(c.get()) + " */" : ""; + } + + private RenderedSql renderSelect(SelectStatement s, RenderOptions options) { + final String itemSep = fmtKw(", ", options); + StringBuilder sb = new StringBuilder(); + List types = new ArrayList<>(); + + // WHERE accumulates the explicit filters plus anything pushed down while rendering + // the FROM clause (non-ANSI comma joins, per-table filters). + List where = new ArrayList<>(s.filters()); + String fromSql = s.from().map(f -> renderFrom(f, where, options)).orElse(null); + + if (options.comments()) { + s.headerComment().ifPresent(h -> { + for (String line : h.split("\\R")) { + sb.append("-- ").append(line).append(System.lineSeparator()); + } + }); + } + + // Statement hints are always emitted (never comment-gated: the DBMS optimizer reads them). + // The selectHint block goes directly after the SELECT keyword, before DISTINCT; when the + // dialect emits none (the default), the keyword renders byte-identically to a hint-free build. + String selectHint = s.statementHints().isEmpty() ? "" + : dialect.hintGenerator().selectHint(s.statementHints()).toString(); + if (selectHint.isEmpty()) { + sb.append(fmtKw(s.distinct() ? "select distinct " : "select ", options)); + } else { + sb.append(fmtKw("select " + selectHint + (s.distinct() ? "distinct " : ""), options)); + } + s.rowLimit().ifPresent(rl -> dialect.paginationGenerator().selectPrefix(rl.maxRows(), rl.offset()) + .ifPresent(prefix -> sb.append(prefix).append(' '))); + + boolean first = true; + for (int i = 0; i < s.projections().size(); i++) { + Projection p = s.projections().get(i); + if (!first) { + sb.append(itemSep); + } + first = false; + sb.append(lineCommentBefore(p.comment(), options, options.indent())); + String exprSql = renderExpression(p.expression()); + sb.append(exprSql); + String alias = effectiveAlias(p, i); + // A whole-row projection can never be aliased ("select * as c0" is invalid SQL): suppress for + // the structured Star node and — kept for byte-compat with existing consumers — a Raw("*"). + if (alias != null && !(p.expression() instanceof SqlExpression.Star) && !"*".equals(exprSql)) { + sb.append(" as ").append(dialect.quoteIdentifier(alias)); + } + sb.append(blockCommentAfter(p.comment(), options)); + types.add(p.columnType()); + } + // GROUPING(...) super-aggregate columns are appended as extra select items. + int g = 0; + for (GroupBy.GroupingFunction gf : s.groupBy().groupingFunctions()) { + sb.append(itemSep).append(dialect.functionGenerator().generateGrouping(renderExpression(gf.argument()))); + sb.append(" as ").append(dialect.quoteIdentifier("g" + g++)); + types.add(null); + } + + if (fromSql != null) { + sb.append(fmtKw(" from ", options)).append(fromSql); + } + if (!where.isEmpty()) { + sb.append(fmtKw(" where ", options)).append(renderWhere(where, options, s.filterComments())); + } + renderGroupBy(s.groupBy(), s.projections(), sb, options); + if (!s.having().isEmpty()) { + // HAVING shares the filterComments map (keyed by predicate identity) so each conjunct can carry + // its provenance comment (native Filter measure condition); byte-identical when comments are off. + // Expose the projections so a Regexp source resolves to its SELECT alias (requiresHavingAlias). + List prevHaving = havingAliasProjections; + havingAliasProjections = s.projections(); + sb.append(fmtKw(" having ", options)).append(renderWhere(s.having(), options, s.filterComments())); + havingAliasProjections = prevHaving; + } + if (!s.orderKeys().isEmpty()) { + sb.append(fmtKw(" order by ", options)); + boolean firstOrder = true; + for (OrderKey k : s.orderKeys()) { + if (!firstOrder) { + sb.append(itemSep); + } + firstOrder = false; + java.util.Optional oc = orderKeyComment(k, s.projections()); + sb.append(lineCommentBefore(oc, options, options.indent())); + sb.append(renderOrderKey(k, s.projections())); + sb.append(blockCommentAfter(oc, options)); + } + } + s.rowLimit().ifPresent(rl -> sb.append(dialect.paginationGenerator().paginate(rl.maxRows(), rl.offset()))); + // Trailing statement option (SQL Server OPTION (...)) goes after the pagination suffix; the + // footer comment (appended by render()) stays last. + if (!s.statementHints().isEmpty()) { + sb.append(dialect.hintGenerator().statementOption(s.statementHints())); + } + + return RenderedSql.of(sb.toString(), Collections.unmodifiableList(types)); + } + + private String effectiveAlias(Projection p, int ordinal) { + if (p.alias().isPresent()) { + return p.alias().get().name(); + } + if (dialect.allowsFieldAlias()) { + return "c" + ordinal; + } + return null; + } + + // ---- FROM ------------------------------------------------------------------ + + private String renderFrom(FromClause from, List whereSink, RenderOptions options) { + if (from instanceof FromClause.FromTable t) { + StringBuilder b = new StringBuilder(); + // Provenance comment for a table carrying one (the FROM's BASE table — joined tables carry + // theirs on the FromJoin): LINE style on its own line before the table, BLOCK style inline + // before it. Byte-neutral when comments are off or the table carries none. + if (t.comment().isPresent()) { + if (lineComments(options)) { + b.append("-- ").append(safeLineComment(t.comment().get())) + .append(System.lineSeparator()).append(options.indent()); + } else if (blockComments(options)) { + b.append("/* ").append(safeBlockComment(t.comment().get())).append(" */ "); + } + } + appendQualifiedTable(b, t.table()); + b.append(dialect.allowsFromAlias() ? " as " : " ").append(dialect.quoteIdentifier(t.alias().name())); + if (!t.hints().isEmpty()) { + dialect.hintGenerator().appendHintsAfterFromClause(b, t.hints()); + } + t.filter().ifPresent(whereSink::add); + return b.toString(); + } + if (from instanceof FromClause.FromSubquery sq) { + String inner = renderSelect(sq.query(), nestedOptions(options)).sql(); + return derivedTable(inner, sq.alias().name(), options); + } + if (from instanceof FromClause.FromSet fs) { + // Internal renderSet (not render): nested bind parameters accumulate in placeholder order. + String inner = renderSet(fs.set(), nestedOptions(options)).sql(); + return derivedTable(inner, fs.alias().name(), options); + } + if (from instanceof FromClause.FromRaw r) { + return "(" + r.sql() + ")" + (dialect.allowsFromAlias() ? " as " : " ") + + dialect.quoteIdentifier(r.alias().name()); + } + if (from instanceof FromClause.FromVariant v) { + // Resolve the per-dialect map here (the one render-time pick), then render exactly like FromRaw. + return "(" + chooseVariant(v.byDialectName()) + ")" + (dialect.allowsFromAlias() ? " as " : " ") + + dialect.quoteIdentifier(v.alias().name()); + } + if (from instanceof FromClause.FromInline inl) { + // Generate the inline-table VALUES SQL for the live dialect here (the one render-time dialect touch), + // then wrap like a derived table — the same generateInline call the legacy build path made. + return "(" + dialect.sqlGenerator().generateInline(inl.columnNames(), inl.columnTypes(), inl.rows()) + + ")" + (dialect.allowsFromAlias() ? " as " : " ") + dialect.quoteIdentifier(inl.alias().name()); + } + if (from instanceof FromClause.FromProduct prod) { + // Comma product: no predicate of its own — the caller put any join conditions in WHERE. + return prod.items().stream().map(item -> renderFrom(item, whereSink, options)) + .collect(Collectors.joining(fmtKw(", ", options))); + } + if (from instanceof FromClause.FromJoin j) { + String left = renderFrom(j.left(), whereSink, options); + String right = renderFrom(j.right(), whereSink, options); + if (j.kind() == JoinKind.CROSS) { + return left + " cross join " + right; + } + // Outer joins (LEFT/RIGHT/FULL) must use ANSI ON; only INNER may fall back to comma + WHERE. + boolean ansi = dialect.allowsJoinOn() || j.kind() != JoinKind.INNER; + if (ansi) { + String kw = switch (j.kind()) { + case LEFT -> "left join "; + case RIGHT -> "right join "; + case FULL -> "full join "; + default -> "join "; + }; + // Parenthesize a nested join on the RIGHT so a non-left-deep tree renders unambiguously + // (`a join (b join c on …) on …`); a left-deep tree's right side is a leaf and needs none. + String rightSql = j.right() instanceof FromClause.FromJoin ? "(" + right + ")" : right; + String onSql = " on " + renderPredicate(j.on()); + if (lineComments(options)) { + // LINE style: one join per line, with any comment on its own line above the join. + String ind = options.indent(); + String cmt = j.comment().isPresent() + ? "-- " + safeLineComment(j.comment().get()) + System.lineSeparator() + ind : ""; + return left + System.lineSeparator() + ind + cmt + kw + rightSql + onSql; + } + String joinComment = blockComments(options) && j.comment().isPresent() + ? "/* " + safeBlockComment(j.comment().get()) + " */ " + : ""; + return left + " " + kw + joinComment + rightSql + onSql; + } + whereSink.add(j.on()); + return left + fmtKw(", ", options) + right; + } + throw new IllegalArgumentException("unsupported from clause: " + from); + } + + /** + * True in the diagnostic combination — formatted AND comments on — where derived tables in FROM + * render formatted (with comments) instead of the default compact-without-comments. Never true + * for executed SQL (comments off), so the executed bytes are unchanged. + */ + private static boolean nestedFormatted(RenderOptions o) { + return o.formatted() && o.comments(); + } + + /** + * The render options for a derived table's inner statement: the enclosing options in diagnostic + * mode (formatted + comments propagate, keeping the comment style), plain compact otherwise. + */ + private static RenderOptions nestedOptions(RenderOptions enclosing) { + return nestedFormatted(enclosing) ? enclosing : RenderOptions.compact(); + } + + /** + * Wraps a rendered inner statement as an aliased derived table. Compact: {@code () as + * } on one line, byte-identical to the historical output. Diagnostic mode + * ({@link #nestedFormatted}): the parentheses go on their own lines and every inner line is + * prefixed with two indent units (one for the paren level the enclosing FROM already opened, one + * for the content) so the nesting reads clearly — indent stacking, applied per nesting depth + * because inner derived tables run through this same wrapper again. + */ + private String derivedTable(String innerSql, String aliasName, RenderOptions options) { + String aliasSql = (dialect.allowsFromAlias() ? " as " : " ") + dialect.quoteIdentifier(aliasName); + if (!nestedFormatted(options)) { + return "(" + innerSql + ")" + aliasSql; + } + String nl = System.lineSeparator(); + String contentIndent = options.indent() + options.indent(); + String indented = innerSql.lines().map(l -> l.isEmpty() ? l : contentIndent + l) + .collect(Collectors.joining(nl)); + return "(" + nl + indented + nl + options.indent() + ")" + aliasSql; + } + + /** + * Resolve a per-dialect SQL-fragment map to the fragment for this renderer's dialect. The one render-time + * dialect pick for {@link FromClause.FromVariant} (and any future expression variant) — a 1:1 lift of the + * legacy {@code ViewCodeSet.chooseQuery} fallback: the live dialect's entry, else {@code "generic"}, else + * an error. + */ + private String chooseVariant(java.util.Map byDialectName) { + String picked = byDialectName.get(dialect.name()); + if (picked != null) { + return picked; + } + String generic = byDialectName.get("generic"); + if (generic == null) { + throw new IllegalArgumentException( + "no SQL variant for dialect '" + dialect.name() + "' and no 'generic' fallback"); + } + return generic; + } + + // ---- GROUP BY -------------------------------------------------------------- + + private void renderGroupBy(GroupBy gb, List projections, StringBuilder sb, RenderOptions options) { + if (gb.isEmpty()) { + return; + } + if (!gb.groupingSets().isEmpty() && dialect.supportsGroupingSets()) { + sb.append(fmtKw(" group by grouping sets (", options)); + sb.append(gb.groupingSets().stream() + .map(set -> "(" + set.keys().stream().map(this::renderExpression) + .collect(Collectors.joining(", ")) + ")") + .collect(Collectors.joining(", "))); + sb.append(")"); + return; + } + // Plain GROUP BY: explicit keys, plus (when grouping sets are unsupported) the distinct + // union of all grouping-set keys as a best-effort fallback. Dedup on the rendered key string, + // keeping each key's comment (reused from the projection it references). + java.util.LinkedHashMap> keyed = new java.util.LinkedHashMap<>(); + for (GroupBy.GroupKey key : gb.keys()) { + keyed.putIfAbsent(renderGroupKey(key, projections), groupKeyComment(key, projections)); + } + for (GroupBy.GroupingSet set : gb.groupingSets()) { + for (SqlExpression e : set.keys()) { + keyed.putIfAbsent(renderExpression(e), java.util.Optional.empty()); + } + } + if (!keyed.isEmpty()) { + sb.append(fmtKw(" group by ", options)); + String sep = fmtKw(", ", options); + boolean firstKey = true; + for (var entry : keyed.entrySet()) { + if (!firstKey) { + sb.append(sep); + } + firstKey = false; + sb.append(lineCommentBefore(entry.getValue(), options, options.indent())); + sb.append(entry.getKey()); + sb.append(blockCommentAfter(entry.getValue(), options)); + } + } + } + + private java.util.Optional groupKeyComment(GroupBy.GroupKey key, List projections) { + if (key instanceof GroupBy.GroupKey.Ref ref) { + return projections.get(ref.projection().ordinal()).comment(); + } + return java.util.Optional.empty(); + } + + private java.util.Optional orderKeyComment(OrderKey key, List projections) { + return key.projectionRef().map(r -> projections.get(r.ordinal()).comment()) + .orElse(java.util.Optional.empty()); + } + + private String renderGroupKey(GroupBy.GroupKey key, List projections) { + if (key instanceof GroupBy.GroupKey.Ref ref) { + int ordinal = ref.projection().ordinal(); + Projection p = projections.get(ordinal); + if (dialect.requiresGroupByAlias()) { + String alias = effectiveAlias(p, ordinal); + if (alias != null) { + return dialect.quoteIdentifier(alias); + } + } + return renderExpression(p.expression()); + } + GroupBy.GroupKey.Expr expr = (GroupBy.GroupKey.Expr) key; + return renderExpression(expr.expression()); + } + + // ---- ORDER BY -------------------------------------------------------------- + + private String renderOrderKey(OrderKey key, List projections) { + String exprSql; + if (key.projectionRef().isPresent() && dialect.requiresOrderByAlias()) { + int ordinal = key.projectionRef().get().ordinal(); + String alias = effectiveAlias(projections.get(ordinal), ordinal); + exprSql = alias != null ? dialect.quoteIdentifier(alias) : renderExpression(key.expression()); + } else { + exprSql = renderExpression(key.expression()); + } + SortSpec spec = key.sort(); + if (spec.collation().isPresent()) { + // COLLATE binds to the key expression (not the direction); the name is a + // dialect-specific identifier the caller chose - emitted verbatim (SortSpec javadoc). + exprSql = exprSql + " collate " + spec.collation().get(); + } + boolean ascending = spec.direction() == SortDirection.ASC; + boolean collateNullsLast = spec.nullOrder() != NullOrder.FIRST; + if (spec.nullSortValue() != null) { + // Order nulls as if they held nullSortValue (e.g. a parent-child hierarchy nullParentValue). + return dialect.orderByGenerator().generateOrderItemForOrderValue( + exprSql, spec.nullSortValue(), spec.nullSortDatatype(), ascending, collateNullsLast).toString(); + } + return dialect.orderByGenerator().generateOrderItem(exprSql, spec.nullable(), ascending, collateNullsLast) + .toString(); + } + + // ---- expressions & predicates ---------------------------------------------- + + /** Renders a single scalar expression to its dialect SQL (e.g. a native-SQL column fragment). */ + public String renderExpression(SqlExpression e) { + if (e instanceof SqlExpression.Column c) { + if (c.name() == null) { + // Degenerate key-less join column: render just the (quoted) qualifier. + return c.tableQualifier().map(dialect::quoteIdentifier).orElse(""); + } + String name = dialect.quoteIdentifier(c.name()); + return c.tableQualifier().map(q -> dialect.quoteIdentifier(q) + "." + name).orElse(name); + } + if (e instanceof SqlExpression.Literal l) { + StringBuilder b = new StringBuilder(); + dialect.quote(b, l.value(), l.datatype()); + return b.toString(); + } + if (e instanceof SqlExpression.Call c) { + boolean distinct = c instanceof SqlExpression.Aggregate a && a.distinct(); + return c.name() + "(" + (distinct ? "distinct " : "") + + c.arguments().stream().map(this::renderExpression).collect(Collectors.joining(", ")) + ")"; + } + if (e instanceof SqlExpression.Binary b) { + String rendered = renderExpression(b.left()) + " " + b.operator().symbol() + " " + + renderExpression(b.right()); + return b.parenthesized() ? "(" + rendered + ")" : rendered; + } + if (e instanceof SqlExpression.Case c) { + // The single-when if-then-else form delegates to the dialect's FunctionGenerator — the ONE + // home of that spelling (default 'CASE WHEN … THEN … ELSE … END'; Access spells IIF(c,a,b)). + // This makes a Case node render byte-identically to producers that historically called + // wrapIntoSqlIfThenElseFunction directly (the ROLAP native IIF compiler). + if (c.whens().size() == 1 && c.elseResult().isPresent()) { + SqlExpression.Case.WhenClause w = c.whens().get(0); + return dialect.functionGenerator().wrapIntoSqlIfThenElseFunction( + renderPredicate(w.condition()), + renderExpression(w.result()), + renderExpression(c.elseResult().get())).toString(); + } + // Multi-when / no-else: generic lowercase form (no dialect generator covers it; no producer + // emits this shape yet). + StringBuilder sb = new StringBuilder("case"); + for (SqlExpression.Case.WhenClause w : c.whens()) { + sb.append(" when ").append(renderPredicate(w.condition())).append(" then ") + .append(renderExpression(w.result())); + } + c.elseResult().ifPresent(er -> sb.append(" else ").append(renderExpression(er))); + return sb.append(" end").toString(); + } + if (e instanceof SqlExpression.Param pm) { + parameters.add(new BoundParameter(pm.value(), pm.bound(), pm.datatype())); + return dialect.parameterPlaceholderGenerator().placeholder(parameters.size()); + } + if (e instanceof SqlExpression.ScalarSubquery sq) { + // Compact like every nested context; nested bind parameters accumulate in placeholder order. + return "(" + renderInternal(sq.query(), RenderOptions.compact()).sql() + ")"; + } + if (e instanceof SqlExpression.Star st) { + return st.tableQualifier().map(q -> dialect.quoteIdentifier(q) + ".*").orElse("*"); + } + if (e instanceof SqlExpression.Ordinal o) { + return Integer.toString(o.position()); + } + if (e instanceof SqlExpression.Raw r) { + return r.sql(); + } + if (e instanceof SqlExpression.RawVariant v) { + return chooseVariant(v.byDialectName()); + } + if (e instanceof SqlExpression.ExtraAggregate ea) { + return renderExtraAggregate(ea); + } + if (e instanceof SqlExpression.CaseFold cf) { + return dialect.functionGenerator().wrapIntoSqlUpperCaseFunction(renderExpression(cf.inner())).toString(); + } + if (e instanceof SqlExpression.KnownCall k) { + // The dialect's FunctionGenerator picks the spelling for the portable function intent. + List renderedArgs = k.arguments().stream().map(this::renderExpression).toList(); + return dialect.functionGenerator().generateKnownFunction(k.function(), renderedArgs).toString(); + } + throw new IllegalArgumentException("unsupported expression: " + e); + } + + /** + * Generates the SQL for a dialect-generated extra aggregate at render time via + * {@code dialect.aggregationGenerator()} — the one dialect touch, dispatched per kind. The generators + * return {@code Optional.empty()} when the dialect does not support the aggregate; that is an error here + * (the legacy code returned {@code null} for the same case). + */ + private String renderExtraAggregate(SqlExpression.ExtraAggregate ea) { + org.eclipse.daanse.jdbc.db.dialect.api.generator.AggregationGenerator g = dialect.aggregationGenerator(); + CharSequence operand = ea.operand().map(this::renderExpression).orElse(null); + SqlExpression.ExtraAggregate.Spec spec = ea.spec(); + java.util.Optional sql; + if (spec instanceof SqlExpression.ExtraAggregate.Spec.Percentile p) { + sql = p.continuous() + ? g.generatePercentileCont(p.fraction(), p.descending(), p.tableName(), p.columnName()) + : g.generatePercentileDisc(p.fraction(), p.descending(), p.tableName(), p.columnName()); + } else if (spec instanceof SqlExpression.ExtraAggregate.Spec.ListAgg l) { + sql = g.generateListAgg(operand, l.distinct(), l.separator(), l.coalesce(), l.onOverflowTruncate(), + l.columns()); + } else if (spec instanceof SqlExpression.ExtraAggregate.Spec.BitAggregation b) { + sql = g.generateBitAggregation(b.operation(), operand); + } else if (spec instanceof SqlExpression.ExtraAggregate.Spec.NthValue nv) { + sql = g.generateNthValueAgg(operand, nv.ignoreNulls(), nv.n(), nv.columns()); + } else { + throw new IllegalArgumentException("unsupported extra-aggregate spec: " + spec); + } + return sql.orElseThrow(() -> new IllegalStateException( + "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)); + } + + /** + * WHERE-specific rendering: like {@link #renderPredicateList} but emits a per-conjunct comment (member + * path / slicer / role-access) when comments are on. Byte-identical to {@code renderPredicateList} when + * comments are off or no filter carries a comment (the common case). + */ + private String renderWhere(List preds, RenderOptions options, java.util.Map comments) { + if (!options.comments() || comments.isEmpty()) { + return renderPredicateList(preds, fmtKw(" and ", options)); + } + String sep = fmtKw(" and ", options); + StringBuilder b = new StringBuilder(); + boolean first = true; + for (Predicate p : preds) { + if (!first) { + b.append(sep); + } + 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(blockCommentAfter(c, options)); + } + return b.toString(); + } + + /** + * Renders a single predicate to its dialect SQL fragment (no surrounding SELECT context). Used by + * callers that need a standalone predicate string, e.g. segment-cache-key construction. + */ + public String renderPredicate(Predicate p) { + if (p instanceof Predicate.Comparison c) { + return renderExpression(c.left()) + " " + symbol(c.operator()) + " " + renderExpression(c.right()); + } + if (p instanceof Predicate.In in) { + return renderExpression(in.expression()) + " in (" + + in.values().stream().map(this::renderExpression).collect(Collectors.joining(", ")) + ")"; + } + 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 ...). + List colSql = it.columns().stream().map(this::renderExpression).toList(); + String ors = it.rows().stream() + .map(row -> { + StringBuilder b = new StringBuilder("("); + for (int i = 0; i < colSql.size(); i++) { + if (i > 0) { + b.append(" and "); + } + b.append(colSql.get(i)).append(" = ").append(renderExpression(row.get(i))); + } + 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() + .map(row -> "(" + row.stream().map(this::renderExpression).collect(Collectors.joining(", ")) + ")") + .collect(Collectors.joining(", ")); + return "(" + cols + ") in (" + rows + ")"; + } + if (p instanceof Predicate.IsNull n) { + return renderExpression(n.expression()) + (n.negated() ? " is not null" : " is null"); + } + if (p instanceof Predicate.Like l) { + String base = renderExpression(l.expression()) + (l.negated() ? " not like " : " like ") + + renderExpression(l.pattern()); + return base + l.escape().map(c -> " escape '" + c + "'").orElse(""); + } + if (p instanceof Predicate.Between b) { + return renderExpression(b.expression()) + (b.negated() ? " not between " : " between ") + + renderExpression(b.low()) + " and " + renderExpression(b.high()); + } + if (p instanceof Predicate.Not not) { + return "not (" + renderPredicate(not.operand()) + ")"; + } + if (p instanceof Predicate.Exists ex) { + // Compact like every nested context; nested bind parameters accumulate in placeholder order. + return (ex.negated() ? "not exists (" : "exists (") + + renderInternal(ex.query(), RenderOptions.compact()).sql() + ")"; + } + if (p instanceof Predicate.Constant c) { + return constantPredicate(c.value()); + } + if (p instanceof Predicate.Connective conn) { + boolean and = conn instanceof Predicate.And; + if (conn.operands().isEmpty()) { + return constantPredicate(and); + } + return "(" + renderPredicateList(conn.operands(), and ? " and " : " or ") + ")"; + } + if (p instanceof Predicate.Raw r) { + return r.sql(); + } + if (p instanceof Predicate.Regexp re) { + // The dialect produces the whole fragment (null-guard + optional UPPER + regex operator). Use the + // SELECT alias (c5) for the source ONLY when the dialect requiresHavingAlias (MySQL family): + // most dialects (PostgreSQL, Oracle, MSSQL, Derby) do NOT resolve select-list aliases in HAVING + // — an unconditional alias swap for computed (RawVariant) sources rendered `cast(c5 as text) ~ …` + // and failed at execution there. A computed or plain-column source re-inlines its expression + // (which is part of GROUP BY, so it is legal in HAVING on every dialect), matching the legacy + // string channel (MatchingSqlCompiler.compile: alias iff requiresHavingAlias). MySQL byte-neutral: + // requiresHavingAlias=true keeps the alias arm. + String alias = dialect.requiresHavingAlias() + ? aliasForHavingSource(re.source()) : null; + String srcSql = alias != null ? alias : renderExpression(re.source()); + String frag = dialect.regexGenerator().generateRegularExpression(srcSql, re.pattern()) + .orElseThrow(() -> new IllegalArgumentException("dialect has no regular-expression support")); + // Legacy RolapNativeSql.MatchingSqlCompiler wraps a negated match as NOT() — uppercase, no + // space — so match that exactly (a lowercase `not (` diverges from the legacy HAVING string). + return re.negated() ? "NOT(" + frag + ")" : frag; + } + throw new IllegalArgumentException("unsupported predicate: " + p); + } + + /** + * The SELECT alias of the projection whose expression equals {@code source}, or {@code null} when not in + * a HAVING render or no projection matches (the caller then renders the source expression directly). + */ + private String aliasForHavingSource(SqlExpression source) { + if (havingAliasProjections == null) { + return null; + } + for (int i = 0; i < havingAliasProjections.size(); i++) { + org.eclipse.daanse.sql.statement.api.model.Projection p = havingAliasProjections.get(i); + if (source.equals(p.expression())) { + return effectiveAlias(p, i); + } + } + return null; + } + + /** + * The one spelling of a constant truth value — {@code 1 = 1} (true) / {@code 1 = 0} (false), no + * parentheses. Used by {@link Predicate.Constant} and by an empty {@link Predicate.Connective}. + */ + private static String constantPredicate(boolean value) { + return value ? "1 = 1" : "1 = 0"; + } + + private static String symbol(ComparisonOperator op) { + return op.symbol(); + } + + // ---- set operations -------------------------------------------------------- + + private RenderedSql renderSet(SetOperation so, RenderOptions options) { + if (so.inputs().size() < 2) { + throw new IllegalArgumentException("set operation needs at least two inputs"); + } + // Render each input exactly once (so its parameters accumulate once, in order). Inputs are + // nested contexts: compact, except in diagnostic mode (formatted + comments) where they render + // formatted with comments and the set keyword sits on its own line between them. + List rendered = new ArrayList<>(); + for (Statement in : so.inputs()) { + rendered.add(renderInternal(in, nestedOptions(options))); + } + // The plain (duplicate-eliminating) UNION spelling is a dialect decision: + // ClickHouse requires the explicit "union distinct" (EXPECTED_ALL_OR_DISTINCT); + // every other dialect keeps the byte-identical bare "union". + String keyword = so.op() == SetOperation.SetOp.UNION + ? dialect.unionDistinctKeyword() + : so.op().keyword(); + String sep = nestedFormatted(options) + ? System.lineSeparator() + keyword + System.lineSeparator() + : " " + keyword + " "; + StringBuilder sb = new StringBuilder(rendered.stream().map(RenderedSql::sql) + .collect(Collectors.joining(sep))); + if (!so.orderKeys().isEmpty()) { + List none = List.of(); + sb.append(" order by ").append( + so.orderKeys().stream().map(k -> renderOrderKey(k, none)).collect(Collectors.joining(", "))); + } + so.rowLimit().ifPresent(rl -> sb.append(dialect.paginationGenerator().paginate(rl.maxRows(), rl.offset()))); + // Column types are those of the first input. + return RenderedSql.of(sb.toString(), rendered.get(0).columnTypes()); + } +} diff --git a/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/render/package-info.java b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/render/package-info.java new file mode 100644 index 0000000..9c87f29 --- /dev/null +++ b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/render/package-info.java @@ -0,0 +1,17 @@ +/* + * 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 + */ + +@org.osgi.annotation.bundle.Export +@org.osgi.annotation.versioning.Version("0.0.1") +package org.eclipse.daanse.sql.statement.render; diff --git a/statement/pom.xml b/statement/pom.xml new file mode 100644 index 0000000..c6d656a --- /dev/null +++ b/statement/pom.xml @@ -0,0 +1,34 @@ + + + + 4.0.0 + + org.eclipse.daanse + org.eclipse.daanse.sql + ${revision} + + org.eclipse.daanse.sql.statement + pom + Daanse SQL Query Builder + Dialect-aware, domain-agnostic SQL SELECT query builder. Provides + an immutable query model, a fluent assembler, and a dialect-driven renderer + that can be reused by any caller (e.g. ROLAP cube querying or an ORM entity loader) to + produce portable SQL via the JDBC dialect API. + + + api + impl + +