Repository navigation
feat(processor): add @ExposeField filters on a new Operation.SEARCH endpoint - #6
Conversation
…ilters on findAll)
…ms, Spec, repository
…erations
Filters are now declared per field instead of as a list of names on the
class annotation:
@ExposeField(operations = {EQ, GT, LT, LIKE})
- New @ExposeField annotation and FilterOperation enum
(EQ, NE, GT, GTE, LT, LTE, LIKE); filterableFields is removed
- EQ binds ?field=, other operations bind suffixed params (?price_gt=)
via @BindParam; @parameter(name) keeps the OpenAPI name in sync
- FilterParams is now an immutable class bound through its constructor
- Spec maps each operation for JPA (CriteriaBuilder) and MongoDB
(Criteria); NE includes nulls on both stores; LIKE is a
case-insensitive contains with wildcards escaped
- findAll adds springdoc's @ParameterObject when springdoc is on the
classpath, so each filter is documented as its own query param
- Compile errors for range ops on non-Comparable types, LIKE on
non-String fields, filters on relations and clashing param names
- Restore comments and Javadoc removed in the earlier filter commits
- Document @ExposeField in README and docs/tech
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
- IN / NOT_IN: ?status_in=A,B (or the param repeated) binds to a List of the field type; an empty list adds no condition. NOT_IN includes nulls, like NE (JPA: NOT IN ... OR IS NULL; Mongo: $nin) - IS_NULL: ?deletedAt_null=true / false → IS NULL / IS NOT NULL (Mongo: is(null) / ne(null)); compile error on primitive fields - Multi-word suffixes map to camel-case properties (statusNotIn) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
?deletedAt_is_null=true instead of ?deletedAt_null=true, so every suffix is the lower-cased operation name. Property: deletedAtIsNull. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
DtoGenerator and RequestDtoGenerator rebuilt each field type from its text with ClassName.bestGuess, which only handles plain class names. Any entity with a List<String>, Set<Integer>, Map<..> or array field failed with "couldn't make a guess for java.util.List<...>". FieldModel now carries the field's TypeMirror and both generators emit TypeName.get(type).box(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
On a JPA @ElementCollection or a MongoDB array: - IN = has any of the values: ?tags_in=sale,new. JPA uses a correlated EXISTS subquery (generated <field>HasAny helper) so a row matching several values is returned once and paging totals stay correct; Mongo uses $in - NOT_IN = has none of the values, empty collections included (NOT EXISTS / $nin) - IS_NULL = null or empty (isEmpty / tags.0 does not exist) - The params bind to a List of the element type EQ, NE, ranges and LIKE are compile errors on collections. JPA collections must be @ElementCollection; raw collections and array fields (except IS_NULL) are rejected with a clear message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
The error now says what happens without it: Hibernate stores an unannotated List<String> in a single array column (or a converter / JSON stores text), which JPA queries can't search element by element. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
…ions
@ExposeField(filterOperations = {EQ, GT}) — the name says what the
operations are for, leaving room for non-filter attributes later.
The Javadoc now also lists the array, raw-collection and name-clash
rules, and warns that ignoredFields can still be filtered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
- annotation-reference: attribute table, operations, value formats (ISO dates, lists, booleans), collection fields, every compile-time error, limitations (AND only, no relations, LIKE and indexes, case sensitivity, unknown params ignored, ignoredFields stay filterable) and a workaround for relation filters - generated-artifacts: file count, property naming, per-operation JPA / MongoDB mapping, 400 response keyed by query parameter name - architecture, generator-guide: the two filter generators and the @ExposeField parsing / validation step - README roadmap and comparison table Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
…rue)
Hiding a field is now declared on the field itself:
@ExposeField(hidden = true) private String passwordHash;
Hidden fields are left out of the response and request DTOs exactly as
ignoredFields did (scalar fields, JPA relations, Mongo fields; never the
@id). hidden = true combined with filterOperations is a compile error:
a filter would let clients guess the value the API hides.
Internally EntityModel.ignoredFields is renamed hiddenFields.
BREAKING CHANGE: ignoredFields is removed from @ExposeEntity and
@ExposeDocument. Move each listed field to @ExposeField(hidden = true).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
…o feat/expose-field-filters # Conflicts: # processor/src/main/java/io/github/notablogger/springxpose/processor/generator/RestControllerGenerator.java
@ExposeField(filterOperations = ...) on a JPA relation now filters by the related entity's id instead of failing the build: - To-one (@manytoone, owning @OnetoOne): ?categoryId=5, categoryId_ne, categoryId_in, categoryId_not_in, categoryId_is_null. Compares root.get("category").get("id") — the FK column, no join. NE / NOT_IN include rows without a related entity. - To-many (@onetomany, @manytomany): ?suppliers_in=1,2 (has any), suppliers_not_in (has none, empty included), suppliers_is_null — the same EXISTS subquery as collection fields, comparing the id. - Param type is the related @id type, found on the entity or a @MappedSuperclass. Compile errors: ranges / LIKE on relations, EQ / NE on to-many, the inverse side of a @OnetoOne (no FK), related entities without a single @id (composite ids). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
|
The check comes from No code change in this PR can fix it; it will fail the same way on every PR until the secret is set under Settings → Secrets and variables → Actions (e.g. via Generated by Claude Code |
…feat/relation-id-filters
feat(processor): filter by relation id with @ExposeField
Filtering moves from GET query parameters on findAll to a new opt-in Operation.SEARCH: POST /api/<path>/search with a flat JSON body using the same keys (price_gt, status_in, categoryId, ...), AND-ed. GET /api/<path> is an unfiltered list again. - Operation.SEARCH, not in the default expose list; secured with the read roles. page / size / sort stay query parameters when pageable. - <Entity>FilterParams becomes <Entity>SearchRequest: @JsonProperty keys, @Schema descriptions, and a @JsonAnySetter that rejects unknown keys (typos, undeclared operations, hidden fields) with 400 whatever the FAIL_ON_UNKNOWN_PROPERTIES setting. A missing body is {}. - Warnings for filterOperations without SEARCH and SEARCH without filterOperations. - SpringXposeExceptionHandler names the failing JSON property in "errors" for unreadable bodies (search, create and update). - Docs: search section, why it is a POST, and the RFC 10008 QUERY plan. BREAKING CHANGE: GET /api/<path> no longer accepts filter query parameters; expose Operation.SEARCH and POST the filters as JSON. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
The OpenAPI request body of POST /search gets one example, "example",
with every search key and a placeholder of its type ("string", 0, true,
the first enum constant, ISO dates, one-element arrays for IN / NOT_IN,
true for IS_NULL). Every value is valid for its key, so Swagger UI can
send the example as is; it replaces the "{}" example.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
Drop wording that contrasts search with filtering on findAll, which was never released; the reference section is now "Search — POST /api/<path>/search", and the OpenAPI example matches the declared fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V
Summary
This release (4.0.0) adds a search endpoint and field-level control over the generated API:
POST /api/<path>/search. ListOperation.SEARCHinexpose. Clients send a JSON body of filters, for example{"price_gt": 10, "status_in": ["A","B"], "categoryId": 5}, and get back the matching records, paged whenpageable = true. It works for JPA and MongoDB, on scalar fields, collections and JPA relations (filtered by id).@ExposeField(filterOperations = {...})declares the filters a field accepts:EQ,NE,GT,GTE,LT,LTE,LIKE,IN,NOT_IN,IS_NULL. Mistakes are reported at compile time.@ExposeField(hidden = true)keeps a field out of the response and request DTOs.List<String>,Set<Integer>,byte[]) now generate DTOs.Type
Related issue
Related: #5 (superseded), #8, #9 (merged into this branch)
What was changed
Annotations:
Operation.SEARCH. It's opt-in: it is not one of the defaultexposeoperations.@ExposeField(filterOperations = ..., hidden = ...)and theFilterOperationenum.ignoredFieldsis removed.The search endpoint:
@PostMapping("/search")with@RequestBody(required = false) <Entity>SearchRequest. An empty body ({}) or no body returns every record.pageable = true,page/size/sortare query parameters and the response is aPage. Without it, the response is aList.POST /api/<path>/searchthe read rule (readRoles/roles/authenticated()/permitAll()), whilePOST /api/<path>(create) keeps the write rule.Body keys:
EQuses the field name (price). Every other operation usesfield_plus the lower-cased operation name. Values are JSON: arrays forIN/NOT_IN, booleans forIS_NULL, ISO strings for dates. All keys are optional and combined with AND.EQprice=isNEprice_ne<> OR IS NULLneGT/GTE/LT/LTEprice_gt…>>=<<=gtgteltlteLIKEname_likeStringlower(f) LIKE %v%(escaped)regex(Pattern.quote(v), "i")IN"status_in": ["A","B"]List<T>IN (…)inNOT_IN"status_not_in": ["A","B"]List<T>NOT IN (…) OR IS NULLninIS_NULL"deletedAt_is_null": trueBooleanIS NULL/IS NOT NULLis(null)/ne(null)Unknown keys are rejected with
400, and the error lists the allowed keys. This covers typos, operations a field doesn't declare, and hidden fields. A@JsonAnySetteron the generated class enforces it, so the check holds whatever the application's Jackson setting for unknown properties is.Collection fields (
@ElementCollection List<String> tags, Mongo arrays):tags_inmeans "has any of".tags_not_inmeans "has none of"; empty collections match.tags_is_nullmatches null or empty.EXISTSsubquery, so rows aren't duplicated and paging totals stay correct.Relation filters:
@ManyToOne, owning@OneToOnecategoryId,categoryId_ne,categoryId_in,categoryId_not_in,categoryId_is_nullEQ,NE,IN,NOT_IN,IS_NULLroot.get("category").get("id"): the FK column, no join@OneToMany,@ManyToManysuppliers_in,suppliers_not_in,suppliers_is_nullIN,NOT_IN,IS_NULLEXISTSsubquery on the related idThe key's type is the related entity's
@Idtype, including one inherited from a@MappedSuperclass.hidden = true: leaves the field out of the response and request DTOs. Works on scalar fields, relations and Mongo fields; never on the@Id.Generated code:
<Entity>SearchRequestis the JSON body. Each property has@JsonProperty("price_gt")and@Schema(description = …).<Entity>Specbuilds a JPASpecificationor a MongoDBQuery.JpaSpecificationExecutor.OpenAPI:
examplethat lists every key with a type-correct placeholder, so it can be sent as is.Pageableis documented as optionalpage/size/sortparameters (@ParameterObjectwhen springdoc is present).Error responses: the malformed-body
400haserrorskeyed by the JSON path, for example"price_gt","categoryId_in[1]", or"pirce_gt": "Unknown search key …".Compile-time errors:
Comparabletypes;LIKEon a non-String;IS_NULLon primitives@ElementCollection; raw collections; arrays (exceptIS_NULL)LIKEon relations;EQ/NEon to-many relations; the inverse side of a@OneToOne; related entities without a single@Idhiddencombined withfilterOperations; two filters mapping to the same keyCompile-time warnings:
filterOperationswithoutSEARCH: the filters are unused.SEARCHwithout anyfilterOperations: the endpoint returns every record.Bug fix:
DtoGenerator/RequestDtoGeneratoremit field types from theTypeMirror, so generic and array fields generate.Tests: 102 processor tests.
FilterGeneratorTestcovers search generation, security, warnings, the OpenAPI example, and Jackson reading the generated body.Docs:
annotation-reference.mdhas a new Search section. It covers keys, values,400errors, collections, relations, compile-time checks, limitations, and why search is a POST, including the plan to also serve it over HTTPQUERY(RFC 10008) once Spring supports it (Spring Framework 7.1, Add RFC 10008 (QUERY HTTP method) support spring-projects/spring-framework#34993).generated-artifacts.md,architecture.md,generator-guide.md, the README, and the comparison table.How to test
expose = {…, Operation.SEARCH}and these fields:@ExposeField(filterOperations = {EQ, NE, LIKE}) String name@ExposeField(filterOperations = {GT, LT}) BigDecimal price@ExposeField(filterOperations = {IN, NOT_IN}) String status@ExposeField(filterOperations = IS_NULL) Instant deletedAt@ElementCollection @ExposeField(filterOperations = {IN, NOT_IN, IS_NULL}) List<String> tags@ManyToOne @ExposeField(filterOperations = {EQ, NE, IN, NOT_IN, IS_NULL}) Category category@ManyToMany @ExposeField(filterOperations = {IN, NOT_IN, IS_NULL}) Set<Supplier> suppliers@ExposeField(hidden = true) String secretPOST /api/products/searchwith these bodies:{"price_gt":10,"price_lt":100},{"name_like":"widg"},{"name_like":"50%"}(a literal%){"status_in":["A","B"]},{"status_in":[]}(no filter),{"status_ne":"X"}(includes null){}, and no body at all (both return everything){"deletedAt_is_null":true}. Each of these should return400witherrorskeyed by the JSON key:{"price_gt":"abc"},{"deletedAt_is_null":"maybe"}, a non-ISO date{"pirce_gt":1}and{"secret":"x"}; these errors also list the allowed keys{"tags_in":["sale","new"]},{"tags_not_in":["sale"]}and{"tags_is_null":true}.{"categoryId":1}: the SQL should bewhere category_id=?, with no join.{"categoryId_ne":1}: includes uncategorised products.{"suppliers_in":[1,2]}with?size=2: each product once, correcttotalElements.secretis absent from responses and from OpenAPI.readRoles/writeRoles, a read-role user can search but not create./v3/api-docs,/searchhas an optional body with theProductSearchRequestschema and anexample. Sending that example unchanged returns200.page/size/sortare optional query params.All of the above was run end to end against H2 (Spring Boot 4.0.6, springdoc 3.0.3), with this branch published to mavenLocal. MongoDB is covered by compile-tests only.
Generated output (if applicable)
Generated source (excerpt)
Checklist
./gradlew :processor:test): 102/102 locally;./gradlew build(including javadoc) is cleanREADME.mdupdated if a public API / annotation attribute changeddocs/tech/updated if architecture or generated artifacts changedtype(scope): descriptionformat🤖 Generated with Claude Code
https://claude.ai/code/session_01LcaFXaRgJhB2GBCbwnAc6V