ソースを参照

Handle array slices and anonymous dollar-quotes in Postgres placeholder translation

The named-to-positional placeholder scanner in PostgresCommand treated any
":" followed by an identifier character as a placeholder start, so a
PostgreSQL array slice like arr[1:2] produced a bogus ":2" parameter and
the query failed with "missing value for parameter ':2'". The scanner now
tracks square-bracket depth and leaves a colon flanked by digits inside
brackets alone, which is exactly the slice form.

Anonymous dollar-quoted strings ($$...$$) were also missed: the tag scan
required at least one tag character between the dollars, so $$ fell
through to the generic path and placeholders inside the body were still
translated. An empty tag is now recognised, and the close-tag search
accepts it.

Also includes SQLSTATE in server error messages produced by
check_result, since the SqlError code is already derived from it and the
raw code makes log triage easier.

New test_placeholder_edge_cases in the live suite covers slices, casts,
anonymous and tagged dollar quotes, string literals, quoted identifiers,
duplicate names, trailing placeholders and comments containing colons.
clanker 1 ヶ月 前
親
コミット
53015446e8

+ 18 - 5
src/postgres/postgres-command.vala

@@ -98,6 +98,7 @@ namespace InvercargillSql {
             int len = text.length;
             int i = 0;
             bool prev_colon = false;
+            int bracket_depth = 0;
             while (i < len) {
                 char c = text[i];
                 // string literal
@@ -120,8 +121,8 @@ namespace InvercargillSql {
                     }
                     continue;
                 }
-                // dollar-quoted string: $tag$ ... $tag$
-                if (c == '$' && i + 1 < len && is_tag_char(text[i + 1])) {
+                // dollar-quoted string: $tag$ ... $tag$ (tag may be empty: $$)
+                if (c == '$' && i + 1 < len && (text[i + 1] == '$' || is_tag_char(text[i + 1]))) {
                     int tag_end = find_dollar_tag_end(text, i);
                     if (tag_end > i) {
                         string tag = text.substring(i, tag_end - i + 1);
@@ -167,7 +168,8 @@ namespace InvercargillSql {
                 }
                 // named placeholder ("::" casts never start one: the
                 // second colon of a cast is skipped via prev_colon)
-                if (c == ':' && i + 1 < len && is_name_char(text[i + 1]) && !prev_colon) {
+                if (c == ':' && i + 1 < len && is_name_char(text[i + 1]) && !prev_colon
+                    && !(bracket_depth > 0 && is_digit(text[i - 1]) && is_digit(text[i + 1]))) {
                     int j = i + 1;
                     while (j < len && is_name_char(text[j])) {
                         j++;
@@ -182,6 +184,11 @@ namespace InvercargillSql {
                     i = j;
                     continue;
                 }
+                if (c == '[') {
+                    bracket_depth++;
+                } else if (c == ']' && bracket_depth > 0) {
+                    bracket_depth--;
+                }
                 sql.append_c(c);
                 i++;
                 prev_colon = (c == ':');
@@ -194,6 +201,10 @@ namespace InvercargillSql {
                    (c >= '0' && c <= '9') || c == '_';
         }
 
+        private static bool is_digit(char c) {
+            return c >= '0' && c <= '9';
+        }
+
         private static bool is_tag_char(char c) {
             return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
                    (c >= '0' && c <= '9') || c == '_';
@@ -209,13 +220,15 @@ namespace InvercargillSql {
         }
 
         private static int find_dollar_tag_end(string text, int start) {
-            // start points at '$'; find the closing '$' of the tag
+            // start points at '$'; find the closing '$' of the tag. The tag
+            // may be empty ($$...$$), in which case the closing '$' is the
+            // very next character.
             int j = start + 1;
             int len = text.length;
             while (j < len && is_tag_char(text[j])) {
                 j++;
             }
-            if (j < len && text[j] == '$' && j > start + 1) {
+            if (j < len && text[j] == '$' && j > start) {
                 return j;
             }
             return -1;

+ 3 - 1
src/postgres/postgres-connection.vala

@@ -141,7 +141,9 @@ namespace InvercargillSql {
             string message = result.get_error_message();
             string sqlstate = result.get_error_field(FieldCode.SQLSTATE) ?? "";
             string context = sql.length > 120 ? sql.substring(0, 120) + "..." : sql;
-            string full_message = "%s (SQL: %s)".printf(message, context);
+            string full_message = sqlstate.length > 0
+                ? "%s [SQLSTATE %s] (SQL: %s)".printf(message, sqlstate, context)
+                : "%s (SQL: %s)".printf(message, context);
             if (sqlstate.has_prefix("08") || sqlstate.has_prefix("57") || sqlstate.has_prefix("58")) {
                 _is_open = false;
                 throw new SqlError.CONNECTION_FAILED(full_message);

+ 59 - 0
src/tests/postgres-test.vala

@@ -81,6 +81,7 @@ public int main(string[] args) {
         print("\n--- Error Path Tests ---\n");
         test_syntax_error(conn);
         test_unique_violation(conn);
+        test_placeholder_edge_cases(conn);
         test_missing_parameter(conn);
 
         print("\n--- Mixed Integration Tests ---\n");
@@ -956,6 +957,64 @@ void test_unique_violation(Connection conn) throws SqlError {
     pass("unique violation");
 }
 
+void test_placeholder_edge_cases(Connection conn) throws SqlError {
+    print("Test: placeholder translation edge cases... ");
+    string? s = null;
+    int64? v = null;
+
+    // Array slice: the colon between digits inside brackets is not a placeholder.
+    var rows = conn.create_command(
+        "SELECT (ARRAY[1,2,3])[1:2]::text AS arr, :w AS w"
+    ).with_parameter("w", 9).execute_query().to_array();
+    assert(rows.length == 1);
+    assert(rows[0].get("arr").try_get_as<string>(out s) && s == "{1,2}");
+    assert(rows[0].get("w").try_get_as<int64?>(out v) && v == 9);
+
+    // Anonymous dollar-quoted string: ':z' inside must stay literal.
+    var dq = conn.create_command(
+        "SELECT $$dq :z$$ AS dq, :w AS w"
+    ).with_parameter("w", 9).execute_query().to_array();
+    assert(dq[0].get("dq").try_get_as<string>(out s) && s == "dq :z");
+
+    // Tagged dollar-quoted string.
+    var tagged = conn.create_command(
+        "SELECT $tag$ has :colon $tag$ AS dq, :w AS w"
+    ).with_parameter("w", 9).execute_query().to_array();
+    assert(tagged[0].get("dq").try_get_as<string>(out s) && s == " has :colon ");
+
+    // String literal containing a placeholder-looking token.
+    var lit = conn.create_command(
+        "SELECT 'lit :y str' AS lit, :w AS w"
+    ).with_parameter("w", 9).execute_query().to_array();
+    assert(lit[0].get("lit").try_get_as<string>(out s) && s == "lit :y str");
+
+    // Quoted identifier containing a colon comes back as the column name.
+    var qid = conn.create_command(
+        "SELECT 7 AS \"we:ird\""
+    ).execute_query().to_array();
+    assert(qid[0].get("we:ird").try_get_as<int64?>(out v) && v == 7);
+
+    // Duplicate names bind to a single $n; a placeholder at the very end works.
+    var dup = conn.create_command("SELECT (:w::int + :w::int) AS sum, :w AS tail")
+        .with_parameter("w", 3).execute_query().to_array();
+    assert(dup[0].get("sum").try_get_as<int64?>(out v) && v == 6);
+    assert(dup[0].get("tail").try_get_as<int64?>(out v) && v == 3);
+
+    // Line and block comments may contain colons.
+    var comments = conn.create_command(
+        "SELECT :w -- comment :ignore\n + 1 AS seven, /* block :nope */ :w AS again"
+    ).with_parameter("w", 3).execute_query().to_array();
+    assert(comments[0].get("seven").try_get_as<int64?>(out v) && v == 4);
+
+    // A placeholder directly followed by a cast operator.
+    var casted = conn.create_command(
+        "SELECT :w::text AS t, :w AS w"
+    ).with_parameter("w", 9).execute_query().to_array();
+    assert(casted[0].get("t").try_get_as<string>(out s) && s == "9");
+
+    pass("placeholder edge cases");
+}
+
 void test_missing_parameter(Connection conn) throws SqlError {
     print("Test: missing parameter rejected... ");
     try {