Skip to content

Commit 259ada6

Browse files
authored
Merge pull request #683 from slingdata-io/v1.5.1
V1.5.1
2 parents e467aa3 + 3bda4d5 commit 259ada6

29 files changed

Lines changed: 1345 additions & 105 deletions

cmd/sling/resource/llm_API_SPEC.md

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ endpoints:
373373
- expression: "upper(record)"
374374
output: "record.user_id_upper"
375375
- expression: "record"
376-
if: "!is_null(record) && record != ''" # Filter
376+
if: '!is_null(record) && record != ""' # Filter
377377
output: "queue.valid_user_ids"
378378

379379
fetch_user_details: # Consumer with API call
@@ -810,11 +810,11 @@ processors:
810810
output: "record.email_normalized"
811811
812812
- expression: "record.id"
813-
if: "record.country == 'US' && record.status == 'active'" # Multiple checks
813+
if: 'record.country == "US" && record.status == "active"' # Multiple checks
814814
output: "queue.us_customer_ids"
815815
816816
- expression: "record.id"
817-
if: "date_parse(record.created_at, 'auto') > date_add(now(), -7, 'day')" # Date filtering
817+
if: 'date_parse(record.created_at, "auto") > date_add(now(), -7, "day")' # Date filtering
818818
output: "queue.recent_ids"
819819
```
820820

@@ -898,6 +898,63 @@ rules:
898898
backoff_base: 2 # Fallback if no rate limit headers
899899
```
900900

901+
**Extending Default Rules/Processors**: Instead of completely overriding defaults, use modifier syntax to prepend or append:
902+
903+
- `+rules` - Prepend rules (evaluated BEFORE defaults)
904+
- `rules+` - Append rules (evaluated AFTER defaults, before hardcoded rules)
905+
- `+processors` / `processors+` - Same pattern for processors
906+
907+
```yaml
908+
# Example: Prepend a rule to break on 403 for a specific endpoint
909+
# without losing the default retry/rate-limit handling
910+
endpoints:
911+
issue_timeline:
912+
response:
913+
+rules:
914+
- action: break
915+
condition: response.status == 403
916+
message: "Access denied for this timeline"
917+
# Default rules from 'defaults.response.rules' are still applied after +rules
918+
```
919+
920+
When combined, the final rule order is:
921+
1. `+rules` (prepend)
922+
2. `rules` (explicit or inherited from defaults)
923+
3. `rules+` (append)
924+
4. Hardcoded retry/fail rules (always last)
925+
926+
**Extending Default Setup/Teardown**: The same modifier syntax works for endpoint `setup` and `teardown` sequences:
927+
928+
- `+setup` - Prepend setup calls (executed BEFORE defaults)
929+
- `setup+` - Append setup calls (executed AFTER defaults)
930+
- `+teardown` / `teardown+` - Same pattern for teardown
931+
932+
```yaml
933+
# Example: Add pre-check before default setup and cleanup after default teardown
934+
defaults:
935+
setup:
936+
- request:
937+
url: "{base_url}/auth/refresh"
938+
939+
endpoints:
940+
my_endpoint:
941+
+setup: # Runs BEFORE default setup
942+
- request:
943+
url: "{base_url}/pre-check"
944+
teardown+: # Runs AFTER default teardown
945+
- request:
946+
url: "{base_url}/cleanup"
947+
request:
948+
url: "{base_url}/data"
949+
```
950+
951+
When combined, the final execution order is:
952+
1. `+setup` (prepend)
953+
2. `setup` (explicit or inherited from defaults)
954+
3. `setup+` (append)
955+
956+
Same pattern applies to teardown.
957+
901958
## Sync State for Incremental Loads
902959

903960
📖 [View documentation](https://docs.slingdata.io/concepts/api-specs/advanced)

cmd/sling/sling_cli.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,9 @@ var cliRunFlags = []g.Flag{
6060
Description: "The pipeline config file to use (JSON or YAML).\n",
6161
},
6262
{
63-
Name: "config",
64-
ShortName: "c",
63+
Name: "directory",
6564
Type: "string",
66-
Description: "The task config string or file to use (JSON or YAML). [deprecated]",
65+
Description: "The directory path file to use to run nested replications/pipelines.\n",
6766
},
6867
{
6968
Name: "src-conn",
@@ -200,7 +199,15 @@ var cliRun = &g.CliSC{
200199
Description: "Execute a run",
201200
AdditionalHelpPrepend: "\nSee more examples and configuration details at https://docs.slingdata.io/sling-cli/",
202201
Flags: cliRunFlags,
203-
ExecProcess: processRun,
202+
PosFlags: []g.Flag{
203+
{
204+
Name: "path",
205+
Type: "string",
206+
Description: "The path file or directory to use to run replications/pipelines.\n",
207+
Required: false,
208+
},
209+
},
210+
ExecProcess: processRun,
204211
}
205212

206213
var cliInteractive = &g.CliSC{

cmd/sling/sling_run.go

Lines changed: 124 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"strings"
1313
"time"
1414

15+
"golang.org/x/term"
16+
1517
"gopkg.in/yaml.v2"
1618

1719
"github.com/samber/lo"
@@ -44,9 +46,9 @@ func processRun(c *g.CliSC) (ok bool, err error) {
4446
Source: sling.Source{Options: &sling.SourceOptions{}},
4547
Target: sling.Target{Options: &sling.TargetOptions{}},
4648
}
47-
replicationCfgPath := ""
48-
pipelineCfgPath := ""
49-
taskCfgStr := ""
49+
50+
var replicationCfgPath, pipelineCfgPath, directoryPath string
51+
5052
showExamples := false
5153
selectStreams := []string{}
5254

@@ -78,9 +80,42 @@ func processRun(c *g.CliSC) (ok bool, err error) {
7880
case "pipeline":
7981
env.SetTelVal("run_mode", "pipeline")
8082
pipelineCfgPath = cast.ToString(v)
81-
case "config":
82-
env.SetTelVal("run_mode", "task")
83-
taskCfgStr = cast.ToString(v)
83+
case "directory":
84+
env.SetTelVal("run_mode", "directory")
85+
directoryPath = cast.ToString(v)
86+
case "path":
87+
filePath := cast.ToString(v)
88+
fileInfo, err := os.Stat(filePath)
89+
if err != nil {
90+
return true, g.Error(err, "error accessing path: %s", filePath)
91+
}
92+
93+
if fileInfo.IsDir() {
94+
env.SetTelVal("run_mode", "directory")
95+
directoryPath = filePath
96+
} else {
97+
dirPath, err := os.Getwd()
98+
if err != nil {
99+
return true, g.Error(err, "unable to determine working dir. Try using flags")
100+
}
101+
runFile := sling.RunFile{File: g.InfoToFileItem(dirPath, filePath, fileInfo)}
102+
valid, err := runFile.IsValid()
103+
if err != nil {
104+
return true, g.Error(err, "could not load file: %s", filePath)
105+
} else if valid {
106+
switch runFile.Type {
107+
case sling.RunFileReplication:
108+
env.SetTelVal("run_mode", "replication")
109+
replicationCfgPath = filePath
110+
case sling.RunFilePipeline:
111+
env.SetTelVal("run_mode", "pipeline")
112+
pipelineCfgPath = filePath
113+
}
114+
} else {
115+
return true, g.Error("file is not a valid replication or pipeline: %s", filePath)
116+
}
117+
}
118+
84119
case "src-conn":
85120
cfg.Source.Conn = cast.ToString(v)
86121
case "src-stream", "src-table", "src-sql", "src-file":
@@ -191,11 +226,7 @@ func processRun(c *g.CliSC) (ok bool, err error) {
191226
return ok, nil
192227
}
193228

194-
if val := os.Getenv("SLING_TASK_CONFIG"); val != "" {
195-
taskCfgStr = val
196-
}
197-
198-
if taskCfgStr != "" {
229+
if taskCfgStr := os.Getenv("SLING_TASK_CONFIG"); taskCfgStr != "" {
199230
err = cfg.Unmarshal(taskCfgStr)
200231
if err != nil {
201232
return ok, g.Error(err, "could not parse task configuration")
@@ -229,7 +260,12 @@ runReplication:
229260
defer printUpdateAvailable()
230261
}
231262

232-
if pipelineCfgPath != "" {
263+
if directoryPath != "" {
264+
err = runDirectory(directoryPath)
265+
if err != nil {
266+
return ok, g.Error(err, "failure running directory (see docs @ https://docs.slingdata.io)")
267+
}
268+
} else if pipelineCfgPath != "" {
233269
err = runPipeline(pipelineCfgPath)
234270
if err != nil {
235271
return ok, g.Error(err, "failure running pipeline (see docs @ https://docs.slingdata.io)")
@@ -654,6 +690,82 @@ func runPipeline(pipelineCfgPath string) (err error) {
654690
return
655691
}
656692

693+
func runDirectory(directoryPath string) (err error) {
694+
if !g.PathExists(directoryPath) {
695+
return g.Error("directory does not exist: %s", directoryPath)
696+
}
697+
698+
files, err := g.ListDirRecursive(directoryPath)
699+
if err != nil {
700+
return g.Error(err, "could not list files from %s", directoryPath)
701+
}
702+
703+
// collect replication or pipeline
704+
runFiles := sling.RunFiles{}
705+
for _, file := range files {
706+
if strings.HasSuffix(file.Name, ".yaml") || strings.HasSuffix(file.Name, ".yml") {
707+
runFile := sling.RunFile{File: file}
708+
valid, err := runFile.IsValid()
709+
if err != nil {
710+
return g.Error(err, "could not load file: %s", file.RelPath)
711+
} else if valid {
712+
runFiles = append(runFiles, runFile)
713+
}
714+
}
715+
}
716+
717+
// get terminal width, fallback to 80 if unable to determine
718+
width := 80
719+
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil {
720+
width = int(float64(w) * 0.75)
721+
}
722+
723+
// change working dir
724+
if err = os.Chdir(directoryPath); err != nil {
725+
return g.Error(err, "could not change working directory to %s", directoryPath)
726+
}
727+
728+
// run them
729+
startTime := time.Now()
730+
eG := g.ErrorGroup{}
731+
successes := 0
732+
for _, runFile := range runFiles.Order() {
733+
ctx = g.NewContext(context.Background()) // reset master context
734+
735+
fileName := g.F("%s (%s)", runFile.File.RelPath, runFile.Type)
736+
remainingWidth := width - len(fileName) - 2 // subtract 2 for spaces around filename
737+
leftSigns := remainingWidth / 2
738+
rightSigns := remainingWidth - leftSigns // accounts for odd numbers
739+
env.Println(env.GreenString(g.F("%s %s %s", strings.Repeat("=", leftSigns), fileName, strings.Repeat("=", rightSigns))))
740+
741+
switch runFile.Type {
742+
case sling.RunFilePipeline:
743+
err = runPipeline(runFile.File.RelPath)
744+
case sling.RunFileReplication:
745+
err = runReplication(runFile.File.RelPath, nil)
746+
}
747+
if eG.Capture(err, runFile.File.RelPath) {
748+
g.LogError(err)
749+
} else {
750+
successes++
751+
}
752+
}
753+
754+
delta := time.Since(startTime)
755+
env.Println(env.GreenString(strings.Repeat("=", width)))
756+
successStr := env.GreenString(g.F("%d Successes", successes))
757+
failureStr := g.F("%d Failures", len(eG.Errors))
758+
if len(eG.Errors) > 0 {
759+
failureStr = env.RedString(failureStr)
760+
} else {
761+
failureStr = env.GreenString(failureStr)
762+
}
763+
764+
g.Info("Sling Run Completed in %s | %s | %s\n", g.DurationString(delta), successStr, failureStr)
765+
766+
return eG.Err()
767+
}
768+
657769
func parsePayload(payload string, validate bool) (options map[string]any, err error) {
658770
payload = strings.TrimSpace(payload)
659771
if payload == "" {

cmd/sling/sling_test.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -670,7 +670,11 @@ func runOneTask(t *testing.T, file g.FileItem, connType dbio.Type) {
670670
orderByStr = strings.Join(taskCfg.Source.PrimaryKey(), ", ")
671671
}
672672
sql := g.F("select * from %s order by %s", taskCfg.Target.Object, orderByStr)
673-
conn, err := taskCfg.TgtConn.AsDatabase()
673+
opt := connection.AsConnOptions{UseCache: false}
674+
if g.In(taskCfg.TgtConn.Type, dbio.TypeDbDuckDb) {
675+
opt.UseCache = true // cannot have dup duckdb connections
676+
}
677+
conn, err := taskCfg.TgtConn.AsDatabase(opt)
674678
if !g.AssertNoError(t, err) {
675679
return
676680
}
@@ -871,14 +875,14 @@ func runOneTask(t *testing.T, file g.FileItem, connType dbio.Type) {
871875
correctType = iop.DatetimeType // clickhouse uses datetime
872876
}
873877
if correctType == iop.BoolType {
874-
correctType = iop.TextType // clickhouse doesn't have bool
878+
correctType = iop.IntegerType // clickhouse bool is integer
875879
}
876880
if correctType == iop.JsonType {
877881
correctType = iop.TextType // clickhouse uses varchar(max) for json
878882
}
879883
case srcType == dbio.TypeDbClickhouse && tgtType == dbio.TypeDbPostgres:
880884
if correctType == iop.BoolType {
881-
correctType = iop.TextType // clickhouse doesn't have bool
885+
correctType = iop.IntegerType // clickhouse bool is integer
882886
}
883887
if correctType == iop.JsonType {
884888
correctType = iop.TextType // clickhouse uses varchar(max) for json

cmd/sling/tests/pipelines/p.07.routine.yaml

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,10 @@ steps:
77
- type: log
88
message: "=== Test 1: Simple logging routine ==="
99

10-
- type: routine
11-
id: simple_test
10+
- id: simple_test
1211
routine: test_logging
1312
params:
14-
test_value: "hello_from_pipeline"
13+
test_value: "hello_from_pipeline from {env.SLING_ROUTINES_DIR}"
1514
on_failure: abort
1615

1716
- type: check
@@ -39,8 +38,7 @@ steps:
3938
(2, 'test2'),
4039
(3, 'test3');
4140
42-
- type: routine
43-
id: validation_test
41+
- id: validation_test
4442
routine: validate_data
4543
params:
4644
connection: "postgres"
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
steps:
2+
- command: ls -l
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
steps:
2+
- log: hello
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
source: swapi
2+
target: postgres
3+
4+
streams:
5+
data_planets:
6+
object: public.{stream_name}

cmd/sling/tests/suite.cli.yaml

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,7 +1122,7 @@
11221122
output_contains:
11231123
- '=== Test 1: Simple logging routine ==='
11241124
- 'Starting routine: test_logging'
1125-
- 'Parameter value: hello_from_pipeline'
1125+
- 'Parameter value: hello_from_pipeline from cmd/sling/tests/pipelines'
11261126
- 'Environment variable: environment_test_value'
11271127
- '=== Test 2: Data validation routine ==='
11281128
- 'Validating data for table: public.test_routine_table'
@@ -1257,4 +1257,14 @@
12571257
- '✓ Test 5 PASSED: String in JSON payload'
12581258
- '✓ Test 6 PASSED: Special characters properly handled in JSON payload'
12591259
- '✓ Test 7 PASSED: Edge cases handled correctly'
1260-
- 'All tests completed successfully!'
1260+
- 'All tests completed successfully!'
1261+
1262+
- id: 144
1263+
name: Test Directory run
1264+
run: |
1265+
sling run cmd/sling/tests/replications/many/nested/p.command.yaml
1266+
sling run --directory cmd/sling/tests/replications/many/nested
1267+
sling run cmd/sling/tests/replications/many/
1268+
output_contains:
1269+
- 'p.log.yaml (pipeline)'
1270+
- 'r.swapi.yaml (replication)'

0 commit comments

Comments
 (0)