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