From 48d4921cb9b809c0a149f828cbec1e9bf8a3a185 Mon Sep 17 00:00:00 2001 From: David Tooke Date: Thu, 8 Oct 2026 17:44:41 -0400 Subject: [PATCH] Add -a/--arg NAME=VALUE: bind query parameters outside the script text A value given with -a is bound to $NAME through the engine's Relix.Builder.parameters, never pasted into the script, so a value holding a quote cannot change the query. The value is text, which the engine reads as the type the parameter was analysed to (relix-core#101): -a id=42 binds a number when $id is compared with a NUMBER, and text that does not read as that type is an analysis error. An unbound parameter is an analysis error (exit 3) that names it. The engine's message names its Java remedy, so EngineMessages puts it in the command's terms: "give it a value with -a NAME=VALUE". fmt, bundle and optimize print $NAME, never the value. Part of #14. --- docs/design.md | 1 + .../relix/cli/EngineMessages.java | 54 +++++++ .../com/darkcollective/relix/cli/Main.java | 4 +- .../relix/cli/command/GlobalOptions.java | 6 + .../relix/cli/command/Invocation.java | 2 + .../relix/cli/command/ScriptRunner.java | 3 +- .../cli/command/QueryParametersTest.java | 142 ++++++++++++++++++ 7 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/darkcollective/relix/cli/EngineMessages.java create mode 100644 src/test/java/com/darkcollective/relix/cli/command/QueryParametersTest.java diff --git a/docs/design.md b/docs/design.md index 7fb8d72..4ea099e 100644 --- a/docs/design.md +++ b/docs/design.md @@ -194,6 +194,7 @@ adds timings. | `-C DIR` | act as if started in DIR (catalog discovery, relative paths) — `git -C`, `make -C`, `tar -C` | — | | `-P NAME`, `--profile=NAME` | the named profile `${VAR}`s resolve from (`RELIX_PROFILE`) | `-e/--env` (renamed: `-e` is the expression, by convention) | | `-D NAME=VALUE` | set one `${VAR}` for this run (`awk -v`, `cc -D`); beats the profile. Not for secrets: it lands in shell history and `ps` | — | +| `-a NAME=VALUE`, `--arg` | bind the query parameter `$NAME` (`awk -v`, `psql -v`); repeatable. Bound, never pasted into the text, so it cannot change the query (R3) | — | | `-N`, `--no-catalog` | ignore `.relix/` discovery | — | | `--catalog=FILE` | add a catalog file (repeatable; `RELIX_CATALOG_PATH`, `:`-separated) | — | | `--now=INSTANT` | pin `NOW()` (`RELIX_NOW`) | same | diff --git a/src/main/java/com/darkcollective/relix/cli/EngineMessages.java b/src/main/java/com/darkcollective/relix/cli/EngineMessages.java new file mode 100644 index 0000000..2c68961 --- /dev/null +++ b/src/main/java/com/darkcollective/relix/cli/EngineMessages.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Darkcollective, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.darkcollective.relix.cli; + +import com.darkcollective.relix.embed.RelixException; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The message of a failure the engine raised, as the command prints it. + * + *

The engine writes for a program that embeds it, so the remedy it names may be a + * Java call. Where the command has its own way to give what is missing, the message names + * that instead; every other message is the engine's own. + */ +public final class EngineMessages { + + /** The engine's unbound-parameter message, whose remedy is {@code Relix.Builder.parameters}. */ + private static final Pattern UNBOUND = + Pattern.compile("parameter \\$(\\S+) is not bound; give a value with .*"); + + private EngineMessages() { + } + + /** + * The message to print for a failure the engine raised. + * + * @param failure what the engine threw + * @return its message, its remedy put in the command's terms + */ + public static String of(RelixException failure) { + String message = failure.getMessage(); + Matcher unbound = UNBOUND.matcher(message); + if (unbound.matches()) { + String name = unbound.group(1); + return "parameter $" + name + " is not bound; give it a value with -a " + name + "=VALUE"; + } + return message; + } +} diff --git a/src/main/java/com/darkcollective/relix/cli/Main.java b/src/main/java/com/darkcollective/relix/cli/Main.java index e4d6a2e..e77a137 100644 --- a/src/main/java/com/darkcollective/relix/cli/Main.java +++ b/src/main/java/com/darkcollective/relix/cli/Main.java @@ -91,8 +91,8 @@ public static int run(Host host, Interruption interruption, String... args) { err.print("relix: " + failure.getMessage() + "\n"); } else if (code == ExitCode.USAGE) { failed.usage(err); - } else if (e instanceof RelixException) { - err.print("relix: " + e.getMessage() + "\n"); + } else if (e instanceof RelixException failure) { + err.print("relix: " + EngineMessages.of(failure) + "\n"); } else if (code == ExitCode.INTERNAL) { err.print("relix: internal error; please report it with what follows\n"); e.printStackTrace(err); diff --git a/src/main/java/com/darkcollective/relix/cli/command/GlobalOptions.java b/src/main/java/com/darkcollective/relix/cli/command/GlobalOptions.java index 0c22ae3..1974246 100644 --- a/src/main/java/com/darkcollective/relix/cli/command/GlobalOptions.java +++ b/src/main/java/com/darkcollective/relix/cli/command/GlobalOptions.java @@ -47,6 +47,12 @@ final class GlobalOptions { "Not for secrets: it lands in shell history and ps."}) Map defines = new LinkedHashMap<>(); + @Option(names = {"-a", "--arg"}, paramLabel = "NAME=VALUE", scope = ScopeType.INHERIT, + description = { + "Bind the query parameter $$NAME to VALUE. Repeatable.", + "The value is bound, never pasted into the script, so it cannot change the query."}) + Map arguments = new LinkedHashMap<>(); + @Option(names = {"-N", "--no-catalog"}, scope = ScopeType.INHERIT, description = "Ignore the .relix/ directories' catalogs and relixrc; --catalog files still load.") boolean noCatalog; diff --git a/src/main/java/com/darkcollective/relix/cli/command/Invocation.java b/src/main/java/com/darkcollective/relix/cli/command/Invocation.java index f71db48..d3e80ac 100644 --- a/src/main/java/com/darkcollective/relix/cli/command/Invocation.java +++ b/src/main/java/com/darkcollective/relix/cli/command/Invocation.java @@ -210,6 +210,8 @@ Relix.Builder session(Path base) { // script still runs: a command line over someone else's database must. .allowUnresolved() .placeholders(placeholders) + // Text, which the engine reads as the type each parameter was analysed to. + .parameters(options.arguments) .remoteFiles(options.remote); if (clock != null) { builder.clock(clock); diff --git a/src/main/java/com/darkcollective/relix/cli/command/ScriptRunner.java b/src/main/java/com/darkcollective/relix/cli/command/ScriptRunner.java index 59c28f3..bb68ae8 100644 --- a/src/main/java/com/darkcollective/relix/cli/command/ScriptRunner.java +++ b/src/main/java/com/darkcollective/relix/cli/command/ScriptRunner.java @@ -16,6 +16,7 @@ package com.darkcollective.relix.cli.command; import com.darkcollective.relix.cli.CommandFailure; +import com.darkcollective.relix.cli.EngineMessages; import com.darkcollective.relix.cli.ExitCode; import com.darkcollective.relix.cli.catalog.Declaration; import com.darkcollective.relix.cli.io.InputBinding; @@ -213,7 +214,7 @@ private ExitCode run(ScriptSource source, View view) { return ExitCode.USAGE; } if (e instanceof RelixException failure) { - reporter.error(source.name() + ": " + failure.getMessage()); + reporter.error(source.name() + ": " + EngineMessages.of(failure)); return ExitCode.of(failure); } throw e; diff --git a/src/test/java/com/darkcollective/relix/cli/command/QueryParametersTest.java b/src/test/java/com/darkcollective/relix/cli/command/QueryParametersTest.java new file mode 100644 index 0000000..629c7b2 --- /dev/null +++ b/src/test/java/com/darkcollective/relix/cli/command/QueryParametersTest.java @@ -0,0 +1,142 @@ +/* + * Copyright 2026 Darkcollective, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.darkcollective.relix.cli.command; + +import com.darkcollective.relix.cli.Cli; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Query parameters bound with {@code -a NAME=VALUE} (design §9, R3), end to end through + * {@code Main}. + */ +@DisplayName("Query parameters bound with -a") +class QueryParametersTest { + + private static final String CUSTOMERS = """ + Customers := [ + | name | city | + |-------|--------| + | Ada | London | + | Grace | Paris | + ]; + """; + + private static final String ORDERS = """ + Orders := [ + | id | who | + |----|-------| + | 7 | Ada | + | 42 | Grace | + ]; + """; + + private static final String BY_CITY = CUSTOMERS + "query { π name (σ city = $city (Customers)) };\n"; + + @TempDir + Path dir; + + @BeforeEach + void script() throws IOException { + Files.writeString(dir.resolve("by-city.relix"), BY_CITY); + } + + @Test + @DisplayName("-a binds the parameter the script names") + void binds() { + var result = Cli.in(dir).run("-a", "city=Paris", "by-city.relix"); + + assertThat(result.status()).as(result.toString()).isZero(); + assertThat(result.out()).contains("Grace").doesNotContain("Ada").contains("(1 row)"); + } + + @Test + @DisplayName("--arg is the long form, and it may follow the script") + void longFormAfterScript() { + var result = Cli.in(dir).run("by-city.relix", "--arg", "city=London"); + + assertThat(result.status()).as(result.toString()).isZero(); + assertThat(result.out()).contains("Ada").doesNotContain("Grace"); + } + + @Test + @DisplayName("each -a binds one parameter") + void several() { + var result = Cli.in(dir).run("-a", "city=London", "-a", "who=Ada", + "-e", CUSTOMERS, "-e", "σ city = $city ∧ name = $who (Customers)"); + + assertThat(result.status()).as(result.toString()).isZero(); + assertThat(result.out()).contains("Ada").contains("(1 row)"); + } + + @Test + @DisplayName("a value takes the type of what its parameter is compared with") + void typedFromText() { + var result = Cli.in(dir).run("-a", "id=42", "-e", ORDERS, "-e", "σ id = $id (Orders)"); + + assertThat(result.status()).as(result.toString()).isZero(); + assertThat(result.out()).contains("Grace").contains("(1 row)"); + } + + @Test + @DisplayName("a value that is not of its parameter's type is an error that names both") + void wrongType() { + var result = Cli.in(dir).run("-a", "id=abc", "-e", ORDERS, "-e", "σ id = $id (Orders)"); + + assertThat(result.status()).as(result.toString()).isEqualTo(3); + assertThat(result.err()).contains("$id").contains("NUMBER").contains("'abc'"); + assertThat(result.out()).isEmpty(); + } + + @Test + @DisplayName("a value holding a quote is a value: it cannot change the query") + void quoteCannotInject() { + var result = Cli.in(dir).run("-a", "city=Paris' ∨ '1' = '1", "by-city.relix"); + + assertThat(result.status()).as(result.toString()).isZero(); + assertThat(result.out()).doesNotContain("Ada").doesNotContain("Grace").contains("(0 rows)"); + } + + @Test + @DisplayName("an unbound parameter is an analysis error that names it") + void unbound() { + var result = Cli.in(dir).run("by-city.relix"); + + assertThat(result.status()).as(result.toString()).isEqualTo(3); + assertThat(result.err()).isEqualTo( + "relix: by-city.relix: parameter $city is not bound; give it a value with -a city=VALUE\n"); + assertThat(result.out()).isEmpty(); + } + + @Test + @DisplayName("fmt, bundle and optimize print $name, never the value") + void reportsPrintTheName() { + for (String command : new String[] {"fmt", "bundle", "optimize"}) { + var result = Cli.in(dir).run(command, "-a", "city=Lisbon", "by-city.relix"); + + assertThat(result.status()).as(command + ": " + result).isZero(); + assertThat(result.out()).as(command).contains("$city").doesNotContain("Lisbon"); + } + } +}