157 lines
3.9 KiB
Go
157 lines
3.9 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"fn-registry/functions/infra"
|
|
)
|
|
|
|
var namedParamRe = regexp.MustCompile(`:([a-zA-Z_][a-zA-Z0-9_]*)`)
|
|
|
|
// QueryEngine executes queries with parameter resolution.
|
|
type QueryEngine struct {
|
|
config *DashboardConfig
|
|
pool map[string]*sql.DB
|
|
}
|
|
|
|
func NewQueryEngine(cfg *DashboardConfig, pool map[string]*sql.DB) *QueryEngine {
|
|
return &QueryEngine{config: cfg, pool: pool}
|
|
}
|
|
|
|
// Execute runs the query for a widget, resolving filter references and named params.
|
|
func (qe *QueryEngine) Execute(widgetID string, filters map[string]any) ([]map[string]any, error) {
|
|
// Find the widget and its query.
|
|
widget, err := qe.findWidget(widgetID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
qdef, ok := qe.config.Queries[widget.Query]
|
|
if !ok {
|
|
return nil, fmt.Errorf("query %q not found", widget.Query)
|
|
}
|
|
|
|
db, ok := qe.pool[qdef.Connection]
|
|
if !ok {
|
|
return nil, fmt.Errorf("connection %q not found", qdef.Connection)
|
|
}
|
|
|
|
// Resolve parameter values: static or $filter.xxx references.
|
|
resolvedParams := qe.resolveParams(qdef.Params, filters)
|
|
|
|
// Convert :name placeholders to driver-appropriate positional params.
|
|
driver := qe.config.Connections[qdef.Connection].Driver
|
|
query, args := convertNamedParams(qdef.SQL, resolvedParams, driver)
|
|
|
|
return infra.DBQuery(db, query, args...)
|
|
}
|
|
|
|
func (qe *QueryEngine) findWidget(id string) (*WidgetDef, error) {
|
|
for _, sec := range qe.config.Sections {
|
|
for i := range sec.Widgets {
|
|
if sec.Widgets[i].ID == id {
|
|
return &sec.Widgets[i], nil
|
|
}
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("widget %q not found", id)
|
|
}
|
|
|
|
// resolveParams replaces $filter.xxx references with actual filter values
|
|
// and resolves relative dates.
|
|
func (qe *QueryEngine) resolveParams(params map[string]string, filters map[string]any) map[string]string {
|
|
resolved := make(map[string]string, len(params))
|
|
|
|
for name, ref := range params {
|
|
val := ref
|
|
|
|
// $filter.date_range.from → filters["date_range"]["from"]
|
|
if strings.HasPrefix(ref, "$filter.") {
|
|
val = resolveFilterRef(ref, filters)
|
|
}
|
|
|
|
// Resolve relative dates (now-7d, etc.)
|
|
if resolved, ok := ResolveRelativeDate(val); ok {
|
|
val = resolved
|
|
}
|
|
|
|
resolved[name] = val
|
|
}
|
|
|
|
return resolved
|
|
}
|
|
|
|
// resolveFilterRef extracts a value from the filters map using dot notation.
|
|
// "$filter.date_range.from" → filters["date_range"] → map["from"]
|
|
func resolveFilterRef(ref string, filters map[string]any) string {
|
|
// Strip "$filter." prefix.
|
|
path := strings.TrimPrefix(ref, "$filter.")
|
|
parts := strings.SplitN(path, ".", 2)
|
|
|
|
v, ok := filters[parts[0]]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
|
|
// Simple value (e.g. $filter.category).
|
|
if len(parts) == 1 {
|
|
return fmt.Sprint(v)
|
|
}
|
|
|
|
// Nested value (e.g. $filter.date_range.from).
|
|
if m, ok := v.(map[string]any); ok {
|
|
if val, ok := m[parts[1]]; ok {
|
|
return fmt.Sprint(val)
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// convertNamedParams replaces :name placeholders with positional params ($1, ?, etc.)
|
|
// and builds the args slice in the correct order.
|
|
func convertNamedParams(query string, params map[string]string, driver string) (string, []any) {
|
|
matches := namedParamRe.FindAllStringSubmatch(query, -1)
|
|
if len(matches) == 0 {
|
|
return query, nil
|
|
}
|
|
|
|
// Deduplicate and order params as they appear.
|
|
seen := make(map[string]int)
|
|
var ordered []string
|
|
for _, m := range matches {
|
|
name := m[1]
|
|
if _, ok := seen[name]; !ok {
|
|
seen[name] = len(ordered)
|
|
ordered = append(ordered, name)
|
|
}
|
|
}
|
|
|
|
// Build args slice.
|
|
args := make([]any, len(ordered))
|
|
for i, name := range ordered {
|
|
args[i] = params[name]
|
|
}
|
|
|
|
// Replace :name with positional placeholder.
|
|
result := namedParamRe.ReplaceAllStringFunc(query, func(match string) string {
|
|
name := match[1:] // strip leading ':'
|
|
idx := seen[name]
|
|
return placeholder(driver, idx)
|
|
})
|
|
|
|
return result, args
|
|
}
|
|
|
|
func placeholder(driver string, idx int) string {
|
|
switch driver {
|
|
case "postgres":
|
|
return fmt.Sprintf("$%d", idx+1)
|
|
default:
|
|
return "?"
|
|
}
|
|
}
|