diff --git a/docs/en/integrations/looker/tools/looker-query-sql.md b/docs/en/integrations/looker/tools/looker-query-sql.md index 8ad24cdbb4d..1789d33372f 100644 --- a/docs/en/integrations/looker/tools/looker-query-sql.md +++ b/docs/en/integrations/looker/tools/looker-query-sql.md @@ -12,17 +12,18 @@ description: > The `looker-query-sql` generates a sql query using the Looker semantic model. -`looker-query-sql` takes nine parameters: +`looker-query-sql` takes ten parameters: 1. the `model` 2. the `explore` 3. the `fields` list 4. an optional set of `filters` 5. an optional `filter_expression` -6. an optional set of `pivots` -7. an optional set of `sorts` -8. an optional `limit` -9. an optional `tz` +6. an optional `dynamic_fields` +7. an optional set of `pivots` +8. an optional set of `sorts` +9. an optional `limit` +10. an optional `tz` Starting in Looker v25.18, these queries can be identified in Looker's System Activity. In the History explore, use the field API Client Name @@ -47,7 +48,7 @@ description: | Parameters: All parameters for this tool are identical to those of the `query` tool. This includes `model_name`, `explore_name`, `fields` (required), - and optional parameters like `pivots`, `filters`, `filter_expression`, `sorts`, `limit`, and `query_timezone`. + and optional parameters like `pivots`, `filters`, `filter_expression`, `dynamic_fields`, `sorts`, `limit`, and `query_timezone`. Output: The result of this tool is the raw SQL text. diff --git a/docs/en/integrations/looker/tools/looker-query-url.md b/docs/en/integrations/looker/tools/looker-query-url.md index c0ea0a6c57e..84fc0524e1b 100644 --- a/docs/en/integrations/looker/tools/looker-query-url.md +++ b/docs/en/integrations/looker/tools/looker-query-url.md @@ -11,18 +11,19 @@ description: > The `looker-query-url` generates a url link to an explore in Looker so the query can be investigated further. -`looker-query-url` takes ten parameters: +`looker-query-url` takes eleven parameters: 1. the `model` 2. the `explore` 3. the `fields` list 4. an optional set of `filters` 5. an optional `filter_expression` -6. an optional set of `pivots` -7. an optional set of `sorts` -8. an optional `limit` -9. an optional `tz` -10. an optional `vis_config` +6. an optional `dynamic_fields` +7. an optional set of `pivots` +8. an optional set of `sorts` +9. an optional `limit` +10. an optional `tz` +11. an optional `vis_config` ## Compatible Sources @@ -42,7 +43,7 @@ description: | Parameters: All query parameters (e.g., `model_name`, `explore_name`, `fields`, `pivots`, - `filters`, `filter_expression`, `sorts`, `limit`, `query_timezone`) are the same as the `query` tool. + `filters`, `filter_expression`, `dynamic_fields`, `sorts`, `limit`, `query_timezone`) are the same as the `query` tool. Additionally, it accepts an optional `vis_config` parameter: - vis_config (optional): A JSON object that controls the default visualization diff --git a/docs/en/integrations/looker/tools/looker-query.md b/docs/en/integrations/looker/tools/looker-query.md index c4682dfea7b..fa95bf4f56c 100644 --- a/docs/en/integrations/looker/tools/looker-query.md +++ b/docs/en/integrations/looker/tools/looker-query.md @@ -12,17 +12,18 @@ description: > The `looker-query` tool runs a query using the Looker semantic model. -`looker-query` takes nine parameters: +`looker-query` takes ten parameters: 1. the `model` 2. the `explore` 3. the `fields` list 4. an optional set of `filters` 5. an optional `filter_expression` -6. an optional set of `pivots` -7. an optional set of `sorts` -8. an optional `limit` -9. an optional `tz` +6. an optional `dynamic_fields` +7. an optional set of `pivots` +8. an optional set of `sorts` +9. an optional `limit` +10. an optional `tz` Starting in Looker v25.18, these queries can be identified in Looker's System Activity. In the History explore, use the field API Client Name @@ -61,6 +62,13 @@ description: | - `${orders.order_date} < add_years(-1, now())` - `${activity.email} != ${activity_drive_facts.current_owner_email}` - `matches_filter(${order.order_month}, '24 months') AND matches_filter(${order.order_month}, 'before 2024/07/01')` + - dynamic_fields: An optional array of dynamic fields (table calculations, custom measures, custom dimensions) defined as JSON objects. + - Useful for ad-hoc calculations that are not defined in the LookML model. + - Reference fields using `${view.field_name}` syntax. + - Examples: + - Table Calculation: `[{"table_calculation": "test", "label": "test", "expression": "${order_items.total_sale_price} * 0.8", "_type_hint": "number"}]` + - Custom Dimension: `[{"dimension": "days_since_order", "label": "days since order", "expression": "diff_days(${order.order_date}, now())", "_type_hint": "number"}]` + - Custom Measure: `[{"measure": "sum_of_revenue", "label": "Sum of Revenue", "based_on": "training.revenue", "type": "sum", "_type_hint": "number"}]` - sorts: A list of fields to sort by, optionally including direction (e.g., `["view.field desc"]`). - limit: Row limit (default 500). Use "-1" for unlimited. - query_timezone: specific timezone for the query (e.g. `America/Los_Angeles`). diff --git a/internal/prebuiltconfigs/tools/looker.yaml b/internal/prebuiltconfigs/tools/looker.yaml index e148636e2fa..2280cf74578 100644 --- a/internal/prebuiltconfigs/tools/looker.yaml +++ b/internal/prebuiltconfigs/tools/looker.yaml @@ -148,6 +148,13 @@ description: | - `${orders.order_date} < add_years(-1, now())` - `${activity.email} != ${activity_drive_facts.current_owner_email}` - `matches_filter(${order.order_month}, '24 months') AND matches_filter(${order.order_month}, 'before 2024/07/01')` + - dynamic_fields: An optional array of dynamic fields (table calculations, custom measures, custom dimensions) defined as JSON objects. + - Useful for ad-hoc calculations that are not defined in the LookML model. + - Reference fields using `${view.field_name}` syntax. + - Examples: + - Table Calculation: `[{"table_calculation": "test", "label": "test", "expression": "${order_items.total_sale_price} * 0.8", "_type_hint": "number"}]` + - Custom Dimension: `[{"dimension": "days_since_order", "label": "days since order", "expression": "diff_days(${order.order_date}, now())", "_type_hint": "number"}]` + - Custom Measure: `[{"measure": "sum_of_revenue", "label": "Sum of Revenue", "based_on": "training.revenue", "type": "sum", "_type_hint": "number"}]` - sorts: A list of fields to sort by, optionally including direction (e.g., `["view.field desc"]`). - limit: Row limit (default 500). Use "-1" for unlimited. - query_timezone: specific timezone for the query (e.g. `America/Los_Angeles`). @@ -166,7 +173,7 @@ description: | Parameters: All parameters for this tool are identical to those of the `query` tool. This includes `model_name`, `explore_name`, `fields` (required), - and optional parameters like `pivots`, `filters`, `filter_expression`, `sorts`, `limit`, and `query_timezone`. + and optional parameters like `pivots`, `filters`, `filter_expression`, `dynamic_fields`, `sorts`, `limit`, and `query_timezone`. Output: The result of this tool is the raw SQL text. @@ -182,7 +189,7 @@ description: | Parameters: All query parameters (e.g., `model_name`, `explore_name`, `fields`, `pivots`, - `filters`, `filter_expression`, `sorts`, `limit`, `query_timezone`) are the same as the `query` tool. + `filters`, `filter_expression`, `dynamic_fields`, `sorts`, `limit`, `query_timezone`) are the same as the `query` tool. Additionally, it accepts an optional `vis_config` parameter: - vis_config (optional): A JSON object that controls the default visualization diff --git a/internal/tools/looker/lookercommon/lookercommon.go b/internal/tools/looker/lookercommon/lookercommon.go index 64a0ff1d1f8..0225b53b9da 100644 --- a/internal/tools/looker/lookercommon/lookercommon.go +++ b/internal/tools/looker/lookercommon/lookercommon.go @@ -15,6 +15,7 @@ package lookercommon import ( "context" + "encoding/json" "fmt" "net/url" "strings" @@ -141,6 +142,12 @@ func GetQueryParameters() parameters.Parameters { limitParameter := parameters.NewIntParameter("limit", "The row limit.", parameters.WithIntDefault(500)) tzParameter := parameters.NewStringParameter("tz", "The query timezone.", parameters.WithStringRequired(false)) filterExpressionParameter := parameters.NewStringParameter("filter_expression", "An optional filter expression string.", parameters.WithStringRequired(false)) + dynamicFieldsParameter := parameters.NewArrayParameter( + "dynamic_fields", + "An optional array of dynamic fields (table calculations, custom measures, custom dimensions).", + parameters.NewMapParameter("dynamic_field", "A dynamic field definition", ""), + parameters.WithArrayDefault([]any{}), + ) return parameters.Parameters{ modelParameter, @@ -152,6 +159,7 @@ func GetQueryParameters() parameters.Parameters { limitParameter, tzParameter, filterExpressionParameter, + dynamicFieldsParameter, } } @@ -343,6 +351,18 @@ func ProcessQueryArgs(ctx context.Context, params parameters.ParamValues) (*v4.W } } + var dynamicFieldsPtr *string + if val, ok := paramsMap["dynamic_fields"]; ok && val != nil { + if sliceVal, ok := val.([]any); ok && len(sliceVal) > 0 { + jsonBytes, err := json.Marshal(sliceVal) + if err != nil { + return nil, fmt.Errorf("error marshaling dynamic_fields: %w", err) + } + jsonStr := string(jsonBytes) + dynamicFieldsPtr = &jsonStr + } + } + wq := v4.WriteQuery{ Model: paramsMap["model"].(string), View: paramsMap["explore"].(string), @@ -353,6 +373,7 @@ func ProcessQueryArgs(ctx context.Context, params parameters.ParamValues) (*v4.W QueryTimezone: &tz, Limit: &limit, FilterExpression: filterExpressionPtr, + DynamicFields: dynamicFieldsPtr, } return &wq, nil } diff --git a/internal/tools/looker/lookercommon/lookercommon_test.go b/internal/tools/looker/lookercommon/lookercommon_test.go index 5835c50e04e..23c561506d8 100644 --- a/internal/tools/looker/lookercommon/lookercommon_test.go +++ b/internal/tools/looker/lookercommon/lookercommon_test.go @@ -463,3 +463,87 @@ func TestProcessQueryArgsWithFilterExpression(t *testing.T) { }) } } + +func TestProcessQueryArgsWithDynamicFields(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + tcs := []struct { + desc string + dynamicFields any + wantVal *string + wantErr bool + }{ + { + desc: "dynamic fields is nil", + dynamicFields: nil, + wantVal: nil, + wantErr: false, + }, + { + desc: "dynamic fields is valid array of maps", + dynamicFields: []any{ + map[string]any{ + "category": "table_calculation", + "expression": "${order_items.total_sale_price} * 0.8", + "label": "test", + "table_calculation": "test", + "_type_hint": "number", + }, + }, + wantVal: func() *string { + s := `[{"_type_hint":"number","category":"table_calculation","expression":"${order_items.total_sale_price} * 0.8","label":"test","table_calculation":"test"}]` + return &s + }(), + wantErr: false, + }, + } + + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + params := parameters.ParamValues{ + {Name: "model", Value: "marketing"}, + {Name: "explore", Value: "cohort_marketing_performance"}, + {Name: "fields", Value: []any{"view.channel"}}, + {Name: "filters", Value: map[string]any{}}, + {Name: "pivots", Value: []any{}}, + {Name: "sorts", Value: []any{}}, + {Name: "limit", Value: 10}, + {Name: "tz", Value: "Etc/UTC"}, + } + if tc.dynamicFields != nil { + params = append(params, parameters.ParamValue{Name: "dynamic_fields", Value: tc.dynamicFields}) + } + wq, err := lookercommon.ProcessQueryArgs(ctx, params) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if wq.DynamicFields == nil && tc.wantVal != nil { + t.Fatalf("expected DynamicFields %v, got nil", *tc.wantVal) + } + if wq.DynamicFields != nil && tc.wantVal == nil { + t.Fatalf("expected DynamicFields nil, got %v", *wq.DynamicFields) + } + if wq.DynamicFields != nil && tc.wantVal != nil { + var gotObj, wantObj any + if err := json.Unmarshal([]byte(*wq.DynamicFields), &gotObj); err != nil { + t.Fatalf("failed to unmarshal got dynamic fields: %v", err) + } + if err := json.Unmarshal([]byte(*tc.wantVal), &wantObj); err != nil { + t.Fatalf("failed to unmarshal want dynamic fields: %v", err) + } + if diff := cmp.Diff(wantObj, gotObj); diff != "" { + t.Fatalf("incorrect DynamicFields: diff %v", diff) + } + } + }) + } +} diff --git a/tests/looker/looker_integration_test.go b/tests/looker/looker_integration_test.go index 988fa140b11..25cd0c7066d 100644 --- a/tests/looker/looker_integration_test.go +++ b/tests/looker/looker_integration_test.go @@ -572,6 +572,22 @@ func TestLooker(t *testing.T) { "required": false, "type": "string", }, + map[string]any{ + "authServices": []any{}, + "default": []any{}, + "description": "An optional array of dynamic fields (table calculations, custom measures, custom dimensions).", + "items": map[string]any{ + "additionalProperties": true, + "authServices": []any{}, + "description": "A dynamic field definition", + "name": "dynamic_field", + "required": false, + "type": "object", + }, + "name": "dynamic_fields", + "required": false, + "type": "array", + }, }, }, }, @@ -671,6 +687,22 @@ func TestLooker(t *testing.T) { "required": false, "type": "string", }, + map[string]any{ + "authServices": []any{}, + "default": []any{}, + "description": "An optional array of dynamic fields (table calculations, custom measures, custom dimensions).", + "items": map[string]any{ + "additionalProperties": true, + "authServices": []any{}, + "description": "A dynamic field definition", + "name": "dynamic_field", + "required": false, + "type": "object", + }, + "name": "dynamic_fields", + "required": false, + "type": "array", + }, }, }, }, @@ -770,6 +802,22 @@ func TestLooker(t *testing.T) { "required": false, "type": "string", }, + map[string]any{ + "authServices": []any{}, + "default": []any{}, + "description": "An optional array of dynamic fields (table calculations, custom measures, custom dimensions).", + "items": map[string]any{ + "additionalProperties": true, + "authServices": []any{}, + "description": "A dynamic field definition", + "name": "dynamic_field", + "required": false, + "type": "object", + }, + "name": "dynamic_fields", + "required": false, + "type": "array", + }, map[string]any{ "additionalProperties": true, "authServices": []any{}, @@ -1029,6 +1077,22 @@ func TestLooker(t *testing.T) { "required": false, "type": "string", }, + map[string]any{ + "authServices": []any{}, + "default": []any{}, + "description": "An optional array of dynamic fields (table calculations, custom measures, custom dimensions).", + "items": map[string]any{ + "additionalProperties": true, + "authServices": []any{}, + "description": "A dynamic field definition", + "name": "dynamic_field", + "required": false, + "type": "object", + }, + "name": "dynamic_fields", + "required": false, + "type": "array", + }, map[string]any{ "authServices": []any{}, "description": "The title of the Look", @@ -1318,6 +1382,22 @@ func TestLooker(t *testing.T) { "required": false, "type": "string", }, + map[string]any{ + "authServices": []any{}, + "default": []any{}, + "description": "An optional array of dynamic fields (table calculations, custom measures, custom dimensions).", + "items": map[string]any{ + "additionalProperties": true, + "authServices": []any{}, + "description": "A dynamic field definition", + "name": "dynamic_field", + "required": false, + "type": "object", + }, + "name": "dynamic_fields", + "required": false, + "type": "array", + }, map[string]any{ "authServices": []any{}, "description": "The id of the dashboard where this tile will exist",