View Javadoc

1   /*
2    * Copyright 2015 Data Archiving and Networked Services (an institute of
3    * Koninklijke Nederlandse Akademie van Wetenschappen), King's College London,
4    * Georg-August-Universitaet Goettingen Stiftung Oeffentlichen Rechts
5    *
6    * Licensed under the EUPL, Version 1.1 or – as soon they will be approved by
7    * the European Commission - subsequent versions of the EUPL (the "Licence");
8    * You may not use this work except in compliance with the Licence.
9    * You may obtain a copy of the Licence at:
10   *
11   * https://joinup.ec.europa.eu/software/page/eupl
12   *
13   * Unless required by applicable law or agreed to in writing, software
14   * distributed under the Licence is distributed on an "AS IS" basis,
15   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16   * See the Licence for the specific language governing
17   * permissions and limitations under the Licence.
18   */
19  
20  package eu.ehri.extension;
21  
22  import com.google.common.collect.Lists;
23  import com.google.common.collect.Ordering;
24  import com.tinkerpop.blueprints.CloseableIterable;
25  import com.tinkerpop.blueprints.Vertex;
26  import eu.ehri.extension.base.AbstractResource;
27  import eu.ehri.project.acl.ContentTypes;
28  import eu.ehri.project.core.Tx;
29  import eu.ehri.project.core.impl.Neo4jGraphManager;
30  import eu.ehri.project.definitions.Entities;
31  import eu.ehri.project.definitions.Ontology;
32  import eu.ehri.project.exceptions.DeserializationError;
33  import eu.ehri.project.exceptions.ItemNotFound;
34  import eu.ehri.project.exceptions.PermissionDenied;
35  import eu.ehri.project.exceptions.SerializationError;
36  import eu.ehri.project.exceptions.ValidationError;
37  import eu.ehri.project.exporters.cvoc.SchemaExporter;
38  import eu.ehri.project.models.AccessPointType;
39  import eu.ehri.project.models.DocumentaryUnit;
40  import eu.ehri.project.models.EntityClass;
41  import eu.ehri.project.models.Link;
42  import eu.ehri.project.models.Repository;
43  import eu.ehri.project.models.base.Accessible;
44  import eu.ehri.project.models.base.Actioner;
45  import eu.ehri.project.models.base.Described;
46  import eu.ehri.project.models.base.Description;
47  import eu.ehri.project.models.base.Linkable;
48  import eu.ehri.project.models.base.PermissionScope;
49  import eu.ehri.project.models.cvoc.Vocabulary;
50  import eu.ehri.project.persistence.Bundle;
51  import eu.ehri.project.persistence.Serializer;
52  import eu.ehri.project.tools.DbUpgrader1to2;
53  import eu.ehri.project.tools.FindReplace;
54  import eu.ehri.project.tools.IdRegenerator;
55  import eu.ehri.project.tools.Linker;
56  import eu.ehri.project.utils.Table;
57  import eu.ehri.project.utils.fixtures.FixtureLoaderFactory;
58  import org.neo4j.graphdb.GraphDatabaseService;
59  
60  import javax.ws.rs.Consumes;
61  import javax.ws.rs.DefaultValue;
62  import javax.ws.rs.FormParam;
63  import javax.ws.rs.GET;
64  import javax.ws.rs.POST;
65  import javax.ws.rs.Path;
66  import javax.ws.rs.PathParam;
67  import javax.ws.rs.Produces;
68  import javax.ws.rs.QueryParam;
69  import javax.ws.rs.core.Context;
70  import javax.ws.rs.core.MediaType;
71  import javax.ws.rs.core.Response;
72  import javax.ws.rs.core.StreamingOutput;
73  import java.io.IOException;
74  import java.io.InputStream;
75  import java.util.Iterator;
76  import java.util.List;
77  import java.util.Set;
78  import java.util.concurrent.atomic.AtomicInteger;
79  import java.util.stream.Collectors;
80  
81  
82  /**
83   * Miscellaneous additional functionality that doesn't
84   * fit anywhere else.
85   */
86  @Path(ToolsResource.ENDPOINT)
87  public class ToolsResource extends AbstractResource {
88  
89      private final Linker linker;
90  
91      public static final String ENDPOINT = "tools";
92  
93      private static final String SINGLE_PARAM = "single";
94      private static final String LANG_PARAM = "lang";
95      private static final String ACCESS_POINT_TYPE_PARAM = "apt";
96      private static final String DEFAULT_LANG = "eng";
97  
98      public ToolsResource(@Context GraphDatabaseService database) {
99          super(database);
100         linker = new Linker(graph);
101     }
102 
103     @GET
104     @Produces(MediaType.TEXT_PLAIN)
105     @Path("version")
106     public String version() {
107         return getClass().getPackage().getImplementationVersion();
108     }
109 
110     /**
111      * Find and replace text in descriptions for a given item type
112      * and description property name.
113      * <p>
114      * Changes will be logged to the audit log.
115      *
116      * @param type     the parent entity type
117      * @param subType  the specific node type
118      * @param property the property name
119      * @param from     the original text
120      * @param to       the replacement text
121      * @param maxItems the max number of items to change
122      *                 (defaults to 100)
123      * @param commit   actually commit the changes
124      * @return a list of item IDs for those items changed
125      */
126     @POST
127     @Path("find-replace")
128     @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
129     @Produces({MediaType.APPLICATION_JSON, CSV_MEDIA_TYPE})
130     public Table findReplace(
131             final @FormParam("from") String from,
132             final @FormParam("to") String to,
133             final @QueryParam("type") String type,
134             final @QueryParam("subtype") String subType,
135             final @QueryParam("property") String property,
136             final @QueryParam("max") @DefaultValue("100") int maxItems,
137             final @QueryParam(COMMIT_PARAM) @DefaultValue("false") boolean commit) throws ValidationError {
138 
139         try {
140             ContentTypes.withName(type);
141         } catch (IllegalArgumentException e) {
142             throw new IllegalArgumentException("Invalid entity type (must be a content type)");
143         }
144 
145         try (final Tx tx = beginTx()) {
146             FindReplace fr = new FindReplace(graph, commit, maxItems);
147             List<List<String>> rows = fr.findAndReplace(EntityClass.withName(type),
148                     EntityClass.withName(subType), property, from, to,
149                     getCurrentActioner(), getLogMessage().orElse(null));
150 
151             tx.success();
152             return Table.of(rows);
153         }
154     }
155 
156     /**
157      * Export an RDFS+OWL representation of the model schema.
158      *
159      * @param format  the RDF format
160      * @param baseUri the RDF base URI
161      * @return a streaming response
162      */
163     @GET
164     @Path("schema")
165     @Produces({TURTLE_MIMETYPE, RDF_XML_MIMETYPE, N3_MIMETYPE})
166     public Response exportSchema(
167             final @QueryParam("format") String format,
168             final @QueryParam("baseUri") String baseUri) throws IOException {
169         final String rdfFormat = getRdfFormat(format, "TTL");
170         final MediaType mediaType = MediaType.valueOf(RDF_MIMETYPE_FORMATS
171                 .inverse().get(rdfFormat));
172         final SchemaExporter schemaExporter = new SchemaExporter(rdfFormat);
173         return Response.ok((StreamingOutput) outputStream ->
174                 schemaExporter.dumpSchema(outputStream, baseUri))
175                 .type(mediaType + "; charset=utf-8").build();
176     }
177 
178     /**
179      * Create concepts and links derived from the access points
180      * on a repository's documentary units.
181      *
182      * @param repositoryId     the repository id
183      * @param vocabularyId     the target vocabulary
184      * @param languageCode     the language code of created concepts
185      * @param accessPointTypes the access point types to process
186      * @param tolerant         proceed even if there are integrity errors due
187      *                         to slug collisions in the created concepts
188      * @param excludeSingle    don't create concepts/links for access points that
189      *                         are unique to a single item
190      * @return the number of links created
191      */
192     @POST
193     @Produces(MediaType.APPLICATION_JSON)
194     @Path("generate-concepts/{repositoryId:[^/]+}/{vocabularyId:[^/]+}")
195     public long autoLinkRepositoryDocs(
196             @PathParam("repositoryId") String repositoryId,
197             @PathParam("vocabularyId") String vocabularyId,
198             @QueryParam(ACCESS_POINT_TYPE_PARAM) Set<AccessPointType> accessPointTypes,
199             @QueryParam(LANG_PARAM) @DefaultValue(DEFAULT_LANG) String languageCode,
200             @QueryParam(SINGLE_PARAM) @DefaultValue("true") boolean excludeSingle,
201             @QueryParam(TOLERANT_PARAM) @DefaultValue("false") boolean tolerant)
202             throws ItemNotFound, ValidationError,
203             PermissionDenied, DeserializationError {
204         try (final Tx tx = beginTx()) {
205             Actioner user = getCurrentActioner();
206             Repository repository = manager.getEntity(repositoryId, Repository.class);
207             Vocabulary vocabulary = manager.getEntity(vocabularyId, Vocabulary.class);
208 
209             long linkCount = linker
210                     .withAccessPointTypes(accessPointTypes)
211                     .withTolerant(tolerant)
212                     .withExcludeSingles(excludeSingle)
213                     .withDefaultLanguage(languageCode)
214                     .withLogMessage(getLogMessage())
215                     .createAndLinkRepositoryVocabulary(repository, vocabulary, user);
216 
217             tx.success();
218             return linkCount;
219         }
220     }
221 
222     /**
223      * Regenerate the hierarchical graph ID for a set of items, optionally
224      * renaming them.
225      * <p>
226      * The default mode is to output items whose IDs would change, without
227      * actually changing them. The {@code collisions} parameter will <b>only</b>
228      * output items that would cause collisions if renamed, whereas {@code tolerant}
229      * mode will skip them altogether.
230      * <p>
231      * The {@code commit} flag will cause renaming to take place.
232      *
233      * @param ids        the existing item IDs
234      * @param collisions only output items that if renamed to the
235      *                   regenerated ID would cause collisions
236      * @param tolerant   skip items that could cause collisions rather
237      *                   then throwing an error
238      * @param commit     whether or not to rename the item
239      * @return a tab old-to-new mapping, or an empty
240      * body if nothing was changed
241      */
242     @POST
243     @Produces({MediaType.APPLICATION_JSON, CSV_MEDIA_TYPE})
244     @Consumes({MediaType.APPLICATION_JSON, CSV_MEDIA_TYPE})
245     @Path("regenerate-ids")
246     public Table regenerateIds(
247             @QueryParam("id") List<String> ids,
248             @QueryParam("collisions") @DefaultValue("false") boolean collisions,
249             @QueryParam(TOLERANT_PARAM) @DefaultValue("false") boolean tolerant,
250             @QueryParam(COMMIT_PARAM) @DefaultValue("false") boolean commit,
251             Table data)
252             throws ItemNotFound, IOException, IdRegenerator.IdCollisionError {
253         try (final Tx tx = beginTx()) {
254             List<String> allIds = Lists.newArrayList(ids);
255             data.rows().stream()
256                     .filter(row -> row.size() == 1)
257                     .forEach(row -> allIds.add(row.get(0)));
258 
259             List<Accessible> items = allIds.stream().map(id -> {
260                 try {
261                     return manager.getEntity(id, Accessible.class);
262                 } catch (ItemNotFound e) {
263                     throw new RuntimeException(e);
264                 }
265             }).collect(Collectors.toList());
266 
267             List<List<String>> remap = new IdRegenerator(graph)
268                     .withActualRename(commit)
269                     .collisionMode(collisions)
270                     .skippingCollisions(tolerant)
271                     .reGenerateIds(items);
272             tx.success();
273             return Table.of(remap);
274         }
275     }
276 
277     /**
278      * Regenerate the hierarchical graph ID all items of a given
279      * type.
280      * <p>
281      * The default mode is to output items whose IDs would change, without
282      * actually changing them. The {@code collisions} parameter will <b>only</b>
283      * output items that would cause collisions if renamed, whereas {@code tolerant}
284      * mode will skip them altogether.
285      * <p>
286      * The {@code commit} flag will cause renaming to take place.
287      *
288      * @param type       the item type
289      * @param collisions only output items that if renamed to the
290      *                   regenerated ID would cause collisions
291      * @param tolerant   skip items that could cause collisions rather
292      *                   then throwing an error
293      * @param commit     whether or not to rename the items
294      * @return a tab list old-to-new mappings, or an empty
295      * body if nothing was changed
296      */
297     @POST
298     @Produces({MediaType.APPLICATION_JSON, CSV_MEDIA_TYPE})
299     @Path("regenerate-ids-for-type/{type:[^/]+}")
300     public Table regenerateIdsForType(
301             @PathParam("type") String type,
302             @QueryParam("collisions") @DefaultValue("false") boolean collisions,
303             @QueryParam(TOLERANT_PARAM) @DefaultValue("false") boolean tolerant,
304             @QueryParam(COMMIT_PARAM) @DefaultValue("false") boolean commit)
305             throws IOException, IdRegenerator.IdCollisionError {
306         try (final Tx tx = beginTx()) {
307             EntityClass entityClass = EntityClass.withName(type);
308             try (CloseableIterable<Accessible> frames = manager
309                     .getEntities(entityClass, Accessible.class)) {
310                 List<List<String>> lists = new IdRegenerator(graph)
311                         .withActualRename(commit)
312                         .collisionMode(collisions)
313                         .skippingCollisions(tolerant)
314                         .reGenerateIds(frames);
315                 tx.success();
316                 return Table.of(lists);
317             }
318         }
319     }
320 
321     /**
322      * Regenerate the hierarchical graph ID for all items within the
323      * permission scope and lower levels.
324      * <p>
325      * The default mode is to output items whose IDs would change, without
326      * actually changing them. The {@code collisions} parameter will <b>only</b>
327      * output items that would cause collisions if renamed, whereas {@code tolerant}
328      * mode will skip them altogether.
329      * <p>
330      * The {@code commit} flag will cause renaming to take place.
331      *
332      * @param scopeId    the scope item's ID
333      * @param collisions only output items that if renamed to the
334      *                   regenerated ID would cause collisions
335      * @param tolerant   skip items that could cause collisions rather
336      *                   then throwing an error
337      * @param commit     whether or not to rename the items
338      * @return a tab list old-to-new mappings, or an empty
339      * body if nothing was changed
340      */
341     @POST
342     @Produces({MediaType.APPLICATION_JSON, CSV_MEDIA_TYPE})
343     @Path("regenerate-ids-for-scope/{scope:[^/]+}")
344     public Table regenerateIdsForScope(
345             @PathParam("scope") String scopeId,
346             @QueryParam("collisions") @DefaultValue("false") boolean collisions,
347             @QueryParam(TOLERANT_PARAM) @DefaultValue("false") boolean tolerant,
348             @QueryParam(COMMIT_PARAM) @DefaultValue("false") boolean commit)
349             throws IOException, ItemNotFound, IdRegenerator.IdCollisionError {
350         try (final Tx tx = beginTx()) {
351             PermissionScope scope = manager.getEntity(scopeId, PermissionScope.class);
352             List<List<String>> lists = new IdRegenerator(graph)
353                     .withActualRename(commit)
354                     .skippingCollisions(tolerant)
355                     .collisionMode(collisions)
356                     .reGenerateIds(scope.getAllContainedItems());
357             tx.success();
358             return Table.of(lists);
359         }
360     }
361 
362     /**
363      * Regenerate description IDs.
364      */
365     @POST
366     @Produces("text/plain")
367     @Path("regenerate-description-ids")
368     public String regenerateDescriptionIds(
369             @QueryParam("buffer") @DefaultValue("-1") int bufferSize,
370             @QueryParam(COMMIT_PARAM) @DefaultValue("false") boolean commit)
371             throws IOException, ItemNotFound, IdRegenerator.IdCollisionError {
372         EntityClass[] types = {EntityClass.DOCUMENTARY_UNIT_DESCRIPTION, EntityClass
373                 .CVOC_CONCEPT_DESCRIPTION, EntityClass.HISTORICAL_AGENT_DESCRIPTION, EntityClass
374                 .REPOSITORY_DESCRIPTION};
375         int done = 0;
376         try (final Tx tx = beginTx()) {
377             final Serializer depSerializer = new Serializer.Builder(graph).dependentOnly().build();
378             for (EntityClass entityClass : types) {
379                 try (CloseableIterable<Description> descriptions = manager.getEntities(entityClass, Description.class)) {
380                     for (Description desc : descriptions) {
381                         Described entity = desc.getEntity();
382                         if (entity != null) {
383                             PermissionScope scope = entity.getPermissionScope();
384                             List<String> idPath = scope != null
385                                     ? Lists.newArrayList(scope.idPath())
386                                     : Lists.newArrayList();
387                             idPath.add(entity.getIdentifier());
388                             Bundle descBundle = depSerializer.entityToBundle(desc);
389                             String newId = entityClass.getIdGen().generateId(idPath, descBundle);
390                             if (!newId.equals(desc.getId()) && commit) {
391                                 manager.renameVertex(desc.asVertex(), desc.getId(), newId);
392                                 done++;
393 
394                                 if (bufferSize > 0 && done % bufferSize == 0) {
395                                     tx.success();
396                                 }
397                             }
398                         }
399                     }
400                 }
401             }
402             if (commit && done > 0) {
403                 tx.success();
404             }
405             return String.valueOf(done);
406         } catch (SerializationError e) {
407             throw new RuntimeException(e);
408         }
409     }
410 
411     @POST
412     @Produces("text/plain")
413     @Path("set-labels")
414     public String setLabels() throws IOException, ItemNotFound, IdRegenerator.IdCollisionError {
415         long done = 0;
416         try (final Tx tx = beginTx()) {
417             for (Vertex v : graph.getVertices()) {
418                 try {
419                     ((Neo4jGraphManager) manager).setLabels(v);
420                     done++;
421                 } catch (org.neo4j.graphdb.ConstraintViolationException e) {
422                     logger.error("Error setting labels on {} ({})", manager.getId(v), v.getId());
423                     e.printStackTrace();
424                 }
425 
426                 if (done % 100000 == 0) {
427                     graph.getBaseGraph().commit();
428                 }
429             }
430             tx.success();
431         }
432 
433         return String.valueOf(done);
434     }
435 
436     @POST
437     @Produces("text/plain")
438     @Path("set-constraints")
439     public void setConstraints() {
440         try (final Tx tx = beginTx()) {
441             logger.info("Initializing graph schema...");
442             manager.initialize();
443             tx.success();
444         }
445     }
446 
447     @POST
448     @Produces("text/plain")
449     @Path("upgrade-1to2")
450     public String upgradeDb1to2() throws IOException {
451         final AtomicInteger done = new AtomicInteger();
452         try (final Tx tx = beginTx()) {
453             logger.info("Upgrading DB schema...");
454             DbUpgrader1to2 upgrader1to2 = new DbUpgrader1to2(graph, () -> {
455                 if (done.getAndIncrement() % 100000 == 0) {
456                     graph.getBaseGraph().commit();
457                 }
458             });
459             upgrader1to2
460                     .upgradeIdAndTypeKeys()
461                     .upgradeTypeValues()
462                     .setIdAndTypeOnEventLinks();
463             tx.success();
464             logger.info("Changed {} items", done.get());
465             return String.valueOf(done.get());
466         }
467     }
468 
469     @POST
470     @Produces("text/plain")
471     @Path("full-upgrade-1to2")
472     public void fullUpgradeDb1to2()
473             throws IOException, IdRegenerator.IdCollisionError, ItemNotFound {
474         upgradeDb1to2();
475         setLabels();
476         setConstraints();
477         try (Tx tx = beginTx()) {
478             new DbUpgrader1to2(graph, () -> {
479             }).setDbSchemaVersion();
480             tx.success();
481         }
482     }
483 
484     /**
485      * Takes a CSV file containing a mapping from one item to
486      * another and moves changes the link target of anything linked
487      * to <code>from</code> to <code>to</code>.
488      *
489      * @param mapping a comma-separated TSV file, excluding headers
490      * @return CSV data with each row indicating the source, target, and how many items
491      * were relinked for each
492      */
493     @POST
494     @Consumes({MediaType.APPLICATION_JSON, CSV_MEDIA_TYPE})
495     @Produces({MediaType.APPLICATION_JSON, CSV_MEDIA_TYPE})
496     @Path("relink-targets")
497     public Table relink(Table mapping) throws DeserializationError {
498         try (final Tx tx = beginTx()) {
499             List<List<String>> done = Lists.newArrayList();
500             for (List<String> row : mapping.rows()) {
501                 if (row.size() != 2) {
502                     throw new DeserializationError(
503                             "Invalid table data: must contain 2 columns only");
504                 }
505                 String fromId = row.get(0);
506                 String toId = row.get(1);
507                 Linkable from = manager.getEntity(fromId, Linkable.class);
508                 Linkable to = manager.getEntity(toId, Linkable.class);
509                 int relinked = 0;
510                 for (Link link : from.getLinks()) {
511                     link.addLinkTarget(to);
512                     link.removeLinkTarget(from);
513                     relinked++;
514                 }
515                 done.add(Lists.newArrayList(fromId, toId, String.valueOf(relinked)));
516             }
517 
518             tx.success();
519             return Table.of(done);
520         } catch (ItemNotFound e) {
521             throw new DeserializationError("Unable to locate item with ID: " + e.getValue());
522         }
523     }
524 
525     /**
526      * Takes a CSV file containing two columns: an item global id, and a new parent
527      * ID. The item will be re-parented and a new global ID regenerated.
528      *
529      * @param commit  actually commit changes
530      * @param mapping a comma-separated CSV file, exluding headers.
531      * @return CSV data containing two columns: the old global ID, and
532      * a newly generated global ID, derived from the new hierarchy.
533      */
534     @POST
535     @Consumes({MediaType.APPLICATION_JSON, CSV_MEDIA_TYPE})
536     @Produces({MediaType.APPLICATION_JSON, CSV_MEDIA_TYPE})
537     @Path("reparent")
538     public Table reparent(@QueryParam("commit") @DefaultValue("false") boolean commit, Table mapping)
539             throws DeserializationError {
540         try (final Tx tx = beginTx()) {
541             IdRegenerator idRegenerator = new IdRegenerator(graph).withActualRename(commit);
542             List<List<String>> done = Lists.newArrayList();
543             for (List<String> row : mapping.rows()) {
544                 if (row.size() != 2) {
545                     throw new DeserializationError(
546                             "Invalid table data: must contain 2 columns only");
547                 }
548                 String id = row.get(0);
549                 String newParentId = row.get(1);
550                 DocumentaryUnit item = manager
551                         .getEntity(id, EntityClass.DOCUMENTARY_UNIT, DocumentaryUnit.class);
552                 PermissionScope parent = manager.getEntity(newParentId, PermissionScope.class);
553                 item.setPermissionScope(parent);
554                 if (Entities.DOCUMENTARY_UNIT.equals(parent.getType())) {
555                     parent.as(DocumentaryUnit.class).addChild(item);
556                 } else if (Entities.REPOSITORY.equals(parent.getType())) {
557                     item.setRepository(parent.as(Repository.class));
558                 } else {
559                     throw new DeserializationError(String.format(
560                             "Unsupported parent type for ID '%s': %s",
561                             newParentId, parent.getType()));
562                 }
563                 try {
564                     idRegenerator.reGenerateId(item).ifPresent(done::add);
565                 } catch (IdRegenerator.IdCollisionError e) {
566                     throw new DeserializationError(String.format(
567                             "%s. Ensure they do not share the same local identifier: '%s'",
568                             e.getMessage(), item.getIdentifier()));
569                 }
570             }
571 
572             if (commit) {
573                 tx.success();
574             }
575             return Table.of(done);
576         } catch (ItemNotFound e) {
577             throw new DeserializationError("Unable to locate item with ID: " + e.getValue());
578         }
579     }
580 
581     /**
582      * Takes a CSV file containing two columns: the global id, and a new local
583      * identifier to rename an item to. A new global ID will be regenerated.
584      * <p>
585      * Input rows will be re-sorted lexically to ensure correct generation
586      * of dependent parent/child hierarchical IDs and output order will
587      * reflect this.
588      *
589      * @param mapping a comma-separated CSV file, exluding headers.
590      * @return CSV data containing two columns: the old global ID, and
591      * a newly generated global ID, derived from the new local identifier,
592      * with ordering corresponding to lexically-ordered input data.
593      */
594     @POST
595     @Consumes({MediaType.APPLICATION_JSON, CSV_MEDIA_TYPE})
596     @Produces({MediaType.APPLICATION_JSON, CSV_MEDIA_TYPE})
597     @Path("rename")
598     public Table rename(Table mapping)
599             throws IdRegenerator.IdCollisionError, DeserializationError {
600         try (final Tx tx = beginTx()) {
601             IdRegenerator idRegenerator = new IdRegenerator(graph).withActualRename(true);
602             // Sorting the toString of each item *should* put hierarchical lists
603             // in parent-child order, which is necessary to have ID-regeneration work
604             // correctly.
605             List<List<String>> sorted = Ordering.usingToString().sortedCopy(mapping.rows());
606 
607             List<List<String>> done = Lists.newArrayList();
608             for (List<String> row : sorted) {
609                 if (row.size() != 2) {
610                     throw new DeserializationError(
611                             "Invalid table data: must contain 2 columns only");
612                 }
613                 String currentId = row.get(0);
614                 String newLocalIdentifier = row.get(1);
615                 Accessible item = manager.getEntity(currentId, Accessible.class);
616                 item.asVertex().setProperty(Ontology.IDENTIFIER_KEY, newLocalIdentifier);
617                 idRegenerator.reGenerateId(item).ifPresent(done::add);
618             }
619 
620             tx.success();
621             return Table.of(done);
622         } catch (ItemNotFound e) {
623             throw new DeserializationError("Unable to locate item with ID: " + e.getValue());
624         }
625     }
626 
627     /**
628      * Extremely lossy helper method for cleaning a test instance
629      *
630      * @param fixtures YAML fixture data to be loaded into the fresh graph
631      */
632     @POST
633     @Path("__INITIALISE")
634     public void initialize(
635             @QueryParam("yes-i-am-sure") @DefaultValue("false") boolean confirm,
636             InputStream fixtures) throws Exception {
637         try (final Tx tx = beginTx()) {
638             sanityCheck(confirm);
639 
640             for (Vertex v : graph.getVertices()) {
641                 v.remove();
642             }
643             tx.success();
644         }
645         setConstraints();
646         try (final Tx tx = beginTx()) {
647             FixtureLoaderFactory.getInstance(graph, true)
648                     .loadTestData(fixtures);
649             tx.success();
650         }
651     }
652 
653     private void sanityCheck(boolean confirm) {
654         // Bail out if we've got many nodes
655         Iterator<Vertex> counter = graph.getVertices().iterator();
656         int c = 0;
657         while (counter.hasNext()) {
658             counter.next();
659             c++;
660             if (c > 500) {
661                 if (!confirm) {
662                     throw new RuntimeException("This database has more than 500 nodes. " +
663                             "Refusing to clear it without confirmation!");
664                 } else break;
665             }
666         }
667     }
668 }