001/*
002 * (C) Copyright 2014-2016 Nuxeo SA (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Florent Guillaume
018 */
019package org.nuxeo.ecm.core.storage.dbs;
020
021import static java.lang.Boolean.FALSE;
022import static java.lang.Boolean.TRUE;
023import static org.nuxeo.ecm.core.api.security.SecurityConstants.BROWSE;
024import static org.nuxeo.ecm.core.api.security.SecurityConstants.EVERYONE;
025import static org.nuxeo.ecm.core.api.security.SecurityConstants.UNSUPPORTED_ACL;
026import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.INITIAL_CHANGE_TOKEN;
027import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.INITIAL_SYS_CHANGE_TOKEN;
028import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ACE_GRANT;
029import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ACE_PERMISSION;
030import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ACE_USER;
031import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ACL;
032import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ACP;
033import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ANCESTOR_IDS;
034import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_CHANGE_TOKEN;
035import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_FULLTEXT_JOBID;
036import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_ID;
037import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_IS_PROXY;
038import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_IS_VERSION;
039import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_MIXIN_TYPES;
040import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_NAME;
041import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_PARENT_ID;
042import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_POS;
043import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_PREFIX;
044import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_PRIMARY_TYPE;
045import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_PROXY_IDS;
046import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_PROXY_TARGET_ID;
047import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_PROXY_VERSION_SERIES_ID;
048import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_READ_ACL;
049import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_SYS_CHANGE_TOKEN;
050import static org.nuxeo.ecm.core.storage.dbs.DBSDocument.KEY_VERSION_SERIES_ID;
051
052import java.io.Serializable;
053import java.util.ArrayList;
054import java.util.Arrays;
055import java.util.Collections;
056import java.util.HashMap;
057import java.util.HashSet;
058import java.util.LinkedHashSet;
059import java.util.LinkedList;
060import java.util.List;
061import java.util.Map;
062import java.util.Map.Entry;
063import java.util.Set;
064
065import org.apache.commons.lang.StringUtils;
066import org.apache.commons.logging.Log;
067import org.apache.commons.logging.LogFactory;
068import org.nuxeo.ecm.core.api.ConcurrentUpdateException;
069import org.nuxeo.ecm.core.api.model.DeltaLong;
070import org.nuxeo.ecm.core.api.repository.RepositoryManager;
071import org.nuxeo.ecm.core.schema.SchemaManager;
072import org.nuxeo.ecm.core.schema.types.Schema;
073import org.nuxeo.ecm.core.security.SecurityService;
074import org.nuxeo.ecm.core.storage.BaseDocument;
075import org.nuxeo.ecm.core.storage.DefaultFulltextParser;
076import org.nuxeo.ecm.core.storage.FulltextConfiguration;
077import org.nuxeo.ecm.core.storage.FulltextParser;
078import org.nuxeo.ecm.core.storage.FulltextUpdaterWork;
079import org.nuxeo.ecm.core.storage.FulltextUpdaterWork.IndexAndText;
080import org.nuxeo.ecm.core.storage.State;
081import org.nuxeo.ecm.core.storage.State.ListDiff;
082import org.nuxeo.ecm.core.storage.State.StateDiff;
083import org.nuxeo.ecm.core.storage.StateHelper;
084import org.nuxeo.ecm.core.work.api.Work;
085import org.nuxeo.ecm.core.work.api.WorkManager;
086import org.nuxeo.ecm.core.work.api.WorkManager.Scheduling;
087import org.nuxeo.runtime.api.Framework;
088
089/**
090 * Transactional state for a session.
091 * <p>
092 * Until {@code save()} is called, data lives in the transient map.
093 * <p>
094 * Upon save, data is written to the repository, even though it has not yet been committed (this means that other
095 * sessions can read uncommitted data). It's also kept in an undo log in order for rollback to be possible.
096 * <p>
097 * On commit, the undo log is forgotten. On rollback, the undo log is replayed.
098 *
099 * @since 5.9.4
100 */
101public class DBSTransactionState {
102
103    private static final Log log = LogFactory.getLog(DBSTransactionState.class);
104
105    private static final String KEY_UNDOLOG_CREATE = "__UNDOLOG_CREATE__\0\0";
106
107    protected final DBSRepository repository;
108
109    protected final DBSSession session;
110
111    /** Retrieved and created document state. */
112    protected Map<String, DBSDocumentState> transientStates = new HashMap<>();
113
114    /** Ids of documents created but not yet saved. */
115    protected Set<String> transientCreated = new LinkedHashSet<>();
116
117    /**
118     * Document ids modified as "user changes", which means that a change token should be checked.
119     *
120     * @since 9.2
121     */
122    protected final Set<Serializable> userChangeIds = new HashSet<>();
123
124    /**
125     * Undo log.
126     * <p>
127     * A map of document ids to null or State. The value is null when the document has to be deleted when applying the
128     * undo log. Otherwise the value is a State. If the State contains the key {@link #KEY_UNDOLOG_CREATE} then the
129     * state must be re-created completely when applying the undo log, otherwise just applied as an update.
130     * <p>
131     * Null when there is no active transaction.
132     */
133    protected Map<String, State> undoLog;
134
135    protected final Set<String> browsePermissions;
136
137    public DBSTransactionState(DBSRepository repository, DBSSession session) {
138        this.repository = repository;
139        this.session = session;
140        SecurityService securityService = Framework.getLocalService(SecurityService.class);
141        browsePermissions = new HashSet<>(Arrays.asList(securityService.getPermissionsToCheck(BROWSE)));
142    }
143
144    /**
145     * New transient state for something just read from the repository.
146     */
147    protected DBSDocumentState newTransientState(State state) {
148        if (state == null) {
149            return null;
150        }
151        String id = (String) state.get(KEY_ID);
152        if (transientStates.containsKey(id)) {
153            throw new IllegalStateException("Already transient: " + id);
154        }
155        DBSDocumentState docState = new DBSDocumentState(state); // copy
156        transientStates.put(id, docState);
157        return docState;
158    }
159
160    /**
161     * Returns a state and marks it as transient, because it's about to be modified or returned to user code (where it
162     * may be modified).
163     */
164    public DBSDocumentState getStateForUpdate(String id) {
165        // check transient state
166        DBSDocumentState docState = transientStates.get(id);
167        if (docState != null) {
168            return docState;
169        }
170        // fetch from repository
171        State state = repository.readState(id);
172        return newTransientState(state);
173    }
174
175    /**
176     * Returns a state which won't be modified.
177     */
178    // TODO in some cases it's good to have this kept in memory instead of
179    // rereading from database every time
180    // XXX getStateForReadOneShot
181    public State getStateForRead(String id) {
182        // check transient state
183        DBSDocumentState docState = transientStates.get(id);
184        if (docState != null) {
185            return docState.getState();
186        }
187        // fetch from repository
188        return repository.readState(id);
189    }
190
191    /**
192     * Returns states and marks them transient, because they're about to be returned to user code (where they may be
193     * modified).
194     */
195    public List<DBSDocumentState> getStatesForUpdate(List<String> ids) {
196        // check which ones we have to fetch from repository
197        List<String> idsToFetch = new LinkedList<>();
198        for (String id : ids) {
199            // check transient state
200            DBSDocumentState docState = transientStates.get(id);
201            if (docState != null) {
202                continue;
203            }
204            // will have to fetch it
205            idsToFetch.add(id);
206        }
207        if (!idsToFetch.isEmpty()) {
208            List<State> states = repository.readStates(idsToFetch);
209            for (State state : states) {
210                newTransientState(state);
211            }
212        }
213        // everything now fetched in transient
214        List<DBSDocumentState> docStates = new ArrayList<>(ids.size());
215        for (String id : ids) {
216            DBSDocumentState docState = transientStates.get(id);
217            if (docState == null) {
218                if (log.isTraceEnabled()) {
219                    log.trace("Cannot fetch document with id: " + id, new Throwable("debug stack trace"));
220                }
221                continue;
222            }
223            docStates.add(docState);
224        }
225        return docStates;
226    }
227
228    // XXX TODO for update or for read?
229    public DBSDocumentState getChildState(String parentId, String name) {
230        // check transient state
231        for (DBSDocumentState docState : transientStates.values()) {
232            if (!parentId.equals(docState.getParentId())) {
233                continue;
234            }
235            if (!name.equals(docState.getName())) {
236                continue;
237            }
238            return docState;
239        }
240        // fetch from repository
241        State state = repository.readChildState(parentId, name, Collections.emptySet());
242        if (state == null) {
243            return null;
244        }
245        String id = (String) state.get(KEY_ID);
246        if (transientStates.containsKey(id)) {
247            // found transient, even though we already checked
248            // that means that in-memory it's not a child, but in-database it's a child (was moved)
249            // -> ignore the database state
250            return null;
251        }
252        return newTransientState(state);
253    }
254
255    public boolean hasChild(String parentId, String name) {
256        // check transient state
257        for (DBSDocumentState docState : transientStates.values()) {
258            if (!parentId.equals(docState.getParentId())) {
259                continue;
260            }
261            if (!name.equals(docState.getName())) {
262                continue;
263            }
264            return true;
265        }
266        // check repository
267        return repository.hasChild(parentId, name, Collections.emptySet());
268    }
269
270    public List<DBSDocumentState> getChildrenStates(String parentId) {
271        List<DBSDocumentState> docStates = new LinkedList<>();
272        Set<String> seen = new HashSet<>();
273        // check transient state
274        for (DBSDocumentState docState : transientStates.values()) {
275            if (!parentId.equals(docState.getParentId())) {
276                continue;
277            }
278            docStates.add(docState);
279            seen.add(docState.getId());
280        }
281        // fetch from repository
282        List<State> states = repository.queryKeyValue(KEY_PARENT_ID, parentId, seen);
283        for (State state : states) {
284            String id = (String) state.get(KEY_ID);
285            if (transientStates.containsKey(id)) {
286                // found transient, even though we passed an exclusion list for known children
287                // that means that in-memory it's not a child, but in-database it's a child (was moved)
288                // -> ignore the database state
289                continue;
290            }
291            docStates.add(newTransientState(state));
292        }
293        return docStates;
294    }
295
296    public List<String> getChildrenIds(String parentId) {
297        List<String> children = new ArrayList<>();
298        Set<String> seen = new HashSet<>();
299        // check transient state
300        for (DBSDocumentState docState : transientStates.values()) {
301            String id = docState.getId();
302            if (!parentId.equals(docState.getParentId())) {
303                continue;
304            }
305            seen.add(id);
306            children.add(id);
307        }
308        // fetch from repository
309        List<State> states = repository.queryKeyValue(KEY_PARENT_ID, parentId, seen);
310        for (State state : states) {
311            String id = (String) state.get(KEY_ID);
312            if (transientStates.containsKey(id)) {
313                // found transient, even though we passed an exclusion list for known children
314                // that means that in-memory it's not a child, but in-database it's a child (was moved)
315                // -> ignore the database state
316                continue;
317            }
318            children.add(id);
319        }
320        return new ArrayList<>(children);
321    }
322
323    public boolean hasChildren(String parentId) {
324        // check transient state
325        for (DBSDocumentState docState : transientStates.values()) {
326            if (!parentId.equals(docState.getParentId())) {
327                continue;
328            }
329            return true;
330        }
331        // check repository
332        return repository.queryKeyValuePresence(KEY_PARENT_ID, parentId, Collections.emptySet());
333    }
334
335    public DBSDocumentState createChild(String id, String parentId, String name, Long pos, String typeName) {
336        // id may be not-null for import
337        if (id == null) {
338            id = repository.generateNewId();
339        }
340        transientCreated.add(id);
341        DBSDocumentState docState = new DBSDocumentState();
342        transientStates.put(id, docState);
343        docState.put(KEY_ID, id);
344        docState.put(KEY_PARENT_ID, parentId);
345        docState.put(KEY_ANCESTOR_IDS, getAncestorIds(parentId));
346        docState.put(KEY_NAME, name);
347        docState.put(KEY_POS, pos);
348        docState.put(KEY_PRIMARY_TYPE, typeName);
349        if (session.changeTokenEnabled) {
350            docState.put(KEY_SYS_CHANGE_TOKEN, INITIAL_SYS_CHANGE_TOKEN);
351        }
352        // update read acls for new doc
353        updateDocumentReadAcls(id);
354        return docState;
355    }
356
357    /** Gets ancestors including id itself. */
358    protected Object[] getAncestorIds(String id) {
359        if (id == null) {
360            return null;
361        }
362        State state = getStateForRead(id);
363        if (state == null) {
364            throw new RuntimeException("No such id: " + id);
365        }
366        Object[] ancestors = (Object[]) state.get(KEY_ANCESTOR_IDS);
367        if (ancestors == null) {
368            return new Object[] { id };
369        } else {
370            Object[] newAncestors = new Object[ancestors.length + 1];
371            System.arraycopy(ancestors, 0, newAncestors, 0, ancestors.length);
372            newAncestors[ancestors.length] = id;
373            return newAncestors;
374        }
375    }
376
377    /**
378     * Copies the document into a newly-created object.
379     * <p>
380     * The copy is automatically saved.
381     */
382    public DBSDocumentState copy(String id) {
383        DBSDocumentState copyState = new DBSDocumentState(getStateForRead(id));
384        String copyId = repository.generateNewId();
385        copyState.put(KEY_ID, copyId);
386        copyState.put(KEY_PROXY_IDS, null); // no proxies to this new doc
387        // other fields updated by the caller
388        transientStates.put(copyId, copyState);
389        transientCreated.add(copyId);
390        return copyState;
391    }
392
393    /**
394     * Updates ancestors recursively after a move.
395     * <p>
396     * Recursing from given doc, replace the first ndel ancestors with those passed.
397     * <p>
398     * Doesn't check transient (assumes save is done). The modifications are automatically saved.
399     */
400    public void updateAncestors(String id, int ndel, Object[] ancestorIds) {
401        int nadd = ancestorIds.length;
402        Set<String> ids = getSubTree(id, null, null);
403        ids.add(id);
404        for (String cid : ids) {
405            // XXX TODO oneShot update, don't pollute transient space
406            DBSDocumentState docState = getStateForUpdate(cid);
407            Object[] ancestors = (Object[]) docState.get(KEY_ANCESTOR_IDS);
408            Object[] newAncestors;
409            if (ancestors == null) {
410                newAncestors = ancestorIds.clone();
411            } else {
412                newAncestors = new Object[ancestors.length - ndel + nadd];
413                System.arraycopy(ancestorIds, 0, newAncestors, 0, nadd);
414                System.arraycopy(ancestors, ndel, newAncestors, nadd, ancestors.length - ndel);
415            }
416            docState.put(KEY_ANCESTOR_IDS, newAncestors);
417        }
418    }
419
420    /**
421     * Updates the Read ACLs recursively on a document.
422     */
423    public void updateTreeReadAcls(String id) {
424        // versions too XXX TODO
425        Set<String> ids = getSubTree(id, null, null);
426        ids.add(id);
427        ids.forEach(this::updateDocumentReadAcls);
428    }
429
430    /**
431     * Updates the Read ACLs on a document (not recursively)
432     */
433    protected void updateDocumentReadAcls(String id) {
434        // XXX TODO oneShot update, don't pollute transient space
435        DBSDocumentState docState = getStateForUpdate(id);
436        docState.put(KEY_READ_ACL, getReadACL(docState));
437    }
438
439    /**
440     * Gets the Read ACL (flat list of users having browse permission, including inheritance) on a document.
441     */
442    protected String[] getReadACL(DBSDocumentState docState) {
443        Set<String> racls = new HashSet<>();
444        State state = docState.getState();
445        LOOP: do {
446            @SuppressWarnings("unchecked")
447            List<Serializable> aclList = (List<Serializable>) state.get(KEY_ACP);
448            if (aclList != null) {
449                for (Serializable aclSer : aclList) {
450                    State aclMap = (State) aclSer;
451                    @SuppressWarnings("unchecked")
452                    List<Serializable> aceList = (List<Serializable>) aclMap.get(KEY_ACL);
453                    for (Serializable aceSer : aceList) {
454                        State aceMap = (State) aceSer;
455                        String username = (String) aceMap.get(KEY_ACE_USER);
456                        String permission = (String) aceMap.get(KEY_ACE_PERMISSION);
457                        Boolean granted = (Boolean) aceMap.get(KEY_ACE_GRANT);
458                        if (TRUE.equals(granted) && browsePermissions.contains(permission)) {
459                            racls.add(username);
460                        }
461                        if (FALSE.equals(granted)) {
462                            if (!EVERYONE.equals(username)) {
463                                // TODO log
464                                racls.add(UNSUPPORTED_ACL);
465                            }
466                            break LOOP;
467                        }
468                    }
469                }
470            }
471            // get parent
472            if (TRUE.equals(state.get(KEY_IS_VERSION))) {
473                String versionSeriesId = (String) state.get(KEY_VERSION_SERIES_ID);
474                state = versionSeriesId == null ? null : getStateForRead(versionSeriesId);
475            } else {
476                String parentId = (String) state.get(KEY_PARENT_ID);
477                state = parentId == null ? null : getStateForRead(parentId);
478            }
479        } while (state != null);
480
481        // sort to have canonical order
482        List<String> racl = new ArrayList<>(racls);
483        Collections.sort(racl);
484        return racl.toArray(new String[racl.size()]);
485    }
486
487    /**
488     * Gets all the ids under a given one, recursively.
489     * <p>
490     * Doesn't check transient (assumes save is done).
491     *
492     * @param id the root of the tree (not included in results)
493     * @param proxyTargets returns a map of proxy to target among the documents found
494     * @param targetProxies returns a map of target to proxies among the document found
495     */
496    protected Set<String> getSubTree(String id, Map<String, String> proxyTargets, Map<String, Object[]> targetProxies) {
497        Set<String> ids = new HashSet<>();
498        // check repository
499        repository.queryKeyValueArray(KEY_ANCESTOR_IDS, id, ids, proxyTargets, targetProxies);
500        return ids;
501    }
502
503    public List<DBSDocumentState> getKeyValuedStates(String key, Object value) {
504        List<DBSDocumentState> docStates = new LinkedList<>();
505        Set<String> seen = new HashSet<>();
506        // check transient state
507        for (DBSDocumentState docState : transientStates.values()) {
508            if (!value.equals(docState.get(key))) {
509                continue;
510            }
511            docStates.add(docState);
512            seen.add(docState.getId());
513        }
514        // fetch from repository
515        List<State> states = repository.queryKeyValue(key, value, seen);
516        for (State state : states) {
517            docStates.add(newTransientState(state));
518        }
519        return docStates;
520    }
521
522    public List<DBSDocumentState> getKeyValuedStates(String key1, Object value1, String key2, Object value2) {
523        List<DBSDocumentState> docStates = new LinkedList<>();
524        Set<String> seen = new HashSet<>();
525        // check transient state
526        for (DBSDocumentState docState : transientStates.values()) {
527            seen.add(docState.getId());
528            if (!(value1.equals(docState.get(key1)) && value2.equals(docState.get(key2)))) {
529                continue;
530            }
531            docStates.add(docState);
532        }
533        // fetch from repository
534        List<State> states = repository.queryKeyValue(key1, value1, key2, value2, seen);
535        for (State state : states) {
536            docStates.add(newTransientState(state));
537        }
538        return docStates;
539    }
540
541    /**
542     * Removes a list of documents.
543     * <p>
544     * Called after a {@link #save} has been done.
545     */
546    public void removeStates(Set<String> ids) {
547        if (undoLog != null) {
548            for (String id : ids) {
549                if (undoLog.containsKey(id)) {
550                    // there's already a create or an update in the undo log
551                    State oldUndo = undoLog.get(id);
552                    if (oldUndo == null) {
553                        // create + delete -> forget
554                        undoLog.remove(id);
555                    } else {
556                        // update + delete -> original old state to re-create
557                        oldUndo.put(KEY_UNDOLOG_CREATE, TRUE);
558                    }
559                } else {
560                    // just delete -> store old state to re-create
561                    State oldState = StateHelper.deepCopy(getStateForRead(id));
562                    oldState.put(KEY_UNDOLOG_CREATE, TRUE);
563                    undoLog.put(id, oldState);
564                }
565            }
566        }
567        for (String id : ids) {
568            transientStates.remove(id);
569        }
570        repository.deleteStates(ids);
571    }
572
573    public void markUserChange(String id) {
574        userChangeIds.add(id);
575    }
576
577    /**
578     * Writes transient state to database.
579     * <p>
580     * An undo log is kept in order to rollback the transaction later if needed.
581     */
582    public void save() {
583        updateProxies();
584        List<Work> works;
585        if (!repository.isFulltextDisabled()) {
586            // TODO getting fulltext already does a getStateChange
587            works = getFulltextWorks();
588        } else {
589            works = Collections.emptyList();
590        }
591        List<State> statesToCreate = new ArrayList<>();
592        for (String id : transientCreated) { // ordered
593            DBSDocumentState docState = transientStates.get(id);
594            docState.setNotDirty();
595            if (undoLog != null) {
596                undoLog.put(id, null); // marker to denote create
597            }
598            State state = docState.getState();
599            state.put(KEY_CHANGE_TOKEN, INITIAL_CHANGE_TOKEN);
600            statesToCreate.add(state);
601        }
602        if (!statesToCreate.isEmpty()) {
603            repository.createStates(statesToCreate);
604        }
605        for (DBSDocumentState docState : transientStates.values()) {
606            String id = docState.getId();
607            if (transientCreated.contains(id)) {
608                continue; // already done
609            }
610            StateDiff diff = docState.getStateChange();
611            if (diff != null) {
612                if (undoLog != null) {
613                    if (!undoLog.containsKey(id)) {
614                        undoLog.put(id, StateHelper.deepCopy(docState.getOriginalState()));
615                    }
616                    // else there's already a create or an update in the undo log so original info is enough
617                }
618                ChangeTokenUpdater changeTokenUpdater;
619                if (session.changeTokenEnabled) {
620                    // increment system change token
621                    Long base = (Long) docState.get(KEY_SYS_CHANGE_TOKEN);
622                    docState.put(KEY_SYS_CHANGE_TOKEN, DeltaLong.valueOf(base, 1));
623                    diff.put(KEY_SYS_CHANGE_TOKEN, DeltaLong.valueOf(base, 1));
624                    // update change token if applicable (user change)
625                    if (userChangeIds.contains(id)) {
626                        changeTokenUpdater = new ChangeTokenUpdater(docState);
627                    } else {
628                        changeTokenUpdater = null;
629                    }
630                } else {
631                    changeTokenUpdater = null;
632                }
633                repository.updateState(id, diff, changeTokenUpdater);
634            }
635            docState.setNotDirty();
636        }
637        transientCreated.clear();
638        userChangeIds.clear();
639        scheduleWork(works);
640    }
641
642    /**
643     * Logic to get the conditions to use to match and update a change token.
644     * <p>
645     * This may be called several times for a single DBS document update, because the low-level storage may need several
646     * database updates for a single high-level update in some cases.
647     *
648     * @since 9.1
649     */
650    public static class ChangeTokenUpdater {
651
652        protected final DBSDocumentState docState;
653
654        protected Long oldToken;
655
656        public ChangeTokenUpdater(DBSDocumentState docState) {
657            this.docState = docState;
658            oldToken = (Long) docState.getOriginalState().get(KEY_CHANGE_TOKEN);
659        }
660
661        /**
662         * Gets the conditions to use to match a change token.
663         */
664        public Map<String, Serializable> getConditions() {
665            return Collections.singletonMap(KEY_CHANGE_TOKEN, oldToken);
666        }
667
668        /**
669         * Gets the updates to make to write the updated change token.
670         */
671        public Map<String, Serializable> getUpdates() {
672            Long newToken;
673            if (oldToken == null) {
674                // document without change token, just created
675                newToken = INITIAL_CHANGE_TOKEN;
676            } else {
677                newToken = BaseDocument.updateChangeToken(oldToken);
678            }
679            // also store the new token in the state (without marking dirty), for the next update
680            docState.getState().put(KEY_CHANGE_TOKEN, newToken);
681            oldToken = newToken;
682            return Collections.singletonMap(KEY_CHANGE_TOKEN, newToken);
683        }
684    }
685
686    protected void applyUndoLog() {
687        Set<String> deletes = new HashSet<>();
688        for (Entry<String, State> es : undoLog.entrySet()) {
689            String id = es.getKey();
690            State state = es.getValue();
691            if (state == null) {
692                deletes.add(id);
693            } else {
694                boolean recreate = state.remove(KEY_UNDOLOG_CREATE) != null;
695                if (recreate) {
696                    repository.createState(state);
697                } else {
698                    // undo update
699                    State currentState = repository.readState(id);
700                    if (currentState != null) {
701                        StateDiff diff = StateHelper.diff(currentState, state);
702                        if (!diff.isEmpty()) {
703                            repository.updateState(id, diff, null);
704                        }
705                    }
706                    // else we expected to read a current state but it was concurrently deleted...
707                    // in that case leave it deleted
708                }
709            }
710        }
711        if (!deletes.isEmpty()) {
712            repository.deleteStates(deletes);
713        }
714    }
715
716    /**
717     * Checks if the changed documents are proxy targets, and updates the proxies if that's the case.
718     */
719    protected void updateProxies() {
720        for (String id : transientCreated) { // ordered
721            DBSDocumentState docState = transientStates.get(id);
722            updateProxies(docState);
723        }
724        // copy as we may modify proxies
725        for (String id : transientStates.keySet().toArray(new String[0])) {
726            DBSDocumentState docState = transientStates.get(id);
727            if (transientCreated.contains(id)) {
728                continue; // already done
729            }
730            if (docState.isDirty()) {
731                updateProxies(docState);
732            }
733        }
734    }
735
736    protected void updateProxies(DBSDocumentState target) {
737        Object[] proxyIds = (Object[]) target.get(KEY_PROXY_IDS);
738        if (proxyIds != null) {
739            for (Object proxyId : proxyIds) {
740                try {
741                    updateProxy(target, (String) proxyId);
742                } catch (ConcurrentUpdateException e) {
743                    e.addInfo("On doc " + target.getId());
744                    log.error(e, e);
745                    // do not throw, this avoids crashing the session
746                }
747            }
748        }
749    }
750
751    /**
752     * Updates the state of a proxy based on its target.
753     */
754    protected void updateProxy(DBSDocumentState target, String proxyId) {
755        DBSDocumentState proxy = getStateForUpdate(proxyId);
756        if (proxy == null) {
757            throw new ConcurrentUpdateException("Proxy " + proxyId + " concurrently deleted");
758        }
759        SchemaManager schemaManager = Framework.getService(SchemaManager.class);
760        // clear all proxy data
761        for (String key : proxy.getState().keyArray()) {
762            if (!isProxySpecific(key, schemaManager)) {
763                proxy.put(key, null);
764            }
765        }
766        // copy from target
767        for (Entry<String, Serializable> en : target.getState().entrySet()) {
768            String key = en.getKey();
769            if (!isProxySpecific(key, schemaManager)) {
770                proxy.put(key, StateHelper.deepCopy(en.getValue()));
771            }
772        }
773    }
774
775    /**
776     * Things that we don't touch on a proxy when updating it.
777     */
778    protected boolean isProxySpecific(String key, SchemaManager schemaManager) {
779        switch (key) {
780        // these are placeful stuff
781        case KEY_ID:
782        case KEY_PARENT_ID:
783        case KEY_ANCESTOR_IDS:
784        case KEY_NAME:
785        case KEY_POS:
786        case KEY_ACP:
787        case KEY_READ_ACL:
788            // these are proxy-specific
789        case KEY_IS_PROXY:
790        case KEY_PROXY_TARGET_ID:
791        case KEY_PROXY_VERSION_SERIES_ID:
792        case KEY_IS_VERSION:
793        case KEY_PROXY_IDS:
794            return true;
795        }
796        int p = key.indexOf(':');
797        if (p == -1) {
798            // no prefix, assume not proxy-specific
799            return false;
800        }
801        String prefix = key.substring(0, p);
802        Schema schema = schemaManager.getSchemaFromPrefix(prefix);
803        if (schema == null) {
804            schema = schemaManager.getSchema(prefix);
805            if (schema == null) {
806                // unknown prefix, assume not proxy-specific
807                return false;
808            }
809        }
810        return schemaManager.isProxySchema(schema.getName(), null); // type unused
811    }
812
813    /**
814     * Called when created in a transaction.
815     *
816     * @since 7.4
817     */
818    public void begin() {
819        undoLog = new HashMap<>();
820        repository.begin();
821    }
822
823    /**
824     * Saves and flushes to database.
825     */
826    public void commit() {
827        save();
828        commitSave();
829        repository.commit();
830    }
831
832    /**
833     * Commits the saved state to the database.
834     */
835    protected void commitSave() {
836        // clear transient, this means that after this references to states will be stale
837        // TODO mark states as invalid
838        clearTransient();
839        // the transaction ended, the proxied DBSSession will disappear and cannot be reused anyway
840        undoLog = null;
841    }
842
843    /**
844     * Rolls back the save state by applying the undo log.
845     */
846    public void rollback() {
847        clearTransient();
848        applyUndoLog();
849        // the transaction ended, the proxied DBSSession will disappear and cannot be reused anyway
850        undoLog = null;
851        repository.rollback();
852    }
853
854    protected void clearTransient() {
855        transientStates.clear();
856        transientCreated.clear();
857    }
858
859    /**
860     * Gets the fulltext updates to do. Called at save() time.
861     *
862     * @return a list of {@link Work} instances to schedule post-commit.
863     */
864    protected List<Work> getFulltextWorks() {
865        Set<String> docsWithDirtyStrings = new HashSet<>();
866        Set<String> docsWithDirtyBinaries = new HashSet<>();
867        findDirtyDocuments(docsWithDirtyStrings, docsWithDirtyBinaries);
868        if (docsWithDirtyStrings.isEmpty() && docsWithDirtyBinaries.isEmpty()) {
869            return Collections.emptyList();
870        }
871        List<Work> works = new LinkedList<>();
872        getFulltextSimpleWorks(works, docsWithDirtyStrings);
873        getFulltextBinariesWorks(works, docsWithDirtyBinaries);
874        return works;
875    }
876
877    /**
878     * Finds the documents having dirty text or dirty binaries that have to be reindexed as fulltext.
879     *
880     * @param docsWithDirtyStrings set of ids, updated by this method
881     * @param docWithDirtyBinaries set of ids, updated by this method
882     */
883    protected void findDirtyDocuments(Set<String> docsWithDirtyStrings, Set<String> docWithDirtyBinaries) {
884        for (DBSDocumentState docState : transientStates.values()) {
885            State originalState = docState.getOriginalState();
886            State state = docState.getState();
887            if (originalState == state) {
888                continue;
889            }
890            StateDiff diff = StateHelper.diff(originalState, state);
891            if (diff.isEmpty()) {
892                continue;
893            }
894            StateDiff rdiff = StateHelper.diff(state, originalState);
895            // we do diffs in both directions to capture removal of complex list elements,
896            // for instance for {foo: [{bar: baz}] -> {foo: []}
897            // diff paths = foo and rdiff paths = foo/*/bar
898            Set<String> paths = new HashSet<>();
899            DirtyPathsFinder dirtyPathsFinder = new DirtyPathsFinder(paths);
900            dirtyPathsFinder.findDirtyPaths(diff);
901            dirtyPathsFinder.findDirtyPaths(rdiff);
902            FulltextConfiguration fulltextConfiguration = repository.getFulltextConfiguration();
903            boolean dirtyStrings = false;
904            boolean dirtyBinaries = false;
905            for (String path : paths) {
906                Set<String> indexesSimple = fulltextConfiguration.indexesByPropPathSimple.get(path);
907                if (indexesSimple != null && !indexesSimple.isEmpty()) {
908                    dirtyStrings = true;
909                    if (dirtyBinaries) {
910                        break;
911                    }
912                }
913                Set<String> indexesBinary = fulltextConfiguration.indexesByPropPathBinary.get(path);
914                if (indexesBinary != null && !indexesBinary.isEmpty()) {
915                    dirtyBinaries = true;
916                    if (dirtyStrings) {
917                        break;
918                    }
919                }
920            }
921            if (dirtyStrings) {
922                docsWithDirtyStrings.add(docState.getId());
923            }
924            if (dirtyBinaries) {
925                docWithDirtyBinaries.add(docState.getId());
926            }
927        }
928    }
929
930    /**
931     * Iterates on a state diff to find the paths corresponding to dirty values.
932     *
933     * @since 7.10-HF04, 8.1
934     */
935    protected static class DirtyPathsFinder {
936
937        protected Set<String> paths;
938
939        public DirtyPathsFinder(Set<String> paths) {
940            this.paths = paths;
941        }
942
943        public void findDirtyPaths(StateDiff value) {
944            findDirtyPaths(value, null);
945        }
946
947        protected void findDirtyPaths(Object value, String path) {
948            if (value instanceof Object[]) {
949                findDirtyPaths((Object[]) value, path);
950            } else if (value instanceof List) {
951                findDirtyPaths((List<?>) value, path);
952            } else if (value instanceof ListDiff) {
953                findDirtyPaths((ListDiff) value, path);
954            } else if (value instanceof State) {
955                findDirtyPaths((State) value, path);
956            } else {
957                paths.add(path);
958            }
959        }
960
961        protected void findDirtyPaths(Object[] value, String path) {
962            String newPath = path + "/*";
963            for (Object v : value) {
964                findDirtyPaths(v, newPath);
965            }
966        }
967
968        protected void findDirtyPaths(List<?> value, String path) {
969            String newPath = path + "/*";
970            for (Object v : value) {
971                findDirtyPaths(v, newPath);
972            }
973        }
974
975        protected void findDirtyPaths(ListDiff value, String path) {
976            String newPath = path + "/*";
977            if (value.diff != null) {
978                findDirtyPaths(value.diff, newPath);
979            }
980            if (value.rpush != null) {
981                findDirtyPaths(value.rpush, newPath);
982            }
983        }
984
985        protected void findDirtyPaths(State value, String path) {
986            for (Entry<String, Serializable> es : value.entrySet()) {
987                String key = es.getKey();
988                Serializable v = es.getValue();
989                String newPath = path == null ? key : path + "/" + key;
990                findDirtyPaths(v, newPath);
991            }
992        }
993    }
994
995    protected void getFulltextSimpleWorks(List<Work> works, Set<String> docsWithDirtyStrings) {
996        // TODO XXX make configurable, see also FulltextExtractorWork
997        FulltextParser fulltextParser = new DefaultFulltextParser();
998        FulltextConfiguration fulltextConfiguration = repository.getFulltextConfiguration();
999        if (fulltextConfiguration.fulltextSearchDisabled) {
1000            return;
1001        }
1002        // update simpletext on documents with dirty strings
1003        for (String id : docsWithDirtyStrings) {
1004            if (id == null) {
1005                // cannot happen, but has been observed :(
1006                log.error("Got null doc id in fulltext update, cannot happen");
1007                continue;
1008            }
1009            DBSDocumentState docState = getStateForUpdate(id);
1010            if (docState == null) {
1011                // cannot happen
1012                continue;
1013            }
1014            String documentType = docState.getPrimaryType();
1015            // Object[] mixinTypes = (Object[]) docState.get(KEY_MIXIN_TYPES);
1016
1017            if (!fulltextConfiguration.isFulltextIndexable(documentType)) {
1018                continue;
1019            }
1020            docState.put(KEY_FULLTEXT_JOBID, docState.getId());
1021            FulltextFinder fulltextFinder = new FulltextFinder(fulltextParser, docState, session);
1022            List<IndexAndText> indexesAndText = new LinkedList<>();
1023            for (String indexName : fulltextConfiguration.indexNames) {
1024                // TODO paths from config
1025                String text = fulltextFinder.findFulltext(indexName);
1026                indexesAndText.add(new IndexAndText(indexName, text));
1027            }
1028            if (!indexesAndText.isEmpty()) {
1029                Work work = new FulltextUpdaterWork(repository.getName(), id, true, false, indexesAndText);
1030                works.add(work);
1031            }
1032        }
1033    }
1034
1035    protected void getFulltextBinariesWorks(List<Work> works, Set<String> docWithDirtyBinaries) {
1036        if (docWithDirtyBinaries.isEmpty()) {
1037            return;
1038        }
1039
1040        FulltextConfiguration fulltextConfiguration = repository.getFulltextConfiguration();
1041
1042        // mark indexing in progress, so that future copies (including versions)
1043        // will be indexed as well
1044        for (String id : docWithDirtyBinaries) {
1045            DBSDocumentState docState = getStateForUpdate(id);
1046            if (docState == null) {
1047                // cannot happen
1048                continue;
1049            }
1050            if (!fulltextConfiguration.isFulltextIndexable(docState.getPrimaryType())) {
1051                continue;
1052            }
1053            docState.put(KEY_FULLTEXT_JOBID, docState.getId());
1054        }
1055
1056        // FulltextExtractorWork does fulltext extraction using converters
1057        // and then schedules a FulltextUpdaterWork to write the results
1058        // single-threaded
1059        for (String id : docWithDirtyBinaries) {
1060            // don't exclude proxies
1061            Work work = new DBSFulltextExtractorWork(repository.getName(), id);
1062            works.add(work);
1063        }
1064    }
1065
1066    protected static class FulltextFinder {
1067
1068        protected final FulltextParser fulltextParser;
1069
1070        protected final DBSDocumentState document;
1071
1072        protected final DBSSession session;
1073
1074        protected final String documentType;
1075
1076        protected final Object[] mixinTypes;
1077
1078        /**
1079         * Prepares parsing for one document.
1080         */
1081        public FulltextFinder(FulltextParser fulltextParser, DBSDocumentState document, DBSSession session) {
1082            this.fulltextParser = fulltextParser;
1083            this.document = document;
1084            this.session = session;
1085            if (document == null) {
1086                documentType = null;
1087                mixinTypes = null;
1088            } else { // null in tests
1089                documentType = document.getPrimaryType();
1090                mixinTypes = (Object[]) document.get(KEY_MIXIN_TYPES);
1091            }
1092        }
1093
1094        /**
1095         * Parses the document for one index.
1096         */
1097        public String findFulltext(String indexName) {
1098            // TODO indexName
1099            // TODO paths
1100            List<String> strings = new ArrayList<>();
1101            findFulltext(indexName, document.getState(), strings);
1102            return StringUtils.join(strings, ' ');
1103        }
1104
1105        protected void findFulltext(String indexName, State state, List<String> strings) {
1106            for (Entry<String, Serializable> en : state.entrySet()) {
1107                String key = en.getKey();
1108                if (key.startsWith(KEY_PREFIX)) {
1109                    switch (key) {
1110                    // allow indexing of this:
1111                    case DBSDocument.KEY_NAME:
1112                        break;
1113                    default:
1114                        continue;
1115                    }
1116                }
1117                Serializable value = en.getValue();
1118                if (value instanceof State) {
1119                    State s = (State) value;
1120                    findFulltext(indexName, s, strings);
1121                } else if (value instanceof List) {
1122                    @SuppressWarnings("unchecked")
1123                    List<State> v = (List<State>) value;
1124                    for (State s : v) {
1125                        findFulltext(indexName, s, strings);
1126                    }
1127                } else if (value instanceof Object[]) {
1128                    Object[] ar = (Object[]) value;
1129                    for (Object v : ar) {
1130                        if (v instanceof String) {
1131                            fulltextParser.parse((String) v, null, strings);
1132                        } else {
1133                            // arrays are homogeneous, no need to continue
1134                            break;
1135                        }
1136                    }
1137                } else {
1138                    if (value instanceof String) {
1139                        fulltextParser.parse((String) value, null, strings);
1140                    }
1141                }
1142            }
1143        }
1144    }
1145
1146    protected void scheduleWork(List<Work> works) {
1147        // do async fulltext indexing only if high-level sessions are available
1148        RepositoryManager repositoryManager = Framework.getLocalService(RepositoryManager.class);
1149        if (repositoryManager != null && !works.isEmpty()) {
1150            WorkManager workManager = Framework.getLocalService(WorkManager.class);
1151            for (Work work : works) {
1152                // schedule work post-commit
1153                // in non-tx mode, this may execute it nearly immediately
1154                workManager.schedule(work, Scheduling.IF_NOT_SCHEDULED, true);
1155            }
1156        }
1157    }
1158
1159}