Przeglądaj źródła

fix: coerce JSON text from -> in numeric and boolean contexts

The -> operator returns the internal jsonText subtype so that nesting the
result in JSON functions keeps JSON semantics. The executor coercions only
knew about int64, float64, string, and bool, so every jsonText value was
treated as 0/false:

- (doc -> 'a') + 1 evaluated to 1 instead of the extracted number plus one.
- Numeric comparisons and ORDER BY fell back to string comparison, so 10
  sorted before 2.
- CAST(doc -> 'a' AS INTEGER) returned 0.
- WHERE doc -> 'a' was always false.

Teach toInt64, toFloat, toBool, and toNumeric to read the text form of a
jsonText value, mirroring the existing string coercion. toInt64 keeps exact
integers exact before falling back to float truncation. jsonText itself is
unchanged, so JSON functions still receive the JSON subtype (json_array(
doc -> 'n') embeds 2, not "2").

Add executor coverage that goes through -> for arithmetic, bitwise, numeric
comparison, CAST, WHERE truthiness, multi-digit ORDER BY, and subtype
preservation.
Danilo Fragoso 1 dzień temu
rodzic
commit
97012129a5
2 zmienionych plików z 78 dodań i 0 usunięć
  1. 17 0
      pkg/executor/executor.go
  2. 61 0
      pkg/executor/json_operator_test.go

+ 17 - 0
pkg/executor/executor.go

@@ -7135,6 +7135,14 @@ func toInt64(v interface{}) int64 {
 			return 1
 		}
 		return 0
+	case jsonText:
+		// -> returns JSON text, so integer conversion parses its text form.
+		// Keep exact integers exact before falling back to float truncation.
+		if i, err := strconv.ParseInt(string(val), 10, 64); err == nil {
+			return i
+		}
+		f, _ := strconv.ParseFloat(string(val), 64)
+		return int64(f)
 	default:
 		return 0
 	}
@@ -7158,6 +7166,9 @@ func toFloat(v interface{}) float64 {
 	case string:
 		f, _ := strconv.ParseFloat(val, 64)
 		return f
+	case jsonText:
+		f, _ := strconv.ParseFloat(string(val), 64)
+		return f
 	default:
 		return 0
 	}
@@ -7177,6 +7188,9 @@ func toBool(v interface{}) bool {
 		return val != 0
 	case string:
 		return val != "" && val != "0" && strings.ToLower(val) != "false"
+	case jsonText:
+		s := string(val)
+		return s != "" && s != "0" && strings.ToLower(s) != "false"
 	default:
 		return false
 	}
@@ -7256,6 +7270,9 @@ func toNumeric(v interface{}) (float64, bool) {
 	case string:
 		f, err := strconv.ParseFloat(val, 64)
 		return f, err == nil
+	case jsonText:
+		f, err := strconv.ParseFloat(string(val), 64)
+		return f, err == nil
 	default:
 		return 0, false
 	}

+ 61 - 0
pkg/executor/json_operator_test.go

@@ -98,3 +98,64 @@ func TestJSONArrowInWhereOrderByAndUpdate(t *testing.T) {
 		}
 	}
 }
+
+// The -> operator returns JSON text (with the engine's internal JSON subtype),
+// not a plain string. Numeric, boolean, and comparison coercions must still
+// read the text form, exactly as they do for SQL TEXT; otherwise arithmetic
+// silently treats every extracted number as 0.
+func TestJSONArrowOperatorValueCoercions(t *testing.T) {
+	_, schema, table := newTestDB(t)
+	e := newExec(schema, table)
+	execMust(t, e, "CREATE TABLE metrics (id INTEGER PRIMARY KEY, doc TEXT)")
+	execMust(t, e, `INSERT INTO metrics VALUES (1, '{"n":2,"big":10,"flag":1}')`)
+	execMust(t, e, `INSERT INTO metrics VALUES (2, '{"n":10,"big":20,"flag":0}')`)
+
+	// Arithmetic uses the text numeric value. Like '2' + 1 elsewhere in the
+	// engine, the result is REAL rather than INTEGER for JSON text.
+	res := execMust(t, e, `SELECT (doc -> 'n') + 1 FROM metrics WHERE id = 1`)
+	if got := res.Rows[0][0]; got != float64(3) {
+		t.Fatalf("(doc -> 'n') + 1 = %v (%T), want 3", got, got)
+	}
+
+	// Bitwise operators coerce through toInt64.
+	res = execMust(t, e, `SELECT (doc -> 'n') & 3 FROM metrics WHERE id = 1`)
+	if got := res.Rows[0][0]; got != int64(2) {
+		t.Fatalf("(doc -> 'n') & 3 = %v (%T), want int64 2", got, got)
+	}
+
+	// Numeric comparison must not fall back to string comparison, where
+	// "10" < "9" and multi-digit values order incorrectly.
+	res = execMust(t, e, `SELECT id FROM metrics WHERE doc -> 'big' > 9 ORDER BY id`)
+	if res.RowCount != 2 || res.Rows[0][0] != int64(1) || res.Rows[1][0] != int64(2) {
+		t.Fatalf("WHERE doc -> 'big' > 9 rows = %v, want ids 1, 2", res.Rows)
+	}
+	res = execMust(t, e, `SELECT id FROM metrics WHERE doc -> 'big' = 10`)
+	if res.RowCount != 1 || res.Rows[0][0] != int64(1) {
+		t.Fatalf("WHERE doc -> 'big' = 10 rows = %v, want id 1", res.Rows)
+	}
+
+	// CAST goes through toFloat.
+	res = execMust(t, e, `SELECT CAST(doc -> 'big' AS INTEGER) FROM metrics WHERE id = 1`)
+	if got := res.Rows[0][0]; got != int64(10) {
+		t.Fatalf("CAST(doc -> 'big' AS INTEGER) = %v (%T), want int64 10", got, got)
+	}
+
+	// WHERE truthiness goes through toBool.
+	res = execMust(t, e, `SELECT id FROM metrics WHERE doc -> 'flag'`)
+	if res.RowCount != 1 || res.Rows[0][0] != int64(1) {
+		t.Fatalf("WHERE doc -> 'flag' rows = %v, want id 1", res.Rows)
+	}
+
+	// ORDER BY compares the extracted values numerically.
+	res = execMust(t, e, `SELECT id FROM metrics ORDER BY doc -> 'n'`)
+	if res.RowCount != 2 || res.Rows[0][0] != int64(1) || res.Rows[1][0] != int64(2) {
+		t.Fatalf("ORDER BY doc -> 'n' rows = %v, want ids 1, 2", res.Rows)
+	}
+
+	// The JSON subtype must survive -> so JSON functions embed the extracted
+	// value as JSON rather than as a quoted string.
+	res = execMust(t, e, `SELECT json_array(doc -> 'n') FROM metrics WHERE id = 1`)
+	if got := res.Rows[0][0]; got != "[2]" {
+		t.Fatalf("json_array(doc -> 'n') = %v, want [2]", got)
+	}
+}