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