001/*
002 * (C) Copyright 2006-2017 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.sql.coremodel;
020
021import java.io.Serializable;
022import java.util.ArrayList;
023import java.util.Arrays;
024import java.util.Calendar;
025import java.util.Collection;
026import java.util.Collections;
027import java.util.Comparator;
028import java.util.HashMap;
029import java.util.HashSet;
030import java.util.LinkedList;
031import java.util.List;
032import java.util.Map;
033import java.util.Map.Entry;
034import java.util.Set;
035import java.util.regex.Matcher;
036import java.util.regex.Pattern;
037import java.util.stream.Collectors;
038
039import javax.resource.ResourceException;
040
041import org.apache.commons.logging.Log;
042import org.apache.commons.logging.LogFactory;
043import org.nuxeo.ecm.core.api.CoreSession;
044import org.nuxeo.ecm.core.api.DocumentNotFoundException;
045import org.nuxeo.ecm.core.api.IterableQueryResult;
046import org.nuxeo.ecm.core.api.PartialList;
047import org.nuxeo.ecm.core.api.ScrollResult;
048import org.nuxeo.ecm.core.api.VersionModel;
049import org.nuxeo.ecm.core.api.security.ACE;
050import org.nuxeo.ecm.core.api.security.ACL;
051import org.nuxeo.ecm.core.api.security.ACP;
052import org.nuxeo.ecm.core.api.security.Access;
053import org.nuxeo.ecm.core.api.security.SecurityConstants;
054import org.nuxeo.ecm.core.api.security.impl.ACLImpl;
055import org.nuxeo.ecm.core.api.security.impl.ACPImpl;
056import org.nuxeo.ecm.core.model.Document;
057import org.nuxeo.ecm.core.model.LockManager;
058import org.nuxeo.ecm.core.model.Repository;
059import org.nuxeo.ecm.core.model.Session;
060import org.nuxeo.ecm.core.query.QueryFilter;
061import org.nuxeo.ecm.core.query.sql.NXQL;
062import org.nuxeo.ecm.core.schema.DocumentType;
063import org.nuxeo.ecm.core.schema.SchemaManager;
064import org.nuxeo.ecm.core.storage.sql.ACLRow;
065import org.nuxeo.ecm.core.storage.sql.Model;
066import org.nuxeo.ecm.core.storage.sql.Node;
067import org.nuxeo.runtime.api.Framework;
068
069/**
070 * This class is the bridge between the Nuxeo SPI Session and the actual low-level implementation of the SQL storage
071 * session.
072 *
073 * @author Florent Guillaume
074 */
075public class SQLSession implements Session<QueryFilter> {
076
077    protected final Log log = LogFactory.getLog(SQLSession.class);
078
079    /**
080     * Framework property to control whether negative ACLs (deny) are allowed.
081     *
082     * @since 6.0
083     */
084    public static final String ALLOW_NEGATIVE_ACL_PROPERTY = "nuxeo.security.allowNegativeACL";
085
086    /**
087     * Framework property to disabled free-name collision detection for copy. This is useful when constraints have been
088     * added to the database to detect collisions at the database level and raise a ConcurrentUpdateException, thus
089     * letting the high-level application deal with collisions.
090     *
091     * @since 7.3
092     */
093    public static final String COPY_FINDFREENAME_DISABLED_PROP = "nuxeo.vcs.copy.findFreeName.disabled";
094
095    private final Repository repository;
096
097    private final org.nuxeo.ecm.core.storage.sql.Session session;
098
099    private Document root;
100
101    private final boolean negativeAclAllowed;
102
103    private final boolean copyFindFreeNameDisabled;
104
105    public SQLSession(org.nuxeo.ecm.core.storage.sql.Session session, Repository repository) {
106        this.session = session;
107        this.repository = repository;
108        Node rootNode = session.getRootNode();
109        root = newDocument(rootNode);
110        negativeAclAllowed = Framework.isBooleanPropertyTrue(ALLOW_NEGATIVE_ACL_PROPERTY);
111        copyFindFreeNameDisabled = Framework.isBooleanPropertyTrue(COPY_FINDFREENAME_DISABLED_PROP);
112    }
113
114    /*
115     * ----- org.nuxeo.ecm.core.model.Session -----
116     */
117
118    @Override
119    public Document getRootDocument() {
120        return root;
121    }
122
123    @Override
124    public Document getNullDocument() {
125        return new SQLDocumentLive(null, null, this, true);
126    }
127
128    @Override
129    public void close() {
130        root = null;
131        try {
132            session.close();
133        } catch (ResourceException e) {
134            throw new RuntimeException(e);
135        }
136    }
137
138    @Override
139    public void save() {
140        session.save();
141    }
142
143    @Override
144    public boolean isLive() {
145        // session can become non-live behind our back
146        // through ConnectionAwareXAResource that closes
147        // all handles (sessions) at tx end() time
148        return session != null && session.isLive();
149    }
150
151    @Override
152    public String getRepositoryName() {
153        return repository.getName();
154    }
155
156    protected String idToString(Serializable id) {
157        return session.getModel().idToString(id);
158    }
159
160    protected Serializable idFromString(String id) {
161        return session.getModel().idFromString(id);
162    }
163
164    @Override
165    public ScrollResult<String> scroll(String query, int batchSize, int keepAliveSeconds) {
166        return session.scroll(query, batchSize, keepAliveSeconds);
167    }
168
169    @Override
170    public ScrollResult<String> scroll(String query, QueryFilter queryFilter, int batchSize, int keepAliveSeconds) {
171        return session.scroll(query, queryFilter, batchSize, keepAliveSeconds);
172    }
173
174    @Override
175    public ScrollResult<String> scroll(String scrollId) {
176        return session.scroll(scrollId);
177    }
178
179    @Override
180    public Document getDocumentByUUID(String uuid) throws DocumentNotFoundException {
181        /*
182         * Document ids coming from higher level have been turned into strings (by {@link SQLDocument#getUUID}) but the
183         * backend may actually expect them to be Longs (for database-generated integer ids).
184         */
185        Document doc = getDocumentById(idFromString(uuid));
186        if (doc == null) {
187            // required by callers such as AbstractSession.exists
188            throw new DocumentNotFoundException(uuid);
189        }
190        return doc;
191    }
192
193    @Override
194    public Document resolvePath(String path) throws DocumentNotFoundException {
195        if (path.endsWith("/") && path.length() > 1) {
196            path = path.substring(0, path.length() - 1);
197        }
198        Node node = session.getNodeByPath(path, session.getRootNode());
199        Document doc = newDocument(node);
200        if (doc == null) {
201            throw new DocumentNotFoundException(path);
202        }
203        return doc;
204    }
205
206    protected void orderBefore(Node node, Node src, Node dest) {
207        session.orderBefore(node, src, dest);
208    }
209
210    @Override
211    public Document move(Document source, Document parent, String name) {
212        assert source instanceof SQLDocument;
213        assert parent instanceof SQLDocument;
214        if (name == null) {
215            name = source.getName();
216        }
217        Node result = session.move(((SQLDocument) source).getNode(), ((SQLDocument) parent).getNode(), name);
218        return newDocument(result);
219    }
220
221    private static final Pattern dotDigitsPattern = Pattern.compile("(.*)\\.[0-9]+$");
222
223    protected String findFreeName(Node parentNode, String name) {
224        if (session.hasChildNode(parentNode, name, false)) {
225            Matcher m = dotDigitsPattern.matcher(name);
226            if (m.matches()) {
227                // remove trailing dot and digits
228                name = m.group(1);
229            }
230            // add dot + unique digits
231            name += "." + System.nanoTime();
232        }
233        return name;
234    }
235
236    @Override
237    public Document copy(Document source, Document parent, String name) {
238        assert source instanceof SQLDocument;
239        assert parent instanceof SQLDocument;
240        if (name == null) {
241            name = source.getName();
242        }
243        Node parentNode = ((SQLDocument) parent).getNode();
244        if (!copyFindFreeNameDisabled) {
245            name = findFreeName(parentNode, name);
246        }
247        Node copy = session.copy(((SQLDocument) source).getNode(), parentNode, name);
248        return newDocument(copy);
249    }
250
251    @Override
252    public Document getVersion(String versionableId, VersionModel versionModel) {
253        Serializable vid = idFromString(versionableId);
254        Node versionNode = session.getVersionByLabel(vid, versionModel.getLabel());
255        if (versionNode == null) {
256            return null;
257        }
258        versionModel.setDescription(versionNode.getSimpleProperty(Model.VERSION_DESCRIPTION_PROP).getString());
259        versionModel.setCreated((Calendar) versionNode.getSimpleProperty(Model.VERSION_CREATED_PROP).getValue());
260        return newDocument(versionNode);
261    }
262
263    @Override
264    public Document createProxy(Document doc, Document folder) {
265        Node folderNode = ((SQLDocument) folder).getNode();
266        Node targetNode = ((SQLDocument) doc).getNode();
267        Serializable targetId = targetNode.getId();
268        Serializable versionableId;
269        if (doc.isVersion()) {
270            versionableId = targetNode.getSimpleProperty(Model.VERSION_VERSIONABLE_PROP).getValue();
271        } else if (doc.isProxy()) {
272            // copy the proxy
273            targetId = targetNode.getSimpleProperty(Model.PROXY_TARGET_PROP).getValue();
274            versionableId = targetNode.getSimpleProperty(Model.PROXY_VERSIONABLE_PROP).getValue();
275        } else {
276            // working copy (live document)
277            versionableId = targetId;
278        }
279        String name = findFreeName(folderNode, doc.getName());
280        Node proxy = session.addProxy(targetId, versionableId, folderNode, name, null);
281        return newDocument(proxy);
282    }
283
284    @Override
285    public List<Document> getProxies(Document document, Document parent) {
286        List<Node> proxyNodes = session.getProxies(((SQLDocument) document).getNode(),
287                parent == null ? null : ((SQLDocument) parent).getNode());
288        List<Document> proxies = new ArrayList<>(proxyNodes.size());
289        for (Node proxyNode : proxyNodes) {
290            proxies.add(newDocument(proxyNode));
291        }
292        return proxies;
293    }
294
295    @Override
296    public List<Document> getProxies(Document doc) {
297        List<Node> proxyNodes = session.getProxies(((SQLDocument) doc).getNode());
298        return proxyNodes.stream().map(this::newDocument).collect(Collectors.toList());
299    }
300
301    @Override
302    public void setProxyTarget(Document proxy, Document target) {
303        Node proxyNode = ((SQLDocument) proxy).getNode();
304        Serializable targetId = idFromString(target.getUUID());
305        session.setProxyTarget(proxyNode, targetId);
306    }
307
308    // returned document is r/w even if a version or a proxy, so that normal
309    // props can be set
310    @Override
311    public Document importDocument(String uuid, Document parent, String name, String typeName,
312            Map<String, Serializable> properties) {
313        boolean isProxy = typeName.equals(Model.PROXY_TYPE);
314        Map<String, Serializable> props = new HashMap<>();
315        Long pos = null; // TODO pos
316        if (!isProxy) {
317            // version & live document
318            props.put(Model.MISC_LIFECYCLE_POLICY_PROP, properties.get(CoreSession.IMPORT_LIFECYCLE_POLICY));
319            props.put(Model.MISC_LIFECYCLE_STATE_PROP, properties.get(CoreSession.IMPORT_LIFECYCLE_STATE));
320
321            Serializable importLockOwnerProp = properties.get(CoreSession.IMPORT_LOCK_OWNER);
322            if (importLockOwnerProp != null) {
323                props.put(Model.LOCK_OWNER_PROP, importLockOwnerProp);
324            }
325            Serializable importLockCreatedProp = properties.get(CoreSession.IMPORT_LOCK_CREATED);
326            if (importLockCreatedProp != null) {
327                props.put(Model.LOCK_CREATED_PROP, importLockCreatedProp);
328            }
329            props.put(Model.MAIN_IS_RETENTION_ACTIVE_PROP, properties.get(CoreSession.IMPORT_IS_RETENTION_ACTIVE));
330
331            props.put(Model.MAIN_MAJOR_VERSION_PROP, properties.get(CoreSession.IMPORT_VERSION_MAJOR));
332            props.put(Model.MAIN_MINOR_VERSION_PROP, properties.get(CoreSession.IMPORT_VERSION_MINOR));
333            props.put(Model.MAIN_IS_VERSION_PROP, properties.get(CoreSession.IMPORT_IS_VERSION));
334        }
335        Node parentNode;
336        if (parent == null) {
337            // version
338            parentNode = null;
339            props.put(Model.VERSION_VERSIONABLE_PROP,
340                    idFromString((String) properties.get(CoreSession.IMPORT_VERSION_VERSIONABLE_ID)));
341            props.put(Model.VERSION_CREATED_PROP, properties.get(CoreSession.IMPORT_VERSION_CREATED));
342            props.put(Model.VERSION_LABEL_PROP, properties.get(CoreSession.IMPORT_VERSION_LABEL));
343            props.put(Model.VERSION_DESCRIPTION_PROP, properties.get(CoreSession.IMPORT_VERSION_DESCRIPTION));
344            props.put(Model.VERSION_IS_LATEST_PROP, properties.get(CoreSession.IMPORT_VERSION_IS_LATEST));
345            props.put(Model.VERSION_IS_LATEST_MAJOR_PROP, properties.get(CoreSession.IMPORT_VERSION_IS_LATEST_MAJOR));
346        } else {
347            parentNode = ((SQLDocument) parent).getNode();
348            if (isProxy) {
349                // proxy
350                props.put(Model.PROXY_TARGET_PROP,
351                        idFromString((String) properties.get(CoreSession.IMPORT_PROXY_TARGET_ID)));
352                props.put(Model.PROXY_VERSIONABLE_PROP,
353                        idFromString((String) properties.get(CoreSession.IMPORT_PROXY_VERSIONABLE_ID)));
354            } else {
355                // live document
356                props.put(Model.MAIN_BASE_VERSION_PROP,
357                        idFromString((String) properties.get(CoreSession.IMPORT_BASE_VERSION_ID)));
358                props.put(Model.MAIN_CHECKED_IN_PROP, properties.get(CoreSession.IMPORT_CHECKED_IN));
359            }
360        }
361        return importChild(uuid, parentNode, name, pos, typeName, props);
362    }
363
364    protected static final Pattern ORDER_BY_PATH_ASC = Pattern.compile(
365            "(.*)\\s+ORDER\\s+BY\\s+" + NXQL.ECM_PATH + "\\s*$", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
366
367    protected static final Pattern ORDER_BY_PATH_DESC = Pattern.compile(
368            "(.*)\\s+ORDER\\s+BY\\s+" + NXQL.ECM_PATH + "\\s+DESC\\s*$", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
369
370    @Override
371    public PartialList<Document> query(String query, String queryType, QueryFilter queryFilter, long countUpTo) {
372        // do ORDER BY ecm:path by hand in SQLQueryResult as we can't
373        // do it in SQL (and has to do limit/offset as well)
374        Boolean orderByPath;
375        Matcher matcher = ORDER_BY_PATH_ASC.matcher(query);
376        if (matcher.matches()) {
377            orderByPath = Boolean.TRUE; // ASC
378        } else {
379            matcher = ORDER_BY_PATH_DESC.matcher(query);
380            if (matcher.matches()) {
381                orderByPath = Boolean.FALSE; // DESC
382            } else {
383                orderByPath = null;
384            }
385        }
386        long limit = 0;
387        long offset = 0;
388        if (orderByPath != null) {
389            query = matcher.group(1);
390            limit = queryFilter.getLimit();
391            offset = queryFilter.getOffset();
392            queryFilter = QueryFilter.withoutLimitOffset(queryFilter);
393        }
394        PartialList<Serializable> pl = session.query(query, queryType, queryFilter, countUpTo);
395
396        // get Documents in bulk, returns a newly-allocated ArrayList
397        List<Document> list = getDocumentsById(pl);
398
399        // order / limit
400        if (orderByPath != null) {
401            Collections.sort(list, new PathComparator(orderByPath.booleanValue()));
402        }
403        if (limit != 0) {
404            // do limit/offset by hand
405            int size = list.size();
406            list.subList(0, (int) (offset > size ? size : offset)).clear();
407            size = list.size();
408            if (limit < size) {
409                list.subList((int) limit, size).clear();
410            }
411        }
412        return new PartialList<>(list, pl.totalSize());
413    }
414
415    public static class PathComparator implements Comparator<Document> {
416
417        private final int sign;
418
419        public PathComparator(boolean asc) {
420            this.sign = asc ? 1 : -1;
421        }
422
423        @Override
424        public int compare(Document doc1, Document doc2) {
425            String p1 = doc1.getPath();
426            String p2 = doc2.getPath();
427            if (p1 == null && p2 == null) {
428                return sign * doc1.getUUID().compareTo(doc2.getUUID());
429            } else if (p1 == null) {
430                return sign;
431            } else if (p2 == null) {
432                return -1 * sign;
433            }
434            return sign * p1.compareTo(p2);
435        }
436    }
437
438    @Override
439    public IterableQueryResult queryAndFetch(String query, String queryType, QueryFilter queryFilter,
440            boolean distinctDocuments, Object[] params) {
441        return session.queryAndFetch(query, queryType, queryFilter, distinctDocuments, params);
442    }
443
444    @Override
445    public PartialList<Map<String, Serializable>> queryProjection(String query, String queryType,
446            QueryFilter queryFilter, boolean distinctDocuments, long countUpTo, Object[] params) {
447        return session.queryProjection(query, queryType, queryFilter, distinctDocuments, countUpTo, params);
448    }
449
450    /*
451     * ----- called by SQLDocument -----
452     */
453
454    private Document newDocument(Node node) {
455        return newDocument(node, true);
456    }
457
458    // "readonly" meaningful for proxies and versions, used for import
459    private Document newDocument(Node node, boolean readonly) {
460        if (node == null) {
461            // root's parent
462            return null;
463        }
464
465        Node targetNode = null;
466        String typeName = node.getPrimaryType();
467        if (node.isProxy()) {
468            Serializable targetId = node.getSimpleProperty(Model.PROXY_TARGET_PROP).getValue();
469            if (targetId == null) {
470                throw new DocumentNotFoundException("Proxy has null target");
471            }
472            targetNode = session.getNodeById(targetId);
473            typeName = targetNode.getPrimaryType();
474        }
475        SchemaManager schemaManager = Framework.getService(SchemaManager.class);
476        DocumentType type = schemaManager.getDocumentType(typeName);
477        if (type == null) {
478            throw new DocumentNotFoundException("Unknown document type: " + typeName);
479        }
480
481        if (node.isProxy()) {
482            // proxy seen as a normal document
483            Document proxy = new SQLDocumentLive(node, type, this, false);
484            Document target = newDocument(targetNode, readonly);
485            return new SQLDocumentProxy(proxy, target);
486        } else if (node.isVersion()) {
487            return new SQLDocumentVersion(node, type, this, readonly);
488        } else {
489            return new SQLDocumentLive(node, type, this, false);
490        }
491    }
492
493    // called by SQLQueryResult iterator & others
494    protected Document getDocumentById(Serializable id) {
495        Node node = session.getNodeById(id);
496        return node == null ? null : newDocument(node);
497    }
498
499    // called by SQLQueryResult iterator
500    protected List<Document> getDocumentsById(List<Serializable> ids) {
501        List<Document> docs = new ArrayList<>(ids.size());
502        List<Node> nodes = session.getNodesByIds(ids);
503        for (int index = 0; index < ids.size(); ++index) {
504            Node eachNode = nodes.get(index);
505            if (eachNode == null) {
506                if (log.isTraceEnabled()) {
507                    Serializable id = ids.get(index);
508                    log.trace("Cannot fetch document with id: " + id, new Throwable("debug stack trace"));
509                }
510                continue;
511            }
512            Document doc;
513            try {
514                doc = newDocument(eachNode);
515            } catch (DocumentNotFoundException e) {
516                // unknown type in db, ignore
517                continue;
518            }
519            docs.add(doc);
520        }
521        return docs;
522    }
523
524    protected Document getParent(Node node) {
525        return newDocument(session.getParentNode(node));
526    }
527
528    protected String getPath(Node node) {
529        return session.getPath(node);
530    }
531
532    protected Document getChild(Node node, String name) throws DocumentNotFoundException {
533        Node childNode = session.getChildNode(node, name, false);
534        Document doc = newDocument(childNode);
535        if (doc == null) {
536            throw new DocumentNotFoundException(name);
537        }
538        return doc;
539    }
540
541    protected Node getChildProperty(Node node, String name, String typeName) {
542        // all complex property children have already been created by SessionImpl.addChildNode or
543        // SessionImpl.addMixinType
544        // if one is missing here, it means that it was concurrently deleted and we're only now finding out
545        // or that a schema change was done and we now expect a new child
546        // return null in that case
547        return session.getChildNode(node, name, true);
548    }
549
550    protected Node getChildPropertyForWrite(Node node, String name, String typeName) {
551        Node childNode = getChildProperty(node, name, typeName);
552        if (childNode == null) {
553            // create the needed complex property immediately
554            childNode = session.addChildNode(node, name, null, typeName, true);
555        }
556        return childNode;
557    }
558
559    protected List<Document> getChildren(Node node) {
560        List<Node> nodes = session.getChildren(node, null, false);
561        List<Document> children = new ArrayList<>(nodes.size());
562        for (Node n : nodes) {
563            try {
564                children.add(newDocument(n));
565            } catch (DocumentNotFoundException e) {
566                // ignore error retrieving one of the children
567                continue;
568            }
569        }
570        return children;
571    }
572
573    protected boolean hasChild(Node node, String name) {
574        return session.hasChildNode(node, name, false);
575    }
576
577    protected boolean hasChildren(Node node) {
578        return session.hasChildren(node, false);
579    }
580
581    protected Document addChild(Node parent, String name, Long pos, String typeName) {
582        return newDocument(session.addChildNode(parent, name, pos, typeName, false));
583    }
584
585    protected Node addChildProperty(Node parent, String name, Long pos, String typeName) {
586        return session.addChildNode(parent, name, pos, typeName, true);
587    }
588
589    protected Document importChild(String uuid, Node parent, String name, Long pos, String typeName,
590            Map<String, Serializable> props) {
591        Serializable id = idFromString(uuid);
592        Node node = session.addChildNode(id, parent, name, pos, typeName, false);
593        for (Entry<String, Serializable> entry : props.entrySet()) {
594            node.setSimpleProperty(entry.getKey(), entry.getValue());
595        }
596        return newDocument(node, false); // not readonly
597    }
598
599    protected boolean addMixinType(Node node, String mixin) {
600        return session.addMixinType(node, mixin);
601    }
602
603    protected boolean removeMixinType(Node node, String mixin) {
604        return session.removeMixinType(node, mixin);
605    }
606
607    protected List<Node> getComplexList(Node node, String name) {
608        List<Node> nodes = session.getChildren(node, name, true);
609        return nodes;
610    }
611
612    protected void remove(Node node) {
613        session.removeNode(node);
614    }
615
616    protected void removeProperty(Node node) {
617        session.removePropertyNode(node);
618    }
619
620    protected Document checkIn(Node node, String label, String checkinComment) {
621        Node versionNode = session.checkIn(node, label, checkinComment);
622        return versionNode == null ? null : newDocument(versionNode);
623    }
624
625    protected void checkOut(Node node) {
626        session.checkOut(node);
627    }
628
629    protected void restore(Node node, Node version) {
630        session.restore(node, version);
631    }
632
633    protected Document getVersionByLabel(String versionSeriesId, String label) {
634        Serializable vid = idFromString(versionSeriesId);
635        Node versionNode = session.getVersionByLabel(vid, label);
636        return versionNode == null ? null : newDocument(versionNode);
637    }
638
639    protected List<Document> getVersions(String versionSeriesId) {
640        Serializable vid = idFromString(versionSeriesId);
641        List<Node> versionNodes = session.getVersions(vid);
642        List<Document> versions = new ArrayList<>(versionNodes.size());
643        for (Node versionNode : versionNodes) {
644            versions.add(newDocument(versionNode));
645        }
646        return versions;
647    }
648
649    public Document getLastVersion(String versionSeriesId) {
650        Serializable vid = idFromString(versionSeriesId);
651        Node versionNode = session.getLastVersion(vid);
652        if (versionNode == null) {
653            return null;
654        }
655        return newDocument(versionNode);
656    }
657
658    protected Node getNodeById(Serializable id) {
659        return session.getNodeById(id);
660    }
661
662    @Override
663    public LockManager getLockManager() {
664        return session.getLockManager();
665    }
666
667    @Override
668    public boolean isNegativeAclAllowed() {
669        return negativeAclAllowed;
670    }
671
672    @Override
673    public void updateReadACLs(Collection<String> docIds) {
674        throw new UnsupportedOperationException();
675    }
676
677    @Override
678    public void setACP(Document doc, ACP acp, boolean overwrite) {
679        if (!overwrite && acp == null) {
680            return;
681        }
682        checkNegativeAcl(acp);
683        Node node = ((SQLDocument) doc).getNode();
684        ACLRow[] aclrows;
685        if (overwrite) {
686            aclrows = acp == null ? null : acpToAclRows(acp);
687        } else {
688            aclrows = (ACLRow[]) node.getCollectionProperty(Model.ACL_PROP).getValue();
689            aclrows = updateAclRows(aclrows, acp);
690        }
691        node.getCollectionProperty(Model.ACL_PROP).setValue(aclrows);
692        session.requireReadAclsUpdate();
693    }
694
695    protected void checkNegativeAcl(ACP acp) {
696        if (negativeAclAllowed) {
697            return;
698        }
699        if (acp == null) {
700            return;
701        }
702        for (ACL acl : acp.getACLs()) {
703            if (acl.getName().equals(ACL.INHERITED_ACL)) {
704                continue;
705            }
706            for (ACE ace : acl.getACEs()) {
707                if (ace.isGranted()) {
708                    continue;
709                }
710                String permission = ace.getPermission();
711                if (permission.equals(SecurityConstants.EVERYTHING)
712                        && ace.getUsername().equals(SecurityConstants.EVERYONE)) {
713                    continue;
714                }
715                // allow Write, as we're sure it doesn't include Read/Browse
716                if (permission.equals(SecurityConstants.WRITE)) {
717                    continue;
718                }
719                throw new IllegalArgumentException("Negative ACL not allowed: " + ace);
720            }
721        }
722    }
723
724    @Override
725    public ACP getMergedACP(Document doc) {
726        Document base = doc.isVersion() ? doc.getSourceDocument() : doc;
727        if (base == null) {
728            return null;
729        }
730        ACP acp = getACP(base);
731        if (doc.getParent() == null) {
732            return acp;
733        }
734        // get inherited acls only if no blocking inheritance ACE exists in the top level acp.
735        ACL acl = null;
736        if (acp == null || acp.getAccess(SecurityConstants.EVERYONE, SecurityConstants.EVERYTHING) != Access.DENY) {
737            acl = getInheritedACLs(doc);
738        }
739        if (acp == null) {
740            if (acl == null) {
741                return null;
742            }
743            acp = new ACPImpl();
744        }
745        if (acl != null) {
746            acp.addACL(acl);
747        }
748        return acp;
749    }
750
751    /*
752     * ----- internal methods -----
753     */
754
755    protected ACP getACP(Document doc) {
756        Node node = ((SQLDocument) doc).getNode();
757        ACLRow[] aclrows = (ACLRow[]) node.getCollectionProperty(Model.ACL_PROP).getValue();
758        return aclRowsToACP(aclrows);
759    }
760
761    // unit tested
762    protected static ACP aclRowsToACP(ACLRow[] acls) {
763        ACP acp = new ACPImpl();
764        ACL acl = null;
765        String name = null;
766        for (ACLRow aclrow : acls) {
767            if (!aclrow.name.equals(name)) {
768                if (acl != null) {
769                    acp.addACL(acl);
770                }
771                name = aclrow.name;
772                acl = new ACLImpl(name);
773            }
774            // XXX should prefix user/group
775            String user = aclrow.user;
776            if (user == null) {
777                user = aclrow.group;
778            }
779            acl.add(ACE.builder(user, aclrow.permission)
780                       .isGranted(aclrow.grant)
781                       .creator(aclrow.creator)
782                       .begin(aclrow.begin)
783                       .end(aclrow.end)
784                       .build());
785        }
786        if (acl != null) {
787            acp.addACL(acl);
788        }
789        return acp;
790    }
791
792    // unit tested
793    protected static ACLRow[] acpToAclRows(ACP acp) {
794        List<ACLRow> aclrows = new LinkedList<>();
795        for (ACL acl : acp.getACLs()) {
796            String name = acl.getName();
797            if (name.equals(ACL.INHERITED_ACL)) {
798                continue;
799            }
800            for (ACE ace : acl.getACEs()) {
801                addACLRow(aclrows, name, ace);
802            }
803        }
804        ACLRow[] array = new ACLRow[aclrows.size()];
805        return aclrows.toArray(array);
806    }
807
808    // unit tested
809    protected static ACLRow[] updateAclRows(ACLRow[] aclrows, ACP acp) {
810        List<ACLRow> newaclrows = new LinkedList<>();
811        Map<String, ACL> aclmap = new HashMap<>();
812        for (ACL acl : acp.getACLs()) {
813            String name = acl.getName();
814            if (ACL.INHERITED_ACL.equals(name)) {
815                continue;
816            }
817            aclmap.put(name, acl);
818        }
819        List<ACE> aces = Collections.emptyList();
820        Set<String> aceKeys = null;
821        String name = null;
822        for (ACLRow aclrow : aclrows) {
823            // new acl?
824            if (!aclrow.name.equals(name)) {
825                // finish remaining aces
826                for (ACE ace : aces) {
827                    addACLRow(newaclrows, name, ace);
828                }
829                // start next round
830                name = aclrow.name;
831                ACL acl = aclmap.remove(name);
832                aces = acl == null ? Collections.<ACE> emptyList() : new LinkedList<>(Arrays.asList(acl.getACEs()));
833                aceKeys = new HashSet<>();
834                for (ACE ace : aces) {
835                    aceKeys.add(getACEkey(ace));
836                }
837            }
838            if (!aceKeys.contains(getACLrowKey(aclrow))) {
839                // no match, keep the aclrow info instead of the ace
840                newaclrows.add(new ACLRow(newaclrows.size(), name, aclrow.grant, aclrow.permission, aclrow.user,
841                        aclrow.group, aclrow.creator, aclrow.begin, aclrow.end, aclrow.status));
842            }
843        }
844        // finish remaining aces for last acl done
845        for (ACE ace : aces) {
846            addACLRow(newaclrows, name, ace);
847        }
848        // do non-done acls
849        for (ACL acl : aclmap.values()) {
850            name = acl.getName();
851            for (ACE ace : acl.getACEs()) {
852                addACLRow(newaclrows, name, ace);
853            }
854        }
855        ACLRow[] array = new ACLRow[newaclrows.size()];
856        return newaclrows.toArray(array);
857    }
858
859    /** Key to distinguish ACEs */
860    protected static String getACEkey(ACE ace) {
861        // TODO separate user/group
862        return ace.getUsername() + '|' + ace.getPermission();
863    }
864
865    /** Key to distinguish ACLRows */
866    protected static String getACLrowKey(ACLRow aclrow) {
867        // TODO separate user/group
868        String user = aclrow.user;
869        if (user == null) {
870            user = aclrow.group;
871        }
872        return user + '|' + aclrow.permission;
873    }
874
875    protected static void addACLRow(List<ACLRow> aclrows, String name, ACE ace) {
876        // XXX should prefix user/group
877        String user = ace.getUsername();
878        if (user == null) {
879            // JCR implementation logs null and skips it
880            return;
881        }
882        String group = null; // XXX all in user for now
883        aclrows.add(new ACLRow(aclrows.size(), name, ace.isGranted(), ace.getPermission(), user, group,
884                ace.getCreator(), ace.getBegin(), ace.getEnd(), ace.getLongStatus()));
885    }
886
887    protected ACL getInheritedACLs(Document doc) {
888        doc = doc.getParent();
889        ACL merged = null;
890        while (doc != null) {
891            ACP acp = getACP(doc);
892            if (acp != null) {
893                ACL acl = acp.getMergedACLs(ACL.INHERITED_ACL);
894                if (merged == null) {
895                    merged = acl;
896                } else {
897                    merged.addAll(acl);
898                }
899                if (acp.getAccess(SecurityConstants.EVERYONE, SecurityConstants.EVERYTHING) == Access.DENY) {
900                    break;
901                }
902            }
903            doc = doc.getParent();
904        }
905        return merged;
906    }
907
908    @Override
909    public Map<String, String> getBinaryFulltext(String id) {
910        return session.getBinaryFulltext(idFromString(id));
911    }
912
913    public boolean isChangeTokenEnabled() {
914        return session.isChangeTokenEnabled();
915    }
916
917    public void markUserChange(Serializable id) {
918        session.markUserChange(id);
919    }
920
921}