/* Copyright 2010 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Provides a DSL for building and managing HQL strings. * For more usage examples see the HqlBuilderTests. * HQL reference see http://docs.jboss.org/hibernate/core/3.6/reference/en-US/html/queryhql.html * * DML reference see http://docs.jboss.org/hibernate/core/3.6/reference/en-US/html/batch.html#batch-direct * DML-style clauses: "update, delete, insert into" may not be well tested, the "set" clause has basic implementation. * So double check the q.query output for these cases. * * Primary goals: * 1. Easy to read and understand in code. * 2. Easy to read and understand when printed (e.g when displayed in a report). * 3. Easy to execute with correct paginateParams and namedParams. * 4. Easy to change a clause and execute again. * * Basic usage: * def q = new HqlBuilder().query { * select 'count(distinct book)' * from 'Book as book' * where 'book.id > 100' * and 'book.inStock = true' * } * def totalCount = Book.executeQuery(q.query, q.namedParams, q.paginateParams)[0].toInteger() * q.select = 'distinct book' * def list = Book.executeQuery(q.query, q.namedParams, q.paginateParams) * * def bookList = new PagedResultList(list, totalCount) * log.debug '\n' + q.printFormattedQuery * * @author Gavin Kromhout * @version DraftB * */ class HqlBuilder { // HQL clauses. // Each clause is a map key with a list of terms. def clauses = [:] // HQL namedParams. // HQL requires the namedParams to match exactly with the clause expressions. def namedParams = [:] // HQL paginateParams. def paginateParams = [max: 1000, offset: 0] // The where clause terms are handled separately from other clauses // and are a list of logicalTerms. // The where clause is built by buildWhereClause. def whereClauseTerms = [] // LogicalIndexStack holds indexes of the current whereClauseTerm nesting. def logicalIndexStack = [] def logicalBuilders = [AND: 'and', OR: 'or'] def nestingStack = [] // Sort and Order. // It is easier and more flexible to simply add order as a clause, e.g: order 'by name desc, id asc' // def sort = "" // e.g. instanceName.id // def order = "" // e.g. asc or desc /** * Constructor. * Any property that exists (or responds) in the class may be supplied as an argument. * E.g: max:20, offset:10, debug:true * The debug property does not really exist, but if true and no external log property * has been setup then the internal mockLogger will be configured in debug mode. * * @param args A map of arguments, defaults to an empty map. * */ def HqlBuilder(Map args = [:]) { args.each { arg -> def argKey = arg.key.toLowerCase() if(super.hasProperty(argKey)) this[argKey] = arg.value } if(!super.metaClass.hasMetaProperty('log')) mockLogging(args.debug) log.debug "HqlBuilder()" } /** * Call with no args. * Has no real use other than to prevent obscure errors. */ def call() { log.debug "call()" } /** * Call with closure as last arg. * A typically used build call, e.g: q { } is equivalent to q.call() { } */ def call(Closure cl) { log.debug "call(Closure cl)" handleClosure(cl) } /** * Domain specific build method. * Has no real use other than to prevent obscure errors * when user makes a call to query() and Groovy calls query(Closure cl) * * @returns This object. * */ def query() { log.debug "query()" return this // Must return this object to q. } /** * Domain specific build method. * The recommended build call, e.g: def q = new HqlBuilder().query { } * * @param cl The closure that will be used to build the query. * @returns This object. * */ def query(Closure cl) { log.debug "query(Closure cl)" handleClosure(cl) return this // Must return this object to q. } /** * InvokeMethod resolves all undefined methods. * Which include the clause methods, e.g: select 'book' is equivalent to select('book'). * Note that defined methods will be called directly since this class does not implement GroovyInterceptable. * If class was "HqlBuilder implements GroovyInterceptable" then even println would be intercepted and * several exlusions might be needed. e.g: if(methodName != 'call' && methodName != 'println') */ def invokeMethod(String methodName, args) { log.debug "invokeMethod(${methodName}, ${args})" // Call any closures first, that way the nesting is handled and we just keep a reference. if(args[-1] instanceof Closure) { handleClosure(args[-1], methodName) args = args.minus(args[-1]) } if(!clauses.containsKey(methodName) && !isLogicalBuilder(methodName)) clauses[methodName] = [] if(args) { if(isWhereClauseBuilder(methodName)) { logicalBuilder(methodName, args) return } } for(arg in args) { if(arg instanceof String || arg instanceof GString) clauses[methodName] << arg } } // invokeMethod() /** * PropertyMissing. * Allows clauses to be added after build, e.g: q.order = 'by book.name asc' * and clauses to be removed, e.g: q.order = null */ def propertyMissing(String propertyName, value) { log.debug "propertyMissing(${propertyName}, ${value})" if(value == null) { removeClause(propertyName) if(propertyName.toLowerCase() == 'where') whereClauseTerms.clear() return } if(!clauses.containsKey(propertyName)) clauses[propertyName] = [] // Occurs when user assigns to where clause, e.g: q.where = 'book.id > 100' if(propertyName.toLowerCase() == 'where') { whereClauseTerms.clear() logicalBuilder(propertyName, [value]) return } if(value instanceof String || value instanceof GString) clauses[propertyName] = [value] } // propertyMissing(String propertyName, value) /** * PropertyMissing. * Allow clauses to be accessed directly by name, e.g: println q.order. * Since clauses is a Map null is simply returned for a non-existant clause. */ def propertyMissing(String propertyName) { log.debug "propertyMissing(${propertyName})" if(!clauses.containsKey(propertyName)) clauses[propertyName] = [] // Occurs when user performs an operation on where clause. // E.g: q.where << "book.id = 100" which is actually NOT a supported operation since // calling the method provides the correct function e.g: q.where "book.id > 100". // Also allows `println q.where` to be short hand for `println q.whereClauseTerms` if(propertyName.toLowerCase() == 'where') { return whereClauseTerms } clauses[propertyName] } // propertyMissing(String propertyName) def setMax(Integer value) { paginateParams.max = value } def getMax() { paginateParams.max } def setOffset(Integer value) { paginateParams.offset = value } def getOffset() { paginateParams.offset } /** * RemoveClause. * Allows clauses to be removed, e.g: q.removeClause('order') * * @param clauseName The clause to remove. * */ def removeClause(String clauseName) { clauses.remove(clauseName) } /** * BuildWhereClause. * Build the where clause from whereClauseTerms. */ def buildWhereClause(printFormat = false) { //log.debug "buildWhereClause()" if(!whereClauseTerms) return '' def whereClause = 'where ' def buildExpression // declared separately to allow recurrsion. buildExpression = { term -> def result = '' def termCount = term.expressions.size() if(termCount > 1) { term.expressions.eachWithIndex { t, index -> if(index == 0) result += buildExpression(t) else if(printFormat) result += " \n\t${t.logic} ${buildExpression(t)}" else result += " ${t.logic} ${buildExpression(t)}" } result = "( "+result+" )" } else { if(term.expressions[0] instanceof Map) result += "${term.expressions[0].expressions[0]}" else result += "${term.expressions[0]}" } return result } whereClauseTerms.eachWithIndex { tm, index -> if(index == 0) whereClause += buildExpression(tm) else if(printFormat) whereClause += " \n\t${tm.logic} ${buildExpression(tm)}" else whereClause += " ${tm.logic} ${buildExpression(tm)}" } return whereClause } // buildWhereClause(printFormat = false) /** * LogicalBuilder. * Build the whereClauseTerms * by appending logicalTerms to the appropriate expressions. */ def logicalBuilder(logicalName, args) { log.debug "logicalBuilder(${logicalName}, ${args})" log.debug "logicalIndexStack: ${logicalIndexStack}" def logic = getLogicalString(logicalName) for(arg in args) { if(arg instanceof String || arg instanceof GString) { arg = arg.trim() if(arg) { // prevent empty strings being added. if(logicalIndexStack.size() > 0) { // Append to current index position. whereClauseTerms[logicalIndexStack[-1]].expressions << logicalTerm(logic, arg) } else { // Append to 'root'. whereClauseTerms << logicalTerm(logic, null) // empty expression logicalTerm. whereClauseTerms[-1].expressions << logicalTerm(logic, arg) // append logicalTerm to expressions } } // if(arg) } // if(arg instanceof) } // for } // logicalBuilder(logicalName, args) /** * LogicalTerm. * A logicalTerm is a map object that holds the logic and list of expressions of a whereClauseTerm. */ def logicalTerm = { logic, expression -> expression = expression ? [expression] : [] ['logic': getLogicalString(logic), 'expressions': expression] } /** * GetLogicalString. * * @param logicalName The name to get the matching logicalBuilder string for. */ private getLogicalString(logicalName) { switch(logicalName.toLowerCase()) { case 'where': logicalBuilders.AND break case logicalBuilders.AND: logicalBuilders.AND break case logicalBuilders.OR: logicalBuilders.OR break } } /** * HandleClosure. * Setting delegate and DELEGATE_FIRST allows closure to access this object's properties first. */ private handleClosure(Closure cl, String methodName = 'root') { log.debug "handleClosure(${cl.toString()}, ${methodName})" if(isWhereClauseBuilder(methodName)) { whereClauseTerms << logicalTerm(getLogicalString(methodName), null) logicalIndexStack << whereClauseTerms.size()-1 } nestingStack.push(methodName) cl.delegate = this cl.resolveStrategy = Closure.DELEGATE_FIRST cl.call() //log.debug "nestingStack: $nestingStack" nestingStack.pop() if(isWhereClauseBuilder(methodName)) { logicalIndexStack.pop() } } /** * MockLogging. * This class has super cow powers and can mock out it's own debug logging. */ private mockLogging(debug = false) { def mockLogger = {} if(debug) { mockLogger = {msg -> println "${super.getClass()} - DEBUG: $msg" } } super.metaClass.log = [debug: mockLogger] log.debug "Internal mockLogger configured." } /** * IsLogicalBuilder. * Determine if a method is a logicalBuilder. */ private isLogicalBuilder(String methodName) { logicalBuilders.find{ it.value == methodName.toLowerCase()} ? true:false } /** * IsWhereClauseBuilder. * Determine if a method is a where clause builder. */ private isWhereClauseBuilder(String methodName) { methodName = methodName.toLowerCase() if(methodName == 'where' || isLogicalBuilder(methodName)) return true else return false } /** * GetQuery. * Assemble and return the query in a format that can be directly executed. * E.g: executeQuery(q.query, q.namedParams, q.paginateParams). */ def getQuery() { clauses.collect { clause -> switch (clause.key.toLowerCase()) { case 'select': clause.key + ' ' + clause.value.join(', ') break case 'set': clause.key + ' ' + clause.value.join(', ') break case 'where': buildWhereClause() break case 'order': clause.key + ' ' + clause.value.join(', ') break case 'group': clause.key + ' ' + clause.value.join(', ') break default: clause.key + ' ' + clause.value.join(' ') } }.join(' ') } // getQuery() /** * GetPrintFormattedQuery. * Assemble and return the query in a format that can be more easily printed and read by a person. * E.g: println q.printFormattedQuery or when displayed in a report. */ def getPrintFormattedQuery() { clauses.collect { clause -> switch (clause.key.toLowerCase()) { case 'select': clause.key + ' ' + clause.value.join(', \n\t') break case 'set': clause.key + ' ' + clause.value.join(', \n\t') break case 'where': buildWhereClause(true) break case 'order': clause.key + ' ' + clause.value.join(', \n\t') break case 'group': clause.key + ' ' + clause.value.join(', \n\t') break default: clause.key + ' ' + clause.value.join(' \n\t') } }.join(' \n') } // getPrintFormattedQuery() } // end class