diff --git a/DBTool/C_Database.pas b/DBTool/C_Database.pas index 297cdfb..e5bf01e 100644 --- a/DBTool/C_Database.pas +++ b/DBTool/C_Database.pas @@ -2365,30 +2365,43 @@ function TDbToolDatabase.SQL_Escape_TableName(sTableName: String): string; case FDatabaseType of dtSqlServer: begin + // SQL Server uses [name] quoting; escape ] by doubling it result := ''; ary := SplitString(sTableName, '.'); for i := 0 to Length(ary) - 1 do begin if i <> 0 then result := result + '.'; - ary[i] := StringReplace(ary[i], '[', '[[]', [rfReplaceAll]); - // TODO: Geht nicht... deshalb dürfen Tabellennamen vorerst keine Klammern haben + ary[i] := StringReplace(ary[i], ']', ']]', [rfReplaceAll]); result := result + '[' + ary[i] + ']'; end; end; dtMySql: begin - result := '`' + sTableName + '`'; + // MySQL uses backtick quoting; escape backticks by doubling them + result := '`' + StringReplace(sTableName, '`', '``', [rfReplaceAll]) + '`'; end; {$IFNDEF WIN64} - dtLocal, // Nicht getestet Unbekannt, ob es Escaping gibt. + dtLocal: + begin + // BDE/Paradox/dBase: use square bracket quoting like Access + result := '[' + StringReplace(sTableName, ']', ']]', [rfReplaceAll]) + ']'; + end; {$ENDIF} - dtInterbase, // Nicht getestet Unbekannt, ob es Escaping gibt. - dtFirebird, // Nicht getestet Unbekannt, ob es Escaping gibt. - dtAccess: // Nicht getestet. Unbekannt, ob es Escaping gibt. - result := sTableName; + dtInterbase, + dtFirebird: + begin + // InterBase/Firebird use SQL-standard double-quote identifier quoting + result := '"' + StringReplace(sTableName, '"', '""', [rfReplaceAll]) + '"'; + end; + + dtAccess: + begin + // Access uses square bracket quoting; escape ] by doubling it + result := '[' + StringReplace(sTableName, ']', ']]', [rfReplaceAll]) + ']'; + end; else raise Exception.Create('(TDbToolDatabase.SQL_Escape_TableName) ' + SInternalError); diff --git a/Tests/TestEscapeTableNameIntegration.sh b/Tests/TestEscapeTableNameIntegration.sh new file mode 100755 index 0000000..624490c --- /dev/null +++ b/Tests/TestEscapeTableNameIntegration.sh @@ -0,0 +1,142 @@ +#!/bin/bash +# Integration test: verify table name escaping against real databases +set -euo pipefail + +PASS=0 +FAIL=0 +TMPDIR=$(mktemp -d) + +assert_eq() { + local test_name="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + echo "[PASS] $test_name" + ((PASS++)) || true + else + echo "[FAIL] $test_name" + echo " Expected: '$expected'" + echo " Actual: '$actual'" + ((FAIL++)) || true + fi +} + +assert_contains() { + local test_name="$1" needle="$2" haystack="$3" + if echo "$haystack" | grep -qi "$needle"; then + echo "[PASS] $test_name" + ((PASS++)) || true + else + echo "[FAIL] $test_name" + echo " Expected to contain: '$needle'" + echo " Actual: '$haystack'" + ((FAIL++)) || true + fi +} + +fb_exec() { + docker exec -i firebird-test /usr/local/firebird/bin/isql \ + -user SYSDBA -password masterkey localhost:/firebird/data/testdb 2>&1 +} + +my_file() { + docker cp "$1" mysql-test:/tmp/query.sql + docker exec mysql-test mariadb -uroot -ptestpass testdb -sN -e "source /tmp/query.sql" 2>&1 +} + +echo "=== Firebird: Table Name Escaping ===" +echo "Waiting for Firebird..." +for i in $(seq 1 30); do + if echo "SELECT 1 FROM RDB\$DATABASE;" | fb_exec > /dev/null 2>&1; then + echo "Firebird ready"; break + fi; sleep 2 +done + +# FB1: Double-quoted identifier works +fb_exec << 'SQL' +CREATE TABLE "My Table" (id INTEGER); +INSERT INTO "My Table" VALUES (1); +COMMIT; +SQL +result=$(echo 'SELECT id FROM "My Table";' | fb_exec | tr -s ' ' | grep "1" | sed "s/^ *//;s/ *$//" | head -1) +assert_eq "Firebird: double-quoted table name with space works" "1" "$result" + +# FB2: Double-quote inside identifier escaped with "" +fb_exec << 'SQL' +CREATE TABLE "My""Table" (id INTEGER); +INSERT INTO "My""Table" VALUES (2); +COMMIT; +SQL +result=$(echo 'SELECT id FROM "My""Table";' | fb_exec | tr -s ' ' | grep "2" | sed "s/^ *//;s/ *$//" | head -1) +assert_eq "Firebird: escaped double-quote in table name works" "2" "$result" + +# FB3: Unquoted table name with special chars must fail +fb3_result=$(echo 'CREATE TABLE My Table (id INTEGER);' | fb_exec 2>&1 || true) +assert_contains "Firebird: unquoted name with space rejected" "error\|token\|unknown" "$fb3_result" + +echo "" +echo "=== MariaDB: Table Name Escaping ===" +echo "Waiting for MariaDB..." +for i in $(seq 1 30); do + if docker exec mysql-test mariadb -uroot -ptestpass testdb -e "SELECT 1;" > /dev/null 2>&1; then + echo "MariaDB ready"; break + fi; sleep 2 +done + +# MY1: Backtick-quoted identifier works +cat > "$TMPDIR/my1.sql" << 'SQLEOF' +CREATE TABLE `My Table` (id INT); +INSERT INTO `My Table` VALUES (1); +SQLEOF +my_file "$TMPDIR/my1.sql" +result=$(docker exec mysql-test mariadb -uroot -ptestpass testdb -sN -e "SELECT id FROM \`My Table\`;") +assert_eq "MySQL: backtick-quoted table name with space works" "1" "$result" + +# MY2: Backtick inside identifier escaped with `` +cat > "$TMPDIR/my2.sql" << 'SQLEOF' +CREATE TABLE `My``Table` (id INT); +INSERT INTO `My``Table` VALUES (2); +SQLEOF +my_file "$TMPDIR/my2.sql" +cat > "$TMPDIR/my2q.sql" << 'SQLEOF' +SELECT id FROM `My``Table`; +SQLEOF +result=$(my_file "$TMPDIR/my2q.sql") +assert_eq "MySQL: escaped backtick in table name works" "2" "$result" + +# MY3: Unquoted table name with space must fail +my3_result=$(docker exec mysql-test mariadb -uroot -ptestpass testdb -e "CREATE TABLE My Table (id INT);" 2>&1 || true) +assert_contains "MySQL: unquoted name with space rejected" "error\|ERROR" "$my3_result" + +echo "" +echo "=== SQL Server: Table Name Escaping ===" +echo "Waiting for SQL Server..." +SQLCMD="docker exec mssql-test /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P TestPass123! -C -d tempdb" +for i in $(seq 1 30); do + if $SQLCMD -Q "SELECT 1" > /dev/null 2>&1; then + echo "SQL Server ready"; break + fi; sleep 2 +done + +# MS1: Bracket-quoted identifier with space +$SQLCMD -Q "CREATE TABLE [My Table] (id INT); INSERT INTO [My Table] VALUES (1);" > /dev/null +result=$($SQLCMD -h -1 -Q "SET NOCOUNT ON; SELECT id FROM [My Table];" | tr -d ' ' | head -1) +assert_eq "SQL Server: bracket-quoted table name with space works" "1" "$result" + +# MS2: ] escaped with ]] +$SQLCMD -Q "CREATE TABLE [My]]Table] (id INT); INSERT INTO [My]]Table] VALUES (2);" > /dev/null +result=$($SQLCMD -h -1 -Q "SET NOCOUNT ON; SELECT id FROM [My]]Table];" | tr -d ' ' | head -1) +assert_eq "SQL Server: escaped ] in table name works" "2" "$result" + +# MS3: [ inside brackets needs NO escaping (only ] must be escaped) +$SQLCMD -Q "CREATE TABLE [My[Table] (id INT); INSERT INTO [My[Table] VALUES (3);" > /dev/null +result=$($SQLCMD -h -1 -Q "SET NOCOUNT ON; SELECT id FROM [My[Table];" | tr -d ' ' | head -1) +assert_eq "SQL Server: [ needs no escaping inside brackets" "3" "$result" + +# MS4: Unquoted name with space must fail +ms4_result=$($SQLCMD -Q "CREATE TABLE My Table (id INT);" 2>&1 || true) +assert_contains "SQL Server: unquoted name with space rejected" "syntax\|error\|Msg" "$ms4_result" + +echo "" +echo "=== Results: $((PASS + FAIL)) tests, $PASS passed, $FAIL failed ===" +rm -rf "$TMPDIR" + +[ "$FAIL" -eq 0 ] diff --git a/Tests/TestSQLEscapeTableName.pas b/Tests/TestSQLEscapeTableName.pas new file mode 100644 index 0000000..2450c44 --- /dev/null +++ b/Tests/TestSQLEscapeTableName.pas @@ -0,0 +1,256 @@ +program TestSQLEscapeTableName; + +{$mode delphi} + +uses + SysUtils; + +var + TestCount: Integer = 0; + PassCount: Integer = 0; + FailCount: Integer = 0; + +{ --- Production code (extracted from C_Database.pas) --- } + +function SQL_Escape_TableName_SqlServer(const sTableName: String): String; +var + ary: TArray; + i: Integer; +begin + Result := ''; + ary := sTableName.Split(['.']); + for i := 0 to Length(ary) - 1 do + begin + if i <> 0 then + Result := Result + '.'; + ary[i] := StringReplace(ary[i], ']', ']]', [rfReplaceAll]); + Result := Result + '[' + ary[i] + ']'; + end; +end; + +function SQL_Escape_TableName_MySQL(const sTableName: String): String; +begin + Result := '`' + StringReplace(sTableName, '`', '``', [rfReplaceAll]) + '`'; +end; + +function SQL_Escape_TableName_Firebird(const sTableName: String): String; +begin + Result := '"' + StringReplace(sTableName, '"', '""', [rfReplaceAll]) + '"'; +end; + +function SQL_Escape_TableName_Access(const sTableName: String): String; +begin + Result := '[' + StringReplace(sTableName, ']', ']]', [rfReplaceAll]) + ']'; +end; + +{ --- Test helper --- } + +procedure AssertEquals(const TestName, Expected, Actual: String); +begin + Inc(TestCount); + if Expected = Actual then + begin + Inc(PassCount); + WriteLn('[PASS] ', TestName); + end + else + begin + Inc(FailCount); + WriteLn('[FAIL] ', TestName); + WriteLn(' Expected: ''', Expected, ''''); + WriteLn(' Actual: ''', Actual, ''''); + end; +end; + +{ --- SQL Server tests --- } + +procedure Test_SqlServer_should_bracket_when_simple_name; +var + Input: String; + Actual: String; +begin + // given + Input := 'Customers'; + // when + Actual := SQL_Escape_TableName_SqlServer(Input); + // then + AssertEquals('SqlServer: should bracket simple name', '[Customers]', Actual); +end; + +procedure Test_SqlServer_should_escape_bracket_when_name_contains_closing_bracket; +var + Input: String; + Actual: String; +begin + // given + Input := 'my]table'; + // when + Actual := SQL_Escape_TableName_SqlServer(Input); + // then + AssertEquals('SqlServer: should escape ] when name contains closing bracket', + '[my]]table]', Actual); +end; + +procedure Test_SqlServer_should_split_on_dot_when_schema_qualified; +var + Input: String; + Actual: String; +begin + // given + Input := 'dbo.Customers'; + // when + Actual := SQL_Escape_TableName_SqlServer(Input); + // then + AssertEquals('SqlServer: should split on dot when schema-qualified', + '[dbo].[Customers]', Actual); +end; + +procedure Test_SqlServer_should_handle_bracket_in_schema_when_complex_name; +var + Input: String; + Actual: String; +begin + // given + Input := 'my]schema.my]table'; + // when + Actual := SQL_Escape_TableName_SqlServer(Input); + // then + AssertEquals('SqlServer: should escape brackets in schema-qualified name', + '[my]]schema].[my]]table]', Actual); +end; + +{ --- MySQL tests --- } + +procedure Test_MySQL_should_backtick_when_simple_name; +var + Input: String; + Actual: String; +begin + // given + Input := 'Customers'; + // when + Actual := SQL_Escape_TableName_MySQL(Input); + // then + AssertEquals('MySQL: should backtick simple name', '`Customers`', Actual); +end; + +procedure Test_MySQL_should_escape_backtick_when_name_contains_backtick; +var + Input: String; + Actual: String; +begin + // given + Input := 'my`table'; + // when + Actual := SQL_Escape_TableName_MySQL(Input); + // then + AssertEquals('MySQL: should escape backtick when name contains backtick', + '`my``table`', Actual); +end; + +{ --- Firebird/InterBase tests --- } + +procedure Test_Firebird_should_doublequote_when_simple_name; +var + Input: String; + Actual: String; +begin + // given + Input := 'Customers'; + // when + Actual := SQL_Escape_TableName_Firebird(Input); + // then + AssertEquals('Firebird: should double-quote simple name', '"Customers"', Actual); +end; + +procedure Test_Firebird_should_escape_quote_when_name_contains_doublequote; +var + Input: String; + Actual: String; +begin + // given + Input := 'my"table'; + // when + Actual := SQL_Escape_TableName_Firebird(Input); + // then + AssertEquals('Firebird: should escape " when name contains double-quote', + '"my""table"', Actual); +end; + +{ --- Access tests --- } + +procedure Test_Access_should_bracket_when_simple_name; +var + Input: String; + Actual: String; +begin + // given + Input := 'Customers'; + // when + Actual := SQL_Escape_TableName_Access(Input); + // then + AssertEquals('Access: should bracket simple name', '[Customers]', Actual); +end; + +procedure Test_Access_should_escape_bracket_when_name_contains_closing_bracket; +var + Input: String; + Actual: String; +begin + // given + Input := 'my]table'; + // when + Actual := SQL_Escape_TableName_Access(Input); + // then + AssertEquals('Access: should escape ] when name contains closing bracket', + '[my]]table]', Actual); +end; + +procedure Test_Access_should_handle_spaces_when_name_contains_space; +var + Input: String; + Actual: String; +begin + // given + Input := 'My Table'; + // when + Actual := SQL_Escape_TableName_Access(Input); + // then + AssertEquals('Access: should handle spaces in name', + '[My Table]', Actual); +end; + +{ --- Main --- } + +begin + WriteLn('=== SQL_Escape_TableName Tests ==='); + WriteLn; + + WriteLn('--- SQL Server ---'); + Test_SqlServer_should_bracket_when_simple_name; + Test_SqlServer_should_escape_bracket_when_name_contains_closing_bracket; + Test_SqlServer_should_split_on_dot_when_schema_qualified; + Test_SqlServer_should_handle_bracket_in_schema_when_complex_name; + + WriteLn; + WriteLn('--- MySQL ---'); + Test_MySQL_should_backtick_when_simple_name; + Test_MySQL_should_escape_backtick_when_name_contains_backtick; + + WriteLn; + WriteLn('--- Firebird / InterBase ---'); + Test_Firebird_should_doublequote_when_simple_name; + Test_Firebird_should_escape_quote_when_name_contains_doublequote; + + WriteLn; + WriteLn('--- Access ---'); + Test_Access_should_bracket_when_simple_name; + Test_Access_should_escape_bracket_when_name_contains_closing_bracket; + Test_Access_should_handle_spaces_when_name_contains_space; + + WriteLn; + WriteLn('=== Results: ', TestCount, ' tests, ', PassCount, ' passed, ', FailCount, ' failed ==='); + + if FailCount > 0 then + Halt(1); +end.