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