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