/*
* (C) Copyright 2006-2016 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:
* Florent Guillaume
*/
package org.nuxeo.ecm.core.storage.sql.jdbc;
import java.io.Serializable;
import java.sql.Types;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
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.common.utils.FullTextUtils;
import org.nuxeo.ecm.core.api.impl.FacetFilter;
import org.nuxeo.ecm.core.query.QueryFilter;
import org.nuxeo.ecm.core.query.QueryParseException;
import org.nuxeo.ecm.core.query.sql.NXQL;
import org.nuxeo.ecm.core.query.sql.SQLQueryParser;
import org.nuxeo.ecm.core.query.sql.model.BooleanLiteral;
import org.nuxeo.ecm.core.query.sql.model.DateLiteral;
import org.nuxeo.ecm.core.query.sql.model.DefaultQueryVisitor;
import org.nuxeo.ecm.core.query.sql.model.DoubleLiteral;
import org.nuxeo.ecm.core.query.sql.model.Expression;
import org.nuxeo.ecm.core.query.sql.model.FromClause;
import org.nuxeo.ecm.core.query.sql.model.FromList;
import org.nuxeo.ecm.core.query.sql.model.Function;
import org.nuxeo.ecm.core.query.sql.model.IntegerLiteral;
import org.nuxeo.ecm.core.query.sql.model.Literal;
import org.nuxeo.ecm.core.query.sql.model.LiteralList;
import org.nuxeo.ecm.core.query.sql.model.MultiExpression;
import org.nuxeo.ecm.core.query.sql.model.Operand;
import org.nuxeo.ecm.core.query.sql.model.Operator;
import org.nuxeo.ecm.core.query.sql.model.OrderByClause;
import org.nuxeo.ecm.core.query.sql.model.OrderByExpr;
import org.nuxeo.ecm.core.query.sql.model.OrderByList;
import org.nuxeo.ecm.core.query.sql.model.Predicate;
import org.nuxeo.ecm.core.query.sql.model.Reference;
import org.nuxeo.ecm.core.query.sql.model.SQLQuery;
import org.nuxeo.ecm.core.query.sql.model.SelectClause;
import org.nuxeo.ecm.core.query.sql.model.StringLiteral;
import org.nuxeo.ecm.core.query.sql.model.WhereClause;
import org.nuxeo.ecm.core.storage.sql.ColumnType;
import org.nuxeo.ecm.core.storage.sql.ColumnType.WrappedId;
import org.nuxeo.ecm.core.storage.sql.Model;
import org.nuxeo.ecm.core.storage.sql.ModelProperty;
import org.nuxeo.ecm.core.storage.sql.Session.PathResolver;
import org.nuxeo.ecm.core.storage.sql.jdbc.SQLInfo.ColumnMapMaker;
import org.nuxeo.ecm.core.storage.sql.jdbc.SQLInfo.SQLInfoSelect;
import org.nuxeo.ecm.core.storage.sql.jdbc.db.Column;
import org.nuxeo.ecm.core.storage.sql.jdbc.db.Database;
import org.nuxeo.ecm.core.storage.sql.jdbc.db.Join;
import org.nuxeo.ecm.core.storage.sql.jdbc.db.Select;
import org.nuxeo.ecm.core.storage.sql.jdbc.db.Table;
import org.nuxeo.ecm.core.storage.sql.jdbc.db.TableAlias;
import org.nuxeo.ecm.core.storage.sql.jdbc.dialect.Dialect;
import org.nuxeo.ecm.core.storage.sql.jdbc.dialect.Dialect.ArraySubQuery;
import org.nuxeo.ecm.core.storage.sql.jdbc.dialect.Dialect.FulltextMatchInfo;
/**
* Transformer of NXQL queries into underlying SQL queries to the actual database.
* <p>
* The examples below are using the NXQL statement syntax:
*
* <pre>
* SELECT * FROM File
* WHERE
* dc:title = 'abc'
* AND uid:uid = '123'
* AND dc:contributors = 'bob' -- multi-valued
* </pre>
*
* If there are no proxies (ecm:isProxy = 0) we get:
*
* <pre>
* SELECT hierarchy.id
* FROM hierarchy
* LEFT JOIN dublincore ON hierarchy.id = dublincore.id
* LEFT JOIN uid ON hierarchy.id = uid.id
* WHERE
* hierarchy.primarytype IN ('File', 'SubFile')
* AND dublincore.title = 'abc'
* AND uid.uid = '123'
* AND EXISTS (SELECT 1 FROM dc_contributors WHERE hierarchy.id = dc_contributors.id
* AND dc_contributors.item = 'bob')
* AND NX_ACCESS_ALLOWED(hierarchy.id, 'user1|user2', 'perm1|perm2')
* </pre>
*
* The data tables (dublincore, uid) are joined using a LEFT JOIN, as the schema may not be present on all documents but
* this shouldn't prevent the WHERE clause from being evaluated. Complex properties are matched using an EXISTS and a
* subselect. When proxies are matched (ecm:isProxy = 1) there are two additional FULL JOINs. Security checks, id, name,
* parents and path use the base hierarchy (_H), but all other data use the joined hierarchy.
*
* <pre>
* SELECT _H.id
* FROM hierarchy _H
* JOIN proxies ON _H.id = proxies.id -- proxy full join
* JOIN hierarchy ON hierarchy.id = proxies.targetid -- proxy full join
* LEFT JOIN dublincore ON hierarchy.id = dublincore.id
* LEFT JOIN uid ON hierarchy.id = uid.id
* WHERE
* hierarchy.primarytype IN ('File', 'SubFile')
* AND dublincore.title = 'abc'
* AND uid.uid = '123'
* AND EXISTS (SELECT 1 FROM dc_contributors WHERE hierarchy.id = dc_contributors.id
* AND dc_contributors.item = 'bob')
* AND NX_ACCESS_ALLOWED(_H.id, 'user1|user2', 'perm1|perm2') -- uses _H
* </pre>
*
* When both normal documents and proxies are matched, we UNION ALL the two queries. If an ORDER BY is requested, then
* columns from the inner SELECTs have to be aliased so that an outer ORDER BY can user their names.
*
* @author Florent Guillaume
*/
public class NXQLQueryMaker implements QueryMaker {
private static final Log log = LogFactory.getLog(NXQLQueryMaker.class);
public static final String TYPE_DOCUMENT = "Document";
public static final String TYPE_RELATION = "Relation";
public static final String TYPE_TAGGING = "Tagging";
public static final String RELATION_TABLE = "relation";
public static final String ECM_SIMPLE_ACP_PRINCIPAL = NXQL.ECM_ACL + "/*/" + NXQL.ECM_ACL_PRINCIPAL;
public static final String ECM_SIMPLE_ACP_PERMISSION = NXQL.ECM_ACL + "/*/" + NXQL.ECM_ACL_PERMISSION;
public static final String ECM_SIMPLE_ACP_GRANT = NXQL.ECM_ACL + "/*/" + NXQL.ECM_ACL_GRANT;
public static final String ECM_SIMPLE_ACP_NAME = NXQL.ECM_ACL + "/*/" + NXQL.ECM_ACL_NAME;
public static final String ECM_SIMPLE_ACP_POS = NXQL.ECM_ACL + "/*/" + NXQL.ECM_ACL_POS;
/**
* @since 7.4
*/
public static final String ECM_SIMPLE_ACP_CREATOR = NXQL.ECM_ACL + "/*/" + NXQL.ECM_ACL_CREATOR;
/**
* @since 7.4
*/
public static final String ECM_SIMPLE_ACP_BEGIN = NXQL.ECM_ACL + "/*/" + NXQL.ECM_ACL_BEGIN;
/**
* @since 7.4
*/
public static final String ECM_SIMPLE_ACP_END = NXQL.ECM_ACL + "/*/" + NXQL.ECM_ACL_END;
/**
* @since 7.4
*/
public static final String ECM_SIMPLE_ACP_STATUS = NXQL.ECM_ACL + "/*/" + NXQL.ECM_ACL_STATUS;
public static final String ECM_TAG_STAR = NXQL.ECM_TAG + "/*";
protected static final String TABLE_HIER_ALIAS = "_H";
protected static final String TABLE_FRAG_ALIAS = "_F";
protected static final String SUBQUERY_ARRAY_ALIAS = "_A";
protected static final String COL_ALIAS_PREFIX = "_C";
protected static final String UNION_ALIAS = "_T";
protected static final String WITH_ALIAS_PREFIX = "_W";
protected static final String READ_ACL_ALIAS = "_RACL";
protected static final String READ_ACL_USER_MAP_ALIAS = "_ACLRUSERMAP";
protected static final String DATE_CAST = "DATE";
protected static final String COUNT_FUNCTION = "COUNT";
protected static final String AVG_FUNCTION = "AVG";
protected static final List<String> AGGREGATE_FUNCTIONS = Arrays.asList(COUNT_FUNCTION, AVG_FUNCTION, "SUM", "MIN",
"MAX");
/*
* Fields used by the search service.
*/
protected SQLInfo sqlInfo;
protected Database database;
protected Dialect dialect;
protected Model model;
protected Set<String> neverPerInstanceMixins;
protected PathResolver pathResolver;
protected final Map<String, String> aliasesByName = new HashMap<>();
protected final List<String> aliases = new LinkedList<>();
/**
* Whether the query must match only proxies (TRUE), no proxies (FALSE), or not specified (null).
*/
protected Boolean proxyClause;
/** The reason why proxyClause was set to non-null. */
protected String proxyClauseReason;
// The hierarchy table for the hierarchy/name, may be an alias table
protected Table hierTable;
// The hierarchy table of the data
protected Table dataHierTable;
// The proxies table if querying for proxies
protected Table proxyTable;
protected List<Join> joins;
protected List<String> whereClauses;
protected List<Serializable> whereParams;
// fragmentName or prefix/fragmentName -> fragment table to join
protected Map<String, Table> propertyFragmentTables = new HashMap<>();
protected int fragJoinCount = 0;
@Override
public String getName() {
return NXQL.NXQL;
}
@Override
public boolean accepts(String queryType) {
return queryType.equals(NXQL.NXQL);
}
public enum DocKind {
DIRECT, PROXY
}
@Override
public Query buildQuery(SQLInfo sqlInfo, Model model, PathResolver pathResolver, String query,
QueryFilter queryFilter, Object... params) {
this.sqlInfo = sqlInfo;
database = sqlInfo.database;
dialect = sqlInfo.dialect;
this.model = model;
this.pathResolver = pathResolver;
neverPerInstanceMixins = new HashSet<>(model.getNoPerDocumentQueryFacets());
// compat
Set<String> npim = model.getRepositoryDescriptor().neverPerInstanceMixins;
if (npim != null) {
neverPerInstanceMixins.addAll(npim);
}
// transform the query according to the transformers defined by the
// security policies
SQLQuery sqlQuery = SQLQueryParser.parse(query);
for (SQLQuery.Transformer transformer : queryFilter.getQueryTransformers()) {
sqlQuery = transformer.transform(queryFilter.getPrincipal(), sqlQuery);
}
// SELECT * -> SELECT ecm:uuid
SelectClause selectClause = sqlQuery.select;
if (selectClause.isEmpty()) {
// turned into SELECT ecm:uuid
selectClause.add(new Reference(NXQL.ECM_UUID));
}
boolean selectStar = selectClause.getSelectList().size() == 1
&& (selectClause.get(0).equals(new Reference(NXQL.ECM_UUID)));
/*
* Analyze query to find all relevant types and keys for the criteria, and fulltext matches.
*/
QueryAnalyzer queryAnalyzer = newQueryAnalyzer(queryFilter.getFacetFilter());
try {
queryAnalyzer.visitQuery(sqlQuery);
} catch (QueryCannotMatchException e) {
// query cannot match
return null;
}
boolean distinct = selectClause.isDistinct();
if (selectStar && queryAnalyzer.hasWildcardIndex) {
distinct = true;
}
boolean reAnalyze = false;
// add a default ORDER BY ecm:fulltextScore DESC
if (queryAnalyzer.ftCount == 1 && !distinct && sqlQuery.orderBy == null) {
sqlQuery.orderBy = new OrderByClause(
new OrderByList(new OrderByExpr(new Reference(NXQL.ECM_FULLTEXT_SCORE), true)), false);
queryAnalyzer.orderByScore = true;
reAnalyze = true;
}
// if ORDER BY ecm:fulltextScore, make sure we SELECT on it too
if (queryAnalyzer.orderByScore && !queryAnalyzer.selectScore) {
selectClause.add(new Reference(NXQL.ECM_FULLTEXT_SCORE));
reAnalyze = true;
}
if (reAnalyze) {
queryAnalyzer.visitQuery(sqlQuery);
}
if (queryAnalyzer.ftCount > 1 && (queryAnalyzer.orderByScore || queryAnalyzer.selectScore)) {
throw new QueryParseException(
"Cannot use " + NXQL.ECM_FULLTEXT_SCORE + " with more than one fulltext match expression");
}
/*
* Find whether to check proxies, relations.
*/
if (!model.getRepositoryDescriptor().getProxiesEnabled() || queryAnalyzer.onlyRelations) {
if (proxyClause == Boolean.TRUE) {
// proxies disabled or searching only relations (which don't have proxies)
// query cannot match
return null;
}
proxyClause = Boolean.FALSE;
}
DocKind[] docKinds;
if (proxyClause == Boolean.TRUE) {
docKinds = new DocKind[] { DocKind.PROXY };
} else if (proxyClause == Boolean.FALSE) {
docKinds = new DocKind[] { DocKind.DIRECT };
} else {
docKinds = new DocKind[] { DocKind.DIRECT, DocKind.PROXY };
}
boolean doUnion = docKinds.length > 1;
/*
* DISTINCT check and add additional selected columns for ORDER BY.
*/
boolean hasSelectCollection = queryAnalyzer.hasSelectCollection;
if (doUnion || distinct) {
// if UNION, we need all the ORDER BY columns in the SELECT list
// for aliasing
List<String> whatColumnNames = queryAnalyzer.whatColumnNames;
if (distinct && !whatColumnNames.contains(NXQL.ECM_UUID)) {
// when using DISTINCT, we can't also ORDER BY on an artificial ecm:uuid + pos
hasSelectCollection = false;
}
Set<String> onlyOrderByColumnNames = new HashSet<>(queryAnalyzer.orderByColumnNames);
if (hasSelectCollection) {
onlyOrderByColumnNames.add(NXQL.ECM_UUID);
}
onlyOrderByColumnNames.removeAll(whatColumnNames);
if (distinct && !onlyOrderByColumnNames.isEmpty()) {
// if DISTINCT, check that the ORDER BY columns are all in the
// SELECT list
if (!selectStar) {
throw new QueryParseException(
"For SELECT DISTINCT the ORDER BY columns must be in the SELECT list, missing: "
+ onlyOrderByColumnNames);
}
// for a SELECT *, we can add the needed columns if they
// don't involve wildcard index array elements
if (queryAnalyzer.orderByHasWildcardIndex) {
throw new QueryParseException("For SELECT * the ORDER BY columns cannot use wildcard indexes");
}
}
for (String name : onlyOrderByColumnNames) {
selectClause.add(new Reference(name));
}
}
/*
* Build the FROM / JOIN criteria for each select.
*/
String from;
List<Column> whatColumns = null;
List<String> whatKeys = null;
Select select = null;
String orderBy = null;
List<String> statements = new ArrayList<>(2);
List<Serializable> selectParams = new LinkedList<>();
List<String> withTables = new LinkedList<>();
List<Select> withSelects = new LinkedList<>();
List<String> withSelectsStatements = new LinkedList<>();
List<Serializable> withParams = new LinkedList<>();
Table hier = database.getTable(Model.HIER_TABLE_NAME);
for (DocKind docKind : docKinds) {
// Quoted id in the hierarchy. This is the id returned by the query.
String hierId;
joins = new LinkedList<>();
whereClauses = new LinkedList<>();
whereParams = new LinkedList<>();
propertyFragmentTables = new HashMap<>();
fragJoinCount = 0;
switch (docKind) {
case DIRECT:
hierTable = hier;
dataHierTable = hierTable;
hierId = hierTable.getColumn(Model.MAIN_KEY).getFullQuotedName();
from = hierTable.getQuotedName();
proxyTable = null;
break;
case PROXY:
hierTable = new TableAlias(hier, TABLE_HIER_ALIAS);
dataHierTable = hier;
// TODO use dialect
from = hier.getQuotedName() + " " + hierTable.getQuotedName();
hierId = hierTable.getColumn(Model.MAIN_KEY).getFullQuotedName();
// proxies
proxyTable = database.getTable(Model.PROXY_TABLE_NAME);
// join all that
addJoin(Join.INNER, null, proxyTable, Model.MAIN_KEY, hierTable, Model.MAIN_KEY, null, -1, null);
addJoin(Join.INNER, null, dataHierTable, Model.MAIN_KEY, proxyTable, Model.PROXY_TARGET_KEY, null, -1,
null);
break;
default:
throw new AssertionError(docKind);
}
fixInitialJoins();
// init builder
WhereBuilder whereBuilder = newWhereBuilder(docKind == DocKind.PROXY);
selectClause.accept(whereBuilder);
whatColumns = whereBuilder.whatColumns;
whatKeys = whereBuilder.whatKeys;
/*
* Process WHERE.
*/
if (queryAnalyzer.wherePredicate != null) {
queryAnalyzer.wherePredicate.accept(whereBuilder);
// WHERE clause
String where = whereBuilder.buf.toString();
if (where.length() != 0) {
whereClauses.add(where);
}
}
/*
* Process WHAT to select.
*/
// alias columns in all cases to simplify logic
List<String> whatNames = new ArrayList<>(1);
List<Serializable> whatNamesParams = new ArrayList<>(1);
String mainAlias = hierId;
aliasesByName.clear();
aliases.clear();
for (int i = 0; i < whatColumns.size(); i++) {
Column col = whatColumns.get(i);
String key = whatKeys.get(i);
String alias;
String whatName;
if (NXQL.ECM_FULLTEXT_SCORE.equals(key)) {
FulltextMatchInfo ftMatchInfo = whereBuilder.ftMatchInfo;
if (ftMatchInfo == null) {
throw new QueryParseException(
NXQL.ECM_FULLTEXT_SCORE + " cannot be used without " + NXQL.ECM_FULLTEXT);
}
alias = ftMatchInfo.scoreAlias;
whatName = ftMatchInfo.scoreExpr;
if (ftMatchInfo.scoreExprParam != null) {
whatNamesParams.add(ftMatchInfo.scoreExprParam);
}
} else {
alias = dialect.openQuote() + COL_ALIAS_PREFIX + (i + 1) + dialect.closeQuote();
whatName = getSelectColName(col, key);
if (col.getTable().getRealTable() == hier && col.getKey().equals(Model.MAIN_KEY)) {
mainAlias = alias;
}
}
aliasesByName.put(key, alias);
aliases.add(alias);
whatNames.add(whatName + " AS " + alias);
}
fixWhatColumns(whatColumns);
/*
* Process ORDER BY.
*/
// ORDER BY computed just once; may use just aliases
if (orderBy == null) {
if (hasSelectCollection) {
if (sqlQuery.orderBy == null) {
OrderByList obl = new OrderByList(null); // stupid constructor
obl.clear();
sqlQuery.orderBy = new OrderByClause(obl);
}
// always add ecm:uuid in ORDER BY
sqlQuery.orderBy.elements.add(new OrderByExpr(new Reference(NXQL.ECM_UUID), false));
}
if (sqlQuery.orderBy != null) {
// needs aliasesByName
whereBuilder.aliasOrderByColumns = doUnion;
whereBuilder.buf.setLength(0);
sqlQuery.orderBy.accept(whereBuilder);
// ends up in WhereBuilder#visitOrderByExpr
if (hasSelectCollection) {
// also add all pos columns
whereBuilder.visitOrderByPosColumns();
}
orderBy = whereBuilder.buf.toString();
}
}
String selectWhat = StringUtils.join(whatNames, ", ");
if (!doUnion && distinct) {
selectWhat = "DISTINCT " + selectWhat;
}
/*
* Soft delete.
*/
if (model.getRepositoryDescriptor().getSoftDeleteEnabled()) {
whereClauses.add(hierTable.getColumn(Model.MAIN_IS_DELETED_KEY).getFullQuotedName() + " IS NULL");
}
/*
* Security check.
*/
String securityClause = null;
List<Serializable> securityParams = new LinkedList<>();
List<Join> securityJoins = new ArrayList<>(2);
if (queryFilter.getPrincipals() != null) {
Serializable principals = queryFilter.getPrincipals();
Serializable permissions = queryFilter.getPermissions();
if (!dialect.supportsArrays()) {
principals = StringUtils.join((String[]) principals, Dialect.ARRAY_SEP);
permissions = StringUtils.join((String[]) permissions, Dialect.ARRAY_SEP);
}
// when using WITH for the query, the main column is referenced
// through an alias because of the subselect
String id = dialect.supportsWith() ? mainAlias : hierId;
if (dialect.supportsReadAcl()) {
/* optimized read acl */
// JOIN hierarchy_read_acl _RACL ON hierarchy.id = _RACL.id
// JOIN aclr_user_map _ACLRUSERMAP ON _RACL.acl_id =
// _ACLRUSERMAP.acl_id
// WHERE _ACLRUSERMAP.user_id = md5('bob,Everyone')
String racl = dialect.openQuote() + READ_ACL_ALIAS + dialect.closeQuote();
String aclrum = dialect.openQuote() + READ_ACL_USER_MAP_ALIAS + dialect.closeQuote();
securityJoins.add(new Join(Join.INNER, Model.HIER_READ_ACL_TABLE_NAME, READ_ACL_ALIAS, null, id,
racl + '.' + Model.HIER_READ_ACL_ID));
securityJoins.add(new Join(Join.INNER, Model.ACLR_USER_MAP_TABLE_NAME, READ_ACL_USER_MAP_ALIAS,
null, racl + '.' + Model.HIER_READ_ACL_ACL_ID, aclrum + '.' + Model.ACLR_USER_MAP_ACL_ID));
securityClause = dialect.getReadAclsCheckSql(aclrum + '.' + Model.ACLR_USER_MAP_USER_ID);
securityParams.add(principals);
} else {
securityClause = dialect.getSecurityCheckSql(id);
securityParams.add(principals);
securityParams.add(permissions);
}
}
/*
* Resulting select.
*/
if (securityClause != null) {
if (dialect.supportsWith()) {
// wrap security into a WITH
String withTable = dialect.openQuote() + WITH_ALIAS_PREFIX + (statements.size() + 1)
+ dialect.closeQuote();
withTables.add(withTable);
Select withSelect = new Select(null);
withSelect.setWhat("*");
String withFrom = withTable;
for (Join j : securityJoins) {
withFrom += j.toSql(dialect);
}
withSelect.setFrom(withFrom);
withSelect.setWhere(securityClause);
withSelects.add(withSelect);
withSelectsStatements.add(withSelect.getStatement());
withParams.addAll(securityParams);
} else {
// add directly to main select
joins.addAll(securityJoins);
whereClauses.add(securityClause);
whereParams.addAll(securityParams);
}
}
select = new Select(null);
select.setWhat(selectWhat);
selectParams.addAll(whatNamesParams);
StringBuilder fromb = new StringBuilder(from);
if (dialect.needsOracleJoins() && doUnion && !securityJoins.isEmpty() && queryAnalyzer.ftCount != 0) {
// NXP-5410 we must use Oracle joins
// when there's union all + fulltext + security
for (Join join : joins) {
if (!join.whereClauses.isEmpty()) {
// we cannot use Oracle join when there are join filters
throw new QueryParseException("Query too complex for Oracle (NXP-5410)");
}
}
// implicit joins for Oracle
List<String> joinClauses = new LinkedList<>();
for (Join join : joins) {
fromb.append(", ");
fromb.append(join.getTable(dialect));
if (join.tableParam != null) {
selectParams.add(join.tableParam);
}
String joinClause = join.getClause(dialect);
if (join.kind == Join.LEFT) {
joinClause += "(+)"; // Oracle implicit LEFT JOIN syntax
}
if (!join.whereClauses.isEmpty()) {
joinClause += " AND " + StringUtils.join(join.whereClauses, " AND ");
selectParams.addAll(join.whereParams);
}
joinClauses.add(joinClause);
}
whereClauses.addAll(0, joinClauses);
} else {
// else ANSI join
Collections.sort(joins); // implicit JOINs last (PostgreSQL)
for (Join join : joins) {
if (join.tableParam != null) {
selectParams.add(join.tableParam);
}
String joinClause = join.toSql(dialect);
// add JOIN filter for complex properties
if (!join.whereClauses.isEmpty()) {
joinClause += " AND " + StringUtils.join(join.whereClauses, " AND ");
selectParams.addAll(join.whereParams);
}
fromb.append(joinClause);
}
}
select.setFrom(fromb.toString());
select.setWhere(StringUtils.join(whereClauses, " AND "));
selectParams.addAll(whereParams);
statements.add(select.getStatement());
}
/*
* Create the whole select.
*/
if (doUnion) {
select = new Select(null);
// use aliases for column names
String selectWhat = StringUtils.join(aliases, ", ");
if (distinct) {
selectWhat = "DISTINCT " + selectWhat;
}
select.setWhat(selectWhat);
// note that Derby has bizarre restrictions on parentheses placement
// around UNION, see http://issues.apache.org/jira/browse/DERBY-2374
String subselect;
if (withSelects.isEmpty()) {
subselect = StringUtils.join(statements, " UNION ALL ");
} else {
StringBuilder with = new StringBuilder("WITH ");
for (int i = 0; i < statements.size(); i++) {
if (i > 0) {
with.append(", ");
}
with.append(withTables.get(i));
with.append(" AS (");
with.append(statements.get(i));
with.append(')');
}
with.append(' ');
subselect = with.toString() + StringUtils.join(withSelectsStatements, " UNION ALL ");
selectParams.addAll(withParams);
}
String selectFrom = '(' + subselect + ')';
if (dialect.needsAliasForDerivedTable()) {
selectFrom += " AS " + dialect.openQuote() + UNION_ALIAS + dialect.closeQuote();
}
select.setFrom(selectFrom);
} else {
// use last (and only) Select in above big loop
if (!withSelects.isEmpty()) {
select = new Select(null);
String with = withTables.get(0) + " AS (" + statements.get(0) + ')';
select.setWith(with);
Select withSelect = withSelects.get(0);
select.setWhat(withSelect.getWhat());
select.setFrom(withSelect.getFrom());
select.setWhere(withSelect.getWhere());
selectParams.addAll(withParams);
}
}
select.setOrderBy(orderBy);
fixSelect(select);
Query q = new Query();
ColumnMapMaker mapMaker = new ColumnMapMaker(whatColumns, whatKeys);
q.selectInfo = new SQLInfoSelect(select.getStatement(), whatColumns, mapMaker, null, null);
q.selectParams = selectParams;
return q;
}
// overridden by specialized query makers that need to tweak some joins
protected void addJoin(int kind, String alias, Table table, String column, Table contextTable, String contextColumn,
String name, int index, String primaryType) {
Column column1 = contextTable.getColumn(contextColumn);
Column column2 = table.getColumn(column);
Join join = new Join(kind, table.getRealTable().getQuotedName(), alias, null, column1, column2);
if (name != null) {
String nameCol = table.getColumn(Model.HIER_CHILD_NAME_KEY).getFullQuotedName();
join.addWhereClause(nameCol + " = ?", name);
}
if (index != -1) {
String posCol = table.getColumn(Model.HIER_CHILD_POS_KEY).getFullQuotedName();
join.addWhereClause(posCol + " = ?", Long.valueOf(index));
}
if (primaryType != null) {
String typeCol = table.getColumn(Model.MAIN_PRIMARY_TYPE_KEY).getFullQuotedName();
join.addWhereClause(typeCol + " = ?", primaryType);
}
joins.add(join);
}
/**
* Gets the table for the given fragmentName in the given contextKey, and maybe adds a join if one is not already
* done.
* <p>
* LEFT JOIN fragmentName _F123 ON contextHier.id = _F123.id
*/
protected Table getFragmentTable(Table contextHier, String contextKey, String fragmentName, int index,
boolean skipJoin) {
return getFragmentTable(Join.LEFT, contextHier, contextKey, fragmentName, Model.MAIN_KEY, index, skipJoin,
null);
}
/**
* Adds a more general JOIN:
* <p>
* (LEFT) JOIN fragmentName _F123 ON contextTable.id = _F123.fragmentColumn
*/
protected Table getFragmentTable(int joinKind, Table contextTable, String contextKey, String fragmentName,
String fragmentColumn, int index, boolean skipJoin, String primaryType) {
Table table = propertyFragmentTables.get(contextKey);
if (table == null) {
Table baseTable = database.getTable(fragmentName);
String alias = TABLE_FRAG_ALIAS + ++fragJoinCount;
table = new TableAlias(baseTable, alias);
propertyFragmentTables.put(contextKey, table);
if (!skipJoin) {
addJoin(joinKind, alias, table, fragmentColumn, contextTable, Model.MAIN_KEY, null, index, primaryType);
}
}
return table;
}
// overridden by specialized query makers that need to tweak some joins
protected void fixInitialJoins() {
// to be overridden
}
// overridden by specialized query makers that need to add COUNT
protected String getSelectColName(Column col) {
return col.getFullQuotedName();
}
/** key used to extract array index if needed */
protected String getSelectColName(Column col, String key) {
String colName = getSelectColName(col);
if (col.isArray()) {
String[] segments = canonicalXPath(key).split("/");
if (segments.length > 1) {
// last segment
String segment = segments[segments.length - 1];
if (INDEX.matcher(segment).matches() && !segment.startsWith("*")) {
int arrayElementIndex = Integer.parseInt(segment);
colName = dialect.getArrayElementString(colName, arrayElementIndex);
}
}
}
return colName;
}
// overridden by specialized query makers that need to add COUNT
protected void fixWhatColumns(List<Column> whatColumns) {
// to be overridden
}
// overridden by specialized query makers that need to add GROUP BY
protected void fixSelect(Select select) {
// to be overridden
}
protected static boolean findFulltextIndexOrField(Model model, String[] nameref) {
boolean useIndex;
String name = nameref[0];
if (name.equals(NXQL.ECM_FULLTEXT)) {
name = Model.FULLTEXT_DEFAULT_INDEX;
useIndex = true;
} else {
// ecm:fulltext_indexname
// ecm:fulltext.field
char sep = name.charAt(NXQL.ECM_FULLTEXT.length());
if (sep != '.' && sep != '_') {
throw new QueryParseException("Unknown field: " + name);
}
useIndex = sep == '_';
name = name.substring(NXQL.ECM_FULLTEXT.length() + 1);
if (useIndex) {
if (!model.getFulltextConfiguration().indexNames.contains(name)) {
throw new QueryParseException("No such fulltext index: " + name);
}
} else {
// find if there's an index holding just that field
String index = model.getFulltextConfiguration().fieldToIndexName.get(name);
if (index != null) {
name = index;
useIndex = true;
}
}
}
nameref[0] = name;
return useIndex;
}
// digits or star or star followed by digits, for segments
protected final static Pattern INDEX = Pattern.compile("\\d+|\\*|\\*\\d+");
// wildcard index in xpath
protected final static Pattern HAS_WILDCARD_INDEX = Pattern.compile(".*/(\\*|\\*\\d+)(/.*|$)");
// wildcard index at the end in xpath
protected final static Pattern HAS_FINAL_WILDCARD_INDEX = Pattern.compile(".*/(\\*|\\*\\d+)");
// digits or star or star followed by digits, then slash, for replaceAll
protected final static Pattern INDEX_SLASH = Pattern.compile("/(?:\\d+|\\*|\\*\\d+)(/|$)");
// non-canonical index syntax, for replaceAll
protected final static Pattern NON_CANON_INDEX = Pattern.compile("[^/\\[\\]]+" // name
+ "\\[(\\d+|\\*|\\*\\d+)\\]" // index in brackets
);
/**
* Canonicalizes a Nuxeo-xpath.
* <p>
* Replaces {@code a/foo[123]/b} with {@code a/123/b}
* <p>
* A star or a star followed by digits can be used instead of just the digits as well.
*
* @param xpath the xpath
* @return the canonicalized xpath.
*/
public static String canonicalXPath(String xpath) {
while (xpath.length() > 0 && xpath.charAt(0) == '/') {
xpath = xpath.substring(1);
}
if (xpath.indexOf('[') == -1) {
return xpath;
} else {
return NON_CANON_INDEX.matcher(xpath).replaceAll("$1");
}
}
/**
* Turns the xpath into one where all indices have been replaced by *.
*
* @param xpath the xpath
* @return the simple xpath
*/
public static String simpleXPath(String xpath) {
xpath = canonicalXPath(xpath);
return INDEX_SLASH.matcher(xpath).replaceAll("/*$1");
}
public boolean hasWildcardIndex(String xpath) {
xpath = canonicalXPath(xpath);
return HAS_WILDCARD_INDEX.matcher(xpath).matches();
}
public boolean hasFinalWildcardIndex(String xpath) {
xpath = canonicalXPath(xpath);
return HAS_FINAL_WILDCARD_INDEX.matcher(xpath).matches();
}
/* Turns foo/*123 into foo#123 */
protected static String keyForPos(String name) {
int i = name.lastIndexOf('/');
if (i == -1) {
throw new RuntimeException("Unexpected name: " + name);
}
return name.substring(0, i) + "#" + name.substring(i + 2);
}
protected QueryAnalyzer newQueryAnalyzer(FacetFilter facetFilter) {
return new QueryAnalyzer(facetFilter);
}
protected static Set<String> getStringLiterals(LiteralList list) {
Set<String> set = new HashSet<>();
for (Literal literal : list) {
if (!(literal instanceof StringLiteral)) {
throw new QueryParseException("requires string literals");
}
set.add(((StringLiteral) literal).value);
}
return set;
}
protected static Serializable getSerializableLiteral(Literal literal) {
Serializable value;
if (literal instanceof BooleanLiteral) {
value = Boolean.valueOf(((BooleanLiteral) literal).value);
} else if (literal instanceof DateLiteral) {
DateLiteral dLit = (DateLiteral) literal;
value = dLit.onlyDate ? dLit.toSqlDate() : dLit.toCalendar();
} else if (literal instanceof DoubleLiteral) {
value = Double.valueOf(((DoubleLiteral) literal).value);
} else if (literal instanceof IntegerLiteral) {
value = Long.valueOf(((IntegerLiteral) literal).value);
} else if (literal instanceof StringLiteral) {
value = ((StringLiteral) literal).value;
} else {
throw new QueryParseException("type of literal in list is not recognized: " + literal.getClass());
}
return value;
}
protected static List<Serializable> getSerializableLiterals(LiteralList list) {
List<Serializable> serList = new ArrayList<>(list.size());
for (Literal literal : list) {
serList.add(getSerializableLiteral(literal));
}
return serList;
}
/**
* Collects various info about the query AST, and rewrites the toplevel AND {@link Predicate}s of the WHERE clause
* into a single {@link MultiExpression} for easier analysis.
*/
protected class QueryAnalyzer extends DefaultQueryVisitor {
private static final long serialVersionUID = 1L;
protected FacetFilter facetFilter;
protected boolean inSelect;
protected boolean inOrderBy;
protected LinkedList<Operand> toplevelOperands;
protected MultiExpression wherePredicate;
/** Do we match only relations (and therefore no proxies). */
protected boolean onlyRelations;
protected List<String> whatColumnNames;
protected List<String> orderByColumnNames;
/** Do we have a SELECT somelist/* FROM ... */
protected boolean hasSelectCollection;
protected boolean hasWildcardIndex;
protected boolean orderByHasWildcardIndex;
protected int ftCount;
protected boolean selectScore;
protected boolean orderByScore;
public QueryAnalyzer(FacetFilter facetFilter) {
this.facetFilter = facetFilter;
}
protected void init() {
toplevelOperands = new LinkedList<>();
whatColumnNames = new LinkedList<>();
orderByColumnNames = new LinkedList<>();
hasWildcardIndex = false;
orderByHasWildcardIndex = false;
ftCount = 0;
selectScore = false;
orderByScore = false;
}
@Override
public void visitQuery(SQLQuery node) {
init();
if (facetFilter != null) {
addFacetFilterClauses(facetFilter);
}
visitSelectClause(node.select);
visitFromClause(node.from);
visitWhereClause(node.where); // may be null
if (node.orderBy != null) {
visitOrderByClause(node.orderBy);
}
}
public void addFacetFilterClauses(FacetFilter facetFilter) {
for (String mixin : facetFilter.required) {
// every facet is required, not just any of them,
// so do them one by one
// expr = getMixinsMatchExpression(Collections.singleton(facet),
// true);
Expression expr = new Expression(new Reference(NXQL.ECM_MIXINTYPE), Operator.EQ,
new StringLiteral(mixin));
toplevelOperands.add(expr);
}
if (!facetFilter.excluded.isEmpty()) {
// expr = getMixinsMatchExpression(facetFilter.excluded, false);
LiteralList list = new LiteralList();
for (String mixin : facetFilter.excluded) {
list.add(new StringLiteral(mixin));
}
Expression expr = new Expression(new Reference(NXQL.ECM_MIXINTYPE), Operator.NOTIN, list);
toplevelOperands.add(expr);
}
}
@Override
public void visitSelectClause(SelectClause node) {
inSelect = true;
super.visitSelectClause(node);
inSelect = false;
}
/**
* Finds all the types to take into account (all concrete types being a subtype of the passed types) based on
* the FROM list.
* <p>
* Adds them as a ecm:primaryType match in the toplevel operands.
*/
@Override
public void visitFromClause(FromClause node) {
onlyRelations = true;
Set<String> fromTypes = new HashSet<>();
FromList elements = node.elements;
for (String typeName : elements.values()) {
if (TYPE_DOCUMENT.equalsIgnoreCase(typeName)) {
typeName = TYPE_DOCUMENT;
}
Set<String> subTypes = model.getDocumentSubTypes(typeName);
if (subTypes == null) {
throw new QueryParseException("Unknown type: " + typeName);
}
fromTypes.addAll(subTypes);
boolean isRelation = false;
do {
if (TYPE_RELATION.equals(typeName)) {
isRelation = true;
break;
}
typeName = model.getDocumentSuperType(typeName);
} while (typeName != null);
onlyRelations = onlyRelations && isRelation;
}
fromTypes.remove(Model.ROOT_TYPE);
LiteralList list = new LiteralList();
for (String type : fromTypes) {
list.add(new StringLiteral(type));
}
toplevelOperands.add(new Expression(new Reference(NXQL.ECM_PRIMARYTYPE), Operator.IN, list));
}
@Override
public void visitWhereClause(WhereClause node) {
if (node != null) {
analyzeToplevelOperands(node.predicate);
}
simplifyToplevelOperands();
wherePredicate = new MultiExpression(Operator.AND, toplevelOperands);
super.visitMultiExpression(wherePredicate);
}
/**
* Process special toplevel ANDed operands: ecm:isProxy
*/
protected void analyzeToplevelOperands(Operand node) {
if (node instanceof Expression) {
Expression expr = (Expression) node;
Operator op = expr.operator;
if (op == Operator.AND) {
analyzeToplevelOperands(expr.lvalue);
analyzeToplevelOperands(expr.rvalue);
return;
}
if (op == Operator.EQ || op == Operator.NOTEQ) {
// put reference on the left side
if (expr.rvalue instanceof Reference) {
expr = new Expression(expr.rvalue, op, expr.lvalue);
}
if (expr.lvalue instanceof Reference) {
String name = ((Reference) expr.lvalue).name;
if (NXQL.ECM_ISPROXY.equals(name)) {
analyzeToplevelIsProxy(expr);
return;
} else if (NXQL.ECM_PROXY_TARGETID.equals(name) || NXQL.ECM_PROXY_VERSIONABLEID.equals(name)) {
analyzeToplevelProxyProperty(expr);
// no return, we want the node
}
}
}
}
toplevelOperands.add(node);
}
/**
* Simplify ecm:primaryType positive references, and non-per-instance mixin types.
*/
protected void simplifyToplevelOperands() {
Set<String> primaryTypes = null; // if defined, required
for (Iterator<Operand> it = toplevelOperands.iterator(); it.hasNext();) {
// whenever we don't know how to optimize the expression,
// we just continue the loop
Operand node = it.next();
if (!(node instanceof Expression)) {
continue;
}
Expression expr = (Expression) node;
if (!(expr.lvalue instanceof Reference)) {
continue;
}
String name = ((Reference) expr.lvalue).name;
Operator op = expr.operator;
Operand rvalue = expr.rvalue;
if (NXQL.ECM_PRIMARYTYPE.equals(name)) {
if (op != Operator.EQ && op != Operator.IN) {
continue;
}
Set<String> set;
if (op == Operator.EQ) {
if (!(rvalue instanceof StringLiteral)) {
continue;
}
String primaryType = ((StringLiteral) rvalue).value;
set = new HashSet<>(Collections.singleton(primaryType));
} else { // Operator.IN
if (!(rvalue instanceof LiteralList)) {
continue;
}
set = getStringLiterals((LiteralList) rvalue);
}
if (primaryTypes == null) {
primaryTypes = set;
} else {
primaryTypes.retainAll(set);
}
it.remove(); // expression simplified into primaryTypes set
} else if (NXQL.ECM_MIXINTYPE.equals(name)) {
if (op != Operator.EQ && op != Operator.NOTEQ) {
continue;
}
if (!(rvalue instanceof StringLiteral)) {
continue;
}
String mixin = ((StringLiteral) rvalue).value;
if (!neverPerInstanceMixins.contains(mixin)) {
// mixin per instance -> primary type checks not enough
continue;
}
Set<String> set = model.getMixinDocumentTypes(mixin);
if (primaryTypes == null) {
if (op == Operator.EQ) {
primaryTypes = new HashSet<>(set); // copy
} else {
continue; // unknown positive, no optimization
}
} else {
if (op == Operator.EQ) {
primaryTypes.retainAll(set);
} else {
primaryTypes.removeAll(set);
}
}
it.remove(); // expression simplified into primaryTypes set
}
}
// readd the simplified primary types constraints
if (primaryTypes != null) {
if (primaryTypes.isEmpty()) {
// TODO better removal
primaryTypes.add("__NOSUCHTYPE__");
}
Expression expr;
if (primaryTypes.size() == 1) {
String pt = primaryTypes.iterator().next();
expr = new Expression(new Reference(NXQL.ECM_PRIMARYTYPE), Operator.EQ, new StringLiteral(pt));
} else { // primaryTypes.size() > 1
LiteralList list = new LiteralList();
for (String pt : primaryTypes) {
list.add(new StringLiteral(pt));
}
expr = new Expression(new Reference(NXQL.ECM_PRIMARYTYPE), Operator.IN, list);
}
toplevelOperands.addFirst(expr);
}
}
protected void analyzeToplevelIsProxy(Expression expr) {
if (!(expr.rvalue instanceof IntegerLiteral)) {
throw new QueryParseException(NXQL.ECM_ISPROXY + " requires literal 0 or 1 as right argument");
}
long v = ((IntegerLiteral) expr.rvalue).value;
if (v != 0 && v != 1) {
throw new QueryParseException(NXQL.ECM_ISPROXY + " requires literal 0 or 1 as right argument");
}
boolean isEq = expr.operator == Operator.EQ;
updateProxyClause(Boolean.valueOf((v == 1) == isEq), expr);
}
protected void analyzeToplevelProxyProperty(Expression expr) {
// proxies required
updateProxyClause(Boolean.TRUE, expr);
}
private void updateProxyClause(Boolean value, Expression expr) {
if (proxyClause != null && proxyClause != value) {
throw new QueryCannotMatchException();
}
proxyClause = value;
proxyClauseReason = expr.toString();
}
@Override
public void visitExpression(Expression node) {
Reference ref = node.lvalue instanceof Reference ? (Reference) node.lvalue : null;
String name = ref != null ? ref.name : null;
if (name != null && name.startsWith(NXQL.ECM_FULLTEXT) && !NXQL.ECM_FULLTEXT_JOBID.equals(name)) {
visitExpressionFulltext(node, name);
} else {
super.visitExpression(node);
}
}
protected void visitExpressionFulltext(Expression node, String name) {
if (node.operator != Operator.EQ && node.operator != Operator.LIKE) {
throw new QueryParseException(NXQL.ECM_FULLTEXT + " requires = or LIKE operator");
}
if (!(node.rvalue instanceof StringLiteral)) {
throw new QueryParseException(NXQL.ECM_FULLTEXT + " requires literal string as right argument");
}
if (model.getRepositoryDescriptor().getFulltextDescriptor().getFulltextSearchDisabled()) {
throw new QueryParseException("Fulltext search disabled by configuration");
}
String[] nameref = new String[] { name };
boolean useIndex = findFulltextIndexOrField(model, nameref);
if (useIndex) {
ftCount++;
}
}
@Override
public void visitReference(Reference node) {
boolean hasTag = false;
if (node.cast != null) {
if (!DATE_CAST.equals(node.cast)) {
throw new QueryParseException("Invalid cast: " + node);
}
}
String name = node.name;
if (NXQL.ECM_PATH.equals(name) || //
NXQL.ECM_ANCESTORID.equals(name) || //
NXQL.ECM_ISPROXY.equals(name) || //
NXQL.ECM_MIXINTYPE.equals(name)) {
if (inSelect) {
throw new QueryParseException("Cannot select on column: " + name);
}
if (inOrderBy) {
throw new QueryParseException("Cannot order by column: " + name);
}
} else if (NXQL.ECM_PRIMARYTYPE.equals(name) || //
NXQL.ECM_UUID.equals(name) || //
NXQL.ECM_NAME.equals(name) || //
NXQL.ECM_POS.equals(name) || //
NXQL.ECM_PARENTID.equals(name) || //
NXQL.ECM_LIFECYCLESTATE.equals(name) || //
NXQL.ECM_VERSIONLABEL.equals(name) || //
NXQL.ECM_VERSIONDESCRIPTION.equals(name) || //
NXQL.ECM_VERSIONCREATED.equals(name) || //
NXQL.ECM_VERSION_VERSIONABLEID.equals(name) || //
NXQL.ECM_ISLATESTVERSION.equals(name) || //
NXQL.ECM_ISLATESTMAJORVERSION.equals(name) || //
NXQL.ECM_ISVERSION_OLD.equals(name) || //
NXQL.ECM_ISVERSION.equals(name) || //
NXQL.ECM_ISCHECKEDIN.equals(name) || //
NXQL.ECM_LOCK_OWNER.equals(name) || //
NXQL.ECM_LOCK_CREATED.equals(name) || //
NXQL.ECM_PROXY_TARGETID.equals(name) || //
NXQL.ECM_PROXY_VERSIONABLEID.equals(name) || //
NXQL.ECM_FULLTEXT_JOBID.equals(name)) {
// ok
} else if (NXQL.ECM_TAG.equals(name) || name.startsWith(ECM_TAG_STAR)) {
hasTag = true;
} else if (NXQL.ECM_FULLTEXT_SCORE.equals(name)) {
if (inOrderBy) {
orderByScore = true;
} else if (inSelect) {
selectScore = true;
} else {
throw new QueryParseException("Can only use column in SELECT or ORDER BY: " + name);
}
} else if (name.startsWith(NXQL.ECM_FULLTEXT)) {
if (inSelect) {
throw new QueryParseException("Cannot select on column: " + name);
}
if (inOrderBy) {
throw new QueryParseException("Cannot order by column: " + name);
}
String[] nameref = new String[] { name };
boolean useIndex = findFulltextIndexOrField(model, nameref);
if (!useIndex) {
// LIKE on a field, continue analysing with that field
name = nameref[0];
checkProperty(name); // may throw
}
// else all is done in fulltext match info
} else if (name.startsWith(NXQL.ECM_ACL)) {
String simple = simpleXPath(name);
if (simple.equals(ECM_SIMPLE_ACP_PRINCIPAL) || simple.equals(ECM_SIMPLE_ACP_PERMISSION)
|| simple.equals(ECM_SIMPLE_ACP_GRANT) || simple.equals(ECM_SIMPLE_ACP_NAME)
|| simple.equals(ECM_SIMPLE_ACP_POS) || simple.equals(ECM_SIMPLE_ACP_CREATOR)
|| simple.equals(ECM_SIMPLE_ACP_BEGIN) || simple.equals(ECM_SIMPLE_ACP_END)
|| simple.equals(ECM_SIMPLE_ACP_STATUS)) {
// ok
} else {
throw new QueryParseException("Unknown field: " + name);
}
} else if (name.startsWith(NXQL.ECM_PREFIX)) {
throw new QueryParseException("Unknown field: " + name);
} else {
checkProperty(name); // may throw
}
if (inSelect) {
whatColumnNames.add(name);
if (hasFinalWildcardIndex(name)) {
hasSelectCollection = true;
}
} else if (inOrderBy) {
orderByColumnNames.add(name);
}
if (hasWildcardIndex(name) || hasTag) {
hasWildcardIndex = true;
if (inOrderBy) {
orderByHasWildcardIndex = true;
}
}
}
/**
* Checks that a property exists.
*
* @throws QueryParseException if the property doesn't exist
*/
protected void checkProperty(String xpath) {
String simple = simpleXPath(xpath);
ModelProperty prop = model.getPathPropertyInfo(simple);
if (prop == null || prop.isIntermediateSegment()) {
throw new QueryParseException("No such property: " + xpath);
}
}
@Override
public void visitFunction(Function node) {
if (!inSelect) {
throw new QueryParseException("Function not supported in WHERE clause: " + node);
}
String func = node.name.toUpperCase();
Operand arg;
if (!AGGREGATE_FUNCTIONS.contains(func) || node.args.size() != 1
|| !((arg = node.args.get(0)) instanceof Reference)) {
throw new QueryParseException("Function not supported: " + node);
}
visitReference((Reference) arg);
}
@Override
public void visitOrderByClause(OrderByClause node) {
inOrderBy = true;
super.visitOrderByClause(node);
inOrderBy = false;
}
}
/**
* Info about a column and its property type.
*/
protected static class ColumnInfo {
public final Column column;
public final Column posColumn;
public final int arrayElementIndex;
public final boolean isArrayElement;
public final boolean needsSubSelect;
public ColumnInfo(Column column, Column posColumn, int arrayElementIndex, boolean isArrayElement, boolean isArray) {
this.column = column;
this.posColumn = posColumn;
this.arrayElementIndex = arrayElementIndex;
this.isArrayElement = isArrayElement;
this.needsSubSelect = !isArrayElement && isArray && !column.getType().isArray();
}
}
protected WhereBuilder newWhereBuilder(boolean isProxies) {
return new WhereBuilder(isProxies);
}
/**
* Builds the database-level WHERE query from the AST.
*/
protected class WhereBuilder extends DefaultQueryVisitor {
private static final long serialVersionUID = 1L;
public static final String PATH_SEP = "/";
public final LinkedList<Column> whatColumns = new LinkedList<>();
public final LinkedList<String> whatKeys = new LinkedList<>();
public final StringBuilder buf = new StringBuilder();
// used to assign unique numbers to join aliases for complex property
// wildcard indexes or tags
protected int uniqueJoinIndex = 0;
protected int hierJoinCount = 0;
// path prefix -> hier table to join,
protected Map<String, Table> propertyHierTables = new HashMap<>();
protected final boolean isProxies;
protected boolean aliasOrderByColumns;
// internal fields
protected boolean allowSubSelect;
protected boolean inSelect;
protected boolean inOrderBy;
protected int ftJoinNumber;
protected FulltextMatchInfo ftMatchInfo;
// true when visiting the rvalue of an id expression
protected boolean visitingId;
// arrayColumnName or prefix/arrayColumnName -> array column subquery to join
protected Map<String, ArraySubQuery> propertyArraySubQueries = new HashMap<>();
protected int arraySubQueryJoinCount = 0;
// additional collection pos columns on which to ORDER BY
protected Map<String, Column> posColumns = new LinkedHashMap<>(0);
// collection pos columns for which we already did an ORDER BY
protected List<Column> posColumnsInOrderBy = new ArrayList<>();
public WhereBuilder(boolean isProxies) {
this.isProxies = isProxies;
}
protected int getUniqueJoinIndex() {
return ++uniqueJoinIndex;
}
/**
* Gets the arraySubquery for the given arrayColumn in the given contextKey, and maybe adds a JOIN if one is not
* already done.
* <p>
* LEFT JOIN (SELECT id, UNNEST(somecol) AS item, generate_subscripts(somecol, 1) AS pos FROM someschema) _A1 ON
* _A1.id = hierarchy.id
*/
protected ArraySubQuery getArraySubQuery(Table contextHier, String contextKey, Column arrayColumn,
boolean skipJoin) {
ArraySubQuery arraySubQuery = propertyArraySubQueries.get(contextKey);
if (arraySubQuery == null) {
String alias = SUBQUERY_ARRAY_ALIAS + ++arraySubQueryJoinCount;
arraySubQuery = dialect.getArraySubQuery(arrayColumn, alias);
propertyArraySubQueries.put(contextKey, arraySubQuery);
if (!skipJoin) {
Join join = new Join(Join.LEFT, arraySubQuery.toSql(), alias, null,
arraySubQuery.getSubQueryIdColumn().getFullQuotedName(),
contextHier.getColumn(Model.MAIN_KEY).getFullQuotedName());
joins.add(join);
}
}
return arraySubQuery;
}
protected ColumnInfo getSpecialColumnInfo(String name) {
String propertyName = null;
Table table = null;
String fragmentKey = null;
if (NXQL.ECM_UUID.equals(name)) {
table = hierTable;
fragmentKey = Model.MAIN_KEY;
} else if (NXQL.ECM_NAME.equals(name)) {
table = hierTable;
fragmentKey = Model.HIER_CHILD_NAME_KEY;
} else if (NXQL.ECM_POS.equals(name)) {
table = hierTable;
fragmentKey = Model.HIER_CHILD_POS_KEY;
} else if (NXQL.ECM_PARENTID.equals(name)) {
table = hierTable;
fragmentKey = Model.HIER_PARENT_KEY;
} else if (NXQL.ECM_ISVERSION_OLD.equals(name) || NXQL.ECM_ISVERSION.equals(name)) {
table = hierTable;
fragmentKey = Model.MAIN_IS_VERSION_KEY;
} else if (NXQL.ECM_ISCHECKEDIN.equals(name)) {
table = hierTable;
fragmentKey = Model.MAIN_CHECKED_IN_KEY;
} else if (NXQL.ECM_PRIMARYTYPE.equals(name)) {
table = dataHierTable;
fragmentKey = Model.MAIN_PRIMARY_TYPE_KEY;
} else if (NXQL.ECM_MIXINTYPE.equals(name)) {
// toplevel ones have been extracted by the analyzer
throw new QueryParseException("Cannot use non-toplevel " + name + " in query");
} else if (NXQL.ECM_LIFECYCLESTATE.equals(name)) {
propertyName = Model.MISC_LIFECYCLE_STATE_PROP;
} else if (NXQL.ECM_VERSIONLABEL.equals(name)) {
propertyName = Model.VERSION_LABEL_PROP;
} else if (NXQL.ECM_VERSIONDESCRIPTION.equals(name)) {
propertyName = Model.VERSION_DESCRIPTION_PROP;
} else if (NXQL.ECM_VERSIONCREATED.equals(name)) {
propertyName = Model.VERSION_CREATED_PROP;
} else if (NXQL.ECM_VERSION_VERSIONABLEID.equals(name)) {
propertyName = Model.VERSION_VERSIONABLE_PROP;
} else if (NXQL.ECM_ISLATESTVERSION.equals(name)) {
propertyName = Model.VERSION_IS_LATEST_PROP;
} else if (NXQL.ECM_ISLATESTMAJORVERSION.equals(name)) {
propertyName = Model.VERSION_IS_LATEST_MAJOR_PROP;
} else if (NXQL.ECM_LOCK_OWNER.equals(name)) {
propertyName = Model.LOCK_OWNER_PROP;
} else if (NXQL.ECM_LOCK_CREATED.equals(name)) {
propertyName = Model.LOCK_CREATED_PROP;
} else if (NXQL.ECM_PROXY_TARGETID.equals(name)) {
table = proxyTable;
fragmentKey = Model.PROXY_TARGET_KEY;
} else if (NXQL.ECM_PROXY_VERSIONABLEID.equals(name)) {
table = proxyTable;
fragmentKey = Model.PROXY_VERSIONABLE_KEY;
} else if (NXQL.ECM_FULLTEXT_JOBID.equals(name)) {
propertyName = Model.FULLTEXT_JOBID_PROP;
} else if (NXQL.ECM_FULLTEXT_SCORE.equals(name)) {
throw new QueryParseException(NXQL.ECM_FULLTEXT_SCORE + " cannot be used in WHERE clause");
} else if (name.startsWith(NXQL.ECM_FULLTEXT)) {
throw new QueryParseException(NXQL.ECM_FULLTEXT + " must be used as left-hand operand");
} else if (NXQL.ECM_TAG.equals(name) || name.startsWith(ECM_TAG_STAR)) {
/*
* JOIN relation _F1 ON hierarchy.id = _F1.source JOIN hierarchy _F2 ON _F1.id = _F2.id AND
* _F2.primarytype = 'Tagging' and returns _F2.name
*/
String suffix;
if (name.startsWith(ECM_TAG_STAR)) {
suffix = name.substring(ECM_TAG_STAR.length());
if (suffix.isEmpty()) {
// any
suffix = "/*-" + getUniqueJoinIndex();
} else {
// named
suffix = "/*" + suffix;
}
} else {
suffix = "";
}
String relContextKey = "_tag_relation" + suffix;
Table rel = getFragmentTable(Join.INNER, dataHierTable, relContextKey, RELATION_TABLE, "source", -1,
false, null);
String fragmentName = Model.HIER_TABLE_NAME;
fragmentKey = Model.HIER_CHILD_NAME_KEY;
String hierContextKey = "_tag_hierarchy" + suffix;
table = getFragmentTable(Join.INNER, rel, hierContextKey, fragmentName, Model.MAIN_KEY, -1, false,
TYPE_TAGGING);
} else if (name.startsWith(NXQL.ECM_ACL)) {
// get index and suffix; we already checked that there are two slashes
int i = name.indexOf('/');
int j = name.lastIndexOf('/');
String index = name.substring(i + 1, j); // like "*1"
String suffix = name.substring(j + 1); // like "principal"
// re-create pseudo property name, which the Model mapped to a ModelProperty
String newName = NXQL.ECM_ACL + '.' + suffix + '/' + index;
return getRegularColumnInfo(newName);
} else {
throw new QueryParseException("No such property: " + name);
}
if (table == null) {
ModelProperty propertyInfo = model.getPropertyInfo(propertyName);
String fragmentName = propertyInfo.fragmentName;
fragmentKey = propertyInfo.fragmentKey;
if (fragmentName.equals(Model.HIER_TABLE_NAME)) {
table = dataHierTable;
} else {
table = getFragmentTable(dataHierTable, fragmentName, fragmentName, -1, false);
}
}
Column column = table.getColumn(fragmentKey);
return new ColumnInfo(column, null, -1, false, false);
}
/**
* Finds info about column (special or not).
*/
public ColumnInfo getColumnInfo(String name) {
if (name.startsWith(NXQL.ECM_PREFIX)) {
return getSpecialColumnInfo(name);
} else {
return getRegularColumnInfo(name);
}
}
/**
* Gets column information for a regular property.
* <p>
* Accumulates info about joins needed to get to this property.
* <p>
* IMPORTANT: THIS MUST NOT BE CALLED TWICE ON THE SAME PROPERTY as some structures are updated (joins,
* counters).
*
* @throws QueryParseException if the property doesn't exist
*/
protected ColumnInfo getRegularColumnInfo(String xpath) {
Table contextHier;
if (model.isProxySchemaPath(xpath)) {
// if xpath for proxy, then change contextHier to proxyTable
if (proxyTable != null) {
contextHier = hierTable;
} else {
contextHier = dataHierTable;
}
} else {
contextHier = dataHierTable;
}
xpath = canonicalXPath(xpath);
String[] segments = xpath.split("/");
String simple = null; // simplified prefix to match model
String contextKey = null; // prefix used as key for table to join
String segment;
ModelProperty prop;
for (int i = 0; i < segments.length; i++) {
segment = segments[i];
simple = simple == null ? segment : simple + '/' + segment;
String contextStart = contextKey == null ? "" : contextKey + '/';
String contextSuffix = "";
int index = -1;
boolean star = false;
boolean isArrayElement = false;
if (i < segments.length - 1) {
// check if we have a complex list index in the next
// position
String next = segments[i + 1];
if (INDEX.matcher(next).matches()) {
isArrayElement = true;
if (next.startsWith("*")) {
star = true;
next = next.substring(1);
}
if (!next.isEmpty()) {
index = Integer.parseInt(next);
}
// swallow next segment
i++;
simple += "/*";
if (star) {
if (index == -1) {
// any
contextSuffix = "/*-" + getUniqueJoinIndex();
} else {
// named
contextSuffix = "/*" + index;
}
index = -1;
} else {
contextSuffix = "/" + index;
}
}
}
prop = model.getPathPropertyInfo(simple);
if (prop == null) {
throw new QueryParseException("No such property: " + xpath);
}
if (i < segments.length - 1) {
// non-final segment
if (!prop.isIntermediateSegment()) {
throw new QueryParseException("No such property: " + xpath);
}
segment = prop.getIntermediateSegment(); // canonical
contextKey = contextStart + segment + contextSuffix;
Table table = propertyHierTables.get(contextKey);
if (table == null) {
// none existing
// create new Join with hierarchy from previous
String alias = TABLE_HIER_ALIAS + ++hierJoinCount;
table = new TableAlias(dataHierTable, alias);
propertyHierTables.put(contextKey, table);
addJoin(Join.LEFT, alias, table, Model.HIER_PARENT_KEY, contextHier, Model.MAIN_KEY, segment,
index, null);
}
contextHier = table;
} else {
// last segment
if (prop.isIntermediateSegment()) {
throw new QueryParseException("No such property: " + xpath);
}
Table table = database.getTable(prop.fragmentName);
Column column = table.getColumn(prop.fragmentKey);
boolean skipJoin = !isArrayElement && prop.propertyType.isArray() && !column.isArray();
Column posColumn = null;
if (column.isArray() && star) {
contextKey = contextStart + segment + contextSuffix;
ArraySubQuery arraySubQuery = getArraySubQuery(contextHier, contextKey, column, skipJoin);
column = arraySubQuery.getSubQueryValueColumn();
} else {
// use fragment name, not segment, for table context key
contextKey = contextStart + prop.fragmentName + contextSuffix;
table = getFragmentTable(contextHier, contextKey, prop.fragmentName,
column.isArray() ? -1 : index, skipJoin);
column = table.getColumn(prop.fragmentKey);
if (star) {
// we'll have to do an ORDER BY on the pos column as well
posColumn = table.getColumn(Model.COLL_TABLE_POS_KEY);
posColumns.put(keyForPos(xpath), posColumn);
}
}
return new ColumnInfo(column, posColumn, column.isArray() ? index : -1, isArrayElement,
prop.propertyType.isArray());
}
}
throw new AssertionError("not reached");
}
@Override
public void visitQuery(SQLQuery node) {
super.visitQuery(node);
// intentionally does not set limit or offset in the query
}
@Override
public void visitSelectClause(SelectClause node) {
inSelect = true;
super.visitSelectClause(node);
inSelect = false;
}
@Override
public void visitMultiExpression(MultiExpression node) {
buf.append('(');
for (Iterator<Operand> it = node.values.iterator(); it.hasNext();) {
it.next().accept(this);
if (it.hasNext()) {
node.operator.accept(this);
}
}
buf.append(')');
}
@Override
public void visitExpression(Expression node) {
buf.append('(');
Reference ref = node.lvalue instanceof Reference ? (Reference) node.lvalue : null;
String name = ref != null ? ref.name : null;
String cast = ref != null ? ref.cast : null;
Operand rvalue = node.rvalue;
if (DATE_CAST.equals(cast)) {
checkDateLiteralForCast(rvalue, node);
}
Operator op = node.operator;
if (op == Operator.STARTSWITH) {
visitExpressionStartsWith(node);
} else if (NXQL.ECM_PATH.equals(name)) {
visitExpressionEcmPath(node);
} else if (NXQL.ECM_ANCESTORID.equals(name)) {
visitExpressionAncestorId(node);
} else if (NXQL.ECM_ISPROXY.equals(name)) {
visitExpressionIsProxy(node);
} else if (NXQL.ECM_ISVERSION_OLD.equals(name) || NXQL.ECM_ISVERSION.equals(name)) {
visitExpressionWhereFalseIsNull(node);
} else if (NXQL.ECM_ISCHECKEDIN.equals(name) || NXQL.ECM_ISLATESTVERSION.equals(name)
|| NXQL.ECM_ISLATESTMAJORVERSION.equals(name)) {
visitExpressionWhereFalseMayBeNull(node);
} else if (NXQL.ECM_MIXINTYPE.equals(name)) {
visitExpressionMixinType(node);
} else if (name != null && name.startsWith(NXQL.ECM_FULLTEXT) && !NXQL.ECM_FULLTEXT_JOBID.equals(name)) {
visitExpressionFulltext(node, name);
} else if ((op == Operator.EQ || op == Operator.NOTEQ || op == Operator.IN || op == Operator.NOTIN
|| op == Operator.LIKE || op == Operator.NOTLIKE || op == Operator.ILIKE
|| op == Operator.NOTILIKE)) {
ColumnInfo info = name == null ? null : getColumnInfo(name);
// node.lvalue must not be accepted from now on
if (info != null && info.needsSubSelect) {
// use EXISTS with subselect clause
boolean direct = op == Operator.EQ || op == Operator.IN || op == Operator.LIKE
|| op == Operator.ILIKE;
Operator directOp = direct ? op
: (op == Operator.NOTEQ ? Operator.EQ
: op == Operator.NOTIN ? Operator.IN
: op == Operator.NOTLIKE ? Operator.LIKE : Operator.ILIKE);
if (!direct) {
buf.append("NOT ");
}
generateExistsStart(buf, info.column.getTable());
allowSubSelect = true;
visitColumnExpression(info.column, directOp, rvalue, cast, name, info.arrayElementIndex);
allowSubSelect = false;
generateExistsEnd(buf);
} else if (info != null) {
// boolean literals have to be translated according the
// database dialect
if (info.column.getType() == ColumnType.BOOLEAN) {
rvalue = getBooleanLiteral(rvalue);
}
visitColumnExpression(info.column, op, rvalue, cast, name, info.arrayElementIndex);
} else {
super.visitExpression(node);
}
} else if (op == Operator.BETWEEN || op == Operator.NOTBETWEEN) {
LiteralList l = (LiteralList) rvalue;
if (DATE_CAST.equals(cast)) {
checkDateLiteralForCast(l.get(0), node);
checkDateLiteralForCast(l.get(1), node);
}
node.lvalue.accept(this);
buf.append(' ');
op.accept(this);
buf.append(' ');
l.get(0).accept(this);
buf.append(" AND ");
l.get(1).accept(this);
} else {
super.visitExpression(node);
}
buf.append(')');
}
protected Operand getBooleanLiteral(Operand rvalue) {
if (!(rvalue instanceof IntegerLiteral)) {
throw new QueryParseException("Boolean expressions require literal 0 or 1 as right argument");
}
long v = ((IntegerLiteral) rvalue).value;
if (v != 0 && v != 1) {
throw new QueryParseException("Boolean expressions require literal 0 or 1 as right argument");
}
return new BooleanLiteral(v == 1);
}
protected void visitColumnExpression(Column column, Operator op, Operand rvalue, String cast, String lvalueName,
int arrayElementIndex) {
if (op == Operator.EQ || op == Operator.NOTEQ || op == Operator.IN || op == Operator.NOTIN) {
visitExpressionEqOrIn(column, op, rvalue, cast, arrayElementIndex);
} else if (op == Operator.LIKE || op == Operator.NOTLIKE) {
visitExpressionLike(column, op, rvalue, lvalueName, arrayElementIndex);
} else if (op == Operator.ILIKE || op == Operator.NOTILIKE) {
visitExpressionIlike(column, op, rvalue, lvalueName, arrayElementIndex);
} else {
visitSimpleExpression(column, op, rvalue, cast, -1);
}
}
protected void visitSimpleExpression(Column column, Operator op, Operand rvalue, String cast,
int arrayElementIndex) {
visitReference(column, cast, arrayElementIndex);
op.accept(this);
boolean oldVisitingId = visitingId;
visitingId = column.getType().isId();
rvalue.accept(this);
visitingId = oldVisitingId;
}
/**
* This operand is going to be used with a lvalue that has a DATE cast, so if it's a date literal make sure it's
* not a TIMESTAMP.
*/
protected void checkDateLiteralForCast(Operand value, Expression node) {
if (value instanceof DateLiteral && !((DateLiteral) value).onlyDate) {
throw new QueryParseException("DATE() cast must be used with DATE literal, not TIMESTAMP: " + node);
}
}
protected void generateExistsStart(StringBuilder buf, Table table) {
String tableName;
if (table.isAlias()) {
tableName = table.getRealTable().getQuotedName() + " " + table.getQuotedName();
} else {
tableName = table.getQuotedName();
}
buf.append(String.format("EXISTS (SELECT 1 FROM %s WHERE %s = %s AND ", tableName,
dataHierTable.getColumn(Model.MAIN_KEY).getFullQuotedName(),
table.getColumn(Model.MAIN_KEY).getFullQuotedName()));
}
protected void generateExistsEnd(StringBuilder buf) {
buf.append(")");
}
protected void visitExpressionStartsWith(Expression node) {
if (!(node.lvalue instanceof Reference)) {
throw new QueryParseException("Illegal left argument for " + Operator.STARTSWITH + ": " + node.lvalue);
}
if (!(node.rvalue instanceof StringLiteral)) {
throw new QueryParseException(Operator.STARTSWITH + " requires literal path as right argument");
}
String path = ((StringLiteral) node.rvalue).value;
if (path.length() > 1 && path.endsWith(PATH_SEP)) {
path = path.substring(0, path.length() - PATH_SEP.length());
}
String name = ((Reference) node.lvalue).name;
if (NXQL.ECM_PATH.equals(name)) {
visitExpressionStartsWithPath(path);
} else {
visitExpressionStartsWithNonPath(node, path);
}
}
protected void visitExpressionStartsWithPath(String path) {
// find the id from the path
Serializable id = pathResolver.getIdForPath(path);
if (id == null) {
// no such path, always return a false
// TODO remove the expression more intelligently from the parse
// tree
buf.append("0=1");
} else {
// id is always valid, no need to pass it as argument to getInTreeSql
buf.append(dialect.getInTreeSql(hierTable.getColumn(Model.MAIN_KEY).getFullQuotedName(), null));
whereParams.add(id);
}
}
protected void visitExpressionStartsWithNonPath(Expression node, String path) {
String name = ((Reference) node.lvalue).name;
ColumnInfo info = getColumnInfo(name);
if (info.needsSubSelect) {
// use EXISTS with subselect clause
generateExistsStart(buf, info.column.getTable());
}
buf.append('(');
visitExpressionEqOrIn(info.column, Operator.EQ, new StringLiteral(path), null, -1);
visitOperator(Operator.OR);
// TODO escape % chars...
visitExpressionLike(info.column, Operator.LIKE, new StringLiteral(path + PATH_SEP + '%'), name, -1);
buf.append(')');
if (info.needsSubSelect) {
generateExistsEnd(buf);
}
}
protected void visitExpressionEcmPath(Expression node) {
if (node.operator != Operator.EQ && node.operator != Operator.NOTEQ) {
throw new QueryParseException(NXQL.ECM_PATH + " requires = or <> operator");
}
if (!(node.rvalue instanceof StringLiteral)) {
throw new QueryParseException(NXQL.ECM_PATH + " requires literal path as right argument");
}
String path = ((StringLiteral) node.rvalue).value;
if (path.length() > 1 && path.endsWith(PATH_SEP)) {
path = path.substring(0, path.length() - PATH_SEP.length());
}
Serializable id = pathResolver.getIdForPath(path);
if (id == null) {
// no such path, always return a false
// TODO remove the expression more intelligently from the parse
// tree
buf.append("0=1");
} else {
visitReference(hierTable.getColumn(Model.MAIN_KEY));
visitOperator(node.operator);
visitId(model.idToString(id));
}
}
protected void visitExpressionAncestorId(Expression node) {
if (node.operator != Operator.EQ && node.operator != Operator.NOTEQ) {
throw new QueryParseException(NXQL.ECM_ANCESTORID + " requires = or <> operator");
}
if (!(node.rvalue instanceof StringLiteral)) {
throw new QueryParseException(NXQL.ECM_ANCESTORID + " requires literal id as right argument");
}
boolean not = node.operator == Operator.NOTEQ;
String id = ((StringLiteral) node.rvalue).value;
if (not) {
buf.append("(NOT (");
}
String sql = dialect.getInTreeSql(hierTable.getColumn(Model.MAIN_KEY).getFullQuotedName(), id);
if (sql == null) {
buf.append("0=1");
} else {
buf.append(sql);
whereParams.add(id);
}
if (not) {
buf.append("))");
}
}
protected void visitExpressionIsProxy(Expression node) {
boolean bool = getBooleanRValue(NXQL.ECM_ISPROXY, node);
buf.append(isProxies == bool ? "1=1" : "0=1");
}
protected void visitExpressionWhereFalseIsNull(Expression node) {
String name = ((Reference) node.lvalue).name;
boolean bool = getBooleanRValue(name, node);
node.lvalue.accept(this);
if (bool) {
buf.append(" = ");
buf.append(dialect.toBooleanValueString(true));
} else {
buf.append(" IS NULL");
}
}
protected void visitExpressionWhereFalseMayBeNull(Expression node) {
String name = ((Reference) node.lvalue).name;
boolean bool = getBooleanRValue(name, node);
if (bool) {
node.lvalue.accept(this);
buf.append(" = ");
buf.append(dialect.toBooleanValueString(true));
} else {
buf.append('(');
node.lvalue.accept(this);
buf.append(" = ");
buf.append(dialect.toBooleanValueString(false));
buf.append(" OR ");
node.lvalue.accept(this);
buf.append(" IS NULL)");
}
}
private boolean getBooleanRValue(String name, Expression node) {
if (node.operator != Operator.EQ && node.operator != Operator.NOTEQ) {
throw new QueryParseException(name + " requires = or <> operator");
}
long v;
if (!(node.rvalue instanceof IntegerLiteral)
|| ((v = ((IntegerLiteral) node.rvalue).value) != 0 && v != 1)) {
throw new QueryParseException(name + " requires literal 0 or 1 as right argument");
}
return node.operator == Operator.EQ ^ v == 0;
}
/**
* Include or exclude mixins.
* <p>
* include: primarytype IN (... types with Foo or Bar ...) OR mixintypes LIKE '%Foo%' OR mixintypes LIKE '%Bar%'
* <p>
* exclude: primarytype IN (... types without Foo or Bar ...) AND (mixintypes NOT LIKE '%Foo%' AND mixintypes
* NOT LIKE '%Bar%' OR mixintypes IS NULL)
*/
protected void visitExpressionMixinType(Expression expr) {
boolean include;
Set<String> mixins;
Operator op = expr.operator;
if (op == Operator.EQ || op == Operator.NOTEQ) {
include = op == Operator.EQ;
if (!(expr.rvalue instanceof StringLiteral)) {
throw new QueryParseException(NXQL.ECM_MIXINTYPE + " = requires literal string as right argument");
}
String value = ((StringLiteral) expr.rvalue).value;
mixins = Collections.singleton(value);
} else if (op == Operator.IN || op == Operator.NOTIN) {
include = op == Operator.IN;
if (!(expr.rvalue instanceof LiteralList)) {
throw new QueryParseException(NXQL.ECM_MIXINTYPE + " = requires string list as right argument");
}
mixins = getStringLiterals((LiteralList) expr.rvalue);
} else {
throw new QueryParseException(NXQL.ECM_MIXINTYPE + " unknown operator: " + op);
}
/*
* Primary types
*/
Set<String> types;
if (include) {
types = new HashSet<>();
for (String mixin : mixins) {
types.addAll(model.getMixinDocumentTypes(mixin));
}
} else {
types = new HashSet<>(model.getDocumentTypes());
for (String mixin : mixins) {
types.removeAll(model.getMixinDocumentTypes(mixin));
}
}
/*
* Instance mixins
*/
Set<String> instanceMixins = new HashSet<>();
for (String mixin : mixins) {
if (!neverPerInstanceMixins.contains(mixin)) {
instanceMixins.add(mixin);
}
}
/*
* SQL generation
*/
if (!types.isEmpty()) {
Column col = dataHierTable.getColumn(Model.MAIN_PRIMARY_TYPE_KEY);
visitReference(col);
buf.append(" IN ");
buf.append('(');
for (Iterator<String> it = types.iterator(); it.hasNext();) {
visitStringLiteral(it.next());
if (it.hasNext()) {
buf.append(", ");
}
}
buf.append(')');
if (!instanceMixins.isEmpty()) {
buf.append(include ? " OR " : " AND ");
}
}
if (!instanceMixins.isEmpty()) {
buf.append('(');
Column mixinsColumn = dataHierTable.getColumn(Model.MAIN_MIXIN_TYPES_KEY);
String[] returnParam = new String[1];
for (Iterator<String> it = instanceMixins.iterator(); it.hasNext();) {
String mixin = it.next();
String sql = dialect.getMatchMixinType(mixinsColumn, mixin, include, returnParam);
buf.append(sql);
if (returnParam[0] != null) {
whereParams.add(returnParam[0]);
}
if (it.hasNext()) {
buf.append(include ? " OR " : " AND ");
}
}
if (!include) {
buf.append(" OR ");
visitReference(mixinsColumn);
buf.append(" IS NULL");
}
buf.append(')');
}
if (types.isEmpty() && instanceMixins.isEmpty()) {
buf.append(include ? "0=1" : "0=0");
}
}
protected void visitExpressionFulltext(Expression node, String name) {
String[] nameref = new String[] { name };
boolean useIndex = findFulltextIndexOrField(model, nameref);
name = nameref[0];
if (useIndex) {
// use actual fulltext query using a dedicated index
String fulltextQuery = ((StringLiteral) node.rvalue).value;
fulltextQuery = dialect.getDialectFulltextQuery(fulltextQuery);
ftJoinNumber++;
Column mainColumn = dataHierTable.getColumn(Model.MAIN_KEY);
FulltextMatchInfo info = dialect.getFulltextScoredMatchInfo(fulltextQuery, name, ftJoinNumber,
mainColumn, model, database);
ftMatchInfo = info;
if (info.joins != null) {
joins.addAll(info.joins);
}
buf.append(info.whereExpr);
if (info.whereExprParam != null) {
whereParams.add(info.whereExprParam);
}
} else {
// single field matched with ILIKE
log.warn("No fulltext index configured for field " + name + ", falling back on LIKE query");
String value = ((StringLiteral) node.rvalue).value;
// fulltext translation into pseudo-LIKE syntax
Set<String> words = FullTextUtils.parseFullText(value, false);
if (words.isEmpty()) {
// only stop words or empty
value = "DONTMATCHANYTHINGFOREMPTYQUERY";
} else {
value = "%" + StringUtils.join(new ArrayList<>(words), "%") + "%";
}
Reference ref = new Reference(name);
if (dialect.supportsIlike()) {
visitReference(ref);
buf.append(" ILIKE ");
visitStringLiteral(value);
} else {
buf.append("LOWER(");
visitReference(ref);
buf.append(") LIKE ");
visitStringLiteral(value);
}
}
}
protected void visitExpressionEqOrIn(Column column, Operator op, Operand rvalue, String cast,
int arrayElementIndex) {
if (column.isArray() && arrayElementIndex == -1) {
List<Serializable> params;
if (rvalue instanceof Literal) {
Serializable param = getSerializableLiteral((Literal) rvalue);
params = Collections.singletonList(param);
} else {
params = getSerializableLiterals((LiteralList) rvalue);
}
boolean positive = op == Operator.EQ || op == Operator.IN;
String sql = dialect.getArrayInSql(column, cast, positive, params);
buf.append(sql);
whereParams.addAll(params);
} else {
visitSimpleExpression(column, op, rvalue, cast, arrayElementIndex);
}
}
protected void visitExpressionLike(Column column, Operator op, Operand rvalue, String lvalueName,
int arrayElementIndex) {
if (column.isArray() && arrayElementIndex == -1) {
if (lvalueName == null) {
throw new AssertionError("Name is required when lvalue is an array");
}
boolean positive = (op == Operator.LIKE);
String sql = dialect.getArrayLikeSql(column, lvalueName, positive, dataHierTable);
buf.append(sql);
whereParams.add(getSerializableLiteral((Literal) rvalue));
} else {
visitSimpleExpression(column, op, rvalue, null, arrayElementIndex);
addLikeEscaping();
}
}
protected void visitExpressionIlike(Column column, Operator op, Operand rvalue, String lvalueName,
int arrayElementIndex) {
if (column.isArray() && arrayElementIndex == -1) {
if (lvalueName == null) {
throw new AssertionError("Name is required when lvalue is an array");
}
boolean positive = op == Operator.ILIKE;
String sql = dialect.getArrayIlikeSql(column, lvalueName, positive, dataHierTable);
buf.append(sql);
whereParams.add(getSerializableLiteral((Literal) rvalue));
} else if (dialect.supportsIlike()) {
visitSimpleExpression(column, op, rvalue, null, arrayElementIndex);
} else {
buf.append("LOWER(");
visitReference(column, arrayElementIndex);
buf.append(") ");
if (op == Operator.NOTILIKE) {
buf.append("NOT ");
}
buf.append("LIKE");
buf.append(" LOWER(");
rvalue.accept(this);
buf.append(")");
addLikeEscaping();
}
}
protected void addLikeEscaping() {
String escape = dialect.getLikeEscaping();
if (escape != null) {
buf.append(escape);
}
}
@Override
public void visitOperator(Operator node) {
if (node != Operator.NOT) {
buf.append(' ');
}
buf.append(node.toString());
buf.append(' ');
}
@Override
public void visitReference(Reference node) {
String name = node.name;
if (NXQL.ECM_FULLTEXT_SCORE.equals(name)) {
visitScore();
return;
}
ColumnInfo info = getColumnInfo(name);
if (info.needsSubSelect && !allowSubSelect) {
String msg = inOrderBy ? "Cannot use collection %s in ORDER BY clause"
: "Can only use collection %s with =, <>, IN or NOT IN clause";
throw new QueryParseException(String.format(msg, name));
}
if (inSelect) {
whatColumns.add(info.column);
whatKeys.add(name);
if (info.posColumn != null) {
whatColumns.add(info.posColumn);
whatKeys.add(keyForPos(name));
}
} else {
visitReference(info.column, node.cast);
if (inOrderBy && info.posColumn != null) {
buf.append(", ");
visitReference(info.posColumn);
posColumnsInOrderBy.add(info.posColumn);
}
}
}
protected void visitReference(Column column) {
visitReference(column, null, -1);
}
protected void visitReference(Column column, String cast) {
visitReference(column, cast, -1);
}
protected void visitReference(Column column, int arrayElementIndex) {
visitReference(column, null, arrayElementIndex);
}
protected void visitReference(Column column, String cast, int arrayElementIndex) {
if (DATE_CAST.equals(cast) && column.getType() != ColumnType.TIMESTAMP) {
throw new QueryParseException("Cannot cast to " + cast + ": " + column);
}
String qname = column.getFullQuotedName();
if (arrayElementIndex != -1) {
if (column.isArray()) {
qname = dialect.getArrayElementString(qname, arrayElementIndex);
} else {
throw new QueryParseException(
"Cannot use array index " + arrayElementIndex + " for non-array column " + column);
}
}
// some databases (Derby) can't do comparisons on CLOB
if (column.getJdbcType() == Types.CLOB) {
String colFmt = dialect.getClobCast(inOrderBy);
if (colFmt != null) {
qname = String.format(colFmt, qname, Integer.valueOf(255));
}
}
if (cast != null) {
// only DATE cast for now
String fmt = dialect.getDateCast();
buf.append(String.format(fmt, qname));
} else {
buf.append(qname);
}
}
@Override
public void visitLiteralList(LiteralList node) {
buf.append('(');
for (Iterator<Literal> it = node.iterator(); it.hasNext();) {
it.next().accept(this);
if (it.hasNext()) {
buf.append(", ");
}
}
buf.append(')');
}
@Override
public void visitDateLiteral(DateLiteral node) {
buf.append('?');
if (node.onlyDate) {
whereParams.add(node.toSqlDate());
} else {
whereParams.add(node.toCalendar());
}
}
@Override
public void visitStringLiteral(StringLiteral node) {
if (visitingId) {
visitId(node.value);
} else {
visitStringLiteral(node.value);
}
}
// wrap the string so that the mapper can detect it
// and map to an actual database id
protected void visitId(String string) {
buf.append('?');
whereParams.add(new WrappedId(string));
}
public void visitStringLiteral(String string) {
buf.append('?');
whereParams.add(string);
}
@Override
public void visitDoubleLiteral(DoubleLiteral node) {
buf.append(node.value);
}
@Override
public void visitIntegerLiteral(IntegerLiteral node) {
buf.append(node.value);
}
@Override
public void visitBooleanLiteral(BooleanLiteral node) {
buf.append('?');
whereParams.add(Boolean.valueOf(node.value));
}
@Override
public void visitFunction(Function node) {
String func = node.name.toUpperCase();
Reference ref = (Reference) node.args.get(0);
ref.accept(this); // whatColumns / whatKeys for column
// replace column info with aggregate
Column col = whatColumns.removeLast();
String key = whatKeys.removeLast();
final String aggFQN = func + "(" + col.getFullQuotedName() + ")";
final ColumnType aggType = getAggregateType(func, col.getType());
final int aggJdbcType = dialect.getJDBCTypeAndString(aggType).jdbcType;
Column cc = new Column(col, col.getTable()) {
private static final long serialVersionUID = 1L;
@Override
public String getFullQuotedName() {
return aggFQN;
}
@Override
public ColumnType getType() {
return aggType;
}
@Override
public int getJdbcType() {
return aggJdbcType;
}
};
whatColumns.add(cc);
whatKeys.add(func + "(" + key + ")");
}
protected void visitScore() {
if (inSelect) {
Column col = new Column(hierTable, null, ColumnType.DOUBLE, null);
whatColumns.add(col);
whatKeys.add(NXQL.ECM_FULLTEXT_SCORE);
} else {
buf.append(aliasesByName.get(NXQL.ECM_FULLTEXT_SCORE));
}
}
protected ColumnType getAggregateType(String func, ColumnType arg) {
if (COUNT_FUNCTION.equals(func)) {
return ColumnType.LONG;
}
if (AVG_FUNCTION.equals(func)) {
return ColumnType.DOUBLE;
}
// SUM, MIN, MAX
return arg;
}
@Override
public void visitOrderByList(OrderByList node) {
inOrderBy = true;
for (OrderByExpr obe : node) {
if (buf.length() != 0) {
// we can do this because we generate in an initially empty buffer
buf.append(", ");
}
obe.accept(this);
}
inOrderBy = false;
}
public void visitOrderByPosColumns() {
inOrderBy = true;
for (Entry<String, Column> es : posColumns.entrySet()) {
Column col = es.getValue();
if (posColumnsInOrderBy.contains(col)) {
continue;
}
if (buf.length() != 0) {
buf.append(", ");
}
int length = buf.length();
visitReference(col);
if (aliasOrderByColumns) {
// but don't use generated values
// make the ORDER BY clause uses the aliases instead
buf.setLength(length);
String alias = aliasesByName.get(es.getKey());
buf.append(alias);
}
}
inOrderBy = false;
}
@Override
public void visitOrderByExpr(OrderByExpr node) {
int length = buf.length();
// generates needed joins
super.visitOrderByExpr(node); // visit reference
if (aliasOrderByColumns) {
// but don't use generated values
// make the ORDER BY clause uses the aliases instead
buf.setLength(length);
buf.append(aliasesByName.get(node.reference.name));
}
if (node.isDescending) {
buf.append(dialect.getDescending());
}
}
}
}