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 */
019package org.nuxeo.ecm.core.storage.sql;
020
021import static org.nuxeo.ecm.core.api.CoreSession.BINARY_FULLTEXT_MAIN_KEY;
022import static org.nuxeo.ecm.core.model.Session.PROP_ALLOW_DELETE_UNDELETABLE_DOCUMENTS;
023
024import java.io.IOException;
025import java.io.Serializable;
026import java.text.Normalizer;
027import java.util.ArrayList;
028import java.util.Arrays;
029import java.util.Calendar;
030import java.util.Collection;
031import java.util.Collections;
032import java.util.HashMap;
033import java.util.HashSet;
034import java.util.LinkedList;
035import java.util.List;
036import java.util.Map;
037import java.util.Map.Entry;
038import java.util.Set;
039import java.util.function.Consumer;
040import java.util.stream.Collectors;
041
042import org.apache.commons.logging.Log;
043import org.apache.commons.logging.LogFactory;
044import org.nuxeo.ecm.core.api.Blob;
045import org.nuxeo.ecm.core.api.ConcurrentUpdateException;
046import org.nuxeo.ecm.core.api.DocumentExistsException;
047import org.nuxeo.ecm.core.api.IterableQueryResult;
048import org.nuxeo.ecm.core.api.NuxeoException;
049import org.nuxeo.ecm.core.api.PartialList;
050import org.nuxeo.ecm.core.api.PropertyException;
051import org.nuxeo.ecm.core.api.ScrollResult;
052import org.nuxeo.ecm.core.api.lock.LockManager;
053import org.nuxeo.ecm.core.api.repository.FulltextConfiguration;
054import org.nuxeo.ecm.core.api.repository.RepositoryManager;
055import org.nuxeo.ecm.core.api.security.ACL;
056import org.nuxeo.ecm.core.api.security.SecurityConstants;
057import org.nuxeo.ecm.core.blob.BlobInfo;
058import org.nuxeo.ecm.core.blob.DocumentBlobManager;
059import org.nuxeo.ecm.core.model.Document;
060import org.nuxeo.ecm.core.query.QueryFilter;
061import org.nuxeo.ecm.core.query.sql.NXQL;
062import org.nuxeo.ecm.core.schema.DocumentType;
063import org.nuxeo.ecm.core.schema.SchemaManager;
064import org.nuxeo.ecm.core.storage.BaseDocument;
065import org.nuxeo.ecm.core.storage.FulltextDescriptor;
066import org.nuxeo.ecm.core.storage.FulltextExtractorWork;
067import org.nuxeo.ecm.core.storage.sql.PersistenceContext.PathAndId;
068import org.nuxeo.ecm.core.storage.sql.RowMapper.NodeInfo;
069import org.nuxeo.ecm.core.storage.sql.RowMapper.RowBatch;
070import org.nuxeo.ecm.core.work.api.Work;
071import org.nuxeo.ecm.core.work.api.WorkManager;
072import org.nuxeo.ecm.core.work.api.WorkManager.Scheduling;
073import org.nuxeo.runtime.api.Framework;
074import org.nuxeo.runtime.metrics.MetricsService;
075import org.nuxeo.runtime.transaction.TransactionHelper;
076
077import io.dropwizard.metrics5.MetricName;
078import io.dropwizard.metrics5.MetricRegistry;
079import io.dropwizard.metrics5.SharedMetricRegistries;
080import io.dropwizard.metrics5.Timer;
081
082/**
083 * The session is the main high level access point to data from the underlying database.
084 */
085public class SessionImpl implements Session {
086
087    private static final Log log = LogFactory.getLog(SessionImpl.class);
088
089    /**
090     * Set this system property to false if you don't want repositories to be looked up under the compatibility name
091     * "default" in the "repositories" table.
092     * <p>
093     * Only do this if you start from an empty database, or if you have migrated the "repositories" table by hand, or if
094     * you need to create a new repository in a database already containing a "default" repository (table sharing, not
095     * recommended).
096     */
097    public static final String COMPAT_REPOSITORY_NAME_KEY = "org.nuxeo.vcs.repository.name.default.compat";
098
099    private static final boolean COMPAT_REPOSITORY_NAME = Boolean.parseBoolean(
100            Framework.getProperty(COMPAT_REPOSITORY_NAME_KEY, "true"));
101
102    protected final RepositoryImpl repository;
103
104    private final Mapper mapper;
105
106    private final Model model;
107
108    // public because used by unit tests
109    public final PersistenceContext context;
110
111    protected final boolean changeTokenEnabled;
112
113    protected final FulltextDescriptor fulltextDescriptor;
114
115    private boolean inTransaction;
116
117    private Serializable rootNodeId;
118
119    private boolean readAclsChanged;
120
121    // @since 5.7
122    protected final MetricRegistry registry = SharedMetricRegistries.getOrCreate(MetricsService.class.getName());
123
124    private final Timer saveTimer;
125
126    private final Timer queryTimer;
127
128    private final Timer aclrUpdateTimer;
129
130    private static final java.lang.String LOG_MIN_DURATION_KEY = "org.nuxeo.vcs.query.log_min_duration_ms";
131
132    private static final long LOG_MIN_DURATION_NS = Long.parseLong(Framework.getProperty(LOG_MIN_DURATION_KEY, "-1"))
133            * 1000000;
134
135    public SessionImpl(RepositoryImpl repository, Model model, Mapper mapper) {
136        this.repository = repository;
137        this.mapper = mapper;
138        this.model = model;
139        context = new PersistenceContext(model, mapper, this);
140        changeTokenEnabled = repository.isChangeTokenEnabled();
141        fulltextDescriptor = repository.getRepositoryDescriptor().getFulltextDescriptor();
142        readAclsChanged = false;
143
144        saveTimer = registry.timer(MetricName.build("nuxeo", "repositories", "repository", "save", "timer")
145                                             .tagged("repository", repository.getName()));
146        queryTimer = registry.timer(MetricName.build("nuxeo", "repositories", "repository", "query", "timer")
147                                              .tagged("repository", repository.getName()));
148        aclrUpdateTimer = registry.timer(MetricName.build("nuxeo", "repositories", "repository", "aclr-update", "timer")
149                                                   .tagged("repository", repository.getName()));
150        computeRootNode();
151    }
152
153    // called by NetServlet when forwarding remote NetMapper calls.
154    @Override
155    public Mapper getMapper() {
156        return mapper;
157    }
158
159    /**
160     * Clears all the caches. Called by RepositoryManagement.
161     */
162    protected int clearCaches() {
163        if (inTransaction) {
164            // avoid potential multi-threaded access to active session
165            return 0;
166        }
167        return context.clearCaches();
168    }
169
170    protected PersistenceContext getContext() {
171        return context;
172    }
173
174    /**
175     * Generates a new id, or used a pre-generated one (import).
176     */
177    protected Serializable generateNewId(Serializable id) {
178        return context.generateNewId(id);
179    }
180
181    protected boolean isIdNew(Serializable id) {
182        return context.isIdNew(id);
183    }
184
185    @Override
186    public void close() {
187        try {
188            closeSession();
189        } finally {
190            repository.closeSession(this);
191        }
192    }
193
194    protected void closeSession() {
195        context.clearCaches();
196        // close the mapper and therefore the connection
197        mapper.close();
198        // don't clean the caches, we keep the pristine cache around
199        // TODO this is getting destroyed, we can clean everything
200    }
201
202    /*
203     * ----- Session -----
204     */
205
206    @Override
207    public String getRepositoryName() {
208        return repository.getName();
209    }
210
211    @Override
212    public Model getModel() {
213        return model;
214    }
215
216    @Override
217    public Node getRootNode() {
218        return getNodeById(rootNodeId);
219    }
220
221    @Override
222    public void save() {
223        @SuppressWarnings("resource")
224        final Timer.Context timerContext = saveTimer.time();
225        try {
226            flush();
227            if (!inTransaction) {
228                sendInvalidationsToOthers();
229                // as we don't have a way to know when the next
230                // non-transactional
231                // statement will start, process invalidations immediately
232            }
233            processReceivedInvalidations();
234        } finally {
235            timerContext.stop();
236        }
237    }
238
239    protected void flush() {
240        List<Work> works;
241        if (!fulltextDescriptor.getFulltextDisabled()) {
242            works = getFulltextWorks();
243        } else {
244            works = Collections.emptyList();
245        }
246        doFlush();
247        if (readAclsChanged) {
248            updateReadAcls();
249        }
250        scheduleWork(works);
251        checkInvalidationsConflict();
252    }
253
254    protected void scheduleWork(List<Work> works) {
255        // do async fulltext indexing only if high-level sessions are available
256        RepositoryManager repositoryManager = Framework.getService(RepositoryManager.class);
257        if (repositoryManager != null && !works.isEmpty()) {
258            WorkManager workManager = Framework.getService(WorkManager.class);
259            for (Work work : works) {
260                // schedule work post-commit
261                // in non-tx mode, this may execute it nearly immediately
262                workManager.schedule(work, Scheduling.IF_NOT_SCHEDULED, true);
263            }
264        }
265    }
266
267    protected void doFlush() {
268        List<Fragment> fragmentsToClearDirty = new ArrayList<>(0);
269        RowBatch batch = context.getSaveBatch(fragmentsToClearDirty);
270        if (!batch.isEmpty()) {
271            log.debug("Saving session");
272            // execute the batch
273            try {
274                mapper.write(batch);
275                log.debug("End of save");
276            } finally {
277                // callers must never observe a DeltaLong in the fragments
278                for (Fragment fragment : fragmentsToClearDirty) {
279                    fragment.clearDirty();
280                }
281            }
282        }
283    }
284
285    protected Serializable getContainingDocument(Serializable id) {
286        return context.getContainingDocument(id);
287    }
288
289    /**
290     * Gets the fulltext updates to do. Called at save() time.
291     *
292     * @return a list of {@link Work} instances to schedule post-commit.
293     */
294    protected List<Work> getFulltextWorks() {
295        Set<Serializable> dirtyStrings = new HashSet<>();
296        Set<Serializable> dirtyBinaries = new HashSet<>();
297        context.findDirtyDocuments(dirtyStrings, dirtyBinaries);
298        if (model.getFulltextConfiguration().fulltextSearchDisabled) {
299            // We only need to update dirty simple strings if fulltext search is not disabled
300            // because in that case Elasticsearch will do its own extraction/indexing.
301            // We need to detect dirty binary strings in all cases, because Elasticsearch
302            // will need them even if the repository itself doesn't use them for search.
303            dirtyStrings = Collections.emptySet();
304        }
305        Set<Serializable> dirtyIds = new HashSet<>();
306        dirtyIds.addAll(dirtyStrings);
307        dirtyIds.addAll(dirtyBinaries);
308        if (dirtyIds.isEmpty()) {
309            return Collections.emptyList();
310        }
311        markIndexingInProgress(dirtyIds);
312        List<Work> works = new ArrayList<>(dirtyIds.size());
313        for (Serializable id : dirtyIds) {
314            boolean updateSimpleText = dirtyStrings.contains(id);
315            boolean updateBinaryText = dirtyBinaries.contains(id);
316            Work work = new FulltextExtractorWork(repository.getName(), model.idToString(id), updateSimpleText,
317                    updateBinaryText, true);
318            works.add(work);
319        }
320        return works;
321    }
322
323    /**
324     * Mark indexing in progress, so that future copies (including versions) will be indexed as well.
325     */
326    protected void markIndexingInProgress(Set<Serializable> dirtyIds) {
327        FulltextConfiguration fulltextConfiguration = model.getFulltextConfiguration();
328        for (Node node : getNodesByIds(dirtyIds)) {
329            if (!fulltextConfiguration.isFulltextIndexable(node.getPrimaryType())) {
330                continue;
331            }
332            node.getSimpleProperty(Model.FULLTEXT_JOBID_PROP).setValue(model.idToString(node.getId()));
333        }
334    }
335
336    /**
337     * Post-transaction invalidations notification.
338     * <p>
339     * Called post-transaction by session commit/rollback or transactionless save.
340     */
341    protected void sendInvalidationsToOthers() {
342        context.sendInvalidationsToOthers();
343    }
344
345    /**
346     * Processes all invalidations accumulated.
347     * <p>
348     * Called pre-transaction by start or transactionless save;
349     */
350    protected void processReceivedInvalidations() {
351        context.processReceivedInvalidations();
352    }
353
354    /**
355     * Post transaction check invalidations processing.
356     */
357    protected void checkInvalidationsConflict() {
358        // repository.receiveClusterInvalidations(this);
359        context.checkInvalidationsConflict();
360    }
361
362    /*
363     * -------------------------------------------------------------
364     * -------------------------------------------------------------
365     * -------------------------------------------------------------
366     */
367
368    protected Node getNodeById(Serializable id, boolean prefetch) {
369        List<Node> nodes = getNodesByIds(Collections.singletonList(id), prefetch);
370        Node node = nodes.get(0);
371        // ((JDBCMapper) ((CachingMapper)
372        // mapper).mapper).logger.log("getNodeById " + id + " -> " + (node ==
373        // null ? "missing" : "found"));
374        return node;
375    }
376
377    @Override
378    public Node getNodeById(Serializable id) {
379        if (id == null) {
380            throw new IllegalArgumentException("Illegal null id");
381        }
382        return getNodeById(id, true);
383    }
384
385    public List<Node> getNodesByIds(Collection<Serializable> ids, boolean prefetch) {
386        // get hier fragments
387        List<RowId> hierRowIds = new ArrayList<>(ids.size());
388        for (Serializable id : ids) {
389            hierRowIds.add(new RowId(Model.HIER_TABLE_NAME, id));
390        }
391
392        List<Fragment> hierFragments = context.getMulti(hierRowIds, false);
393
394        // find available paths
395        Map<Serializable, String> paths = new HashMap<>();
396        Set<Serializable> parentIds = new HashSet<>();
397        for (Fragment fragment : hierFragments) {
398            Serializable id = fragment.getId();
399            PathAndId pathOrId = context.getPathOrMissingParentId((SimpleFragment) fragment, false);
400            // find missing fragments
401            if (pathOrId.path != null) {
402                paths.put(id, pathOrId.path);
403            } else {
404                parentIds.add(pathOrId.id);
405            }
406        }
407        // fetch the missing parents and their ancestors in bulk
408        if (!parentIds.isEmpty()) {
409            // fetch them in the context
410            getHierarchyAndAncestors(parentIds);
411            // compute missing paths using context
412            for (Fragment fragment : hierFragments) {
413                Serializable id = fragment.getId();
414                if (paths.containsKey(id)) {
415                    continue;
416                }
417                String path = context.getPath((SimpleFragment) fragment);
418                paths.put(id, path);
419            }
420        }
421
422        // prepare fragment groups to build nodes
423        Map<Serializable, FragmentGroup> fragmentGroups = new HashMap<>(ids.size());
424        for (Fragment fragment : hierFragments) {
425            Serializable id = fragment.row.id;
426            fragmentGroups.put(id, new FragmentGroup((SimpleFragment) fragment, new FragmentsMap()));
427        }
428
429        if (prefetch) {
430            List<RowId> bulkRowIds = new ArrayList<>();
431            Set<Serializable> proxyIds = new HashSet<>();
432
433            // get rows to prefetch for hier fragments
434            for (Fragment fragment : hierFragments) {
435                findPrefetchedFragments((SimpleFragment) fragment, bulkRowIds, proxyIds);
436            }
437
438            // proxies
439
440            // get proxies fragments
441            List<RowId> proxiesRowIds = new ArrayList<>(proxyIds.size());
442            for (Serializable id : proxyIds) {
443                proxiesRowIds.add(new RowId(Model.PROXY_TABLE_NAME, id));
444            }
445            List<Fragment> proxiesFragments = context.getMulti(proxiesRowIds, true);
446            Set<Serializable> targetIds = new HashSet<>();
447            for (Fragment fragment : proxiesFragments) {
448                Serializable targetId = ((SimpleFragment) fragment).get(Model.PROXY_TARGET_KEY);
449                targetIds.add(targetId);
450            }
451
452            // get hier fragments for proxies' targets
453            targetIds.removeAll(ids); // only those we don't have already
454            hierRowIds = new ArrayList<>(targetIds.size());
455            for (Serializable id : targetIds) {
456                hierRowIds.add(new RowId(Model.HIER_TABLE_NAME, id));
457            }
458            hierFragments = context.getMulti(hierRowIds, true);
459            for (Fragment fragment : hierFragments) {
460                findPrefetchedFragments((SimpleFragment) fragment, bulkRowIds, null);
461            }
462
463            // we have everything to be prefetched
464
465            // fetch all the prefetches in bulk
466            List<Fragment> fragments = context.getMulti(bulkRowIds, true);
467
468            // put each fragment in the map of the proper group
469            for (Fragment fragment : fragments) {
470                FragmentGroup fragmentGroup = fragmentGroups.get(fragment.row.id);
471                if (fragmentGroup != null) {
472                    fragmentGroup.fragments.put(fragment.row.tableName, fragment);
473                }
474            }
475        }
476
477        // assemble nodes from the fragment groups
478        List<Node> nodes = new ArrayList<>(ids.size());
479        for (Serializable id : ids) {
480            FragmentGroup fragmentGroup = fragmentGroups.get(id);
481            // null if deleted/absent
482            Node node = fragmentGroup == null ? null : new Node(context, fragmentGroup, paths.get(id));
483            nodes.add(node);
484        }
485
486        return nodes;
487    }
488
489    /**
490     * Finds prefetched fragments for a hierarchy fragment, takes note of the ones that are proxies.
491     */
492    protected void findPrefetchedFragments(SimpleFragment hierFragment, List<RowId> bulkRowIds,
493            Set<Serializable> proxyIds) {
494        Serializable id = hierFragment.row.id;
495
496        // find type
497        String typeName = (String) hierFragment.get(Model.MAIN_PRIMARY_TYPE_KEY);
498        if (Model.PROXY_TYPE.equals(typeName)) {
499            if (proxyIds != null) {
500                proxyIds.add(id);
501            }
502            return;
503        }
504
505        // find table names
506        Set<String> tableNames = model.getTypePrefetchedFragments(typeName);
507        if (tableNames == null) {
508            return; // unknown (obsolete) type
509        }
510
511        // add row id for each table name
512        Serializable parentId = hierFragment.get(Model.HIER_PARENT_KEY);
513        for (String tableName : tableNames) {
514            if (Model.HIER_TABLE_NAME.equals(tableName)) {
515                continue; // already fetched
516            }
517            if (parentId != null && Model.VERSION_TABLE_NAME.equals(tableName)) {
518                continue; // not a version, don't fetch this table
519                // TODO incorrect if we have filed versions
520            }
521            bulkRowIds.add(new RowId(tableName, id));
522        }
523    }
524
525    @Override
526    public List<Node> getNodesByIds(Collection<Serializable> ids) {
527        return getNodesByIds(ids, true);
528    }
529
530    @Override
531    public Node getParentNode(Node node) {
532        if (node == null) {
533            throw new IllegalArgumentException("Illegal null node");
534        }
535        Serializable id = node.getHierFragment().get(Model.HIER_PARENT_KEY);
536        return id == null ? null : getNodeById(id);
537    }
538
539    @Override
540    public String getPath(Node node) {
541        String path = node.getPath();
542        if (path == null) {
543            path = context.getPath(node.getHierFragment());
544        }
545        return path;
546    }
547
548    /*
549     * Normalize using NFC to avoid decomposed characters (like 'e' + COMBINING ACUTE ACCENT instead of LATIN SMALL
550     * LETTER E WITH ACUTE). NFKC (normalization using compatibility decomposition) is not used, because compatibility
551     * decomposition turns some characters (LATIN SMALL LIGATURE FFI, TRADE MARK SIGN, FULLWIDTH SOLIDUS) into a series
552     * of characters ('f'+'f'+'i', 'T'+'M', '/') that cannot be re-composed into the original, and therefore loses
553     * information.
554     */
555    protected String normalize(String path) {
556        return Normalizer.normalize(path, Normalizer.Form.NFC);
557    }
558
559    /* Does not apply to properties for now (no use case). */
560    @Override
561    public Node getNodeByPath(String path, Node node) {
562        // TODO optimize this to use a dedicated path-based table
563        if (path == null) {
564            throw new IllegalArgumentException("Illegal null path");
565        }
566        path = normalize(path);
567        int i;
568        if (path.startsWith("/")) {
569            node = getRootNode();
570            if (path.equals("/")) {
571                return node;
572            }
573            i = 1;
574        } else {
575            if (node == null) {
576                throw new IllegalArgumentException("Illegal relative path with null node: " + path);
577            }
578            i = 0;
579        }
580        String[] names = path.split("/", -1);
581        for (; i < names.length; i++) {
582            String name = names[i];
583            if (name.length() == 0) {
584                throw new IllegalArgumentException("Illegal path with empty component: " + path);
585            }
586            node = getChildNode(node, name, false);
587            if (node == null) {
588                return null;
589            }
590        }
591        return node;
592    }
593
594    @Override
595    public boolean addMixinType(Node node, String mixin) {
596        if (model.getMixinPropertyInfos(mixin) == null) {
597            throw new IllegalArgumentException("No such mixin: " + mixin);
598        }
599        if (model.getDocumentTypeFacets(node.getPrimaryType()).contains(mixin)) {
600            return false; // already present in type
601        }
602        List<String> list = new ArrayList<>(Arrays.asList(node.getMixinTypes()));
603        if (list.contains(mixin)) {
604            return false; // already present in node
605        }
606        Set<String> otherChildrenNames = getChildrenNames(node.getPrimaryType(), list);
607        list.add(mixin);
608        String[] mixins = list.toArray(new String[list.size()]);
609        node.hierFragment.put(Model.MAIN_MIXIN_TYPES_KEY, mixins);
610        // immediately create child nodes (for complex properties) in order
611        // to avoid concurrency issue later on
612        Map<String, String> childrenTypes = model.getMixinComplexChildren(mixin);
613        for (Entry<String, String> es : childrenTypes.entrySet()) {
614            String childName = es.getKey();
615            String childType = es.getValue();
616            // child may already exist if the schema is part of the primary type or another facet
617            if (otherChildrenNames.contains(childName)) {
618                continue;
619            }
620            addChildNode(node, childName, null, childType, true);
621        }
622        return true;
623    }
624
625    @Override
626    public boolean removeMixinType(Node node, String mixin) {
627        List<String> list = new ArrayList<>(Arrays.asList(node.getMixinTypes()));
628        if (!list.remove(mixin)) {
629            return false; // not present in node
630        }
631        String[] mixins = list.toArray(new String[list.size()]);
632        if (mixins.length == 0) {
633            mixins = null;
634        }
635        node.hierFragment.put(Model.MAIN_MIXIN_TYPES_KEY, mixins);
636        Set<String> otherChildrenNames = getChildrenNames(node.getPrimaryType(), list);
637        Map<String, String> childrenTypes = model.getMixinComplexChildren(mixin);
638        for (String childName : childrenTypes.keySet()) {
639            // child must be kept if the schema is part of primary type or another facet
640            if (otherChildrenNames.contains(childName)) {
641                continue;
642            }
643            Node child = getChildNode(node, childName, true);
644            removePropertyNode(child);
645        }
646        node.clearCache();
647        return true;
648    }
649
650    @Override
651    public ScrollResult<String> scroll(String query, int batchSize, int keepAliveSeconds) {
652        return mapper.scroll(query, batchSize, keepAliveSeconds);
653    }
654
655    @Override
656    public ScrollResult<String> scroll(String query, QueryFilter queryFilter, int batchSize, int keepAliveSeconds) {
657        return mapper.scroll(query, queryFilter, batchSize, keepAliveSeconds);
658    }
659
660    @Override
661    public ScrollResult<String> scroll(String scrollId) {
662        return mapper.scroll(scrollId);
663    }
664
665    /**
666     * Gets complex children names defined by the primary type and the list of mixins.
667     */
668    protected Set<String> getChildrenNames(String primaryType, List<String> mixins) {
669        Map<String, String> cc = model.getTypeComplexChildren(primaryType);
670        if (cc == null) {
671            cc = Collections.emptyMap();
672        }
673        Set<String> childrenNames = new HashSet<>(cc.keySet());
674        for (String mixin : mixins) {
675            cc = model.getMixinComplexChildren(mixin);
676            if (cc != null) {
677                childrenNames.addAll(cc.keySet());
678            }
679        }
680        return childrenNames;
681    }
682
683    @Override
684    public Node addChildNode(Node parent, String name, Long pos, String typeName, boolean complexProp) {
685        if (pos == null && !complexProp && parent != null) {
686            pos = context.getNextPos(parent.getId(), complexProp);
687        }
688        return addChildNode(null, parent, name, pos, typeName, complexProp);
689    }
690
691    @Override
692    public Node addChildNode(Serializable id, Node parent, String name, Long pos, String typeName,
693            boolean complexProp) {
694        if (name == null) {
695            throw new IllegalArgumentException("Illegal null name");
696        }
697        name = normalize(name);
698        if (name.contains("/") || name.equals(".") || name.equals("..")) {
699            throw new IllegalArgumentException("Illegal name: " + name);
700        }
701        if (!model.isType(typeName)) {
702            throw new IllegalArgumentException("Unknown type: " + typeName);
703        }
704        id = generateNewId(id);
705        Serializable parentId = parent == null ? null : parent.hierFragment.getId();
706        Node node = addNode(id, parentId, name, pos, typeName, complexProp);
707        // immediately create child nodes (for complex properties) in order
708        // to avoid concurrency issue later on
709        Map<String, String> childrenTypes = model.getTypeComplexChildren(typeName);
710        for (Entry<String, String> es : childrenTypes.entrySet()) {
711            String childName = es.getKey();
712            String childType = es.getValue();
713            addChildNode(node, childName, null, childType, true);
714        }
715        return node;
716    }
717
718    protected Node addNode(Serializable id, Serializable parentId, String name, Long pos, String typeName,
719            boolean complexProp) {
720        requireReadAclsUpdate();
721        // main info
722        Row hierRow = new Row(Model.HIER_TABLE_NAME, id);
723        hierRow.putNew(Model.HIER_PARENT_KEY, parentId);
724        hierRow.putNew(Model.HIER_CHILD_NAME_KEY, name);
725        hierRow.putNew(Model.HIER_CHILD_POS_KEY, pos);
726        hierRow.putNew(Model.MAIN_PRIMARY_TYPE_KEY, typeName);
727        hierRow.putNew(Model.HIER_CHILD_ISPROPERTY_KEY, Boolean.valueOf(complexProp));
728        if (changeTokenEnabled) {
729            hierRow.putNew(Model.MAIN_SYS_CHANGE_TOKEN_KEY, Model.INITIAL_SYS_CHANGE_TOKEN);
730        }
731        SimpleFragment hierFragment = context.createHierarchyFragment(hierRow);
732        FragmentGroup fragmentGroup = new FragmentGroup(hierFragment, new FragmentsMap());
733        return new Node(context, fragmentGroup, context.getPath(hierFragment));
734    }
735
736    @Override
737    public Node addProxy(Serializable targetId, Serializable versionableId, Node parent, String name, Long pos) {
738        if (!repository.getRepositoryDescriptor().getProxiesEnabled()) {
739            throw new NuxeoException("Proxies are disabled by configuration");
740        }
741        Node proxy = addChildNode(parent, name, pos, Model.PROXY_TYPE, false);
742        proxy.setSimpleProperty(Model.PROXY_TARGET_PROP, targetId);
743        proxy.setSimpleProperty(Model.PROXY_VERSIONABLE_PROP, versionableId);
744        if (changeTokenEnabled) {
745            proxy.setSimpleProperty(Model.MAIN_SYS_CHANGE_TOKEN_PROP, Model.INITIAL_SYS_CHANGE_TOKEN);
746            proxy.setSimpleProperty(Model.MAIN_CHANGE_TOKEN_PROP, Model.INITIAL_CHANGE_TOKEN);
747        }
748        SimpleFragment proxyFragment = (SimpleFragment) proxy.fragments.get(Model.PROXY_TABLE_NAME);
749        context.createdProxyFragment(proxyFragment);
750        return proxy;
751    }
752
753    @Override
754    public void setProxyTarget(Node proxy, Serializable targetId) {
755        if (!repository.getRepositoryDescriptor().getProxiesEnabled()) {
756            throw new NuxeoException("Proxies are disabled by configuration");
757        }
758        SimpleProperty prop = proxy.getSimpleProperty(Model.PROXY_TARGET_PROP);
759        Serializable oldTargetId = prop.getValue();
760        if (!oldTargetId.equals(targetId)) {
761            SimpleFragment proxyFragment = (SimpleFragment) proxy.fragments.get(Model.PROXY_TABLE_NAME);
762            context.removedProxyTarget(proxyFragment);
763            proxy.setSimpleProperty(Model.PROXY_TARGET_PROP, targetId);
764            context.addedProxyTarget(proxyFragment);
765        }
766    }
767
768    @Override
769    public boolean hasChildNode(Node parent, String name, boolean complexProp) {
770        // TODO could optimize further by not fetching the fragment at all
771        SimpleFragment fragment = context.getChildHierByName(parent.getId(), normalize(name), complexProp);
772        return fragment != null;
773    }
774
775    @Override
776    public Node getChildNode(Node parent, String name, boolean complexProp) {
777        if (name == null || name.contains("/") || name.equals(".") || name.equals("..")) {
778            throw new IllegalArgumentException("Illegal name: " + name);
779        }
780        SimpleFragment fragment = context.getChildHierByName(parent.getId(), name, complexProp);
781        return fragment == null ? null : getNodeById(fragment.getId());
782    }
783
784    // TODO optimize with dedicated backend call
785    @Override
786    public boolean hasChildren(Node parent, boolean complexProp) {
787        List<SimpleFragment> children = context.getChildren(parent.getId(), null, complexProp);
788        if (complexProp) {
789            return !children.isEmpty();
790        }
791        if (children.isEmpty()) {
792            return false;
793        }
794        // we have to check that type names are not obsolete, as they wouldn't be returned
795        // by getChildren and we must be consistent
796        SchemaManager schemaManager = Framework.getService(SchemaManager.class);
797        for (SimpleFragment simpleFragment : children) {
798            String primaryType = simpleFragment.getString(Model.MAIN_PRIMARY_TYPE_KEY);
799            if (primaryType.equals(Model.PROXY_TYPE)) {
800                Node node = getNodeById(simpleFragment.getId(), false);
801                Serializable targetId = node.getSimpleProperty(Model.PROXY_TARGET_PROP).getValue();
802                if (targetId == null) {
803                    // missing target, should not happen, ignore
804                    continue;
805                }
806                Node target = getNodeById(targetId, false);
807                if (target == null) {
808                    continue;
809                }
810                primaryType = target.getPrimaryType();
811            }
812            DocumentType type = schemaManager.getDocumentType(primaryType);
813            if (type == null) {
814                // obsolete type, ignored in getChildren
815                continue;
816            }
817            return true;
818        }
819        return false;
820    }
821
822    @Override
823    public List<Node> getChildren(Node parent, String name, boolean complexProp) {
824        List<SimpleFragment> fragments = context.getChildren(parent.getId(), name, complexProp);
825        List<Node> nodes = new ArrayList<>(fragments.size());
826        for (SimpleFragment fragment : fragments) {
827            Node node = getNodeById(fragment.getId());
828            if (node == null) {
829                // cannot happen
830                log.error("Child node cannot be created: " + fragment.getId());
831                continue;
832            }
833            nodes.add(node);
834        }
835        return nodes;
836    }
837
838    @Override
839    public void orderBefore(Node parent, Node source, Node dest) {
840        context.orderBefore(parent.getId(), source.getId(), dest == null ? null : dest.getId());
841    }
842
843    @Override
844    public Node move(Node source, Node parent, String name) {
845        if (!parent.getId().equals(source.getParentId())) {
846            flush(); // needed when doing many moves for circular stuff
847        }
848        context.move(source, parent.getId(), name);
849        requireReadAclsUpdate();
850        return source;
851    }
852
853    @Override
854    public Node copy(Node source, Node parent, String name, Consumer<Node> afterRecordCopy) {
855        flush();
856        Consumer<Serializable> afterRecordCopyWithId = afterRecordCopy == null ? null
857                : recId -> afterRecordCopy.accept(getNodeById(recId));
858        Serializable id = context.copy(source, parent.getId(), name, afterRecordCopyWithId);
859        requireReadAclsUpdate();
860        return getNodeById(id);
861    }
862
863    @Override
864    public void removeNode(Node node, Consumer<Node> beforeRecordRemove) {
865        flush();
866        // remove the lock using the lock manager
867        // TODO children locks?
868        Serializable id = node.getId();
869        getLockManager().removeLock(model.idToString(id), null);
870        // find all descendants
871        List<NodeInfo> nodeInfos = context.getNodeAndDescendantsInfo(node.getHierFragment());
872
873        // check that there is no retention / hold
874        Set<Serializable> undeletableIds = nodeInfos.stream() //
875                                                    .filter(info -> info.isUndeletable)
876                                                    .map(info -> info.id)
877                                                    .collect(Collectors.toSet());
878        if (!undeletableIds.isEmpty()) {
879            // in tests we may want to delete everything
880            boolean allowDeleteUndeletable = Framework.isBooleanPropertyTrue(PROP_ALLOW_DELETE_UNDELETABLE_DOCUMENTS);
881            if (!allowDeleteUndeletable) {
882                if (undeletableIds.contains(id)) {
883                    throw new DocumentExistsException("Cannot remove " + id + ", it is under retention / hold");
884                } else {
885                    throw new DocumentExistsException("Cannot remove " + id + ", subdocument "
886                            + undeletableIds.iterator().next() + " is under retention / hold");
887                }
888            }
889        }
890
891        // pre-processing before record removal (notify the record blob manager)
892        if (beforeRecordRemove != null) {
893            nodeInfos.stream() //
894                     .filter(info -> info.isRecord)
895                     .map(info -> getNodeById(info.id))
896                     .forEach(beforeRecordRemove::accept);
897        }
898
899        // if a proxy target is removed, check that all proxies to it are removed
900        if (repository.getRepositoryDescriptor().getProxiesEnabled()) {
901            Set<Serializable> removedIds = nodeInfos.stream().map(info -> info.id).collect(Collectors.toSet());
902            // find proxies pointing to any removed document
903            Set<Serializable> proxyIds = context.getTargetProxies(removedIds);
904            for (Serializable proxyId : proxyIds) {
905                if (!removedIds.contains(proxyId)) {
906                    Node proxy = getNodeById(proxyId);
907                    Serializable targetId = (Serializable) proxy.getSingle(Model.PROXY_TARGET_PROP);
908                    throw new DocumentExistsException(
909                            "Cannot remove " + id + ", subdocument " + targetId + " is the target of proxy " + proxyId);
910                }
911            }
912        }
913
914        // remove all nodes
915        context.removeNode(node.getHierFragment(), nodeInfos);
916    }
917
918    @Override
919    public void removePropertyNode(Node node) {
920        // no flush needed
921        context.removePropertyNode(node.getHierFragment());
922    }
923
924    @Override
925    public Node checkIn(Node node, String label, String checkinComment) {
926        flush();
927        Serializable id = context.checkIn(node, label, checkinComment);
928        requireReadAclsUpdate();
929        // save to reflect changes immediately in database
930        flush();
931        return getNodeById(id);
932    }
933
934    @Override
935    public void checkOut(Node node) {
936        context.checkOut(node);
937        requireReadAclsUpdate();
938    }
939
940    @Override
941    public void restore(Node node, Node version) {
942        // save done inside method
943        context.restoreVersion(node, version);
944        requireReadAclsUpdate();
945    }
946
947    @Override
948    public Node getVersionByLabel(Serializable versionSeriesId, String label) {
949        if (label == null) {
950            return null;
951        }
952        List<Node> versions = getVersions(versionSeriesId);
953        for (Node node : versions) {
954            String l = (String) node.getSimpleProperty(Model.VERSION_LABEL_PROP).getValue();
955            if (label.equals(l)) {
956                return node;
957            }
958        }
959        return null;
960    }
961
962    @Override
963    public Node getLastVersion(Serializable versionSeriesId) {
964        List<Serializable> ids = context.getVersionIds(versionSeriesId);
965        return ids.isEmpty() ? null : getNodeById(ids.get(ids.size() - 1));
966    }
967
968    @Override
969    public List<Node> getVersions(Serializable versionSeriesId) {
970        List<Serializable> ids = context.getVersionIds(versionSeriesId);
971        List<Node> nodes = new ArrayList<>(ids.size());
972        for (Serializable id : ids) {
973            nodes.add(getNodeById(id));
974        }
975        return nodes;
976    }
977
978    @Override
979    public List<Node> getProxies(Node document, Node parent) {
980        if (!repository.getRepositoryDescriptor().getProxiesEnabled()) {
981            return Collections.emptyList();
982        }
983
984        List<Serializable> ids;
985        if (document.isVersion()) {
986            ids = context.getTargetProxyIds(document.getId());
987        } else {
988            Serializable versionSeriesId;
989            if (document.isProxy()) {
990                versionSeriesId = document.getSimpleProperty(Model.PROXY_VERSIONABLE_PROP).getValue();
991            } else {
992                versionSeriesId = document.getId();
993            }
994            ids = context.getSeriesProxyIds(versionSeriesId);
995        }
996
997        List<Node> nodes = getNodes(ids);
998
999        if (parent != null) {
1000            // filter by parent
1001            Serializable parentId = parent.getId();
1002            nodes.removeIf(node -> !parentId.equals(node.getParentId()));
1003        }
1004
1005        return nodes;
1006    }
1007
1008    protected List<Node> getNodes(List<Serializable> ids) {
1009        List<Node> nodes = new LinkedList<>();
1010        for (Serializable id : ids) {
1011            Node node = getNodeById(id);
1012            if (node != null || Boolean.TRUE.booleanValue()) { // XXX
1013                // null if deleted, which means selection wasn't correctly
1014                // updated
1015                nodes.add(node);
1016            }
1017        }
1018        return nodes;
1019    }
1020
1021    @Override
1022    public List<Node> getProxies(Node document) {
1023        if (!repository.getRepositoryDescriptor().getProxiesEnabled()) {
1024            return Collections.emptyList();
1025        }
1026        List<Serializable> ids = context.getTargetProxyIds(document.getId());
1027        return getNodes(ids);
1028    }
1029
1030    /**
1031     * Fetches the hierarchy fragment for the given rows and all their ancestors.
1032     *
1033     * @param ids the fragment ids
1034     */
1035    protected List<Fragment> getHierarchyAndAncestors(Collection<Serializable> ids) {
1036        Set<Serializable> allIds = mapper.getAncestorsIds(ids);
1037        allIds.addAll(ids);
1038        List<RowId> rowIds = new ArrayList<>(allIds.size());
1039        for (Serializable id : allIds) {
1040            rowIds.add(new RowId(Model.HIER_TABLE_NAME, id));
1041        }
1042        return context.getMulti(rowIds, true);
1043    }
1044
1045    @SuppressWarnings("resource") // Time.Context closed by stop()
1046    @Override
1047    public PartialList<Serializable> query(String query, QueryFilter queryFilter, boolean countTotal) {
1048        final Timer.Context timerContext = queryTimer.time();
1049        try {
1050            return mapper.query(query, NXQL.NXQL, queryFilter, countTotal);
1051        } finally {
1052            timerContext.stop();
1053        }
1054    }
1055
1056    @SuppressWarnings("resource") // Time.Context closed by stop()
1057    @Override
1058    public PartialList<Serializable> query(String query, String queryType, QueryFilter queryFilter, long countUpTo) {
1059        final Timer.Context timerContext = queryTimer.time();
1060        try {
1061            return mapper.query(query, queryType, queryFilter, countUpTo);
1062        } finally {
1063            long duration = timerContext.stop();
1064            if ((LOG_MIN_DURATION_NS >= 0) && (duration > LOG_MIN_DURATION_NS)) {
1065                String msg = String.format("duration_ms:\t%.2f\t%s %s\tquery\t%s", duration / 1000000.0, queryFilter,
1066                        countUpToAsString(countUpTo), query);
1067                if (log.isTraceEnabled()) {
1068                    log.info(msg, new Throwable("Slow query stack trace"));
1069                } else {
1070                    log.info(msg);
1071                }
1072            }
1073        }
1074    }
1075
1076    private String countUpToAsString(long countUpTo) {
1077        if (countUpTo > 0) {
1078            return String.format("count total results up to %d", countUpTo);
1079        }
1080        return countUpTo == -1 ? "count total results UNLIMITED" : "";
1081    }
1082
1083    protected static class QueryResultContext extends Exception {
1084
1085        private static final long serialVersionUID = 1L;
1086
1087        public final IterableQueryResult queryResult;
1088
1089        public QueryResultContext(IterableQueryResult queryResult) {
1090            super("queryAndFetch call context");
1091            this.queryResult = queryResult;
1092        }
1093    }
1094
1095    protected final Set<QueryResultContext> queryResults = new HashSet<>();
1096
1097    protected void noteQueryResult(IterableQueryResult result) {
1098        queryResults.add(new QueryResultContext(result));
1099    }
1100
1101    protected void closeQueryResults() {
1102        for (QueryResultContext ctx : queryResults) {
1103            if (!ctx.queryResult.mustBeClosed()) {
1104                continue;
1105            }
1106            try {
1107                ctx.queryResult.close();
1108            } catch (RuntimeException e) {
1109                log.error("Cannot close query result", e);
1110            } finally {
1111                log.warn("Closing a query results for you, check stack trace for allocating point", ctx);
1112            }
1113        }
1114        queryResults.clear();
1115    }
1116
1117    @Override
1118    public IterableQueryResult queryAndFetch(String query, String queryType, QueryFilter queryFilter,
1119            Object... params) {
1120        return queryAndFetch(query, queryType, queryFilter, false, params);
1121    }
1122
1123    @SuppressWarnings("resource") // Time.Context closed by stop()
1124    @Override
1125    public IterableQueryResult queryAndFetch(String query, String queryType, QueryFilter queryFilter,
1126            boolean distinctDocuments, Object... params) {
1127        final Timer.Context timerContext = queryTimer.time();
1128        try {
1129            IterableQueryResult result = mapper.queryAndFetch(query, queryType, queryFilter, distinctDocuments, params);
1130            noteQueryResult(result);
1131            return result;
1132        } finally {
1133            long duration = timerContext.stop();
1134            if ((LOG_MIN_DURATION_NS >= 0) && (duration > LOG_MIN_DURATION_NS)) {
1135                String msg = String.format("duration_ms:\t%.2f\t%s\tqueryAndFetch\t%s", duration / 1000000.0,
1136                        queryFilter, query);
1137                if (log.isTraceEnabled()) {
1138                    log.info(msg, new Throwable("Slow query stack trace"));
1139                } else {
1140                    log.info(msg);
1141                }
1142            }
1143        }
1144    }
1145
1146    @SuppressWarnings("resource") // Time.Context closed by stop()
1147    @Override
1148    public PartialList<Map<String, Serializable>> queryProjection(String query, String queryType,
1149            QueryFilter queryFilter, boolean distinctDocuments, long countUpTo, Object... params) {
1150        final Timer.Context timerContext = queryTimer.time();
1151        try {
1152            return mapper.queryProjection(query, queryType, queryFilter, distinctDocuments, countUpTo, params);
1153        } finally {
1154            long duration = timerContext.stop();
1155            if ((LOG_MIN_DURATION_NS >= 0) && (duration > LOG_MIN_DURATION_NS)) {
1156                String msg = String.format("duration_ms:\t%.2f\t%s\tqueryProjection\t%s", duration / 1000000.0,
1157                        queryFilter, query);
1158                if (log.isTraceEnabled()) {
1159                    log.info(msg, new Throwable("Slow query stack trace"));
1160                } else {
1161                    log.info(msg);
1162                }
1163            }
1164        }
1165    }
1166
1167    @Override
1168    public LockManager getLockManager() {
1169        return repository.getLockManager();
1170    }
1171
1172    @Override
1173    public void requireReadAclsUpdate() {
1174        readAclsChanged = true;
1175    }
1176
1177    @Override
1178    public void updateReadAcls() {
1179        @SuppressWarnings("resource")
1180        final Timer.Context timerContext = aclrUpdateTimer.time();
1181        try {
1182            mapper.updateReadAcls();
1183            readAclsChanged = false;
1184        } finally {
1185            timerContext.stop();
1186        }
1187    }
1188
1189    @Override
1190    public void rebuildReadAcls() {
1191        mapper.rebuildReadAcls();
1192        readAclsChanged = false;
1193    }
1194
1195    private void computeRootNode() {
1196        String repositoryId = repository.getName();
1197        Serializable rootId = mapper.getRootId(repositoryId);
1198        if (rootId == null && COMPAT_REPOSITORY_NAME) {
1199            // compat, old repositories had fixed id "default"
1200            rootId = mapper.getRootId("default");
1201        }
1202        if (rootId == null) {
1203            log.debug("Creating root");
1204            addRootNode();
1205            save();
1206            // record information about the root id
1207            mapper.setRootId(repositoryId, rootNodeId);
1208        } else {
1209            rootNodeId = rootId;
1210        }
1211    }
1212
1213    // TODO factor with addChildNode
1214    private Node addRootNode() {
1215        rootNodeId = generateNewId(null);
1216        Node rootNode = addNode(rootNodeId, null, "", null, Model.ROOT_TYPE, false);
1217        addRootACP(rootNode);
1218        return rootNode;
1219    }
1220
1221    private void addRootACP(Node rootNode) {
1222        ACLRow[] aclrows = new ACLRow[3];
1223        // TODO put groups in their proper place. like that now for consistency.
1224        aclrows[0] = new ACLRow(0, ACL.LOCAL_ACL, true, SecurityConstants.EVERYTHING, SecurityConstants.ADMINISTRATORS,
1225                null);
1226        aclrows[1] = new ACLRow(1, ACL.LOCAL_ACL, true, SecurityConstants.EVERYTHING, SecurityConstants.ADMINISTRATOR,
1227                null);
1228        aclrows[2] = new ACLRow(2, ACL.LOCAL_ACL, true, SecurityConstants.READ, SecurityConstants.MEMBERS, null);
1229        rootNode.setCollectionProperty(Model.ACL_PROP, aclrows);
1230        requireReadAclsUpdate();
1231    }
1232
1233    public void markReferencedBinaries() {
1234        mapper.markReferencedBinaries();
1235    }
1236
1237    public int cleanupDeletedDocuments(int max, Calendar beforeTime) {
1238        if (!repository.getRepositoryDescriptor().getSoftDeleteEnabled()) {
1239            return 0;
1240        }
1241        return mapper.cleanupDeletedRows(max, beforeTime);
1242    }
1243
1244    /*
1245     * ----- Transaction management -----
1246     */
1247
1248    public void start() {
1249        inTransaction = true;
1250        processReceivedInvalidations();
1251    }
1252
1253    public void end() {
1254        closeQueryResults();
1255        try {
1256            flush();
1257        } catch (ConcurrentUpdateException e) {
1258            TransactionHelper.setTransactionRollbackOnly();
1259            throw e;
1260        }
1261    }
1262
1263    public void commit() {
1264        try {
1265            sendInvalidationsToOthers();
1266        } finally {
1267            inTransaction = false;
1268        }
1269    }
1270
1271    public void rollback() {
1272        try {
1273            try {
1274                mapper.rollback();
1275            } finally {
1276                context.clearCaches();
1277            }
1278        } finally {
1279            inTransaction = false;
1280        }
1281    }
1282
1283    public long getCacheSize() {
1284        return context.getCacheSize();
1285    }
1286
1287    public long getCacheMapperSize() {
1288        return context.getCacheMapperSize();
1289    }
1290
1291    public long getCachePristineSize() {
1292        return context.getCachePristineSize();
1293    }
1294
1295    public long getCacheSelectionSize() {
1296        return context.getCacheSelectionSize();
1297    }
1298
1299    @Override
1300    public boolean isFulltextStoredInBlob() {
1301        return fulltextDescriptor.getFulltextStoredInBlob();
1302    }
1303
1304    @Override
1305    public Map<String, String> getBinaryFulltext(Serializable id, Document doc) {
1306        if (fulltextDescriptor.getFulltextDisabled()) {
1307            return null;
1308        }
1309        RowId rowId = new RowId(Model.FULLTEXT_TABLE_NAME, id);
1310        Map<String, String> map = mapper.getBinaryFulltext(rowId);
1311        String fulltext = map.get(BINARY_FULLTEXT_MAIN_KEY);
1312        if (fulltextDescriptor.getFulltextStoredInBlob() && fulltext != null) {
1313            if (doc == null) {
1314                // could not find doc (shouldn't happen)
1315                fulltext = null;
1316            } else {
1317                // fulltext is actually the blob key
1318                // now retrieve the actual fulltext from the blob content
1319                DocumentBlobManager blobManager = Framework.getService(DocumentBlobManager.class);
1320                try {
1321                    BlobInfo blobInfo = new BlobInfo();
1322                    blobInfo.key = fulltext;
1323                    String xpath = BaseDocument.FULLTEXT_BINARYTEXT_PROP;
1324                    Blob blob = blobManager.readBlob(blobInfo, doc, xpath);
1325                    fulltext = blob.getString();
1326                } catch (IOException e) {
1327                    throw new PropertyException("Cannot read fulltext blob for doc: " + id, e);
1328                }
1329            }
1330            map.put(BINARY_FULLTEXT_MAIN_KEY, fulltext);
1331        }
1332        return map;
1333    }
1334
1335    @Override
1336    public boolean isChangeTokenEnabled() {
1337        return changeTokenEnabled;
1338    }
1339
1340    @Override
1341    public void markUserChange(Serializable id) {
1342        context.markUserChange(id);
1343    }
1344
1345}