Files
2026-04-06 00:57:13 +02:00

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 "?"
}
}