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