From 3527804ef5d708e355130524b7605baa803c9751 Mon Sep 17 00:00:00 2001 From: chikamura Date: Tue, 14 May 2024 10:06:51 +0900 Subject: [PATCH 1/7] init --- .github/workflows/main.yml | 57 ++++ .gitignore | 16 + LICENSE.txt | 21 ++ README.md | 187 ++++++++++- build.gradle | 112 +++++++ config/checkstyle/checkstyle.xml | 128 ++++++++ config/checkstyle/default.xml | 108 +++++++ example/test.yml.example | 10 + .../compileClasspath.lockfile | 17 + .../embulkPluginRuntime.lockfile | 14 + .../runtimeClasspath.lockfile | 14 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 185 +++++++++++ gradlew.bat | 89 +++++ .../input/DatabricksInputConnection.java | 57 ++++ .../embulk/input/DatabricksInputPlugin.java | 111 +++++++ .../embulk/input/NoAutoCommitConnection.java | 304 ++++++++++++++++++ .../AbstractTestDatabricksInputPlugin.java | 46 +++ .../input/databricks/TestConnectionUtil.java | 47 +++ .../TestDatabricksInputPluginByQuery.java | 94 ++++++ .../TestDatabricksInputPluginByTable.java | 157 +++++++++ ...DatabricksInputPluginWithAbnormalType.java | 102 ++++++ ...tDatabricksInputPluginWithIncremental.java | 201 ++++++++++++ ...abricksInputPluginWithLikeBeforeSetup.java | 77 +++++ .../TestDatabricksInputPluginWithType.java | 149 +++++++++ .../databricks/util/ColumnOptionData.java | 60 ++++ .../input/databricks/util/ConfigUtil.java | 120 +++++++ .../input/databricks/util/ConnectionUtil.java | 82 +++++ .../util/DefaultColumnOptionData.java | 60 ++++ .../databricks/util/TestingEmbulkUtil.java | 20 ++ 31 files changed, 2649 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/main.yml create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 build.gradle create mode 100644 config/checkstyle/checkstyle.xml create mode 100644 config/checkstyle/default.xml create mode 100644 example/test.yml.example create mode 100644 gradle/dependency-locks/compileClasspath.lockfile create mode 100644 gradle/dependency-locks/embulkPluginRuntime.lockfile create mode 100644 gradle/dependency-locks/runtimeClasspath.lockfile create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 src/main/java/org/embulk/input/DatabricksInputConnection.java create mode 100644 src/main/java/org/embulk/input/DatabricksInputPlugin.java create mode 100644 src/main/java/org/embulk/input/NoAutoCommitConnection.java create mode 100644 src/test/java/org/embulk/input/databricks/AbstractTestDatabricksInputPlugin.java create mode 100644 src/test/java/org/embulk/input/databricks/TestConnectionUtil.java create mode 100644 src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginByQuery.java create mode 100644 src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginByTable.java create mode 100644 src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginWithAbnormalType.java create mode 100644 src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginWithIncremental.java create mode 100644 src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginWithLikeBeforeSetup.java create mode 100644 src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginWithType.java create mode 100644 src/test/java/org/embulk/input/databricks/util/ColumnOptionData.java create mode 100644 src/test/java/org/embulk/input/databricks/util/ConfigUtil.java create mode 100644 src/test/java/org/embulk/input/databricks/util/ConnectionUtil.java create mode 100644 src/test/java/org/embulk/input/databricks/util/DefaultColumnOptionData.java create mode 100644 src/test/java/org/embulk/input/databricks/util/TestingEmbulkUtil.java diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..eb2fd2e --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,57 @@ +name: main + +on: + push: + branches: + - 'main' + tags: + - '*' + pull_request: + branches: + - 'main' + types: [opened, synchronize] + pull_request_target: + branches: + - 'main' + types: [labeled] + +jobs: + test: + name: test + runs-on: ubuntu-latest + if: > + ${{ + github.event_name == 'pull_request' || + (github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe to test')) || + startsWith(github.ref, 'refs/tags/') + }} + steps: + - uses: actions/checkout@v2 + - name: Set up JDK 1.8 + uses: actions/setup-java@v1 + with: + java-version: 1.8 + - name: lint + run: ./gradlew spotlessCheck + - name: Test + run: ./gradlew test + build: + name: Build + Publish + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + needs: [ test ] + if: ${{ github.event_name == 'workflow_dispatch' || contains(github.ref, 'refs/tags/') }} + steps: + - uses: actions/checkout@v2 + - name: Set up Ruby 2.7 + uses: ruby/setup-ruby@v1 + with: + ruby-version: 2.7 + - name: push gem + uses: trocco-io/push-gem-to-gpr-action@v1 + with: + language: java + gem-path: "./pkg/*.gem" + github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff15ab2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +*~ +/pkg/ +/tmp/ +*.gemspec +.gradle/ +/classpath/ +build/ +.idea +/.settings/ +/.metadata/ +.classpath +.project +config.yml +default_jdbc_driver +/bin/ +example/test.yml \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..43acdd5 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 8d71222..ab5ae86 100644 --- a/README.md +++ b/README.md @@ -1 +1,186 @@ -# embulk-input-databricks +# Databricks input plugin for Embulk + +Databricks input plugin for Embulk loads records from Databricks. + +## Overview + +* **Plugin type**: input +* **Resume supported**: yes + +## Configuration + +- **driver_path**: path to the jar file of the JDBC driver. If not set, [the bundled JDBC driver](https://docs.databricks.com/en/integrations/jdbc/index.html) will be used. (string, optional) +- **server_hostname**: The Databricks compute resource’s Server Hostname value, see [Compute settings for the Databricks JDBC Driver](https://docs.databricks.com/en/integrations/jdbc/compute.html). (string, required) +- **http_path**: The Databricks compute resource’s HTTP Path value, see [Compute settings for the Databricks JDBC Driver](https://docs.databricks.com/en/integrations/jdbc/compute.html). (string, required) +- **personal_access_token**: The Databaricks personal_access_token, see [Authentication settings for the Databricks JDBC Driver](https://docs.databricks.com/en/integrations/jdbc/authentication.html#authentication-pat). (string, required) +- **catalog_name**: destination catalog name (string, optional) +- **schema_name**: destination schema name (string, optional) +- **where**: WHERE condition to filter the rows (string, default: no-condition) +- **fetch_rows**: number of rows to fetch one time (used for java.sql.Statement#setFetchSize) (integer, default: 10000) +- **connect_timeout**: timeout for establishment of a database connection. (integer (seconds), default: 300) +- **socket_timeout**: timeout for socket read operations. 0 means no timeout. (integer (seconds), default: 1800) +- If you write SQL directly, + - **query**: SQL to run (string) +- If **query** is not set, + - **table**: destination table name (string, required) + - **select**: expression of select (e.g. `id, created_at`) (string, default: "*") + - **where**: WHERE condition to filter the rows (string, default: no-condition) + - **order_by**: expression of ORDER BY to sort rows (e.g. `created_at DESC, id ASC`) (string, default: not sorted) +- **incremental**: if true, enables incremental loading. See next section for details (boolean, default: false) +- **incremental_columns**: column names for incremental loading (array of strings, default: use primary keys). Columns of integer types, string types, `timestamp` are supported. +- **last_record**: values of the last record for incremental loading (array of objects, default: load all records) +- **default_timezone**: If the sql type of a column is `date`/`time`/`datetime` and the embulk type is `string`, column values are formatted int this default_timezone. You can overwrite timezone for each columns using column_options option. (string, default: `UTC`) +- **default_column_options**: advanced: column_options for each JDBC type as default. key-value pairs where key is a JDBC type (e.g. 'DATE', 'BIGINT') and value is same as column_options's value. +- **column_options**: advanced: key-value pairs where key is a column name and value is options for the column. + - **value_type**: embulk get values from database as this value_type. Typically, the value_type determines `getXXX` method of `java.sql.PreparedStatement`. + (string, default: depends on the sql type of the column. Available values options are: `long`, `double`, `float`, `decimal`, `boolean`, `string`, `json`, `date`, `time`, `timestamp`) + - **type**: Column values are converted to this embulk type. + Available values options are: `boolean`, `long`, `double`, `string`, `json`, `timestamp`). + By default, the embulk type is determined according to the sql type of the column (or value_type if specified). + - **timestamp_format**: If the sql type of the column is `date`/`time`/`datetime` and the embulk type is `string`, column values are formatted by this timestamp_format. And if the embulk type is `timestamp`, this timestamp_format may be used in the output plugin. For example, stdout plugin use the timestamp_format, but *csv formatter plugin doesn't use*. (string, default : `%Y-%m-%d` for `date`, `%H:%M:%S` for `time`, `%Y-%m-%d %H:%M:%S` for `timestamp`) + - **timezone**: If the sql type of the column is `date`/`time`/`datetime` and the embulk type is `string`, column values are formatted in this timezone. +(string, value of default_timezone option is used by default) +- **before_setup**: if set, this SQL will be executed before setup. You can prepare table for input by this option. +- **before_select**: if set, this SQL will be executed before the SELECT query. (Other plugins execute query in the same transaction, but Databricks does not support transaction in multi statement, so this plugin does not support it.) +- **after_select**: if set, this SQL will be executed after the SELECT query. (Other plugins execute query in the same transaction, but Databricks does not support transaction in multi statement, so this plugin does not support it.) + + +### Incremental loading + +Incremental loading uses monotonically increasing unique columns (such as auto-increment (IDENTITY) column) to load records inserted (or updated) after last execution. + +First, if `incremental: true` is set, this plugin loads all records with additional ORDER BY. For example, if `incremental_columns: [updated_at, id]` option is set, query will be as following: + +``` +SELECT * FROM ( + ...original query is here... +) +ORDER BY updated_at, id +``` + +When bulk data loading finishes successfully, it outputs `last_record: ` paramater as config-diff so that next execution uses it. + +At the next execution, when `last_record: ` is also set, this plugin generates additional WHERE conditions to load records larger than the last record. For example, if `last_record: ["2017-01-01T00:32:12.487659", 5291]` is set, + +``` +SELECT * FROM ( + ...original query is here... +) +WHERE updated_at > '2017-01-01 00:32:12.487659' OR (updated_at = '2017-01-01 00:32:12.487659' AND id > 5291) +ORDER BY updated_at, id +``` + +Then, it updates `last_record: ` so that next execution uses the updated last_record. + +**IMPORTANT**: If you set `incremental_columns: ` option, make sure that there is an index on the columns to avoid full table scan. For this example, following index should be created: + +``` +CREATE INDEX embulk_incremental_loading_index ON table (updated_at, id); +``` + +Recommended usage is to leave `incremental_columns` unset and let this plugin automatically finds an auto-increment (IDENTITY) primary key. Currently, only strings, integers, TIMESTAMP and TIMESTAMPTZ are supported as incremental_columns. + +## Example + +```yaml +in: + type: databricks + server_hostname: dbc-xxxx.cloud.databricks.com + http_path: /sql/1.0/warehouses/xxxxx + personal_access_token: dapixxxxxx + catalog_name: test_catalog + schema_name: test_schema + table: test_date + select: "col1, col2, col3" + where: "col4 != 'a'" + order_by: "col1 DESC" +``` + +This configuration will generate following SQL: + +``` +SELECT col1, col2, col3 +FROM "my_table" +WHERE col4 != 'a' +ORDER BY col1 DESC +``` + +If you need a complex SQL, + +```yaml +in: + type: databricks + server_hostname: dbc-xxxx.cloud.databricks.com + http_path: /sql/1.0/warehouses/xxxxx + personal_access_token: dapixxxxxx + catalog_name: test_catalog + query: | + SELECT t1.id, t1.name, t2.id AS t2_id, t2.name AS t2_name + FROM table1 AS t1 + LEFT JOIN table2 AS t2 + ON t1.id = t2.t1_id +``` + +Advanced configuration: + +```yaml +in: + type: databricks + server_hostname: dbc-xxxx.cloud.databricks.com + http_path: /sql/1.0/warehouses/xxxxx + personal_access_token: dapixxxxxx + catalog_name: test_catalog + schema_name: test_schema + table: test_date + select: "col1, col2, col3" + where: "col4 != 'a'" + default_column_options: + TIMESTAMP: { type: string, timestamp_format: "%Y/%m/%d %H:%M:%S", timezone: "+0900"} + BIGINT: { type: string } + column_options: + col1: {type: long} + col3: {type: string, timestamp_format: "%Y/%m/%d", timezone: "+0900"} + after_select: "update my_table set col5 = '1' where col4 != 'a'" + +``` + +## NOTE + +### Correspondence table for databrick types and JDBC Types + +| databrick types | JDBC Types | +|--------------- |----------- | +| BIGINT | BIGINT | +| BINARY | unsupported | +| BOOLEAN | BOOLEAN | +| DATE | DATE | +| DECIMAL | DECIMAL | +| DOUBLE | DOUBLE | +| FLOAT | REAL | +| INT | INTEGER | +| INTERVAL | VARCHAR | +| SMALLINT | SMALLINT | +| STRING | VARCHAR | +| TIMETAMP | TIMESTAMP | +| TIMETAMP\_NTZ | unsupported | +| TINYINT | TINYINT | +| ARRAY | VARCHAR | +| MAP | VARCHAR | +| STRUCT | VARCHAR | + +### TIMESTAMP_NTZ + +[The official Databricks JDBC driver does not support TIMESTAMP_NTZ](https://docs.databricks.com/en/sql/language-manual/data-types/timestamp-ntz-type.html#notes), so this plugin officially does not support TIMESTAMP_NTZ. + + +## Build + +``` +$ ./gradlew gem +``` + +Running tests: + +``` +$ EMBULK_INPUT_DATABRICKS_TEST_CONFIG="example/test.yml" ./gradlew test # Create example/test.yml based on example/test.yml.example +``` diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..7a2a28c --- /dev/null +++ b/build.gradle @@ -0,0 +1,112 @@ +plugins { + id "java" + id "maven-publish" + id "checkstyle" + id "org.embulk.embulk-plugins" version "0.5.5" + id "com.palantir.git-version" version "0.13.0" + id "com.diffplug.spotless" version "5.15.0" + id "com.adarshr.test-logger" version "3.0.0" +} + +repositories { + mavenCentral() +} + +group = "io.trocco" +description = "Loads records from Databricks." + +sourceCompatibility = 1.8 +targetCompatibility = 1.8 + +version = { + def vd = versionDetails() + if (vd.commitDistance == 0 && vd.lastTag ==~ /^[0-9]+\.[0-9]+\.[0-9]+(\.[a-zA-Z0-9]+)?/) { + vd.lastTag + } else { + "0.0.0.${vd.gitHash}" + } +}() + +dependencies { + def embulkVersion = "0.10.36" + + compileOnly("org.embulk:embulk-api:${embulkVersion}") + compileOnly("org.embulk:embulk-spi:${embulkVersion}") + compile("org.embulk:embulk-input-jdbc:0.13.2") + compile('com.databricks:databricks-jdbc:2.6.38') + + testImplementation "junit:junit:4.+" + testImplementation "org.embulk:embulk-junit4:${embulkVersion}" + testImplementation "org.embulk:embulk-core:${embulkVersion}" + testImplementation "org.embulk:embulk-deps:${embulkVersion}" + testImplementation "org.embulk:embulk-formatter-csv:${embulkVersion}" + testImplementation "org.embulk:embulk-output-file:${embulkVersion}" + + //SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder". + //SLF4J: Defaulting to no-operation (NOP) logger implementation + //SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details. + testImplementation("org.slf4j:slf4j-simple:1.7.30") +} + +embulkPlugin { + mainClass = "org.embulk.input.DatabricksInputPlugin" + category = "input" + type = "databricks" +} + +test { + environment "TZ", "UTC" +} + +clean { + delete "classpath" + delete 'default_jdbc_driver' +} + +checkstyle { + configFile = file("${project.rootDir}/config/checkstyle/checkstyle.xml") + toolVersion = '6.14.1' +} +checkstyleMain { + configFile = file("${project.rootDir}/config/checkstyle/default.xml") + ignoreFailures = true +} +checkstyleTest { + configFile = file("${project.rootDir}/config/checkstyle/default.xml") + ignoreFailures = true +} + + +// This Gradle plugin's POM dependency modification works for "maven-publish" tasks. +// +// Note that "uploadArchives" is no longer supported. It is deprecated in Gradle 6 to be removed in Gradle 7. +// https://github.com/gradle/gradle/issues/3003#issuecomment-495025844 +publishing { + publications { + embulkPluginMaven(MavenPublication) { // Publish it with "publishEmbulkPluginMavenPublicationToMavenRepository". + from components.java // Must be "components.java". The dependency modification works only for it. + } + } + repositories { + maven { + url = "${project.buildDir}/mavenPublishLocal" + } + } +} +gem { + from("LICENSE.txt") + authors = [ "" ] + email = [ "" ] + summary = "Databricks input plugin for Embulk" + homepage = "https://github.com/trocco-io/embulk-input-databricks" + licenses = [ "MIT" ] +} +spotless { + java { + importOrder() + removeUnusedImports() + googleJavaFormat() + toggleOffOn() + } +} + diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000..24515d7 --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/checkstyle/default.xml b/config/checkstyle/default.xml new file mode 100644 index 0000000..d6ba96f --- /dev/null +++ b/config/checkstyle/default.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/test.yml.example b/example/test.yml.example new file mode 100644 index 0000000..83c7e79 --- /dev/null +++ b/example/test.yml.example @@ -0,0 +1,10 @@ +# The catalog_name.schema_name and another_catalog_name.schema_name must be created in advance. +# The another_catalog_name must be different from catalog_name. + +server_hostname: +http_path: +personal_access_token: +catalog_name: +schema_name: +table_prefix: +another_catalog_name: diff --git a/gradle/dependency-locks/compileClasspath.lockfile b/gradle/dependency-locks/compileClasspath.lockfile new file mode 100644 index 0000000..5a6f08f --- /dev/null +++ b/gradle/dependency-locks/compileClasspath.lockfile @@ -0,0 +1,17 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.databricks:databricks-jdbc:2.6.38 +com.fasterxml.jackson.core:jackson-annotations:2.6.7 +com.fasterxml.jackson.core:jackson-core:2.6.7 +com.fasterxml.jackson.core:jackson-databind:2.6.7 +com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.6.7 +javax.validation:validation-api:1.1.0.Final +org.embulk:embulk-api:0.10.36 +org.embulk:embulk-input-jdbc:0.13.2 +org.embulk:embulk-spi:0.10.36 +org.embulk:embulk-util-config:0.3.2 +org.embulk:embulk-util-json:0.1.1 +org.embulk:embulk-util-timestamp:0.2.1 +org.msgpack:msgpack-core:0.8.11 +org.slf4j:slf4j-api:1.7.30 diff --git a/gradle/dependency-locks/embulkPluginRuntime.lockfile b/gradle/dependency-locks/embulkPluginRuntime.lockfile new file mode 100644 index 0000000..394c7de --- /dev/null +++ b/gradle/dependency-locks/embulkPluginRuntime.lockfile @@ -0,0 +1,14 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.databricks:databricks-jdbc:2.6.38 +com.fasterxml.jackson.core:jackson-annotations:2.6.7 +com.fasterxml.jackson.core:jackson-core:2.6.7 +com.fasterxml.jackson.core:jackson-databind:2.6.7 +com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.6.7 +javax.validation:validation-api:1.1.0.Final +org.embulk:embulk-input-jdbc:0.13.2 +org.embulk:embulk-util-config:0.3.2 +org.embulk:embulk-util-json:0.1.1 +org.embulk:embulk-util-rubytime:0.3.2 +org.embulk:embulk-util-timestamp:0.2.1 diff --git a/gradle/dependency-locks/runtimeClasspath.lockfile b/gradle/dependency-locks/runtimeClasspath.lockfile new file mode 100644 index 0000000..394c7de --- /dev/null +++ b/gradle/dependency-locks/runtimeClasspath.lockfile @@ -0,0 +1,14 @@ +# This is a Gradle generated file for dependency locking. +# Manual edits can break the build and are not advised. +# This file is expected to be part of source control. +com.databricks:databricks-jdbc:2.6.38 +com.fasterxml.jackson.core:jackson-annotations:2.6.7 +com.fasterxml.jackson.core:jackson-core:2.6.7 +com.fasterxml.jackson.core:jackson-databind:2.6.7 +com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.6.7 +javax.validation:validation-api:1.1.0.Final +org.embulk:embulk-input-jdbc:0.13.2 +org.embulk:embulk-util-config:0.3.2 +org.embulk:embulk-util-json:0.1.1 +org.embulk:embulk-util-rubytime:0.3.2 +org.embulk:embulk-util-timestamp:0.2.1 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..e708b1c023ec8b20f512888fe07c5bd3ff77bb8f GIT binary patch literal 59203 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

K7UpUK$>l@ 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 E2O(~oYXATM literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3ab0b72 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.9.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# 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 +# +# https://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. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/main/java/org/embulk/input/DatabricksInputConnection.java b/src/main/java/org/embulk/input/DatabricksInputConnection.java new file mode 100644 index 0000000..80c1559 --- /dev/null +++ b/src/main/java/org/embulk/input/DatabricksInputConnection.java @@ -0,0 +1,57 @@ +package org.embulk.input; + +import java.lang.invoke.MethodHandles; +import java.sql.*; +import org.embulk.input.jdbc.JdbcInputConnection; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class DatabricksInputConnection extends JdbcInputConnection { + private static final Logger logger = + LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + final String catalogName; + + public DatabricksInputConnection(Connection connection, String catalogName, String schemaName) + throws SQLException { + super(new NoAutoCommitConnection(connection), schemaName); + this.catalogName = catalogName; + useCatalog(catalogName); + useSchema(schemaName); + } + + @Override + protected void setSearchPath(String schema) throws SQLException { + // There is nothing to do here as the schema needs to be configured after the catalogue has been + // set up. + // Also, the command to set the schema is unique to Databricks. + } + + protected void useCatalog(String catalog) throws SQLException { + // https://docs.databricks.com/en/sql/language-manual/sql-ref-syntax-ddl-use-catalog.html + if (catalog != null) { + executeUpdate("USE CATALOG " + quoteIdentifierString(catalog)); + } else { + String res = fetchOneColumn("SELECT CURRENT_CATALOG()"); + logger.debug("catalog_name is not set. current catalog is {}.", res); + } + } + + protected void useSchema(String schema) throws SQLException { + // https://docs.databricks.com/en/sql/language-manual/sql-ref-syntax-ddl-use-schema.html + if (schema != null) { + executeUpdate("USE SCHEMA " + quoteIdentifierString(schema)); + } else { + String res = fetchOneColumn("SELECT CURRENT_SCHEMA()"); + logger.debug("schema_name is not set. current schema is {}.", res); + } + } + + protected String fetchOneColumn(String sql) throws SQLException { + logger.info("SQL: " + sql); + try (Statement stmt = connection.createStatement()) { + ResultSet rs = stmt.executeQuery(sql); + rs.next(); + return rs.getString(1); + } + } +} diff --git a/src/main/java/org/embulk/input/DatabricksInputPlugin.java b/src/main/java/org/embulk/input/DatabricksInputPlugin.java new file mode 100644 index 0000000..85a425d --- /dev/null +++ b/src/main/java/org/embulk/input/DatabricksInputPlugin.java @@ -0,0 +1,111 @@ +package org.embulk.input; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.Optional; +import java.util.Properties; +import org.embulk.config.ConfigException; +import org.embulk.input.jdbc.AbstractJdbcInputPlugin; +import org.embulk.input.jdbc.JdbcInputConnection; +import org.embulk.spi.Schema; +import org.embulk.util.config.Config; +import org.embulk.util.config.ConfigDefault; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class DatabricksInputPlugin extends AbstractJdbcInputPlugin { + + private static final Logger logger = LoggerFactory.getLogger(DatabricksInputPlugin.class); + + public interface DatabricksPluginTask extends PluginTask { + @Config("driver_path") + @ConfigDefault("null") + public Optional getDriverPath(); + + @Config("server_hostname") + public String getServerHostname(); + + @Config("http_path") + public String getHTTPPath(); + + @Config("personal_access_token") + public String getPersonalAccessToken(); + + @Config("catalog_name") + @ConfigDefault("null") + public Optional getCatalogName(); + + @Config("schema_name") + @ConfigDefault("null") + public Optional getSchemaName(); + } + + @Override + protected Class getTaskClass() { + return DatabricksPluginTask.class; + } + + @Override + protected JdbcInputConnection newConnection(PluginTask task) throws SQLException { + // https://docs.databricks.com/en/integrations/jdbc/index.html + // https://docs.databricks.com/en/integrations/jdbc/authentication.html + // https://docs.databricks.com/en/integrations/jdbc/compute.html + DatabricksPluginTask t = (DatabricksPluginTask) task; + if (t.getDriverPath().isPresent()) { + addDriverJarToClasspath(t.getDriverPath().get()); + } else { + try { + Class.forName("com.databricks.client.jdbc.Driver"); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + String url = String.format("jdbc:databricks://%s:443", t.getServerHostname()); + Properties props = new java.util.Properties(); + props.put("httpPath", t.getHTTPPath()); + props.put("AuthMech", "3"); + props.put("UID", "token"); + props.put("PWD", t.getPersonalAccessToken()); + props.put("SSL", "1"); + if (t.getCatalogName().isPresent()) { + props.put("ConnCatalog", t.getCatalogName().get()); + } + if (t.getSchemaName().isPresent()) { + props.put("ConnSchema", t.getSchemaName().get()); + } + logConnectionProperties(url, props); + Connection c = DriverManager.getConnection(url, props); + return new DatabricksInputConnection( + c, t.getCatalogName().orElse(null), t.getSchemaName().orElse(null)); + } + + @Override + protected void logConnectionProperties(String url, Properties props) { + Properties maskedProps = new Properties(); + for (Object keyObj : props.keySet()) { + String key = (String) keyObj; + String maskedVal = key.equals("PWD") ? "***" : props.getProperty(key); + maskedProps.setProperty(key, maskedVal); + } + super.logConnectionProperties(url, maskedProps); + } + + @Override + protected Schema setupTask(JdbcInputConnection con, PluginTask task) throws SQLException { + if (task.getUseRawQueryWithIncremental()) { + // spotless:off + // A query metadata with placeholder result is empty in databricks jdbc. + // + // Example: + // CREATE TABLE t (_c0 LONG PRIMARY KEY, _c1 STRING) + // connection().prepareStatement("select * from t where c0 > ?").getMetaData().getColumnCount(); // 0 + // + // So, the input schema cannot be determined by the query, use_raw_query_with_incremental is not supported. + // If that behaviour changes, enable use_raw_query_with_incremental support. + // spotless:on + throw new ConfigException("use_raw_query_with_incremental option is not supported."); + } + return super.setupTask(con, task); + } +} diff --git a/src/main/java/org/embulk/input/NoAutoCommitConnection.java b/src/main/java/org/embulk/input/NoAutoCommitConnection.java new file mode 100644 index 0000000..eafa8e4 --- /dev/null +++ b/src/main/java/org/embulk/input/NoAutoCommitConnection.java @@ -0,0 +1,304 @@ +package org.embulk.input; + +import java.lang.invoke.MethodHandles; +import java.sql.*; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.Executor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +// Databricks do not support setAutoCommit and commit. +// This class was created just to avoid calling them. +// If only there were some other good workaround, fix me. +class NoAutoCommitConnection implements Connection { + private static final Logger logger = + LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); + + Connection conn; + + public NoAutoCommitConnection(Connection conn) { + this.conn = conn; + } + + @Override + public void setAutoCommit(boolean autoCommit) { + logger.info( + "ignore setAutoCommit({}). (Databricks does not support setAutoCommit. AutoCommit state is always true)", + autoCommit); + } + + @Override + public boolean getAutoCommit() throws SQLException { + return conn.getAutoCommit(); + } + + @Override + public void commit() throws SQLException { + logger.info( + "ignore commit(). (Databricks autocommit state is always true and cannot be commit.)"); + } + + @Override + public Statement createStatement() throws SQLException { + return conn.createStatement(); + } + + @Override + public PreparedStatement prepareStatement(String sql) throws SQLException { + return conn.prepareStatement(sql); + } + + @Override + public CallableStatement prepareCall(String sql) throws SQLException { + return conn.prepareCall(sql); + } + + @Override + public String nativeSQL(String sql) throws SQLException { + return conn.nativeSQL(sql); + } + + @Override + public void rollback() throws SQLException { + conn.rollback(); + } + + @Override + public void close() throws SQLException { + conn.close(); + } + + @Override + public boolean isClosed() throws SQLException { + return conn.isClosed(); + } + + @Override + public DatabaseMetaData getMetaData() throws SQLException { + return conn.getMetaData(); + } + + @Override + public void setReadOnly(boolean readOnly) throws SQLException { + conn.setReadOnly(readOnly); + } + + @Override + public boolean isReadOnly() throws SQLException { + return conn.isReadOnly(); + } + + @Override + public void setCatalog(String catalog) throws SQLException { + conn.setCatalog(catalog); + } + + @Override + public String getCatalog() throws SQLException { + return conn.getCatalog(); + } + + @Override + public void setTransactionIsolation(int level) throws SQLException { + conn.setTransactionIsolation(level); + } + + @Override + public int getTransactionIsolation() throws SQLException { + return conn.getTransactionIsolation(); + } + + @Override + public SQLWarning getWarnings() throws SQLException { + return conn.getWarnings(); + } + + @Override + public void clearWarnings() throws SQLException { + conn.clearWarnings(); + } + + @Override + public Statement createStatement(int resultSetType, int resultSetConcurrency) + throws SQLException { + return conn.createStatement(resultSetType, resultSetConcurrency); + } + + @Override + public PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency) + throws SQLException { + return conn.prepareStatement(sql, resultSetType, resultSetConcurrency); + } + + @Override + public CallableStatement prepareCall(String sql, int resultSetType, int resultSetConcurrency) + throws SQLException { + return conn.prepareCall(sql, resultSetType, resultSetConcurrency); + } + + @Override + public Map> getTypeMap() throws SQLException { + return conn.getTypeMap(); + } + + @Override + public void setTypeMap(Map> map) throws SQLException { + conn.setTypeMap(map); + } + + @Override + public void setHoldability(int holdability) throws SQLException { + conn.setHoldability(holdability); + } + + @Override + public int getHoldability() throws SQLException { + return conn.getHoldability(); + } + + @Override + public Savepoint setSavepoint() throws SQLException { + return conn.setSavepoint(); + } + + @Override + public Savepoint setSavepoint(String name) throws SQLException { + return conn.setSavepoint(name); + } + + @Override + public void rollback(Savepoint savepoint) throws SQLException { + conn.rollback(savepoint); + } + + @Override + public void releaseSavepoint(Savepoint savepoint) throws SQLException { + conn.releaseSavepoint(savepoint); + } + + @Override + public Statement createStatement( + int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException { + return conn.createStatement(resultSetType, resultSetConcurrency, resultSetHoldability); + } + + @Override + public PreparedStatement prepareStatement( + String sql, int resultSetType, int resultSetConcurrency, int resultSetHoldability) + throws SQLException { + return conn.prepareStatement(sql, resultSetType, resultSetConcurrency, resultSetHoldability); + } + + @Override + public CallableStatement prepareCall( + String sql, int resultSetType, int resultSetConcurrency, int resultSetHoldability) + throws SQLException { + return conn.prepareCall(sql, resultSetType, resultSetConcurrency, resultSetHoldability); + } + + @Override + public PreparedStatement prepareStatement(String sql, int autoGeneratedKeys) throws SQLException { + return conn.prepareStatement(sql, autoGeneratedKeys); + } + + @Override + public PreparedStatement prepareStatement(String sql, int[] columnIndexes) throws SQLException { + return conn.prepareStatement(sql, columnIndexes); + } + + @Override + public PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException { + return conn.prepareStatement(sql, columnNames); + } + + @Override + public Clob createClob() throws SQLException { + return conn.createClob(); + } + + @Override + public Blob createBlob() throws SQLException { + return conn.createBlob(); + } + + @Override + public NClob createNClob() throws SQLException { + return conn.createNClob(); + } + + @Override + public SQLXML createSQLXML() throws SQLException { + return conn.createSQLXML(); + } + + @Override + public boolean isValid(int timeout) throws SQLException { + return conn.isValid(timeout); + } + + @Override + public void setClientInfo(String name, String value) throws SQLClientInfoException { + conn.setClientInfo(name, value); + } + + @Override + public void setClientInfo(Properties properties) throws SQLClientInfoException { + conn.setClientInfo(properties); + } + + @Override + public String getClientInfo(String name) throws SQLException { + return conn.getClientInfo(name); + } + + @Override + public Properties getClientInfo() throws SQLException { + return conn.getClientInfo(); + } + + @Override + public Array createArrayOf(String typeName, Object[] elements) throws SQLException { + return conn.createArrayOf(typeName, elements); + } + + @Override + public Struct createStruct(String typeName, Object[] attributes) throws SQLException { + return conn.createStruct(typeName, attributes); + } + + @Override + public void setSchema(String schema) throws SQLException { + conn.setSchema(schema); + } + + @Override + public String getSchema() throws SQLException { + return conn.getSchema(); + } + + @Override + public void abort(Executor executor) throws SQLException { + conn.abort(executor); + } + + @Override + public void setNetworkTimeout(Executor executor, int milliseconds) throws SQLException { + conn.setNetworkTimeout(executor, milliseconds); + } + + @Override + public int getNetworkTimeout() throws SQLException { + return conn.getNetworkTimeout(); + } + + @Override + public T unwrap(Class iface) throws SQLException { + return conn.unwrap(iface); + } + + @Override + public boolean isWrapperFor(Class iface) throws SQLException { + return conn.isWrapperFor(iface); + } +} diff --git a/src/test/java/org/embulk/input/databricks/AbstractTestDatabricksInputPlugin.java b/src/test/java/org/embulk/input/databricks/AbstractTestDatabricksInputPlugin.java new file mode 100644 index 0000000..4c498c6 --- /dev/null +++ b/src/test/java/org/embulk/input/databricks/AbstractTestDatabricksInputPlugin.java @@ -0,0 +1,46 @@ +package org.embulk.input.databricks; + +import java.util.Properties; +import org.embulk.EmbulkSystemProperties; +import org.embulk.formatter.csv.CsvFormatterPlugin; +import org.embulk.input.DatabricksInputPlugin; +import org.embulk.input.databricks.util.ConfigUtil; +import org.embulk.input.databricks.util.ConnectionUtil; +import org.embulk.output.file.LocalFileOutputPlugin; +import org.embulk.spi.FileOutputPlugin; +import org.embulk.spi.FormatterPlugin; +import org.embulk.spi.InputPlugin; +import org.embulk.test.TestingEmbulk; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; + +public class AbstractTestDatabricksInputPlugin { + private static final EmbulkSystemProperties EMBULK_SYSTEM_PROPERTIES = + EmbulkSystemProperties.of(new Properties()); + + @Rule + public TestingEmbulk embulk = + TestingEmbulk.builder() + .setEmbulkSystemProperties(EMBULK_SYSTEM_PROPERTIES) + .registerPlugin(FormatterPlugin.class, "csv", CsvFormatterPlugin.class) + .registerPlugin(FileOutputPlugin.class, "file", LocalFileOutputPlugin.class) + .registerPlugin(InputPlugin.class, "databricks", DatabricksInputPlugin.class) + .build(); + + @Before + public void setup() { + if (ConfigUtil.disableOnlineTest()) { + return; + } + ConnectionUtil.dropAllTemporaryTables(); + } + + @After + public void cleanup() { + if (ConfigUtil.disableOnlineTest()) { + return; + } + ConnectionUtil.dropAllTemporaryTables(); + } +} diff --git a/src/test/java/org/embulk/input/databricks/TestConnectionUtil.java b/src/test/java/org/embulk/input/databricks/TestConnectionUtil.java new file mode 100644 index 0000000..3929006 --- /dev/null +++ b/src/test/java/org/embulk/input/databricks/TestConnectionUtil.java @@ -0,0 +1,47 @@ +package org.embulk.input.databricks; + +import java.sql.*; +import org.embulk.input.databricks.util.ConfigUtil; +import org.embulk.input.databricks.util.ConnectionUtil; +import org.junit.Assert; +import org.junit.Test; + +public class TestConnectionUtil extends AbstractTestDatabricksInputPlugin { + @Test + public void TestConnect() { + try (Connection con = ConnectionUtil.connectByTestTask()) { + try (Statement stmt = con.createStatement()) { + try (ResultSet rs = stmt.executeQuery("SELECT 1")) { + rs.next(); + Assert.assertEquals("1", rs.getString(1)); + } + } + } catch (SQLException | ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + + @Test + public void TestGetMetaData() { + String quotedFullTableName = ConfigUtil.createRandomQuotedFullTableName(); + ConnectionUtil.run( + String.format("create table %s (_c0 LONG PRIMARY KEY, _c1 STRING)", quotedFullTableName)); + try (Connection con = ConnectionUtil.connectByTestTask()) { + assertMetaData(1, con, "select 1"); + assertMetaData(2, con, String.format("select * from %s", quotedFullTableName)); + assertMetaData(2, con, String.format("select * from %s where _c0 > 0", quotedFullTableName)); + + // expected value is 2, but 0. (databricks jdbc bug?) + // If that changes, enable use_raw_query_with_incremental support. + // (see DatabricksInputPlugin.setupTask). + assertMetaData(0, con, String.format("select * from %s where _c0 > ?", quotedFullTableName)); + } catch (SQLException | ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + + private static void assertMetaData(long expected, Connection conn, String query) + throws SQLException { + Assert.assertEquals(expected, conn.prepareStatement(query).getMetaData().getColumnCount()); + } +} diff --git a/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginByQuery.java b/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginByQuery.java new file mode 100644 index 0000000..f76105c --- /dev/null +++ b/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginByQuery.java @@ -0,0 +1,94 @@ +package org.embulk.input.databricks; + +import static org.embulk.input.databricks.util.TestingEmbulkUtil.assertNameEquals; +import static org.embulk.input.databricks.util.TestingEmbulkUtil.assertTypeEquals; +import static org.embulk.test.EmbulkTests.readFile; + +import java.io.IOException; +import java.nio.file.Path; +import java.sql.SQLException; +import org.embulk.config.ConfigSource; +import org.embulk.exec.PartialExecutionException; +import org.embulk.input.databricks.util.ConfigUtil; +import org.embulk.input.databricks.util.ConnectionUtil; +import org.embulk.test.TestingEmbulk; +import org.junit.Assert; +import org.junit.Test; + +public class TestDatabricksInputPluginByQuery extends AbstractTestDatabricksInputPlugin { + @Test + public void testQuery() throws IOException { + String quotedFullTableName = ConfigUtil.createRandomQuotedFullTableName(); + ConnectionUtil.run( + String.format( + "create table %s (_c0 LONG PRIMARY KEY, _c1 STRING, _c2 DOUBLE)", quotedFullTableName), + String.format( + "INSERT INTO %s VALUES (1, 'TEST0', 0.1), (2, 'TEST1', 0.2), (3, 'TEST1', 0.3)", + quotedFullTableName)); + Path out = embulk.createTempFile("csv"); + ConfigSource configSource = + ConfigUtil.createPluginConfigSourceByQuery( + String.format( + "select _c2, _c0 from %s where _c1 like '%%EST1' order by _c0 DESC", + quotedFullTableName)); + TestingEmbulk.RunResult runResult = embulk.runInput(configSource, out); + Assert.assertEquals("0.3,3\n0.2,2\n", readFile(out)); + assertNameEquals(runResult.getInputSchema(), "_c2", "_c0"); + assertTypeEquals(runResult.getInputSchema(), "double", "long"); + } + + @Test + public void testQueryFoundByCatalogName() throws IOException { + String tableName = ConfigUtil.createRandomTableName(); + String quotedFullTableName = ConfigUtil.createQuotedFullTableName(tableName); + ConnectionUtil.run( + String.format("create table %s (_c0 LONG PRIMARY KEY)", quotedFullTableName), + String.format("INSERT INTO %s VALUES (1)", quotedFullTableName)); + Path out = embulk.createTempFile("csv"); + ConfigUtil.TestTask t = ConfigUtil.createTestTask(); + ConfigSource configSource = + ConfigUtil.createPluginConfigSourceByQuery( + String.format("select _c0 from `%s`.`%s`", t.getSchemaName(), tableName)) + .set("catalog_name", t.getCatalogName()); + TestingEmbulk.RunResult runResult = embulk.runInput(configSource, out); + Assert.assertEquals("1\n", readFile(out)); + assertNameEquals(runResult.getInputSchema(), "_c0"); + assertTypeEquals(runResult.getInputSchema(), "long"); + } + + @Test + public void testQueryFoundByCatalogNameAndSchemaName() throws IOException { + String tableName = ConfigUtil.createRandomTableName(); + String quotedFullTableName = ConfigUtil.createQuotedFullTableName(tableName); + ConnectionUtil.run( + String.format("create table %s (_c0 LONG PRIMARY KEY)", quotedFullTableName), + String.format("INSERT INTO %s VALUES (1)", quotedFullTableName)); + Path out = embulk.createTempFile("csv"); + ConfigUtil.TestTask t = ConfigUtil.createTestTask(); + ConfigSource configSource = + ConfigUtil.createPluginConfigSourceByQuery(String.format("select _c0 from `%s`", tableName)) + .set("catalog_name", t.getCatalogName()) + .set("schema_name", t.getSchemaName()); + TestingEmbulk.RunResult runResult = embulk.runInput(configSource, out); + Assert.assertEquals("1\n", readFile(out)); + assertNameEquals(runResult.getInputSchema(), "_c0"); + assertTypeEquals(runResult.getInputSchema(), "long"); + } + + @Test + public void testQueryNotFoundByNoCatalogNameAndSchemaName() throws IOException { + String tableName = ConfigUtil.createRandomTableName(); + String quotedFullTableName = ConfigUtil.createQuotedFullTableName(tableName); + ConnectionUtil.run( + String.format("create table %s (_c0 LONG PRIMARY KEY)", quotedFullTableName), + String.format("INSERT INTO %s VALUES (1)", quotedFullTableName)); + Path out = embulk.createTempFile("csv"); + ConfigSource configSource = + ConfigUtil.createPluginConfigSourceByQuery( + String.format("select _c0 from `%s`", tableName)); + PartialExecutionException e = + Assert.assertThrows( + PartialExecutionException.class, () -> embulk.runInput(configSource, out)); + Assert.assertTrue(e.getCause().getCause() instanceof SQLException); + } +} diff --git a/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginByTable.java b/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginByTable.java new file mode 100644 index 0000000..66063d0 --- /dev/null +++ b/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginByTable.java @@ -0,0 +1,157 @@ +package org.embulk.input.databricks; + +import static org.embulk.input.databricks.util.TestingEmbulkUtil.assertNameEquals; +import static org.embulk.input.databricks.util.TestingEmbulkUtil.assertTypeEquals; +import static org.embulk.test.EmbulkTests.readFile; + +import java.io.IOException; +import java.nio.file.Path; +import org.embulk.config.ConfigSource; +import org.embulk.exec.PartialExecutionException; +import org.embulk.input.databricks.util.ConfigUtil; +import org.embulk.input.databricks.util.ConnectionUtil; +import org.embulk.test.TestingEmbulk; +import org.junit.Assert; +import org.junit.Test; + +public class TestDatabricksInputPluginByTable extends AbstractTestDatabricksInputPlugin { + @Test + public void testOnlyTable() throws IOException { + runTest( + null, + null, + null, + "1,TEST0,2.0\n", + new String[] {"_c0", "_c1", "_c2"}, + new String[] {"long", "string", "double"}, + "INSERT INTO %s VALUES (1, 'TEST0', 2.0)"); + } + + @Test + public void testSelect() throws IOException { + runTest( + "_c2, _c0", + null, + "_c0", + "2.0,1\n1.0,2\n3.0,3\n", + new String[] {"_c2", "_c0"}, + new String[] {"double", "long"}); + } + + @Test + public void testWhere() throws IOException { + runTest( + null, + "_c1 like '%EST0'", + "_c0", + "1,TEST0,2.0\n2,TEST0,1.0\n", + new String[] {"_c0", "_c1", "_c2"}, + new String[] {"long", "string", "double"}); + } + + @Test + public void testOrderBy() throws IOException { + runTest( + null, + null, + "_c1 ASC, _c0 DESC", + "2,TEST0,1.0\n1,TEST0,2.0\n3,TEST1,3.0\n", + new String[] {"_c0", "_c1", "_c2"}, + new String[] {"long", "string", "double"}); + } + + @Test + public void testAll() throws IOException { + runTest("_c0", "_c2 > 1.9", "_c2 DESC", "3\n1\n", new String[] {"_c0"}, new String[] {"long"}); + } + + @Test + public void testSameTableNameButOnlyDifferentCatalogName() throws IOException { + String tableName = ConfigUtil.createRandomTableName(); + + String quotedFullTableName0 = ConfigUtil.createQuotedFullTableName(tableName); + String quotedFullTableName1 = ConfigUtil.createAnotherCatalogQuotedFullTableName(tableName); + ConnectionUtil.run( + String.format("create table %s (a LONG, b DOUBLE)", quotedFullTableName0), + String.format("insert into %s values (100, 10.0)", quotedFullTableName0), + String.format("create table %s (x STRING)", quotedFullTableName1), + String.format("insert into %s values ('TEST')", quotedFullTableName1)); + + Path out0 = embulk.createTempFile("csv"); + TestingEmbulk.RunResult runResult0 = + embulk.runInput(ConfigUtil.createPluginConfigSourceByTable(tableName), out0); + Assert.assertEquals("100,10.0\n", readFile(out0)); + assertNameEquals(runResult0.getInputSchema(), "a", "b"); + assertTypeEquals(runResult0.getInputSchema(), "long", "double"); + + Path out1 = embulk.createTempFile("csv"); + TestingEmbulk.RunResult runResult1 = + embulk.runInput(ConfigUtil.createPluginConfigSourceByAnotherCatalogTable(tableName), out1); + Assert.assertEquals("TEST\n", readFile(out1)); + assertNameEquals(runResult1.getInputSchema(), "x"); + assertTypeEquals(runResult1.getInputSchema(), "string"); + } + + @Test + public void testRaiseErrorWhenCatalogNameDifferent() throws IOException { + String tableName = ConfigUtil.createRandomTableName(); + + String quotedFullTableName1 = ConfigUtil.createAnotherCatalogQuotedFullTableName(tableName); + ConnectionUtil.run( + String.format("create table %s (x STRING)", quotedFullTableName1), + String.format("insert into %s values ('TEST')", quotedFullTableName1)); + + Assert.assertThrows( + PartialExecutionException.class, + () -> + embulk.runInput( + ConfigUtil.createPluginConfigSourceByTable(tableName), + embulk.createTempFile("csv"))); + } + + private void runTest( + String select, + String where, + String orderBy, + String expectedCSV, + String[] expectedColumnNames, + String[] expectedTypeNames, + String insertValue) + throws IOException { + String tableName = ConfigUtil.createRandomTableName(); + String quotedFullTableName = ConfigUtil.createQuotedFullTableName(tableName); + ConnectionUtil.run( + String.format( + "create table %s (_c0 LONG PRIMARY KEY, _c1 STRING, _c2 DOUBLE)", quotedFullTableName), + String.format(insertValue, quotedFullTableName)); + + ConfigSource configSource = + ConfigUtil.createPluginConfigSourceByTable(tableName) + .set("select", select) + .set("where", where) + .set("order_by", orderBy); + Path out = embulk.createTempFile("csv"); + TestingEmbulk.RunResult runResult = embulk.runInput(configSource, out); + Assert.assertEquals(expectedCSV, readFile(out)); + assertNameEquals(runResult.getInputSchema(), expectedColumnNames); + assertTypeEquals(runResult.getInputSchema(), expectedTypeNames); + } + + private void runTest( + String select, + String where, + String orderBy, + String expectedCSV, + String[] expectedColumnNames, + String[] expectedTypeNames) + throws IOException { + runTest( + select, + where, + orderBy, + expectedCSV, + expectedColumnNames, + expectedTypeNames, + "INSERT INTO %s VALUES (1, 'TEST0', 2.0), (2, 'TEST0', 1.0), (3, 'TEST1', 3.0)"); + } +} diff --git a/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginWithAbnormalType.java b/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginWithAbnormalType.java new file mode 100644 index 0000000..dbfb9b1 --- /dev/null +++ b/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginWithAbnormalType.java @@ -0,0 +1,102 @@ +package org.embulk.input.databricks; + +import static org.embulk.test.EmbulkTests.readFile; + +import java.io.IOException; +import java.nio.file.Path; +import java.sql.SQLException; +import org.embulk.config.ConfigSource; +import org.embulk.exec.PartialExecutionException; +import org.embulk.input.databricks.util.ConfigUtil; +import org.embulk.input.databricks.util.ConnectionUtil; +import org.embulk.input.databricks.util.TestingEmbulkUtil; +import org.embulk.test.TestingEmbulk; +import org.junit.Assert; +import org.junit.Test; + +public class TestDatabricksInputPluginWithAbnormalType extends AbstractTestDatabricksInputPlugin { + // embulk-input-jdbc does not support binary type. + @Test + public void testBinary() { + ConfigSource configSource = ConfigUtil.createPluginConfigSourceByQuery("select X'1ABF'"); + PartialExecutionException e = + Assert.assertThrows( + PartialExecutionException.class, + () -> embulk.runInput(configSource, embulk.createTempFile("csv"))); + Assert.assertTrue(e.getCause() instanceof java.lang.UnsupportedOperationException); + } + + @Test + public void testBinaryWithTable() { + String tableName = ConfigUtil.createRandomTableName(); + String quotedFullTableName = ConfigUtil.createQuotedFullTableName(tableName); + ConnectionUtil.run( + String.format("create table %s (_c0 BINARY)", quotedFullTableName), + String.format("INSERT INTO %s VALUES (X'1ABF')", quotedFullTableName)); + ConfigSource configSource = + ConfigUtil.createPluginConfigSourceByQuery( + String.format("select _c0 from %s", quotedFullTableName)); + PartialExecutionException e = + Assert.assertThrows( + PartialExecutionException.class, + () -> embulk.runInput(configSource, embulk.createTempFile("csv"))); + Assert.assertTrue(e.getCause() instanceof java.lang.UnsupportedOperationException); + } + + // Delta Lake does not support the INTERVAL type. + // https://docs.databricks.com/en/sql/language-manual/data-types/interval-type.html + @Test + public void testInterval() throws IOException { + ConfigSource configSource = + ConfigUtil.createPluginConfigSourceByQuery( + "SELECT INTERVAL '100-00' YEAR TO MONTH as time"); + Path out = embulk.createTempFile("csv"); + TestingEmbulk.RunResult runResult = embulk.runInput(configSource, out); + Assert.assertEquals("100-0\n", readFile(out)); + TestingEmbulkUtil.assertNameEquals(runResult.getInputSchema(), "time"); + TestingEmbulkUtil.assertTypeEquals(runResult.getInputSchema(), "string"); + } + + @Test + public void testIntervalWithTable() { + String tableName = ConfigUtil.createRandomTableName(); + String quotedFullTableName = ConfigUtil.createQuotedFullTableName(tableName); + RuntimeException e = + Assert.assertThrows( + RuntimeException.class, + () -> + ConnectionUtil.run( + String.format("create table %s (_c0 INTERVAL)", quotedFullTableName))); + Assert.assertTrue(e.getCause() instanceof SQLException); + } + + // Databricks JDBC does not support the TIMESTAMP_NTZ type. + // https://docs.databricks.com/en/sql/language-manual/data-types/timestamp-ntz-type.html#notes + @Test + public void testTimestampNTZ() throws IOException { + ConfigSource configSource = + ConfigUtil.createPluginConfigSourceByQuery("SELECT TIMESTAMP_NTZ'2020-12-31' as time"); + Path out = embulk.createTempFile("csv"); + TestingEmbulk.RunResult runResult = embulk.runInput(configSource, out); + Assert.assertEquals("2020-12-31 00:00:00.000000 +0000\n", readFile(out)); + TestingEmbulkUtil.assertNameEquals(runResult.getInputSchema(), "time"); + TestingEmbulkUtil.assertTypeEquals(runResult.getInputSchema(), "timestamp"); + } + + @Test + public void testTimestampNTZWithTable() throws IOException { + String tableName = ConfigUtil.createRandomTableName(); + String quotedFullTableName = ConfigUtil.createQuotedFullTableName(tableName); + ConnectionUtil.run( + String.format("create table %s (_c0 TIMESTAMP_NTZ)", quotedFullTableName), + String.format("INSERT INTO %s VALUES ( TIMESTAMP_NTZ'2020-12-31' )", quotedFullTableName)); + Path out = embulk.createTempFile("csv"); + ConfigSource configSource = + ConfigUtil.createPluginConfigSourceByQuery( + String.format("select _c0 from %s", quotedFullTableName)); + TestingEmbulk.RunResult runResult = embulk.runInput(configSource, out); + Assert.assertEquals("2020-12-31 00:00:00.000000 +0000\n", readFile(out)); + TestingEmbulkUtil.assertNameEquals(runResult.getInputSchema(), "_c0"); + TestingEmbulkUtil.assertTypeEquals(runResult.getInputSchema(), "timestamp"); + } +} diff --git a/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginWithIncremental.java b/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginWithIncremental.java new file mode 100644 index 0000000..de4f597 --- /dev/null +++ b/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginWithIncremental.java @@ -0,0 +1,201 @@ +package org.embulk.input.databricks; + +import static org.embulk.input.databricks.util.TestingEmbulkUtil.assertNameEquals; +import static org.embulk.input.databricks.util.TestingEmbulkUtil.assertTypeEquals; +import static org.embulk.test.EmbulkTests.readFile; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.nio.file.Path; +import org.embulk.config.ConfigException; +import org.embulk.config.ConfigSource; +import org.embulk.exec.PartialExecutionException; +import org.embulk.input.databricks.util.ConfigUtil; +import org.embulk.input.databricks.util.ConnectionUtil; +import org.embulk.test.TestingEmbulk; +import org.junit.Assert; +import org.junit.Ignore; +import org.junit.Test; + +public class TestDatabricksInputPluginWithIncremental extends AbstractTestDatabricksInputPlugin { + @Test + public void testIncrementalColumns() throws IOException { + String tableName = ConfigUtil.createRandomTableName(); + String quotedFullTableName = ConfigUtil.createQuotedFullTableName(tableName); + ConnectionUtil.run( + String.format("create table %s (_c0 LONG PRIMARY KEY, _c1 STRING)", quotedFullTableName), + String.format( + "INSERT INTO %s VALUES (1,'TEST0'), (2, 'TEST0'), (2, 'TEST1'), (3, 'TEST2')", + quotedFullTableName)); + Path out = embulk.createTempFile("csv"); + ConfigSource configSource = + ConfigUtil.createPluginConfigSourceByTable(tableName) + .set("incremental", true) + .set("incremental_columns", new String[] {"_c0", "_c1"}) + .set("last_record", new String[] {"2", "TEST0"}); + TestingEmbulk.RunResult runResult = embulk.runInput(configSource, out); + Assert.assertEquals("2,TEST1\n3,TEST2\n", readFile(out)); + assertNameEquals(runResult.getInputSchema(), "_c0", "_c1"); + assertTypeEquals(runResult.getInputSchema(), "long", "string"); + } + + @Test + public void testIncrementalColumnsNoLastRecord() throws IOException { + String tableName = ConfigUtil.createRandomTableName(); + String quotedFullTableName = ConfigUtil.createQuotedFullTableName(tableName); + ConnectionUtil.run( + String.format("create table %s (_c0 LONG PRIMARY KEY, _c1 STRING)", quotedFullTableName), + String.format( + "INSERT INTO %s VALUES (1,'TEST0'), (2, 'TEST0'), (2, 'TEST1'), (3, 'TEST2')", + quotedFullTableName)); + Path out = embulk.createTempFile("csv"); + ConfigSource configSource = + ConfigUtil.createPluginConfigSourceByTable(tableName) + .set("incremental", true) + .set("incremental_columns", new String[] {"_c0", "_c1"}); + TestingEmbulk.RunResult runResult = embulk.runInput(configSource, out); + Assert.assertEquals("1,TEST0\n2,TEST0\n2,TEST1\n3,TEST2\n", readFile(out)); + assertNameEquals(runResult.getInputSchema(), "_c0", "_c1"); + assertTypeEquals(runResult.getInputSchema(), "long", "string"); + } + + // If use_raw_query_with_incremental option is supported, remove @Ignore. + @Test + @Ignore + public void testUseRawQueryWithIncremental() throws IOException { + String quotedFullTableName = ConfigUtil.createRandomQuotedFullTableName(); + ConnectionUtil.run( + String.format("create table %s (_c0 LONG PRIMARY KEY, _c1 STRING)", quotedFullTableName), + String.format( + "INSERT INTO %s VALUES (1,'T0'), (2, 'T1'), (3, 'T2'), (4, 'T3')", + quotedFullTableName)); + Path out = embulk.createTempFile("csv"); + + ConfigSource configSource = + ConfigUtil.createPluginConfigSourceByQuery( + String.format( + "select %s._c0 as x from %s where %s._c0 > :x AND %s._c1 != 'T2'", + quotedFullTableName, + quotedFullTableName, + quotedFullTableName, + quotedFullTableName)) + .set("incremental", true) + .set("incremental_columns", new String[] {"x"}) + .set("use_raw_query_with_incremental", true) + .set("last_record", new String[] {"1"}); + TestingEmbulk.RunResult runResult = embulk.runInput(configSource, out); + Assert.assertEquals("2\n4\n", readFile(out)); + assertNameEquals(runResult.getInputSchema(), "x"); + assertTypeEquals(runResult.getInputSchema(), "long"); + } + + @Test + public void testUseRawQueryWithIncrementalNotImplementation() throws IOException { + String quotedFullTableName = ConfigUtil.createRandomQuotedFullTableName(); + ConnectionUtil.run( + String.format("create table %s (_c0 LONG PRIMARY KEY, _c1 STRING)", quotedFullTableName)); + Path out = embulk.createTempFile("csv"); + + ConfigSource configSource = + ConfigUtil.createPluginConfigSourceByQuery( + String.format( + "select %s._c0 as x from %s where %s._c0 > :x AND %s._c1 != 'T2'", + quotedFullTableName, + quotedFullTableName, + quotedFullTableName, + quotedFullTableName)) + .set("incremental", true) + .set("incremental_columns", new String[] {"x"}) + .set("use_raw_query_with_incremental", true) + .set("last_record", new String[] {"1"}); + PartialExecutionException e = + Assert.assertThrows( + PartialExecutionException.class, + () -> embulk.runInput(configSource, embulk.createTempFile("csv"))); + assertTrue(e.getCause() instanceof ConfigException); + } + + @Test + public void testUseRawQueryWithIncrementalNoPlaceholderInQueryString() { + String quotedFullTableName = ConfigUtil.createRandomQuotedFullTableName(); + ConnectionUtil.run(String.format("create table %s (_c0 LONG)", quotedFullTableName)); + ConfigSource configSource = + ConfigUtil.createPluginConfigSourceByQuery( + String.format("select * from %s", quotedFullTableName)) + .set("incremental", true) + .set("incremental_columns", new String[] {"_c0"}) + .set("use_raw_query_with_incremental", true); + PartialExecutionException e = + Assert.assertThrows( + PartialExecutionException.class, + () -> embulk.runInput(configSource, embulk.createTempFile("csv"))); + assertTrue(e.getCause() instanceof ConfigException); + } + + @Test + public void testUseRawQueryWithIncrementalFalseWithQuery() { + String quotedFullTableName = ConfigUtil.createRandomQuotedFullTableName(); + ConnectionUtil.run(String.format("create table %s (_c0 LONG)", quotedFullTableName)); + ConfigSource configSource = + ConfigUtil.createPluginConfigSourceByQuery( + String.format("select * from %s", quotedFullTableName)) + .set("incremental", true); + PartialExecutionException e = + Assert.assertThrows( + PartialExecutionException.class, + () -> embulk.runInput(configSource, embulk.createTempFile("csv"))); + assertTrue(e.getCause() instanceof ConfigException); + } + + @Test + public void testNoIncrementalColumns() throws IOException { + String tableName = ConfigUtil.createRandomTableName(); + String quotedFullTableName = ConfigUtil.createQuotedFullTableName(tableName); + ConnectionUtil.run( + String.format("create table %s (_c0 LONG PRIMARY KEY, _c1 STRING)", quotedFullTableName), + String.format( + "INSERT INTO %s VALUES (1,'TEST0'), (2, 'TEST1'), (3, 'TEST2')", quotedFullTableName)); + Path out = embulk.createTempFile("csv"); + ConfigSource configSource = + ConfigUtil.createPluginConfigSourceByTable(tableName) + .set("incremental", true) + .set("last_record", new String[] {"2"}); + TestingEmbulk.RunResult runResult = embulk.runInput(configSource, out); + Assert.assertEquals("3,TEST2\n", readFile(out)); + assertNameEquals(runResult.getInputSchema(), "_c0", "_c1"); + assertTypeEquals(runResult.getInputSchema(), "long", "string"); + } + + @Test + public void testNoIncrementalColumnsByDifferentSchemaButSameTableName() throws IOException { + String tableName = ConfigUtil.createRandomTableName(); + String quotedFullTableName0 = ConfigUtil.createQuotedFullTableName(tableName); + String quotedFullTableName1 = ConfigUtil.createAnotherCatalogQuotedFullTableName(tableName); + ConnectionUtil.run( + String.format("create table %s (_c0 LONG PRIMARY KEY)", quotedFullTableName0), + String.format("INSERT INTO %s VALUES (1), (2)", quotedFullTableName0)); + ConnectionUtil.run( + String.format("create table %s (_c1 STRING PRIMARY KEY)", quotedFullTableName1), + String.format("INSERT INTO %s VALUES ('T0'), ('T1')", quotedFullTableName1)); + + Path out0 = embulk.createTempFile("csv"); + ConfigSource configSource0 = + ConfigUtil.createPluginConfigSourceByTable(tableName) + .set("incremental", true) + .set("last_record", new String[] {"1"}); + TestingEmbulk.RunResult runResult0 = embulk.runInput(configSource0, out0); + Assert.assertEquals("2\n", readFile(out0)); + assertNameEquals(runResult0.getInputSchema(), "_c0"); + assertTypeEquals(runResult0.getInputSchema(), "long"); + + Path out1 = embulk.createTempFile("csv"); + ConfigSource configSource1 = + ConfigUtil.createPluginConfigSourceByAnotherCatalogTable(tableName) + .set("incremental", true) + .set("last_record", new String[] {"T0"}); + TestingEmbulk.RunResult runResult1 = embulk.runInput(configSource1, out1); + Assert.assertEquals("T1\n", readFile(out1)); + assertNameEquals(runResult1.getInputSchema(), "_c1"); + assertTypeEquals(runResult1.getInputSchema(), "string"); + } +} diff --git a/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginWithLikeBeforeSetup.java b/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginWithLikeBeforeSetup.java new file mode 100644 index 0000000..79ea683 --- /dev/null +++ b/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginWithLikeBeforeSetup.java @@ -0,0 +1,77 @@ +package org.embulk.input.databricks; + +import static org.embulk.input.databricks.util.TestingEmbulkUtil.assertNameEquals; +import static org.embulk.input.databricks.util.TestingEmbulkUtil.assertTypeEquals; +import static org.embulk.test.EmbulkTests.readFile; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.embulk.config.ConfigSource; +import org.embulk.input.databricks.util.ConfigUtil; +import org.embulk.input.databricks.util.ConnectionUtil; +import org.embulk.test.TestingEmbulk; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +public class TestDatabricksInputPluginWithLikeBeforeSetup + extends AbstractTestDatabricksInputPlugin { + String quotedFullTableName; + Path out; + ConfigSource configSource; + + @Before + public void setup() { + super.setup(); + quotedFullTableName = ConfigUtil.createRandomQuotedFullTableName(); + out = embulk.createTempFile("csv"); + configSource = + ConfigUtil.createPluginConfigSourceByQuery( + String.format("select * from %s", quotedFullTableName)); + ConnectionUtil.run( + String.format("create table %s (_c0 LONG PRIMARY KEY)", quotedFullTableName), + String.format("INSERT INTO %s VALUES (1)", quotedFullTableName)); + } + + @Test + public void testBeforeSetup() throws IOException { + configSource = + configSource.set( + "before_setup", String.format("update %s set _c0 = 3", quotedFullTableName)); + + TestingEmbulk.RunResult embulkRunResult = embulk.runInput(configSource, out); + Assert.assertEquals("3\n", readFile(out)); + assertNameEquals(embulkRunResult.getInputSchema(), "_c0"); + assertTypeEquals(embulkRunResult.getInputSchema(), "long"); + } + + @Test + public void testBeforeSelect() throws IOException { + configSource = + configSource.set( + "before_select", String.format("update %s set _c0 = 3", quotedFullTableName)); + + TestingEmbulk.RunResult embulkRunResult = embulk.runInput(configSource, out); + Assert.assertEquals("3\n", readFile(out)); + assertNameEquals(embulkRunResult.getInputSchema(), "_c0"); + assertTypeEquals(embulkRunResult.getInputSchema(), "long"); + } + + @Test + public void testAfterSelect() throws IOException { + configSource = + configSource.set( + "after_select", String.format("update %s set _c0 = 3", quotedFullTableName)); + + TestingEmbulk.RunResult embulkRunResult = embulk.runInput(configSource, out); + Assert.assertEquals("1\n", readFile(out)); + assertNameEquals(embulkRunResult.getInputSchema(), "_c0"); + assertTypeEquals(embulkRunResult.getInputSchema(), "long"); + + List> afterResult = + ConnectionUtil.runQuery(String.format("select * from %s", quotedFullTableName)); + Assert.assertEquals(afterResult.get(0).get("_c0"), 3L); + } +} diff --git a/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginWithType.java b/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginWithType.java new file mode 100644 index 0000000..d4d0e79 --- /dev/null +++ b/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginWithType.java @@ -0,0 +1,149 @@ +package org.embulk.input.databricks; + +import static org.embulk.input.databricks.util.TestingEmbulkUtil.assertTypeEquals; +import static org.embulk.test.EmbulkTests.readSortedFile; + +import com.databricks.client.jdbc42.internal.google.common.collect.Streams; +import java.io.IOException; +import java.nio.file.Path; +import java.time.ZoneId; +import java.util.Arrays; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.embulk.config.ConfigSource; +import org.embulk.input.databricks.util.ColumnOptionData; +import org.embulk.input.databricks.util.ConfigUtil; +import org.embulk.input.databricks.util.ConnectionUtil; +import org.embulk.input.databricks.util.DefaultColumnOptionData; +import org.embulk.test.TestingEmbulk; +import org.junit.Assert; +import org.junit.Test; + +public class TestDatabricksInputPluginWithType extends AbstractTestDatabricksInputPlugin { + @Test + public void testAllTypes() throws IOException { + run( + new TestSet("BIGINT", "200", "long", "200"), + new TestSet("BOOLEAN", "true", "boolean", "true"), + new TestSet("DATE", "'2020-03-04'", "timestamp", "2020-03-04 00:00:00.000000 +0000"), + new TestSet("DECIMAL(4,2)", "12.25", "double", "12.25"), + new TestSet("DOUBLE", "12.5", "double", "12.5"), + new TestSet("FLOAT", "13.5", "double", "13.5"), + new TestSet("INT", "300", "long", "300"), + new TestSet("SMALLINT", "12", "long", "12"), + new TestSet("STRING", "'test'", "string", "test"), + new TestSet( + "TIMESTAMP", "'2020-03-04 12:00:00Z'", "timestamp", "2020-03-04 12:00:00.000000 +0000"), + new TestSet("TINYINT", "8", "long", "8"), + new TestSet("ARRAY", "ARRAY(1, 2, 3)", "string", "\"[1,2,3]\""), + new TestSet( + "Map", "map('a',1,'b',2)", "string", "\"{\"\"a\"\":1,\"\"b\"\":2}\""), + new TestSet( + "STRUCT", + "struct('test',2)", + "string", + "\"{\"\"c1\"\":\"\"test\"\",\"\"c2\"\":2}\"")); + } + + @Test + public void testDefaultColumnOptions() throws IOException { + run( + c -> { + DefaultColumnOptionData.create("BIGINT", "string", null).apply(c); + DefaultColumnOptionData.create("DATE", "string", null).apply(c); + DefaultColumnOptionData.create("VARCHAR", "json", null).apply(c); + DefaultColumnOptionData.create("REAL", "string", null).apply(c); + return c; + }, + new TestSet("BIGINT", "200", "string", "200"), + new TestSet("FLOAT", "12.5", "string", "12.5"), + new TestSet("DATE", "'2020-03-04'", "string", "2020-03-04"), + new TestSet("ARRAY", "ARRAY(1, 2, 3)", "json", "\"[1,2,3]\""), + new TestSet( + "Map", "map('a',1,'b',2)", "json", "\"{\"\"a\"\":1,\"\"b\"\":2}\""), + new TestSet("STRUCT", "struct('test')", "json", "\"{\"\"c1\"\":\"\"test\"\"}\""), + new TestSet("STRING", "'{\"a\":1,\"b\":2}'", "json", "\"{\"\"a\"\":1,\"\"b\"\":2}\"")); + } + + @Test + public void testColumnOptions() throws IOException { + run( + c -> { + ColumnOptionData.create("_c0", "json", null).apply(c); + ColumnOptionData.create("_c1", "json", null).apply(c); + ColumnOptionData.create("_c2", "json", null).apply(c); + ColumnOptionData.create("_c3", "json", null).apply(c); + ColumnOptionData.create("_c4", null, "%Y/%m/%d %H:%M:%S", null).apply(c); + ColumnOptionData.create("_c5", null, "%Y-%m-%d %H:%M:%S", ZoneId.of("Asia/Tokyo")) + .apply(c); + ColumnOptionData.create("_c6", null, "%Y/%m/%d %H:%M:%S", null).apply(c); + ColumnOptionData.create("_c7", null, "%Y-%m-%d %H:%M:%S", ZoneId.of("Asia/Tokyo")) + .apply(c); + ColumnOptionData.create("_c8", null, "long").apply(c); + ColumnOptionData.create("_c9", null, "date").apply(c); + ColumnOptionData.create("_c10", null, "string").apply(c); + return c; + }, + new TestSet("STRING", "'{\"a\":1,\"b\":2}'", "json", "\"{\"\"a\"\":1,\"\"b\"\":2}\""), + new TestSet("ARRAY", "ARRAY(1, 2, 3)", "json", "\"[1,2,3]\""), + new TestSet( + "Map", "map('a',1,'b',2)", "json", "\"{\"\"a\"\":1,\"\"b\"\":2}\""), + new TestSet("STRUCT", "struct('test')", "json", "\"{\"\"c1\"\":\"\"test\"\"}\""), + new TestSet("TIMESTAMP", "'2020-03-04 12:00:00Z'", "string", "2020/03/04 12:00:00"), + new TestSet("TIMESTAMP", "'2020-03-04 12:00:00Z'", "string", "2020-03-04 21:00:00"), + new TestSet("DATE", "'2020-03-04'", "string", "2020/03/04 00:00:00"), + new TestSet("DATE", "'2020-03-04'", "string", "2020-03-04 09:00:00"), + new TestSet("STRING", "'2020'", "long", "2020"), + new TestSet("STRING", "'2020-03-04'", "timestamp", "2020-03-04 00:00:00.000000 +0000"), + new TestSet( + "TIMESTAMP", "'2020-03-04 12:00:00Z'", "string", "2020-03-04 12:00:00.000000000")); + } + + public class TestSet { + final String tableType; + final String tableValue; + final String expectedValue; + final String expectedType; + + public TestSet(String tableType, String tableValue, String expectedType, String expectedValue) { + this.tableType = tableType; + this.tableValue = tableValue; + this.expectedValue = expectedValue; + this.expectedType = expectedType; + } + } + + private void run(TestSet... testSets) throws IOException { + run(null, testSets); + } + + private void run(Function convert, TestSet... testSets) + throws IOException { + String quotedFullTableName = ConfigUtil.createRandomQuotedFullTableName(); + ConnectionUtil.run( + String.format( + "create table %s (%s)", + quotedFullTableName, + Streams.mapWithIndex( + Arrays.stream(testSets), (e, i) -> String.format("_c%d %s", i, e.tableType)) + .collect(Collectors.joining(" ,"))), + String.format( + "INSERT INTO %s VALUES (%s)", + quotedFullTableName, + Arrays.stream(testSets).map(x -> x.tableValue).collect(Collectors.joining(" ,")))); + ConfigSource configSource = + ConfigUtil.createPluginConfigSourceByQuery( + String.format("select * from %s", quotedFullTableName)); + if (convert != null) { + configSource = convert.apply(configSource); + } + Path out = embulk.createTempFile("csv"); + TestingEmbulk.RunResult runResult = embulk.runInput(configSource, out); + Assert.assertEquals( + Arrays.stream(testSets).map(x -> x.expectedValue).collect(Collectors.joining(",")) + "\n", + readSortedFile(out)); + assertTypeEquals( + runResult.getInputSchema(), + Arrays.stream(testSets).map(x -> x.expectedType).toArray(String[]::new)); + } +} diff --git a/src/test/java/org/embulk/input/databricks/util/ColumnOptionData.java b/src/test/java/org/embulk/input/databricks/util/ColumnOptionData.java new file mode 100644 index 0000000..5ab2f34 --- /dev/null +++ b/src/test/java/org/embulk/input/databricks/util/ColumnOptionData.java @@ -0,0 +1,60 @@ +package org.embulk.input.databricks.util; + +import java.time.ZoneId; +import org.embulk.config.ConfigSource; +import org.embulk.util.config.ConfigMapperFactory; +import org.embulk.util.config.modules.ZoneIdModule; + +public class ColumnOptionData { + private static final ConfigMapperFactory CONFIG_MAPPER_FACTORY = + ConfigMapperFactory.builder() + .addDefaultModules() + .addModule(ZoneIdModule.withLegacyNames()) + .build(); + + final String columnName; + final String type; + final String valueType; + final String timestampFormat; + final ZoneId timeZone; + + private ColumnOptionData( + String columnName, String type, String valueType, String timestampFormat, ZoneId timeZone) { + this.columnName = columnName; + this.type = type; + this.valueType = valueType; + this.timestampFormat = timestampFormat; + this.timeZone = timeZone; + } + + public static ColumnOptionData create(String columnName, String type, String valueType) { + return new ColumnOptionData(columnName, type, valueType, null, null); + } + + public static ColumnOptionData create( + String columnName, String valueType, String timestampFormat, ZoneId timeZone) { + return new ColumnOptionData(columnName, "string", valueType, timestampFormat, timeZone); + } + + public ConfigSource apply(ConfigSource configSource) { + ConfigSource columnOption = CONFIG_MAPPER_FACTORY.newConfigSource(); + if (type != null) { + columnOption.set("type", type); + } + if (valueType != null) { + columnOption.set("value_type", valueType); + } + if (timestampFormat != null) { + columnOption.set("timestamp_format", timestampFormat); + } + if (timeZone != null) { + columnOption.set("timezone", timeZone); + } + ConfigSource columnOptions = + configSource.get( + ConfigSource.class, "column_options", CONFIG_MAPPER_FACTORY.newConfigSource()); + columnOptions.set(columnName, columnOption); + configSource.set("column_options", columnOptions); + return configSource; + } +} diff --git a/src/test/java/org/embulk/input/databricks/util/ConfigUtil.java b/src/test/java/org/embulk/input/databricks/util/ConfigUtil.java new file mode 100644 index 0000000..0c0a23c --- /dev/null +++ b/src/test/java/org/embulk/input/databricks/util/ConfigUtil.java @@ -0,0 +1,120 @@ +package org.embulk.input.databricks.util; + +import static com.google.common.base.Strings.isNullOrEmpty; + +import java.util.UUID; +import org.embulk.config.ConfigSource; +import org.embulk.input.DatabricksInputPlugin; +import org.embulk.test.EmbulkTests; +import org.embulk.util.config.Config; +import org.embulk.util.config.ConfigMapper; +import org.embulk.util.config.ConfigMapperFactory; +import org.embulk.util.config.Task; +import org.embulk.util.config.modules.ZoneIdModule; + +public class ConfigUtil { + private static final ConfigMapperFactory CONFIG_MAPPER_FACTORY = + ConfigMapperFactory.builder() + .addDefaultModules() + .addModule(ZoneIdModule.withLegacyNames()) + .build(); + private static final ConfigMapper CONFIG_MAPPER = CONFIG_MAPPER_FACTORY.createConfigMapper(); + + private static final String configEnvName = "EMBULK_INPUT_DATABRICKS_TEST_CONFIG"; + + public static Boolean disableOnlineTest() { + return isNullOrEmpty(System.getenv(configEnvName)); + } + + public static ConfigSource baseConfigSource() { + return EmbulkTests.config(configEnvName); + } + + public interface TestTask extends Task { + @Config("server_hostname") + public String getServerHostname(); + + @Config("http_path") + public String getHTTPPath(); + + @Config("personal_access_token") + public String getPersonalAccessToken(); + + @Config("catalog_name") + public String getCatalogName(); + + @Config("schema_name") + public String getSchemaName(); + + @Config("table_prefix") + public String getTablePrefix(); + + @Config("another_catalog_name") + public String getAnotherCatalogName(); + } + + public static TestTask createTestTask() { + return CONFIG_MAPPER.map(baseConfigSource(), TestTask.class); + } + + public static String createAnotherCatalogQuotedFullTableName(String table) { + final TestTask t = createTestTask(); + return String.format("`%s`.`%s`.`%s`", t.getAnotherCatalogName(), t.getSchemaName(), table); + } + + public static String createQuotedFullTableName(String table) { + final TestTask t = createTestTask(); + return String.format("`%s`.`%s`.`%s`", t.getCatalogName(), t.getSchemaName(), table); + } + + public static String createRandomQuotedFullTableName() { + return createQuotedFullTableName(createRandomTableName()); + } + + public static String createTableName(String tableSuffix) { + final TestTask t = createTestTask(); + return t.getTablePrefix() + tableSuffix; + } + + public static String createRandomTableName() { + return createTableName(UUID.randomUUID().toString()); + } + + public static ConfigSource createBasePluginConfigSource() { + final TestTask t = createTestTask(); + + return CONFIG_MAPPER_FACTORY + .newConfigSource() + .set("type", "databricks") + .set("server_hostname", t.getServerHostname()) + .set("http_path", t.getHTTPPath()) + .set("personal_access_token", t.getPersonalAccessToken()); + } + + public static ConfigSource createPluginConfigSourceByQuery(String query) { + return createBasePluginConfigSource().set("query", query); + } + + public static ConfigSource createPluginConfigSourceByTable(String table) { + final TestTask t = createTestTask(); + + return createBasePluginConfigSource() + .set("catalog_name", t.getCatalogName()) + .set("schema_name", t.getSchemaName()) + .set("table", table); + } + + public static ConfigSource createPluginConfigSourceByAnotherCatalogTable(String table) { + final TestTask t = createTestTask(); + + return createBasePluginConfigSource() + .set("catalog_name", t.getAnotherCatalogName()) + .set("schema_name", t.getSchemaName()) + .set("table", table); + } + + public static DatabricksInputPlugin.DatabricksPluginTask createPluginTask( + ConfigSource configSource) { + return CONFIG_MAPPER.map(configSource, DatabricksInputPlugin.DatabricksPluginTask.class); + } +} diff --git a/src/test/java/org/embulk/input/databricks/util/ConnectionUtil.java b/src/test/java/org/embulk/input/databricks/util/ConnectionUtil.java new file mode 100644 index 0000000..25224a6 --- /dev/null +++ b/src/test/java/org/embulk/input/databricks/util/ConnectionUtil.java @@ -0,0 +1,82 @@ +package org.embulk.input.databricks.util; + +import java.sql.*; +import java.util.*; + +public class ConnectionUtil { + public static Connection connect( + String serverHostname, String httpPath, String personalAccessToken) + throws SQLException, ClassNotFoundException { + Class.forName("com.databricks.client.jdbc.Driver"); + String url = String.format("jdbc:databricks://%s:443", serverHostname); + Properties props = new java.util.Properties(); + props.put("httpPath", httpPath); + props.put("AuthMech", "3"); + props.put("UID", "token"); + props.put("PWD", personalAccessToken); + props.put("SSL", "1"); + return DriverManager.getConnection(url, props); + } + + public static Connection connectByTestTask() throws SQLException, ClassNotFoundException { + ConfigUtil.TestTask testTask = ConfigUtil.createTestTask(); + return connect( + testTask.getServerHostname(), testTask.getHTTPPath(), testTask.getPersonalAccessToken()); + } + + public static void dropAllTemporaryTables() { + ConfigUtil.TestTask t = ConfigUtil.createTestTask(); + for (String catalogName : new String[] {t.getCatalogName(), t.getAnotherCatalogName()}) { + String tableNamesSQL = + String.format( + "select table_name from system.information_schema.tables where table_catalog = '%s' AND table_schema = '%s' AND table_name LIKE '%s%%'", + catalogName, t.getSchemaName(), t.getTablePrefix()); + runQuery(tableNamesSQL) + .forEach( + x -> { + String tableName = (String) x.get("table_name"); + String dropSql = + String.format( + "drop table if exists `%s`.`%s`.`%s`", + catalogName, t.getSchemaName(), tableName); + run(dropSql); + }); + } + } + + public static List> runQuery(String query) { + try (Connection conn = connectByTestTask(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(query)) { + return toMap(rs); + } catch (SQLException | ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + + public static List> toMap(ResultSet rs) throws SQLException { + List> result = new ArrayList<>(); + while (rs.next()) { + Map resMap = new HashMap<>(); + for (int i = 1; i <= rs.getMetaData().getColumnCount(); i++) { + resMap.put(rs.getMetaData().getColumnName(i), rs.getObject(i)); + } + result.add(resMap); + } + return result; + } + + public static List run(String... queries) { + try (Connection conn = connectByTestTask()) { + List results = new ArrayList<>(); + for (String query : queries) { + try (Statement stmt = conn.createStatement()) { + results.add(stmt.execute(query)); + } + } + return results; + } catch (SQLException | ClassNotFoundException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/org/embulk/input/databricks/util/DefaultColumnOptionData.java b/src/test/java/org/embulk/input/databricks/util/DefaultColumnOptionData.java new file mode 100644 index 0000000..e89608c --- /dev/null +++ b/src/test/java/org/embulk/input/databricks/util/DefaultColumnOptionData.java @@ -0,0 +1,60 @@ +package org.embulk.input.databricks.util; + +import java.time.ZoneId; +import org.embulk.config.ConfigSource; +import org.embulk.util.config.ConfigMapperFactory; +import org.embulk.util.config.modules.ZoneIdModule; + +public class DefaultColumnOptionData { + private static final ConfigMapperFactory CONFIG_MAPPER_FACTORY = + ConfigMapperFactory.builder() + .addDefaultModules() + .addModule(ZoneIdModule.withLegacyNames()) + .build(); + + final String jdbcType; + final String type; + final String valueType; + final String timestampFormat; + final ZoneId timeZone; + + private DefaultColumnOptionData( + String jdbcType, String type, String valueType, String timestampFormat, ZoneId timeZone) { + this.jdbcType = jdbcType; + this.type = type; + this.valueType = valueType; + this.timestampFormat = timestampFormat; + this.timeZone = timeZone; + } + + public static DefaultColumnOptionData create(String jdbcType, String type, String valueType) { + return new DefaultColumnOptionData(jdbcType, type, valueType, null, null); + } + + public static DefaultColumnOptionData create( + String jdbcType, String valueType, String timestampFormat, ZoneId timeZone) { + return new DefaultColumnOptionData(jdbcType, "string", valueType, timestampFormat, timeZone); + } + + public ConfigSource apply(ConfigSource configSource) { + ConfigSource columnOption = CONFIG_MAPPER_FACTORY.newConfigSource(); + if (type != null) { + columnOption.set("type", type); + } + if (valueType != null) { + columnOption.set("value_type", valueType); + } + if (timestampFormat != null) { + columnOption.set("timestamp_format", timestampFormat); + } + if (timeZone != null) { + columnOption.set("timezone", timeZone); + } + ConfigSource columnOptions = + configSource.get( + ConfigSource.class, "default_column_options", CONFIG_MAPPER_FACTORY.newConfigSource()); + columnOptions.set(jdbcType, columnOption); + configSource.set("default_column_options", columnOptions); + return configSource; + } +} diff --git a/src/test/java/org/embulk/input/databricks/util/TestingEmbulkUtil.java b/src/test/java/org/embulk/input/databricks/util/TestingEmbulkUtil.java new file mode 100644 index 0000000..dc851fb --- /dev/null +++ b/src/test/java/org/embulk/input/databricks/util/TestingEmbulkUtil.java @@ -0,0 +1,20 @@ +package org.embulk.input.databricks.util; + +import org.embulk.spi.Schema; +import org.junit.Assert; + +public class TestingEmbulkUtil { + public static void assertTypeEquals(Schema schema, String... types) { + Assert.assertEquals(types.length, schema.getColumnCount()); + for (int i = 0; i < types.length; i++) { + Assert.assertEquals(schema.getColumnType(i).getName(), types[i]); + } + } + + public static void assertNameEquals(Schema schema, String... names) { + Assert.assertEquals(names.length, schema.getColumnCount()); + for (int i = 0; i < names.length; i++) { + Assert.assertEquals(schema.getColumnName(i), names[i]); + } + } +} From 8283af70b39c44e9ce5cae733cee7ae53fa04003 Mon Sep 17 00:00:00 2001 From: chikamura Date: Tue, 11 Jun 2024 15:08:42 +0900 Subject: [PATCH 2/7] add new line to .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ff15ab2..f3b4716 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,4 @@ build/ config.yml default_jdbc_driver /bin/ -example/test.yml \ No newline at end of file +example/test.yml From c016a40eb630f18a2f89b67a7a68900c68c6460b Mon Sep 17 00:00:00 2001 From: chikamura Date: Tue, 11 Jun 2024 15:09:05 +0900 Subject: [PATCH 3/7] fix publish gem-path --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index eb2fd2e..50a247e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -53,5 +53,5 @@ jobs: uses: trocco-io/push-gem-to-gpr-action@v1 with: language: java - gem-path: "./pkg/*.gem" + gem-path: "./build/gems/*.gem" github-token: "${{ secrets.GITHUB_TOKEN }}" From 316e8e79fd078d73f5c5bef498c4c7899b1e3547 Mon Sep 17 00:00:00 2001 From: chikamura Date: Tue, 11 Jun 2024 15:27:04 +0900 Subject: [PATCH 4/7] =?UTF-8?q?embulk-output-databricks=20=E3=81=A8?= =?UTF-8?q?=E3=83=90=E3=83=BC=E3=82=B8=E3=83=A7=E3=83=8B=E3=83=B3=E3=82=B0?= =?UTF-8?q?=E3=82=92=E6=8F=83=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 7a2a28c..99c2c44 100644 --- a/build.gradle +++ b/build.gradle @@ -20,8 +20,8 @@ targetCompatibility = 1.8 version = { def vd = versionDetails() - if (vd.commitDistance == 0 && vd.lastTag ==~ /^[0-9]+\.[0-9]+\.[0-9]+(\.[a-zA-Z0-9]+)?/) { - vd.lastTag + if (vd.commitDistance == 0 && vd.lastTag ==~ /^v[0-9]+\.[0-9]+\.[0-9]+(\.[a-zA-Z0-9]+)?/) { + vd.lastTag.substring(1) } else { "0.0.0.${vd.gitHash}" } From e68effdd3e6d182795a16da9cb526a0b9dd53cfc Mon Sep 17 00:00:00 2001 From: chikamura Date: Tue, 25 Jun 2024 11:14:45 +0900 Subject: [PATCH 5/7] to suppress ERROR StatusLogger Unrecognized format log, com.databricks:databricks-jdbc 2.6.38 -> 2.6.34 --- build.gradle | 2 +- gradle/dependency-locks/compileClasspath.lockfile | 2 +- gradle/dependency-locks/runtimeClasspath.lockfile | 2 +- .../input/databricks/TestDatabricksInputPluginWithType.java | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/build.gradle b/build.gradle index 99c2c44..a65dee3 100644 --- a/build.gradle +++ b/build.gradle @@ -33,7 +33,7 @@ dependencies { compileOnly("org.embulk:embulk-api:${embulkVersion}") compileOnly("org.embulk:embulk-spi:${embulkVersion}") compile("org.embulk:embulk-input-jdbc:0.13.2") - compile('com.databricks:databricks-jdbc:2.6.38') + compile('com.databricks:databricks-jdbc:2.6.34') testImplementation "junit:junit:4.+" testImplementation "org.embulk:embulk-junit4:${embulkVersion}" diff --git a/gradle/dependency-locks/compileClasspath.lockfile b/gradle/dependency-locks/compileClasspath.lockfile index 5a6f08f..bbd7620 100644 --- a/gradle/dependency-locks/compileClasspath.lockfile +++ b/gradle/dependency-locks/compileClasspath.lockfile @@ -1,7 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.databricks:databricks-jdbc:2.6.38 +com.databricks:databricks-jdbc:2.6.34 com.fasterxml.jackson.core:jackson-annotations:2.6.7 com.fasterxml.jackson.core:jackson-core:2.6.7 com.fasterxml.jackson.core:jackson-databind:2.6.7 diff --git a/gradle/dependency-locks/runtimeClasspath.lockfile b/gradle/dependency-locks/runtimeClasspath.lockfile index 394c7de..ac9df4c 100644 --- a/gradle/dependency-locks/runtimeClasspath.lockfile +++ b/gradle/dependency-locks/runtimeClasspath.lockfile @@ -1,7 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.databricks:databricks-jdbc:2.6.38 +com.databricks:databricks-jdbc:2.6.34 com.fasterxml.jackson.core:jackson-annotations:2.6.7 com.fasterxml.jackson.core:jackson-core:2.6.7 com.fasterxml.jackson.core:jackson-databind:2.6.7 diff --git a/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginWithType.java b/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginWithType.java index d4d0e79..71d2a97 100644 --- a/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginWithType.java +++ b/src/test/java/org/embulk/input/databricks/TestDatabricksInputPluginWithType.java @@ -3,13 +3,13 @@ import static org.embulk.input.databricks.util.TestingEmbulkUtil.assertTypeEquals; import static org.embulk.test.EmbulkTests.readSortedFile; -import com.databricks.client.jdbc42.internal.google.common.collect.Streams; import java.io.IOException; import java.nio.file.Path; import java.time.ZoneId; import java.util.Arrays; import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.IntStream; import org.embulk.config.ConfigSource; import org.embulk.input.databricks.util.ColumnOptionData; import org.embulk.input.databricks.util.ConfigUtil; @@ -124,8 +124,8 @@ private void run(Function convert, TestSet... testSe String.format( "create table %s (%s)", quotedFullTableName, - Streams.mapWithIndex( - Arrays.stream(testSets), (e, i) -> String.format("_c%d %s", i, e.tableType)) + IntStream.range(0, testSets.length) + .mapToObj(i -> String.format("_c%d %s", i, testSets[i].tableType)) .collect(Collectors.joining(" ,"))), String.format( "INSERT INTO %s VALUES (%s)", From aea2e304d2111237812e3f689f871ee75faa9888 Mon Sep 17 00:00:00 2001 From: chikamura Date: Tue, 25 Jun 2024 11:23:17 +0900 Subject: [PATCH 6/7] remove unused publishing --- build.gradle | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/build.gradle b/build.gradle index a65dee3..9103edb 100644 --- a/build.gradle +++ b/build.gradle @@ -76,23 +76,6 @@ checkstyleTest { ignoreFailures = true } - -// This Gradle plugin's POM dependency modification works for "maven-publish" tasks. -// -// Note that "uploadArchives" is no longer supported. It is deprecated in Gradle 6 to be removed in Gradle 7. -// https://github.com/gradle/gradle/issues/3003#issuecomment-495025844 -publishing { - publications { - embulkPluginMaven(MavenPublication) { // Publish it with "publishEmbulkPluginMavenPublicationToMavenRepository". - from components.java // Must be "components.java". The dependency modification works only for it. - } - } - repositories { - maven { - url = "${project.buildDir}/mavenPublishLocal" - } - } -} gem { from("LICENSE.txt") authors = [ "" ] From d9486f6294e6747721405750834748e732cc5fa4 Mon Sep 17 00:00:00 2001 From: chikamura Date: Tue, 25 Jun 2024 11:28:06 +0900 Subject: [PATCH 7/7] remove checkstyle --- build.gradle | 14 ---- config/checkstyle/checkstyle.xml | 128 ------------------------------- config/checkstyle/default.xml | 108 -------------------------- 3 files changed, 250 deletions(-) delete mode 100644 config/checkstyle/checkstyle.xml delete mode 100644 config/checkstyle/default.xml diff --git a/build.gradle b/build.gradle index 9103edb..5823b5a 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,6 @@ plugins { id "java" id "maven-publish" - id "checkstyle" id "org.embulk.embulk-plugins" version "0.5.5" id "com.palantir.git-version" version "0.13.0" id "com.diffplug.spotless" version "5.15.0" @@ -63,19 +62,6 @@ clean { delete 'default_jdbc_driver' } -checkstyle { - configFile = file("${project.rootDir}/config/checkstyle/checkstyle.xml") - toolVersion = '6.14.1' -} -checkstyleMain { - configFile = file("${project.rootDir}/config/checkstyle/default.xml") - ignoreFailures = true -} -checkstyleTest { - configFile = file("${project.rootDir}/config/checkstyle/default.xml") - ignoreFailures = true -} - gem { from("LICENSE.txt") authors = [ "" ] diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml deleted file mode 100644 index 24515d7..0000000 --- a/config/checkstyle/checkstyle.xml +++ /dev/null @@ -1,128 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/config/checkstyle/default.xml b/config/checkstyle/default.xml deleted file mode 100644 index d6ba96f..0000000 --- a/config/checkstyle/default.xml +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -