001/*
002 * (C) Copyright 2006-2015 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 *     George Lefter
018 *     Florent Guillaume
019 */
020package org.nuxeo.ecm.directory.sql;
021
022import static org.nuxeo.ecm.directory.sql.SQLDirectory.TENANT_ID_FIELD;
023
024import java.io.Serializable;
025import java.sql.Connection;
026import java.sql.PreparedStatement;
027import java.sql.ResultSet;
028import java.sql.SQLException;
029import java.sql.Statement;
030import java.util.ArrayList;
031import java.util.Collections;
032import java.util.HashMap;
033import java.util.Iterator;
034import java.util.LinkedHashMap;
035import java.util.LinkedList;
036import java.util.List;
037import java.util.Map;
038import java.util.Map.Entry;
039import java.util.Set;
040
041import org.apache.commons.lang.StringUtils;
042import org.apache.commons.logging.Log;
043import org.apache.commons.logging.LogFactory;
044import org.nuxeo.ecm.core.api.ConcurrentUpdateException;
045import org.nuxeo.ecm.core.api.DataModel;
046import org.nuxeo.ecm.core.api.DocumentModel;
047import org.nuxeo.ecm.core.api.DocumentModelList;
048import org.nuxeo.ecm.core.api.NuxeoPrincipal;
049import org.nuxeo.ecm.core.api.PropertyException;
050import org.nuxeo.ecm.core.api.impl.DocumentModelListImpl;
051import org.nuxeo.ecm.core.api.local.ClientLoginModule;
052import org.nuxeo.ecm.core.api.security.SecurityConstants;
053import org.nuxeo.ecm.core.schema.types.Field;
054import org.nuxeo.ecm.core.storage.sql.ColumnSpec;
055import org.nuxeo.ecm.core.storage.sql.jdbc.JDBCLogger;
056import org.nuxeo.ecm.core.storage.sql.jdbc.db.Column;
057import org.nuxeo.ecm.core.storage.sql.jdbc.db.Delete;
058import org.nuxeo.ecm.core.storage.sql.jdbc.db.Insert;
059import org.nuxeo.ecm.core.storage.sql.jdbc.db.Select;
060import org.nuxeo.ecm.core.storage.sql.jdbc.db.Table;
061import org.nuxeo.ecm.core.storage.sql.jdbc.db.Update;
062import org.nuxeo.ecm.core.storage.sql.jdbc.dialect.Dialect;
063import org.nuxeo.ecm.core.utils.SIDGenerator;
064import org.nuxeo.ecm.directory.BaseDirectoryDescriptor.SubstringMatchType;
065import org.nuxeo.ecm.directory.BaseSession;
066import org.nuxeo.ecm.directory.DirectoryException;
067import org.nuxeo.ecm.directory.EntrySource;
068import org.nuxeo.ecm.directory.OperationNotAllowedException;
069import org.nuxeo.ecm.directory.PasswordHelper;
070import org.nuxeo.ecm.directory.Reference;
071import org.nuxeo.ecm.directory.sql.filter.SQLComplexFilter;
072
073/**
074 * This class represents a session against an SQLDirectory.
075 */
076public class SQLSession extends BaseSession implements EntrySource {
077
078    private static final Log log = LogFactory.getLog(SQLSession.class);
079
080    protected final Map<String, Field> schemaFieldMap;
081
082    protected final List<String> storedFieldNames;
083
084    protected final Set<String> emptySet = Collections.emptySet();
085
086    final String schemaName;
087
088    final Table table;
089
090    private final SubstringMatchType substringMatchType;
091
092    String dataSourceName;
093
094    final String idField;
095
096    final String passwordField;
097
098    final String passwordHashAlgorithm;
099
100    private final boolean autoincrementIdField;
101
102    private final boolean computeMultiTenantId;
103
104    protected SQLStaticFilter[] staticFilters;
105
106    String sid;
107
108    Connection sqlConnection;
109
110    private final Dialect dialect;
111
112    protected JDBCLogger logger = new JDBCLogger("SQLDirectory");
113
114    public SQLSession(SQLDirectory directory, SQLDirectoryDescriptor config) throws DirectoryException {
115        super(directory);
116        schemaName = config.schemaName;
117        table = directory.getTable();
118        idField = config.idField;
119        passwordField = config.passwordField;
120        passwordHashAlgorithm = config.passwordHashAlgorithm;
121        schemaFieldMap = directory.getSchemaFieldMap();
122        storedFieldNames = directory.getStoredFieldNames();
123        dialect = directory.getDialect();
124        sid = String.valueOf(SIDGenerator.next());
125        substringMatchType = config.getSubstringMatchType();
126        autoincrementIdField = config.isAutoincrementIdField();
127        staticFilters = config.getStaticFilters();
128        computeMultiTenantId = config.isComputeMultiTenantId();
129        permissions = config.permissions;
130        acquireConnection();
131    }
132
133    @Override
134    public SQLDirectory getDirectory() {
135        return (SQLDirectory) directory;
136    }
137
138    protected DocumentModel fieldMapToDocumentModel(Map<String, Object> fieldMap) {
139        String idFieldName = schemaFieldMap.get(getIdField()).getName().getPrefixedName();
140        // If the prefixed id is not here, try to get without prefix
141        // It may happen when we gentry from sql
142        if (!fieldMap.containsKey(idFieldName)) {
143            idFieldName = getIdField();
144        }
145
146        String id = String.valueOf(fieldMap.get(idFieldName));
147        try {
148            DocumentModel docModel = BaseSession.createEntryModel(sid, schemaName, id, fieldMap, isReadOnly());
149            return docModel;
150        } catch (PropertyException e) {
151            log.error(e, e);
152            return null;
153        }
154    }
155
156    private void acquireConnection() throws DirectoryException {
157        try {
158            if (sqlConnection == null || sqlConnection.isClosed()) {
159                sqlConnection = getDirectory().getConnection();
160            }
161        } catch (SQLException e) {
162            throw new DirectoryException("Cannot connect to SQL directory '" + directory.getName() + "': "
163                    + e.getMessage(), e);
164        }
165    }
166
167    /**
168     * Checks the SQL error we got and determine if a concurrent update happened. Throws if that's the case.
169     *
170     * @param e the exception
171     * @since 7.10-HF04, 8.2
172     */
173    protected void checkConcurrentUpdate(Throwable e) throws ConcurrentUpdateException {
174        if (dialect.isConcurrentUpdateException(e)) {
175            throw new ConcurrentUpdateException(e);
176        }
177    }
178
179    @Override
180    public DocumentModel createEntry(Map<String, Object> fieldMap) {
181        checkPermission(SecurityConstants.WRITE);
182
183        Field schemaIdField = schemaFieldMap.get(idField);
184
185        String idFieldName = schemaIdField.getName().getPrefixedName();
186
187        acquireConnection();
188        if (autoincrementIdField) {
189            fieldMap.remove(idFieldName);
190        } else {
191            // check id that was given
192            Object rawId = fieldMap.get(idFieldName);
193            if (rawId == null) {
194                throw new DirectoryException("Missing id");
195            }
196            String id = String.valueOf(rawId);
197            if (hasEntry(id)) {
198                throw new DirectoryException(String.format("Entry with id %s already exists", id));
199            }
200
201            if (isMultiTenant()) {
202                String tenantId = getCurrentTenantId();
203                if (!StringUtils.isBlank(tenantId)) {
204                    fieldMap.put(TENANT_ID_FIELD, tenantId);
205                    if (computeMultiTenantId) {
206                        fieldMap.put(idFieldName, computeMultiTenantDirectoryId(tenantId, id));
207                    }
208                }
209            }
210        }
211
212        List<Column> columnList = new ArrayList<>(table.getColumns());
213        Column idColumn = null;
214        for (Iterator<Column> i = columnList.iterator(); i.hasNext();) {
215            Column column = i.next();
216            if (column.isIdentity()) {
217                idColumn = column;
218            }
219            String prefixField = schemaFieldMap.get(column.getKey()).getName().getPrefixedName();
220            if (fieldMap.get(prefixField) == null) {
221                i.remove();
222            }
223        }
224        Insert insert = new Insert(table);
225        for (Column column : columnList) {
226            insert.addColumn(column);
227        }
228        // needed for Oracle for empty map insert
229        insert.addIdentityColumn(idColumn);
230        String sql = insert.getStatement();
231
232        if (logger.isLogEnabled()) {
233            List<Serializable> values = new ArrayList<>(columnList.size());
234            for (Column column : columnList) {
235                String prefixField = schemaFieldMap.get(column.getKey()).getName().getPrefixedName();
236                Object value = fieldMap.get(prefixField);
237                values.add(fieldValueForWrite(value, column));
238            }
239            logger.logSQL(sql, values);
240        }
241
242        DocumentModel entry;
243        PreparedStatement ps = null;
244        Statement st = null;
245        try {
246            if (autoincrementIdField && dialect.hasIdentityGeneratedKey()) {
247                ps = sqlConnection.prepareStatement(sql, new String[] { idField });
248            } else {
249                ps = sqlConnection.prepareStatement(sql);
250            }
251            int index = 1;
252            for (Column column : columnList) {
253                String prefixField = schemaFieldMap.get(column.getKey()).getName().getPrefixedName();
254                Object value = fieldMap.get(prefixField);
255                setFieldValue(ps, index, column, value);
256                index++;
257            }
258            ps.execute();
259            if (autoincrementIdField) {
260                Column column = table.getColumn(idField);
261                ResultSet rs;
262                if (dialect.hasIdentityGeneratedKey()) {
263                    rs = ps.getGeneratedKeys();
264                } else {
265                    // needs specific statements
266                    sql = dialect.getIdentityGeneratedKeySql(column);
267                    st = sqlConnection.createStatement();
268                    rs = st.executeQuery(sql);
269                }
270                if (!rs.next()) {
271                    throw new DirectoryException("Cannot get generated key");
272                }
273                if (logger.isLogEnabled()) {
274                    logger.logResultSet(rs, Collections.singletonList(column));
275                }
276                Serializable rawId = column.getFromResultSet(rs, 1);
277                fieldMap.put(idFieldName, rawId);
278                rs.close();
279            }
280            entry = fieldMapToDocumentModel(fieldMap);
281        } catch (SQLException e) {
282            checkConcurrentUpdate(e);
283            throw new DirectoryException("createEntry failed", e);
284        } finally {
285            try {
286                if (ps != null) {
287                    ps.close();
288                }
289                if (st != null) {
290                    st.close();
291                }
292            } catch (SQLException sqle) {
293                throw new DirectoryException(sqle);
294            }
295        }
296
297        // second step: add references fields
298        String sourceId = entry.getId();
299        for (Reference reference : getDirectory().getReferences()) {
300            String referenceFieldName = schemaFieldMap.get(reference.getFieldName()).getName().getPrefixedName();
301            if (getDirectory().getReferences(reference.getFieldName()).size() > 1) {
302                log.warn("Directory " + getDirectory().getName() + " cannot create field " + reference.getFieldName()
303                        + " for entry " + fieldMap.get(idFieldName)
304                        + ": this field is associated with more than one reference");
305                continue;
306            }
307            @SuppressWarnings("unchecked")
308            List<String> targetIds = (List<String>) fieldMap.get(referenceFieldName);
309            if (reference instanceof TableReference) {
310                // optim: reuse the current session
311                // but still initialize the reference if not yet done
312                TableReference tableReference = (TableReference) reference;
313                tableReference.maybeInitialize(this);
314                tableReference.addLinks(sourceId, targetIds, this);
315            } else {
316                reference.addLinks(sourceId, targetIds);
317            }
318        }
319        getDirectory().invalidateCaches();
320        return entry;
321    }
322
323    @Override
324    public DocumentModel getEntry(String id) throws DirectoryException {
325        return getEntry(id, true);
326    }
327
328    @Override
329    public DocumentModel getEntry(String id, boolean fetchReferences) throws DirectoryException {
330        if (!hasPermission(SecurityConstants.READ)) {
331            return null;
332        }
333        return directory.getCache().getEntry(id, this, fetchReferences);
334    }
335
336    protected String addFilterWhereClause(String whereClause) throws DirectoryException {
337        if (staticFilters.length == 0) {
338            return whereClause;
339        }
340        if (whereClause != null && whereClause.trim().length() > 0) {
341            whereClause = whereClause + " AND ";
342        } else {
343            whereClause = "";
344        }
345        for (int i = 0; i < staticFilters.length; i++) {
346            SQLStaticFilter filter = staticFilters[i];
347            whereClause += filter.getDirectoryColumn(table, getDirectory().useNativeCase()).getQuotedName();
348            whereClause += " " + filter.getOperator() + " ";
349            whereClause += "? ";
350
351            if (i < staticFilters.length - 1) {
352                whereClause = whereClause + " AND ";
353            }
354        }
355        return whereClause;
356    }
357
358    protected void addFilterValues(PreparedStatement ps, int startIdx) throws DirectoryException {
359        for (int i = 0; i < staticFilters.length; i++) {
360            SQLStaticFilter filter = staticFilters[i];
361            setFieldValue(ps, startIdx + i, filter.getDirectoryColumn(table, getDirectory().useNativeCase()),
362                    filter.getValue());
363        }
364    }
365
366    protected void addFilterValuesForLog(List<Serializable> values) {
367        for (int i = 0; i < staticFilters.length; i++) {
368            values.add(staticFilters[i].getValue());
369        }
370    }
371
372    @Override
373    public DocumentModel getEntryFromSource(String id, boolean fetchReferences) throws DirectoryException {
374        acquireConnection();
375        // String sql = String.format("SELECT * FROM %s WHERE %s = ?",
376        // tableName, idField);
377        Select select = new Select(table);
378        select.setFrom(table.getQuotedName());
379        select.setWhat("*");
380
381        String whereClause = table.getPrimaryColumn().getQuotedName() + " = ?";
382        whereClause = addFilterWhereClause(whereClause);
383
384        select.setWhere(whereClause);
385        String sql = select.getStatement();
386
387        if (logger.isLogEnabled()) {
388            List<Serializable> values = new ArrayList<>();
389            values.add(id);
390            addFilterValuesForLog(values);
391            logger.logSQL(sql, values);
392        }
393
394        PreparedStatement ps = null;
395        try {
396            ps = sqlConnection.prepareStatement(sql);
397            setFieldValue(ps, 1, table.getPrimaryColumn(), id);
398            addFilterValues(ps, 2);
399
400            ResultSet rs = ps.executeQuery();
401            if (!rs.next()) {
402                rs.close();
403                return null;
404            }
405
406            // fetch the stored fields
407            Map<String, Object> fieldMap = new HashMap<>();
408            for (String fieldName : storedFieldNames) {
409                Object value = getFieldValue(rs, fieldName);
410                fieldMap.put(fieldName, value);
411            }
412            rs.close();
413
414            if (isMultiTenant()) {
415                // check that the entry is from the current tenant, or no tenant
416                // at all
417                String tenantId = getCurrentTenantId();
418                if (!StringUtils.isBlank(tenantId)) {
419                    String entryTenantId = (String) fieldMap.get(TENANT_ID_FIELD);
420                    if (!StringUtils.isBlank(entryTenantId)) {
421                        if (!entryTenantId.equals(tenantId)) {
422                            return null;
423                        }
424                    }
425                }
426            }
427
428            DocumentModel entry = fieldMapToDocumentModel(fieldMap);
429
430            // fetch the reference fields
431            if (fetchReferences) {
432                Map<String, List<String>> targetIdsMap = new HashMap<>();
433                for (Reference reference : directory.getReferences()) {
434                    List<String> targetIds = reference.getTargetIdsForSource(entry.getId());
435                    targetIds = new ArrayList<>(targetIds);
436                    Collections.sort(targetIds);
437                    String fieldName = reference.getFieldName();
438                    if (targetIdsMap.containsKey(fieldName)) {
439                        targetIdsMap.get(fieldName).addAll(targetIds);
440                    } else {
441                        targetIdsMap.put(fieldName, targetIds);
442                    }
443                }
444                for (Entry<String, List<String>> en : targetIdsMap.entrySet()) {
445                    String fieldName = en.getKey();
446                    List<String> targetIds = en.getValue();
447                    try {
448                        entry.setProperty(schemaName, fieldName, targetIds);
449                    } catch (PropertyException e) {
450                        throw new DirectoryException(e);
451                    }
452                }
453            }
454            return entry;
455        } catch (SQLException e) {
456            throw new DirectoryException("getEntry failed", e);
457        } finally {
458            try {
459                if (ps != null) {
460                    ps.close();
461                }
462            } catch (SQLException sqle) {
463                throw new DirectoryException(sqle);
464            }
465        }
466    }
467
468    @Override
469    public DocumentModelList getEntries() {
470        Map<String, Serializable> emptyMap = Collections.emptyMap();
471        return query(emptyMap);
472    }
473
474    @Override
475    public void updateEntry(DocumentModel docModel) {
476        checkPermission(SecurityConstants.WRITE);
477        acquireConnection();
478        List<Column> storedColumnList = new LinkedList<>();
479        List<String> referenceFieldList = new LinkedList<>();
480        DataModel dataModel = docModel.getDataModel(schemaName);
481
482        if (isMultiTenant()) {
483            // can only update entry from the current tenant
484            String tenantId = getCurrentTenantId();
485            if (!StringUtils.isBlank(tenantId)) {
486                String entryTenantId = (String) dataModel.getValue(TENANT_ID_FIELD);
487                if (StringUtils.isBlank(entryTenantId) || !entryTenantId.equals(tenantId)) {
488                    if (log.isDebugEnabled()) {
489                        log.debug(String.format("Trying to update entry '%s' not part of current tenant '%s'",
490                                docModel.getId(), tenantId));
491                    }
492                    throw new OperationNotAllowedException("Operation not allowed in the current tenant context",
493                            "label.directory.error.multi.tenant.operationNotAllowed", null);
494                }
495            }
496        }
497
498        // collect fields to update
499        for (String fieldName : schemaFieldMap.keySet()) {
500            if (fieldName.equals(idField)) {
501                continue;
502            }
503            if (!dataModel.isDirty(fieldName)) {
504                continue;
505            }
506            if (getDirectory().isReference(fieldName)) {
507                referenceFieldList.add(fieldName);
508            } else {
509                storedColumnList.add(table.getColumn(fieldName));
510            }
511        }
512
513        if (!storedColumnList.isEmpty()) {
514            // update stored fields
515            // String whereString = StringUtils.join(
516            // storedFieldPredicateList.iterator(), ", ");
517            // String sql = String.format("UPDATE %s SET %s WHERE %s = ?",
518            // tableName, whereString,
519            // primaryColumn);
520
521            Update update = new Update(table);
522            update.setUpdatedColumns(storedColumnList);
523            String whereString = table.getPrimaryColumn().getQuotedName() + " = ?";
524            update.setWhere(whereString);
525            String sql = update.getStatement();
526
527            if (logger.isLogEnabled()) {
528                List<Serializable> values = new ArrayList<>(storedColumnList.size());
529                for (Column column : storedColumnList) {
530                    Object value = dataModel.getData(column.getKey());
531                    values.add((Serializable) value);
532                }
533                values.add(docModel.getId());
534                logger.logSQL(sql, values);
535            }
536
537            PreparedStatement ps = null;
538            try {
539                ps = sqlConnection.prepareStatement(sql);
540
541                int index = 1;
542                // TODO: how can I reset dirty fields?
543                for (Column column : storedColumnList) {
544                    Object value = dataModel.getData(column.getKey());
545                    setFieldValue(ps, index, column, value);
546                    index++;
547                }
548                setFieldValue(ps, index, table.getPrimaryColumn(), docModel.getId());
549                ps.execute();
550            } catch (SQLException e) {
551                checkConcurrentUpdate(e);
552                throw new DirectoryException("updateEntry failed for " + docModel.getId(), e);
553            } finally {
554                try {
555                    if (ps != null) {
556                        ps.close();
557                    }
558                } catch (SQLException sqle) {
559                    throw new DirectoryException(sqle);
560                }
561            }
562        }
563
564        // update reference fields
565        for (String referenceFieldName : referenceFieldList) {
566            List<Reference> references = directory.getReferences(referenceFieldName);
567            if (references.size() > 1) {
568                // not supported
569                log.warn("Directory " + getDirectory().getName() + " cannot update field " + referenceFieldName
570                        + " for entry " + docModel.getId() + ": this field is associated with more than one reference");
571            } else {
572                Reference reference = references.get(0);
573                @SuppressWarnings("unchecked")
574                List<String> targetIds = (List<String>) docModel.getProperty(schemaName, referenceFieldName);
575                if (reference instanceof TableReference) {
576                    // optim: reuse current session
577                    TableReference tableReference = (TableReference) reference;
578                    tableReference.setTargetIdsForSource(docModel.getId(), targetIds, this);
579                } else {
580                    reference.setTargetIdsForSource(docModel.getId(), targetIds);
581                }
582            }
583        }
584        getDirectory().invalidateCaches();
585    }
586
587    @Override
588    public void deleteEntry(DocumentModel docModel) {
589        deleteEntry(docModel.getId());
590    }
591
592    @Override
593    public void deleteEntry(String id) {
594        checkPermission(SecurityConstants.WRITE);
595
596        acquireConnection();
597
598        if (!canDeleteMultiTenantEntry(id)) {
599            throw new OperationNotAllowedException("Operation not allowed in the current tenant context",
600                    "label.directory.error.multi.tenant.operationNotAllowed", null);
601        }
602
603        // first step: remove references for this entry
604        for (Reference reference : getDirectory().getReferences()) {
605            if (reference instanceof TableReference) {
606                // optim: reuse current session
607                TableReference tableReference = (TableReference) reference;
608                tableReference.removeLinksForSource(id, this);
609            } else {
610                reference.removeLinksForSource(id);
611            }
612        }
613
614        // second step: clean stored fields
615        PreparedStatement ps = null;
616        try {
617            Delete delete = new Delete(table);
618            String whereString = table.getPrimaryColumn().getQuotedName() + " = ?";
619            delete.setWhere(whereString);
620            String sql = delete.getStatement();
621            if (logger.isLogEnabled()) {
622                logger.logSQL(sql, Collections.<Serializable> singleton(id));
623            }
624            ps = sqlConnection.prepareStatement(sql);
625            setFieldValue(ps, 1, table.getPrimaryColumn(), id);
626            ps.execute();
627        } catch (SQLException e) {
628            checkConcurrentUpdate(e);
629            throw new DirectoryException("deleteEntry failed", e);
630        } finally {
631            try {
632                if (ps != null) {
633                    ps.close();
634                }
635            } catch (SQLException sqle) {
636                throw new DirectoryException(sqle);
637            }
638        }
639        getDirectory().invalidateCaches();
640    }
641
642    protected boolean canDeleteMultiTenantEntry(String entryId) throws DirectoryException {
643        if (isMultiTenant()) {
644            // can only delete entry from the current tenant
645            String tenantId = getCurrentTenantId();
646            if (!StringUtils.isBlank(tenantId)) {
647                DocumentModel entry = getEntry(entryId);
648                DataModel dataModel = entry.getDataModel(schemaName);
649                String entryTenantId = (String) dataModel.getValue(TENANT_ID_FIELD);
650                if (StringUtils.isBlank(entryTenantId) || !entryTenantId.equals(tenantId)) {
651                    if (log.isDebugEnabled()) {
652                        log.debug(String.format("Trying to delete entry '%s' not part of current tenant '%s'", entryId,
653                                tenantId));
654                    }
655                    return false;
656                }
657            }
658        }
659        return true;
660    }
661
662    @Override
663    public void deleteEntry(String id, Map<String, String> map) throws DirectoryException {
664        checkPermission(SecurityConstants.WRITE);
665        acquireConnection();
666
667        if (!canDeleteMultiTenantEntry(id)) {
668            throw new DirectoryException("Operation not allowed in the current tenant context");
669        }
670
671        // Assume in this case that there are no References to this entry.
672        PreparedStatement ps = null;
673        try {
674            Delete delete = new Delete(table);
675            StringBuilder whereClause = new StringBuilder();
676            List<Serializable> values = new ArrayList<>(1 + map.size());
677
678            whereClause.append(table.getPrimaryColumn().getQuotedName());
679            whereClause.append(" = ?");
680            values.add(id);
681            for (Entry<String, String> e : map.entrySet()) {
682                String key = e.getKey();
683                String value = e.getValue();
684                whereClause.append(" AND ");
685                Column col = table.getColumn(key);
686                if (col == null) {
687                    throw new IllegalArgumentException("Unknown column " + key);
688                }
689                whereClause.append(col.getQuotedName());
690                if (value == null) {
691                    whereClause.append(" IS NULL");
692                } else {
693                    whereClause.append(" = ?");
694                    values.add(value);
695                }
696            }
697            delete.setWhere(whereClause.toString());
698            String sql = delete.getStatement();
699
700            if (logger.isLogEnabled()) {
701                logger.logSQL(sql, values);
702            }
703
704            ps = sqlConnection.prepareStatement(sql);
705            for (int i = 0; i < values.size(); i++) {
706                if (i == 0) {
707                    setFieldValue(ps, 1, table.getPrimaryColumn(), values.get(i));
708                } else {
709                    ps.setString(1 + i, (String) values.get(i));
710                }
711            }
712            ps.execute();
713        } catch (SQLException e) {
714            checkConcurrentUpdate(e);
715            throw new DirectoryException("deleteEntry failed", e);
716        } finally {
717            try {
718                if (ps != null) {
719                    ps.close();
720                }
721            } catch (SQLException sqle) {
722                throw new DirectoryException(sqle);
723            }
724        }
725        getDirectory().invalidateCaches();
726    }
727
728    @Override
729    public DocumentModelList query(Map<String, Serializable> filter, Set<String> fulltext, Map<String, String> orderBy) {
730        // XXX not fetch references by default: breaks current behavior
731        return query(filter, fulltext, orderBy, false);
732    }
733
734    @Override
735    public DocumentModelList query(Map<String, Serializable> filter, Set<String> fulltext, Map<String, String> orderBy,
736            boolean fetchReferences) {
737        return query(filter, fulltext, orderBy, fetchReferences, -1, -1);
738    }
739
740    @Override
741    public DocumentModelList query(Map<String, Serializable> filter, Set<String> fulltext, Map<String, String> orderBy,
742            boolean fetchReferences, int limit, int offset) throws DirectoryException {
743        if (!hasPermission(SecurityConstants.READ)) {
744            return new DocumentModelListImpl();
745        }
746        acquireConnection();
747        Map<String, Object> filterMap = new LinkedHashMap<>(filter);
748
749        if (isMultiTenant()) {
750            // filter entries on the tenantId field also
751            String tenantId = getCurrentTenantId();
752            if (!StringUtils.isBlank(tenantId)) {
753                filterMap.put(TENANT_ID_FIELD, tenantId);
754            }
755        }
756
757        try {
758            // build count query statement
759            StringBuilder whereClause = new StringBuilder();
760            String separator = "";
761            List<Column> orderedColumns = new LinkedList<>();
762            for (String columnName : filterMap.keySet()) {
763
764                if (getDirectory().isReference(columnName)) {
765                    log.warn(columnName + " is a reference and will be ignored" + " as a query criterion");
766                    continue;
767                }
768
769                Object value = filterMap.get(columnName);
770                Column column = table.getColumn(columnName);
771                if (null == column) {
772                    // this might happen if we have a case like a chain
773                    // selection and a directory without parent column
774                    throw new DirectoryException("cannot find column '" + columnName + "' for table: " + table);
775                }
776                String leftSide = column.getQuotedName();
777                String rightSide = "?";
778                String operator;
779                boolean substring = fulltext != null && fulltext.contains(columnName);
780                if ("".equals(value) && dialect.hasNullEmptyString() && !substring) {
781                    // see NXP-6172, empty values are Null in Oracle
782                    value = null;
783                }
784                if (value != null) {
785                    if (value instanceof SQLComplexFilter) {
786                        SQLComplexFilter complexFilter = (SQLComplexFilter) value;
787                        operator = complexFilter.getOperator();
788                        rightSide = complexFilter.getRightSide();
789                    } else if (substring) {
790                        // NB : remove double % in like query NXGED-833
791                        String searchedValue = null;
792                        switch (substringMatchType) {
793                        case subany:
794                            searchedValue = '%' + String.valueOf(value).toLowerCase() + '%';
795                            break;
796                        case subinitial:
797                            searchedValue = String.valueOf(value).toLowerCase() + '%';
798                            break;
799                        case subfinal:
800                            searchedValue = '%' + String.valueOf(value).toLowerCase();
801                            break;
802                        }
803                        filterMap.put(columnName, searchedValue);
804                        if (dialect.supportsIlike()) {
805                            operator = " ILIKE "; // postgresql rules
806                        } else {
807                            leftSide = "LOWER(" + leftSide + ')';
808                            operator = " LIKE ";
809                        }
810                    } else {
811                        operator = " = ";
812                    }
813                } else {
814                    operator = " IS NULL";
815                }
816                whereClause.append(separator).append(leftSide).append(operator);
817                if (value != null) {
818                    whereClause.append(rightSide);
819                    orderedColumns.add(column);
820                }
821                separator = " AND ";
822            }
823
824            int queryLimitSize = getDirectory().getDescriptor().getQuerySizeLimit();
825            boolean trucatedResults = false;
826            if (queryLimitSize != 0 && (limit <= 0 || limit > queryLimitSize)) {
827                PreparedStatement ps = null;
828                try {
829                    // create a preparedStatement for counting and bind the
830                    // values
831                    // String countQuery = new StringBuilder("SELECT count(*)
832                    // FROM ")
833                    // .append(table.getQuotedName(dialect)).append(whereClause).toString();
834                    Select select = new Select(table);
835                    select.setWhat("count(*)");
836                    select.setFrom(table.getQuotedName());
837
838                    String where = whereClause.toString();
839                    where = addFilterWhereClause(where);
840                    select.setWhere(where);
841
842                    String countQuery = select.getStatement();
843                    ps = sqlConnection.prepareStatement(countQuery);
844                    fillPreparedStatementFields(filterMap, orderedColumns, ps);
845
846                    ResultSet rs = ps.executeQuery();
847                    rs.next();
848                    int count = rs.getInt(1);
849                    rs.close();
850                    if (count > queryLimitSize) {
851                        trucatedResults = true;
852                        limit = queryLimitSize;
853                        log.error("Displayed results will be truncated because too many rows in result: " + count);
854                        // throw new SizeLimitExceededException("too many rows in result: " + count);
855                    }
856                } finally {
857                    if (ps != null) {
858                        ps.close();
859                    }
860                }
861            }
862
863            // create a preparedStatement and bind the values
864            // String query = new StringBuilder("SELECT * FROM
865            // ").append(tableName).append(
866            // whereClause).toString();
867
868            Select select = new Select(table);
869            select.setWhat("*");
870            select.setFrom(table.getQuotedName());
871
872            String where = whereClause.toString();
873            where = addFilterWhereClause(where);
874            select.setWhere(where);
875
876            StringBuilder orderby = new StringBuilder(128);
877            if (orderBy != null) {
878                for (Iterator<Map.Entry<String, String>> it = orderBy.entrySet().iterator(); it.hasNext();) {
879                    Entry<String, String> entry = it.next();
880                    orderby.append(dialect.openQuote())
881                           .append(entry.getKey())
882                           .append(dialect.closeQuote())
883                           .append(' ')
884                           .append(entry.getValue());
885                    if (it.hasNext()) {
886                        orderby.append(',');
887                    }
888                }
889            }
890            select.setOrderBy(orderby.toString());
891            String query = select.getStatement();
892            boolean manualLimitOffset;
893            if (limit <= 0) {
894                manualLimitOffset = false;
895            } else {
896                if (offset < 0) {
897                    offset = 0;
898                }
899                if (dialect.supportsPaging()) {
900                    query = dialect.addPagingClause(query, limit, offset);
901                    manualLimitOffset = false;
902                } else {
903                    manualLimitOffset = true;
904                }
905            }
906
907            if (logger.isLogEnabled()) {
908                List<Serializable> values = new ArrayList<>(orderedColumns.size());
909                for (Column column : orderedColumns) {
910                    Object value = filterMap.get(column.getKey());
911                    values.add((Serializable) value);
912                }
913                addFilterValuesForLog(values);
914                logger.logSQL(query, values);
915            }
916
917            PreparedStatement ps = null;
918            try {
919                ps = sqlConnection.prepareStatement(query);
920                fillPreparedStatementFields(filterMap, orderedColumns, ps);
921
922                // execute the query and create a documentModel list
923                ResultSet rs = ps.executeQuery();
924                DocumentModelList list = new DocumentModelListImpl();
925                while (rs.next()) {
926
927                    // fetch values for stored fields
928                    Map<String, Object> map = new HashMap<>();
929                    for (String fieldName : storedFieldNames) {
930                        Object o = getFieldValue(rs, fieldName);
931                        map.put(fieldName, o);
932                    }
933
934                    DocumentModel docModel = fieldMapToDocumentModel(map);
935
936                    // fetch the reference fields
937                    if (fetchReferences) {
938                        Map<String, List<String>> targetIdsMap = new HashMap<>();
939                        for (Reference reference : directory.getReferences()) {
940                            List<String> targetIds = reference.getTargetIdsForSource(docModel.getId());
941                            String fieldName = reference.getFieldName();
942                            if (targetIdsMap.containsKey(fieldName)) {
943                                targetIdsMap.get(fieldName).addAll(targetIds);
944                            } else {
945                                targetIdsMap.put(fieldName, targetIds);
946                            }
947                        }
948                        for (Entry<String, List<String>> en : targetIdsMap.entrySet()) {
949                            String fieldName = en.getKey();
950                            List<String> targetIds = en.getValue();
951                            docModel.setProperty(schemaName, fieldName, targetIds);
952                        }
953                    }
954                    list.add(docModel);
955                }
956                rs.close();
957                if (manualLimitOffset) {
958                    int totalSize = list.size();
959                    if (offset > 0) {
960                        if (offset >= totalSize) {
961                            list = new DocumentModelListImpl();
962                        } else {
963                            list = new DocumentModelListImpl(list.subList(offset, totalSize));
964                        }
965                    }
966                    if (list.size() > limit) { // list.size() not totalSize, we may have an offset already
967                        list = new DocumentModelListImpl(list.subList(0, limit));
968                    }
969                    ((DocumentModelListImpl) list).setTotalSize(totalSize);
970                }
971                if (trucatedResults) {
972                    ((DocumentModelListImpl) list).setTotalSize(-2);
973                }
974                return list;
975            } finally {
976                if (ps != null) {
977                    ps.close();
978                }
979            }
980
981        } catch (SQLException e) {
982            try {
983                sqlConnection.close();
984            } catch (SQLException e1) {
985            }
986            throw new DirectoryException("query failed", e);
987        }
988    }
989
990    protected void fillPreparedStatementFields(Map<String, Object> filterMap, List<Column> orderedColumns,
991            PreparedStatement ps) throws DirectoryException {
992        int index = 1;
993        for (Column column : orderedColumns) {
994            Object value = filterMap.get(column.getKey());
995
996            if (value instanceof SQLComplexFilter) {
997                index = ((SQLComplexFilter) value).setFieldValue(ps, index, column);
998            } else {
999                setFieldValue(ps, index, column, value);
1000                index++;
1001            }
1002        }
1003        addFilterValues(ps, index);
1004    }
1005
1006    @Override
1007    public DocumentModelList query(Map<String, Serializable> filter) {
1008        return query(filter, emptySet);
1009    }
1010
1011    private Object getFieldValue(ResultSet rs, String fieldName) throws DirectoryException {
1012        try {
1013            Column column = table.getColumn(fieldName);
1014            if (column == null) {
1015                throw new DirectoryException(String.format("Column '%s' does not exist in table '%s'", fieldName,
1016                        table.getKey()));
1017            }
1018            int index = rs.findColumn(column.getPhysicalName());
1019            return column.getFromResultSet(rs, index);
1020        } catch (SQLException e) {
1021            throw new DirectoryException("getFieldValue failed", e);
1022        }
1023    }
1024
1025    private void setFieldValue(PreparedStatement ps, int index, Column column, Object value) throws DirectoryException {
1026        try {
1027            column.setToPreparedStatement(ps, index, fieldValueForWrite(value, column));
1028        } catch (SQLException e) {
1029            throw new DirectoryException("setFieldValue failed", e);
1030        }
1031    }
1032
1033    protected Serializable fieldValueForWrite(Object value, Column column) {
1034        ColumnSpec spec = column.getType().spec;
1035        if (value instanceof String) {
1036            if (spec == ColumnSpec.LONG || spec == ColumnSpec.AUTOINC) {
1037                // allow storing string into integer/long key
1038                return Long.valueOf((String) value);
1039            }
1040            if (column.getKey().equals(passwordField)) {
1041                // hash password if not already hashed
1042                String password = (String) value;
1043                if (!PasswordHelper.isHashed(password)) {
1044                    password = PasswordHelper.hashPassword(password, passwordHashAlgorithm);
1045                }
1046                return password;
1047            }
1048        } else if (value instanceof Number) {
1049            if (spec == ColumnSpec.LONG || spec == ColumnSpec.AUTOINC) {
1050                // canonicalize to Long
1051                if (value instanceof Integer) {
1052                    return Long.valueOf(((Integer) value).longValue());
1053                }
1054            } else if (spec == ColumnSpec.STRING) {
1055                // allow storing number in string field
1056                return value.toString();
1057            }
1058        }
1059        return (Serializable) value;
1060    }
1061
1062    @Override
1063    public void close() throws DirectoryException {
1064        try {
1065            if (!sqlConnection.isClosed()) {
1066                sqlConnection.close();
1067            }
1068        } catch (SQLException e) {
1069            throw new DirectoryException("close failed", e);
1070        } finally {
1071            getDirectory().removeSession(this);
1072        }
1073    }
1074
1075    /**
1076     * Enable connection status checking on SQL directory connections
1077     *
1078     * @since 5.7.2
1079     */
1080    public boolean isLive() throws DirectoryException {
1081        try {
1082            return !sqlConnection.isClosed();
1083        } catch (SQLException e) {
1084            throw new DirectoryException("Cannot check connection status of " + this, e);
1085        }
1086    }
1087
1088    @Override
1089    public List<String> getProjection(Map<String, Serializable> filter, Set<String> fulltext, String columnName) {
1090        DocumentModelList docList = query(filter, fulltext);
1091        List<String> result = new ArrayList<>();
1092        for (DocumentModel docModel : docList) {
1093            Object obj = docModel.getProperty(schemaName, columnName);
1094            String propValue;
1095            if (obj instanceof String) {
1096                propValue = (String) obj;
1097            } else {
1098                propValue = String.valueOf(obj);
1099            }
1100            result.add(propValue);
1101        }
1102        return result;
1103    }
1104
1105    @Override
1106    public List<String> getProjection(Map<String, Serializable> filter, String columnName) {
1107        return getProjection(filter, emptySet, columnName);
1108    }
1109
1110    @Override
1111    public boolean authenticate(String username, String password) {
1112        DocumentModel entry = getEntry(username);
1113        if (entry == null) {
1114            return false;
1115        }
1116        String storedPassword = (String) entry.getProperty(schemaName, getPasswordField());
1117        return PasswordHelper.verifyPassword(password, storedPassword);
1118    }
1119
1120    @Override
1121    public boolean isAuthenticating() {
1122        return schemaFieldMap.containsKey(getPasswordField());
1123    }
1124
1125    @Override
1126    public DocumentModelList query(Map<String, Serializable> filter, Set<String> fulltext) {
1127        return query(filter, fulltext, new HashMap<String, String>());
1128    }
1129
1130    @Override
1131    public DocumentModel createEntry(DocumentModel entry) {
1132        Map<String, Object> fieldMap = entry.getProperties(schemaName);
1133        return createEntry(fieldMap);
1134    }
1135
1136    @Override
1137    public boolean hasEntry(String id) {
1138        acquireConnection();
1139        Select select = new Select(table);
1140        select.setFrom(table.getQuotedName());
1141        select.setWhat("*");
1142        select.setWhere(table.getPrimaryColumn().getQuotedName() + " = ?");
1143        String sql = select.getStatement();
1144
1145        if (logger.isLogEnabled()) {
1146            logger.logSQL(sql, Collections.<Serializable> singleton(id));
1147        }
1148
1149        PreparedStatement ps = null;
1150        ResultSet rs = null;
1151        try {
1152            ps = sqlConnection.prepareStatement(sql);
1153            setFieldValue(ps, 1, table.getPrimaryColumn(), id);
1154            rs = ps.executeQuery();
1155            return rs.next();
1156        } catch (SQLException e) {
1157            throw new DirectoryException("hasEntry failed", e);
1158        } finally {
1159            try {
1160                if (rs != null) {
1161                    rs.close();
1162                }
1163                if (ps != null) {
1164                    ps.close();
1165                }
1166            } catch (SQLException sqle) {
1167                throw new DirectoryException(sqle);
1168            }
1169        }
1170    }
1171
1172    /**
1173     * Public getter to allow custom {@link Reference} implementation to access the current connection even if it lives
1174     * in a separate java package, typically: com.company.custom.nuxeo.project.MyCustomReference
1175     *
1176     * @return the current {@link Connection} instance
1177     */
1178    public Connection getSqlConnection() {
1179        return sqlConnection;
1180    }
1181
1182    /**
1183     * Returns {@code true} if this directory supports multi tenancy, {@code false} otherwise.
1184     */
1185    protected boolean isMultiTenant() {
1186        return directory.isMultiTenant();
1187    }
1188
1189    /**
1190     * Returns the tenant id of the logged user if any, {@code null} otherwise.
1191     */
1192    protected String getCurrentTenantId() {
1193        NuxeoPrincipal principal = ClientLoginModule.getCurrentPrincipal();
1194        return principal != null ? principal.getTenantId() : null;
1195    }
1196
1197    @Override
1198    public String toString() {
1199        return "SQLSession [directory=" + directory.getName() + ", sid=" + sid + "]";
1200    }
1201
1202}