From afe3b3f3c23584145a12a07dd9662eaa37e188c5 Mon Sep 17 00:00:00 2001 From: zipg Date: Tue, 1 Sep 2026 00:33:06 +0800 Subject: [PATCH] feat(sqlserver): add sql server 2000 legacy support --- .../sqlserver2000-windows-user-test.yml | 73 +++++ .../java/com/dbx/agent/AbstractJdbcAgent.java | 12 + .../java/com/dbx/agent/JsonRpcServer.java | 3 +- agents/drivers/sqlserver-legacy/build.gradle | 1 + .../sqlserverlegacy/SqlServerLegacyAgent.java | 289 +++++++++++++++++- .../SqlServerLegacyAgentTest.java | 118 +++++++ apps/desktop/src/components/grid/DataGrid.vue | 1 + .../search/DatabaseSearchDialog.vue | 1 + .../src/composables/useDataGridActions.ts | 7 +- .../database/databaseDriverManifest.spec.ts | 3 + apps/desktop/src/lib/dataGrid/dataGridSql.ts | 1 + .../lib/database/databaseDriverManifest.ts | 3 +- .../src/lib/database/databaseSearch.ts | 1 + .../queryStore.queryResultExport.spec.ts | 31 ++ apps/desktop/src/stores/queryStore.ts | 47 ++- crates/dbx-core/src/data_grid_sql.rs | 35 +++ crates/dbx-core/src/database_search_sql.rs | 34 ++- crates/dbx-core/src/db/sqlserver.rs | 19 ++ crates/dbx-core/src/schema.rs | 7 +- .../dbx-core/src/sql_dialect/table_select.rs | 20 ++ 20 files changed, 682 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/sqlserver2000-windows-user-test.yml diff --git a/.github/workflows/sqlserver2000-windows-user-test.yml b/.github/workflows/sqlserver2000-windows-user-test.yml new file mode 100644 index 0000000000..75aafa5cd7 --- /dev/null +++ b/.github/workflows/sqlserver2000-windows-user-test.yml @@ -0,0 +1,73 @@ +name: SQL Server 2000 Windows User Test Build + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build-windows-x64: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v5 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + cache: pnpm + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: "./ -> target" + shared-key: sqlserver2000-windows-x64 + cache-on-failure: true + + - name: Build SQL Server legacy agent + run: .\\gradlew.bat :sqlserver-legacy:shadowJar --no-daemon + working-directory: agents + + - name: Stage SQL Server legacy agent + shell: pwsh + run: | + $agent = Get-ChildItem "agents/drivers/sqlserver-legacy/build/libs/dbx-agent-sqlserver-legacy.jar" | Select-Object -First 1 + if (!$agent) { + Write-Error "Missing SQL Server legacy agent JAR" + exit 1 + } + Copy-Item $agent.FullName "sqlserver-legacy-agent.jar" -Force + + - name: Build Windows x64 installer + run: pnpm tauri build --bundles nsis --target x86_64-pc-windows-msvc --ci --no-sign + + - name: Upload installer + uses: actions/upload-artifact@v7 + with: + name: DBX-SQLServer2000-Windows-x64 + path: | + target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe + sqlserver-legacy-agent.jar + if-no-files-found: error + retention-days: 7 diff --git a/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java b/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java index 61cad50dba..87b4732428 100644 --- a/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java +++ b/agents/common/src/main/java/com/dbx/agent/AbstractJdbcAgent.java @@ -90,14 +90,26 @@ public abstract class AbstractJdbcAgent extends BaseDatabaseAgent { return unchecked(() -> { loadDriver(params); try (Connection conn = openTestConnection(params)) { + String validationQuery = connectionValidationQuery(); boolean valid = isConnectionValid(conn, 5); Map result = new LinkedHashMap<>(); result.put("ok", valid); + result.put( + "validation", + validationQuery == null || validationQuery.isBlank() ? "jdbc_connection_isValid" : validationQuery + ); if (valid) { Map databaseInfo = JdbcDatabaseInfo.from(conn); if (!databaseInfo.isEmpty()) { result.put("databaseInfo", databaseInfo); } + } else { + result.put( + "error", + "Connection opened, but validation failed (method: " + + (validationQuery == null || validationQuery.isBlank() ? "Connection.isValid" : validationQuery) + + ")" + ); } return result; } diff --git a/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java b/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java index 2e2f61c119..7af6257a04 100644 --- a/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java +++ b/agents/common/src/main/java/com/dbx/agent/JsonRpcServer.java @@ -162,7 +162,8 @@ public final class JsonRpcServer { if (AgentProtocol.METHOD_TEST_CONNECTION.equals(method)) { Map result = agent.testConnectionWithInfo(gson.fromJson(params, ConnectParams.class)); if (!Boolean.TRUE.equals(result.get("ok"))) { - throw new RuntimeException("Connection failed"); + Object error = result.get("error"); + throw new RuntimeException(error == null ? "Connection failed" : String.valueOf(error)); } return result; } diff --git a/agents/drivers/sqlserver-legacy/build.gradle b/agents/drivers/sqlserver-legacy/build.gradle index 69300226e7..56edb4eaab 100644 --- a/agents/drivers/sqlserver-legacy/build.gradle +++ b/agents/drivers/sqlserver-legacy/build.gradle @@ -1,5 +1,6 @@ dependencies { implementation 'com.microsoft.sqlserver:mssql-jdbc:13.2.0.jre11' + implementation 'net.sourceforge.jtds:jtds:1.3.1' } tasks.named('shadowJar') { diff --git a/agents/drivers/sqlserver-legacy/src/main/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgent.java b/agents/drivers/sqlserver-legacy/src/main/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgent.java index 76a319acb1..c31090d506 100644 --- a/agents/drivers/sqlserver-legacy/src/main/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgent.java +++ b/agents/drivers/sqlserver-legacy/src/main/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgent.java @@ -8,6 +8,7 @@ import com.dbx.agent.ForeignKeyInfo; import com.dbx.agent.IndexInfo; import com.dbx.agent.JdbcAgentProfile; import com.dbx.agent.MultiSessionJsonRpcServer; +import com.dbx.agent.ObjectSource; import java.security.Security; import java.sql.Connection; @@ -16,6 +17,7 @@ import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Types; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -27,6 +29,9 @@ import java.util.Objects; import java.util.Set; public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent { + private static final String JTDS_DRIVER_CLASS = "net.sourceforge.jtds.jdbc.Driver"; + private static final String SQLSERVER_JDBC_PREFIX = "jdbc:sqlserver://"; + private static final String JTDS_JDBC_PREFIX = "jdbc:jtds:sqlserver://"; private static final String TLS_DISABLED_ALGORITHMS_KEY = "jdk.tls.disabledAlgorithms"; private static final Set LEGACY_TLS_ALGORITHMS_TO_ALLOW = Set.of( "TLSV1", @@ -57,6 +62,7 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent { Set.of("INFORMATION_SCHEMA", "SYS"), Arrays.asList("TABLE", "VIEW", "SYSTEM TABLE") ); + private volatile boolean sqlServer2000Mode; public SqlServerLegacyAgent() { super(PROFILE); @@ -67,20 +73,231 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent { @Override protected String buildJdbcUrl(ConnectParams params) { - return legacyTlsUrl(params); + return sqlServer2000Mode ? jtdsUrl(params) : legacyTlsUrl(params); } @Override protected Connection openConnection(ConnectParams params) throws Exception { + sqlServer2000Mode = false; + logConnectionEvent("mssql-jdbc attempt", params, null); try { - return super.openConnection(params); + Connection connection = super.openConnection(params); + sqlServer2000Mode = false; + logConnectionEvent("mssql-jdbc connected", params, null); + return connection; } catch (SQLException error) { + logConnectionEvent("mssql-jdbc failed", params, error); + if (isSqlServer2000Unsupported(error)) { + logConnectionEvent("switching to jTDS 1.3.1 fallback", params, null); + try { + super.loadDriver(jtdsDriverParams()); + sqlServer2000Mode = true; + Connection connection = super.openConnection(params); + logConnectionEvent("jTDS 1.3.1 connected", params, null); + return connection; + } catch (SQLException fallbackError) { + sqlServer2000Mode = false; + logConnectionEvent("jTDS 1.3.1 failed", params, fallbackError); + fallbackError.addSuppressed(error); + throw withLegacyTlsDiagnostics(fallbackError, "jTDS 1.3.1"); + } catch (Exception fallbackError) { + sqlServer2000Mode = false; + logConnectionEvent("jTDS 1.3.1 failed", params, fallbackError); + fallbackError.addSuppressed(error); + throw fallbackError; + } + } throw withLegacyTlsDiagnostics(error); } } + private static ConnectParams jtdsDriverParams() { + ConnectParams params = new ConnectParams(); + params.setJdbc_driver_class(JTDS_DRIVER_CLASS); + params.setJdbc_driver_paths(Collections.emptyList()); + return params; + } + + @Override + protected String connectionValidationQuery() { + // jTDS can establish a SQL Server 2000 session but its JDBC 4 + // isValid() implementation is not reliable on this legacy endpoint. + return "SELECT 1"; + } + + @Override + protected void afterDisconnect() { + sqlServer2000Mode = false; + } + + @Override + protected Object resultValue(ResultSet resultSet, int index, int sqlType) { + Object value = super.resultValue(resultSet, index, sqlType); + return normalizeSqlServer2000ResultValue(value, sqlType, sqlServer2000Mode); + } + + static Object normalizeSqlServer2000ResultValue(Object value, int sqlType, boolean sqlServer2000Mode) { + if (!sqlServer2000Mode || !(value instanceof String) || !isCharacterType(sqlType)) { + return value; + } + String text = (String) value; + if (text.isEmpty()) { + return text; + } + for (int index = 0; index < text.length(); index++) { + if (text.charAt(index) != '\0') { + return text; + } + } + // jTDS can expose an empty SQL Server 2000 varchar as NUL padding up to + // the declared column length. Keep SQL NULL and mixed binary-like text + // distinct, but restore an all-NUL character value to the empty string. + return ""; + } + + private static boolean isCharacterType(int sqlType) { + return sqlType == Types.CHAR + || sqlType == Types.VARCHAR + || sqlType == Types.LONGVARCHAR + || sqlType == Types.NCHAR + || sqlType == Types.NVARCHAR + || sqlType == Types.LONGNVARCHAR + || sqlType == Types.CLOB + || sqlType == Types.NCLOB; + } + + private static void logConnectionEvent(String stage, ConnectParams params, Throwable error) { + String detail = error == null ? "" : ", error=" + sanitizeDiagnostic(error.getClass().getSimpleName() + ": " + error.getMessage()); + System.err.println( + "[sqlserver-legacy] " + stage + + ", host=" + sanitizeDiagnostic(params.getHost()) + + ", port=" + params.getPort() + + ", portExplicit=" + params.isPort_explicit() + + ", database=" + sanitizeDiagnostic(params.getDatabase()) + + ", usernamePresent=" + (params.getUsername() != null && !params.getUsername().isBlank()) + + detail + ); + } + + private static String sanitizeDiagnostic(String value) { + if (value == null || value.isBlank()) { + return ""; + } + return value + .replaceAll("(?i)(password|passwd|pwd)\\s*[=:]\\s*[^;,&\\s]+", "$1=") + .replace('\r', ' ') + .replace('\n', ' '); + } + + static boolean isSqlServer2000Unsupported(Throwable error) { + Throwable current = error; + while (current != null) { + String message = current.getMessage(); + if (message != null) { + String normalized = message.toLowerCase(Locale.ROOT); + // mssql-jdbc rejects SQL Server 2000 (major version 8) during + // prelogin with "SQL Server version 8 is not supported by + // this driver." — the version word breaks a plain + // "sql server 8" substring match, so accept both shapes. + boolean version8Rejection = (normalized.contains("sql server 8") || normalized.contains("sql server version 8")) + && (normalized.contains("not support") || normalized.contains("不支持")); + // Other driver wordings name the supported floor instead. + boolean floor2005Rejection = normalized.contains("sql server 2005 or later"); + if (version8Rejection || floor2005Rejection) { + return true; + } + } + if (current instanceof SQLException) { + SQLException next = ((SQLException) current).getNextException(); + if (next != null && next != current.getCause()) { + if (isSqlServer2000Unsupported(next)) { + return true; + } + } + } + current = current.getCause(); + } + return false; + } + + static String jtdsUrl(ConnectParams params) { + String explicit = params.getConnection_string() == null ? "" : params.getConnection_string().trim(); + if (explicit.toLowerCase(Locale.ROOT).startsWith(JTDS_JDBC_PREFIX)) { + return appendProperties(trimSqlServerUrl(explicit), jtdsConnectionProperties(params)); + } + + String sqlServerUrl = baseJdbcUrl(params); + String body = sqlServerUrl.substring(SQLSERVER_JDBC_PREFIX.length()); + String[] parts = body.split(";", -1); + String authority = parts[0].trim(); + String database = ""; + for (int i = 1; i < parts.length; i++) { + int separator = parts[i].indexOf('='); + if (separator <= 0) { + continue; + } + String key = parts[i].substring(0, separator).trim(); + if ("databaseName".equalsIgnoreCase(key)) { + database = parts[i].substring(separator + 1).trim(); + break; + } + } + + StringBuilder url = new StringBuilder(JTDS_JDBC_PREFIX); + String namedInstance = namedInstance(authority); + if (namedInstance != null && !params.isPort_explicit()) { + url.append(namedInstance.substring(0, namedInstance.indexOf('\\'))); + } else { + url.append(authority); + } + if (database.length() > 0) { + url.append('/').append(database); + } + if (namedInstance != null && !params.isPort_explicit()) { + url.append(";instance=") + .append(namedInstance.substring(namedInstance.indexOf('\\') + 1)); + } + return appendProperties(url.toString(), jtdsConnectionProperties(params)); + } + + private static String namedInstance(String authority) { + int separator = authority.indexOf('\\'); + if (separator <= 0 || separator >= authority.length() - 1) { + return null; + } + return authority; + } + + private static Map jtdsConnectionProperties(ConnectParams params) { + Map properties = new LinkedHashMap<>(); + String urlParams = params.getUrl_params(); + if (urlParams == null || urlParams.trim().isEmpty()) { + return properties; + } + for (String pair : urlParams.trim().split("[&;]")) { + String value = pair.trim(); + int separator = value.indexOf('='); + if (separator <= 0) { + continue; + } + String key = value.substring(0, separator).trim(); + String property = value.substring(separator + 1).trim(); + if ("applicationName".equalsIgnoreCase(key)) { + properties.put("appName", property); + } else if ("ssl".equalsIgnoreCase(key) + || "socketTimeout".equalsIgnoreCase(key) + || "loginTimeout".equalsIgnoreCase(key)) { + properties.put(key, property); + } + } + return properties; + } + @Override public String getTableComment(String schema, String table) { + if (sqlServer2000Mode) { + return null; + } return unchecked(() -> { try (PreparedStatement statement = requireConnection().prepareStatement(tableCommentSql())) { statement.setString(1, schema); @@ -111,12 +328,59 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent { return super.listForeignKeys(metadataSchema(schema, table), table); } + @Override + public ObjectSource getObjectSource(String schema, String name, String objectType) { + String normalizedType = objectType == null ? "" : objectType.trim().toUpperCase(Locale.ROOT); + String objectXtype = sqlServer2000ObjectXtype(normalizedType); + if (objectXtype == null) { + throw new IllegalArgumentException("Unsupported object type: " + objectType); + } + return unchecked(() -> { + String resolvedSchema = metadataSchema(schema, name); + StringBuilder source = new StringBuilder(); + try (PreparedStatement statement = requireConnection().prepareStatement(sqlServer2000ObjectSourceSql())) { + statement.setString(1, resolvedSchema); + statement.setString(2, name); + statement.setString(3, objectXtype); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + String chunk = resultSet.getString("source_text"); + if (chunk != null) { + source.append(chunk); + } + } + } + } + // SQL Server 2000 exposes syscomments as source chunks. The legacy + // editor is read-only because rewriting old procedure syntax safely + // needs a separate DDL compatibility path. + return new ObjectSource(name, objectType, resolvedSchema, source.toString(), false); + }); + } + + static String sqlServer2000ObjectSourceSql() { + return "SELECT c.text AS source_text FROM syscomments c " + + "JOIN sysobjects o ON c.id = o.id " + + "JOIN sysusers u ON o.uid = u.uid " + + "WHERE u.name = ? AND o.name = ? AND o.xtype = ? " + + "ORDER BY c.colid"; + } + + private static String sqlServer2000ObjectXtype(String objectType) { + return switch (objectType) { + case "PROCEDURE" -> "P"; + case "FUNCTION" -> "FN"; + default -> null; + }; + } + private String metadataSchema(String schema, String table) { if (schema != null && !schema.trim().isEmpty()) { return schema; } return unchecked(() -> { - try (PreparedStatement statement = requireConnection().prepareStatement(unqualifiedObjectSchemaSql())) { + String schemaSql = sqlServer2000Mode ? sqlServer2000ObjectSchemaSql() : unqualifiedObjectSchemaSql(); + try (PreparedStatement statement = requireConnection().prepareStatement(schemaSql)) { statement.setString(1, table); try (ResultSet resultSet = statement.executeQuery()) { return normalizeMetadataSchema(schema, resultSet.next() ? resultSet.getString("schema_name") : null); @@ -130,6 +394,13 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent { + "NULLIF(SCHEMA_NAME(), N''), N'dbo') AS schema_name"; } + static String sqlServer2000ObjectSchemaSql() { + return "SELECT TOP 1 u.name AS schema_name FROM sysobjects o " + + "JOIN sysusers u ON o.uid = u.uid " + + "WHERE o.name = ? AND o.xtype IN ('U', 'V', 'P', 'FN', 'IF', 'TF') " + + "ORDER BY CASE WHEN u.name = 'dbo' THEN 0 ELSE 1 END, u.name"; + } + static String normalizeMetadataSchema(String schema, String defaultSchema) { if (schema != null && !schema.trim().isEmpty()) { return schema; @@ -224,10 +495,14 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent { } static String legacyTlsDiagnostics() { + return legacyTlsDiagnostics(jdbcDriverVersion()); + } + + private static String legacyTlsDiagnostics(String jdbcVersion) { String disabledAlgorithms = Security.getProperty(TLS_DISABLED_ALGORITHMS_KEY); return "DBX SQL Server legacy TLS diagnostics: java=" + System.getProperty("java.version", "unknown") + ", javaVendor=" + System.getProperty("java.vendor", "unknown") - + ", jdbc=" + jdbcDriverVersion() + + ", jdbc=" + jdbcVersion + ", sslProtocol=TLSv1" + ", tlsV1Disabled=" + isDisabled(disabledAlgorithms, "TLSV1") + ", tlsRsaDisabled=" + isDisabled(disabledAlgorithms, "TLS_RSA_*") @@ -238,9 +513,13 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent { } static SQLException withLegacyTlsDiagnostics(SQLException error) { + return withLegacyTlsDiagnostics(error, jdbcDriverVersion()); + } + + static SQLException withLegacyTlsDiagnostics(SQLException error, String jdbcVersion) { String message = error.getMessage() == null ? error.toString() : error.getMessage(); return new SQLException( - message + "\n\n" + legacyTlsDiagnostics(), + message + "\n\n" + legacyTlsDiagnostics(jdbcVersion), error.getSQLState(), error.getErrorCode(), error diff --git a/agents/drivers/sqlserver-legacy/src/test/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgentTest.java b/agents/drivers/sqlserver-legacy/src/test/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgentTest.java index bc3df8afb0..2c37236f88 100644 --- a/agents/drivers/sqlserver-legacy/src/test/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgentTest.java +++ b/agents/drivers/sqlserver-legacy/src/test/java/com/dbx/agent/sqlserverlegacy/SqlServerLegacyAgentTest.java @@ -6,8 +6,74 @@ import org.junit.jupiter.api.Test; import java.security.Security; import java.sql.SQLException; +import java.sql.Types; class SqlServerLegacyAgentTest { + @Test + void onlySqlServer8UnsupportedErrorsTriggerTheOldDriverFallback() { + // Real mssql-jdbc prelogin rejection for SQL Server 2000 + // (R_unsupportedServerVersion, English-only resources). + Assertions.assertTrue(SqlServerLegacyAgent.isSqlServer2000Unsupported( + new SQLException("SQL Server version 8 is not supported by this driver.") + )); + // Older driver wordings name the supported floor instead + // (mssql-jdbc R_notSQLServer family). + Assertions.assertTrue(SqlServerLegacyAgent.isSqlServer2000Unsupported( + new SQLException("This version of the driver can be used only with SQL Server 2005 or later.") + )); + Assertions.assertTrue(SqlServerLegacyAgent.isSqlServer2000Unsupported( + new SQLException("该驱动程序不支持 SQL Server 8 版") + )); + Assertions.assertTrue(SqlServerLegacyAgent.isSqlServer2000Unsupported( + new SQLException("The driver does not support SQL Server 8") + )); + Assertions.assertFalse(SqlServerLegacyAgent.isSqlServer2000Unsupported( + new SQLException("TLS handshake failed") + )); + Assertions.assertFalse(SqlServerLegacyAgent.isSqlServer2000Unsupported( + new SQLException("Login failed for user 'sa'") + )); + } + + @Test + void jtdsUrlUsesLegacySqlServerSyntax() { + ConnectParams params = new ConnectParams( + "db.example.com", + 1433, + "appdb", + "sa", + "secret", + "applicationName=dbx;encrypt=true;sslProtocol=TLSv1", + "", + false + ); + + Assertions.assertEquals( + "jdbc:jtds:sqlserver://db.example.com:1433/appdb;appName=dbx", + SqlServerLegacyAgent.jtdsUrl(params) + ); + } + + @Test + void jtdsUrlPreservesExplicitPortForNamedInstance() { + ConnectParams params = new ConnectParams( + "db.example.com\\MSSQLSERVER", + 11433, + "appdb", + "sa", + "secret", + "", + "", + false + ); + params.setPort_explicit(true); + + Assertions.assertEquals( + "jdbc:jtds:sqlserver://db.example.com:11433/appdb", + SqlServerLegacyAgent.jtdsUrl(params) + ); + } + @Test void metadataSchemaKeepsExplicitSchemaAndResolvesDefault() { Assertions.assertEquals("sales", SqlServerLegacyAgent.normalizeMetadataSchema("sales", "dbo")); @@ -17,6 +83,22 @@ class SqlServerLegacyAgentTest { "SELECT COALESCE(OBJECT_SCHEMA_NAME(OBJECT_ID(QUOTENAME(?))), NULLIF(SCHEMA_NAME(), N''), N'dbo') AS schema_name", SqlServerLegacyAgent.unqualifiedObjectSchemaSql() ); + Assertions.assertEquals( + "SELECT TOP 1 u.name AS schema_name FROM sysobjects o JOIN sysusers u ON o.uid = u.uid " + + "WHERE o.name = ? AND o.xtype IN ('U', 'V', 'P', 'FN', 'IF', 'TF') " + + "ORDER BY CASE WHEN u.name = 'dbo' THEN 0 ELSE 1 END, u.name", + SqlServerLegacyAgent.sqlServer2000ObjectSchemaSql() + ); + } + + @Test + void sqlServer2000ObjectSourceReadsOrderedProcedureChunks() { + Assertions.assertEquals( + "SELECT c.text AS source_text FROM syscomments c JOIN sysobjects o ON c.id = o.id " + + "JOIN sysusers u ON o.uid = u.uid WHERE u.name = ? AND o.name = ? AND o.xtype = ? " + + "ORDER BY c.colid", + SqlServerLegacyAgent.sqlServer2000ObjectSourceSql() + ); } @Test @@ -44,6 +126,42 @@ class SqlServerLegacyAgentTest { } } + @Test + void usesSelectOneForLegacyConnectionValidation() { + Assertions.assertEquals("SELECT 1", new SqlServerLegacyAgent().connectionValidationQuery()); + } + + @Test + void sqlServer2000AllNulCharacterPaddingBecomesEmptyString() { + Assertions.assertEquals( + "", + SqlServerLegacyAgent.normalizeSqlServer2000ResultValue("\0".repeat(20), Types.VARCHAR, true) + ); + Assertions.assertEquals( + "", + SqlServerLegacyAgent.normalizeSqlServer2000ResultValue("", Types.VARCHAR, true) + ); + } + + @Test + void sqlServer2000NulNormalizationPreservesNullMixedTextAndOtherModes() { + Assertions.assertNull( + SqlServerLegacyAgent.normalizeSqlServer2000ResultValue(null, Types.VARCHAR, true) + ); + Assertions.assertEquals( + "A\0B", + SqlServerLegacyAgent.normalizeSqlServer2000ResultValue("A\0B", Types.VARCHAR, true) + ); + Assertions.assertEquals( + "\0\0", + SqlServerLegacyAgent.normalizeSqlServer2000ResultValue("\0\0", Types.VARBINARY, true) + ); + Assertions.assertEquals( + "\0\0", + SqlServerLegacyAgent.normalizeSqlServer2000ResultValue("\0\0", Types.VARCHAR, false) + ); + } + @Test void connectionErrorsPreserveDetailsAndIncludeRuntimeDiagnostics() { SQLException original = new SQLException("TLS handshake failed", "08001", 1234); diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 4df65c47ca..27d52dca8e 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -1461,6 +1461,7 @@ async function loadServerFilterValues(columnIndex: number, searchValue: string) const columnInfo = tableMeta.columns.find((column) => column.name === columnName); const sql = await buildDataGridColumnDistinctValuesSql({ databaseType: resolvedDatabaseType.value, + driverProfile: props.connectionId ? connectionStore.getConfig(props.connectionId)?.driver_profile : undefined, identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId), catalog: tableMeta.catalog, database: tableMeta.database, diff --git a/apps/desktop/src/components/search/DatabaseSearchDialog.vue b/apps/desktop/src/components/search/DatabaseSearchDialog.vue index 5c2d68bcb4..1da6a8aef2 100644 --- a/apps/desktop/src/components/search/DatabaseSearchDialog.vue +++ b/apps/desktop/src/components/search/DatabaseSearchDialog.vue @@ -198,6 +198,7 @@ async function searchTable(task: SearchTableTask, databaseType: DatabaseType, cu const columns = await api.getColumns(props.prefillConnectionId, props.prefillDatabase, schema, task.table.name); const query = await buildDatabaseSearchSql({ databaseType, + driverProfile: connection.value?.driver_profile, schema: task.schema, tableName: task.table.name, columns, diff --git a/apps/desktop/src/composables/useDataGridActions.ts b/apps/desktop/src/composables/useDataGridActions.ts index 61dbcdf746..7d6a366615 100644 --- a/apps/desktop/src/composables/useDataGridActions.ts +++ b/apps/desktop/src/composables/useDataGridActions.ts @@ -446,8 +446,13 @@ export function useDataGridActions(activeTab: ComputedRef) tab.whereInput = whereInput ?? ""; const sql = await buildTableSql(tab, { limit, offset, whereInput, orderBy: orderBy ?? tab.orderByInput }); queryStore.updateSql(tab.id, sql); + const expectedNextOffset = appendResult ? tab.result?.rows.length : (tab.resultPageOffset ?? 0) + (tab.resultPageLimit ?? limit); + const continuesResultSession = offset === expectedNextOffset && limit === tab.resultPageLimit; + const connection = useConnectionStore().getConfig(tab.connectionId); + const isSqlServerLegacy = connection?.db_type === "sqlserver" && connection.driver_profile?.trim().toLowerCase() === "sqlserver-legacy"; + const sessionId = isSqlServerLegacy && tab.result?.has_more && tab.result.session_id && continuesResultSession ? tab.result.session_id : undefined; await queryStore.executeTabSql(tab.id, sql, { - pagination: { offset, limit }, + pagination: { offset, limit, sessionId, clientSessionId: sessionId ? tab.resultClientSessionId : undefined }, ...appendOptions, preserveResultDuringExecution: true, preserveTotalRowCountDuringExecution: true, diff --git a/apps/desktop/src/lib/__tests__/database/databaseDriverManifest.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseDriverManifest.spec.ts index 02368fc862..6a38fe146a 100644 --- a/apps/desktop/src/lib/__tests__/database/databaseDriverManifest.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/databaseDriverManifest.spec.ts @@ -12,6 +12,9 @@ describe("databaseDriverManifest", () => { expect(usesAgentCursorForQuery("mysql")).toBe(false); expect(usesAgentCursorForQuery("jdbc")).toBe(true); expect(usesAgentCursorForQuery("prestosql")).toBe(true); + expect(usesAgentCursorForQuery("sqlserver")).toBe(false); + expect(usesAgentCursorForQuery("sqlserver", "sqlserver-legacy")).toBe(true); + expect(usesAgentCursorForQuery("sqlserver", " SQLSERVER-LEGACY ")).toBe(true); }); it("exposes connection defaults from the shared manifest", () => { diff --git a/apps/desktop/src/lib/dataGrid/dataGridSql.ts b/apps/desktop/src/lib/dataGrid/dataGridSql.ts index d257dc255c..23d2e47695 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridSql.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridSql.ts @@ -89,6 +89,7 @@ export interface DataGridColumnValuesFilterConditionOptions { export interface DataGridColumnDistinctValuesSqlOptions { databaseType?: DatabaseType; + driverProfile?: string; identifierQuote?: string; catalog?: string; database?: string; diff --git a/apps/desktop/src/lib/database/databaseDriverManifest.ts b/apps/desktop/src/lib/database/databaseDriverManifest.ts index fd2556230f..ebef349c8d 100644 --- a/apps/desktop/src/lib/database/databaseDriverManifest.ts +++ b/apps/desktop/src/lib/database/databaseDriverManifest.ts @@ -153,7 +153,8 @@ export function manifestDatabaseTypes(): DatabaseType[] { return DATABASE_DRIVER_ENTRIES.map((entry) => entry.dbType); } -export function usesAgentCursorForQuery(dbType?: DatabaseType): boolean { +export function usesAgentCursorForQuery(dbType?: DatabaseType, driverProfile?: string): boolean { + if (dbType === "sqlserver" && driverProfile?.trim().toLowerCase() === "sqlserver-legacy") return true; const runtimeMode = databaseRuntimeMode(dbType); return runtimeMode === "agent" || runtimeMode === "external"; } diff --git a/apps/desktop/src/lib/database/databaseSearch.ts b/apps/desktop/src/lib/database/databaseSearch.ts index 49e862a7bc..ba3c23091e 100644 --- a/apps/desktop/src/lib/database/databaseSearch.ts +++ b/apps/desktop/src/lib/database/databaseSearch.ts @@ -3,6 +3,7 @@ import * as api from "@/lib/backend/api.ts"; export interface DatabaseSearchSqlOptions { databaseType?: DatabaseType; + driverProfile?: string; schema?: string; tableName: string; columns: ColumnInfo[]; diff --git a/apps/desktop/src/stores/__tests__/queryStore.queryResultExport.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.queryResultExport.spec.ts index 4cb4217109..955ae3523d 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.queryResultExport.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.queryResultExport.spec.ts @@ -76,4 +76,35 @@ describe("queryStore query result export", () => { expect(request?.identifierQuote).toBe("["); expect(mocks.connectionIdentifierQuote).toHaveBeenCalledWith("kingbase-1"); }); + + it("uses the Agent cursor for SQL Server legacy result export", async () => { + mocks.getConfig.mockReturnValue({ + id: "sqlserver-2000", + name: "SQL Server 2000", + db_type: "sqlserver", + driver_profile: "sqlserver-legacy", + database: "master", + query_timeout_secs: 30, + }); + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + const tabId = store.createTab("sqlserver-2000", "master", "Query"); + const tab = store.tabs.find((item) => item.id === tabId)!; + tab.sql = "SELECT * FROM dbo.Users"; + tab.lastExecutedSql = tab.sql; + tab.result = { + columns: ["id"], + rows: [[1]], + affected_rows: 0, + execution_time_ms: 1, + }; + + const request = await store.buildQueryResultExportRequest(tabId, { + exportId: "export-legacy", + filePath: "users.csv", + format: "csv", + }); + + expect(request?.useAgentCursor).toBe(true); + }); }); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 7434a8b017..598a88650f 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -4714,7 +4714,7 @@ export const useQueryStore = defineStore("query", () => { const targetDatabase = resumedExecutionTarget ? resumedExecutionTarget.database : targetContext?.scope === "connection" ? "" : (contextDatabase ?? executionTarget?.database ?? tab.database); const targetSchema = resumedExecutionTarget ? resumedExecutionTarget.schema : targetContext?.scope === "connection" ? undefined : (databaseTargetContext?.schema ?? executionTarget?.schema ?? tab.schema); const executionDatabase = dataTabExecutionDatabase(conn, targetDatabase, executionCatalog); - const useAgentCursor = usesAgentCursorForQuery(conn?.db_type); + const useAgentCursor = usesAgentCursorForQuery(conn?.db_type, conn?.driver_profile); const queryTimeoutSecs = queryTimeoutSecsForConnection(conn, settingsStore.editorSettings.globalQueryTimeoutSecs); if (!batchResume) { const statementExecution = @@ -5322,6 +5322,7 @@ export const useQueryStore = defineStore("query", () => { const pagination = limitQueryPagination(requestedPagination, queryResultMaxRows); pageLimit = pagination.limit; pageOffset = pagination.offset; + useAgentResultSession = conn?.db_type === "sqlserver" && conn?.driver_profile?.trim().toLowerCase() === "sqlserver-legacy"; } const executionSchema = connectionQueryExecutionSchema(conn, targetDatabase, targetSchema, tab.mode === "data"); @@ -6495,6 +6496,8 @@ export const useQueryStore = defineStore("query", () => { let offset = 0; const clientSessionId = tabClientSessionId(tab, "export"); const exportExecutionId = uuid(); + const useAgentCursor = conn?.db_type === "sqlserver" && conn?.driver_profile?.trim().toLowerCase() === "sqlserver-legacy"; + let sessionId: string | undefined; try { while (true) { @@ -6514,23 +6517,43 @@ export const useQueryStore = defineStore("query", () => { limit: pageLimit, offset, }); - const results = await api.executeMulti(tab.connectionId, executionDatabase, sql, undefined, exportExecutionId, { - maxRows: pageLimit, - fetchSize: pageLimit, - clientSessionId, - catalog: tableMeta.catalog, - timeoutSecs: queryTimeoutSecs, - }); + const results = await api.executeMulti( + tab.connectionId, + executionDatabase, + sql, + undefined, + exportExecutionId, + useAgentCursor + ? { + maxRows: 2_147_483_647, + fetchSize: pageLimit, + pageSize: pageLimit, + resultSessionId: sessionId, + clientSessionId, + catalog: tableMeta.catalog, + timeoutSecs: queryTimeoutSecs, + } + : { + maxRows: pageLimit, + fetchSize: pageLimit, + clientSessionId, + catalog: tableMeta.catalog, + timeoutSecs: queryTimeoutSecs, + }, + ); const result = results[0]; if (!result) break; if (columns.length === 0) columns = result.columns; rows.push(...result.rows); executionTimeMs += result.execution_time_ms ?? 0; onProgress?.({ rowsExported: rows.length, totalRows }); - if (result.rows.length < pageLimit) break; + sessionId = result.session_id ?? undefined; + const shouldFetchNextPage = useAgentCursor ? result.has_more === true : result.rows.length >= pageLimit; + if (!shouldFetchNextPage) break; offset += result.rows.length; } } finally { + if (sessionId) void api.closeQuerySession(tab.connectionId, executionDatabase, sessionId, clientSessionId, tableMeta.catalog); void closeClientSessionId(tab.connectionId, executionDatabase, clientSessionId, tableMeta.catalog, { tabId: tab.id }); } @@ -6558,7 +6581,7 @@ export const useQueryStore = defineStore("query", () => { // main 引入全局查询超时:queryTimeoutSecsForConnection 现需传入全局默认值; // settingsStore 取 defineStore 顶层声明的实例(本函数无局部覆盖)。 const queryTimeoutSecs = queryTimeoutSecsForConnection(conn, settingsStore.editorSettings.globalQueryTimeoutSecs); - const useAgentCursor = usesAgentCursorForQuery(conn?.db_type); + const useAgentCursor = usesAgentCursorForQuery(conn?.db_type, conn?.driver_profile); const queryBaseSql = queryResultBaseSql(tab); const exportSettings = useSettingsStore().editorSettings; const exportRowLimit = exportSettings.exportRowLimitEnabled ? exportSettings.exportRowLimit : Number.POSITIVE_INFINITY; @@ -6699,7 +6722,7 @@ export const useQueryStore = defineStore("query", () => { const effectiveDbType = effectiveDatabaseTypeForConnection(conn); if (!effectiveDbType) return undefined; if (effectiveDbType === "mongodb") return undefined; - const useAgentCursor = usesAgentCursorForQuery(conn?.db_type); + const useAgentCursor = usesAgentCursorForQuery(conn?.db_type, conn?.driver_profile); const queryBaseSql = queryResultBaseSql(tab); const resultStatementIndex = tab.result.statement_index; const batchSql = tab.resultBaseSql ?? tab.lastExecutedSql ?? tab.sql; @@ -6757,7 +6780,7 @@ export const useQueryStore = defineStore("query", () => { sql, queryBaseSql: sql, databaseType: effectiveDbType, - useAgentCursor: usesAgentCursorForQuery(conn?.db_type), + useAgentCursor: usesAgentCursorForQuery(conn?.db_type, conn?.driver_profile), filePath, format, pageSize: settings.exportBatchSize, diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index 975ee9963d..92f78c289c 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -235,6 +235,8 @@ pub struct DataGridColumnDistinctValuesSqlOptions { #[serde(default, skip_serializing_if = "Option::is_none")] pub database_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub driver_profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub identifier_quote: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub catalog: Option, @@ -892,6 +894,9 @@ pub fn build_data_grid_column_distinct_values_sql(options: DataGridColumnDistinc let from_clause = format!(" FROM {table}{where_clause}{group_by}{order_by}"); match table_pagination_strategy(options.database_type) { + TablePaginationStrategy::SqlServerTop if is_sqlserver_legacy_profile(options.driver_profile.as_deref()) => { + format!("SELECT {select_list}{from_clause}") + } TablePaginationStrategy::SqlServerTop => format!("SELECT TOP ({limit}) {select_list}{from_clause}"), TablePaginationStrategy::IrisTop => format!("SELECT TOP {limit} {select_list}{from_clause}"), TablePaginationStrategy::InformixFirst => format!("SELECT FIRST {limit} {select_list}{from_clause}"), @@ -915,6 +920,10 @@ pub fn build_data_grid_column_distinct_values_sql(options: DataGridColumnDistinc } } +fn is_sqlserver_legacy_profile(driver_profile: Option<&str>) -> bool { + driver_profile.is_some_and(|profile| profile.trim().eq_ignore_ascii_case("sqlserver-legacy")) +} + pub fn build_data_grid_count_sql(options: DataGridCountSqlOptions) -> String { let table = if crate::sql_dialect::uses_connection_identifier_quote( options.database_type, @@ -4730,6 +4739,7 @@ mod tests { assert_eq!( build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { database_type: Some(DatabaseType::Postgres), + driver_profile: None, identifier_quote: None, catalog: None, database: None, @@ -4747,6 +4757,7 @@ mod tests { assert_eq!( build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { database_type: Some(DatabaseType::SqlServer), + driver_profile: None, identifier_quote: None, catalog: None, database: None, @@ -4764,6 +4775,7 @@ mod tests { assert_eq!( build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { database_type: Some(DatabaseType::SqlServer), + driver_profile: None, identifier_quote: None, catalog: None, database: None, @@ -4778,9 +4790,28 @@ mod tests { }), "SELECT TOP (25) [id] AS dbx_value, COUNT(*) AS dbx_count FROM [users] WHERE [id] = 42 GROUP BY [id] ORDER BY dbx_count DESC, dbx_value" ); + assert_eq!( + build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { + database_type: Some(DatabaseType::SqlServer), + driver_profile: Some(" SQLSERVER-LEGACY ".to_string()), + identifier_quote: None, + catalog: None, + database: None, + schema: None, + table_name: "users".to_string(), + column_name: "status".to_string(), + column_info: Some(column("status", "nvarchar", true, None)), + where_input: None, + search_value: None, + limit: Some(25), + include_counts: true, + }), + "SELECT [status] AS dbx_value, COUNT(*) AS dbx_count FROM [users] GROUP BY [status] ORDER BY dbx_count DESC, dbx_value" + ); assert_eq!( build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { database_type: Some(DatabaseType::Oracle), + driver_profile: None, identifier_quote: None, catalog: None, database: None, @@ -4798,6 +4829,7 @@ mod tests { assert_eq!( build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { database_type: Some(DatabaseType::Firebird), + driver_profile: None, identifier_quote: None, catalog: None, database: None, @@ -4816,6 +4848,7 @@ mod tests { assert_eq!( build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { database_type: Some(DatabaseType::Doris), + driver_profile: None, identifier_quote: None, catalog: Some("iceberg_catalog".to_string()), database: None, @@ -4833,6 +4866,7 @@ mod tests { assert_eq!( build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { database_type: Some(DatabaseType::StarRocks), + driver_profile: None, identifier_quote: None, catalog: Some("hive_catalog".to_string()), database: None, @@ -4851,6 +4885,7 @@ mod tests { assert_eq!( build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions { database_type: Some(DatabaseType::Doris), + driver_profile: None, identifier_quote: None, catalog: Some("internal".to_string()), database: None, diff --git a/crates/dbx-core/src/database_search_sql.rs b/crates/dbx-core/src/database_search_sql.rs index 036736d8d1..d67ddf4870 100644 --- a/crates/dbx-core/src/database_search_sql.rs +++ b/crates/dbx-core/src/database_search_sql.rs @@ -21,6 +21,8 @@ pub struct DatabaseSearchSqlOptions { #[serde(default, skip_serializing_if = "Option::is_none")] pub database_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub driver_profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub schema: Option, pub table_name: String, #[serde(default)] @@ -92,7 +94,7 @@ pub fn build_database_search_sql(options: DatabaseSearchSqlOptions) -> Option String { pattern } -fn text_cast_expression(database_type: Option, identifier: &str) -> String { +fn text_cast_expression(database_type: Option, driver_profile: Option<&str>, identifier: &str) -> String { match database_type { Some(DatabaseType::Mysql) => format!("LOWER(CAST({identifier} AS CHAR))"), + Some(DatabaseType::SqlServer) + if driver_profile.is_some_and(|profile| profile.trim().eq_ignore_ascii_case("sqlserver-legacy")) => + { + format!("LOWER(CAST({identifier} AS NVARCHAR(4000)))") + } Some(DatabaseType::SqlServer) => format!("LOWER(CAST({identifier} AS NVARCHAR(MAX)))"), Some(DatabaseType::Oracle | DatabaseType::OceanbaseOracle) => { format!("LOWER(CAST({identifier} AS VARCHAR2(4000)))") @@ -278,6 +285,7 @@ mod tests { fn builds_table_search_query_over_text_columns() { let query = build_database_search_sql(DatabaseSearchSqlOptions { database_type: Some(DatabaseType::Mysql), + driver_profile: None, schema: None, table_name: "users".to_string(), columns: vec![col("id", "bigint", true), col("email", "varchar", false), col("avatar", "blob", false)], @@ -293,10 +301,30 @@ mod tests { ); } + #[test] + fn uses_legacy_sqlserver_cast_without_max_type() { + let query = build_database_search_sql(DatabaseSearchSqlOptions { + database_type: Some(DatabaseType::SqlServer), + driver_profile: Some(" SQLSERVER-LEGACY ".to_string()), + schema: Some("dbo".to_string()), + table_name: "users".to_string(), + columns: vec![col("name", "nvarchar", false)], + term: "alice".to_string(), + limit: Some(20), + }) + .unwrap(); + + assert_eq!( + query.sql, + "SELECT TOP 20 * FROM [dbo].[users] WHERE (LOWER(CAST([name] AS NVARCHAR(4000))) LIKE '%alice%' ESCAPE '~')" + ); + } + #[test] fn adds_numeric_predicates_only_for_numeric_terms() { let query = build_database_search_sql(DatabaseSearchSqlOptions { database_type: Some(DatabaseType::Postgres), + driver_profile: None, schema: Some("public".to_string()), table_name: "orders".to_string(), columns: vec![col("id", "integer", true), col("note", "text", false)], @@ -314,6 +342,7 @@ mod tests { fn builds_oceanbase_oracle_search_query_with_rownum_limit() { let query = build_database_search_sql(DatabaseSearchSqlOptions { database_type: Some(DatabaseType::OceanbaseOracle), + driver_profile: None, schema: Some("APP".to_string()), table_name: "USERS".to_string(), columns: vec![col("NAME", "varchar2", false)], @@ -334,6 +363,7 @@ mod tests { assert_eq!( build_database_search_sql(DatabaseSearchSqlOptions { database_type: Some(DatabaseType::Sqlite), + driver_profile: None, schema: None, table_name: "files".to_string(), columns: vec![col("payload", "blob", false)], diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index 8c9ca26d85..4dbafe643f 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -131,6 +131,14 @@ pub fn completion_context_sql() -> &'static str { SQLSERVER_COMPLETION_CONTEXT_SQL } +pub fn completion_context_sql_for_profile(driver_profile: Option<&str>) -> &'static str { + if driver_profile.is_some_and(|profile| profile.trim().eq_ignore_ascii_case(SQLSERVER_LEGACY_DRIVER_PROFILE)) { + "SELECT TOP 1 u.name AS default_schema, 1 AS engine_edition FROM sysusers u WHERE u.name = USER_NAME()" + } else { + completion_context_sql() + } +} + pub fn completion_context_from_query_result(result: QueryResult) -> Result { let row = result.rows.first().ok_or_else(|| "SQL Server completion context query returned no rows".to_string())?; let default_schema = row.first().and_then(serde_json::Value::as_str); @@ -4163,6 +4171,17 @@ mod tests { assert!(SQLSERVER_COMPLETION_CONTEXT_SQL.contains("EngineEdition")); } + #[test] + fn sqlserver_legacy_completion_context_uses_sql_server_2000_catalogs() { + let sql = super::completion_context_sql_for_profile(Some(" SQLSERVER-LEGACY ")); + assert_eq!( + sql, + "SELECT TOP 1 u.name AS default_schema, 1 AS engine_edition FROM sysusers u WHERE u.name = USER_NAME()" + ); + assert!(!sql.contains("sys.schemas")); + assert!(!sql.contains("SERVERPROPERTY")); + } + #[test] fn sqlserver_completion_context_disables_use_for_azure_database_endpoints() { assert!(!sqlserver_supports_session_database_switch(5)); diff --git a/crates/dbx-core/src/schema.rs b/crates/dbx-core/src/schema.rs index 91ef7a6318..674c3fd1bd 100644 --- a/crates/dbx-core/src/schema.rs +++ b/crates/dbx-core/src/schema.rs @@ -290,6 +290,9 @@ pub async fn get_sqlserver_completion_context_core( retry_metadata_connection(state, connection_id, Some(database), || async { let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?; let db_config = connection_config(state, connection_id).await; + let completion_context_sql = db::sqlserver::completion_context_sql_for_profile( + db_config.as_ref().and_then(|config| config.driver_profile.as_deref()), + ); let connections = state.connections.read().await; if let Some(PoolKind::ExternalDriver { config, session, .. }) = connections.get(&pool_key) { let config = config.clone(); @@ -301,7 +304,7 @@ pub async fn get_sqlserver_completion_context_core( serde_json::json!({ "connection": config.as_ref(), "database": database, - "sql": db::sqlserver::completion_context_sql(), + "sql": completion_context_sql, "maxRows": 1 }), agent_metadata_timeout(Some(config.as_ref())), @@ -316,7 +319,7 @@ pub async fn get_sqlserver_completion_context_core( let result = client .execute_query_with_timeout::( agent_execute_query_params( - db::sqlserver::completion_context_sql(), + completion_context_sql, if database.is_empty() { None } else { Some(database) }, None, QueryExecutionOptions { max_rows: Some(1), ..Default::default() }, diff --git a/crates/dbx-core/src/sql_dialect/table_select.rs b/crates/dbx-core/src/sql_dialect/table_select.rs index bb304ef7b3..d54d71e6dc 100644 --- a/crates/dbx-core/src/sql_dialect/table_select.rs +++ b/crates/dbx-core/src/sql_dialect/table_select.rs @@ -359,6 +359,10 @@ pub fn build_table_data_select_sql_with_database( &options.columns, limit, options.offset.unwrap_or(0), + options + .driver_profile + .as_deref() + .is_some_and(|profile| profile.trim().eq_ignore_ascii_case("sqlserver-legacy")), ), TablePaginationStrategy::QuestDbLimit => build_questdb_table_select_sql( &table_alias, @@ -641,6 +645,7 @@ pub(super) fn build_sqlserver_table_select_sql( columns: &[String], limit: usize, offset: usize, + legacy_compatible: bool, ) -> String { let columns_sql = if columns.is_empty() { "*".to_string() @@ -652,6 +657,9 @@ pub(super) fn build_sqlserver_table_select_sql( .join(", ") }; let order = if order_by == "(SELECT NULL)" { String::new() } else { format!(" ORDER BY {order_by}") }; + if legacy_compatible { + return format!("SELECT {columns_sql} FROM {table}{where_clause}{order}"); + } if offset == 0 { return format!("SELECT TOP ({limit}) {columns_sql} FROM {table}{where_clause}{order}"); } @@ -893,4 +901,16 @@ mod tests { r#"count({__name__="rack\\\"temperature"})"# ); } + + #[test] + fn sqlserver_legacy_table_preview_leaves_paging_to_the_agent_cursor() { + let mut options = opts(DatabaseType::SqlServer, None, None, "users"); + options.driver_profile = Some(" SQLSERVER-LEGACY ".to_string()); + options.columns = vec!["id".to_string(), "name".to_string()]; + options.order_by = Some("[id] ASC".to_string()); + options.limit = Some(100); + options.offset = Some(100); + + assert_eq!(build_table_data_select_sql(options), "SELECT [id], [name] FROM [users] ORDER BY [id] ASC"); + } }