From 91eb3d5906dc50f9ae086aab638f96c3a06de0d2 Mon Sep 17 00:00:00 2001 From: Stefan Bischof Date: Sat, 31 Oct 2026 10:26:29 +0100 Subject: [PATCH] dialect: known functions, portable expressions, native types, and fixes from a database run - known functions MONTH_NAME, TO_TEXT, PAD_RIGHT, DATE_FROM_PARTS and TO_WIDE_INTEGER; an expression and a derived table written once for every database beside the texts of some dialects (SqlExpression.Portable, FromClause.FromPortable) - native types: SQL Server keeps national text national, long text and binary are MAX; DuckDB long text is VARCHAR, binary BLOB; PostgreSQL and MySQL name their large objects (a BLOB is an OID as pgjdbc binds it, other binary BYTEA; a VARBINARY without a length is a LONGBLOB) - MySQL reads AVG as a double; SQL Server declines a distinct text list; Derby spells a typed null, keeps a text null untyped and reads a part of a date by its own YEAR, MONTH and DAY - a capability says whether SUM keeps its operand's type (SQL Server, Derby: an INTEGER column overflows past 2 147 483 647), and TO_WIDE_INTEGER widens the operand Co-authored-by: dbulahov Signed-off-by: dbulahov Signed-off-by: Stefan Bischof --- .../DialectCapabilitiesProvider.java | 12 +++ .../api/generator/FunctionGenerator.java | 37 ++++++++ .../dialect/api/generator/KnownFunction.java | 31 ++++++- .../KnownFunctionDefaultSpellingTest.java | 16 +++- .../sql/dialect/db/derby/DerbyDialect.java | 43 +++++++++ .../dialect/db/derby/DerbyDialectTest.java | 36 ++++++++ dialect/db/duckdb/pom.xml | 6 ++ .../sql/dialect/db/duckdb/DuckDbDialect.java | 47 ++++++++++ .../db/duckdb/DuckDbKnownFunctionTest.java | 38 ++++++++ .../db/duckdb/DuckDbNativeTypeTest.java | 48 ++++++++++ .../MicrosoftSqlServerDialect.java | 71 ++++++++++++++- .../MicrosoftSqlServerGeneratorsTest.java | 31 +++++++ .../sqlgen/MssqlNativeTypeTest.java | 62 +++++++++++++ .../sql/dialect/db/mysql/MySqlDialect.java | 51 +++++++++++ .../dialect/db/mysql/MySqlGeneratorsTest.java | 21 +++++ .../dialect/db/mysql/MySqlNativeTypeTest.java | 50 +++++++++++ .../sql/dialect/db/oracle/OracleDialect.java | 14 +++ .../db/oracle/OracleGeneratorsTest.java | 10 +++ .../db/postgresql/PostgreSqlDialect.java | 37 ++++++++ .../postgresql/PostgreSqlGeneratorsTest.java | 7 ++ .../postgresql/PostgreSqlNativeTypeTest.java | 49 ++++++++++ .../daanse/sql/statement/api/Expressions.java | 13 +++ .../api/expression/SqlExpression.java | 15 ++++ .../sql/statement/api/model/FromClause.java | 19 +++- .../statement/render/DialectSqlRenderer.java | 15 ++++ .../render/PortableRenderingTest.java | 89 +++++++++++++++++++ 26 files changed, 861 insertions(+), 7 deletions(-) create mode 100644 dialect/db/duckdb/src/test/java/org/eclipse/daanse/sql/dialect/db/duckdb/DuckDbKnownFunctionTest.java create mode 100644 dialect/db/duckdb/src/test/java/org/eclipse/daanse/sql/dialect/db/duckdb/DuckDbNativeTypeTest.java create mode 100644 dialect/db/mssqlserver/src/test/java/org/eclipse/daanse/sql/dialect/db/mssqlserver/sqlgen/MssqlNativeTypeTest.java create mode 100644 dialect/db/mysql/src/test/java/org/eclipse/daanse/sql/dialect/db/mysql/MySqlNativeTypeTest.java create mode 100644 dialect/db/postgresql/src/test/java/org/eclipse/daanse/sql/dialect/db/postgresql/PostgreSqlNativeTypeTest.java create mode 100644 statement/impl/src/test/java/org/eclipse/daanse/sql/statement/render/PortableRenderingTest.java diff --git a/dialect/api/src/main/java/org/eclipse/daanse/sql/dialect/api/capability/DialectCapabilitiesProvider.java b/dialect/api/src/main/java/org/eclipse/daanse/sql/dialect/api/capability/DialectCapabilitiesProvider.java index 0e0dd4e..0c6dc6d 100644 --- a/dialect/api/src/main/java/org/eclipse/daanse/sql/dialect/api/capability/DialectCapabilitiesProvider.java +++ b/dialect/api/src/main/java/org/eclipse/daanse/sql/dialect/api/capability/DialectCapabilitiesProvider.java @@ -141,6 +141,18 @@ default boolean supportsDropTableIfExists() { return true; } + /** + * Whether {@code SUM} keeps the type of its operand: SQL Server and Derby sum an INTEGER column as an INTEGER and + * fail past 2 147 483 647 ("Arithmetic overflow error converting expression to data type int"); PostgreSQL, + * MySQL, Oracle, H2 and DuckDB sum it wider. Where true, the engine widens the operand of a sum over a whole number + * column ({@code TO_WIDE_INTEGER}). + * + * @return true if a sum of an INTEGER operand is itself an INTEGER + */ + default boolean sumKeepsTheTypeOfItsOperand() { + return false; + } + /** * @return true if the dialect honours {@code IF EXISTS} on {@code DROP SCHEMA} */ diff --git a/dialect/api/src/main/java/org/eclipse/daanse/sql/dialect/api/generator/FunctionGenerator.java b/dialect/api/src/main/java/org/eclipse/daanse/sql/dialect/api/generator/FunctionGenerator.java index 05d1501..ed6dda0 100644 --- a/dialect/api/src/main/java/org/eclipse/daanse/sql/dialect/api/generator/FunctionGenerator.java +++ b/dialect/api/src/main/java/org/eclipse/daanse/sql/dialect/api/generator/FunctionGenerator.java @@ -108,6 +108,10 @@ yield new StringBuilder("POSITION(").append(arguments.get(0)).append(" IN ").app yield new StringBuilder("EXTRACT(").append(function.name()).append(" FROM ").append(arguments.get(0)) .append(")"); } + case TO_WIDE_INTEGER -> { + checkArity(function, arguments, 1, 1); + yield new StringBuilder("CAST(").append(arguments.get(0)).append(" AS BIGINT)"); + } case DATE -> { checkArity(function, arguments, 1, 1); yield new StringBuilder("CAST(").append(arguments.get(0)).append(" AS DATE)"); @@ -141,6 +145,39 @@ yield new StringBuilder(function.name()).append("(").append(arguments.get(0)).ap checkArity(function, arguments, 0, 0); yield new StringBuilder("CURRENT_TIMESTAMP"); } + case MONTH_NAME -> { + // no standard function names a month: the case over the month number is the same in every database and + // independent of its language settings + checkArity(function, arguments, 1, 1); + // the month number in this dialect's own spelling + StringBuilder sb = new StringBuilder("CASE ").append(generateKnownFunction(KnownFunction.MONTH, arguments)); + String[] months = { "January", "February", "March", "April", "May", "June", "July", "August", + "September", "October", "November", "December" }; + for (int i = 0; i < months.length; i++) { + sb.append(" WHEN ").append(i + 1).append(" THEN '").append(months[i]).append("'"); + } + yield sb.append(" END"); + } + case TO_TEXT -> { + checkArity(function, arguments, 1, 1); + yield new StringBuilder("CAST(").append(arguments.get(0)).append(" AS VARCHAR(4000))"); + } + case PAD_RIGHT -> { + checkArity(function, arguments, 2, 2); + yield new StringBuilder("RPAD(").append(arguments.get(0)).append(", ").append(arguments.get(1)) + .append(", ' ')"); + } + case DATE_FROM_PARTS -> { + // no standard function builds a date from its parts: the ISO text yyyy-mm-dd, which a database reads as a + // date whatever its language, joined in this dialect's own spelling + checkArity(function, arguments, 3, 3); + java.util.List iso = java.util.List.of( + "CAST(" + arguments.get(0) + " AS VARCHAR(4))", "'-'", + "LPAD(CAST(" + arguments.get(1) + " AS VARCHAR(2)), 2, '0')", "'-'", + "LPAD(CAST(" + arguments.get(2) + " AS VARCHAR(2)), 2, '0')"); + yield new StringBuilder("CAST(").append(generateKnownFunction(KnownFunction.CONCAT, iso)) + .append(" AS DATE)"); + } }; } diff --git a/dialect/api/src/main/java/org/eclipse/daanse/sql/dialect/api/generator/KnownFunction.java b/dialect/api/src/main/java/org/eclipse/daanse/sql/dialect/api/generator/KnownFunction.java index 4a747fe..67288c6 100644 --- a/dialect/api/src/main/java/org/eclipse/daanse/sql/dialect/api/generator/KnownFunction.java +++ b/dialect/api/src/main/java/org/eclipse/daanse/sql/dialect/api/generator/KnownFunction.java @@ -96,5 +96,34 @@ public enum KnownFunction { SQRT, /** Current date and time; no arguments. */ - NOW + NOW, + + /** The English name of the month of a date/time value ({@code January} ... {@code December}); 1 argument. */ + MONTH_NAME, + + /** + * A value as text, the way the database writes it ({@code 2003}, {@code 2.24}); 1 argument. Transact-SQL writes + * {@code CONVERT(varchar, x)}. + */ + TO_TEXT, + + /** + * A text padded with spaces on the right to a length, or cut to it: {@code PAD_RIGHT(text, length)} with a literal + * length; 2 arguments. Transact-SQL's {@code CONVERT(CHAR(n), x)} of a text is this (week 1 as {@code "1 "}). + */ + PAD_RIGHT, + + /** + * The date of a year, a month and a day, each a number: {@code DATE_FROM_PARTS(year, month, day)}; 3 arguments. + * Transact-SQL writes {@code DATEFROMPARTS}; a database without such a function reads the ISO text + * {@code yyyy-mm-dd} as a date. + */ + DATE_FROM_PARTS, + + /** + * A whole number as the widest whole number the database has: {@code CAST(x AS BIGINT)}; 1 argument. The operand + * of a sum over an INTEGER column where the sum keeps its operand's type (SQL Server, Derby) and would overflow + * past 2 147 483 647 ({@link org.eclipse.daanse.sql.dialect.api.capability.DialectCapabilitiesProvider#sumKeepsTheTypeOfItsOperand()}). + */ + TO_WIDE_INTEGER } diff --git a/dialect/db/common/src/test/java/org/eclipse/daanse/sql/dialect/db/common/KnownFunctionDefaultSpellingTest.java b/dialect/db/common/src/test/java/org/eclipse/daanse/sql/dialect/db/common/KnownFunctionDefaultSpellingTest.java index a6e729b..17201df 100644 --- a/dialect/db/common/src/test/java/org/eclipse/daanse/sql/dialect/db/common/KnownFunctionDefaultSpellingTest.java +++ b/dialect/db/common/src/test/java/org/eclipse/daanse/sql/dialect/db/common/KnownFunctionDefaultSpellingTest.java @@ -62,7 +62,18 @@ static Stream defaultSpellings() { Arguments.of(KnownFunction.MOD, List.of("a", "b"), "MOD(a, b)"), Arguments.of(KnownFunction.POWER, List.of("a", "b"), "POWER(a, b)"), Arguments.of(KnownFunction.SQRT, List.of("x"), "SQRT(x)"), - Arguments.of(KnownFunction.NOW, List.of(), "CURRENT_TIMESTAMP")); + Arguments.of(KnownFunction.NOW, List.of(), "CURRENT_TIMESTAMP"), + Arguments.of(KnownFunction.MONTH_NAME, List.of("x"), + "CASE EXTRACT(MONTH FROM x) WHEN 1 THEN 'January' WHEN 2 THEN 'February' WHEN 3 THEN 'March'" + + " WHEN 4 THEN 'April' WHEN 5 THEN 'May' WHEN 6 THEN 'June' WHEN 7 THEN 'July'" + + " WHEN 8 THEN 'August' WHEN 9 THEN 'September' WHEN 10 THEN 'October'" + + " WHEN 11 THEN 'November' WHEN 12 THEN 'December' END"), + Arguments.of(KnownFunction.TO_TEXT, List.of("x"), "CAST(x AS VARCHAR(4000))"), + Arguments.of(KnownFunction.TO_WIDE_INTEGER, List.of("x"), "CAST(x AS BIGINT)"), + Arguments.of(KnownFunction.PAD_RIGHT, List.of("x", "2"), "RPAD(x, 2, ' ')"), + Arguments.of(KnownFunction.DATE_FROM_PARTS, List.of("y", "m", "d"), + "CAST((CAST(y AS VARCHAR(4)) || '-' || LPAD(CAST(m AS VARCHAR(2)), 2, '0') || '-'" + + " || LPAD(CAST(d AS VARCHAR(2)), 2, '0')) AS DATE)")); } @ParameterizedTest @@ -82,7 +93,8 @@ static Stream arityViolations() { Arguments.of(KnownFunction.YEAR, List.of("x", "y")), Arguments.of(KnownFunction.ROUND, List.of("x", "2", "3")), Arguments.of(KnownFunction.MOD, List.of("a")), - Arguments.of(KnownFunction.NOW, List.of("x"))); + Arguments.of(KnownFunction.NOW, List.of("x")), + Arguments.of(KnownFunction.DATE_FROM_PARTS, List.of("y", "m"))); } @ParameterizedTest diff --git a/dialect/db/derby/src/main/java/org/eclipse/daanse/sql/dialect/db/derby/DerbyDialect.java b/dialect/db/derby/src/main/java/org/eclipse/daanse/sql/dialect/db/derby/DerbyDialect.java index b1f87f5..7762db5 100644 --- a/dialect/db/derby/src/main/java/org/eclipse/daanse/sql/dialect/db/derby/DerbyDialect.java +++ b/dialect/db/derby/src/main/java/org/eclipse/daanse/sql/dialect/db/derby/DerbyDialect.java @@ -45,6 +45,28 @@ public String booleanTypeName() { return "SMALLINT"; } + @Override + public void quote(StringBuilder buf, Object value, org.eclipse.daanse.sql.model.type.Datatype datatype) { + // Derby refuses an untyped null in a select list ("Syntax error: Encountered null"), as a union arm without a + // column gives one (writeback view, database run 30.10.2026): a null of a known type is spelled typed, in a + // type the union takes beside any column of its family. A text null stays untyped: VARCHAR is also the type a + // null takes where nothing better is known (a CASE branch beside a quotient), and a typed one there clashes + // ("Types 'VARCHAR' and 'DOUBLE' are not type compatible"), where Derby takes the untyped null + if (value == null && datatype != null && datatype != org.eclipse.daanse.sql.model.type.Datatype.VARCHAR) { + buf.append("CAST(NULL AS ").append(switch (datatype) { + case DOUBLE, FLOAT, REAL -> "DOUBLE"; + case BIGINT -> "BIGINT"; + case BOOLEAN -> booleanTypeName(); + case DATE -> "DATE"; + case TIME -> "TIME"; + case TIMESTAMP -> "TIMESTAMP"; + default -> "INTEGER"; + }).append(")"); + return; + } + super.quote(buf, value, datatype); + } + /** * {@code 1} and {@code 0}: this dialect stores a truth value as SMALLINT ({@link #booleanTypeName()}), and Derby does * not compare SMALLINT with BOOLEAN ("ERROR 42818: Comparisons between 'SMALLINT' and 'BOOLEAN' are not supported", @@ -62,6 +84,27 @@ public void quoteBooleanLiteral(StringBuilder buf, String value) { } } + /** + * Derby has no EXTRACT ("Syntax error: Encountered FROM", database run 30.10.2026); its own YEAR(x), MONTH(x), + * DAY(x), HOUR(x), MINUTE(x) and SECOND(x) take a part of a date or time. + */ + @Override + public StringBuilder generateKnownFunction(org.eclipse.daanse.sql.dialect.api.generator.KnownFunction function, + List arguments) { + return switch (function) { + case YEAR, MONTH, DAY, HOUR, MINUTE, SECOND -> arguments.size() == 1 + ? new StringBuilder(function.name()).append("(").append(arguments.get(0)).append(")") + : super.generateKnownFunction(function, arguments); + default -> super.generateKnownFunction(function, arguments); + }; + } + + /** A sum keeps the type of its operand: an INTEGER column sums to an INTEGER and overflows past 2^31 - 1. */ + @Override + public boolean sumKeepsTheTypeOfItsOperand() { + return true; + } + /** JDBC-free constructor for SQL generation. */ public DerbyDialect() { super(org.eclipse.daanse.sql.dialect.api.DialectInitData.ansiDefaults()); diff --git a/dialect/db/derby/src/test/java/org/eclipse/daanse/sql/dialect/db/derby/DerbyDialectTest.java b/dialect/db/derby/src/test/java/org/eclipse/daanse/sql/dialect/db/derby/DerbyDialectTest.java index 6d15a17..23c93a8 100644 --- a/dialect/db/derby/src/test/java/org/eclipse/daanse/sql/dialect/db/derby/DerbyDialectTest.java +++ b/dialect/db/derby/src/test/java/org/eclipse/daanse/sql/dialect/db/derby/DerbyDialectTest.java @@ -27,6 +27,42 @@ class DerbyDialectTest { + /** Derby sums an INTEGER column as an INTEGER: the engine widens the operand to BIGINT. */ + @Test + void aSumKeepsTheTypeOfItsOperand() { + assertTrue(new DerbyDialect().sumKeepsTheTypeOfItsOperand()); + assertEquals("CAST(x AS BIGINT)", new DerbyDialect().generateKnownFunction( + org.eclipse.daanse.sql.dialect.api.generator.KnownFunction.TO_WIDE_INTEGER, + java.util.List.of("x")).toString()); + } + + /** + * Derby has no EXTRACT ("Syntax error: Encountered FROM", the month of a birth date written as a tree, database + * run 30.10.2026); its own YEAR(x), MONTH(x) and DAY(x) take the part. + */ + @Test + void aPartOfADateIsTakenByDerbysOwnFunction() { + assertEquals("MONTH(d)", new DerbyDialect().generateKnownFunction( + org.eclipse.daanse.sql.dialect.api.generator.KnownFunction.MONTH, java.util.List.of("d")).toString()); + assertEquals("YEAR(d)", new DerbyDialect().generateKnownFunction( + org.eclipse.daanse.sql.dialect.api.generator.KnownFunction.YEAR, java.util.List.of("d")).toString()); + } + + /** + * Derby refuses an untyped null in a select list ("Syntax error: Encountered null", a union arm without a column, + * database run 30.10.2026): a null of a known type is spelled typed. + */ + @Test + void aNullOfAKnownTypeIsSpelledTyped() { + StringBuilder integer = new StringBuilder(); + new DerbyDialect().quote(integer, null, org.eclipse.daanse.sql.model.type.Datatype.INTEGER); + assertEquals("CAST(NULL AS INTEGER)", integer.toString()); + // a text null stays untyped: VARCHAR is also the type of a null nothing better is known of (a CASE branch) + StringBuilder text = new StringBuilder(); + new DerbyDialect().quote(text, null, org.eclipse.daanse.sql.model.type.Datatype.VARCHAR); + assertEquals("null", text.toString().toLowerCase(java.util.Locale.ROOT)); + } + private Connection connection = mock(Connection.class); private DatabaseMetaData metaData = mock(DatabaseMetaData.class); private DerbyDialect dialect; diff --git a/dialect/db/duckdb/pom.xml b/dialect/db/duckdb/pom.xml index b0a1f74..46af7aa 100644 --- a/dialect/db/duckdb/pom.xml +++ b/dialect/db/duckdb/pom.xml @@ -46,5 +46,11 @@ biz.aQute.bnd biz.aQute.bndlib + + org.eclipse.daanse + org.eclipse.daanse.sql.jdbc.record + ${project.version} + test + diff --git a/dialect/db/duckdb/src/main/java/org/eclipse/daanse/sql/dialect/db/duckdb/DuckDbDialect.java b/dialect/db/duckdb/src/main/java/org/eclipse/daanse/sql/dialect/db/duckdb/DuckDbDialect.java index 846c585..41e4e89 100644 --- a/dialect/db/duckdb/src/main/java/org/eclipse/daanse/sql/dialect/db/duckdb/DuckDbDialect.java +++ b/dialect/db/duckdb/src/main/java/org/eclipse/daanse/sql/dialect/db/duckdb/DuckDbDialect.java @@ -74,6 +74,24 @@ public String booleanTypeName() { return "SMALLINT"; } + /** + * Long text is {@code VARCHAR}, binary {@code BLOB}: DuckDB knows no {@code CLOB}, and its {@code VARCHAR} has no + * length limit, so a large object of text is a plain {@code VARCHAR} (the CWM structure of Adventure Works DW + * declares {@code nvarchar(max)} and {@code xml} as national large objects, its images as binary ones). + */ + @Override + public String nativeType(org.eclipse.daanse.sql.model.schema.ColumnMetaData meta) { + java.sql.JDBCType type = meta.dataType(); + if (type == null) { + return super.nativeType(meta); + } + return switch (type) { + case CLOB, NCLOB, LONGVARCHAR, LONGNVARCHAR -> "VARCHAR"; + case BLOB, BINARY, VARBINARY, LONGVARBINARY -> "BLOB"; + default -> super.nativeType(meta); + }; + } + private volatile org.eclipse.daanse.sql.dialect.api.generator.PaginationGenerator cachedPaginationGenerator; /** JDBC-free constructor for SQL generation. */ @@ -425,4 +443,33 @@ public boolean supportsNthValue() { public boolean supportsListAgg() { return true; } + + @Override + public StringBuilder generateKnownFunction(org.eclipse.daanse.sql.dialect.api.generator.KnownFunction function, + java.util.List arguments) { + return switch (function) { + case MONTH_NAME -> { + if (arguments.size() != 1) { + throw new IllegalArgumentException("MONTH_NAME expects 1 argument(s), got " + arguments.size()); + } + // monthname gives the English name whatever the locale + yield new StringBuilder("monthname(").append(arguments.get(0)).append(")"); + } + case TO_TEXT -> { + if (arguments.size() != 1) { + throw new IllegalArgumentException("TO_TEXT expects 1 argument(s), got " + arguments.size()); + } + // DuckDB's VARCHAR has no length + yield new StringBuilder("CAST(").append(arguments.get(0)).append(" AS VARCHAR)"); + } + case DATE_FROM_PARTS -> { + if (arguments.size() != 3) { + throw new IllegalArgumentException("DATE_FROM_PARTS expects 3 argument(s), got " + arguments.size()); + } + yield new StringBuilder("make_date(").append(arguments.get(0)).append(", ").append(arguments.get(1)) + .append(", ").append(arguments.get(2)).append(")"); + } + default -> super.generateKnownFunction(function, arguments); + }; + } } diff --git a/dialect/db/duckdb/src/test/java/org/eclipse/daanse/sql/dialect/db/duckdb/DuckDbKnownFunctionTest.java b/dialect/db/duckdb/src/test/java/org/eclipse/daanse/sql/dialect/db/duckdb/DuckDbKnownFunctionTest.java new file mode 100644 index 0000000..9be22bb --- /dev/null +++ b/dialect/db/duckdb/src/test/java/org/eclipse/daanse/sql/dialect/db/duckdb/DuckDbKnownFunctionTest.java @@ -0,0 +1,38 @@ +/* + * 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: + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.dialect.db.duckdb; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.eclipse.daanse.sql.dialect.api.generator.KnownFunction; +import org.junit.jupiter.api.Test; + +/** The functions a data source view's expressions need, in DuckDB's spelling. */ +class DuckDbKnownFunctionTest { + + private final DuckDbDialect d = new DuckDbDialect(); + + @Test + void knownFunctionsOfADataSourceView() { + assertThat(d.functionGenerator().generateKnownFunction(KnownFunction.MONTH_NAME, List.of("x"))) + .hasToString("monthname(x)"); + assertThat(d.functionGenerator().generateKnownFunction(KnownFunction.TO_TEXT, List.of("x"))) + .hasToString("CAST(x AS VARCHAR)"); + assertThat(d.functionGenerator().generateKnownFunction(KnownFunction.PAD_RIGHT, List.of("x", "2"))) + .hasToString("RPAD(x, 2, ' ')"); + assertThat(d.functionGenerator().generateKnownFunction(KnownFunction.DATE_FROM_PARTS, List.of("y", "m", "d"))) + .hasToString("make_date(y, m, d)"); + } +} diff --git a/dialect/db/duckdb/src/test/java/org/eclipse/daanse/sql/dialect/db/duckdb/DuckDbNativeTypeTest.java b/dialect/db/duckdb/src/test/java/org/eclipse/daanse/sql/dialect/db/duckdb/DuckDbNativeTypeTest.java new file mode 100644 index 0000000..1841d33 --- /dev/null +++ b/dialect/db/duckdb/src/test/java/org/eclipse/daanse/sql/dialect/db/duckdb/DuckDbNativeTypeTest.java @@ -0,0 +1,48 @@ +/* + * 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: + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.dialect.db.duckdb; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.JDBCType; +import java.util.Optional; +import java.util.OptionalInt; + +import org.eclipse.daanse.sql.jdbc.record.schema.ColumnMetaDataRecord; +import org.eclipse.daanse.sql.model.schema.ColumnMetaData; +import org.junit.jupiter.api.Test; + +/** DuckDB knows no CLOB: long text is VARCHAR, binary BLOB. */ +class DuckDbNativeTypeTest { + + private final DuckDbDialect dialect = new DuckDbDialect(); + + private static ColumnMetaData meta(JDBCType jdbc, OptionalInt size) { + return new ColumnMetaDataRecord(jdbc, jdbc.getName(), size, OptionalInt.empty(), OptionalInt.empty(), + ColumnMetaData.Nullability.NULLABLE, OptionalInt.empty(), Optional.empty(), Optional.empty(), + ColumnMetaData.AutoIncrement.UNKNOWN, ColumnMetaData.GeneratedColumn.UNKNOWN); + } + + @Test + void largeObjectsHaveDuckDbNames() { + assertThat(dialect.nativeType(meta(JDBCType.NCLOB, OptionalInt.empty()))).isEqualTo("VARCHAR"); + assertThat(dialect.nativeType(meta(JDBCType.CLOB, OptionalInt.empty()))).isEqualTo("VARCHAR"); + assertThat(dialect.nativeType(meta(JDBCType.BLOB, OptionalInt.empty()))).isEqualTo("BLOB"); + } + + @Test + void otherTypesStayAsTheGeneratorWritesThem() { + assertThat(dialect.nativeType(meta(JDBCType.NVARCHAR, OptionalInt.of(40)))).isEqualTo("VARCHAR(40)"); + assertThat(dialect.nativeType(meta(JDBCType.INTEGER, OptionalInt.empty()))).isEqualTo("INTEGER"); + } +} diff --git a/dialect/db/mssqlserver/src/main/java/org/eclipse/daanse/sql/dialect/db/mssqlserver/MicrosoftSqlServerDialect.java b/dialect/db/mssqlserver/src/main/java/org/eclipse/daanse/sql/dialect/db/mssqlserver/MicrosoftSqlServerDialect.java index 95e81bf..43aefe2 100644 --- a/dialect/db/mssqlserver/src/main/java/org/eclipse/daanse/sql/dialect/db/mssqlserver/MicrosoftSqlServerDialect.java +++ b/dialect/db/mssqlserver/src/main/java/org/eclipse/daanse/sql/dialect/db/mssqlserver/MicrosoftSqlServerDialect.java @@ -73,10 +73,45 @@ public String timestampTypeName() { return "DATETIME"; } + /** + * National text keeps its national type, long text and binary their {@code MAX} form. SQL Server's + * {@code VARCHAR(n)} counts bytes: the UTF-8 of a national text of n characters overran it (Adventure Works' + * product descriptions in Arabic, Chinese and Thai). A large object without a length is no SQL Server type + * ({@code CLOB}) or a single byte ({@code VARBINARY}); n above 4000 national or 8000 other characters or bytes + * is {@code MAX}. + */ + @Override + public String nativeType(ColumnMetaData meta) { + java.sql.JDBCType type = meta.dataType(); + java.util.OptionalInt size = meta.columnSize(); + if (type == null) { + return super.nativeType(meta); + } + return switch (type) { + case NVARCHAR, NCHAR -> size.isPresent() && size.getAsInt() <= 4000 + ? (type == java.sql.JDBCType.NCHAR ? "NCHAR(" : "NVARCHAR(") + size.getAsInt() + ")" + : "NVARCHAR(MAX)"; + case LONGNVARCHAR, NCLOB -> "NVARCHAR(MAX)"; + case LONGVARCHAR, CLOB -> "VARCHAR(MAX)"; + case VARCHAR -> size.isPresent() && size.getAsInt() > 8000 ? "VARCHAR(MAX)" : super.nativeType(meta); + case VARBINARY, BINARY -> size.isPresent() && size.getAsInt() <= 8000 + ? (type == java.sql.JDBCType.BINARY ? "BINARY(" : "VARBINARY(") + size.getAsInt() + ")" + : "VARBINARY(MAX)"; + case LONGVARBINARY, BLOB -> "VARBINARY(MAX)"; + default -> super.nativeType(meta); + }; + } + private volatile org.eclipse.daanse.sql.dialect.api.generator.ReturningGenerator cachedReturningGenerator; private volatile org.eclipse.daanse.sql.dialect.api.generator.MergeGenerator cachedMergeGenerator; private volatile org.eclipse.daanse.sql.dialect.api.generator.CteGenerator cachedCteGenerator; + /** A sum keeps the type of its operand: an INTEGER column sums to an INTEGER and overflows past 2^31 - 1. */ + @Override + public boolean sumKeepsTheTypeOfItsOperand() { + return true; + } + public MicrosoftSqlServerDialect() { super(org.eclipse.daanse.sql.dialect.api.DialectInitData.ansiDefaults()); } @@ -413,6 +448,34 @@ yield new StringBuilder("DATEPART(").append(function.name().toLowerCase(java.uti // ubiquitous T-SQL spelling and its precision suffices for NOW's intent. yield new StringBuilder("GETDATE()"); } + case MONTH_NAME -> { + if (arguments.size() != 1) { + throw new IllegalArgumentException("MONTH_NAME expects 1 argument(s), got " + arguments.size()); + } + // DATENAME follows the session's language; the English names of the default language us_english + yield new StringBuilder("DATENAME(month, ").append(arguments.get(0)).append(")"); + } + case TO_TEXT -> { + if (arguments.size() != 1) { + throw new IllegalArgumentException("TO_TEXT expects 1 argument(s), got " + arguments.size()); + } + yield new StringBuilder("CAST(").append(arguments.get(0)).append(" AS NVARCHAR(4000))"); + } + case PAD_RIGHT -> { + if (arguments.size() != 2) { + throw new IllegalArgumentException("PAD_RIGHT expects 2 argument(s), got " + arguments.size()); + } + // a fixed-length text pads and cuts, as CONVERT(CHAR(n), x) does + yield new StringBuilder("CAST(").append(arguments.get(0)).append(" AS NCHAR(").append(arguments.get(1)) + .append("))"); + } + case DATE_FROM_PARTS -> { + if (arguments.size() != 3) { + throw new IllegalArgumentException("DATE_FROM_PARTS expects 3 argument(s), got " + arguments.size()); + } + yield new StringBuilder("DATEFROMPARTS(").append(arguments.get(0)).append(", ").append(arguments.get(1)) + .append(", ").append(arguments.get(2)).append(")"); + } default -> super.generateKnownFunction(function, arguments); }; } @@ -462,12 +525,14 @@ public CharSequence averageOperand(CharSequence operand) { @Override public java.util.Optional generateListAgg(CharSequence operand, boolean distinct, String separator, String coalesce, String onOverflowTruncate, List columns) { + if (distinct) { + // SQL Server knows no STRING_AGG(DISTINCT ...) ("Incorrect syntax near ','", database run 30.10.2026): + // declined, so the engine names the refusal instead of sending SQL the server rejects + return java.util.Optional.empty(); + } StringBuilder buf = new StringBuilder(64); buf.append("STRING_AGG"); buf.append("( "); - if (distinct) { - buf.append("DISTINCT "); - } buf.append(operand); buf.append(", '"); if (separator != null) { diff --git a/dialect/db/mssqlserver/src/test/java/org/eclipse/daanse/sql/dialect/db/mssqlserver/MicrosoftSqlServerGeneratorsTest.java b/dialect/db/mssqlserver/src/test/java/org/eclipse/daanse/sql/dialect/db/mssqlserver/MicrosoftSqlServerGeneratorsTest.java index 131b6fc..1ec6855 100644 --- a/dialect/db/mssqlserver/src/test/java/org/eclipse/daanse/sql/dialect/db/mssqlserver/MicrosoftSqlServerGeneratorsTest.java +++ b/dialect/db/mssqlserver/src/test/java/org/eclipse/daanse/sql/dialect/db/mssqlserver/MicrosoftSqlServerGeneratorsTest.java @@ -40,6 +40,25 @@ void avg_reads_its_operand_as_float() { .hasToString("CAST(\"t\".\"x\" AS float)"); } + /** + * SQL Server knows no STRING_AGG(DISTINCT ...) ("Incorrect syntax near ','", database run 30.10.2026): the dialect + * declines a distinct list, the engine names the refusal; a plain one stays. + */ + @Test + void a_distinct_text_list_is_declined() { + assertThat(d.aggregationGenerator().generateListAgg("x", true, ",", null, null, List.of())).isEmpty(); + assertThat(d.aggregationGenerator().generateListAgg("x", false, ",", null, null, List.of())).isPresent(); + } + + /** SQL Server sums an INTEGER column as an INTEGER: the engine widens the operand to BIGINT. */ + @Test + void a_sum_keeps_its_operands_type_and_the_operand_is_widened() { + assertThat(d.sumKeepsTheTypeOfItsOperand()).isTrue(); + assertThat(d.functionGenerator().generateKnownFunction( + org.eclipse.daanse.sql.dialect.api.generator.KnownFunction.TO_WIDE_INTEGER, List.of("x"))) + .hasToString("CAST(x AS BIGINT)"); + } + @Test void output_clause() { assertThat(d.returningGenerator().supportsReturning()).isTrue(); @@ -166,4 +185,16 @@ void rename_constraint_uses_the_schema_scoped_name() { assertThat(d.renameConstraint(table("dbo", "T"), "PK_OLD", "PK_NEW")) .isEqualTo("EXEC sp_rename '\"dbo\".\"PK_OLD\"', 'PK_NEW', 'OBJECT'"); } + + @Test + void known_functions_of_a_data_source_view() { + assertThat(d.functionGenerator().generateKnownFunction(KnownFunction.MONTH_NAME, List.of("x"))) + .hasToString("DATENAME(month, x)"); + assertThat(d.functionGenerator().generateKnownFunction(KnownFunction.DATE_FROM_PARTS, List.of("y", "m", "d"))) + .hasToString("DATEFROMPARTS(y, m, d)"); + assertThat(d.functionGenerator().generateKnownFunction(KnownFunction.TO_TEXT, List.of("x"))) + .hasToString("CAST(x AS NVARCHAR(4000))"); + assertThat(d.functionGenerator().generateKnownFunction(KnownFunction.PAD_RIGHT, List.of("x", "2"))) + .hasToString("CAST(x AS NCHAR(2))"); + } } diff --git a/dialect/db/mssqlserver/src/test/java/org/eclipse/daanse/sql/dialect/db/mssqlserver/sqlgen/MssqlNativeTypeTest.java b/dialect/db/mssqlserver/src/test/java/org/eclipse/daanse/sql/dialect/db/mssqlserver/sqlgen/MssqlNativeTypeTest.java new file mode 100644 index 0000000..3634642 --- /dev/null +++ b/dialect/db/mssqlserver/src/test/java/org/eclipse/daanse/sql/dialect/db/mssqlserver/sqlgen/MssqlNativeTypeTest.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: + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.dialect.db.mssqlserver.sqlgen; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.JDBCType; +import java.util.Optional; +import java.util.OptionalInt; + +import org.eclipse.daanse.sql.dialect.db.mssqlserver.MicrosoftSqlServerDialect; +import org.eclipse.daanse.sql.model.schema.ColumnMetaData; +import org.eclipse.daanse.sql.jdbc.record.schema.ColumnMetaDataRecord; +import org.junit.jupiter.api.Test; + +/** + * National text keeps its national type on SQL Server, whose VARCHAR(n) counts bytes; long text and binary take their + * MAX form, which a large object without a length has no other name for. + */ +class MssqlNativeTypeTest { + + private final MicrosoftSqlServerDialect dialect = new MicrosoftSqlServerDialect(); + + private static ColumnMetaData meta(JDBCType jdbc, OptionalInt size) { + return new ColumnMetaDataRecord(jdbc, jdbc.getName(), size, OptionalInt.empty(), OptionalInt.empty(), + ColumnMetaData.Nullability.NULLABLE, OptionalInt.empty(), Optional.empty(), Optional.empty(), + ColumnMetaData.AutoIncrement.UNKNOWN, ColumnMetaData.GeneratedColumn.UNKNOWN); + } + + @Test + void nationalTextStaysNational() { + assertThat(dialect.nativeType(meta(JDBCType.NVARCHAR, OptionalInt.of(400)))).isEqualTo("NVARCHAR(400)"); + assertThat(dialect.nativeType(meta(JDBCType.NCHAR, OptionalInt.of(3)))).isEqualTo("NCHAR(3)"); + assertThat(dialect.nativeType(meta(JDBCType.NVARCHAR, OptionalInt.of(5000)))).isEqualTo("NVARCHAR(MAX)"); + } + + @Test + void longTextAndBinaryAreMax() { + assertThat(dialect.nativeType(meta(JDBCType.NCLOB, OptionalInt.empty()))).isEqualTo("NVARCHAR(MAX)"); + assertThat(dialect.nativeType(meta(JDBCType.CLOB, OptionalInt.empty()))).isEqualTo("VARCHAR(MAX)"); + assertThat(dialect.nativeType(meta(JDBCType.BLOB, OptionalInt.empty()))).isEqualTo("VARBINARY(MAX)"); + assertThat(dialect.nativeType(meta(JDBCType.VARBINARY, OptionalInt.empty()))).isEqualTo("VARBINARY(MAX)"); + assertThat(dialect.nativeType(meta(JDBCType.VARBINARY, OptionalInt.of(16)))).isEqualTo("VARBINARY(16)"); + assertThat(dialect.nativeType(meta(JDBCType.VARCHAR, OptionalInt.of(9000)))).isEqualTo("VARCHAR(MAX)"); + } + + @Test + void otherTypesStayAsTheGeneratorWritesThem() { + assertThat(dialect.nativeType(meta(JDBCType.VARCHAR, OptionalInt.of(40)))).isEqualTo("VARCHAR(40)"); + assertThat(dialect.nativeType(meta(JDBCType.INTEGER, OptionalInt.empty()))).isEqualTo("INTEGER"); + } +} diff --git a/dialect/db/mysql/src/main/java/org/eclipse/daanse/sql/dialect/db/mysql/MySqlDialect.java b/dialect/db/mysql/src/main/java/org/eclipse/daanse/sql/dialect/db/mysql/MySqlDialect.java index 6bc5d7e..afada6c 100644 --- a/dialect/db/mysql/src/main/java/org/eclipse/daanse/sql/dialect/db/mysql/MySqlDialect.java +++ b/dialect/db/mysql/src/main/java/org/eclipse/daanse/sql/dialect/db/mysql/MySqlDialect.java @@ -50,6 +50,27 @@ * @since Nov 23, 2008 */ public class MySqlDialect extends AbstractJdbcDialect { + + /** + * Long text is {@code LONGTEXT}, binary {@code LONGBLOB}: MySQL and MariaDB know no {@code CLOB} and no + * {@code VARBINARY} without a length (the CWM structure of Adventure Works DW declares {@code nvarchar(max)} and + * {@code xml} as national large objects, its images as {@code varbinary(max)}). + */ + @Override + public String nativeType(org.eclipse.daanse.sql.model.schema.ColumnMetaData meta) { + java.sql.JDBCType type = meta.dataType(); + if (type == null) { + return super.nativeType(meta); + } + return switch (type) { + case CLOB, NCLOB, LONGVARCHAR, LONGNVARCHAR -> "LONGTEXT"; + case BLOB, LONGVARBINARY -> "LONGBLOB"; + // MySQL's VARBINARY needs a length; one of any length is a LONGBLOB + case VARBINARY -> meta.columnSize().isPresent() ? "VARBINARY(" + meta.columnSize().getAsInt() + ")" : "LONGBLOB"; + default -> super.nativeType(meta); + }; + } + private static final String SUPPORTED_PRODUCT_NAME = "MYSQL"; /** @@ -457,6 +478,15 @@ public org.eclipse.daanse.sql.dialect.api.IdentifierCaseFolding caseFolding() { return org.eclipse.daanse.sql.dialect.api.IdentifierCaseFolding.PRESERVE; } + /** + * MySQL and MariaDB average an INTEGER column to a DECIMAL of four places ({@code div_precision_increment}): 35 / 3 + * reads 11.6667 (database run 30.10.2026). The operand is read as a double, the way every measure value travels. + */ + @Override + public CharSequence averageOperand(CharSequence operand) { + return "CAST(" + operand + " AS DOUBLE)"; + } + @Override public StringBuilder generateKnownFunction(KnownFunction function, List arguments) { return switch (function) { @@ -482,6 +512,27 @@ yield new StringBuilder("LOCATE(").append(arguments.get(0)).append(", ").append( .append(")"); } // LENGTH stays CHAR_LENGTH: MySQL's LENGTH() counts bytes, CHAR_LENGTH() characters. + case MONTH_NAME -> { + if (arguments.size() != 1) { + throw new IllegalArgumentException("MONTH_NAME expects 1 argument(s), got " + arguments.size()); + } + // MONTHNAME follows lc_time_names, en_US unless the session sets another + yield new StringBuilder("MONTHNAME(").append(arguments.get(0)).append(")"); + } + case TO_TEXT -> { + if (arguments.size() != 1) { + throw new IllegalArgumentException("TO_TEXT expects 1 argument(s), got " + arguments.size()); + } + yield new StringBuilder("CAST(").append(arguments.get(0)).append(" AS CHAR)"); + } + case DATE_FROM_PARTS -> { + if (arguments.size() != 3) { + throw new IllegalArgumentException("DATE_FROM_PARTS expects 3 argument(s), got " + arguments.size()); + } + // MySQL has MAKEDATE(year, day of the year) only: the parts as text, read with their format + yield new StringBuilder("STR_TO_DATE(CONCAT_WS('-', ").append(arguments.get(0)).append(", ") + .append(arguments.get(1)).append(", ").append(arguments.get(2)).append("), '%Y-%c-%e')"); + } default -> super.generateKnownFunction(function, arguments); }; } diff --git a/dialect/db/mysql/src/test/java/org/eclipse/daanse/sql/dialect/db/mysql/MySqlGeneratorsTest.java b/dialect/db/mysql/src/test/java/org/eclipse/daanse/sql/dialect/db/mysql/MySqlGeneratorsTest.java index 47f6252..1181684 100644 --- a/dialect/db/mysql/src/test/java/org/eclipse/daanse/sql/dialect/db/mysql/MySqlGeneratorsTest.java +++ b/dialect/db/mysql/src/test/java/org/eclipse/daanse/sql/dialect/db/mysql/MySqlGeneratorsTest.java @@ -32,6 +32,15 @@ class MySqlGeneratorsTest { private final MySqlDialect d = new MySqlDialect(); + /** + * AVG over an INTEGER column is a DECIMAL of four places on MySQL and MariaDB (11.6667 for 35 / 3, database run + * 30.10.2026); the operand is read as a double, the way every measure value travels. + */ + @Test + void avg_reads_its_operand_as_double() { + assertThat(d.aggregationGenerator().averageOperand("x")).hasToString("CAST(x AS DOUBLE)"); + } + /** * A negated bit aggregate is the signed bitwise complement: MySQL's NOT is logical (NOT(BIT_AND(1)) is 0), and ~ on * the unsigned 64-bit BIT_AND result is 18446744073709551614 where the other arts answer -2. @@ -170,4 +179,16 @@ void comment_on_column_requires_meta() { assertThatThrownBy(() -> d.commentOnColumn(table("app", "users"), "name", "x", null)) .isInstanceOf(IllegalArgumentException.class); } + + @Test + void known_functions_of_a_data_source_view() { + assertThat(d.functionGenerator().generateKnownFunction(KnownFunction.MONTH_NAME, List.of("x"))) + .hasToString("MONTHNAME(x)"); + assertThat(d.functionGenerator().generateKnownFunction(KnownFunction.DATE_FROM_PARTS, List.of("y", "m", "d"))) + .hasToString("STR_TO_DATE(CONCAT_WS('-', y, m, d), '%Y-%c-%e')"); + assertThat(d.functionGenerator().generateKnownFunction(KnownFunction.TO_TEXT, List.of("x"))) + .hasToString("CAST(x AS CHAR)"); + assertThat(d.functionGenerator().generateKnownFunction(KnownFunction.PAD_RIGHT, List.of("x", "2"))) + .hasToString("RPAD(x, 2, ' ')"); + } } diff --git a/dialect/db/mysql/src/test/java/org/eclipse/daanse/sql/dialect/db/mysql/MySqlNativeTypeTest.java b/dialect/db/mysql/src/test/java/org/eclipse/daanse/sql/dialect/db/mysql/MySqlNativeTypeTest.java new file mode 100644 index 0000000..e40b483 --- /dev/null +++ b/dialect/db/mysql/src/test/java/org/eclipse/daanse/sql/dialect/db/mysql/MySqlNativeTypeTest.java @@ -0,0 +1,50 @@ +/* + * 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: + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.dialect.db.mysql; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.JDBCType; +import java.util.Optional; +import java.util.OptionalInt; + +import org.eclipse.daanse.sql.jdbc.record.schema.ColumnMetaDataRecord; +import org.eclipse.daanse.sql.model.schema.ColumnMetaData; +import org.junit.jupiter.api.Test; + +/** MySQL and MariaDB have no CLOB: long text is LONGTEXT, binary LONGBLOB; other types stay as the generator writes them. */ +class MySqlNativeTypeTest { + + private final MySqlDialect dialect = new MySqlDialect(); + + private static ColumnMetaData meta(JDBCType jdbc, OptionalInt size) { + return new ColumnMetaDataRecord(jdbc, jdbc.getName(), size, OptionalInt.empty(), OptionalInt.empty(), + ColumnMetaData.Nullability.NULLABLE, OptionalInt.empty(), Optional.empty(), Optional.empty(), + ColumnMetaData.AutoIncrement.UNKNOWN, ColumnMetaData.GeneratedColumn.UNKNOWN); + } + + @Test + void largeObjectsHaveMySqlNames() { + assertThat(dialect.nativeType(meta(JDBCType.NCLOB, OptionalInt.empty()))).isEqualTo("LONGTEXT"); + assertThat(dialect.nativeType(meta(JDBCType.CLOB, OptionalInt.empty()))).isEqualTo("LONGTEXT"); + assertThat(dialect.nativeType(meta(JDBCType.BLOB, OptionalInt.empty()))).isEqualTo("LONGBLOB"); + assertThat(dialect.nativeType(meta(JDBCType.VARBINARY, OptionalInt.empty()))).isEqualTo("LONGBLOB"); + assertThat(dialect.nativeType(meta(JDBCType.VARBINARY, OptionalInt.of(16)))).isEqualTo("VARBINARY(16)"); + } + + @Test + void otherTypesStayAsTheGeneratorWritesThem() { + assertThat(dialect.nativeType(meta(JDBCType.NVARCHAR, OptionalInt.of(40)))).isEqualTo("VARCHAR(40)"); + assertThat(dialect.nativeType(meta(JDBCType.INTEGER, OptionalInt.empty()))).isEqualTo("INTEGER"); + } +} diff --git a/dialect/db/oracle/src/main/java/org/eclipse/daanse/sql/dialect/db/oracle/OracleDialect.java b/dialect/db/oracle/src/main/java/org/eclipse/daanse/sql/dialect/db/oracle/OracleDialect.java index 71ae165..242129a 100644 --- a/dialect/db/oracle/src/main/java/org/eclipse/daanse/sql/dialect/db/oracle/OracleDialect.java +++ b/dialect/db/oracle/src/main/java/org/eclipse/daanse/sql/dialect/db/oracle/OracleDialect.java @@ -443,6 +443,20 @@ public StringBuilder generateKnownFunction(KnownFunction function, List { + if (arguments.size() != 1) { + throw new IllegalArgumentException("TO_TEXT expects 1 argument(s), got " + arguments.size()); + } + // VARCHAR needs a length in Oracle; TO_CHAR writes a number or a date as text + yield new StringBuilder("TO_CHAR(").append(arguments.get(0)).append(")"); + } + case DATE_FROM_PARTS -> { + if (arguments.size() != 3) { + throw new IllegalArgumentException("DATE_FROM_PARTS expects 3 argument(s), got " + arguments.size()); + } + yield new StringBuilder("TO_DATE(").append(arguments.get(0)).append(" || '-' || ").append(arguments.get(1)) + .append(" || '-' || ").append(arguments.get(2)).append(", 'YYYY-MM-DD')"); + } default -> super.generateKnownFunction(function, arguments); }; } diff --git a/dialect/db/oracle/src/test/java/org/eclipse/daanse/sql/dialect/db/oracle/OracleGeneratorsTest.java b/dialect/db/oracle/src/test/java/org/eclipse/daanse/sql/dialect/db/oracle/OracleGeneratorsTest.java index da8b182..ff2d671 100644 --- a/dialect/db/oracle/src/test/java/org/eclipse/daanse/sql/dialect/db/oracle/OracleGeneratorsTest.java +++ b/dialect/db/oracle/src/test/java/org/eclipse/daanse/sql/dialect/db/oracle/OracleGeneratorsTest.java @@ -111,4 +111,14 @@ void foreign_key_writes_only_actions_oracle_knows() { table("APP", "P"), List.of("ID"), "cascade", null); assertThat(cascade).endsWith(" ON DELETE CASCADE"); } + + @org.junit.jupiter.api.Test + void known_functions_of_a_data_source_view() { + assertThat(d.functionGenerator().generateKnownFunction(KnownFunction.TO_TEXT, List.of("x"))) + .hasToString("TO_CHAR(x)"); + assertThat(d.functionGenerator().generateKnownFunction(KnownFunction.DATE_FROM_PARTS, List.of("y", "m", "d"))) + .hasToString("TO_DATE(y || '-' || m || '-' || d, 'YYYY-MM-DD')"); + assertThat(d.functionGenerator().generateKnownFunction(KnownFunction.MONTH_NAME, List.of("x")).toString()) + .startsWith("CASE EXTRACT(MONTH FROM x) WHEN 1 THEN 'January'"); + } } diff --git a/dialect/db/postgresql/src/main/java/org/eclipse/daanse/sql/dialect/db/postgresql/PostgreSqlDialect.java b/dialect/db/postgresql/src/main/java/org/eclipse/daanse/sql/dialect/db/postgresql/PostgreSqlDialect.java index 8ba34c9..197c7fc 100644 --- a/dialect/db/postgresql/src/main/java/org/eclipse/daanse/sql/dialect/db/postgresql/PostgreSqlDialect.java +++ b/dialect/db/postgresql/src/main/java/org/eclipse/daanse/sql/dialect/db/postgresql/PostgreSqlDialect.java @@ -50,6 +50,28 @@ */ public class PostgreSqlDialect extends AbstractJdbcDialect { + /** + * Long text is {@code TEXT}; PostgreSQL knows no {@code CLOB} (the CWM structure of Adventure Works DW declares + * {@code nvarchar(max)} and {@code xml} as national large objects). A {@code BLOB} is what pgjdbc reads and writes + * as {@link java.sql.Blob}: a large object referenced by an {@code OID} column -- a NULL bound as BLOB is an oid, and + * a {@code BYTEA} column refused it ("is of type bytea but expression is of type oid"). Other binary types bind as + * bytes and are {@code BYTEA}; PostgreSQL knows no {@code VARBINARY}. + */ + @Override + public String nativeType(org.eclipse.daanse.sql.model.schema.ColumnMetaData meta) { + java.sql.JDBCType type = meta.dataType(); + if (type == null) { + return super.nativeType(meta); + } + return switch (type) { + case CLOB, NCLOB, LONGVARCHAR, LONGNVARCHAR -> "TEXT"; + case BLOB -> "OID"; + case BINARY, VARBINARY, LONGVARBINARY -> "BYTEA"; + default -> super.nativeType(meta); + }; + } + + private static final Pattern LEADING_EMBEDDED_OPTIONS = Pattern.compile("^(?:\\(\\?[a-zA-Z-]+\\))+"); // Lazy-initialized generator caches — built on first call. Stateless @@ -492,4 +514,19 @@ public boolean supportsNthValue() { return true; } + + @Override + public StringBuilder generateKnownFunction(org.eclipse.daanse.sql.dialect.api.generator.KnownFunction function, + java.util.List arguments) { + if (function == org.eclipse.daanse.sql.dialect.api.generator.KnownFunction.DATE_FROM_PARTS) { + if (arguments.size() != 3) { + throw new IllegalArgumentException("DATE_FROM_PARTS expects 3 argument(s), got " + arguments.size()); + } + // make_date takes integers: a smallint or a numeric part is cast + return new StringBuilder("make_date(CAST(").append(arguments.get(0)).append(" AS INTEGER), CAST(") + .append(arguments.get(1)).append(" AS INTEGER), CAST(").append(arguments.get(2)) + .append(" AS INTEGER))"); + } + return super.generateKnownFunction(function, arguments); + } } diff --git a/dialect/db/postgresql/src/test/java/org/eclipse/daanse/sql/dialect/db/postgresql/PostgreSqlGeneratorsTest.java b/dialect/db/postgresql/src/test/java/org/eclipse/daanse/sql/dialect/db/postgresql/PostgreSqlGeneratorsTest.java index 10762bc..385a26e 100644 --- a/dialect/db/postgresql/src/test/java/org/eclipse/daanse/sql/dialect/db/postgresql/PostgreSqlGeneratorsTest.java +++ b/dialect/db/postgresql/src/test/java/org/eclipse/daanse/sql/dialect/db/postgresql/PostgreSqlGeneratorsTest.java @@ -35,6 +35,13 @@ class PostgreSqlGeneratorsTest { private final PostgreSqlDialect d = new PostgreSqlDialect(); + @Test + void aDateFromItsPartsIsMakeDateOverIntegers() { + assertThat(d.functionGenerator().generateKnownFunction( + org.eclipse.daanse.sql.dialect.api.generator.KnownFunction.DATE_FROM_PARTS, List.of("y", "m", "d"))) + .hasToString("make_date(CAST(y AS INTEGER), CAST(m AS INTEGER), CAST(d AS INTEGER))"); + } + /** PostgreSQL divides integers to an integer; the dividend is read as double precision. */ @Test void a_quotient_reads_its_dividend_as_double_precision() { diff --git a/dialect/db/postgresql/src/test/java/org/eclipse/daanse/sql/dialect/db/postgresql/PostgreSqlNativeTypeTest.java b/dialect/db/postgresql/src/test/java/org/eclipse/daanse/sql/dialect/db/postgresql/PostgreSqlNativeTypeTest.java new file mode 100644 index 0000000..708e69f --- /dev/null +++ b/dialect/db/postgresql/src/test/java/org/eclipse/daanse/sql/dialect/db/postgresql/PostgreSqlNativeTypeTest.java @@ -0,0 +1,49 @@ +/* + * 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: + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.dialect.db.postgresql; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.JDBCType; +import java.util.Optional; +import java.util.OptionalInt; + +import org.eclipse.daanse.sql.jdbc.record.schema.ColumnMetaDataRecord; +import org.eclipse.daanse.sql.model.schema.ColumnMetaData; +import org.junit.jupiter.api.Test; + +/** PostgreSQL has no CLOB: long text is TEXT; a BLOB is an OID as pgjdbc binds it, other binary BYTEA. */ +class PostgreSqlNativeTypeTest { + + private final PostgreSqlDialect dialect = new PostgreSqlDialect(); + + private static ColumnMetaData meta(JDBCType jdbc, OptionalInt size) { + return new ColumnMetaDataRecord(jdbc, jdbc.getName(), size, OptionalInt.empty(), OptionalInt.empty(), + ColumnMetaData.Nullability.NULLABLE, OptionalInt.empty(), Optional.empty(), Optional.empty(), + ColumnMetaData.AutoIncrement.UNKNOWN, ColumnMetaData.GeneratedColumn.UNKNOWN); + } + + @Test + void largeObjectsHavePostgreSqlNames() { + assertThat(dialect.nativeType(meta(JDBCType.NCLOB, OptionalInt.empty()))).isEqualTo("TEXT"); + assertThat(dialect.nativeType(meta(JDBCType.CLOB, OptionalInt.empty()))).isEqualTo("TEXT"); + assertThat(dialect.nativeType(meta(JDBCType.BLOB, OptionalInt.empty()))).isEqualTo("OID"); + assertThat(dialect.nativeType(meta(JDBCType.VARBINARY, OptionalInt.empty()))).isEqualTo("BYTEA"); + } + + @Test + void otherTypesStayAsTheGeneratorWritesThem() { + assertThat(dialect.nativeType(meta(JDBCType.NVARCHAR, OptionalInt.of(40)))).isEqualTo("VARCHAR(40)"); + assertThat(dialect.nativeType(meta(JDBCType.INTEGER, OptionalInt.empty()))).isEqualTo("INTEGER"); + } +} diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/Expressions.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/Expressions.java index 9efb4ce..b4e973b 100644 --- a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/Expressions.java +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/Expressions.java @@ -82,6 +82,14 @@ public static SqlExpression rawVariant(java.util.Map byDialectNa return new SqlExpression.RawVariant(byDialectName); } + /** + * An expression as a tree for every database, beside per-dialect texts that win where the live dialect has one + * ({@link SqlExpression.Portable}). + */ + public static SqlExpression portable(SqlExpression tree, java.util.Map byDialectName) { + return new SqlExpression.Portable(tree, byDialectName); + } + /** * A bind parameter carrying an immediate value (rendered as a placeholder, * bound by the executor). @@ -242,6 +250,11 @@ public static SqlExpression substring(SqlExpression expression, SqlExpression st return new SqlExpression.KnownCall(KnownFunction.SUBSTRING, List.of(expression, start, length)); } + /** A whole number as the widest whole number of the database ({@code CAST(x AS BIGINT)}). */ + public static SqlExpression toWideInteger(SqlExpression expression) { + return new SqlExpression.KnownCall(KnownFunction.TO_WIDE_INTEGER, List.of(expression)); + } + /** Character length of a string. */ public static SqlExpression length(SqlExpression expression) { return new SqlExpression.KnownCall(KnownFunction.LENGTH, List.of(expression)); diff --git a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/SqlExpression.java b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/SqlExpression.java index 854cd0f..eaf98a3 100644 --- a/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/SqlExpression.java +++ b/statement/api/src/main/java/org/eclipse/daanse/sql/statement/api/expression/SqlExpression.java @@ -234,6 +234,21 @@ record Raw(String sql) implements SqlExpression { record RawVariant(java.util.Map byDialectName) implements SqlExpression { } + /** + * An expression written once for every database, beside the texts a description gives for some dialects: the + * renderer takes the live dialect's own text where there is one, else renders the tree in that dialect, else takes + * the {@code "generic"} text. A named calculation of an Analysis Services data source view comes this way: its + * Transact-SQL for SQL Server, its tree for every other database. + * + * @param tree the expression as a tree, rendered by the dialect; {@code null} when there is none + * @param byDialectName the {@code dialect-name -> SQL} texts, possibly empty + */ + record Portable(SqlExpression tree, java.util.Map byDialectName) implements SqlExpression { + public Portable { + byDialectName = java.util.Map.copyOf(byDialectName); + } + } + /** * A dialect-generated extra aggregate (PERCENTILE / LISTAGG / bitwise / * NTH_VALUE) whose SQL is produced at RENDER time by 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 index 99efc72..714e147 100644 --- 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 @@ -38,7 +38,7 @@ public sealed interface FromClause { * {@code instanceof Aliased} rather than enumerating every leaf variant. */ sealed interface Aliased extends FromClause - permits FromTable, FromSubquery, FromSet, FromRaw, FromVariant, FromInline { + permits FromTable, FromSubquery, FromSet, FromRaw, FromVariant, FromPortable, FromInline { TableAlias alias(); } @@ -122,6 +122,23 @@ record FromRaw(String sql, TableAlias alias) implements Aliased { record FromVariant(Map byDialectName, TableAlias alias) implements Aliased { } + /** + * A derived table written once for every database, beside the texts a description gives for some dialects: the + * renderer takes the live dialect's own text where there is one, else renders the tree (a subquery or a set + * operation under the same alias) in that dialect, else takes the {@code "generic"} text. A named query of an + * Analysis Services data source view comes this way: its Transact-SQL for SQL Server, its tree for every other + * database. + * + * @param tree the derived table as a tree, or {@code null} when there is none + * @param byDialectName the {@code dialect-name -> SQL} texts, possibly empty + * @param alias the derived-table alias + */ + record FromPortable(Aliased tree, Map byDialectName, TableAlias alias) implements Aliased { + public FromPortable { + byDialectName = Map.copyOf(byDialectName); + } + } + /** * A derived table from an inline {@code VALUES} table: the structured data * (column names, column type names, and rows of raw string values) carried diff --git a/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/render/DialectSqlRenderer.java b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/render/DialectSqlRenderer.java index 718ddea..5f9235b 100644 --- a/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/render/DialectSqlRenderer.java +++ b/statement/impl/src/main/java/org/eclipse/daanse/sql/statement/render/DialectSqlRenderer.java @@ -545,6 +545,14 @@ private String renderFrom(FromClause from, List whereSink, RenderOpti return "(" + chooseVariant(v.byDialectName()) + ")" + (dialect.allowsFromAlias() ? " as " : " ") + dialect.quoteIdentifier(v.alias().name()); } + if (from instanceof FromClause.FromPortable p) { + String own = p.byDialectName().get(dialect.name()); + if (own == null && p.tree() != null) { + return renderFrom(p.tree(), whereSink, options); + } + return "(" + (own != null ? own : chooseVariant(p.byDialectName())) + ")" + + (dialect.allowsFromAlias() ? " as " : " ") + dialect.quoteIdentifier(p.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. @@ -871,6 +879,13 @@ public String renderExpression(SqlExpression e) { if (e instanceof SqlExpression.RawVariant v) { return chooseVariant(v.byDialectName()); } + if (e instanceof SqlExpression.Portable p) { + String own = p.byDialectName().get(dialect.name()); + if (own != null) { + return own; + } + return p.tree() != null ? renderExpression(p.tree()) : chooseVariant(p.byDialectName()); + } if (e instanceof SqlExpression.ExtraAggregate ea) { return renderExtraAggregate(ea); } diff --git a/statement/impl/src/test/java/org/eclipse/daanse/sql/statement/render/PortableRenderingTest.java b/statement/impl/src/test/java/org/eclipse/daanse/sql/statement/render/PortableRenderingTest.java new file mode 100644 index 0000000..1ef9b66 --- /dev/null +++ b/statement/impl/src/test/java/org/eclipse/daanse/sql/statement/render/PortableRenderingTest.java @@ -0,0 +1,89 @@ +/* + * 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: + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.render; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; + +import org.eclipse.daanse.sql.dialect.api.generator.KnownFunction; +import org.eclipse.daanse.sql.dialect.db.common.AnsiDialect; +import org.eclipse.daanse.sql.statement.api.Expressions; +import org.eclipse.daanse.sql.statement.api.From; +import org.eclipse.daanse.sql.statement.api.SelectStatementBuilder; +import org.eclipse.daanse.sql.statement.api.expression.SqlExpression; +import org.eclipse.daanse.sql.statement.api.model.FromClause; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.junit.jupiter.api.Test; + +/** + * An expression written once for every database beside the texts some dialects have: the dialect's own text wins, else + * the tree is rendered in that dialect, else the generic text is taken. + */ +class PortableRenderingTest { + + private static String render(AnsiDialect dialect, SqlExpression expression) { + SelectStatementBuilder q = SelectStatementBuilder.create(); + q.project(expression, null); + q.from(From.raw("select 'a' as x", TableAlias.of("f"))); + return new DialectSqlRenderer(dialect).render(q.build()).sql(); + } + + private static final SqlExpression TREE = new SqlExpression.KnownCall(KnownFunction.TO_TEXT, + List.of(Expressions.column(TableAlias.of("f"), "x"))); + + @Test + void theDialectsOwnTextWins() { + AnsiDialect ansi = new AnsiDialect(); + assertEquals("select own(x) as \"c0\" from (select 'a' as x) as \"f\"", + render(ansi, Expressions.portable(TREE, Map.of(ansi.name(), "own(x)", "mssql", "CONVERT(varchar, x)")))); + } + + @Test + void elseTheTreeIsRenderedInTheDialect() { + assertEquals("select CAST(\"f\".\"x\" AS VARCHAR(4000)) as \"c0\" from (select 'a' as x) as \"f\"", + render(new AnsiDialect(), Expressions.portable(TREE, Map.of("mssql", "CONVERT(varchar, x)")))); + } + + @Test + void withoutATreeTheGenericTextIsTaken() { + assertEquals("select generic(x) as \"c0\" from (select 'a' as x) as \"f\"", + render(new AnsiDialect(), Expressions.portable(null, Map.of("generic", "generic(x)")))); + } + + private static String renderFrom(AnsiDialect dialect, FromClause from) { + SelectStatementBuilder q = SelectStatementBuilder.create(); + q.project(Expressions.column(TableAlias.of("v"), "x"), null); + q.from(from); + return new DialectSqlRenderer(dialect).render(q.build()).sql(); + } + + /** A derived table: the dialect's own text, else the tree, else the generic text. */ + @Test + void aDerivedTableTakesItsTextOrItsTree() { + AnsiDialect ansi = new AnsiDialect(); + SelectStatementBuilder inner = SelectStatementBuilder.create(); + inner.project(Expressions.column(TableAlias.of("t"), "x"), null, + org.eclipse.daanse.sql.statement.api.model.ColumnAlias.of("x")); + inner.from(From.table("t", TableAlias.of("t"))); + FromClause.Aliased tree = From.subquery(inner.build(), TableAlias.of("v")); + assertEquals("select \"v\".\"x\" as \"c0\" from (select own) as \"v\"", renderFrom(ansi, + new FromClause.FromPortable(tree, Map.of(ansi.name(), "select own"), TableAlias.of("v")))); + assertEquals("select \"v\".\"x\" as \"c0\" from (select \"t\".\"x\" as \"x\" from \"t\" as \"t\") as \"v\"", + renderFrom(ansi, new FromClause.FromPortable(tree, Map.of("mssql", "select x from t"), + TableAlias.of("v")))); + assertEquals("select \"v\".\"x\" as \"c0\" from (select generic) as \"v\"", renderFrom(ansi, + new FromClause.FromPortable(null, Map.of("generic", "select generic"), TableAlias.of("v")))); + } +}