001/*
002 * (C) Copyright 2014-2019 Nuxeo (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Florent Guillaume
018 */
019package org.nuxeo.ecm.core.storage.dbs;
020
021import static java.lang.Boolean.TRUE;
022
023import java.io.Serializable;
024import java.util.ArrayList;
025import java.util.Arrays;
026import java.util.Calendar;
027import java.util.Collection;
028import java.util.Collections;
029import java.util.HashMap;
030import java.util.HashSet;
031import java.util.LinkedList;
032import java.util.List;
033import java.util.Map;
034import java.util.Set;
035import java.util.function.Consumer;
036
037import org.apache.commons.lang3.StringUtils;
038import org.nuxeo.ecm.core.api.CoreSession;
039import org.nuxeo.ecm.core.api.DocumentNotFoundException;
040import org.nuxeo.ecm.core.api.LifeCycleException;
041import org.nuxeo.ecm.core.api.Lock;
042import org.nuxeo.ecm.core.api.NuxeoException;
043import org.nuxeo.ecm.core.api.NuxeoPrincipal;
044import org.nuxeo.ecm.core.api.PropertyException;
045import org.nuxeo.ecm.core.api.lock.LockManager;
046import org.nuxeo.ecm.core.api.model.DocumentPart;
047import org.nuxeo.ecm.core.api.model.Property;
048import org.nuxeo.ecm.core.api.model.PropertyNotFoundException;
049import org.nuxeo.ecm.core.api.model.ReadOnlyPropertyException;
050import org.nuxeo.ecm.core.api.model.VersionNotModifiableException;
051import org.nuxeo.ecm.core.api.model.impl.ComplexProperty;
052import org.nuxeo.ecm.core.blob.DocumentBlobManager;
053import org.nuxeo.ecm.core.lifecycle.LifeCycle;
054import org.nuxeo.ecm.core.lifecycle.LifeCycleService;
055import org.nuxeo.ecm.core.model.Document;
056import org.nuxeo.ecm.core.model.Session;
057import org.nuxeo.ecm.core.schema.DocumentType;
058import org.nuxeo.ecm.core.schema.SchemaManager;
059import org.nuxeo.ecm.core.schema.types.CompositeType;
060import org.nuxeo.ecm.core.schema.types.Field;
061import org.nuxeo.ecm.core.schema.types.Schema;
062import org.nuxeo.ecm.core.schema.types.Type;
063import org.nuxeo.ecm.core.storage.BaseDocument;
064import org.nuxeo.ecm.core.storage.State;
065import org.nuxeo.runtime.api.Framework;
066
067/**
068 * Implementation of a {@link Document} for Document-Based Storage. The document is stored as a JSON-like Map. The keys
069 * of the Map are the property names (including special names for system properties), and the values Map are
070 * Serializable values, either:
071 * <ul>
072 * <li>a scalar (String, Long, Double, Boolean, Calendar, Binary),
073 * <li>an array of scalars,
074 * <li>a List of Maps, recursively,
075 * <li>or another Map, recursively.
076 * </ul>
077 * An ACP value is stored as a list of maps. Each map has a keys for the ACL name and the actual ACL which is a list of
078 * ACEs. An ACE is a map having as keys username, permission, and grant.
079 *
080 * @since 5.9.4
081 */
082public class DBSDocument extends BaseDocument<State> {
083
084    private static final Long ZERO = Long.valueOf(0);
085
086    public static final String SYSPROP_FULLTEXT_SIMPLE = "fulltextSimple";
087
088    public static final String SYSPROP_FULLTEXT_BINARY = "fulltextBinary";
089
090    public static final String SYSPROP_FULLTEXT_JOBID = "fulltextJobId";
091
092    public static final String SYSPROP_IS_TRASHED = "isTrashed";
093
094    public static final String KEY_PREFIX = "ecm:";
095
096    public static final String KEY_ID = "ecm:id";
097
098    public static final String KEY_PARENT_ID = "ecm:parentId";
099
100    public static final String KEY_ANCESTOR_IDS = "ecm:ancestorIds";
101
102    public static final String KEY_PRIMARY_TYPE = "ecm:primaryType";
103
104    public static final String KEY_MIXIN_TYPES = "ecm:mixinTypes";
105
106    public static final String KEY_NAME = "ecm:name";
107
108    public static final String KEY_POS = "ecm:pos";
109
110    public static final String KEY_ACP = "ecm:acp";
111
112    public static final String KEY_ACL_NAME = "name";
113
114    public static final String KEY_PATH_INTERNAL = "ecm:__path";
115
116    public static final String KEY_ACL = "acl";
117
118    public static final String KEY_ACE_USER = "user";
119
120    public static final String KEY_ACE_PERMISSION = "perm";
121
122    public static final String KEY_ACE_GRANT = "grant";
123
124    public static final String KEY_ACE_CREATOR = "creator";
125
126    public static final String KEY_ACE_BEGIN = "begin";
127
128    public static final String KEY_ACE_END = "end";
129
130    public static final String KEY_ACE_STATUS = "status";
131
132    public static final String KEY_READ_ACL = "ecm:racl";
133
134    /** @since 11.1 */
135    public static final String KEY_IS_RECORD = "ecm:isRecord";
136
137    /** @since 11.1 */
138    public static final String KEY_RETAIN_UNTIL = "ecm:retainUntil";
139
140    /** @since 11.1 */
141    public static final String KEY_HAS_LEGAL_HOLD = "ecm:hasLegalHold";
142
143    /** @deprecated since 11.1 */
144    @Deprecated
145    public static final String KEY_IS_RETENTION_ACTIVE = "ecm:isRetentionActive";
146
147    public static final String KEY_IS_CHECKED_IN = "ecm:isCheckedIn";
148
149    public static final String KEY_IS_VERSION = "ecm:isVersion";
150
151    public static final String KEY_IS_LATEST_VERSION = "ecm:isLatestVersion";
152
153    public static final String KEY_IS_LATEST_MAJOR_VERSION = "ecm:isLatestMajorVersion";
154
155    public static final String KEY_MAJOR_VERSION = "ecm:majorVersion";
156
157    public static final String KEY_MINOR_VERSION = "ecm:minorVersion";
158
159    public static final String KEY_VERSION_SERIES_ID = "ecm:versionSeriesId";
160
161    public static final String KEY_VERSION_CREATED = "ecm:versionCreated";
162
163    public static final String KEY_VERSION_LABEL = "ecm:versionLabel";
164
165    public static final String KEY_VERSION_DESCRIPTION = "ecm:versionDescription";
166
167    public static final String KEY_BASE_VERSION_ID = "ecm:baseVersionId";
168
169    public static final String KEY_IS_PROXY = "ecm:isProxy";
170
171    public static final String KEY_PROXY_TARGET_ID = "ecm:proxyTargetId";
172
173    public static final String KEY_PROXY_VERSION_SERIES_ID = "ecm:proxyVersionSeriesId";
174
175    public static final String KEY_PROXY_IDS = "ecm:proxyIds";
176
177    public static final String KEY_LIFECYCLE_POLICY = "ecm:lifeCyclePolicy";
178
179    public static final String KEY_LIFECYCLE_STATE = "ecm:lifeCycleState";
180
181    /**
182     * @since 10.1
183     */
184    public static final String KEY_IS_TRASHED = "ecm:isTrashed";
185
186    public static final String KEY_LOCK_OWNER = "ecm:lockOwner";
187
188    public static final String KEY_LOCK_CREATED = "ecm:lockCreated";
189
190    public static final String KEY_SYS_CHANGE_TOKEN = "ecm:systemChangeToken";
191
192    public static final String KEY_CHANGE_TOKEN = "ecm:changeToken";
193
194    // used instead of ecm:changeToken when change tokens are disabled
195    public static final String KEY_DC_MODIFIED = "dc:modified";
196
197    public static final String KEY_BLOB_NAME = "name";
198
199    public static final String KEY_BLOB_MIME_TYPE = "mime-type";
200
201    public static final String KEY_BLOB_ENCODING = "encoding";
202
203    public static final String KEY_BLOB_DIGEST = "digest";
204
205    public static final String KEY_BLOB_LENGTH = "length";
206
207    public static final String KEY_BLOB_DATA = "data";
208
209    public static final String KEY_FULLTEXT_SIMPLE = "ecm:fulltextSimple";
210
211    public static final String KEY_FULLTEXT_BINARY = "ecm:fulltextBinary";
212
213    public static final String KEY_FULLTEXT_JOBID = "ecm:fulltextJobId";
214
215    public static final String KEY_FULLTEXT_SCORE = "ecm:fulltextScore";
216
217    public static final String APPLICATION_OCTET_STREAM = "application/octet-stream";
218
219    public static final String PROP_UID_MAJOR_VERSION = "uid:major_version";
220
221    public static final String PROP_UID_MINOR_VERSION = "uid:minor_version";
222
223    public static final String PROP_MAJOR_VERSION = "major_version";
224
225    public static final String PROP_MINOR_VERSION = "minor_version";
226
227    /**
228     * @since 9.3
229     */
230    public static final String FACETED_TAG = "nxtag:tags";
231
232    /**
233     * @since 9.3
234     */
235    public static final String FACETED_TAG_LABEL = "label";
236
237    public static final Long INITIAL_SYS_CHANGE_TOKEN = Long.valueOf(0);
238
239    public static final Long INITIAL_CHANGE_TOKEN = Long.valueOf(0);
240
241    protected final String id;
242
243    protected final DBSDocumentState docState;
244
245    protected final DocumentType type;
246
247    protected final List<Schema> proxySchemas;
248
249    protected final DBSSession session;
250
251    protected boolean readonly;
252
253    protected static final Map<String, String> systemPropNameMap;
254
255    static {
256        systemPropNameMap = new HashMap<>();
257        systemPropNameMap.put(SYSPROP_FULLTEXT_JOBID, KEY_FULLTEXT_JOBID);
258        systemPropNameMap.put(SYSPROP_IS_TRASHED, KEY_IS_TRASHED);
259    }
260
261    public DBSDocument(DBSDocumentState docState, DocumentType type, DBSSession session, boolean readonly) {
262        // no state for NullDocument (parent of placeless children)
263        this.id = docState == null ? null : (String) docState.get(KEY_ID);
264        this.docState = docState;
265        this.type = type;
266        this.session = session;
267        if (docState != null && isProxy()) {
268            SchemaManager schemaManager = Framework.getService(SchemaManager.class);
269            proxySchemas = schemaManager.getProxySchemas(type.getName());
270        } else {
271            proxySchemas = null;
272        }
273        this.readonly = readonly;
274    }
275
276    @Override
277    public DocumentType getType() {
278        return type;
279    }
280
281    @Override
282    public Session getSession() {
283        return session;
284    }
285
286    @Override
287    public String getRepositoryName() {
288        return session.getRepositoryName();
289    }
290
291    @Override
292    protected List<Schema> getProxySchemas() {
293        return proxySchemas;
294    }
295
296    @Override
297    public String getUUID() {
298        return id;
299    }
300
301    @Override
302    public String getName() {
303        return docState.getName();
304    }
305
306    @Override
307    public Long getPos() {
308        return (Long) docState.get(KEY_POS);
309    }
310
311    @Override
312    public Document getParent() {
313        if (isVersion()) {
314            Document workingCopy = session.getDocument(getVersionSeriesId());
315            return workingCopy == null ? null : workingCopy.getParent();
316        }
317        String parentId = docState.getParentId();
318        return parentId == null ? null : session.getDocument(parentId);
319    }
320
321    @Override
322    public boolean isProxy() {
323        return TRUE.equals(docState.get(KEY_IS_PROXY));
324    }
325
326    @Override
327    public boolean isVersion() {
328        return TRUE.equals(docState.get(KEY_IS_VERSION));
329    }
330
331    @Override
332    public String getPath() {
333        if (isVersion()) {
334            Document workingCopy = session.getDocument(getVersionSeriesId());
335            return workingCopy == null ? null : workingCopy.getPath();
336        }
337        String name = getName();
338        Document doc = getParent();
339        if (doc == null) {
340            if ("".equals(name)) {
341                return "/"; // root
342            } else {
343                return name; // placeless, no slash
344            }
345        }
346        LinkedList<String> list = new LinkedList<>();
347        list.addFirst(name);
348        while (doc != null) {
349            list.addFirst(doc.getName());
350            doc = doc.getParent();
351        }
352        return StringUtils.join(list, '/');
353    }
354
355    @Override
356    public Document getChild(String name) {
357        return session.getChild(id, name);
358    }
359
360    @Override
361    public List<Document> getChildren() {
362        return session.getChildren(id);
363    }
364
365    @Override
366    public List<String> getChildrenIds() {
367        return session.getChildrenIds(id);
368    }
369
370    @Override
371    public boolean hasChild(String name) {
372        return session.hasChild(id, name);
373    }
374
375    @Override
376    public boolean hasChildren() {
377        return session.hasChildren(id);
378    }
379
380    @Override
381    public Document addChild(String name, String typeName) {
382        return session.createChild(null, id, name, null, typeName);
383    }
384
385    @Override
386    public void orderBefore(String src, String dest) {
387        Document srcDoc = getChild(src);
388        if (srcDoc == null) {
389            throw new DocumentNotFoundException("Document " + this + " has no child: " + src);
390        }
391        Document destDoc;
392        if (dest == null) {
393            destDoc = null;
394        } else {
395            destDoc = getChild(dest);
396            if (destDoc == null) {
397                throw new DocumentNotFoundException("Document " + this + " has no child: " + dest);
398            }
399        }
400        session.orderBefore(id, srcDoc.getUUID(), destDoc == null ? null : destDoc.getUUID());
401    }
402
403    // simple property only
404    @Override
405    public Serializable getPropertyValue(String name) {
406        DBSDocumentState docState = getStateOrTarget(name);
407        return docState.get(name);
408    }
409
410    // simple property only
411    @Override
412    public void setPropertyValue(String name, Serializable value) {
413        DBSDocumentState docState = getStateOrTarget(name);
414        docState.put(name, value);
415    }
416
417    // helpers for getValue / setValue
418
419    @Override
420    protected State getChild(State state, String name, Type type) {
421        return (State) state.get(name);
422    }
423
424    @Override
425    protected State getChildForWrite(State state, String name, Type type) throws PropertyException {
426        State child = getChild(state, name, type);
427        if (child == null) {
428            state.put(name, child = new State());
429        }
430        return child;
431    }
432
433    @Override
434    protected List<State> getChildAsList(State state, String name) {
435        @SuppressWarnings("unchecked")
436        List<State> list = (List<State>) state.get(name);
437        if (list == null) {
438            list = new ArrayList<>();
439        }
440        return list;
441    }
442
443    @Override
444    protected void updateList(State state, String name, Field field, String xpath, List<Object> values) {
445        List<State> childStates = new ArrayList<>(values.size());
446        int i = 0;
447        for (Object v : values) {
448            State childState = new State();
449            setValueComplex(childState, field, xpath + '/' + i, v);
450            childStates.add(childState);
451            i++;
452        }
453        state.put(name, (Serializable) childStates);
454    }
455
456    @Override
457    protected List<State> updateList(State state, String name, Property property) throws PropertyException {
458        Collection<Property> properties = property.getChildren();
459        int newSize = properties.size();
460        @SuppressWarnings("unchecked")
461        List<State> childStates = (List<State>) state.get(name);
462        if (newSize == 0) {
463            // storage invariant is that empty complex lists are not stored
464            if (childStates != null) {
465                state.put(name, null);
466            }
467            return null;
468        }
469        if (childStates == null) {
470            childStates = new ArrayList<>(newSize);
471            state.put(name, (Serializable) childStates);
472        }
473        int oldSize = childStates.size();
474        // remove extra list elements
475        if (oldSize > newSize) {
476            for (int i = oldSize - 1; i >= newSize; i--) {
477                childStates.remove(i);
478            }
479        }
480        // add new list elements
481        if (oldSize < newSize) {
482            for (int i = oldSize; i < newSize; i++) {
483                childStates.add(new State());
484            }
485        }
486        return childStates;
487    }
488
489    @Override
490    public Object getValue(String xpath) throws PropertyException {
491        DBSDocumentState docState = getStateOrTarget(xpath);
492        return getValueObject(docState.getState(), xpath);
493    }
494
495    @Override
496    public void setValue(String xpath, Object value) throws PropertyException {
497        DBSDocumentState docState = getStateOrTarget(xpath);
498        // markDirty has to be called *before* we change the state
499        docState.markDirty();
500        setValueObject(docState.getState(), xpath, value);
501    }
502
503    @Override
504    public void visitBlobs(Consumer<BlobAccessor> blobVisitor) throws PropertyException {
505        if (isProxy()) {
506            getTargetDocument().visitBlobs(blobVisitor);
507            // fall through for proxy schemas
508        }
509        visitBlobs(docState.getState(), blobVisitor, docState::markDirty);
510    }
511
512    protected DocumentBlobManager getDocumentBlobManager() {
513        return Framework.getService(DocumentBlobManager.class);
514    }
515
516    @Override
517    public void makeRecord() {
518        DBSDocumentState docState = getStateOrTarget();
519        docState.put(KEY_IS_RECORD, TRUE);
520        DBSDocument doc = session.getDocument(docState);
521        getDocumentBlobManager().notifyMakeRecord(doc);
522    }
523
524    @Override
525    public boolean isRecord() {
526        DBSDocumentState docState = getStateOrTarget();
527        return TRUE.equals(docState.get(KEY_IS_RECORD));
528    }
529
530    @Override
531    public void setRetainUntil(Calendar retainUntil) throws PropertyException {
532        DBSDocumentState docState = getStateOrTarget();
533        Calendar current = (Calendar) docState.get(KEY_RETAIN_UNTIL);
534        if (!allowNewRetention(current, retainUntil)) {
535            throw new PropertyException(
536                    "Cannot reduce retention time from: " + (current == null ? "null" : current.toInstant()) + " to: "
537                            + (retainUntil == null ? "null" : retainUntil.toInstant()));
538        }
539        docState.put(KEY_RETAIN_UNTIL, retainUntil);
540        DBSDocument doc = session.getDocument(docState);
541        getDocumentBlobManager().notifySetRetainUntil(doc, retainUntil);
542    }
543
544    @Override
545    public Calendar getRetainUntil() {
546        DBSDocumentState docState = getStateOrTarget();
547        return (Calendar) docState.get(KEY_RETAIN_UNTIL);
548    }
549
550    @Override
551    public void setLegalHold(boolean hold) {
552        DBSDocumentState docState = getStateOrTarget();
553        docState.put(KEY_HAS_LEGAL_HOLD, hold ? TRUE : null);
554        DBSDocument doc = session.getDocument(docState);
555        getDocumentBlobManager().notifySetLegalHold(doc, hold);
556    }
557
558    @Override
559    public boolean hasLegalHold() {
560        DBSDocumentState docState = getStateOrTarget();
561        return TRUE.equals(docState.get(KEY_HAS_LEGAL_HOLD));
562    }
563
564    @Override
565    public boolean isRetentionActive() {
566        DBSDocumentState docState = getStateOrTarget();
567        return TRUE.equals(docState.get(KEY_IS_RETENTION_ACTIVE));
568    }
569
570    @Override
571    public void setRetentionActive(boolean retentionActive) {
572        DBSDocumentState docState = getStateOrTarget();
573        docState.put(KEY_IS_RETENTION_ACTIVE, retentionActive ? TRUE : null);
574    }
575
576    @Override
577    public Document checkIn(String label, String checkinComment) {
578        if (isRecord()) {
579            throw new PropertyException("Record cannot be checked in: " + getUUID());
580        } else if (isProxy()) {
581            return getTargetDocument().checkIn(label, checkinComment);
582        } else if (isVersion()) {
583            throw new VersionNotModifiableException();
584        } else {
585            Document version = session.checkIn(id, label, checkinComment);
586            getDocumentBlobManager().freezeVersion(version);
587            return version;
588        }
589    }
590
591    @Override
592    public void checkOut() {
593        if (isProxy()) {
594            getTargetDocument().checkOut();
595        } else if (isVersion()) {
596            throw new VersionNotModifiableException();
597        } else {
598            session.checkOut(id);
599        }
600    }
601
602    @Override
603    public List<String> getVersionsIds() {
604        return session.getVersionsIds(getVersionSeriesId());
605    }
606
607    @Override
608    public List<Document> getVersions() {
609        List<String> ids = session.getVersionsIds(getVersionSeriesId());
610        List<Document> versions = new ArrayList<>();
611        for (String id : ids) {
612            versions.add(session.getDocument(id));
613        }
614        return versions;
615    }
616
617    @Override
618    public Document getLastVersion() {
619        return session.getLastVersion(getVersionSeriesId());
620    }
621
622    @Override
623    public Document getSourceDocument() {
624        if (isProxy()) {
625            return getTargetDocument();
626        } else if (isVersion()) {
627            return getWorkingCopy();
628        } else {
629            return this;
630        }
631    }
632
633    @Override
634    public void restore(Document version) {
635        if (!version.isVersion()) {
636            throw new NuxeoException("Cannot restore a non-version: " + version);
637        }
638        session.restoreVersion(this, version);
639    }
640
641    @Override
642    public Document getVersion(String label) {
643        DBSDocumentState state = session.getVersionByLabel(getVersionSeriesId(), label);
644        return session.getDocument(state);
645    }
646
647    @Override
648    public Document getBaseVersion() {
649        if (isProxy()) {
650            return getTargetDocument().getBaseVersion();
651        } else if (isVersion()) {
652            return null;
653        } else {
654            if (isCheckedOut()) {
655                return null;
656            } else {
657                String id = (String) docState.get(KEY_BASE_VERSION_ID);
658                if (id == null) {
659                    // shouldn't happen
660                    return null;
661                }
662                return session.getDocument(id);
663            }
664        }
665    }
666
667    @Override
668    public boolean isCheckedOut() {
669        if (isProxy()) {
670            return getTargetDocument().isCheckedOut();
671        } else if (isVersion()) {
672            return false;
673        } else {
674            return !TRUE.equals(docState.get(KEY_IS_CHECKED_IN));
675        }
676    }
677
678    @Override
679    public String getVersionSeriesId() {
680        if (isProxy()) {
681            return (String) docState.get(KEY_PROXY_VERSION_SERIES_ID);
682        } else if (isVersion()) {
683            return (String) docState.get(KEY_VERSION_SERIES_ID);
684        } else {
685            return getUUID();
686        }
687    }
688
689    @Override
690    public Calendar getVersionCreationDate() {
691        DBSDocumentState docState = getStateOrTarget();
692        return (Calendar) docState.get(KEY_VERSION_CREATED);
693    }
694
695    @Override
696    public String getVersionLabel() {
697        DBSDocumentState docState = getStateOrTarget();
698        return (String) docState.get(KEY_VERSION_LABEL);
699    }
700
701    @Override
702    public String getCheckinComment() {
703        DBSDocumentState docState = getStateOrTarget();
704        return (String) docState.get(KEY_VERSION_DESCRIPTION);
705    }
706
707    @Override
708    public boolean isLatestVersion() {
709        return isEqualOnVersion(TRUE, KEY_IS_LATEST_VERSION);
710    }
711
712    @Override
713    public boolean isMajorVersion() {
714        return isEqualOnVersion(ZERO, KEY_MINOR_VERSION);
715    }
716
717    @Override
718    public boolean isLatestMajorVersion() {
719        return isEqualOnVersion(TRUE, KEY_IS_LATEST_MAJOR_VERSION);
720    }
721
722    protected boolean isEqualOnVersion(Object ob, String key) {
723        if (isProxy()) {
724            // TODO avoid getting the target just to check if it's a version
725            // use another specific property instead
726            if (getTargetDocument().isVersion()) {
727                return ob.equals(docState.get(key));
728            } else {
729                // if live version, return false
730                return false;
731            }
732        } else if (isVersion()) {
733            return ob.equals(docState.get(key));
734        } else {
735            return false;
736        }
737    }
738
739    @Override
740    public boolean isVersionSeriesCheckedOut() {
741        if (isProxy() || isVersion()) {
742            Document workingCopy = getWorkingCopy();
743            return workingCopy != null && workingCopy.isCheckedOut();
744        } else {
745            return isCheckedOut();
746        }
747    }
748
749    @Override
750    public Document getWorkingCopy() {
751        if (isProxy() || isVersion()) {
752            String versionSeriesId = getVersionSeriesId();
753            return versionSeriesId == null ? null : session.getDocument(versionSeriesId);
754        } else {
755            return this;
756        }
757    }
758
759    @Override
760    public boolean isFolder() {
761        return type == null // null document
762                || type.isFolder();
763    }
764
765    @Override
766    public void setReadOnly(boolean readonly) {
767        this.readonly = readonly;
768    }
769
770    @Override
771    public boolean isReadOnly() {
772        return readonly;
773    }
774
775    @Override
776    public void remove() {
777        remove(null);
778    }
779
780    @Override
781    public void remove(NuxeoPrincipal principal) {
782        session.remove(id, principal);
783    }
784
785    @Override
786    public void removeSingleton() {
787        session.removeDocument(id);
788    }
789
790    @Override
791    public String getLifeCycleState() {
792        DBSDocumentState docState = getStateOrTarget();
793        return (String) docState.get(KEY_LIFECYCLE_STATE);
794    }
795
796    @Override
797    public void setCurrentLifeCycleState(String lifeCycleState) throws LifeCycleException {
798        DBSDocumentState docState = getStateOrTarget();
799        docState.put(KEY_LIFECYCLE_STATE, lifeCycleState);
800        getDocumentBlobManager().notifyChanges(this, Collections.singleton(KEY_LIFECYCLE_STATE));
801    }
802
803    @Override
804    public String getLifeCyclePolicy() {
805        DBSDocumentState docState = getStateOrTarget();
806        return (String) docState.get(KEY_LIFECYCLE_POLICY);
807    }
808
809    @Override
810    public void setLifeCyclePolicy(String policy) throws LifeCycleException {
811        DBSDocumentState docState = getStateOrTarget();
812        docState.put(KEY_LIFECYCLE_POLICY, policy);
813        getDocumentBlobManager().notifyChanges(this, Collections.singleton(KEY_LIFECYCLE_POLICY));
814    }
815
816    // TODO generic
817    @Override
818    public void followTransition(String transition) throws LifeCycleException {
819        LifeCycleService service = Framework.getService(LifeCycleService.class);
820        if (service == null) {
821            throw new LifeCycleException("LifeCycleService not available");
822        }
823        service.followTransition(this, transition);
824    }
825
826    // TODO generic
827    @Override
828    public Collection<String> getAllowedStateTransitions() throws LifeCycleException {
829        LifeCycleService service = Framework.getService(LifeCycleService.class);
830        if (service == null) {
831            throw new LifeCycleException("LifeCycleService not available");
832        }
833        LifeCycle lifeCycle = service.getLifeCycleFor(this);
834        if (lifeCycle == null) {
835            return Collections.emptyList();
836        }
837        return lifeCycle.getAllowedStateTransitionsFrom(getLifeCycleState());
838    }
839
840    @Override
841    public void setSystemProp(String name, Serializable value) {
842        String propertyName;
843        if (name.startsWith(SYSPROP_FULLTEXT_SIMPLE)) {
844            propertyName = name.replace(SYSPROP_FULLTEXT_SIMPLE, KEY_FULLTEXT_SIMPLE);
845            if (session.fulltextStoredInBlob) {
846                // if binary fulltext is stored in blob, there is no simple fulltext available
847                return;
848            }
849        } else if (name.startsWith(SYSPROP_FULLTEXT_BINARY)) {
850            propertyName = name.replace(SYSPROP_FULLTEXT_BINARY, KEY_FULLTEXT_BINARY);
851            if (session.fulltextStoredInBlob) {
852                if (!(value instanceof String)) {
853                    throw new PropertyException("Property " + name + " must be a string");
854                }
855                setPropertyBlobData(propertyName, (String) value);
856                return;
857            }
858        } else {
859            propertyName = systemPropNameMap.get(name);
860        }
861        if (propertyName == null) {
862            throw new PropertyNotFoundException(name, "Unknown system property");
863        }
864        setPropertyValue(propertyName, value);
865    }
866
867    @SuppressWarnings("unchecked")
868    @Override
869    public <T extends Serializable> T getSystemProp(String name, Class<T> type) {
870        String propertyName = systemPropNameMap.get(name);
871        if (propertyName == null) {
872            throw new PropertyNotFoundException(name, "Unknown system property: ");
873        }
874        Serializable value = getPropertyValue(propertyName);
875        if (value == null) {
876            if (type == Boolean.class) {
877                value = Boolean.FALSE;
878            } else if (type == Long.class) {
879                value = Long.valueOf(0);
880            }
881        }
882        return (T) value;
883    }
884
885    public static final String CHANGE_TOKEN_PROXY_SEP = "/";
886
887    @Override
888    public String getChangeToken() {
889        if (session.changeTokenEnabled) {
890            Long sysChangeToken = docState.getSysChangeToken();
891            Long changeToken = docState.getChangeToken();
892            String userVisibleChangeToken = buildUserVisibleChangeToken(sysChangeToken, changeToken);
893            if (isProxy()) {
894                String targetUserVisibleChangeToken = getTargetDocument().getChangeToken();
895                return getProxyUserVisibleChangeToken(userVisibleChangeToken, targetUserVisibleChangeToken);
896            } else {
897                return userVisibleChangeToken;
898            }
899        } else {
900            DBSDocumentState docState = getStateOrTarget();
901            Calendar modified = (Calendar) docState.get(KEY_DC_MODIFIED);
902            return getLegacyChangeToken(modified);
903        }
904    }
905
906    protected static String getProxyUserVisibleChangeToken(String proxyToken, String targetToken) {
907        if (proxyToken == null && targetToken == null) {
908            return null;
909        } else {
910            if (proxyToken == null) {
911                proxyToken = "";
912            } else if (targetToken == null) {
913                targetToken = "";
914            }
915            return proxyToken + CHANGE_TOKEN_PROXY_SEP + targetToken;
916        }
917    }
918
919    @Override
920    public boolean validateUserVisibleChangeToken(String userVisibleChangeToken) {
921        if (userVisibleChangeToken == null) {
922            return true;
923        }
924        if (session.changeTokenEnabled) {
925            if (isProxy()) {
926                return validateProxyChangeToken(userVisibleChangeToken, docState, getTargetDocument().docState);
927            } else {
928                return docState.validateUserVisibleChangeToken(userVisibleChangeToken);
929            }
930        } else {
931            DBSDocumentState docState = getStateOrTarget();
932            Calendar modified = (Calendar) docState.get(KEY_DC_MODIFIED);
933            return validateLegacyChangeToken(modified, userVisibleChangeToken);
934        }
935    }
936
937    protected static boolean validateProxyChangeToken(String userVisibleChangeToken, DBSDocumentState proxyState,
938            DBSDocumentState targetState) {
939        String[] parts = userVisibleChangeToken.split(CHANGE_TOKEN_PROXY_SEP, 2);
940        if (parts.length != 2) {
941            // invalid format
942            return false;
943        }
944        String proxyToken = parts[0];
945        if (proxyToken.isEmpty()) {
946            proxyToken = null;
947        }
948        String targetToken = parts[1];
949        if (targetToken.isEmpty()) {
950            targetToken = null;
951        }
952        if (proxyToken == null && targetToken == null) {
953            return true;
954        }
955        return proxyState.validateUserVisibleChangeToken(proxyToken)
956                && targetState.validateUserVisibleChangeToken(targetToken);
957    }
958
959    @Override
960    public void markUserChange() {
961        if (isProxy()) {
962            session.markUserChange(getTargetDocumentId());
963        }
964        session.markUserChange(id);
965    }
966
967    protected DBSDocumentState getStateOrTarget(Type type) throws PropertyException {
968        return getStateOrTargetForSchema(type.getName());
969    }
970
971    protected DBSDocumentState getStateOrTarget(String xpath) {
972        return getStateOrTargetForSchema(getSchema(xpath));
973    }
974
975    /**
976     * Checks if the given schema should be resolved on the proxy or the target.
977     */
978    protected DBSDocumentState getStateOrTargetForSchema(String schema) {
979        if (isProxy() && !isSchemaForProxy(schema)) {
980            return getTargetDocument().docState;
981        } else {
982            return docState;
983        }
984    }
985
986    /**
987     * Gets the target state if this is a proxy, or the regular state otherwise.
988     */
989    protected DBSDocumentState getStateOrTarget() {
990        if (isProxy()) {
991            return getTargetDocument().docState;
992        } else {
993            return docState;
994        }
995    }
996
997    protected boolean isSchemaForProxy(String schema) {
998        SchemaManager schemaManager = Framework.getService(SchemaManager.class);
999        return schemaManager.isProxySchema(schema, getType().getName());
1000    }
1001
1002    protected String getSchema(String xpath) {
1003        switch (xpath) {
1004        case KEY_MAJOR_VERSION:
1005        case KEY_MINOR_VERSION:
1006        case "major_version":
1007        case "minor_version":
1008            return "uid";
1009        case KEY_FULLTEXT_JOBID:
1010        case KEY_IS_TRASHED:
1011        case KEY_LIFECYCLE_POLICY:
1012        case KEY_LIFECYCLE_STATE:
1013            return "__ecm__";
1014        }
1015        if (xpath.startsWith(KEY_FULLTEXT_SIMPLE) || xpath.startsWith(KEY_FULLTEXT_BINARY)) {
1016            return "__ecm__";
1017        }
1018        String[] segments = xpath.split("/");
1019        String segment = segments[0];
1020        Field field = type.getField(segment);
1021        if (field == null) {
1022            // check facets
1023            SchemaManager schemaManager = Framework.getService(SchemaManager.class);
1024            for (String facet : getFacets()) {
1025                CompositeType facetType = schemaManager.getFacet(facet);
1026                field = facetType.getField(segment);
1027                if (field != null) {
1028                    break;
1029                }
1030            }
1031        }
1032        if (field == null && getProxySchemas() != null) {
1033            // check proxy schemas
1034            for (Schema schema : getProxySchemas()) {
1035                field = schema.getField(segment);
1036                if (field != null) {
1037                    break;
1038                }
1039            }
1040        }
1041        if (field == null) {
1042            throw new PropertyNotFoundException(xpath);
1043        }
1044        return field.getDeclaringType().getName();
1045    }
1046
1047    @Override
1048    public void readDocumentPart(DocumentPart dp) throws PropertyException {
1049        DBSDocumentState docState = getStateOrTarget(dp.getType());
1050        readComplexProperty(docState.getState(), (ComplexProperty) dp);
1051    }
1052
1053    @Override
1054    protected String internalName(String name) {
1055        switch (name) {
1056        case PROP_MAJOR_VERSION:
1057            return KEY_MAJOR_VERSION;
1058        case PROP_MINOR_VERSION:
1059            return KEY_MINOR_VERSION;
1060        }
1061        return name;
1062    }
1063
1064    @Override
1065    public boolean writeDocumentPart(DocumentPart dp, WriteContext writeContext, boolean create)
1066            throws PropertyException {
1067        DBSDocumentState docState = getStateOrTarget(dp.getType());
1068        // markDirty has to be called *before* we change the state
1069        docState.markDirty();
1070        boolean changed = writeDocumentPart(docState.getState(), dp, writeContext, create);
1071        clearDirtyFlags(dp);
1072        return changed;
1073    }
1074
1075    @Override
1076    public Set<String> getAllFacets() {
1077        Set<String> facets = new HashSet<>(getType().getFacets());
1078        facets.addAll(Arrays.asList(getFacets()));
1079        return facets;
1080    }
1081
1082    @Override
1083    public String[] getFacets() {
1084        DBSDocumentState docState = getStateOrTarget();
1085        Object[] mixins = (Object[]) docState.get(KEY_MIXIN_TYPES);
1086        if (mixins == null) {
1087            return EMPTY_STRING_ARRAY;
1088        } else {
1089            String[] res = new String[mixins.length];
1090            System.arraycopy(mixins, 0, res, 0, mixins.length);
1091            return res;
1092        }
1093    }
1094
1095    @Override
1096    public boolean hasFacet(String facet) {
1097        return getAllFacets().contains(facet);
1098    }
1099
1100    @Override
1101    public boolean addFacet(String facet) {
1102        if (getType().getFacets().contains(facet)) {
1103            return false; // already present in type
1104        }
1105        DBSDocumentState docState = getStateOrTarget();
1106        Object[] mixins = (Object[]) docState.get(KEY_MIXIN_TYPES);
1107        if (mixins == null) {
1108            mixins = new Object[] { facet };
1109        } else {
1110            List<Object> list = Arrays.asList(mixins);
1111            if (list.contains(facet)) {
1112                return false; // already present in doc
1113            }
1114            list = new ArrayList<>(list);
1115            list.add(facet);
1116            mixins = list.toArray(new Object[list.size()]);
1117        }
1118        docState.put(KEY_MIXIN_TYPES, mixins);
1119        return true;
1120    }
1121
1122    @Override
1123    public boolean removeFacet(String facet) {
1124        DBSDocumentState docState = getStateOrTarget();
1125        Object[] mixins = (Object[]) docState.get(KEY_MIXIN_TYPES);
1126        if (mixins == null) {
1127            return false;
1128        }
1129        List<Object> list = new ArrayList<>(Arrays.asList(mixins));
1130        if (!list.remove(facet)) {
1131            return false; // not present in doc
1132        }
1133        mixins = list.toArray(new Object[list.size()]);
1134        if (mixins.length == 0) {
1135            mixins = null;
1136        }
1137        docState.put(KEY_MIXIN_TYPES, mixins);
1138        // remove the fields belonging to the facet
1139        // except for schemas still present due to the primary type or another facet
1140        SchemaManager schemaManager = Framework.getService(SchemaManager.class);
1141        CompositeType ft = schemaManager.getFacet(facet);
1142        Set<String> otherSchemas = getSchemas(getType(), list);
1143        for (Schema schema : ft.getSchemas()) {
1144            if (otherSchemas.contains(schema.getName())) {
1145                continue;
1146            }
1147            for (Field field : schema.getFields()) {
1148                String name = field.getName().getPrefixedName();
1149                if (docState.containsKey(name)) {
1150                    docState.put(name, null);
1151                }
1152            }
1153        }
1154        return true;
1155    }
1156
1157    protected static Set<String> getSchemas(DocumentType type, List<Object> facets) {
1158        SchemaManager schemaManager = Framework.getService(SchemaManager.class);
1159        Set<String> schemas = new HashSet<>(Arrays.asList(type.getSchemaNames()));
1160        for (Object facet : facets) {
1161            CompositeType ft = schemaManager.getFacet((String) facet);
1162            if (ft != null) {
1163                schemas.addAll(Arrays.asList(ft.getSchemaNames()));
1164            }
1165        }
1166        return schemas;
1167    }
1168
1169    @Override
1170    public DBSDocument getTargetDocument() {
1171        String targetId = getTargetDocumentId();
1172        return targetId == null ? null : session.getDocument(targetId);
1173    }
1174
1175    protected String getTargetDocumentId() {
1176        return isProxy() ? (String) docState.get(KEY_PROXY_TARGET_ID) : null;
1177    }
1178
1179    @Override
1180    public void setTargetDocument(Document target) {
1181        if (isProxy()) {
1182            if (isReadOnly()) {
1183                throw new ReadOnlyPropertyException("Cannot write proxy: " + this);
1184            }
1185            if (!target.getVersionSeriesId().equals(getVersionSeriesId())) {
1186                throw new ReadOnlyPropertyException("Cannot set proxy target to different version series");
1187            }
1188            session.setProxyTarget(this, target);
1189        } else {
1190            throw new NuxeoException("Cannot set proxy target on non-proxy");
1191        }
1192    }
1193
1194    @Override
1195    protected Lock getDocumentLock() {
1196        String owner = (String) docState.get(KEY_LOCK_OWNER);
1197        if (owner == null) {
1198            return null;
1199        }
1200        Calendar created = (Calendar) docState.get(KEY_LOCK_CREATED);
1201        return new Lock(owner, created);
1202    }
1203
1204    @Override
1205    protected Lock setDocumentLock(Lock lock) {
1206        String owner = (String) docState.get(KEY_LOCK_OWNER);
1207        if (owner != null) {
1208            // return old lock
1209            Calendar created = (Calendar) docState.get(KEY_LOCK_CREATED);
1210            return new Lock(owner, created);
1211        }
1212        docState.put(KEY_LOCK_OWNER, lock.getOwner());
1213        docState.put(KEY_LOCK_CREATED, lock.getCreated());
1214        return null;
1215    }
1216
1217    @Override
1218    protected Lock removeDocumentLock(String owner) {
1219        String oldOwner = (String) docState.get(KEY_LOCK_OWNER);
1220        if (oldOwner == null) {
1221            // no previous lock
1222            return null;
1223        }
1224        Calendar oldCreated = (Calendar) docState.get(KEY_LOCK_CREATED);
1225        if (!LockManager.canLockBeRemoved(oldOwner, owner)) {
1226            // existing mismatched lock, flag failure
1227            return new Lock(oldOwner, oldCreated, true);
1228        }
1229        // remove lock
1230        docState.put(KEY_LOCK_OWNER, null);
1231        docState.put(KEY_LOCK_CREATED, null);
1232        // return old lock
1233        return new Lock(oldOwner, oldCreated);
1234    }
1235
1236    @Override
1237    public String toString() {
1238        return getClass().getSimpleName() + '(' + getName() + ',' + getUUID() + ')';
1239    }
1240
1241    @Override
1242    public boolean equals(Object other) {
1243        if (other == this) {
1244            return true;
1245        }
1246        if (other == null) {
1247            return false;
1248        }
1249        if (other.getClass() == getClass()) {
1250            return equals((DBSDocument) other);
1251        }
1252        return false;
1253    }
1254
1255    private boolean equals(DBSDocument other) {
1256        return id.equals(other.id);
1257    }
1258
1259    @Override
1260    public int hashCode() {
1261        return id.hashCode();
1262    }
1263
1264}