DATAGRAPH-544 Relationship Properties are not saved when using new remote implementation
This commit is contained in:
@@ -23,6 +23,8 @@ import org.neo4j.helpers.collection.IteratorUtil;
|
||||
import org.neo4j.helpers.collection.IteratorWrapper;
|
||||
import org.springframework.data.neo4j.aspects.Friendship;
|
||||
import org.springframework.data.neo4j.aspects.Person;
|
||||
import org.springframework.data.neo4j.core.GraphDatabase;
|
||||
import org.springframework.data.neo4j.template.GraphCallback;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -81,6 +83,25 @@ public class RelationshipEntityTests extends EntityTestBase {
|
||||
friends.add(friendship);
|
||||
assertEquals(1, IteratorUtil.count(friends));
|
||||
assertEquals(friendship,IteratorUtil.first(friends));
|
||||
assertEquals(friendship.getYears(),IteratorUtil.first(friends).getYears());
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional
|
||||
public void shouldSupportManagedSetAddOfRelationshipEntitiesSave() {
|
||||
Person p = persistedPerson("Michael", 35);
|
||||
Person p2 = persistedPerson("David", 25);
|
||||
final Set<Friendship> friends = p.getFriendshipsSet();
|
||||
assertEquals(0, IteratorUtil.count(friends));
|
||||
final Friendship friendship = new Friendship(p, p2, 10);
|
||||
Friendship friends2 = neo4jTemplate.save(friendship);
|
||||
assertEquals(friendship, friends2);
|
||||
assertEquals(friendship.getYears(),friends2.getYears());
|
||||
neo4jTemplate.fetch(p);
|
||||
Set<Friendship> friendships = neo4jTemplate.fetch(p.getFriendshipsSet());
|
||||
Friendship friends3 = IteratorUtil.first(friendships);
|
||||
assertEquals(friendship, friends3);
|
||||
assertEquals(friendship.getYears(),friends3.getYears());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -106,6 +127,21 @@ public class RelationshipEntityTests extends EntityTestBase {
|
||||
assertEquals(1, getRelationshipState(f).getProperty("Friendship.years"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional
|
||||
public void testRelationshipSetPropertyLater() {
|
||||
Person p = persistedPerson("Michael", 35);
|
||||
Person p2 = persistedPerson("David", 25);
|
||||
Friendship f = new Friendship(p,p2,1);
|
||||
f = neo4jTemplate.save(f);
|
||||
Relationship r = getRelationshipState(f);
|
||||
assertEquals(1, r.getProperty("Friendship.years"));
|
||||
Friendship f2 = neo4jTemplate.findOne(r.getId(), Friendship.class);
|
||||
f2.setYears(2);
|
||||
f2 = neo4jTemplate.save(f2);
|
||||
assertEquals(2, getRelationshipState(f2).getProperty("Friendship.years"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional
|
||||
public void testRelationshipGetProperty() {
|
||||
@@ -135,7 +171,6 @@ public class RelationshipEntityTests extends EntityTestBase {
|
||||
assertEquals(f, neo4jTemplate.getRelationshipBetween(p, p2, Friendship.class, "knows"));
|
||||
}
|
||||
|
||||
//@Ignore("The NodeBacking.getRelationshipTo() method is broken at the moment")
|
||||
@Test
|
||||
@Transactional
|
||||
public void testGetRelationshipTo() {
|
||||
|
||||
@@ -86,12 +86,6 @@ public interface RestAPI extends RestAPIIndex, RestAPIInternal {
|
||||
|
||||
// internal
|
||||
|
||||
RestRequest getRestRequest();
|
||||
|
||||
RestNode addToCache(RestNode restNode);
|
||||
RestNode getFromCache(long id);
|
||||
void removeFromCache(long id);
|
||||
|
||||
void close();
|
||||
|
||||
Relationship getOrCreateRelationship(Node start, Node end, RelationshipType type, Direction direction, Map<String, Object> props);
|
||||
|
||||
@@ -104,7 +104,7 @@ public class RestAPICypherImpl implements RestAPI {
|
||||
@Override
|
||||
public RestNode getNodeById(long id, Load force) {
|
||||
if (force != Load.ForceFromServer) {
|
||||
RestNode restNode = getFromCache(id);
|
||||
RestNode restNode = getNodeFromCache(id);
|
||||
if (restNode != null) return restNode;
|
||||
}
|
||||
if (force == Load.FromCache) return new RestNode(RestNode.nodeUri(this, id), this);
|
||||
@@ -116,13 +116,49 @@ public class RestAPICypherImpl implements RestAPI {
|
||||
return addToCache(toNode(row));
|
||||
}
|
||||
|
||||
public RestNode getFromCache(long id) {
|
||||
return restAPI.getFromCache(id);
|
||||
@Override
|
||||
public RestRelationship addToCache(RestRelationship restRelationship) {
|
||||
return restAPI.addToCache(restRelationship);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeFromCache(long id) {
|
||||
restAPI.removeFromCache(id);
|
||||
public RestRelationship getRelationshipById(long id, Load force) {
|
||||
if (force != Load.ForceFromServer) {
|
||||
RestRelationship restRel = getRelFromCache(id);
|
||||
if (restRel != null) return restRel;
|
||||
}
|
||||
if (force == Load.FromCache) return new RestRelationship(RestRelationship.relUri(this, id), this);
|
||||
try {
|
||||
Iterator<List<Object>> result = runQuery(GET_REL_QUERY, map("id", id)).getRows().iterator();
|
||||
if (!result.hasNext()) {
|
||||
throw new NotFoundException("Relationship not found " + id);
|
||||
}
|
||||
List<Object> row = result.next();
|
||||
return addToCache(toRel(row));
|
||||
} catch (NotFoundException e) {
|
||||
throw e;
|
||||
} catch (CypherTransactionExecutionException ctee) {
|
||||
if (ctee.contains("Neo.DatabaseError.Statement.ExecutionFailure","not found")) {
|
||||
throw new NotFoundException("Relationship not found " + id);
|
||||
}
|
||||
throw ctee;
|
||||
}
|
||||
}
|
||||
|
||||
public RestNode getNodeFromCache(long id) {
|
||||
return restAPI.getNodeFromCache(id);
|
||||
}
|
||||
public RestRelationship getRelFromCache(long id) {
|
||||
return restAPI.getRelFromCache(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeNodeFromCache(long id) {
|
||||
restAPI.removeNodeFromCache(id);
|
||||
}
|
||||
@Override
|
||||
public void removeRelFromCache(long id) {
|
||||
restAPI.removeRelFromCache(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -130,6 +166,12 @@ public class RestAPICypherImpl implements RestAPI {
|
||||
return getNodeById(id, Load.FromServer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestRelationship getRelationshipById(long id) {
|
||||
return getRelationshipById(id, Load.FromServer);
|
||||
}
|
||||
|
||||
|
||||
private RestNode toNode(List<Object> row) {
|
||||
long id = ((Number) row.get(0)).longValue();
|
||||
List<String> labels = (List<String>) row.get(1);
|
||||
@@ -146,26 +188,6 @@ public class RestAPICypherImpl implements RestAPI {
|
||||
return RestRelationship.fromCypher(id, type, props, start, end, this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestRelationship getRelationshipById(long id) {
|
||||
try {
|
||||
Iterator<List<Object>> result = runQuery(GET_REL_QUERY, map("id", id)).getRows().iterator();
|
||||
if (!result.hasNext()) {
|
||||
throw new NotFoundException("Relationship not found " + id);
|
||||
}
|
||||
List<Object> row = result.next();
|
||||
return toRel(row);
|
||||
} catch (NotFoundException e) {
|
||||
throw e;
|
||||
} catch (CypherTransactionExecutionException ctee) {
|
||||
if (ctee.contains("Neo.DatabaseError.Statement.ExecutionFailure","not found")) {
|
||||
throw new NotFoundException("Relationship not found " + id);
|
||||
}
|
||||
throw ctee;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public RestNode createNode(Map<String, Object> props) {
|
||||
return createNode(props, Collections.<String>emptyList());
|
||||
@@ -381,9 +403,10 @@ public class RestAPICypherImpl implements RestAPI {
|
||||
public void deleteEntity(RestEntity entity) {
|
||||
if (entity instanceof Node) {
|
||||
runQuery(_MATCH_NODE_QUERY + " DELETE n", map("id", entity.getId()));
|
||||
restAPI.removeFromCache(entity.getId());
|
||||
restAPI.removeNodeFromCache(entity.getId());
|
||||
} else if (entity instanceof Relationship) {
|
||||
runQuery(_MATCH_REL_QUERY + " DELETE r", map("id", entity.getId()));
|
||||
restAPI.removeRelFromCache(entity.getId());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,8 +47,6 @@ import org.neo4j.rest.graphdb.traversal.RestTraverser;
|
||||
import org.neo4j.rest.graphdb.util.JsonHelper;
|
||||
import org.neo4j.rest.graphdb.util.QueryResult;
|
||||
import org.neo4j.rest.graphdb.util.ResultConverter;
|
||||
import org.springframework.data.neo4j.mapping.Neo4jPersistentEntity;
|
||||
import org.springframework.data.neo4j.support.mapping.Neo4jMappingContext;
|
||||
|
||||
import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.core.Response.Status;
|
||||
@@ -119,36 +117,63 @@ public class RestAPIImpl implements RestAPI {
|
||||
return entityCache.addToCache(node);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestRelationship getRelationshipById(long id, Load force) {
|
||||
if (force != Load.ForceFromServer) {
|
||||
RestRelationship restRel = entityCache.getRelationship(id);
|
||||
if (restRel != null) return restRel;
|
||||
}
|
||||
if (force == Load.FromCache) return new RestRelationship(RestRelationship.relUri(this, id),this);
|
||||
|
||||
// BatchRestAPI batchRestAPI = new BatchRestAPI(this);
|
||||
// RestNode node = batchRestAPI.getNodeById(id);
|
||||
RequestResult response = restRequest.get("relationship/" + id);
|
||||
if (response.statusIs(Status.NOT_FOUND)) {
|
||||
throw new NotFoundException("" + id);
|
||||
}
|
||||
Map<String, Object> data = (Map<String, Object>) response.toMap();
|
||||
RestRelationship rel = new RestRelationship(data, this);
|
||||
return entityCache.addToCache(rel);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestNode addToCache(RestNode restNode) {
|
||||
return entityCache.addToCache(restNode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestNode getFromCache(long id) {
|
||||
public RestRelationship addToCache(RestRelationship rel) {
|
||||
return entityCache.addToCache(rel);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestNode getNodeFromCache(long id) {
|
||||
return entityCache.getNode(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeFromCache(long id) {
|
||||
entityCache.remove(id);
|
||||
public RestRelationship getRelFromCache(long id) {
|
||||
return entityCache.getRelationship(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeNodeFromCache(long id) {
|
||||
entityCache.removeNode(id);
|
||||
}
|
||||
@Override
|
||||
public void removeRelFromCache(long id) {
|
||||
entityCache.removeRelationship(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestNode getNodeById(long id) {
|
||||
return getNodeById(id, Load.FromServer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RestRelationship getRelationshipById(long id) {
|
||||
RequestResult requestResult = restRequest.get("relationship/" + id);
|
||||
if (requestResult.statusIs(Status.NOT_FOUND)) {
|
||||
throw new NotFoundException("" + id);
|
||||
}
|
||||
return new RestRelationship(requestResult.toMap(), this);
|
||||
return getRelationshipById(id, Load.FromServer);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public RestNode createNode(Map<String, Object> props) {
|
||||
return createNode(props,Collections.<String>emptyList());
|
||||
@@ -582,7 +607,7 @@ public class RestAPIImpl implements RestAPI {
|
||||
@Override
|
||||
public void deleteEntity(RestEntity entity) {
|
||||
getRestRequest().with(entity.getUri()).delete( "" );
|
||||
entityCache.remove(entity.getId());
|
||||
entityCache.removeNode(entity.getId());
|
||||
}
|
||||
@Override
|
||||
public IndexInfo indexInfo(final String indexType) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import org.neo4j.graphdb.PropertyContainer;
|
||||
import org.neo4j.rest.graphdb.converter.RestEntityExtractor;
|
||||
import org.neo4j.rest.graphdb.entity.RestEntity;
|
||||
import org.neo4j.rest.graphdb.entity.RestNode;
|
||||
import org.neo4j.rest.graphdb.entity.RestRelationship;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@@ -13,6 +14,7 @@ import java.util.Map;
|
||||
*/
|
||||
public interface RestAPIInternal {
|
||||
RestNode getNodeById(long id, RestAPI.Load force);
|
||||
RestRelationship getRelationshipById(long id, RestAPI.Load force);
|
||||
|
||||
boolean hasToUpdate(long lastUpdate);
|
||||
|
||||
@@ -30,4 +32,13 @@ public interface RestAPIInternal {
|
||||
FromServer,
|
||||
ForceFromServer
|
||||
}
|
||||
|
||||
RestRequest getRestRequest();
|
||||
|
||||
RestNode addToCache(RestNode restNode);
|
||||
RestRelationship addToCache(RestRelationship restRelationship);
|
||||
RestNode getNodeFromCache(long id);
|
||||
RestRelationship getRelFromCache(long id);
|
||||
void removeNodeFromCache(long id);
|
||||
void removeRelFromCache(long id);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import org.neo4j.rest.graphdb.RestAPI;
|
||||
*/
|
||||
public class RestEntityCache {
|
||||
|
||||
private LruCache<Long,RestNode> lruCache = new LruCache<>("RestNode",10000);
|
||||
private LruCache<Long,RestNode> lruNodeCache = new LruCache<>("RestNode",10000);
|
||||
private LruCache<Long,RestRelationship> lruRelCache = new LruCache<>("RestRelationship",10000);
|
||||
|
||||
private final RestAPI restAPI;
|
||||
|
||||
@@ -21,22 +22,42 @@ public class RestEntityCache {
|
||||
if (node == null) return null;
|
||||
long id = node.getId();
|
||||
if (id != -1) {
|
||||
RestNode existing = lruCache.get(id);
|
||||
RestNode existing = lruNodeCache.get(id);
|
||||
if (existing !=null) {
|
||||
if (existing != node) existing.updateFrom(node, restAPI);
|
||||
return existing;
|
||||
} else {
|
||||
lruCache.put(id, node);
|
||||
lruNodeCache.put(id, node);
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
public RestRelationship addToCache(RestRelationship rel) {
|
||||
if (rel == null) return null;
|
||||
long id = rel.getId();
|
||||
if (id != -1) {
|
||||
RestRelationship existing = lruRelCache.get(id);
|
||||
if (existing !=null) {
|
||||
if (existing != rel) existing.updateFrom(rel, restAPI);
|
||||
return existing;
|
||||
} else {
|
||||
lruRelCache.put(id, rel);
|
||||
}
|
||||
}
|
||||
return rel;
|
||||
}
|
||||
|
||||
public RestNode getNode(long id) {
|
||||
return lruCache.get(id);
|
||||
return lruNodeCache.get(id);
|
||||
}
|
||||
public RestRelationship getRelationship(long id) {
|
||||
return lruRelCache.get(id);
|
||||
}
|
||||
|
||||
public void remove(long id) {
|
||||
lruCache.remove(id);
|
||||
public void removeNode(long id) {
|
||||
lruNodeCache.remove(id);
|
||||
}
|
||||
public void removeRelationship(long id) {
|
||||
lruRelCache.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ public class RestRelationship extends RestEntity implements Relationship {
|
||||
|
||||
@Override
|
||||
protected void doUpdate() {
|
||||
updateFrom(restApi.getRelationshipById(getId()), restApi);
|
||||
updateFrom(restApi.getRelationshipById(getId(),RestAPIInternal.Load.ForceFromServer), restApi);
|
||||
}
|
||||
|
||||
public Node getEndNode() {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Copyright 2011 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.data.neo4j.rest.integration;
|
||||
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.Before;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.neo4j.graphdb.GraphDatabaseService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.data.neo4j.annotation.*;
|
||||
import org.springframework.data.neo4j.config.Neo4jConfiguration;
|
||||
import org.springframework.data.neo4j.rest.SpringCypherRestGraphDatabase;
|
||||
import org.springframework.data.neo4j.rest.SpringRestGraphDatabase;
|
||||
import org.springframework.data.neo4j.rest.support.RestTestBase;
|
||||
import org.springframework.data.neo4j.support.Neo4jTemplate;
|
||||
import org.springframework.test.context.CleanContextCacheTestExecutionListener;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.test.context.TestExecutionListeners;
|
||||
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
|
||||
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener;
|
||||
import org.springframework.test.context.transaction.TransactionalTestExecutionListener;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
/**
|
||||
* @author mh
|
||||
* @since 28.03.11
|
||||
*/
|
||||
@RunWith(SpringJUnit4ClassRunner.class)
|
||||
@ContextConfiguration(classes = RestRelationshipTests.MyConfig.class)
|
||||
@TestExecutionListeners({CleanContextCacheTestExecutionListener.class, DependencyInjectionTestExecutionListener.class, TransactionalTestExecutionListener.class})
|
||||
public class RestRelationshipTests {
|
||||
|
||||
static class MyConfig extends Neo4jConfiguration {
|
||||
public MyConfig() {
|
||||
setBasePackage("org.springframework.data.neo4j.rest.integration");
|
||||
}
|
||||
|
||||
@Bean
|
||||
public GraphDatabaseService graphDatabaseService() {
|
||||
return new SpringCypherRestGraphDatabase(RestTestBase.SERVER_ROOT_URI);
|
||||
// return new SpringRestGraphDatabase(RestTestBase.SERVER_ROOT_URI);
|
||||
}
|
||||
}
|
||||
|
||||
@NodeEntity
|
||||
public static class A {
|
||||
@GraphId
|
||||
Long id;
|
||||
String name;
|
||||
|
||||
public A(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public A() { }
|
||||
}
|
||||
|
||||
@RelationshipEntity(type = "AREL")
|
||||
public static class ARel {
|
||||
@GraphId
|
||||
Long id;
|
||||
@StartNode A a1;
|
||||
@EndNode
|
||||
A a2;
|
||||
|
||||
String prop;
|
||||
|
||||
public ARel(A a1, A a2, String prop) {
|
||||
this.a1 = a1;
|
||||
this.a2 = a2;
|
||||
this.prop = prop;
|
||||
}
|
||||
|
||||
public ARel() { }
|
||||
}
|
||||
|
||||
@Autowired
|
||||
Neo4jTemplate neo4jTemplate;
|
||||
|
||||
@BeforeClass
|
||||
public static void startDb() throws Exception {
|
||||
RestTestBase.startDb();
|
||||
}
|
||||
|
||||
@Before
|
||||
public void cleanDb() {
|
||||
RestTestBase.cleanDb();
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
public static void shutdownDb() {
|
||||
RestTestBase.shutdownDb();
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testRelationshipSaveProperty() {
|
||||
A a1 = neo4jTemplate.save(new A("a1"));
|
||||
A a2 = neo4jTemplate.save(new A("a2"));
|
||||
ARel rel = neo4jTemplate.save(new ARel(a1, a2, "foo"));
|
||||
ARel rel2 = neo4jTemplate.findOne(rel.id, ARel.class);
|
||||
assertEquals("foo",rel2.prop);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.neo4j.aspects.support.query.QueryEngineTests;
|
||||
import org.springframework.data.neo4j.core.GraphDatabase;
|
||||
import org.springframework.data.neo4j.rest.SpringCypherRestGraphDatabase;
|
||||
import org.springframework.data.neo4j.rest.SpringRestGraphDatabase;
|
||||
import org.springframework.data.neo4j.rest.SpringCypherRestGraphDatabase;
|
||||
import org.springframework.test.context.CleanContextCacheTestExecutionListener;
|
||||
import org.springframework.test.context.ContextConfiguration;
|
||||
import org.springframework.test.context.TestExecutionListeners;
|
||||
|
||||
@@ -36,7 +36,7 @@ import org.neo4j.server.configuration.ServerConfigurator;
|
||||
import org.neo4j.test.ImpermanentGraphDatabase;
|
||||
import org.springframework.data.neo4j.core.GraphDatabase;
|
||||
import org.springframework.data.neo4j.rest.SpringCypherRestGraphDatabase;
|
||||
import org.springframework.data.neo4j.rest.SpringRestGraphDatabase;
|
||||
import org.springframework.data.neo4j.rest.SpringCypherRestGraphDatabase;
|
||||
|
||||
import java.util.Iterator;
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ public class RestTestHelper
|
||||
}
|
||||
|
||||
public GraphDatabase createGraphDatabase() throws URISyntaxException {
|
||||
// return new SpringRestGraphDatabase(SERVER_ROOT_URI);
|
||||
return new SpringCypherRestGraphDatabase(SERVER_ROOT_URI);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user