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