001/*
002 * (C) Copyright 2006-2018 Nuxeo (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Florent Guillaume
018 *     Benoit Delbosc
019 */
020package org.nuxeo.ecm.core.storage.sql.jdbc.dialect;
021
022import java.io.Serializable;
023import java.sql.Array;
024import java.sql.Connection;
025import java.sql.DatabaseMetaData;
026import java.sql.PreparedStatement;
027import java.sql.ResultSet;
028import java.sql.SQLException;
029import java.sql.Statement;
030import java.sql.Timestamp;
031import java.sql.Types;
032import java.util.ArrayList;
033import java.util.Arrays;
034import java.util.Collections;
035import java.util.HashMap;
036import java.util.HashSet;
037import java.util.LinkedList;
038import java.util.List;
039import java.util.Map;
040import java.util.Map.Entry;
041import java.util.Set;
042import java.util.UUID;
043import java.util.regex.Matcher;
044import java.util.regex.Pattern;
045import java.util.stream.Collectors;
046
047import org.apache.commons.lang3.StringUtils;
048import org.apache.commons.logging.Log;
049import org.apache.commons.logging.LogFactory;
050import org.nuxeo.ecm.core.NXCore;
051import org.nuxeo.ecm.core.api.NuxeoException;
052import org.nuxeo.ecm.core.api.security.SecurityConstants;
053import org.nuxeo.ecm.core.query.QueryParseException;
054import org.nuxeo.ecm.core.security.SecurityService;
055import org.nuxeo.ecm.core.storage.FulltextConfiguration;
056import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer;
057import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer.FulltextQuery;
058import org.nuxeo.ecm.core.storage.FulltextQueryAnalyzer.Op;
059import org.nuxeo.ecm.core.storage.sql.ColumnType;
060import org.nuxeo.ecm.core.storage.sql.Model;
061import org.nuxeo.ecm.core.storage.sql.RepositoryDescriptor;
062import org.nuxeo.ecm.core.storage.sql.jdbc.JDBCLogger;
063import org.nuxeo.ecm.core.storage.sql.jdbc.db.Column;
064import org.nuxeo.ecm.core.storage.sql.jdbc.db.Database;
065import org.nuxeo.ecm.core.storage.sql.jdbc.db.Join;
066import org.nuxeo.ecm.core.storage.sql.jdbc.db.Table;
067import org.nuxeo.ecm.core.storage.sql.jdbc.db.TableAlias;
068
069/**
070 * PostgreSQL-specific dialect.
071 *
072 * @author Florent Guillaume
073 */
074public class DialectPostgreSQL extends Dialect {
075
076    private static final Log log = LogFactory.getLog(DialectPostgreSQL.class);
077
078    private static final String DEFAULT_FULLTEXT_ANALYZER = "english";
079
080    private static final String DEFAULT_USERS_SEPARATOR = ",";
081
082    private static final String PREFIX_SEARCH = ":*";
083
084    // prefix search syntax foo* or foo% or foo:*-> foo:*
085    private static final Pattern PREFIX_PATTERN = Pattern.compile("(\\*|%|:\\*)( |\"|$)");
086
087    private static final String PREFIX_REPL = PREFIX_SEARCH + "$2";
088
089    private static final String[] RESERVED_COLUMN_NAMES = { "xmin", "xmax", "cmin", "cmax", "ctid", "oid", "tableoid" };
090
091    private static final String UNLOGGED_KEYWORD = "UNLOGGED";
092
093    protected final String fulltextAnalyzer;
094
095    protected final boolean supportsWith;
096
097    protected boolean hierarchyCreated;
098
099    protected boolean pathOptimizationsEnabled;
100
101    protected final boolean arrayColumnsEnabled;
102
103    protected final boolean childNameUniqueConstraintEnabled;
104
105    protected final boolean collectionUniqueConstraintEnabled;
106
107    protected String usersSeparator;
108
109    protected final DialectIdType idType;
110
111    protected boolean compatibilityFulltextTable;
112
113    protected final String unloggedKeyword;
114
115    protected String idSequenceName;
116
117    public DialectPostgreSQL(DatabaseMetaData metadata, RepositoryDescriptor repositoryDescriptor) {
118        super(metadata, repositoryDescriptor);
119        fulltextAnalyzer = repositoryDescriptor == null ? null
120                : repositoryDescriptor.getFulltextAnalyzer() == null ? DEFAULT_FULLTEXT_ANALYZER
121                        : repositoryDescriptor.getFulltextAnalyzer();
122        pathOptimizationsEnabled = repositoryDescriptor != null && repositoryDescriptor.getPathOptimizationsEnabled();
123        if (repositoryDescriptor != null) {
124            log.info("Path optimizations " + (pathOptimizationsEnabled ? "enabled" : "disabled"));
125        }
126        arrayColumnsEnabled = repositoryDescriptor != null && repositoryDescriptor.getArrayColumns();
127        childNameUniqueConstraintEnabled = repositoryDescriptor != null
128                && repositoryDescriptor.getChildNameUniqueConstraintEnabled();
129        collectionUniqueConstraintEnabled = repositoryDescriptor != null
130                && repositoryDescriptor.getCollectionUniqueConstraintEnabled();
131        int major, minor;
132        try {
133            major = metadata.getDatabaseMajorVersion();
134            minor = metadata.getDatabaseMinorVersion();
135        } catch (SQLException e) {
136            throw new NuxeoException(e);
137        }
138        supportsWith = major > 8 || (major == 8 && minor >= 4);
139        if ((major == 9 && minor >= 1) || (major > 9)) {
140            unloggedKeyword = UNLOGGED_KEYWORD;
141        } else {
142            unloggedKeyword = "";
143        }
144        usersSeparator = repositoryDescriptor == null ? null
145                : repositoryDescriptor.usersSeparatorKey == null ? DEFAULT_USERS_SEPARATOR
146                        : repositoryDescriptor.usersSeparatorKey;
147        String idt = repositoryDescriptor == null ? null : repositoryDescriptor.idType;
148        if (idt == null || "".equals(idt) || "varchar".equalsIgnoreCase(idt)) {
149            idType = DialectIdType.VARCHAR;
150        } else if ("uuid".equalsIgnoreCase(idt)) {
151            idType = DialectIdType.UUID;
152        } else if (idt.toLowerCase().startsWith("sequence")) {
153            idType = DialectIdType.SEQUENCE;
154            if (idt.toLowerCase().startsWith("sequence:")) {
155                String[] split = idt.split(":");
156                idSequenceName = split[1];
157            } else {
158                idSequenceName = "hierarchy_seq";
159            }
160        } else {
161            throw new NuxeoException("Unknown id type: '" + idt + "'");
162        }
163        try {
164            compatibilityFulltextTable = getCompatibilityFulltextTable(metadata);
165        } catch (SQLException e) {
166            throw new NuxeoException(e);
167        }
168    }
169
170    protected boolean getCompatibilityFulltextTable(DatabaseMetaData metadata) throws SQLException {
171        ResultSet rs = metadata.getColumns(null, null, Model.FULLTEXT_TABLE_NAME, "%");
172        while (rs.next()) {
173            // COLUMN_NAME=fulltext DATA_TYPE=1111 TYPE_NAME=tsvector
174            String columnName = rs.getString("COLUMN_NAME");
175            if (Model.FULLTEXT_FULLTEXT_KEY.equals(columnName)) {
176                String typeName = rs.getString("TYPE_NAME");
177                return "tsvector".equals(typeName);
178            }
179        }
180        return false;
181    }
182
183    @Override
184    public String toBooleanValueString(boolean bool) {
185        return bool ? "true" : "false";
186    }
187
188    @Override
189    public String getNoColumnsInsertString(Column idColumn) {
190        return "DEFAULT VALUES";
191    }
192
193    @Override
194    public String getCascadeDropConstraintsString() {
195        return "CASCADE";
196    }
197
198    @Override
199    public JDBCInfo getJDBCTypeAndString(ColumnType type) {
200        switch (type.spec) {
201        case STRING:
202            if (type.isUnconstrained()) {
203                return jdbcInfo("varchar", Types.VARCHAR);
204            } else if (type.isClob()) {
205                return jdbcInfo("text", Types.CLOB);
206            } else {
207                return jdbcInfo("varchar(%d)", type.length, Types.VARCHAR);
208            }
209        case ARRAY_STRING:
210            if (type.isUnconstrained()) {
211                return jdbcInfo("varchar[]", Types.ARRAY, "varchar", Types.VARCHAR);
212            } else if (type.isClob()) {
213                return jdbcInfo("text[]", Types.ARRAY, "text", Types.CLOB);
214            } else {
215                return jdbcInfo("varchar(%d)[]", type.length, Types.ARRAY, "varchar", Types.VARCHAR);
216            }
217        case BOOLEAN:
218            return jdbcInfo("bool", Types.BIT);
219        case ARRAY_BOOLEAN:
220            return jdbcInfo("bool[]", Types.ARRAY, "bool", Types.BOOLEAN);
221        case LONG:
222            return jdbcInfo("int8", Types.BIGINT);
223        case ARRAY_LONG:
224            return jdbcInfo("int8[]", Types.ARRAY, "int8", Types.BIGINT);
225        case DOUBLE:
226            return jdbcInfo("float8", Types.DOUBLE);
227        case ARRAY_DOUBLE:
228            return jdbcInfo("float8[]", Types.ARRAY, "float8", Types.DOUBLE);
229        case TIMESTAMP:
230            return jdbcInfo("timestamp", Types.TIMESTAMP);
231        case ARRAY_TIMESTAMP:
232            return jdbcInfo("timestamp[]", Types.ARRAY, "timestamp", Types.TIMESTAMP);
233        case BLOBID:
234            return jdbcInfo("varchar(250)", Types.VARCHAR);
235        case ARRAY_BLOBID:
236            return jdbcInfo("varchar(250)[]", Types.ARRAY, "varchar", Types.VARCHAR);
237        // -----
238        case NODEID:
239        case NODEIDFK:
240        case NODEIDFKNP:
241        case NODEIDFKMUL:
242        case NODEIDFKNULL:
243        case NODEIDPK:
244        case NODEVAL:
245            switch (idType) {
246            case VARCHAR:
247                return jdbcInfo("varchar(36)", Types.VARCHAR);
248            case UUID:
249                return jdbcInfo("uuid", Types.OTHER);
250            case SEQUENCE:
251                return jdbcInfo("int8", Types.BIGINT);
252            }
253            throw new AssertionError("Unknown id type: " + idType);
254        case NODEARRAY:
255            switch (idType) {
256            case VARCHAR:
257                return jdbcInfo("varchar(36)[]", Types.ARRAY, "varchar", Types.VARCHAR);
258            case UUID:
259                return jdbcInfo("uuid[]", Types.ARRAY, "uuid", Types.OTHER);
260            case SEQUENCE:
261                return jdbcInfo("int8[]", Types.ARRAY, "int8", Types.BIGINT);
262            }
263            throw new AssertionError("Unknown id type: " + idType);
264        case SYSNAME:
265            return jdbcInfo("varchar(250)", Types.VARCHAR);
266        case SYSNAMEARRAY:
267            return jdbcInfo("varchar(250)[]", Types.ARRAY, "varchar", Types.VARCHAR);
268        case TINYINT:
269            return jdbcInfo("int2", Types.SMALLINT);
270        case INTEGER:
271            return jdbcInfo("int4", Types.INTEGER);
272        case ARRAY_INTEGER:
273            return jdbcInfo("int4[]", Types.ARRAY, "int4", Types.INTEGER);
274        case AUTOINC:
275            return jdbcInfo("serial", Types.INTEGER);
276        case FTINDEXED:
277            if (compatibilityFulltextTable) {
278                return jdbcInfo("tsvector", Types.OTHER);
279            } else {
280                return jdbcInfo("text", Types.CLOB);
281            }
282        case FTSTORED:
283            if (compatibilityFulltextTable) {
284                return jdbcInfo("tsvector", Types.OTHER);
285            } else {
286                return jdbcInfo("text", Types.CLOB);
287            }
288        case CLUSTERNODE:
289            return jdbcInfo("int4", Types.INTEGER);
290        case CLUSTERFRAGS:
291            return jdbcInfo("varchar[]", Types.ARRAY, "varchar", Types.VARCHAR);
292        }
293        throw new AssertionError(type);
294    }
295
296    @Override
297    public boolean isAllowedConversion(int expected, int actual, String actualName, int actualSize) {
298        // CLOB vs VARCHAR compatibility
299        if (expected == Types.VARCHAR && actual == Types.CLOB) {
300            return true;
301        }
302        if (expected == Types.CLOB && actual == Types.VARCHAR) {
303            return true;
304        }
305        // INTEGER vs BIGINT compatibility
306        if (expected == Types.BIGINT && actual == Types.INTEGER) {
307            return true;
308        }
309        if (expected == Types.INTEGER && actual == Types.BIGINT) {
310            return true;
311        }
312        // TSVECTOR vs CLOB compatibility during upgrade tests
313        // where column detection is done before upgrade test setup
314        if (expected == Types.CLOB && (actual == Types.OTHER && actualName.equals("tsvector"))) {
315            return true;
316        }
317        return false;
318    }
319
320    @Override
321    public Serializable getGeneratedId(Connection connection) throws SQLException {
322        if (idType != DialectIdType.SEQUENCE) {
323            return super.getGeneratedId(connection);
324        }
325        String sql = String.format("SELECT NEXTVAL('%s')", idSequenceName);
326        try (Statement s = connection.createStatement()) {
327            try (ResultSet rs = s.executeQuery(sql)) {
328                rs.next();
329                return Long.valueOf(rs.getLong(1));
330            }
331        }
332    }
333
334    @Override
335    public void setId(PreparedStatement ps, int index, Serializable value) throws SQLException {
336        switch (idType) {
337        case VARCHAR:
338            ps.setObject(index, value);
339            break;
340        case UUID:
341            ps.setObject(index, value, Types.OTHER);
342            break;
343        case SEQUENCE:
344            setIdLong(ps, index, value);
345            break;
346        default:
347            throw new AssertionError();
348        }
349    }
350
351    @SuppressWarnings("boxing")
352    public Serializable getId(ResultSet rs, int index) throws SQLException {
353        switch (idType) {
354        case VARCHAR:
355        case UUID:
356            return rs.getString(index);
357        case SEQUENCE:
358            return rs.getLong(index);
359        default:
360            throw new AssertionError();
361        }
362    }
363
364    @Override
365    public void setToPreparedStatement(PreparedStatement ps, int index, Serializable value, Column column)
366            throws SQLException {
367        switch (column.getJdbcType()) {
368        case Types.VARCHAR:
369        case Types.CLOB:
370            setToPreparedStatementString(ps, index, value, column);
371            return;
372        case Types.BIT:
373            ps.setBoolean(index, ((Boolean) value).booleanValue());
374            return;
375        case Types.SMALLINT:
376            ps.setInt(index, ((Long) value).intValue());
377            return;
378        case Types.INTEGER:
379        case Types.BIGINT:
380            ps.setLong(index, ((Number) value).longValue());
381            return;
382        case Types.DOUBLE:
383            ps.setDouble(index, ((Double) value).doubleValue());
384            return;
385        case Types.TIMESTAMP:
386            setToPreparedStatementTimestamp(ps, index, value, column);
387            return;
388        case Types.ARRAY:
389            int jdbcBaseType = column.getJdbcBaseType();
390            String jdbcBaseTypeName = column.getSqlBaseTypeString();
391            if (jdbcBaseType == Types.TIMESTAMP) {
392                value = getTimestampFromCalendar((Serializable[]) value);
393            }
394            Array array = ps.getConnection().createArrayOf(jdbcBaseTypeName, (Object[]) value);
395            ps.setArray(index, array);
396            return;
397        case Types.OTHER:
398            ColumnType type = column.getType();
399            if (type.isId()) {
400                setId(ps, index, value);
401                return;
402            } else if (type == ColumnType.FTSTORED) {
403                ps.setString(index, (String) value);
404                return;
405            }
406            throw new SQLException("Unhandled type: " + column.getType());
407        default:
408            throw new SQLException("Unhandled JDBC type: " + column.getJdbcType());
409        }
410    }
411
412    @Override
413    @SuppressWarnings("boxing")
414    public Serializable getFromResultSet(ResultSet rs, int index, Column column) throws SQLException {
415        int jdbcType = rs.getMetaData().getColumnType(index);
416        if (column.getJdbcType() == Types.ARRAY && jdbcType != Types.ARRAY) {
417            jdbcType = column.getJdbcBaseType();
418        } else {
419            jdbcType = column.getJdbcType();
420        }
421        switch (jdbcType) {
422        case Types.VARCHAR:
423        case Types.CLOB:
424            return getFromResultSetString(rs, index, column);
425        case Types.BIT:
426            return rs.getBoolean(index);
427        case Types.SMALLINT:
428        case Types.INTEGER:
429        case Types.BIGINT:
430            return rs.getLong(index);
431        case Types.DOUBLE:
432            return rs.getDouble(index);
433        case Types.TIMESTAMP:
434            return getFromResultSetTimestamp(rs, index, column);
435        case Types.ARRAY:
436            Array array = rs.getArray(index);
437            if (array == null) {
438                return null;
439            }
440            if (array.getBaseType() == Types.TIMESTAMP) {
441                return getCalendarFromTimestamp((Timestamp[]) array.getArray());
442            } else {
443                return (Serializable) array.getArray();
444            }
445        case Types.OTHER:
446            ColumnType type = column.getType();
447            if (type.isId()) {
448                return getId(rs, index);
449            }
450            throw new SQLException("Unhandled type: " + column.getType());
451        }
452        throw new SQLException(
453                "Unhandled JDBC type: " + column.getJdbcType() + " for type " + column.getType().toString());
454    }
455
456    @Override
457    protected int getMaxNameSize() {
458        return 63;
459    }
460
461    @Override
462    public String getColumnName(String name) {
463        // ignore suffixed "_" when checking for reservedness
464        String n = name.replaceAll("_+$", "");
465        for (String reserved : RESERVED_COLUMN_NAMES) {
466            if (n.equals(reserved)) {
467                // reserved, add one more suffix "_"
468                name += "_";
469                break;
470            }
471        }
472        return super.getColumnName(name);
473    }
474
475    @Override
476    public List<String> getCustomPostCreateSqls(Table table, Model model) {
477        List<String> sqls = new ArrayList<>();
478        String key = table.getKey();
479        if (childNameUniqueConstraintEnabled && key.equals(Model.HIER_TABLE_NAME)) {
480            // CREATE UNIQUE INDEX hierarchy_unique_child ON hierarchy (parentid, name)
481            // WHERE isproperty = false
482            sqls.add(String.format(
483                    "CREATE UNIQUE INDEX \"hierarchy_unique_child\" ON \"%s\" (\"%s\", \"%s\") WHERE \"%s\" = false",
484                    Model.HIER_TABLE_NAME, Model.HIER_PARENT_KEY, Model.HIER_CHILD_NAME_KEY,
485                    Model.HIER_CHILD_ISPROPERTY_KEY));
486            // CREATE UNIQUE INDEX hierarchy_unique_child_complex ON hierarchy (parentid, name)
487            // WHERE isproperty = true AND pos IS NULL
488            sqls.add(String.format(
489                    "CREATE UNIQUE INDEX \"hierarchy_unique_child_complex\" ON \"%s\" (\"%s\", \"%s\") WHERE \"%s\" = true AND \"%s\" IS NULL",
490                    Model.HIER_TABLE_NAME, Model.HIER_PARENT_KEY, Model.HIER_CHILD_NAME_KEY,
491                    Model.HIER_CHILD_ISPROPERTY_KEY, Model.HIER_CHILD_POS_KEY));
492            // CREATE UNIQUE INDEX hierarchy_unique_child_complex_list ON hierarchy (parentid, name, pos)
493            // WHERE isproperty = true AND pos IS NOT NULL
494            sqls.add(String.format(
495                    "CREATE UNIQUE INDEX \"hierarchy_unique_child_complex_list\" ON \"%s\" (\"%s\", \"%s\", \"%s\") WHERE \"%s\" = true AND \"%s\" IS NOT NULL",
496                    Model.HIER_TABLE_NAME, Model.HIER_PARENT_KEY, Model.HIER_CHILD_NAME_KEY, Model.HIER_CHILD_POS_KEY,
497                    Model.HIER_CHILD_ISPROPERTY_KEY, Model.HIER_CHILD_POS_KEY));
498        } else if (collectionUniqueConstraintEnabled && model.isCollectionFragment(key)) {
499            // CREATE UNIQUE INDEX dc_contributors_unique_pos ON dc_contributors (id, pos)
500            String name = table.getPhysicalName();
501            String indexName = makeName(name, "", "_unique_pos", getMaxIndexNameSize());
502            sqls.add(String.format("CREATE UNIQUE INDEX \"%s\" ON \"%s\" (\"%s\", \"%s\")", indexName, name,
503                    Model.MAIN_KEY, Model.COLL_TABLE_POS_KEY));
504        }
505        return sqls;
506    }
507
508    @Override
509    public String getCreateFulltextIndexSql(String indexName, String quotedIndexName, Table table, List<Column> columns,
510            Model model) {
511        String sql;
512        if (compatibilityFulltextTable) {
513            sql = "CREATE INDEX %s ON %s USING GIN(%s)";
514        } else {
515            sql = "CREATE INDEX %s ON %s USING GIN(NX_TO_TSVECTOR(%s))";
516        }
517        return String.format(sql, quotedIndexName.toLowerCase(), table.getQuotedName(), columns.get(0).getQuotedName());
518    }
519
520    // must not be interpreted as a regexp, we split on it
521    protected static final String FT_LIKE_SEP = " @#AND#@ ";
522
523    protected static final String FT_LIKE_COL = "??";
524
525    /**
526     * {@inheritDoc}
527     * <p>
528     * The result of this is passed to {@link #getFulltextScoredMatchInfo}.
529     */
530    @Override
531    public String getDialectFulltextQuery(String query) {
532        query = query.replace(" & ", " "); // PostgreSQL compatibility BBB
533        query = PREFIX_PATTERN.matcher(query).replaceAll(PREFIX_REPL);
534        FulltextQuery ft = FulltextQueryAnalyzer.analyzeFulltextQuery(query);
535        if (ft == null) {
536            return ""; // won't match anything
537        }
538        if (!FulltextQueryAnalyzer.hasPhrase(ft)) {
539            return FulltextQueryAnalyzer.translateFulltext(ft, "|", "&", "& !", "");
540        }
541        if (compatibilityFulltextTable) {
542            throw new QueryParseException("Cannot use phrase search in fulltext compatibilty mode. "
543                    + "Please upgrade the fulltext table: " + query);
544        }
545        /*
546         * Phrase search. We have to do the phrase query using a LIKE, but for performance we pre-filter using as many
547         * fulltext matches as possible by breaking some of the phrases into words. We do an AND of the two. 1.
548         * pre-filter using fulltext on a query that is a superset of the original query,
549         */
550        FulltextQuery broken = breakPhrases(ft);
551        String ftsql = FulltextQueryAnalyzer.translateFulltext(broken, "|", "&", "& !", "");
552        /*
553         * 2. AND with a LIKE-based search for all terms, except those that are already exactly matched by the first
554         * part, i.e., toplevel ANDed non-phrases.
555         */
556        FulltextQuery noand = removeToplevelAndedWords(ft);
557        if (noand != null) {
558            StringBuilder buf = new StringBuilder();
559            generateLikeSql(noand, buf);
560            ftsql += FT_LIKE_SEP + buf.toString();
561
562        }
563        return ftsql;
564    }
565
566    /**
567     * Returns a fulltext query that is a superset of the original one and does not have phrase searches.
568     * <p>
569     * Negative phrases (which are at AND level) are removed, positive phrases are split into ANDed words.
570     */
571    protected static FulltextQuery breakPhrases(FulltextQuery ft) {
572        FulltextQuery newFt = new FulltextQuery();
573        if (ft.op == Op.AND || ft.op == Op.OR) {
574            List<FulltextQuery> newTerms = new LinkedList<>();
575            for (FulltextQuery term : ft.terms) {
576                FulltextQuery broken = breakPhrases(term);
577                if (broken == null) {
578                    // remove negative phrase
579                } else if (ft.op == Op.AND && broken.op == Op.AND) {
580                    // associativity (sub-AND hoisting)
581                    newTerms.addAll(broken.terms);
582                } else {
583                    newTerms.add(broken);
584                }
585            }
586            if (newTerms.size() == 1) {
587                // single-term parenthesis elimination
588                newFt = newTerms.get(0);
589            } else {
590                newFt.op = ft.op;
591                newFt.terms = newTerms;
592            }
593        } else {
594            boolean isPhrase = ft.isPhrase();
595            if (!isPhrase) {
596                newFt = ft;
597            } else if (ft.op == Op.WORD) {
598                // positive phrase
599                // split it
600                List<FulltextQuery> newTerms = new LinkedList<>();
601                for (String subword : ft.word.split(" ")) {
602                    FulltextQuery sft = new FulltextQuery();
603                    sft.op = Op.WORD;
604                    sft.word = subword;
605                    newTerms.add(sft);
606                }
607                newFt.op = Op.AND;
608                newFt.terms = newTerms;
609            } else {
610                // negative phrase
611                // removed
612                newFt = null;
613            }
614        }
615        return newFt;
616    }
617
618    /**
619     * Removes toplevel ANDed simple words from the query.
620     */
621    protected static FulltextQuery removeToplevelAndedWords(FulltextQuery ft) {
622        if (ft.op == Op.OR || ft.op == Op.NOTWORD) {
623            return ft;
624        }
625        if (ft.op == Op.WORD) {
626            if (ft.isPhrase()) {
627                return ft;
628            }
629            return null;
630        }
631        List<FulltextQuery> newTerms = new LinkedList<>();
632        for (FulltextQuery term : ft.terms) {
633            if (term.op == Op.NOTWORD) {
634                newTerms.add(term);
635            } else { // Op.WORD
636                if (term.isPhrase()) {
637                    newTerms.add(term);
638                }
639            }
640        }
641        if (newTerms.isEmpty()) {
642            return null;
643        } else if (newTerms.size() == 1) {
644            // single-term parenthesis elimination
645            return newTerms.get(0);
646        } else {
647            FulltextQuery newFt = new FulltextQuery();
648            newFt.op = Op.AND;
649            newFt.terms = newTerms;
650            return newFt;
651        }
652    }
653
654    // turn non-toplevel ANDed single words into SQL
655    // abc "foo bar" -"gee man"
656    // -> ?? LIKE '% foo bar %' AND ?? NOT LIKE '% gee man %'
657    // ?? is a pseudo-parameter for the col
658    protected static void generateLikeSql(FulltextQuery ft, StringBuilder buf) {
659        if (ft.op == Op.AND || ft.op == Op.OR) {
660            buf.append('(');
661            boolean first = true;
662            for (FulltextQuery term : ft.terms) {
663                if (!first) {
664                    if (ft.op == Op.AND) {
665                        buf.append(" AND ");
666                    } else { // Op.OR
667                        buf.append(" OR ");
668                    }
669                }
670                first = false;
671                generateLikeSql(term, buf);
672            }
673            buf.append(')');
674        } else {
675            buf.append(FT_LIKE_COL);
676            if (ft.op == Op.NOTWORD) {
677                buf.append(" NOT");
678            }
679            buf.append(" LIKE '% ");
680            String word = ft.word.toLowerCase();
681            // SQL escaping
682            word = word.replace("'", "''");
683            word = word.replace("\\", ""); // don't take chances
684            word = word.replace(PREFIX_SEARCH, "%");
685            buf.append(word);
686            if (!word.endsWith("%")) {
687                buf.append(" %");
688            }
689            buf.append("'");
690        }
691    }
692
693    // OLD having problems in pre-9.2 (NXP-9228)
694    // SELECT ...,
695    // TS_RANK_CD(NX_TO_TSVECTOR(fulltext), nxquery, 32) as nxscore
696    // FROM ...
697    // LEFT JOIN fulltext ON fulltext.id = hierarchy.id,
698    // TO_TSQUERY('french', ?) nxquery
699    // WHERE ...
700    // AND nxquery @@ NX_TO_TSVECTOR(fulltext)
701    // AND fulltext LIKE '% foo bar %' -- when phrase search
702    // ORDER BY nxscore DESC
703
704    // NEW
705    // SELECT ...,
706    // TS_RANK_CD(NX_TO_TSVECTOR(fulltext), TO_TSQUERY('french', ?), 32) as
707    // nxscore
708    // FROM ...
709    // LEFT JOIN fulltext ON fulltext.id = hierarchy.id
710    // WHERE ...
711    // AND TO_TSQUERY('french', ?) @@ NX_TO_TSVECTOR(fulltext)
712    // AND fulltext LIKE '% foo bar %' -- when phrase search
713    // ORDER BY nxscore DESC
714    @Override
715    public FulltextMatchInfo getFulltextScoredMatchInfo(String fulltextQuery, String indexName, int nthMatch,
716            Column mainColumn, Model model, Database database) {
717        String indexSuffix = model.getFulltextIndexSuffix(indexName);
718        Table ft = database.getTable(Model.FULLTEXT_TABLE_NAME);
719        Column ftMain = ft.getColumn(Model.MAIN_KEY);
720        Column ftColumn = ft.getColumn(Model.FULLTEXT_FULLTEXT_KEY + indexSuffix);
721        String ftColumnName = ftColumn.getFullQuotedName();
722        String nthSuffix = nthMatch == 1 ? "" : String.valueOf(nthMatch);
723        FulltextMatchInfo info = new FulltextMatchInfo();
724        info.joins = new ArrayList<>();
725        if (nthMatch == 1) {
726            // Need only one JOIN involving the fulltext table
727            info.joins.add(new Join(Join.INNER, ft.getQuotedName(), null, null, ftMain.getFullQuotedName(),
728                    mainColumn.getFullQuotedName()));
729        }
730        /*
731         * for phrase search, fulltextQuery may contain a LIKE part
732         */
733        String like;
734        if (fulltextQuery.contains(FT_LIKE_SEP)) {
735            String[] tmp = fulltextQuery.split(FT_LIKE_SEP, 2);
736            fulltextQuery = tmp[0];
737            like = tmp[1].replace(FT_LIKE_COL, ftColumnName);
738        } else {
739            like = null;
740        }
741        String tsquery = String.format("TO_TSQUERY('%s', ?)", fulltextAnalyzer);
742        String tsvector;
743        if (compatibilityFulltextTable) {
744            tsvector = ftColumnName;
745        } else {
746            tsvector = String.format("NX_TO_TSVECTOR(%s)", ftColumnName);
747        }
748        String where = String.format("(%s @@ %s)", tsquery, tsvector);
749        if (like != null) {
750            where += " AND (" + like + ")";
751        }
752        info.whereExpr = where;
753        info.whereExprParam = fulltextQuery;
754        info.scoreExpr = String.format("TS_RANK_CD(%s, %s, 32)", tsvector, tsquery);
755        info.scoreExprParam = fulltextQuery;
756        info.scoreAlias = "_nxscore" + nthSuffix;
757        info.scoreCol = new Column(mainColumn.getTable(), null, ColumnType.DOUBLE, null);
758        return info;
759    }
760
761    @Override
762    public boolean getMaterializeFulltextSyntheticColumn() {
763        return true;
764    }
765
766    @Override
767    public int getFulltextIndexedColumns() {
768        return 1;
769    }
770
771    @Override
772    public String getFreeVariableSetterForType(ColumnType type) {
773        if (type == ColumnType.FTSTORED && compatibilityFulltextTable) {
774            return "NX_TO_TSVECTOR(?)";
775        }
776        return "?";
777    }
778
779    @Override
780    public boolean supportsUpdateFrom() {
781        return true;
782    }
783
784    @Override
785    public boolean doesUpdateFromRepeatSelf() {
786        return false;
787    }
788
789    @Override
790    public boolean needsAliasForDerivedTable() {
791        return true;
792    }
793
794    @Override
795    public boolean supportsIlike() {
796        return true;
797    }
798
799    @Override
800    public boolean supportsReadAcl() {
801        return aclOptimizationsEnabled;
802    }
803
804    @Override
805    public String getPrepareUserReadAclsSql() {
806        return "SELECT nx_prepare_user_read_acls(?)";
807    }
808
809    @Override
810    public String getReadAclsCheckSql(String userIdCol) {
811        return String.format("%s = md5(array_to_string(?, '%s'))", userIdCol, getUsersSeparator());
812    }
813
814    @Override
815    public String getUpdateReadAclsSql() {
816        return "SELECT nx_update_read_acls();";
817    }
818
819    @Override
820    public String getRebuildReadAclsSql() {
821        return "SELECT nx_rebuild_read_acls();";
822    }
823
824    @Override
825    public String getSecurityCheckSql(String idColumnName) {
826        return String.format("NX_ACCESS_ALLOWED(%s, ?, ?)", idColumnName);
827    }
828
829    @Override
830    public boolean supportsAncestorsTable() {
831        return true;
832    }
833
834    @Override
835    public boolean supportsFastDescendants() {
836        return pathOptimizationsEnabled;
837    }
838
839    @Override
840    public String getInTreeSql(String idColumnName, String id) {
841        String cast;
842        try {
843            cast = getCastForId(id);
844        } catch (IllegalArgumentException e) {
845            // discard query with invalid id
846            return null;
847        }
848        if (pathOptimizationsEnabled) {
849            return String.format("EXISTS(SELECT 1 FROM ancestors WHERE id = %s AND ARRAY[?]%s <@ ancestors)",
850                    idColumnName, getCastForArray(cast));
851        }
852        return String.format("%s IN (SELECT * FROM nx_children(?%s))", idColumnName, cast);
853    }
854
855    protected String getCastForArray(String cast) {
856        if (cast.isEmpty()) {
857            return cast;
858        }
859        return cast + "[]";
860    }
861
862    protected String getCastForId(String id) {
863        String ret;
864        switch (idType) {
865        case VARCHAR:
866            return "";
867        case UUID:
868            // check that it's really a uuid
869            if (id != null) {
870                UUID.fromString(id);
871            }
872            ret = "::uuid";
873            break;
874        case SEQUENCE:
875            // check that it's really an integer
876            if (id != null && !StringUtils.isNumeric(id)) {
877                throw new IllegalArgumentException("Invalid sequence id: " + id);
878            }
879            ret = "::bigint";
880            break;
881        default:
882            throw new AssertionError("Unknown id type: " + idType);
883        }
884        return ret;
885    }
886
887    @Override
888    public String getMatchMixinType(Column mixinsColumn, String mixin, boolean positive, String[] returnParam) {
889        returnParam[0] = mixin;
890        String sql = "ARRAY[?]::varchar[] <@ " + mixinsColumn.getFullQuotedName();
891        return positive ? sql : "NOT(" + sql + ")";
892    }
893
894    @Override
895    public boolean supportsSysNameArray() {
896        return true;
897    }
898
899    @Override
900    public boolean supportsArrays() {
901        return true;
902    }
903
904    @Override
905    public boolean supportsArrayColumns() {
906        return true;
907    }
908
909    public static class ArraySubQueryPostgreSQL extends ArraySubQuery {
910
911        protected Dialect dialect = null;
912
913        protected Table fakeSubqueryTableAlias = null;
914
915        public ArraySubQueryPostgreSQL(Column arrayColumn, String alias) {
916            super(arrayColumn, alias);
917            dialect = arrayColumn.getTable().getDialect();
918            fakeSubqueryTableAlias = new TableAlias(arrayColumn.getTable(), alias);
919        }
920
921        @Override
922        public Column getSubQueryIdColumn() {
923            Column column = fakeSubqueryTableAlias.getColumn(Model.MAIN_KEY);
924            return new ArraySubQueryPostgreSQLColumn(column.getPhysicalName(), column.getType());
925        }
926
927        @Override
928        public Column getSubQueryValueColumn() {
929            return new ArraySubQueryPostgreSQLColumn(Model.COLL_TABLE_VALUE_KEY, arrayColumn.getBaseType());
930        }
931
932        public class ArraySubQueryPostgreSQLColumn extends Column {
933            private static final long serialVersionUID = 1L;
934
935            ArraySubQueryPostgreSQLColumn(String columnName, ColumnType columnType) {
936                super(fakeSubqueryTableAlias, columnName, columnType, columnName);
937            }
938
939            @Override
940            public String getFullQuotedName() {
941                return dialect.openQuote() + subQueryAlias + dialect.closeQuote() + '.' + getQuotedName();
942            }
943        }
944
945        @Override
946        public String toSql() {
947            Table table = arrayColumn.getTable();
948            return String.format("(SELECT %s, UNNEST(%s) AS %s, generate_subscripts(%s, 1) AS %s FROM %s) ",
949                    table.getColumn(Model.MAIN_KEY).getQuotedName(), arrayColumn.getQuotedName(),
950                    Model.COLL_TABLE_VALUE_KEY, arrayColumn.getQuotedName(), Model.COLL_TABLE_POS_KEY,
951                    table.getRealTable().getQuotedName());
952        }
953    }
954
955    @Override
956    public ArraySubQuery getArraySubQuery(Column arrayColumn, String subQueryAlias) {
957        return new ArraySubQueryPostgreSQL(arrayColumn, subQueryAlias);
958    }
959
960    @Override
961    public String getArrayElementString(String arrayColumnName, int arrayElementIndex) {
962        // PostgreSQL arrays index start at 1
963        return arrayColumnName + "[" + (arrayElementIndex + 1) + "]";
964    }
965
966    @Override
967    public String getArrayInSql(Column arrayColumn, String cast, boolean positive, List<Serializable> params) {
968        StringBuilder sql = new StringBuilder();
969        if (!positive) {
970            sql.append("(NOT(");
971        }
972        if (params.size() == 1) {
973            // ? = ANY(arrayColumn)
974            sql.append("? = ANY(");
975            sql.append(arrayColumn.getFullQuotedName());
976            if (cast != null) {
977                // DATE cast
978                sql.append("::");
979                sql.append(cast);
980                sql.append("[]");
981            }
982            sql.append(")");
983        } else {
984            // arrayColumn && ARRAY[?, ?, ?]
985            sql.append(arrayColumn.getFullQuotedName());
986            sql.append(" && ");
987            sql.append("ARRAY[");
988            for (int i = 0; i < params.size(); i++) {
989                if (i != 0) {
990                    sql.append(", ");
991                }
992                sql.append('?');
993            }
994            sql.append("]::");
995            sql.append(arrayColumn.getSqlTypeString());
996        }
997        if (!positive) {
998            sql.append(") OR ");
999            sql.append(arrayColumn.getFullQuotedName());
1000            sql.append(" IS NULL)");
1001        }
1002        return sql.toString();
1003    }
1004
1005    @Override
1006    public String getArrayLikeSql(Column arrayColumn, String refName, boolean positive, Table dataHierTable) {
1007        return getArrayOpSql(arrayColumn, refName, positive, dataHierTable, "LIKE");
1008    }
1009
1010    @Override
1011    public String getArrayIlikeSql(Column arrayColumn, String refName, boolean positive, Table dataHierTable) {
1012        return getArrayOpSql(arrayColumn, refName, positive, dataHierTable, "ILIKE");
1013    }
1014
1015    protected String getArrayOpSql(Column arrayColumn, String refName, boolean positive, Table dataHierTable,
1016            String op) {
1017        Table table = arrayColumn.getTable();
1018        String tableAliasName = openQuote() + getTableName(refName) + closeQuote();
1019        String sql = String.format("EXISTS (SELECT 1 FROM %s AS %s WHERE %s = %s AND %s %s ?)",
1020                getArraySubQuery(arrayColumn, tableAliasName).toSql(), tableAliasName,
1021                dataHierTable.getColumn(Model.MAIN_KEY).getFullQuotedName(),
1022                tableAliasName + '.' + table.getColumn(Model.MAIN_KEY).getQuotedName(),
1023                tableAliasName + '.' + Model.COLL_TABLE_VALUE_KEY, op);
1024        if (!positive) {
1025            sql = "NOT(" + sql + ")";
1026        }
1027        return sql;
1028    }
1029
1030    @Override
1031    public Array createArrayOf(int type, Object[] elements, Connection connection) throws SQLException {
1032        if (elements == null || elements.length == 0) {
1033            return null;
1034        }
1035        String typeName;
1036        switch (type) {
1037        case Types.VARCHAR:
1038            typeName = "varchar";
1039            break;
1040        case Types.CLOB:
1041            typeName = "text";
1042            break;
1043        case Types.BIT:
1044            typeName = "bool";
1045            break;
1046        case Types.BIGINT:
1047            typeName = "int8";
1048            break;
1049        case Types.DOUBLE:
1050            typeName = "float8";
1051            break;
1052        case Types.TIMESTAMP:
1053            typeName = "timestamp";
1054            break;
1055        case Types.SMALLINT:
1056            typeName = "int2";
1057            break;
1058        case Types.INTEGER:
1059            typeName = "int4";
1060            break;
1061        case Types.OTHER: // id
1062            switch (idType) {
1063            case VARCHAR:
1064                typeName = "varchar";
1065                break;
1066            case UUID:
1067                typeName = "uuid";
1068                break;
1069            case SEQUENCE:
1070                typeName = "int8";
1071                break;
1072            default:
1073                throw new AssertionError("Unknown id type: " + idType);
1074            }
1075            break;
1076        default:
1077            throw new AssertionError("Unknown type: " + type);
1078        }
1079        return connection.createArrayOf(typeName, elements);
1080    }
1081
1082    @Override
1083    public String getSQLStatementsFilename() {
1084        return "nuxeovcs/postgresql.sql.txt";
1085    }
1086
1087    @Override
1088    public String getTestSQLStatementsFilename() {
1089        return "nuxeovcs/postgresql.test.sql.txt";
1090    }
1091
1092    @Override
1093    public Map<String, Serializable> getSQLStatementsProperties(Model model, Database database) {
1094        Map<String, Serializable> properties = new HashMap<>();
1095        switch (idType) {
1096        case VARCHAR:
1097            properties.put("idType", "varchar(36)");
1098            properties.put("idTypeParam", "varchar");
1099            properties.put("idNotPresent", "'-'");
1100            properties.put("sequenceEnabled", Boolean.FALSE);
1101            break;
1102        case UUID:
1103            properties.put("idType", "uuid");
1104            properties.put("idTypeParam", "uuid");
1105            properties.put("idNotPresent", "'00000000-FFFF-FFFF-FFFF-FFFF00000000'");
1106            properties.put("sequenceEnabled", Boolean.FALSE);
1107            break;
1108        case SEQUENCE:
1109            properties.put("idType", "int8");
1110            properties.put("idTypeParam", "int8");
1111            properties.put("idNotPresent", "-1");
1112            properties.put("sequenceEnabled", Boolean.TRUE);
1113            properties.put("idSequenceName", idSequenceName);
1114        }
1115        properties.put("aclOptimizationsEnabled", Boolean.valueOf(aclOptimizationsEnabled));
1116        properties.put("pathOptimizationsEnabled", Boolean.valueOf(pathOptimizationsEnabled));
1117        properties.put("fulltextAnalyzer", fulltextAnalyzer);
1118        properties.put("fulltextEnabled", Boolean.valueOf(!fulltextDisabled));
1119        properties.put("fulltextSearchEnabled", Boolean.valueOf(!fulltextSearchDisabled));
1120        properties.put("clusteringEnabled", Boolean.valueOf(clusteringEnabled));
1121        properties.put("proxiesEnabled", Boolean.valueOf(proxiesEnabled));
1122        properties.put("softDeleteEnabled", Boolean.valueOf(softDeleteEnabled));
1123        properties.put("arrayColumnsEnabled", Boolean.valueOf(arrayColumnsEnabled));
1124        if (!fulltextSearchDisabled) {
1125            Table ft = database.getTable(Model.FULLTEXT_TABLE_NAME);
1126            FulltextConfiguration fti = model.getFulltextConfiguration();
1127            List<String> lines = new ArrayList<>(fti.indexNames.size());
1128            for (String indexName : fti.indexNames) {
1129                String suffix = model.getFulltextIndexSuffix(indexName);
1130                Column ftft = ft.getColumn(Model.FULLTEXT_FULLTEXT_KEY + suffix);
1131                Column ftst = ft.getColumn(Model.FULLTEXT_SIMPLETEXT_KEY + suffix);
1132                Column ftbt = ft.getColumn(Model.FULLTEXT_BINARYTEXT_KEY + suffix);
1133                String concat;
1134                if (compatibilityFulltextTable) {
1135                    // tsvector
1136                    concat = "  NEW.%s := COALESCE(NEW.%s, ''::TSVECTOR) || COALESCE(NEW.%s, ''::TSVECTOR);";
1137                } else {
1138                    // text with space at beginning and end
1139                    concat = "  NEW.%s := ' ' || COALESCE(NEW.%s, '') || ' ' || COALESCE(NEW.%s, '') || ' ';";
1140                }
1141                String line = String.format(concat, ftft.getQuotedName(), ftst.getQuotedName(), ftbt.getQuotedName());
1142                lines.add(line);
1143            }
1144            properties.put("fulltextTriggerStatements", String.join("\n", lines));
1145        }
1146        String[] permissions = NXCore.getSecurityService().getPermissionsToCheck(SecurityConstants.BROWSE);
1147        List<String> permsList = new LinkedList<>();
1148        for (String perm : permissions) {
1149            permsList.add("('" + perm + "')");
1150        }
1151        properties.put("readPermissions", String.join(", ", permsList));
1152        properties.put("usersSeparator", getUsersSeparator());
1153        properties.put("everyone", SecurityConstants.EVERYONE);
1154        properties.put("readAclMaxSize", Integer.toString(readAclMaxSize));
1155        properties.put("unlogged", unloggedKeyword);
1156        return properties;
1157    }
1158
1159    @Override
1160    public List<String> getStartupSqls(Model model, Database database) {
1161        if (aclOptimizationsEnabled) {
1162            log.info("Vacuuming tables used by optimized acls");
1163            return Collections.singletonList("SELECT nx_vacuum_read_acls()");
1164        }
1165        return Collections.emptyList();
1166    }
1167
1168    @Override
1169    public boolean isClusteringSupported() {
1170        return true;
1171    }
1172
1173    @Override
1174    public String getClusterInsertInvalidations() {
1175        return "SELECT NX_CLUSTER_INVAL(?, ?, ?, ?)";
1176    }
1177
1178    @Override
1179    public String getClusterGetInvalidations() {
1180        return "DELETE FROM cluster_invals WHERE nodeid = ? RETURNING id, fragments, kind";
1181    }
1182
1183    @Override
1184    public boolean isConcurrentUpdateException(Throwable t) {
1185        while (t.getCause() != null) {
1186            t = t.getCause();
1187        }
1188        if (t instanceof SQLException) {
1189            String sqlState = ((SQLException) t).getSQLState();
1190            if ("23503".equals(sqlState)) {
1191                // insert or update on table ... violates foreign key constraint
1192                return true;
1193            }
1194            if ("23505".equals(sqlState)) {
1195                // duplicate key value violates unique constraint
1196                return true;
1197            }
1198            if ("40P01".equals(sqlState)) {
1199                // deadlock detected
1200                return true;
1201            }
1202        }
1203        return false;
1204    }
1205
1206    @Override
1207    public boolean supportsPaging() {
1208        return true;
1209    }
1210
1211    @Override
1212    public String addPagingClause(String sql, long limit, long offset) {
1213        return sql + String.format(" LIMIT %d OFFSET %d", limit, offset);
1214    }
1215
1216    @Override
1217    public boolean supportsWith() {
1218        return false; // don't activate until proven useful
1219        // return supportsWith;
1220    }
1221
1222    @Override
1223    public void performAdditionalStatements(Connection connection) throws SQLException {
1224        // Check if there is a pre-existing aclr_permission table
1225        boolean createAclrPermission;
1226        try (Statement s = connection.createStatement()) {
1227            String sql = "SELECT 1 FROM pg_tables WHERE tablename = 'aclr_permission'";
1228            try (ResultSet rs = s.executeQuery(sql)) {
1229                createAclrPermission = !rs.next();
1230            }
1231        }
1232        // If no table, it will be created and filled at DDL execution time
1233        if (createAclrPermission) {
1234            return;
1235        }
1236        // Warn user if BROWSE permissions has changed
1237        Set<String> dbPermissions = new HashSet<>();
1238        try (Statement s = connection.createStatement()) {
1239            String sql = "SELECT * FROM aclr_permission";
1240            try (ResultSet rs = s.executeQuery(sql)) {
1241                while (rs.next()) {
1242                    dbPermissions.add(rs.getString(1));
1243                }
1244            }
1245        }
1246        SecurityService securityService = NXCore.getSecurityService();
1247        Set<String> confPermissions = new HashSet<>(
1248                Arrays.asList(securityService.getPermissionsToCheck(SecurityConstants.BROWSE)));
1249        if (!dbPermissions.equals(confPermissions)) {
1250            log.error("Security permission for BROWSE has changed, you need to rebuild the optimized read acls:"
1251                    + "DROP TABLE aclr_permission; DROP TABLE aclr; then restart.");
1252        }
1253    }
1254
1255    public String getUsersSeparator() {
1256        if (usersSeparator == null) {
1257            return DEFAULT_USERS_SEPARATOR;
1258        }
1259        return usersSeparator;
1260    }
1261
1262    @Override
1263    public String getValidationQuery() {
1264        return "";
1265    }
1266
1267    @Override
1268    public String getAncestorsIdsSql() {
1269        return "SELECT NX_ANCESTORS(?)";
1270    }
1271
1272    @Override
1273    public boolean needsNullsLastOnDescSort() {
1274        return true;
1275    }
1276
1277    @Override
1278    public String getDateCast() {
1279        // this is more amenable to being indexed than a CAST
1280        return "DATE(%s)";
1281    }
1282
1283    @Override
1284    public String castIdToVarchar(String expr) {
1285        switch (idType) {
1286        case VARCHAR:
1287            return expr;
1288        case UUID:
1289            return expr + "::varchar";
1290        case SEQUENCE:
1291            return expr + "::varchar";
1292        default:
1293            throw new AssertionError("Unknown id type: " + idType);
1294        }
1295    }
1296
1297    @Override
1298    public DialectIdType getIdType() {
1299        return idType;
1300    }
1301
1302    @Override
1303    public String getSoftDeleteSql() {
1304        return "SELECT NX_DELETE(?, ?)";
1305    }
1306
1307    @Override
1308    public String getSoftDeleteCleanupSql() {
1309        return "SELECT NX_DELETE_PURGE(?, ?)";
1310    }
1311
1312    @Override
1313    public String getBinaryFulltextSql(List<String> columns) {
1314        if (compatibilityFulltextTable) {
1315            // extract tokens from tsvector
1316            String columnsAs = columns.stream()
1317                                      .map(col -> "regexp_replace(" + col + "::text, $$'|'\\:[^']*'?$$, ' ', 'g')")
1318                                      .collect(Collectors.joining(", "));
1319            return "SELECT " + columnsAs + " FROM fulltext WHERE id=?";
1320        }
1321        return super.getBinaryFulltextSql(columns);
1322    }
1323
1324    // parenthesizes parameter part, with optional nested parentheses
1325    private static final Pattern SIG_MATCH = Pattern.compile("[^(]*\\((([^()]*|\\([^()]*\\))*)\\).*", Pattern.DOTALL);
1326
1327    @Override
1328    public List<String> checkStoredProcedure(String procName, String procCreate, String ddlMode, Connection connection,
1329            JDBCLogger logger, Map<String, Serializable> properties) throws SQLException {
1330        boolean compatCheck = ddlMode.contains(RepositoryDescriptor.DDL_MODE_COMPAT);
1331        if (compatCheck) {
1332            procCreate = "CREATE OR REPLACE " + procCreate.substring("create ".length());
1333            return Collections.singletonList(procCreate);
1334        }
1335        // extract signature from create statement
1336        Matcher m = SIG_MATCH.matcher(procCreate);
1337        if (!m.matches()) {
1338            throw new NuxeoException("Cannot parse arguments: " + procCreate);
1339        }
1340        String procArgs = normalizeArgs(m.group(1));
1341        try (Statement st = connection.createStatement()) {
1342            // check if the stored procedure exists and its content
1343            String getBody = "SELECT prosrc, pg_get_function_identity_arguments(oid) FROM pg_proc WHERE proname = '"
1344                    + procName + "'";
1345            logger.log(getBody);
1346            try (ResultSet rs = st.executeQuery(getBody)) {
1347                while (rs.next()) {
1348                    String body = rs.getString(1);
1349                    String args = rs.getString(2);
1350                    if (!args.equals(procArgs)) {
1351                        // different signature
1352                        continue;
1353                    }
1354                    // stored proc already exists
1355                    if (normalizeString(procCreate).contains(normalizeString(body))) {
1356                        logger.log("  -> exists, unchanged");
1357                        return Collections.emptyList();
1358                    } else {
1359                        logger.log("  -> exists, old");
1360                        // we can't drop then recreate as for instance a function used by a trigger
1361                        // would say "cannot drop function ... because other objects depend on it"
1362                        // so we hack and do an do a replace
1363                        if (!procCreate.toLowerCase().startsWith("create ")) {
1364                            throw new NuxeoException("Should start with CREATE: " + procCreate);
1365                        }
1366                        procCreate = "CREATE OR REPLACE " + procCreate.substring("create ".length());
1367                        return Collections.singletonList(procCreate);
1368                    }
1369                }
1370            }
1371            logger.log("  -> missing");
1372            return Collections.singletonList(procCreate);
1373        }
1374    }
1375
1376    protected static String normalizeString(String string) {
1377        return string.replaceAll("[ \n\r\t]+", " ").trim();
1378    }
1379
1380    /** The type aliases that we use for our stored procedure argument definitions. */
1381    private static final Map<String, String> TYPE_ALIASES = new HashMap<>();
1382
1383    static {
1384        TYPE_ALIASES.put("bool", "boolean");
1385        TYPE_ALIASES.put("varchar", "character varying");
1386        TYPE_ALIASES.put("int", "integer");
1387        TYPE_ALIASES.put("int4", "integer");
1388        TYPE_ALIASES.put("int8", "bigint");
1389        TYPE_ALIASES.put("timestamp", "timestamp without time zone");
1390    }
1391
1392    /** Normalize PostgreSQL type aliases. */
1393    protected static String normalizeArgs(String args) {
1394        if (args.isEmpty()) {
1395            return args;
1396        }
1397        args = args.toLowerCase();
1398        List<String> argList = Arrays.asList(args.split(",[ ]*"));
1399        List<String> newArgList = new ArrayList<>(argList.size());
1400        for (String arg : argList) {
1401            // array or size spec
1402            int i = arg.indexOf('(');
1403            if (i == -1) {
1404                i = arg.indexOf('[');
1405            }
1406            String suffix = "";
1407            if (i > 0) {
1408                suffix = arg.substring(i);
1409                arg = arg.substring(0, i);
1410            }
1411            for (Entry<String, String> es : TYPE_ALIASES.entrySet()) {
1412                String type = es.getKey();
1413                if (arg.equals(type) || arg.endsWith(" " + type)) {
1414                    arg = arg.substring(0, arg.length() - type.length()) + es.getValue();
1415                    break;
1416                }
1417            }
1418            newArgList.add(arg + suffix);
1419        }
1420        return String.join(", ", newArgList);
1421    }
1422
1423}