// Package templates loads ClickHouse SQL templates from disk. Templates are // rendered via text/template so we can interpolate validated structural bits // (e.g. which event table to read from); value parameters are bound via // clickhouse.Named at call site rather than rendered. package templates import ( "bytes" "fmt" "os" "path/filepath" "strings" "sync" "text/template" ) type Store struct { dir string mu sync.RWMutex cache map[string]*template.Template } func New(dir string) *Store { return &Store{dir: dir, cache: map[string]*template.Template{}} } // Render loads `name` (with a `.sql.tmpl` suffix appended if not given) and // renders it against `data`. Templates are parsed once and cached. func (s *Store) Render(name string, data any) (string, error) { tpl, err := s.load(name) if err != nil { return "", err } var buf bytes.Buffer if err := tpl.Execute(&buf, data); err != nil { return "", fmt.Errorf("render %s: %w", name, err) } return buf.String(), nil } func (s *Store) load(name string) (*template.Template, error) { if !strings.HasSuffix(name, ".sql") && !strings.HasSuffix(name, ".sql.tmpl") { name += ".sql.tmpl" } s.mu.RLock() if t, ok := s.cache[name]; ok { s.mu.RUnlock() return t, nil } s.mu.RUnlock() path := filepath.Join(s.dir, name) raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read template %s: %w", path, err) } t, err := template.New(name).Parse(string(raw)) if err != nil { return nil, fmt.Errorf("parse template %s: %w", path, err) } s.mu.Lock() s.cache[name] = t s.mu.Unlock() return t, nil }