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