From 259eb9f6d0be39343206ac8bf5de029f6788b045 Mon Sep 17 00:00:00 2001 From: Alexander Marshalov Date: Mon, 20 Oct 2025 15:40:41 +0200 Subject: [PATCH] Initial JDBC driver support --- .github/workflows/release.yml | 49 +- .github/workflows/settings.xml | 30 + .gitignore | 3 +- .goreleaser.yaml | 6 - Makefile | 8 +- logsql-jdbc/README.md | 50 + logsql-jdbc/pom.xml | 150 ++ .../logsql/jdbc/LogsqlConnection.java | 1000 ++++++++++++ .../logsql/jdbc/LogsqlConnectionConfig.java | 127 ++ .../logsql/jdbc/LogsqlDriver.java | 69 + .../logsql/jdbc/LogsqlPreparedStatement.java | 503 ++++++ .../logsql/jdbc/LogsqlQueryResult.java | 34 + .../logsql/jdbc/LogsqlResultSet.java | 1451 +++++++++++++++++ .../logsql/jdbc/LogsqlResultSetMetaData.java | 206 +++ .../logsql/jdbc/LogsqlStatement.java | 381 +++++ .../logsql/jdbc/LogsqlUrlParser.java | 187 +++ .../META-INF/services/java.sql.Driver | 1 + .../logsql/jdbc/LogsqlIntegrationTest.java | 96 ++ scripts/jdbc-build.sh | 1 + scripts/jdbc-test.sh | 1 + 20 files changed, 4344 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/settings.xml create mode 100644 logsql-jdbc/README.md create mode 100644 logsql-jdbc/pom.xml create mode 100644 logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlConnection.java create mode 100644 logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlConnectionConfig.java create mode 100644 logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlDriver.java create mode 100644 logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlPreparedStatement.java create mode 100644 logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlQueryResult.java create mode 100644 logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlResultSet.java create mode 100644 logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlResultSetMetaData.java create mode 100644 logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlStatement.java create mode 100644 logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlUrlParser.java create mode 100644 logsql-jdbc/src/main/resources/META-INF/services/java.sql.Driver create mode 100644 logsql-jdbc/src/test/java/com/victoriametrics/logsql/jdbc/LogsqlIntegrationTest.java create mode 100644 scripts/jdbc-build.sh create mode 100644 scripts/jdbc-test.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f9e0a3..70b6596 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ permissions: id-token: write jobs: - release: + release-sql-to-logsql: runs-on: ubuntu-latest steps: @@ -90,3 +90,50 @@ jobs: args: release --clean --verbose --timeout 60m -f .goreleaser.yaml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + release-logsql-jdbc: + runs-on: ubuntu-latest + steps: + + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: '0' + fetch-tags: 'true' + + - name: Install JDK and Maven + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '11' + cache: 'maven' + + - name: Fetch tags + run: | + if [[ "${{ github.ref_type }}" != "tag" ]]; then + git fetch --tags + else + echo "Skipping tag fetch - already on tag ${{ github.ref_name }}" + fi + + - name: Update Configuration + working-directory: logsql-jdbc + run: | + if [[ "${{ github.ref_type }}" == "tag" ]]; then + TAG_VERSION=$(echo "${{ github.ref_name }}" | sed 's/^v//') + else + LATEST_TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-.*)?$' | head -n 1) + [ -z "$LATEST_TAG" ] && { echo "No release tag found"; exit 1; } + TAG_VERSION=$(echo "$LATEST_TAG" | sed 's/^v//') + echo "Using latest tag: $LATEST_TAG" + fi + mvn versions:set versions:commit -DnewVersion="${TAG_VERSION}"; + + - name: Publish package + working-directory: logsql-jdbc + run: | + mvn -s ${{ github.workspace }}/.github/workflows/settings.xml -DskipTests --batch-mode deploy + rm -rf */target/logsql-jdbc-*.jar + env: + USERNAME: ${{ secrets.USERNAME }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/settings.xml b/.github/workflows/settings.xml new file mode 100644 index 0000000..40ed124 --- /dev/null +++ b/.github/workflows/settings.xml @@ -0,0 +1,30 @@ + + + + + github + + + + + github + + + github + https://maven.pkg.github.com/victoriametrics/sql-to-logsql + + true + + + + + + + + + github + ${env.USERNAME} + ${env.GITHUB_TOKEN} + + + \ No newline at end of file diff --git a/.gitignore b/.gitignore index 426b595..91dc5df 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? -/sql-to-logsql \ No newline at end of file +/sql-to-logsql +/logsql-jdbc/target/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 39e81df..585d0b8 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,10 +1,6 @@ version: 2 project_name: sql-to-logsql -#before: -# hooks: -# - make ui-build - builds: - env: - CGO_ENABLED=0 @@ -17,8 +13,6 @@ builds: - arm64 - arm - "386" -# hooks: -# pre: make ui-build main: ./cmd/sql-to-logsql/main.go binary: sql-to-logsql ldflags: diff --git a/Makefile b/Makefile index 7c1c017..a74e31f 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ UI_DIR=cmd/sql-to-logsql/web/ui -.PHONY: ui-install ui-build build backend-build run test +.PHONY: ui-install ui-build build backend-build run test all jdbc-build jdbc-test ui-install: cd $(UI_DIR) && npm install @@ -26,3 +26,9 @@ lint: bash ./scripts/lint-all.sh all: test check lint build + +jdbc-build: + bash ./scripts/jdbc-build.sh + +jdbc-test: + bash ./scripts/jdbc-build.sh diff --git a/logsql-jdbc/README.md b/logsql-jdbc/README.md new file mode 100644 index 0000000..841fda1 --- /dev/null +++ b/logsql-jdbc/README.md @@ -0,0 +1,50 @@ +# LogSQL JDBC Driver + +This module provides a JDBC 4.0 compatible driver for the VictoriaLogs (via `sql-to-logsql` service). +The driver interacts http(s) with sql-to-logsql API and exposes query results as regular JDBC result sets, +making it possible to integrate VictoriaLogs with the broader JVM ecosystem (BI tools, JDBC-based frameworks, etc.). + +## Connection URL + +``` +jdbc:logsql://host[:port][/basePath]?property=value&... +``` + +Supported properties: + +- `scheme` – `http` (default) or `https`. +- `endpoint` – optional VictoriaLogs endpoint URL override. +- `bearerToken` – optional bearer token sent to the translation service. +- `timeout` – request timeout in milliseconds (default 60000). +- `verify` – when `false`, TLS certificate validation is disabled. +- `header.` – additional HTTP headers to include with every request. + +Example: + +``` +jdbc:logsql://localhost:8080?scheme=https&endpoint=https%3A%2F%2Fvictorialogs.example.com&bearerToken=secret +``` + +Properties provided through `java.util.Properties` when creating the connection are merged with the URL query parameters (query parameters take precedence). + +## Building + +``` +mvn -DskipTests package +``` + +The standard artifact is placed in `target/logsql-jdbc-.jar`, and a fat jar with all dependencies is available as `target/logsql-jdbc--all.jar`. + +## Testing + +``` +mvn test +``` + +These integration tests connect to https://play-sql.victoriametrics.com. They will be marked as skipped automatically if the playground cannot be reached (for example, when outbound network access is disabled). + +## Notes + +- The driver performs a health check against `/healthz` when establishing a connection. +- Result sets are fully buffered in memory to simplify cursor navigation and metadata reporting. Avoid query patterns that return unbounded result sets. +- HTTPS certificate verification can be disabled for testing by setting `verify=false`, but this is not recommended for production use. diff --git a/logsql-jdbc/pom.xml b/logsql-jdbc/pom.xml new file mode 100644 index 0000000..d044a20 --- /dev/null +++ b/logsql-jdbc/pom.xml @@ -0,0 +1,150 @@ + + 4.0.0 + + com.victoriametrics + logsql-jdbc + 0.0.0-SNAPSHOT + jar + LogsQL JDBC Driver + JDBC driver for VictoriaLogs (via sql-to-logsql service) + https://github.com/VictoriaMetrics/sql-to-logsql/tree/main/logsql-jdbc + + + VictoriaMetrics Inc. + https://victoriametrics.com/ + + + + + Apache License 2.0 + https://www.apache.org/licenses/LICENSE-2.0 + + + + + + VictoriaMetrics + https://victoriametrics.com + + + + + Github + https://github.com/VictoriaMetrics/sql-to-logsql/issues + + + + Github + https://github.com/VictoriaMetrics/sql-to-logsql/actions + + + + + github + GitHub VictoriaMetrics Apache Maven Packages + https://maven.pkg.github.com/VictoriaMetrics/sql-to-logsql + + + + + JDBC + 4.2 + 11 + 11 + UTF-8 + 2.17.2 + 5.10.2 + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + org.junit.jupiter + junit-jupiter-api + ${junit.jupiter.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.jupiter.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + ${maven.compiler.source} + ${maven.compiler.target} + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + com.victoriametrics.logsql.jdbc + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.3 + + + package + + shade + + + false + true + all + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + com.victoriametrics.logsql.jdbc + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + false + + + + + diff --git a/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlConnection.java b/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlConnection.java new file mode 100644 index 0000000..714869f --- /dev/null +++ b/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlConnection.java @@ -0,0 +1,1000 @@ +package com.victoriametrics.logsql.jdbc; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.io.IOException; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.sql.Array; +import java.sql.Blob; +import java.sql.CallableStatement; +import java.sql.Clob; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.NClob; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.sql.SQLWarning; +import java.sql.SQLXML; +import java.sql.Savepoint; +import java.sql.Statement; +import java.sql.Struct; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.regex.Pattern; + +final class LogsqlConnection implements Connection { + + private static final TypeReference> MAP_TYPE = + new TypeReference>() { + }; + + private static final class TableEntry { + final String name; + final String type; + final String remarks; + + TableEntry(String name, String type, String remarks) { + this.name = name; + this.type = type; + this.remarks = remarks; + } + } + + private final LogsqlConnectionConfig config; + private final HttpClient httpClient; + private final ObjectMapper mapper = new ObjectMapper(); + private final String baseUrl; + private final DatabaseMetaData metadata; + private boolean closed; + private boolean readOnly = true; + private boolean autoCommit = true; + + LogsqlConnection(LogsqlConnectionConfig config) throws SQLException { + this.config = Objects.requireNonNull(config, "config"); + this.httpClient = createHttpClient(config); + this.baseUrl = buildBaseUrl(config); + this.metadata = createMetadata(); + performHealthCheck(); + } + + LogsqlQueryResult executeQuery(String sql, int maxRows) throws SQLException { + ensureOpen(); + if (sql == null) { + throw new SQLException("SQL must not be null"); + } + String payload; + try { + Map body = new LinkedHashMap<>(); + body.put("sql", sql); + if (config.getEndpoint() != null) { + body.put("endpoint", config.getEndpoint()); + } + if (config.getBearerToken() != null) { + body.put("bearerToken", config.getBearerToken()); + } + payload = mapper.writeValueAsString(body); + } catch (JsonProcessingException e) { + throw new SQLException("Failed to serialize request payload", e); + } + + HttpRequest request = baseRequestBuilder(buildUri("/api/v1/sql-to-logsql")) + .timeout(config.getTimeout()) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + + HttpResponse response = send(request); + if (response.statusCode() >= 400) { + throw new SQLException("Query execution failed: " + extractErrorMessage(response)); + } + + Map resultMap = parseJson(response.body()); + String translated = (String) resultMap.getOrDefault("logsql", null); + String data = (String) resultMap.getOrDefault("data", ""); + + List> rows = new ArrayList<>(); + Set columnOrder = new LinkedHashSet<>(); + if (data != null && !data.isBlank()) { + String[] lines = data.split("\\r?\\n"); + for (String line : lines) { + if (line == null) { + continue; + } + String trimmed = line.trim(); + if (trimmed.isEmpty()) { + continue; + } + Map row = parseRow(trimmed); + rows.add(row); + columnOrder.addAll(row.keySet()); + } + } + + List columns = new ArrayList<>(columnOrder); + for (Map row : rows) { + for (String column : columns) { + row.putIfAbsent(column, null); + } + } + + if (maxRows > 0 && rows.size() > maxRows) { + rows = new ArrayList<>(rows.subList(0, maxRows)); + } + + return new LogsqlQueryResult(translated, columns, rows); + } + + private Map parseJson(String json) throws SQLException { + if (json == null || json.isBlank()) { + return Collections.emptyMap(); + } + try { + return mapper.readValue(json, MAP_TYPE); + } catch (IOException e) { + throw new SQLException("Failed to parse response JSON", e); + } + } + + private Map parseRow(String jsonLine) throws SQLException { + try { + return mapper.readValue(jsonLine, MAP_TYPE); + } catch (IOException e) { + throw new SQLException("Failed to parse response row: " + jsonLine, e); + } + } + + private HttpResponse send(HttpRequest request) throws SQLException { + try { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + return response; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new SQLException("Request interrupted", e); + } catch (IOException e) { + throw new SQLException("HTTP request failed", e); + } + } + + private void performHealthCheck() throws SQLException { + HttpRequest request = baseRequestBuilder(buildUri("/healthz")) + .timeout(Duration.ofSeconds(Math.min(5, Math.max(1, config.getTimeout().toSeconds())))) + .GET() + .build(); + + HttpResponse response = send(request); + if (response.statusCode() >= 400) { + throw new SQLException("Failed to connect to sql-to-logsql service at " + baseUrl + + ": status=" + response.statusCode()); + } + } + + private HttpRequest.Builder baseRequestBuilder(URI uri) { + HttpRequest.Builder builder = HttpRequest.newBuilder(uri) + .header("Accept", "application/json"); + for (Map.Entry header : config.getHeaders().entrySet()) { + builder.header(header.getKey(), header.getValue()); + } + return builder; + } + + private static HttpClient createHttpClient(LogsqlConnectionConfig config) throws SQLException { + HttpClient.Builder builder = HttpClient.newBuilder(); + Duration connectTimeout = config.getTimeout(); + if (connectTimeout == null || connectTimeout.isZero() || connectTimeout.isNegative()) { + connectTimeout = LogsqlConnectionConfig.DEFAULT_TIMEOUT; + } + builder.connectTimeout(connectTimeout.compareTo(Duration.ofSeconds(5)) > 0 + ? Duration.ofSeconds(5) + : connectTimeout); + + if ("https".equalsIgnoreCase(config.getScheme()) && !config.isVerifyTls()) { + try { + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, new TrustManager[]{new InsecureTrustManager()}, new SecureRandom()); + builder.sslContext(sslContext); + SSLParameters parameters = new SSLParameters(); + parameters.setEndpointIdentificationAlgorithm(""); + builder.sslParameters(parameters); + } catch (NoSuchAlgorithmException | KeyManagementException e) { + throw new SQLException("Failed to configure insecure SSL context", e); + } + } + + return builder.build(); + } + + private String buildBaseUrl(LogsqlConnectionConfig config) { + StringBuilder sb = new StringBuilder(); + sb.append(config.getScheme()) + .append("://") + .append(config.getHost()); + if (config.getPort() > 0) { + sb.append(":").append(config.getPort()); + } + String basePath = config.getBasePath(); + if (basePath != null && !basePath.isBlank()) { + if (!basePath.startsWith("/")) { + sb.append('/'); + } + sb.append(trimTrailingSlash(basePath)); + } + return sb.toString(); + } + + private DatabaseMetaData createMetadata() { + InvocationHandler handler = this::handleMetadataInvocation; + return (DatabaseMetaData) Proxy.newProxyInstance( + DatabaseMetaData.class.getClassLoader(), + new Class[]{DatabaseMetaData.class}, + handler + ); + } + + private Object handleMetadataInvocation(Object proxy, Method method, Object[] args) throws Throwable { + String name = method.getName(); + switch (name) { + case "getConnection": + return this; + case "getURL": + return baseUrl; + case "getUserName": + return null; + case "getDatabaseProductName": + return "VictoriaLogs (via sql-to-logsql)"; + case "getDatabaseProductVersion": + return "unknown"; + case "getDriverName": + return "LogSQL JDBC Driver"; + case "getDriverVersion": + return "0.1.0-SNAPSHOT"; + case "getDriverMajorVersion": + return Integer.valueOf(0); + case "getDriverMinorVersion": + return Integer.valueOf(1); + case "getJDBCMajorVersion": + return Integer.valueOf(4); + case "getJDBCMinorVersion": + return Integer.valueOf(2); + case "getIdentifierQuoteString": + return "\""; + case "supportsResultSetType": + return Boolean.valueOf(((Integer) args[0]) == ResultSet.TYPE_FORWARD_ONLY); + case "supportsResultSetConcurrency": + return Boolean.valueOf(((Integer) args[0]) == ResultSet.TYPE_FORWARD_ONLY + && ((Integer) args[1]) == ResultSet.CONCUR_READ_ONLY); + case "supportsBatchUpdates": + case "supportsTransactions": + case "supportsSchemasInTableDefinitions": + case "supportsCatalogsInTableDefinitions": + case "supportsStoredProcedures": + return Boolean.FALSE; + case "supportsMixedCaseIdentifiers": + case "supportsMixedCaseQuotedIdentifiers": + return Boolean.TRUE; + case "storesLowerCaseIdentifiers": + case "storesLowerCaseQuotedIdentifiers": + case "storesUpperCaseIdentifiers": + case "storesUpperCaseQuotedIdentifiers": + return Boolean.FALSE; + case "storesMixedCaseIdentifiers": + case "storesMixedCaseQuotedIdentifiers": + return Boolean.TRUE; + case "isReadOnly": + return Boolean.TRUE; + case "allTablesAreSelectable": + return Boolean.TRUE; + case "nullsAreSortedHigh": + case "nullsAreSortedLow": + case "nullsAreSortedAtStart": + case "nullsAreSortedAtEnd": + return Boolean.FALSE; + case "supportsResultSetHoldability": + return Boolean.valueOf(((Integer) args[0]) == ResultSet.HOLD_CURSORS_OVER_COMMIT); + case "getResultSetHoldability": + return ResultSet.HOLD_CURSORS_OVER_COMMIT; + case "getDefaultTransactionIsolation": + return Integer.valueOf(Connection.TRANSACTION_NONE); + case "getDatabaseMajorVersion": + case "getDatabaseMinorVersion": + return Integer.valueOf(0); + case "getTableTypes": + return buildTableTypesMetadata(); + case "getTables": { + String tableNamePattern = args != null && args.length > 2 ? (String) args[2] : null; + String[] tableTypes = args != null && args.length > 3 ? (String[]) args[3] : null; + return buildTablesMetadata(tableNamePattern, tableTypes); + } + case "getColumns": { + String tableNamePattern = args != null && args.length > 2 ? (String) args[2] : null; + String columnNamePattern = args != null && args.length > 3 ? (String) args[3] : null; + return buildColumnsMetadata(tableNamePattern, columnNamePattern); + } + case "getSQLKeywords": + return ""; + case "getExtraNameCharacters": + return ""; + case "getCatalogSeparator": + return "."; + case "getCatalogTerm": + case "getSchemaTerm": + return ""; + case "supportsGetGeneratedKeys": + case "supportsStatementPooling": + case "supportsSavepoints": + case "supportsNamedParameters": + return Boolean.FALSE; + case "unwrap": { + Class iface = (Class) args[0]; + if (iface.isInstance(proxy)) { + return iface.cast(proxy); + } + if (iface.isInstance(this)) { + return iface.cast(this); + } + throw new SQLFeatureNotSupportedException("Not a wrapper for " + iface.getName()); + } + case "isWrapperFor": { + Class iface = (Class) args[0]; + return iface.isInstance(proxy) || iface.isInstance(this); + } + case "toString": + return "LogsqlDatabaseMetaDataProxy"; + case "hashCode": + return System.identityHashCode(proxy); + case "equals": + return proxy == args[0]; + default: + throw new SQLFeatureNotSupportedException(name + " is not supported"); + } + } + + private ResultSet buildTablesMetadata(String tableNamePattern, String[] types) throws SQLException { + List tables = fetchTables(tableNamePattern, types); + List columns = List.of( + "TABLE_CAT", + "TABLE_SCHEM", + "TABLE_NAME", + "TABLE_TYPE", + "REMARKS", + "TYPE_CAT", + "TYPE_SCHEM", + "TYPE_NAME", + "SELF_REFERENCING_COL_NAME", + "REF_GENERATION" + ); + int[] columnTypes = new int[]{ + java.sql.Types.VARCHAR, + java.sql.Types.VARCHAR, + java.sql.Types.VARCHAR, + java.sql.Types.VARCHAR, + java.sql.Types.VARCHAR, + java.sql.Types.VARCHAR, + java.sql.Types.VARCHAR, + java.sql.Types.VARCHAR, + java.sql.Types.VARCHAR, + java.sql.Types.VARCHAR + }; + List rows = new ArrayList<>(); + for (TableEntry table : tables) { + rows.add(new Object[]{ + null, + null, + table.name, + table.type, + table.remarks, + null, + null, + null, + null, + null + }); + } + return new LogsqlResultSet(null, columns, columnTypes, rows); + } + + private ResultSet buildTableTypesMetadata() { + List columns = List.of("TABLE_TYPE"); + int[] columnTypes = new int[]{java.sql.Types.VARCHAR}; + List rows = new ArrayList<>(); + rows.add(new Object[]{"TABLE"}); + rows.add(new Object[]{"VIEW"}); + return new LogsqlResultSet(null, columns, columnTypes, rows); + } + + private ResultSet buildColumnsMetadata(String tableNamePattern, String columnNamePattern) throws SQLException { + List columns = List.of( + "TABLE_CAT", + "TABLE_SCHEM", + "TABLE_NAME", + "COLUMN_NAME", + "DATA_TYPE", + "TYPE_NAME", + "COLUMN_SIZE", + "BUFFER_LENGTH", + "DECIMAL_DIGITS", + "NUM_PREC_RADIX", + "NULLABLE", + "REMARKS", + "COLUMN_DEF", + "SQL_DATA_TYPE", + "SQL_DATETIME_SUB", + "CHAR_OCTET_LENGTH", + "ORDINAL_POSITION", + "IS_NULLABLE", + "SCOPE_CATALOG", + "SCOPE_SCHEMA", + "SCOPE_TABLE", + "SOURCE_DATA_TYPE", + "IS_AUTOINCREMENT", + "IS_GENERATEDCOLUMN" + ); + int[] columnTypes = new int[]{ + java.sql.Types.VARCHAR, + java.sql.Types.VARCHAR, + java.sql.Types.VARCHAR, + java.sql.Types.VARCHAR, + java.sql.Types.INTEGER, + java.sql.Types.VARCHAR, + java.sql.Types.INTEGER, + java.sql.Types.INTEGER, + java.sql.Types.INTEGER, + java.sql.Types.INTEGER, + java.sql.Types.INTEGER, + java.sql.Types.VARCHAR, + java.sql.Types.VARCHAR, + java.sql.Types.INTEGER, + java.sql.Types.INTEGER, + java.sql.Types.INTEGER, + java.sql.Types.INTEGER, + java.sql.Types.VARCHAR, + java.sql.Types.VARCHAR, + java.sql.Types.VARCHAR, + java.sql.Types.VARCHAR, + java.sql.Types.INTEGER, + java.sql.Types.VARCHAR, + java.sql.Types.VARCHAR + }; + + List rows = new ArrayList<>(); + List tables = fetchTables(tableNamePattern, null); + for (TableEntry table : tables) { + List> describeRows = runDescribeCommand(table); + int ordinal = 1; + for (Map row : describeRows) { + String columnName = stringValue(row, "field_name"); + if (columnName == null || !matchesPattern(columnName, columnNamePattern)) { + continue; + } + String hits = stringValue(row, "hits"); + rows.add(new Object[]{ + null, + null, + table.name, + columnName, + Integer.valueOf(java.sql.Types.NULL), + "null", + null, + null, + null, + Integer.valueOf(10), + Integer.valueOf(DatabaseMetaData.columnNullable), + hits, + null, + null, + null, + null, + Integer.valueOf(ordinal++), + "YES", + null, + null, + null, + null, + "NO", + "NO" + }); + } + } + + rows.sort(Comparator.comparing((Object[] r) -> ((String) r[2]).toUpperCase(Locale.ROOT)) + .thenComparing(r -> ((String) r[3]).toUpperCase(Locale.ROOT))); + return new LogsqlResultSet(null, columns, columnTypes, rows); + } + + private List fetchTables(String tableNamePattern, String[] types) throws SQLException { + List result = new ArrayList<>(); + if (isTableTypeIncluded(types, "TABLE")) { + for (Map entry : runShowCommand("SHOW TABLES")) { + String name = stringValue(entry, "table_name"); + if (name == null || !matchesPattern(name, tableNamePattern)) { + continue; + } + String remarks = stringValue(entry, "query"); + result.add(new TableEntry(name, "TABLE", remarks)); + } + } + if (isTableTypeIncluded(types, "VIEW")) { + for (Map entry : runShowCommand("SHOW VIEWS")) { + String name = stringValue(entry, "view_name"); + if (name == null || !matchesPattern(name, tableNamePattern)) { + continue; + } + String remarks = stringValue(entry, "query"); + result.add(new TableEntry(name, "VIEW", remarks)); + } + } + result.sort(Comparator.comparing(t -> t.name.toUpperCase(Locale.ROOT))); + return result; + } + + private List> runShowCommand(String sql) throws SQLException { + LogsqlQueryResult result = executeQuery(sql, 0); + return result.getRows(); + } + + private List> runDescribeCommand(TableEntry table) throws SQLException { + String sql = ("VIEW".equalsIgnoreCase(table.type) ? "DESCRIBE VIEW " : "DESCRIBE TABLE ") + table.name; + return executeQuery(sql, 0).getRows(); + } + + private boolean isTableTypeIncluded(String[] requestedTypes, String candidateType) { + if (requestedTypes == null || requestedTypes.length == 0) { + return true; + } + for (String type : requestedTypes) { + if (type == null || type.isEmpty()) { + return true; + } + if (type.equalsIgnoreCase(candidateType)) { + return true; + } + } + return false; + } + + private boolean matchesPattern(String value, String pattern) { + if (pattern == null || pattern.isEmpty()) { + return true; + } + if (value == null) { + return false; + } + String upperValue = value.toUpperCase(Locale.ROOT); + String upperPattern = pattern.toUpperCase(Locale.ROOT); + + StringBuilder regex = new StringBuilder(); + regex.append('^'); + StringBuilder literal = new StringBuilder(); + for (int i = 0; i < upperPattern.length(); i++) { + char c = upperPattern.charAt(i); + if (c == '%') { + if (literal.length() > 0) { + regex.append(Pattern.quote(literal.toString())); + literal.setLength(0); + } + regex.append(".*"); + } else if (c == '_') { + if (literal.length() > 0) { + regex.append(Pattern.quote(literal.toString())); + literal.setLength(0); + } + regex.append('.'); + } else { + literal.append(c); + } + } + if (literal.length() > 0) { + regex.append(Pattern.quote(literal.toString())); + } + regex.append('$'); + return upperValue.matches(regex.toString()); + } + + private String stringValue(Map entry, String key) { + Object value = entry.get(key); + return value == null ? null : value.toString(); + } + + private URI buildUri(String path) { + String normalizedPath = path == null ? "" : path; + if (!normalizedPath.startsWith("/")) { + normalizedPath = "/" + normalizedPath; + } + return URI.create(baseUrl + normalizedPath); + } + + private static String trimTrailingSlash(String value) { + if (value == null) { + return null; + } + int len = value.length(); + while (len > 0 && value.charAt(len - 1) == '/') { + len--; + } + return value.substring(0, len); + } + + private String extractErrorMessage(HttpResponse response) { + String body = response.body(); + if (body == null || body.isBlank()) { + return "status=" + response.statusCode(); + } + try { + Map map = mapper.readValue(body, MAP_TYPE); + Object error = map.get("error"); + if (error != null) { + return error.toString(); + } + } catch (IOException ignored) { + // fall back to raw body + } + return body; + } + + private void ensureOpen() throws SQLException { + if (closed) { + throw new SQLException("Connection is closed"); + } + } + + @Override + public Statement createStatement() throws SQLException { + ensureOpen(); + return new LogsqlStatement(this); + } + + @Override + public PreparedStatement prepareStatement(String sql) throws SQLException { + ensureOpen(); + return new LogsqlPreparedStatement(this, sql); + } + + @Override + public CallableStatement prepareCall(String sql) throws SQLException { + throw new SQLFeatureNotSupportedException("Callable statements are not supported"); + } + + @Override + public String nativeSQL(String sql) { + return sql; + } + + @Override + public void setAutoCommit(boolean autoCommit) { + this.autoCommit = autoCommit; + } + + @Override + public boolean getAutoCommit() { + return autoCommit; + } + + @Override + public void commit() { + // no-op: read only + } + + @Override + public void rollback() { + // no-op: read only + } + + @Override + public void close() { + closed = true; + } + + @Override + public boolean isClosed() { + return closed; + } + + @Override + public DatabaseMetaData getMetaData() throws SQLException { + ensureOpen(); + return metadata; + } + + @Override + public void setReadOnly(boolean readOnly) { + this.readOnly = readOnly; + } + + @Override + public boolean isReadOnly() { + return readOnly; + } + + @Override + public void setCatalog(String catalog) { + // no-op + } + + @Override + public String getCatalog() { + return null; + } + + @Override + public void setTransactionIsolation(int level) { + // no-op + } + + @Override + public int getTransactionIsolation() { + return Connection.TRANSACTION_NONE; + } + + @Override + public SQLWarning getWarnings() { + return null; + } + + @Override + public void clearWarnings() { + // no warnings to clear + } + + @Override + public Statement createStatement(int resultSetType, int resultSetConcurrency) throws SQLException { + ensureOpen(); + if (resultSetType != ResultSet.TYPE_FORWARD_ONLY || resultSetConcurrency != ResultSet.CONCUR_READ_ONLY) { + throw new SQLFeatureNotSupportedException("Only forward-only, read-only result sets are supported"); + } + return new LogsqlStatement(this); + } + + @Override + public PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency) throws SQLException { + if (resultSetType != ResultSet.TYPE_FORWARD_ONLY || resultSetConcurrency != ResultSet.CONCUR_READ_ONLY) { + throw new SQLFeatureNotSupportedException("Only forward-only, read-only result sets are supported"); + } + return prepareStatement(sql); + } + + @Override + public CallableStatement prepareCall(String sql, int resultSetType, int resultSetConcurrency) throws SQLException { + throw new SQLFeatureNotSupportedException("Callable statements are not supported"); + } + + @Override + public Map> getTypeMap() { + return Collections.emptyMap(); + } + + @Override + public void setTypeMap(Map> map) throws SQLFeatureNotSupportedException { + throw new SQLFeatureNotSupportedException("Custom type maps are not supported"); + } + + @Override + public void setHoldability(int holdability) throws SQLFeatureNotSupportedException { + if (holdability != ResultSet.HOLD_CURSORS_OVER_COMMIT) { + throw new SQLFeatureNotSupportedException("Holdability " + holdability + " not supported"); + } + } + + @Override + public int getHoldability() { + return ResultSet.HOLD_CURSORS_OVER_COMMIT; + } + + @Override + public Savepoint setSavepoint() throws SQLFeatureNotSupportedException { + throw new SQLFeatureNotSupportedException("Savepoints are not supported"); + } + + @Override + public Savepoint setSavepoint(String name) throws SQLFeatureNotSupportedException { + throw new SQLFeatureNotSupportedException("Savepoints are not supported"); + } + + @Override + public void rollback(Savepoint savepoint) throws SQLFeatureNotSupportedException { + throw new SQLFeatureNotSupportedException("Savepoints are not supported"); + } + + @Override + public void releaseSavepoint(Savepoint savepoint) throws SQLFeatureNotSupportedException { + throw new SQLFeatureNotSupportedException("Savepoints are not supported"); + } + + @Override + public Statement createStatement(int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException { + if (resultSetHoldability != ResultSet.HOLD_CURSORS_OVER_COMMIT) { + throw new SQLFeatureNotSupportedException("Holdability " + resultSetHoldability + " not supported"); + } + return createStatement(resultSetType, resultSetConcurrency); + } + + @Override + public PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException { + if (resultSetHoldability != ResultSet.HOLD_CURSORS_OVER_COMMIT) { + throw new SQLFeatureNotSupportedException("Holdability " + resultSetHoldability + " not supported"); + } + return prepareStatement(sql, resultSetType, resultSetConcurrency); + } + + @Override + public CallableStatement prepareCall(String sql, int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException { + throw new SQLFeatureNotSupportedException("Callable statements are not supported"); + } + + @Override + public PreparedStatement prepareStatement(String sql, int autoGeneratedKeys) throws SQLException { + if (autoGeneratedKeys != Statement.NO_GENERATED_KEYS) { + throw new SQLFeatureNotSupportedException("Auto-generated keys are not supported"); + } + return prepareStatement(sql); + } + + @Override + public PreparedStatement prepareStatement(String sql, int[] columnIndexes) throws SQLException { + if (columnIndexes != null && columnIndexes.length > 0) { + throw new SQLFeatureNotSupportedException("Auto-generated keys are not supported"); + } + return prepareStatement(sql); + } + + @Override + public PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException { + if (columnNames != null && columnNames.length > 0) { + throw new SQLFeatureNotSupportedException("Auto-generated keys are not supported"); + } + return prepareStatement(sql); + } + + @Override + public Clob createClob() throws SQLFeatureNotSupportedException { + throw new SQLFeatureNotSupportedException("Clob not supported"); + } + + @Override + public Blob createBlob() throws SQLFeatureNotSupportedException { + throw new SQLFeatureNotSupportedException("Blob not supported"); + } + + @Override + public NClob createNClob() throws SQLFeatureNotSupportedException { + throw new SQLFeatureNotSupportedException("NClob not supported"); + } + + @Override + public SQLXML createSQLXML() throws SQLFeatureNotSupportedException { + throw new SQLFeatureNotSupportedException("SQLXML not supported"); + } + + @Override + public boolean isValid(int timeout) { + return !closed; + } + + @Override + public void setClientInfo(String name, String value) { + // ignored + } + + @Override + public void setClientInfo(Properties properties) { + // ignored + } + + @Override + public String getClientInfo(String name) { + return config.getRawProperties().getProperty(name); + } + + @Override + public Properties getClientInfo() { + Properties copy = new Properties(); + copy.putAll(config.getRawProperties()); + return copy; + } + + @Override + public Array createArrayOf(String typeName, Object[] elements) throws SQLFeatureNotSupportedException { + throw new SQLFeatureNotSupportedException("Arrays are not supported"); + } + + @Override + public Struct createStruct(String typeName, Object[] attributes) throws SQLFeatureNotSupportedException { + throw new SQLFeatureNotSupportedException("Structs are not supported"); + } + + @Override + public void setSchema(String schema) { + // no-op + } + + @Override + public String getSchema() { + return null; + } + + @Override + public void abort(java.util.concurrent.Executor executor) { + close(); + } + + @Override + public void setNetworkTimeout(java.util.concurrent.Executor executor, int milliseconds) throws SQLException { + if (milliseconds <= 0) { + throw new SQLException("Network timeout must be positive"); + } + } + + @Override + public int getNetworkTimeout() { + return (int) config.getTimeout().toMillis(); + } + + @Override + public T unwrap(Class iface) throws SQLException { + if (iface.isInstance(this)) { + return iface.cast(this); + } + throw new SQLFeatureNotSupportedException("Not a wrapper for " + iface.getName()); + } + + @Override + public boolean isWrapperFor(Class iface) throws SQLException { + return iface.isInstance(this); + } + + LogsqlConnectionConfig getConfig() { + return config; + } + + HttpClient getHttpClient() { + return httpClient; + } + + ObjectMapper getMapper() { + return mapper; + } + + private static final class InsecureTrustManager implements X509TrustManager { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + // trust all + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + // trust all + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } +} diff --git a/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlConnectionConfig.java b/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlConnectionConfig.java new file mode 100644 index 0000000..c06825e --- /dev/null +++ b/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlConnectionConfig.java @@ -0,0 +1,127 @@ +package com.victoriametrics.logsql.jdbc; + +import java.sql.DriverPropertyInfo; +import java.sql.SQLException; +import java.time.Duration; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Properties; + +final class LogsqlConnectionConfig { + + static final String DEFAULT_HOST = "localhost"; + static final int DEFAULT_PORT = 8080; + static final String DEFAULT_SCHEME = "http"; + static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(60); + + private final String host; + private final int port; + private final String scheme; + private final String basePath; + private final String endpoint; + private final String bearerToken; + private final Duration timeout; + private final boolean verifyTls; + private final Map headers; + private final Properties rawProperties; + + LogsqlConnectionConfig( + String host, + int port, + String scheme, + String basePath, + String endpoint, + String bearerToken, + Duration timeout, + boolean verifyTls, + Map headers, + Properties rawProperties + ) { + this.host = Objects.requireNonNullElse(host, DEFAULT_HOST); + this.port = port <= 0 ? DEFAULT_PORT : port; + this.scheme = scheme == null || scheme.isBlank() ? DEFAULT_SCHEME : scheme.toLowerCase(Locale.ROOT); + this.basePath = basePath == null ? "" : basePath; + this.endpoint = endpoint; + this.bearerToken = bearerToken; + this.timeout = timeout == null ? DEFAULT_TIMEOUT : timeout; + this.verifyTls = verifyTls; + this.headers = headers == null ? Collections.emptyMap() : Collections.unmodifiableMap(new LinkedHashMap<>(headers)); + this.rawProperties = rawProperties; + } + + String getHost() { + return host; + } + + int getPort() { + return port; + } + + String getScheme() { + return scheme; + } + + String getBasePath() { + return basePath; + } + + String getEndpoint() { + return endpoint; + } + + String getBearerToken() { + return bearerToken; + } + + Duration getTimeout() { + return timeout; + } + + boolean isVerifyTls() { + return verifyTls; + } + + Map getHeaders() { + return headers; + } + + Properties getRawProperties() { + return rawProperties; + } + + DriverPropertyInfo[] toDriverPropertyInfo() throws SQLException { + DriverPropertyInfo hostInfo = new DriverPropertyInfo("host", host); + hostInfo.description = "sql-to-logsql service host"; + + DriverPropertyInfo portInfo = new DriverPropertyInfo("port", Integer.toString(port)); + portInfo.description = "sql-to-logsql service port"; + + DriverPropertyInfo schemeInfo = new DriverPropertyInfo("scheme", scheme); + schemeInfo.description = "HTTP scheme (http or https)"; + + DriverPropertyInfo endpointInfo = new DriverPropertyInfo("endpoint", endpoint); + endpointInfo.description = "VictoriaLogs endpoint URL"; + + DriverPropertyInfo bearerTokenInfo = new DriverPropertyInfo("bearerToken", bearerToken); + bearerTokenInfo.description = "Bearer token for VictoriaLogs"; + + DriverPropertyInfo timeoutInfo = new DriverPropertyInfo("timeout", Long.toString(timeout.toMillis())); + timeoutInfo.description = "Request timeout in milliseconds"; + + DriverPropertyInfo verifyInfo = new DriverPropertyInfo("verify", Boolean.toString(verifyTls)); + verifyInfo.description = "Verify TLS certificates when using HTTPS"; + + return new DriverPropertyInfo[] { + hostInfo, + portInfo, + schemeInfo, + endpointInfo, + bearerTokenInfo, + timeoutInfo, + verifyInfo + }; + } +} diff --git a/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlDriver.java b/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlDriver.java new file mode 100644 index 0000000..cfedd44 --- /dev/null +++ b/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlDriver.java @@ -0,0 +1,69 @@ +package com.victoriametrics.logsql.jdbc; + +import java.sql.Connection; +import java.sql.Driver; +import java.sql.DriverManager; +import java.sql.DriverPropertyInfo; +import java.sql.SQLException; +import java.util.Properties; +import java.util.logging.Logger; + +/** + * JDBC driver implementation for the sql-to-logsql translation service. + */ +public final class LogsqlDriver implements Driver { + + /** + * JDBC URL prefix for the driver. + */ + public static final String URL_PREFIX = "jdbc:logsql://"; + + static { + try { + DriverManager.registerDriver(new LogsqlDriver()); + } catch (SQLException e) { + throw new ExceptionInInitializerError(e); + } + } + + @Override + public Connection connect(String url, Properties info) throws SQLException { + if (!acceptsURL(url)) { + return null; + } + + LogsqlConnectionConfig config = LogsqlUrlParser.parse(url, info); + return new LogsqlConnection(config); + } + + @Override + public boolean acceptsURL(String url) { + return url != null && url.startsWith(URL_PREFIX); + } + + @Override + public DriverPropertyInfo[] getPropertyInfo(String url, Properties info) throws SQLException { + LogsqlConnectionConfig config = LogsqlUrlParser.parse(url, info); + return config.toDriverPropertyInfo(); + } + + @Override + public int getMajorVersion() { + return 0; + } + + @Override + public int getMinorVersion() { + return 1; + } + + @Override + public boolean jdbcCompliant() { + return false; + } + + @Override + public Logger getParentLogger() { + return Logger.getLogger("com.victoriametrics.logsql.jdbc"); + } +} diff --git a/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlPreparedStatement.java b/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlPreparedStatement.java new file mode 100644 index 0000000..975b942 --- /dev/null +++ b/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlPreparedStatement.java @@ -0,0 +1,503 @@ +package com.victoriametrics.logsql.jdbc; + +import java.io.InputStream; +import java.io.Reader; +import java.math.BigDecimal; +import java.net.URL; +import java.sql.Array; +import java.sql.Blob; +import java.sql.Clob; +import java.sql.Date; +import java.sql.NClob; +import java.sql.ParameterMetaData; +import java.sql.PreparedStatement; +import java.sql.Ref; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.RowId; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.sql.SQLType; +import java.sql.SQLXML; +import java.sql.Time; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.Calendar; + +class LogsqlPreparedStatement extends LogsqlStatement implements PreparedStatement { + + private static final Object UNSET = new Object(); + private static final Object NULL_VALUE = new Object(); + + private final String sqlTemplate; + private final int parameterCount; + private final Object[] parameters; + + LogsqlPreparedStatement(LogsqlConnection connection, String sql) throws SQLException { + super(connection); + if (sql == null) { + throw new SQLException("SQL must not be null"); + } + this.sqlTemplate = sql; + this.parameterCount = countParameters(sql); + this.parameters = new Object[parameterCount]; + Arrays.fill(this.parameters, UNSET); + } + + @Override + public ResultSet executeQuery() throws SQLException { + checkOpen(); + return super.executeQuery(renderSql()); + } + + @Override + public int executeUpdate() throws SQLException { + throw new SQLFeatureNotSupportedException("Updates are not supported"); + } + + @Override + public void setNull(int parameterIndex, int sqlType) throws SQLException { + setParameter(parameterIndex, NULL_VALUE); + } + + @Override + public void setBoolean(int parameterIndex, boolean x) throws SQLException { + setParameter(parameterIndex, x); + } + + @Override + public void setByte(int parameterIndex, byte x) throws SQLException { + setParameter(parameterIndex, x); + } + + @Override + public void setShort(int parameterIndex, short x) throws SQLException { + setParameter(parameterIndex, x); + } + + @Override + public void setInt(int parameterIndex, int x) throws SQLException { + setParameter(parameterIndex, x); + } + + @Override + public void setLong(int parameterIndex, long x) throws SQLException { + setParameter(parameterIndex, x); + } + + @Override + public void setFloat(int parameterIndex, float x) throws SQLException { + setParameter(parameterIndex, x); + } + + @Override + public void setDouble(int parameterIndex, double x) throws SQLException { + setParameter(parameterIndex, x); + } + + @Override + public void setBigDecimal(int parameterIndex, BigDecimal x) throws SQLException { + setParameter(parameterIndex, x); + } + + @Override + public void setString(int parameterIndex, String x) throws SQLException { + setParameter(parameterIndex, x); + } + + @Override + public void setBytes(int parameterIndex, byte[] x) throws SQLException { + setParameter(parameterIndex, x == null ? NULL_VALUE : x.clone()); + } + + @Override + public void setDate(int parameterIndex, Date x) throws SQLException { + setParameter(parameterIndex, x == null ? NULL_VALUE : new Date(x.getTime())); + } + + @Override + public void setTime(int parameterIndex, Time x) throws SQLException { + setParameter(parameterIndex, x == null ? NULL_VALUE : new Time(x.getTime())); + } + + @Override + public void setTimestamp(int parameterIndex, Timestamp x) throws SQLException { + setParameter(parameterIndex, x == null ? NULL_VALUE : new Timestamp(x.getTime())); + } + + @Override + public void setAsciiStream(int parameterIndex, InputStream x, int length) throws SQLException { + throw new SQLFeatureNotSupportedException("Streams are not supported"); + } + + @Override + public void setUnicodeStream(int parameterIndex, InputStream x, int length) throws SQLException { + throw new SQLFeatureNotSupportedException("Streams are not supported"); + } + + @Override + public void setBinaryStream(int parameterIndex, InputStream x, int length) throws SQLException { + throw new SQLFeatureNotSupportedException("Streams are not supported"); + } + + @Override + public void clearParameters() throws SQLException { + Arrays.fill(parameters, UNSET); + } + + @Override + public void setObject(int parameterIndex, Object x, int targetSqlType, int scaleOrLength) throws SQLException { + setObject(parameterIndex, x); + } + + @Override + public void setObject(int parameterIndex, Object x, int targetSqlType) throws SQLException { + setObject(parameterIndex, x); + } + + @Override + public void setObject(int parameterIndex, Object x) throws SQLException { + setParameter(parameterIndex, x == null ? NULL_VALUE : x); + } + + @Override + public boolean execute() throws SQLException { + checkOpen(); + super.executeQuery(renderSql()); + return true; + } + + @Override + public void addBatch() throws SQLException { + throw new SQLFeatureNotSupportedException("Batch execution is not supported"); + } + + @Override + public void setCharacterStream(int parameterIndex, Reader reader, int length) throws SQLException { + throw new SQLFeatureNotSupportedException("Streams are not supported"); + } + + @Override + public void setRef(int parameterIndex, Ref x) throws SQLException { + throw new SQLFeatureNotSupportedException("SQL REF is not supported"); + } + + @Override + public void setBlob(int parameterIndex, Blob x) throws SQLException { + throw new SQLFeatureNotSupportedException("Blob is not supported"); + } + + @Override + public void setClob(int parameterIndex, Clob x) throws SQLException { + throw new SQLFeatureNotSupportedException("Clob is not supported"); + } + + @Override + public void setArray(int parameterIndex, Array x) throws SQLException { + throw new SQLFeatureNotSupportedException("Array is not supported"); + } + + @Override + public ResultSetMetaData getMetaData() throws SQLException { + ResultSet rs = getCurrentResultSet(); + return rs != null ? rs.getMetaData() : null; + } + + @Override + public void setDate(int parameterIndex, Date x, Calendar cal) throws SQLException { + setDate(parameterIndex, x); + } + + @Override + public void setTime(int parameterIndex, Time x, Calendar cal) throws SQLException { + setTime(parameterIndex, x); + } + + @Override + public void setTimestamp(int parameterIndex, Timestamp x, Calendar cal) throws SQLException { + setTimestamp(parameterIndex, x); + } + + @Override + public void setNull(int parameterIndex, int sqlType, String typeName) throws SQLException { + setNull(parameterIndex, sqlType); + } + + @Override + public void setURL(int parameterIndex, URL x) throws SQLException { + setParameter(parameterIndex, x == null ? NULL_VALUE : x.toString()); + } + + @Override + public ParameterMetaData getParameterMetaData() throws SQLException { + throw new SQLFeatureNotSupportedException("Parameter metadata is not supported"); + } + + @Override + public void setRowId(int parameterIndex, RowId x) throws SQLException { + throw new SQLFeatureNotSupportedException("RowId is not supported"); + } + + @Override + public void setNString(int parameterIndex, String value) throws SQLException { + setString(parameterIndex, value); + } + + @Override + public void setNCharacterStream(int parameterIndex, Reader value, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("Streams are not supported"); + } + + @Override + public void setNClob(int parameterIndex, NClob value) throws SQLException { + throw new SQLFeatureNotSupportedException("NClob is not supported"); + } + + @Override + public void setClob(int parameterIndex, Reader reader, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("Streams are not supported"); + } + + @Override + public void setBlob(int parameterIndex, InputStream inputStream, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("Streams are not supported"); + } + + @Override + public void setNClob(int parameterIndex, Reader reader, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("Streams are not supported"); + } + + @Override + public void setSQLXML(int parameterIndex, SQLXML xmlObject) throws SQLException { + throw new SQLFeatureNotSupportedException("SQLXML is not supported"); + } + + @Override + public void setObject(int parameterIndex, Object x, SQLType targetSqlType, int scaleOrLength) throws SQLException { + setObject(parameterIndex, x); + } + + @Override + public void setObject(int parameterIndex, Object x, SQLType targetSqlType) throws SQLException { + setObject(parameterIndex, x); + } + + @Override + public long executeLargeUpdate() throws SQLException { + throw new SQLFeatureNotSupportedException("Updates are not supported"); + } + + @Override + public void setAsciiStream(int parameterIndex, InputStream x, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("Streams are not supported"); + } + + @Override + public void setBinaryStream(int parameterIndex, InputStream x, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("Streams are not supported"); + } + + @Override + public void setCharacterStream(int parameterIndex, Reader reader, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("Streams are not supported"); + } + + @Override + public void setAsciiStream(int parameterIndex, InputStream x) throws SQLException { + throw new SQLFeatureNotSupportedException("Streams are not supported"); + } + + @Override + public void setBinaryStream(int parameterIndex, InputStream x) throws SQLException { + throw new SQLFeatureNotSupportedException("Streams are not supported"); + } + + @Override + public void setCharacterStream(int parameterIndex, Reader reader) throws SQLException { + throw new SQLFeatureNotSupportedException("Streams are not supported"); + } + + @Override + public void setNCharacterStream(int parameterIndex, Reader value) throws SQLException { + throw new SQLFeatureNotSupportedException("Streams are not supported"); + } + + @Override + public void setClob(int parameterIndex, Reader reader) throws SQLException { + throw new SQLFeatureNotSupportedException("Streams are not supported"); + } + + @Override + public void setBlob(int parameterIndex, InputStream inputStream) throws SQLException { + throw new SQLFeatureNotSupportedException("Streams are not supported"); + } + + @Override + public void setNClob(int parameterIndex, Reader reader) throws SQLException { + throw new SQLFeatureNotSupportedException("Streams are not supported"); + } + + private void setParameter(int parameterIndex, Object value) throws SQLException { + if (parameterIndex < 1 || parameterIndex > parameterCount) { + throw new SQLException("Parameter index out of range: " + parameterIndex); + } + parameters[parameterIndex - 1] = value; + } + + private String renderSql() throws SQLException { + if (parameterCount == 0) { + return sqlTemplate; + } + + StringBuilder builder = new StringBuilder(); + boolean inSingleQuote = false; + boolean inDoubleQuote = false; + int parameterIndex = 0; + int length = sqlTemplate.length(); + for (int i = 0; i < length; i++) { + char c = sqlTemplate.charAt(i); + if (c == '\'') { + builder.append(c); + if (inSingleQuote && i + 1 < length && sqlTemplate.charAt(i + 1) == '\'') { + builder.append('\''); + i++; + } else { + inSingleQuote = !inSingleQuote; + } + } else if (c == '"') { + builder.append(c); + if (inDoubleQuote && i + 1 < length && sqlTemplate.charAt(i + 1) == '"') { + builder.append('"'); + i++; + } else { + inDoubleQuote = !inDoubleQuote; + } + } else if (!inSingleQuote && !inDoubleQuote && c == '?') { + if (parameterIndex >= parameterCount) { + throw new SQLException("Too many parameters in SQL template"); + } + builder.append(formatParameter(getParameterValue(parameterIndex))); + parameterIndex++; + } else { + builder.append(c); + } + } + + if (parameterIndex < parameterCount) { + throw new SQLException("Not all parameters were set"); + } + return builder.toString(); + } + + private Object getParameterValue(int index) throws SQLException { + Object value = parameters[index]; + if (value == UNSET) { + throw new SQLException("Parameter " + (index + 1) + " is not set"); + } + return value == NULL_VALUE ? null : value; + } + + private String formatParameter(Object value) throws SQLException { + if (value == null) { + return "NULL"; + } + if (value instanceof String || value instanceof Character) { + return quote(value.toString()); + } + if (value instanceof Boolean) { + return ((Boolean) value) ? "TRUE" : "FALSE"; + } + if (value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long) { + return value.toString(); + } + if (value instanceof Float || value instanceof Double) { + if (((Number) value).doubleValue() == Double.POSITIVE_INFINITY || ((Number) value).doubleValue() == Double.NEGATIVE_INFINITY || Double.isNaN(((Number) value).doubleValue())) { + throw new SQLException("Floating point value cannot be represented: " + value); + } + return value.toString(); + } + if (value instanceof BigDecimal) { + return ((BigDecimal) value).toPlainString(); + } + if (value instanceof byte[]) { + return formatBytes((byte[]) value); + } + if (value instanceof Date) { + return quote(value.toString()); + } + if (value instanceof Time) { + return quote(value.toString()); + } + if (value instanceof Timestamp) { + return quote(value.toString()); + } + if (value instanceof Instant) { + return quote(DateTimeFormatter.ISO_INSTANT.format((Instant) value)); + } + if (value instanceof OffsetDateTime) { + return quote(DateTimeFormatter.ISO_OFFSET_DATE_TIME.format((OffsetDateTime) value)); + } + if (value instanceof ZonedDateTime) { + return quote(DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(((ZonedDateTime) value).toOffsetDateTime())); + } + if (value instanceof LocalDateTime) { + return quote(DateTimeFormatter.ISO_LOCAL_DATE_TIME.format((LocalDateTime) value)); + } + if (value instanceof LocalDate) { + return quote(DateTimeFormatter.ISO_LOCAL_DATE.format((LocalDate) value)); + } + if (value instanceof Calendar) { + Calendar calendar = (Calendar) value; + return quote(DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(ZonedDateTime.ofInstant(calendar.toInstant(), calendar.getTimeZone().toZoneId()))); + } + return quote(value.toString()); + } + + private String quote(String value) { + String escaped = value.replace("'", "''"); + return "'" + escaped + "'"; + } + + private int countParameters(String sql) { + boolean inSingleQuote = false; + boolean inDoubleQuote = false; + int count = 0; + int length = sql.length(); + for (int i = 0; i < length; i++) { + char c = sql.charAt(i); + if (c == '\'') { + if (inSingleQuote && i + 1 < length && sql.charAt(i + 1) == '\'') { + i++; + } else { + inSingleQuote = !inSingleQuote; + } + } else if (c == '\"') { + if (inDoubleQuote && i + 1 < length && sql.charAt(i + 1) == '\"') { + i++; + } else { + inDoubleQuote = !inDoubleQuote; + } + } else if (!inSingleQuote && !inDoubleQuote && c == '?') { + count++; + } + } + return count; + } + + private String formatBytes(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + sb.append("X'"); + for (byte b : bytes) { + sb.append(String.format("%02X", b)); + } + sb.append("'"); + return sb.toString(); + } +} diff --git a/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlQueryResult.java b/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlQueryResult.java new file mode 100644 index 0000000..cf0c2b4 --- /dev/null +++ b/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlQueryResult.java @@ -0,0 +1,34 @@ +package com.victoriametrics.logsql.jdbc; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +final class LogsqlQueryResult { + + private final String logsql; + private final List columnNames; + private final List> rows; + + LogsqlQueryResult(String logsql, List columnNames, List> rows) { + this.logsql = logsql; + this.columnNames = columnNames == null ? Collections.emptyList() : Collections.unmodifiableList(columnNames); + this.rows = rows == null ? Collections.emptyList() : Collections.unmodifiableList(rows); + } + + String getLogsql() { + return logsql; + } + + List getColumnNames() { + return columnNames; + } + + List> getRows() { + return rows; + } + + int getRowCount() { + return rows.size(); + } +} diff --git a/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlResultSet.java b/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlResultSet.java new file mode 100644 index 0000000..da9fba1 --- /dev/null +++ b/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlResultSet.java @@ -0,0 +1,1451 @@ +package com.victoriametrics.logsql.jdbc; + +import java.io.InputStream; +import java.io.Reader; +import java.io.StringReader; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.net.MalformedURLException; +import java.net.URL; +import java.sql.Array; +import java.sql.Statement; +import java.sql.Blob; +import java.sql.Clob; +import java.sql.Date; +import java.sql.NClob; +import java.sql.Ref; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.RowId; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.sql.SQLWarning; +import java.sql.SQLXML; +import java.sql.Time; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Calendar; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +class LogsqlResultSet implements ResultSet { + + private final LogsqlStatement statement; // may be null for metadata result sets + private final List columnNames; + private final Map columnIndexes; + private final List rows; + private final int[] columnTypes; + private final LogsqlResultSetMetaData metaData; + + private int cursor = -1; + private boolean closed = false; + private boolean wasNull = false; + + LogsqlResultSet(LogsqlStatement statement, List columnNames, int[] columnTypes, List rows) { + this.statement = statement; + this.columnNames = columnNames; + this.columnTypes = columnTypes; + this.rows = rows; + this.columnIndexes = new HashMap<>(); + for (int i = 0; i < columnNames.size(); i++) { + columnIndexes.put(columnNames.get(i), i); + } + this.metaData = new LogsqlResultSetMetaData(columnNames, columnTypes); + } + + void closeFromStatement() { + closed = true; + cursor = rows.size(); + } + + @Override + public boolean next() throws SQLException { + ensureOpen(); + if (cursor + 1 < rows.size()) { + cursor++; + wasNull = false; + return true; + } + cursor = rows.size(); + wasNull = false; + return false; + } + + @Override + public void close() throws SQLException { + if (!closed) { + closed = true; + if (statement != null) { + statement.onResultSetClosed(this); + } + } + } + + @Override + public boolean wasNull() { + return wasNull; + } + + @Override + public String getString(int columnIndex) throws SQLException { + Object value = getColumnValue(columnIndex); + return value != null ? value.toString() : null; + } + + @Override + public boolean getBoolean(int columnIndex) throws SQLException { + Boolean value = toBoolean(getColumnValue(columnIndex)); + return value != null && value; + } + + @Override + public byte getByte(int columnIndex) throws SQLException { + Number number = toNumber(getColumnValue(columnIndex)); + return number == null ? 0 : number.byteValue(); + } + + @Override + public short getShort(int columnIndex) throws SQLException { + Number number = toNumber(getColumnValue(columnIndex)); + return number == null ? 0 : number.shortValue(); + } + + @Override + public int getInt(int columnIndex) throws SQLException { + Number number = toNumber(getColumnValue(columnIndex)); + return number == null ? 0 : number.intValue(); + } + + @Override + public long getLong(int columnIndex) throws SQLException { + Number number = toNumber(getColumnValue(columnIndex)); + return number == null ? 0L : number.longValue(); + } + + @Override + public float getFloat(int columnIndex) throws SQLException { + Number number = toNumber(getColumnValue(columnIndex)); + return number == null ? 0F : number.floatValue(); + } + + @Override + public double getDouble(int columnIndex) throws SQLException { + Number number = toNumber(getColumnValue(columnIndex)); + return number == null ? 0D : number.doubleValue(); + } + + @Override + public BigDecimal getBigDecimal(int columnIndex, int scale) throws SQLException { + BigDecimal value = getBigDecimal(columnIndex); + if (value == null) { + return null; + } + return value.setScale(scale, RoundingMode.HALF_UP); + } + + @Override + public byte[] getBytes(int columnIndex) throws SQLException { + Object value = getColumnValue(columnIndex); + if (value == null) { + return null; + } + if (value instanceof byte[]) { + byte[] bytes = (byte[]) value; + return bytes.clone(); + } + if (value instanceof String) { + return ((String) value).getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + throw new SQLException("Cannot convert value to byte[]: " + value.getClass().getName()); + } + + @Override + public Date getDate(int columnIndex) throws SQLException { + return toSqlDate(getColumnValue(columnIndex)); + } + + @Override + public Time getTime(int columnIndex) throws SQLException { + return toSqlTime(getColumnValue(columnIndex)); + } + + @Override + public Timestamp getTimestamp(int columnIndex) throws SQLException { + return toSqlTimestamp(getColumnValue(columnIndex)); + } + + @Override + public InputStream getAsciiStream(int columnIndex) throws SQLException { + throw unsupported(); + } + + @Override + public InputStream getUnicodeStream(int columnIndex) throws SQLException { + throw unsupported(); + } + + @Override + public InputStream getBinaryStream(int columnIndex) throws SQLException { + throw unsupported(); + } + + @Override + public String getString(String columnLabel) throws SQLException { + Object value = getColumnValue(columnLabel); + return value != null ? value.toString() : null; + } + + @Override + public boolean getBoolean(String columnLabel) throws SQLException { + Boolean value = toBoolean(getColumnValue(columnLabel)); + return value != null && value; + } + + @Override + public byte getByte(String columnLabel) throws SQLException { + Number number = toNumber(getColumnValue(columnLabel)); + return number == null ? 0 : number.byteValue(); + } + + @Override + public short getShort(String columnLabel) throws SQLException { + Number number = toNumber(getColumnValue(columnLabel)); + return number == null ? 0 : number.shortValue(); + } + + @Override + public int getInt(String columnLabel) throws SQLException { + Number number = toNumber(getColumnValue(columnLabel)); + return number == null ? 0 : number.intValue(); + } + + @Override + public long getLong(String columnLabel) throws SQLException { + Number number = toNumber(getColumnValue(columnLabel)); + return number == null ? 0L : number.longValue(); + } + + @Override + public float getFloat(String columnLabel) throws SQLException { + Number number = toNumber(getColumnValue(columnLabel)); + return number == null ? 0F : number.floatValue(); + } + + @Override + public double getDouble(String columnLabel) throws SQLException { + Number number = toNumber(getColumnValue(columnLabel)); + return number == null ? 0D : number.doubleValue(); + } + + @Override + public BigDecimal getBigDecimal(String columnLabel, int scale) throws SQLException { + BigDecimal value = getBigDecimal(columnLabel); + if (value == null) { + return null; + } + return value.setScale(scale, RoundingMode.HALF_UP); + } + + @Override + public byte[] getBytes(String columnLabel) throws SQLException { + Object value = getColumnValue(columnLabel); + if (value == null) { + return null; + } + if (value instanceof byte[]) { + return ((byte[]) value).clone(); + } + if (value instanceof String) { + return ((String) value).getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + throw new SQLException("Cannot convert value to byte[]: " + value.getClass().getName()); + } + + @Override + public Date getDate(String columnLabel) throws SQLException { + return toSqlDate(getColumnValue(columnLabel)); + } + + @Override + public Time getTime(String columnLabel) throws SQLException { + return toSqlTime(getColumnValue(columnLabel)); + } + + @Override + public Timestamp getTimestamp(String columnLabel) throws SQLException { + return toSqlTimestamp(getColumnValue(columnLabel)); + } + + @Override + public InputStream getAsciiStream(String columnLabel) throws SQLException { + throw unsupported(); + } + + @Override + public InputStream getUnicodeStream(String columnLabel) throws SQLException { + throw unsupported(); + } + + @Override + public InputStream getBinaryStream(String columnLabel) throws SQLException { + throw unsupported(); + } + + @Override + public SQLWarning getWarnings() throws SQLException { + return null; + } + + @Override + public void clearWarnings() throws SQLException { + // no warnings produced + } + + @Override + public String getCursorName() throws SQLException { + return null; + } + + @Override + public ResultSetMetaData getMetaData() throws SQLException { + return metaData; + } + + @Override + public Object getObject(int columnIndex) throws SQLException { + return getColumnValue(columnIndex); + } + + @Override + public Object getObject(String columnLabel) throws SQLException { + return getColumnValue(columnLabel); + } + + @Override + public int findColumn(String columnLabel) throws SQLException { + Integer index = columnIndexes.get(columnLabel); + if (index == null) { + throw new SQLException("Column not found: " + columnLabel); + } + return index + 1; + } + + @Override + public Reader getCharacterStream(int columnIndex) throws SQLException { + String value = getString(columnIndex); + return value == null ? null : new StringReader(value); + } + + @Override + public Reader getCharacterStream(String columnLabel) throws SQLException { + String value = getString(columnLabel); + return value == null ? null : new StringReader(value); + } + + @Override + public BigDecimal getBigDecimal(int columnIndex) throws SQLException { + Object value = getColumnValue(columnIndex); + if (value == null) { + return null; + } + if (value instanceof BigDecimal) { + return (BigDecimal) value; + } + if (value instanceof Number) { + return BigDecimal.valueOf(((Number) value).doubleValue()); + } + if (value instanceof String) { + try { + return new BigDecimal((String) value); + } catch (NumberFormatException ex) { + throw new SQLException("Failed to convert value to BigDecimal: " + value, ex); + } + } + throw new SQLException("Cannot convert value to BigDecimal: " + value.getClass().getName()); + } + + @Override + public BigDecimal getBigDecimal(String columnLabel) throws SQLException { + Object value = getColumnValue(columnLabel); + if (value == null) { + return null; + } + if (value instanceof BigDecimal) { + return (BigDecimal) value; + } + if (value instanceof Number) { + return BigDecimal.valueOf(((Number) value).doubleValue()); + } + if (value instanceof String) { + try { + return new BigDecimal((String) value); + } catch (NumberFormatException ex) { + throw new SQLException("Failed to convert value to BigDecimal: " + value, ex); + } + } + throw new SQLException("Cannot convert value to BigDecimal: " + value.getClass().getName()); + } + + @Override + public boolean isBeforeFirst() throws SQLException { + ensureOpen(); + return cursor < 0 && !rows.isEmpty(); + } + + @Override + public boolean isAfterLast() throws SQLException { + ensureOpen(); + return rows.isEmpty() ? false : cursor >= rows.size(); + } + + @Override + public boolean isFirst() throws SQLException { + ensureOpen(); + return cursor == 0 && !rows.isEmpty(); + } + + @Override + public boolean isLast() throws SQLException { + ensureOpen(); + return !rows.isEmpty() && cursor == rows.size() - 1; + } + + @Override + public void beforeFirst() throws SQLException { + ensureOpen(); + cursor = -1; + } + + @Override + public void afterLast() throws SQLException { + ensureOpen(); + cursor = rows.size(); + } + + @Override + public boolean first() throws SQLException { + ensureOpen(); + if (rows.isEmpty()) { + cursor = rows.size(); + return false; + } + cursor = 0; + wasNull = false; + return true; + } + + @Override + public boolean last() throws SQLException { + ensureOpen(); + if (rows.isEmpty()) { + cursor = rows.size(); + return false; + } + cursor = rows.size() - 1; + wasNull = false; + return true; + } + + @Override + public int getRow() throws SQLException { + ensureOpen(); + if (cursor < 0 || cursor >= rows.size()) { + return 0; + } + return cursor + 1; + } + + @Override + public boolean absolute(int row) throws SQLException { + ensureOpen(); + int target; + if (row > 0) { + target = row - 1; + } else if (row < 0) { + target = rows.size() + row; + } else { + cursor = -1; + return false; + } + if (target < 0) { + cursor = -1; + return false; + } + if (target >= rows.size()) { + cursor = rows.size(); + return false; + } + cursor = target; + wasNull = false; + return true; + } + + @Override + public boolean relative(int rowsOffset) throws SQLException { + ensureOpen(); + int target = cursor + rowsOffset; + if (cursor < 0) { + target = rowsOffset - 1; + } + if (target < 0) { + cursor = -1; + return false; + } + if (target >= rows.size()) { + cursor = rows.size(); + return false; + } + cursor = target; + wasNull = false; + return true; + } + + @Override + public boolean previous() throws SQLException { + ensureOpen(); + if (cursor <= 0) { + cursor = -1; + return false; + } + cursor--; + wasNull = false; + return true; + } + + @Override + public void setFetchDirection(int direction) throws SQLException { + if (direction != ResultSet.FETCH_FORWARD) { + throw unsupported(); + } + } + + @Override + public int getFetchDirection() throws SQLException { + return ResultSet.FETCH_FORWARD; + } + + @Override + public void setFetchSize(int rows) throws SQLException { + if (rows < 0) { + throw new SQLException("Fetch size must be non-negative"); + } + } + + @Override + public int getFetchSize() throws SQLException { + return 0; + } + + @Override + public int getType() throws SQLException { + return ResultSet.TYPE_FORWARD_ONLY; + } + + @Override + public int getConcurrency() throws SQLException { + return ResultSet.CONCUR_READ_ONLY; + } + + @Override + public boolean rowUpdated() throws SQLException { + return false; + } + + @Override + public boolean rowInserted() throws SQLException { + return false; + } + + @Override + public boolean rowDeleted() throws SQLException { + return false; + } + + @Override + public void updateNull(int columnIndex) throws SQLException { + throw unsupported(); + } + + @Override + public void updateBoolean(int columnIndex, boolean x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateByte(int columnIndex, byte x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateShort(int columnIndex, short x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateInt(int columnIndex, int x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateLong(int columnIndex, long x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateFloat(int columnIndex, float x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateDouble(int columnIndex, double x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateBigDecimal(int columnIndex, BigDecimal x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateString(int columnIndex, String x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateBytes(int columnIndex, byte[] x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateDate(int columnIndex, Date x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateTime(int columnIndex, Time x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateTimestamp(int columnIndex, Timestamp x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateAsciiStream(int columnIndex, InputStream x, int length) throws SQLException { + throw unsupported(); + } + + @Override + public void updateBinaryStream(int columnIndex, InputStream x, int length) throws SQLException { + throw unsupported(); + } + + @Override + public void updateCharacterStream(int columnIndex, Reader x, int length) throws SQLException { + throw unsupported(); + } + + @Override + public void updateObject(int columnIndex, Object x, int scaleOrLength) throws SQLException { + throw unsupported(); + } + + @Override + public void updateObject(int columnIndex, Object x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateNull(String columnLabel) throws SQLException { + throw unsupported(); + } + + @Override + public void updateBoolean(String columnLabel, boolean x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateByte(String columnLabel, byte x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateShort(String columnLabel, short x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateInt(String columnLabel, int x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateLong(String columnLabel, long x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateFloat(String columnLabel, float x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateDouble(String columnLabel, double x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateBigDecimal(String columnLabel, BigDecimal x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateString(String columnLabel, String x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateBytes(String columnLabel, byte[] x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateDate(String columnLabel, Date x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateTime(String columnLabel, Time x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateTimestamp(String columnLabel, Timestamp x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateAsciiStream(String columnLabel, InputStream x, int length) throws SQLException { + throw unsupported(); + } + + @Override + public void updateBinaryStream(String columnLabel, InputStream x, int length) throws SQLException { + throw unsupported(); + } + + @Override + public void updateCharacterStream(String columnLabel, Reader reader, int length) throws SQLException { + throw unsupported(); + } + + @Override + public void updateObject(String columnLabel, Object x, int scaleOrLength) throws SQLException { + throw unsupported(); + } + + @Override + public void updateObject(String columnLabel, Object x) throws SQLException { + throw unsupported(); + } + + @Override + public void insertRow() throws SQLException { + throw unsupported(); + } + + @Override + public void updateRow() throws SQLException { + throw unsupported(); + } + + @Override + public void deleteRow() throws SQLException { + throw unsupported(); + } + + @Override + public void refreshRow() throws SQLException { + throw unsupported(); + } + + @Override + public void cancelRowUpdates() throws SQLException { + throw unsupported(); + } + + @Override + public void moveToInsertRow() throws SQLException { + throw unsupported(); + } + + @Override + public void moveToCurrentRow() throws SQLException { + throw unsupported(); + } + + @Override + public Statement getStatement() throws SQLException { + return statement; + } + + @Override + public Object getObject(int columnIndex, Map> map) throws SQLException { + return getObject(columnIndex); + } + + @Override + public Ref getRef(int columnIndex) throws SQLException { + throw unsupported(); + } + + @Override + public Blob getBlob(int columnIndex) throws SQLException { + throw unsupported(); + } + + @Override + public Clob getClob(int columnIndex) throws SQLException { + throw unsupported(); + } + + @Override + public Array getArray(int columnIndex) throws SQLException { + throw unsupported(); + } + + @Override + public Object getObject(String columnLabel, Map> map) throws SQLException { + return getObject(columnLabel); + } + + @Override + public Ref getRef(String columnLabel) throws SQLException { + throw unsupported(); + } + + @Override + public Blob getBlob(String columnLabel) throws SQLException { + throw unsupported(); + } + + @Override + public Clob getClob(String columnLabel) throws SQLException { + throw unsupported(); + } + + @Override + public Array getArray(String columnLabel) throws SQLException { + throw unsupported(); + } + + @Override + public Date getDate(int columnIndex, java.util.Calendar cal) throws SQLException { + return adjustWithCalendar(getDate(columnIndex), cal); + } + + @Override + public Date getDate(String columnLabel, java.util.Calendar cal) throws SQLException { + return adjustWithCalendar(getDate(columnLabel), cal); + } + + @Override + public Time getTime(int columnIndex, java.util.Calendar cal) throws SQLException { + return adjustWithCalendar(getTime(columnIndex), cal); + } + + @Override + public Time getTime(String columnLabel, java.util.Calendar cal) throws SQLException { + return adjustWithCalendar(getTime(columnLabel), cal); + } + + @Override + public Timestamp getTimestamp(int columnIndex, java.util.Calendar cal) throws SQLException { + return adjustWithCalendar(getTimestamp(columnIndex), cal); + } + + @Override + public Timestamp getTimestamp(String columnLabel, java.util.Calendar cal) throws SQLException { + return adjustWithCalendar(getTimestamp(columnLabel), cal); + } + + @Override + public URL getURL(int columnIndex) throws SQLException { + String value = getString(columnIndex); + if (value == null) { + return null; + } + try { + return new URL(value); + } catch (MalformedURLException e) { + throw new SQLException("Invalid URL value: " + value, e); + } + } + + @Override + public URL getURL(String columnLabel) throws SQLException { + String value = getString(columnLabel); + if (value == null) { + return null; + } + try { + return new URL(value); + } catch (MalformedURLException e) { + throw new SQLException("Invalid URL value: " + value, e); + } + } + + @Override + public void updateRef(int columnIndex, Ref x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateRef(String columnLabel, Ref x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateBlob(int columnIndex, Blob x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateBlob(String columnLabel, Blob x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateClob(int columnIndex, Clob x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateClob(String columnLabel, Clob x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateArray(int columnIndex, Array x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateArray(String columnLabel, Array x) throws SQLException { + throw unsupported(); + } + + @Override + public RowId getRowId(int columnIndex) throws SQLException { + throw unsupported(); + } + + @Override + public RowId getRowId(String columnLabel) throws SQLException { + throw unsupported(); + } + + @Override + public void updateRowId(int columnIndex, RowId x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateRowId(String columnLabel, RowId x) throws SQLException { + throw unsupported(); + } + + @Override + public int getHoldability() throws SQLException { + return ResultSet.HOLD_CURSORS_OVER_COMMIT; + } + + @Override + public boolean isClosed() throws SQLException { + return closed; + } + + @Override + public void updateNString(int columnIndex, String nString) throws SQLException { + throw unsupported(); + } + + @Override + public void updateNString(String columnLabel, String nString) throws SQLException { + throw unsupported(); + } + + @Override + public void updateNClob(int columnIndex, NClob nClob) throws SQLException { + throw unsupported(); + } + + @Override + public void updateNClob(String columnLabel, NClob nClob) throws SQLException { + throw unsupported(); + } + + @Override + public NClob getNClob(int columnIndex) throws SQLException { + throw unsupported(); + } + + @Override + public NClob getNClob(String columnLabel) throws SQLException { + throw unsupported(); + } + + @Override + public SQLXML getSQLXML(int columnIndex) throws SQLException { + throw unsupported(); + } + + @Override + public SQLXML getSQLXML(String columnLabel) throws SQLException { + throw unsupported(); + } + + @Override + public void updateSQLXML(int columnIndex, SQLXML xmlObject) throws SQLException { + throw unsupported(); + } + + @Override + public void updateSQLXML(String columnLabel, SQLXML xmlObject) throws SQLException { + throw unsupported(); + } + + @Override + public String getNString(int columnIndex) throws SQLException { + return getString(columnIndex); + } + + @Override + public String getNString(String columnLabel) throws SQLException { + return getString(columnLabel); + } + + @Override + public Reader getNCharacterStream(int columnIndex) throws SQLException { + throw unsupported(); + } + + @Override + public Reader getNCharacterStream(String columnLabel) throws SQLException { + throw unsupported(); + } + + @Override + public void updateNCharacterStream(int columnIndex, Reader x, long length) throws SQLException { + throw unsupported(); + } + + @Override + public void updateNCharacterStream(String columnLabel, Reader reader, long length) throws SQLException { + throw unsupported(); + } + + @Override + public void updateAsciiStream(int columnIndex, InputStream x, long length) throws SQLException { + throw unsupported(); + } + + @Override + public void updateBinaryStream(int columnIndex, InputStream x, long length) throws SQLException { + throw unsupported(); + } + + @Override + public void updateCharacterStream(int columnIndex, Reader x, long length) throws SQLException { + throw unsupported(); + } + + @Override + public void updateAsciiStream(String columnLabel, InputStream x, long length) throws SQLException { + throw unsupported(); + } + + @Override + public void updateBinaryStream(String columnLabel, InputStream x, long length) throws SQLException { + throw unsupported(); + } + + @Override + public void updateCharacterStream(String columnLabel, Reader reader, long length) throws SQLException { + throw unsupported(); + } + + @Override + public void updateBlob(int columnIndex, InputStream inputStream, long length) throws SQLException { + throw unsupported(); + } + + @Override + public void updateBlob(String columnLabel, InputStream inputStream, long length) throws SQLException { + throw unsupported(); + } + + @Override + public void updateClob(int columnIndex, Reader reader, long length) throws SQLException { + throw unsupported(); + } + + @Override + public void updateClob(String columnLabel, Reader reader, long length) throws SQLException { + throw unsupported(); + } + + @Override + public void updateNClob(int columnIndex, Reader reader, long length) throws SQLException { + throw unsupported(); + } + + @Override + public void updateNClob(String columnLabel, Reader reader, long length) throws SQLException { + throw unsupported(); + } + + @Override + public void updateNCharacterStream(int columnIndex, Reader x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateNCharacterStream(String columnLabel, Reader reader) throws SQLException { + throw unsupported(); + } + + @Override + public void updateAsciiStream(int columnIndex, InputStream x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateBinaryStream(int columnIndex, InputStream x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateCharacterStream(int columnIndex, Reader x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateAsciiStream(String columnLabel, InputStream x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateBinaryStream(String columnLabel, InputStream x) throws SQLException { + throw unsupported(); + } + + @Override + public void updateCharacterStream(String columnLabel, Reader reader) throws SQLException { + throw unsupported(); + } + + @Override + public void updateBlob(int columnIndex, InputStream inputStream) throws SQLException { + throw unsupported(); + } + + @Override + public void updateBlob(String columnLabel, InputStream inputStream) throws SQLException { + throw unsupported(); + } + + @Override + public void updateClob(int columnIndex, Reader reader) throws SQLException { + throw unsupported(); + } + + @Override + public void updateClob(String columnLabel, Reader reader) throws SQLException { + throw unsupported(); + } + + @Override + public void updateNClob(int columnIndex, Reader reader) throws SQLException { + throw unsupported(); + } + + @Override + public void updateNClob(String columnLabel, Reader reader) throws SQLException { + throw unsupported(); + } + + @Override + public T getObject(int columnIndex, Class type) throws SQLException { + return convertToType(getColumnValue(columnIndex), type, columnIndex); + } + + @Override + public T getObject(String columnLabel, Class type) throws SQLException { + return convertToType(getColumnValue(columnLabel), type, columnIndexes.get(columnLabel) + 1); + } + + private T convertToType(Object value, Class type, int columnIndex) throws SQLException { + if (value == null) { + return null; + } + if (type.isInstance(value)) { + return type.cast(value); + } + if (type == String.class) { + return type.cast(value.toString()); + } + if (Number.class.isAssignableFrom(type)) { + Number number = toNumber(value); + if (number == null) { + return null; + } + if (type == Integer.class) { + return type.cast(Integer.valueOf(number.intValue())); + } + if (type == Long.class) { + return type.cast(Long.valueOf(number.longValue())); + } + if (type == Double.class) { + return type.cast(Double.valueOf(number.doubleValue())); + } + if (type == Float.class) { + return type.cast(Float.valueOf(number.floatValue())); + } + if (type == Short.class) { + return type.cast(Short.valueOf(number.shortValue())); + } + if (type == Byte.class) { + return type.cast(Byte.valueOf(number.byteValue())); + } + if (type == BigDecimal.class) { + return type.cast(new BigDecimal(number.toString())); + } + } + if (type == Boolean.class) { + Boolean b = toBoolean(value); + return b == null ? null : type.cast(b); + } + if (type == Date.class) { + return type.cast(toSqlDate(value)); + } + if (type == Time.class) { + return type.cast(toSqlTime(value)); + } + if (type == Timestamp.class) { + return type.cast(toSqlTimestamp(value)); + } + if (type == byte[].class && value instanceof byte[]) { + return type.cast(((byte[]) value).clone()); + } + throw new SQLException("Cannot convert column " + columnIndex + " to type " + type.getName()); + } + + private void ensureOpen() throws SQLException { + if (closed) { + throw new SQLException("ResultSet is closed"); + } + } + + private Object getColumnValue(int columnIndex) throws SQLException { + ensureOpen(); + ensureValidColumnIndex(columnIndex); + if (cursor < 0 || cursor >= rows.size()) { + throw new SQLException("Cursor is not positioned on a row"); + } + Object value = rows.get(cursor)[columnIndex - 1]; + wasNull = value == null; + return value; + } + + private Object getColumnValue(String columnLabel) throws SQLException { + ensureOpen(); + Integer index = columnIndexes.get(columnLabel); + if (index == null) { + throw new SQLException("Column not found: " + columnLabel); + } + return getColumnValue(index + 1); + } + + private void ensureValidColumnIndex(int columnIndex) throws SQLException { + if (columnIndex < 1 || columnIndex > columnNames.size()) { + throw new SQLException("Column index out of range: " + columnIndex); + } + } + + private Number toNumber(Object value) throws SQLException { + if (value == null) { + return null; + } + if (value instanceof Number) { + return (Number) value; + } + if (value instanceof String) { + try { + return new BigDecimal((String) value); + } catch (NumberFormatException ex) { + throw new SQLException("Failed to convert value to number: " + value, ex); + } + } + throw new SQLException("Cannot convert value to number: " + value.getClass().getName()); + } + + private Boolean toBoolean(Object value) throws SQLException { + if (value == null) { + return null; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + if (value instanceof Number) { + return ((Number) value).intValue() != 0; + } + if (value instanceof String) { + String normalized = ((String) value).trim().toLowerCase(java.util.Locale.ROOT); + if (normalized.isEmpty()) { + return null; + } + if (normalized.equals("true") || normalized.equals("1") || normalized.equals("yes")) { + return true; + } + if (normalized.equals("false") || normalized.equals("0") || normalized.equals("no")) { + return false; + } + } + throw new SQLException("Cannot convert value to boolean: " + value); + } + + private Date toSqlDate(Object value) throws SQLException { + if (value == null) { + return null; + } + if (value instanceof Date) { + return (Date) value; + } + if (value instanceof java.util.Date) { + return new Date(((java.util.Date) value).getTime()); + } + if (value instanceof Number) { + return new Date(((Number) value).longValue()); + } + if (value instanceof String) { + String str = (String) value; + try { + if (str.length() == 10 && str.charAt(4) == '-' && str.charAt(7) == '-') { + return Date.valueOf(str); + } + Instant instant = parseInstant(str); + return new Date(instant.toEpochMilli()); + } catch (Exception e) { + throw new SQLException("Cannot parse date value: " + str, e); + } + } + throw new SQLException("Cannot convert value to Date: " + value.getClass().getName()); + } + + private Time toSqlTime(Object value) throws SQLException { + if (value == null) { + return null; + } + if (value instanceof Time) { + return (Time) value; + } + if (value instanceof java.util.Date) { + return new Time(((java.util.Date) value).getTime()); + } + if (value instanceof Number) { + return new Time(((Number) value).longValue()); + } + if (value instanceof String) { + String str = (String) value; + try { + if (str.length() == 8 && str.charAt(2) == ':' && str.charAt(5) == ':') { + return Time.valueOf(str); + } + Instant instant = parseInstant(str); + return new Time(instant.toEpochMilli()); + } catch (Exception e) { + throw new SQLException("Cannot parse time value: " + str, e); + } + } + throw new SQLException("Cannot convert value to Time: " + value.getClass().getName()); + } + + private Timestamp toSqlTimestamp(Object value) throws SQLException { + if (value == null) { + return null; + } + if (value instanceof Timestamp) { + return (Timestamp) value; + } + if (value instanceof java.util.Date) { + return new Timestamp(((java.util.Date) value).getTime()); + } + if (value instanceof Number) { + return new Timestamp(((Number) value).longValue()); + } + if (value instanceof String) { + String str = (String) value; + try { + Instant instant = parseInstant(str); + return Timestamp.from(instant); + } catch (DateTimeParseException e) { + try { + return Timestamp.valueOf(str); + } catch (IllegalArgumentException ex) { + throw new SQLException("Cannot parse timestamp value: " + str, e); + } + } + } + throw new SQLException("Cannot convert value to Timestamp: " + value.getClass().getName()); + } + + private Instant parseInstant(String value) { + try { + return Instant.parse(value); + } catch (DateTimeParseException e) { + try { + return OffsetDateTime.parse(value).toInstant(); + } catch (DateTimeParseException ex) { + return LocalDateTime.parse(value).atZone(ZoneId.systemDefault()).toInstant(); + } + } + } + + private T adjustWithCalendar(T date, java.util.Calendar cal) { + if (date == null || cal == null) { + return date; + } + cal.setTimeInMillis(date.getTime()); + return date; + } + + private SQLFeatureNotSupportedException unsupported() { + return new SQLFeatureNotSupportedException("Operation not supported on LogsqlResultSet"); + } + + @Override + public T unwrap(Class iface) throws SQLException { + if (iface.isInstance(this)) { + return iface.cast(this); + } + throw new SQLFeatureNotSupportedException("Not a wrapper for " + iface.getName()); + } + + @Override + public boolean isWrapperFor(Class iface) throws SQLException { + return iface.isInstance(this); + } +} diff --git a/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlResultSetMetaData.java b/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlResultSetMetaData.java new file mode 100644 index 0000000..71f4ca0 --- /dev/null +++ b/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlResultSetMetaData.java @@ -0,0 +1,206 @@ +package com.victoriametrics.logsql.jdbc; + +import java.math.BigDecimal; +import java.sql.Date; +import java.sql.ResultSetMetaData; +import java.sql.Time; +import java.sql.Timestamp; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.sql.Types; +import java.util.List; + +class LogsqlResultSetMetaData implements ResultSetMetaData { + + private final List columnNames; + private final int[] columnTypes; + + LogsqlResultSetMetaData(List columnNames, int[] columnTypes) { + this.columnNames = columnNames; + this.columnTypes = columnTypes; + } + + @Override + public int getColumnCount() throws SQLException { + return columnNames.size(); + } + + @Override + public boolean isAutoIncrement(int column) throws SQLException { + return false; + } + + @Override + public boolean isCaseSensitive(int column) throws SQLException { + int type = getColumnType(column); + return type == Types.VARCHAR || type == Types.CHAR || type == Types.LONGVARCHAR; + } + + @Override + public boolean isSearchable(int column) throws SQLException { + return true; + } + + @Override + public boolean isCurrency(int column) throws SQLException { + return false; + } + + @Override + public int isNullable(int column) throws SQLException { + return ResultSetMetaData.columnNullable; + } + + @Override + public boolean isSigned(int column) throws SQLException { + int type = getColumnType(column); + return type == Types.INTEGER + || type == Types.BIGINT + || type == Types.DECIMAL + || type == Types.NUMERIC + || type == Types.DOUBLE + || type == Types.FLOAT + || type == Types.REAL + || type == Types.SMALLINT + || type == Types.TINYINT; + } + + @Override + public int getColumnDisplaySize(int column) throws SQLException { + return 0; + } + + @Override + public String getColumnLabel(int column) throws SQLException { + return getColumnName(column); + } + + @Override + public String getColumnName(int column) throws SQLException { + return columnNames.get(column - 1); + } + + @Override + public String getSchemaName(int column) throws SQLException { + return ""; + } + + @Override + public int getPrecision(int column) throws SQLException { + return 0; + } + + @Override + public int getScale(int column) throws SQLException { + return 0; + } + + @Override + public String getTableName(int column) throws SQLException { + return ""; + } + + @Override + public String getCatalogName(int column) throws SQLException { + return ""; + } + + @Override + public int getColumnType(int column) throws SQLException { + return columnTypes[column - 1]; + } + + @Override + public String getColumnTypeName(int column) throws SQLException { + int type = getColumnType(column); + switch (type) { + case Types.BOOLEAN: + case Types.BIT: + return "BOOLEAN"; + case Types.INTEGER: + return "INTEGER"; + case Types.BIGINT: + return "BIGINT"; + case Types.DOUBLE: + return "DOUBLE"; + case Types.FLOAT: + return "FLOAT"; + case Types.REAL: + return "REAL"; + case Types.NUMERIC: + case Types.DECIMAL: + return "NUMERIC"; + case Types.TIMESTAMP: + return "TIMESTAMP"; + case Types.DATE: + return "DATE"; + case Types.TIME: + return "TIME"; + case Types.VARBINARY: + return "VARBINARY"; + default: + return "VARCHAR"; + } + } + + @Override + public boolean isReadOnly(int column) throws SQLException { + return true; + } + + @Override + public boolean isWritable(int column) throws SQLException { + return false; + } + + @Override + public boolean isDefinitelyWritable(int column) throws SQLException { + return false; + } + + @Override + public String getColumnClassName(int column) throws SQLException { + int type = getColumnType(column); + switch (type) { + case Types.BOOLEAN: + case Types.BIT: + return Boolean.class.getName(); + case Types.INTEGER: + case Types.SMALLINT: + case Types.TINYINT: + return Integer.class.getName(); + case Types.BIGINT: + return Long.class.getName(); + case Types.DOUBLE: + case Types.FLOAT: + case Types.REAL: + return Double.class.getName(); + case Types.NUMERIC: + case Types.DECIMAL: + return BigDecimal.class.getName(); + case Types.TIMESTAMP: + return Timestamp.class.getName(); + case Types.DATE: + return Date.class.getName(); + case Types.TIME: + return Time.class.getName(); + case Types.VARBINARY: + return byte[].class.getName(); + default: + return String.class.getName(); + } + } + + @Override + public T unwrap(Class iface) throws SQLException { + if (iface.isInstance(this)) { + return iface.cast(this); + } + throw new SQLFeatureNotSupportedException("Not a wrapper for " + iface.getName()); + } + + @Override + public boolean isWrapperFor(Class iface) throws SQLException { + return iface.isInstance(this); + } +} diff --git a/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlStatement.java b/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlStatement.java new file mode 100644 index 0000000..a4f04be --- /dev/null +++ b/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlStatement.java @@ -0,0 +1,381 @@ +package com.victoriametrics.logsql.jdbc; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.sql.SQLWarning; +import java.sql.Statement; +import java.sql.Types; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +class LogsqlStatement implements Statement { + + private final LogsqlConnection connection; + private LogsqlResultSet currentResultSet; + private String translatedLogsql; + private boolean closed; + private int maxRows; + private int fetchSize = 0; + private int queryTimeoutSeconds = 0; + private boolean poolable = false; + private boolean closeOnCompletion = false; + private boolean closing = false; + + LogsqlStatement(LogsqlConnection connection) { + this.connection = Objects.requireNonNull(connection, "connection"); + } + + LogsqlConnection getConnectionInternal() { + return connection; + } + + String getTranslatedLogsql() { + return translatedLogsql; + } + + @Override + public ResultSet executeQuery(String sql) throws SQLException { + checkOpen(); + LogsqlQueryResult result = connection.executeQuery(sql, maxRows); + this.translatedLogsql = result.getLogsql(); + closeCurrentResultSet(); + this.currentResultSet = buildResultSet(result); + return currentResultSet; + } + + @Override + public int executeUpdate(String sql) throws SQLException { + throw new SQLFeatureNotSupportedException("Updates are not supported"); + } + + @Override + public void close() throws SQLException { + if (!closed) { + closing = true; + try { + closeCurrentResultSet(); + } finally { + closing = false; + closed = true; + } + } + } + + @Override + public int getMaxFieldSize() throws SQLException { + return 0; + } + + @Override + public void setMaxFieldSize(int max) throws SQLException { + if (max < 0) { + throw new SQLException("max field size must be non-negative"); + } + // no-op + } + + @Override + public int getMaxRows() throws SQLException { + return maxRows; + } + + @Override + public void setMaxRows(int max) throws SQLException { + if (max < 0) { + throw new SQLException("max rows must be non-negative"); + } + this.maxRows = max; + } + + @Override + public void setEscapeProcessing(boolean enable) { + // no-op + } + + @Override + public int getQueryTimeout() throws SQLException { + return queryTimeoutSeconds; + } + + @Override + public void setQueryTimeout(int seconds) throws SQLException { + if (seconds < 0) { + throw new SQLException("query timeout must be non-negative"); + } + this.queryTimeoutSeconds = seconds; + } + + @Override + public void cancel() throws SQLException { + throw new SQLFeatureNotSupportedException("Cancel is not supported"); + } + + @Override + public SQLWarning getWarnings() throws SQLException { + return null; + } + + @Override + public void clearWarnings() throws SQLException { + // no warnings + } + + @Override + public void setCursorName(String name) throws SQLException { + throw new SQLFeatureNotSupportedException("Cursor naming is not supported"); + } + + @Override + public boolean execute(String sql) throws SQLException { + executeQuery(sql); + return true; + } + + @Override + public ResultSet getResultSet() throws SQLException { + return currentResultSet; + } + + @Override + public int getUpdateCount() throws SQLException { + return -1; + } + + @Override + public boolean getMoreResults() throws SQLException { + closeCurrentResultSet(); + return false; + } + + @Override + public void setFetchDirection(int direction) throws SQLException { + if (direction != ResultSet.FETCH_FORWARD) { + throw new SQLFeatureNotSupportedException("Only forward fetch direction is supported"); + } + } + + @Override + public int getFetchDirection() throws SQLException { + return ResultSet.FETCH_FORWARD; + } + + @Override + public void setFetchSize(int rows) throws SQLException { + if (rows < 0) { + throw new SQLException("fetch size must be non-negative"); + } + this.fetchSize = rows; + } + + @Override + public int getFetchSize() throws SQLException { + return fetchSize; + } + + @Override + public int getResultSetConcurrency() throws SQLException { + return ResultSet.CONCUR_READ_ONLY; + } + + @Override + public int getResultSetType() throws SQLException { + return ResultSet.TYPE_FORWARD_ONLY; + } + + @Override + public void addBatch(String sql) throws SQLException { + throw new SQLFeatureNotSupportedException("Batch updates are not supported"); + } + + @Override + public void clearBatch() throws SQLException { + // no-op + } + + @Override + public int[] executeBatch() throws SQLException { + throw new SQLFeatureNotSupportedException("Batch updates are not supported"); + } + + @Override + public Connection getConnection() throws SQLException { + return connection; + } + + @Override + public boolean getMoreResults(int current) throws SQLException { + if (current == Statement.CLOSE_CURRENT_RESULT) { + closeCurrentResultSet(); + } else if (current == Statement.CLOSE_ALL_RESULTS) { + closeCurrentResultSet(); + } + return false; + } + + @Override + public ResultSet getGeneratedKeys() throws SQLException { + throw new SQLFeatureNotSupportedException("Generated keys are not supported"); + } + + @Override + public int executeUpdate(String sql, int autoGeneratedKeys) throws SQLException { + throw new SQLFeatureNotSupportedException("Updates are not supported"); + } + + @Override + public int executeUpdate(String sql, int[] columnIndexes) throws SQLException { + throw new SQLFeatureNotSupportedException("Updates are not supported"); + } + + @Override + public int executeUpdate(String sql, String[] columnNames) throws SQLException { + throw new SQLFeatureNotSupportedException("Updates are not supported"); + } + + @Override + public boolean execute(String sql, int autoGeneratedKeys) throws SQLException { + return execute(sql); + } + + @Override + public boolean execute(String sql, int[] columnIndexes) throws SQLException { + return execute(sql); + } + + @Override + public boolean execute(String sql, String[] columnNames) throws SQLException { + return execute(sql); + } + + @Override + public int getResultSetHoldability() throws SQLException { + return ResultSet.HOLD_CURSORS_OVER_COMMIT; + } + + @Override + public boolean isClosed() throws SQLException { + return closed; + } + + @Override + public void setPoolable(boolean poolable) throws SQLException { + this.poolable = poolable; + } + + @Override + public boolean isPoolable() throws SQLException { + return poolable; + } + + @Override + public void closeOnCompletion() throws SQLException { + this.closeOnCompletion = true; + } + + @Override + public boolean isCloseOnCompletion() throws SQLException { + return closeOnCompletion; + } + + @Override + public T unwrap(Class iface) throws SQLException { + if (iface.isInstance(this)) { + return iface.cast(this); + } + throw new SQLFeatureNotSupportedException("Not a wrapper for " + iface.getName()); + } + + @Override + public boolean isWrapperFor(Class iface) throws SQLException { + return iface.isInstance(this); + } + + private LogsqlResultSet buildResultSet(LogsqlQueryResult result) { + List columns = result.getColumnNames(); + int columnCount = columns.size(); + int[] columnTypes = new int[columnCount]; + for (int i = 0; i < columnCount; i++) { + columnTypes[i] = inferColumnType(result.getRows(), columns.get(i)); + } + + List rows = new ArrayList<>(result.getRows().size()); + for (Map row : result.getRows()) { + Object[] values = new Object[columnCount]; + for (int i = 0; i < columnCount; i++) { + values[i] = row.get(columns.get(i)); + } + rows.add(values); + } + + return new LogsqlResultSet(this, columns, columnTypes, rows); + } + + private int inferColumnType(List> rows, String column) { + for (Map row : rows) { + Object value = row.get(column); + if (value == null) { + continue; + } + if (value instanceof Boolean) { + return Types.BOOLEAN; + } + if (value instanceof Byte || value instanceof Short || value instanceof Integer) { + return Types.INTEGER; + } + if (value instanceof Long) { + return Types.BIGINT; + } + if (value instanceof Float) { + return Types.REAL; + } + if (value instanceof Double) { + return Types.DOUBLE; + } + if (value instanceof java.math.BigDecimal) { + return Types.NUMERIC; + } + if (value instanceof java.time.temporal.Temporal || value instanceof java.util.Date) { + return Types.TIMESTAMP; + } + if (value instanceof byte[]) { + return Types.VARBINARY; + } + return Types.VARCHAR; + } + return Types.VARCHAR; + } + + void onResultSetClosed(LogsqlResultSet resultSet) throws SQLException { + if (currentResultSet == resultSet) { + currentResultSet = null; + } + if (closeOnCompletion && !closing && !closed) { + close(); + } + } + + void closeCurrentResultSet() throws SQLException { + if (currentResultSet != null) { + LogsqlResultSet rs = currentResultSet; + currentResultSet = null; + rs.closeFromStatement(); + } + } + + void checkOpen() throws SQLException { + if (closed) { + throw new SQLException("Statement is closed"); + } + if (connection.isClosed()) { + throw new SQLException("Connection is closed"); + } + } + + ResultSet getCurrentResultSet() { + return currentResultSet; + } +} diff --git a/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlUrlParser.java b/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlUrlParser.java new file mode 100644 index 0000000..0777594 --- /dev/null +++ b/logsql-jdbc/src/main/java/com/victoriametrics/logsql/jdbc/LogsqlUrlParser.java @@ -0,0 +1,187 @@ +package com.victoriametrics.logsql.jdbc; + +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.sql.SQLException; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Properties; + +final class LogsqlUrlParser { + + private static final String HEADER_PREFIX = "header."; + private static final String LEGACY_HEADERS_PREFIX = "headers."; + + private LogsqlUrlParser() { + } + + static LogsqlConnectionConfig parse(String url, Properties supplied) throws SQLException { + if (url == null || !url.startsWith(LogsqlDriver.URL_PREFIX)) { + throw new SQLException("Invalid LogSQL JDBC URL: " + url); + } + + Properties props = new Properties(); + if (supplied != null) { + props.putAll(supplied); + } + + String tail = url.substring(LogsqlDriver.URL_PREFIX.length()); + String query = null; + int queryIndex = tail.indexOf('?'); + if (queryIndex >= 0) { + query = tail.substring(queryIndex + 1); + tail = tail.substring(0, queryIndex); + } + + if (query != null && !query.isEmpty()) { + for (Map.Entry entry : parseQuery(query).entrySet()) { + props.setProperty(entry.getKey(), entry.getValue()); + } + } + + URI uri = URI.create("http://" + (tail.isEmpty() ? LogsqlConnectionConfig.DEFAULT_HOST : tail)); + String hostFromUri = uri.getHost(); + if (hostFromUri == null || hostFromUri.isEmpty()) { + hostFromUri = props.getProperty("host", LogsqlConnectionConfig.DEFAULT_HOST); + } + int portFromUri = uri.getPort(); + if (portFromUri <= 0) { + portFromUri = parsePort(props.getProperty("port")); + } + + String basePath = uri.getPath(); + if (basePath == null) { + basePath = ""; + } else if (basePath.equals("/")) { + basePath = ""; + } + + String scheme = props.getProperty("scheme", LogsqlConnectionConfig.DEFAULT_SCHEME); + String endpoint = trimToNull(props.getProperty("endpoint")); + String bearerToken = trimToNull(props.getProperty("bearerToken")); + Duration timeout = parseTimeout(props.getProperty("timeout")); + boolean verify = parseBoolean(props.getProperty("verify"), true); + + Map headers = extractHeaders(props); + Properties raw = new Properties(); + raw.putAll(props); + + return new LogsqlConnectionConfig( + hostFromUri, + portFromUri <= 0 ? LogsqlConnectionConfig.DEFAULT_PORT : portFromUri, + scheme, + basePath, + endpoint, + bearerToken, + timeout, + verify, + headers, + raw + ); + } + + private static Map parseQuery(String query) throws SQLException { + Map params = new LinkedHashMap<>(); + String[] parts = query.split("&"); + for (String part : parts) { + if (part.isEmpty()) { + continue; + } + String[] kv = part.split("=", 2); + String key = decode(kv[0]); + String value = kv.length > 1 ? decode(kv[1]) : ""; + params.put(key, value); + } + return params; + } + + private static String decode(String value) throws SQLException { + try { + return URLDecoder.decode(value, StandardCharsets.UTF_8.name()); + } catch (UnsupportedEncodingException e) { + throw new SQLException("Failed to decode URL parameter", e); + } + } + + private static int parsePort(String port) throws SQLException { + if (port == null || port.isEmpty()) { + return LogsqlConnectionConfig.DEFAULT_PORT; + } + try { + int value = Integer.parseInt(port); + if (value <= 0) { + throw new SQLException("Port must be positive: " + port); + } + return value; + } catch (NumberFormatException ex) { + throw new SQLException("Invalid port: " + port, ex); + } + } + + private static Duration parseTimeout(String timeout) throws SQLException { + if (timeout == null || timeout.isEmpty()) { + return LogsqlConnectionConfig.DEFAULT_TIMEOUT; + } + try { + long millis = Long.parseLong(timeout); + if (millis <= 0) { + return LogsqlConnectionConfig.DEFAULT_TIMEOUT; + } + return Duration.ofMillis(millis); + } catch (NumberFormatException ex) { + throw new SQLException("Invalid timeout value: " + timeout, ex); + } + } + + private static boolean parseBoolean(String value, boolean defaultValue) { + if (value == null) { + return defaultValue; + } + switch (value.trim().toLowerCase(Locale.ROOT)) { + case "true": + case "1": + case "yes": + case "on": + return true; + case "false": + case "0": + case "no": + case "off": + return false; + default: + return defaultValue; + } + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static Map extractHeaders(Properties props) { + Map headers = new LinkedHashMap<>(); + for (String name : props.stringPropertyNames()) { + String lower = name.toLowerCase(Locale.ROOT); + if (lower.startsWith(HEADER_PREFIX) || lower.startsWith(LEGACY_HEADERS_PREFIX)) { + String cleanName; + if (lower.startsWith(HEADER_PREFIX)) { + cleanName = name.substring(HEADER_PREFIX.length()); + } else { + cleanName = name.substring(LEGACY_HEADERS_PREFIX.length()); + } + if (!cleanName.isEmpty()) { + headers.put(cleanName, props.getProperty(name)); + } + } + } + return headers; + } +} diff --git a/logsql-jdbc/src/main/resources/META-INF/services/java.sql.Driver b/logsql-jdbc/src/main/resources/META-INF/services/java.sql.Driver new file mode 100644 index 0000000..d597a15 --- /dev/null +++ b/logsql-jdbc/src/main/resources/META-INF/services/java.sql.Driver @@ -0,0 +1 @@ +com.victoriametrics.logsql.jdbc.LogsqlDriver diff --git a/logsql-jdbc/src/test/java/com/victoriametrics/logsql/jdbc/LogsqlIntegrationTest.java b/logsql-jdbc/src/test/java/com/victoriametrics/logsql/jdbc/LogsqlIntegrationTest.java new file mode 100644 index 0000000..95f2f2f --- /dev/null +++ b/logsql-jdbc/src/test/java/com/victoriametrics/logsql/jdbc/LogsqlIntegrationTest.java @@ -0,0 +1,96 @@ +package com.victoriametrics.logsql.jdbc; + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Timestamp; + +import static org.junit.jupiter.api.Assertions.*; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class LogsqlIntegrationTest { + + private static final String PLAYGROUND_URL = "jdbc:logsql://play-sql.victoriametrics.com?scheme=https"; + + private boolean playgroundAvailable; + private String playgroundFailureMessage; + + @BeforeAll + public void ensurePlaygroundAvailable() throws Exception { + Class.forName("com.victoriametrics.logsql.jdbc.LogsqlDriver"); + try (Connection ignored = DriverManager.getConnection(PLAYGROUND_URL)) { + // connection ok + this.playgroundAvailable = true; + } catch (SQLException e) { + this.playgroundAvailable = false; + this.playgroundFailureMessage = e.getMessage(); + System.err.println("[LogsqlIntegrationTest] Playground connection failed: " + e.getMessage()); + } + } + + @org.junit.jupiter.api.BeforeEach + public void checkPlaygroundAvailable() { + Assumptions.assumeTrue(playgroundAvailable, () -> "play-sql.victoriametrics.com is unavailable: " + playgroundFailureMessage); + } + + @Test + public void simpleQueryReturnsDataAndMetadata() throws SQLException { + try (Connection conn = DriverManager.getConnection(PLAYGROUND_URL); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT * FROM logs LIMIT 5")) { + + assertTrue(stmt instanceof LogsqlStatement); + LogsqlStatement logsqlStmt = (LogsqlStatement) stmt; + String translated = logsqlStmt.getTranslatedLogsql(); + assertNotNull(translated); + assertTrue(translated.contains("limit 5")); + + assertTrue(rs.next(), "Expected at least one row from playground data"); + Timestamp timestamp = rs.getTimestamp("_time"); + assertNotNull(timestamp, "_time column should be convertible to Timestamp"); + + ResultSetMetaData meta = rs.getMetaData(); + assertTrue(meta.getColumnCount() > 0); + assertNotNull(meta.getColumnName(1)); + } + } + + @Test + public void preparedStatementBindsParameters() throws SQLException { + try (Connection conn = DriverManager.getConnection(PLAYGROUND_URL)) { + String repoName; + try (PreparedStatement sample = conn.prepareStatement( + "SELECT repo.name FROM logs WHERE repo.name IS NOT NULL LIMIT 1"); + ResultSet rs = sample.executeQuery()) { + assertTrue(rs.next(), "Expected a repo.name value to exist"); + repoName = rs.getString(1); + assertNotNull(repoName); + } + + try (PreparedStatement countStmt = conn.prepareStatement( + "SELECT COUNT(*) AS cnt FROM logs WHERE repo.name = ?")) { + countStmt.setString(1, repoName); + try (ResultSet rs = countStmt.executeQuery()) { + assertTrue(rs.next(), "Expected at least one row for the repo"); + long count = rs.getLong("cnt"); + assertTrue(count >= 1, "Expected count >= 1 for repo " + repoName); + assertFalse(rs.next(), "Expected single row result for COUNT(*)"); + } + + LogsqlStatement logsqlStmt = (LogsqlStatement) countStmt; + String translated = logsqlStmt.getTranslatedLogsql(); + assertNotNull(translated); + assertTrue(translated.contains("repo.name")); + } + } + } +} diff --git a/scripts/jdbc-build.sh b/scripts/jdbc-build.sh new file mode 100644 index 0000000..76afefc --- /dev/null +++ b/scripts/jdbc-build.sh @@ -0,0 +1 @@ +mvn -DskipTests -f logsql-jdbc/pom.xml package \ No newline at end of file diff --git a/scripts/jdbc-test.sh b/scripts/jdbc-test.sh new file mode 100644 index 0000000..e581ec2 --- /dev/null +++ b/scripts/jdbc-test.sh @@ -0,0 +1 @@ +mvn -f logsql-jdbc/pom.xml test