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