diff --git a/README.md b/README.md
index e698a00..d07d7e2 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ Add the dependency:
ai.singlrscim-sql
- 1.0.0
+ 1.2.0
```
@@ -26,16 +26,22 @@ var engine = new ScimEngine();
var filter = engine.parseFilter("userName eq \"john\"", "p", null);
filter.toClause();
-// → "user_name = :p_userName1"
+// → "p.user_name = :userName1"
filter.context().indexedParams();
-// → {p_userName1=john}
+// → {userName1=john}
```
-The `prefix` parameter namespaces all generated parameter keys, making it safe to combine multiple parsed filters in a single query.
+The `prefix` is the table alias put in front of every attribute that does not name its own. Pass an empty string for no alias: `user_name = :userName1`.
+
+Parameter keys are the attribute name plus a counter (`userName1`, `userName2`). They are unique within one parsed filter and are not prefixed, so two separately parsed filters can produce the same key. To use two filters in one query, combine them into one expression first (see [Scoping a Client Filter](#scoping-a-client-filter)) and parse that.
+
+Call `toClause()` before reading `indexedParams()`. The parameters are collected while the clause is rendered.
## Supported Operators
+The examples use an empty prefix.
+
| SCIM Operator | SQL Output | Example |
|--------------|------------|---------|
| `eq` | `=` | `name eq "John"` → `name = :name1` |
@@ -50,19 +56,21 @@ The `prefix` parameter namespaces all generated parameter keys, making it safe t
| `pr` | `IS NOT NULL` | `name pr` → `name IS NOT NULL` |
| `in` | `IN (…)` | `status in ["active", "pending"]` → `status IN (:status1, :status2)` |
+Values are strings in double quotes, whole numbers, decimals, `true`, `false` and `null`.
+
## Logical Operators
Combine filters with `and`, `or`, `not`, and parentheses:
```java
engine.parseFilter("name eq \"John\" and age gt 21", "p", null).toClause();
-// → "name = :p_name1 AND age > :p_age1"
+// → "p.name = :name1 AND p.age > :age1"
engine.parseFilter("not (active eq true)", "p", null).toClause();
-// → "NOT (active = :p_active1)"
+// → "NOT (p.active = :active1)"
engine.parseFilter("(a eq 1 or b eq 2) and c eq 3", "p", null).toClause();
-// → "(a = :p_a1 OR b = :p_b1) AND c = :p_c1"
+// → "(p.a = :a1 OR p.b = :b1) AND p.c = :c1"
```
`and` binds tighter than `or`, as in SQL. Use parentheses to group.
@@ -87,7 +95,6 @@ A filter is also rejected with `FilterSyntaxException` when it exceeds one of th
|-------|-------|
| Levels of nested parentheses | 50 |
| Logical operators (`and`, `or`) | 500 |
-| Segments in one attribute path | 10 |
A list of values (`in [...]`) is not limited. A whole number that does not fit in a `long` is rejected the same way.
@@ -112,6 +119,8 @@ The result is itself a filter expression, so it fits wherever a single filter is
Do not join filter text by hand. `scope + " and " + clientFilter` renders as `owner AND status OR status`, and a client filter containing `or` then matches rows outside the scope.
+The allowlist sees the scope's attributes too. Either check the client filter against its allowlist before scoping it, or include the scope's attributes when checking the scoped filter. A client that names a scope attribute can only narrow the result within the scope.
+
## Typed Value Prefixes
Values can carry type hints that produce SQL `CAST` expressions. Prefix the value inside the quotes:
@@ -120,16 +129,21 @@ Values can carry type hints that produce SQL `CAST` expressions. Prefix the valu
|--------|------|----------|---------------|
| `#` | UUID | `CAST(… AS UUID)` | `"#550e8400-e29b-41d4-a716-446655440000"` |
| `@` | Timestamp | `CAST(… AS timestamptz)` | `"@2026-01-15T10:30:00Z"` |
-| `$` | JSON | `CAST(… AS jsonb)` | `"${"key":"value"}"` |
+| `$` | JSON | `CAST(… AS jsonb)` | `"${\"key\":\"value\"}"` |
```java
engine.parseFilter("id eq \"#550e8400-e29b-41d4-a716-446655440000\"", "p", null).toClause();
-// → "id = CAST(:p_id1 AS UUID)"
+// → "p.id = CAST(:id1 AS UUID)"
-engine.parseFilter("metadata eq \"${\"role\":\"admin\"}\"", "p", null).toClause();
-// → "metadata @> CAST(:p_metadata1 AS jsonb)"
+engine.parseFilter("createdAt gt \"@2026-01-15T10:30:00Z\"", "p", null).toClause();
+// → "p.created_at > CAST(:createdAt1 AS timestamptz)"
+
+engine.parseFilter("metadata eq \"${\\\"role\\\":\\\"admin\\\"}\"", "p", null).toClause();
+// → "p.metadata @> CAST(:metadata1 AS jsonb)"
```
+Quotes inside a JSON value are escaped with a backslash in the filter text: `metadata eq "${\"role\":\"admin\"}"`.
+
JSON equality uses PostgreSQL's `@>` (contains) operator instead of `=`.
## Attribute Name Conversion
@@ -139,11 +153,18 @@ CamelCase attribute names are automatically converted to snake_case column names
- `userName` → `user_name`
- `createdAtUtc` → `created_at_utc`
-Dotted paths (e.g. `name.familyName`) are preserved as-is in the clause and flattened to underscores in parameter keys.
+An attribute can name its own table alias, which replaces the prefix for that attribute:
+
+```java
+engine.parseFilter("u.userName eq \"john\" and active eq true", "p", null).toClause();
+// → "u.user_name = :u_userName1 AND p.active = :active1"
+```
+
+The dot becomes an underscore in the parameter key. Only `alias.attribute` is supported. A deeper path such as `emails.work.value` is rejected with `FilterSyntaxException`.
## Parameter Binding
-`Context` collects all parameter bindings as the filter tree is evaluated:
+`Context` collects the parameter bindings while the clause is rendered:
```java
var filter = engine.parseFilter("name eq \"John\" and age gt 21", "p", null);
@@ -151,11 +172,16 @@ var clause = filter.toClause();
var params = filter.context().indexedParams();
// Use with JDBC named parameters, JOOQ, or any query builder:
-// clause = "name = :p_name1 AND age > :p_age1"
-// params = {p_name1=John, p_age1=21}
+// clause = "p.name = :name1 AND p.age > :age1"
+// params = {name1=John, age1=21}
```
-Use `context().isValid(Set.of("name", "age"))` to allowlist which attributes callers are permitted to filter on.
+Use `context().isValid(Set.of("name", "age"))` to allowlist which attributes callers are permitted to filter on. Every attribute the filter uses must be listed, whether it appears in a comparison, a list check or a presence check (`pr`). An attribute with its own alias is listed by its full path (`u.userName`). The answer is the same before and after `toClause()`.
+
+```java
+engine.parseFilter("name eq \"John\" and secret pr", "p", null).context().isValid(Set.of("name"));
+// → false
+```
## Custom Filter Builders
@@ -165,11 +191,15 @@ Override SQL generation for specific comparisons by passing a `compareFilterBuil
var filter = engine.parseFilter(
"tags eq \"admin\"",
"p",
- cf -> new ComparisonFilter.ListFilter(cf) // wraps as ListFilter
+ cf -> new ComparisonFilter.ListFilter(cf)
);
```
-This lets you intercept `ComparisonFilter` instances and return a subclass with custom `toClause()` or `paramKey()` behavior.
+This lets you intercept `ComparisonFilter` instances and return a subclass with custom `toClause()` or `paramKey()` behavior. `ComparisonFilter.ListFilter` is a ready-made subclass that renders like the default and marks the comparison as coming from a list query.
+
+## Known Limitations
+
+- **`eq null` and `ne null`.** They render as `= :param` and `!= :param` with a null value, which SQL never matches. Use `pr` and `not (… pr)` to test for a value.
## PostgreSQL Query Analysis
@@ -182,13 +212,15 @@ var analysis = PostgresQueryAnalyzer.analyze(
analysis.statementKind(); // SELECT
analysis.statementCount(); // 1
analysis.relations(); // [RelationReference[schema=null, name=users, alias=u, kind=PHYSICAL]]
-analysis.columns(); // [u.id, u.created_at, u.id]
-analysis.functions(); // [count at 1:17]
+analysis.columns(); // [ColumnReference[qualifier=u, name=id], ColumnReference[qualifier=u, name=created_at], …]
+analysis.functions(); // [FunctionReference[schema=null, name=count, line=1, column=13]]
analysis.parameters(); // [start_at]
analysis.features(); // []
-analysis.normalizedSql(); // deterministic form for hashing/audit
+analysis.normalizedSql(); // "select u . id , count ( * ) from users u where u . created_at >= :start_at group by u . id"
```
+`normalizedSql()` is a deterministic form for hashing and audit, not SQL meant to be read.
+
Key properties:
- **Named parameters** (`:start_at`, `:user_id`) are first-class expression values via a deliberate, documented grammar extension. Names are reported exactly; values are never bound or substituted.
diff --git a/RELEASE.md b/RELEASE.md
index 06aca29..b1883ac 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -99,10 +99,12 @@ git checkout -b release/1.0.0
### 3. Set the release version
+Set the same version in the dependency snippet in `README.md` first, so the README never names an older release.
+
```bash
mvn versions:set -DnewVersion=1.0.0
mvn versions:commit
-git add pom.xml
+git add pom.xml README.md
git commit -m "Release 1.0.0"
```
diff --git a/src/main/java/ai/singlr/scimsql/BoundedScimLexer.java b/src/main/java/ai/singlr/scimsql/BoundedScimLexer.java
index 8389bee..1b24709 100644
--- a/src/main/java/ai/singlr/scimsql/BoundedScimLexer.java
+++ b/src/main/java/ai/singlr/scimsql/BoundedScimLexer.java
@@ -9,19 +9,21 @@
import org.antlr.v4.runtime.Token;
/**
- * A lexer that rejects a filter before it grows past what the parser and the filter tree can
- * handle.
+ * A lexer that rejects a filter the parser and the evaluator cannot handle faithfully.
*
- *
Parsing and evaluating a filter recurses once per level of parentheses, once per logical
- * operator and once per attribute path segment. Without a bound, a filter built for the purpose
- * exhausts the thread's stack. The limits are far above any filter written for a real query. A list
- * of values ({@code in [...]}) does not recurse and is not limited.
+ *
Parsing and evaluating a filter recurses once per level of parentheses and once per logical
+ * operator. Without a bound, a filter built for the purpose exhausts the thread's stack. The limits
+ * are far above any filter written for a real query. A list of values ({@code in [...]}) does not
+ * recurse and is not limited.
+ *
+ *
An attribute path is {@code alias.attribute} at most. That is all the evaluator uses, and a
+ * deeper path would otherwise be cut short without notice.
*/
final class BoundedScimLexer extends ScimLexer {
static final int MAX_NESTING_DEPTH = 50;
static final int MAX_LOGICAL_OPERATORS = 500;
- static final int MAX_PATH_SEGMENTS = 10;
+ static final int MAX_PATH_SEGMENTS = 2;
private int nestingDepth;
private int logicalOperators;
@@ -48,7 +50,7 @@ public Token nextToken() {
}
case DOT -> {
if (++pathSegments > MAX_PATH_SEGMENTS) {
- reject(token, "attribute path longer than " + MAX_PATH_SEGMENTS + " segments");
+ reject(token, "attribute path deeper than alias.attribute");
}
}
case ATTRNAME -> {}
diff --git a/src/main/java/ai/singlr/scimsql/Context.java b/src/main/java/ai/singlr/scimsql/Context.java
index efeceed..2d117d8 100644
--- a/src/main/java/ai/singlr/scimsql/Context.java
+++ b/src/main/java/ai/singlr/scimsql/Context.java
@@ -7,6 +7,7 @@
import java.util.ArrayList;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -15,13 +16,26 @@
public class Context {
private final Map> params;
private final Map indexedParams;
+ private final Set attributes;
+ private final Map keyCounts;
public Context() {
this.params = new HashMap<>();
this.indexedParams = new HashMap<>();
+ this.attributes = new HashSet<>();
+ this.keyCounts = new HashMap<>();
+ }
+
+ /**
+ * Records that the filter uses an attribute, so the allowlist sees it before the clause is
+ * rendered and whether or not it binds a parameter.
+ */
+ void reference(Filter attribute) {
+ attributes.add(attribute.toString());
}
public String process(Filter attribute, Filter value, Function keyMapper) {
+ reference(attribute);
var rawValue = value instanceof ValueFilter ? ((ValueFilter) value).value() : null;
var key = attribute.toString();
@@ -29,7 +43,7 @@ public String process(Filter attribute, Filter value, Function k
List