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"); + } + } +}