package assetloader import ( "context" "fmt" "os" "path/filepath" "regexp" "strings" "github.com/jackc/pgx/v5" ) // namedPlaceholder matches :identifier patterns (but not ::cast syntax). var namedPlaceholder = regexp.MustCompile(`:([a-zA-Z_][a-zA-Z0-9_]*)`) // pgCastMarker temporarily replaces :: to protect PostgreSQL cast syntax. const pgCastMarker = "\x00PGCAST\x00" // BuildQuery converts a SQL call that uses :name named placeholders into a // pgx-compatible positional-parameter query ($1, $2, …) and returns the // corresponding argument slice. // // Built-in placeholders: // - :bytes → fileBytes ([]byte) // - :filename → filename (string, base name only) // - :any_key → staticParams["any_key"] (string) // // A placeholder that appears more than once maps to the same $N. An unknown // placeholder (not built-in and not in staticParams) returns an error. // PostgreSQL cast syntax (::type) is left untouched. func BuildQuery(call string, fileBytes []byte, filename string, staticParams map[string]string) (string, []any, error) { // Protect :: casts before running the placeholder regex. protected := strings.ReplaceAll(call, "::", pgCastMarker) paramIndex := map[string]int{} // name → 1-based position var args []any var firstErr error result := namedPlaceholder.ReplaceAllStringFunc(protected, func(match string) string { if firstErr != nil { return match } name := match[1:] // strip leading ':' // Return existing positional param for repeated placeholders. if idx, seen := paramIndex[name]; seen { return fmt.Sprintf("$%d", idx) } // Resolve the placeholder value. var val any switch name { case "bytes": val = fileBytes case "filename": val = filename default: if staticParams != nil { if v, ok := staticParams[name]; ok { val = v } } if val == nil { firstErr = fmt.Errorf("unknown placeholder %q in SQL call (not a built-in and not listed in params)", match) return match } } idx := len(args) + 1 paramIndex[name] = idx args = append(args, val) return fmt.Sprintf("$%d", idx) }) if firstErr != nil { return "", nil, firstErr } // Restore :: casts. result = strings.ReplaceAll(result, pgCastMarker, "::") return result, args, nil } // ExecuteItem reads the asset file referenced by item.Entry.File (which is the // absolute path set by ScanDir) and executes the configured SQL call via conn. // The file's raw bytes are bound as a []byte parameter — no encoding or escaping. func ExecuteItem(ctx context.Context, conn *pgx.Conn, item Item) error { data, err := os.ReadFile(item.Entry.File) if err != nil { return fmt.Errorf("reading asset file %s: %w", item.Entry.File, err) } filename := filepath.Base(item.Entry.File) sql, args, err := BuildQuery(item.Entry.Call, data, filename, item.Entry.Params) if err != nil { return fmt.Errorf("building query for %s: %w", filename, err) } if _, err := conn.Exec(ctx, sql, args...); err != nil { return fmt.Errorf("executing asset %s: %w", filename, err) } return nil }