/* * (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 * Benoit Delbosc */ package org.nuxeo.ecm.core.storage.sql.jdbc.dialect; import java.io.Serializable; import java.sql.Array; import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.sql.Timestamp; import java.sql.Types; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.UUID; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.nuxeo.ecm.core.NXCore; import org.nuxeo.ecm.core.api.NuxeoException; import org.nuxeo.ecm.core.api.security.SecurityConstants; import org.nuxeo.ecm.core.query.QueryParseException; import org.nuxeo.ecm.core.security.SecurityService; import org.nuxeo.ecm.core.storage.FulltextConfiguration; import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer; import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer.FulltextQuery; import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer.Op; import org.nuxeo.ecm.core.storage.sql.ColumnType; import org.nuxeo.ecm.core.storage.sql.Model; import org.nuxeo.ecm.core.storage.sql.RepositoryDescriptor; import org.nuxeo.ecm.core.storage.sql.jdbc.JDBCLogger; 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.Table; import org.nuxeo.ecm.core.storage.sql.jdbc.db.TableAlias; /** * PostgreSQL-specific dialect. * * @author Florent Guillaume */ public class DialectPostgreSQL extends Dialect { private static final Log log = LogFactory.getLog(DialectPostgreSQL.class); private static final String DEFAULT_FULLTEXT_ANALYZER = "english"; private static final String DEFAULT_USERS_SEPARATOR = ","; private static final String PREFIX_SEARCH = ":*"; // prefix search syntax foo* or foo% or foo:*-> foo:* private static final Pattern PREFIX_PATTERN = Pattern.compile("(\\*|%|:\\*)( |\"|$)"); private static final String PREFIX_REPL = PREFIX_SEARCH + "$2"; private static final String[] RESERVED_COLUMN_NAMES = { "xmin", "xmax", "cmin", "cmax", "ctid", "oid", "tableoid" }; private static final String UNLOGGED_KEYWORD = "UNLOGGED"; protected final String fulltextAnalyzer; protected final boolean supportsWith; protected boolean hierarchyCreated; protected boolean pathOptimizationsEnabled; protected final boolean arrayColumnsEnabled; protected String usersSeparator; protected final DialectIdType idType; protected boolean compatibilityFulltextTable; protected final String unloggedKeyword; protected String idSequenceName; public DialectPostgreSQL(DatabaseMetaData metadata, RepositoryDescriptor repositoryDescriptor) { super(metadata, repositoryDescriptor); fulltextAnalyzer = repositoryDescriptor == null ? null : repositoryDescriptor.getFulltextAnalyzer() == null ? DEFAULT_FULLTEXT_ANALYZER : repositoryDescriptor.getFulltextAnalyzer(); pathOptimizationsEnabled = repositoryDescriptor != null && repositoryDescriptor.getPathOptimizationsEnabled(); if (repositoryDescriptor != null) { log.info("Path optimizations " + (pathOptimizationsEnabled ? "enabled" : "disabled")); } arrayColumnsEnabled = repositoryDescriptor != null && repositoryDescriptor.getArrayColumns(); int major, minor; try { major = metadata.getDatabaseMajorVersion(); minor = metadata.getDatabaseMinorVersion(); } catch (SQLException e) { throw new NuxeoException(e); } supportsWith = major > 8 || (major == 8 && minor >= 4); if ((major == 9 && minor >= 1) || (major > 9)) { unloggedKeyword = UNLOGGED_KEYWORD; } else { unloggedKeyword = ""; } usersSeparator = repositoryDescriptor == null ? null : repositoryDescriptor.usersSeparatorKey == null ? DEFAULT_USERS_SEPARATOR : repositoryDescriptor.usersSeparatorKey; String idt = repositoryDescriptor == null ? null : repositoryDescriptor.idType; if (idt == null || "".equals(idt) || "varchar".equalsIgnoreCase(idt)) { idType = DialectIdType.VARCHAR; } else if ("uuid".equalsIgnoreCase(idt)) { idType = DialectIdType.UUID; } else if (idt.toLowerCase().startsWith("sequence")) { idType = DialectIdType.SEQUENCE; if (idt.toLowerCase().startsWith("sequence:")) { String[] split = idt.split(":"); idSequenceName = split[1]; } else { idSequenceName = "hierarchy_seq"; } } else { throw new NuxeoException("Unknown id type: '" + idt + "'"); } try { compatibilityFulltextTable = getCompatibilityFulltextTable(metadata); } catch (SQLException e) { throw new NuxeoException(e); } } protected boolean getCompatibilityFulltextTable(DatabaseMetaData metadata) throws SQLException { ResultSet rs = metadata.getColumns(null, null, Model.FULLTEXT_TABLE_NAME, "%"); while (rs.next()) { // COLUMN_NAME=fulltext DATA_TYPE=1111 TYPE_NAME=tsvector String columnName = rs.getString("COLUMN_NAME"); if (Model.FULLTEXT_FULLTEXT_KEY.equals(columnName)) { String typeName = rs.getString("TYPE_NAME"); return "tsvector".equals(typeName); } } return false; } @Override public String toBooleanValueString(boolean bool) { return bool ? "true" : "false"; } @Override public String getNoColumnsInsertString(Column idColumn) { return "DEFAULT VALUES"; } @Override public String getCascadeDropConstraintsString() { return "CASCADE"; } @Override public JDBCInfo getJDBCTypeAndString(ColumnType type) { switch (type.spec) { case STRING: if (type.isUnconstrained()) { return jdbcInfo("varchar", Types.VARCHAR); } else if (type.isClob()) { return jdbcInfo("text", Types.CLOB); } else { return jdbcInfo("varchar(%d)", type.length, Types.VARCHAR); } case ARRAY_STRING: if (type.isUnconstrained()) { return jdbcInfo("varchar[]", Types.ARRAY, "varchar", Types.VARCHAR); } else if (type.isClob()) { return jdbcInfo("text[]", Types.ARRAY, "text", Types.CLOB); } else { return jdbcInfo("varchar(%d)[]", type.length, Types.ARRAY, "varchar", Types.VARCHAR); } case BOOLEAN: return jdbcInfo("bool", Types.BIT); case ARRAY_BOOLEAN: return jdbcInfo("bool[]", Types.ARRAY, "bool", Types.BOOLEAN); case LONG: return jdbcInfo("int8", Types.BIGINT); case ARRAY_LONG: return jdbcInfo("int8[]", Types.ARRAY, "int8", Types.BIGINT); case DOUBLE: return jdbcInfo("float8", Types.DOUBLE); case ARRAY_DOUBLE: return jdbcInfo("float8[]", Types.ARRAY, "float8", Types.DOUBLE); case TIMESTAMP: return jdbcInfo("timestamp", Types.TIMESTAMP); case ARRAY_TIMESTAMP: return jdbcInfo("timestamp[]", Types.ARRAY, "timestamp", Types.TIMESTAMP); case BLOBID: return jdbcInfo("varchar(250)", Types.VARCHAR); case ARRAY_BLOBID: return jdbcInfo("varchar(250)[]", Types.ARRAY, "varchar", Types.VARCHAR); // ----- case NODEID: case NODEIDFK: case NODEIDFKNP: case NODEIDFKMUL: case NODEIDFKNULL: case NODEIDPK: case NODEVAL: switch (idType) { case VARCHAR: return jdbcInfo("varchar(36)", Types.VARCHAR); case UUID: return jdbcInfo("uuid", Types.OTHER); case SEQUENCE: return jdbcInfo("int8", Types.BIGINT); } case NODEARRAY: switch (idType) { case VARCHAR: return jdbcInfo("varchar(36)[]", Types.ARRAY, "varchar", Types.VARCHAR); case UUID: return jdbcInfo("uuid[]", Types.ARRAY, "uuid", Types.OTHER); case SEQUENCE: return jdbcInfo("int8[]", Types.ARRAY, "int8", Types.BIGINT); } case SYSNAME: return jdbcInfo("varchar(250)", Types.VARCHAR); case SYSNAMEARRAY: return jdbcInfo("varchar(250)[]", Types.ARRAY, "varchar", Types.VARCHAR); case TINYINT: return jdbcInfo("int2", Types.SMALLINT); case INTEGER: return jdbcInfo("int4", Types.INTEGER); case ARRAY_INTEGER: return jdbcInfo("int4[]", Types.ARRAY, "int4", Types.INTEGER); case AUTOINC: return jdbcInfo("serial", Types.INTEGER); case FTINDEXED: if (compatibilityFulltextTable) { return jdbcInfo("tsvector", Types.OTHER); } else { return jdbcInfo("text", Types.CLOB); } case FTSTORED: if (compatibilityFulltextTable) { return jdbcInfo("tsvector", Types.OTHER); } else { return jdbcInfo("text", Types.CLOB); } case CLUSTERNODE: return jdbcInfo("int4", Types.INTEGER); case CLUSTERFRAGS: return jdbcInfo("varchar[]", Types.ARRAY, "varchar", Types.VARCHAR); } throw new AssertionError(type); } @Override public boolean isAllowedConversion(int expected, int actual, String actualName, int actualSize) { // CLOB vs VARCHAR compatibility if (expected == Types.VARCHAR && actual == Types.CLOB) { return true; } if (expected == Types.CLOB && actual == Types.VARCHAR) { return true; } // INTEGER vs BIGINT compatibility if (expected == Types.BIGINT && actual == Types.INTEGER) { return true; } if (expected == Types.INTEGER && actual == Types.BIGINT) { return true; } // TSVECTOR vs CLOB compatibility during upgrade tests // where column detection is done before upgrade test setup if (expected == Types.CLOB && (actual == Types.OTHER && actualName.equals("tsvector"))) { return true; } return false; } @Override public Serializable getGeneratedId(Connection connection) throws SQLException { if (idType != DialectIdType.SEQUENCE) { return super.getGeneratedId(connection); } String sql = String.format("SELECT NEXTVAL('%s')", idSequenceName); try (Statement s = connection.createStatement()) { ResultSet rs = s.executeQuery(sql); rs.next(); return Long.valueOf(rs.getLong(1)); } } @Override public void setId(PreparedStatement ps, int index, Serializable value) throws SQLException { switch (idType) { case VARCHAR: ps.setObject(index, value); break; case UUID: ps.setObject(index, value, Types.OTHER); break; case SEQUENCE: setIdLong(ps, index, value); break; default: throw new AssertionError(); } } @SuppressWarnings("boxing") public Serializable getId(ResultSet rs, int index) throws SQLException { switch (idType) { case VARCHAR: case UUID: return rs.getString(index); case SEQUENCE: return rs.getLong(index); default: throw new AssertionError(); } } @Override public void setToPreparedStatement(PreparedStatement ps, int index, Serializable value, Column column) throws SQLException { switch (column.getJdbcType()) { case Types.VARCHAR: case Types.CLOB: setToPreparedStatementString(ps, index, value, column); return; case Types.BIT: ps.setBoolean(index, ((Boolean) value).booleanValue()); return; case Types.SMALLINT: ps.setInt(index, ((Long) value).intValue()); return; case Types.INTEGER: case Types.BIGINT: ps.setLong(index, ((Number) value).longValue()); return; case Types.DOUBLE: ps.setDouble(index, ((Double) value).doubleValue()); return; case Types.TIMESTAMP: ps.setTimestamp(index, getTimestampFromCalendar((Calendar) value)); return; case Types.ARRAY: int jdbcBaseType = column.getJdbcBaseType(); String jdbcBaseTypeName = column.getSqlBaseTypeString(); if (jdbcBaseType == Types.TIMESTAMP) { value = getTimestampFromCalendar((Serializable[]) value); } Array array = ps.getConnection().createArrayOf(jdbcBaseTypeName, (Object[]) value); ps.setArray(index, array); return; case Types.OTHER: ColumnType type = column.getType(); if (type.isId()) { setId(ps, index, value); return; } else if (type == ColumnType.FTSTORED) { ps.setString(index, (String) value); return; } throw new SQLException("Unhandled type: " + column.getType()); default: throw new SQLException("Unhandled JDBC type: " + column.getJdbcType()); } } @Override @SuppressWarnings("boxing") public Serializable getFromResultSet(ResultSet rs, int index, Column column) throws SQLException { int jdbcType = rs.getMetaData().getColumnType(index); if (column.getJdbcType() == Types.ARRAY && jdbcType != Types.ARRAY) { jdbcType = column.getJdbcBaseType(); } else { jdbcType = column.getJdbcType(); } switch (jdbcType) { case Types.VARCHAR: case Types.CLOB: return getFromResultSetString(rs, index, column); case Types.BIT: return rs.getBoolean(index); case Types.SMALLINT: case Types.INTEGER: case Types.BIGINT: return rs.getLong(index); case Types.DOUBLE: return rs.getDouble(index); case Types.TIMESTAMP: return getCalendarFromTimestamp(rs.getTimestamp(index)); case Types.ARRAY: Array array = rs.getArray(index); if (array == null) { return null; } if (array.getBaseType() == Types.TIMESTAMP) { return getCalendarFromTimestamp((Timestamp[]) array.getArray()); } else { return (Serializable) array.getArray(); } case Types.OTHER: ColumnType type = column.getType(); if (type.isId()) { return getId(rs, index); } throw new SQLException("Unhandled type: " + column.getType()); } throw new SQLException( "Unhandled JDBC type: " + column.getJdbcType() + " for type " + column.getType().toString()); } @Override protected int getMaxNameSize() { return 63; } @Override public String getColumnName(String name) { // ignore suffixed "_" when checking for reservedness String n = name.replaceAll("_+$", ""); for (String reserved : RESERVED_COLUMN_NAMES) { if (n.equals(reserved)) { // reserved, add one more suffix "_" name += "_"; break; } } return super.getColumnName(name); } @Override public String getCreateFulltextIndexSql(String indexName, String quotedIndexName, Table table, List<Column> columns, Model model) { String sql; if (compatibilityFulltextTable) { sql = "CREATE INDEX %s ON %s USING GIN(%s)"; } else { sql = "CREATE INDEX %s ON %s USING GIN(NX_TO_TSVECTOR(%s))"; } return String.format(sql, quotedIndexName.toLowerCase(), table.getQuotedName(), columns.get(0).getQuotedName()); } // must not be interpreted as a regexp, we split on it protected static final String FT_LIKE_SEP = " @#AND#@ "; protected static final String FT_LIKE_COL = "??"; /** * {@inheritDoc} * <p> * The result of this is passed to {@link #getFulltextScoredMatchInfo}. */ @Override public String getDialectFulltextQuery(String query) { query = query.replace(" & ", " "); // PostgreSQL compatibility BBB query = PREFIX_PATTERN.matcher(query).replaceAll(PREFIX_REPL); FulltextQuery ft = FulltextQueryAnalyzer.analyzeFulltextQuery(query); if (ft == null) { return ""; // won't match anything } if (!FulltextQueryAnalyzer.hasPhrase(ft)) { return FulltextQueryAnalyzer.translateFulltext(ft, "|", "&", "& !", ""); } if (compatibilityFulltextTable) { throw new QueryParseException("Cannot use phrase search in fulltext compatibilty mode. " + "Please upgrade the fulltext table: " + query); } /* * Phrase search. We have to do the phrase query using a LIKE, but for performance we pre-filter using as many * fulltext matches as possible by breaking some of the phrases into words. We do an AND of the two. 1. * pre-filter using fulltext on a query that is a superset of the original query, */ FulltextQuery broken = breakPhrases(ft); String ftsql = FulltextQueryAnalyzer.translateFulltext(broken, "|", "&", "& !", ""); /* * 2. AND with a LIKE-based search for all terms, except those that are already exactly matched by the first * part, i.e., toplevel ANDed non-phrases. */ FulltextQuery noand = removeToplevelAndedWords(ft); if (noand != null) { StringBuilder buf = new StringBuilder(); generateLikeSql(noand, buf); ftsql += FT_LIKE_SEP + buf.toString(); } return ftsql; } /** * Returns a fulltext query that is a superset of the original one and does not have phrase searches. * <p> * Negative phrases (which are at AND level) are removed, positive phrases are split into ANDed words. */ protected static FulltextQuery breakPhrases(FulltextQuery ft) { FulltextQuery newFt = new FulltextQuery(); if (ft.op == Op.AND || ft.op == Op.OR) { List<FulltextQuery> newTerms = new LinkedList<>(); for (FulltextQuery term : ft.terms) { FulltextQuery broken = breakPhrases(term); if (broken == null) { // remove negative phrase } else if (ft.op == Op.AND && broken.op == Op.AND) { // associativity (sub-AND hoisting) newTerms.addAll(broken.terms); } else { newTerms.add(broken); } } if (newTerms.size() == 1) { // single-term parenthesis elimination newFt = newTerms.get(0); } else { newFt.op = ft.op; newFt.terms = newTerms; } } else { boolean isPhrase = ft.isPhrase(); if (!isPhrase) { newFt = ft; } else if (ft.op == Op.WORD) { // positive phrase // split it List<FulltextQuery> newTerms = new LinkedList<>(); for (String subword : ft.word.split(" ")) { FulltextQuery sft = new FulltextQuery(); sft.op = Op.WORD; sft.word = subword; newTerms.add(sft); } newFt.op = Op.AND; newFt.terms = newTerms; } else { // negative phrase // removed newFt = null; } } return newFt; } /** * Removes toplevel ANDed simple words from the query. */ protected static FulltextQuery removeToplevelAndedWords(FulltextQuery ft) { if (ft.op == Op.OR || ft.op == Op.NOTWORD) { return ft; } if (ft.op == Op.WORD) { if (ft.isPhrase()) { return ft; } return null; } List<FulltextQuery> newTerms = new LinkedList<>(); for (FulltextQuery term : ft.terms) { if (term.op == Op.NOTWORD) { newTerms.add(term); } else { // Op.WORD if (term.isPhrase()) { newTerms.add(term); } } } if (newTerms.isEmpty()) { return null; } else if (newTerms.size() == 1) { // single-term parenthesis elimination return newTerms.get(0); } else { FulltextQuery newFt = new FulltextQuery(); newFt.op = Op.AND; newFt.terms = newTerms; return newFt; } } // turn non-toplevel ANDed single words into SQL // abc "foo bar" -"gee man" // -> ?? LIKE '% foo bar %' AND ?? NOT LIKE '% gee man %' // ?? is a pseudo-parameter for the col protected static void generateLikeSql(FulltextQuery ft, StringBuilder buf) { if (ft.op == Op.AND || ft.op == Op.OR) { buf.append('('); boolean first = true; for (FulltextQuery term : ft.terms) { if (!first) { if (ft.op == Op.AND) { buf.append(" AND "); } else { // Op.OR buf.append(" OR "); } } first = false; generateLikeSql(term, buf); } buf.append(')'); } else { buf.append(FT_LIKE_COL); if (ft.op == Op.NOTWORD) { buf.append(" NOT"); } buf.append(" LIKE '% "); String word = ft.word.toLowerCase(); // SQL escaping word = word.replace("'", "''"); word = word.replace("\\", ""); // don't take chances word = word.replace(PREFIX_SEARCH, "%"); buf.append(word); if (!word.endsWith("%")) { buf.append(" %"); } buf.append("'"); } } // OLD having problems in pre-9.2 (NXP-9228) // SELECT ..., // TS_RANK_CD(NX_TO_TSVECTOR(fulltext), nxquery, 32) as nxscore // FROM ... // LEFT JOIN fulltext ON fulltext.id = hierarchy.id, // TO_TSQUERY('french', ?) nxquery // WHERE ... // AND nxquery @@ NX_TO_TSVECTOR(fulltext) // AND fulltext LIKE '% foo bar %' -- when phrase search // ORDER BY nxscore DESC // NEW // SELECT ..., // TS_RANK_CD(NX_TO_TSVECTOR(fulltext), TO_TSQUERY('french', ?), 32) as // nxscore // FROM ... // LEFT JOIN fulltext ON fulltext.id = hierarchy.id // WHERE ... // AND TO_TSQUERY('french', ?) @@ NX_TO_TSVECTOR(fulltext) // AND fulltext LIKE '% foo bar %' -- when phrase search // ORDER BY nxscore DESC @Override public FulltextMatchInfo getFulltextScoredMatchInfo(String fulltextQuery, String indexName, int nthMatch, Column mainColumn, Model model, Database database) { String indexSuffix = model.getFulltextIndexSuffix(indexName); Table ft = database.getTable(Model.FULLTEXT_TABLE_NAME); Column ftMain = ft.getColumn(Model.MAIN_KEY); Column ftColumn = ft.getColumn(Model.FULLTEXT_FULLTEXT_KEY + indexSuffix); String ftColumnName = ftColumn.getFullQuotedName(); String nthSuffix = nthMatch == 1 ? "" : String.valueOf(nthMatch); FulltextMatchInfo info = new FulltextMatchInfo(); info.joins = new ArrayList<>(); if (nthMatch == 1) { // Need only one JOIN involving the fulltext table info.joins.add(new Join(Join.INNER, ft.getQuotedName(), null, null, ftMain.getFullQuotedName(), mainColumn.getFullQuotedName())); } /* * for phrase search, fulltextQuery may contain a LIKE part */ String like; if (fulltextQuery.contains(FT_LIKE_SEP)) { String[] tmp = fulltextQuery.split(FT_LIKE_SEP, 2); fulltextQuery = tmp[0]; like = tmp[1].replace(FT_LIKE_COL, ftColumnName); } else { like = null; } String tsquery = String.format("TO_TSQUERY('%s', ?)", fulltextAnalyzer); String tsvector; if (compatibilityFulltextTable) { tsvector = ftColumnName; } else { tsvector = String.format("NX_TO_TSVECTOR(%s)", ftColumnName); } String where = String.format("(%s @@ %s)", tsquery, tsvector); if (like != null) { where += " AND (" + like + ")"; } info.whereExpr = where; info.whereExprParam = fulltextQuery; info.scoreExpr = String.format("TS_RANK_CD(%s, %s, 32)", tsvector, tsquery); info.scoreExprParam = fulltextQuery; info.scoreAlias = "_nxscore" + nthSuffix; info.scoreCol = new Column(mainColumn.getTable(), null, ColumnType.DOUBLE, null); return info; } @Override public boolean getMaterializeFulltextSyntheticColumn() { return true; } @Override public int getFulltextIndexedColumns() { return 1; } @Override public String getFreeVariableSetterForType(ColumnType type) { if (type == ColumnType.FTSTORED && compatibilityFulltextTable) { return "NX_TO_TSVECTOR(?)"; } return "?"; } @Override public boolean supportsUpdateFrom() { return true; } @Override public boolean doesUpdateFromRepeatSelf() { return false; } @Override public boolean needsAliasForDerivedTable() { return true; } @Override public boolean supportsIlike() { return true; } @Override public boolean supportsReadAcl() { return aclOptimizationsEnabled; } @Override public String getPrepareUserReadAclsSql() { return "SELECT nx_prepare_user_read_acls(?)"; } @Override public String getReadAclsCheckSql(String userIdCol) { return String.format("%s = md5(array_to_string(?, '%s'))", userIdCol, getUsersSeparator()); } @Override public String getUpdateReadAclsSql() { return "SELECT nx_update_read_acls();"; } @Override public String getRebuildReadAclsSql() { return "SELECT nx_rebuild_read_acls();"; } @Override public String getSecurityCheckSql(String idColumnName) { return String.format("NX_ACCESS_ALLOWED(%s, ?, ?)", idColumnName); } @Override public boolean supportsAncestorsTable() { return true; } @Override public boolean supportsFastDescendants() { return pathOptimizationsEnabled; } @Override public String getInTreeSql(String idColumnName, String id) { String cast; try { cast = getCastForId(id); } catch (IllegalArgumentException e) { // discard query with invalid id return null; } if (pathOptimizationsEnabled) { return String.format("EXISTS(SELECT 1 FROM ancestors WHERE id = %s AND ARRAY[?]%s <@ ancestors)", idColumnName, getCastForArray(cast)); } return String.format("%s IN (SELECT * FROM nx_children(?%s))", idColumnName, cast); } protected String getCastForArray(String cast) { if (cast.isEmpty()) { return cast; } return cast + "[]"; } protected String getCastForId(String id) { String ret; switch (idType) { case VARCHAR: return ""; case UUID: // check that it's really a uuid if (id != null) { UUID.fromString(id); } ret = "::uuid"; break; case SEQUENCE: // check that it's really an integer if (id != null && !org.apache.commons.lang.StringUtils.isNumeric(id)) { throw new IllegalArgumentException("Invalid sequence id: " + id); } ret = "::bigint"; break; default: throw new AssertionError("Unknown id type: " + idType); } return ret; } @Override public String getMatchMixinType(Column mixinsColumn, String mixin, boolean positive, String[] returnParam) { returnParam[0] = mixin; String sql = "ARRAY[?]::varchar[] <@ " + mixinsColumn.getFullQuotedName(); return positive ? sql : "NOT(" + sql + ")"; } @Override public boolean supportsSysNameArray() { return true; } @Override public boolean supportsArrays() { return true; } @Override public boolean supportsArrayColumns() { return true; } public static class ArraySubQueryPostgreSQL extends ArraySubQuery { protected Dialect dialect = null; protected Table fakeSubqueryTableAlias = null; public ArraySubQueryPostgreSQL(Column arrayColumn, String alias) { super(arrayColumn, alias); dialect = arrayColumn.getTable().getDialect(); fakeSubqueryTableAlias = new TableAlias(arrayColumn.getTable(), alias); } @Override public Column getSubQueryIdColumn() { Column column = fakeSubqueryTableAlias.getColumn(Model.MAIN_KEY); return new ArraySubQueryPostgreSQLColumn(column.getPhysicalName(), column.getType()); } @Override public Column getSubQueryValueColumn() { return new ArraySubQueryPostgreSQLColumn(Model.COLL_TABLE_VALUE_KEY, arrayColumn.getBaseType()); } public class ArraySubQueryPostgreSQLColumn extends Column { private static final long serialVersionUID = 1L; ArraySubQueryPostgreSQLColumn(String columnName, ColumnType columnType) { super(fakeSubqueryTableAlias, columnName, columnType, columnName); } @Override public String getFullQuotedName() { return dialect.openQuote() + subQueryAlias + dialect.closeQuote() + '.' + getQuotedName(); } } @Override public String toSql() { Table table = arrayColumn.getTable(); return String.format("(SELECT %s, UNNEST(%s) AS %s, generate_subscripts(%s, 1) AS %s FROM %s) ", table.getColumn(Model.MAIN_KEY).getQuotedName(), arrayColumn.getQuotedName(), Model.COLL_TABLE_VALUE_KEY, arrayColumn.getQuotedName(), Model.COLL_TABLE_POS_KEY, table.getRealTable().getQuotedName()); } } @Override public ArraySubQuery getArraySubQuery(Column arrayColumn, String subQueryAlias) { return new ArraySubQueryPostgreSQL(arrayColumn, subQueryAlias); } @Override public String getArrayElementString(String arrayColumnName, int arrayElementIndex) { // PostgreSQL arrays index start at 1 return arrayColumnName + "[" + (arrayElementIndex + 1) + "]"; } @Override public String getArrayInSql(Column arrayColumn, String cast, boolean positive, List<Serializable> params) { StringBuilder sql = new StringBuilder(); if (!positive) { sql.append("(NOT("); } if (params.size() == 1) { // ? = ANY(arrayColumn) sql.append("? = ANY("); sql.append(arrayColumn.getFullQuotedName()); if (cast != null) { // DATE cast sql.append("::"); sql.append(cast); sql.append("[]"); } sql.append(")"); } else { // arrayColumn && ARRAY[?, ?, ?] sql.append(arrayColumn.getFullQuotedName()); sql.append(" && "); sql.append("ARRAY["); for (int i = 0; i < params.size(); i++) { if (i != 0) { sql.append(", "); } sql.append('?'); } sql.append("]::"); sql.append(arrayColumn.getSqlTypeString()); } if (!positive) { sql.append(") OR "); sql.append(arrayColumn.getFullQuotedName()); sql.append(" IS NULL)"); } return sql.toString(); } @Override public String getArrayLikeSql(Column arrayColumn, String refName, boolean positive, Table dataHierTable) { return getArrayOpSql(arrayColumn, refName, positive, dataHierTable, "LIKE"); } @Override public String getArrayIlikeSql(Column arrayColumn, String refName, boolean positive, Table dataHierTable) { return getArrayOpSql(arrayColumn, refName, positive, dataHierTable, "ILIKE"); } protected String getArrayOpSql(Column arrayColumn, String refName, boolean positive, Table dataHierTable, String op) { Table table = arrayColumn.getTable(); String tableAliasName = openQuote() + getTableName(refName) + closeQuote(); String sql = String.format("EXISTS (SELECT 1 FROM %s AS %s WHERE %s = %s AND %s %s ?)", getArraySubQuery(arrayColumn, tableAliasName).toSql(), tableAliasName, dataHierTable.getColumn(Model.MAIN_KEY).getFullQuotedName(), tableAliasName + '.' + table.getColumn(Model.MAIN_KEY).getQuotedName(), tableAliasName + '.' + Model.COLL_TABLE_VALUE_KEY, op); if (!positive) { sql = "NOT(" + sql + ")"; } return sql; } @Override public Array createArrayOf(int type, Object[] elements, Connection connection) throws SQLException { if (elements == null || elements.length == 0) { return null; } String typeName; switch (type) { case Types.VARCHAR: typeName = "varchar"; break; case Types.CLOB: typeName = "text"; break; case Types.BIT: typeName = "bool"; break; case Types.BIGINT: typeName = "int8"; break; case Types.DOUBLE: typeName = "float8"; break; case Types.TIMESTAMP: typeName = "timestamp"; break; case Types.SMALLINT: typeName = "int2"; break; case Types.INTEGER: typeName = "int4"; break; case Types.OTHER: // id switch (idType) { case VARCHAR: typeName = "varchar"; break; case UUID: typeName = "uuid"; break; case SEQUENCE: typeName = "int8"; break; default: throw new AssertionError("Unknown id type: " + idType); } break; default: throw new AssertionError("Unknown type: " + type); } return connection.createArrayOf(typeName, elements); } @Override public String getSQLStatementsFilename() { return "nuxeovcs/postgresql.sql.txt"; } @Override public String getTestSQLStatementsFilename() { return "nuxeovcs/postgresql.test.sql.txt"; } @Override public Map<String, Serializable> getSQLStatementsProperties(Model model, Database database) { Map<String, Serializable> properties = new HashMap<>(); switch (idType) { case VARCHAR: properties.put("idType", "varchar(36)"); properties.put("idTypeParam", "varchar"); properties.put("idNotPresent", "'-'"); properties.put("sequenceEnabled", Boolean.FALSE); break; case UUID: properties.put("idType", "uuid"); properties.put("idTypeParam", "uuid"); properties.put("idNotPresent", "'00000000-FFFF-FFFF-FFFF-FFFF00000000'"); properties.put("sequenceEnabled", Boolean.FALSE); break; case SEQUENCE: properties.put("idType", "int8"); properties.put("idTypeParam", "int8"); properties.put("idNotPresent", "-1"); properties.put("sequenceEnabled", Boolean.TRUE); properties.put("idSequenceName", idSequenceName); } properties.put("aclOptimizationsEnabled", Boolean.valueOf(aclOptimizationsEnabled)); properties.put("pathOptimizationsEnabled", Boolean.valueOf(pathOptimizationsEnabled)); properties.put("fulltextAnalyzer", fulltextAnalyzer); properties.put("fulltextEnabled", Boolean.valueOf(!fulltextDisabled)); properties.put("fulltextSearchEnabled", Boolean.valueOf(!fulltextSearchDisabled)); properties.put("clusteringEnabled", Boolean.valueOf(clusteringEnabled)); properties.put("proxiesEnabled", Boolean.valueOf(proxiesEnabled)); properties.put("softDeleteEnabled", Boolean.valueOf(softDeleteEnabled)); properties.put("arrayColumnsEnabled", Boolean.valueOf(arrayColumnsEnabled)); if (!fulltextSearchDisabled) { Table ft = database.getTable(Model.FULLTEXT_TABLE_NAME); FulltextConfiguration fti = model.getFulltextConfiguration(); List<String> lines = new ArrayList<>(fti.indexNames.size()); for (String indexName : fti.indexNames) { String suffix = model.getFulltextIndexSuffix(indexName); Column ftft = ft.getColumn(Model.FULLTEXT_FULLTEXT_KEY + suffix); Column ftst = ft.getColumn(Model.FULLTEXT_SIMPLETEXT_KEY + suffix); Column ftbt = ft.getColumn(Model.FULLTEXT_BINARYTEXT_KEY + suffix); String concat; if (compatibilityFulltextTable) { // tsvector concat = " NEW.%s := COALESCE(NEW.%s, ''::TSVECTOR) || COALESCE(NEW.%s, ''::TSVECTOR);"; } else { // text with space at beginning and end concat = " NEW.%s := ' ' || COALESCE(NEW.%s, '') || ' ' || COALESCE(NEW.%s, '') || ' ';"; } String line = String.format(concat, ftft.getQuotedName(), ftst.getQuotedName(), ftbt.getQuotedName()); lines.add(line); } properties.put("fulltextTriggerStatements", String.join("\n", lines)); } String[] permissions = NXCore.getSecurityService().getPermissionsToCheck(SecurityConstants.BROWSE); List<String> permsList = new LinkedList<>(); for (String perm : permissions) { permsList.add("('" + perm + "')"); } properties.put("readPermissions", String.join(", ", permsList)); properties.put("usersSeparator", getUsersSeparator()); properties.put("everyone", SecurityConstants.EVERYONE); properties.put("readAclMaxSize", Integer.toString(readAclMaxSize)); properties.put("unlogged", unloggedKeyword); return properties; } @Override public List<String> getStartupSqls(Model model, Database database) { if (aclOptimizationsEnabled) { log.info("Vacuuming tables used by optimized acls"); return Collections.singletonList("SELECT nx_vacuum_read_acls()"); } return Collections.emptyList(); } @Override public boolean isClusteringSupported() { return true; } @Override public String getClusterInsertInvalidations() { return "SELECT NX_CLUSTER_INVAL(?, ?, ?, ?)"; } @Override public String getClusterGetInvalidations() { return "DELETE FROM cluster_invals WHERE nodeid = ? RETURNING id, fragments, kind"; } @Override public boolean isConcurrentUpdateException(Throwable t) { while (t.getCause() != null) { t = t.getCause(); } if (t instanceof SQLException) { String sqlState = ((SQLException) t).getSQLState(); if ("23503".equals(sqlState)) { // insert or update on table ... violates foreign key constraint return true; } if ("23505".equals(sqlState)) { // duplicate key value violates unique constraint return true; } if ("40P01".equals(sqlState)) { // deadlock detected return true; } } return false; } @Override public boolean supportsPaging() { return true; } @Override public String addPagingClause(String sql, long limit, long offset) { return sql + String.format(" LIMIT %d OFFSET %d", limit, offset); } @Override public boolean supportsWith() { return false; // don't activate until proven useful // return supportsWith; } @Override public void performAdditionalStatements(Connection connection) throws SQLException { // Check if there is a pre-existing aclr_permission table boolean createAclrPermission; try (Statement s = connection.createStatement()) { String sql = "SELECT 1 FROM pg_tables WHERE tablename = 'aclr_permission'"; try (ResultSet rs = s.executeQuery(sql)) { createAclrPermission = !rs.next(); } } // If no table, it will be created and filled at DDL execution time if (createAclrPermission) { return; } // Warn user if BROWSE permissions has changed Set<String> dbPermissions = new HashSet<>(); try (Statement s = connection.createStatement()) { String sql = "SELECT * FROM aclr_permission"; try (ResultSet rs = s.executeQuery(sql)) { while (rs.next()) { dbPermissions.add(rs.getString(1)); } } } SecurityService securityService = NXCore.getSecurityService(); Set<String> confPermissions = new HashSet<>( Arrays.asList(securityService.getPermissionsToCheck(SecurityConstants.BROWSE))); if (!dbPermissions.equals(confPermissions)) { log.error("Security permission for BROWSE has changed, you need to rebuild the optimized read acls:" + "DROP TABLE aclr_permission; DROP TABLE aclr; then restart."); } } public String getUsersSeparator() { if (usersSeparator == null) { return DEFAULT_USERS_SEPARATOR; } return usersSeparator; } @Override public String getValidationQuery() { return ""; } @Override public String getAncestorsIdsSql() { return "SELECT NX_ANCESTORS(?)"; } @Override public boolean needsNullsLastOnDescSort() { return true; } @Override public String getDateCast() { // this is more amenable to being indexed than a CAST return "DATE(%s)"; } @Override public String castIdToVarchar(String expr) { switch (idType) { case VARCHAR: return expr; case UUID: return expr + "::varchar"; case SEQUENCE: return expr + "::varchar"; default: throw new AssertionError("Unknown id type: " + idType); } } @Override public DialectIdType getIdType() { return idType; } @Override public String getSoftDeleteSql() { return "SELECT NX_DELETE(?, ?)"; } @Override public String getSoftDeleteCleanupSql() { return "SELECT NX_DELETE_PURGE(?, ?)"; } @Override public String getBinaryFulltextSql(List<String> columns) { if (compatibilityFulltextTable) { // extract tokens from tsvector String columnsAs = columns.stream() .map(col -> "regexp_replace(" + col + "::text, $$'|'\\:[^']*'?$$, ' ', 'g')") .collect(Collectors.joining(", ")); return "SELECT " + columnsAs + " FROM fulltext WHERE id=?"; } return super.getBinaryFulltextSql(columns); } // parenthesizes parameter part, with optional nested parentheses private static final Pattern SIG_MATCH = Pattern.compile("[^(]*\\((([^()]*|\\([^()]*\\))*)\\).*", Pattern.DOTALL); @Override public List<String> checkStoredProcedure(String procName, String procCreate, String ddlMode, Connection connection, JDBCLogger logger, Map<String, Serializable> properties) throws SQLException { boolean compatCheck = ddlMode.contains(RepositoryDescriptor.DDL_MODE_COMPAT); if (compatCheck) { procCreate = "CREATE OR REPLACE " + procCreate.substring("create ".length()); return Collections.singletonList(procCreate); } // extract signature from create statement Matcher m = SIG_MATCH.matcher(procCreate); if (!m.matches()) { throw new NuxeoException("Cannot parse arguments: " + procCreate); } String procArgs = normalizeArgs(m.group(1)); try (Statement st = connection.createStatement()) { // check if the stored procedure exists and its content String getBody = "SELECT prosrc, pg_get_function_identity_arguments(oid) FROM pg_proc WHERE proname = '" + procName + "'"; logger.log(getBody); try (ResultSet rs = st.executeQuery(getBody)) { while (rs.next()) { String body = rs.getString(1); String args = rs.getString(2); if (!args.equals(procArgs)) { // different signature continue; } // stored proc already exists if (normalizeString(procCreate).contains(normalizeString(body))) { logger.log(" -> exists, unchanged"); return Collections.emptyList(); } else { logger.log(" -> exists, old"); // we can't drop then recreate as for instance a function used by a trigger // would say "cannot drop function ... because other objects depend on it" // so we hack and do an do a replace if (!procCreate.toLowerCase().startsWith("create ")) { throw new NuxeoException("Should start with CREATE: " + procCreate); } procCreate = "CREATE OR REPLACE " + procCreate.substring("create ".length()); return Collections.singletonList(procCreate); } } } logger.log(" -> missing"); return Collections.singletonList(procCreate); } } protected static String normalizeString(String string) { return string.replaceAll("[ \n\r\t]+", " ").trim(); } /** The type aliases that we use for our stored procedure argument definitions. */ private static final Map<String, String> TYPE_ALIASES = new HashMap<>(); static { TYPE_ALIASES.put("bool", "boolean"); TYPE_ALIASES.put("varchar", "character varying"); TYPE_ALIASES.put("int", "integer"); TYPE_ALIASES.put("int4", "integer"); TYPE_ALIASES.put("int8", "bigint"); TYPE_ALIASES.put("timestamp", "timestamp without time zone"); } /** Normalize PostgreSQL type aliases. */ protected static String normalizeArgs(String args) { if (args.isEmpty()) { return args; } args = args.toLowerCase(); List<String> argList = Arrays.asList(args.split(",[ ]*")); List<String> newArgList = new ArrayList<>(argList.size()); for (String arg : argList) { // array or size spec int i = arg.indexOf('('); if (i == -1) { i = arg.indexOf('['); } String suffix = ""; if (i > 0) { suffix = arg.substring(i); arg = arg.substring(0, i); } for (Entry<String, String> es : TYPE_ALIASES.entrySet()) { String type = es.getKey(); if (arg.equals(type) || arg.endsWith(" " + type)) { arg = arg.substring(0, arg.length() - type.length()) + es.getValue(); break; } } newArgList.add(arg + suffix); } return String.join(", ", newArgList); } }