001/*
002 * (C) Copyright 2006-2018 Nuxeo (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Florent Guillaume
018 *
019 * $Id: MultiDirectorySession.java 29556 2008-01-23 00:59:39Z jcarsique $
020 */
021
022package org.nuxeo.ecm.directory.multi;
023
024import java.io.Serializable;
025import java.util.ArrayList;
026import java.util.Collections;
027import java.util.HashMap;
028import java.util.HashSet;
029import java.util.List;
030import java.util.Map;
031import java.util.Map.Entry;
032import java.util.Set;
033
034import org.apache.commons.lang3.StringUtils;
035import org.apache.commons.logging.Log;
036import org.apache.commons.logging.LogFactory;
037import org.nuxeo.ecm.core.api.DocumentModel;
038import org.nuxeo.ecm.core.api.DocumentModelList;
039import org.nuxeo.ecm.core.api.PropertyException;
040import org.nuxeo.ecm.core.api.impl.DocumentModelListImpl;
041import org.nuxeo.ecm.core.api.security.SecurityConstants;
042import org.nuxeo.ecm.core.query.sql.model.OrderByList;
043import org.nuxeo.ecm.core.query.sql.model.QueryBuilder;
044import org.nuxeo.ecm.core.schema.SchemaManager;
045import org.nuxeo.ecm.core.schema.types.Field;
046import org.nuxeo.ecm.core.schema.types.Schema;
047import org.nuxeo.ecm.directory.AbstractDirectory;
048import org.nuxeo.ecm.directory.BaseSession;
049import org.nuxeo.ecm.directory.DirectoryException;
050import org.nuxeo.ecm.directory.Session;
051import org.nuxeo.ecm.directory.api.DirectoryService;
052import org.nuxeo.runtime.api.Framework;
053
054/**
055 * Directory session aggregating entries from different sources.
056 * <p>
057 * Each source can build an entry aggregating fields from one or several directories.
058 *
059 * @author Florent Guillaume
060 * @author Anahide Tchertchian
061 */
062public class MultiDirectorySession extends BaseSession {
063
064    private static final Log log = LogFactory.getLog(MultiDirectorySession.class);
065
066    private final DirectoryService directoryService;
067
068    private final String schemaIdField;
069
070    private List<SourceInfo> sourceInfos;
071
072    public MultiDirectorySession(MultiDirectory directory) {
073        super(directory, null);
074        directoryService = Framework.getService(DirectoryService.class);
075        MultiDirectoryDescriptor descriptor = directory.getDescriptor();
076        schemaIdField = descriptor.idField;
077    }
078
079    @Override
080    public MultiDirectory getDirectory() {
081        return (MultiDirectory) directory;
082    }
083
084    protected class SubDirectoryInfo {
085
086        final String dirName;
087
088        final String dirSchemaName;
089
090        final String idField;
091
092        final boolean isAuthenticating;
093
094        final Map<String, String> fromSource;
095
096        final Map<String, String> toSource;
097
098        final Map<String, Serializable> defaultEntry;
099
100        final boolean isOptional;
101
102        Session session;
103
104        SubDirectoryInfo(String dirName, String dirSchemaName, String idField, boolean isAuthenticating,
105                Map<String, String> fromSource, Map<String, String> toSource, Map<String, Serializable> defaultEntry,
106                boolean isOptional) {
107            this.dirName = dirName;
108            this.dirSchemaName = dirSchemaName;
109            this.idField = idField;
110            this.isAuthenticating = isAuthenticating;
111            this.fromSource = fromSource;
112            this.toSource = toSource;
113            this.defaultEntry = defaultEntry;
114            this.isOptional = isOptional;
115        }
116
117        /** Gets the {@link Session} associated to this subdirectory; the session MUST NOT be closed. */
118        Session getSession() {
119            if (session == null) {
120                session = directoryService.open(dirName);
121            }
122            return session;
123        }
124
125        @Override
126        public String toString() {
127            return String.format("{directory=%s fromSource=%s toSource=%s}", dirName, fromSource, toSource);
128        }
129    }
130
131    protected static class SourceInfo {
132
133        final SourceDescriptor source;
134
135        final List<SubDirectoryInfo> subDirectoryInfos;
136
137        final List<SubDirectoryInfo> requiredSubDirectoryInfos;
138
139        final List<SubDirectoryInfo> optionalSubDirectoryInfos;
140
141        final SubDirectoryInfo authDirectoryInfo;
142
143        SourceInfo(SourceDescriptor source, List<SubDirectoryInfo> subDirectoryInfos, SubDirectoryInfo authDirectoryInfo) {
144            this.source = source;
145            this.subDirectoryInfos = subDirectoryInfos;
146            requiredSubDirectoryInfos = new ArrayList<>();
147            optionalSubDirectoryInfos = new ArrayList<>();
148            for (SubDirectoryInfo subDirInfo : subDirectoryInfos) {
149                if (subDirInfo.isOptional) {
150                    optionalSubDirectoryInfos.add(subDirInfo);
151                } else {
152                    requiredSubDirectoryInfos.add(subDirInfo);
153                }
154            }
155            this.authDirectoryInfo = authDirectoryInfo;
156        }
157
158        @Override
159        public String toString() {
160            return String.format("{source=%s infos=%s}", source.name, subDirectoryInfos);
161        }
162    }
163
164    private void init() {
165        if (sourceInfos == null) {
166            recomputeSourceInfos();
167        }
168    }
169
170    /**
171     * Recomputes all the info needed for efficient access.
172     */
173    private void recomputeSourceInfos() {
174        SchemaManager schemaManager = Framework.getService(SchemaManager.class);
175        final Schema schema = schemaManager.getSchema(schemaName);
176        if (schema == null) {
177            throw new DirectoryException(String.format("Directory '%s' has unknown schema '%s'", getName(),
178                    schemaName));
179        }
180        final Set<String> sourceFields = new HashSet<>();
181        for (Field f : schema.getFields()) {
182            sourceFields.add(f.getName().getLocalName());
183        }
184        if (!sourceFields.contains(schemaIdField)) {
185            throw new DirectoryException(String.format("Directory '%s' schema '%s' has no id field '%s'",
186                    getName(), schemaName, schemaIdField));
187        }
188
189        List<SourceInfo> newSourceInfos = new ArrayList<>(2);
190        for (SourceDescriptor source : getDirectory().getDescriptor().sources) {
191            int ndirs = source.subDirectories.length;
192            if (ndirs == 0) {
193                throw new DirectoryException(String.format("Directory '%s' source '%s' has no subdirectories",
194                        getName(), source.name));
195            }
196
197            final List<SubDirectoryInfo> subDirectoryInfos = new ArrayList<>(ndirs);
198
199            SubDirectoryInfo authDirectoryInfo = null;
200            boolean hasRequiredDir = false;
201            for (SubDirectoryDescriptor subDir : source.subDirectories) {
202                final String dirName = subDir.name;
203                final String dirSchemaName = directoryService.getDirectorySchema(dirName);
204                final String dirIdField = directoryService.getDirectoryIdField(dirName);
205                final boolean dirIsAuth = directoryService.getDirectoryPasswordField(dirName) != null;
206                final Map<String, String> fromSource = new HashMap<>();
207                final Map<String, String> toSource = new HashMap<>();
208                final Map<String, Serializable> defaultEntry = new HashMap<>();
209                final boolean dirIsOptional = subDir.isOptional;
210
211                // XXX check authenticating
212                final Schema dirSchema = schemaManager.getSchema(dirSchemaName);
213                if (dirSchema == null) {
214                    throw new DirectoryException(String.format("Directory '%s' source '%s' subdirectory '%s' "
215                            + "has unknown schema '%s'", getName(), source.name, dirName, dirSchemaName));
216                }
217                // record default field mappings if same name and record default
218                // values
219                final Set<String> dirSchemaFields = new HashSet<>();
220                for (Field f : dirSchema.getFields()) {
221                    final String fieldName = f.getName().getLocalName();
222                    dirSchemaFields.add(fieldName);
223                    if (sourceFields.contains(fieldName)) {
224                        // XXX check no duplicates!
225                        fromSource.put(fieldName, fieldName);
226                        toSource.put(fieldName, fieldName);
227                    }
228                    // XXX cast to Serializable
229                    defaultEntry.put(fieldName, (Serializable) f.getDefaultValue());
230                }
231                // treat renamings
232                // XXX id field ?
233                for (FieldDescriptor field : subDir.fields) {
234                    final String sourceFieldName = field.forField;
235                    final String fieldName = field.name;
236                    if (!sourceFields.contains(sourceFieldName)) {
237                        throw new DirectoryException(String.format("Directory '%s' source '%s' subdirectory '%s' "
238                                + "has mapping for unknown field '%s'", getName(), source.name, dirName,
239                                sourceFieldName));
240                    }
241                    if (!dirSchemaFields.contains(fieldName)) {
242                        throw new DirectoryException(String.format("Directory '%s' source '%s' subdirectory '%s' "
243                                + "has mapping of unknown field' '%s'", getName(), source.name, dirName,
244                                fieldName));
245                    }
246                    fromSource.put(sourceFieldName, fieldName);
247                    toSource.put(fieldName, sourceFieldName);
248                }
249                SubDirectoryInfo subDirectoryInfo = new SubDirectoryInfo(dirName, dirSchemaName, dirIdField, dirIsAuth,
250                        fromSource, toSource, defaultEntry, dirIsOptional);
251                subDirectoryInfos.add(subDirectoryInfo);
252
253                if (dirIsAuth) {
254                    if (authDirectoryInfo != null) {
255                        throw new DirectoryException(String.format("Directory '%s' source '%s' has two subdirectories "
256                                + "with a password field, '%s' and '%s'", getName(), source.name,
257                                authDirectoryInfo.dirName, dirName));
258                    }
259                    authDirectoryInfo = subDirectoryInfo;
260                }
261                if (!dirIsOptional) {
262                    hasRequiredDir = true;
263                }
264            }
265            if (isAuthenticating() && authDirectoryInfo == null) {
266                throw new DirectoryException(String.format("Directory '%s' source '%s' has no subdirectory "
267                        + "with a password field", getName(), source.name));
268            }
269            if (!hasRequiredDir) {
270                throw new DirectoryException(String.format(
271                        "Directory '%s' source '%s' only has optional subdirectories: "
272                                + "no directory can be used has a reference.", getName(), source.name));
273            }
274            newSourceInfos.add(new SourceInfo(source, subDirectoryInfos, authDirectoryInfo));
275        }
276        sourceInfos = newSourceInfos;
277    }
278
279    @Override
280    public void close() {
281        try {
282            if (sourceInfos == null) {
283                return;
284            }
285            DirectoryException exc = null;
286            for (SourceInfo sourceInfo : sourceInfos) {
287                for (SubDirectoryInfo subDirectoryInfo : sourceInfo.subDirectoryInfos) {
288                    Session session = subDirectoryInfo.session;
289                    subDirectoryInfo.session = null;
290                    if (session != null) {
291                        try {
292                            session.close();
293                        } catch (DirectoryException e) {
294                            // remember exception, we want to close all session
295                            // first
296                            if (exc == null) {
297                                exc = e;
298                            } else {
299                                // we can't reraise both, log this one
300                                log.error("Error closing directory " + subDirectoryInfo.dirName, e);
301                            }
302                        }
303                    }
304                }
305                if (exc != null) {
306                    throw exc;
307                }
308            }
309        } finally {
310            getDirectory().removeSession(this);
311        }
312    }
313
314    public String getName() {
315        return directory.getName();
316    }
317
318    @Override
319    public boolean authenticate(String username, String password) {
320        init();
321        for (SourceInfo sourceInfo : sourceInfos) {
322            for (SubDirectoryInfo dirInfo : sourceInfo.subDirectoryInfos) {
323                if (!dirInfo.isAuthenticating) {
324                    continue;
325                }
326                if (dirInfo.getSession().authenticate(username, password)) {
327                    return true;
328                }
329                if (dirInfo.isOptional && dirInfo.getSession().getEntry(username) == null) {
330                    // check if given password equals to default value
331                    String passwordField = dirInfo.getSession().getPasswordField();
332                    String defaultPassword = (String) dirInfo.defaultEntry.get(passwordField);
333                    if (defaultPassword != null && defaultPassword.equals(password)) {
334                        return true;
335                    }
336                }
337            }
338        }
339        return false;
340    }
341
342    @Override
343    public DocumentModel getEntry(String id, boolean fetchReferences) {
344        if (!hasPermission(SecurityConstants.READ)) {
345            return null;
346        }
347        init();
348        String entryId = id;
349        source_loop: for (SourceInfo sourceInfo : sourceInfos) {
350            boolean isReadOnlyEntry = true;
351            final Map<String, Object> map = new HashMap<>();
352
353            for (SubDirectoryInfo dirInfo : sourceInfo.subDirectoryInfos) {
354                final DocumentModel entry = dirInfo.getSession().getEntry(id, fetchReferences);
355                boolean isOptional = dirInfo.isOptional;
356                if (entry == null && !isOptional) {
357                    // not in this source
358                    continue source_loop;
359                }
360                if (entry != null && !isReadOnlyEntry(entry)) {
361                    // set readonly to false if at least one source is writable
362                    isReadOnlyEntry = false;
363                }
364                if (entry == null && isOptional && !dirInfo.getSession().isReadOnly()) {
365                    // set readonly to false if null entry is from optional and writable directory
366                    isReadOnlyEntry = false;
367                }
368                if (entry != null && StringUtils.isNotBlank(entry.getId())) {
369                    entryId = entry.getId();
370                }
371                String passwordField = dirInfo.getSession().getPasswordField();
372                for (Entry<String, String> e : dirInfo.toSource.entrySet()) {
373                    String dirProp = e.getKey();
374                    if (dirProp.equals(passwordField)) {
375                        // subdirectory entry are already returned without password
376                        // but a default schema value could still be returned
377                        continue;
378                    }
379                    String prop = e.getValue();
380                    if (entry != null) {
381                        try {
382                            map.put(prop, entry.getProperty(dirInfo.dirSchemaName, dirProp));
383                        } catch (PropertyException e1) {
384                            throw new DirectoryException(e1);
385                        }
386                    } else {
387                        // fill with default values for this directory
388                        if (!map.containsKey(prop)) {
389                            map.put(prop, dirInfo.defaultEntry.get(dirProp));
390                        }
391                    }
392                }
393            }
394            // force the entry in readonly if it's defined on the multidirectory
395            if (isReadOnly()) {
396                isReadOnlyEntry = true;
397            }
398            // ok we have the data
399            try {
400                return BaseSession.createEntryModel(null, schemaName, entryId, map, isReadOnlyEntry);
401            } catch (PropertyException e) {
402                throw new DirectoryException(e);
403            }
404        }
405        return null;
406    }
407
408    @Override
409    @SuppressWarnings("boxing")
410    public DocumentModelList getEntries() {
411        if (!hasPermission(SecurityConstants.READ)) {
412            return new DocumentModelListImpl();
413        }
414        init();
415
416        // list of entries
417        final DocumentModelList results = new DocumentModelListImpl();
418        // entry ids already seen (mapped to the source name)
419        final Map<String, String> seen = new HashMap<>();
420        Set<String> readOnlyEntries = new HashSet<>();
421
422        for (SourceInfo sourceInfo : sourceInfos) {
423            // accumulated map for each entry
424            final Map<String, Map<String, Object>> maps = new HashMap<>();
425            // number of dirs seen for each entry
426            final Map<String, Integer> counts = new HashMap<>();
427            for (SubDirectoryInfo dirInfo : sourceInfo.requiredSubDirectoryInfos) {
428                final DocumentModelList entries = dirInfo.getSession().getEntries();
429                for (DocumentModel entry : entries) {
430                    final String id = entry.getId();
431                    // find or create map for this entry
432                    Map<String, Object> map = maps.get(id);
433                    if (map == null) {
434                        map = new HashMap<>();
435                        maps.put(id, map);
436                        counts.put(id, 1);
437                    } else {
438                        counts.put(id, counts.get(id) + 1);
439                    }
440                    // put entry data in map
441                    for (Entry<String, String> e : dirInfo.toSource.entrySet()) {
442                        map.put(e.getValue(), entry.getProperty(dirInfo.dirSchemaName, e.getKey()));
443                    }
444                    if (BaseSession.isReadOnlyEntry(entry)) {
445                        readOnlyEntries.add(id);
446                    }
447                }
448            }
449            for (SubDirectoryInfo dirInfo : sourceInfo.optionalSubDirectoryInfos) {
450                final DocumentModelList entries = dirInfo.getSession().getEntries();
451                Set<String> existingIds = new HashSet<>();
452                for (DocumentModel entry : entries) {
453                    final String id = entry.getId();
454                    final Map<String, Object> map = maps.get(id);
455                    if (map != null) {
456                        existingIds.add(id);
457                        // put entry data in map
458                        for (Entry<String, String> e : dirInfo.toSource.entrySet()) {
459                            map.put(e.getValue(), entry.getProperty(dirInfo.dirSchemaName, e.getKey()));
460                        }
461                    } else {
462                        log.warn(String.format("Entry '%s' for source '%s' is present in optional directory '%s' "
463                                + "but not in any required one. " + "It will be skipped.", id, sourceInfo.source.name,
464                                dirInfo.dirName));
465                    }
466                }
467                for (Entry<String, Map<String, Object>> mapEntry : maps.entrySet()) {
468                    if (!existingIds.contains(mapEntry.getKey())) {
469                        final Map<String, Object> map = mapEntry.getValue();
470                        // put entry data in map
471                        for (Entry<String, String> e : dirInfo.toSource.entrySet()) {
472                            // fill with default values for this directory
473                            if (!map.containsKey(e.getValue())) {
474                                map.put(e.getValue(), dirInfo.defaultEntry.get(e.getKey()));
475                            }
476                        }
477                    }
478                }
479            }
480            // now create entries for all full maps
481            int numdirs = sourceInfo.requiredSubDirectoryInfos.size();
482            ((ArrayList<?>) results).ensureCapacity(results.size() + maps.size());
483            for (Entry<String, Map<String, Object>> e : maps.entrySet()) {
484                final String id = e.getKey();
485                if (seen.containsKey(id)) {
486                    log.warn(String.format("Entry '%s' is present in source '%s' but also in source '%s'. "
487                            + "The second one will be ignored.", id, seen.get(id), sourceInfo.source.name));
488                    continue;
489                }
490                final Map<String, Object> map = e.getValue();
491                if (counts.get(id) != numdirs) {
492                    log.warn(String.format("Entry '%s' for source '%s' is not present in all directories. "
493                            + "It will be skipped.", id, sourceInfo.source.name));
494                    continue;
495                }
496                seen.put(id, sourceInfo.source.name);
497                final DocumentModel entry = BaseSession.createEntryModel(null, schemaName, id, map,
498                        readOnlyEntries.contains(id));
499                results.add(entry);
500            }
501        }
502        return results;
503    }
504
505    @Override
506    public DocumentModel createEntryWithoutReferences(Map<String, Object> fieldMap) {
507        init();
508        final Object rawid = fieldMap.get(schemaIdField);
509        if (rawid == null) {
510            throw new DirectoryException(String.format("Entry is missing id field '%s'", schemaIdField));
511        }
512        final String id = String.valueOf(rawid); // XXX allow longs too
513        for (SourceInfo sourceInfo : sourceInfos) {
514            if (!sourceInfo.source.creation) {
515                continue;
516            }
517            for (SubDirectoryInfo dirInfo : sourceInfo.subDirectoryInfos) {
518                Map<String, Object> map = new HashMap<>();
519                map.put(dirInfo.idField, id);
520                for (Entry<String, String> e : dirInfo.fromSource.entrySet()) {
521                    map.put(e.getValue(), fieldMap.get(e.getKey()));
522                }
523                dirInfo.getSession().createEntry(map);
524            }
525            return getEntry(id);
526        }
527        throw new DirectoryException(String.format("Directory '%s' has no source allowing creation", getName()));
528    }
529
530    @Override
531    protected List<String> updateEntryWithoutReferences(DocumentModel docModel) {
532        throw new UnsupportedOperationException();
533    }
534
535    @Override protected void deleteEntryWithoutReferences(String id) {
536        throw new UnsupportedOperationException();
537    }
538
539    @Override
540    public void deleteEntry(DocumentModel docModel) {
541        deleteEntry(docModel.getId());
542    }
543
544    @Override
545    public void deleteEntry(String id) {
546        checkPermission(SecurityConstants.WRITE);
547        checkDeleteConstraints(id);
548        init();
549        for (SourceInfo sourceInfo : sourceInfos) {
550            for (SubDirectoryInfo dirInfo : sourceInfo.subDirectoryInfos) {
551                if (!dirInfo.getSession().isReadOnly()) {
552                    // Check if the platform is able to manage entry
553                    if (!sourceInfo.source.creation) {
554                        // If not check if entry exist to prevent exception that may
555                        // stop the deletion loop to other subdirectories
556                        // Do not raise exception, because creation is not managed
557                        // by the platform
558                        DocumentModel docModel = dirInfo.getSession().getEntry(id);
559                        if (docModel == null) {
560                            log.warn(String.format(
561                                    "MultiDirectory '%s' : The entry id '%s' could not be deleted on subdirectory '%s' because it does not exist",
562                                    getName(), id, dirInfo.dirName));
563                        } else {
564                            dirInfo.getSession().deleteEntry(id);
565                        }
566                    } else {
567                        dirInfo.getSession().deleteEntry(id);
568                    }
569                }
570            }
571        }
572    }
573
574    @Override
575    public void deleteEntry(String id, Map<String, String> map) {
576        log.warn("Calling deleteEntry extended on multi directory");
577        deleteEntry(id);
578    }
579
580    private static void updateSubDirectoryEntry(SubDirectoryInfo dirInfo, Map<String, Object> fieldMap, String id,
581            boolean canCreateIfOptional) {
582        DocumentModel dirEntry = dirInfo.getSession().getEntry(id);
583        if (dirInfo.getSession().isReadOnly() || (dirEntry != null && isReadOnlyEntry(dirEntry))) {
584            return;
585        }
586        if (dirEntry == null && !canCreateIfOptional) {
587            // entry to update doesn't belong to this directory
588            return;
589        }
590        Map<String, Object> map = new HashMap<>();
591        map.put(dirInfo.idField, id);
592        for (Entry<String, String> e : dirInfo.fromSource.entrySet()) {
593            map.put(e.getValue(), fieldMap.get(e.getKey()));
594        }
595        if (map.size() > 1) {
596            if (canCreateIfOptional && dirInfo.isOptional && dirEntry == null) {
597                // if entry does not exist, create it
598                dirInfo.getSession().createEntry(map);
599            } else {
600                final DocumentModel entry = BaseSession.createEntryModel(null, dirInfo.dirSchemaName, id, null);
601                entry.setProperties(dirInfo.dirSchemaName, map);
602                dirInfo.getSession().updateEntry(entry);
603            }
604        }
605    }
606
607    @Override
608    public void updateEntry(DocumentModel docModel) {
609        checkPermission(SecurityConstants.WRITE);
610        if (isReadOnlyEntry(docModel)) {
611            return;
612        }
613        init();
614        final String id = docModel.getId();
615        Map<String, Object> fieldMap = docModel.getProperties(schemaName);
616        for (SourceInfo sourceInfo : sourceInfos) {
617            // check if entry exists in this source, in case it can be created
618            // in optional subdirectories
619            boolean canCreateIfOptional = false;
620            for (SubDirectoryInfo dirInfo : sourceInfo.requiredSubDirectoryInfos) {
621                if (!canCreateIfOptional) {
622                    canCreateIfOptional = dirInfo.getSession().getEntry(id) != null;
623                }
624                updateSubDirectoryEntry(dirInfo, fieldMap, id, false);
625            }
626            for (SubDirectoryInfo dirInfo : sourceInfo.optionalSubDirectoryInfos) {
627                updateSubDirectoryEntry(dirInfo, fieldMap, id, canCreateIfOptional);
628            }
629        }
630    }
631
632    @Override
633    public DocumentModelList query(Map<String, Serializable> filter, Set<String> fulltext, Map<String, String> orderBy,
634            boolean fetchReferences, int limit, int offset) {
635        if (!hasPermission(SecurityConstants.READ)) {
636            return new DocumentModelListImpl();
637        }
638        init();
639
640        // entry ids already seen (mapped to the source name)
641        final Map<String, String> seen = new HashMap<>();
642        if (fulltext == null) {
643            fulltext = Collections.emptySet();
644        }
645        Set<String> readOnlyEntries = new HashSet<>();
646
647        DocumentModelList results = new DocumentModelListImpl();
648        for (SourceInfo sourceInfo : sourceInfos) {
649            // accumulated map for each entry
650            final Map<String, Map<String, Object>> maps = new HashMap<>();
651            // number of dirs seen for each entry
652            final Map<String, Integer> counts;
653            counts = new HashMap<>();
654
655            // list of optional dirs where filter matches default values
656            List<SubDirectoryInfo> optionalDirsMatching = new ArrayList<>();
657            for (SubDirectoryInfo dirInfo : sourceInfo.subDirectoryInfos) {
658                // compute filter
659                final Map<String, Serializable> dirFilter = new HashMap<>();
660                for (Entry<String, Serializable> e : filter.entrySet()) {
661                    final String fieldName = dirInfo.fromSource.get(e.getKey());
662                    if (fieldName == null) {
663                        continue;
664                    }
665                    dirFilter.put(fieldName, e.getValue());
666                }
667                if (dirInfo.isOptional) {
668                    // check if filter matches directory default values
669                    boolean matches = true;
670                    for (Map.Entry<String, Serializable> dirFilterEntry : dirFilter.entrySet()) {
671                        Object defaultValue = dirInfo.defaultEntry.get(dirFilterEntry.getKey());
672                        Object filterValue = dirFilterEntry.getValue();
673                        if (defaultValue == null && filterValue != null) {
674                            matches = false;
675                        } else if (defaultValue != null && !defaultValue.equals(filterValue)) {
676                            matches = false;
677                        }
678                    }
679                    if (matches) {
680                        optionalDirsMatching.add(dirInfo);
681                    }
682                }
683                // compute fulltext
684                Set<String> dirFulltext = new HashSet<>();
685                for (String sourceFieldName : fulltext) {
686                    final String fieldName = dirInfo.fromSource.get(sourceFieldName);
687                    if (fieldName != null) {
688                        dirFulltext.add(fieldName);
689                    }
690                }
691                // make query to subdirectory
692                DocumentModelList l = dirInfo.getSession().query(dirFilter, dirFulltext, null, fetchReferences);
693                for (DocumentModel entry : l) {
694                    final String id = entry.getId();
695                    Map<String, Object> map = maps.get(id);
696                    if (map == null) {
697                        map = new HashMap<>();
698                        maps.put(id, map);
699                        counts.put(id, 1);
700                    } else {
701                        counts.put(id, counts.get(id) + 1);
702                    }
703                    for (Entry<String, String> e : dirInfo.toSource.entrySet()) {
704                        map.put(e.getValue(), entry.getProperty(dirInfo.dirSchemaName, e.getKey()));
705                    }
706                    if (BaseSession.isReadOnlyEntry(entry)) {
707                        readOnlyEntries.add(id);
708                    }
709                }
710            }
711            // add default entry values for optional dirs
712            for (SubDirectoryInfo dirInfo : optionalDirsMatching) {
713                // add entry for every data found in other dirs
714                Set<String> existingIds = new HashSet<>(
715                        dirInfo.getSession().getProjection(Collections.emptyMap(), dirInfo.idField));
716                for (Entry<String, Map<String, Object>> result : maps.entrySet()) {
717                    final String id = result.getKey();
718                    if (!existingIds.contains(id)) {
719                        counts.put(id, counts.get(id) + 1);
720                        final Map<String, Object> map = result.getValue();
721                        for (Entry<String, String> e : dirInfo.toSource.entrySet()) {
722                            String value = e.getValue();
723                            if (!map.containsKey(value)) {
724                                map.put(value, dirInfo.defaultEntry.get(e.getKey()));
725                            }
726                        }
727                    }
728                }
729            }
730            // intersection, ignore entries not in all subdirectories
731            final int numdirs = sourceInfo.subDirectoryInfos.size();
732            maps.keySet().removeIf(id -> counts.get(id) != numdirs);
733            // now create entries
734            ((ArrayList<?>) results).ensureCapacity(results.size() + maps.size());
735            for (Entry<String, Map<String, Object>> e : maps.entrySet()) {
736                final String id = e.getKey();
737                if (seen.containsKey(id)) {
738                    log.warn(String.format("Entry '%s' is present in source '%s' but also in source '%s'. "
739                            + "The second one will be ignored.", id, seen.get(id), sourceInfo.source.name));
740                    continue;
741                }
742                final Map<String, Object> map = e.getValue();
743                seen.put(id, sourceInfo.source.name);
744                final DocumentModel entry = BaseSession.createEntryModel(null, schemaName, id, map,
745                        readOnlyEntries.contains(id));
746                results.add(entry);
747            }
748        }
749        if (orderBy != null && !orderBy.isEmpty()) {
750            getDirectory().orderEntries(results, orderBy);
751        }
752        return applyQueryLimits(results, limit, offset);
753    }
754
755    @Override
756    public DocumentModelList query(QueryBuilder queryBuilder, boolean fetchReferences) {
757        if (!hasPermission(SecurityConstants.READ)) {
758            return new DocumentModelListImpl();
759        }
760        if (FieldDetector.hasField(queryBuilder.predicate(), getPasswordField())) {
761            throw new DirectoryException("Cannot filter on password");
762        }
763        init();
764
765        Map<String, String> sources = new HashMap<>(); // map of id to source
766        DocumentModelList results = new DocumentModelListImpl();
767
768        for (SourceInfo sourceInfo : sourceInfos) {
769
770            // find all ids by evaluating the expression with this source of subdirectories
771            MultiDirectoryExpressionEvaluator evaluator = new MultiDirectoryExpressionEvaluator(sourceInfo,
772                    schemaIdField, getName());
773            Set<String> ids = evaluator.eval(queryBuilder.predicate());
774
775            // create entries from ids
776
777            ((ArrayList<?>) results).ensureCapacity(results.size() + ids.size());
778            // TODO batch fetch entries
779            for (String id : ids) {
780                String otherSource = sources.putIfAbsent(id, sourceInfo.source.name);
781                if (otherSource != null) {
782                    log.warn(String.format("Entry '%s' is present in source '%s' but also in source '%s'. "
783                            + "The second one will be ignored.", id, otherSource, sourceInfo.source.name));
784                    continue;
785                }
786                DocumentModel entry = getEntry(id, fetchReferences);
787                results.add(entry);
788            }
789        }
790
791        // order/limit/offset
792        int limit = Math.max(0, (int) queryBuilder.limit());
793        int offset = Math.max(0, (int) queryBuilder.offset());
794        boolean countTotal = queryBuilder.countTotal();
795        OrderByList orders = queryBuilder.orders();
796        Map<String, String> orderBy = AbstractDirectory.makeOrderBy(orders);
797        if (!orderBy.isEmpty()) {
798            getDirectory().orderEntries(results, orderBy);
799        }
800        results = applyQueryLimits(results, limit, offset);
801        if ((limit != 0 || offset != 0) && !countTotal) {
802            // compat with other directories
803            ((DocumentModelListImpl) results).setTotalSize(-2);
804        }
805        return results;
806    }
807
808    @Override
809    public List<String> queryIds(QueryBuilder queryBuilder) {
810        if (!hasPermission(SecurityConstants.READ)) {
811            return Collections.emptyList();
812        }
813        if (FieldDetector.hasField(queryBuilder.predicate(), getPasswordField())) {
814            throw new DirectoryException("Cannot filter on password");
815        }
816        init();
817
818        Map<String, String> sources = new HashMap<>(); // map of id to source
819        DocumentModelList entries = new DocumentModelListImpl(); // needed if we have ordering
820        List<String> ids = new ArrayList<>();
821        // order/limit/offset
822        int limit = Math.max(0, (int) queryBuilder.limit());
823        int offset = Math.max(0, (int) queryBuilder.offset());
824        OrderByList orders = queryBuilder.orders();
825        boolean order = !orders.isEmpty();
826
827        for (SourceInfo sourceInfo : sourceInfos) {
828
829            // find all ids by evaluating the expression with this source of subdirectories
830            MultiDirectoryExpressionEvaluator evaluator = new MultiDirectoryExpressionEvaluator(sourceInfo,
831                    schemaIdField, getName());
832            Set<String> sourceIds = evaluator.eval(queryBuilder.predicate());
833
834            // TODO batch fetch entries
835            for (String id : sourceIds) {
836                String otherSource = sources.putIfAbsent(id, sourceInfo.source.name);
837                if (otherSource != null) {
838                    log.warn(String.format("Entry '%s' is present in source '%s' but also in source '%s'. "
839                            + "The second one will be ignored.", id, otherSource, sourceInfo.source.name));
840                    continue;
841                }
842                if (order) {
843                    entries.add(getEntry(id, false));
844                } else {
845                    ids.add(id);
846                }
847            }
848        }
849
850        if (order) {
851            getDirectory().orderEntries(entries, AbstractDirectory.makeOrderBy(orders));
852            entries.forEach(doc -> ids.add(doc.getId()));
853        }
854        return applyQueryLimits(ids, limit, offset);
855    }
856
857    @Override
858    public List<String> getProjection(Map<String, Serializable> filter, Set<String> fulltext, String columnName)
859            {
860
861        // There's no way to do an efficient getProjection to a source with
862        // multiple subdirectories given the current API (we'd need an API that
863        // passes several columns).
864        // So just do a non-optimal implementation for now.
865
866        final DocumentModelList entries = query(filter, fulltext);
867        final List<String> results = new ArrayList<>(entries.size());
868        for (DocumentModel entry : entries) {
869            final Object value = entry.getProperty(schemaName, columnName);
870            if (value == null) {
871                results.add(null);
872            } else {
873                results.add(value.toString());
874            }
875        }
876        return results;
877    }
878
879    @Override
880    public DocumentModel createEntry(DocumentModel entry) {
881        Map<String, Object> fieldMap = entry.getProperties(schemaName);
882        return createEntry(fieldMap);
883    }
884
885    @SuppressWarnings("resource") // dirInfo session must not be closed
886    @Override
887    public boolean hasEntry(String id) {
888        init();
889        for (SourceInfo sourceInfo : sourceInfos) {
890            for (SubDirectoryInfo dirInfo : sourceInfo.subDirectoryInfos) {
891                Session session = dirInfo.getSession();
892                if (session.hasEntry(id)) {
893                    return true;
894                }
895            }
896        }
897        return false;
898    }
899
900}