Skip to content

Commit e84d1ec

Browse files
committed
feat(tools/looker): Enable Lookml View creation from table in Looker
1 parent 4110140 commit e84d1ec

4 files changed

Lines changed: 441 additions & 0 deletions

File tree

internal/prebuiltconfigs/tools/looker.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1143,6 +1143,24 @@ tools:
11431143
- errors: Array of error objects (if any), containing details like `message`, `file_path`, `line_number`, and `severity`.
11441144
- warnings: Array of warning messages (if any).
11451145
1146+
create_view_from_table:
1147+
kind: looker-create-view-from-table
1148+
source: looker-source
1149+
description: |
1150+
This tool generates boilerplate LookML views directly from the database schema.
1151+
It does not create model or explore files, only view files in the specified folder.
1152+
1153+
Prerequisite: The Looker session must be in Development Mode. Use `dev_mode: true` first.
1154+
1155+
Parameters:
1156+
- project_id (required): The unique ID of the LookML project.
1157+
- connection (required): The database connection name.
1158+
- tables (required): A list of objects to generate views for. Each object must contain `schema` and `table_name` (note: table names are case-sensitive). Optional fields include `primary_key`, `base_view`, and `columns` (array of objects with `column_name`).
1159+
- folder_name (optional): The folder to place the view files in (defaults to 'views/').
1160+
1161+
Output:
1162+
A confirmation message upon successful view generation, or an error message if the operation fails.
1163+
11461164
toolsets:
11471165
looker_tools:
11481166
- get_models
@@ -1184,3 +1202,4 @@ toolsets:
11841202
- get_connection_table_columns
11851203
- get_lookml_tests
11861204
- run_lookml_tests
1205+
- create_view_from_table

internal/tools/looker/lookercommon/lookercommon.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,3 +330,45 @@ func DeleteProjectDirectory(l *v4.LookerSDK, projectId string, directoryPath str
330330
path := fmt.Sprintf("/projects/%s/directories", url.PathEscape(projectId))
331331
return l.AuthSession.Do(&result, "DELETE", "/4.0", path, query, nil, options)
332332
}
333+
334+
type ProjectGeneratorColumn struct {
335+
ColumnName string `json:"column_name"`
336+
}
337+
338+
type ProjectGeneratorTable struct {
339+
Schema string `json:"schema"`
340+
TableName string `json:"table_name"`
341+
PrimaryKey *string `json:"primary_key,omitempty"`
342+
BaseView *bool `json:"base_view,omitempty"`
343+
Columns []ProjectGeneratorColumn `json:"columns,omitempty"`
344+
}
345+
346+
type ProjectGeneratorRequestBody struct {
347+
Tables []ProjectGeneratorTable `json:"tables"`
348+
}
349+
350+
type ProjectGeneratorQueryParams struct {
351+
Connection string `json:"connection"`
352+
FileTypeForExplores string `json:"file_type_for_explores"`
353+
FolderName string `json:"folder_name,omitempty"`
354+
}
355+
356+
func CreateViewsFromTables(ctx context.Context, l *v4.LookerSDK, projectId string, queryParams ProjectGeneratorQueryParams, reqBody ProjectGeneratorRequestBody, options *rtl.ApiSettings) error {
357+
path := fmt.Sprintf("/projects/%s/generate", url.PathEscape(projectId))
358+
359+
// Construct query parameter map
360+
query := map[string]any{
361+
"connection": queryParams.Connection,
362+
"file_type_for_explores": queryParams.FileTypeForExplores,
363+
"folder_name": queryParams.FolderName,
364+
}
365+
366+
// Pass the Tables slice directly as the body, not the wrapped struct.
367+
// The API spec defines `tables` as `body_param ... array: true`,
368+
// which means the body itself should be the array.
369+
err := l.AuthSession.Do(nil, "POST", "/4.0", path, query, reqBody.Tables, options)
370+
371+
logger, _ := util.LoggerFromContext(ctx)
372+
logger.DebugContext(ctx, fmt.Sprintf("generating views with request: query=%v body=%v error=%v", query, reqBody.Tables, err))
373+
return err
374+
}
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
package lookercreateviewfromtable
15+
16+
import (
17+
"context"
18+
"fmt"
19+
20+
yaml "github.com/goccy/go-yaml"
21+
"github.com/googleapis/genai-toolbox/internal/embeddingmodels"
22+
"github.com/googleapis/genai-toolbox/internal/sources"
23+
"github.com/googleapis/genai-toolbox/internal/tools"
24+
"github.com/googleapis/genai-toolbox/internal/tools/looker/lookercommon"
25+
"github.com/googleapis/genai-toolbox/internal/util"
26+
"github.com/googleapis/genai-toolbox/internal/util/parameters"
27+
28+
"github.com/looker-open-source/sdk-codegen/go/rtl"
29+
v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4"
30+
)
31+
32+
const resourceType string = "looker-create-view-from-table"
33+
34+
func init() {
35+
if !tools.Register(resourceType, newConfig) {
36+
panic(fmt.Sprintf("tool type %q already registered", resourceType))
37+
}
38+
}
39+
40+
func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) {
41+
actual := Config{Name: name}
42+
if err := decoder.DecodeContext(ctx, &actual); err != nil {
43+
return nil, err
44+
}
45+
return actual, nil
46+
}
47+
48+
type compatibleSource interface {
49+
UseClientAuthorization() bool
50+
GetAuthTokenHeaderName() string
51+
LookerApiSettings() *rtl.ApiSettings
52+
GetLookerSDK(string) (*v4.LookerSDK, error)
53+
}
54+
55+
type Config struct {
56+
Name string `yaml:"name" validate:"required"`
57+
Type string `yaml:"type" validate:"required"`
58+
Source string `yaml:"source" validate:"required"`
59+
Description string `yaml:"description" validate:"required"`
60+
AuthRequired []string `yaml:"authRequired"`
61+
Annotations *tools.ToolAnnotations `yaml:"annotations,omitempty"`
62+
}
63+
64+
// validate interface
65+
var _ tools.ToolConfig = Config{}
66+
67+
func (cfg Config) ToolConfigType() string {
68+
return resourceType
69+
}
70+
71+
func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) {
72+
projectIdParameter := parameters.NewStringParameter("project_id", "The id of the project to create the view in.")
73+
connectionParameter := parameters.NewStringParameter("connection", "The database connection name.")
74+
75+
tableDef := parameters.NewMapParameter("table", "Table definition.", "")
76+
tablesParameter := parameters.NewArrayParameter("tables", `The tables to generate views for.
77+
Each item must be a map with:
78+
- schema (string, required)
79+
- table_name (string, required)
80+
- primary_key (string, optional)
81+
- base_view (boolean, optional)
82+
- columns (array of objects, optional): Each object must have 'column_name' (string).`, tableDef)
83+
84+
folderNameParameter := parameters.NewStringParameterWithDefault("folder_name", "views", "The folder to place the view files in (e.g., 'views').")
85+
86+
params := parameters.Parameters{projectIdParameter, connectionParameter, tablesParameter, folderNameParameter}
87+
88+
annotations := cfg.Annotations
89+
if annotations == nil {
90+
readOnlyHint := false
91+
annotations = &tools.ToolAnnotations{
92+
ReadOnlyHint: &readOnlyHint,
93+
}
94+
}
95+
96+
mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, params, annotations)
97+
98+
// finish tool setup
99+
return Tool{
100+
Config: cfg,
101+
Parameters: params,
102+
manifest: tools.Manifest{
103+
Description: cfg.Description,
104+
Parameters: params.Manifest(),
105+
AuthRequired: cfg.AuthRequired,
106+
},
107+
mcpManifest: mcpManifest,
108+
}, nil
109+
}
110+
111+
// validate interface
112+
var _ tools.Tool = Tool{}
113+
114+
type Tool struct {
115+
Config
116+
Parameters parameters.Parameters `yaml:"parameters"`
117+
manifest tools.Manifest
118+
mcpManifest tools.McpManifest
119+
}
120+
121+
func (t Tool) ToConfig() tools.ToolConfig {
122+
return t.Config
123+
}
124+
125+
func (t Tool) Invoke(ctx context.Context, resourceMgr tools.SourceProvider, params parameters.ParamValues, accessToken tools.AccessToken) (any, error) {
126+
source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Type)
127+
if err != nil {
128+
return nil, err
129+
}
130+
131+
logger, err := util.LoggerFromContext(ctx)
132+
if err != nil {
133+
return nil, fmt.Errorf("unable to get logger from ctx: %s", err)
134+
}
135+
136+
sdk, err := source.GetLookerSDK(string(accessToken))
137+
if err != nil {
138+
return nil, fmt.Errorf("error getting sdk: %w", err)
139+
}
140+
141+
mapParams := params.AsMap()
142+
projectId, ok := mapParams["project_id"].(string)
143+
if !ok {
144+
return nil, fmt.Errorf("'project_id' must be a string, got %T", mapParams["project_id"])
145+
}
146+
connection, ok := mapParams["connection"].(string)
147+
if !ok {
148+
return nil, fmt.Errorf("'connection' must be a string, got %T", mapParams["connection"])
149+
}
150+
folderName, ok := mapParams["folder_name"].(string)
151+
if !ok {
152+
return nil, fmt.Errorf("'folder_name' must be a string, got %T", mapParams["folder_name"])
153+
}
154+
155+
tablesSlice, ok := mapParams["tables"].([]any)
156+
if !ok {
157+
return nil, fmt.Errorf("'tables' must be an array, got %T", mapParams["tables"])
158+
}
159+
160+
logger.DebugContext(ctx, "generating views with request", "tables", tablesSlice)
161+
162+
var generatorTables []lookercommon.ProjectGeneratorTable
163+
for _, tRaw := range tablesSlice {
164+
t, ok := tRaw.(map[string]any)
165+
if !ok {
166+
return nil, fmt.Errorf("expected map in tables list, got %T", tRaw)
167+
}
168+
169+
var schema, tableName string
170+
var primaryKey *string
171+
var baseView *bool
172+
var columns []lookercommon.ProjectGeneratorColumn
173+
174+
if s, ok := t["schema"].(string); ok {
175+
schema = s
176+
}
177+
if tn, ok := t["table_name"].(string); ok {
178+
tableName = tn
179+
}
180+
// Enforce required fields for map input
181+
if schema == "" || tableName == "" {
182+
return nil, fmt.Errorf("schema and table_name are required in table map")
183+
}
184+
185+
if pk, ok := t["primary_key"].(string); ok {
186+
primaryKey = &pk
187+
}
188+
if bv, ok := t["base_view"].(bool); ok {
189+
baseView = &bv
190+
}
191+
if colsRaw, ok := t["columns"].([]any); ok {
192+
for _, cRaw := range colsRaw {
193+
if cMap, ok := cRaw.(map[string]any); ok {
194+
if cName, ok := cMap["column_name"].(string); ok {
195+
columns = append(columns, lookercommon.ProjectGeneratorColumn{ColumnName: cName})
196+
}
197+
}
198+
}
199+
}
200+
201+
if tableName == "" {
202+
continue // Skip invalid entries
203+
}
204+
205+
generatorTables = append(generatorTables, lookercommon.ProjectGeneratorTable{
206+
Schema: schema,
207+
TableName: tableName,
208+
PrimaryKey: primaryKey,
209+
BaseView: baseView,
210+
Columns: columns,
211+
})
212+
}
213+
214+
queryParams := lookercommon.ProjectGeneratorQueryParams{
215+
Connection: connection,
216+
FileTypeForExplores: "none",
217+
FolderName: folderName,
218+
}
219+
220+
reqBody := lookercommon.ProjectGeneratorRequestBody{
221+
Tables: generatorTables,
222+
}
223+
224+
logger.DebugContext(ctx, "generating views with request", "query", queryParams, "body", reqBody)
225+
226+
err = lookercommon.CreateViewsFromTables(ctx, sdk, projectId, queryParams, reqBody, source.LookerApiSettings())
227+
if err != nil {
228+
return nil, fmt.Errorf("error generating views: %w", err)
229+
}
230+
231+
return map[string]string{
232+
"status": "success",
233+
"message": fmt.Sprintf("Triggered view generation for project %s in folder %s", projectId, folderName),
234+
}, nil
235+
}
236+
237+
func (t Tool) EmbedParams(ctx context.Context, paramValues parameters.ParamValues, embeddingModelsMap map[string]embeddingmodels.EmbeddingModel) (parameters.ParamValues, error) {
238+
return parameters.EmbedParams(ctx, t.Parameters, paramValues, embeddingModelsMap, nil)
239+
}
240+
241+
func (t Tool) Manifest() tools.Manifest {
242+
return t.manifest
243+
}
244+
245+
func (t Tool) McpManifest() tools.McpManifest {
246+
return t.mcpManifest
247+
}
248+
249+
func (t Tool) RequiresClientAuthorization(resourceMgr tools.SourceProvider) (bool, error) {
250+
source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Type)
251+
if err != nil {
252+
return false, err
253+
}
254+
return source.UseClientAuthorization(), nil
255+
}
256+
257+
func (t Tool) Authorized(verifiedAuthServices []string) bool {
258+
return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices)
259+
}
260+
261+
func (t Tool) GetAuthTokenHeaderName(resourceMgr tools.SourceProvider) (string, error) {
262+
source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Type)
263+
if err != nil {
264+
return "", err
265+
}
266+
return source.GetAuthTokenHeaderName(), nil
267+
}
268+
269+
func (t Tool) GetParameters() parameters.Parameters {
270+
return t.Parameters
271+
}

0 commit comments

Comments
 (0)