Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,18 @@ RETURN ELEMENTS(page, ".result-item")
Pass dynamic values to your scripts:

```bash
ferret run -p 'url:"https://example.com"' -p 'limit:10' my-script.fql
ferret run --param url=https://example.com --param limit=10 my-script.fql
```

Parameter values are parsed as JSON when possible; otherwise they are passed as strings. Use JSON string syntax when a value looks like another JSON type but should stay a string:

```bash
ferret run hello.fql --param name=Steve
ferret run hello.fql --param age=42
ferret run hello.fql --param active=true
ferret run hello.fql --param tags='["admin","editor"]'
ferret run hello.fql --param user='{"name":"Ada"}'
ferret run hello.fql --param code='"123"'
```

Use parameters in your FQL script:
Expand Down Expand Up @@ -226,7 +237,7 @@ ferret exec [script] # alias
| `--browser-open` | `-B` | Open a visible browser for execution | `false` |
| `--browser-headless` | `-b` | Open a headless browser for execution | `false` |
| `--browser-cookies` | `-c` | Keep cookies between queries | `false` |
| `--param` | `-p` | Query parameter (`key:value`, repeatable) | |
| `--param` | `-p` | Runtime parameter (`name=value`, repeatable; values parse as JSON when possible, otherwise strings) | |
| `--eval` | `-e` | Inline FQL expression (cannot be used with file args) | |

Compiled artifacts are auto-detected by content for file inputs and piped stdin, so artifacts produced by `ferret build` work even when they do not use a `.fqlc` filename. Artifact execution currently requires the builtin runtime.
Expand Down
2 changes: 1 addition & 1 deletion cmd/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func addRuntimeFlags(cmd *cobra.Command) {
}

func addParamFlags(cmd *cobra.Command) {
cmd.Flags().StringArrayP(paramFlag, "p", []string{}, "Query bind parameter (--param=foo:\"bar\", --param=id:1)")
cmd.Flags().StringArrayP(paramFlag, "p", []string{}, "Runtime parameter as name=value. Values parse as JSON when possible, otherwise strings. Examples: --param name=Steve, --param age=42, --param active=true, --param tags='[\"admin\",\"editor\"]', --param user='{\"name\":\"Ada\"}', --param code='\"123\"'")
}

func addEvalFlag(cmd *cobra.Command) {
Expand Down
39 changes: 25 additions & 14 deletions cmd/params.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import (
"encoding/json"
"fmt"
"strings"

"github.com/MontFerret/ferret/v2/pkg/runtime"
)

const paramFlag = "param"
Expand All @@ -14,23 +12,36 @@ func parseParams(flags []string) (map[string]interface{}, error) {
res := make(map[string]interface{})

for _, entry := range flags {
pair := strings.SplitN(entry, ":", 2)

if len(pair) < 2 {
return nil, runtime.Error(runtime.ErrInvalidArgument, entry)
}

var value interface{}
key := pair[0]

err := json.Unmarshal([]byte(pair[1]), &value)

key, value, err := parseParam(entry)
if err != nil {
return nil, fmt.Errorf("invalid value for parameter %q: %w", key, err)
return nil, err
}

res[key] = value
}

return res, nil
}

func parseParam(input string) (string, any, error) {
name, raw, ok := strings.Cut(input, "=")
if !ok {
name, raw, ok = strings.Cut(input, ":")
}

if !ok {
return "", nil, fmt.Errorf("invalid param %q: expected name=value", input)
}

Comment on lines +27 to +35
name = strings.TrimSpace(name)
if name == "" {
return "", nil, fmt.Errorf("invalid param %q: parameter name cannot be empty", input)
}

var value any
if err := json.Unmarshal([]byte(raw), &value); err == nil {
return name, value, nil
}

return name, raw, nil
}
110 changes: 59 additions & 51 deletions cmd/params_test.go
Original file line number Diff line number Diff line change
@@ -1,53 +1,50 @@
package cmd

import (
"reflect"
"strings"
"testing"
)

func TestParseParams_ValidJSON(t *testing.T) {
flags := []string{
`name:"John"`,
`age:30`,
`active:true`,
`tags:["a","b"]`,
func TestParseParams(t *testing.T) {
tests := []struct {
name string
input string
want any
}{
{name: "raw string", input: "name=Steve", want: "Steve"},
{name: "raw string URL", input: "url=https://example.com", want: "https://example.com"},
{name: "raw string with colon", input: "time=10:30", want: "10:30"},
{name: "JSON number", input: "age=42", want: float64(42)},
{name: "JSON bool", input: "active=true", want: true},
{name: "JSON null", input: "missing=null", want: nil},
{name: "explicit JSON string", input: `name="Steve"`, want: "Steve"},
{name: "explicit numeric string", input: `code="123"`, want: "123"},
{name: "explicit boolean string", input: `enabled="false"`, want: "false"},
{name: "JSON array", input: `tags=["admin","editor"]`, want: []any{"admin", "editor"}},
{name: "JSON object", input: `user={"name":"Ada"}`, want: map[string]any{"name": "Ada"}},
{name: "backward compatible JSON string", input: `name:"Steve"`, want: "Steve"},
{name: "backward compatible raw string", input: "name:Steve", want: "Steve"},
{name: "invalid JSON falls back to string", input: "key:not-valid-json", want: "not-valid-json"},
}

params, err := parseParams(flags)

if err != nil {
t.Fatalf("unexpected error: %v", err)
}

if params["name"] != "John" {
t.Errorf("expected name=John, got %v", params["name"])
}

if params["age"] != float64(30) {
t.Errorf("expected age=30, got %v", params["age"])
}

if params["active"] != true {
t.Errorf("expected active=true, got %v", params["active"])
}
}

func TestParseParams_MissingSeparator(t *testing.T) {
flags := []string{"invalid"}

_, err := parseParams(flags)

if err == nil {
t.Fatal("expected error for missing separator")
}
}

func TestParseParams_InvalidJSON(t *testing.T) {
flags := []string{"key:not-valid-json"}

_, err := parseParams(flags)

if err == nil {
t.Fatal("expected error for invalid JSON value")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params, err := parseParams([]string{tt.input})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

key, _, _ := strings.Cut(tt.input, "=")
if key == tt.input {
key, _, _ = strings.Cut(tt.input, ":")
}
key = strings.TrimSpace(key)

if got := params[key]; !reflect.DeepEqual(got, tt.want) {
t.Fatalf("expected %v (%T), got %v (%T)", tt.want, tt.want, got, got)
}
})
}
}

Expand All @@ -63,16 +60,27 @@ func TestParseParams_Empty(t *testing.T) {
}
}

func TestParseParams_ColonInValue(t *testing.T) {
flags := []string{`url:"http://example.com"`}

params, err := parseParams(flags)

if err != nil {
t.Fatalf("unexpected error: %v", err)
func TestParseParams_InvalidInput(t *testing.T) {
tests := []struct {
name string
input string
wantError string
}{
{name: "missing separator", input: "name", wantError: `invalid param "name": expected name=value`},
{name: "empty equals name", input: "=Steve", wantError: `invalid param "=Steve": parameter name cannot be empty`},
{name: "empty colon name", input: ":Steve", wantError: `invalid param ":Steve": parameter name cannot be empty`},
}

if params["url"] != "http://example.com" {
t.Errorf("expected url=http://example.com, got %v", params["url"])
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := parseParams([]string{tt.input})
if err == nil {
t.Fatal("expected error")
}

if err.Error() != tt.wantError {
t.Fatalf("expected error %q, got %q", tt.wantError, err.Error())
}
})
}
}
Loading