Skip to content

Commit

Permalink
feat(fieldParser): support to write native sql queries (#16)
Browse files Browse the repository at this point in the history
* feat(fieldParser): support to write native sql queries

* commit badge

---------

Co-authored-by: Verissimo Ribeiro <[email protected]>
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Sep 12, 2023
1 parent 2803d85 commit 7fcd52c
Show file tree
Hide file tree
Showing 29 changed files with 1,501 additions and 321 deletions.
2 changes: 1 addition & 1 deletion .github/badges/branches.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion .github/badges/jacoco.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 41 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,10 +165,7 @@ where u.name like '%Matthew%' and u.age > 23 and c.name = 'London'
| NOT_IN | _not_in | `?age_not_in=23,24,25,26` |
| IS_NULL | _is_null | `?age_is_null` |
| IS_NOT_NULL | _is_not_null | `?age_is_not_null` |

### GREATER_THAN / LESS_THAN vs Between

There is no support for `between`. You can achieve it by using the combination of a `GREATER_THAN` and `LESS_THAN`.
| BETWEEN | _is_between | `?age_is_between=22,30` |

### LIKE vs LIKE_EXP

Expand Down Expand Up @@ -235,6 +232,24 @@ Execute a GET call using:
// more info: https://docs.spring.io/spring-data/rest/docs/current/reference/html/#paging-and-sorting.sorting
```

# Groups and combine Operators

It's supported to change the operator. To do so, add `or__` at the start of your parameter.

Eg.: the following expression:
`?success=1&or__active=1`, would render as:
`success = 1 or active = 1`

It's also supported groups, by adding a double underscore and a number at the end of the parameter.

Each group will be rendered inside a 'and/or' criteria.

Eg.: the following expression:
`?name__1=Joe&age__1=35&success__2=1&active__2=1`, would render as:
`(name = 'Joe' and age = 35) and (success = 1 and active = 1)`

The concatenation of groups is done by `&searchType=and` or `&searchType=or`.

# Integrate with other business rules

Let's say there is in place a rule that says: `Users can only see users that are in the same city`.
Expand Down Expand Up @@ -273,6 +288,28 @@ val filter = mapOf(
).toR2dbcMagicFilter()
```

## Writing native sql queries

Spring R2dbc doesn't have support to joins 🫠. When this is required, then it's needed to use native queries.

```kotlin
@Autowired protected lateinit var template: R2dbcEntityTemplate
@Autowired protected lateinit var converter: MappingR2dbcConverter

val sqlBinder = r2dbcMagicFilter.toSqlBinder(ReactiveUser::class.java, "t")
val sql = """
SELECT t.*
FROM user t
LEFT JOIN city c ON c.id = t.city_id
WHERE c.country = :country ${sqlBinder?.sql}
"""
val rows = template.databaseClient.sql(sql, sqlBinder)
.bind("country", "US")
.map { row, metadata -> converter.read(ReactiveUser::class.java, row, metadata) }
.all()
.asFlow()
```

## Advanced Postgres Function

Execute the following piece of code on your db, [more info](https://stackoverflow.com/a/11007216/5795553):
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ plugins {
}

group = "io.github.verissimor.lib"
version = System.getenv("RELEASE_VERSION") ?: "1.0.6-SNAPSHOT"
version = System.getenv("RELEASE_VERSION") ?: "1.0.7-SNAPSHOT"

java {
sourceCompatibility = JavaVersion.VERSION_1_8
Expand Down
120 changes: 120 additions & 0 deletions src/main/kotlin/io/github/verissimor/lib/fieldparser/FieldParser.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package io.github.verissimor.lib.fieldparser

import io.github.verissimor.lib.fieldparser.domain.CombineOperator
import io.github.verissimor.lib.fieldparser.domain.CombineOperator.AND
import io.github.verissimor.lib.fieldparser.domain.CombineOperator.OR
import io.github.verissimor.lib.fieldparser.domain.FieldType
import io.github.verissimor.lib.fieldparser.domain.FieldType.Companion.toFieldType
import io.github.verissimor.lib.fieldparser.domain.FieldType.ENUMERATED
import io.github.verissimor.lib.fieldparser.domain.FieldType.INSTANT
import io.github.verissimor.lib.fieldparser.domain.FieldType.LOCAL_DATE
import io.github.verissimor.lib.fieldparser.domain.FieldType.NUMBER
import io.github.verissimor.lib.fieldparser.domain.FieldType.UUID
import io.github.verissimor.lib.fieldparser.domain.FilterOperator
import io.github.verissimor.lib.fieldparser.domain.ParsedField
import io.github.verissimor.lib.fieldparser.domain.ValueParser.Companion.parseStringIntoList
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.lang.reflect.Field

object FieldParser {

private val log: Logger = LoggerFactory.getLogger(this::class.java)

fun parseFields(params: Map<String, List<String>?>, clazz: Class<*>): List<ParsedField> {
return params.mapNotNull { (field, value) ->

val parsedField = parseField(field, value, clazz)
parsedField.validate()

if (parsedField.getStringOrNull() == null && !parsedField.filterOperator.allowNullableValue) {
log.debug("Ignoring parameter $field - value is null (you can use _is_null)")
return@mapNotNull null
}

if (parsedField.fieldClass == null) {
log.debug("Ignoring parameter $field - field not found")
return@mapNotNull null
}

parsedField
}
}

private fun parseField(field: String, value: List<String>?, clazz: Class<*>): ParsedField {
val group: Int = parseGroup(field)
val combineOperator: CombineOperator = if (field.startsWith("or__")) OR else AND
val normalized = normalize(field, group)

val filterOperator = fieldToFilterOperator(normalized)
val resolvedFieldName = resolveFieldName(normalized, filterOperator)
val fieldClass: Field? = fieldToClass(resolvedFieldName, clazz)

val resolvedOperator = overloadFilterOperator(filterOperator, value, fieldClass)

return ParsedField(resolvedOperator, resolvedFieldName, fieldClass, value, group, combineOperator)
}

private fun normalize(field: String, group: Int) = field.trim()
.replace("[]", "") // remove array format of a few js libraries
.replace("__$group", "") // remove the group
.let { if (it.startsWith("or__")) it.replace("or__", "") else it } // remove the or

private fun fieldToFilterOperator(field: String): FilterOperator {
val type = FilterOperator.values()
.sortedByDescending { it.suffix.length }
.firstOrNull { field.lowercase().endsWith(it.suffix) } ?: FilterOperator.EQUAL

return type
}

private fun overloadFilterOperator(
filterOperator: FilterOperator,
value: List<String>?,
fieldClass: Field?
): FilterOperator {
val shouldTryOverload = value != null && filterOperator == FilterOperator.EQUAL
val fieldType: FieldType? = fieldClass.toFieldType()

if (shouldTryOverload && value!!.size == 2 && (fieldType == LOCAL_DATE || fieldType == INSTANT)) {
return FilterOperator.BETWEEN
}

if (shouldTryOverload && value!!.size > 1) {
return FilterOperator.IN
}

val shouldTrySplitComma = value!!.size == 1 && listOf(ENUMERATED, NUMBER, LOCAL_DATE, UUID).contains(fieldType)
if (shouldTryOverload && shouldTrySplitComma && value.firstOrNull()!!.contains(",")) {
val list = parseStringIntoList(value.firstOrNull()!!)
if (list?.isNotEmpty() == true)
return FilterOperator.IN
}

return filterOperator
}

private fun resolveFieldName(field: String, type: FilterOperator?) =
type?.let { field.replace(it.suffix, "") } ?: field

private fun fieldToClass(field: String, root: Class<*>): Field? {
var resultField: Field? = null
field.split(".")
.forEach { fieldP ->
resultField = if (resultField == null) {
root.declaredFields.firstOrNull { it.name == fieldP }
} else {
resultField?.type?.declaredFields?.firstOrNull { it.name == fieldP }
}
}

return resultField
}

internal fun parseGroup(field: String): Int {
val regex = "__(\\d+)$".toRegex()
val matchResult = regex.find(field)

return matchResult?.groupValues?.get(1)?.toIntOrNull() ?: 0
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package io.github.verissimor.lib.fieldparser.domain

enum class CombineOperator {
AND, OR;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package io.github.verissimor.lib.fieldparser.domain

import io.github.verissimor.lib.fieldparser.domain.FilterOperator.BETWEEN
import io.github.verissimor.lib.fieldparser.domain.FilterOperator.GREATER_THAN
import io.github.verissimor.lib.fieldparser.domain.FilterOperator.GREATER_THAN_EQUAL
import io.github.verissimor.lib.fieldparser.domain.FilterOperator.LESS_THAN
import io.github.verissimor.lib.fieldparser.domain.FilterOperator.LESS_THAN_EQUAL
import java.lang.reflect.Field
import java.math.BigDecimal
import java.time.Instant
import java.time.LocalDate

enum class FieldType {
ENUMERATED,
NUMBER,
LOCAL_DATE,
INSTANT,
BOOLEAN,
UUID,
GENERIC;

companion object {
val comparisonOperators = listOf(GREATER_THAN, GREATER_THAN_EQUAL, LESS_THAN, LESS_THAN_EQUAL, BETWEEN)
val comparisonTypes = listOf(NUMBER, LOCAL_DATE, INSTANT)

fun Field?.toFieldType(): FieldType? = when {
this == null -> null
this.type?.superclass?.name == "java.lang.Enum" -> ENUMERATED
this.type == LocalDate::class.java -> LOCAL_DATE
this.type == Instant::class.java -> INSTANT
this.type == Boolean::class.java ||
// this solves conflicts between kotlin/java
this.type.name == "java.lang.Boolean" -> BOOLEAN

this.type == java.util.UUID::class.java -> UUID

this.type == Int::class.java ||
this.type == Long::class.java ||
this.type == BigDecimal::class.java ||
this.type.isAssignableFrom(Number::class.java) ||
// this solves conflicts between kotlin/java
this.type.name == "java.lang.Integer" ||
this.type.name == "java.math.BigDecimal" ||
this.type.name == "java.lang.Long" ||
this.type.isAssignableFrom(java.lang.Number::class.java) -> NUMBER

else -> GENERIC
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package io.github.verissimor.lib.jpamagicfilter.domain
package io.github.verissimor.lib.fieldparser.domain

enum class FilterOperator(val suffix: String) {
enum class FilterOperator(val suffix: String, val allowNullableValue: Boolean = false) {
GREATER_THAN("_gt"),
GREATER_THAN_EQUAL("_ge"),
LESS_THAN("_lt"),
Expand All @@ -14,8 +14,8 @@ enum class FilterOperator(val suffix: String) {
IN("_in"),
NOT_IN("_not_in"),

IS_NULL("_is_null"),
IS_NOT_NULL("_is_not_null"),
IS_NULL("_is_null", true),
IS_NOT_NULL("_is_not_null", true),

BETWEEN("_is_between"),

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.github.verissimor.lib.fieldparser.domain

import io.github.verissimor.lib.fieldparser.domain.FieldType.Companion.comparisonOperators
import io.github.verissimor.lib.fieldparser.domain.FieldType.Companion.comparisonTypes
import io.github.verissimor.lib.fieldparser.domain.FieldType.Companion.toFieldType
import io.github.verissimor.lib.fieldparser.domain.FilterOperator.BETWEEN
import java.lang.reflect.Field

data class ParsedField(
val filterOperator: FilterOperator,
val resolvedFieldName: String,
val fieldClass: Field?,
val sourceValue: List<String>?,
val group: Int,
val combineOperator: CombineOperator,
) : ValueParser(resolvedFieldName, sourceValue) {

fun getFieldType(): FieldType? = fieldClass.toFieldType()

fun validate() {
if (comparisonOperators.contains(filterOperator) && !comparisonTypes.contains(getFieldType())) {
error("The field $resolvedFieldName is not compatible with operator $filterOperator")
}

if (filterOperator == BETWEEN && getListStringOrNull()?.size != 2) {
error("The field $resolvedFieldName uses the $filterOperator which requires 2 parameters $sourceValue")
}
}
}
Loading

0 comments on commit 7fcd52c

Please sign in to comment.