gorm.tools.mango.api.QueryArgs.groovy Maven / Gradle / Ivy
The newest version!
/*
* Copyright 2021 Yak.Works - Licensed under the Apache License, Version 2.0 (the "License")
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*/
package gorm.tools.mango.api
import java.net.http.HttpRequest
import groovy.json.JsonParserType
import groovy.json.JsonSlurper
import groovy.transform.CompileStatic
import groovy.transform.builder.Builder
import groovy.transform.builder.SimpleStrategy
import groovy.util.logging.Slf4j
import gorm.tools.beans.Pager
import yakworks.api.HttpStatus
import yakworks.api.problem.data.DataProblem
import yakworks.api.problem.data.DataProblemException
import yakworks.commons.map.Maps
import yakworks.json.groovy.JsonEngine
import static gorm.tools.mango.MangoOps.CRITERIA
import static gorm.tools.mango.MangoOps.QSEARCH
/**
* Builder arguments for a query to pass from controllers etc to the MangoQuery
* Can think of it a bit like a SQL query.
*
* This holds
* - what we want to select (includes)
* - projections if it s projection query (projections)
* - the where conditions (criteria)
* - the orderBy (sort)
* - and how we want the paging to be handled (pager), number of items per page, etc..
*
* contains some intermediary fields such as 'q' that are used to parse it into what we need
*/
@Builder(
builderStrategy=SimpleStrategy, prefix="",
includes=['strict', 'projections', 'select', 'timeout', 'uri'],
useSetters=true
)
@Slf4j
@CompileStatic
class QueryArgs {
List ignoreKeys = ['controller', 'action', 'format', 'nd', '_search', 'includes', 'includesKey' ]
/**
* the alias for the root entity of the query.
* MangoDetachedCriteria will default to entity name with "_" suffix ("${entityClass.simpleName}_")
* NOT USED, POC
*/
//String rootAlias
/**
* extra closure that can be passed to MangoCriteria
* future
*/
// Closure closure
/**
* The HttpRequest if there is one. Can be used for logging and can use uri.query for cache key
* not required and wont be set for internal usage
*/
URI uri
/**
* The source original params that were used to build this
*/
Map originalParams
/**
* when true in build method, will only add params that are under q.
* When false(default) and no q is present in the params
* then build will add any param that are not special(like max, sort, page, etc)
* into q as a criteria param.
*/
boolean strict = false
private void setStrict(boolean v) {
ensureNotBuilt()
strict = v
}
/**
* when true, then q criteria is required and will fail if its not provided so it cant list without it
*/
// boolean qRequired = false
// private void setQRequired(boolean v) {
// ensureNotBuilt()
// qRequired = v
// }
/**
* Criteria map to pass to the MangoBuilder. when QueryArgs is built from query params, then this is the q=...
* This is the one to modify if making changes in an override.
*/
private Map criteriaMap = [:] as Map
/**
* The Mango criteriaMap
*/
Map getCriteriaMap() {
return this.criteriaMap
}
/**
* alias to criteriaMap
*/
@Deprecated
Map getqCriteria() {
return this.criteriaMap
}
/**
* The Pager instance for paged list queries
*/
private Pager pager
Pager getPager() {
pager
}
/**
* holder for sort configuration to make it easier to grok
* The key is the field, can be dot path for nested like foo.bar.baz
* The value is either 'asc' or 'desc'
*/
Map sort
/**
* holder for projections
* The key is the field, can be dot path for nested like foo.bar.baz
* The value is one of 'group', 'sum', 'count'
*/
Map projections
/**
* holder for select list
*/
List select
/**
* Query timeout in seconds. If value is set, the timeout would be set on hibernate query/criteria instance.
*/
Integer timeout = 0
private boolean isBuilt = false
/**
* Construct from a pager
* DOES NOT BUILD
*/
static QueryArgs withPager(Pager pager){
def qa = new QueryArgs()
qa.pager = pager
return qa
}
/**
* Construct AND Build from a controller style params object where each key has as string value
* just as if it came from a url
*/
static QueryArgs of(Map params){
def qa = new QueryArgs()
return qa.build(params)
}
/**
* Construct from a mango closure
* Future concept
*/
// static QueryArgs of(@DelegatesTo(MangoDetachedCriteria) Closure closure){
// def qa = new QueryArgs()
// return qa.query(closure)
// }
/**
* Construct with projections, used mostly for testing
* Does not build, should call .build after if more is needed
* Future concept
*/
// static QueryArgs withProjections(Map projs){
// def qa = new QueryArgs()
// return qa.projections(projs)
// }
/**
* Intelligent defaults to setup the criteria and pager from the controller style params map
*
* - looks for q param and parse if json object (starts with {)
* - or sets up the $qSearch map if its text
* - if qSearch is provided as separate param along with q then adds it as a $qSearch
* - for any of above options for $qSearch sets up object with configured qSearchIncludes
*
* Pager
* - if pager key is passed in then uses that
* - removes 'max', 'offset', 'page' and sets up pager object if not passed in
*
* Sort and Order
* - sets up the sort map if sort or order key are passed in
*
* Translates q from json
* example params= [q: "{foo: 'test*'}", sort:'foo', page: 2, offset: 10]
* criteria= [foo:'test*'], sort=[foo: 'asc'] and pager will be setup
*
* Transalates qSearch fields when q is a string
* params= [q: "foo", qSearchFields:['name', 'num']]
* criteria= [$q: [text: "foo", 'fields': ['name', 'num']
*
* @param paramsMap the params to parse, this is clones and no changes will be made to it
*
* @return this instance
*/
QueryArgs build(Map paramsMap){
if(isBuilt) throw new UnsupportedOperationException("build has already been called and cant be called again")
//keep ref to the orginalParams in case we need it later. can be used for debugging too
originalParams = paramsMap
//copy it
Map params = Maps.clone(paramsMap) as Map
//remove the fields that grails adds for controller and action
params.removeAll {it.key in ignoreKeys }
// pull out the max, page and offset and assume the rest is criteria,
// if pager is already set then we do nothing with the pagerMap
Map pagerMap = [:]
['max', 'offset', 'page'].each{ String k ->
if(params.containsKey(k)) pagerMap[k] = params.remove(k)
}
// if no pager was set then use what we just removed to set one up
if(!pager) pager = Pager.of(pagerMap)
//sorts and orderBy
String orderBy = params.remove('order') ?: 'asc' //FIXME this is legacy concept
def sortField = params.remove('sort')
if(sortField) sort = buildSort(sortField, orderBy)
//projections
def projField = params.remove('projections')
if(projField) projections = buildProjections(projField)
//projections
var selField = params.remove('select')
if(selField) select = buildSelectList(selField)
// check for and remove the q param
// whatever is in q if its parsed as a map and set to the criteria so it overrides everything
def qParam = params.q ? params.remove('q') : params.remove(CRITERIA)
if(qParam && qParam instanceof String) qParam = qParam.trim()
if(qParam) {
if (qParam instanceof String) {
String qString = qParam as String
//FIXME
//if q=* just put it as QSEARCH, it will get removed whn building criteria
//Its used by Rest tests, otherwise because of qRequired, rests tests cant query without passing any criterias
if(qString.trim() == "*") {
criteriaMap[QSEARCH] = qString
} else {
//if the q param start with { then assume its json and parse it
//the parsed map will be set to the criteria.
criteriaMap = parseJson(qString, Map)
//clone so it can me modified later
criteriaMap = Maps.clone(criteriaMap)
}
}
//as is, mostly for testing and programtic stuff
else if(qParam instanceof Map) {
criteriaMap = qParam as Map
}
}
//if no q was passed in then use whatever is left in the params as the criteria if strict is false
else if(!strict){
//FIXME should we not be making a copy of this?
criteriaMap = params
}
//now check if qSearch was passed as a separate param and its doesn't already exists in the criteria
String qSearchParam = params.remove('qSearch')
if(qSearchParam && !criteriaMap.containsKey(QSEARCH)){
criteriaMap[QSEARCH] = qSearchParam
}
//set that it was built
isBuilt = true
//validate if qRequired
//if(qRequired) validateQ()
return this
}
/**
* builds a COPY of qCrieria merged with sort if it exists and removes the $qSearch=* if it exists
*/
Map buildCriteriaMap(){
ensureBuilt()
//OLD kept for ref for now
// Map criterium = criteriaMap
// if sort was populated, add it to the criteria with the $sort if its doesn't exist
// if(sort && !qCriteria.containsKey(SORT) ) {
// criterium = qCriteria + ([(SORT): sort] as Map)
// }
//FIXME make a shallow copy for now to keep it like how it was,
// not sure if this is really needed but if changes are made to it after this call it wont mess with the criteriaMap
return criteriaMap + ([:] as Map)
}
/**
* Throws IllegalArgumentException if qRequired is true.
* This forces it to pick up the q params in case it accidentally or inadvertantly dropped off.
* Can bypass this by passing in q=* or qSearch=*
* @throws DataProblemException
*/
QueryArgs validateQ(boolean qRequired){
ensureBuilt()
//put in initially because we loose params query parsing / lost params issue is fixed - See #1924
if(qRequired && !criteriaMap){
throw DataProblem.of('error.query.qRequired')
.status(HttpStatus.I_AM_A_TEAPOT) //TODO 418 error for now so its easy to add to retry as it gets droppped sometimes
.title("q or qSearch parameter restriction is required").toException()
}
return this
}
/**
* Applies Default sort by id:asc if no sort is provided in params
* Does not apply default sort if
* - if $sort is provided in `q` criteria, then during the build it will use that and ignore the sort property
* - If params has projections - because then there's no id column.
* In this case, if required, params should explicitely pass sort
*
* when paging we need a sort so rows dont show up next page see https://github.com/9ci/domain9/issues/2280
*/
QueryArgs defaultSortById() {
ensureBuilt()
if(!sort && !projections) {
sort = ['id':'asc']
}
return this
}
/**
* if the string is known to be json then parse the json and returns the map
*/
static T parseJson(String text, Class clazz) {
try {
//jsonSlurper LAX allows fields to not be quoted
JsonSlurper jsonSlurper = new JsonSlurper().setType(JsonParserType.LAX)
Object parsedObj = jsonSlurper.parseText(text)
JsonEngine.validateExpectedClass(clazz, parsedObj)
return (T)parsedObj
} catch (ex) {
//JsonException
throw DataProblem.of('error.query.invalid')
.detail("Invalid JSON. Error parsing query - $ex.message")
.toException()
}
}
/**
* parses the sort string. if its just a simple string without , or : then creates a
* asc sort map. if its starts with { then parses as json.
* sort string should be in one of the following formats
* - simple field name such as 'name'
* - field seperated by : such as 'name:desc'
* - multiple fields seperated by comma, ex: 'num:asc, name:desc'
* - json in same format as above, ex '{num:"asc", name:"desc"}' but parses simply by stripping out the { and "
*
* @param sortObj see above for valid options
* @param orderBy only relevant if sortText is a single sort string with field name
* @return the sort Map or null if failed
*/
protected Map buildSort(Object sortObj, String orderBy = 'asc'){
if(sortObj instanceof Map) {
return sortObj
} else if(sortObj instanceof String) {
//make sure its trimmed
String sortText = sortObj.trim()
Map sortMap = [:] as Map
//sort just looks like json in case api programmer wants to be consistent.
//but its a query param and we really expect it in the format like sort=foo:asc,bar:desc
// so we convert something passes as json like q={"foo":"asc","bar":"desc"} by simply stripping out the " and the {
// We DONT use json pareser since it messes up the order, and the order matters here.
sortText = sortText.replaceAll(/[}{'"]/, "")
//will only be one item in list if no ',' token
List sortList = sortText.tokenize(',')*.trim() as List
for (String sortEntry : sortList) {
if (sortEntry.contains(':')) {
List sortTokens = sortEntry.tokenize(':')*.trim() as List
sortMap[sortTokens[0]] = sortTokens[1]
} else if (sortEntry.contains(' ')){
//could be in format sort:"foo desc,bar asc"
String[] sorting = sortEntry.trim().split(" ")
sortMap[(sorting[0])] = sorting[1] ?: orderBy
}
else {
//its should just a field name
sortMap[sortEntry] = orderBy
}
}
return sortMap
} else {
log.error("sort argument must be map or string")
return [:]
}
}
/**
* parses the projection string. If it start with { and will parse as json.
* parse string should be in one of the following formats
* - fields seperated by comma, ex: 'type:group,calc.totalDue:sum'
* - json in same format as above, ex '{type:"group", "calc.totalDue":"sum"}'
*
* @param projText see above for valid options
* @return the projection Map or null if failed
*/
protected Map buildProjections(Object projectionsObj){
if(projectionsObj instanceof Map) {
return projectionsObj
} else if(projectionsObj instanceof String){
//make sure its trimmed
String projText = (projectionsObj as String).trim()
//for convienience we allow the { to be left off so we add it if it is
if (!projText.startsWith('{')) projText = "{$projText}"
// clone since parseText returns LazyValueMap which will throw `Not that kind of map` when trying to add new key
Map projMap = Maps.clone(parseJson(projText, Map))
return projMap
} else {
log.error("projection argument must be map or string")
return [:]
}
}
/**
* If its a list then just returns it.
* Otherwise parses the select string. If it start with [ and is a string it will parse as json.
*
* parse string should be in one of the following formats
* - fields seperated by comma, ex: 'id,name,num,foo.bar'
* - json in same format as above, ex '["id","name"]'
*
* @param qSelect see above for valid options
* @return the list or null if failed
*/
protected List buildSelectList(Object qSelect){
if(qSelect instanceof List) {
return qSelect
} else if(qSelect instanceof String){
//make sure its trimmed
String selectText = (qSelect as String).trim()
//for convenience we allow the [ to be left off so we add it if it is
if (!selectText.startsWith('[')) selectText = "[$selectText]"
List parsedList = (List)parseJson(selectText, List)
return parsedList
} else {
log.error("projection argument must be map or string")
return [] as List
}
}
// throws error if its not built yet
private void ensureBuilt(){
if(!isBuilt) throw new UnsupportedOperationException("Can only be called after this has been built")
}
// throws error if its already built
private void ensureNotBuilt(){
if(isBuilt) throw new UnsupportedOperationException("Can't be called after its already built")
}
// QueryArgs query(@DelegatesTo(MangoDetachedCriteria) Closure closure) {
// this.closure = closure
// return this
// }
/**
* looks for the qsearch fields for this entity and returns the map
* like [text: "foo", 'fields': ['name', 'num']]
* if no qSearchFields then its just returns [text: "foo"]
*/
// Map makeQSearchMap(String searchText){
// Map qMap = [text: searchText] as Map
// if (qSearchFields) {
// qMap['fields'] = qSearchFields
// }
// return qMap
// }
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy