Skip to content

Commit 1e3de96

Browse files
fix(tools/looker): strip wrapping quotes from filter values for unquoted parameters (#3273)
## Description LookML `parameter` fields declared `type: unquoted` reject queries through the Looker tools because filter values arrive with a literal layer of wrapping quote characters and are substituted into SQL bare via `{% parameter %}` — producing invalid SQL like `... = "first_touch"`. `ProcessQueryArgs` in `internal/tools/looker/lookercommon/lookercommon.go` already strips a wrapping layer of quotes from filter *keys*; this extends the same handling to string *values*, stripping a single layer of wrapping `"` or `'` only when the first and last characters match. Bare values are correct for all parameter types over the `WriteQuery` wire format, so this is applied unconditionally — no need to branch on the field's declared LookML type. The `filters` parameter description is also tightened so the model is told explicitly not to wrap values in extra quotes, addressing the upstream cause while the normalization stays a backstop. One fix covers every tool built on the shared helper: `looker-query`, `looker-query-sql`, `looker-query-url`, `looker-make-look`, and `looker-add-dashboard-element`. ## Tests - **Unit:** added `TestProcessQueryArgsStripsWrappingQuotes` exercising `ProcessQueryArgs` directly — bare values (unchanged), double-quoted values, single-quoted values, quoted keys (regression), quoted-key+quoted-value, non-string values (untouched), single-character strings (no length-check footgun), and mismatched wrapping characters (left alone). `go test -race ./internal/tools/looker/lookercommon/...` passes. - **Integration:** updated the `filters` manifest assertions in `tests/looker/looker_integration_test.go` to match the tightened parameter description, keeping the live `looker-query`/`looker-query-sql`/`looker-query-url` integration suite green. ## PR Checklist - [x] Make sure you reviewed [CONTRIBUTING.md](https://github.com/googleapis/mcp-toolbox/blob/main/CONTRIBUTING.md) - [x] Make sure to open an issue as a bug/issue before writing your code! - [x] Ensure the tests and linter pass - [x] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) - [ ] Make sure to add `!` if this involve a breaking change 🛠️ Fixes #3272 Co-authored-by: Dr. Strangelove <drstrangelove@google.com>
1 parent c3b7248 commit 1e3de96

3 files changed

Lines changed: 111 additions & 10 deletions

File tree

internal/tools/looker/lookercommon/lookercommon.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,11 @@ func GetQueryParameters() parameters.Parameters {
118118
)
119119
filtersParameter := parameters.NewMapParameterWithDefault("filters",
120120
map[string]any{},
121-
"The filters for the query",
121+
"The filters for the query. Keys are fully-qualified field names "+
122+
"(e.g. \"view.field\") and values are filter expressions or "+
123+
"parameter values. Pass values bare — do not wrap them in extra "+
124+
"quote characters. For LookML `parameter` fields, use the raw "+
125+
"allowed_value (e.g. `first_touch`), not `\"first_touch\"`.",
122126
"",
123127
)
124128
pivotsParameter := parameters.NewArrayParameterWithDefault("pivots",
@@ -174,13 +178,25 @@ func ProcessQueryArgs(ctx context.Context, params parameters.ParamValues) (*v4.W
174178
}
175179
fields := f.([]string)
176180
filters := paramsMap["filters"].(map[string]any)
177-
// Sometimes filters come as "'field.id'": "expression" so strip extra ''
181+
// Strip a single layer of wrapping quotes from keys and string values.
182+
// Values matter for LookML `type: unquoted` parameters, where Looker
183+
// substitutes the value bare into SQL via {% parameter %}. Build a new map
184+
// rather than mutating during iteration, and avoid comparing `any` values
185+
// directly (non-comparable dynamic types like slices would panic).
186+
processedFilters := make(map[string]any, len(filters))
178187
for k, v := range filters {
179-
if len(k) > 0 && k[0] == '\'' && k[len(k)-1] == '\'' {
180-
delete(filters, k)
181-
filters[k[1:len(k)-1]] = v
188+
newKey := k
189+
if len(k) >= 2 && (k[0] == '\'' || k[0] == '"') && k[0] == k[len(k)-1] {
190+
newKey = k[1 : len(k)-1]
182191
}
192+
newVal := v
193+
if s, ok := v.(string); ok && len(s) >= 2 &&
194+
(s[0] == '\'' || s[0] == '"') && s[0] == s[len(s)-1] {
195+
newVal = s[1 : len(s)-1]
196+
}
197+
processedFilters[newKey] = newVal
183198
}
199+
filters = processedFilters
184200
p, err := parameters.ConvertAnySliceToTyped(paramsMap["pivots"].([]any), "string")
185201
if err != nil {
186202
return nil, fmt.Errorf("can't convert pivots to array of strings: %s", err)

internal/tools/looker/lookercommon/lookercommon_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/google/go-cmp/cmp"
2222
"github.com/googleapis/mcp-toolbox/internal/testutils"
2323
"github.com/googleapis/mcp-toolbox/internal/tools/looker/lookercommon"
24+
"github.com/googleapis/mcp-toolbox/internal/util/parameters"
2425
v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4"
2526
)
2627

@@ -171,6 +172,90 @@ func TestExtractLookerFieldPropertiesWithNilFields(t *testing.T) {
171172
}
172173
}
173174

175+
func TestProcessQueryArgsStripsWrappingQuotes(t *testing.T) {
176+
ctx, err := testutils.ContextWithNewLogger()
177+
if err != nil {
178+
t.Fatalf("unexpected error: %s", err)
179+
}
180+
181+
tcs := []struct {
182+
desc string
183+
filtersIn map[string]any
184+
filtersOut map[string]any
185+
}{
186+
{
187+
desc: "bare string value passed through unchanged",
188+
filtersIn: map[string]any{"view.attribution_model": "first_touch"},
189+
filtersOut: map[string]any{"view.attribution_model": "first_touch"},
190+
},
191+
{
192+
desc: "double-quoted value has wrapping quotes stripped",
193+
filtersIn: map[string]any{"view.attribution_model": `"first_touch"`},
194+
filtersOut: map[string]any{"view.attribution_model": "first_touch"},
195+
},
196+
{
197+
desc: "single-quoted value has wrapping quotes stripped",
198+
filtersIn: map[string]any{"view.attribution_model": "'first_touch'"},
199+
filtersOut: map[string]any{"view.attribution_model": "first_touch"},
200+
},
201+
{
202+
desc: "single-quoted key has wrapping quotes stripped",
203+
filtersIn: map[string]any{"'view.field'": "value"},
204+
filtersOut: map[string]any{"view.field": "value"},
205+
},
206+
{
207+
desc: "quoted key and quoted value are both stripped",
208+
filtersIn: map[string]any{`"view.field"`: `"value"`},
209+
filtersOut: map[string]any{"view.field": "value"},
210+
},
211+
{
212+
desc: "non-string values are not touched",
213+
filtersIn: map[string]any{"view.threshold": 42, "view.enabled": true},
214+
filtersOut: map[string]any{"view.threshold": 42, "view.enabled": true},
215+
},
216+
{
217+
desc: "non-comparable values are passed through without panic",
218+
filtersIn: map[string]any{"view.ids": []any{"a", "b"}, "view.meta": map[string]any{"k": "v"}},
219+
filtersOut: map[string]any{"view.ids": []any{"a", "b"}, "view.meta": map[string]any{"k": "v"}},
220+
},
221+
{
222+
desc: "single-character string is not mangled by the length check",
223+
filtersIn: map[string]any{"view.code": "x"},
224+
filtersOut: map[string]any{"view.code": "x"},
225+
},
226+
{
227+
desc: "mismatched wrapping characters are left alone",
228+
filtersIn: map[string]any{"view.f": `"value'`},
229+
filtersOut: map[string]any{"view.f": `"value'`},
230+
},
231+
}
232+
233+
for _, tc := range tcs {
234+
t.Run(tc.desc, func(t *testing.T) {
235+
params := parameters.ParamValues{
236+
{Name: "model", Value: "marketing"},
237+
{Name: "explore", Value: "cohort_marketing_performance"},
238+
{Name: "fields", Value: []any{"view.channel"}},
239+
{Name: "filters", Value: tc.filtersIn},
240+
{Name: "pivots", Value: []any{}},
241+
{Name: "sorts", Value: []any{}},
242+
{Name: "limit", Value: 10},
243+
{Name: "tz", Value: "Etc/UTC"},
244+
}
245+
wq, err := lookercommon.ProcessQueryArgs(ctx, params)
246+
if err != nil {
247+
t.Fatalf("unexpected error: %v", err)
248+
}
249+
if wq.Filters == nil {
250+
t.Fatalf("expected non-nil Filters")
251+
}
252+
if diff := cmp.Diff(tc.filtersOut, *wq.Filters); diff != "" {
253+
t.Fatalf("incorrect filters: diff %v", diff)
254+
}
255+
})
256+
}
257+
}
258+
174259
func TestRequestRunInlineQuery2(t *testing.T) {
175260
fields := make([]string, 1)
176261
fields[0] = "foo.bar"

tests/looker/looker_integration_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,7 @@ func TestLooker(t *testing.T) {
514514
map[string]any{
515515
"additionalProperties": true,
516516
"authServices": []any{},
517-
"description": "The filters for the query",
517+
"description": "The filters for the query. Keys are fully-qualified field names (e.g. \"view.field\") and values are filter expressions or parameter values. Pass values bare — do not wrap them in extra quote characters. For LookML `parameter` fields, use the raw allowed_value (e.g. `first_touch`), not `\"first_touch\"`.",
518518
"name": "filters",
519519
"required": false,
520520
"default": map[string]any{},
@@ -606,7 +606,7 @@ func TestLooker(t *testing.T) {
606606
map[string]any{
607607
"additionalProperties": true,
608608
"authServices": []any{},
609-
"description": "The filters for the query",
609+
"description": "The filters for the query. Keys are fully-qualified field names (e.g. \"view.field\") and values are filter expressions or parameter values. Pass values bare — do not wrap them in extra quote characters. For LookML `parameter` fields, use the raw allowed_value (e.g. `first_touch`), not `\"first_touch\"`.",
610610
"name": "filters",
611611
"required": false,
612612
"default": map[string]any{},
@@ -698,7 +698,7 @@ func TestLooker(t *testing.T) {
698698
map[string]any{
699699
"additionalProperties": true,
700700
"authServices": []any{},
701-
"description": "The filters for the query",
701+
"description": "The filters for the query. Keys are fully-qualified field names (e.g. \"view.field\") and values are filter expressions or parameter values. Pass values bare — do not wrap them in extra quote characters. For LookML `parameter` fields, use the raw allowed_value (e.g. `first_touch`), not `\"first_touch\"`.",
702702
"name": "filters",
703703
"required": false,
704704
"default": map[string]any{},
@@ -950,7 +950,7 @@ func TestLooker(t *testing.T) {
950950
map[string]any{
951951
"additionalProperties": true,
952952
"authServices": []any{},
953-
"description": "The filters for the query",
953+
"description": "The filters for the query. Keys are fully-qualified field names (e.g. \"view.field\") and values are filter expressions or parameter values. Pass values bare — do not wrap them in extra quote characters. For LookML `parameter` fields, use the raw allowed_value (e.g. `first_touch`), not `\"first_touch\"`.",
954954
"name": "filters",
955955
"required": false,
956956
"default": map[string]any{},
@@ -1232,7 +1232,7 @@ func TestLooker(t *testing.T) {
12321232
map[string]any{
12331233
"additionalProperties": true,
12341234
"authServices": []any{},
1235-
"description": "The filters for the query",
1235+
"description": "The filters for the query. Keys are fully-qualified field names (e.g. \"view.field\") and values are filter expressions or parameter values. Pass values bare — do not wrap them in extra quote characters. For LookML `parameter` fields, use the raw allowed_value (e.g. `first_touch`), not `\"first_touch\"`.",
12361236
"name": "filters",
12371237
"required": false,
12381238
"default": map[string]any{},

0 commit comments

Comments
 (0)