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