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
76 changes: 54 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Add the dependency:
<dependency>
<groupId>ai.singlr</groupId>
<artifactId>scim-sql</artifactId>
<version>1.0.0</version>
<version>1.2.0</version>
</dependency>
```

Expand All @@ -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` |
Expand All @@ -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.
Expand All @@ -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.

Expand All @@ -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:
Expand All @@ -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
Expand All @@ -139,23 +153,35 @@ 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);
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

Expand All @@ -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

Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
```

Expand Down
18 changes: 10 additions & 8 deletions src/main/java/ai/singlr/scimsql/BoundedScimLexer.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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.
* <p>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.
*
* <p>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;
Expand All @@ -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 -> {}
Expand Down
44 changes: 39 additions & 5 deletions src/main/java/ai/singlr/scimsql/Context.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,28 +16,42 @@
public class Context {
private final Map<String, List<Object>> params;
private final Map<String, Object> indexedParams;
private final Set<String> attributes;
private final Map<String, Integer> 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<String, String> keyMapper) {
reference(attribute);
var rawValue = value instanceof ValueFilter ? ((ValueFilter) value).value() : null;

var key = attribute.toString();

List<Object> values = params.computeIfAbsent(key, k -> new ArrayList<>());
values.add(rawValue);

var indexedKey = key.replace(".", "_") + values.size();
var indexedKey = nextIndexedKey(key);
indexedParams.put(indexedKey, rawValue);

return keyMapper.apply(indexedKey);
}

public String processArray(
Filter attribute, List<Filter> valueFilters, Function<String, String> keyMapper) {
reference(attribute);
var key = attribute.toString();
List<Object> values = params.computeIfAbsent(key, k -> new ArrayList<>());
var builder = new StringBuilder();
Expand All @@ -53,7 +68,7 @@ public String processArray(

values.add(rawValue);

var indexedKey = key.replace(".", "_") + values.size();
var indexedKey = nextIndexedKey(key);
indexedParams.put(indexedKey, rawValue);

var paramKey = keyMapper.apply(indexedKey);
Expand All @@ -70,13 +85,32 @@ public String processArray(
return builder.toString();
}

/**
* A parameter key is the attribute path with its dot flattened to an underscore, plus a counter.
* The counter runs per flattened name, so {@code u.name} and {@code u_name} never share a key.
*/
private String nextIndexedKey(String attributeKey) {
var flattened = attributeKey.replace(".", "_");
return flattened + keyCounts.merge(flattened, 1, Integer::sum);
}

/**
* Checks the filter against an allowlist of attributes.
*
* <p>Every attribute the filter uses must be listed: those in comparisons, in list checks and in
* presence checks ({@code pr}). An attribute with its own alias is listed by its full path
* ({@code u.userName}). For a filter parsed by {@link ScimEngine} the answer does not depend on
* whether the clause has been rendered.
*
* @param validParamKeys the attributes callers may filter on
* @return {@code true} if the filter uses only listed attributes
*/
public boolean isValid(Set<String> validParamKeys) {
for (String key : params.keySet()) {
if (!validParamKeys.contains(key)) {
for (String attribute : attributes) {
if (!validParamKeys.contains(attribute)) {
return false;
}
}

return true;
}

Expand Down
4 changes: 2 additions & 2 deletions src/main/java/ai/singlr/scimsql/ScimEngine.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ public class ScimEngine {
* @param compareFilterBuilder customises how comparisons render, or {@code null} for the default
* @return the parsed filter
* @throws FilterSyntaxException if the expression is not one complete filter, holds a whole
* number that does not fit, or exceeds a limit: 50 levels of parentheses, 500 logical
* operators, or 10 segments in an attribute path
* number that does not fit, names an attribute path deeper than {@code alias.attribute}, or
* exceeds a limit: 50 levels of parentheses or 500 logical operators
* @throws IllegalArgumentException if the expression or the prefix is missing; that is a mistake
* in the calling code, never in a client's filter
*/
Expand Down
8 changes: 6 additions & 2 deletions src/main/java/ai/singlr/scimsql/ScimEvaluator.java
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,15 @@ public Filter visitLogicalExp(ScimParser.LogicalExpContext ctx) {
@Override
public Filter visitPresentExp(ScimParser.PresentExpContext ctx) {
Filter attributePath = visitAttrPath(ctx.attrPath());
context.reference(attributePath);
return new PresentFilter(attributePath);
}

@Override
public Filter visitInExp(ScimParser.InExpContext ctx) {
Filter attributePath = visitAttrPath(ctx.attrPath());
Filter values = visitArrayValue(ctx.arrayValue());
context.reference(attributePath);
return new InFilter(attributePath, (ArrayValueFilter) values, context);
}

Expand All @@ -74,8 +76,10 @@ public Filter visitCompareExp(ScimParser.CompareExpContext ctx) {
String operator = ctx.op.getText();
Filter value = visit(ctx.value());

return compareFilterBuilder.apply(
new ComparisonFilter(attributePath, operator, value, context));
var comparison =
compareFilterBuilder.apply(new ComparisonFilter(attributePath, operator, value, context));
context.reference(comparison.attribute());
return comparison;
}

@Override
Expand Down
Loading
Loading