feat(assets): add native Go asset/file loader for migrate-apply
Implements a new `relspec assets` command (list/execute subcommands) that
loads local binary and text asset files into PostgreSQL by binding file bytes
as native pgx query parameters — never as SQL text literals — so binary data
stays byte-exact with no escaping overhead.
Key design points:
- YAML manifest (assets.yaml) colocated with files describes each entry:
file path, SQL call with :bytes/:filename/:param named placeholders, and
optional static params map.
- Placeholder substitution converts :name to positional $N params; PostgreSQL
::cast syntax is protected before substitution to avoid false matches.
- Directory scan follows the existing {priority}_{sequence}_{name} naming
convention, enabling asset-loading steps to be correctly interleaved with
relspec scripts execute in a migrate-apply pipeline.
- Symlink components and path traversal (../) are silently skipped to prevent
directory escape attacks.
- 14 unit tests cover manifest loading, directory scanning, ordering, symlink
skipping, path traversal rejection, placeholder substitution edge cases
(repeated, cast protection, binary byte-exact, unknown).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user