diff --git a/dialect/api/src/main/java/org/eclipse/daanse/sql/dialect/api/generator/AggregationGenerator.java b/dialect/api/src/main/java/org/eclipse/daanse/sql/dialect/api/generator/AggregationGenerator.java index c9527b0..463ffb0 100644 --- a/dialect/api/src/main/java/org/eclipse/daanse/sql/dialect/api/generator/AggregationGenerator.java +++ b/dialect/api/src/main/java/org/eclipse/daanse/sql/dialect/api/generator/AggregationGenerator.java @@ -27,6 +27,17 @@ public interface AggregationGenerator extends IdentifierQuoter { String IGNORE_NULLS = " IGNORE NULLS "; String RESPECT_NULLS = " RESPECT NULLS "; + // -------------------- AVG -------------------- + + /** + * The operand of an {@code avg} aggregate as this dialect needs it to compute the mean: the operand itself, unless + * the engine averages whole numbers to a whole number. SQL Server returns 31 for the mean of 31 and 32 over an + * INTEGER column, where every other engine, MDX and SSAS give 31.5 (database run 19./20.10., F-10.6 2). + */ + default CharSequence averageOperand(CharSequence operand) { + return operand; + } + // -------------------- list / string aggregation -------------------- default java.util.Optional generateListAgg(CharSequence operand, boolean distinct, String separator, 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 e033957..05d1501 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 @@ -11,6 +11,15 @@ public interface FunctionGenerator { + /** + * The dividend of a division as this dialect needs it to divide without losing the fraction: the dividend itself, + * unless the engine cuts a quotient's scale. Derby divides DECIMAL(31,4) by DECIMAL(31,4) at scale 0, so a native + * ratio of 1.9 came out as 1 (a native Filter over a calculated measure read no row, database run 20.10.). + */ + default CharSequence quotientDividend(CharSequence dividend) { + return dividend; + } + default StringBuilder wrapIntoSqlUpperCaseFunction(CharSequence sqlExpression) { return new StringBuilder("UPPER(").append(sqlExpression).append(")"); } 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 1b33775..b1f87f5 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,23 @@ public String booleanTypeName() { return "SMALLINT"; } + /** + * {@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", + * a boolean level's key, database run 20.10.). + */ + @Override + public void quoteBooleanLiteral(StringBuilder buf, String value) { + String literal = value.trim(); + if (literal.equalsIgnoreCase("TRUE") || literal.equals("1")) { + buf.append('1'); + } else if (literal.equalsIgnoreCase("FALSE") || literal.equals("0")) { + buf.append('0'); + } else { + throw new NumberFormatException("Illegal BOOLEAN literal: " + value); + } + } + /** JDBC-free constructor for SQL generation. */ public DerbyDialect() { super(org.eclipse.daanse.sql.dialect.api.DialectInitData.ansiDefaults()); @@ -141,6 +158,34 @@ public boolean requiresAliasForFromQuery() { return true; } + /** + * Derby averages an integer column to an integer (31 for 31 and 32, the Measure Aggregator Base tutorial, database + * run 20.10.); the operand is read as DOUBLE, as on SQL Server. + */ + @Override + public CharSequence averageOperand(CharSequence operand) { + return "CAST(" + operand + " AS DOUBLE)"; + } + + /** + * Derby divides decimals at a scale its precision leaves over: DECIMAL(31,4) by DECIMAL(31,4) at scale 0, so a + * ratio of 1.9 is 1; integers divide to an integer. The dividend is read as DOUBLE (a native Filter over a calculated + * measure, FilterTest#testCmNativeFilter, database run 20.10.). + */ + @Override + public CharSequence quotientDividend(CharSequence dividend) { + return "CAST(" + dividend + " AS DOUBLE)"; + } + + /** + * One writer: Derby locks its system tables for DDL, and tables and indexes created at once deadlock ("A lock could + * not be obtained due to a deadlock", CREATE INDEX beside CREATE TABLE while FoodMart is loaded, database run 20.10.). + */ + @Override + public boolean supportsParallelLoading() { + return false; + } + @Override public boolean allowsMultipleCountDistinct() { // Derby allows at most one distinct-count per query. 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 5b8bca5..6d15a17 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 @@ -39,6 +39,36 @@ protected void setUp() throws Exception { dialect = new DerbyDialect(org.eclipse.daanse.sql.dialect.api.DialectInitData.fromConnection(connection)); } + /** A truth value is stored as SMALLINT, so its literal is 1 or 0: Derby does not compare SMALLINT with BOOLEAN. */ + @Test + void aTruthValueIsWrittenAsTheNumberItIsStoredAs() { + for (String[] pair : new String[][] { { "true", "1" }, { "FALSE", "0" }, { "1", "1" }, { "0", "0" } }) { + StringBuilder buf = new StringBuilder(); + dialect.quoteBooleanLiteral(buf, pair[0]); + org.junit.jupiter.api.Assertions.assertEquals(pair[1], buf.toString(), pair[0]); + } + } + + /** Derby cuts a quotient's scale; the dividend is read as DOUBLE. */ + @Test + void aDivisionReadsItsDividendAsDouble() { + org.junit.jupiter.api.Assertions.assertEquals("CAST(x AS DOUBLE)", + dialect.functionGenerator().quotientDividend("x").toString()); + } + + /** AVG over an INTEGER column is an integer in Derby; the operand is read as DOUBLE. */ + @Test + void anAverageReadsItsOperandAsDouble() { + org.junit.jupiter.api.Assertions.assertEquals("CAST(x AS DOUBLE)", + dialect.aggregationGenerator().averageOperand("x").toString()); + } + + /** Derby deadlocks on DDL run at once; a loader writes one statement at a time. */ + @Test + void loadingIsOneWriterAtATime() { + org.junit.jupiter.api.Assertions.assertFalse(dialect.supportsParallelLoading()); + } + @Test void testGetDialectName() { assertEquals("derby", dialect.name()); diff --git a/dialect/db/h2/src/main/java/org/eclipse/daanse/sql/dialect/db/h2/H2Dialect.java b/dialect/db/h2/src/main/java/org/eclipse/daanse/sql/dialect/db/h2/H2Dialect.java index f6a8b3c..76e2812 100644 --- a/dialect/db/h2/src/main/java/org/eclipse/daanse/sql/dialect/db/h2/H2Dialect.java +++ b/dialect/db/h2/src/main/java/org/eclipse/daanse/sql/dialect/db/h2/H2Dialect.java @@ -109,6 +109,16 @@ public H2Dialect(org.eclipse.daanse.sql.dialect.api.DialectInitData init) { super(init); } + /** + * H2 divides integers to an integer. MDX divides with a fraction, and a native condition over a quotient has to as + * well: the dividend is read as DOUBLE (F-10.9, a native Filter over Units Shipped / Units Ordered, database run + * 20.10.). + */ + @Override + public CharSequence quotientDividend(CharSequence dividend) { + return "CAST(" + dividend + " AS DOUBLE)"; + } + @Override public String name() { return SUPPORTED_PRODUCT_NAME.toLowerCase(); diff --git a/dialect/db/h2/src/test/java/org/eclipse/daanse/sql/dialect/db/h2/H2DialectTest.java b/dialect/db/h2/src/test/java/org/eclipse/daanse/sql/dialect/db/h2/H2DialectTest.java index 95ce5b7..b8428da 100644 --- a/dialect/db/h2/src/test/java/org/eclipse/daanse/sql/dialect/db/h2/H2DialectTest.java +++ b/dialect/db/h2/src/test/java/org/eclipse/daanse/sql/dialect/db/h2/H2DialectTest.java @@ -48,6 +48,13 @@ protected void setUp() throws Exception { dialect = new H2Dialect(org.eclipse.daanse.sql.dialect.api.DialectInitData.fromConnection(connection)); } + /** H2 divides integers to an integer; the dividend is read as DOUBLE. */ + @Test + void aQuotientReadsItsDividendAsDouble() { + org.junit.jupiter.api.Assertions.assertEquals("CAST(x AS DOUBLE)", + dialect.functionGenerator().quotientDividend("x").toString()); + } + @Test void testGetDialectName() { assertEquals("h2", dialect.name()); 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 dcd31cd..95e81bf 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 @@ -441,6 +441,24 @@ public boolean supportsPercentileCont() { return true; } + /** + * SQL Server divides integers to an integer. MDX divides with a fraction, and a native condition over a quotient has to as well: the dividend is read as + * float (F-10.9, a native Filter over Units Shipped / Units Ordered, database run 20.10.). + */ + @Override + public CharSequence quotientDividend(CharSequence dividend) { + return "CAST(" + dividend + " AS float)"; + } + + /** + * SQL Server averages an integer column to an integer (the result type of AVG is the operand's); the operand is + * read as float, the double way every measure value takes. + */ + @Override + public CharSequence averageOperand(CharSequence operand) { + return "CAST(" + operand + " AS float)"; + } + @Override public java.util.Optional generateListAgg(CharSequence operand, boolean distinct, String separator, String coalesce, String onOverflowTruncate, List columns) { 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 d682776..131b6fc 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 @@ -27,6 +27,19 @@ class MicrosoftSqlServerGeneratorsTest { private final MicrosoftSqlServerDialect d = new MicrosoftSqlServerDialect(); + /** SQL Server divides integers to an integer; the dividend is read as float. */ + @Test + void a_quotient_reads_its_dividend_as_float() { + assertThat(d.functionGenerator().quotientDividend("x")).hasToString("CAST(x AS float)"); + } + + /** AVG over an INTEGER column is an integer on SQL Server; the operand is read as float. */ + @Test + void avg_reads_its_operand_as_float() { + assertThat(d.aggregationGenerator().averageOperand("\"t\".\"x\"")) + .hasToString("CAST(\"t\".\"x\" AS float)"); + } + @Test void output_clause() { assertThat(d.returningGenerator().supportsReturning()).isTrue(); 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 b89e0a1..6bc5d7e 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 @@ -424,9 +424,12 @@ public Optional generateRegularExpression(String source, String javaRege } sb.append(" REGEXP "); // MDX MATCHES is anchored (Pattern.matches); REGEXP is contains — - // anchor AFTER the case fold, or the fold would turn \z into \Z + // anchor AFTER the case fold, or the fold would turn \z into \Z. + // REGEXP follows the collation of its operand, case-insensitive by default (utf8mb4_0900_ai_ci): a + // case-sensitive match says so in the pattern. (?-i) is read by ICU (MySQL 8.0.4+) and PCRE (MariaDB), the + // engines \A and \z already need; without it "a.*" matched "A..." natively, not when interpreted. if (caseSensitive) { - quoteStringLiteral(sb, "\\A(?:" + javaRegex + ")\\z"); + quoteStringLiteral(sb, "(?-i)\\A(?:" + javaRegex + ")\\z"); } else { quoteStringLiteral(sb, "\\A(?:" + javaRegex.toUpperCase() + ")\\z"); } @@ -492,9 +495,12 @@ public java.util.Optional generateBitAggregation(BitOperation operation, case AND -> buf.append("BIT_AND(").append(operand).append(")"); case OR -> buf.append("BIT_OR(").append(operand).append(")"); case XOR -> buf.append("BIT_XOR(").append(operand).append(")"); - case NAND -> buf.append("NOT(BIT_AND(").append(operand).append("))"); - case NOR -> buf.append("NOT(BIT_OR(").append(operand).append("))"); - case NXOR -> buf.append("NOT(BIT_XOR(").append(operand).append("))"); + // NOT is logical in MySQL and MariaDB: NOT(BIT_AND(1)) is 0, which the tutorial oracle read silently as the + // NAND (database run 20.10.). ~ is bitwise, but on the unsigned 64-bit result of BIT_AND (~1 is + // 18446744073709551614); read as SIGNED it is -2, as DuckDB writes it and the provider computes it + case NAND -> buf.append("CAST(~BIT_AND(").append(operand).append(") AS SIGNED)"); + case NOR -> buf.append("CAST(~BIT_OR(").append(operand).append(") AS SIGNED)"); + case NXOR -> buf.append("CAST(~BIT_XOR(").append(operand).append(") AS SIGNED)"); }; return java.util.Optional.of(result.toString()); } diff --git a/dialect/db/mysql/src/test/java/org/eclipse/daanse/sql/dialect/db/mysql/MySqlDialect3Test.java b/dialect/db/mysql/src/test/java/org/eclipse/daanse/sql/dialect/db/mysql/MySqlDialect3Test.java index 4b613f5..33d2191 100644 --- a/dialect/db/mysql/src/test/java/org/eclipse/daanse/sql/dialect/db/mysql/MySqlDialect3Test.java +++ b/dialect/db/mysql/src/test/java/org/eclipse/daanse/sql/dialect/db/mysql/MySqlDialect3Test.java @@ -76,7 +76,8 @@ void testGenerateRegularExpression_CaseInsensitive() throws Exception { @Test void testGenerateRegularExpression_CaseSensitive() throws Exception { String sql = dialect.regexGenerator().generateRegularExpression("table.column", ".*a.*").get().toString(); - assertEquals("table.column IS NOT NULL AND table.column REGEXP '\\\\A(?:.*a.*)\\\\z'", sql); + // REGEXP follows the collation, case-insensitive by default: the pattern asks for case + assertEquals("table.column IS NOT NULL AND table.column REGEXP '(?-i)\\\\A(?:.*a.*)\\\\z'", sql); } @Test 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 53d4a74..47f6252 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,21 @@ class MySqlGeneratorsTest { private final MySqlDialect d = new MySqlDialect(); + /** + * 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. + */ + @Test + void a_negated_bit_aggregate_is_the_signed_bitwise_complement() { + var g = d.aggregationGenerator(); + assertThat(g.generateBitAggregation(org.eclipse.daanse.sql.model.sql.BitOperation.NAND, "x").orElseThrow()) + .isEqualTo("CAST(~BIT_AND(x) AS SIGNED)"); + assertThat(g.generateBitAggregation(org.eclipse.daanse.sql.model.sql.BitOperation.NOR, "x").orElseThrow()) + .isEqualTo("CAST(~BIT_OR(x) AS SIGNED)"); + assertThat(g.generateBitAggregation(org.eclipse.daanse.sql.model.sql.BitOperation.NXOR, "x").orElseThrow()) + .isEqualTo("CAST(~BIT_XOR(x) AS SIGNED)"); + } + @Test void pagination_offset_limit() { assertThat(d.paginationGenerator().paginate(OptionalLong.of(20), OptionalLong.of(40))) 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 55ab7aa..8ba34c9 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 @@ -394,6 +394,43 @@ protected boolean supportsNullsOrdering() { return productVersion != null && productVersion.compareTo("8.3") >= 0; } + /** + * PostgreSQL divides integers to an integer. MDX divides with a fraction, and a native condition over a quotient has to as well: the dividend is read as + * double precision (F-10.9, a native Filter over Units Shipped / Units Ordered, database run 20.10.). + */ + @Override + public CharSequence quotientDividend(CharSequence dividend) { + return "CAST(" + dividend + " AS double precision)"; + } + + /** + * {@code STRING_AGG(CAST(operand AS TEXT), 'separator' ORDER BY ...)}. PostgreSQL had no list aggregation here, and + * every text aggregation failed in the SQL builder and in the recorder alike (the writeback tutorials on + * PostgreSQL, database run 20.10.). The operand is cast, because STRING_AGG takes text only. DISTINCT with an ORDER BY + * over other columns is an error in PostgreSQL (the ORDER BY must name the arguments); DISTINCT is then left out, as + * DuckDB does, so that both write the same list. + */ + @Override + public java.util.Optional generateListAgg(CharSequence operand, boolean distinct, String separator, + String coalesce, String onOverflowTruncate, List columns) { + boolean ordered = columns != null && !columns.isEmpty(); + StringBuilder buf = new StringBuilder(64).append("STRING_AGG("); + if (distinct && !ordered) { + buf.append("DISTINCT "); + } + buf.append("CAST(").append(operand).append(" AS TEXT), '") + .append((separator != null ? separator : ",").replace("'", "''")).append('\''); + if (ordered) { + buf.append(" ORDER BY ").append(buildOrderedColumnsClause(columns)); + } + return java.util.Optional.of(buf.append(')').toString()); + } + + @Override + public boolean supportsListAgg() { + return true; + } + // Unified BitOperation methods @Override @@ -403,9 +440,12 @@ public java.util.Optional generateBitAggregation(BitOperation operation, case AND -> buf.append("bit_and(").append(operand).append(")"); case OR -> buf.append("bit_or(").append(operand).append(")"); case XOR -> buf.append("bit_xor(").append(operand).append(")"); - case NAND -> buf.append("NOT(bit_and(").append(operand).append("))"); - case NOR -> buf.append("NOT(bit_or(").append(operand).append("))"); - case NXOR -> buf.append("NOT(bit_xor(").append(operand).append("))"); + // NOT is logical in PostgreSQL and takes a boolean, so NOT(bit_and(x)) failed on every integer column + // (tutorial oracle, database run 20.10.); the bitwise complement is ~, as DuckDB writes it and the provider + // computes it (the NAND of 1 is -2) + case NAND -> buf.append("~(bit_and(").append(operand).append("))"); + case NOR -> buf.append("~(bit_or(").append(operand).append("))"); + case NXOR -> buf.append("~(bit_xor(").append(operand).append("))"); }; return java.util.Optional.of(result.toString()); } 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 815f655..10762bc 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,43 @@ class PostgreSqlGeneratorsTest { private final PostgreSqlDialect d = new PostgreSqlDialect(); + /** PostgreSQL divides integers to an integer; the dividend is read as double precision. */ + @Test + void a_quotient_reads_its_dividend_as_double_precision() { + assertThat(d.functionGenerator().quotientDividend("x")).hasToString("CAST(x AS double precision)"); + } + + /** A negated bit aggregate is the bitwise complement: PostgreSQL's NOT is logical and takes no integer. */ + @Test + void a_negated_bit_aggregate_is_the_bitwise_complement() { + org.eclipse.daanse.sql.dialect.api.generator.AggregationGenerator g = d.aggregationGenerator(); + assertThat(g.generateBitAggregation(org.eclipse.daanse.sql.model.sql.BitOperation.NAND, "x").orElseThrow()) + .isEqualTo("~(bit_and(x))"); + assertThat(g.generateBitAggregation(org.eclipse.daanse.sql.model.sql.BitOperation.NOR, "x").orElseThrow()) + .isEqualTo("~(bit_or(x))"); + assertThat(g.generateBitAggregation(org.eclipse.daanse.sql.model.sql.BitOperation.NXOR, "x").orElseThrow()) + .isEqualTo("~(bit_xor(x))"); + } + + /** PostgreSQL lists text with STRING_AGG; the operand is cast, the separator quoted. */ + @Test + void list_aggregation_is_string_agg() { + assertThat(d.aggregationGenerator().supportsListAgg()).isTrue(); + assertThat(d.aggregationGenerator().generateListAgg("\"t\".\"c\"", true, " | ", null, null, List.of()) + .orElseThrow()).isEqualTo("STRING_AGG(DISTINCT CAST(\"t\".\"c\" AS TEXT), ' | ')"); + assertThat(d.aggregationGenerator().generateListAgg("x", false, "it's", null, null, + List.of(new org.eclipse.daanse.sql.model.sql.OrderedColumn("y", null))).orElseThrow()) + .isEqualTo("STRING_AGG(CAST(x AS TEXT), 'it''s' ORDER BY \"y\")"); + } + + /** DISTINCT beside an ORDER BY over other columns is an error in PostgreSQL; it is left out, as in DuckDB. */ + @Test + void a_distinct_ordered_list_is_written_without_distinct() { + assertThat(d.aggregationGenerator().generateListAgg("x", true, null, null, null, + List.of(new org.eclipse.daanse.sql.model.sql.OrderedColumn("y", null))).orElseThrow()) + .isEqualTo("STRING_AGG(CAST(x AS TEXT), ',' ORDER BY \"y\")"); + } + @Test void pagination_limit_offset() { assertThat(d.paginationGenerator().paginate(OptionalLong.of(20), OptionalLong.of(40))) diff --git a/jdbc/importer/csv/src/main/java/org/eclipse/daanse/sql/jdbc/importer/csv/impl/CsvDataImporter.java b/jdbc/importer/csv/src/main/java/org/eclipse/daanse/sql/jdbc/importer/csv/impl/CsvDataImporter.java index 9c9dd87..23a2ca3 100644 --- a/jdbc/importer/csv/src/main/java/org/eclipse/daanse/sql/jdbc/importer/csv/impl/CsvDataImporter.java +++ b/jdbc/importer/csv/src/main/java/org/eclipse/daanse/sql/jdbc/importer/csv/impl/CsvDataImporter.java @@ -225,7 +225,12 @@ private void insertTable(Connection connection, CloseableIterator headersTypeList, TableDefinition table) throws SQLException { try (Statement stmt = connection.createStatement();) { diff --git a/jdbc/importer/csv/src/test/java/org/eclipse/daanse/sql/jdbc/importer/csv/impl/ATableIsLookedUpBeforeItIsDroppedTest.java b/jdbc/importer/csv/src/test/java/org/eclipse/daanse/sql/jdbc/importer/csv/impl/ATableIsLookedUpBeforeItIsDroppedTest.java new file mode 100644 index 0000000..32d5dda --- /dev/null +++ b/jdbc/importer/csv/src/test/java/org/eclipse/daanse/sql/jdbc/importer/csv/impl/ATableIsLookedUpBeforeItIsDroppedTest.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: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.jdbc.importer.csv.impl; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; +import java.util.Optional; + +import org.eclipse.daanse.sql.model.schema.SchemaReference; +import org.eclipse.daanse.sql.model.schema.TableReference; +import org.junit.jupiter.api.Test; + +/** + * A dialect without DROP TABLE IF EXISTS (Derby) drops a table only after finding it: the lookup is by exact name in + * the table's schema, and an underscore in the name is no wildcard. + */ +class ATableIsLookedUpBeforeItIsDroppedTest { + + @Test + void aTableIsFoundByItsExactName() throws Exception { + try (Connection connection = DriverManager.getConnection("jdbc:h2:mem:lookup" + System.nanoTime()); + Statement statement = connection.createStatement()) { + statement.execute("CREATE SCHEMA \"shop\""); + statement.execute("CREATE TABLE \"shop\".\"store_csv\" (\"id\" INT)"); + statement.execute("CREATE TABLE \"line\" (\"id\" INT)"); + Optional shop = Optional.of(new SchemaReference("shop")); + + assertTrue(CsvDataImporter.exists(connection, new TableReference(shop, "store_csv"))); + assertTrue(CsvDataImporter.exists(connection, new TableReference("line")), "the current schema"); + assertFalse(CsvDataImporter.exists(connection, new TableReference(shop, "storeXcsv")), "_ is no wildcard"); + assertFalse(CsvDataImporter.exists(connection, new TableReference(shop, "line")), "another schema"); + assertFalse(CsvDataImporter.exists(connection, new TableReference(shop, "STORE_CSV")), "the exact name"); + } + } +} 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 f98cfe1..718ddea 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 @@ -809,14 +809,24 @@ public String renderExpression(SqlExpression e) { dialect.quote(b, l.value(), l.datatype()); return b.toString(); } + if (e instanceof SqlExpression.Aggregate a && a.arguments().size() == 1 && "avg".equalsIgnoreCase(a.name())) { + // the one dialect touch of a plain aggregate: an engine that averages whole numbers to a whole number + // reads the operand otherwise (SQL Server; F-10.6 2) + return a.name() + "(" + (a.distinct() ? "distinct " : "") + + dialect.aggregationGenerator().averageOperand(renderExpression(a.arguments().get(0))) + ")"; + } 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()); + String left = renderExpression(b.left()); + if (b.operator() == org.eclipse.daanse.sql.statement.api.expression.ArithmeticOperator.DIVIDE) { + // the one dialect touch of arithmetic: an engine that cuts a quotient's scale divides a cast dividend + left = dialect.functionGenerator().quotientDividend(left).toString(); + } + String rendered = left + " " + b.operator().symbol() + " " + renderExpression(b.right()); return b.parenthesized() ? "(" + rendered + ")" : rendered; } if (e instanceof SqlExpression.Case c) { diff --git a/statement/impl/src/test/java/org/eclipse/daanse/sql/statement/render/AverageRenderingTest.java b/statement/impl/src/test/java/org/eclipse/daanse/sql/statement/render/AverageRenderingTest.java new file mode 100644 index 0000000..4986799 --- /dev/null +++ b/statement/impl/src/test/java/org/eclipse/daanse/sql/statement/render/AverageRenderingTest.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.render; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.eclipse.daanse.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.model.TableAlias; +import org.junit.jupiter.api.Test; + +/** + * An {@code avg} aggregate asks the dialect for its operand: an engine that averages whole numbers to a whole number + * (SQL Server) reads it otherwise; every other aggregate and every other dialect render as written. + */ +class AverageRenderingTest { + + private static String avgOver(AnsiDialect dialect, boolean distinct) { + SelectStatementBuilder q = SelectStatementBuilder.create(); + var column = Expressions.column(TableAlias.of("f"), "x"); + q.project(distinct ? Expressions.aggregateDistinct("avg", column) : Expressions.aggregate("avg", column), null); + q.project(Expressions.aggregate("sum", column), null); + q.from(From.raw("select 1 as x", TableAlias.of("f"))); + return new DialectSqlRenderer(dialect).render(q.build()).sql(); + } + + private static final AnsiDialect CASTING = new AnsiDialect() { + @Override + public CharSequence averageOperand(CharSequence operand) { + return "CAST(" + operand + " AS float)"; + } + }; + + @Test + void anAverageRendersAsWrittenByDefault() { + assertEquals("select avg(\"f\".\"x\") as \"c0\", sum(\"f\".\"x\") as \"c1\" from (select 1 as x) as \"f\"", + avgOver(new AnsiDialect(), false)); + } + + @Test + void anAverageTakesTheOperandTheDialectNames() { + assertEquals("select avg(CAST(\"f\".\"x\" AS float)) as \"c0\", sum(\"f\".\"x\") as \"c1\" from (select 1 as x)" + + " as \"f\"", avgOver(CASTING, false)); + } + + @Test + void aDistinctAverageKeepsItsDistinct() { + assertEquals("select avg(distinct CAST(\"f\".\"x\" AS float)) as \"c0\", sum(\"f\".\"x\") as \"c1\" from" + + " (select 1 as x) as \"f\"", avgOver(CASTING, true)); + } +} diff --git a/statement/impl/src/test/java/org/eclipse/daanse/sql/statement/render/QuotientRenderingTest.java b/statement/impl/src/test/java/org/eclipse/daanse/sql/statement/render/QuotientRenderingTest.java new file mode 100644 index 0000000..5c7d91d --- /dev/null +++ b/statement/impl/src/test/java/org/eclipse/daanse/sql/statement/render/QuotientRenderingTest.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation. + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * SmartCity Jena - initial + * Stefan Bischof (bipolis.org) - initial + */ +package org.eclipse.daanse.sql.statement.render; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.eclipse.daanse.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.ArithmeticOperator; +import org.eclipse.daanse.sql.statement.api.expression.SqlExpression; +import org.eclipse.daanse.sql.statement.api.model.TableAlias; +import org.junit.jupiter.api.Test; + +/** A division asks the dialect for its dividend; every other operator and the default dialect render as written. */ +class QuotientRenderingTest { + + private static String render(AnsiDialect dialect, ArithmeticOperator operator) { + SelectStatementBuilder q = SelectStatementBuilder.create(); + SqlExpression a = Expressions.column(TableAlias.of("f"), "a"); + SqlExpression b = Expressions.column(TableAlias.of("f"), "b"); + q.project(new SqlExpression.Binary(a, operator, b), null); + q.from(From.raw("select 1 as a, 2 as b", TableAlias.of("f"))); + return new DialectSqlRenderer(dialect).render(q.build()).sql(); + } + + private static final AnsiDialect CASTING = new AnsiDialect() { + @Override + public CharSequence quotientDividend(CharSequence dividend) { + return "CAST(" + dividend + " AS DOUBLE)"; + } + }; + + @Test + void aQuotientRendersAsWrittenByDefault() { + assertEquals("select (\"f\".\"a\" / \"f\".\"b\") as \"c0\" from (select 1 as a, 2 as b) as \"f\"", + render(new AnsiDialect(), ArithmeticOperator.DIVIDE)); + } + + @Test + void aQuotientTakesTheDividendTheDialectNames() { + assertEquals("select (CAST(\"f\".\"a\" AS DOUBLE) / \"f\".\"b\") as \"c0\" from (select 1 as a, 2 as b) as \"f\"", + render(CASTING, ArithmeticOperator.DIVIDE)); + } + + @Test + void otherOperatorsAreLeftAlone() { + assertEquals("select (\"f\".\"a\" - \"f\".\"b\") as \"c0\" from (select 1 as a, 2 as b) as \"f\"", + render(CASTING, ArithmeticOperator.SUBTRACT)); + } +}