diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e35c7c9..d0f16ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,6 +96,22 @@ jobs: - name: Package Java engine working-directory: java run: ./mvnw -B -DskipTests package + - name: Prepare verified DM JDBC driver pack + run: ./scripts/prepare-dm-driver-pack.sh + - name: Verify managed DM driver loading + env: + CHAT2DB_JAVA_ENGINE_JAR: "${{ github.workspace }}/java/compat-runtime/target/chat2db-compat-runtime-0.1.0-SNAPSHOT.jar" + DM_TEST_DRIVER_PACK_DIR: "${{ github.workspace }}/target/driver-packs" + run: >- + cargo test -p chat2db-core --features java-integration + --test java_dm_driver_pack --locked + - name: Verify Rust-owned DM SPI without Community + env: + CHAT2DB_JAVA_ENGINE_JAR: "${{ github.workspace }}/java/compat-runtime/target/chat2db-compat-runtime-0.1.0-SNAPSHOT.jar" + DM_TEST_DRIVER_PACK_DIR: "${{ github.workspace }}/target/driver-packs" + run: >- + cargo test -p chat2db-core --features java-integration + --test java_dm_product --locked - name: Verify Rust-Java process protocol env: CHAT2DB_JAVA_ENGINE_JAR: "${{ github.workspace }}/java/compat-runtime/target/chat2db-compat-runtime-0.1.0-SNAPSHOT.jar" @@ -351,7 +367,7 @@ jobs: env: CHAT2DB_JAVA_ENGINE_JAR: "${{ github.workspace }}/java/compat-runtime/target/chat2db-compat-runtime-0.1.0-SNAPSHOT.jar" CHAT2DB_COMMUNITY_CLASSPATH_DIR: "${{ github.workspace }}/target/community-h2-classpath" - MYSQL_TEST_DRIVER_PACK_DIR: "${{ github.workspace }}/target/mysql-driver-packs" + MYSQL_TEST_DRIVER_PACK_DIR: "${{ github.workspace }}/target/driver-packs" MYSQL_TEST_HOST: 127.0.0.1 MYSQL_TEST_PORT: "3306" MYSQL_TEST_USER: root diff --git a/Makefile b/Makefile index 9481d63..2da8162 100644 --- a/Makefile +++ b/Makefile @@ -1,23 +1,25 @@ .PHONY: verify rust rust-process-tests java ipc-integration jdbc-h2-integration \ community-h2-classpath community-h2-reproducibility community-java-h2-integration \ community-h2-integration \ - community-product-h2-integration product-h2-integration mysql-driver-pack h2-driver-pack \ + community-product-h2-integration product-h2-integration mysql-driver-pack h2-driver-pack dm-driver-pack \ + dm-driver-pack-integration dm-product-integration \ native-mysql-integration native-mysql-direct-integration native-mysql-ssh-integration \ community-product-mysql-integration \ frontend-deps frontend-source frontend desktop generate-contracts check-contracts \ - macos-runtime macos-package-java macos-package macos-package-verify + macos-runtime macos-driver-packs macos-package-java macos-package macos-package-verify JAVA_ENGINE_JAR := $(CURDIR)/java/compat-runtime/target/chat2db-compat-runtime-0.1.0-SNAPSHOT.jar H2_DRIVER_JAR := $(CURDIR)/java/compat-runtime/target/test-drivers/h2-2.3.232.jar COMMUNITY_CLASSPATH_DIR := $(CURDIR)/target/community-h2-classpath -MYSQL_DRIVER_PACK_DIR := $(CURDIR)/target/mysql-driver-packs +DRIVER_PACK_DIR := $(CURDIR)/target/driver-packs MYSQL_TEST_HOST ?= 127.0.0.1 MYSQL_TEST_PORT ?= 3306 MYSQL_TEST_JDBC_PARAMETERS ?= sslMode=DISABLED&allowPublicKeyRetrieval=true&serverTimezone=UTC&zeroDateTimeBehavior=CONVERT_TO_NULL&tinyInt1isBit=false verify: rust rust-process-tests java ipc-integration jdbc-h2-integration \ community-java-h2-integration community-h2-integration \ - community-product-h2-integration product-h2-integration frontend desktop + community-product-h2-integration product-h2-integration \ + dm-driver-pack-integration dm-product-integration frontend desktop rust: cargo fmt --all --check @@ -60,10 +62,23 @@ product-h2-integration: java CHAT2DB_JAVA_ENGINE_JAR="$(JAVA_ENGINE_JAR)" CHAT2DB_H2_DRIVER_JAR="$(H2_DRIVER_JAR)" cargo test -p chat2db-core --features java-integration --test java_h2_product --locked mysql-driver-pack: - ./scripts/prepare-mysql-driver-pack.sh "$(MYSQL_DRIVER_PACK_DIR)" + ./scripts/prepare-mysql-driver-pack.sh "$(DRIVER_PACK_DIR)" h2-driver-pack: - ./scripts/prepare-h2-driver-pack.sh "$(MYSQL_DRIVER_PACK_DIR)" + ./scripts/prepare-h2-driver-pack.sh "$(DRIVER_PACK_DIR)" + +dm-driver-pack: + ./scripts/prepare-dm-driver-pack.sh "$(DRIVER_PACK_DIR)" + +dm-driver-pack-integration: java dm-driver-pack + CHAT2DB_JAVA_ENGINE_JAR="$(JAVA_ENGINE_JAR)" \ + DM_TEST_DRIVER_PACK_DIR="$(DRIVER_PACK_DIR)" \ + cargo test -p chat2db-core --features java-integration --test java_dm_driver_pack --locked + +dm-product-integration: java dm-driver-pack + CHAT2DB_JAVA_ENGINE_JAR="$(JAVA_ENGINE_JAR)" \ + DM_TEST_DRIVER_PACK_DIR="$(DRIVER_PACK_DIR)" \ + cargo test -p chat2db-core --features java-integration --test java_dm_product --locked native-mysql-integration: native-mysql-direct-integration native-mysql-ssh-integration @@ -151,7 +166,7 @@ community-product-mysql-integration: java community-h2-classpath mysql-driver-pa @test -n "$(MYSQL_TEST_PASSWORD)" || (echo "MYSQL_TEST_PASSWORD is required" >&2; exit 1) @CHAT2DB_JAVA_ENGINE_JAR="$(JAVA_ENGINE_JAR)" \ CHAT2DB_COMMUNITY_CLASSPATH_DIR="$(COMMUNITY_CLASSPATH_DIR)" \ - MYSQL_TEST_DRIVER_PACK_DIR="$(MYSQL_DRIVER_PACK_DIR)" \ + MYSQL_TEST_DRIVER_PACK_DIR="$(DRIVER_PACK_DIR)" \ MYSQL_TEST_HOST="$(MYSQL_TEST_HOST)" \ MYSQL_TEST_PORT="$(MYSQL_TEST_PORT)" \ MYSQL_TEST_USER="$(MYSQL_TEST_USER)" \ @@ -182,10 +197,13 @@ desktop: frontend macos-runtime: ./scripts/build-macos-runtime.sh +macos-driver-packs: + ./scripts/prepare-macos-driver-packs.sh + macos-package-java: community-h2-classpath $(MAKE) java -macos-package: macos-package-java mysql-driver-pack h2-driver-pack frontend macos-runtime +macos-package: macos-package-java macos-driver-packs frontend macos-runtime ./scripts/build-macos-package.sh macos-package-verify: diff --git a/README.md b/README.md index 3ffb97d..128d04a 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,40 @@ Connector/J is downloaded from Maven Central only after its pinned byte length and SHA-256 are verified; it remains an external driver pack and is never embedded in the Java engine. +Prepare and verify the pinned DM JDBC driver pack without requiring a running +DM server: + +```bash +make dm-driver-pack-integration +``` + +The gate starts the project-owned Java 17 JDBC engine and loads only +`dm.jdbc.driver.DmDriver` from the managed pack. DM capability routing, +metadata SQL, validation, result mapping, and bounded table-preview SQL belong +to the Rust `DmDriver` registered in the unified native-driver SPI. The Java +engine is only the generic JDBC transport for the official vendor JAR; it does +not load or invoke the Chat2DB Community DM or Oracle plugins. + +A separate product gate proves that the Rust-owned DM SPI works without any +Community classpath or Community database plugin configured: + +```bash +make dm-product-integration +``` + +A live product path is also exercised by this target when `DM_TEST_HOST`, `DM_TEST_PORT`, +`DM_TEST_USER`, and `DM_TEST_PASSWORD` are all set; use `DM_TEST_REQUIRED=1` to +make their absence an error. `DM_TEST_JDBC_URL` can override the default +`jdbc:dm://:` URL. The live test covers connection, database, +schema, table, and column discovery, bounded preview/query execution, and +fixture cleanup. Without an endpoint, the gate still verifies the Driver Pack +and Rust SPI identity without loading a Community classpath. + +The public macOS package does not bundle the proprietary DM JDBC JAR because +the JAR contains no verifiable redistribution grant. For local testing, prepare +the pinned pack explicitly and point Web or Desktop at it with +`CHAT2DB_DRIVER_PACK_DIR`. + Those targets require a clean submodule at the fixed commit, build through the checked-in Maven Wrapper and a repository-local Maven cache, derive archive timestamps from the commit, exclude the H2 JDBC driver, and deterministically @@ -302,7 +336,7 @@ enabled: make java community-h2-classpath mysql-driver-pack frontend CHAT2DB_JAVA_ENGINE_JAR="$PWD/java/compat-runtime/target/chat2db-compat-runtime-0.1.0-SNAPSHOT.jar" \ CHAT2DB_COMMUNITY_CLASSPATH_DIR="$PWD/target/community-h2-classpath" \ -CHAT2DB_DRIVER_PACK_DIR="$PWD/target/mysql-driver-packs" \ +CHAT2DB_DRIVER_PACK_DIR="$PWD/target/driver-packs" \ CHAT2DB_VAULT_MASTER_KEY="$(openssl rand -base64 32)" \ cargo run -p chat2db-web ``` diff --git a/apps/chat2db-desktop/tauri.package.conf.json b/apps/chat2db-desktop/tauri.package.conf.json index af2ec38..051e6b5 100644 --- a/apps/chat2db-desktop/tauri.package.conf.json +++ b/apps/chat2db-desktop/tauri.package.conf.json @@ -11,7 +11,7 @@ "../../target/macos-runtime": "chat2db/java", "../../java/compat-runtime/target/chat2db-compat-runtime-0.1.0-SNAPSHOT.jar": "chat2db/engine/chat2db-compat-runtime.jar", "../../target/community-h2-classpath": "chat2db/community-classpath", - "../../target/mysql-driver-packs": "chat2db/driver-packs", + "../../target/macos-driver-packs": "chat2db/driver-packs", "../../LICENSE": "chat2db/licenses/Chat2DB-Rust-LICENSE.txt", "../../third_party/chat2db-community/LICENSE": "chat2db/licenses/Chat2DB-Community-LICENSE.txt", "../../packaging/macos/THIRD_PARTY_NOTICES.md": "chat2db/licenses/THIRD_PARTY_NOTICES.md" diff --git a/crates/chat2db-core/Cargo.toml b/crates/chat2db-core/Cargo.toml index 0336994..f2afeb9 100644 --- a/crates/chat2db-core/Cargo.toml +++ b/crates/chat2db-core/Cargo.toml @@ -61,6 +61,16 @@ name = "java_community_mysql_product" path = "tests/java_community_mysql_product.rs" required-features = ["java-integration"] +[[test]] +name = "java_dm_driver_pack" +path = "tests/java_dm_driver_pack.rs" +required-features = ["java-integration"] + +[[test]] +name = "java_dm_product" +path = "tests/java_dm_product.rs" +required-features = ["java-integration"] + [features] default = [] java-integration = [] diff --git a/crates/chat2db-core/src/community.rs b/crates/chat2db-core/src/community.rs index 9634a55..ceeb0c2 100644 --- a/crates/chat2db-core/src/community.rs +++ b/crates/chat2db-core/src/community.rs @@ -99,6 +99,7 @@ use crate::{ AppError, Application, datasource_session::{SessionReadOnly, open_datasource_session, resolve_datasource_connection}, engine_manager::EngineLease, + native_driver::native_capability_not_supported, }; impl Application { @@ -127,11 +128,13 @@ impl Application { &self, request: ListCommunitySchemasRequest, ) -> Result { - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(metadata) = driver.metadata() - { - return metadata - .list_schemas(self, request.into()) + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + driver.metadata().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "schema metadata") + })?; + let database_type = request.database_type.clone(); + return self + .list_native_schemas(&database_type, request.into()) .await .map(crate::native_api_adapter::schema_list_response); } @@ -170,11 +173,13 @@ impl Application { &self, request: ListCommunityDatabasesRequest, ) -> Result { - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(metadata) = driver.metadata() - { - return metadata - .list_databases(self, request.into()) + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + driver.metadata().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "database metadata") + })?; + let database_type = request.database_type.clone(); + return self + .list_native_databases(&database_type, request.into()) .await .map(crate::native_api_adapter::database_list_response); } @@ -212,11 +217,13 @@ impl Application { &self, request: ListCommunityTablesRequest, ) -> Result { - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(metadata) = driver.metadata() - { - return metadata - .list_tables(self, request.into()) + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + driver.metadata().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "table metadata") + })?; + let database_type = request.database_type.clone(); + return self + .list_native_tables(&database_type, request.into()) .await .map(crate::native_api_adapter::table_list_response); } @@ -264,11 +271,13 @@ impl Application { &self, request: ListCommunityColumnsRequest, ) -> Result { - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(metadata) = driver.metadata() - { - return metadata - .list_columns(self, request.into()) + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + driver.metadata().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "column metadata") + })?; + let database_type = request.database_type.clone(); + return self + .list_native_columns(&database_type, request.into()) .await .map(crate::native_api_adapter::column_list_response); } @@ -368,9 +377,10 @@ impl Application { &self, request: ListCommunityIndexesRequest, ) -> Result { - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(metadata) = driver.metadata() - { + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + let metadata = driver.metadata().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "metadata") + })?; return metadata .list_indexes(self, request.into()) .await @@ -420,9 +430,10 @@ impl Application { &self, request: ListCommunityViewsRequest, ) -> Result { - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(metadata) = driver.metadata() - { + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + let metadata = driver.metadata().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "metadata") + })?; return metadata .list_views(self, request.into()) .await @@ -473,9 +484,10 @@ impl Application { request: ListCommunityViewsRequest, ) -> Result { let view_name = request.view_name_pattern.clone(); - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(metadata) = driver.metadata() - { + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + let metadata = driver.metadata().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "metadata") + })?; return metadata .get_view(self, request.into()) .await @@ -503,9 +515,10 @@ impl Application { &self, request: ListCommunityTableKeysRequest, ) -> Result { - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(metadata) = driver.metadata() - { + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + let metadata = driver.metadata().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "metadata") + })?; return metadata .list_imported_keys(self, request.into()) .await @@ -555,9 +568,10 @@ impl Application { &self, request: ListCommunityTableKeysRequest, ) -> Result { - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(metadata) = driver.metadata() - { + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + let metadata = driver.metadata().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "metadata") + })?; return metadata .list_exported_keys(self, request.into()) .await @@ -607,9 +621,10 @@ impl Application { &self, request: ListCommunityTableKeysRequest, ) -> Result { - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(metadata) = driver.metadata() - { + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + let metadata = driver.metadata().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "metadata") + })?; return metadata .list_primary_keys(self, request.into()) .await @@ -659,9 +674,10 @@ impl Application { &self, request: ListCommunityFunctionsRequest, ) -> Result { - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(metadata) = driver.metadata() - { + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + let metadata = driver.metadata().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "metadata") + })?; return metadata .list_functions(self, request.into()) .await @@ -703,9 +719,10 @@ impl Application { &self, request: GetCommunityFunctionRequest, ) -> Result { - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(metadata) = driver.metadata() - { + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + let metadata = driver.metadata().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "metadata") + })?; return metadata .get_function(self, request.into()) .await @@ -753,9 +770,10 @@ impl Application { &self, request: GetCommunityFunctionRequest, ) -> Result { - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(metadata) = driver.metadata() - { + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + let metadata = driver.metadata().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "metadata") + })?; return metadata .list_function_parameters(self, request.into()) .await @@ -808,9 +826,10 @@ impl Application { &self, request: ListCommunityProceduresRequest, ) -> Result { - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(metadata) = driver.metadata() - { + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + let metadata = driver.metadata().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "metadata") + })?; return metadata .list_procedures(self, request.into()) .await @@ -852,9 +871,10 @@ impl Application { &self, request: GetCommunityProcedureRequest, ) -> Result { - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(metadata) = driver.metadata() - { + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + let metadata = driver.metadata().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "metadata") + })?; return metadata .get_procedure(self, request.into()) .await @@ -902,9 +922,10 @@ impl Application { &self, request: GetCommunityProcedureRequest, ) -> Result { - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(metadata) = driver.metadata() - { + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + let metadata = driver.metadata().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "metadata") + })?; return metadata .list_procedure_parameters(self, request.into()) .await @@ -1049,9 +1070,10 @@ impl Application { &self, request: ListCommunityTriggersRequest, ) -> Result { - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(metadata) = driver.metadata() - { + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + let metadata = driver.metadata().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "metadata") + })?; return metadata .list_triggers(self, request.into()) .await @@ -1093,9 +1115,10 @@ impl Application { &self, request: GetCommunityTriggerRequest, ) -> Result { - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(metadata) = driver.metadata() - { + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + let metadata = driver.metadata().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "metadata") + })?; return metadata .get_trigger(self, request.into()) .await @@ -1144,9 +1167,10 @@ impl Application { &self, request: BuildCommunityCreateSchemaRequest, ) -> Result { - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(dialect) = driver.dialect() - { + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + let dialect = driver.dialect().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "SQL dialect") + })?; return dialect .build_create_schema(CreateSchemaSqlRequest { schema: native_schema(request.schema), @@ -1172,9 +1196,10 @@ impl Application { &self, request: BuildCommunityNamespaceSqlRequest, ) -> Result { - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(dialect) = driver.dialect() - { + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + let dialect = driver.dialect().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "SQL dialect") + })?; return dialect .build_namespace_sql(native_namespace_request(request)) .map(|built| CommunityBuiltSql { sql: built.sql }); @@ -1198,9 +1223,10 @@ impl Application { &self, request: BuildCommunityDmlRequest, ) -> Result { - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(dialect) = driver.dialect() - { + if let Some(driver) = self.native_driver_for_database_type(&request.database_type) { + let dialect = driver.dialect().ok_or_else(|| { + native_capability_not_supported(&request.database_type, "SQL dialect") + })?; return dialect .build_dml(native_dml_request(request)?) .map(|built| CommunityBuiltSql { sql: built.sql }); @@ -1231,11 +1257,13 @@ impl Application { "datasourceId cannot be empty", )); } - if let Some(driver) = self.native_driver_for_database_type(&request.database_type) - && let Some(tables) = driver.tables() + if self + .native_driver_for_database_type(&request.database_type) + .is_some() { - return tables - .start_table_preview(self, request.into(), row_limit) + let database_type = request.database_type.clone(); + return self + .start_native_table_preview(&database_type, request.into(), row_limit) .await .map(crate::native_api_adapter::table_preview_response) .map_err(crate::native_api_adapter::compatibility_api_error); @@ -2360,18 +2388,18 @@ fn preserve_primary_result( mod tests { use async_trait::async_trait; use chat2db_contract::{ - BuildCommunityDmlRequest, BuildCommunityNamespaceSqlRequest, CommunityDatabase, - CommunityDmlColumn, CommunityDmlRow, CommunityDmlStatement, CommunityDmlTarget, - CommunityDmlValue, CommunityDriverConfig, CommunityForeignKey, CommunityFormattedSql, - CommunityFunction, CommunityFunctionParameter, CommunityNamespaceSqlOperation, - CommunityParsedStatement, CommunityPlugin, CommunityPluginBehavior, CommunityPluginCatalog, - CommunityPluginServices, CommunityPrimaryKey, CommunityProcedure, - CommunityProcedureParameter, CommunitySchema, CommunitySqlAnalysis, CommunitySqlDiagnostic, - CommunitySqlValidation, CommunityTable, CommunityTableColumn, CommunityTableIndex, - CommunityTableIndexColumn, CommunityTrigger, DatasourceConnection, - ListCommunityColumnsRequest, ListCommunityDatabasesRequest, ListCommunityIndexesRequest, - ListCommunitySchemasRequest, ListCommunityTableKeysRequest, ListCommunityTablesRequest, - ListCommunityViewsRequest, + BuildCommunityCreateSchemaRequest, BuildCommunityDmlRequest, + BuildCommunityNamespaceSqlRequest, CommunityDatabase, CommunityDmlColumn, CommunityDmlRow, + CommunityDmlStatement, CommunityDmlTarget, CommunityDmlValue, CommunityDriverConfig, + CommunityForeignKey, CommunityFormattedSql, CommunityFunction, CommunityFunctionParameter, + CommunityNamespaceSqlOperation, CommunityParsedStatement, CommunityPlugin, + CommunityPluginBehavior, CommunityPluginCatalog, CommunityPluginServices, + CommunityPrimaryKey, CommunityProcedure, CommunityProcedureParameter, CommunitySchema, + CommunitySqlAnalysis, CommunitySqlDiagnostic, CommunitySqlValidation, CommunityTable, + CommunityTableColumn, CommunityTableIndex, CommunityTableIndexColumn, CommunityTrigger, + DatasourceConnection, ListCommunityColumnsRequest, ListCommunityDatabasesRequest, + ListCommunityIndexesRequest, ListCommunitySchemasRequest, ListCommunityTableKeysRequest, + ListCommunityTablesRequest, ListCommunityViewsRequest, }; use chat2db_java_bridge::{ CommunityDatabase as BridgeCommunityDatabase, @@ -2437,8 +2465,8 @@ mod tests { &FAKE_POSTGRES_DESCRIPTOR } - fn connection(&self) -> &dyn NativeConnectionDriver { - self + fn connection(&self) -> Option<&dyn NativeConnectionDriver> { + Some(self) } fn dialect(&self) -> Option<&dyn NativeDialectDriver> { @@ -2498,6 +2526,51 @@ mod tests { assert_eq!(response.sql, "fake-postgres:namespace"); } + #[tokio::test] + async fn registered_driver_never_falls_back_to_community_for_missing_dialect_capabilities() { + let application = Application::new(); + + let create_schema_error = application + .build_community_create_schema(BuildCommunityCreateSchemaRequest { + database_type: "DM".to_owned(), + schema: CommunitySchema { + name: "APP".to_owned(), + ..CommunitySchema::default() + }, + }) + .await + .expect_err("DM must not fall back to the Community schema builder"); + assert_eq!( + create_schema_error.api_error().code, + "native_driver_capability_not_supported" + ); + + let namespace_error = application + .build_community_namespace_sql(BuildCommunityNamespaceSqlRequest { + database_type: "DM".to_owned(), + operation: CommunityNamespaceSqlOperation::DropSchema { + schema_name: "APP".to_owned(), + }, + }) + .await + .expect_err("DM must not fall back to the Community namespace builder"); + assert_eq!( + namespace_error.api_error().code, + "native_driver_capability_not_supported" + ); + + let mut dml_request = binary_dml_request("AAH/"); + dml_request.database_type = "DM".to_owned(); + let dml_error = application + .build_community_dml(dml_request) + .await + .expect_err("DM must not fall back to the Community DML builder"); + assert_eq!( + dml_error.api_error().code, + "native_driver_capability_not_supported" + ); + } + fn binary_dml_request(base64: &str) -> BuildCommunityDmlRequest { BuildCommunityDmlRequest { database_type: "H2".to_owned(), diff --git a/crates/chat2db-core/src/datasource_compatibility.rs b/crates/chat2db-core/src/datasource_compatibility.rs index 90f00fe..8698696 100644 --- a/crates/chat2db-core/src/datasource_compatibility.rs +++ b/crates/chat2db-core/src/datasource_compatibility.rs @@ -263,12 +263,21 @@ impl Application { ) })?; let descriptor = driver.descriptor(); + let artifact_required = driver.connection().is_none(); + let driver_id = if artifact_required { + managed_jdbc_driver_for_descriptor(&self.inner.drivers, descriptor).map_or_else( + || descriptor.id.to_owned(), + |driver| driver.driver_id.clone(), + ) + } else { + descriptor.id.to_owned() + }; Ok(NativeDriverCompatibility { database_type: descriptor.database_types[0].to_owned(), - driver_id: descriptor.id.to_owned(), + driver_id, action, implementation: descriptor.implementation.to_owned(), - artifact_required: false, + artifact_required, changed: false, }) } @@ -321,7 +330,18 @@ pub(crate) fn native_driver_for_datasource_driver_id( registry.driver_for_datasource_driver_id(driver_id) } -fn jdbc_driver_matches_descriptor( +pub(crate) fn managed_jdbc_driver_for_descriptor<'a>( + managed_drivers: &'a [JdbcDriver], + descriptor: &NativeDriverDescriptor, +) -> Option<&'a JdbcDriver> { + let mut matches = managed_drivers + .iter() + .filter(|driver| jdbc_driver_matches_descriptor(driver, descriptor)); + let driver = matches.next()?; + matches.next().is_none().then_some(driver) +} + +pub(crate) fn jdbc_driver_matches_descriptor( jdbc_driver: &JdbcDriver, descriptor: &NativeDriverDescriptor, ) -> bool { @@ -340,9 +360,14 @@ fn jdbc_driver_matches_descriptor( (!alias.is_empty()).then_some(alias) }) .any(|alias| { - compatibility_values - .iter() - .any(|value| value.to_ascii_lowercase().contains(&alias)) + compatibility_values.iter().any(|value| { + let value = value.trim().to_ascii_lowercase(); + value == alias + || (alias.contains('.') + && value + .strip_prefix(&alias) + .is_some_and(|suffix| suffix.starts_with('.'))) + }) }) } @@ -502,9 +527,29 @@ mod tests { use tempfile::TempDir; use super::{ - CloneDatasourceRequest, jdbc_driver_from_descriptor, native_driver_for_datasource_driver_id, + CloneDatasourceRequest, jdbc_driver_from_descriptor, managed_jdbc_driver_for_descriptor, + native_driver_for_datasource_driver_id, + }; + use crate::{ + Application, + native_driver::{NativeDriver, NativeDriverRegistry}, + native_driver_types::NativeDriverDescriptor, + }; + + struct JdbcOnlyDriver; + + const JDBC_ONLY_DESCRIPTOR: NativeDriverDescriptor = NativeDriverDescriptor { + id: "jdbc-only", + implementation: "managed_jdbc", + database_types: &["JDBC_ONLY"], + compatibility_aliases: &["vendor.jdbc.Driver"], }; - use crate::{Application, native_driver::NativeDriverRegistry}; + + impl NativeDriver for JdbcOnlyDriver { + fn descriptor(&self) -> &'static NativeDriverDescriptor { + &JDBC_ONLY_DESCRIPTOR + } + } #[derive(Debug, Default)] struct MemoryVault { @@ -732,6 +777,41 @@ mod tests { assert!(!compatibility.changed); } + #[test] + fn jdbc_only_driver_is_not_advertised_as_a_standalone_native_driver() { + let registry = NativeDriverRegistry::try_new(vec![Arc::new(JdbcOnlyDriver)]) + .expect("JDBC-only registry is valid"); + let application = Application::with_native_drivers_for_test(registry); + + assert!(application.list_drivers().items.is_empty()); + let compatibility = application + .native_driver_compatibility("JDBC_ONLY", NativeDriverAction::Download) + .expect("JDBC-only compatibility resolves"); + assert!(compatibility.artifact_required); + assert!(!compatibility.changed); + } + + #[tokio::test] + async fn jdbc_only_connection_capability_falls_back_to_a_managed_driver_pack() { + let registry = NativeDriverRegistry::try_new(vec![Arc::new(JdbcOnlyDriver)]) + .expect("JDBC-only registry is valid"); + let application = Application::with_native_drivers_for_test(registry); + let error = application + .test_datasource_connection( + "jdbc-only", + DatasourceConnection { + jdbc_url: "jdbc:vendor://localhost/test".to_owned(), + properties: Vec::new(), + read_only: true, + ssh: None, + }, + ) + .await + .expect_err("a JDBC-only SPI delegates connection testing to the JDBC engine"); + + assert_eq!(error.api_error().code, "database_engine_unavailable"); + } + #[test] fn native_descriptor_preserves_the_existing_mysql_jdbc_wire_shape() { let registry = NativeDriverRegistry::built_in(); @@ -772,4 +852,44 @@ mod tests { .expect("managed MySQL descriptor resolves to the native implementation"); assert_eq!(driver.descriptor().id, "mysql"); } + + #[test] + fn managed_driver_selection_rejects_ambiguous_alias_matches() { + let managed = |driver_id: &str| JdbcDriver { + pack_id: format!("pack-{driver_id}"), + name: "Vendor JDBC".to_owned(), + version: "1".to_owned(), + driver_id: driver_id.to_owned(), + driver_class: "vendor.jdbc.Driver".to_owned(), + artifact_count: 1, + artifact_bytes: "1".to_owned(), + }; + + assert!( + managed_jdbc_driver_for_descriptor( + &[managed("managed-1"), managed("managed-2")], + &JDBC_ONLY_DESCRIPTOR, + ) + .is_none(), + "an alias must not select an arbitrary managed driver" + ); + } + + #[test] + fn short_driver_alias_does_not_match_an_unrelated_name_substring() { + let unrelated = JdbcDriver { + pack_id: "admin-tools".to_owned(), + name: "Admin database".to_owned(), + version: "1".to_owned(), + driver_id: "managed-admin".to_owned(), + driver_class: "com.example.AdminDriver".to_owned(), + artifact_count: 1, + artifact_bytes: "1".to_owned(), + }; + + assert!(!super::jdbc_driver_matches_descriptor( + &unrelated, + &crate::native_dm::DM_DRIVER_DESCRIPTOR, + )); + } } diff --git a/crates/chat2db-core/src/datasource_session.rs b/crates/chat2db-core/src/datasource_session.rs index de2c4c9..15ca1e2 100644 --- a/crates/chat2db-core/src/datasource_session.rs +++ b/crates/chat2db-core/src/datasource_session.rs @@ -1,8 +1,22 @@ use chat2db_contract::{ApiError, DatasourceConnection}; -use chat2db_java_bridge::{ConnectionProperty, EngineClient, Session, SessionConfig}; +use chat2db_java_bridge::{ + ConnectionProperty, EngineClient, JdbcColumn, JdbcParameter, JdbcRow, QueryCompleted, + QueryEvent, QueryOptions, QueryRequest, Session, SessionConfig, +}; use chat2db_storage::Storage; -use crate::{AppError, AppErrorKind}; +use crate::{ + AppError, AppErrorKind, Application, + datasource_compatibility::{ + jdbc_driver_matches_descriptor, managed_jdbc_driver_for_descriptor, + }, + driver_not_installed, + engine_manager::EngineLease, + native_driver_types::NativeDriverDescriptor, +}; + +const JDBC_QUERY_BATCH_ROWS: u32 = 128; +const JDBC_QUERY_BATCH_BYTES: u32 = 64 * 1024; pub(crate) struct ResolvedDatasourceConnection { pub(crate) datasource_id: String, @@ -18,6 +32,19 @@ pub(crate) enum SessionReadOnly { Forced, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct JdbcQueryLimits { + pub(crate) max_rows: u64, + pub(crate) max_result_bytes: u64, +} + +#[derive(Debug, PartialEq)] +pub(crate) struct JdbcQueryResult { + pub(crate) columns: Vec, + pub(crate) rows: Vec, + pub(crate) completed: QueryCompleted, +} + pub(crate) async fn resolve_datasource_connection( storage: &Storage, datasource_id: &str, @@ -65,6 +92,323 @@ pub(crate) async fn open_datasource_session( .map_err(AppError::from) } +pub(crate) async fn open_mapped_datasource_session( + application: &Application, + engine: &EngineClient, + mut resolved: ResolvedDatasourceConnection, + read_only: SessionReadOnly, +) -> Result { + resolved.driver_id = resolve_jdbc_driver_id(application, &resolved.driver_id, None)?; + open_datasource_session(engine, resolved, read_only).await +} + +/// Opens and closes a managed JDBC connection while retaining the Java generation lease. +pub(crate) async fn jdbc_test_connection( + application: &Application, + driver_id: &str, + connection: DatasourceConnection, +) -> Result<(), AppError> { + let resolved = ResolvedDatasourceConnection { + datasource_id: "connection-test".to_owned(), + datasource_revision: 0, + driver_id: driver_id.to_owned(), + datasource_name: "Connection test".to_owned(), + connection, + }; + ManagedJdbcSession::open(application, resolved, SessionReadOnly::Configured, None) + .await? + .finish(Ok(())) + .await +} + +/// Executes one bounded, forced-read-only JDBC query for a native SPI driver. +/// +/// The native driver owns database-specific SQL and result mapping. This helper owns +/// datasource secret resolution, managed driver selection, Java lifecycle, flow control, +/// and session cleanup without loading any Community plugin. +pub(crate) async fn jdbc_query( + application: &Application, + descriptor: &'static NativeDriverDescriptor, + datasource_id: &str, + sql: String, + parameters: Vec, + limits: JdbcQueryLimits, +) -> Result { + if limits.max_rows == 0 || limits.max_result_bytes == 0 { + return Err(AppError::invalid( + "invalid_jdbc_query_limits", + "JDBC helper queries require non-zero row and byte limits", + )); + } + let storage = application.require_storage()?; + let resolved = resolve_datasource_connection(&storage, datasource_id).await?; + let session = ManagedJdbcSession::open( + application, + resolved, + SessionReadOnly::Forced, + Some(descriptor), + ) + .await?; + session.query(sql, parameters, limits).await +} + +/// Normal paths consume this owner through `finish`, which awaits `Session::close` +/// before releasing the engine lease. `Drop` is only a cancellation/panic guard. +struct ManagedJdbcSession { + session: Option, + engine: Option, +} + +impl ManagedJdbcSession { + async fn open( + application: &Application, + mut resolved: ResolvedDatasourceConnection, + read_only: SessionReadOnly, + expected_driver: Option<&NativeDriverDescriptor>, + ) -> Result { + resolved.driver_id = + resolve_jdbc_driver_id(application, &resolved.driver_id, expected_driver)?; + let engine = application.require_engine().await?; + let driver = engine.driver_client().map_err(AppError::from)?; + let session = driver + .open_session(session_config(resolved, read_only)) + .await + .map_err(AppError::from)?; + Ok(Self { + session: Some(session), + engine: Some(engine), + }) + } + + async fn query( + self, + sql: String, + parameters: Vec, + limits: JdbcQueryLimits, + ) -> Result { + let task = tokio::spawn(async move { self.run_query(sql, parameters, limits).await }); + match task.await { + Ok(result) => result, + Err(error) => { + tracing::error!( + cancelled = error.is_cancelled(), + panicked = error.is_panic(), + "managed JDBC query task ended without a product result" + ); + Err(AppError::internal()) + } + } + } + + async fn run_query( + self, + sql: String, + parameters: Vec, + limits: JdbcQueryLimits, + ) -> Result { + let session = self.session.as_ref().expect("open JDBC session is present"); + let result = match session + .execute_query(QueryRequest { + sql, + parameters, + transaction_id: None, + options: QueryOptions { + max_rows: limits.max_rows, + target_batch_rows: JDBC_QUERY_BATCH_ROWS, + target_batch_bytes: JDBC_QUERY_BATCH_BYTES, + initial_batch_credits: 0, + max_result_bytes: limits.max_result_bytes, + }, + }) + .await + { + Ok(mut stream) => { + let result = consume_bounded_query(&mut stream).await; + let result = match result { + Ok(result) => Ok(result), + Err(primary) => { + if let Err(cleanup_error) = + crate::query::settle_query_stream(&mut stream).await + { + tracing::warn!( + cleanup_error = %cleanup_error, + "JDBC query stream cleanup also failed after the primary operation failure" + ); + } + Err(primary) + } + }; + drop(stream); + result + } + Err(error) => Err(error.into()), + }; + self.finish(result).await + } + + async fn finish(mut self, result: Result) -> Result { + let session = self.session.take(); + let engine = self.engine.take(); + let cleanup = tokio::spawn(async move { + let close_result = match session { + Some(session) => session.close().await.map_err(AppError::from), + None => Ok(()), + }; + drop(engine); + close_result + }); + let close_result = match cleanup.await { + Ok(result) => result, + Err(error) => { + tracing::error!( + cancelled = error.is_cancelled(), + panicked = error.is_panic(), + "managed JDBC session cleanup task ended without a product result" + ); + Err(AppError::internal()) + } + }; + match (result, close_result) { + (Ok(value), Ok(())) => Ok(value), + (Ok(_), Err(close_error)) => Err(close_error), + (Err(primary), Ok(())) => Err(primary), + (Err(primary), Err(close_error)) => { + tracing::warn!( + close_error = %close_error, + "JDBC session cleanup also failed after the primary operation failure" + ); + Err(primary) + } + } + } +} + +impl Drop for ManagedJdbcSession { + fn drop(&mut self) { + let Some(session) = self.session.take() else { + return; + }; + let engine = self.engine.take(); + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + tracing::error!( + "JDBC session owner was dropped outside a Tokio runtime; asynchronous cleanup could not run" + ); + return; + }; + runtime.spawn(async move { + if let Err(error) = session.close().await { + tracing::warn!( + close_error = %error, + "best-effort JDBC session cleanup failed" + ); + } + drop(engine); + }); + } +} + +async fn consume_bounded_query( + stream: &mut chat2db_java_bridge::QueryStream, +) -> Result { + let mut columns = None; + let mut rows = Vec::new(); + loop { + let event = stream.next_event().await.map_err(AppError::from)?; + match event { + Some(QueryEvent::Started(started)) if columns.is_none() => { + columns = Some(started.columns); + crate::query::grant_next_credit(stream).await?; + } + Some(QueryEvent::Started(_)) => { + return Err(invalid_jdbc_query_stream( + "JDBC query emitted more than one schema event", + )); + } + Some(QueryEvent::Batch(batch)) => { + if columns.is_none() { + return Err(invalid_jdbc_query_stream( + "JDBC query emitted rows before its schema", + )); + } + rows.extend(batch.rows); + crate::query::grant_next_credit(stream).await?; + } + Some(QueryEvent::Completed(completed)) => { + let columns = columns.ok_or_else(|| { + invalid_jdbc_query_stream("JDBC query completed without a schema") + })?; + return Ok(JdbcQueryResult { + columns, + rows, + completed, + }); + } + None => { + return Err(invalid_jdbc_query_stream( + "JDBC query ended without a completion event", + )); + } + } + } +} + +fn resolve_jdbc_driver_id( + application: &Application, + requested_driver_id: &str, + expected_driver: Option<&NativeDriverDescriptor>, +) -> Result { + let requested_driver_id = requested_driver_id.trim(); + let exact_managed = application + .inner + .drivers + .iter() + .find(|driver| driver.driver_id.eq_ignore_ascii_case(requested_driver_id)); + let selected_native = application.native_driver_for_datasource_driver_id(requested_driver_id); + + if let Some(expected) = expected_driver { + let matches_expected = selected_native + .as_ref() + .is_some_and(|driver| driver.descriptor().id.eq_ignore_ascii_case(expected.id)) + || exact_managed.is_some_and(|driver| jdbc_driver_matches_descriptor(driver, expected)); + if !matches_expected { + return Err(AppError::invalid( + "jdbc_driver_mismatch", + "The datasource is not compatible with the selected database driver", + )); + } + } + + if let Some(driver) = exact_managed { + return Ok(driver.driver_id.clone()); + } + + let descriptor = + expected_driver.or_else(|| selected_native.as_ref().map(|driver| driver.descriptor())); + if let Some(descriptor) = descriptor { + if let Some(driver) = + managed_jdbc_driver_for_descriptor(&application.inner.drivers, descriptor) + { + return Ok(driver.driver_id.clone()); + } + return match &application.inner.managed_driver_ids { + Some(_) => Err(driver_not_installed()), + None => Ok(requested_driver_id.to_owned()), + }; + } + + match &application.inner.managed_driver_ids { + Some(_) => Err(driver_not_installed()), + None => Ok(requested_driver_id.to_owned()), + } +} + +fn invalid_jdbc_query_stream(message: &'static str) -> AppError { + AppError::new( + AppErrorKind::Internal, + ApiError::new("invalid_jdbc_query_stream", message), + ) +} + fn session_config( resolved: ResolvedDatasourceConnection, read_only: SessionReadOnly, @@ -99,7 +443,10 @@ fn session_config( mod tests { use chat2db_contract::{DatasourceConnection, DatasourceConnectionProperty}; - use super::{ResolvedDatasourceConnection, SessionReadOnly, session_config}; + use super::{ + ResolvedDatasourceConnection, SessionReadOnly, resolve_jdbc_driver_id, session_config, + }; + use crate::Application; #[test] fn configured_session_preserves_the_datasource_read_only_setting() { @@ -121,6 +468,17 @@ mod tests { assert!(session_config(resolved(false), SessionReadOnly::Forced).read_only); } + #[test] + fn unmanaged_engine_host_preserves_an_external_native_driver_id() { + let application = Application::new(); + + assert_eq!( + resolve_jdbc_driver_id(&application, "mysql", None) + .expect("an unmanaged host cannot inspect the external driver's inventory"), + "mysql" + ); + } + fn resolved(read_only: bool) -> ResolvedDatasourceConnection { ResolvedDatasourceConnection { datasource_id: "datasource-1".to_owned(), diff --git a/crates/chat2db-core/src/lib.rs b/crates/chat2db-core/src/lib.rs index 26c3717..cb0e52f 100644 --- a/crates/chat2db-core/src/lib.rs +++ b/crates/chat2db-core/src/lib.rs @@ -19,6 +19,7 @@ mod mysql_schema_diff; mod mysql_workspace; mod native_administration_types; mod native_api_adapter; +mod native_dm; mod native_driver; mod native_driver_types; mod native_mysql; @@ -64,6 +65,11 @@ pub use large_value::{ LargeValueType, }; pub use legacy_community_import::LegacyCommunityImportOutcome; +pub use native_driver_types::{ + ColumnList, ColumnMetadata, DatabaseList, DatabaseMetadata, ListColumnsRequest, + ListDatabasesRequest, ListSchemasRequest, ListTablesRequest, MetadataScope, SchemaList, + SchemaMetadata, TableList, TableMetadata, TablePreviewAccepted, TablePreviewRequest, TableRef, +}; pub use operation::OperationSubscription; pub use query::{NativeConsoleCancellation, NativeConsoleRequest, NativeConsoleResult}; pub use transfer::TransferArtifactDownload; @@ -519,7 +525,7 @@ impl Application { #[must_use] pub fn list_drivers(&self) -> JdbcDriverList { let mut items = self.inner.drivers.clone(); - for descriptor in self.inner.native_drivers.descriptors() { + for descriptor in self.inner.native_drivers.standalone_descriptors() { let descriptor = datasource_compatibility::jdbc_driver_from_descriptor(descriptor); if !items .iter() @@ -550,23 +556,12 @@ impl Application { )); } self.require_managed_driver(driver_id)?; - if let Some(driver) = self.native_driver_for_datasource_driver_id(driver_id) { - return driver.connection().test_connection(&connection).await; + if let Some(driver) = self.native_driver_for_datasource_driver_id(driver_id) + && let Some(connection_driver) = driver.connection() + { + return connection_driver.test_connection(&connection).await; } - let engine = self.require_engine().await?; - let session = datasource_session::open_datasource_session( - &engine, - datasource_session::ResolvedDatasourceConnection { - datasource_id: "connection-test".to_owned(), - datasource_revision: 0, - driver_id: driver_id.to_owned(), - datasource_name: "Connection test".to_owned(), - connection, - }, - datasource_session::SessionReadOnly::Configured, - ) - .await?; - session.close().await.map_err(AppError::from) + datasource_session::jdbc_test_connection(self, driver_id, connection).await } /// Lists secret-free datasource metadata. @@ -660,9 +655,13 @@ impl Application { } fn require_managed_driver(&self, driver_id: &str) -> Result<(), AppError> { - if self - .native_driver_for_datasource_driver_id(driver_id) - .is_some() + if let Some(driver) = self.native_driver_for_datasource_driver_id(driver_id) + && (driver.connection().is_some() + || datasource_compatibility::managed_jdbc_driver_for_descriptor( + &self.inner.drivers, + driver.descriptor(), + ) + .is_some()) { return Ok(()); } @@ -723,9 +722,13 @@ impl Application { datasource_id: &str, driver_id: &str, ) -> Result<(), AppError> { - if self - .native_driver_for_datasource_driver_id(driver_id) - .is_some() + if let Some(driver) = self.native_driver_for_datasource_driver_id(driver_id) + && (driver.connection().is_some() + || datasource_compatibility::managed_jdbc_driver_for_descriptor( + &self.inner.drivers, + driver.descriptor(), + ) + .is_some()) { return Ok(()); } diff --git a/crates/chat2db-core/src/native_dm.rs b/crates/chat2db-core/src/native_dm.rs new file mode 100644 index 0000000..2bd1079 --- /dev/null +++ b/crates/chat2db-core/src/native_dm.rs @@ -0,0 +1,736 @@ +use chat2db_contract::{QueryLimits, StartQueryRequest}; +use chat2db_java_bridge::{JdbcColumn, JdbcParameter, JdbcRow, JdbcValue}; + +use crate::{ + AppError, AppErrorKind, Application, + datasource_session::{JdbcQueryLimits, JdbcQueryResult, jdbc_query}, + native_driver_types::{ + ColumnList, ColumnMetadata, DatabaseList, DatabaseMetadata, NativeDriverDescriptor, + SchemaList, SchemaMetadata, TableList, TableMetadata, TablePreviewAccepted, + TablePreviewRequest, + }, +}; + +const DM_DATABASE_TYPE: &str = "DM"; +const MAX_DM_IDENTIFIER_BYTES: usize = 128; +const MAX_DM_TABLE_PREVIEW_ROWS: u32 = 1_000; +const DATABASE_QUERY_LIMITS: JdbcQueryLimits = JdbcQueryLimits { + max_rows: 8, + max_result_bytes: 64 * 1024, +}; +const SCHEMA_QUERY_LIMITS: JdbcQueryLimits = JdbcQueryLimits { + max_rows: 4_096, + max_result_bytes: 4 * 1024 * 1024, +}; +const TABLE_QUERY_LIMITS: JdbcQueryLimits = JdbcQueryLimits { + max_rows: 20_000, + max_result_bytes: 16 * 1024 * 1024, +}; +const COLUMN_QUERY_LIMITS: JdbcQueryLimits = JdbcQueryLimits { + max_rows: 8_192, + max_result_bytes: 8 * 1024 * 1024, +}; + +const DM_SYSTEM_SCHEMAS: &[&str] = &["CTISYS", "SYS", "SYSDBA", "SYSSSO", "SYSAUDITOR"]; + +const LIST_DATABASES_SQL: &str = "SELECT NAME AS DATABASE_NAME FROM V$DATABASE"; +const LIST_SCHEMAS_SQL: &str = "SELECT USERNAME AS SCHEMA_NAME FROM ALL_USERS ORDER BY USERNAME"; + +const LIST_TABLES_SQL: &str = "SELECT T.OWNER AS SCHEMA_NAME, \ + T.TABLE_NAME AS TABLE_NAME, 'TABLE' AS TABLE_TYPE, \ + C.COMMENTS AS TABLE_COMMENT, T.TABLESPACE_NAME AS TABLESPACE_NAME, \ + T.NUM_ROWS AS ROW_COUNT \ + FROM ALL_TABLES T \ + LEFT JOIN ALL_TAB_COMMENTS C \ + ON C.OWNER = T.OWNER AND C.TABLE_NAME = T.TABLE_NAME \ + WHERE T.OWNER = ? \ + ORDER BY T.TABLE_NAME"; + +const LIST_TABLES_WITH_PATTERN_SQL: &str = "SELECT T.OWNER AS SCHEMA_NAME, \ + T.TABLE_NAME AS TABLE_NAME, 'TABLE' AS TABLE_TYPE, \ + C.COMMENTS AS TABLE_COMMENT, T.TABLESPACE_NAME AS TABLESPACE_NAME, \ + T.NUM_ROWS AS ROW_COUNT \ + FROM ALL_TABLES T \ + LEFT JOIN ALL_TAB_COMMENTS C \ + ON C.OWNER = T.OWNER AND C.TABLE_NAME = T.TABLE_NAME \ + WHERE T.OWNER = ? AND T.TABLE_NAME LIKE ? \ + ORDER BY T.TABLE_NAME"; + +const LIST_COLUMNS_SQL: &str = "SELECT C.COLUMN_NAME AS COLUMN_NAME, \ + C.DATA_TYPE AS DATA_TYPE, C.DATA_DEFAULT AS DATA_DEFAULT, \ + CC.COMMENTS AS COLUMN_COMMENT, C.NULLABLE AS IS_NULLABLE, \ + C.COLUMN_ID AS ORDINAL_POSITION, C.DATA_LENGTH AS DATA_LENGTH, \ + C.DATA_PRECISION AS DATA_PRECISION, C.DATA_SCALE AS DATA_SCALE, \ + PK.CONSTRAINT_NAME AS PRIMARY_KEY_NAME, PK.POSITION AS PRIMARY_KEY_ORDER \ + FROM ALL_TAB_COLUMNS C \ + LEFT JOIN ALL_COL_COMMENTS CC \ + ON CC.OWNER = C.OWNER AND CC.TABLE_NAME = C.TABLE_NAME \ + AND CC.COLUMN_NAME = C.COLUMN_NAME \ + LEFT JOIN ( \ + SELECT AC.OWNER, ACC.TABLE_NAME, ACC.COLUMN_NAME, \ + AC.CONSTRAINT_NAME, ACC.POSITION \ + FROM ALL_CONSTRAINTS AC \ + JOIN ALL_CONS_COLUMNS ACC \ + ON ACC.OWNER = AC.OWNER AND ACC.CONSTRAINT_NAME = AC.CONSTRAINT_NAME \ + AND ACC.TABLE_NAME = AC.TABLE_NAME \ + WHERE AC.CONSTRAINT_TYPE = 'P' \ + ) PK \ + ON PK.OWNER = C.OWNER AND PK.TABLE_NAME = C.TABLE_NAME \ + AND PK.COLUMN_NAME = C.COLUMN_NAME \ + WHERE C.OWNER = ? AND C.TABLE_NAME = ? \ + ORDER BY C.COLUMN_ID"; + +pub(crate) const DM_DRIVER_DESCRIPTOR: NativeDriverDescriptor = NativeDriverDescriptor { + id: "dm", + implementation: "dm-jdbc", + database_types: &[DM_DATABASE_TYPE], + compatibility_aliases: &["dm", "dm-jdbc", "dm.jdbc.driver.DmDriver"], +}; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct DmMetadataQuery { + pub(crate) sql: String, + pub(crate) parameters: Vec, +} + +pub(crate) fn list_schemas_query() -> DmMetadataQuery { + DmMetadataQuery { + sql: LIST_SCHEMAS_SQL.to_owned(), + parameters: Vec::new(), + } +} + +pub(crate) fn list_databases_query() -> DmMetadataQuery { + DmMetadataQuery { + sql: LIST_DATABASES_SQL.to_owned(), + parameters: Vec::new(), + } +} + +pub(crate) fn list_tables_query( + schema_name: &str, + table_name_pattern: &str, +) -> Result { + validate_metadata_name(schema_name, "schemaName")?; + let mut parameters = vec![text_parameter(1, schema_name)]; + let sql = if table_name_pattern.is_empty() { + LIST_TABLES_SQL + } else { + if table_name_pattern.len() > MAX_DM_IDENTIFIER_BYTES * 4 + || table_name_pattern.chars().any(char::is_control) + { + return Err(invalid_metadata_request("tableNamePattern")); + } + // Preserve JDBC/SQL LIKE semantics: `%`, `_`, and letter case are passed through. + parameters.push(text_parameter(2, table_name_pattern)); + LIST_TABLES_WITH_PATTERN_SQL + }; + Ok(DmMetadataQuery { + sql: sql.to_owned(), + parameters, + }) +} + +pub(crate) fn list_columns_query( + schema_name: &str, + table_name: &str, +) -> Result { + validate_metadata_name(schema_name, "schemaName")?; + validate_metadata_name(table_name, "tableName")?; + Ok(DmMetadataQuery { + sql: LIST_COLUMNS_SQL.to_owned(), + parameters: vec![ + text_parameter(1, schema_name), + text_parameter(2, table_name), + ], + }) +} + +pub(crate) async fn list_schemas( + application: &Application, + datasource_id: &str, + database_name: &str, +) -> Result { + let query = list_schemas_query(); + let result = jdbc_query( + application, + &DM_DRIVER_DESCRIPTOR, + datasource_id, + query.sql, + query.parameters, + SCHEMA_QUERY_LIMITS, + ) + .await?; + ensure_complete_metadata(&result, "schemas")?; + map_schemas(database_name, &result.columns, &result.rows) +} + +pub(crate) async fn list_databases( + application: &Application, + datasource_id: &str, +) -> Result { + let query = list_databases_query(); + let result = jdbc_query( + application, + &DM_DRIVER_DESCRIPTOR, + datasource_id, + query.sql, + query.parameters, + DATABASE_QUERY_LIMITS, + ) + .await?; + ensure_complete_metadata(&result, "databases")?; + map_databases(&result.columns, &result.rows) +} + +pub(crate) async fn list_tables( + application: &Application, + datasource_id: &str, + database_name: &str, + schema_name: &str, + table_name_pattern: &str, +) -> Result { + let query = list_tables_query(schema_name, table_name_pattern)?; + let result = jdbc_query( + application, + &DM_DRIVER_DESCRIPTOR, + datasource_id, + query.sql, + query.parameters, + TABLE_QUERY_LIMITS, + ) + .await?; + ensure_complete_metadata(&result, "tables")?; + map_tables(database_name, schema_name, &result.columns, &result.rows) +} + +pub(crate) async fn list_columns( + application: &Application, + datasource_id: &str, + database_name: &str, + schema_name: &str, + table_name: &str, +) -> Result { + let query = list_columns_query(schema_name, table_name)?; + let result = jdbc_query( + application, + &DM_DRIVER_DESCRIPTOR, + datasource_id, + query.sql, + query.parameters, + COLUMN_QUERY_LIMITS, + ) + .await?; + ensure_complete_metadata(&result, "columns")?; + map_columns( + database_name, + schema_name, + table_name, + &result.columns, + &result.rows, + ) +} + +pub(crate) fn map_schemas( + database_name: &str, + columns: &[JdbcColumn], + rows: &[JdbcRow], +) -> Result { + let schema_name = required_column(columns, "SCHEMA_NAME")?; + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + let name = required_text(row, schema_name)?; + items.push(SchemaMetadata { + database_name: database_name.to_owned(), + owner: name.clone(), + system: DM_SYSTEM_SCHEMAS + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(&name)), + name, + ..SchemaMetadata::default() + }); + } + Ok(SchemaList { items }) +} + +pub(crate) fn map_databases( + columns: &[JdbcColumn], + rows: &[JdbcRow], +) -> Result { + let database_name = required_column(columns, "DATABASE_NAME")?; + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + items.push(DatabaseMetadata { + name: required_text(row, database_name)?, + ..DatabaseMetadata::default() + }); + } + Ok(DatabaseList { items }) +} + +pub(crate) fn map_tables( + database_name: &str, + requested_schema_name: &str, + columns: &[JdbcColumn], + rows: &[JdbcRow], +) -> Result { + let schema_name = required_column(columns, "SCHEMA_NAME")?; + let table_name = required_column(columns, "TABLE_NAME")?; + let table_type = required_column(columns, "TABLE_TYPE")?; + let comment = required_column(columns, "TABLE_COMMENT")?; + let tablespace = required_column(columns, "TABLESPACE_NAME")?; + let row_count = required_column(columns, "ROW_COUNT")?; + + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + let returned_schema_name = optional_text(row, schema_name)?.unwrap_or_default(); + items.push(TableMetadata { + database_name: database_name.to_owned(), + schema_name: if returned_schema_name.is_empty() { + requested_schema_name.to_owned() + } else { + returned_schema_name + }, + name: required_text(row, table_name)?, + table_type: required_text(row, table_type)?, + comment: optional_text(row, comment)?.unwrap_or_default(), + database_type: DM_DATABASE_TYPE.to_owned(), + tablespace: optional_text(row, tablespace)?.unwrap_or_default(), + rows: optional_text(row, row_count)?, + ..TableMetadata::default() + }); + } + Ok(TableList { items }) +} + +pub(crate) fn map_columns( + database_name: &str, + schema_name: &str, + table_name: &str, + columns: &[JdbcColumn], + rows: &[JdbcRow], +) -> Result { + let column_name = required_column(columns, "COLUMN_NAME")?; + let data_type = required_column(columns, "DATA_TYPE")?; + let default_value = required_column(columns, "DATA_DEFAULT")?; + let comment = required_column(columns, "COLUMN_COMMENT")?; + let nullable = required_column(columns, "IS_NULLABLE")?; + let ordinal_position = required_column(columns, "ORDINAL_POSITION")?; + let data_length = required_column(columns, "DATA_LENGTH")?; + let data_precision = required_column(columns, "DATA_PRECISION")?; + let data_scale = required_column(columns, "DATA_SCALE")?; + let primary_key_name = required_column(columns, "PRIMARY_KEY_NAME")?; + let primary_key_order = required_column(columns, "PRIMARY_KEY_ORDER")?; + + let mut items = Vec::with_capacity(rows.len()); + for row in rows { + let column_type = normalize_dm_column_type(&required_text(row, data_type)?); + let precision = optional_i32(row, data_precision)?; + let length = optional_i32(row, data_length)?; + let decimal_digits = optional_i32(row, data_scale)?; + let primary_key_name = optional_text(row, primary_key_name)?.unwrap_or_default(); + let column_size = if column_type.eq_ignore_ascii_case("TIMESTAMP") { + decimal_digits + } else { + precision.or(length) + }; + items.push(ColumnMetadata { + database_name: database_name.to_owned(), + schema_name: schema_name.to_owned(), + table_name: table_name.to_owned(), + name: required_text(row, column_name)?, + data_type: Some(dm_jdbc_type(&column_type)), + column_type, + default_value: optional_text(row, default_value)?, + comment: optional_text(row, comment)?.unwrap_or_default(), + primary_key: Some(!primary_key_name.is_empty()), + primary_key_name, + primary_key_order: optional_i32(row, primary_key_order)?.unwrap_or_default(), + column_size, + decimal_digits, + ordinal_position: optional_i32(row, ordinal_position)?, + nullable: optional_text(row, nullable)?.map_or(Some(2), |value| { + match value.to_ascii_uppercase().as_str() { + "N" | "NO" => Some(0), + "Y" | "YES" => Some(1), + _ => Some(2), + } + }), + ..ColumnMetadata::default() + }); + } + Ok(ColumnList { items }) +} + +pub(crate) async fn start_table_preview( + application: &Application, + request: TablePreviewRequest, + row_limit: u32, +) -> Result { + let sql = table_preview_sql( + &request.table.scope.schema_name, + &request.table.table_name, + row_limit, + )?; + let accepted = application + .start_read_query(StartQueryRequest { + datasource_id: request.table.scope.datasource_id, + sql: sql.clone(), + parameters: Vec::new(), + limits: QueryLimits { + max_rows: row_limit.to_string(), + max_result_bytes: (8 * 1024 * 1024_u64).to_string(), + batch_rows: row_limit.min(200), + batch_bytes: 1024 * 1024, + result_ttl_seconds: 60 * 60, + }, + }) + .await?; + Ok(TablePreviewAccepted { + operation_id: accepted.operation_id, + sql, + row_limit, + }) +} + +pub(crate) fn table_preview_sql( + schema_name: &str, + table_name: &str, + row_limit: u32, +) -> Result { + if row_limit == 0 || row_limit > MAX_DM_TABLE_PREVIEW_ROWS { + return Err(AppError::invalid( + "invalid_dm_table_preview_request", + format!("rowLimit must be between 1 and {MAX_DM_TABLE_PREVIEW_ROWS}"), + )); + } + Ok(format!( + "SELECT * FROM {}.{} LIMIT {row_limit}", + quote_identifier(schema_name, "schemaName")?, + quote_identifier(table_name, "tableName")? + )) +} + +pub(crate) fn quote_identifier(value: &str, field: &str) -> Result { + validate_metadata_name(value, field)?; + Ok(format!("\"{}\"", value.replace('"', "\"\""))) +} + +fn text_parameter(position: u32, value: &str) -> JdbcParameter { + JdbcParameter { + position, + value: JdbcValue::Text(value.to_owned()), + jdbc_type: Some(12), + jdbc_type_name: None, + } +} + +fn validate_metadata_name(value: &str, field: &str) -> Result<(), AppError> { + if value.trim().is_empty() + || value.len() > MAX_DM_IDENTIFIER_BYTES + || value.chars().any(char::is_control) + { + return Err(invalid_metadata_request(field)); + } + Ok(()) +} + +fn invalid_metadata_request(field: &str) -> AppError { + AppError::invalid("invalid_dm_metadata_request", format!("{field} is invalid")) +} + +fn ensure_complete_metadata(result: &JdbcQueryResult, kind: &str) -> Result<(), AppError> { + if result.completed.truncated_by_max_rows || result.completed.truncated_by_max_result_bytes { + return Err(AppError::new( + AppErrorKind::ResourceExhausted, + chat2db_contract::ApiError::new( + "dm_metadata_limit_exceeded", + format!("DM {kind} metadata exceeded the bounded JDBC result limit"), + ), + )); + } + Ok(()) +} + +fn required_column(columns: &[JdbcColumn], label: &str) -> Result { + columns + .iter() + .position(|column| { + column.label.eq_ignore_ascii_case(label) || column.name.eq_ignore_ascii_case(label) + }) + .ok_or_else(AppError::internal) +} + +fn row_value(row: &JdbcRow, index: usize) -> Result<&JdbcValue, AppError> { + row.values.get(index).ok_or_else(AppError::internal) +} + +fn required_text(row: &JdbcRow, index: usize) -> Result { + optional_text(row, index)?.ok_or_else(AppError::internal) +} + +fn optional_text(row: &JdbcRow, index: usize) -> Result, AppError> { + let value = match row_value(row, index)? { + JdbcValue::Null => return Ok(None), + JdbcValue::Boolean(value) => value.to_string(), + JdbcValue::SignedInteger(value) => value.to_string(), + JdbcValue::UnsignedInteger(value) => value.to_string(), + JdbcValue::Float32(value) => value.to_string(), + JdbcValue::Float64(value) => value.to_string(), + JdbcValue::Decimal(value) + | JdbcValue::Text(value) + | JdbcValue::Date(value) + | JdbcValue::Time(value) + | JdbcValue::Timestamp(value) + | JdbcValue::TimestampWithTimeZone(value) + | JdbcValue::Json(value) + | JdbcValue::Uuid(value) => value.clone(), + JdbcValue::Opaque { display_value, .. } => display_value.clone(), + JdbcValue::Binary(_) => return Err(AppError::internal()), + }; + Ok(Some(value)) +} + +fn optional_i32(row: &JdbcRow, index: usize) -> Result, AppError> { + let value = match row_value(row, index)? { + JdbcValue::Null => return Ok(None), + JdbcValue::SignedInteger(value) => i32::try_from(*value).ok(), + JdbcValue::UnsignedInteger(value) => i32::try_from(*value).ok(), + JdbcValue::Decimal(value) | JdbcValue::Text(value) => value.parse().ok(), + _ => None, + }; + value.map(Some).ok_or_else(AppError::internal) +} + +fn normalize_dm_column_type(value: &str) -> String { + let mut normalized = String::with_capacity(value.len()); + let mut remaining = value.trim(); + while let Some(open) = remaining.find('(') { + normalized.push_str(&remaining[..open]); + let after_open = &remaining[open + 1..]; + let Some(close) = after_open.find(')') else { + normalized.push('('); + normalized.push_str(after_open); + return normalized; + }; + let contents = &after_open[..close]; + if contents.is_empty() || !contents.bytes().all(|byte| byte.is_ascii_digit()) { + normalized.push('('); + normalized.push_str(contents); + normalized.push(')'); + } + remaining = &after_open[close + 1..]; + } + normalized.push_str(remaining); + normalized +} + +fn dm_jdbc_type(data_type: &str) -> i32 { + match data_type.trim().to_ascii_uppercase().as_str() { + "CHAR" => 1, + "VARCHAR" | "VARCHAR2" => 12, + "NCHAR" => -15, + "NVARCHAR" | "NVARCHAR2" => -9, + "BIT" => -7, + "TINYINT" => -6, + "SMALLINT" => 5, + "INTEGER" | "INT" => 4, + "BIGINT" => -5, + "NUMERIC" => 2, + "DECIMAL" | "NUMBER" => 3, + "REAL" => 7, + "FLOAT" => 6, + "DOUBLE" | "DOUBLE PRECISION" => 8, + "BINARY" => -2, + "VARBINARY" => -3, + "LONGVARBINARY" | "IMAGE" => -4, + "DATE" => 91, + "TIME" => 92, + "TIMESTAMP" | "TIMESTAMP WITH TIME ZONE" | "TIMESTAMP WITH LOCAL TIME ZONE" => 93, + "BOOLEAN" => 16, + "BLOB" => 2004, + "CLOB" | "TEXT" => 2005, + "ARRAY" => 2003, + "ROWID" => -8, + "SQLXML" => 2009, + _ => 1111, + } +} + +#[cfg(test)] +mod tests { + use chat2db_java_bridge::{ColumnNullability, JdbcColumn, JdbcRow, JdbcValue, JdbcValueType}; + + use super::{ + list_columns_query, list_databases_query, list_tables_query, map_columns, map_databases, + map_schemas, map_tables, quote_identifier, table_preview_sql, + }; + + #[test] + fn dm_identifiers_use_double_quotes_and_escape_embedded_quotes() { + assert_eq!( + quote_identifier("sales\"2026", "schemaName").expect("identifier must quote"), + "\"sales\"\"2026\"" + ); + assert!(quote_identifier("", "schemaName").is_err()); + assert!(quote_identifier("bad\nname", "tableName").is_err()); + assert!(quote_identifier(&"x".repeat(129), "tableName").is_err()); + } + + #[test] + fn dm_table_preview_is_schema_qualified_and_bounded() { + assert_eq!( + table_preview_sql("APP", "ORDER", 200).expect("preview SQL must build"), + "SELECT * FROM \"APP\".\"ORDER\" LIMIT 200" + ); + assert!(table_preview_sql("APP", "ORDER", 0).is_err()); + assert!(table_preview_sql("APP", "ORDER", 1_001).is_err()); + } + + #[test] + fn dm_catalog_queries_bind_request_values() { + let database_query = list_databases_query(); + assert_eq!( + database_query.sql, + "SELECT NAME AS DATABASE_NAME FROM V$DATABASE" + ); + assert!(database_query.parameters.is_empty()); + + let table_query = list_tables_query("APP' OR 1=1 --", "Order_%") + .expect("quoted schema names are valid metadata values"); + assert!(!table_query.sql.contains("APP' OR 1=1 --")); + assert_eq!(table_query.parameters.len(), 2); + assert!(matches!( + &table_query.parameters[0].value, + JdbcValue::Text(value) if value == "APP' OR 1=1 --" + )); + assert!(matches!( + &table_query.parameters[1].value, + JdbcValue::Text(value) if value == "Order_%" + )); + + let column_query = list_columns_query("APP", "ORDER").expect("column query must build"); + assert_eq!(column_query.parameters.len(), 2); + assert!(column_query.sql.contains("ALL_TAB_COLUMNS")); + assert!(column_query.sql.contains("ALL_CONSTRAINTS")); + } + + #[test] + fn dm_database_mapping_uses_the_live_catalog_name() { + let columns = vec![column("DATABASE_NAME")]; + let rows = vec![row(vec![JdbcValue::Text("DAMENG".to_owned())])]; + let mapped = map_databases(&columns, &rows).expect("database must map"); + assert_eq!(mapped.items.len(), 1); + assert_eq!(mapped.items[0].name, "DAMENG"); + } + + #[test] + fn dm_schema_mapping_marks_known_system_schemas() { + let columns = vec![column("SCHEMA_NAME")]; + let rows = vec![ + row(vec![JdbcValue::Text("SYS".to_owned())]), + row(vec![JdbcValue::Text("APP".to_owned())]), + ]; + let mapped = map_schemas("DMDB", &columns, &rows).expect("schemas must map"); + assert_eq!(mapped.items.len(), 2); + assert!(mapped.items[0].system); + assert!(!mapped.items[1].system); + assert_eq!(mapped.items[1].database_name, "DMDB"); + assert_eq!(mapped.items[1].owner, "APP"); + } + + #[test] + fn dm_table_mapping_preserves_comment_tablespace_and_estimated_rows() { + let columns = columns(&[ + "SCHEMA_NAME", + "TABLE_NAME", + "TABLE_TYPE", + "TABLE_COMMENT", + "TABLESPACE_NAME", + "ROW_COUNT", + ]); + let rows = vec![row(vec![ + JdbcValue::Text("APP".to_owned()), + JdbcValue::Text("ORDERS".to_owned()), + JdbcValue::Text("TABLE".to_owned()), + JdbcValue::Text("sales orders".to_owned()), + JdbcValue::Text("MAIN".to_owned()), + JdbcValue::Decimal("42".to_owned()), + ])]; + let mapped = map_tables("DMDB", "APP", &columns, &rows).expect("tables must map"); + let table = &mapped.items[0]; + assert_eq!(table.name, "ORDERS"); + assert_eq!(table.schema_name, "APP"); + assert_eq!(table.comment, "sales orders"); + assert_eq!(table.tablespace, "MAIN"); + assert_eq!(table.rows.as_deref(), Some("42")); + assert_eq!(table.database_type, "DM"); + } + + #[test] + fn dm_column_mapping_preserves_type_nullability_and_primary_key_order() { + let columns = columns(&[ + "COLUMN_NAME", + "DATA_TYPE", + "DATA_DEFAULT", + "COLUMN_COMMENT", + "IS_NULLABLE", + "ORDINAL_POSITION", + "DATA_LENGTH", + "DATA_PRECISION", + "DATA_SCALE", + "PRIMARY_KEY_NAME", + "PRIMARY_KEY_ORDER", + ]); + let rows = vec![row(vec![ + JdbcValue::Text("ID".to_owned()), + JdbcValue::Text("BIGINT".to_owned()), + JdbcValue::Null, + JdbcValue::Text("identifier".to_owned()), + JdbcValue::Text("N".to_owned()), + JdbcValue::SignedInteger(1), + JdbcValue::SignedInteger(8), + JdbcValue::SignedInteger(19), + JdbcValue::SignedInteger(0), + JdbcValue::Text("PK_ORDERS".to_owned()), + JdbcValue::SignedInteger(1), + ])]; + let mapped = + map_columns("DMDB", "APP", "ORDERS", &columns, &rows).expect("columns must map"); + let column = &mapped.items[0]; + assert_eq!(column.name, "ID"); + assert_eq!(column.data_type, Some(-5)); + assert_eq!(column.column_size, Some(19)); + assert_eq!(column.nullable, Some(0)); + assert_eq!(column.primary_key, Some(true)); + assert_eq!(column.primary_key_name, "PK_ORDERS"); + assert_eq!(column.primary_key_order, 1); + } + + fn columns(labels: &[&str]) -> Vec { + labels.iter().map(|label| column(label)).collect() + } + + fn column(label: &str) -> JdbcColumn { + JdbcColumn { + ordinal: 1, + label: label.to_owned(), + name: label.to_owned(), + jdbc_type: 12, + jdbc_type_name: "VARCHAR".to_owned(), + value_type: JdbcValueType::Text, + nullability: ColumnNullability::Unknown, + precision: None, + scale: None, + display_size: None, + signed: None, + catalog_name: None, + schema_name: None, + table_name: None, + } + } + + fn row(values: Vec) -> JdbcRow { + JdbcRow { values } + } +} diff --git a/crates/chat2db-core/src/native_driver.rs b/crates/chat2db-core/src/native_driver.rs index cc01d48..1265dd9 100644 --- a/crates/chat2db-core/src/native_driver.rs +++ b/crates/chat2db-core/src/native_driver.rs @@ -18,6 +18,7 @@ use crate::{ AdministrationCapability, AdministrationCommand, AdministrationExecution, AdministrationPreview, PrincipalGrantList, PrincipalGrantsRequest, PrincipalList, }, + native_dm, native_driver_types::{ BuiltSql, ColumnList, CreateSchemaSqlRequest, DatabaseList, DmlSqlRequest, EntityRelationTable, ForeignKeyList, FunctionList, FunctionMetadata, FunctionParameterList, @@ -347,7 +348,9 @@ pub(crate) trait NativeSchemaDiffDriver: Send + Sync { pub(crate) trait NativeDriver: Send + Sync { fn descriptor(&self) -> &'static NativeDriverDescriptor; - fn connection(&self) -> &dyn NativeConnectionDriver; + fn connection(&self) -> Option<&dyn NativeConnectionDriver> { + None + } fn query(&self) -> Option<&dyn NativeQueryDriver> { None @@ -390,7 +393,7 @@ pub(crate) struct NativeDriverRegistry { impl NativeDriverRegistry { pub(crate) fn built_in() -> Self { - Self::try_new(vec![Arc::new(MysqlNativeDriver)]) + Self::try_new(vec![Arc::new(MysqlNativeDriver), Arc::new(DmNativeDriver)]) .expect("built-in native drivers must have unique identities") } @@ -458,6 +461,15 @@ impl NativeDriverRegistry { self.drivers.iter().map(|driver| driver.descriptor()) } + pub(crate) fn standalone_descriptors( + &self, + ) -> impl Iterator + '_ { + self.drivers + .iter() + .filter(|driver| driver.connection().is_some()) + .map(|driver| driver.descriptor()) + } + /// Resolves a persisted datasource driver ID to its native implementation. pub(crate) fn driver_for_datasource_driver_id( &self, @@ -494,7 +506,116 @@ impl NativeDriverRegistry { } } +impl Application { + /// Lists databases through the runtime-selected Rust Driver SPI. + /// + /// # Errors + /// + /// Returns driver selection, capability, datasource, JDBC, or metadata errors. + pub async fn list_native_databases( + &self, + database_type: &str, + request: ListDatabasesRequest, + ) -> Result { + let driver = self.require_native_driver_capability(database_type)?; + let metadata = driver + .metadata() + .ok_or_else(|| native_capability_not_supported(database_type, "database metadata"))?; + metadata.list_databases(self, request).await + } + + /// Lists schemas through the runtime-selected Rust Driver SPI. + /// + /// # Errors + /// + /// Returns driver selection, capability, datasource, JDBC, or metadata errors. + pub async fn list_native_schemas( + &self, + database_type: &str, + request: ListSchemasRequest, + ) -> Result { + let driver = self.require_native_driver_capability(database_type)?; + let metadata = driver + .metadata() + .ok_or_else(|| native_capability_not_supported(database_type, "schema metadata"))?; + metadata.list_schemas(self, request).await + } + + /// Lists tables through the runtime-selected Rust Driver SPI. + /// + /// # Errors + /// + /// Returns driver selection, capability, datasource, JDBC, or metadata errors. + pub async fn list_native_tables( + &self, + database_type: &str, + request: ListTablesRequest, + ) -> Result { + let driver = self.require_native_driver_capability(database_type)?; + let metadata = driver + .metadata() + .ok_or_else(|| native_capability_not_supported(database_type, "table metadata"))?; + metadata.list_tables(self, request).await + } + + /// Lists table columns through the runtime-selected Rust Driver SPI. + /// + /// # Errors + /// + /// Returns driver selection, capability, datasource, JDBC, or metadata errors. + pub async fn list_native_columns( + &self, + database_type: &str, + request: ListColumnsRequest, + ) -> Result { + let driver = self.require_native_driver_capability(database_type)?; + let metadata = driver + .metadata() + .ok_or_else(|| native_capability_not_supported(database_type, "column metadata"))?; + metadata.list_columns(self, request).await + } + + /// Starts a bounded table preview through the runtime-selected Rust Driver SPI. + /// + /// # Errors + /// + /// Returns driver selection, capability, validation, datasource, or query errors. + pub async fn start_native_table_preview( + &self, + database_type: &str, + request: TablePreviewRequest, + row_limit: u32, + ) -> Result { + let driver = self.require_native_driver_capability(database_type)?; + let tables = driver + .tables() + .ok_or_else(|| native_capability_not_supported(database_type, "table preview"))?; + tables.start_table_preview(self, request, row_limit).await + } + + fn require_native_driver_capability( + &self, + database_type: &str, + ) -> Result, AppError> { + self.native_driver_for_database_type(database_type) + .ok_or_else(|| { + AppError::invalid( + "native_driver_not_available", + format!("No Rust Driver SPI implementation is registered for {database_type}"), + ) + }) + } +} + +pub(crate) fn native_capability_not_supported(database_type: &str, capability: &str) -> AppError { + AppError::invalid( + "native_driver_capability_not_supported", + format!("The {database_type} driver does not implement {capability}"), + ) +} + struct MysqlNativeDriver; +struct DmNativeDriver; const MYSQL_DRIVER_DESCRIPTOR: NativeDriverDescriptor = NativeDriverDescriptor { id: "mysql", @@ -508,8 +629,8 @@ impl NativeDriver for MysqlNativeDriver { &MYSQL_DRIVER_DESCRIPTOR } - fn connection(&self) -> &dyn NativeConnectionDriver { - self + fn connection(&self) -> Option<&dyn NativeConnectionDriver> { + Some(self) } fn query(&self) -> Option<&dyn NativeQueryDriver> { @@ -545,6 +666,232 @@ impl NativeDriver for MysqlNativeDriver { } } +impl NativeDriver for DmNativeDriver { + fn descriptor(&self) -> &'static NativeDriverDescriptor { + &native_dm::DM_DRIVER_DESCRIPTOR + } + + fn metadata(&self) -> Option<&dyn NativeMetadataDriver> { + Some(self) + } + + fn tables(&self) -> Option<&dyn NativeTableDriver> { + Some(self) + } +} + +#[async_trait] +impl NativeMetadataDriver for DmNativeDriver { + async fn list_schemas( + &self, + application: &Application, + request: ListSchemasRequest, + ) -> Result { + native_dm::list_schemas(application, &request.datasource_id, &request.database_name).await + } + + async fn list_databases( + &self, + application: &Application, + request: ListDatabasesRequest, + ) -> Result { + native_dm::list_databases(application, &request.datasource_id).await + } + + async fn list_tables( + &self, + application: &Application, + request: ListTablesRequest, + ) -> Result { + native_dm::list_tables( + application, + &request.scope.datasource_id, + &request.scope.database_name, + &request.scope.schema_name, + &request.name_pattern, + ) + .await + } + + async fn list_columns( + &self, + application: &Application, + request: ListColumnsRequest, + ) -> Result { + native_dm::list_columns( + application, + &request.table.scope.datasource_id, + &request.table.scope.database_name, + &request.table.scope.schema_name, + &request.table.table_name, + ) + .await + } + + async fn list_indexes( + &self, + _application: &Application, + _request: ListIndexesRequest, + ) -> Result { + Err(dm_capability_not_supported("index metadata")) + } + + async fn list_views( + &self, + _application: &Application, + _request: ListViewsRequest, + ) -> Result { + Err(dm_capability_not_supported("view metadata")) + } + + async fn get_view( + &self, + _application: &Application, + _request: MetadataObjectRef, + ) -> Result { + Err(dm_capability_not_supported("view detail metadata")) + } + + async fn list_imported_keys( + &self, + _application: &Application, + _request: ListTableKeysRequest, + ) -> Result { + Err(dm_capability_not_supported("imported key metadata")) + } + + async fn list_exported_keys( + &self, + _application: &Application, + _request: ListTableKeysRequest, + ) -> Result { + Err(dm_capability_not_supported("exported key metadata")) + } + + async fn list_primary_keys( + &self, + _application: &Application, + _request: ListTableKeysRequest, + ) -> Result { + Err(dm_capability_not_supported("primary key metadata")) + } + + async fn list_functions( + &self, + _application: &Application, + _request: ListRoutinesRequest, + ) -> Result { + Err(dm_capability_not_supported("function metadata")) + } + + async fn get_function( + &self, + _application: &Application, + _request: MetadataObjectRef, + ) -> Result { + Err(dm_capability_not_supported("function detail metadata")) + } + + async fn list_function_parameters( + &self, + _application: &Application, + _request: MetadataObjectRef, + ) -> Result { + Err(dm_capability_not_supported("function parameter metadata")) + } + + async fn list_procedures( + &self, + _application: &Application, + _request: ListRoutinesRequest, + ) -> Result { + Err(dm_capability_not_supported("procedure metadata")) + } + + async fn get_procedure( + &self, + _application: &Application, + _request: MetadataObjectRef, + ) -> Result { + Err(dm_capability_not_supported("procedure detail metadata")) + } + + async fn list_procedure_parameters( + &self, + _application: &Application, + _request: MetadataObjectRef, + ) -> Result { + Err(dm_capability_not_supported("procedure parameter metadata")) + } + + async fn list_triggers( + &self, + _application: &Application, + _request: ListTriggersRequest, + ) -> Result { + Err(dm_capability_not_supported("trigger metadata")) + } + + async fn get_trigger( + &self, + _application: &Application, + _request: MetadataObjectRef, + ) -> Result { + Err(dm_capability_not_supported("trigger detail metadata")) + } +} + +#[async_trait] +impl NativeTableDriver for DmNativeDriver { + async fn load_er_tables( + &self, + _application: &Application, + _datasource_id: &str, + _database_name: &str, + _schema_name: &str, + ) -> Result, AppError> { + Err(dm_capability_not_supported("entity relation metadata")) + } + + async fn validate_column_reorder( + &self, + _application: &Application, + _datasource_id: &str, + _database_name: &str, + _table_name: &str, + _column_names: &[String], + ) -> Result<(), AppError> { + Err(dm_capability_not_supported("column reordering")) + } + + async fn table_ddl( + &self, + _application: &Application, + _datasource_id: &str, + _database_name: &str, + _schema_name: &str, + _table_name: &str, + ) -> Result { + Err(dm_capability_not_supported("table DDL")) + } + + async fn start_table_preview( + &self, + application: &Application, + request: TablePreviewRequest, + row_limit: u32, + ) -> Result { + native_dm::start_table_preview(application, request, row_limit).await + } +} + +fn dm_capability_not_supported(capability: &'static str) -> AppError { + AppError::invalid( + "native_driver_capability_not_supported", + format!("The DM driver does not implement {capability}"), + ) +} + #[async_trait] impl NativeConnectionDriver for MysqlNativeDriver { async fn test_connection(&self, connection: &DatasourceConnection) -> Result<(), AppError> { @@ -1106,8 +1453,8 @@ mod tests { &FAKE_POSTGRES_DESCRIPTOR } - fn connection(&self) -> &dyn NativeConnectionDriver { - self + fn connection(&self) -> Option<&dyn NativeConnectionDriver> { + Some(self) } fn dialect(&self) -> Option<&dyn NativeDialectDriver> { @@ -1120,8 +1467,8 @@ mod tests { self.0 } - fn connection(&self) -> &dyn NativeConnectionDriver { - self + fn connection(&self) -> Option<&dyn NativeConnectionDriver> { + Some(self) } } @@ -1235,8 +1582,8 @@ mod tests { &DESCRIPTOR } - fn connection(&self) -> &dyn NativeConnectionDriver { - self + fn connection(&self) -> Option<&dyn NativeConnectionDriver> { + Some(self) } } diff --git a/crates/chat2db-core/src/native_driver_types.rs b/crates/chat2db-core/src/native_driver_types.rs index 09edda9..1e0e0f3 100644 --- a/crates/chat2db-core/src/native_driver_types.rs +++ b/crates/chat2db-core/src/native_driver_types.rs @@ -12,59 +12,59 @@ pub(crate) struct NativeDriverDescriptor { } #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct SchemaMetadata { - pub(crate) database_name: String, - pub(crate) name: String, - pub(crate) comment: String, - pub(crate) owner: String, - pub(crate) system: bool, +pub struct SchemaMetadata { + pub database_name: String, + pub name: String, + pub comment: String, + pub owner: String, + pub system: bool, } #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct SchemaList { - pub(crate) items: Vec, +pub struct SchemaList { + pub items: Vec, } #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct DatabaseMetadata { - pub(crate) name: String, - pub(crate) comment: String, - pub(crate) charset: String, - pub(crate) collation: String, - pub(crate) owner: String, - pub(crate) system: bool, +pub struct DatabaseMetadata { + pub name: String, + pub comment: String, + pub charset: String, + pub collation: String, + pub owner: String, + pub system: bool, } #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct DatabaseList { - pub(crate) items: Vec, +pub struct DatabaseList { + pub items: Vec, } #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct TableMetadata { - pub(crate) database_name: String, - pub(crate) schema_name: String, - pub(crate) name: String, - pub(crate) table_type: String, - pub(crate) comment: String, - pub(crate) database_type: String, - pub(crate) pinned: bool, - pub(crate) ddl: String, - pub(crate) engine: String, - pub(crate) charset: String, - pub(crate) collation: String, - pub(crate) increment_value: Option, - pub(crate) partition: String, - pub(crate) tablespace: String, - pub(crate) rows: Option, - pub(crate) data_length: Option, - pub(crate) create_time: String, - pub(crate) update_time: String, +pub struct TableMetadata { + pub database_name: String, + pub schema_name: String, + pub name: String, + pub table_type: String, + pub comment: String, + pub database_type: String, + pub pinned: bool, + pub ddl: String, + pub engine: String, + pub charset: String, + pub collation: String, + pub increment_value: Option, + pub partition: String, + pub tablespace: String, + pub rows: Option, + pub data_length: Option, + pub create_time: String, + pub update_time: String, } #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct TableList { - pub(crate) items: Vec, +pub struct TableList { + pub items: Vec, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -73,43 +73,43 @@ pub(crate) struct ViewList { } #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct ColumnMetadata { - pub(crate) database_name: String, - pub(crate) schema_name: String, - pub(crate) table_name: String, - pub(crate) name: String, - pub(crate) column_type: String, - pub(crate) data_type: Option, - pub(crate) default_value: Option, - pub(crate) auto_increment: Option, - pub(crate) comment: String, - pub(crate) primary_key: Option, - pub(crate) primary_key_name: String, - pub(crate) primary_key_order: i32, - pub(crate) column_size: Option, - pub(crate) buffer_length: Option, - pub(crate) decimal_digits: Option, - pub(crate) num_prec_radix: Option, - pub(crate) sql_data_type: Option, - pub(crate) sql_datetime_sub: Option, - pub(crate) char_octet_length: Option, - pub(crate) ordinal_position: Option, - pub(crate) nullable: Option, - pub(crate) generated_column: Option, - pub(crate) extent: String, - pub(crate) charset: String, - pub(crate) collation: String, - pub(crate) unit: String, - pub(crate) sparse: Option, - pub(crate) default_constraint_name: String, - pub(crate) seed: Option, - pub(crate) increment: Option, - pub(crate) on_update_current_timestamp: Option, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct ColumnList { - pub(crate) items: Vec, +pub struct ColumnMetadata { + pub database_name: String, + pub schema_name: String, + pub table_name: String, + pub name: String, + pub column_type: String, + pub data_type: Option, + pub default_value: Option, + pub auto_increment: Option, + pub comment: String, + pub primary_key: Option, + pub primary_key_name: String, + pub primary_key_order: i32, + pub column_size: Option, + pub buffer_length: Option, + pub decimal_digits: Option, + pub num_prec_radix: Option, + pub sql_data_type: Option, + pub sql_datetime_sub: Option, + pub char_octet_length: Option, + pub ordinal_position: Option, + pub nullable: Option, + pub generated_column: Option, + pub extent: String, + pub charset: String, + pub collation: String, + pub unit: String, + pub sparse: Option, + pub default_constraint_name: String, + pub seed: Option, + pub increment: Option, + pub on_update_current_timestamp: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ColumnList { + pub items: Vec, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -318,10 +318,10 @@ pub(crate) struct EntityRelationTable { } #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct TablePreviewAccepted { - pub(crate) operation_id: String, - pub(crate) sql: String, - pub(crate) row_limit: u32, +pub struct TablePreviewAccepted { + pub operation_id: String, + pub sql: String, + pub row_limit: u32, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -474,16 +474,16 @@ pub(crate) enum DmlStatement { } #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct MetadataScope { - pub(crate) datasource_id: String, - pub(crate) database_name: String, - pub(crate) schema_name: String, +pub struct MetadataScope { + pub datasource_id: String, + pub database_name: String, + pub schema_name: String, } #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct TableRef { - pub(crate) scope: MetadataScope, - pub(crate) table_name: String, +pub struct TableRef { + pub scope: MetadataScope, + pub table_name: String, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -493,25 +493,25 @@ pub(crate) struct MetadataObjectRef { } #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ListDatabasesRequest { - pub(crate) datasource_id: String, +pub struct ListDatabasesRequest { + pub datasource_id: String, } #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ListSchemasRequest { - pub(crate) datasource_id: String, - pub(crate) database_name: String, +pub struct ListSchemasRequest { + pub datasource_id: String, + pub database_name: String, } #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ListTablesRequest { - pub(crate) scope: MetadataScope, - pub(crate) name_pattern: String, +pub struct ListTablesRequest { + pub scope: MetadataScope, + pub name_pattern: String, } #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ListColumnsRequest { - pub(crate) table: TableRef, +pub struct ListColumnsRequest { + pub table: TableRef, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -541,8 +541,8 @@ pub(crate) struct ListTriggersRequest { } #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct TablePreviewRequest { - pub(crate) table: TableRef, +pub struct TablePreviewRequest { + pub table: TableRef, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/chat2db-core/src/query.rs b/crates/chat2db-core/src/query.rs index 78d8e35..3451962 100644 --- a/crates/chat2db-core/src/query.rs +++ b/crates/chat2db-core/src/query.rs @@ -16,7 +16,7 @@ use tokio_util::sync::CancellationToken; use crate::{ AppError, AppErrorKind, Application, convert, datasource_session::{ - ResolvedDatasourceConnection, SessionReadOnly, open_datasource_session, + ResolvedDatasourceConnection, SessionReadOnly, open_mapped_datasource_session, resolve_datasource_connection, }, engine_manager::EngineLease, @@ -498,7 +498,7 @@ impl Application { } else { SessionReadOnly::Configured }; - let session = open_datasource_session(&engine, resolved, read_only).await?; + let session = open_mapped_datasource_session(self, &engine, resolved, read_only).await?; let retention = query.retention; let cancellation_request = { cancellation.borrow().clone() }; @@ -870,7 +870,7 @@ fn is_cancelled(error: &BridgeError) -> bool { ) } -async fn grant_next_credit(stream: &QueryStream) -> Result<(), AppError> { +pub(crate) async fn grant_next_credit(stream: &QueryStream) -> Result<(), AppError> { match stream.grant_credits(1).await { Ok(accepted) => validate_credit_grant(accepted), Err(error) if is_inactive_credit_grant_error(&error) => { @@ -895,7 +895,7 @@ fn validate_credit_grant(accepted: u32) -> Result<(), AppError> { )) } -async fn settle_query_stream(stream: &mut QueryStream) -> Result<(), AppError> { +pub(crate) async fn settle_query_stream(stream: &mut QueryStream) -> Result<(), AppError> { match stream .cancel(Some( "Chat2DB stopped the query after local result handling failed".to_owned(), diff --git a/crates/chat2db-core/src/ssh.rs b/crates/chat2db-core/src/ssh.rs index 802c5a1..9fc0e1a 100644 --- a/crates/chat2db-core/src/ssh.rs +++ b/crates/chat2db-core/src/ssh.rs @@ -220,8 +220,13 @@ impl Application { })?; let mut forwarded = request.connection; forwarded.ssh = Some(ssh); - let local_port = driver - .connection() + let connection_driver = driver.connection().ok_or_else(|| { + AppError::invalid( + "ssh_driver_not_supported", + "SSH forwarding requires a native Rust connection driver", + ) + })?; + let local_port = connection_driver .test_connection_with_local_port(&forwarded) .await? .ok_or_else(AppError::internal)?; diff --git a/crates/chat2db-core/tests/java_dm_driver_pack.rs b/crates/chat2db-core/tests/java_dm_driver_pack.rs new file mode 100644 index 0000000..b1250df --- /dev/null +++ b/crates/chat2db-core/tests/java_dm_driver_pack.rs @@ -0,0 +1,261 @@ +use std::{path::PathBuf, time::Duration}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use chat2db_contract::{ + CreateDatasourceRequest, DatasourceConnection, DatasourceConnectionProperty, JdbcDriver, + JdbcValue, OperationEvent, QueryLimits, ResultPageRequest, StartQueryRequest, +}; +use chat2db_core::{Application, RuntimeConfig, RuntimeHost}; +use chat2db_java_bridge::{EngineCommand, EngineConfig}; +use tempfile::TempDir; + +const DM_DRIVER_CLASS: &str = "dm.jdbc.driver.DmDriver"; +const DM_DRIVER_VERSION: &str = "8.1.2.141"; +const EVENT_TIMEOUT: Duration = Duration::from_secs(20); +const REQUIRED_LIVE_ENV: [&str; 4] = [ + "DM_TEST_HOST", + "DM_TEST_PORT", + "DM_TEST_USER", + "DM_TEST_PASSWORD", +]; + +struct DmHarness { + _directory: TempDir, + host: RuntimeHost, + application: Application, + driver: JdbcDriver, +} + +struct LiveDmConfig { + jdbc_url: String, + user: String, + password: String, +} + +impl DmHarness { + async fn start() -> Self { + let engine_jar = required_file("CHAT2DB_JAVA_ENGINE_JAR"); + let driver_pack_dir = required_directory("DM_TEST_DRIVER_PACK_DIR"); + let directory = TempDir::new().expect("temporary DM product directory"); + let engine = EngineConfig::new(EngineCommand::java_jar("java", engine_jar)).with_timeouts( + Duration::from_secs(15), + Duration::from_secs(15), + Duration::from_secs(5), + ); + let runtime = RuntimeConfig::new(engine) + .with_data_dir(directory.path().join("data")) + .with_driver_pack_dir(driver_pack_dir) + .with_vault_master_key_base64(STANDARD.encode([0x44; 32])); + let host = RuntimeHost::open(runtime) + .await + .expect("runtime must discover the managed DM driver pack"); + let application = host.application(); + let mut matches = application + .list_drivers() + .items + .into_iter() + .filter(|driver| driver.pack_id == "dm"); + let driver = matches + .next() + .expect("managed DM driver must be present in the inventory"); + assert!(matches.next().is_none(), "DM driver pack must be unique"); + assert_eq!(driver.name, "DM"); + assert_eq!(driver.version, DM_DRIVER_VERSION); + assert_eq!(driver.driver_class, DM_DRIVER_CLASS); + assert_eq!(driver.artifact_count, 1); + assert_eq!(driver.artifact_bytes, "1030636"); + assert!(driver.driver_id.starts_with("sha256:")); + + let lease = host + .acquire_engine() + .await + .expect("Java 17 must load the managed DM driver class"); + drop(lease); + + Self { + _directory: directory, + host, + application, + driver, + } + } + + async fn finish(mut self) { + self.host + .shutdown() + .await + .expect("DM driver runtime must shut down cleanly"); + } +} + +impl LiveDmConfig { + fn from_environment() -> Option { + let configured = REQUIRED_LIVE_ENV + .iter() + .filter(|name| std::env::var_os(name).is_some()) + .count(); + let required = std::env::var("DM_TEST_REQUIRED").is_ok_and(|value| value == "1"); + if configured == 0 { + assert!( + !required, + "DM_TEST_REQUIRED is enabled but the live DM endpoint variables are absent" + ); + eprintln!("skipping live DM connection/query; DM_TEST_* endpoint variables are absent"); + return None; + } + assert_eq!( + configured, + REQUIRED_LIVE_ENV.len(), + "live DM integration is partially configured; set every required DM_TEST_* variable" + ); + + let host = required_text("DM_TEST_HOST"); + assert!( + !host.trim().is_empty() + && !host.chars().any(char::is_control) + && !host.contains(['/', '?', '#']), + "DM_TEST_HOST must contain only a JDBC host name or address" + ); + let port = required_text("DM_TEST_PORT") + .parse::() + .expect("DM_TEST_PORT must be a decimal TCP port"); + assert_ne!(port, 0, "DM_TEST_PORT cannot be zero"); + let jdbc_url = std::env::var("DM_TEST_JDBC_URL") + .unwrap_or_else(|_| format!("jdbc:dm://{host}:{port}")); + assert!( + jdbc_url.starts_with("jdbc:dm://") && !jdbc_url.chars().any(char::is_control), + "DM_TEST_JDBC_URL must be a valid DM JDBC URL" + ); + + Some(Self { + jdbc_url, + user: required_text("DM_TEST_USER"), + password: required_text("DM_TEST_PASSWORD"), + }) + } + + fn connection(&self) -> DatasourceConnection { + DatasourceConnection { + jdbc_url: self.jdbc_url.clone(), + properties: vec![ + DatasourceConnectionProperty { + key: "user".to_owned(), + value: self.user.clone(), + sensitive: false, + }, + DatasourceConnectionProperty { + key: "password".to_owned(), + value: self.password.clone(), + sensitive: true, + }, + ], + read_only: true, + ssh: None, + } + } +} + +#[tokio::test] +async fn managed_dm_pack_is_discovered_and_loaded_without_community_metadata() { + DmHarness::start().await.finish().await; +} + +#[tokio::test] +async fn managed_dm_pack_connects_and_streams_a_query_when_endpoint_is_configured() { + let Some(config) = LiveDmConfig::from_environment() else { + return; + }; + let harness = DmHarness::start().await; + harness + .application + .test_datasource_connection(&harness.driver.driver_id, config.connection()) + .await + .expect("managed DM driver must open and close a real JDBC connection"); + let datasource = harness + .application + .create_datasource(CreateDatasourceRequest { + name: "DM JDBC integration".to_owned(), + driver_id: harness.driver.driver_id.clone(), + connection: Some(config.connection()), + }) + .await + .expect("DM datasource must be persisted through the generic datasource contract"); + let accepted = harness + .application + .start_query(StartQueryRequest { + datasource_id: datasource.id, + sql: "SELECT CAST(1 AS BIGINT) AS ID, 'dm-ok' AS LABEL FROM DUAL".to_owned(), + parameters: Vec::new(), + limits: QueryLimits { + max_rows: "10".to_owned(), + max_result_bytes: (1024_u64 * 1024).to_string(), + batch_rows: 16, + batch_bytes: 16 * 1024, + result_ttl_seconds: 60, + }, + }) + .await + .expect("DM query must be accepted through the generic JDBC product path"); + let result_id = wait_for_result(&harness.application, &accepted.operation_id).await; + let page = harness + .application + .result_page( + &result_id, + ResultPageRequest { + offset: "0".to_owned(), + max_rows: "10".to_owned(), + max_bytes: (1024_u64 * 1024).to_string(), + }, + ) + .await + .expect("DM retained result must be readable"); + assert_eq!(page.rows.len(), 1); + assert!(matches!( + page.rows[0].values.as_slice(), + [JdbcValue::SignedInteger { value: id }, JdbcValue::Text { value: label }] + if id == "1" && label == "dm-ok" + )); + harness.finish().await; +} + +async fn wait_for_result(application: &Application, operation_id: &str) -> String { + let mut events = application + .subscribe_operation(operation_id, Some(0)) + .await + .expect("DM operation subscription must open"); + loop { + let event = tokio::time::timeout(EVENT_TIMEOUT, events.next_event()) + .await + .expect("DM operation event must arrive") + .expect("DM operation event stream must remain valid") + .expect("DM operation must emit a terminal event"); + match event.event { + OperationEvent::Started | OperationEvent::Progress { .. } => {} + OperationEvent::Completed { result } => return result.id, + OperationEvent::Failed { error } => panic!("DM query failed: {error:?}"), + OperationEvent::Cancelled { reason } => panic!("DM query was cancelled: {reason:?}"), + } + } +} + +fn required_file(variable: &str) -> PathBuf { + let path = std::env::var_os(variable).map_or_else( + || panic!("{variable} must point to a packaged JAR"), + PathBuf::from, + ); + assert!(path.is_file(), "{variable} does not point to a file"); + path +} + +fn required_directory(variable: &str) -> PathBuf { + let path = std::env::var_os(variable).map_or_else( + || panic!("{variable} must point to a driver-pack directory"), + PathBuf::from, + ); + assert!(path.is_dir(), "{variable} does not point to a directory"); + path +} + +fn required_text(variable: &str) -> String { + std::env::var(variable).unwrap_or_else(|_| panic!("{variable} must be configured")) +} diff --git a/crates/chat2db-core/tests/java_dm_product.rs b/crates/chat2db-core/tests/java_dm_product.rs new file mode 100644 index 0000000..dbe6cfe --- /dev/null +++ b/crates/chat2db-core/tests/java_dm_product.rs @@ -0,0 +1,613 @@ +use std::{panic::AssertUnwindSafe, path::PathBuf, time::Duration}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use chat2db_contract::{ + ComponentState, CreateDatasourceRequest, Datasource, DatasourceConnection, + DatasourceConnectionProperty, JdbcDriver, JdbcValue, NativeDriverAction, OperationEvent, + ResultPageRequest, +}; +use chat2db_core::{ + Application, ListColumnsRequest, ListDatabasesRequest, ListSchemasRequest, ListTablesRequest, + MetadataScope, RuntimeConfig, RuntimeHost, TablePreviewRequest, TableRef, +}; +use chat2db_java_bridge::{ + BridgeError, ConnectionProperty, DriverClient, EngineCommand, EngineConfig, Session, + SessionConfig, UpdateRequest, +}; +use futures_util::FutureExt as _; +use tempfile::TempDir; +use uuid::Uuid; + +const DM_DATABASE_TYPE: &str = "DM"; +const DM_DRIVER_CLASS: &str = "dm.jdbc.driver.DmDriver"; +const DM_DRIVER_VERSION: &str = "8.1.2.141"; +const EVENT_TIMEOUT: Duration = Duration::from_secs(30); +const DM_RUNTIME_PREREQUISITE_ENV: [&str; 2] = + ["CHAT2DB_JAVA_ENGINE_JAR", "DM_TEST_DRIVER_PACK_DIR"]; +const REQUIRED_DM_ENDPOINT_ENV: [&str; 4] = [ + "DM_TEST_HOST", + "DM_TEST_PORT", + "DM_TEST_USER", + "DM_TEST_PASSWORD", +]; + +struct DmProductHarness { + _directory: TempDir, + host: RuntimeHost, + application: Application, +} + +struct DmTestConfig { + user: String, + password: String, + jdbc_url: String, + database_name: String, + schema_name: String, +} + +struct DmFixture { + datasource: Datasource, + session: Session, + schema_name: String, + table_name: String, +} + +impl DmProductHarness { + async fn start() -> Self { + let engine_jar = required_file("CHAT2DB_JAVA_ENGINE_JAR"); + let driver_pack_dir = required_directory("DM_TEST_DRIVER_PACK_DIR"); + let directory = TempDir::new().expect("temporary DM product directory"); + let engine = EngineConfig::new(EngineCommand::java_jar("java", engine_jar)).with_timeouts( + Duration::from_secs(20), + Duration::from_secs(20), + Duration::from_secs(10), + ); + let runtime = RuntimeConfig::new(engine) + .with_data_dir(directory.path().join("data")) + .with_driver_pack_dir(driver_pack_dir) + .with_vault_master_key_base64(STANDARD.encode([0x44; 32])); + let host = RuntimeHost::open(runtime) + .await + .expect("DM product runtime must discover the managed driver pack"); + let application = host.application(); + Self { + _directory: directory, + host, + application, + } + } + + async fn shutdown(mut self) { + self.host + .shutdown() + .await + .expect("DM product runtime must shut down cleanly"); + } +} + +impl DmTestConfig { + fn from_environment() -> Option { + let required = dm_test_required(); + let configured = REQUIRED_DM_ENDPOINT_ENV + .iter() + .filter(|name| std::env::var_os(name).is_some()) + .count(); + if configured == 0 { + assert!( + !required, + "DM_TEST_REQUIRED is enabled but the real DM endpoint variables are absent" + ); + eprintln!( + "skipping real DM product integration test; DM_TEST_* endpoint variables are absent" + ); + return None; + } + assert_eq!( + configured, + REQUIRED_DM_ENDPOINT_ENV.len(), + "real DM integration is partially configured; set every required DM_TEST_* endpoint variable" + ); + + let host = required_text("DM_TEST_HOST"); + assert!( + !host.trim().is_empty() + && !host.chars().any(char::is_control) + && !host.contains(['/', '?', '#']), + "DM_TEST_HOST must contain only a JDBC host name or address" + ); + let port = required_text("DM_TEST_PORT") + .parse::() + .expect("DM_TEST_PORT must be a decimal TCP port"); + assert_ne!(port, 0, "DM_TEST_PORT cannot be zero"); + let user = required_text("DM_TEST_USER"); + assert!(!user.is_empty(), "DM_TEST_USER cannot be empty"); + let jdbc_host = if host.contains(':') && !(host.starts_with('[') && host.ends_with(']')) { + format!("[{host}]") + } else { + host.clone() + }; + let jdbc_url = std::env::var("DM_TEST_JDBC_URL") + .unwrap_or_else(|_| format!("jdbc:dm://{jdbc_host}:{port}/")); + assert!( + jdbc_url.starts_with("jdbc:dm://") && !jdbc_url.chars().any(char::is_control), + "DM_TEST_JDBC_URL must be a valid DM JDBC URL" + ); + let database_name = optional_text("DM_TEST_DATABASE").unwrap_or_default(); + assert!( + !database_name.chars().any(char::is_control), + "DM_TEST_DATABASE cannot contain control characters" + ); + let schema_name = + optional_text("DM_TEST_SCHEMA").unwrap_or_else(|| user.to_ascii_uppercase()); + assert_identifier(&schema_name, "DM_TEST_SCHEMA"); + + Some(Self { + user, + password: required_text("DM_TEST_PASSWORD"), + jdbc_url, + database_name, + schema_name, + }) + } + + fn connection(&self) -> DatasourceConnection { + DatasourceConnection { + jdbc_url: self.jdbc_url.clone(), + properties: vec![ + DatasourceConnectionProperty { + key: "user".to_owned(), + value: self.user.clone(), + sensitive: false, + }, + DatasourceConnectionProperty { + key: "password".to_owned(), + value: self.password.clone(), + sensitive: true, + }, + ], + read_only: false, + ssh: None, + } + } + + fn bridge_properties(&self) -> Vec { + vec![ + ConnectionProperty { + key: "user".to_owned(), + value: self.user.clone(), + sensitive: false, + }, + ConnectionProperty { + key: "password".to_owned(), + value: self.password.clone(), + sensitive: true, + }, + ] + } +} + +#[tokio::test] +async fn managed_dm_pack_maps_to_the_jdbc_backed_spi() { + if !dm_runtime_prerequisites_available() { + return; + } + let harness = DmProductHarness::start().await; + let driver = managed_dm_driver(&harness.application); + verify_dm_driver_compatibility(&harness.application, &driver); + verify_legacy_compatibility_disabled(&harness.application); + harness.shutdown().await; +} + +#[tokio::test] +async fn managed_dm_pack_exercises_real_metadata_preview_and_retained_result() { + let Some(config) = DmTestConfig::from_environment() else { + return; + }; + let harness = DmProductHarness::start().await; + let driver = managed_dm_driver(&harness.application); + verify_dm_driver_compatibility(&harness.application, &driver); + verify_legacy_compatibility_disabled(&harness.application); + if let Err(error) = harness + .application + .test_datasource_connection(&driver.driver_id, config.connection()) + .await + { + harness.shutdown().await; + panic!("managed DM driver must open and close a real JDBC connection: {error}"); + } + let engine_lease = match harness.host.acquire_engine().await { + Ok(engine_lease) => engine_lease, + Err(error) => { + harness.shutdown().await; + panic!("real DM integration must start the Java engine: {error}"); + } + }; + let driver_client = engine_lease + .driver_client() + .expect("running Java engine must expose JDBC"); + let fixture = + match provision_fixture(&config, &harness.application, &driver_client, &driver).await { + Ok(fixture) => fixture, + Err(error) => { + drop(driver_client); + drop(engine_lease); + harness.shutdown().await; + panic!("real DM fixture setup failed: {error}"); + } + }; + let verification = + AssertUnwindSafe(verify_live_product(&config, &harness.application, &fixture)) + .catch_unwind() + .await; + let cleanup_errors = cleanup_fixture(fixture).await; + drop(driver_client); + drop(engine_lease); + harness.shutdown().await; + + if let Err(payload) = verification { + for error in cleanup_errors { + eprintln!("DM integration cleanup also failed: {error}"); + } + std::panic::resume_unwind(payload); + } + assert!( + cleanup_errors.is_empty(), + "DM integration cleanup failed: {}", + cleanup_errors.join("; ") + ); +} + +fn managed_dm_driver(application: &Application) -> JdbcDriver { + let mut drivers = application + .list_drivers() + .items + .into_iter() + .filter(|driver| driver.pack_id == "dm"); + let driver = drivers + .next() + .expect("managed DM driver pack must be discovered"); + assert!(drivers.next().is_none(), "DM driver pack must be unique"); + assert_eq!(driver.version, DM_DRIVER_VERSION); + assert_eq!(driver.driver_class, DM_DRIVER_CLASS); + assert_eq!(driver.artifact_count, 1); + assert!(driver.driver_id.starts_with("sha256:")); + driver +} + +fn verify_dm_driver_compatibility(application: &Application, driver: &JdbcDriver) { + let compatibility = application + .native_driver_compatibility(DM_DATABASE_TYPE, NativeDriverAction::Download) + .expect("DM must resolve through the JDBC-backed driver SPI"); + assert_eq!(compatibility.database_type, DM_DATABASE_TYPE); + assert_eq!(compatibility.driver_id, driver.driver_id); + assert_eq!(compatibility.implementation, "dm-jdbc"); + assert!(compatibility.artifact_required); + assert!(!compatibility.changed); +} + +fn verify_legacy_compatibility_disabled(application: &Application) { + let compatibility = application + .health() + .components + .into_iter() + .find(|component| component.id == "community-compatibility") + .expect("legacy compatibility health must be explicit"); + assert_eq!(compatibility.state, ComponentState::Disabled); +} + +async fn provision_fixture( + config: &DmTestConfig, + application: &Application, + driver_client: &DriverClient, + driver: &JdbcDriver, +) -> Result { + let datasource = application + .create_datasource(CreateDatasourceRequest { + name: "DM product integration".to_owned(), + driver_id: driver.driver_id.clone(), + connection: Some(config.connection()), + }) + .await + .map_err(|error| format!("persist DM datasource: {error}"))?; + let session = driver_client + .open_session(SessionConfig { + driver_id: driver.driver_id.clone(), + jdbc_url: config.jdbc_url.clone(), + properties: config.bridge_properties(), + read_only: false, + }) + .await + .map_err(|error| format!("open fixture session: {error}"))?; + let table_name = format!("CHAT2DB_DM_IT_{}", Uuid::new_v4().simple()).to_ascii_uppercase(); + let table = qualified_name(&config.schema_name, &table_name); + let create_sql = format!( + "CREATE TABLE {table} (\"ID\" BIGINT NOT NULL PRIMARY KEY, \"LABEL\" VARCHAR(128) NOT NULL)" + ); + if let Err(error) = try_execute_update(&session, &create_sql).await { + let close_error = session.close().await.err(); + return Err(format!( + "create fixture table: {error}; session cleanup: {close_error:?}" + )); + } + let insert_sql = format!("INSERT INTO {table} (\"ID\", \"LABEL\") VALUES (1, 'dm-fixture')"); + if let Err(error) = try_execute_update(&session, &insert_sql).await { + let drop_error = try_execute_update(&session, &format!("DROP TABLE {table}")) + .await + .err(); + let close_error = session.close().await.err(); + return Err(format!( + "insert fixture row: {error}; table cleanup: {drop_error:?}; session cleanup: {close_error:?}" + )); + } + Ok(DmFixture { + datasource, + session, + schema_name: config.schema_name.clone(), + table_name, + }) +} + +async fn verify_live_product( + config: &DmTestConfig, + application: &Application, + fixture: &DmFixture, +) { + let databases = application + .list_native_databases( + DM_DATABASE_TYPE, + ListDatabasesRequest { + datasource_id: fixture.datasource.id.clone(), + }, + ) + .await + .expect("DM driver SPI must list the current database directly"); + assert!( + !databases.items.is_empty() + && databases + .items + .iter() + .all(|database| !database.name.trim().is_empty()), + "DM database metadata must contain a named current database" + ); + + let schemas = application + .list_native_schemas( + DM_DATABASE_TYPE, + ListSchemasRequest { + datasource_id: fixture.datasource.id.clone(), + database_name: config.database_name.clone(), + }, + ) + .await + .expect("DM driver SPI must list real schemas directly"); + assert!( + schemas + .items + .iter() + .any(|schema| schema.name.eq_ignore_ascii_case(&config.schema_name)), + "DM metadata omitted configured schema {}", + config.schema_name + ); + + let tables = application + .list_native_tables( + DM_DATABASE_TYPE, + ListTablesRequest { + scope: MetadataScope { + datasource_id: fixture.datasource.id.clone(), + database_name: config.database_name.clone(), + schema_name: config.schema_name.clone(), + }, + name_pattern: fixture.table_name.clone(), + }, + ) + .await + .expect("DM driver SPI must list the fixture table directly"); + assert!( + tables + .items + .iter() + .any(|table| table.name.eq_ignore_ascii_case(&fixture.table_name)), + "DM metadata omitted fixture table {}", + fixture.table_name + ); + + let columns = application + .list_native_columns( + DM_DATABASE_TYPE, + ListColumnsRequest { + table: TableRef { + scope: MetadataScope { + datasource_id: fixture.datasource.id.clone(), + database_name: config.database_name.clone(), + schema_name: config.schema_name.clone(), + }, + table_name: fixture.table_name.clone(), + }, + }, + ) + .await + .expect("DM driver SPI must list fixture columns directly"); + for expected in ["ID", "LABEL"] { + assert!( + columns + .items + .iter() + .any(|column| column.name.eq_ignore_ascii_case(expected)), + "DM metadata omitted fixture column {expected}" + ); + } + + verify_table_preview(config, application, fixture).await; +} + +async fn verify_table_preview( + config: &DmTestConfig, + application: &Application, + fixture: &DmFixture, +) { + let table = qualified_name(&config.schema_name, &fixture.table_name); + let preview = application + .start_native_table_preview( + DM_DATABASE_TYPE, + TablePreviewRequest { + table: TableRef { + scope: MetadataScope { + datasource_id: fixture.datasource.id.clone(), + database_name: config.database_name.clone(), + schema_name: config.schema_name.clone(), + }, + table_name: fixture.table_name.clone(), + }, + }, + 1, + ) + .await + .expect("DM driver SPI must accept a bounded table preview directly"); + assert_eq!(preview.row_limit, 1); + assert!(preview.sql.contains(&table)); + assert!(preview.sql.to_ascii_uppercase().contains("LIMIT 1")); + + let result_id = wait_for_result(application, &preview.operation_id).await; + let page = application + .result_page( + &result_id, + ResultPageRequest { + offset: "0".to_owned(), + max_rows: "1".to_owned(), + max_bytes: (1024_u64 * 1024).to_string(), + }, + ) + .await + .expect("DM table preview result must be retained"); + assert_eq!(page.metadata.row_count, "1"); + assert_eq!(page.rows.len(), 1); + assert!(matches!( + page.rows[0].values.as_slice(), + [JdbcValue::SignedInteger { value: id }, JdbcValue::Text { value: label }] + if id == "1" && label == "dm-fixture" + )); +} + +async fn cleanup_fixture(fixture: DmFixture) -> Vec { + let mut errors = Vec::new(); + let table = qualified_name(&fixture.schema_name, &fixture.table_name); + if let Err(error) = try_execute_update(&fixture.session, &format!("DROP TABLE {table}")).await { + errors.push(format!("drop fixture table: {error}")); + } + if let Err(error) = fixture.session.close().await { + errors.push(format!("close fixture session: {error}")); + } + errors +} + +async fn wait_for_result(application: &Application, operation_id: &str) -> String { + let mut events = application + .subscribe_operation(operation_id, Some(0)) + .await + .expect("DM operation subscription must open"); + loop { + let event = tokio::time::timeout(EVENT_TIMEOUT, events.next_event()) + .await + .expect("DM operation event must arrive") + .expect("DM operation event stream must remain valid") + .expect("DM operation must emit a terminal event"); + match event.event { + OperationEvent::Started | OperationEvent::Progress { .. } => {} + OperationEvent::Completed { result } => return result.id, + OperationEvent::Failed { error } => panic!("DM preview failed: {error:?}"), + OperationEvent::Cancelled { reason } => { + panic!("DM preview was cancelled: {reason:?}") + } + } + } +} + +async fn try_execute_update(session: &Session, sql: &str) -> Result { + session + .execute_update(UpdateRequest { + sql: sql.to_owned(), + parameters: Vec::new(), + transaction_id: None, + }) + .await + .map(|result| result.affected_rows) +} + +fn qualified_name(schema_name: &str, object_name: &str) -> String { + format!( + "{}.{}", + quote_identifier(schema_name), + quote_identifier(object_name) + ) +} + +fn quote_identifier(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\"\"")) +} + +fn assert_identifier(value: &str, variable: &str) { + assert!( + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$' | b'#')), + "{variable} must be a non-empty DM identifier" + ); +} + +fn dm_test_required() -> bool { + match std::env::var("DM_TEST_REQUIRED") { + Err(std::env::VarError::NotPresent) => false, + Ok(value) if value == "1" || value.eq_ignore_ascii_case("true") => true, + Ok(value) if value == "0" || value.eq_ignore_ascii_case("false") => false, + Ok(_) | Err(std::env::VarError::NotUnicode(_)) => { + panic!("DM_TEST_REQUIRED must be 1, 0, true, or false") + } + } +} + +fn dm_runtime_prerequisites_available() -> bool { + let missing = DM_RUNTIME_PREREQUISITE_ENV + .iter() + .copied() + .filter(|variable| std::env::var_os(variable).is_none()) + .collect::>(); + if missing.is_empty() { + return true; + } + eprintln!( + "skipping managed DM pack integration test; missing runtime prerequisites: {}", + missing.join(", ") + ); + false +} + +fn optional_text(variable: &str) -> Option { + std::env::var(variable) + .ok() + .filter(|value| !value.is_empty()) +} + +fn required_file(variable: &str) -> PathBuf { + let path = std::env::var_os(variable).map_or_else( + || panic!("{variable} must point to a packaged JAR"), + PathBuf::from, + ); + assert!(path.is_file(), "{variable} does not point to a file"); + path +} + +fn required_directory(variable: &str) -> PathBuf { + let path = std::env::var_os(variable).map_or_else( + || panic!("{variable} must point to a driver-pack directory"), + PathBuf::from, + ); + assert!(path.is_dir(), "{variable} does not point to a directory"); + path +} + +fn required_text(variable: &str) -> String { + std::env::var(variable).unwrap_or_else(|_| panic!("{variable} must be configured")) +} diff --git a/docs/architecture.md b/docs/architecture.md index 48e90e6..9dd5614 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -95,7 +95,8 @@ real-MySQL rerun pass with this Dashboard/Chart increment included. | AI agent | Rust | Provider adapters, tool loop, limits, compaction, and cancellation | | MCP and CLI | Rust | Adapters around the same product services and policy | | Native MySQL product slice | Rust / `mysql_async` | Connection and SSH, datasource lifecycle/portability, object metadata, typed SELECT binds, editable DML/DDL, Console, Dashboard/Chart refresh, routines/migration, transfer and class generation, accounts, schema diff, workspace state, Agent/CLI/MCP writes, cancellation, large values, and historical HTTP/IPC envelopes | -| Compatibility databases and exact Community helpers | Java 17 | Existing SPI/plugins for non-MySQL databases plus Community parsing, formatting, completion, SQL builders, and plugin-specific behavior | +| Hybrid DM product slice | Rust SPI plus generic Java JDBC | Rust owns DM metadata and preview behavior; the Java engine loads only the official DM JDBC JAR and streams typed JDBC results | +| Remaining compatibility databases and exact Community helpers | Java 17 | Existing SPI/plugins for databases without a Rust-owned adapter plus Community parsing, formatting, completion, SQL builders, and plugin-specific behavior | | SQL parsing, formatting, and completion | Java 17 | Existing Java ANTLR grammars, parser behavior, formatter behavior, and completion | | Rust-to-Java IPC | Shared Protobuf contract | Length-prefixed frames over private stdin/stdout | @@ -112,15 +113,16 @@ React in system WebView React in browser <- owner-only local attachment <- rmcp stdio server <- MCP client -> SQLite dashboard/chart/workspace state and result store -> AI agent runtime - -> native MySQL connection / metadata / editable DDL / Console / chart refresh - -> Java process supervisor + -> Rust Driver SPI + -> native MySQL / mysql_async + -> DM metadata and preview adapter + -> generic JDBC session bridge + -> official dmJdbcDriver JAR + -> Java Community compatibility for unregistered database types -> Protobuf stdin/stdout - -> Java database compatibility engine - -> plugin registry - -> JDBC sessions and transactions - -> metadata and SQL operations - -> Java ANTLR parsers - -> vendor driver + -> fixed Community plugin registry + -> plugin metadata, SQL builders, and ANTLR parsers + -> isolated vendor JDBC drivers ``` Only Rust exposes product transports. Java has no listening port. JDBC @@ -150,8 +152,12 @@ cross-language acceptance gates pass. ## Database boundary -Java/JDBC remains the compatibility implementation for other databases and for -fixed Community parser, formatter, completion, builder, and plugin behavior. +For DM, the Rust Driver SPI owns database-specific behavior and the project-owned +Java process is only a generic JDBC transport for the official vendor JAR. It +does not load Community's DM or Oracle plugins. The fixed Community runtime +remains the compatibility implementation only for database types that do not +have a registered Rust-owned driver, and for explicitly requested Community +parser, formatter, completion, builder, and plugin behavior. The native route uses upstream `mysql_async 0.37.0` for the complete MySQL product data plane: connection and SSH, metadata, editable DML and DDL, Console, typed SELECT bind parameters, Dashboard/Chart refresh, routines, @@ -204,6 +210,18 @@ The JDBC baseline implements: - typed row batches with row, byte, frame, and scalar limits; - credit flow control, cancellation, deadlines, and conservative outcomes. +The unified Rust driver SPI also supports hybrid drivers whose +database-specific behavior is owned by Rust while wire transport remains JDBC. +DM is the first implementation: `DmNativeDriver` owns capability selection, +catalog SQL, identifier validation, neutral metadata mapping, and bounded table +preview construction. `datasource_session::jdbc_query` resolves the encrypted +datasource, selects the unique managed DM pack, retains the lazy Java generation +lease, forces read-only sessions, binds parameters, consumes credit-controlled +typed batches, and closes the session. The Java engine only invokes the +official `dm.jdbc.driver.DmDriver`; no Community DM/Oracle plugin or +`CommunityPluginRegistry` call participates in this path. Unsupported DM SPI +capabilities return `native_driver_capability_not_supported` explicitly. + Stage 7B additionally implements: - a Git submodule fixed at Community commit @@ -410,16 +428,20 @@ qualified identifier. This generation step accepts no raw SQL, opens no JDBC session, and never executes the returned statement. Core applies the product default of 200 rows and rejects limits outside -`1..=1000`. The current MySQL route safely quotes the database and table as two -identifier segments and builds the bounded SELECT directly in Rust. Other -drivers retain the Community builder and parser checks for `is_select`, one -projected statement, a SELECT prefix, and no semicolon. Both routes pass the SQL -to `start_read_query`, which dispatches MySQL to a native read-only transaction -and other drivers to a forced-read-only JDBC session. It caps the result at the -same row limit and 8 MiB, writes batches of at most 1 MiB, and retains the result -for one hour. The accepted response carries the operation id, exact SQL, and -effective row limit; normal operation events, cancellation, and retained-result -paging remain unchanged. +`1..=1000`. The MySQL route safely quotes the database and table as two +identifier segments and builds the bounded SELECT directly in Rust. The DM +route uses Rust-owned double-quoted schema/table identifiers and a bounded +`LIMIT`; it does not invoke a Community builder or parser. Unregistered +database types retain the Community builder and parser checks for `is_select`, +one projected statement, a SELECT prefix, and no semicolon. A database type +already registered in the Rust Driver SPI never falls through to Community: a +missing capability returns `native_driver_capability_not_supported`. +All routes pass the SQL to `start_read_query`: MySQL uses a native read-only +transaction, while DM and other JDBC-backed drivers use a forced-read-only JDBC +session. The result is capped at the same row limit and 8 MiB, written in +batches of at most 1 MiB, and retained for one hour. The accepted response +carries the operation id, exact SQL, and effective row limit; normal operation +events, cancellation, and retained-result paging remain unchanged. Axum exposes `POST /api/v1/community/table-preview`; Tauri exposes `start_community_table_preview`; generated OpenAPI/TypeScript and both frontend @@ -574,7 +596,8 @@ product UI from those intermediate slices with the exact original Community frontend while retaining the Rust capabilities behind explicit historical API adapters. Signing, installation, hot reload, downloading, compatibility selection, updates, -rollback, and non-MySQL compatibility operations are not implemented. +rollback, and compatibility operations beyond the implemented DM metadata and +preview slice are not implemented. ## Local attachment and MCP boundary diff --git a/docs/driver-packs.md b/docs/driver-packs.md index d4bdaed..9d81066 100644 --- a/docs/driver-packs.md +++ b/docs/driver-packs.md @@ -131,3 +131,33 @@ driver IDs. Renaming the pack directory or changing display metadata does not change the ID; changing the driver class, artifact order, or artifact bytes does. + +## DM Driver Scope + +The local `dm-driver-pack` build target prepares a pinned DM JDBC `8.1.2.141` +pack with driver class `dm.jdbc.driver.DmDriver`. The Rust `DmDriver` owns DM +capability routing, metadata SQL, identifier validation, neutral metadata +mapping, and bounded preview SQL through the unified native-driver SPI. Its +connection and query operations use the generic managed JDBC session path. + +The Java compatibility runtime only discovers and loads the official DM JDBC +JAR, opens the JDBC session, binds prepared parameters, and streams bounded +typed results. DM does not use the fixed Community classpath, +`CommunityPluginRegistry`, Community metadata DTOs, or the Community DM/Oracle +plugins. The environment-gated `java_dm_product` test exercises the complete +Rust-owned path against a real DM endpoint when configured and explicitly +asserts that Community compatibility is disabled; without an endpoint it +verifies Driver Pack loading and native SPI identity only. + +The public macOS package does not contain the DM JDBC JAR. Its archive has no +LICENSE, NOTICE, EULA, or other verifiable redistribution grant, so bundling it +requires separate written authorization. Local users can provide the verified +JAR through `DM_JDBC_DRIVER_JAR` or let the preparation script fetch the pinned +bytes for local use, then select the resulting root with +`CHAT2DB_DRIVER_PACK_DIR`. + +Local preparation targets share `target/driver-packs`, which may contain MySQL, +H2, and DM packs. macOS packaging instead rebuilds the independent +`target/macos-driver-packs` root from an exact allowlist containing only +`01-mysql` and `02-h2-migration`; package verification rejects DM and every +other additional entry. diff --git a/docs/protocol.md b/docs/protocol.md index 0bc8fd2..3d1501c 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -416,7 +416,8 @@ reflectively from that loader. The binding is thread-local and restored after the call, so the Community loader does not gain the H2 driver and the bridge does not retain a driver loader after session cleanup. -For table preview, Core applies a default of 200 and a maximum of 1,000 rows, +For Community-generated table preview, Core applies a default of 200 and a +maximum of 1,000 rows, parses the generated SQL, requires `is_select`, at most one projected SELECT statement, a SELECT prefix, and no semicolon, and only then submits it through the existing forced-read-only query service. Execution uses the same row limit, diff --git a/packaging/macos/THIRD_PARTY_NOTICES.md b/packaging/macos/THIRD_PARTY_NOTICES.md index 615c0cc..4391c67 100644 --- a/packaging/macos/THIRD_PARTY_NOTICES.md +++ b/packaging/macos/THIRD_PARTY_NOTICES.md @@ -6,12 +6,15 @@ revision, artifact names, byte lengths, and SHA-256 digests are recorded in: - `scripts/community-frontend.lock.json` - `third_party/community-h2-classpath.lock` -- `target/mysql-driver-packs/01-mysql/driver-pack.json` -- `target/mysql-driver-packs/02-h2-migration/driver-pack.json` +- `target/macos-driver-packs/01-mysql/driver-pack.json` +- `target/macos-driver-packs/02-h2-migration/driver-pack.json` The H2 2.1.214 driver is bundled only for read-only migration of the previous Chat2DB local store. H2 is available under MPL 2.0 or EPL 1.0. +The public package driver-pack allowlist contains only MySQL Connector/J and +the H2 migration driver. It does not contain the proprietary DM JDBC driver. + The package includes copies of both the Chat2DB Rust license and the pinned Chat2DB Community license under `Contents/Resources/chat2db/licenses`. diff --git a/scripts/build-macos-package.sh b/scripts/build-macos-package.sh index afc8516..a4598cd 100755 --- a/scripts/build-macos-package.sh +++ b/scripts/build-macos-package.sh @@ -78,7 +78,7 @@ for path in \ "${repository_root}/target/macos-runtime/bin/java" \ "${repository_root}/java/compat-runtime/target/chat2db-compat-runtime-0.1.0-SNAPSHOT.jar" \ "${repository_root}/target/community-h2-classpath" \ - "${repository_root}/target/mysql-driver-packs" \ + "${repository_root}/target/macos-driver-packs" \ "${repository_root}/apps/frontend/dist"; do if [[ ! -e "${path}" ]]; then echo "package prerequisite is missing: ${path}" >&2 diff --git a/scripts/prepare-dm-driver-pack.sh b/scripts/prepare-dm-driver-pack.sh new file mode 100755 index 0000000..36e9d47 --- /dev/null +++ b/scripts/prepare-dm-driver-pack.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly driver_version="8.1.2.141" +readonly driver_filename="DmJdbcDriver18-${driver_version}.jar" +readonly driver_sha256="8b8d7b18aa4f048b68700ac9d53661767bc6fdd857bbf48e928a8293289b96dc" +readonly driver_bytes="1030636" +readonly driver_url="https://cdn.chat2db-ai.com/lib/${driver_filename}" +readonly pack_directory_name="03-dm" + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +output_root="${1:-${repository_root}/target/driver-packs}" +source_jar="${DM_JDBC_DRIVER_JAR:-}" +staging_directory="" +backup_directory="" +pack_directory="" + +sha256_file() { + local path="$1" + local output + local digest + + if command -v sha256sum >/dev/null 2>&1; then + output="$(sha256sum -- "${path}")" + elif command -v shasum >/dev/null 2>&1; then + output="$(shasum -a 256 -- "${path}")" + elif command -v openssl >/dev/null 2>&1; then + output="$(openssl dgst -sha256 -r "${path}")" + else + echo "sha256sum, shasum, or openssl is required" >&2 + return 1 + fi + + digest="${output%% *}" + digest="$(printf '%s' "${digest}" | tr '[:upper:]' '[:lower:]')" + if [[ ! "${digest}" =~ ^[0-9a-f]{64}$ ]]; then + echo "invalid SHA-256 output for ${path}" >&2 + return 1 + fi + printf '%s' "${digest}" +} + +cleanup() { + if [[ -n "${staging_directory}" && -d "${staging_directory}" ]]; then + rm -rf -- "${staging_directory}" + fi + if [[ -n "${backup_directory}" && -d "${backup_directory}" ]]; then + if [[ -n "${pack_directory}" && ! -e "${pack_directory}" ]]; then + mv -- "${backup_directory}" "${pack_directory}" + else + rm -rf -- "${backup_directory}" + fi + fi +} +trap cleanup EXIT + +if [[ -e "${output_root}" && ( ! -d "${output_root}" || -L "${output_root}" ) ]]; then + echo "driver-pack root must be a non-symbolic directory: ${output_root}" >&2 + exit 1 +fi +mkdir -p "${output_root}" +output_root="$(cd "${output_root}" && pwd -P)" + +staging_directory="$(mktemp -d "${output_root}/.${pack_directory_name}.staging.XXXXXX")" +staged_jar="${staging_directory}/${driver_filename}" + +if [[ -n "${source_jar}" ]]; then + if [[ ! -f "${source_jar}" || -L "${source_jar}" ]]; then + echo "DM_JDBC_DRIVER_JAR must be a non-symbolic regular file" >&2 + exit 1 + fi + cp -- "${source_jar}" "${staged_jar}" +else + if ! command -v curl >/dev/null 2>&1; then + echo "curl is required when DM_JDBC_DRIVER_JAR is not set" >&2 + exit 1 + fi + curl --fail --location --silent --show-error \ + --retry 3 --retry-all-errors \ + --output "${staged_jar}" \ + "${driver_url}" +fi + +actual_bytes="$(LC_ALL=C wc -c < "${staged_jar}" | tr -d '[:space:]')" +if [[ "${actual_bytes}" != "${driver_bytes}" ]]; then + echo "DM JDBC driver byte length mismatch: expected ${driver_bytes}, found ${actual_bytes}" >&2 + exit 1 +fi + +actual_sha256="$(sha256_file "${staged_jar}")" +if [[ "${actual_sha256}" != "${driver_sha256}" ]]; then + echo "DM JDBC driver SHA-256 mismatch: expected ${driver_sha256}, found ${actual_sha256}" >&2 + exit 1 +fi + +printf '%s\n' \ + '{' \ + ' "schemaVersion": 1,' \ + ' "id": "dm",' \ + ' "name": "DM",' \ + " \"version\": \"${driver_version}\"," \ + ' "driverClass": "dm.jdbc.driver.DmDriver",' \ + ' "artifacts": [' \ + ' {' \ + " \"path\": \"${driver_filename}\"," \ + " \"sha256\": \"${actual_sha256}\"" \ + ' }' \ + ' ]' \ + '}' > "${staging_directory}/driver-pack.json" +chmod 0644 "${staged_jar}" "${staging_directory}/driver-pack.json" + +pack_directory="${output_root}/${pack_directory_name}" +if [[ -e "${pack_directory}" ]]; then + if [[ ! -d "${pack_directory}" || -L "${pack_directory}" ]]; then + echo "refusing to replace unsafe pack path: ${pack_directory}" >&2 + exit 1 + fi + backup_directory="${output_root}/.${pack_directory_name}.previous.$$" + if [[ -e "${backup_directory}" ]]; then + echo "refusing to replace existing backup path: ${backup_directory}" >&2 + exit 1 + fi + mv -- "${pack_directory}" "${backup_directory}" +fi + +mv -- "${staging_directory}" "${pack_directory}" +staging_directory="" +if [[ -n "${backup_directory}" ]]; then + rm -rf -- "${backup_directory}" + backup_directory="" +fi + +echo "Prepared DM JDBC ${driver_version} driver pack at ${output_root}" +echo "Set DM_TEST_DRIVER_PACK_DIR=${output_root} when running the DM product test" diff --git a/scripts/prepare-h2-driver-pack.sh b/scripts/prepare-h2-driver-pack.sh index 5cab48c..61007da 100755 --- a/scripts/prepare-h2-driver-pack.sh +++ b/scripts/prepare-h2-driver-pack.sh @@ -9,7 +9,7 @@ readonly h2_url="https://repo.maven.apache.org/maven2/com/h2database/h2/${h2_ver readonly pack_directory_name="02-h2-migration" repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -output_root="${1:-${repository_root}/target/mysql-driver-packs}" +output_root="${1:-${repository_root}/target/driver-packs}" source_jar="${H2_MIGRATION_DRIVER_JAR:-}" staging_directory="" backup_directory="" diff --git a/scripts/prepare-macos-driver-packs.sh b/scripts/prepare-macos-driver-packs.sh new file mode 100755 index 0000000..257e5fc --- /dev/null +++ b/scripts/prepare-macos-driver-packs.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +target_root="${repository_root}/target" +output_root="${target_root}/macos-driver-packs" +staging_directory="" +previous_directory="" + +cleanup() { + if [[ -n "${staging_directory}" && -d "${staging_directory}" ]]; then + rm -rf -- "${staging_directory}" + fi + if [[ -n "${previous_directory}" && -d "${previous_directory}" ]]; then + if [[ ! -e "${output_root}" ]]; then + mv -- "${previous_directory}" "${output_root}" + else + rm -rf -- "${previous_directory}" + fi + fi +} +trap cleanup EXIT + +if [[ -e "${target_root}" && ( ! -d "${target_root}" || -L "${target_root}" ) ]]; then + echo "refusing to use unsafe target directory: ${target_root}" >&2 + exit 1 +fi +mkdir -p "${target_root}" + +staging_directory="$(mktemp -d "${target_root}/.macos-driver-packs.staging.XXXXXX")" +"${repository_root}/scripts/prepare-mysql-driver-pack.sh" "${staging_directory}" +"${repository_root}/scripts/prepare-h2-driver-pack.sh" "${staging_directory}" + +mysql_found=false +h2_found=false +entry_count=0 +while IFS= read -r -d '' entry; do + entry_count=$((entry_count + 1)) + entry_name="$(basename "${entry}")" + if [[ ! -d "${entry}" || -L "${entry}" ]]; then + echo "macOS driver-pack entry must be a non-symbolic directory: ${entry_name}" >&2 + exit 1 + fi + case "${entry_name}" in + 01-mysql) mysql_found=true ;; + 02-h2-migration) h2_found=true ;; + *) + echo "macOS driver-pack staging contains an unauthorized entry: ${entry_name}" >&2 + exit 1 + ;; + esac +done < <(find "${staging_directory}" -mindepth 1 -maxdepth 1 -print0) + +if [[ "${entry_count}" -ne 2 || "${mysql_found}" != true || "${h2_found}" != true ]]; then + echo "macOS driver packs must contain exactly 01-mysql and 02-h2-migration" >&2 + exit 1 +fi + +if [[ -e "${output_root}" ]]; then + if [[ ! -d "${output_root}" || -L "${output_root}" ]]; then + echo "refusing to replace unsafe macOS driver-pack root: ${output_root}" >&2 + exit 1 + fi + previous_directory="${target_root}/.macos-driver-packs.previous.$$" + if [[ -e "${previous_directory}" ]]; then + echo "refusing to replace existing macOS driver-pack backup: ${previous_directory}" >&2 + exit 1 + fi + mv -- "${output_root}" "${previous_directory}" +fi + +mv -- "${staging_directory}" "${output_root}" +staging_directory="" +if [[ -n "${previous_directory}" ]]; then + rm -rf -- "${previous_directory}" + previous_directory="" +fi + +echo "Prepared public macOS driver packs at ${output_root}" diff --git a/scripts/prepare-mysql-driver-pack.sh b/scripts/prepare-mysql-driver-pack.sh index b4c7d3c..e57def9 100755 --- a/scripts/prepare-mysql-driver-pack.sh +++ b/scripts/prepare-mysql-driver-pack.sh @@ -9,7 +9,7 @@ readonly connector_url="https://repo.maven.apache.org/maven2/mysql/mysql-connect readonly pack_directory_name="01-mysql" repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -output_root="${1:-${repository_root}/target/mysql-driver-packs}" +output_root="${1:-${repository_root}/target/driver-packs}" source_jar="${MYSQL_CONNECTOR_JAR:-}" staging_directory="" backup_directory="" diff --git a/scripts/verify-macos-package.sh b/scripts/verify-macos-package.sh index c5a4d52..9faa30a 100755 --- a/scripts/verify-macos-package.sh +++ b/scripts/verify-macos-package.sh @@ -51,6 +51,30 @@ fi "${repository_root}/third_party/community-h2-classpath.lock" \ "3cb8af54cad5bd5caa20bb25f10d9b0e4f01931c" +driver_entry_count=0 +mysql_pack_found=false +h2_pack_found=false +while IFS= read -r -d '' driver_entry; do + driver_entry_count=$((driver_entry_count + 1)) + driver_entry_name="$(basename "${driver_entry}")" + if [[ ! -d "${driver_entry}" || -L "${driver_entry}" ]]; then + echo "packaged driver-pack entry must be a non-symbolic directory: ${driver_entry_name}" >&2 + exit 1 + fi + case "${driver_entry_name}" in + 01-mysql) mysql_pack_found=true ;; + 02-h2-migration) h2_pack_found=true ;; + *) + echo "packaged driver-pack root contains an unauthorized entry: ${driver_entry_name}" >&2 + exit 1 + ;; + esac +done < <(find "${driver_root}" -mindepth 1 -maxdepth 1 -print0) +if [[ "${driver_entry_count}" -ne 2 || "${mysql_pack_found}" != true || "${h2_pack_found}" != true ]]; then + echo "packaged driver packs must contain exactly 01-mysql and 02-h2-migration" >&2 + exit 1 +fi + driver_manifest="${driver_root}/01-mysql/driver-pack.json" driver_jar="${driver_root}/01-mysql/mysql-connector-java-8.0.30.jar" require_file "${driver_manifest}"