feat(scripts): support external file embedding
This commit is contained in:
@@ -45,6 +45,21 @@ migrations/
|
||||
- `1_001_test.txt` - Wrong extension
|
||||
- `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
|
||||
|
||||
### Basic Usage
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/assetloader"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/readers"
|
||||
)
|
||||
@@ -151,6 +152,10 @@ func (r *Reader) readScripts() ([]*models.Script, error) {
|
||||
if err != nil {
|
||||
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
|
||||
relPath, err := filepath.Rel(r.options.FilePath, path)
|
||||
@@ -161,9 +166,10 @@ func (r *Reader) readScripts() ([]*models.Script, error) {
|
||||
// Create Script model
|
||||
script := models.InitScript(name)
|
||||
script.Description = fmt.Sprintf("SQL script from %s", relPath)
|
||||
script.SQL = string(content)
|
||||
script.SQL = sql
|
||||
script.Priority = priority
|
||||
script.Sequence = uint(sequence)
|
||||
script.Metadata[assetloader.ScriptSourcePathMetadataKey] = path
|
||||
|
||||
scripts = append(scripts, script)
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package sqldir
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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
|
||||
testFiles := map[string]string{
|
||||
"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);",
|
||||
"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_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);",
|
||||
"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');",
|
||||
"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 {
|
||||
@@ -267,10 +269,10 @@ func TestReader_HyphenFormat(t *testing.T) {
|
||||
|
||||
// Create test files with hyphen separators
|
||||
testFiles := map[string]string{
|
||||
"1-001-create-table.sql": "CREATE TABLE test (id INT);",
|
||||
"1-002-insert-data.pgsql": "INSERT INTO test VALUES (1);",
|
||||
"1-001-create-table.sql": "CREATE TABLE test (id INT);",
|
||||
"1-002-insert-data.pgsql": "INSERT INTO test VALUES (1);",
|
||||
"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 {
|
||||
@@ -301,10 +303,10 @@ func TestReader_HyphenFormat(t *testing.T) {
|
||||
priority int
|
||||
sequence uint
|
||||
}{
|
||||
"create-table": {1, 1},
|
||||
"insert-data": {1, 2},
|
||||
"add-index": {2, 5},
|
||||
"create-newid": {10, 10},
|
||||
"create-table": {1, 1},
|
||||
"insert-data": {1, 2},
|
||||
"add-index": {2, 5},
|
||||
"create-newid": {10, 10},
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user