Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/is null #82

Merged
merged 28 commits into from
Jun 20, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
bd97d3a
feat: is null/is not null
Jan 12, 2024
4299470
Merge branch 'feat/add-between' into feat/is-null
Jan 17, 2024
6af7374
fix: search null for collections
Jan 17, 2024
446fb05
refactor: null not case sensitive
Jan 17, 2024
8f45975
doc: update README.md
Jan 17, 2024
3c118a1
Merge branch 'feat/refactor' into feat/is-null
reifocS Jan 22, 2024
df52d0d
refactor: throw if operand != IS | IS NOT
reifocS Jan 22, 2024
f363a56
Merge branch 'feat/gte-string' into feat/is-null
reifocS Jan 22, 2024
7887789
refactor: throw if calling buildPredicate parent method with empty cl…
reifocS Jan 22, 2024
00615a2
refactor: don't allow search for null collections
reifocS Jan 22, 2024
17b189f
refactor: don't allow search for null collections
reifocS Jan 22, 2024
9f0107f
fix: lint
reifocS Jan 22, 2024
4bbb7a7
test: add test for searching for empty non-collection fields
reifocS Jan 22, 2024
d03db3d
refactor: avoid passing null value, check literal value instead (Sear…
reifocS Jan 23, 2024
a68662f
fix: add missing check for "NULL" value in strategies
reifocS Jan 23, 2024
4df5299
Merge branch 'master' into feat/is-null
reifocS May 6, 2024
8223e61
fix: update threshold
reifocS May 6, 2024
a9927b2
fix: remove unreachable code
reifocS May 6, 2024
ae8e7bc
test: add test for UUID null
reifocS May 15, 2024
e6e45c2
test: canGetUsersWithUpdatedDateAtNull
reifocS May 15, 2024
131c7ab
test: LocalDateTime is not null
reifocS May 16, 2024
f145cd5
test: Int Boolean and Date is null
reifocS May 16, 2024
6e8aba2
trigger-ci
reifocS May 16, 2024
e1aace9
trigger-ci
reifocS May 16, 2024
3197797
Merge branch 'master' into feat/is-null
reifocS Jun 20, 2024
31c675e
fix: ktlint
reifocS Jun 20, 2024
dee880f
fix: ktlint
reifocS Jun 20, 2024
8355580
fix: ktlint
reifocS Jun 20, 2024
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
84 changes: 32 additions & 52 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,67 +124,47 @@ 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` |


## 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' | 'NULL'
;

STRING
: '"' DoubleStringCharacter* '"'
| '\'' SingleStringCharacter* '\''
Expand Down
12 changes: 9 additions & 3 deletions src/main/kotlin/com/sipios/springsearch/QueryVisitorImpl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,19 @@
}

override fun visitIsCriteria(ctx: QueryParser.IsCriteriaContext): Specification<T> {
val key = ctx.key().text
val key = ctx.key()!!.text
reifocS marked this conversation as resolved.
Show resolved Hide resolved
val op = if (ctx.IS() != null) {
SearchOperation.IS
} else {
} else if (ctx.IS_NOT() != null) {
SearchOperation.IS_NOT
} else {
throw IllegalArgumentException("Invalid operation")

Check warning on line 40 in src/main/kotlin/com/sipios/springsearch/QueryVisitorImpl.kt

View check run for this annotation

Codecov / codecov/patch

src/main/kotlin/com/sipios/springsearch/QueryVisitorImpl.kt#L40

Added line #L40 was not covered by tests
}
val value = if (ctx.is_value.NULL() != null) {
null
reifocS marked this conversation as resolved.
Show resolved Hide resolved
} else {
ctx.is_value.text
}
val value = ctx.is_value!!.text
return toSpec(key, op, value)
}

Expand Down
1 change: 1 addition & 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,7 @@ enum class SearchOperation {
val AND_OPERATOR = "AND"
val LEFT_PARANTHESIS = "("
val RIGHT_PARANTHESIS = ")"
val EMPTY = "EMPTY"

/**
* Parse a string into an operation.
Expand Down
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 @@ -4,6 +4,7 @@ import kotlin.reflect.KClass

class EnumStrategy : ParsingStrategy {
override fun parse(value: String?, fieldClass: KClass<out Any>): Any? {
if (value == null) return null
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 @@ -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 == null) {
reifocS marked this conversation as resolved.
Show resolved Hide resolved
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 == null) {
reifocS marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Up @@ -1283,6 +1283,31 @@ class SpringSearchApplicationTest {
Assertions.assertTrue(users.isEmpty())
}

@Test
fun cantSearchForEmptyWithNonFieldProperties() {
val johnBook = Book()
val john = Author()
john.name = "john"
john.addBook(johnBook)
authorRepository.save(john)
val janeBook = Book()
val jane = Author()
jane.name = "jane"
jane.addBook(janeBook)
authorRepository.save(jane)
val specification = SpecificationsBuilder<Author>(
SearchSpec::class.constructors.first().call("", false)
).withSearch("name IS EMPTY").build()
Assertions.assertThrows(
ResponseStatusException::class.java
) { authorRepository.findAll(specification) }
val specification2 = SpecificationsBuilder<Author>(
SearchSpec::class.constructors.first().call("", false)
).withSearch("name IS NOT EMPTY").build()
Assertions.assertThrows(
ResponseStatusException::class.java
) { authorRepository.findAll(specification2) }
}
@Test
fun canGetAuthorsWithEmptyBookWithResult() {
val johnBook = Book()
Expand Down Expand Up @@ -1352,6 +1377,27 @@ class SpringSearchApplicationTest {
val users = authorRepository.findAll(specification)
Assertions.assertTrue(users.size == 0)
}
@Test
fun cantGetAuthorsWithBooksNull() {
val john = Author()
john.name = "john"
authorRepository.save(john)
val jane = Author()
jane.name = "jane"
authorRepository.save(jane)
val spec = SpecificationsBuilder<Author>(
SearchSpec::class.constructors.first().call("", false)
).withSearch("books IS null").build()
Assertions.assertThrows(
ResponseStatusException::class.java
) { authorRepository.findAll(spec) }
val specNotNull = SpecificationsBuilder<Author>(
SearchSpec::class.constructors.first().call("", false)
).withSearch("books IS NOT null").build()
Assertions.assertThrows(
ResponseStatusException::class.java
) { authorRepository.findAll(specNotNull) }
}

@Test
fun canGetUsersWithNumberOfChildrenBetween() {
Expand Down Expand Up @@ -1519,4 +1565,34 @@ class SpringSearchApplicationTest {
).withSearch("userId IN [").build()
}
}

@Test
fun canGetUsersWithNullColumn() {
userRepository.save(Users(userFirstName = "john", type = null))
userRepository.save(Users(userFirstName = "jane", type = UserType.ADMINISTRATOR))
userRepository.save(Users(userFirstName = "joe", type = UserType.MANAGER))
userRepository.save(Users(userFirstName = "jean", type = null))
val specification = SpecificationsBuilder<Users>(
SearchSpec::class.constructors.first().call("", false)
).withSearch("type IS null").build()
val users = userRepository.findAll(specification)
Assertions.assertEquals(2, users.size)
val setNames = users.map { user -> user.userFirstName }.toSet()
Assertions.assertEquals(setOf("john", "jean"), setNames)
}

@Test
fun canGetUsersWithNotNullColumn() {
userRepository.save(Users(userFirstName = "john", type = null))
userRepository.save(Users(userFirstName = "jane", type = UserType.ADMINISTRATOR))
userRepository.save(Users(userFirstName = "joe", type = UserType.MANAGER))
userRepository.save(Users(userFirstName = "jean", type = null))
val specification = SpecificationsBuilder<Users>(
SearchSpec::class.constructors.first().call("", false)
).withSearch("type IS NOT null").build()
val users = userRepository.findAll(specification)
Assertions.assertEquals(2, users.size)
val setNames = users.map { user -> user.userFirstName }.toSet()
Assertions.assertEquals(setOf("jane", "joe"), setNames)
}
}