/* * (C) Copyright 2010 Nuxeo SA (http://nuxeo.com/) and others. * * 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. * * Contributors: * Anahide Tchertchian */ package org.nuxeo.ecm.platform.query.nxql; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.GregorianCalendar; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.commons.lang.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.nuxeo.ecm.core.api.DocumentModel; import org.nuxeo.ecm.core.api.NuxeoException; import org.nuxeo.ecm.core.api.PropertyException; import org.nuxeo.ecm.core.api.SortInfo; import org.nuxeo.ecm.core.api.model.PropertyNotFoundException; import org.nuxeo.ecm.core.query.sql.NXQL; import org.nuxeo.ecm.core.query.sql.model.Literal; import org.nuxeo.ecm.core.schema.SchemaManager; import org.nuxeo.ecm.core.schema.types.Field; import org.nuxeo.ecm.core.schema.types.Schema; import org.nuxeo.ecm.core.schema.types.SimpleTypeImpl; import org.nuxeo.ecm.core.schema.types.Type; import org.nuxeo.ecm.core.schema.types.primitives.StringType; import org.nuxeo.ecm.core.search.api.client.querymodel.Escaper; import org.nuxeo.ecm.platform.query.api.PageProviderService; import org.nuxeo.ecm.platform.query.api.PredicateDefinition; import org.nuxeo.ecm.platform.query.api.PredicateFieldDefinition; import org.nuxeo.ecm.platform.query.api.WhereClauseDefinition; import org.nuxeo.ecm.platform.query.core.FieldDescriptor; import org.nuxeo.runtime.api.Framework; import org.nuxeo.runtime.services.config.ConfigurationService; /** * Helper to generate NXQL queries from XMap descriptors * * @since 5.4 * @author Anahide Tchertchian */ public class NXQLQueryBuilder { private static final Log log = LogFactory.getLog(NXQLQueryBuilder.class); // @since 5.9.2 public static final String DEFAULT_SELECT_STATEMENT = "SELECT * FROM Document"; // @since 5.9 public static final String SORTED_COLUMN = "SORTED_COLUMN"; public static final String REGEXP_NAMED_PARAMETER = "[^a-zA-Z]:\\s*" + "([a-zA-Z0-9:]*)"; public static final String REGEXP_EXCLUDE_QUOTE = "'[^']*'"; public static final String REGEXP_EXCLUDE_DOUBLE_QUOTE = "\"[^\"]*\""; private NXQLQueryBuilder() { } public static String getSortClause(SortInfo... sortInfos) { StringBuilder queryBuilder = new StringBuilder(); if (sortInfos != null) { int index = 0; for (SortInfo sortInfo : sortInfos) { String sortColumn = sortInfo.getSortColumn(); boolean sortAscending = sortInfo.getSortAscending(); if (index == 0) { queryBuilder.append("ORDER BY ").append(sortColumn).append(' ').append(sortAscending ? "" : "DESC"); } else { queryBuilder.append(", ").append(sortColumn).append(' ').append(sortAscending ? "" : "DESC"); } index++; } } return queryBuilder.toString(); } public static String getQuery(DocumentModel model, WhereClauseDefinition whereClause, Object[] params, SortInfo... sortInfos) { return getQuery(model, whereClause, null, params, sortInfos); } /** * @since 8.4 */ public static String getQuery(DocumentModel model, WhereClauseDefinition whereClause, String quickFiltersClause, Object[] params, SortInfo... sortInfos) { StringBuilder queryBuilder = new StringBuilder(); String selectStatement = whereClause.getSelectStatement(); if (StringUtils.isBlank(selectStatement)) { selectStatement = DEFAULT_SELECT_STATEMENT; } queryBuilder.append(selectStatement); if (whereClause != null) { queryBuilder.append(getQueryElement(model, whereClause, quickFiltersClause, params)); } String sortClause = getSortClause(sortInfos); if (sortClause != null && sortClause.length() > 0) { queryBuilder.append(" "); queryBuilder.append(sortClause); } return queryBuilder.toString().trim(); } public static String getQueryElement(DocumentModel model, WhereClauseDefinition whereClause, Object[] params) { return getQueryElement(model, whereClause, null, params); } /** * @since 8.4 */ public static String getQueryElement(DocumentModel model, WhereClauseDefinition whereClause, String quickFiltersClause, Object[] params) { List<String> elements = new ArrayList<String>(); PredicateDefinition[] predicates = whereClause.getPredicates(); if (predicates != null) { Escaper escaper = null; Class<? extends Escaper> escaperClass = whereClause.getEscaperClass(); if (escaperClass != null) { try { escaper = escaperClass.newInstance(); } catch (ReflectiveOperationException e) { throw new NuxeoException(e); } } for (PredicateDefinition predicate : predicates) { String predicateString = getQueryElement(model, predicate, escaper); if (predicateString == null) { continue; } predicateString = predicateString.trim(); if (!predicateString.equals("")) { elements.add(predicateString); } } } // add fixed part if applicable String fixedPart = whereClause.getFixedPart(); if (!StringUtils.isBlank(fixedPart)) { if (StringUtils.isNotBlank(quickFiltersClause)) { fixedPart = appendClause(fixedPart, quickFiltersClause); } if (elements.isEmpty()) { elements.add(getQuery(fixedPart, params, whereClause.getQuoteFixedPartParameters(), whereClause.getEscapeFixedPartParameters(), model)); } else { elements.add('(' + getQuery(fixedPart, params, whereClause.getQuoteFixedPartParameters(), whereClause.getEscapeFixedPartParameters(), model) + ')'); } } else if (StringUtils.isNotBlank(quickFiltersClause)) { fixedPart = quickFiltersClause; } if (elements.isEmpty()) { return ""; } // XXX: for now only a one level implement conjunctive WHERE clause String clauseValues = StringUtils.join(elements, " AND ").trim(); // GR: WHERE (x = 1) is invalid NXQL while (elements.size() == 1 && clauseValues.startsWith("(") && clauseValues.endsWith(")")) { clauseValues = clauseValues.substring(1, clauseValues.length() - 1).trim(); } if (clauseValues.length() == 0) { return ""; } return " WHERE " + clauseValues; } public static String getQuery(String pattern, Object[] params, boolean quoteParameters, boolean escape, DocumentModel searchDocumentModel, SortInfo... sortInfos) { String sortedColumn; if (sortInfos == null || sortInfos.length == 0) { // If there is no ORDER BY use the id sortedColumn = NXQL.ECM_UUID; } else { sortedColumn = sortInfos[0].getSortColumn(); } if (pattern != null && pattern.contains(SORTED_COLUMN)) { pattern = pattern.replace(SORTED_COLUMN, sortedColumn); } StringBuilder queryBuilder; // handle named parameters replacements if (searchDocumentModel != null) { // Find all query named parameters as ":parameter" not between // quotes and add them to matches String query = pattern.replaceAll(REGEXP_EXCLUDE_DOUBLE_QUOTE, StringUtils.EMPTY); query = query.replaceAll(REGEXP_EXCLUDE_QUOTE, StringUtils.EMPTY); Pattern p1 = Pattern.compile(REGEXP_NAMED_PARAMETER); Matcher m1 = p1.matcher(query); List<String> matches = new ArrayList<String>(); while (m1.find()) { matches.add(m1.group().substring(m1.group().indexOf(":") + 1)); } for (String key : matches) { Object parameter = getRawValue(searchDocumentModel, new FieldDescriptor(key)); if (parameter == null) { continue; } key = ":" + key; if (parameter instanceof String[]) { replaceStringList(pattern, Arrays.asList((String[]) parameter), quoteParameters, escape, key); } else if (parameter instanceof List) { replaceStringList(pattern, (List<?>) parameter, quoteParameters, escape, key); } else if (parameter instanceof Boolean) { pattern = buildPattern(pattern, key, ((Boolean) parameter) ? "1" : "0"); } else if (parameter instanceof Number) { pattern = buildPattern(pattern, key, parameter.toString()); } else if (parameter instanceof Literal) { if (quoteParameters) { pattern = buildPattern(pattern, key, "'" + parameter.toString() + "'"); } else { pattern = buildPattern(pattern, key, ((Literal) parameter).asString()); } } else { if (quoteParameters) { pattern = buildPattern(pattern, key, "'" + parameter + "'"); } else { pattern = buildPattern(pattern, key, parameter != null ? parameter.toString() : null); } } } } if (params == null) { queryBuilder = new StringBuilder(pattern + ' '); } else { // handle "standard" parameters replacements (referenced by ? characters) // XXX: the + " " is a workaround for the buggy implementation // of the split function in case the pattern ends with '?' String[] queryStrList = (pattern + ' ').split("\\?"); queryBuilder = new StringBuilder(queryStrList[0]); for (int i = 0; i < params.length; i++) { if (params[i] instanceof String[]) { appendStringList(queryBuilder, Arrays.asList((String[]) params[i]), quoteParameters, escape); } else if (params[i] instanceof List) { appendStringList(queryBuilder, (List<?>) params[i], quoteParameters, escape); } else if (params[i] instanceof Boolean) { boolean b = ((Boolean) params[i]).booleanValue(); queryBuilder.append(b ? 1 : 0); } else if (params[i] instanceof Number) { queryBuilder.append(params[i]); } else if (params[i] instanceof Literal) { if (quoteParameters) { queryBuilder.append(params[i].toString()); } else { queryBuilder.append(((Literal) params[i]).asString()); } } else { if (params[i] == null) { if (quoteParameters) { queryBuilder.append("''"); } } else { String queryParam = params[i].toString(); queryBuilder.append(prepareStringLiteral(queryParam, quoteParameters, escape)); } } queryBuilder.append(queryStrList[i + 1]); } } queryBuilder.append(getSortClause(sortInfos)); return queryBuilder.toString().trim(); } public static void appendStringList(StringBuilder queryBuilder, List<?> listParam, boolean quoteParameters, boolean escape) { // avoid appending parentheses if the query builder ends with one boolean addParentheses = !queryBuilder.toString().endsWith("("); if (addParentheses) { queryBuilder.append('('); } List<String> result = new ArrayList<String>(listParam.size()); for (Object param : listParam) { result.add(prepareStringLiteral(param.toString(), quoteParameters, escape)); } queryBuilder.append(StringUtils.join(result, ", ")); if (addParentheses) { queryBuilder.append(')'); } } public static String replaceStringList(String pattern, List<?> listParams, boolean quoteParameters, boolean escape, String key) { List<String> result = new ArrayList<String>(listParams.size()); for (Object param : listParams) { result.add(prepareStringLiteral(param.toString(), quoteParameters, escape)); } return buildPattern(pattern, key, '(' + StringUtils.join(result, ", " + "") + ')'); } /** * Return the string literal in a form ready to embed in an NXQL statement. */ public static String prepareStringLiteral(String s, boolean quoteParameter, boolean escape) { if (escape) { if (quoteParameter) { return NXQL.escapeString(s); } else { return NXQL.escapeStringInner(s); } } else { if (quoteParameter) { return "'" + s + "'"; } else { return s; } } } public static String getQueryElement(DocumentModel model, PredicateDefinition predicateDescriptor, Escaper escaper) { String type = predicateDescriptor.getType(); if (PredicateDefinition.ATOMIC_PREDICATE.equals(type)) { return atomicQueryElement(model, predicateDescriptor, escaper); } if (PredicateDefinition.SUB_CLAUSE_PREDICATE.equals(type)) { return subClauseQueryElement(model, predicateDescriptor); } throw new NuxeoException("Unknown predicate type: " + type); } protected static String subClauseQueryElement(DocumentModel model, PredicateDefinition predicateDescriptor) { PredicateFieldDefinition[] values = predicateDescriptor.getValues(); if (values == null || values.length != 1) { throw new NuxeoException("subClause predicate needs exactly one field"); } PredicateFieldDefinition fieldDescriptor = values[0]; if (!getFieldType(model, fieldDescriptor).equals("string")) { if (fieldDescriptor.getXpath() != null) { throw new NuxeoException(String.format("type of field %s is not string", fieldDescriptor.getXpath())); } else { throw new NuxeoException(String.format("type of field %s.%s is not string", fieldDescriptor.getSchema(), fieldDescriptor.getName())); } } Object subclauseValue = getRawValue(model, fieldDescriptor); if (subclauseValue == null) { return ""; } return "(" + subclauseValue + ")"; } protected static String atomicQueryElement(DocumentModel model, PredicateDefinition predicateDescriptor, Escaper escaper) { String operator = null; String operatorField = predicateDescriptor.getOperatorField(); String operatorSchema = predicateDescriptor.getOperatorSchema(); PredicateFieldDefinition[] values = predicateDescriptor.getValues(); if (operatorField != null && operatorSchema != null) { PredicateFieldDefinition operatorFieldDescriptor = new FieldDescriptor(operatorSchema, operatorField); operator = getPlainStringValue(model, operatorFieldDescriptor); if (operator != null) { operator = operator.toUpperCase(); } } if (StringUtils.isBlank(operator)) { operator = predicateDescriptor.getOperator(); } String hint = predicateDescriptor.getHint(); String parameter = getParameterWithHint(operator, predicateDescriptor.getParameter(), hint); if (operator.equals("=") || operator.equals("!=") || operator.equals("<") || operator.equals(">") || operator.equals("<=") || operator.equals(">=") || operator.equals("<>") || operator.equals("LIKE") || operator.equals("ILIKE")) { // Unary predicate String value = getStringValue(model, values[0]); if (value == null) { // value not provided: ignore predicate return ""; } if (escaper != null && (operator.equals("LIKE") || operator.equals("ILIKE"))) { value = escaper.escape(value); } return serializeUnary(parameter, operator, value); } else if (operator.equals("BETWEEN")) { String min = getStringValue(model, values[0]); String max = getStringValue(model, values[1]); if (min != null && max != null) { StringBuilder builder = new StringBuilder(); builder.append(parameter); builder.append(' '); builder.append(operator); builder.append(' '); builder.append(min); builder.append(" AND "); builder.append(max); return builder.toString(); } else if (max != null) { return serializeUnary(parameter, "<=", max); } else if (min != null) { return serializeUnary(parameter, ">=", min); } else { // both min and max are not provided, ignore predicate return ""; } } else if (operator.equals("IN")) { List<String> options = getListValue(model, values[0]); if (options == null || options.isEmpty()) { return ""; } else if (options.size() == 1) { return serializeUnary(parameter, "=", options.get(0)); } else { StringBuilder builder = new StringBuilder(); builder.append(parameter); builder.append(" IN ("); for (int i = 0; i < options.size(); i++) { if (i != 0) { builder.append(", "); } builder.append(options.get(i)); } builder.append(')'); return builder.toString(); } } else if (operator.equals("STARTSWITH")) { String fieldType = getFieldType(model, values[0]); if (fieldType.equals("string")) { String value = getStringValue(model, values[0]); if (value == null) { return ""; } else { return serializeUnary(parameter, operator, value); } } else { List<String> options = getListValue(model, values[0]); if (options == null || options.isEmpty()) { return ""; } else if (options.size() == 1) { return serializeUnary(parameter, operator, options.get(0)); } else { StringBuilder builder = new StringBuilder(); builder.append('('); for (int i = 0; i < options.size() - 1; i++) { builder.append(serializeUnary(parameter, operator, options.get(i))); builder.append(" OR "); } builder.append(serializeUnary(parameter, operator, options.get(options.size() - 1))); builder.append(')'); return builder.toString(); } } } else if (operator.equals("EMPTY") || operator.equals("ISEMPTY")) { return parameter + " = ''"; } else if (operator.equals("FULLTEXT ALL") // BBB || operator.equals("FULLTEXT")) { String value = getPlainStringValue(model, values[0]); if (value == null) { // value not provided: ignore predicate return ""; } if (escaper != null) { value = escaper.escape(value); } return parameter + ' ' + serializeFullText(value); } else if (operator.equals("IS NULL")) { Boolean value = getBooleanValue(model, values[0]); if (value == null) { // value not provided: ignore predicate return ""; } else if (Boolean.TRUE.equals(value)) { return parameter + " IS NULL"; } else { return parameter + " IS NOT NULL"; } } else { throw new NuxeoException("Unsupported operator: " + operator); } } protected static String getParameterWithHint(String operator, String parameter, String hint) { String ret = parameter; // add ecm:fulltext. prefix if needed if ((operator.equals("FULLTEXT ALL") || operator.equals("FULLTEXT")) && !parameter.startsWith(NXQL.ECM_FULLTEXT)) { ret = NXQL.ECM_FULLTEXT + '.' + parameter; } // add the hint if (hint != null && !hint.isEmpty()) { ret = String.format("/*+%s */ %s", hint.trim(), ret); } return ret; } /** * Prepares a statement for a fulltext field by converting FULLTEXT virtual operators to a syntax that the search * syntax accepts. * * @param value * @return the serialized statement */ public static final String DEFAULT_SPECIAL_CHARACTERS_REGEXP = "!#$%&'()+,./\\\\:-@{|}`^~"; public static final String IGNORED_CHARS_KEY = "org.nuxeo.query.builder.ignored.chars"; /** * Remove any special character that could be mis-interpreted as a low level full-text query operator. This method * should be used by user facing callers of CoreQuery*PageProviders that use a fixed part or a pattern query. Fields * in where clause already dealt with. * * @since 5.6 * @return sanitized text value */ public static String sanitizeFulltextInput(String value) { // Ideally the low level full-text language // parser should be robust to any user input however this is much more // complicated to implement correctly than the following simple user // input filtering scheme. ConfigurationService cs = Framework.getService(ConfigurationService.class); String ignoredChars = cs.getProperty(IGNORED_CHARS_KEY, DEFAULT_SPECIAL_CHARACTERS_REGEXP); String res = ""; value = value.replaceAll("[" + ignoredChars + "]", " "); value = value.trim(); String[] tokens = value.split("[\\s]+"); for (int i = 0; i < tokens.length; i++) { if ("-".equals(tokens[i]) || "*".equals(tokens[i]) || "*-".equals(tokens[i]) || "-*".equals(tokens[i])) { continue; } if (res.length() > 0) { res += " "; } if (tokens[i].startsWith("-") || tokens[i].endsWith("*")) { res += tokens[i]; } else { res += tokens[i].replace("-", " ").replace("*", " "); } } return res.trim(); } public static String serializeFullText(String value) { value = sanitizeFulltextInput(value); return "= " + NXQL.escapeString(value); } protected static String serializeUnary(String parameter, String operator, String rvalue) { StringBuilder builder = new StringBuilder(); builder.append(parameter); builder.append(' '); builder.append(operator); builder.append(' '); builder.append(rvalue); return builder.toString(); } public static String getPlainStringValue(DocumentModel model, PredicateFieldDefinition fieldDescriptor) { Object rawValue = getRawValue(model, fieldDescriptor); if (rawValue == null) { return null; } String value = (String) rawValue; if (value.equals("")) { return null; } return value; } public static Integer getIntValue(DocumentModel model, PredicateFieldDefinition fieldDescriptor) { Object rawValue = getRawValue(model, fieldDescriptor); if (rawValue == null || "".equals(rawValue)) { return null; } else if (rawValue instanceof Integer) { return (Integer) rawValue; } else if (rawValue instanceof String) { return Integer.valueOf((String) rawValue); } else { return Integer.valueOf(rawValue.toString()); } } public static String getFieldType(DocumentModel model, PredicateFieldDefinition fieldDescriptor) { String xpath = fieldDescriptor.getXpath(); String schema = fieldDescriptor.getSchema(); String name = fieldDescriptor.getName(); try { SchemaManager typeManager = Framework.getService(SchemaManager.class); Field field = null; if (xpath != null) { if (model != null) { field = model.getProperty(xpath).getField(); } } else { if (schema != null) { Schema schemaObj = typeManager.getSchema(schema); if (schemaObj == null) { throw new NuxeoException("failed to obtain schema: " + schema); } field = schemaObj.getField(name); } else { // assume named parameter use case: hard-code on String in this case return StringType.ID; } } if (field == null) { throw new NuxeoException("failed to obtain field: " + schema + ":" + name); } Type type = field.getType(); if (type instanceof SimpleTypeImpl) { // type with constraint type = type.getSuperType(); } return type.getName(); } catch (PropertyException e) { e.addInfo("failed to get field type for " + (xpath != null ? xpath : (schema + ":" + name))); throw e; } } @SuppressWarnings("unchecked") public static Object getRawValue(DocumentModel model, PredicateFieldDefinition fieldDescriptor) { String xpath = fieldDescriptor.getXpath(); String schema = fieldDescriptor.getSchema(); String name = fieldDescriptor.getName(); try { if (xpath != null) { return model.getPropertyValue(xpath); } else if (schema == null) { return model.getPropertyValue(name); } else { return model.getProperty(schema, name); } } catch (PropertyNotFoundException e) { // fall back on named parameters if any Map<String, Object> params = (Map<String, Object>) model.getContextData( PageProviderService.NAMED_PARAMETERS); if (params != null) { if (xpath != null) { return params.get(xpath); } else { return params.get(name); } } } catch (PropertyException e) { return null; } return null; } public static String getStringValue(DocumentModel model, PredicateFieldDefinition fieldDescriptor) { Object rawValue = getRawValue(model, fieldDescriptor); if (rawValue == null) { return null; } String value; if (rawValue instanceof GregorianCalendar) { GregorianCalendar gc = (GregorianCalendar) rawValue; value = "TIMESTAMP '" + getDateFormat().format(gc.getTime()) + "'"; } else if (rawValue instanceof Date) { Date date = (Date) rawValue; value = "TIMESTAMP '" + getDateFormat().format(date) + "'"; } else if (rawValue instanceof Integer || rawValue instanceof Long || rawValue instanceof Double) { value = rawValue.toString(); // no quotes } else if (rawValue instanceof Boolean) { value = ((Boolean) rawValue).booleanValue() ? "1" : "0"; } else { value = rawValue.toString().trim(); if (value.equals("")) { return null; } String fieldType = getFieldType(model, fieldDescriptor); if ("long".equals(fieldType) || "integer".equals(fieldType) || "double".equals(fieldType)) { return value; } else { return NXQL.escapeString(value); } } return value; } protected static DateFormat getDateFormat() { // not thread-safe so don't use a static instance return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); } @SuppressWarnings("unchecked") public static List<String> getListValue(DocumentModel model, PredicateFieldDefinition fieldDescriptor) { Object rawValue = getRawValue(model, fieldDescriptor); if (rawValue == null) { return null; } List<String> values = new ArrayList<String>(); if (rawValue instanceof ArrayList) { rawValue = ((ArrayList<Object>) rawValue).toArray(); } for (Object element : (Object[]) rawValue) { if (element != null) { if (element instanceof Number) { values.add(element.toString()); } else { String value = element.toString().trim(); if (!value.equals("")) { values.add(NXQL.escapeString(value)); } } } } return values; } public static Boolean getBooleanValue(DocumentModel model, PredicateFieldDefinition fieldDescriptor) { Object rawValue = getRawValue(model, fieldDescriptor); if (rawValue == null) { return null; } else { return (Boolean) rawValue; } } /** * @since 8.4 */ public static String appendClause(String query, String clause) { return query + " AND " + clause; } /** * @since 8.4 */ public static String buildPattern(String pattern, String key, String replacement) { int index = pattern.indexOf(key); while (index >= 0) { // All keys not prefixed by a letter or a digit has to be replaced, because // It could be part of a schema name if (!Character.isLetterOrDigit(pattern.charAt(index - 1)) && (index + key.length() == pattern.length() || !Character.isLetterOrDigit(pattern.charAt(index + key.length())))) { pattern = pattern.substring(0, index) + pattern.substring(index).replaceFirst(key, replacement); } index = pattern.indexOf(key, index + 1); } return pattern; } }