Java tutorial
/* * (C) Copyright 2010 Nuxeo SA (http://nuxeo.com/) and contributors. * * All rights reserved. This program and the accompanying materials * are made available under the terms of the GNU Lesser General Public License * (LGPL) version 2.1 which accompanies this distribution, and is available at * http://www.gnu.org/licenses/lgpl.html * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * Contributors: * Olivier Grisel */ package org.nuxeo.ecm.platform.semanticentities.service; import java.io.IOException; import java.io.Serializable; import java.net.URI; import java.text.Normalizer; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.TreeSet; import java.util.UUID; import java.util.concurrent.TimeUnit; import org.apache.commons.lang.NotImplementedException; import org.apache.commons.lang.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.nuxeo.ecm.core.api.ClientException; import org.nuxeo.ecm.core.api.CoreSession; import org.nuxeo.ecm.core.api.DocumentModel; import org.nuxeo.ecm.core.api.DocumentModelList; import org.nuxeo.ecm.core.api.DocumentRef; import org.nuxeo.ecm.core.api.IdRef; import org.nuxeo.ecm.core.api.PathRef; import org.nuxeo.ecm.core.api.UnrestrictedSessionRunner; import org.nuxeo.ecm.core.api.model.PropertyException; import org.nuxeo.ecm.core.api.pathsegment.PathSegmentService; import org.nuxeo.ecm.core.api.security.ACE; import org.nuxeo.ecm.core.api.security.ACL; import org.nuxeo.ecm.core.api.security.ACP; import org.nuxeo.ecm.core.api.security.SecurityConstants; import org.nuxeo.ecm.core.api.security.impl.ACPImpl; import org.nuxeo.ecm.core.schema.SchemaManager; import org.nuxeo.ecm.core.trash.TrashService; import org.nuxeo.ecm.platform.query.api.PageProvider; import org.nuxeo.ecm.platform.query.nxql.NXQLQueryBuilder; import org.nuxeo.ecm.platform.semanticentities.Constants; import org.nuxeo.ecm.platform.semanticentities.DereferencingException; import org.nuxeo.ecm.platform.semanticentities.EntitySuggestion; import org.nuxeo.ecm.platform.semanticentities.LocalEntityService; import org.nuxeo.ecm.platform.semanticentities.RemoteEntityService; import org.nuxeo.ecm.platform.semanticentities.adapter.OccurrenceGroup; import org.nuxeo.ecm.platform.semanticentities.adapter.OccurrenceInfo; import org.nuxeo.ecm.platform.semanticentities.adapter.OccurrenceRelation; import org.nuxeo.runtime.api.Framework; import org.nuxeo.runtime.model.DefaultComponent; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; /** * Service to handle semantic entities linked to documents in the local repository. Relations between documents and * entities are stored in the Nuxeo repository and documents of type "Occurrence" which is a sub-type of the "Relation" * core type. */ public class LocalEntityServiceImpl extends DefaultComponent implements LocalEntityService { public static final Log log = LogFactory.getLog(LocalEntityServiceImpl.class); // TODO: make me configurable in an extension point public String SEPARATOR_CHARS_TO_IGNORE = "(\\p{P}|\\n| |<|>|\\+|\\-)+"; // remove diacritics and the arabic hamzah public String CHARS_TO_IGNORE = "(\\p{InCombiningDiacriticalMarks}|\u0654|\u0655)+"; // TODO: make me configurable in an extension point public static final String ENTITY_CONTAINER_PATH = "/default-domain/entities"; public static final String ENTITY_CONTAINER_TITLE = "Entities"; protected Cache<String, DocumentRef> recentlyDereferenced = CacheBuilder.newBuilder().concurrencyLevel(4) .expireAfterWrite(5, TimeUnit.SECONDS).build(); protected Cache<DocumentRef, String> progressMessages = CacheBuilder.newBuilder().concurrencyLevel(4) .expireAfterWrite(30, TimeUnit.MINUTES).build(); @Override synchronized public DocumentModel getEntityContainer(CoreSession session) throws ClientException { final PathRef ref = new PathRef(ENTITY_CONTAINER_PATH); if (!session.exists(ref)) { // either the container has not been created yet or the current user // cannot see it because of a lack of permissions to do so int lastSlashIdx = ENTITY_CONTAINER_PATH.lastIndexOf('/'); final String id = ENTITY_CONTAINER_PATH.substring(lastSlashIdx + 1); final String parentPath = ENTITY_CONTAINER_PATH.substring(0, lastSlashIdx + 1); UnrestrictedSessionRunner runner = new UnrestrictedSessionRunner(session) { @Override public void run() throws ClientException { if (!session.exists(ref)) { // create the entity container DocumentModel entityContainer = session.createDocumentModel(parentPath, id, Constants.ENTITY_CONTAINER_TYPE); entityContainer.setPropertyValue("dc:title", ENTITY_CONTAINER_TITLE); DocumentModel createdContainer = session.createDocument(entityContainer); if (!ENTITY_CONTAINER_PATH.equals(createdContainer.getPathAsString())) { // container concurrent creation in a race // condition: delete it session.removeDocument(createdContainer.getRef()); } else { // create the occurrence container String parentPath = entityContainer.getPathAsString(); DocumentModel occurrenceContainer = session.createDocumentModel(parentPath, "occurrences", "OccurrenceContainer"); occurrenceContainer = session.createDocument(occurrenceContainer); // put a single ACL that will be inherited for all // occurrences ACP openAcp = new ACPImpl(); ACL acl = openAcp.getOrCreateACL(); acl.add(new ACE("members", SecurityConstants.WRITE, true)); acl.add(new ACE(SecurityConstants.EVERYONE, SecurityConstants.BROWSE, true)); openAcp.addACL(acl); session.setACP(occurrenceContainer.getRef(), openAcp, true); session.save(); } } } }; runner.runUnrestricted(); } if (!session.exists(ref)) { // the user does not have the right to see the container return null; } return session.getDocument(ref); } public DocumentModel getOccurrenceContainer(CoreSession session) throws ClientException { DocumentModel entityContainer = getEntityContainer(session); String parentPath = entityContainer.getPathAsString(); String localId = "occurrences"; DocumentRef occurrencesRef = new PathRef(parentPath + "/" + localId); return session.getDocument(occurrencesRef); } @Override public OccurrenceRelation addOccurrence(CoreSession session, DocumentRef docRef, DocumentRef entityRef, String quoteContext, int startPosInContext, int endPosInContext) throws ClientException { OccurrenceInfo info = new OccurrenceInfo(quoteContext, startPosInContext, endPosInContext); return addOccurrences(session, docRef, entityRef, Arrays.asList(info)); } @Override public OccurrenceRelation getOccurrenceRelation(CoreSession session, DocumentRef docRef, DocumentRef entityRef) throws ClientException { return getOccurrenceRelation(session, docRef, entityRef, false); } @Override public void removeOccurrences(CoreSession session, DocumentRef docRef, final DocumentRef entityRef, final boolean forcePhysicalDelete) throws ClientException { OccurrenceRelation rel = getOccurrenceRelation(session, docRef, entityRef, false); if (rel == null) { return; } // mark the relation document for deletion DocumentModel relDoc = rel.getOccurrenceDocument(); final List<DocumentRef> docToDelete = new ArrayList<DocumentRef>(); docToDelete.add(relDoc.getRef()); // find the linked entity to check whether it was automatically created // by the system PageProvider<DocumentModel> relatedDocuments = getRelatedDocuments(session, entityRef, null); if (relatedDocuments.getCurrentPage().size() == 1 && relatedDocuments.getCurrentEntry().getRef().equals(docRef)) { // only related to the current document DocumentModel entity = session.getDocument(entityRef); Boolean auto = entity.getProperty("entity:automaticallyCreated").getValue(Boolean.class); @SuppressWarnings("unchecked") List<String> contributors = entity.getProperty("dc:contributors").getValue(List.class); if (auto && contributors.size() <= 1) { // remove the automatically created entity as well docToDelete.add(entity.getRef()); } } final DocumentRef[] docToDeleteArray = docToDelete.toArray(new DocumentRef[docToDelete.size()]); UnrestrictedSessionRunner runner = new UnrestrictedSessionRunner(session) { @Override public void run() throws ClientException { if (!docToDelete.contains(entityRef)) { // update the popularity count of the entity DocumentModel entity = session.getDocument(entityRef); Long newPopularity = entity.getProperty("entity:popularity").getValue(Long.class) - 1; entity.setPropertyValue("entity:popularity", newPopularity); session.saveDocument(entity); } if (forcePhysicalDelete) { session.removeDocuments(docToDeleteArray); } else { try { // try to perform the actual deletion using the trash // service TrashService trashService = Framework.getService(TrashService.class); trashService.trashDocuments(session.getDocuments(docToDeleteArray)); } catch (Exception e) { // the trash service is not deployed session.removeDocuments(docToDeleteArray); } } } }; runner.runUnrestricted(); } public OccurrenceRelation getOccurrenceRelation(CoreSession session, DocumentRef docRef, DocumentRef entityRef, boolean createIfMissing) throws ClientException { String q = String.format("SELECT * FROM Occurrence" + " WHERE relation:source = '%s'" + " AND relation:target = '%s'" + " ORDER BY dc:created LIMIT 2", docRef, entityRef); DocumentModelList occurrences = session.query(q); if (occurrences.isEmpty()) { if (createIfMissing) { // create an empty document model in memory and adapt it to the // OccurrenceRelation interface // use a subcontainer of the entity container document as // parent to avoid having to put read ACLs on occurrence that // are not behaving correctly concurrently DocumentModel occ = session.createDocumentModel(getOccurrenceContainer(session).getPathAsString(), "occurrence-" + UUID.randomUUID().toString(), Constants.OCCURRENCE_TYPE); occ.setPropertyValue("relation:source", docRef.toString()); occ.setPropertyValue("relation:target", entityRef.toString()); return occ.getAdapter(OccurrenceRelation.class); } else { return null; } } else { if (occurrences.size() > 1) { log.warn(String.format("more than one occurrence found linking document" + " '%s' to entity '%s'", docRef, entityRef)); } return occurrences.get(0).getAdapter(OccurrenceRelation.class); } } @Override public void addOccurrences(CoreSession session, DocumentRef docRef, EntitySuggestion entitySuggestion, List<OccurrenceInfo> occurrences) throws ClientException, IOException { DocumentRef entityRef = null; if (entitySuggestion.isLocal()) { entityRef = entitySuggestion.entity.getRef(); } else { // use the recentlyDereferenced cache that is shared among threads // and concurrent transaction to avoid dereferencing the same remote // entity to duplicated local entities for (String uri : entitySuggestion.remoteEntityUris) { entityRef = recentlyDereferenced.getIfPresent(uri); if (entityRef != null) { break; } } if (entityRef == null) { entityRef = asLocalEntity(session, entitySuggestion).getRef(); } } addOccurrences(session, docRef, entityRef, occurrences); } @Override public OccurrenceRelation addOccurrences(CoreSession session, DocumentRef docRef, DocumentRef entityRef, List<OccurrenceInfo> occurrences) throws ClientException { if (!session.hasPermission(docRef, Constants.ADD_OCCURRENCE_PERMISSION)) { // check the permission on the source document throw new SecurityException(String.format( "%s has not the permission to add an entity" + " occurrence on document with id '%s'", session.getPrincipal().getName(), docRef)); } OccurrenceRelation relation = getOccurrenceRelation(session, docRef, entityRef, true); if (occurrences != null && !occurrences.isEmpty()) { relation.addOccurrences(occurrences); } UpdateOrCreateOccurrenceRelation op = new UpdateOrCreateOccurrenceRelation(session, relation, this); op.runUnrestricted(); return session.getDocument(op.occRef).getAdapter(OccurrenceRelation.class, true); } protected static class UpdateOrCreateOccurrenceRelation extends UnrestrictedSessionRunner { protected final OccurrenceRelation relation; protected DocumentRef occRef; protected final LocalEntityService service; public UpdateOrCreateOccurrenceRelation(CoreSession session, OccurrenceRelation relation, LocalEntityService service) { super(session); this.relation = relation; this.service = service; } @SuppressWarnings("unchecked") @Override public void run() throws ClientException { // update the entity aggregated alternative names for better // fulltext indexing DocumentModel entity = null; try { entity = session.getDocument(relation.getTargetEntityRef()); } catch (ClientException e) { // There is a potential race condition if two users deference // the same entity exactly at the same time. // Will assume that the problem does not occur to often in // practice so that the popularity score and the alternative // names update discrepancies are not a major issue } if (entity != null) { List<String> altnames = entity.getProperty("entity:altnames").getValue(List.class); for (OccurrenceInfo occInfo : relation.getOccurrences()) { if (!occInfo.mention.equals(entity.getPropertyValue("dc:title"))) { if (!altnames.contains(occInfo.mention)) { altnames = new ArrayList<String>(altnames); altnames.add(occInfo.mention); } } } entity.setPropertyValue("entity:altnames", (Serializable) altnames); } // materialize the target entities id ref directly on the document // to make // it possible to do fast concept queries DocumentModel doc = session.getDocument(relation.getSourceDocumentRef()); if (!doc.hasFacet(HAS_SEMANTICS_FACET)) { doc.addFacet(HAS_SEMANTICS_FACET); } List<String> entities = new ArrayList<String>(); if (doc.getPropertyValue("semantics:entities") != null) { entities = doc.getProperty("semantics:entities").getValue(List.class); } if (!entities.contains(relation.getTargetEntityRef())) { entities = new ArrayList<String>(entities); entities.add(relation.getTargetEntityRef().toString()); } doc.setPropertyValue("semantics:entities", (Serializable) entities); // TODO: handle deletion of relation to cleanup that list session.saveDocument(doc); // create / update the relation document itself if (relation.getOccurrenceDocument().getId() == null) { // this is a creation of a new relation between a document and // the entity occRef = session.createDocument(relation.getOccurrenceDocument()).getRef(); if (entity != null) { // update the popularity estimate Long newPopularity = entity.getProperty("entity:popularity").getValue(Long.class) + 1; entity.setPropertyValue("entity:popularity", newPopularity); } } else { // this is an update of an existing relation occRef = session.saveDocument(relation.getOccurrenceDocument()).getRef(); } if (entity != null) { // save popularity and altnames info at once session.saveDocument(entity); } session.save(); } } @Override public PageProvider<DocumentModel> getRelatedDocuments(CoreSession session, DocumentRef entityRef, String documentType) throws ClientException { if (documentType == null) { documentType = "cmis:document"; } if (!(entityRef instanceof IdRef)) { throw new NotImplementedException( "Only IdRef instance are currently supported, got " + entityRef.getClass().getName()); } String query = String.format( "SELECT Doc.cmis:objectId FROM %s Doc " + "JOIN Occurrence Occ ON Occ.relation:source = Doc.cmis:objectId " + "WHERE Occ.relation:target = '%s' " + "ORDER BY Doc.dc:modified DESC", documentType, entityRef); return new CMISQLDocumentPageProvider(session, query, "Doc.cmis:objectId", "relatedDocuments"); } @Override public PageProvider<DocumentModel> getRelatedEntities(CoreSession session, DocumentRef docRef, String entityType) throws ClientException { if (entityType == null) { entityType = Constants.ENTITY_TYPE; } if (!(docRef instanceof IdRef)) { throw new NotImplementedException( "Only IdRef instance are currently supported, got " + docRef.getClass().getName()); } // order by number of incoming links instead? String query = String.format("SELECT Ent.cmis:objectId FROM %s Ent " + "JOIN Occurrence Occ ON Occ.relation:target = Ent.cmis:objectId " + "WHERE Occ.relation:source = '%s' " + "ORDER BY Ent.dc:title", entityType, docRef); return new CMISQLDocumentPageProvider(session, query, "Ent.cmis:objectId", "relatedEntities"); } public String normalizeName(String name) { // remove punctuation and normalize whitespaces name = name.replaceAll(SEPARATOR_CHARS_TO_IGNORE, " ").trim(); // strip accents and diacritics name = Normalizer.normalize(name, Normalizer.Form.NFD).replaceAll(CHARS_TO_IGNORE, ""); // make name lookups case insensitive by normalizing case return name.toLowerCase(); } @SuppressWarnings("unchecked") public boolean updateNormalizedNames(DocumentModel doc, boolean forceUpdate) throws PropertyException, ClientException { if (!doc.hasSchema("entity")) { log.warn( String.format("Cannot normalize names on document '%s' as it does not have the 'entity' schema", doc.getTitle())); } if (!forceUpdate && doc.getPropertyValue("entity:normalizednames") != null && !doc.getProperty("dc:title").isDirty() && !doc.getProperty("entity:altnames").isDirty()) { // nothing to update return false; } Set<String> names = new LinkedHashSet<String>(); Set<String> normalized = new LinkedHashSet<String>(); names.add(doc.getTitle()); if (doc.getPropertyValue("entity:altnames") != null) { names.addAll(doc.getProperty("entity:altnames").getValue(List.class)); } for (String name : names) { normalized.add(normalizeName(name)); } doc.setPropertyValue("entity:normalizednames", normalized.toArray()); if (log.isDebugEnabled()) { log.debug(String.format( "Updated names for document '%s'. Form tource names: '%s' to normalized names: '%s'", doc.getTitle(), StringUtils.join(names, "', '"), StringUtils.join(normalized, "', '"))); } return true; } @Override public List<EntitySuggestion> suggestLocalEntity(CoreSession session, String keywords, String type, int maxSuggestions) throws ClientException { Set<String> entityTypeNames = new TreeSet<String>(); if (type == null) { try { entityTypeNames = getEntityTypeNames(); } catch (Exception e) { throw new RuntimeException(e); } entityTypeNames.remove(Constants.ENTITY_TYPE); } else { entityTypeNames.add(type); } String sanitized = NXQLQueryBuilder.sanitizeFulltextInput(keywords); String normalized = normalizeName(keywords); String q = String.format( "SELECT * FROM %s WHERE (ecm:fulltext_title = '%s' OR entity:normalizednames = '%s')" + " AND ecm:primaryType IN ('%s')" + " AND ecm:currentLifeCycleState != 'deleted'" + " AND ecm:isCheckedInVersion = 0" + " ORDER BY entity:popularity DESC, dc:title LIMIT %d", Constants.ENTITY_TYPE, sanitized, normalized, StringUtils.join(entityTypeNames, "', '"), maxSuggestions); // TODO: read the score info as well List<EntitySuggestion> suggestions = new ArrayList<EntitySuggestion>(); for (DocumentModel doc : session.query(q)) { suggestions.add(new EntitySuggestion(doc)); } return suggestions; } @Override public List<EntitySuggestion> suggestEntity(CoreSession session, OccurrenceGroup group, int maxSuggestions) throws DereferencingException, ClientException { if (group.hasPrefetchedSuggestions()) { return suggestEntity(session, group.name, group.type, group.entitySuggestions, maxSuggestions); } else { return suggestEntity(session, group.name, group.type, null, maxSuggestions); } } @Override public List<EntitySuggestion> suggestEntity(CoreSession session, String keywords, String type, int maxSuggestions) throws ClientException, DereferencingException { return suggestEntity(session, keywords, type, null, maxSuggestions); } protected List<EntitySuggestion> suggestEntity(CoreSession session, String keywords, String type, List<EntitySuggestion> precomputedRemoteSuggestions, int maxSuggestions) throws ClientException, DereferencingException { // lookup remote entities RemoteEntityService reService; try { reService = Framework.getService(RemoteEntityService.class); } catch (Exception e) { throw new RuntimeException(e); } List<EntitySuggestion> remoteSuggestions = precomputedRemoteSuggestions; if (remoteSuggestions == null) { if (reService.canSuggestRemoteEntity()) { try { remoteSuggestions = reService.suggestRemoteEntity(keywords, type, maxSuggestions); } catch (IOException e) { log.warn(String.format("failed to suggest remote entity for '%s' with type '%s': %s", keywords, type, e.getMessage())); remoteSuggestions = Collections.emptyList(); } } else { remoteSuggestions = Collections.emptyList(); } } // lookup local entities List<EntitySuggestion> suggestions = suggestLocalEntity(session, keywords, type, maxSuggestions); Set<String> alreadySeenRemoteUris = new HashSet<String>(); for (EntitySuggestion suggestion : suggestions) { alreadySeenRemoteUris.addAll(suggestion.remoteEntityUris); } for (EntitySuggestion remoteSuggestion : remoteSuggestions) { String remoteUri = remoteSuggestion.getRemoteUri(); if (alreadySeenRemoteUris.contains(remoteUri)) { // filter out remote suggestions that already have a match // in the local suggestions // TODO: how to account for a rank boost? continue; } suggestions.add(remoteSuggestion); } // TODO: re-rank results to boost exact name matches return suggestions; } @Override public List<DocumentModel> suggestDocument(CoreSession session, String keywords, String type, int maxSuggestions) throws Exception { if (type == null) { type = "cmis:document"; } String query = String.format( "SELECT cmis:objectId, SCORE() relevance FROM %s " + "WHERE CONTAINS('%s')" + " AND cmis:objectTypeId NOT IN ('%s')" + " AND nuxeo:isVersion = false " + "ORDER BY relevance DESC", type, normalizeName(keywords), StringUtils.join(getEntityTypeNames(), "', '")); PageProvider<DocumentModel> provider = new CMISQLDocumentPageProvider(session, query, "cmis:objectId", "suggestedDocuments"); provider.setPageSize(maxSuggestions); return provider.getCurrentPage(); } @Override public Set<String> getEntityTypeNames() throws Exception { Set<String> types = Framework.getService(SchemaManager.class) .getDocumentTypeNamesExtending(Constants.ENTITY_TYPE); return new TreeSet<String>(types); } @Override public DocumentModel getLinkedLocalEntity(CoreSession session, URI remoteEntityURI) throws ClientException { String query = String.format( "SELECT cmis:objectId FROM Entity " + "WHERE '%s' = ANY entity:sameas ORDER BY dc:created", remoteEntityURI.toString()); PageProvider<DocumentModel> provider = new CMISQLDocumentPageProvider(session, query, "cmis:objectId", "linkedEntities"); provider.setPageSize(1); List<DocumentModel> currentPage = provider.getCurrentPage(); long count = provider.getResultsCount(); if (count == 0) { return null; } else if (count > 1) { log.warn(String.format("semantic inconsistency: found %d local entities linked to '%s'", count, remoteEntityURI)); } return currentPage.get(0); } @Override public DocumentModel asLocalEntity(CoreSession session, EntitySuggestion suggestion) throws ClientException, IOException { if (suggestion.isLocal()) { return suggestion.entity; } else if (suggestion.remoteEntityUris.isEmpty()) { throw new IllegalArgumentException( "The provided suggestion has neither local" + " entity nor emote entities links"); } // TODO: optimize to do a single core query using a disjunction on all // the remote URI if we ever have the case for many remote URI for (String remoteEntityUri : suggestion.remoteEntityUris) { DocumentModel localEntity = getLinkedLocalEntity(session, URI.create(remoteEntityUri)); if (localEntity != null) { return localEntity; } } // dereference remote entity as a local entity RemoteEntityService reService; PathSegmentService psService; try { reService = Framework.getService(RemoteEntityService.class); psService = Framework.getService(PathSegmentService.class); } catch (Exception e) { throw new RuntimeException(e); } DocumentModel entityContainer = getEntityContainer(session); DocumentModel localEntity; if (suggestion.entity != null) { // this is pre-fetched in memory representation of a remote entity // that does not already exist in the local repository localEntity = suggestion.entity; // ensure that the parent location and path segment are ok String pathSegment = psService.generatePathSegment(localEntity); localEntity.setPathInfo(entityContainer.getPathAsString(), pathSegment); } else { // lazy dereferencing into a new local entity document localEntity = session.createDocumentModel(suggestion.type); localEntity.setPropertyValue("dc:title", suggestion.label); localEntity.setPropertyValue("entity:automaticallyCreated", suggestion.automaticallyCreated); String pathSegment = psService.generatePathSegment(localEntity); localEntity.setPathInfo(entityContainer.getPathAsString(), pathSegment); for (String remoteEntity : suggestion.remoteEntityUris) { URI uri = URI.create(remoteEntity); reService.dereferenceInto(localEntity, uri, false, false); } } localEntity = session.createDocument(localEntity); for (String remoteEntity : suggestion.remoteEntityUris) { recentlyDereferenced.put(remoteEntity, localEntity.getRef()); } session.save(); return localEntity; } }