Skip to content

Commit

Permalink
feat: is null/is not null
Browse files Browse the repository at this point in the history
* feat: is null/is not null

* fix: search null for collections

* refactor: null not case sensitive

* doc: update README.md

* refactor: throw if operand != IS | IS NOT

* refactor: throw if calling buildPredicate parent method with empty clause

* refactor: don't allow search for null collections

* refactor: don't allow search for null collections

* fix: lint

* test: add test for searching for empty non-collection fields

* refactor: avoid passing null value, check literal value instead (SearchOperation.NULL)

* fix: add missing check for "NULL" value in strategies

* fix: update threshold

* fix: remove unreachable code

* test: add test for UUID null

* test: canGetUsersWithUpdatedDateAtNull

* test: LocalDateTime is not null

* test: Int Boolean and Date is null

* trigger-ci

* trigger-ci

* fix: ktlint

* fix: ktlint

* fix: ktlint

---------

Co-authored-by: vincentescoffier <[email protected]>
  • Loading branch information
reifocS and vincentescoffier authored Jun 20, 2024
1 parent 2c60a58 commit 8e23f5f
Show file tree
Hide file tree
Showing 21 changed files with 289 additions and 64 deletions.
6 changes: 6 additions & 0 deletions .jpb/jpb-settings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JpaPluginProjectSettings">
<option name="lastSelectedLanguage" value="Kotlin" />
</component>
</project>
85 changes: 33 additions & 52 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,67 +124,48 @@ fun yourFunctionNameHere(@SearchSpec specs: Specification<YourModel>): ResponseE
}
```

1. Using the equal operator `:`
Request : `/cars?search=color:Red`
![equal operator example](./docs/images/equal-example.gif)

2. Using the not equal operator `!`
Request : `/cars?search=color!Red`
![not equal operator example](./docs/images/not-equal-example.gif)

3. Using the greater than operator `>`
Request : `/cars?search=creationyear>2017`
> Note: You can use the `>:` operator as well.
![greater than operator example](./docs/images/greater-than-example.gif)

4. Using the less than operator `<`
Request : `/cars?search=price<100000`
> Note: You can use the `<:` operator as well.
![less than operator example](./docs/images/less-than-example.gif)

5. Using the starts with operator `*`
*For the ends with operator, simply place `*` at the beginning of the word*.
*For the contains operator, simply place `*` at the beginning and the end of the word*.
Request : `/cars?search=brand:Aston*`
![starts with operator example](./docs/images/starts-with-example.gif)

6. Using the `OR` operator
Request : `/cars?search=color:Red OR color:Blue`
![or operator example](./docs/images/or-example.gif)

7. Using the `AND` operator
Request : `/cars?search=brand:Aston* AND price<300000`
![and operator example](./docs/images/and-example.gif)

8. Checking if value is or not in a list
Request : `/cars?search=color IN ['Red', 'Blue']`
Request : `/cars?search=color NOT IN ['Red', 'Blue']`
*Note: Spaces inside the brackets are not necessary*
*Note: You will need to encode the value (e.g. encodeURI) as brackets are not valid url parts*

9. Using the `IS EMPTY` and `IS NOT EMPTY` operators for collection fields
Request : `/users?search=cars IS EMPTY`

10. Using parenthesis
## Operators
<!-- table of operators -->
| Operator | Description | Example |
|----------------|---------------------------------|----------------------------------------------------|
| `:` | Equal | `color:Red` |
| `!` | Not equal | `color!Red` |
| `>` | Greater than | `creationyear>2017` |
| `>:` | Greater than eq | `creationyear>2017` |
| `<` | Less than | `price<100000` |
| `<:` | Less than eq | `price<100000` |
| `*` | Starts with | `brand:*Martin` |
| `*` | Ends with | `brand:Aston*` |
| `*` | Contains | `brand:*Martin*` |
| `OR` | Logical OR | `color:Red OR color:Blue` |
| `AND` | Logical AND | `brand:Aston* AND price<300000` |
| `IN` | Value is in list | `color IN ['Red', 'Blue']` |
| `NOT IN` | Value is not in list | `color NOT IN ['Red', 'Blue']` |
| `IS EMPTY` | Collection field is empty | `cars IS EMPTY` |
| `IS NOT EMPTY` | Collection field is not empty | `cars IS NOT EMPTY` |
| `IS NULL` | Field is null | `brand IS NULL` |
| `IS NOT NULL` | Field is not null | `brand IS NOT NULL` |
| `()` | Parenthesis | `brand:Nissan OR (brand:Chevrolet AND color:Blue)` |
| `BETWEEN` | Value is between two values | `creationyear BETWEEN 2017 AND 2019` |
| `NOT BETWEEN` | Value is not between two values | `creationyear NOT BETWEEN 2017 AND 2019` |


## Examples

1. Using parenthesis
Request : `/cars?search=( brand:Nissan OR brand:Chevrolet ) AND color:Blue`
*Note: Spaces inside the parenthesis are not necessary*
![parenthesis example](./docs/images/parenthesis-example.gif)

11. Using space in nouns
2. Using space in nouns
Request : `/cars?search=model:'Spacetourer Business Lounge'`
![space example](./docs/images/space-example.gif)

12. Using special characters
3. Using special characters
Request: `/cars?search=model:中华V7`
![special characters example](./docs/images/special-characters-example.gif)

13. Using deep fields
4. Using deep fields
Request : `/cars?search=options.transmission:Auto`
![deep field example](./docs/images/deep-field-example.gif)

14. Complex example
5. Complex example
Request : `/cars?search=creationyear:2018 AND price<300000 AND (color:Yellow OR color:Blue) AND options.transmission:Auto`
![complex example](./docs/images/complex-example.gif)
15. Using the BETWEEN operator
Expand Down
5 changes: 5 additions & 0 deletions src/main/antlr4/Query.g4
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ criteria

is_value
: EMPTY
| NULL
;

key
Expand Down Expand Up @@ -57,6 +58,10 @@ BOOL
| 'false'
;

NULL
: 'NULL'
;

STRING
: '"' DoubleStringCharacter* '"'
| '\'' SingleStringCharacter* '\''
Expand Down
3 changes: 1 addition & 2 deletions src/main/kotlin/com/sipios/springsearch/QueryVisitorImpl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ class QueryVisitorImpl<T>(private val searchSpecAnnotation: SearchSpec) : QueryB
} else {
SearchOperation.IS_NOT
}
val value = ctx.is_value!!.text
return toSpec(key, op, value)
return toSpec(key, op, ctx.is_value().text)
}

override fun visitEqArrayCriteria(ctx: QueryParser.EqArrayCriteriaContext): Specification<T> {
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/com/sipios/springsearch/SearchOperation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ enum class SearchOperation {
val AND_OPERATOR = "AND"
val LEFT_PARANTHESIS = "("
val RIGHT_PARANTHESIS = ")"
val EMPTY = "EMPTY"
val NULL = "NULL"

/**
* Parse a string into an operation.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.sipios.springsearch.strategies

import com.sipios.springsearch.SearchOperation
import kotlin.reflect.KClass

class BooleanStrategy : ParsingStrategy {
override fun parse(value: String?, fieldClass: KClass<out Any>): Any? {
if (value == SearchOperation.NULL) return value
return value?.toBoolean()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,26 @@ import com.sipios.springsearch.SearchOperation
import jakarta.persistence.criteria.CriteriaBuilder
import jakarta.persistence.criteria.Path
import jakarta.persistence.criteria.Predicate
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException

class CollectionStrategy : ParsingStrategy {

override fun buildPredicate(
builder: CriteriaBuilder,
path: Path<*>,
fieldName: String,
ops: SearchOperation?,
value: Any?
): Predicate? {
if (ops == SearchOperation.IS && value != null) {
if (ops == SearchOperation.IS && value == SearchOperation.EMPTY) {
return builder.isEmpty(path[fieldName])
}
if (ops == SearchOperation.IS_NOT && value != null) {
if (ops == SearchOperation.IS_NOT && value == SearchOperation.EMPTY) {
return builder.isNotEmpty(path[fieldName])
}
throw IllegalArgumentException("Unsupported operation $ops for collection field $fieldName, only IS and IS_NOT are supported")
throw ResponseStatusException(HttpStatus.BAD_REQUEST,
"Unsupported operation $ops $value for collection field $fieldName, " +
"only IS EMPTY and IS NOT EMPTY are supported"
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class DateStrategy : ParsingStrategy {
}

override fun parse(value: String?, fieldClass: KClass<out Any>): Any? {
if (value == SearchOperation.NULL) return value
return standardDateFormat.parse(value)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class DoubleStrategy : ParsingStrategy {
}

override fun parse(value: String?, fieldClass: KClass<out Any>): Any? {
if (value == SearchOperation.NULL) return value
return value?.toDouble()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class DurationStrategy : ParsingStrategy {
}

override fun parse(value: String?, fieldClass: KClass<out Any>): Any? {
if (value == SearchOperation.NULL) return value
return Duration.parse(value)
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.sipios.springsearch.strategies

import com.sipios.springsearch.SearchOperation
import kotlin.reflect.KClass

class EnumStrategy : ParsingStrategy {
override fun parse(value: String?, fieldClass: KClass<out Any>): Any? {
if (value == SearchOperation.NULL) return value
return Class.forName(fieldClass.qualifiedName).getMethod("valueOf", String::class.java).invoke(null, value)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class FloatStrategy : ParsingStrategy {
}

override fun parse(value: String?, fieldClass: KClass<out Any>): Any? {
if (value == SearchOperation.NULL) return value
return value?.toFloat()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class InstantStrategy : ParsingStrategy {
}

override fun parse(value: String?, fieldClass: KClass<out Any>): Any? {
if (value == SearchOperation.NULL) return value
return Instant.parse(value)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class IntStrategy : ParsingStrategy {
}

override fun parse(value: String?, fieldClass: KClass<out Any>): Any? {
if (value == SearchOperation.NULL) return value
return value?.toInt()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class LocalDateStrategy : ParsingStrategy {
}

override fun parse(value: String?, fieldClass: KClass<out Any>): Any? {
if (value == SearchOperation.NULL) return value
return LocalDate.parse(value)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class LocalDateTimeStrategy : ParsingStrategy {
}

override fun parse(value: String?, fieldClass: KClass<out Any>): Any? {
if (value == SearchOperation.NULL) return value
return LocalDateTime.parse(value)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class LocalTimeStrategy : ParsingStrategy {
}

override fun parse(value: String?, fieldClass: KClass<out Any>): Any? {
if (value == SearchOperation.NULL) return value
return LocalTime.parse(value)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import java.util.Date
import java.util.UUID
import kotlin.reflect.KClass
import kotlin.reflect.full.isSubclassOf

import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
interface ParsingStrategy {
/**
* Method to parse the value specified to the corresponding strategy
Expand Down Expand Up @@ -59,6 +60,27 @@ interface ParsingStrategy {
builder.not(inClause)
}

SearchOperation.IS -> {
if (value == SearchOperation.NULL) {
builder.isNull(path.get<Any>(fieldName))
} else {
// we should not call parent method for collection fields
// so this makes no sense to search for EMPTY with a non-collection field
throw ResponseStatusException(HttpStatus.BAD_REQUEST,
"Unsupported operation $ops $value for field $fieldName")
}
}
SearchOperation.IS_NOT -> {
if (value == SearchOperation.NULL) {
builder.isNotNull(path.get<Any>(fieldName))
} else {
// we should not call parent method for collection fields
// so this makes no sense to search for NOT EMPTY with a non-collection field
throw ResponseStatusException(HttpStatus.BAD_REQUEST,
"Unsupported operation $ops $value for field $fieldName")
}
}

SearchOperation.EQUALS -> builder.equal(path.get<Any>(fieldName), value)
SearchOperation.NOT_EQUALS -> builder.notEqual(path.get<Any>(fieldName), value)
SearchOperation.STARTS_WITH -> builder.like(path[fieldName], "$value%")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.sipios.springsearch.strategies

import com.sipios.springsearch.SearchOperation
import java.util.UUID
import kotlin.reflect.KClass

class UUIDStrategy : ParsingStrategy {
override fun parse(value: String?, fieldClass: KClass<out Any>): Any? {
if (value == SearchOperation.NULL) return value
return UUID.fromString(value)
}
}
Loading

0 comments on commit 8e23f5f

Please sign in to comment.