001/*
002 * (C) Copyright 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.platform.tag;
020
021import static java.lang.Boolean.TRUE;
022import static org.nuxeo.ecm.core.api.CoreSession.ALLOW_VERSION_WRITE;
023import static org.nuxeo.ecm.core.query.sql.NXQL.ECM_NAME;
024import static org.nuxeo.ecm.core.query.sql.NXQL.ECM_UUID;
025import static org.nuxeo.ecm.platform.tag.FacetedTagService.LABEL_PROPERTY;
026import static org.nuxeo.ecm.platform.tag.FacetedTagService.USERNAME_PROPERTY;
027import static org.nuxeo.ecm.platform.tag.TagConstants.TAGGING_SOURCE_FIELD;
028import static org.nuxeo.ecm.platform.tag.TagConstants.TAG_LIST;
029
030import java.io.Serializable;
031import java.util.Collection;
032import java.util.HashMap;
033import java.util.HashSet;
034import java.util.List;
035import java.util.Map;
036import java.util.Set;
037import java.util.function.Consumer;
038import java.util.stream.Collectors;
039
040import org.apache.commons.logging.Log;
041import org.apache.commons.logging.LogFactory;
042import org.nuxeo.ecm.core.api.CoreInstance;
043import org.nuxeo.ecm.core.api.CoreSession;
044import org.nuxeo.ecm.core.api.DocumentModel;
045import org.nuxeo.ecm.core.api.DocumentNotFoundException;
046import org.nuxeo.ecm.core.api.IdRef;
047import org.nuxeo.ecm.core.api.model.PropertyNotFoundException;
048import org.nuxeo.ecm.core.repository.RepositoryService;
049import org.nuxeo.runtime.api.Framework;
050import org.nuxeo.runtime.migration.MigrationService.MigrationContext;
051import org.nuxeo.runtime.migration.MigrationService.Migrator;
052import org.nuxeo.runtime.transaction.TransactionHelper;
053
054/**
055 * Migrator of tags from relations to facets
056 *
057 * @since 9.3
058 */
059public class TagsRelationsToFacetsMigrator implements Migrator {
060
061    private static final Log log = LogFactory.getLog(TagsRelationsToFacetsMigrator.class);
062
063    /**
064     * A label + username.
065     *
066     * @since 9.3
067     */
068    protected static class Tag {
069
070        protected final String label;
071
072        protected final String username;
073
074        public Tag(String label, String username) {
075            this.label = label;
076            this.username = username;
077        }
078
079        @Override
080        public int hashCode() {
081            final int prime = 31;
082            int result = 1;
083            result = prime * result + ((label == null) ? 0 : label.hashCode());
084            result = prime * result + ((username == null) ? 0 : username.hashCode());
085            return result;
086        }
087
088        @Override
089        public boolean equals(Object obj) {
090            if (this == obj) {
091                return true;
092            }
093            if (obj == null) {
094                return false;
095            }
096            if (!(obj instanceof Tag)) {
097                return false;
098            }
099            Tag other = (Tag) obj;
100            if (label == null) {
101                if (other.label != null) {
102                    return false;
103                }
104            } else if (!label.equals(other.label)) {
105                return false;
106            }
107            if (username == null) {
108                if (other.username != null) {
109                    return false;
110                }
111            } else if (!username.equals(other.username)) {
112                return false;
113            }
114            return true;
115        }
116
117        @Override
118        public String toString() {
119            return "Tag(" + label + "," + username + ")";
120        }
121    }
122
123    protected static final int BATCH_SIZE = 50;
124
125    protected MigrationContext migrationContext;
126
127    // exception used for simpler flow control
128    protected static class MigrationShutdownException extends RuntimeException {
129
130        private static final long serialVersionUID = 1L;
131
132        public MigrationShutdownException() {
133            super();
134        }
135    }
136
137    @Override
138    public void run(MigrationContext migrationContext) {
139        this.migrationContext = migrationContext;
140        reportProgress("Initializing", 0, -1); // unknown
141        List<String> repositoryNames = Framework.getService(RepositoryService.class).getRepositoryNames();
142        try {
143            repositoryNames.forEach(this::migrateRepository);
144        } catch (MigrationShutdownException e) {
145            return;
146        }
147    }
148
149    protected void checkShutdownRequested() {
150        if (migrationContext.isShutdownRequested()) {
151            throw new MigrationShutdownException();
152        }
153    }
154
155    protected void reportProgress(String message, long num, long total) {
156        log.debug(message + ": " + num + "/" + total);
157        migrationContext.reportProgress(message, num, total);
158    }
159
160    protected void migrateRepository(String repositoryName) {
161        TransactionHelper.runInTransaction(() -> CoreInstance.doPrivileged(repositoryName, this::migrateSession));
162    }
163
164    protected void migrateSession(CoreSession session) {
165        // query all tagging
166        String taggingSql = "SELECT ecm:uuid, relation:source, ecm:name, dc:creator FROM Tagging WHERE ecm:isProxy = 0";
167        List<Map<String, Serializable>> taggingMaps = session.queryProjection(taggingSql, -1, 0);
168
169        checkShutdownRequested();
170
171        // query all tags we'll have to remove too
172        String tagSql = "SELECT ecm:uuid FROM Tag WHERE ecm:isProxy = 0";
173        List<Map<String, Serializable>> tagMaps = session.queryProjection(tagSql, -1, 0);
174
175        checkShutdownRequested();
176
177        // compute all tagged documents and their tag label and username
178        Map<String, Set<Tag>> docTags = new HashMap<>();
179        for (Map<String, Serializable> map : taggingMaps) {
180            String docId = (String) map.get(TAGGING_SOURCE_FIELD);
181            String label = (String) map.get(ECM_NAME);
182            String username = (String) map.get("dc:creator");
183            Tag tag = new Tag(label, username);
184            docTags.computeIfAbsent(docId, key -> new HashSet<>()).add(tag);
185        }
186        // compute all Tagging doc ids
187        Set<String> taggingIds = taggingMaps.stream() //
188                                            .map(map -> (String) map.get(ECM_UUID))
189                                            .collect(Collectors.toSet());
190        // compute all Tag doc ids
191        Set<String> tagIds = tagMaps.stream() //
192                                    .map(map -> (String) map.get(ECM_UUID))
193                                    .collect(Collectors.toSet());
194
195        checkShutdownRequested();
196
197        // recreate all doc tags
198        processBatched(docTags.entrySet(), es -> addTags(session, es.getKey(), es.getValue()), "Creating new tags");
199
200        // delete all Tagging and Tag documents
201        processBatched(taggingIds, docId -> removeDocument(session, docId), "Deleting old Tagging documents");
202        processBatched(tagIds, docId -> removeDocument(session, docId), "Deleting old Tag documents");
203
204        reportProgress("Done", docTags.size(), docTags.size());
205    }
206
207    protected void removeDocument(CoreSession session, String docId) {
208        try {
209            session.removeDocument(new IdRef(docId));
210        } catch (DocumentNotFoundException e) {
211            // ignore document that was already removed, or whose type is unknown
212            return;
213        }
214    }
215
216    protected void addTags(CoreSession session, String docId, Set<Tag> tags) {
217        DocumentModel doc;
218        try {
219            doc = session.getDocument(new IdRef(docId));
220        } catch (DocumentNotFoundException e) {
221            // ignore document that was already removed, or whose type is unknown
222            return;
223        }
224        addTags(doc, tags);
225    }
226
227    @SuppressWarnings("unchecked")
228    protected void addTags(DocumentModel doc, Set<Tag> tags) {
229        if (doc.isProxy()) {
230            // adding tags is not allowed on proxies
231            return;
232        }
233        List<Map<String, Serializable>> tagsList;
234        try {
235            tagsList = (List<Map<String, Serializable>>) doc.getPropertyValue(TAG_LIST);
236        } catch (PropertyNotFoundException e) {
237            // missing facet, cannot add tag
238            return;
239        }
240        boolean changed = false;
241        for (Tag tag : tags) {
242            if (tagsList.stream().noneMatch(t -> tag.label.equals(t.get(LABEL_PROPERTY)))) {
243                Map<String, Serializable> tagMap = new HashMap<>(2);
244                tagMap.put(LABEL_PROPERTY, tag.label);
245                tagMap.put(USERNAME_PROPERTY, tag.username);
246                tagsList.add(tagMap);
247                changed = true;
248            }
249        }
250        if (changed) {
251            doc.putContextData(ALLOW_VERSION_WRITE, TRUE);
252            doc.setPropertyValue(TAG_LIST, (Serializable) tagsList);
253            doc.getCoreSession().saveDocument(doc);
254        }
255    }
256
257    /**
258     * Runs a consumer on the collection, committing every BATCH_SIZE elements, reporting progress and checking for
259     * shutdown request.
260     */
261    protected <T> void processBatched(Collection<T> collection, Consumer<T> consumer, String progressMessage) {
262        int size = collection.size();
263        int i = -1;
264        for (T element : collection) {
265            consumer.accept(element);
266            checkShutdownRequested();
267            i++;
268            if (i % BATCH_SIZE == 0 || i == size - 1) {
269                reportProgress(progressMessage, i + 1, size);
270                TransactionHelper.commitOrRollbackTransaction();
271                TransactionHelper.startTransaction();
272            }
273        }
274    }
275
276}