diff --git a/plugin/trino-base-jdbc/src/test/java/io/trino/plugin/jdbc/BaseJdbcTableStatisticsTest.java b/plugin/trino-base-jdbc/src/test/java/io/trino/plugin/jdbc/BaseJdbcTableStatisticsTest.java index eeee51c6f31c..7b2158e24dc0 100644 --- a/plugin/trino-base-jdbc/src/test/java/io/trino/plugin/jdbc/BaseJdbcTableStatisticsTest.java +++ b/plugin/trino-base-jdbc/src/test/java/io/trino/plugin/jdbc/BaseJdbcTableStatisticsTest.java @@ -74,7 +74,7 @@ private void setUpTableFromTpch(String tableName) @Test public void testEmptyTable() { - String tableName = "test_stats_table_empty_" + randomTableSuffix(); + String tableName = "test_empty_" + randomTableSuffix(); computeActual(format("CREATE TABLE %s AS SELECT orderkey, custkey, orderpriority, comment FROM tpch.tiny.orders WHERE false", tableName)); try { gatherStats(tableName); diff --git a/plugin/trino-oracle/pom.xml b/plugin/trino-oracle/pom.xml index 344590a8cd29..cc22c391e4e1 100644 --- a/plugin/trino-oracle/pom.xml +++ b/plugin/trino-oracle/pom.xml @@ -77,6 +77,11 @@ runtime + + org.jdbi + jdbi3-core + + io.airlift log-manager diff --git a/plugin/trino-oracle/src/main/java/io/trino/plugin/oracle/OracleClient.java b/plugin/trino-oracle/src/main/java/io/trino/plugin/oracle/OracleClient.java index 53c7103872a4..8ec6eee1d8d4 100644 --- a/plugin/trino-oracle/src/main/java/io/trino/plugin/oracle/OracleClient.java +++ b/plugin/trino-oracle/src/main/java/io/trino/plugin/oracle/OracleClient.java @@ -28,6 +28,8 @@ import io.trino.plugin.jdbc.JdbcColumnHandle; import io.trino.plugin.jdbc.JdbcExpression; import io.trino.plugin.jdbc.JdbcJoinCondition; +import io.trino.plugin.jdbc.JdbcSortItem; +import io.trino.plugin.jdbc.JdbcStatisticsConfig; import io.trino.plugin.jdbc.JdbcTableHandle; import io.trino.plugin.jdbc.JdbcTypeHandle; import io.trino.plugin.jdbc.LongReadFunction; @@ -56,6 +58,10 @@ import io.trino.spi.connector.ConnectorSession; import io.trino.spi.connector.JoinCondition; import io.trino.spi.connector.SchemaTableName; +import io.trino.spi.predicate.TupleDomain; +import io.trino.spi.statistics.ColumnStatistics; +import io.trino.spi.statistics.Estimate; +import io.trino.spi.statistics.TableStatistics; import io.trino.spi.type.CharType; import io.trino.spi.type.DecimalType; import io.trino.spi.type.Decimals; @@ -63,6 +69,8 @@ import io.trino.spi.type.VarcharType; import oracle.jdbc.OraclePreparedStatement; import oracle.jdbc.OracleTypes; +import org.jdbi.v3.core.Handle; +import org.jdbi.v3.core.Jdbi; import javax.inject.Inject; @@ -85,7 +93,10 @@ import java.util.Set; import java.util.function.BiFunction; +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Throwables.throwIfInstanceOf; import static com.google.common.base.Verify.verify; +import static com.google.common.collect.ImmutableMap.toImmutableMap; import static io.airlift.slice.Slices.utf8Slice; import static io.airlift.slice.Slices.wrappedBuffer; import static io.trino.plugin.jdbc.JdbcErrorCode.JDBC_ERROR; @@ -142,6 +153,7 @@ import static java.lang.String.format; import static java.util.Locale.ENGLISH; import static java.util.concurrent.TimeUnit.DAYS; +import static java.util.function.Function.identity; public class OracleClient extends BaseJdbcClient @@ -198,6 +210,7 @@ public class OracleClient .buildOrThrow(); private final boolean synonymsEnabled; + private final boolean statisticsEnabled; private final ConnectorExpressionRewriter connectorExpressionRewriter; private final AggregateFunctionRewriter aggregateFunctionRewriter; @@ -205,6 +218,7 @@ public class OracleClient public OracleClient( BaseJdbcConfig config, OracleConfig oracleConfig, + JdbcStatisticsConfig statisticsConfig, ConnectionFactory connectionFactory, QueryBuilder queryBuilder, IdentifierMapping identifierMapping) @@ -212,6 +226,7 @@ public OracleClient( super(config, "\"", connectionFactory, queryBuilder, identifierMapping); this.synonymsEnabled = oracleConfig.isSynonymsEnabled(); + this.statisticsEnabled = requireNonNull(statisticsConfig, "statisticsConfig is null").isEnabled(); this.connectorExpressionRewriter = JdbcConnectorExpressionRewriterBuilder.newBuilder() .addStandardRules(this::quoted) @@ -470,12 +485,109 @@ public boolean isLimitGuaranteed(ConnectorSession session) return true; } + @Override + public boolean supportsTopN(ConnectorSession session, JdbcTableHandle handle, List sortOrder) + { + return true; + } + + @Override + protected Optional topNFunction() + { + // NOTE: The syntax used here is supported since Oracle 12c (older releases are not supported by Oracle) + return Optional.of(TopNFunction.sqlStandard(this::quoted)); + } + + @Override + public boolean isTopNGuaranteed(ConnectorSession session) + { + return true; + } + @Override protected boolean isSupportedJoinCondition(ConnectorSession session, JdbcJoinCondition joinCondition) { return joinCondition.getOperator() != JoinCondition.Operator.IS_DISTINCT_FROM; } + @Override + public TableStatistics getTableStatistics(ConnectorSession session, JdbcTableHandle handle, TupleDomain tupleDomain) + { + if (!statisticsEnabled) { + return TableStatistics.empty(); + } + if (!handle.isNamedRelation()) { + return TableStatistics.empty(); + } + try { + return readTableStatistics(session, handle); + } + catch (SQLException | RuntimeException e) { + throwIfInstanceOf(e, TrinoException.class); + throw new TrinoException(JDBC_ERROR, "Failed fetching statistics for table: " + handle, e); + } + } + + private TableStatistics readTableStatistics(ConnectorSession session, JdbcTableHandle table) + throws SQLException + { + checkArgument(table.isNamedRelation(), "Relation is not a table: %s", table); + + try (Connection connection = connectionFactory.openConnection(session); + Handle handle = Jdbi.open(connection)) { + StatisticsDao statisticsDao = new StatisticsDao(handle); + + Long rowCount = statisticsDao.getRowCount(table.getSchemaName(), table.getTableName()); + if (rowCount == null) { + return TableStatistics.empty(); + } + + TableStatistics.Builder tableStatistics = TableStatistics.builder(); + tableStatistics.setRowCount(Estimate.of(rowCount)); + + if (rowCount == 0) { + return tableStatistics.build(); + } + + Map columnStatistics = statisticsDao.getColumnStatistics(table.getSchemaName(), table.getTableName()).stream() + .collect(toImmutableMap(ColumnStatisticsResult::getColumnName, identity())); + + for (JdbcColumnHandle column : this.getColumns(session, table)) { + ColumnStatisticsResult result = columnStatistics.get(column.getColumnName()); + if (result == null) { + continue; + } + + ColumnStatistics statistics = ColumnStatistics.builder() + .setNullsFraction(result.getNullsCount() + .map(nullsCount -> Estimate.of(1.0 * nullsCount / rowCount)) + .orElseGet(Estimate::unknown)) + .setDistinctValuesCount(result.getDistinctValuesCount() + .map(Estimate::of) + .orElseGet(Estimate::unknown)) + .setDataSize(result.getAverageColumnLength() + /* + * ALL_TAB_COLUMNS.AVG_COL_LEN is hard to interpret precisely: + * - it can be `0` for all-null column + * - it can be `len+1` for varchar column filled with constant of length `len`, as if each row contained a is-null byte or length + * - it can be `len/2+1` for varchar column half-filled with constant (or random) of length `len`, as if each row contained a is-null byte or length + * - it can be `2` for varchar column with single non-null value of length 10, as if ... (?) + * - it looks storage size does not directly depend on `IS NULL` column attribute + * + * Since the interpretation of the value is not obvious, we do not deduce is-null bytes. They will be accounted for second time in + * `PlanNodeStatsEstimate.getOutputSizeForSymbol`, but this is the safer thing to do. + */ + .map(averageColumnLength -> Estimate.of(1.0 * averageColumnLength * rowCount)) + .orElseGet(Estimate::unknown)) + .build(); + + tableStatistics.setColumnStatistics(column, statistics); + } + + return tableStatistics.build(); + } + } + public static LongWriteFunction trinoDateToOracleDateWriteFunction() { return new LongWriteFunction() @@ -683,4 +795,76 @@ public void setColumnComment(ConnectorSession session, JdbcTableHandle handle, J varcharLiteral(comment.orElse(""))); execute(session, sql); } + + private static class StatisticsDao + { + private final Handle handle; + + public StatisticsDao(Handle handle) + { + this.handle = requireNonNull(handle, "handle is null"); + } + + Long getRowCount(String schema, String tableName) + { + return handle.createQuery("SELECT NUM_ROWS FROM ALL_TAB_STATISTICS WHERE OWNER = :schema AND TABLE_NAME = :table_name and PARTITION_NAME IS NULL") + .bind("schema", schema) + .bind("table_name", tableName) + .mapTo(Long.class) + .findFirst() + .orElse(null); + } + + List getColumnStatistics(String schema, String tableName) + { + // [SEP-3425] we are not using ALL_TAB_COL_STATISTICS, here because we observed queries which took multiple minutes when obtaining statistics for partitioned tables. + // It adds slight risk, because the statistics-related columns in ALL_TAB_COLUMNS are marked as deprecated and present only for backward + // compatibility with Oracle 7 (see: https://docs.oracle.com/cd/B14117_01/server.101/b10755/statviews_1180.htm) + return handle.createQuery("SELECT COLUMN_NAME, NUM_NULLS, NUM_DISTINCT, AVG_COL_LEN FROM ALL_TAB_COLUMNS WHERE OWNER = :schema AND TABLE_NAME = :table_name") + .bind("schema", schema) + .bind("table_name", tableName) + .map((rs, ctx) -> new ColumnStatisticsResult( + requireNonNull(rs.getString("COLUMN_NAME"), "COLUMN_NAME is null"), + Optional.ofNullable(rs.getObject("NUM_NULLS", Long.class)), + Optional.ofNullable(rs.getObject("NUM_DISTINCT", Long.class)), + Optional.ofNullable(rs.getObject("AVG_COL_LEN", Long.class)))) + .list(); + } + } + + private static class ColumnStatisticsResult + { + private final String columnName; + private final Optional nullsCount; + private final Optional distinctValuesCount; + private final Optional averageColumnLength; + + ColumnStatisticsResult(String columnName, Optional nullsCount, Optional distinctValuesCount, Optional averageColumnLength) + { + this.columnName = columnName; + this.nullsCount = nullsCount; + this.distinctValuesCount = distinctValuesCount; + this.averageColumnLength = averageColumnLength; + } + + String getColumnName() + { + return columnName; + } + + Optional getNullsCount() + { + return nullsCount; + } + + Optional getDistinctValuesCount() + { + return distinctValuesCount; + } + + Optional getAverageColumnLength() + { + return averageColumnLength; + } + } } diff --git a/plugin/trino-oracle/src/main/java/io/trino/plugin/oracle/OracleClientModule.java b/plugin/trino-oracle/src/main/java/io/trino/plugin/oracle/OracleClientModule.java index befc4338b224..d43897143e07 100644 --- a/plugin/trino-oracle/src/main/java/io/trino/plugin/oracle/OracleClientModule.java +++ b/plugin/trino-oracle/src/main/java/io/trino/plugin/oracle/OracleClientModule.java @@ -15,15 +15,17 @@ import com.google.inject.Binder; import com.google.inject.Key; -import com.google.inject.Module; import com.google.inject.Provides; import com.google.inject.Scopes; import com.google.inject.Singleton; +import io.airlift.configuration.AbstractConfigurationAwareModule; import io.trino.plugin.jdbc.BaseJdbcConfig; import io.trino.plugin.jdbc.ConnectionFactory; import io.trino.plugin.jdbc.DriverConnectionFactory; import io.trino.plugin.jdbc.ForBaseJdbc; import io.trino.plugin.jdbc.JdbcClient; +import io.trino.plugin.jdbc.JdbcJoinPushdownSupportModule; +import io.trino.plugin.jdbc.JdbcStatisticsConfig; import io.trino.plugin.jdbc.MaxDomainCompactionThreshold; import io.trino.plugin.jdbc.RetryingConnectionFactory; import io.trino.plugin.jdbc.credential.CredentialProvider; @@ -42,16 +44,18 @@ import static io.trino.plugin.oracle.OracleClient.ORACLE_MAX_LIST_EXPRESSIONS; public class OracleClientModule - implements Module + extends AbstractConfigurationAwareModule { @Override - public void configure(Binder binder) + protected void setup(Binder binder) { binder.bind(JdbcClient.class).annotatedWith(ForBaseJdbc.class).to(OracleClient.class).in(Scopes.SINGLETON); bindSessionPropertiesProvider(binder, OracleSessionProperties.class); configBinder(binder).bindConfig(OracleConfig.class); + configBinder(binder).bindConfig(JdbcStatisticsConfig.class); newOptionalBinder(binder, Key.get(int.class, MaxDomainCompactionThreshold.class)).setBinding().toInstance(ORACLE_MAX_LIST_EXPRESSIONS); newSetBinder(binder, ConnectorTableFunction.class).addBinding().toProvider(Query.class).in(Scopes.SINGLETON); + install(new JdbcJoinPushdownSupportModule()); } @Provides diff --git a/plugin/trino-oracle/src/test/java/io/trino/plugin/oracle/BaseOracleConnectorTest.java b/plugin/trino-oracle/src/test/java/io/trino/plugin/oracle/BaseOracleConnectorTest.java index ce415a83c1c9..33f6a91a0cc2 100644 --- a/plugin/trino-oracle/src/test/java/io/trino/plugin/oracle/BaseOracleConnectorTest.java +++ b/plugin/trino-oracle/src/test/java/io/trino/plugin/oracle/BaseOracleConnectorTest.java @@ -48,9 +48,6 @@ public abstract class BaseOracleConnectorTest protected boolean hasBehavior(TestingConnectorBehavior connectorBehavior) { switch (connectorBehavior) { - case SUPPORTS_TOPN_PUSHDOWN: - return false; - case SUPPORTS_AGGREGATION_PUSHDOWN: case SUPPORTS_AGGREGATION_PUSHDOWN_STDDEV: case SUPPORTS_AGGREGATION_PUSHDOWN_VARIANCE: diff --git a/plugin/trino-oracle/src/test/java/io/trino/plugin/oracle/TestOracleTableStatistics.java b/plugin/trino-oracle/src/test/java/io/trino/plugin/oracle/TestOracleTableStatistics.java new file mode 100644 index 000000000000..516e7993e364 --- /dev/null +++ b/plugin/trino-oracle/src/test/java/io/trino/plugin/oracle/TestOracleTableStatistics.java @@ -0,0 +1,396 @@ +/* + * Licensed 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 io.trino.plugin.oracle; + +import com.google.common.collect.ImmutableMap; +import io.trino.plugin.jdbc.BaseJdbcTableStatisticsTest; +import io.trino.testing.QueryRunner; +import io.trino.testing.sql.TestTable; +import io.trino.tpch.TpchTable; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.sql.CallableStatement; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import static io.trino.plugin.oracle.OracleQueryRunner.createOracleQueryRunner; +import static io.trino.plugin.oracle.TestingOracleServer.TEST_PASS; +import static io.trino.plugin.oracle.TestingOracleServer.TEST_USER; +import static io.trino.testing.sql.TestTable.fromColumns; +import static java.lang.String.format; + +@Test(singleThreaded = true) +public class TestOracleTableStatistics + extends BaseJdbcTableStatisticsTest +{ + private TestingOracleServer oracleServer; + + @Override + protected QueryRunner createQueryRunner() + throws Exception + { + oracleServer = closeAfterClass(new TestingOracleServer()); + return createOracleQueryRunner( + oracleServer, + Map.of(), + Map.of( + "connection-url", oracleServer.getJdbcUrl(), + "connection-user", TEST_USER, + "connection-password", TEST_PASS, + "case-insensitive-name-matching", "true"), + List.of(TpchTable.ORDERS)); + } + + @Override + @Test + public void testNotAnalyzed() + { + String tableName = "test_stats_not_analyzed"; + assertUpdate("DROP TABLE IF EXISTS " + tableName); + computeActual(format("CREATE TABLE %s AS SELECT * FROM tpch.tiny.orders", tableName)); + try { + assertQuery( + "SHOW STATS FOR " + tableName, + "VALUES " + + "('orderkey', null, null, null, null, null, null)," + + "('custkey', null, null, null, null, null, null)," + + "('orderstatus', null, null, null, null, null, null)," + + "('totalprice', null, null, null, null, null, null)," + + "('orderdate', null, null, null, null, null, null)," + + "('orderpriority', null, null, null, null, null, null)," + + "('clerk', null, null, null, null, null, null)," + + "('shippriority', null, null, null, null, null, null)," + + "('comment', null, null, null, null, null, null)," + + "(null, null, null, null, null, null, null)"); + } + finally { + assertUpdate("DROP TABLE " + tableName); + } + } + + @Override + @Test + public void testBasic() + { + String tableName = "test_stats_orders"; + assertUpdate("DROP TABLE IF EXISTS " + tableName); + computeActual(format("CREATE TABLE %s AS SELECT * FROM tpch.tiny.orders", tableName)); + try { + gatherStats(tableName); + assertQuery( + "SHOW STATS FOR " + tableName, + "VALUES " + + "('orderkey', null, 15000, 0, null, null, null)," + + "('custkey', null, 1000, 0, null, null, null)," + + "('orderstatus', 30000, 3, 0, null, null, null)," + + "('totalprice', null, 14996, 0, null, null, null)," + + "('orderdate', null, 2401, 0, null, null, null)," + + "('orderpriority', 150000, 5, 0, null, null, null)," + + "('clerk', 240000, 1000, 0, null, null, null)," + + "('shippriority', null, 1, 0, null, null, null)," + + "('comment', 750000, 14995, 0, null, null, null)," + + "(null, null, null, null, 15000, null, null)"); + } + finally { + assertUpdate("DROP TABLE " + tableName); + } + } + + @Override + @Test + public void testAllNulls() + { + String tableName = "test_stats_table_all_nulls"; + assertUpdate("DROP TABLE IF EXISTS " + tableName); + computeActual(format("CREATE TABLE %s AS SELECT orderkey, custkey, orderpriority, comment FROM tpch.tiny.orders WHERE false", tableName)); + try { + computeActual(format("INSERT INTO %s (orderkey) VALUES NULL, NULL, NULL", tableName)); + gatherStats(tableName); + assertQuery( + "SHOW STATS FOR " + tableName, + "VALUES " + + "('orderkey', 0, 0, 1, null, null, null)," + + "('custkey', 0, 0, 1, null, null, null)," + + "('orderpriority', 0, 0, 1, null, null, null)," + + "('comment', 0, 0, 1, null, null, null)," + + "(null, null, null, null, 3, null, null)"); + } + finally { + assertUpdate("DROP TABLE " + tableName); + } + } + + @Override + @Test + public void testNullsFraction() + { + String tableName = "test_stats_table_with_nulls"; + assertUpdate("DROP TABLE IF EXISTS " + tableName); + assertUpdate("" + + "CREATE TABLE " + tableName + " AS " + + "SELECT " + + " orderkey, " + + " if(orderkey % 3 = 0, NULL, custkey) custkey, " + + " if(orderkey % 5 = 0, NULL, orderpriority) orderpriority " + + "FROM tpch.tiny.orders", + 15000); + try { + gatherStats(tableName); + assertQuery( + "SHOW STATS FOR " + tableName, + "VALUES " + + "('orderkey', null, 15000, 0, null, null, null)," + + "('custkey', null, 1000, 0.3333333333333333, null, null, null)," + + "('orderpriority', 120000, 5, 0.2, null, null, null)," + + "(null, null, null, null, 15000, null, null)"); + } + finally { + assertUpdate("DROP TABLE " + tableName); + } + } + + @Override + @Test + public void testAverageColumnLength() + { + String tableName = "test_stats_table_avg_col_len"; + assertUpdate("DROP TABLE IF EXISTS " + tableName); + computeActual("" + + "CREATE TABLE " + tableName + " AS SELECT " + + " orderkey, " + + " 'abc' v3_in_3, " + + " CAST('abc' AS varchar(42)) v3_in_42, " + + " if(orderkey = 1, '0123456789', NULL) single_10v_value, " + + " if(orderkey % 2 = 0, '0123456789', NULL) half_10v_value, " + + " if(orderkey % 2 = 0, CAST((1000000 - orderkey) * (1000000 - orderkey) AS varchar(20)), NULL) half_distinct_20v_value, " + // 12 chars each + " CAST(NULL AS varchar(10)) all_nulls " + + "FROM tpch.tiny.orders " + + "ORDER BY orderkey LIMIT 100"); + try { + gatherStats(tableName); + assertQuery( + "SHOW STATS FOR " + tableName, + "VALUES " + + "('orderkey', null, 100, 0, null, null, null)," + + "('v3_in_3', 400, 1, 0, null, null, null)," + + "('v3_in_42', 400, 1, 0, null, null, null)," + + "('single_10v_value', 200, 1, 0.99, null, null, null)," + + "('half_10v_value', 600, 1, 0.5, null, null, null)," + + "('half_distinct_20v_value', 700, 50, 0.5, null, null, null)," + + "('all_nulls', 0, 0, 1, null, null, null)," + + "(null, null, null, null, 100, null, null)"); + } + finally { + assertUpdate("DROP TABLE " + tableName); + } + } + + @Override + @Test + public void testPartitionedTable() + { + String tableName = "test_stats_orders_part"; + assertUpdate("DROP TABLE IF EXISTS " + tableName); + executeInOracle("CREATE TABLE " + tableName + " PARTITION BY HASH(ORDERKEY) PARTITIONS 4 AS SELECT * FROM ORDERS"); + try { + gatherStats(tableName); + assertQuery( + "SHOW STATS FOR " + tableName, + "VALUES " + + "('orderkey', null, 15000, 0, null, null, null)," + + "('custkey', null, 1000, 0, null, null, null)," + + "('orderstatus', 30000, 3, 0, null, null, null)," + + "('totalprice', null, 14996, 0, null, null, null)," + + "('orderdate', null, 2401, 0, null, null, null)," + + "('orderpriority', 150000, 5, 0, null, null, null)," + + "('clerk', 240000, 1000, 0, null, null, null)," + + "('shippriority', null, 1, 0, null, null, null)," + + "('comment', 750000, 14995, 0, null, null, null)," + + "(null, null, null, null, 15000, null, null)"); + } + finally { + assertUpdate("DROP TABLE " + tableName); + } + } + + @Override + @Test + public void testView() + { + String tableName = "test_stats_view"; + executeInOracle("CREATE OR REPLACE VIEW " + tableName + " AS SELECT orderkey, custkey, orderpriority, \"COMMENT\" FROM orders"); + try { + assertQuery( + "SHOW STATS FOR " + tableName, + "VALUES " + + "('orderkey', null, null, null, null, null, null)," + + "('custkey', null, null, null, null, null, null)," + + "('orderpriority', null, null, null, null, null, null)," + + "('comment', null, null, null, null, null, null)," + + "(null, null, null, null, null, null, null)"); + // It's not possible to DBMS_STATS.GATHER_TABLE_STATS on a VIEW in Oracle + } + finally { + executeInOracle("DROP VIEW " + tableName); + } + } + + @Override + @Test + public void testMaterializedView() + { + String tableName = "test_stats_materialized_view"; + executeInOracle("" + + "CREATE MATERIALIZED VIEW " + tableName + " " + + "BUILD IMMEDIATE REFRESH ON DEMAND " + + "AS SELECT orderkey, custkey, orderpriority, \"COMMENT\" FROM orders"); + try { + // MATERIALIZED VIEW has stats gathered implicitly + assertQuery( + "SHOW STATS FOR " + tableName, + "VALUES " + + "('orderkey', null, 15000, 0, null, null, null)," + + "('custkey', null, 1000, 0, null, null, null)," + + "('orderpriority', 150000, 5, 0, null, null, null)," + + "('comment', 750000, 14995, 0, null, null, null)," + + "(null, null, null, null, 15000, null, null)"); + } + finally { + executeInOracle("DROP MATERIALIZED VIEW " + tableName); + } + } + + @Override + @Test(dataProvider = "testCaseColumnNamesDataProvider") + public void testCaseColumnNames(String tableName) + { + executeInOracle("" + + "CREATE TABLE " + tableName + " " + + "AS SELECT " + + " orderkey AS CASE_UNQUOTED_UPPER, " + + " custkey AS case_unquoted_lower, " + + " orderstatus AS cASe_uNQuoTeD_miXED, " + + " totalprice AS \"CASE_QUOTED_UPPER\", " + + " orderdate AS \"case_quoted_lower\"," + + " orderpriority AS \"CasE_QuoTeD_miXED\" " + + "FROM orders"); + try { + gatherStats(tableName); + assertQuery( + "SHOW STATS FOR " + tableName, + "VALUES " + + "('case_unquoted_upper', null, 15000, 0, null, null, null)," + + "('case_unquoted_lower', null, 1000, 0, null, null, null)," + + "('case_unquoted_mixed', 30000, 3, 0, null, null, null)," + + "('case_quoted_upper', null, 14996, 0, null, null, null)," + + "('case_quoted_lower', null, 2401, 0, null, null, null)," + + "('case_quoted_mixed', 150000, 5, 0, null, null, null)," + + "(null, null, null, null, 15000, null, null)"); + } + finally { + executeInOracle("DROP TABLE " + tableName); + } + } + + @Override + @DataProvider + public Object[][] testCaseColumnNamesDataProvider() + { + return new Object[][] { + {"MIXED_UNQUOTED_UPPER"}, + {"mixed_unquoted_lower"}, + {"mixed_uNQuoTeD_miXED"}, + {"\"MIXED_QUOTED_UPPER\""}, + {"\"mixed_quoted_lower\""}, + {"\"mixed_QuoTeD_miXED\""}, + }; + } + + @Override + @Test + public void testNumericCornerCases() + { + try (TestTable table = fromColumns( + getQueryRunner()::execute, + "numeric_corner_cases_", + ImmutableMap.>builder() + .put("only_negative_infinity double", List.of("-infinity()", "-infinity()", "-infinity()", "-infinity()")) + .put("only_positive_infinity double", List.of("infinity()", "infinity()", "infinity()", "infinity()")) + .put("mixed_infinities double", List.of("-infinity()", "infinity()", "-infinity()", "infinity()")) + .put("mixed_infinities_and_numbers double", List.of("-infinity()", "infinity()", "-5.0", "7.0")) + .put("nans_only double", List.of("nan()", "nan()")) + .put("nans_and_numbers double", List.of("nan()", "nan()", "-5.0", "7.0")) + .put("large_doubles double", List.of("CAST(-50371909150609548946090.0 AS DOUBLE)", "CAST(50371909150609548946090.0 AS DOUBLE)")) // 2^77 DIV 3 + .put("short_decimals_big_fraction decimal(16,15)", List.of("-1.234567890123456", "1.234567890123456")) + .put("short_decimals_big_integral decimal(16,1)", List.of("-123456789012345.6", "123456789012345.6")) + .put("long_decimals_big_fraction decimal(38,37)", List.of("-1.2345678901234567890123456789012345678", "1.2345678901234567890123456789012345678")) + .put("long_decimals_middle decimal(38,16)", List.of("-1234567890123456.7890123456789012345678", "1234567890123456.7890123456789012345678")) + .put("long_decimals_big_integral decimal(38,1)", List.of("-1234567890123456789012345678901234567.8", "1234567890123456789012345678901234567.8")) + .buildOrThrow(), + "null")) { + gatherStats(table.getName()); + assertQuery( + "SHOW STATS FOR " + table.getName(), + "VALUES " + + "('only_negative_infinity', null, 1, 0, null, null, null)," + + "('only_positive_infinity', null, 1, 0, null, null, null)," + + "('mixed_infinities', null, 2, 0, null, null, null)," + + "('mixed_infinities_and_numbers', null, 4.0, 0.0, null, null, null)," + + "('nans_only', null, 1.0, 0.5, null, null, null)," + + "('nans_and_numbers', null, 3.0, 0.0, null, null, null)," + + "('large_doubles', null, 2.0, 0.5, null, null, null)," + + "('short_decimals_big_fraction', null, 2.0, 0.5, null, null, null)," + + "('short_decimals_big_integral', null, 2.0, 0.5, null, null, null)," + + "('long_decimals_big_fraction', null, 2.0, 0.5, null, null, null)," + + "('long_decimals_middle', null, 2.0, 0.5, null, null, null)," + + "('long_decimals_big_integral', null, 2.0, 0.5, null, null, null)," + + "(null, null, null, null, 4, null, null)"); + } + } + + @Override + protected void gatherStats(String tableName) + { + executeInOracle(connection -> { + try (CallableStatement statement = connection.prepareCall("{CALL DBMS_STATS.GATHER_TABLE_STATS(?, ?)}")) { + statement.setString(1, TestingOracleServer.TEST_SCHEMA); + statement.setString(2, tableName); + statement.execute(); + } + catch (SQLException e) { + throw new RuntimeException(e); + } + }); + } + + private void executeInOracle(String sql) + { + oracleServer.execute(sql); + } + + private void executeInOracle(Consumer connectionCallback) + { + try (Connection connection = DriverManager.getConnection(oracleServer.getJdbcUrl(), TestingOracleServer.TEST_USER, TestingOracleServer.TEST_PASS)) { + connectionCallback.accept(connection); + } + catch (SQLException e) { + throw new RuntimeException(e); + } + } +}