feat(scripts): support external file embedding #12
@@ -85,6 +85,23 @@ migrations/
|
|||||||
|
|
||||||
All files will be found and executed in Priority→Sequence order regardless of directory structure.
|
All files will be found and executed in Priority→Sequence order regardless of directory structure.
|
||||||
|
|
||||||
|
## External File Embedding
|
||||||
|
|
||||||
|
Script SQL can embed nearby text or binary files before execution using `-- @embed` directives:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- @embed: path=assets/message.txt var=:message mode=text
|
||||||
|
-- @embed: path=assets/photo.bin var=:payload mode=base64
|
||||||
|
INSERT INTO assets (message, payload)
|
||||||
|
VALUES (:message, decode(:payload, 'base64')::bytea);
|
||||||
|
```
|
||||||
|
|
||||||
|
- `path`: File path resolved relative to the SQL file containing the directive
|
||||||
|
- `var`: Named placeholder to replace, such as `:message`
|
||||||
|
- `mode`: `text` embeds an escaped SQL string literal; `base64` embeds a base64 string literal
|
||||||
|
|
||||||
|
The directive comment is removed from the SQL, and every matching placeholder is replaced before the script is listed or executed.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### relspec scripts list
|
### relspec scripts list
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package assetloader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ScriptSourcePathMetadataKey = "source_path"
|
||||||
|
|
||||||
|
var (
|
||||||
|
embedDirectivePattern = regexp.MustCompile(`(?m)^\s*--\s*@embed:\s*(.+?)\s*$`)
|
||||||
|
embedAttrPattern = regexp.MustCompile(`([a-zA-Z_][a-zA-Z0-9_]*)=("[^"]*"|'[^']*'|\S+)`)
|
||||||
|
embedVarPattern = regexp.MustCompile(`^:[a-zA-Z_][a-zA-Z0-9_]*$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProcessEmbedDirectives expands SQL comments in the form:
|
||||||
|
//
|
||||||
|
// -- @embed: path=... var=:... mode=text|base64
|
||||||
|
//
|
||||||
|
// Paths are resolved relative to sqlPath. Text mode embeds a quoted UTF-8 SQL
|
||||||
|
// string literal. Base64 mode embeds a quoted base64 literal suitable for
|
||||||
|
// decode(:var, 'base64').
|
||||||
|
func ProcessEmbedDirectives(sqlPath, sql string) (string, error) {
|
||||||
|
directives := embedDirectivePattern.FindAllStringSubmatch(sql, -1)
|
||||||
|
if len(directives) == 0 {
|
||||||
|
return sql, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if sqlPath == "" {
|
||||||
|
return "", fmt.Errorf("sql path is required for embed directives")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := embedDirectivePattern.ReplaceAllString(sql, "")
|
||||||
|
for i, directive := range directives {
|
||||||
|
literal, placeholder, err := embedDirectiveLiteral(sqlPath, directive[1], i+1)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !embedPlaceholderPattern(placeholder).MatchString(result) {
|
||||||
|
return "", fmt.Errorf("%s embed directive %d: placeholder %s not found", sqlPath, i+1, placeholder)
|
||||||
|
}
|
||||||
|
result = replaceEmbedPlaceholder(result, placeholder, literal)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func embedDirectiveLiteral(sqlPath, raw string, directiveNumber int) (string, string, error) {
|
||||||
|
attrs, err := parseEmbedAttrs(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("%s embed directive %d: %w", sqlPath, directiveNumber, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pathValue := attrs["path"]
|
||||||
|
varValue := attrs["var"]
|
||||||
|
modeValue := attrs["mode"]
|
||||||
|
if pathValue == "" {
|
||||||
|
return "", "", fmt.Errorf("%s embed directive %d: missing path", sqlPath, directiveNumber)
|
||||||
|
}
|
||||||
|
if !embedVarPattern.MatchString(varValue) {
|
||||||
|
return "", "", fmt.Errorf("%s embed directive %d: var must be a named placeholder like :asset", sqlPath, directiveNumber)
|
||||||
|
}
|
||||||
|
if modeValue != "text" && modeValue != "base64" {
|
||||||
|
return "", "", fmt.Errorf("%s embed directive %d: mode must be text or base64", sqlPath, directiveNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := filepath.Join(filepath.Dir(sqlPath), filepath.Clean(pathValue))
|
||||||
|
data, err := os.ReadFile(resolved)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("%s embed directive %d: reading %s: %w", sqlPath, directiveNumber, resolved, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch modeValue {
|
||||||
|
case "text":
|
||||||
|
if !utf8.Valid(data) {
|
||||||
|
return "", "", fmt.Errorf("%s embed directive %d: %s is not valid UTF-8", sqlPath, directiveNumber, resolved)
|
||||||
|
}
|
||||||
|
literal, err := sqlStringLiteral(string(data))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("%s embed directive %d: %w", sqlPath, directiveNumber, err)
|
||||||
|
}
|
||||||
|
return literal, varValue, nil
|
||||||
|
case "base64":
|
||||||
|
literal, err := sqlStringLiteral(base64.StdEncoding.EncodeToString(data))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("%s embed directive %d: %w", sqlPath, directiveNumber, err)
|
||||||
|
}
|
||||||
|
return literal, varValue, nil
|
||||||
|
default:
|
||||||
|
return "", "", fmt.Errorf("%s embed directive %d: mode must be text or base64", sqlPath, directiveNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEmbedAttrs(raw string) (map[string]string, error) {
|
||||||
|
attrs := map[string]string{}
|
||||||
|
matches := embedAttrPattern.FindAllStringSubmatchIndex(raw, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil, fmt.Errorf("expected path, var, and mode attributes")
|
||||||
|
}
|
||||||
|
|
||||||
|
lastEnd := 0
|
||||||
|
for _, match := range matches {
|
||||||
|
gap := strings.TrimSpace(raw[lastEnd:match[0]])
|
||||||
|
if gap != "" {
|
||||||
|
return nil, fmt.Errorf("invalid attribute syntax near %q", gap)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := raw[match[2]:match[3]]
|
||||||
|
value := raw[match[4]:match[5]]
|
||||||
|
if _, exists := attrs[key]; exists {
|
||||||
|
return nil, fmt.Errorf("duplicate attribute %q", key)
|
||||||
|
}
|
||||||
|
unquoted, err := unquoteEmbedValue(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid %s value: %w", key, err)
|
||||||
|
}
|
||||||
|
attrs[key] = unquoted
|
||||||
|
lastEnd = match[1]
|
||||||
|
}
|
||||||
|
if tail := strings.TrimSpace(raw[lastEnd:]); tail != "" {
|
||||||
|
return nil, fmt.Errorf("invalid attribute syntax near %q", tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
for key := range attrs {
|
||||||
|
if key != "path" && key != "var" && key != "mode" {
|
||||||
|
return nil, fmt.Errorf("unknown attribute %q", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return attrs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unquoteEmbedValue(value string) (string, error) {
|
||||||
|
if len(value) < 2 {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
if value[0] == '"' {
|
||||||
|
return strconv.Unquote(value)
|
||||||
|
}
|
||||||
|
if value[0] == '\'' && value[len(value)-1] == '\'' {
|
||||||
|
return value[1 : len(value)-1], nil
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sqlStringLiteral(value string) (string, error) {
|
||||||
|
if strings.ContainsRune(value, '\x00') {
|
||||||
|
return "", fmt.Errorf("embedded text contains NUL byte")
|
||||||
|
}
|
||||||
|
return "'" + strings.ReplaceAll(value, "'", "''") + "'", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceEmbedPlaceholder(sql, placeholder, literal string) string {
|
||||||
|
return embedPlaceholderPattern(placeholder).ReplaceAllString(sql, "${1}"+literal+"${2}")
|
||||||
|
}
|
||||||
|
|
||||||
|
func embedPlaceholderPattern(placeholder string) *regexp.Regexp {
|
||||||
|
return regexp.MustCompile(`(^|[^a-zA-Z0-9_:])` + regexp.QuoteMeta(placeholder) + `([^a-zA-Z0-9_]|$)`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package assetloader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProcessEmbedDirectives_TextLiteralEscapesQuotes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sqlPath := filepath.Join(dir, "1_001_seed.sql")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "body.txt"), []byte("Line 1\nIt's fine"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ProcessEmbedDirectives(sqlPath, `
|
||||||
|
-- @embed: path=body.txt var=:body mode=text
|
||||||
|
INSERT INTO notes (body) VALUES (:body);
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProcessEmbedDirectives failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "VALUES ('Line 1\nIt''s fine');") {
|
||||||
|
t.Fatalf("embedded SQL did not contain escaped text literal:\n%s", got)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "VALUES (:body);") {
|
||||||
|
t.Fatalf("placeholder was not replaced:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessEmbedDirectives_Base64Literal(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sqlPath := filepath.Join(dir, "1_001_seed.sql")
|
||||||
|
binary := []byte{0x00, 0xff, 0x10, 0x20}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "blob.bin"), binary, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ProcessEmbedDirectives(sqlPath, `
|
||||||
|
-- @embed: path=blob.bin var=:payload mode=base64
|
||||||
|
INSERT INTO files (payload) VALUES (decode(:payload, 'base64')::bytea);
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProcessEmbedDirectives failed: %v", err)
|
||||||
|
}
|
||||||
|
want := "decode('" + base64.StdEncoding.EncodeToString(binary) + "', 'base64')::bytea"
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Fatalf("embedded SQL did not contain base64 literal %q:\n%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessEmbedDirectives_RelativeToSQLFile(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
sqlDir := filepath.Join(root, "nested", "seed")
|
||||||
|
if err := os.MkdirAll(filepath.Join(sqlDir, "assets"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sqlPath := filepath.Join(sqlDir, "1_001_seed.sql")
|
||||||
|
if err := os.WriteFile(filepath.Join(sqlDir, "assets", "body.txt"), []byte("relative body"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ProcessEmbedDirectives(sqlPath, `
|
||||||
|
-- @embed: path=assets/body.txt var=:body mode=text
|
||||||
|
SELECT :body;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProcessEmbedDirectives failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "SELECT 'relative body';") {
|
||||||
|
t.Fatalf("path was not resolved relative to SQL file:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessEmbedDirectives_InvalidDirectiveAndFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sqlPath := filepath.Join(dir, "1_001_seed.sql")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "body.txt"), []byte("ok"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "binary.txt"), []byte{0xff, 0xfe}, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sql string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing mode",
|
||||||
|
sql: "-- @embed: path=body.txt var=:body\nSELECT :body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid var",
|
||||||
|
sql: "-- @embed: path=body.txt var=body mode=text\nSELECT :body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing file",
|
||||||
|
sql: "-- @embed: path=missing.txt var=:body mode=text\nSELECT :body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid utf8 text",
|
||||||
|
sql: "-- @embed: path=binary.txt var=:body mode=text\nSELECT :body;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "placeholder not found",
|
||||||
|
sql: "-- @embed: path=body.txt var=:body mode=text\nSELECT 1;",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown attribute",
|
||||||
|
sql: "-- @embed: path=body.txt var=:body mode=text extra=yes\nSELECT :body;",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if _, err := ProcessEmbedDirectives(sqlPath, tt.sql); err == nil {
|
||||||
|
t.Fatal("expected error, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessEmbedDirectives_DoesNotReplacePlaceholderPrefix(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sqlPath := filepath.Join(dir, "1_001_seed.sql")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "body.txt"), []byte("ok"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ProcessEmbedDirectives(sqlPath, `
|
||||||
|
-- @embed: path=body.txt var=:body mode=text
|
||||||
|
SELECT :body, :body_extra;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProcessEmbedDirectives failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "SELECT 'ok', :body_extra;") {
|
||||||
|
t.Fatalf("placeholder boundary was not respected:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-10
@@ -350,16 +350,17 @@ const (
|
|||||||
// Script represents a database migration or initialization script.
|
// Script represents a database migration or initialization script.
|
||||||
// Scripts can have dependencies and rollback capabilities.
|
// Scripts can have dependencies and rollback capabilities.
|
||||||
type Script struct {
|
type Script struct {
|
||||||
Name string `json:"name" yaml:"name" xml:"name"`
|
Name string `json:"name" yaml:"name" xml:"name"`
|
||||||
Description string `json:"description" yaml:"description" xml:"description"`
|
Description string `json:"description" yaml:"description" xml:"description"`
|
||||||
SQL string `json:"sql" yaml:"sql" xml:"sql"`
|
SQL string `json:"sql" yaml:"sql" xml:"sql"`
|
||||||
Rollback string `json:"rollback,omitempty" yaml:"rollback,omitempty" xml:"rollback,omitempty"`
|
Rollback string `json:"rollback,omitempty" yaml:"rollback,omitempty" xml:"rollback,omitempty"`
|
||||||
RunAfter []string `json:"run_after,omitempty" yaml:"run_after,omitempty" xml:"run_after,omitempty"`
|
RunAfter []string `json:"run_after,omitempty" yaml:"run_after,omitempty" xml:"run_after,omitempty"`
|
||||||
Schema string `json:"schema,omitempty" yaml:"schema,omitempty" xml:"schema,omitempty"`
|
Schema string `json:"schema,omitempty" yaml:"schema,omitempty" xml:"schema,omitempty"`
|
||||||
Version string `json:"version,omitempty" yaml:"version,omitempty" xml:"version,omitempty"`
|
Version string `json:"version,omitempty" yaml:"version,omitempty" xml:"version,omitempty"`
|
||||||
Priority int `json:"priority,omitempty" yaml:"priority,omitempty" xml:"priority,omitempty"`
|
Priority int `json:"priority,omitempty" yaml:"priority,omitempty" xml:"priority,omitempty"`
|
||||||
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
Sequence uint `json:"sequence,omitempty" yaml:"sequence,omitempty" xml:"sequence,omitempty"`
|
||||||
GUID string `json:"guid" yaml:"guid" xml:"guid"`
|
GUID string `json:"guid" yaml:"guid" xml:"guid"`
|
||||||
|
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty" xml:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SQLName returns the script name in lowercase for SQL compatibility.
|
// SQLName returns the script name in lowercase for SQL compatibility.
|
||||||
@@ -468,6 +469,7 @@ func InitScript(name string) *Script {
|
|||||||
return &Script{
|
return &Script{
|
||||||
Name: name,
|
Name: name,
|
||||||
RunAfter: make([]string, 0),
|
RunAfter: make([]string, 0),
|
||||||
|
Metadata: make(map[string]any),
|
||||||
GUID: uuid.New().String(),
|
GUID: uuid.New().String(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,21 @@ migrations/
|
|||||||
- `1_001_test.txt` - Wrong extension
|
- `1_001_test.txt` - Wrong extension
|
||||||
- `readme.md` - Not a SQL file
|
- `readme.md` - Not a SQL file
|
||||||
|
|
||||||
|
## External File Embedding
|
||||||
|
|
||||||
|
SQL files can include external files with `-- @embed` directives. File paths are resolved relative to the SQL file being read.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- @embed: path=assets/message.txt var=:message mode=text
|
||||||
|
-- @embed: path=assets/payload.bin var=:payload mode=base64
|
||||||
|
INSERT INTO assets (message, payload)
|
||||||
|
VALUES (:message, decode(:payload, 'base64')::bytea);
|
||||||
|
```
|
||||||
|
|
||||||
|
- `mode=text` reads UTF-8 text and replaces the placeholder with an escaped SQL string literal.
|
||||||
|
- `mode=base64` reads any bytes and replaces the placeholder with a base64 SQL string literal.
|
||||||
|
- The placeholder must be named, for example `:message`, and must appear in the SQL body.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Basic Usage
|
### Basic Usage
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/relspecgo/pkg/assetloader"
|
||||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||||
)
|
)
|
||||||
@@ -151,6 +152,10 @@ func (r *Reader) readScripts() ([]*models.Script, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read file %s: %w", path, err)
|
return fmt.Errorf("failed to read file %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
sql, err := assetloader.ProcessEmbedDirectives(path, string(content))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Get relative path from base directory
|
// Get relative path from base directory
|
||||||
relPath, err := filepath.Rel(r.options.FilePath, path)
|
relPath, err := filepath.Rel(r.options.FilePath, path)
|
||||||
@@ -161,9 +166,10 @@ func (r *Reader) readScripts() ([]*models.Script, error) {
|
|||||||
// Create Script model
|
// Create Script model
|
||||||
script := models.InitScript(name)
|
script := models.InitScript(name)
|
||||||
script.Description = fmt.Sprintf("SQL script from %s", relPath)
|
script.Description = fmt.Sprintf("SQL script from %s", relPath)
|
||||||
script.SQL = string(content)
|
script.SQL = sql
|
||||||
script.Priority = priority
|
script.Priority = priority
|
||||||
script.Sequence = uint(sequence)
|
script.Sequence = uint(sequence)
|
||||||
|
script.Metadata[assetloader.ScriptSourcePathMetadataKey] = path
|
||||||
|
|
||||||
scripts = append(scripts, script)
|
scripts = append(scripts, script)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package sqldir
|
package sqldir
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||||
@@ -18,12 +20,12 @@ func TestReader_ReadDatabase(t *testing.T) {
|
|||||||
|
|
||||||
// Create test SQL files with both underscore and hyphen separators
|
// Create test SQL files with both underscore and hyphen separators
|
||||||
testFiles := map[string]string{
|
testFiles := map[string]string{
|
||||||
"1_001_create_users.sql": "CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT);",
|
"1_001_create_users.sql": "CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT);",
|
||||||
"1_002_create_posts.sql": "CREATE TABLE posts (id SERIAL PRIMARY KEY, user_id INT);",
|
"1_002_create_posts.sql": "CREATE TABLE posts (id SERIAL PRIMARY KEY, user_id INT);",
|
||||||
"2_001_add_indexes.sql": "CREATE INDEX idx_posts_user_id ON posts(user_id);",
|
"2_001_add_indexes.sql": "CREATE INDEX idx_posts_user_id ON posts(user_id);",
|
||||||
"1_003_seed_data.pgsql": "INSERT INTO users (name) VALUES ('Alice'), ('Bob');",
|
"1_003_seed_data.pgsql": "INSERT INTO users (name) VALUES ('Alice'), ('Bob');",
|
||||||
"10-10-create-newid.pgsql": "CREATE TABLE newid (id SERIAL PRIMARY KEY);",
|
"10-10-create-newid.pgsql": "CREATE TABLE newid (id SERIAL PRIMARY KEY);",
|
||||||
"2-005-add-column.sql": "ALTER TABLE users ADD COLUMN email TEXT;",
|
"2-005-add-column.sql": "ALTER TABLE users ADD COLUMN email TEXT;",
|
||||||
}
|
}
|
||||||
|
|
||||||
for filename, content := range testFiles {
|
for filename, content := range testFiles {
|
||||||
@@ -267,10 +269,10 @@ func TestReader_HyphenFormat(t *testing.T) {
|
|||||||
|
|
||||||
// Create test files with hyphen separators
|
// Create test files with hyphen separators
|
||||||
testFiles := map[string]string{
|
testFiles := map[string]string{
|
||||||
"1-001-create-table.sql": "CREATE TABLE test (id INT);",
|
"1-001-create-table.sql": "CREATE TABLE test (id INT);",
|
||||||
"1-002-insert-data.pgsql": "INSERT INTO test VALUES (1);",
|
"1-002-insert-data.pgsql": "INSERT INTO test VALUES (1);",
|
||||||
"10-10-create-newid.pgsql": "CREATE TABLE newid (id SERIAL);",
|
"10-10-create-newid.pgsql": "CREATE TABLE newid (id SERIAL);",
|
||||||
"2-005-add-index.sql": "CREATE INDEX idx_test ON test(id);",
|
"2-005-add-index.sql": "CREATE INDEX idx_test ON test(id);",
|
||||||
}
|
}
|
||||||
|
|
||||||
for filename, content := range testFiles {
|
for filename, content := range testFiles {
|
||||||
@@ -301,10 +303,10 @@ func TestReader_HyphenFormat(t *testing.T) {
|
|||||||
priority int
|
priority int
|
||||||
sequence uint
|
sequence uint
|
||||||
}{
|
}{
|
||||||
"create-table": {1, 1},
|
"create-table": {1, 1},
|
||||||
"insert-data": {1, 2},
|
"insert-data": {1, 2},
|
||||||
"add-index": {2, 5},
|
"add-index": {2, 5},
|
||||||
"create-newid": {10, 10},
|
"create-newid": {10, 10},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, script := range schema.Scripts {
|
for _, script := range schema.Scripts {
|
||||||
@@ -435,3 +437,61 @@ func TestReader_SkipSymlinks(t *testing.T) {
|
|||||||
t.Error("Symlink script should have been skipped but was found")
|
t.Error("Symlink script should have been skipped but was found")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReader_EmbedDirectives(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
assetDir := filepath.Join(tempDir, "assets")
|
||||||
|
if err := os.MkdirAll(assetDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(assetDir, "message.txt"), []byte("Reader's text"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
binary := []byte{0x00, 0x01, 0xfe, 0xff}
|
||||||
|
if err := os.WriteFile(filepath.Join(assetDir, "payload.bin"), binary, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sql := `
|
||||||
|
-- @embed: path=assets/message.txt var=:message mode=text
|
||||||
|
-- @embed: path=assets/payload.bin var=:payload mode=base64
|
||||||
|
INSERT INTO assets (message, payload) VALUES (:message, decode(:payload, 'base64')::bytea);
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(tempDir, "1_001_embed.sql"), []byte(sql), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := NewReader(&readers.ReaderOptions{FilePath: tempDir})
|
||||||
|
db, err := reader.ReadDatabase()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDatabase failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(db.Schemas[0].Scripts) != 1 {
|
||||||
|
t.Fatalf("expected 1 script, got %d", len(db.Schemas[0].Scripts))
|
||||||
|
}
|
||||||
|
|
||||||
|
got := db.Schemas[0].Scripts[0].SQL
|
||||||
|
if !strings.Contains(got, "'Reader''s text'") {
|
||||||
|
t.Fatalf("text asset was not embedded as an escaped SQL literal:\n%s", got)
|
||||||
|
}
|
||||||
|
wantBase64 := "decode('" + base64.StdEncoding.EncodeToString(binary) + "', 'base64')::bytea"
|
||||||
|
if !strings.Contains(got, wantBase64) {
|
||||||
|
t.Fatalf("binary asset was not embedded as a base64 SQL literal:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReader_EmbedDirectiveErrors(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
sql := "-- @embed: path=missing.txt var=:message mode=text\nSELECT :message;"
|
||||||
|
if err := os.WriteFile(filepath.Join(tempDir, "1_001_embed.sql"), []byte(sql), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := NewReader(&readers.ReaderOptions{FilePath: tempDir})
|
||||||
|
_, err := reader.ReadDatabase()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected embed error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "missing.txt") {
|
||||||
|
t.Fatalf("expected missing file in error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/relspecgo/pkg/assetloader"
|
||||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||||
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
|
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
|
||||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||||
@@ -138,7 +139,28 @@ func (w *Writer) executeScripts(ctx context.Context, conn *pgx.Conn, scripts []*
|
|||||||
script.Name, script.Priority, script.Sequence)
|
script.Name, script.Priority, script.Sequence)
|
||||||
|
|
||||||
// Execute the SQL script
|
// Execute the SQL script
|
||||||
_, err := conn.Exec(ctx, script.SQL)
|
sql, err := processEmbedDirectives(script)
|
||||||
|
if err != nil {
|
||||||
|
if ignoreErrors {
|
||||||
|
fmt.Printf("⚠ Error preparing %s: %v (continuing due to --ignore-errors)\n", script.Name, err)
|
||||||
|
failedScripts = append(failedScripts, struct {
|
||||||
|
name string
|
||||||
|
priority int
|
||||||
|
sequence uint
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
name: script.Name,
|
||||||
|
priority: script.Priority,
|
||||||
|
sequence: script.Sequence,
|
||||||
|
err: err,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("script %s (Priority=%d, Sequence=%d): %w",
|
||||||
|
script.Name, script.Priority, script.Sequence, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = conn.Exec(ctx, sql)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ignoreErrors {
|
if ignoreErrors {
|
||||||
fmt.Printf("⚠ Error executing %s: %v (continuing due to --ignore-errors)\n", script.Name, err)
|
fmt.Printf("⚠ Error executing %s: %v (continuing due to --ignore-errors)\n", script.Name, err)
|
||||||
@@ -179,3 +201,11 @@ func (w *Writer) executeScripts(ctx context.Context, conn *pgx.Conn, scripts []*
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func processEmbedDirectives(script *models.Script) (string, error) {
|
||||||
|
sqlPath, _ := script.Metadata[assetloader.ScriptSourcePathMetadataKey].(string)
|
||||||
|
if sqlPath == "" {
|
||||||
|
return script.SQL, nil
|
||||||
|
}
|
||||||
|
return assetloader.ProcessEmbedDirectives(sqlPath, script.SQL)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package sqlexec
|
package sqlexec
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.warky.dev/wdevs/relspecgo/pkg/assetloader"
|
||||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||||
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
||||||
)
|
)
|
||||||
@@ -216,3 +220,36 @@ func TestWriter_WriteSchema_EmptyScripts(t *testing.T) {
|
|||||||
// // Verify results
|
// // Verify results
|
||||||
// // Cleanup
|
// // Cleanup
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
func TestProcessEmbedDirectives_UsesScriptSourcePath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
sqlPath := filepath.Join(dir, "1_001_seed.sql")
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "body.txt"), []byte("writer text"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
script := models.InitScript("seed")
|
||||||
|
script.SQL = "-- @embed: path=body.txt var=:body mode=text\nSELECT :body;"
|
||||||
|
script.Metadata[assetloader.ScriptSourcePathMetadataKey] = sqlPath
|
||||||
|
|
||||||
|
got, err := processEmbedDirectives(script)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processEmbedDirectives failed: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "SELECT 'writer text';") {
|
||||||
|
t.Fatalf("script embed directive was not processed:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessEmbedDirectives_NoSourcePathLeavesSQLUnchanged(t *testing.T) {
|
||||||
|
script := models.InitScript("seed")
|
||||||
|
script.SQL = "-- @embed: path=missing.txt var=:body mode=text\nSELECT :body;"
|
||||||
|
|
||||||
|
got, err := processEmbedDirectives(script)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processEmbedDirectives failed: %v", err)
|
||||||
|
}
|
||||||
|
if got != script.SQL {
|
||||||
|
t.Fatalf("expected unchanged SQL without source path, got:\n%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user