diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 086f114480e4..349778ac8830 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -42,6 +42,8 @@ val testH2 by configurations.creating(integrationTestConfig) val testOracle by configurations.creating(integrationTestConfig) val testPostgresql by configurations.creating(integrationTestConfig) val testMysql by configurations.creating(integrationTestConfig) +val testMssql by configurations.creating(integrationTestConfig) +val testPostgresql42 by configurations.creating(integrationTestConfig) dependencies { api(project(":linq4j")) @@ -86,6 +88,8 @@ dependencies { testMysql("mysql:mysql-connector-java") testOracle("com.oracle.ojdbc:ojdbc8") testPostgresql("org.postgresql:postgresql") + testMssql("com.microsoft.sqlserver:mssql-jdbc:${property("mssql-jdbc.version")}") + testPostgresql42("org.postgresql:postgresql:${property("postgresql-test.version")}") testImplementation(project(":testkit")) testImplementation("net.bytebuddy:byte-buddy") @@ -95,6 +99,7 @@ dependencies { testImplementation("org.apache.commons:commons-pool2") testImplementation("org.hsqldb:hsqldb::jdk8") testImplementation("sqlline:sqlline") + testImplementation("com.oracle.ojdbc:ojdbc8") testImplementation(kotlin("stdlib-jdk8")) testImplementation(kotlin("test")) testImplementation(kotlin("test-junit5")) @@ -296,3 +301,13 @@ for (db in listOf("h2", "mysql", "oracle", "postgresql")) { dependsOn(task) } } + +tasks.register("integTestCalcite6654", Test::class) { + group = LifecycleBasePlugin.VERIFICATION_GROUP + description = "Integration tests for CALCITE-6654 decimal precision fix" + include("org/apache/calcite/jdbc/JdbcSchemaDecimalBugTest.class") + systemProperty("calcite.integration.tests", "true") + classpath += configurations.getAt("testPostgresql42") + classpath += configurations.getAt("testMssql") + classpath += configurations.getAt("testOracle") +} diff --git a/core/src/main/java/org/apache/calcite/adapter/jdbc/JdbcSchema.java b/core/src/main/java/org/apache/calcite/adapter/jdbc/JdbcSchema.java index eb1f11b74d0b..db88542050dc 100644 --- a/core/src/main/java/org/apache/calcite/adapter/jdbc/JdbcSchema.java +++ b/core/src/main/java/org/apache/calcite/adapter/jdbc/JdbcSchema.java @@ -445,6 +445,14 @@ private static RelDataType sqlType(RelDataTypeFactory typeFactory, int dataType, default: break; } + // CALCITE-6654: Some JDBC drivers (Oracle, PostgreSQL, MSSQL) report + // DECIMAL/NUMERIC columns without explicit precision as precision=0. + // Treat this as unspecified precision/scale to avoid IllegalArgumentException. + if (precision == 0 && sqlTypeName == SqlTypeName.DECIMAL) { + precision = RelDataType.PRECISION_NOT_SPECIFIED; + scale = RelDataType.SCALE_NOT_SPECIFIED; + } + if (precision >= 0 && scale >= 0 && sqlTypeName.allowsPrecScale(true, true)) { diff --git a/core/src/test/java/org/apache/calcite/jdbc/JdbcSchemaDecimalBugTest.java b/core/src/test/java/org/apache/calcite/jdbc/JdbcSchemaDecimalBugTest.java new file mode 100644 index 000000000000..02fe6acc8106 --- /dev/null +++ b/core/src/test/java/org/apache/calcite/jdbc/JdbcSchemaDecimalBugTest.java @@ -0,0 +1,298 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.calcite.jdbc; + +import org.apache.calcite.adapter.jdbc.JdbcSchema; +import org.apache.calcite.schema.SchemaPlus; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +import java.io.PrintWriter; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.sql.Statement; +import java.util.Properties; +import java.util.logging.Logger; +import javax.sql.DataSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Integration tests for + * [CALCITE-6654] + * JdbcSchema throws error for NUMERIC/DECIMAL columns without explicit + * precision (Oracle, PostgreSQL, MSSQL). + * + *

These are integration tests that require live database instances. + * Run with: + *

{@code ./gradlew :core:integTestCalcite6654}
+ * + *

Required Docker containers – start with: + *

{@code
+ * docker compose -f core/src/test/resources/docker-compose.yml up -d
+ * }
+ * + *

Tests are skipped automatically if a container is unreachable. + */ + +@Tag("integration") +@EnabledIfSystemProperty(named = "calcite.integration.tests", matches = "true") +class JdbcSchemaDecimalBugTest { + + // --------------------------------------------------------------------------- + // DataSource factory + // --------------------------------------------------------------------------- + + /** + * Minimal {@link DataSource} backed by a JDBC URL. + * Sufficient for integration tests; not for production use. + */ + private static class DriverManagerDataSource implements DataSource { + private final String url; + private final String user; + private final String password; + + DriverManagerDataSource(String url, String user, String password) { + this.url = url; + this.user = user; + this.password = password; + } + + @Override public Connection getConnection() throws SQLException { + return DriverManager.getConnection(url, user, password); + } + + @Override public Connection getConnection(String u, String p) throws SQLException { + return getConnection(); + } + + @Override public PrintWriter getLogWriter() { + return null; + } + + @Override public void setLogWriter(PrintWriter w) { + } + + @Override public void setLoginTimeout(int seconds) { + } + + @Override public int getLoginTimeout() { + return 0; + } + + @Override public Logger getParentLogger() throws SQLFeatureNotSupportedException { + throw new SQLFeatureNotSupportedException("getParentLogger"); + } + + @Override public T unwrap(Class iface) throws SQLException { + throw new SQLException("Not a wrapper for " + iface); + } + + @Override public boolean isWrapperFor(Class iface) { + return false; + } + } + + private static DataSource oracleDataSource() { + return new DriverManagerDataSource( + "jdbc:oracle:thin:@localhost:1521/FREEPDB1", "system", "testpass"); + } + + private static DataSource postgresDataSource() { + return new DriverManagerDataSource( + "jdbc:postgresql://localhost:5432/testdb", "testuser", "testpass"); + } + + private static DataSource mssqlDataSource() { + return new DriverManagerDataSource( + "jdbc:sqlserver://localhost:1433;databaseName=master;encrypt=false", + "sa", "TestPass123!"); + } + + // --------------------------------------------------------------------------- + // Helper methods + // --------------------------------------------------------------------------- + + /** Skips the test if the database is not reachable. */ + private static void assumeReachable(DataSource ds, String dbName) { + try (Connection ignored = ds.getConnection()) { + // connection succeeded - proceed with the test + } catch (SQLException e) { + assumeTrue(false, + dbName + " is not reachable - skipping test. Cause: " + e.getMessage()); + } + } + + /** + * Executes DDL and DML statements to set up a test table. + * Silently ignores the drop statement if the table does not exist. + */ + private static void createTestTable(DataSource ds, String dropSql, + String createSql, String insertSql) throws SQLException { + try (Connection conn = ds.getConnection(); + Statement st = conn.createStatement()) { + try { + st.execute(dropSql); + } catch (SQLException ignored) { + // table did not exist yet - that is fine + } + st.execute(createSql); + st.execute(insertSql); + } + // connection is closed here via try-with-resources + } + + // --------------------------------------------------------------------------- + // Tests - Bug fix: columns without explicit precision must not throw + // --------------------------------------------------------------------------- + + /** + * Oracle {@code NUMBER} without precision (e.g. {@code col NUMBER}) must + * not cause Calcite to throw during schema registration or query execution. + * + *

Oracle reports {@code precision=0, scale=-127} for such columns. + */ + @Test void oracleNumberWithoutPrecisionShouldNotThrow() throws Exception { + DataSource ds = oracleDataSource(); + assumeReachable(ds, "Oracle"); + + // Oracle stores unquoted identifiers in UPPERCASE + createTestTable(ds, + "BEGIN EXECUTE IMMEDIATE 'DROP TABLE TEST_NUMBERS';" + + " EXCEPTION WHEN OTHERS THEN NULL; END;", + "CREATE TABLE TEST_NUMBERS (id NUMBER, val NUMBER)", + "INSERT INTO TEST_NUMBERS VALUES (1, 42.5)"); + + try (Connection conn = DriverManager.getConnection("jdbc:calcite:", new Properties())) { + CalciteConnection calciteConn = conn.unwrap(CalciteConnection.class); + SchemaPlus root = calciteConn.getRootSchema(); + root.add("oracle", JdbcSchema.create(root, "oracle", ds, null, "SYSTEM")); + try (Statement st = conn.createStatement(); + ResultSet rs = + st.executeQuery("SELECT * FROM \"oracle\".\"TEST_NUMBERS\"")) { + while (rs.next()) { + //drain + } + } + } + } + + /** + * PostgreSQL {@code NUMERIC} without precision (e.g. {@code col NUMERIC}) + * must not cause Calcite to throw. + *

PostgreSQL reports {@code precision=0, scale=0} for such columns. + */ + @Test void postgresNumericWithoutPrecisionShouldNotThrow() throws Exception { + DataSource ds = postgresDataSource(); + assumeReachable(ds, "PostgreSQL"); + + // Create the test table directly in the test — with NUMERIC (no precision) + createTestTable(ds, + "DROP TABLE IF EXISTS test_numbers", + "CREATE TABLE test_numbers (id INTEGER, val NUMERIC)", + "INSERT INTO test_numbers VALUES (1, 42.5)"); + + try (Connection conn = DriverManager.getConnection("jdbc:calcite:", new Properties())) { + CalciteConnection calciteConn = conn.unwrap(CalciteConnection.class); + SchemaPlus root = calciteConn.getRootSchema(); + root.add("postgres", + JdbcSchema.create(root, "postgres", ds, "testdb", "public")); + try (Statement st = conn.createStatement(); + ResultSet rs = + st.executeQuery("SELECT * FROM \"postgres\".\"test_numbers\"")) { + while (rs.next()) { + // drain + } + } + } + } + + /** + * SQL Server {@code DECIMAL} without precision (e.g. {@code col DECIMAL}) + * must not cause Calcite to throw. + */ + @Test void mssqlDecimalWithoutPrecisionShouldNotThrow() throws Exception { + DataSource ds = mssqlDataSource(); + assumeReachable(ds, "MSSQL"); + + // Create the test table directly in the test — with DECIMAL (no precision) + createTestTable(ds, + "DROP TABLE IF EXISTS test_numbers", + "CREATE TABLE test_numbers (id INT, val DECIMAL)", + "INSERT INTO test_numbers VALUES (1, 42.5)"); + + try (Connection conn = DriverManager.getConnection("jdbc:calcite:", new Properties())) { + CalciteConnection calciteConn = conn.unwrap(CalciteConnection.class); + SchemaPlus root = calciteConn.getRootSchema(); + root.add("mssql", JdbcSchema.create(root, "mssql", ds, null, "dbo")); + try (Statement st = conn.createStatement(); + ResultSet rs = + st.executeQuery("SELECT * FROM \"mssql\".\"test_numbers\"")) { + while (rs.next()) { + // drain + } + } + } + } + + // --------------------------------------------------------------------------- + // Tests - Regression: explicit precision/scale must be preserved + // --------------------------------------------------------------------------- + + /** + * A column declared as {@code DECIMAL(10,2)} must still report the correct + * type, precision, and scale after the fix — i.e. the fix must not break + * columns that have explicit precision. + */ + @Test void postgresDecimalWithExplicitPrecisionShouldRemainUnchanged() + throws Exception { + DataSource ds = postgresDataSource(); + assumeReachable(ds, "PostgreSQL"); + + // Create a table with EXPLICIT precision — regression test + createTestTable(ds, + "DROP TABLE IF EXISTS test_explicit", + "CREATE TABLE test_explicit (amount DECIMAL(10, 2))", + "INSERT INTO test_explicit VALUES (12345.67)"); + + try (Connection conn = DriverManager.getConnection("jdbc:calcite:", new Properties())) { + CalciteConnection calciteConn = conn.unwrap(CalciteConnection.class); + SchemaPlus root = calciteConn.getRootSchema(); + root.add("postgres", + JdbcSchema.create(root, "postgres", ds, "testdb", "public")); + try (Statement st = conn.createStatement(); + ResultSet rs = + st.executeQuery("SELECT * FROM \"postgres\".\"test_explicit\"")) { + ResultSetMetaData meta = rs.getMetaData(); + assertEquals(10, meta.getPrecision(1), + "Precision of DECIMAL(10,2) column must be 10"); + assertEquals(2, meta.getScale(1), + "Scale of DECIMAL(10,2) column must be 2"); + while (rs.next()) { + // drain + } + } + } + } +} diff --git a/core/src/test/resources/docker-compose.yml b/core/src/test/resources/docker-compose.yml new file mode 100644 index 000000000000..f2df0002f6e1 --- /dev/null +++ b/core/src/test/resources/docker-compose.yml @@ -0,0 +1,44 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +services: + + postgres: + image: postgres:15 + container_name: pg-calcite + environment: + POSTGRES_DB: testdb + POSTGRES_USER: testuser + POSTGRES_PASSWORD: testpass + ports: + - "5432:5432" + + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: mssql-calcite + environment: + ACCEPT_EULA: "Y" + SA_PASSWORD: TestPass123! + ports: + - "1433:1433" + + oracle: + image: gvenzl/oracle-free:slim + container_name: oracle-calcite + environment: + ORACLE_PASSWORD: testpass + ports: + - "1521:1521" diff --git a/gradle.properties b/gradle.properties index 8c7e62d40aea..1e0cb8e6be83 100644 --- a/gradle.properties +++ b/gradle.properties @@ -170,6 +170,8 @@ tpch.version=1.0 uzaygezen.version=0.2 xalan.version=2.7.1 xercesImpl.version=2.9.1 +mssql-jdbc.version=12.4.2.jre11 +postgresql-test.version=42.7.3 # sonar properties systemProp.sonar.gradle.skipCompile=true