fix(sqlserver): jtds prelogin fallback and legacy 2000 support

Closes #9333
This commit is contained in:
zipg
2026-09-17 00:18:07 +08:00
committed by GitHub
parent d35d578b03
commit 1a7b44f8fc
12 changed files with 380 additions and 64 deletions
@@ -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
@@ -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;
}
}
@@ -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;
+32 -2
View File
@@ -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,
&current_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");