{"id":51091451,"url":"https://github.com/suprim-corp/suprim-query","last_synced_at":"2026-06-24T02:32:30.117Z","repository":{"id":355692982,"uuid":"1229177257","full_name":"suprim-corp/suprim-query","owner":"suprim-corp","description":"Type-safe dynamic SQL query builder for Spring Boot applications","archived":false,"fork":false,"pushed_at":"2026-06-12T04:57:33.000Z","size":311,"stargazers_count":1,"open_issues_count":8,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-12T06:24:38.304Z","etag":null,"topics":["java","jdbc","jte","postgresql","query-builder","rsql","spring-boot","sql"],"latest_commit_sha":null,"homepage":null,"language":"Java","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/suprim-corp.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-04T19:27:02.000Z","updated_at":"2026-06-12T04:57:33.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/suprim-corp/suprim-query","commit_stats":null,"previous_names":["suprim-corp/suprim-query"],"tags_count":10,"template":false,"template_full_name":null,"purl":"pkg:github/suprim-corp/suprim-query","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/suprim-corp%2Fsuprim-query","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/suprim-corp%2Fsuprim-query/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/suprim-corp%2Fsuprim-query/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/suprim-corp%2Fsuprim-query/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/suprim-corp","download_url":"https://codeload.github.com/suprim-corp/suprim-query/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/suprim-corp%2Fsuprim-query/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34714992,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-24T02:00:07.484Z","response_time":106,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["java","jdbc","jte","postgresql","query-builder","rsql","spring-boot","sql"],"created_at":"2026-06-24T02:32:29.067Z","updated_at":"2026-06-24T02:32:30.108Z","avatar_url":"https://github.com/suprim-corp.png","language":"Java","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Suprim Query\n\n[![Java](https://img.shields.io/badge/Java-21+-orange.svg)](https://openjdk.org/)\n[![Maven](https://img.shields.io/badge/Maven-3.9+-blue.svg)](https://maven.apache.org/)\n[![](https://jitpack.io/v/dev.suprim/suprim-query.svg)](https://jitpack.io/#dev.suprim/suprim-query)\n[![codecov](https://codecov.io/gh/suprim-corp/suprim-query/branch/main/graph/badge.svg)](https://codecov.io/gh/suprim-corp/suprim-query)\n[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)\n\nType-safe dynamic SQL query builder for Spring Boot applications. Supports RSQL filtering, JTE SQL templating,\nmulti-tenant routing, and automatic metadata extraction.\n\n## Modules\n\n| Module                | Description                                             |\n|-----------------------|---------------------------------------------------------|\n| `core`                | Core models, dialect interface, exceptions, utilities   |\n| `rsql`                | RSQL parser integration and fluent filter/join builders |\n| `jdbc`                | Spring JDBC operations with JTE SQL templating          |\n| `postgresql`          | PostgreSQL dialect and metadata extraction              |\n| `spring-boot-starter` | Spring Boot auto-configuration                          |\n\n## Requirements\n\n- Java 21+\n- Maven 3.9+\n\n## Installation\n\nAdd the JitPack repository and dependency to your `pom.xml`:\n\n```xml\n\n\u003crepositories\u003e\n\t\u003crepository\u003e\n\t\t\u003cid\u003ejitpack.io\u003c/id\u003e\n\t\t\u003curl\u003ehttps://jitpack.io\u003c/url\u003e\n\t\u003c/repository\u003e\n\u003c/repositories\u003e\n\n\u003cdependency\u003e\n\u003cgroupId\u003edev.suprim\u003c/groupId\u003e\n\u003cartifactId\u003espring-boot-starter\u003c/artifactId\u003e\n\u003cversion\u003e1.1.0\u003c/version\u003e\n\u003c/dependency\u003e\n```\n\nIf you only need the RSQL builder (no JDBC/Spring dependency):\n\n```xml\n\n\u003cdependency\u003e\n\t\u003cgroupId\u003edev.suprim\u003c/groupId\u003e\n\t\u003cartifactId\u003ersql\u003c/artifactId\u003e\n\t\u003cversion\u003e1.1.0\u003c/version\u003e\n\u003c/dependency\u003e\n```\n\n## Configuration\n\n```yaml\ndb:\n    enabled: true\n    default-database-id: main\n    databases:\n        -   id: main\n            type: postgresql\n            url: jdbc:postgresql://localhost:5432/mydb\n            username: user\n            password: pass\n            max-connections: 10\n            schemas:\n                - public\n```\n\n## Usage\n\n### Reading data\n\n```java\n\n@Autowired\nprivate ReadService readService;\n\n// Simple query with RSQL filter\nReadContext context = ReadContext.builder()\n                                 .dbId(\"main\")\n                                 .schemaName(\"public\")\n                                 .tableName(\"users\")\n                                 .fields(\"id,name,email,age\")\n                                 .filter(\"age=gt=18;status==active\")\n                                 .limit(20)\n                                 .offset(0)\n                                 .build();\n\nList\u003cMap\u003cString, Object\u003e\u003e users = readService.findAll(context);\n\n// Find one record\nReadContext singleContext = ReadContext.builder()\n                                       .dbId(\"main\")\n                                       .tableName(\"users\")\n                                       .fields(\"*\")\n                                       .filter(\"id==123\")\n                                       .build();\n\nMap\u003cString, Object\u003e user = readService.findOne(singleContext);\n\n// Count\nReadContext countContext = ReadContext.builder()\n                                      .dbId(\"main\")\n                                      .tableName(\"orders\")\n                                      .filter(\"status==pending\")\n                                      .build();\n\nlong pendingCount = readService.count(countContext);\n\n// Paginated query (single call, returns data + metadata)\nReadContext pageContext = ReadContext.builder()\n                                     .dbId(\"main\")\n                                     .tableName(\"users\")\n                                     .fields(\"id,name,email\")\n                                     .filter(\"status==active\")\n                                     .limit(20)\n                                     .offset(40)\n                                     .build();\n\nPage page = readService.findPage(pageContext);\n// page.data()    → List\u003cMap\u003cString, Object\u003e\u003e (current page rows)\n// page.total()   → 150 (total matching rows)\n// page.limit()   → 20\n// page.offset()  → 40\n// page.hasNext() → true (40 + 20 \u003c 150)\n```\n\n### Creating records\n\n```java\n\n@Autowired\nprivate CreationService creationService;\n\nMap\u003cString, Object\u003e data = Map.of(\n\t\t\"name\", \"John Doe\",\n\t\t\"email\", \"john@example.com\",\n\t\t\"age\", 30\n);\n\n// Simple insert\nCreationResponse response = creationService.execute(\n\t\t\"main\", \"public\", \"users\",\n\t\tnull,   // columns (null = use data keys)\n\t\tdata,\n\t\tfalse,  // tsIdEnabled\n\t\tnull    // sequences\n);\n\n// Insert with TSID auto-generated primary key\nCreationResponse withTsid = creationService.execute(\n\t\t\"main\", \"public\", \"users\",\n\t\tnull, data,\n\t\ttrue,   // generates TSID for PK columns\n\t\tnull\n);\n\n// Insert with specific columns and sequence\nList\u003cString\u003e columns = List.of(\"name\", \"email\");\nList\u003cString\u003e sequences = List.of(\"order_number:orders_seq\");\n\nCreationResponse withSeq = creationService.execute(\n\t\t\"main\", \"public\", \"orders\",\n\t\tcolumns, data, false, sequences\n);\n```\n\n### Updating records\n\n```java\n\n@Autowired\nprivate UpdateService updateService;\n\nMap\u003cString, Object\u003e updates = Map.of(\n\t\t\"status\", \"verified\",\n\t\t\"verified_at\", \"2026-01-15T10:30:00\"\n);\n\n// Update with RSQL filter (filter is required)\nint rowsUpdated = updateService.patch(\n\t\t\"main\", \"public\", \"users\",\n\t\tupdates,\n\t\t\"id==123\"\n);\n\n// Update multiple records\nint bulkUpdated = updateService.patch(\n\t\t\"main\", \"public\", \"orders\",\n\t\tMap.of(\"status\", \"cancelled\"),\n\t\t\"status==pending;created_at=lt=2026-01-01\"\n);\n```\n\n#### Bulk update (single transaction)\n\n```java\nimport dev.suprim.query.model.dto.BulkUpdate;\n\n// Each BulkUpdate has its own data and filter — all execute in one transaction\nList\u003cBulkUpdate\u003e updates = List.of(\n\t\tnew BulkUpdate(Map.of(\"status\", \"shipped\"), \"id==101\"),\n\t\tnew BulkUpdate(Map.of(\"status\", \"cancelled\"), \"id==102\"),\n\t\tnew BulkUpdate(Map.of(\"status\", \"refunded\", \"refunded_at\", \"2026-05-05\"), \"id==103\")\n);\n\n\t\tint totalUpdated = updateService.patchBulk(\"main\", \"public\", \"orders\", updates);\n// If any single update fails, all changes are rolled back\n```\n\n### Deleting records\n\n```java\n\n@Autowired\nprivate DeleteService deleteService;\n\n// Delete with RSQL filter (filter is required)\nint rowsDeleted = deleteService.delete(\n\t\t\"main\", \"public\", \"sessions\",\n\t\t\"expired_at=lt=2026-01-01\"\n);\n```\n\n#### Bulk delete (single transaction)\n\n```java\n// Multiple filters — each scopes a separate DELETE, all in one transaction\nList\u003cString\u003e filters = List.of(\n\t\t\t\t\"status==expired;created_at=lt=2025-01-01\",\n\t\t\t\t\"status==cancelled;updated_at=lt=2025-06-01\",\n\t\t\t\t\"id==999\"\n\t\t);\n\nint totalDeleted = deleteService.deleteBulk(\"main\", \"public\", \"sessions\", filters);\n// If any single delete fails, all changes are rolled back\n```\n\n### Soft delete\n\nWhen enabled, `DELETE` operations are rewritten to `UPDATE SET deleted_at = NOW()` and all read\nqueries automatically append `AND deleted_at IS NULL` to exclude soft-deleted rows.\n\n#### Configuration\n\n```yaml\ndb:\n    soft-delete:\n        enabled: true\n        column: deleted_at          # optional, defaults to \"deleted_at\"\n        tables: # optional, if empty applies to ALL tables\n            - users\n            - orders\n```\n\n#### Per-query opt-out\n\nTo include soft-deleted rows in a specific query:\n\n```java\nReadContext context = ReadContext.builder()\n                                 .dbId(\"main\")\n                                 .tableName(\"users\")\n                                 .fields(\"*\")\n                                 .filter(\"email==john@example.com\")\n                                 .includeSoftDeleted(true)  // bypasses the IS NULL filter\n                                 .build();\n\nList\u003cMap\u003cString, Object\u003e\u003e allUsers = readService.findAll(context);\n```\n\n#### Behavior summary\n\n| Operation                                                  | Soft-delete enabled                             | Soft-delete disabled          |\n|------------------------------------------------------------|-------------------------------------------------|-------------------------------|\n| `readService.findAll(ctx)`                                 | Appends `AND deleted_at IS NULL`                | No change                     |\n| `readService.findAll(ctx)` with `includeSoftDeleted(true)` | No filter appended                              | No change                     |\n| `deleteService.delete(...)`                                | `UPDATE table SET deleted_at = NOW() WHERE ...` | `DELETE FROM table WHERE ...` |\n| `deleteService.deleteBulk(...)`                            | Same rewrite per filter                         | Same as above                 |\n\n### Upsert (INSERT ... ON CONFLICT)\n\n```java\n\n@Autowired\nprivate CreationService creationService;\n\nimport dev.suprim.query.model.UpsertConfig;\n\nMap\u003cString, Object\u003e data = Map.of(\n\t\t\"email\", \"john@example.com\",\n\t\t\"name\", \"John Doe\",\n\t\t\"login_count\", 1\n);\n\n// Upsert with DO UPDATE — update specific columns on conflict\nUpsertConfig config = new UpsertConfig(\n\t\tList.of(\"email\"),                    // conflict target columns\n\t\tList.of(\"name\", \"login_count\")       // columns to update on conflict\n);\n\nCreationResponse response = creationService.upsert(\n\t\t\"main\", \"public\", \"users\",\n\t\tnull,   // columns (null = infer from data keys)\n\t\tdata,\n\t\tconfig\n);\n\n// Upsert with DO NOTHING — skip insert if conflict\nUpsertConfig doNothing = new UpsertConfig(\n\t\tList.of(\"email\"),\n\t\tList.of()   // empty = DO NOTHING\n);\n\nCreationResponse skipped = creationService.upsert(\n\t\t\"main\", \"public\", \"users\",\n\t\tnull, data, doNothing\n);\n```\n\n### Raw SQL queries\n\nFor cases where the query builder doesn't cover your needs, use `RawQueryService` as an escape hatch.\nAlways use named parameters (`:paramName`) — never concatenate user input into SQL.\n\n```java\n\n@Autowired\nprivate RawQueryService rawQueryService;\n\n// Select single row\nOptional\u003cMap\u003cString, Object\u003e\u003e user = rawQueryService.queryOne(\n\t\t\"main\",\n\t\t\"SELECT * FROM users WHERE id = :id\",\n\t\tMap.of(\"id\", 123)\n);\n\n// Select multiple rows\nList\u003cMap\u003cString, Object\u003e\u003e rows = rawQueryService.queryList(\n\t\t\"main\",\n\t\t\"SELECT u.name, COUNT(o.id) as order_count FROM users u \" +\n\t\t\"LEFT JOIN orders o ON o.user_id = u.id \" +\n\t\t\"WHERE u.status = :status GROUP BY u.name\",\n\t\tMap.of(\"status\", \"active\")\n);\n\n// Execute write statement (INSERT/UPDATE/DELETE)\nint affected = rawQueryService.execute(\n\t\t\"main\",\n\t\t\"UPDATE users SET last_login = NOW() WHERE id = :id\",\n\t\tMap.of(\"id\", 456)\n);\n```\n\n### Building RSQL filters programmatically\n\nUse `FilterBuilder` instead of concatenating RSQL strings manually:\n\n```java\nimport dev.suprim.query.rsql.builder.FilterBuilder;\n\n// Simple AND filter\nString filter = FilterBuilder.and()\n                             .eq(\"status\", \"active\")\n                             .gte(\"age\", \"18\")\n                             .build();\n\t\t// Result: (status=='active' and age=ge='18')\n\n\t\t// OR filter\n\t\tString orFilter = FilterBuilder.or()\n\t\t                               .eq(\"role\", \"admin\")\n\t\t                               .eq(\"role\", \"moderator\")\n\t\t                               .build();\n\t\t// Result: (role=='admin' or role=='moderator')\n\n\t\t// Nested conditions\n\t\tString nested = FilterBuilder.and()\n\t\t                             .eq(\"workspace_id\", workspaceId)\n\t\t                             .or(FilterBuilder.or()\n\t\t                                              .eq(\"visibility\", \"public\")\n\t\t                                              .and(FilterBuilder.and()\n\t\t                                                                .eq(\"visibility\", \"private\")\n\t\t                                                                .eq(\"owner_id\", currentUserId)\n\t\t                                              )\n\t\t                             )\n\t\t                             .build();\n\n\t\t// IN operator\n\t\tString inFilter = FilterBuilder.and()\n\t\t                               .in(\"status\", \"active\", \"pending\", \"review\")\n\t\t                               .neq(\"deleted\", \"true\")\n\t\t                               .build();\n\n\t\t// Pattern matching\n\t\tString searchFilter = FilterBuilder.and()\n\t\t                                   .ilike(\"name\", \"john\")\n\t\t                                   .startWith(\"email\", \"john\")\n\t\t                                   .isNotNull(\"verified_at\")\n\t\t                                   .build();\n\n\t\t// JSONB operators (PostgreSQL)\n\t\tString jsonFilter = FilterBuilder.and()\n\t\t                                 .jsonbContains(\"metadata\", \"tier\", \"premium\")\n\t\t                                 .jsonbContains(\"config\", Map.of(\"active\", true, \"plan\", \"annual\"))\n\t\t                                 .jsonbKeyExists(\"settings\", \"notifications\")\n\t\t                                 .build();\n\n\t\t// Raw RSQL passthrough\n\t\tString withRaw = FilterBuilder.and()\n\t\t                              .eq(\"type\", \"order\")\n\t\t                              .raw(\"total=gt=100;total=lt=500\")\n\t\t                              .build();\n\n\t\t// PostgreSQL array contains (TEXT[] columns)\n\t\tString arrayFilter = FilterBuilder.and()\n\t\t                                  .eqIfPresent(\"status\", filter.status())\n\t\t                                  .arrayContainsIfPresent(\"question_types\", filter.questionType())\n\t\t                                  .build();\n```\n\n### Filter → SQL mapping\n\nShows what SQL each RSQL filter generates (assuming table `users` with alias `t0`):\n\n| FilterBuilder code                              | RSQL output                                  | Generated SQL WHERE                          |\n|-------------------------------------------------|----------------------------------------------|----------------------------------------------|\n| `.eq(\"status\", \"active\")`                       | `status=='active'`                           | `t0.\"status\" = :status`                      |\n| `.neq(\"role\", \"guest\")`                         | `role!='guest'`                              | `t0.\"role\" \u003c\u003e :role`                         |\n| `.gt(\"age\", \"18\")`                              | `age=gt='18'`                                | `t0.\"age\" \u003e :age`                            |\n| `.gte(\"price\", \"100\")`                          | `price=ge='100'`                             | `t0.\"price\" \u003e= :price`                       |\n| `.lt(\"stock\", \"5\")`                             | `stock=lt='5'`                               | `t0.\"stock\" \u003c :stock`                        |\n| `.lte(\"rating\", \"3\")`                           | `rating=le='3'`                              | `t0.\"rating\" \u003c= :rating`                     |\n| `.in(\"status\", \"active\", \"pending\")`            | `status=in=(active,pending)`                 | `t0.\"status\" IN (:status)`                   |\n| `.notIn(\"type\", \"draft\", \"archived\")`           | `type=out=(draft,archived)`                  | `t0.\"type\" NOT IN (:type)`                   |\n| `.like(\"name\", \"john\")`                         | `name=like='john'`                           | `t0.\"name\" LIKE :name` (value: `%john%`)     |\n| `.ilike(\"email\", \"JOHN\")`                       | `email=ilike='JOHN'`                         | `t0.\"email\" ILIKE :email` (value: `%JOHN%`)  |\n| `.startWith(\"name\", \"Jo\")`                      | `name=startWith='Jo'`                        | `t0.\"name\" LIKE :name` (value: `Jo%`)        |\n| `.endWith(\"email\", \".com\")`                     | `email=endWith='.com'`                       | `t0.\"email\" LIKE :email` (value: `%.com`)    |\n| `.isNull(\"deleted_at\")`                         | `deleted_at=isnull='true'`                   | `t0.\"deleted_at\" IS NULL`                    |\n| `.isNotNull(\"verified_at\")`                     | `verified_at=nn='true'`                      | `t0.\"verified_at\" IS NOT NULL`               |\n| `.jsonbContains(\"metadata\", \"tier\", \"premium\")` | `metadata=jsonbContain='{\"tier\":\"premium\"}'` | `t0.\"metadata\" @\u003e :metadata::jsonb`          |\n| `.jsonbKeyExists(\"settings\", \"theme\")`          | `settings=jbKeyExist='theme'`                | `t0.\"settings\" ? :settings`                  |\n| `.arrayContains(\"question_types\", \"CLOZE\")`     | `question_types=arrayContains='CLOZE'`       | `:question_types = ANY(t0.\"question_types\")` |\n\n**Compound filters:**\n\n```\nFilterBuilder.and()\n    .eq(\"status\", \"active\")\n    .gte(\"age\", \"18\")\n    .build()\n```\n\nRSQL: `(status=='active' and age=ge='18')`\nSQL: `WHERE t0.\"status\" = :status AND t0.\"age\" \u003e= :age`\n\n```\nFilterBuilder.or()\n    .eq(\"role\", \"admin\")\n    .eq(\"role\", \"moderator\")\n    .build()\n```\n\nRSQL: `(role=='admin' or role=='moderator')`\nSQL: `WHERE (t0.\"role\" = :role OR t0.\"role\" = :role_1)`\n\n```\nFilterBuilder.and()\n    .eq(\"workspace_id\", \"ws-123\")\n    .or(FilterBuilder.or()\n        .eq(\"visibility\", \"public\")\n        .and(FilterBuilder.and()\n            .eq(\"visibility\", \"private\")\n            .eq(\"owner_id\", \"user-456\")\n        )\n    )\n    .build()\n```\n\nRSQL: `(workspace_id=='ws-123' and (visibility=='public' or (visibility=='private' and owner_id=='user-456')))`\nSQL:\n\n```sql\nWHERE t0.\"workspace_id\" = :workspace_id\n  AND (t0.\"visibility\" = :visibility OR (t0.\"visibility\" = :visibility_1 AND t0.\"owner_id\" = :owner_id))\n```\n\n**JOIN filter example:**\n\n```\nJoinBuilder.left(\"orders\")\n    .on(JoinCondition.eq(\"id\", \"user_id\"))\n    .fields(JoinField.of(\"total\"))\n    .filter(FilterBuilder.and().eq(\"status\", \"completed\"))\n    .build()\n```\n\nSQL:\n\n```sql\nLEFT JOIN \"public\".\"orders\" t1 ON t0.\"id\" = t1.\"user_id\" AND t1.\"status\" = :status\n```\n\n### Building JOINs\n\n```java\nimport dev.suprim.query.rsql.builder.JoinBuilder;\nimport dev.suprim.query.rsql.builder.JoinBuilder.JoinCondition;\nimport dev.suprim.query.rsql.builder.JoinBuilder.JoinField;\nimport dev.suprim.query.model.JoinDetail;\n\n// Inner join with specific fields\nJoinDetail memberJoin = JoinBuilder.inner(\"workspace_members\")\n                                   .on(JoinCondition.eq(\"id\", \"workspace_id\"))\n                                   .fields(\n\t\t                                   JoinField.of(\"member_id\"),\n\t\t                                   JoinField.aliased(\"role\", \"member_role\")\n                                   )\n                                   .build();\n\n\t\t// Left join with filter on joined table\n\t\tJoinDetail orderJoin = JoinBuilder.left(\"orders\")\n\t\t                                  .on(JoinCondition.eq(\"id\", \"user_id\"))\n\t\t                                  .fields(JoinField.of(\"total\"), JoinField.of(\"status\"))\n\t\t                                  .filter(FilterBuilder.and().eq(\"status\", \"completed\"))\n\t\t                                  .build();\n\n\t\t// Multiple ON conditions\n\t\tJoinDetail complexJoin = JoinBuilder.inner(\"permissions\")\n\t\t                                    .on(JoinCondition.eq(\"id\", \"resource_id\"))\n\t\t                                    .on(JoinCondition.of(\"type\", JoinOperator.EQ, \"resource_type\"))\n\t\t                                    .fields(JoinField.of(\"level\"))\n\t\t                                    .build();\n\n\t\t// Use joins in a read context\n\t\tReadContext context = ReadContext.builder()\n\t\t                                 .dbId(\"main\")\n\t\t                                 .tableName(\"users\")\n\t\t                                 .fields(\"*\")\n\t\t                                 .joins(List.of(memberJoin, orderJoin))\n\t\t                                 .filter(\"status==active\")\n\t\t                                 .limit(50)\n\t\t                                 .build();\n\n\t\tList\u003cMap\u003cString, Object\u003e\u003e results = readService.findAll(context);\n```\n\n### Sorting\n\n```java\n// Sort by single column (default ASC)\nReadContext sorted = ReadContext.builder()\n                                .dbId(\"main\")\n                                .tableName(\"users\")\n                                .fields(\"*\")\n                                .sorts(List.of(\"created_at;DESC\"))\n                                .limit(10)\n                                .build();\n\n// Multiple sort columns\nReadContext multiSort = ReadContext.builder()\n                                   .dbId(\"main\")\n                                   .tableName(\"products\")\n                                   .fields(\"*\")\n                                   .sorts(List.of(\"category;ASC\", \"price;DESC\", \"name;ASC\"))\n                                   .limit(100)\n                                   .build();\n```\n\n### Multi-tenant database routing\n\n```java\nimport dev.suprim.query.jdbc.config.DatabaseContextHolder;\n\n// Switch database context for the current thread\nDatabaseContextHolder.setCurrentDbId(\"tenant_abc\");\n\ntry{\n// All queries now route to tenant_abc's datasource\nList\u003cMap\u003cString, Object\u003e\u003e data = readService.findAll(\n\t\tReadContext.builder()\n\t\t           .dbId(\"tenant_abc\")\n\t\t           .tableName(\"invoices\")\n\t\t           .fields(\"*\")\n\t\t           .build()\n);\n} finally {\n\t\tDatabaseContextHolder.clear();\n}\n```\n\n## RSQL Operators\n\n| Operator                   | Description                | Example                        |\n|----------------------------|----------------------------|--------------------------------|\n| `==`                       | Equal                      | `status==active`               |\n| `!=`                       | Not equal                  | `role!=guest`                  |\n| `=gt=`                     | Greater than               | `age=gt=18`                    |\n| `=ge=`                     | Greater than or equal      | `price=ge=100`                 |\n| `=lt=`                     | Less than                  | `stock=lt=5`                   |\n| `=le=`                     | Less than or equal         | `rating=le=3`                  |\n| `=in=`                     | In list                    | `status=in=(active,pending)`   |\n| `=out=`                    | Not in list                | `type=out=(draft,archived)`    |\n| `=like=`                   | LIKE pattern               | `name=like=john`               |\n| `=ilike=`                  | Case-insensitive LIKE      | `email=ilike=JOHN`             |\n| `=startWith=`              | Starts with                | `name=startWith=Jo`            |\n| `=endWith=`                | Ends with                  | `email=endWith=.com`           |\n| `=isnull=`                 | IS NULL                    | `deleted_at=isnull=true`       |\n| `=nn=`                     | IS NOT NULL                | `verified_at=nn=true`          |\n| `=notlike=`                | NOT LIKE                   | `name=notlike=test`            |\n| `=jbc=`                    | JSONB contains (`@\u003e`)      | `metadata=jbc={\"key\":\"value\"}` |\n| `=jbKeyExist=`             | JSONB key exists (`?`)     | `settings=jbKeyExist=theme`    |\n| `=jba=`                    | JSONB arrow (`-\u003e\u003e`)        | `data.name=jba=John`           |\n| `=arrayContains=` / `=ac=` | Array contains (`= ANY()`) | `question_types=ac=CLOZE`      |\n\nLogical operators: `;` (AND), `,` (OR). Use parentheses for grouping.\n\n## Build\n\n```bash\n# Compile\nmvn clean compile\n\n# Test\nmvn test\n\n# Install locally\nmvn clean install\n\n# Package with coverage\nmvn clean verify\n```\n\nCoverage reports generated at:\n\n- Per-module: `{module}/target/site/jacoco/index.html`\n- Aggregate: `target/site/jacoco-aggregate/index.html`\n\n## License\n\nMIT - See [LICENSE](LICENSE) for details.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsuprim-corp%2Fsuprim-query","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsuprim-corp%2Fsuprim-query","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsuprim-corp%2Fsuprim-query/lists"}