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
126 changes: 68 additions & 58 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ List<BulkUpdate> updates = List.of(
new BulkUpdate(Map.of("status", "refunded", "refunded_at", "2026-05-05"), "id==103")
);

int totalUpdated = updateService.patchBulk("main", "public", "orders", updates);
int totalUpdated = updateService.patchBulk("main", "public", "orders", updates);
// If any single update fails, all changes are rolled back
```

Expand All @@ -233,10 +233,10 @@ int rowsDeleted = deleteService.delete(
```java
// Multiple filters — each scopes a separate DELETE, all in one transaction
List<String> filters = List.of(
"status==expired;created_at=lt=2025-01-01",
"status==cancelled;updated_at=lt=2025-06-01",
"id==999"
);
"status==expired;created_at=lt=2025-01-01",
"status==cancelled;updated_at=lt=2025-06-01",
"id==999"
);

int totalDeleted = deleteService.deleteBulk("main", "public", "sessions", filters);
// If any single delete fails, all changes are rolled back
Expand All @@ -254,7 +254,7 @@ db:
soft-delete:
enabled: true
column: deleted_at # optional, defaults to "deleted_at"
tables: # optional, if empty applies to ALL tables
tables: # optional, if empty applies to ALL tables
- users
- orders
```
Expand All @@ -277,16 +277,17 @@ List<Map<String, Object>> allUsers = readService.findAll(context);

#### Behavior summary

| Operation | Soft-delete enabled | Soft-delete disabled |
|-----------|--------------------|--------------------|
| `readService.findAll(ctx)` | Appends `AND deleted_at IS NULL` | No change |
| `readService.findAll(ctx)` with `includeSoftDeleted(true)` | No filter appended | No change |
| `deleteService.delete(...)` | `UPDATE table SET deleted_at = NOW() WHERE ...` | `DELETE FROM table WHERE ...` |
| `deleteService.deleteBulk(...)` | Same rewrite per filter | Same as above |
| Operation | Soft-delete enabled | Soft-delete disabled |
|------------------------------------------------------------|-------------------------------------------------|-------------------------------|
| `readService.findAll(ctx)` | Appends `AND deleted_at IS NULL` | No change |
| `readService.findAll(ctx)` with `includeSoftDeleted(true)` | No filter appended | No change |
| `deleteService.delete(...)` | `UPDATE table SET deleted_at = NOW() WHERE ...` | `DELETE FROM table WHERE ...` |
| `deleteService.deleteBulk(...)` | Same rewrite per filter | Same as above |

### Upsert (INSERT ... ON CONFLICT)

```java

@Autowired
private CreationService creationService;

Expand Down Expand Up @@ -329,6 +330,7 @@ For cases where the query builder doesn't cover your needs, use `RawQueryService
Always use named parameters (`:paramName`) — never concatenate user input into SQL.

```java

@Autowired
private RawQueryService rawQueryService;

Expand Down Expand Up @@ -414,30 +416,37 @@ String filter = FilterBuilder.and()
.eq("type", "order")
.raw("total=gt=100;total=lt=500")
.build();

// PostgreSQL array contains (TEXT[] columns)
String arrayFilter = FilterBuilder.and()
.eqIfPresent("status", filter.status())
.arrayContainsIfPresent("question_types", filter.questionType())
.build();
```

### Filter → SQL mapping

Shows what SQL each RSQL filter generates (assuming table `users` with alias `t0`):

| FilterBuilder code | RSQL output | Generated SQL WHERE |
|-------------------------------------------------|----------------------------------------------|---------------------------------------------|
| `.eq("status", "active")` | `status=='active'` | `t0."status" = :status` |
| `.neq("role", "guest")` | `role!='guest'` | `t0."role" <> :role` |
| `.gt("age", "18")` | `age=gt='18'` | `t0."age" > :age` |
| `.gte("price", "100")` | `price=ge='100'` | `t0."price" >= :price` |
| `.lt("stock", "5")` | `stock=lt='5'` | `t0."stock" < :stock` |
| `.lte("rating", "3")` | `rating=le='3'` | `t0."rating" <= :rating` |
| `.in("status", "active", "pending")` | `status=in=(active,pending)` | `t0."status" IN (:status)` |
| `.notIn("type", "draft", "archived")` | `type=out=(draft,archived)` | `t0."type" NOT IN (:type)` |
| `.like("name", "john")` | `name=like='john'` | `t0."name" LIKE :name` (value: `%john%`) |
| `.ilike("email", "JOHN")` | `email=ilike='JOHN'` | `t0."email" ILIKE :email` (value: `%JOHN%`) |
| `.startWith("name", "Jo")` | `name=startWith='Jo'` | `t0."name" LIKE :name` (value: `Jo%`) |
| `.endWith("email", ".com")` | `email=endWith='.com'` | `t0."email" LIKE :email` (value: `%.com`) |
| `.isNull("deleted_at")` | `deleted_at=isnull='true'` | `t0."deleted_at" IS NULL` |
| `.isNotNull("verified_at")` | `verified_at=nn='true'` | `t0."verified_at" IS NOT NULL` |
| `.jsonbContains("metadata", "tier", "premium")` | `metadata=jsonbContain='{"tier":"premium"}'` | `t0."metadata" @> :metadata::jsonb` |
| `.jsonbKeyExists("settings", "theme")` | `settings=jbKeyExist='theme'` | `t0."settings" ? :settings` |
| FilterBuilder code | RSQL output | Generated SQL WHERE |
|-------------------------------------------------|----------------------------------------------|----------------------------------------------|
| `.eq("status", "active")` | `status=='active'` | `t0."status" = :status` |
| `.neq("role", "guest")` | `role!='guest'` | `t0."role" <> :role` |
| `.gt("age", "18")` | `age=gt='18'` | `t0."age" > :age` |
| `.gte("price", "100")` | `price=ge='100'` | `t0."price" >= :price` |
| `.lt("stock", "5")` | `stock=lt='5'` | `t0."stock" < :stock` |
| `.lte("rating", "3")` | `rating=le='3'` | `t0."rating" <= :rating` |
| `.in("status", "active", "pending")` | `status=in=(active,pending)` | `t0."status" IN (:status)` |
| `.notIn("type", "draft", "archived")` | `type=out=(draft,archived)` | `t0."type" NOT IN (:type)` |
| `.like("name", "john")` | `name=like='john'` | `t0."name" LIKE :name` (value: `%john%`) |
| `.ilike("email", "JOHN")` | `email=ilike='JOHN'` | `t0."email" ILIKE :email` (value: `%JOHN%`) |
| `.startWith("name", "Jo")` | `name=startWith='Jo'` | `t0."name" LIKE :name` (value: `Jo%`) |
| `.endWith("email", ".com")` | `email=endWith='.com'` | `t0."email" LIKE :email` (value: `%.com`) |
| `.isNull("deleted_at")` | `deleted_at=isnull='true'` | `t0."deleted_at" IS NULL` |
| `.isNotNull("verified_at")` | `verified_at=nn='true'` | `t0."verified_at" IS NOT NULL` |
| `.jsonbContains("metadata", "tier", "premium")` | `metadata=jsonbContain='{"tier":"premium"}'` | `t0."metadata" @> :metadata::jsonb` |
| `.jsonbKeyExists("settings", "theme")` | `settings=jbKeyExist='theme'` | `t0."settings" ? :settings` |
| `.arrayContains("question_types", "CLOZE")` | `question_types=arrayContains='CLOZE'` | `:question_types = ANY(t0."question_types")` |

**Compound filters:**

Expand Down Expand Up @@ -573,41 +582,42 @@ import dev.suprim.query.jdbc.config.DatabaseContextHolder;
DatabaseContextHolder.setCurrentDbId("tenant_abc");

try{
// All queries now route to tenant_abc's datasource
List<Map<String, Object>> data = readService.findAll(
ReadContext.builder()
.dbId("tenant_abc")
.tableName("invoices")
.fields("*")
.build()
);
// All queries now route to tenant_abc's datasource
List<Map<String, Object>> data = readService.findAll(
ReadContext.builder()
.dbId("tenant_abc")
.tableName("invoices")
.fields("*")
.build()
);
} finally {
DatabaseContextHolder.clear();
}
```

## RSQL Operators

| Operator | Description | Example |
|----------------|------------------------|--------------------------------|
| `==` | Equal | `status==active` |
| `!=` | Not equal | `role!=guest` |
| `=gt=` | Greater than | `age=gt=18` |
| `=ge=` | Greater than or equal | `price=ge=100` |
| `=lt=` | Less than | `stock=lt=5` |
| `=le=` | Less than or equal | `rating=le=3` |
| `=in=` | In list | `status=in=(active,pending)` |
| `=out=` | Not in list | `type=out=(draft,archived)` |
| `=like=` | LIKE pattern | `name=like=john` |
| `=ilike=` | Case-insensitive LIKE | `email=ilike=JOHN` |
| `=startWith=` | Starts with | `name=startWith=Jo` |
| `=endWith=` | Ends with | `email=endWith=.com` |
| `=isnull=` | IS NULL | `deleted_at=isnull=true` |
| `=nn=` | IS NOT NULL | `verified_at=nn=true` |
| `=notlike=` | NOT LIKE | `name=notlike=test` |
| `=jbc=` | JSONB contains (`@>`) | `metadata=jbc={"key":"value"}` |
| `=jbKeyExist=` | JSONB key exists (`?`) | `settings=jbKeyExist=theme` |
| `=jba=` | JSONB arrow (`->>`) | `data.name=jba=John` |
| Operator | Description | Example |
|----------------------------|----------------------------|--------------------------------|
| `==` | Equal | `status==active` |
| `!=` | Not equal | `role!=guest` |
| `=gt=` | Greater than | `age=gt=18` |
| `=ge=` | Greater than or equal | `price=ge=100` |
| `=lt=` | Less than | `stock=lt=5` |
| `=le=` | Less than or equal | `rating=le=3` |
| `=in=` | In list | `status=in=(active,pending)` |
| `=out=` | Not in list | `type=out=(draft,archived)` |
| `=like=` | LIKE pattern | `name=like=john` |
| `=ilike=` | Case-insensitive LIKE | `email=ilike=JOHN` |
| `=startWith=` | Starts with | `name=startWith=Jo` |
| `=endWith=` | Ends with | `email=endWith=.com` |
| `=isnull=` | IS NULL | `deleted_at=isnull=true` |
| `=nn=` | IS NOT NULL | `verified_at=nn=true` |
| `=notlike=` | NOT LIKE | `name=notlike=test` |
| `=jbc=` | JSONB contains (`@>`) | `metadata=jbc={"key":"value"}` |
| `=jbKeyExist=` | JSONB key exists (`?`) | `settings=jbKeyExist=theme` |
| `=jba=` | JSONB arrow (`->>`) | `data.name=jba=John` |
| `=arrayContains=` / `=ac=` | Array contains (`= ANY()`) | `question_types=ac=CLOZE` |

Logical operators: `;` (AND), `,` (OR). Use parentheses for grouping.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,22 @@ public FilterBuilder jsonbKeyExists(String field, String key) {
return this;
}

Comment thread
sant1ago-da-hanoi marked this conversation as resolved.
/**
* Filters rows where the PostgreSQL array column contains the given value.
* Generates: {@code field=arrayContains='value'} → SQL: {@code :param = ANY(column)}.
*/
public FilterBuilder arrayContains(String field, String value) {
Objects.requireNonNull(value, "value must not be null");
predicates.add(
new Comparison(
field,
CustomRSQLOperators.ARRAY_CONTAINS.getSymbol(),
List.of(value)
)
);
return this;
}

// ==================== CONDITIONAL (null-safe) ====================

/**
Expand Down Expand Up @@ -434,6 +450,16 @@ public FilterBuilder notInIfPresent(String field, String... values) {
return this;
Comment thread
sant1ago-da-hanoi marked this conversation as resolved.
}

/**
* Adds an array-contains predicate only if value is non-null and non-blank.
*/
public FilterBuilder arrayContainsIfPresent(String field, String value) {
if (value != null && !value.isBlank()) {
arrayContains(field, value);
}
return this;
}

// ==================== NESTING ====================

public FilterBuilder and(FilterBuilder nested) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package dev.suprim.query.rsql.handler;

import dev.suprim.query.dialect.Dialect;
import dev.suprim.query.model.DbColumn;
import dev.suprim.query.model.DbWhere;

import java.util.Map;

/**
* Handles =arrayContains= / =ac=. Generates :param = ANY(column) for PostgreSQL TEXT[] columns.
*/
public class ArrayContainsOperatorHandler implements OperatorHandler {
Comment thread
sant1ago-da-hanoi marked this conversation as resolved.

@Override
public String handle(
Dialect dialect,
DbColumn column,
DbWhere dbWhere,
String value,
Class<?> type,
Map<String, Object> paramMap
) {
if (dialect.supportAlias()) {
String key = reviewAndSetParam(
dialect.getAliasedNameParam(column, dbWhere.isDelete()),
value,
paramMap
);
String columnRef = dialect.getAliasedName(column, dbWhere.isDelete());
return PREFIX + key + " = ANY(" + columnRef + ")";
} else {
String key = reviewAndSetParam(column.name(), value, paramMap);
return PREFIX + key + " = ANY(" + column.name() + ")";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public class RSQLOperatorHandlers {
map.put(CustomRSQLOperators.NOT_LIKE.getSymbol(), new NotLikeOperatorHandler());
map.put(CustomRSQLOperators.NOT_NULL.getSymbol(), new IsNotNullOperatorHandler());
map.put(CustomRSQLOperators.JSONB_ARROW.getSymbol(), new JsonbArrowOperatorHandler());
map.put(CustomRSQLOperators.ARRAY_CONTAINS.getSymbol(), new ArrayContainsOperatorHandler());
OPERATOR_HANDLER_MAP = Collections.unmodifiableMap(map);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ public class CustomRSQLOperators extends RSQLOperators {
false
);

public static final ComparisonOperator ARRAY_CONTAINS = new ComparisonOperator(
new String[]{"=arrayContains=", "=ac="},
false
);

public static Set<ComparisonOperator> customOperators() {
Set<ComparisonOperator> comparisonOperators = RSQLOperators.defaultOperators();
comparisonOperators.addAll(
Expand All @@ -89,7 +94,8 @@ public static Set<ComparisonOperator> customOperators() {
JSONB_KEY_EXISTS,
JSON_CONTAINS_IN_ARRAY,
NOT_LIKE,
JSONB_ARROW
JSONB_ARROW,
ARRAY_CONTAINS
)
);
return comparisonOperators;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,12 @@ void jsonbKeyExists_shouldUseJsonbKeyExistsOperator() {
String result = FilterBuilder.and().jsonbKeyExists("data", "name").build();
assertThat(result).isEqualTo("data=jbKeyExist='name'");
}

@Test
void arrayContains_shouldUseArrayContainsOperator() {
String result = FilterBuilder.and().arrayContains("question_types", "CLOZE").build();
assertThat(result).isEqualTo("question_types=arrayContains='CLOZE'");
}
}

// ==================== Builder: nesting ====================
Expand Down Expand Up @@ -664,6 +670,30 @@ void notInIfPresent_empty_shouldSkip() {
assertThat(result).isEmpty();
}

@Test
void arrayContainsIfPresent_nonBlank_shouldAddPredicate() {
String result = FilterBuilder.and()
.arrayContainsIfPresent("question_types", "CLOZE")
.build();
assertThat(result).isEqualTo("question_types=arrayContains='CLOZE'");
}

@Test
void arrayContainsIfPresent_null_shouldSkip() {
String result = FilterBuilder.and()
.arrayContainsIfPresent("question_types", null)
.build();
assertThat(result).isEmpty();
}

@Test
void arrayContainsIfPresent_blank_shouldSkip() {
String result = FilterBuilder.and()
.arrayContainsIfPresent("question_types", " ")
.build();
assertThat(result).isEmpty();
}

@Test
void combined_mixedNullAndNonNull_shouldOnlyIncludePresent() {
String result = FilterBuilder.and()
Expand Down
Loading