mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(sqlserver): jtds prelogin fallback and legacy 2000 support
Closes #9333
This commit is contained in:
@@ -7,8 +7,12 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-windows-x64:
|
||||
build-windows-7-x64:
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
RUSTFLAGS: -C debuginfo=line-tables-only -C target-feature=+crt-static
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -33,16 +37,25 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: Setup Rust for standard Windows
|
||||
uses: dtolnay/rust-toolchain@1.97.1
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- name: Check standard Windows dependency path
|
||||
run: cargo check --locked --package dbx --no-default-features --target x86_64-pc-windows-msvc
|
||||
|
||||
- name: Setup Rust for Windows 7
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
toolchain: nightly-2026-07-22
|
||||
components: rust-src
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: "./ -> target"
|
||||
shared-key: sqlserver2000-windows-x64
|
||||
shared-key: sqlserver2000-windows-7-x64
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Build SQL Server legacy agent
|
||||
@@ -59,15 +72,63 @@ jobs:
|
||||
}
|
||||
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: Prepare Win7-compatible WebView2 loader
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/prepare-webview2-win7-loader.ps1
|
||||
|
||||
- name: Prepare WebView2 109 fixed runtime
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/prepare-webview2-win7-runtime.ps1
|
||||
|
||||
- name: Probe WebView2 109 fixed runtime
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/assert-webview2-win7-runtime.ps1
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm build
|
||||
|
||||
- name: Build DBX for Windows 7
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:TAURI_CONFIG = Get-Content src-tauri/tauri.webview2-win7-fixed.conf.json -Raw
|
||||
cargo build --locked --package dbx --release --features custom-protocol --target x86_64-win7-windows-msvc -Z build-std=std,panic_abort
|
||||
|
||||
- name: Audit Windows 7 PE imports
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/assert-win7-pe-compat.ps1 -BinaryPath target/x86_64-win7-windows-msvc/release/dbx.exe
|
||||
|
||||
- name: Audit Win7 WebView2 loader markers
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/assert-webview2-win7-loader.ps1 -BinaryPath target/x86_64-win7-windows-msvc/release/dbx.exe
|
||||
|
||||
- name: Bundle Windows 7 fixed-runtime installer
|
||||
shell: pwsh
|
||||
run: |
|
||||
$bundleDir = "target/x86_64-win7-windows-msvc/release/bundle/nsis"
|
||||
pnpm tauri bundle --bundles nsis --target x86_64-win7-windows-msvc --config src-tauri/tauri.webview2-win7-fixed.conf.json
|
||||
$installer = Get-ChildItem $bundleDir -Filter "*.exe" |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -First 1
|
||||
if (!$installer) {
|
||||
Write-Error "Missing Windows 7 fixed-runtime installer in ${bundleDir}"
|
||||
exit 1
|
||||
}
|
||||
Get-FileHash -LiteralPath $installer.FullName -Algorithm SHA256
|
||||
|
||||
- name: Audit Windows 7 installer contents
|
||||
shell: pwsh
|
||||
run: |
|
||||
$installer = Get-ChildItem "target/x86_64-win7-windows-msvc/release/bundle/nsis" -Filter "*.exe" |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -First 1
|
||||
./.github/scripts/assert-win7-installer-content.ps1 -InstallerPath $installer.FullName
|
||||
|
||||
- name: Upload installer
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: DBX-SQLServer2000-Windows-x64
|
||||
name: DBX-SQLServer2000-Windows-7-x64
|
||||
path: |
|
||||
target/x86_64-pc-windows-msvc/release/bundle/nsis/*.exe
|
||||
target/x86_64-win7-windows-msvc/release/bundle/nsis/*.exe
|
||||
sqlserver-legacy-agent.jar
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
+19
-6
@@ -96,7 +96,7 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
return connection;
|
||||
} catch (SQLException error) {
|
||||
logConnectionEvent("mssql-jdbc failed", params, error);
|
||||
if (isSqlServer2000Unsupported(error)) {
|
||||
if (shouldFallbackToJtds(error)) {
|
||||
logConnectionEvent("switching to jTDS 1.3.1 fallback", params, null);
|
||||
try {
|
||||
super.loadDriver(jtdsDriverParams());
|
||||
@@ -203,7 +203,7 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
.replace('\n', ' ');
|
||||
}
|
||||
|
||||
static boolean isSqlServer2000Unsupported(Throwable error) {
|
||||
static boolean shouldFallbackToJtds(Throwable error) {
|
||||
Throwable current = error;
|
||||
while (current != null) {
|
||||
String message = current.getMessage();
|
||||
@@ -215,16 +215,29 @@ public final class SqlServerLegacyAgent extends ConfiguredJdbcAgent {
|
||||
// "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) {
|
||||
// Other driver wordings name the supported floor instead. The
|
||||
// localized mssql-jdbc resource keeps "SQL Server 2005" in
|
||||
// English while translating the "or later" suffix.
|
||||
boolean floor2005Rejection = normalized.contains("sql server 2005 or later")
|
||||
|| (normalized.contains("sql server 2005") && normalized.contains("更高版本"));
|
||||
// Some SQL Server 2000 installations close the TDS 7.4
|
||||
// prelogin socket before mssql-jdbc can report the server
|
||||
// version. In legacy mode, retry that handshake once with
|
||||
// jTDS, which speaks the older protocol.
|
||||
boolean preloginRejection = normalized.contains("connection reset")
|
||||
|| normalized.contains("connection was reset")
|
||||
|| normalized.contains("forcibly closed")
|
||||
|| normalized.contains("意外的登录前响应")
|
||||
|| (normalized.contains("unexpected")
|
||||
&& (normalized.contains("prelogin") || normalized.contains("pre-login")));
|
||||
if (version8Rejection || floor2005Rejection || preloginRejection) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (current instanceof SQLException) {
|
||||
SQLException next = ((SQLException) current).getNextException();
|
||||
if (next != null && next != current.getCause()) {
|
||||
if (isSqlServer2000Unsupported(next)) {
|
||||
if (shouldFallbackToJtds(next)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+18
-9
@@ -11,6 +11,7 @@ import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.net.SocketException;
|
||||
import java.security.Security;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
@@ -26,27 +27,35 @@ import java.util.Map;
|
||||
|
||||
class SqlServerLegacyAgentTest {
|
||||
@Test
|
||||
void onlySqlServer8UnsupportedErrorsTriggerTheOldDriverFallback() {
|
||||
// Real mssql-jdbc prelogin rejection for SQL Server 2000
|
||||
// (R_unsupportedServerVersion, English-only resources).
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.isSqlServer2000Unsupported(
|
||||
void sqlServer2000PreloginFailuresTriggerTheOldDriverFallback() {
|
||||
// Real mssql-jdbc prelogin rejection for SQL Server 2000.
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(
|
||||
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(
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(
|
||||
new SQLException("This version of the driver can be used only with SQL Server 2005 or later.")
|
||||
));
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.isSqlServer2000Unsupported(
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(
|
||||
new SQLException("该驱动程序只能与 SQL Server 2005 或更高版本一起使用。")
|
||||
));
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(
|
||||
new SQLException("该驱动程序不支持 SQL Server 8 版")
|
||||
));
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.isSqlServer2000Unsupported(
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(
|
||||
new SQLException("The driver does not support SQL Server 8")
|
||||
));
|
||||
Assertions.assertFalse(SqlServerLegacyAgent.isSqlServer2000Unsupported(
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(
|
||||
new SQLException("Connection failed", new SocketException("Connection reset"))
|
||||
));
|
||||
SQLException chained = new SQLException("Connection failed");
|
||||
chained.setNextException(new SQLException("驱动程序收到意外的登录前响应。"));
|
||||
Assertions.assertTrue(SqlServerLegacyAgent.shouldFallbackToJtds(chained));
|
||||
Assertions.assertFalse(SqlServerLegacyAgent.shouldFallbackToJtds(
|
||||
new SQLException("TLS handshake failed")
|
||||
));
|
||||
Assertions.assertFalse(SqlServerLegacyAgent.isSqlServer2000Unsupported(
|
||||
Assertions.assertFalse(SqlServerLegacyAgent.shouldFallbackToJtds(
|
||||
new SQLException("Login failed for user 'sa'")
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1848,6 +1848,24 @@ WHERE t2.product_name = '12345'
|
||||
|
||||
expect(candidateSummaries(candidates)).toEqual(["cursor:KILL 580", `all:${sql}`]);
|
||||
});
|
||||
|
||||
it("keeps SQL Server IF/ELSE control-flow batches as one execution range", () => {
|
||||
const sql = [
|
||||
"IF EXISTS (SELECT 1 FROM ::fn_listextendedproperty('MS_Description','USER','dbo','TABLE','Categories','COLUMN','CategoryID'))",
|
||||
"BEGIN",
|
||||
" EXEC sp_updateextendedproperty @name=N'MS_Description', @value=N'test', @level0type=N'USER', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'Categories', @level2type=N'COLUMN', @level2name=N'CategoryID'",
|
||||
"END",
|
||||
"ELSE",
|
||||
"BEGIN",
|
||||
" EXEC sp_addextendedproperty @name=N'MS_Description', @value=N'test', @level0type=N'USER', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'Categories', @level2type=N'COLUMN', @level2name=N'CategoryID'",
|
||||
"END",
|
||||
].join("\n");
|
||||
const ranges = executableStatementRanges(sql, "sqlserver");
|
||||
const candidates = buildExecutionCandidates(sql, indexOf(sql, "sp_addextendedproperty"), "sqlserver");
|
||||
|
||||
expect(rangeSqlTexts(ranges)).toEqual([sql]);
|
||||
expect(candidateSummaries(candidates)).toEqual([`all:${sql}`]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasMultipleExecutionTargets", () => {
|
||||
|
||||
@@ -744,6 +744,8 @@ function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, d
|
||||
if (isSapHanaScriptBlockStatement(statement.sql, databaseType)) return [statement];
|
||||
// Routine bodies contain top-level-looking SET/INSERT/SELECT lines that are not independent statements.
|
||||
if (isMysqlRoutineBlockDatabase(databaseType) && startsWithMysqlRoutineBlock(statement.sql, parameterOptions)) return [statement];
|
||||
// SQL Server control-flow batches use line-oriented BEGIN/EXEC tokens inside one IF/ELSE statement.
|
||||
if (isSqlServerIfElseControlFlowBatch(sql, statement, databaseType, parameterOptions)) return [statement];
|
||||
|
||||
const lineStarts = topLevelSoftStatementLineStarts(sql, statement, databaseType, parameterOptions);
|
||||
if (lineStarts.length <= 1) return [statement];
|
||||
@@ -849,6 +851,13 @@ function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, d
|
||||
return ranges.length > 0 ? ranges : [statement];
|
||||
}
|
||||
|
||||
function isSqlServerIfElseControlFlowBatch(sql: string, statement: RawStatement, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): boolean {
|
||||
if (databaseType !== "sqlserver" || !startsWithSqlWords(sql, statement.from, ["IF"], databaseType, parameterOptions)) return false;
|
||||
|
||||
const words = topLevelWordsBefore(sql, statement.from, statement.to, 64, databaseType, parameterOptions);
|
||||
return words.includes("ELSE") && words.includes("BEGIN") && words.includes("END");
|
||||
}
|
||||
|
||||
function topLevelSoftStatementLineStarts(sql: string, statement: RawStatement, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions): Array<{ hitFrom: number; from: number; keyword: string }> {
|
||||
const starts: Array<{ hitFrom: number; from: number; keyword: string }> = [];
|
||||
const len = statement.to;
|
||||
|
||||
@@ -1271,7 +1271,9 @@ fn find_sqlserver_statement_at_cursor(sql: &str, cursor_pos: usize) -> String {
|
||||
|
||||
for (idx, batch) in batches.iter().enumerate() {
|
||||
if cursor >= batch.start && cursor <= batch.end {
|
||||
if profile.keeps_sqlserver_module_batch_at_cursor && starts_with_sqlserver_module_ddl(&batch.text) {
|
||||
if starts_with_sqlserver_control_flow_batch(&batch.text)
|
||||
|| (profile.keeps_sqlserver_module_batch_at_cursor && starts_with_sqlserver_module_ddl(&batch.text))
|
||||
{
|
||||
return batch.text.clone();
|
||||
}
|
||||
let relative_cursor = sql[..cursor].encode_utf16().count() - sql[..batch.start].encode_utf16().count();
|
||||
@@ -1280,7 +1282,9 @@ fn find_sqlserver_statement_at_cursor(sql: &str, cursor_pos: usize) -> String {
|
||||
|
||||
if cursor < batch.start {
|
||||
if let Some(prev) = idx.checked_sub(1).and_then(|prev_idx| batches.get(prev_idx)) {
|
||||
if profile.keeps_sqlserver_module_batch_at_cursor && starts_with_sqlserver_module_ddl(&prev.text) {
|
||||
if starts_with_sqlserver_control_flow_batch(&prev.text)
|
||||
|| (profile.keeps_sqlserver_module_batch_at_cursor && starts_with_sqlserver_module_ddl(&prev.text))
|
||||
{
|
||||
return prev.text.clone();
|
||||
}
|
||||
let relative_cursor = prev.text.encode_utf16().count();
|
||||
@@ -1297,6 +1301,14 @@ fn find_sqlserver_statement_at_cursor(sql: &str, cursor_pos: usize) -> String {
|
||||
batches.last().map(|batch| batch.text.clone()).unwrap_or_else(|| sql.trim().to_string())
|
||||
}
|
||||
|
||||
fn starts_with_sqlserver_control_flow_batch(sql: &str) -> bool {
|
||||
let tokens = first_sql_tokens(sql, 128);
|
||||
tokens.first().is_some_and(|token| token.eq_ignore_ascii_case("IF"))
|
||||
&& tokens.iter().any(|token| token.eq_ignore_ascii_case("ELSE"))
|
||||
&& tokens.iter().any(|token| token.eq_ignore_ascii_case("BEGIN"))
|
||||
&& tokens.iter().any(|token| token.eq_ignore_ascii_case("END"))
|
||||
}
|
||||
|
||||
fn starts_with_sqlserver_module_ddl(sql: &str) -> bool {
|
||||
let tokens = first_sql_tokens(sql, 4);
|
||||
if tokens.len() >= 4
|
||||
@@ -4770,6 +4782,24 @@ END";
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_current_statement_keeps_if_else_control_flow_batch() {
|
||||
let sql = "\
|
||||
IF EXISTS (SELECT 1 FROM ::fn_listextendedproperty('MS_Description','USER','dbo','TABLE','Categories','COLUMN','CategoryID'))
|
||||
BEGIN
|
||||
EXEC sp_updateextendedproperty @name=N'MS_Description', @value=N'test', @level0type=N'USER', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'Categories', @level2type=N'COLUMN', @level2name=N'CategoryID'
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty @name=N'MS_Description', @value=N'test', @level0type=N'USER', @level0name=N'dbo', @level1type=N'TABLE', @level1name=N'Categories', @level2type=N'COLUMN', @level2name=N'CategoryID'
|
||||
END";
|
||||
let update_cursor = sql[..sql.find("sp_updateextendedproperty").unwrap()].encode_utf16().count();
|
||||
let add_cursor = sql[..sql.find("sp_addextendedproperty").unwrap()].encode_utf16().count();
|
||||
|
||||
assert_eq!(super::find_statement_at_cursor_for_database(sql, update_cursor, DatabaseType::SqlServer), sql);
|
||||
assert_eq!(super::find_statement_at_cursor_for_database(sql, add_cursor, DatabaseType::SqlServer), sql);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_hash_comments_split_statements_per_issue_428() {
|
||||
let sql = "SELECT 1; # mysql comment\n\nSELECT 2 # trailing comment";
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::column_format::{
|
||||
original_mysql_generated_clause,
|
||||
};
|
||||
use super::columns::build_drop_column_sql;
|
||||
use super::comments::build_sqlserver_column_comment_sql;
|
||||
use super::comments::build_sqlserver_column_comment_sql_for_profile;
|
||||
use super::dialect::{capabilities_for, database_label, StructureDialect};
|
||||
use super::types::{EditableStructureColumn, SingleColumnAlterSqlOptions, TableStructureSqlResult};
|
||||
use super::util::{
|
||||
@@ -124,6 +124,7 @@ pub fn build_single_column_alter_sql(options: SingleColumnAlterSqlOptions) -> Ta
|
||||
&options.column,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
options.driver_profile.as_deref(),
|
||||
&mut warnings,
|
||||
)),
|
||||
StructureDialect::Sqlite => {
|
||||
@@ -701,6 +702,7 @@ pub(super) fn build_sqlserver_existing_column_sql(
|
||||
column: &EditableStructureColumn,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
driver_profile: Option<&str>,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Vec<String> {
|
||||
let Some(original) = &column.original else {
|
||||
@@ -777,12 +779,13 @@ pub(super) fn build_sqlserver_existing_column_sql(
|
||||
|
||||
// Column comment changes via extended properties
|
||||
if clean(&column.comment) != original_comment(column) {
|
||||
statements.extend(build_sqlserver_column_comment_sql(
|
||||
statements.extend(build_sqlserver_column_comment_sql_for_profile(
|
||||
table,
|
||||
schema,
|
||||
table_name,
|
||||
¤t_name,
|
||||
&column.comment,
|
||||
driver_profile,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ use super::column_format::{
|
||||
column_definition, has_dameng_identity, is_dameng_identity_compatible_type, is_mysql_character_data_type,
|
||||
original_is_mysql_generated_column, original_mysql_generated_clause,
|
||||
};
|
||||
use super::comments::build_sqlserver_column_comment_sql;
|
||||
use super::comments::build_sqlserver_column_comment_sql_for_profile;
|
||||
use super::dialect::{capabilities_for, database_label, is_oracle_like, StructureDialect};
|
||||
use super::indexes::has_existing_index_change;
|
||||
use super::types::{EditableStructureColumn, TableStructureSqlOptions};
|
||||
@@ -167,6 +167,7 @@ pub(super) fn build_column_sql(options: &TableStructureSqlOptions, warnings: &mu
|
||||
&position_clause,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
}
|
||||
if has_original_column_positions
|
||||
@@ -286,6 +287,7 @@ pub(super) fn build_column_sql(options: &TableStructureSqlOptions, warnings: &mu
|
||||
column,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
options.driver_profile.as_deref(),
|
||||
warnings,
|
||||
)),
|
||||
StructureDialect::Sqlite => statements.extend(build_sqlite_existing_column_sql(&table, column, warnings)),
|
||||
@@ -585,6 +587,7 @@ pub(super) fn build_add_column_sql(
|
||||
position_clause: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
driver_profile: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let definition = column_definition(dialect, column);
|
||||
let mut statements = if is_oracle_like(dialect) || dialect == StructureDialect::Informix {
|
||||
@@ -620,7 +623,14 @@ pub(super) fn build_add_column_sql(
|
||||
));
|
||||
}
|
||||
if dialect == StructureDialect::SqlServer && !clean(&column.comment).is_empty() {
|
||||
statements.extend(build_sqlserver_column_comment_sql(table, schema, table_name, &column.name, &column.comment));
|
||||
statements.extend(build_sqlserver_column_comment_sql_for_profile(
|
||||
table,
|
||||
schema,
|
||||
table_name,
|
||||
&column.name,
|
||||
&column.comment,
|
||||
driver_profile,
|
||||
));
|
||||
}
|
||||
statements
|
||||
}
|
||||
|
||||
@@ -33,9 +33,13 @@ pub(super) fn build_table_comment_sql(options: &TableStructureSqlOptions, warnin
|
||||
StructureDialect::ClickHouse => {
|
||||
vec![format!("ALTER TABLE {table} MODIFY COMMENT {quoted};")]
|
||||
}
|
||||
StructureDialect::SqlServer => {
|
||||
build_sqlserver_table_comment_sql(&table, options.schema.as_deref(), &options.table_name, new_comment)
|
||||
}
|
||||
StructureDialect::SqlServer => build_sqlserver_table_comment_sql_for_profile(
|
||||
&table,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
new_comment,
|
||||
options.driver_profile.as_deref(),
|
||||
),
|
||||
_ => {
|
||||
if !clean(new_comment).is_empty() {
|
||||
warnings
|
||||
@@ -50,15 +54,22 @@ pub(super) fn sqlserver_schema_name(schema: Option<&str>) -> String {
|
||||
schema.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string()).unwrap_or_else(|| "dbo".to_string())
|
||||
}
|
||||
|
||||
fn build_sqlserver_extended_property_comment_sql(exists: &str, levels: &str, new_comment: &str) -> Vec<String> {
|
||||
fn build_sqlserver_extended_property_comment_sql(
|
||||
exists: &str,
|
||||
levels: &str,
|
||||
new_comment: &str,
|
||||
procedure_prefix: &str,
|
||||
) -> Vec<String> {
|
||||
let new_comment = clean(new_comment);
|
||||
if new_comment.is_empty() {
|
||||
return vec![format!("IF {exists} EXEC sys.sp_dropextendedproperty @name=N'MS_Description', {levels};")];
|
||||
return vec![format!(
|
||||
"IF {exists} EXEC {procedure_prefix}sp_dropextendedproperty @name=N'MS_Description', {levels};"
|
||||
)];
|
||||
}
|
||||
|
||||
let escaped_comment = new_comment.replace('\'', "''");
|
||||
vec![format!(
|
||||
"IF {exists} EXEC sys.sp_updateextendedproperty @name=N'MS_Description', @value=N'{escaped_comment}', {levels} ELSE EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=N'{escaped_comment}', {levels};"
|
||||
"IF {exists} EXEC {procedure_prefix}sp_updateextendedproperty @name=N'MS_Description', @value=N'{escaped_comment}', {levels} ELSE EXEC {procedure_prefix}sp_addextendedproperty @name=N'MS_Description', @value=N'{escaped_comment}', {levels};"
|
||||
)]
|
||||
}
|
||||
|
||||
@@ -67,41 +78,82 @@ pub(crate) fn build_sqlserver_table_comment_sql(
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
new_comment: &str,
|
||||
) -> Vec<String> {
|
||||
build_sqlserver_table_comment_sql_for_profile(qualified_table, schema, table_name, new_comment, None)
|
||||
}
|
||||
|
||||
pub(crate) fn build_sqlserver_table_comment_sql_for_profile(
|
||||
qualified_table: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
new_comment: &str,
|
||||
driver_profile: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let schema_name = sqlserver_schema_name(schema);
|
||||
let escaped_qualified = qualified_table.replace('\'', "''");
|
||||
let escaped_schema = schema_name.replace('\'', "''");
|
||||
let escaped_table = table_name.replace('\'', "''");
|
||||
let exists = format!(
|
||||
"EXISTS (SELECT 1 FROM sys.extended_properties AS ep WHERE ep.class = 1 AND ep.major_id = OBJECT_ID(N'{escaped_qualified}') AND ep.minor_id = 0 AND ep.name = N'MS_Description')"
|
||||
);
|
||||
let levels = format!(
|
||||
"@level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}'"
|
||||
);
|
||||
let (exists, levels, procedure_prefix) = if is_sqlserver_legacy_profile(driver_profile) {
|
||||
(
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM ::fn_listextendedproperty(N'MS_Description', N'USER', N'{escaped_schema}', N'TABLE', N'{escaped_table}', NULL, NULL))"
|
||||
),
|
||||
format!(
|
||||
"@level0type=N'USER', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}'"
|
||||
),
|
||||
"",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM sys.extended_properties AS ep WHERE ep.class = 1 AND ep.major_id = OBJECT_ID(N'{escaped_qualified}') AND ep.minor_id = 0 AND ep.name = N'MS_Description')"
|
||||
),
|
||||
format!(
|
||||
"@level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}'"
|
||||
),
|
||||
"sys.",
|
||||
)
|
||||
};
|
||||
|
||||
build_sqlserver_extended_property_comment_sql(&exists, &levels, new_comment)
|
||||
build_sqlserver_extended_property_comment_sql(&exists, &levels, new_comment, procedure_prefix)
|
||||
}
|
||||
|
||||
pub(super) fn build_sqlserver_index_comment_sql(
|
||||
pub(super) fn build_sqlserver_index_comment_sql_for_profile(
|
||||
qualified_table: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
index_name: &str,
|
||||
new_comment: &str,
|
||||
driver_profile: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let schema_name = sqlserver_schema_name(schema);
|
||||
let escaped_qualified = qualified_table.replace('\'', "''");
|
||||
let escaped_schema = schema_name.replace('\'', "''");
|
||||
let escaped_table = table_name.replace('\'', "''");
|
||||
let escaped_idx = index_name.replace('\'', "''");
|
||||
let exists = format!(
|
||||
"EXISTS (SELECT 1 FROM sys.extended_properties AS ep INNER JOIN sys.indexes AS i ON i.object_id = ep.major_id AND i.index_id = ep.minor_id WHERE ep.class = 7 AND ep.major_id = OBJECT_ID(N'{escaped_qualified}') AND i.name = N'{escaped_idx}' AND ep.name = N'MS_Description')"
|
||||
);
|
||||
let levels = format!(
|
||||
"@level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'INDEX', @level2name=N'{escaped_idx}'"
|
||||
);
|
||||
let (exists, levels, procedure_prefix) = if is_sqlserver_legacy_profile(driver_profile) {
|
||||
(
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM ::fn_listextendedproperty(N'MS_Description', N'USER', N'{escaped_schema}', N'TABLE', N'{escaped_table}', N'INDEX', N'{escaped_idx}'))"
|
||||
),
|
||||
format!(
|
||||
"@level0type=N'USER', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'INDEX', @level2name=N'{escaped_idx}'"
|
||||
),
|
||||
"",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM sys.extended_properties AS ep INNER JOIN sys.indexes AS i ON i.object_id = ep.major_id AND i.index_id = ep.minor_id WHERE ep.class = 7 AND ep.major_id = OBJECT_ID(N'{escaped_qualified}') AND i.name = N'{escaped_idx}' AND ep.name = N'MS_Description')"
|
||||
),
|
||||
format!(
|
||||
"@level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'INDEX', @level2name=N'{escaped_idx}'"
|
||||
),
|
||||
"sys.",
|
||||
)
|
||||
};
|
||||
|
||||
build_sqlserver_extended_property_comment_sql(&exists, &levels, new_comment)
|
||||
build_sqlserver_extended_property_comment_sql(&exists, &levels, new_comment, procedure_prefix)
|
||||
}
|
||||
|
||||
pub(crate) fn build_sqlserver_column_comment_sql(
|
||||
@@ -110,20 +162,50 @@ pub(crate) fn build_sqlserver_column_comment_sql(
|
||||
table_name: &str,
|
||||
column_name: &str,
|
||||
new_comment: &str,
|
||||
) -> Vec<String> {
|
||||
build_sqlserver_column_comment_sql_for_profile(qualified_table, schema, table_name, column_name, new_comment, None)
|
||||
}
|
||||
|
||||
pub(crate) fn build_sqlserver_column_comment_sql_for_profile(
|
||||
qualified_table: &str,
|
||||
schema: Option<&str>,
|
||||
table_name: &str,
|
||||
column_name: &str,
|
||||
new_comment: &str,
|
||||
driver_profile: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let schema_name = sqlserver_schema_name(schema);
|
||||
let escaped_qualified = qualified_table.replace('\'', "''");
|
||||
let escaped_schema = schema_name.replace('\'', "''");
|
||||
let escaped_table = table_name.replace('\'', "''");
|
||||
let escaped_col = column_name.replace('\'', "''");
|
||||
let exists = format!(
|
||||
"EXISTS (SELECT 1 FROM sys.extended_properties AS ep WHERE ep.class = 1 AND ep.major_id = OBJECT_ID(N'{escaped_qualified}') AND ep.minor_id = COLUMNPROPERTY(OBJECT_ID(N'{escaped_qualified}'), N'{escaped_col}', 'ColumnId') AND ep.name = N'MS_Description')"
|
||||
);
|
||||
let levels = format!(
|
||||
"@level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'COLUMN', @level2name=N'{escaped_col}'"
|
||||
);
|
||||
let (exists, levels, procedure_prefix) = if is_sqlserver_legacy_profile(driver_profile) {
|
||||
(
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM ::fn_listextendedproperty(N'MS_Description', N'USER', N'{escaped_schema}', N'TABLE', N'{escaped_table}', N'COLUMN', N'{escaped_col}'))"
|
||||
),
|
||||
format!(
|
||||
"@level0type=N'USER', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'COLUMN', @level2name=N'{escaped_col}'"
|
||||
),
|
||||
"",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM sys.extended_properties AS ep WHERE ep.class = 1 AND ep.major_id = OBJECT_ID(N'{escaped_qualified}') AND ep.minor_id = COLUMNPROPERTY(OBJECT_ID(N'{escaped_qualified}'), N'{escaped_col}', 'ColumnId') AND ep.name = N'MS_Description')"
|
||||
),
|
||||
format!(
|
||||
"@level0type=N'SCHEMA', @level0name=N'{escaped_schema}', @level1type=N'TABLE', @level1name=N'{escaped_table}', @level2type=N'COLUMN', @level2name=N'{escaped_col}'"
|
||||
),
|
||||
"sys.",
|
||||
)
|
||||
};
|
||||
|
||||
build_sqlserver_extended_property_comment_sql(&exists, &levels, new_comment)
|
||||
build_sqlserver_extended_property_comment_sql(&exists, &levels, new_comment, procedure_prefix)
|
||||
}
|
||||
|
||||
fn is_sqlserver_legacy_profile(driver_profile: Option<&str>) -> bool {
|
||||
driver_profile.is_some_and(|profile| profile.trim().eq_ignore_ascii_case("sqlserver-legacy"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -171,8 +253,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn sqlserver_index_comment_uses_index_extended_property_identity() {
|
||||
let statements =
|
||||
build_sqlserver_index_comment_sql("[dbo].[orders]", None, "orders", "ix_owner's", "index comment");
|
||||
let statements = build_sqlserver_index_comment_sql_for_profile(
|
||||
"[dbo].[orders]",
|
||||
None,
|
||||
"orders",
|
||||
"ix_owner's",
|
||||
"index comment",
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(statements.len(), 1);
|
||||
let sql = &statements[0];
|
||||
@@ -182,4 +270,28 @@ mod tests {
|
||||
assert!(sql.contains("sys.sp_updateextendedproperty"), "update existing comment: {sql}");
|
||||
assert!(sql.contains("ELSE EXEC sys.sp_addextendedproperty"), "add missing comment: {sql}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_legacy_column_comment_uses_sql_server_2000_compatibility_syntax() {
|
||||
let statements = build_sqlserver_column_comment_sql_for_profile(
|
||||
"[dbo].[Categories]",
|
||||
Some("dbo"),
|
||||
"Categories",
|
||||
"CategoryID",
|
||||
"test",
|
||||
Some("sqlserver-legacy"),
|
||||
);
|
||||
|
||||
assert_eq!(statements.len(), 1);
|
||||
let sql = &statements[0];
|
||||
assert!(sql.contains("::fn_listextendedproperty"), "legacy property lookup: {sql}");
|
||||
assert!(sql.contains("@level0type=N'USER'"), "legacy hierarchy: {sql}");
|
||||
assert!(sql.contains("EXEC sp_updateextendedproperty"), "legacy update procedure: {sql}");
|
||||
assert!(sql.contains("ELSE EXEC sp_addextendedproperty"), "legacy add procedure: {sql}");
|
||||
assert!(
|
||||
!sql.contains("sys.extended_properties"),
|
||||
"SQL Server 2005+ catalog view leaked into legacy SQL: {sql}"
|
||||
);
|
||||
assert!(!sql.contains("EXEC sys."), "SQL Server 2005+ procedure qualification leaked into legacy SQL: {sql}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::column_format::{
|
||||
is_mysql_character_data_type, is_mysql_timestamp_type, mysql_on_update_current_timestamp_clause,
|
||||
strip_inherited_mysql_column_charsets,
|
||||
};
|
||||
use super::comments::{build_sqlserver_column_comment_sql, build_sqlserver_table_comment_sql};
|
||||
use super::comments::{build_sqlserver_column_comment_sql_for_profile, build_sqlserver_table_comment_sql_for_profile};
|
||||
use super::dialect::{capabilities_for, database_label, StructureDialect};
|
||||
use super::foreign_keys::build_foreign_key_sql_for_new_table;
|
||||
use super::indexes::build_create_index_statements;
|
||||
@@ -190,11 +190,12 @@ pub fn build_create_table_sql(mut options: TableStructureSqlOptions) -> TableStr
|
||||
} else if dialect == StructureDialect::ClickHouse {
|
||||
statements.push(format!("ALTER TABLE {table} MODIFY COMMENT {};", quote_string(&table_comment)));
|
||||
} else if dialect == StructureDialect::SqlServer {
|
||||
statements.extend(build_sqlserver_table_comment_sql(
|
||||
statements.extend(build_sqlserver_table_comment_sql_for_profile(
|
||||
&table,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
&table_comment,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -234,12 +235,13 @@ pub fn build_create_table_sql(mut options: TableStructureSqlOptions) -> TableStr
|
||||
if capabilities.comment && dialect == StructureDialect::SqlServer {
|
||||
for column in &active_columns {
|
||||
if !clean(&column.comment).is_empty() {
|
||||
statements.extend(build_sqlserver_column_comment_sql(
|
||||
statements.extend(build_sqlserver_column_comment_sql_for_profile(
|
||||
&table,
|
||||
options.schema.as_deref(),
|
||||
&options.table_name,
|
||||
&column.name,
|
||||
&column.comment,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -264,6 +266,7 @@ pub fn build_create_table_sql(mut options: TableStructureSqlOptions) -> TableStr
|
||||
false,
|
||||
capabilities.index_concurrent,
|
||||
true,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::comments::build_sqlserver_index_comment_sql;
|
||||
use super::comments::build_sqlserver_index_comment_sql_for_profile;
|
||||
use super::dialect::{capabilities_for, database_label, database_type_for_dialect, dialect_label, StructureDialect};
|
||||
use super::types::{EditableStructureIndex, IndexInfo, TableStructureSqlOptions};
|
||||
use super::util::{clean, qualified_table, quote_ident, quote_new_ident, quote_string};
|
||||
@@ -94,6 +94,7 @@ pub(super) fn build_index_sql(options: &TableStructureSqlOptions, warnings: &mut
|
||||
or_replace,
|
||||
capabilities.index_concurrent,
|
||||
false,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
@@ -113,6 +114,7 @@ pub(super) fn build_index_sql(options: &TableStructureSqlOptions, warnings: &mut
|
||||
false,
|
||||
capabilities.index_concurrent,
|
||||
false,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -174,6 +176,7 @@ fn build_dameng_constraint_index_edit(
|
||||
false,
|
||||
concurrently_supported,
|
||||
false,
|
||||
options.driver_profile.as_deref(),
|
||||
));
|
||||
return statements;
|
||||
}
|
||||
@@ -421,6 +424,7 @@ pub(super) fn build_drop_index_sql(
|
||||
format!("DROP INDEX {};", quote_ident(dialect, index_name))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn build_create_index_statements(
|
||||
database_type: Option<DatabaseType>,
|
||||
dialect: StructureDialect,
|
||||
@@ -432,6 +436,7 @@ pub(super) fn build_create_index_statements(
|
||||
or_replace: bool,
|
||||
concurrently_supported: bool,
|
||||
for_new_table: bool,
|
||||
driver_profile: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let capabilities = capabilities_for(database_type_for_dialect(dialect), None);
|
||||
let name = clean(&index.name);
|
||||
@@ -558,7 +563,14 @@ pub(super) fn build_create_index_statements(
|
||||
if !comment.is_empty() && capabilities.index_comment && dialect == StructureDialect::Postgres {
|
||||
statements.push(format!("COMMENT ON INDEX {} IS {};", quote_ident(dialect, &name), quote_string(&comment)));
|
||||
} else if !comment.is_empty() && capabilities.index_comment && dialect == StructureDialect::SqlServer {
|
||||
statements.extend(build_sqlserver_index_comment_sql(table, schema, table_name, &name, &comment));
|
||||
statements.extend(build_sqlserver_index_comment_sql_for_profile(
|
||||
table,
|
||||
schema,
|
||||
table_name,
|
||||
&name,
|
||||
&comment,
|
||||
driver_profile,
|
||||
));
|
||||
} else if !comment.is_empty()
|
||||
&& capabilities.index_comment
|
||||
&& matches!(dialect, StructureDialect::Mysql | StructureDialect::GaussdbM)
|
||||
|
||||
@@ -3373,6 +3373,42 @@ fn sqlserver_add_column_with_identity() {
|
||||
assert_eq!(result.statements, vec!["ALTER TABLE [dbo].[orders] ADD [id] int NOT NULL IDENTITY(10, 2);"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_legacy_column_comment_change_uses_legacy_extended_properties() {
|
||||
let mut category_id = column("CategoryID");
|
||||
category_id.comment = "test".to_string();
|
||||
category_id.original = Some(ColumnInfo {
|
||||
name: "CategoryID".to_string(),
|
||||
data_type: "varchar(255)".to_string(),
|
||||
is_nullable: true,
|
||||
comment: None,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
driver_profile: Some("sqlserver-legacy".to_string()),
|
||||
schema: Some("dbo".to_string()),
|
||||
table_name: "Categories".to_string(),
|
||||
columns: vec![category_id],
|
||||
indexes: Vec::new(),
|
||||
foreign_keys: Vec::new(),
|
||||
triggers: Vec::new(),
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
mysql_engine: None,
|
||||
partitioned: false,
|
||||
is_gaussdb_m_mode: false,
|
||||
table_collation: None,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(result.statements.len(), 1);
|
||||
assert!(result.statements[0].contains("::fn_listextendedproperty"));
|
||||
assert!(result.statements[0].contains("EXEC sp_addextendedproperty"));
|
||||
assert!(!result.statements[0].contains("sys.extended_properties"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dameng_add_column_with_identity() {
|
||||
let mut id = column("ID");
|
||||
|
||||
Reference in New Issue
Block a user