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
1 change: 1 addition & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
54 changes: 54 additions & 0 deletions src/main/java/com/darkcollective/relix/cli/EngineMessages.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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;
}
}
4 changes: 2 additions & 2 deletions src/main/java/com/darkcollective/relix/cli/Main.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ final class GlobalOptions {
"Not for secrets: it lands in shell history and ps."})
Map<String, String> 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<String, String> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
}
}
}
Loading