diff --git a/query_parameters.go b/query_parameters.go index bb11b77d7a..f74d2a5ad8 100644 --- a/query_parameters.go +++ b/query_parameters.go @@ -39,6 +39,9 @@ func bindQueryOrAppendParameters(paramsProtocolSupport bool, options *QueryOptio if err != nil { return "", err } + if strVal == "NULL" { + strVal = "\\N" + } options.parameters[p.Name] = strVal case driver.NamedDateValue: diff --git a/query_parameters_test.go b/query_parameters_test.go new file mode 100644 index 0000000000..acfdc9de87 --- /dev/null +++ b/query_parameters_test.go @@ -0,0 +1,148 @@ +package clickhouse + +import ( + "testing" + "time" + + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + "github.com/stretchr/testify/assert" +) + +func TestBindQueryOrAppendParameters(t *testing.T) { + testTime := time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + param any + expectedValue string + expectError bool + }{ + // Nil / NULL case (The fixed bug) + { + name: "nil translates to \\N", + param: Named("param", nil), + expectedValue: "\\N", + }, + // Basic types + { + name: "boolean true", + param: Named("param", true), + expectedValue: "1", + }, + { + name: "boolean false", + param: Named("param", false), + expectedValue: "0", + }, + { + name: "string direct bypass", + param: Named("param", "hello_world"), + expectedValue: "hello_world", + }, + { + name: "string with quotes bypass", + param: Named("param", "hello 'world'"), + expectedValue: "hello 'world'", // String bypasses format(), so it shouldn't have extra quotes added + }, + { + name: "integer", + param: Named("param", 42), + expectedValue: "42", + }, + { + name: "float", + param: Named("param", 3.1415), + expectedValue: "3.1415", + }, + // Collections + { + name: "slice of ints", + param: Named("param", []int{1, 2, 3}), + expectedValue: "[1, 2, 3]", + }, + { + name: "slice of strings", + param: Named("param", []string{"a", "b", "c"}), + expectedValue: "['a', 'b', 'c']", + }, + // Time types + // formatTime adds quotes and toDateTime + { + name: "time.Time", + param: Named("param", testTime), + expectedValue: "toDateTime('2023-01-01 12:00:05', 'UTC')", + }, + // formatTimeWithScale behavior + { + name: "NamedDateValue", + param: driver.NamedDateValue{ + Name: "param", + Value: testTime, + Scale: uint8(Seconds), + }, + expectedValue: "2023-01-01 12:00:00", + }, + // Error cases + // Not a NamedValue or NamedDateValue + { + name: "unsupported type", + param: struct{ A int }{A: 1}, + expectedValue: "", + expectError: true, + }, + } + + // The query must contain {param:Type} + query := ` + SELECT * + FROM t + WHERE col = {param:String}` + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &QueryOptions{parameters: make(Parameters)} + + _, err := bindQueryOrAppendParameters(true, opts, query, time.UTC, tt.param) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + + // For time.Time standard format returns toDateTime('...', 'UTC'), we just verify it formats without error + if tt.name == "time.Time" { + assert.Contains(t, opts.parameters["param"], "2023-01-01") + } else { + assert.Equal(t, tt.expectedValue, opts.parameters["param"]) + } + } + }) + } +} + +func TestBindQueryOrAppendParameters_NoProtocolSupport(t *testing.T) { + opts := &QueryOptions{parameters: make(Parameters)} + query := "SELECT * FROM t WHERE col = @param" + + // If paramsProtocolSupport is false, it should fallback to legacy bind (which replaces @param directly) + resQuery, err := bindQueryOrAppendParameters(false, opts, query, time.UTC, Named("param", "val")) + + assert.NoError(t, err) + assert.Equal(t, "SELECT * FROM t WHERE col = 'val'", resQuery) + assert.Empty(t, opts.parameters, "Parameters map should be empty when fallback to bind") +} + +func TestBindQueryOrAppendParameters_ExplicitParams(t *testing.T) { + opts := &QueryOptions{parameters: Parameters{"param": "explicit_val"}} + query := ` + SELECT * + FROM t + WHERE col = {param:String}` + + // If explicit parameters are provided in options, args are ignored for native parameters + resQuery, err := bindQueryOrAppendParameters(true, opts, query, time.UTC, Named("param", "arg_val")) + + assert.NoError(t, err) + assert.Equal(t, query, resQuery) + assert.Equal(t, "explicit_val", opts.parameters["param"], "Explicit parameters should be preferred") +} diff --git a/tests/query_parameters_test.go b/tests/query_parameters_test.go index e3bb84d6aa..686144b974 100644 --- a/tests/query_parameters_test.go +++ b/tests/query_parameters_test.go @@ -134,4 +134,16 @@ func TestQueryParameters(t *testing.T) { assert.Equal(t, uint8(42), actualNum) assert.Equal(t, "hello", actualStr) }) + + t.Run("with nullable parameter", func(t *testing.T) { + var actualVal *string + row := client.QueryRow( + ctx, + "SELECT {val:Nullable(String)}", + clickhouse.Named("val", (*string)(nil)), + ) + require.NoError(t, row.Err()) + require.NoError(t, row.Scan(&actualVal)) + require.True(t, actualVal == nil || *actualVal == "", "expected nil or empty string, got %v", actualVal) + }) } diff --git a/tests/std/query_parameters_test.go b/tests/std/query_parameters_test.go index 7ca7f4098b..c47b33ee94 100644 --- a/tests/std/query_parameters_test.go +++ b/tests/std/query_parameters_test.go @@ -112,6 +112,17 @@ func TestQueryParameters(t *testing.T) { assert.Equal(t, uint8(42), actualNum) assert.Equal(t, "hello", actualStr) }) + + t.Run("with nullable parameter", func(t *testing.T) { + var actualVal *string + row := conn.QueryRow( + "SELECT {val:Nullable(String)}", + clickhouse.Named("val", (*string)(nil)), + ) + require.NoError(t, row.Err()) + require.NoError(t, row.Scan(&actualVal)) + require.True(t, actualVal == nil || *actualVal == "", "expected nil or empty string, got %v", actualVal) + }) }) } }