From aa69fdd5a6996ab4da56c18e2c5f9763fb345a99 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Mon, 13 Jul 2026 17:26:59 +0100 Subject: [PATCH 1/3] Move write handling to application commands --- .../challengesrouting/AuthRoutes.java | 14 +- .../restapihandlers/BodyArgsProcessor.java | 40 --- .../BodyRelationshipValidator.java | 23 -- .../restapihandlers/RelationshipCreation.java | 187 ----------- .../restapihandlers/RelationshipCreator.java | 54 --- .../restapihandlers/RestApiDeleteHandler.java | 55 +--- .../restapihandlers/RestApiPostHandler.java | 85 +---- .../restapihandlers/RestApiPutHandler.java | 104 +----- .../api/restapihandlers/ThingAmendment.java | 94 ------ .../ThingBodyCommandMapper.java | 236 ++++++++++++++ .../ThingCommandResultApiMapper.java | 81 +++++ .../api/restapihandlers/ThingCreation.java | 250 -------------- .../ThingWriteRequestMapper.java | 308 ++++++++++++++++++ .../ThingWriteRequestMapping.java | 36 ++ .../application/ThingCommandResult.java | 23 +- .../application/ThingCommandService.java | 72 ++++ .../command/AmendThingCommand.java | 43 +++ .../ConnectExistingRelationshipCommand.java | 31 ++ .../CreateAndConnectRelationshipCommand.java | 43 +++ .../command/CreateThingCommand.java | 35 ++ .../command/DeleteThingCommand.java | 16 + .../DisconnectRelationshipCommand.java | 31 ++ .../command/ThingWriteCommand.java | 3 + .../ThingWriteRequestMapperTest.java | 251 ++++++++++++++ .../application/ThingCommandServiceTest.java | 253 ++++++++++++++ 25 files changed, 1495 insertions(+), 873 deletions(-) delete mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/BodyArgsProcessor.java delete mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/BodyRelationshipValidator.java delete mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RelationshipCreation.java delete mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RelationshipCreator.java delete mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingAmendment.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingBodyCommandMapper.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingCommandResultApiMapper.java delete mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingCreation.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingWriteRequestMapper.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingWriteRequestMapping.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/AmendThingCommand.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/ConnectExistingRelationshipCommand.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/CreateAndConnectRelationshipCommand.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/CreateThingCommand.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/DeleteThingCommand.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/DisconnectRelationshipCommand.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/ThingWriteCommand.java create mode 100644 thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingWriteRequestMapperTest.java create mode 100644 thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/ThingCommandServiceTest.java diff --git a/challenger/src/main/java/uk/co/compendiumdev/challenge/challengesrouting/AuthRoutes.java b/challenger/src/main/java/uk/co/compendiumdev/challenge/challengesrouting/AuthRoutes.java index 263426fe..4dbfcbbb 100644 --- a/challenger/src/main/java/uk/co/compendiumdev/challenge/challengesrouting/AuthRoutes.java +++ b/challenger/src/main/java/uk/co/compendiumdev/challenge/challengesrouting/AuthRoutes.java @@ -19,7 +19,6 @@ import uk.co.compendiumdev.thingifier.api.http.headers.headerparser.BearerAuthHeaderParser; import uk.co.compendiumdev.thingifier.api.http.headers.headerparser.ContentTypeHeaderParser; import uk.co.compendiumdev.thingifier.api.response.ApiResponse; -import uk.co.compendiumdev.thingifier.api.restapihandlers.ThingCreation; import uk.co.compendiumdev.thingifier.application.internalhttp.InternalHttpRequest; import uk.co.compendiumdev.thingifier.application.internalhttpconversion.HttpApiResponseToInternalHttpResponse; import uk.co.compendiumdev.thingifier.application.internalhttpconversion.InternalHttpRequestToHttpApiRequest; @@ -308,11 +307,14 @@ public void configure( if (httpApiResponse == null) { ApiResponse response = - new ThingCreation(this.secretNoteStore) - .with( - new BodyParser(myRequest, List.of("secretnote")), - this.secretNote, - EntityRelModel.DEFAULT_DATABASE_NAME); + this.secretNoteStore + .api() + .post( + "secretnote", + new BodyParser( + myRequest, + this.secretNoteStore.getThingNames()), + myRequest.getHeaders()); if (!response.isErrorResponse()) { EntityInstance returnedInstance = response.getReturnedInstance(); diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/BodyArgsProcessor.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/BodyArgsProcessor.java deleted file mode 100644 index b88bbbdf..00000000 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/BodyArgsProcessor.java +++ /dev/null @@ -1,40 +0,0 @@ -package uk.co.compendiumdev.thingifier.api.restapihandlers; - -import java.util.List; -import java.util.Map; -import uk.co.compendiumdev.thingifier.Thingifier; -import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; -import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; -import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; - -// todo: potentially move this into the BodyParser -public class BodyArgsProcessor { - - private final Thingifier thingifier; - private final BodyParser bodyargs; - - public BodyArgsProcessor(Thingifier thingifier, BodyParser bodyargs) { - this.thingifier = thingifier; - this.bodyargs = bodyargs; - } - - public List> removeRelationshipsFrom( - final EntityInstance instance, final String database) { - return removeRelationshipsFrom(instance.getEntity(), database); - } - - public List> removeRelationshipsFrom( - final EntityDefinition entity, final String database) { - - List> fullargs = bodyargs.getFlattenedStringMap(); - - for (Map.Entry removeMe : - new RelationshipBodyCommandParser(thingifier) - .parse(fullargs, entity, database) - .relationshipEntries()) { - fullargs.remove(removeMe); - } - - return fullargs; - } -} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/BodyRelationshipValidator.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/BodyRelationshipValidator.java deleted file mode 100644 index 141a2c23..00000000 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/BodyRelationshipValidator.java +++ /dev/null @@ -1,23 +0,0 @@ -package uk.co.compendiumdev.thingifier.api.restapihandlers; - -import uk.co.compendiumdev.thingifier.Thingifier; -import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; -import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; -import uk.co.compendiumdev.thingifier.core.reporting.ValidationReport; - -public class BodyRelationshipValidator { - private final Thingifier thingifier; - - public BodyRelationshipValidator(final Thingifier thingifier) { - this.thingifier = thingifier; - } - - public ValidationReport validate( - final BodyParser bodyargs, - final EntityDefinition thingDefinition, - final String database) { - return new RelationshipBodyCommandParser(thingifier) - .parse(bodyargs, thingDefinition, database) - .validationReport(); - } -} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RelationshipCreation.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RelationshipCreation.java deleted file mode 100644 index 4e688641..00000000 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RelationshipCreation.java +++ /dev/null @@ -1,187 +0,0 @@ -package uk.co.compendiumdev.thingifier.api.restapihandlers; - -import java.util.List; -import java.util.Map; -import uk.co.compendiumdev.thingifier.Thingifier; -import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; -import uk.co.compendiumdev.thingifier.api.response.ApiResponse; -import uk.co.compendiumdev.thingifier.application.ThingCommandResult; -import uk.co.compendiumdev.thingifier.application.ThingCommandService; -import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; -import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; -import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; -import uk.co.compendiumdev.thingifier.core.domain.definitions.relationship.RelationshipVectorDefinition; -import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; - -public class RelationshipCreation { - - private final Thingifier thingifier; - - public RelationshipCreation(final Thingifier aThingifier) { - this.thingifier = aThingifier; - } - - public ApiResponse create( - final String url, - final BodyParser bodyargs, - final RepositoryBackedRelationshipUrlResolver.RelationshipUrlResolution relationship, - final String database) { - - final Map args = bodyargs.getStringMap(); - - // get the relationship name - String relationshipName = relationship.relationshipName(); - - // find the thing in the body - - EntityInstance relatedItem = null; - - // find the thing from the query to connect the relatedItem to - EntityInstance connectThis = relationship.parentInstance(); - if (connectThis == null) { - // TODO: I don't think it is possible to ever hit this line of code - return ApiResponse.error404( - String.format("Could not find parent thing for relationship %s", url)); - } - - RelationshipVectorDefinition relationshipToUse; - List possibleRelationships = - connectThis.getEntity().related().getRelationships(relationshipName); - // if no way to narrow it down then use the first one TODO: potential bug if multiple named - // relationshps - relationshipToUse = possibleRelationships.get(0); - - EntityDefinition thingTo = relationshipToUse.getTo(); - - // if there is a guid in the body then use that to try and find a thing that matches it - // if there is a guid field or an id field then use whichever first matches a thing - boolean amExpectingARelatedItem = false; - String matchingFieldNames = ""; - for (String fieldName : args.keySet()) { - final Field field = thingTo.getField(fieldName); - // in theory this is any 'key' unique field - if (field.getType() == FieldType.AUTO_GUID - || field.getType() == FieldType.AUTO_INCREMENT) { - amExpectingARelatedItem = true; - if (!matchingFieldNames.contains(fieldName + " ")) { - matchingFieldNames = matchingFieldNames + fieldName + " "; - } - relatedItem = - thingifier - .getStore(database) - .entityQueries() - .findByField(thingTo, fieldName, args.get(fieldName)); - if (relatedItem != null) { - // found something - break; - } - } - } - if (amExpectingARelatedItem && relatedItem == null) { - matchingFieldNames = matchingFieldNames.trim().replace(" ", ", "); - return ApiResponse.error404( - String.format( - "Could not find thing matching value for %s", matchingFieldNames)); - } - - EntityInstance returnThing = null; - - EntityDefinition thingToCreate = null; - ApiResponse response = null; - - // if we have a parent thing, but no GUID then can we create a Thing and connect it later? - if (relatedItem == null) { - EntityDefinition createThing = relationshipToUse.getTo(); - - thingToCreate = createThing; - - response = - new ThingCreation(thingifier) - .withDeferredRelationshipValidation(bodyargs, thingToCreate, database); - if (response.isErrorResponse()) { - return response; - } else { - // Created it, so relate it later - relatedItem = response.getReturnedInstance(); - returnThing = relatedItem; - } - - } else { - // we know what we are connecting to, find the correct relationship - relationshipToUse = - connectThis - .getEntity() - .getNamedRelationshipTo(relationshipName, relatedItem.getEntity()); - } - - try { - - if (relationshipToUse == null) { - response = - ApiResponse.error( - 400, - String.format( - "Could not find a relationship named %s between %s and a %s", - relationshipName, - connectThis.getEntity().getName(), - relatedItem.getEntity().getName())); - - } else { - if (relationshipToUse.getTo() != relatedItem.getEntity()) { - response = - ApiResponse.error( - 400, - String.format( - "Could not connect %s (%s) to %s (%s) via relationship %s because it is a %s instead of a %s", - connectThis.getPrimaryKeyValue(), - connectThis.getEntity().getName(), - relatedItem.getPrimaryKeyValue(), - relatedItem.getEntity().getName(), - relationshipToUse.getName(), - relatedItem.getEntity().getName(), - relationshipToUse.getTo().getName())); - } - } - - if (response != null && response.isErrorResponse()) { - if (thingToCreate != null) { - // we had an error so delete the created thing - new ThingCommandService(thingifier.getStore(database)).delete(relatedItem); - response.addToErrorMessages( - " the newly created item was deleted. No new items have been created."); - } - // we already have an error so return now - return response; - } - - ThingCommandResult connectResult = - new ThingCommandService(thingifier.getStore(database)) - .connectRelationship( - connectThis, - relationshipToUse.getName(), - relatedItem, - thingToCreate != null); - if (connectResult.isError()) { - response = ApiResponse.error(400, connectResult.getErrorMessages()); - if (thingToCreate != null) { - response.addToErrorMessages( - " the newly created item was deleted. No new items have been created."); - } - return response; - } - - } catch (Exception e) { - return ApiResponse.error( - 400, - String.format( - "Could not connect %s (%s) to %s (%s) via relationship %s", - connectThis.getPrimaryKeyValue(), - connectThis.getEntity().getName(), - relatedItem.getPrimaryKeyValue(), - relatedItem.getEntity().getName(), - relationshipToUse.getName())); - } - - return ApiResponse.created(returnThing, thingifier.apiConfig()); - } -} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RelationshipCreator.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RelationshipCreator.java deleted file mode 100644 index d92729e1..00000000 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RelationshipCreator.java +++ /dev/null @@ -1,54 +0,0 @@ -package uk.co.compendiumdev.thingifier.api.restapihandlers; - -import java.util.List; -import uk.co.compendiumdev.thingifier.Thingifier; -import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; -import uk.co.compendiumdev.thingifier.api.response.ApiResponse; -import uk.co.compendiumdev.thingifier.application.RelationshipConnection; -import uk.co.compendiumdev.thingifier.application.ThingCommandResult; -import uk.co.compendiumdev.thingifier.application.ThingCommandService; -import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; -import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; -import uk.co.compendiumdev.thingifier.core.reporting.ValidationReport; - -public class RelationshipCreator { - private final Thingifier thingifier; - - public RelationshipCreator(final Thingifier thingifier) { - this.thingifier = thingifier; - } - - public ApiResponse createRelationships( - final BodyParser bodyargs, final EntityInstance instance, final String database) { - try { - ThingCommandResult result = - new ThingCommandService(thingifier.getStore(database)) - .connectRelationships( - instance, - relationshipConnectionsFromArgs(bodyargs, instance, database)); - if (result.isError()) { - return ApiResponse.error(400, result.getErrorMessages()); - } - - return ApiResponse.created(instance, thingifier.apiConfig()); - } catch (Exception e) { - return ApiResponse.error(400, "Error creating relationships " + e.getMessage()); - } - } - - public List relationshipConnectionsFromArgs( - final BodyParser bodyargs, final EntityInstance instance, final String database) { - return relationshipConnectionsFromArgs(bodyargs, instance.getEntity(), database); - } - - public List relationshipConnectionsFromArgs( - final BodyParser bodyargs, final EntityDefinition entity, final String database) { - RelationshipBodyCommands commands = - new RelationshipBodyCommandParser(thingifier).parse(bodyargs, entity, database); - ValidationReport validation = commands.validationReport(); - if (!validation.isValid()) { - throw new IllegalArgumentException(validation.getCombinedErrorMessages()); - } - return commands.connections(); - } -} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiDeleteHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiDeleteHandler.java index a28e55a5..f869a971 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiDeleteHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiDeleteHandler.java @@ -5,8 +5,6 @@ import uk.co.compendiumdev.thingifier.api.response.ApiResponse; import uk.co.compendiumdev.thingifier.application.ThingCommandResult; import uk.co.compendiumdev.thingifier.application.ThingCommandService; -import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; -import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; public class RestApiDeleteHandler { private final Thingifier thingifier; @@ -20,51 +18,16 @@ public ApiResponse handle(final String url, HttpHeadersBlock requestHeaders) { String instanceDatabaseName = SessionHeaderParser.getDatabaseNameFromHeaderValue(requestHeaders); - // this should probably not delete root items - EntityDefinition thing = EntityUrlMatcher.entityFromCollectionUrl(thingifier, url); - if (thing != null) { - // can't delete root level with a DELETE - return ApiResponse.error(405, "Cannot delete root level entity"); + ThingWriteRequestMapping mapping = + new ThingWriteRequestMapper(thingifier, instanceDatabaseName).mapDelete(url); + if (mapping.isError()) { + return mapping.getErrorResponse(); } - EntityDefinition entity = EntityUrlMatcher.entityFromInstanceUrl(thingifier, url); - if (entity != null) { - EntityInstance instance = - EntityUrlMatcher.findInstanceFromUrl(thingifier, url, instanceDatabaseName); - if (instance == null) { - return ApiResponse.error404( - String.format("Could not find any instances with %s", url)); - } - return responseFrom( - new ThingCommandService(thingifier.getStore(instanceDatabaseName)) - .delete(instance)); - } - - RepositoryBackedRelationshipUrlResolver.RelationshipUrlResolution relationship = - new RepositoryBackedRelationshipUrlResolver(thingifier, instanceDatabaseName) - .resolveRelationshipInstance(url); - if (relationship.matchedRelationshipPath()) { - if (!relationship.relationshipInstancePath() - || relationship.parentInstance() == null - || relationship.childInstance() == null) { - return ApiResponse.error404( - String.format("Could not find any instances with %s", url)); - } - return responseFrom( - new ThingCommandService(thingifier.getStore(instanceDatabaseName)) - .disconnectRelationship( - relationship.parentInstance(), - relationship.childInstance(), - relationship.relationshipName())); - } - - return ApiResponse.error404(String.format("Could not find any instances with %s", url)); - } - - private ApiResponse responseFrom(final ThingCommandResult result) { - if (result.isError()) { - return ApiResponse.error(400, result.getErrorMessages()); - } - return ApiResponse.success(); + ThingCommandResult result = + new ThingCommandService(thingifier.getStore(instanceDatabaseName)) + .execute(mapping.getCommand()); + return new ThingCommandResultApiMapper(thingifier.apiConfig()) + .map(mapping.getCommand(), result); } } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPostHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPostHandler.java index e7971c22..6829ca33 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPostHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPostHandler.java @@ -4,9 +4,8 @@ import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; import uk.co.compendiumdev.thingifier.api.response.ApiResponse; -import uk.co.compendiumdev.thingifier.api.restapihandlers.commonerrorresponse.NoSuchEntity; -import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; -import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; +import uk.co.compendiumdev.thingifier.application.ThingCommandResult; +import uk.co.compendiumdev.thingifier.application.ThingCommandService; public class RestApiPostHandler { private final Thingifier thingifier; @@ -17,81 +16,19 @@ public RestApiPostHandler(final Thingifier aThingifier) { public ApiResponse handle( final String url, final BodyParser args, final HttpHeadersBlock requestHeaders) { - // we want to - String instanceDatabaseName = SessionHeaderParser.getDatabaseNameFromHeaderValue(requestHeaders); - /* - No GUID and match a Thing - */ - // if queryis empty then need a way to check if the query matched - // create a thing - EntityDefinition entityDefinition = - EntityUrlMatcher.entityFromCollectionUrl(thingifier, url); - if (entityDefinition != null) { - return new ThingCreation(thingifier).with(args, entityDefinition, instanceDatabaseName); - } - - /* - Match a specific thing - */ - // amend a thing - // thing/guid - entityDefinition = EntityUrlMatcher.entityFromInstanceUrl(thingifier, url); - if (entityDefinition != null) { - - String primaryKey = EntityUrlMatcher.identifierFromInstanceUrl(url); - - if (entityDefinition.hasPrimaryKeyField()) { - EntityInstance instance = - thingifier - .getStore(instanceDatabaseName) - .entityQueries() - .findByQueryIdentifier(entityDefinition, primaryKey); - - if (instance == null) { - // cannot amend something that does not exist - return ApiResponse.error404( - String.format( - "No such %s entity instance with %s == %s found", - entityDefinition.getName(), - entityDefinition.getPrimaryKeyField().getName(), - primaryKey)); - } - - return amendAThingWithPost(args, instance, instanceDatabaseName); - } else { - return ApiResponse.error404( - String.format( - "Entity %s does not have a primary key defined", - entityDefinition.getName())); - } + ThingWriteRequestMapping mapping = + new ThingWriteRequestMapper(thingifier, instanceDatabaseName).mapPost(url, args); + if (mapping.isError()) { + return mapping.getErrorResponse(); } - String thingName = EntityUrlMatcher.entityTermFromUrl(url); - if (!thingName.isEmpty() && EntityUrlMatcher.hasPartCount(url, 2)) { - return NoSuchEntity.response(thingName); - } - - /* - Match a Relationship - */ - RepositoryBackedRelationshipUrlResolver.RelationshipUrlResolution relationship = - new RepositoryBackedRelationshipUrlResolver(thingifier, instanceDatabaseName) - .resolveCollection(url); - if (relationship.matchedRelationshipPath()) { - return new RelationshipCreation(thingifier) - .create(url, args, relationship, instanceDatabaseName); - } - - // WHAT was that query? - return ApiResponse.error(400, "Your request was not understood"); - } - - private ApiResponse amendAThingWithPost( - BodyParser args, EntityInstance instance, final String database) { - // with a post we do not want to clear fields before setting - we only amend what we pass in - return new ThingAmendment(thingifier).amendInstance(args, instance, false, database); + ThingCommandResult result = + new ThingCommandService(thingifier.getStore(instanceDatabaseName)) + .execute(mapping.getCommand()); + return new ThingCommandResultApiMapper(thingifier.apiConfig()) + .map(mapping.getCommand(), result); } } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPutHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPutHandler.java index 3a0107d7..624dec37 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPutHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiPutHandler.java @@ -1,16 +1,11 @@ package uk.co.compendiumdev.thingifier.api.restapihandlers; -import java.util.List; import uk.co.compendiumdev.thingifier.Thingifier; import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; import uk.co.compendiumdev.thingifier.api.response.ApiResponse; -import uk.co.compendiumdev.thingifier.api.restapihandlers.commonerrorresponse.NoSuchEntity; -import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; -import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; -import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; -import uk.co.compendiumdev.thingifier.core.domain.definitions.field.instance.NamedValue; -import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; +import uk.co.compendiumdev.thingifier.application.ThingCommandResult; +import uk.co.compendiumdev.thingifier.application.ThingCommandService; public class RestApiPutHandler { final Thingifier thingifier; @@ -25,93 +20,16 @@ public ApiResponse handle( String instanceDatabaseName = SessionHeaderParser.getDatabaseNameFromHeaderValue(requestHeaders); - // if queryis empty then need a way to check if the query matched - // create a thing - EntityDefinition thing = EntityUrlMatcher.entityFromCollectionUrl(thingifier, url); - if (thing != null) { - // can't create a new thing at root level with PUT - return ApiResponse.error(405, "Cannot create root level entity with a PUT"); + ThingWriteRequestMapping mapping = + new ThingWriteRequestMapper(thingifier, instanceDatabaseName).mapPut(url, args); + if (mapping.isError()) { + return mapping.getErrorResponse(); } - // amend a thing - // thing/guid - thing = EntityUrlMatcher.entityFromInstanceUrl(thingifier, url); - if (thing == null) { - // WHAT was that query? - if (EntityUrlMatcher.hasPartCount(url, 2)) { - String thingName = EntityUrlMatcher.entityTermFromUrl(url); - if (!thingName.isEmpty()) { - return NoSuchEntity.response(thingName); - } - } - return ApiResponse.error(400, "Your request was not understood"); - } - - String instanceGuid = EntityUrlMatcher.identifierFromInstanceUrl(url); - - EntityInstance instance = - thingifier - .getStore(instanceDatabaseName) - .entityQueries() - .findByQueryIdentifier(thing, instanceGuid); - - if (instance == null) { - - // cannot create on put when AUTO fields are present - // if the primary key is an AUTO i.e. AUTO_GUID or AUTO_INCREMENT then we should not be - // able to create it - // in fact if there are any fields at all which are AUTO then we should not be able to - // create it with PUT - - List forbiddenPutCreationFields = - thing.getFieldsOfType(FieldType.AUTO_INCREMENT, FieldType.AUTO_GUID); - if (forbiddenPutCreationFields.size() > 0) { - - String names = ""; - for (Field field : forbiddenPutCreationFields) { - if (!names.isEmpty()) { - names = names + ", "; - } - names = names + field.getName(); - } - return ApiResponse.error( - 400, - String.format( - "Cannot create %s with PUT due to Auto fields %s", - thing.getName(), names)); - } - - // it does not exist, but we have a primary key - create it - // any field in the body for the primary key must match the primarykey field - List fieldValues = - FieldValues.fromListMapEntryStringString(args.getFlattenedStringMap()); - for (NamedValue namedValue : fieldValues) { - if (namedValue.name.equals(thing.getPrimaryKeyField().getName())) { - if (!namedValue.value.equals(instanceGuid)) { - // primary key does not match the value in the message - return ApiResponse.error( - 400, - String.format( - "Cannot create %s with PUT as key does not match body value %s != %s", - thing.getName(), instanceGuid, namedValue.value)); - } - } - } - - // if we were given an ID then this will fail because - // ID will not match GUID formatting - return new ThingCreation(thingifier) - .withPrimaryKey(instanceGuid, args, thing, instanceDatabaseName); - } else { - // when amending existing thing with PUT it must be idempotent so - // check that all fields are valid in the args - return amendAThingWithPut(args, instance, instanceDatabaseName); - } - } - - private ApiResponse amendAThingWithPut( - final BodyParser bodyargs, final EntityInstance instance, final String database) { - - return new ThingAmendment(thingifier).amendInstance(bodyargs, instance, true, database); + ThingCommandResult result = + new ThingCommandService(thingifier.getStore(instanceDatabaseName)) + .execute(mapping.getCommand()); + return new ThingCommandResultApiMapper(thingifier.apiConfig()) + .map(mapping.getCommand(), result); } } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingAmendment.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingAmendment.java deleted file mode 100644 index 2638dc35..00000000 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingAmendment.java +++ /dev/null @@ -1,94 +0,0 @@ -package uk.co.compendiumdev.thingifier.api.restapihandlers; - -import java.util.List; -import uk.co.compendiumdev.thingifier.Thingifier; -import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; -import uk.co.compendiumdev.thingifier.api.response.ApiResponse; -import uk.co.compendiumdev.thingifier.application.RelationshipConnection; -import uk.co.compendiumdev.thingifier.application.ThingCommandResult; -import uk.co.compendiumdev.thingifier.application.ThingCommandService; -import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; -import uk.co.compendiumdev.thingifier.core.domain.definitions.field.instance.NamedValue; -import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; -import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; -import uk.co.compendiumdev.thingifier.core.reporting.ValidationReport; - -// TODO - there should be a generic API handlers package that does create, read, update, delete -// which I think this is, and they should not use http status codes -// also cloning instances feels like the wrong way to amend, we should create 'amend/patch' requests -// would also handle relationship - -public class ThingAmendment { - - private final Thingifier thingifier; - - public ThingAmendment(final Thingifier thingifier) { - this.thingifier = thingifier; - } - - public ApiResponse amendInstance( - final BodyParser bodyargs, - final EntityInstance instance, - final Boolean clearFieldsBeforeSettingFromArgs, - final String database) { - - bodyargs.getMap(); - - if (thingifier.apiConfig().willApiEnforceDeclaredTypesInInput()) { - List doNotValidateFields = - instance.getEntity() - .getFieldNamesOfType(FieldType.AUTO_INCREMENT, FieldType.AUTO_GUID); - ValidationReport validatedTypes = - bodyargs.validateAgainstTypeIgnoring(instance.getEntity(), doNotValidateFields); - if (!validatedTypes.isValid()) { - return ApiResponse.error(400, validatedTypes.getCombinedErrorMessages()); - } - } - - EntityInstanceDraft draft; - - try { - List fieldValues = - FieldValues.fromListMapEntryStringString( - new BodyArgsProcessor(thingifier, bodyargs) - .removeRelationshipsFrom(instance, database)); - - draft = new EntityInstanceBulkUpdater(instance).setFieldValuesFrom(fieldValues); - - } catch (Exception e) { - return ApiResponse.error(400, e.getMessage()); - } - - // validate the relationships as well - ValidationReport validation = - new BodyRelationshipValidator(thingifier) - .validate(bodyargs, instance.getEntity(), database); - - if (validation.isValid()) { - List relationships; - try { - relationships = - new RelationshipCreator(thingifier) - .relationshipConnectionsFromArgs(bodyargs, instance, database); - } catch (Exception e) { - return ApiResponse.error(400, e.getMessage()); - } - - ThingCommandResult result = - new ThingCommandService(thingifier.getStore(database)) - .amend( - instance, - draft, - clearFieldsBeforeSettingFromArgs, - relationships); - if (result.isError()) { - return ApiResponse.error(400, result.getErrorMessages()); - } - - return ApiResponse.success().returnSingleInstance(result.getInstance()); - } else { - // do not add it, report the errors - return ApiResponse.error(400, validation.getErrorMessages()); - } - } -} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingBodyCommandMapper.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingBodyCommandMapper.java new file mode 100644 index 00000000..d3a5f492 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingBodyCommandMapper.java @@ -0,0 +1,236 @@ +package uk.co.compendiumdev.thingifier.api.restapihandlers; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.application.command.AmendThingCommand; +import uk.co.compendiumdev.thingifier.application.command.CreateAndConnectRelationshipCommand; +import uk.co.compendiumdev.thingifier.application.command.CreateThingCommand; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.instance.NamedValue; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; +import uk.co.compendiumdev.thingifier.core.reporting.ValidationReport; + +public final class ThingBodyCommandMapper { + + private final Thingifier thingifier; + private final String database; + + public ThingBodyCommandMapper(final Thingifier thingifier, final String database) { + this.thingifier = thingifier; + this.database = database; + } + + public ThingWriteRequestMapping mapCreate( + final BodyParser bodyargs, + final EntityDefinition entity, + final boolean validateFinalRelationships) { + RelationshipBodyCommands relationships = parseRelationships(bodyargs, entity); + if (!relationships.validationReport().isValid()) { + return invalidRelationships(relationships.validationReport()); + } + + ValidationReport validated = new BodyCreationValidator().validate(bodyargs, entity); + if (!validated.isValid()) { + return ThingWriteRequestMapping.error( + ApiResponse.error( + 400, + String.format( + "Invalid Creation: %s", validated.getCombinedErrorMessages()))); + } + + return mapCreateFromValidatedBody( + bodyargs, + entity, + EntityInstanceDraft.forEntity(entity), + relationships, + validateFinalRelationships); + } + + public ThingWriteRequestMapping mapCreateWithPrimaryKey( + final String primaryKey, final BodyParser bodyargs, final EntityDefinition entity) { + ValidationReport validated = + new BodyCreationValidator() + .areFieldsUnique( + bodyargs, + entity, + thingifier.getStore(database).entityQueries(), + entity.getFieldNamesOfType( + FieldType.AUTO_INCREMENT, FieldType.AUTO_GUID)); + if (!validated.isValid()) { + return ThingWriteRequestMapping.error( + ApiResponse.error( + 409, + "Cannot Create with duplicate values: " + + validated.getCombinedErrorMessages())); + } + + EntityInstanceDraft baseDraft = EntityInstanceDraft.forEntity(entity); + if (isProtectedField(entity.getPrimaryKeyField().getType())) { + baseDraft.withProtectedField(entity.getPrimaryKeyField().getName(), primaryKey); + } else { + baseDraft.withField(entity.getPrimaryKeyField().getName(), primaryKey); + } + + RelationshipBodyCommands relationships = parseRelationships(bodyargs, entity); + if (!relationships.validationReport().isValid()) { + return invalidRelationships(relationships.validationReport()); + } + + List fieldValues = + FieldValues.fromListMapEntryStringString(bodyargs.getFlattenedStringMap()); + thingifier.getStore(database).administration().accommodateProtectedIds(entity, fieldValues); + + if (thingifier.apiConfig().willApiEnforceDeclaredTypesInInput()) { + ValidationReport validatedTypes = bodyargs.validateAgainstType(entity); + if (!validatedTypes.isValid()) { + return ThingWriteRequestMapping.error( + ApiResponse.error(400, validatedTypes.getCombinedErrorMessages())); + } + } + + try { + List ignoreFields = entity.getFieldNamesOfType(FieldType.AUTO_GUID); + EntityInstanceDraft draft = + new EntityInstanceBulkUpdater(entity) + .overrideFieldValuesFromArgsIgnoring( + FieldValues.fromListMapEntryStringString( + fieldValuesExcludingRelationships( + bodyargs, relationships)), + ignoreFields); + copyBaseDraftValues(baseDraft, draft); + return ThingWriteRequestMapping.command( + new CreateThingCommand(draft, relationships.connections(), true)); + } catch (Exception e) { + return ThingWriteRequestMapping.error(ApiResponse.error(400, e.getMessage())); + } + } + + public ThingWriteRequestMapping mapAmend( + final BodyParser bodyargs, + final EntityInstance instance, + final boolean replaceExistingFieldsAndRelationships) { + bodyargs.getMap(); + + if (thingifier.apiConfig().willApiEnforceDeclaredTypesInInput()) { + List doNotValidateFields = + instance.getEntity() + .getFieldNamesOfType(FieldType.AUTO_INCREMENT, FieldType.AUTO_GUID); + ValidationReport validatedTypes = + bodyargs.validateAgainstTypeIgnoring(instance.getEntity(), doNotValidateFields); + if (!validatedTypes.isValid()) { + return ThingWriteRequestMapping.error( + ApiResponse.error(400, validatedTypes.getCombinedErrorMessages())); + } + } + + RelationshipBodyCommands relationships = parseRelationships(bodyargs, instance.getEntity()); + EntityInstanceDraft draft; + try { + List fieldValues = + FieldValues.fromListMapEntryStringString( + fieldValuesExcludingRelationships(bodyargs, relationships)); + draft = new EntityInstanceBulkUpdater(instance).setFieldValuesFrom(fieldValues); + } catch (Exception e) { + return ThingWriteRequestMapping.error(ApiResponse.error(400, e.getMessage())); + } + + if (!relationships.validationReport().isValid()) { + return ThingWriteRequestMapping.error( + ApiResponse.error(400, relationships.validationReport().getErrorMessages())); + } + + return ThingWriteRequestMapping.command( + new AmendThingCommand( + instance, + draft, + replaceExistingFieldsAndRelationships, + relationships.connections())); + } + + public ThingWriteRequestMapping mapCreateAndConnect( + final BodyParser bodyargs, + final EntityInstance parent, + final String relationshipName, + final EntityDefinition childEntity) { + ThingWriteRequestMapping createMapping = mapCreate(bodyargs, childEntity, false); + if (createMapping.isError()) { + return createMapping; + } + + CreateThingCommand create = (CreateThingCommand) createMapping.getCommand(); + return ThingWriteRequestMapping.command( + new CreateAndConnectRelationshipCommand( + parent, relationshipName, create.getDraft(), create.getRelationships())); + } + + private ThingWriteRequestMapping mapCreateFromValidatedBody( + final BodyParser bodyargs, + final EntityDefinition entity, + final EntityInstanceDraft baseDraft, + final RelationshipBodyCommands relationships, + final boolean validateFinalRelationships) { + if (thingifier.apiConfig().willApiEnforceDeclaredTypesInInput()) { + ValidationReport validatedTypes = bodyargs.validateAgainstType(entity); + if (!validatedTypes.isValid()) { + return ThingWriteRequestMapping.error( + ApiResponse.error(400, validatedTypes.getCombinedErrorMessages())); + } + } + + try { + List fieldValues = + FieldValues.fromListMapEntryStringString( + fieldValuesExcludingRelationships(bodyargs, relationships)); + EntityInstanceDraft draft = + new EntityInstanceBulkUpdater(entity).setFieldValuesFrom(fieldValues); + copyBaseDraftValues(baseDraft, draft); + return ThingWriteRequestMapping.command( + new CreateThingCommand( + draft, relationships.connections(), validateFinalRelationships)); + } catch (Exception e) { + return ThingWriteRequestMapping.error(ApiResponse.error(400, e.getMessage())); + } + } + + private RelationshipBodyCommands parseRelationships( + final BodyParser bodyargs, final EntityDefinition entity) { + return new RelationshipBodyCommandParser(thingifier).parse(bodyargs, entity, database); + } + + private ThingWriteRequestMapping invalidRelationships(final ValidationReport validation) { + return ThingWriteRequestMapping.error( + ApiResponse.error( + 400, + String.format( + "Invalid relationships: %s", + validation.getCombinedErrorMessages()))); + } + + private List> fieldValuesExcludingRelationships( + final BodyParser bodyargs, final RelationshipBodyCommands relationships) { + List> flattenedArgs = + new ArrayList<>(bodyargs.getFlattenedStringMap()); + flattenedArgs.removeAll(relationships.relationshipEntries()); + return flattenedArgs; + } + + private void copyBaseDraftValues( + final EntityInstanceDraft baseDraft, final EntityInstanceDraft draft) { + for (NamedValue value : baseDraft.getFieldValues()) { + draft.withField(value.getName(), value.asString()); + } + for (NamedValue protectedValue : baseDraft.getProtectedFieldValues()) { + draft.withProtectedField(protectedValue.getName(), protectedValue.asString()); + } + } + + private boolean isProtectedField(final FieldType fieldType) { + return fieldType == FieldType.AUTO_INCREMENT || fieldType == FieldType.AUTO_GUID; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingCommandResultApiMapper.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingCommandResultApiMapper.java new file mode 100644 index 00000000..82171eaa --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingCommandResultApiMapper.java @@ -0,0 +1,81 @@ +package uk.co.compendiumdev.thingifier.api.restapihandlers; + +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfig; +import uk.co.compendiumdev.thingifier.application.ThingCommandResult; +import uk.co.compendiumdev.thingifier.application.command.AmendThingCommand; +import uk.co.compendiumdev.thingifier.application.command.ConnectExistingRelationshipCommand; +import uk.co.compendiumdev.thingifier.application.command.CreateAndConnectRelationshipCommand; +import uk.co.compendiumdev.thingifier.application.command.CreateThingCommand; +import uk.co.compendiumdev.thingifier.application.command.DeleteThingCommand; +import uk.co.compendiumdev.thingifier.application.command.DisconnectRelationshipCommand; +import uk.co.compendiumdev.thingifier.application.command.ThingWriteCommand; + +public final class ThingCommandResultApiMapper { + + private static final String CREATED_ITEM_ROLLED_BACK_MESSAGE = + " the newly created item was deleted. No new items have been created."; + + private final ThingifierApiConfig apiConfig; + + public ThingCommandResultApiMapper(final ThingifierApiConfig apiConfig) { + this.apiConfig = apiConfig; + } + + public ApiResponse map(final ThingWriteCommand command, final ThingCommandResult result) { + if (result.isError()) { + return errorResponseFor(command, result); + } + + if (command instanceof CreateThingCommand + || command instanceof CreateAndConnectRelationshipCommand) { + return ApiResponse.created(result.getInstance(), apiConfig); + } + + if (command instanceof ConnectExistingRelationshipCommand) { + return ApiResponse.created(null, apiConfig); + } + + if (command instanceof AmendThingCommand) { + return ApiResponse.success().returnSingleInstance(result.getInstance()); + } + + if (command instanceof DeleteThingCommand + || command instanceof DisconnectRelationshipCommand) { + return ApiResponse.success(); + } + + return ApiResponse.error(400, result.getErrorMessages()); + } + + private ApiResponse errorResponseFor( + final ThingWriteCommand command, final ThingCommandResult result) { + ApiResponse response; + if (command instanceof CreateThingCommand && result.getErrorMessages().size() == 1) { + response = + ApiResponse.error(400, creationErrorMessage(result.getCombinedErrorMessage())); + } else { + response = ApiResponse.error(400, result.getErrorMessages()); + } + + if (result.rolledBackCreatedInstance()) { + response.addToErrorMessages(CREATED_ITEM_ROLLED_BACK_MESSAGE); + } + return response; + } + + private String creationErrorMessage(final String rawMessage) { + String message = rawMessage; + if (message == null) { + return ""; + } + + String validationPrefix = "Failed Validation: "; + if (message.startsWith(validationPrefix) + && message.substring(validationPrefix.length()).endsWith(" : field is mandatory") + && !message.substring(validationPrefix.length()).contains(", ")) { + return message.substring(validationPrefix.length()); + } + return message; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingCreation.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingCreation.java deleted file mode 100644 index a3164c07..00000000 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingCreation.java +++ /dev/null @@ -1,250 +0,0 @@ -package uk.co.compendiumdev.thingifier.api.restapihandlers; - -import java.util.List; -import uk.co.compendiumdev.thingifier.Thingifier; -import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; -import uk.co.compendiumdev.thingifier.api.response.ApiResponse; -import uk.co.compendiumdev.thingifier.application.RelationshipConnection; -import uk.co.compendiumdev.thingifier.application.ThingCommandResult; -import uk.co.compendiumdev.thingifier.application.ThingCommandService; -import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; -import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; -import uk.co.compendiumdev.thingifier.core.domain.definitions.field.instance.NamedValue; -import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; -import uk.co.compendiumdev.thingifier.core.reporting.ValidationReport; - -public class ThingCreation { - - private final Thingifier thingifier; - - public ThingCreation(final Thingifier thingifier) { - this.thingifier = thingifier; - } - - public ApiResponse with( - final BodyParser bodyargs, final EntityDefinition thing, final String database) { - - ValidationReport validated = - new BodyRelationshipValidator(thingifier).validate(bodyargs, thing, database); - - if (!validated.isValid()) { - return ApiResponse.error( - 400, - String.format( - "Invalid relationships: %s", validated.getCombinedErrorMessages())); - } - - validated = new BodyCreationValidator().validate(bodyargs, thing); - if (!validated.isValid()) { - return ApiResponse.error( - 400, - String.format("Invalid Creation: %s", validated.getCombinedErrorMessages())); - } - - return addNewThingWithFields( - bodyargs, EntityInstanceDraft.forEntity(thing), thing, database, true); - } - - ApiResponse withDeferredRelationshipValidation( - final BodyParser bodyargs, final EntityDefinition thing, final String database) { - - ValidationReport validated = - new BodyRelationshipValidator(thingifier).validate(bodyargs, thing, database); - - if (!validated.isValid()) { - return ApiResponse.error( - 400, - String.format( - "Invalid relationships: %s", validated.getCombinedErrorMessages())); - } - - validated = new BodyCreationValidator().validate(bodyargs, thing); - if (!validated.isValid()) { - return ApiResponse.error( - 400, - String.format("Invalid Creation: %s", validated.getCombinedErrorMessages())); - } - - return addNewThingWithFields( - bodyargs, EntityInstanceDraft.forEntity(thing), thing, database, false); - } - - // create with GUID and IDs is normally associated with PUT or 'insert' - public ApiResponse withPrimaryKey( - final String primaryKey, - final BodyParser bodyargs, - final EntityDefinition thing, - final String database) { - - ValidationReport validated; - - validated = - new BodyCreationValidator() - .areFieldsUnique( - bodyargs, - thing, - thingifier.getStore(database).entityQueries(), - thing.getFieldNamesOfType( - FieldType.AUTO_INCREMENT, FieldType.AUTO_GUID)); - if (!validated.isValid()) { - return ApiResponse.error( - 409, - "Cannot Create with duplicate values: " + validated.getCombinedErrorMessages()); - } - - EntityInstanceDraft draft = EntityInstanceDraft.forEntity(thing); - if (isProtectedField(thing.getPrimaryKeyField().getType())) { - draft.withProtectedField(thing.getPrimaryKeyField().getName(), primaryKey); - } else { - draft.withField(thing.getPrimaryKeyField().getName(), primaryKey); - } - - validated = new BodyRelationshipValidator(thingifier).validate(bodyargs, thing, database); - - if (!validated.isValid()) { - return ApiResponse.error( - 400, - String.format( - "Invalid relationships: %s", validated.getCombinedErrorMessages())); - } - - // any next id counts should be set higher than the ids mentioned in here - List fieldValues = - FieldValues.fromListMapEntryStringString(bodyargs.getFlattenedStringMap()); - - thingifier.getStore(database).administration().accommodateProtectedIds(thing, fieldValues); - - return insertNewThingWithFields(bodyargs, draft, thing, database, true); - } - - private ApiResponse addNewThingWithFields( - final BodyParser bodyargs, - final EntityInstanceDraft baseDraft, - final EntityDefinition thing, - final String database, - final boolean validateFinalRelationships) { - - if (thingifier.apiConfig().willApiEnforceDeclaredTypesInInput()) { - ValidationReport validatedTypes = bodyargs.validateAgainstType(thing); - if (!validatedTypes.isValid()) { - return ApiResponse.error(400, validatedTypes.getCombinedErrorMessages()); - } - } - - // todo: need to separate relationships from field in the path prior to sending through to - // setFields - EntityInstanceDraft draft; - - try { - // if any guids or ids then throw an error if they are not the same - List fieldValues = - FieldValues.fromListMapEntryStringString( - new BodyArgsProcessor(thingifier, bodyargs) - .removeRelationshipsFrom(thing, database)); - - draft = new EntityInstanceBulkUpdater(thing).setFieldValuesFrom(fieldValues); - copyBaseDraftValues(baseDraft, draft); - } catch (Exception e) { - return ApiResponse.error(400, e.getMessage()); - } - - return addValidatedInstance(bodyargs, draft, database, validateFinalRelationships); - } - - private ApiResponse insertNewThingWithFields( - final BodyParser bodyargs, - final EntityInstanceDraft baseDraft, - final EntityDefinition thing, - final String database, - final boolean validateFinalRelationships) { - - if (thingifier.apiConfig().willApiEnforceDeclaredTypesInInput()) { - ValidationReport validatedTypes = bodyargs.validateAgainstType(thing); - if (!validatedTypes.isValid()) { - return ApiResponse.error(400, validatedTypes.getCombinedErrorMessages()); - } - } - - EntityInstanceDraft draft; - try { - // set all the fields and values, except guids - List ignoreFields = thing.getFieldNamesOfType(FieldType.AUTO_GUID); - - List fieldValues = - FieldValues.fromListMapEntryStringString( - new BodyArgsProcessor(thingifier, bodyargs) - .removeRelationshipsFrom(thing, database)); - - draft = - new EntityInstanceBulkUpdater(thing) - .overrideFieldValuesFromArgsIgnoring(fieldValues, ignoreFields); - copyBaseDraftValues(baseDraft, draft); - } catch (Exception e) { - return ApiResponse.error(400, e.getMessage()); - } - - return addValidatedInstance(bodyargs, draft, database, validateFinalRelationships); - } - - private ApiResponse addValidatedInstance( - final BodyParser bodyargs, - final EntityInstanceDraft draft, - final String database, - final boolean validateFinalRelationships) { - - try { - List relationships = - new RelationshipCreator(thingifier) - .relationshipConnectionsFromArgs(bodyargs, draft.getEntity(), database); - ThingCommandResult result = - new ThingCommandService(thingifier.getStore(database)) - .create(draft, relationships, validateFinalRelationships); - if (result.isError()) { - return creationErrorResponse(result); - } - return ApiResponse.created(result.getInstance(), thingifier.apiConfig()); - } catch (Exception e) { - return ApiResponse.error(400, creationErrorMessage(e)); - } - } - - private ApiResponse creationErrorResponse(final ThingCommandResult result) { - if (result.getErrorMessages().size() == 1) { - return ApiResponse.error(400, creationErrorMessage(result.getCombinedErrorMessage())); - } - return ApiResponse.error(400, result.getErrorMessages()); - } - - private String creationErrorMessage(final Exception e) { - return creationErrorMessage(e.getMessage()); - } - - private String creationErrorMessage(final String rawMessage) { - String message = rawMessage; - if (message == null) { - return ""; - } - - String validationPrefix = "Failed Validation: "; - if (message.startsWith(validationPrefix) - && message.substring(validationPrefix.length()).endsWith(" : field is mandatory") - && !message.substring(validationPrefix.length()).contains(", ")) { - return message.substring(validationPrefix.length()); - } - return message; - } - - private void copyBaseDraftValues( - final EntityInstanceDraft baseDraft, final EntityInstanceDraft draft) { - for (NamedValue value : baseDraft.getFieldValues()) { - draft.withField(value.getName(), value.asString()); - } - for (NamedValue protectedValue : baseDraft.getProtectedFieldValues()) { - draft.withProtectedField(protectedValue.getName(), protectedValue.asString()); - } - } - - private boolean isProtectedField(final FieldType fieldType) { - return fieldType == FieldType.AUTO_INCREMENT || fieldType == FieldType.AUTO_GUID; - } -} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingWriteRequestMapper.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingWriteRequestMapper.java new file mode 100644 index 00000000..23f8dd76 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingWriteRequestMapper.java @@ -0,0 +1,308 @@ +package uk.co.compendiumdev.thingifier.api.restapihandlers; + +import java.util.List; +import java.util.Map; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.api.restapihandlers.commonerrorresponse.NoSuchEntity; +import uk.co.compendiumdev.thingifier.application.command.ConnectExistingRelationshipCommand; +import uk.co.compendiumdev.thingifier.application.command.DeleteThingCommand; +import uk.co.compendiumdev.thingifier.application.command.DisconnectRelationshipCommand; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.instance.NamedValue; +import uk.co.compendiumdev.thingifier.core.domain.definitions.relationship.RelationshipVectorDefinition; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; + +public final class ThingWriteRequestMapper { + + private final Thingifier thingifier; + private final String database; + private final ThingBodyCommandMapper bodyCommandMapper; + + public ThingWriteRequestMapper(final Thingifier thingifier, final String database) { + this.thingifier = thingifier; + this.database = database; + this.bodyCommandMapper = new ThingBodyCommandMapper(thingifier, database); + } + + public ThingWriteRequestMapping mapPost(final String url, final BodyParser args) { + EntityDefinition entityDefinition = + EntityUrlMatcher.entityFromCollectionUrl(thingifier, url); + if (entityDefinition != null) { + return bodyCommandMapper.mapCreate(args, entityDefinition, true); + } + + entityDefinition = EntityUrlMatcher.entityFromInstanceUrl(thingifier, url); + if (entityDefinition != null) { + return mapPostToInstance(url, args, entityDefinition); + } + + String thingName = EntityUrlMatcher.entityTermFromUrl(url); + if (!thingName.isEmpty() && EntityUrlMatcher.hasPartCount(url, 2)) { + return ThingWriteRequestMapping.error(NoSuchEntity.response(thingName)); + } + + RepositoryBackedRelationshipUrlResolver.RelationshipUrlResolution relationship = + new RepositoryBackedRelationshipUrlResolver(thingifier, database) + .resolveCollection(url); + if (relationship.matchedRelationshipPath()) { + return mapPostToRelationship(url, args, relationship); + } + + return ThingWriteRequestMapping.error( + ApiResponse.error(400, "Your request was not understood")); + } + + public ThingWriteRequestMapping mapPut(final String url, final BodyParser args) { + EntityDefinition thing = EntityUrlMatcher.entityFromCollectionUrl(thingifier, url); + if (thing != null) { + return ThingWriteRequestMapping.error( + ApiResponse.error(405, "Cannot create root level entity with a PUT")); + } + + thing = EntityUrlMatcher.entityFromInstanceUrl(thingifier, url); + if (thing == null) { + if (EntityUrlMatcher.hasPartCount(url, 2)) { + String thingName = EntityUrlMatcher.entityTermFromUrl(url); + if (!thingName.isEmpty()) { + return ThingWriteRequestMapping.error(NoSuchEntity.response(thingName)); + } + } + return ThingWriteRequestMapping.error( + ApiResponse.error(400, "Your request was not understood")); + } + + String instanceGuid = EntityUrlMatcher.identifierFromInstanceUrl(url); + EntityInstance instance = + thingifier + .getStore(database) + .entityQueries() + .findByQueryIdentifier(thing, instanceGuid); + + if (instance == null) { + return mapPutCreate(args, thing, instanceGuid); + } + + return bodyCommandMapper.mapAmend(args, instance, true); + } + + public ThingWriteRequestMapping mapDelete(final String url) { + EntityDefinition thing = EntityUrlMatcher.entityFromCollectionUrl(thingifier, url); + if (thing != null) { + return ThingWriteRequestMapping.error( + ApiResponse.error(405, "Cannot delete root level entity")); + } + + EntityDefinition entity = EntityUrlMatcher.entityFromInstanceUrl(thingifier, url); + if (entity != null) { + EntityInstance instance = + EntityUrlMatcher.findInstanceFromUrl(thingifier, url, database); + if (instance == null) { + return ThingWriteRequestMapping.error( + ApiResponse.error404( + String.format("Could not find any instances with %s", url))); + } + return ThingWriteRequestMapping.command(new DeleteThingCommand(instance)); + } + + RepositoryBackedRelationshipUrlResolver.RelationshipUrlResolution relationship = + new RepositoryBackedRelationshipUrlResolver(thingifier, database) + .resolveRelationshipInstance(url); + if (relationship.matchedRelationshipPath()) { + if (!relationship.relationshipInstancePath() + || relationship.parentInstance() == null + || relationship.childInstance() == null) { + return ThingWriteRequestMapping.error( + ApiResponse.error404( + String.format("Could not find any instances with %s", url))); + } + return ThingWriteRequestMapping.command( + new DisconnectRelationshipCommand( + relationship.parentInstance(), + relationship.childInstance(), + relationship.relationshipName())); + } + + return ThingWriteRequestMapping.error( + ApiResponse.error404(String.format("Could not find any instances with %s", url))); + } + + private ThingWriteRequestMapping mapPostToInstance( + final String url, final BodyParser args, final EntityDefinition entityDefinition) { + String primaryKey = EntityUrlMatcher.identifierFromInstanceUrl(url); + + if (entityDefinition.hasPrimaryKeyField()) { + EntityInstance instance = + thingifier + .getStore(database) + .entityQueries() + .findByQueryIdentifier(entityDefinition, primaryKey); + + if (instance == null) { + return ThingWriteRequestMapping.error( + ApiResponse.error404( + String.format( + "No such %s entity instance with %s == %s found", + entityDefinition.getName(), + entityDefinition.getPrimaryKeyField().getName(), + primaryKey))); + } + + return bodyCommandMapper.mapAmend(args, instance, false); + } + + return ThingWriteRequestMapping.error( + ApiResponse.error404( + String.format( + "Entity %s does not have a primary key defined", + entityDefinition.getName()))); + } + + private ThingWriteRequestMapping mapPutCreate( + final BodyParser args, final EntityDefinition thing, final String instanceGuid) { + List forbiddenPutCreationFields = + thing.getFieldsOfType(FieldType.AUTO_INCREMENT, FieldType.AUTO_GUID); + if (!forbiddenPutCreationFields.isEmpty()) { + return ThingWriteRequestMapping.error( + ApiResponse.error( + 400, + String.format( + "Cannot create %s with PUT due to Auto fields %s", + thing.getName(), fieldNames(forbiddenPutCreationFields)))); + } + + List fieldValues = + FieldValues.fromListMapEntryStringString(args.getFlattenedStringMap()); + for (NamedValue namedValue : fieldValues) { + if (namedValue.name.equals(thing.getPrimaryKeyField().getName()) + && !namedValue.value.equals(instanceGuid)) { + return ThingWriteRequestMapping.error( + ApiResponse.error( + 400, + String.format( + "Cannot create %s with PUT as key does not match body value %s != %s", + thing.getName(), instanceGuid, namedValue.value))); + } + } + + return bodyCommandMapper.mapCreateWithPrimaryKey(instanceGuid, args, thing); + } + + private ThingWriteRequestMapping mapPostToRelationship( + final String url, + final BodyParser bodyargs, + final RepositoryBackedRelationshipUrlResolver.RelationshipUrlResolution relationship) { + final Map args = bodyargs.getStringMap(); + String relationshipName = relationship.relationshipName(); + + EntityInstance connectThis = relationship.parentInstance(); + if (connectThis == null) { + return ThingWriteRequestMapping.error( + ApiResponse.error404( + String.format("Could not find parent thing for relationship %s", url))); + } + + List possibleRelationships = + connectThis.getEntity().related().getRelationships(relationshipName); + RelationshipVectorDefinition relationshipToUse = possibleRelationships.get(0); + EntityDefinition thingTo = relationshipToUse.getTo(); + + EntityInstance relatedItem = null; + boolean amExpectingARelatedItem = false; + String matchingFieldNames = ""; + for (String fieldName : args.keySet()) { + final Field field = thingTo.getField(fieldName); + if (field == null) { + continue; + } + if (field.getType() == FieldType.AUTO_GUID + || field.getType() == FieldType.AUTO_INCREMENT) { + amExpectingARelatedItem = true; + if (!matchingFieldNames.contains(fieldName + " ")) { + matchingFieldNames = matchingFieldNames + fieldName + " "; + } + relatedItem = + thingifier + .getStore(database) + .entityQueries() + .findByField(thingTo, fieldName, args.get(fieldName)); + if (relatedItem != null) { + break; + } + } + } + if (amExpectingARelatedItem && relatedItem == null) { + matchingFieldNames = matchingFieldNames.trim().replace(" ", ", "); + return ThingWriteRequestMapping.error( + ApiResponse.error404( + String.format( + "Could not find thing matching value for %s", + matchingFieldNames))); + } + + if (relatedItem == null) { + return bodyCommandMapper.mapCreateAndConnect( + bodyargs, connectThis, relationshipToUse.getName(), relationshipToUse.getTo()); + } + + relationshipToUse = + connectThis + .getEntity() + .getNamedRelationshipTo(relationshipName, relatedItem.getEntity()); + ApiResponse relationshipError = + relationshipErrorIfInvalid( + connectThis, relatedItem, relationshipToUse, relationshipName); + if (relationshipError != null) { + return ThingWriteRequestMapping.error(relationshipError); + } + + return ThingWriteRequestMapping.command( + new ConnectExistingRelationshipCommand( + connectThis, relationshipToUse.getName(), relatedItem)); + } + + private ApiResponse relationshipErrorIfInvalid( + final EntityInstance connectThis, + final EntityInstance relatedItem, + final RelationshipVectorDefinition relationshipToUse, + final String relationshipName) { + if (relationshipToUse == null) { + return ApiResponse.error( + 400, + String.format( + "Could not find a relationship named %s between %s and a %s", + relationshipName, + connectThis.getEntity().getName(), + relatedItem.getEntity().getName())); + } + + if (relationshipToUse.getTo() != relatedItem.getEntity()) { + return ApiResponse.error( + 400, + String.format( + "Could not connect %s (%s) to %s (%s) via relationship %s because it is a %s instead of a %s", + connectThis.getPrimaryKeyValue(), + connectThis.getEntity().getName(), + relatedItem.getPrimaryKeyValue(), + relatedItem.getEntity().getName(), + relationshipToUse.getName(), + relatedItem.getEntity().getName(), + relationshipToUse.getTo().getName())); + } + return null; + } + + private String fieldNames(final List fields) { + String names = ""; + for (Field field : fields) { + if (!names.isEmpty()) { + names = names + ", "; + } + names = names + field.getName(); + } + return names; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingWriteRequestMapping.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingWriteRequestMapping.java new file mode 100644 index 00000000..88acfd61 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingWriteRequestMapping.java @@ -0,0 +1,36 @@ +package uk.co.compendiumdev.thingifier.api.restapihandlers; + +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.application.command.ThingWriteCommand; + +public final class ThingWriteRequestMapping { + + private final ThingWriteCommand command; + private final ApiResponse errorResponse; + + private ThingWriteRequestMapping( + final ThingWriteCommand command, final ApiResponse errorResponse) { + this.command = command; + this.errorResponse = errorResponse; + } + + public static ThingWriteRequestMapping command(final ThingWriteCommand command) { + return new ThingWriteRequestMapping(command, null); + } + + public static ThingWriteRequestMapping error(final ApiResponse errorResponse) { + return new ThingWriteRequestMapping(null, errorResponse); + } + + public boolean isError() { + return errorResponse != null; + } + + public ThingWriteCommand getCommand() { + return command; + } + + public ApiResponse getErrorResponse() { + return errorResponse; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/ThingCommandResult.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/ThingCommandResult.java index 2961ddcf..4b21193c 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/ThingCommandResult.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/ThingCommandResult.java @@ -11,33 +11,40 @@ public final class ThingCommandResult { private final boolean successful; private final EntityInstance instance; private final List errorMessages; + private final boolean rolledBackCreatedInstance; private ThingCommandResult( final boolean successful, final EntityInstance instance, - final List errorMessages) { + final List errorMessages, + final boolean rolledBackCreatedInstance) { this.successful = successful; this.instance = instance; this.errorMessages = Collections.unmodifiableList(new ArrayList<>(errorMessages)); + this.rolledBackCreatedInstance = rolledBackCreatedInstance; } public static ThingCommandResult success() { - return new ThingCommandResult(true, null, List.of()); + return new ThingCommandResult(true, null, List.of(), false); } public static ThingCommandResult success(final EntityInstance instance) { - return new ThingCommandResult(true, instance, List.of()); + return new ThingCommandResult(true, instance, List.of(), false); } public static ThingCommandResult error(final String message) { if (message == null || message.isEmpty()) { - return new ThingCommandResult(false, null, List.of("")); + return new ThingCommandResult(false, null, List.of(""), false); } - return new ThingCommandResult(false, null, List.of(message)); + return new ThingCommandResult(false, null, List.of(message), false); } public static ThingCommandResult error(final Collection messages) { - return new ThingCommandResult(false, null, new ArrayList<>(messages)); + return new ThingCommandResult(false, null, new ArrayList<>(messages), false); + } + + public ThingCommandResult withRolledBackCreatedInstance() { + return new ThingCommandResult(successful, instance, errorMessages, true); } public boolean isSuccessful() { @@ -59,4 +66,8 @@ public List getErrorMessages() { public String getCombinedErrorMessage() { return String.join(", ", errorMessages); } + + public boolean rolledBackCreatedInstance() { + return rolledBackCreatedInstance; + } } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/ThingCommandService.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/ThingCommandService.java index 7128eebd..ea6ea33e 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/ThingCommandService.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/ThingCommandService.java @@ -4,6 +4,13 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import uk.co.compendiumdev.thingifier.application.command.AmendThingCommand; +import uk.co.compendiumdev.thingifier.application.command.ConnectExistingRelationshipCommand; +import uk.co.compendiumdev.thingifier.application.command.CreateAndConnectRelationshipCommand; +import uk.co.compendiumdev.thingifier.application.command.CreateThingCommand; +import uk.co.compendiumdev.thingifier.application.command.DeleteThingCommand; +import uk.co.compendiumdev.thingifier.application.command.DisconnectRelationshipCommand; +import uk.co.compendiumdev.thingifier.application.command.ThingWriteCommand; import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; import uk.co.compendiumdev.thingifier.core.domain.definitions.field.instance.FieldValue; @@ -21,6 +28,51 @@ public ThingCommandService(final ThingStore store) { this.store = store; } + public ThingCommandResult execute(final ThingWriteCommand command) { + if (command instanceof CreateThingCommand) { + CreateThingCommand create = (CreateThingCommand) command; + return create( + create.getDraft(), + create.getRelationships(), + create.shouldValidateFinalRelationships()); + } + + if (command instanceof AmendThingCommand) { + AmendThingCommand amend = (AmendThingCommand) command; + return amend( + amend.getInstance(), + amend.getDraft(), + amend.shouldReplaceExistingFieldsAndRelationships(), + amend.getRelationships()); + } + + if (command instanceof DeleteThingCommand) { + return delete(((DeleteThingCommand) command).getInstance()); + } + + if (command instanceof ConnectExistingRelationshipCommand) { + ConnectExistingRelationshipCommand connect = + (ConnectExistingRelationshipCommand) command; + return connectRelationship( + connect.getParent(), connect.getRelationshipName(), connect.getChild(), false); + } + + if (command instanceof CreateAndConnectRelationshipCommand) { + return createAndConnect((CreateAndConnectRelationshipCommand) command); + } + + if (command instanceof DisconnectRelationshipCommand) { + DisconnectRelationshipCommand disconnect = (DisconnectRelationshipCommand) command; + return disconnectRelationship( + disconnect.getParent(), + disconnect.getChild(), + disconnect.getRelationshipName()); + } + + return ThingCommandResult.error( + String.format("Unsupported command %s", command.getClass().getSimpleName())); + } + public ThingCommandResult create( final EntityInstanceDraft draft, final List relationships) { return create(draft, relationships, true); @@ -139,6 +191,26 @@ public ThingCommandResult connectRelationships( return connectRelationships(instance, relationships, true); } + private ThingCommandResult createAndConnect(final CreateAndConnectRelationshipCommand command) { + ThingCommandResult createResult = + create(command.getChildDraft(), command.getChildRelationships(), false); + if (createResult.isError()) { + return createResult; + } + + ThingCommandResult connectResult = + connectRelationship( + command.getParent(), + command.getRelationshipName(), + createResult.getInstance(), + true); + if (connectResult.isError()) { + return connectResult.withRolledBackCreatedInstance(); + } + + return ThingCommandResult.success(createResult.getInstance()); + } + private ThingCommandResult connectRelationships( final EntityInstance instance, final List relationships, diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/AmendThingCommand.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/AmendThingCommand.java new file mode 100644 index 00000000..5861f667 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/AmendThingCommand.java @@ -0,0 +1,43 @@ +package uk.co.compendiumdev.thingifier.application.command; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import uk.co.compendiumdev.thingifier.application.RelationshipConnection; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; + +public final class AmendThingCommand implements ThingWriteCommand { + + private final EntityInstance instance; + private final EntityInstanceDraft draft; + private final boolean replaceExistingFieldsAndRelationships; + private final List relationships; + + public AmendThingCommand( + final EntityInstance instance, + final EntityInstanceDraft draft, + final boolean replaceExistingFieldsAndRelationships, + final List relationships) { + this.instance = instance; + this.draft = draft; + this.replaceExistingFieldsAndRelationships = replaceExistingFieldsAndRelationships; + this.relationships = Collections.unmodifiableList(new ArrayList<>(relationships)); + } + + public EntityInstance getInstance() { + return instance; + } + + public EntityInstanceDraft getDraft() { + return draft; + } + + public boolean shouldReplaceExistingFieldsAndRelationships() { + return replaceExistingFieldsAndRelationships; + } + + public List getRelationships() { + return relationships; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/ConnectExistingRelationshipCommand.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/ConnectExistingRelationshipCommand.java new file mode 100644 index 00000000..7e467578 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/ConnectExistingRelationshipCommand.java @@ -0,0 +1,31 @@ +package uk.co.compendiumdev.thingifier.application.command; + +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; + +public final class ConnectExistingRelationshipCommand implements ThingWriteCommand { + + private final EntityInstance parent; + private final String relationshipName; + private final EntityInstance child; + + public ConnectExistingRelationshipCommand( + final EntityInstance parent, + final String relationshipName, + final EntityInstance child) { + this.parent = parent; + this.relationshipName = relationshipName; + this.child = child; + } + + public EntityInstance getParent() { + return parent; + } + + public String getRelationshipName() { + return relationshipName; + } + + public EntityInstance getChild() { + return child; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/CreateAndConnectRelationshipCommand.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/CreateAndConnectRelationshipCommand.java new file mode 100644 index 00000000..2404ab91 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/CreateAndConnectRelationshipCommand.java @@ -0,0 +1,43 @@ +package uk.co.compendiumdev.thingifier.application.command; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import uk.co.compendiumdev.thingifier.application.RelationshipConnection; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; + +public final class CreateAndConnectRelationshipCommand implements ThingWriteCommand { + + private final EntityInstance parent; + private final String relationshipName; + private final EntityInstanceDraft childDraft; + private final List childRelationships; + + public CreateAndConnectRelationshipCommand( + final EntityInstance parent, + final String relationshipName, + final EntityInstanceDraft childDraft, + final List childRelationships) { + this.parent = parent; + this.relationshipName = relationshipName; + this.childDraft = childDraft; + this.childRelationships = Collections.unmodifiableList(new ArrayList<>(childRelationships)); + } + + public EntityInstance getParent() { + return parent; + } + + public String getRelationshipName() { + return relationshipName; + } + + public EntityInstanceDraft getChildDraft() { + return childDraft; + } + + public List getChildRelationships() { + return childRelationships; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/CreateThingCommand.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/CreateThingCommand.java new file mode 100644 index 00000000..18b7e8dd --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/CreateThingCommand.java @@ -0,0 +1,35 @@ +package uk.co.compendiumdev.thingifier.application.command; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import uk.co.compendiumdev.thingifier.application.RelationshipConnection; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; + +public final class CreateThingCommand implements ThingWriteCommand { + + private final EntityInstanceDraft draft; + private final List relationships; + private final boolean validateFinalRelationships; + + public CreateThingCommand( + final EntityInstanceDraft draft, + final List relationships, + final boolean validateFinalRelationships) { + this.draft = draft; + this.relationships = Collections.unmodifiableList(new ArrayList<>(relationships)); + this.validateFinalRelationships = validateFinalRelationships; + } + + public EntityInstanceDraft getDraft() { + return draft; + } + + public List getRelationships() { + return relationships; + } + + public boolean shouldValidateFinalRelationships() { + return validateFinalRelationships; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/DeleteThingCommand.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/DeleteThingCommand.java new file mode 100644 index 00000000..13aa27b2 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/DeleteThingCommand.java @@ -0,0 +1,16 @@ +package uk.co.compendiumdev.thingifier.application.command; + +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; + +public final class DeleteThingCommand implements ThingWriteCommand { + + private final EntityInstance instance; + + public DeleteThingCommand(final EntityInstance instance) { + this.instance = instance; + } + + public EntityInstance getInstance() { + return instance; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/DisconnectRelationshipCommand.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/DisconnectRelationshipCommand.java new file mode 100644 index 00000000..ceb0bbce --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/DisconnectRelationshipCommand.java @@ -0,0 +1,31 @@ +package uk.co.compendiumdev.thingifier.application.command; + +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; + +public final class DisconnectRelationshipCommand implements ThingWriteCommand { + + private final EntityInstance parent; + private final EntityInstance child; + private final String relationshipName; + + public DisconnectRelationshipCommand( + final EntityInstance parent, + final EntityInstance child, + final String relationshipName) { + this.parent = parent; + this.child = child; + this.relationshipName = relationshipName; + } + + public EntityInstance getParent() { + return parent; + } + + public EntityInstance getChild() { + return child; + } + + public String getRelationshipName() { + return relationshipName; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/ThingWriteCommand.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/ThingWriteCommand.java new file mode 100644 index 00000000..6591278e --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/command/ThingWriteCommand.java @@ -0,0 +1,3 @@ +package uk.co.compendiumdev.thingifier.application.command; + +public interface ThingWriteCommand {} diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingWriteRequestMapperTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingWriteRequestMapperTest.java new file mode 100644 index 00000000..5917cf2c --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingWriteRequestMapperTest.java @@ -0,0 +1,251 @@ +package uk.co.compendiumdev.thingifier.api.restapihandlers; + +import com.google.gson.Gson; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.api.http.HttpApiRequest; +import uk.co.compendiumdev.thingifier.api.http.bodyparser.BodyParser; +import uk.co.compendiumdev.thingifier.application.command.AmendThingCommand; +import uk.co.compendiumdev.thingifier.application.command.ConnectExistingRelationshipCommand; +import uk.co.compendiumdev.thingifier.application.command.CreateAndConnectRelationshipCommand; +import uk.co.compendiumdev.thingifier.application.command.CreateThingCommand; +import uk.co.compendiumdev.thingifier.application.command.DeleteThingCommand; +import uk.co.compendiumdev.thingifier.application.command.DisconnectRelationshipCommand; +import uk.co.compendiumdev.thingifier.core.EntityRelModel; +import uk.co.compendiumdev.thingifier.core.domain.definitions.Cardinality; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; +import uk.co.compendiumdev.thingifier.core.repository.ThingStore; + +public class ThingWriteRequestMapperTest { + + @Test + public void mapsPostCollectionToCreateCommand() { + Thingifier thingifier = taskProjectThingifier(); + ThingWriteRequestMapping mapping = + mapperFor(thingifier).mapPost("task", parserFor(thingifier, "title", "Task")); + + Assertions.assertFalse(mapping.isError()); + Assertions.assertTrue(mapping.getCommand() instanceof CreateThingCommand); + } + + @Test + public void mapsPostInstanceToPatchAmendCommand() { + Thingifier thingifier = taskProjectThingifier(); + EntityInstance task = createTask(thingifier, "Task"); + + ThingWriteRequestMapping mapping = + mapperFor(thingifier) + .mapPost( + String.format("task/%s", task.getPrimaryKeyValue()), + parserFor(thingifier, "title", "Patched")); + + Assertions.assertFalse(mapping.isError()); + AmendThingCommand command = (AmendThingCommand) mapping.getCommand(); + Assertions.assertFalse(command.shouldReplaceExistingFieldsAndRelationships()); + } + + @Test + public void mapsPutExistingInstanceToReplaceAmendCommand() { + Thingifier thingifier = taskProjectThingifier(); + EntityInstance task = createTask(thingifier, "Task"); + + ThingWriteRequestMapping mapping = + mapperFor(thingifier) + .mapPut( + String.format("task/%s", task.getPrimaryKeyValue()), + parserFor(thingifier, "title", "Replaced")); + + Assertions.assertFalse(mapping.isError()); + AmendThingCommand command = (AmendThingCommand) mapping.getCommand(); + Assertions.assertTrue(command.shouldReplaceExistingFieldsAndRelationships()); + } + + @Test + public void mapsPutMissingStringKeyInstanceToCreateCommand() { + Thingifier thingifier = stringKeyThingifier(); + + ThingWriteRequestMapping mapping = + mapperFor(thingifier).mapPut("note/n-1", parserFor(thingifier, "title", "Created")); + + Assertions.assertFalse(mapping.isError()); + Assertions.assertTrue(mapping.getCommand() instanceof CreateThingCommand); + } + + @Test + public void rejectsPutCreateWhenBodyPrimaryKeyDoesNotMatchUrlKey() { + Thingifier thingifier = stringKeyThingifier(); + Map body = new HashMap<>(); + body.put("key", "other"); + body.put("title", "Created"); + + ThingWriteRequestMapping mapping = + mapperFor(thingifier).mapPut("note/n-1", parserFor(thingifier, body)); + + Assertions.assertTrue(mapping.isError()); + Assertions.assertEquals(400, mapping.getErrorResponse().getStatusCode()); + } + + @Test + public void rejectsPutCreateWhenEntityHasAutoFields() { + Thingifier thingifier = taskProjectThingifier(); + + ThingWriteRequestMapping mapping = + mapperFor(thingifier) + .mapPut("task/manual", parserFor(thingifier, "title", "Created")); + + Assertions.assertTrue(mapping.isError()); + Assertions.assertEquals(400, mapping.getErrorResponse().getStatusCode()); + } + + @Test + public void postMissingInstanceMapsToNotFound() { + Thingifier thingifier = taskProjectThingifier(); + + ThingWriteRequestMapping mapping = + mapperFor(thingifier) + .mapPost("task/missing", parserFor(thingifier, "title", "Patched")); + + Assertions.assertTrue(mapping.isError()); + Assertions.assertEquals(404, mapping.getErrorResponse().getStatusCode()); + } + + @Test + public void mapsRelationshipPostWithExistingChildToConnectCommand() { + Thingifier thingifier = taskProjectThingifier(); + EntityInstance task = createTask(thingifier, "Task"); + EntityInstance project = createProject(thingifier, "Project"); + + ThingWriteRequestMapping mapping = + mapperFor(thingifier) + .mapPost( + String.format("project/%s/tasks", project.getPrimaryKeyValue()), + parserFor(thingifier, "guid", task.getPrimaryKeyValue())); + + Assertions.assertFalse(mapping.isError()); + Assertions.assertTrue(mapping.getCommand() instanceof ConnectExistingRelationshipCommand); + } + + @Test + public void mapsRelationshipPostWithoutChildKeyToCreateAndConnectCommand() { + Thingifier thingifier = taskProjectThingifier(); + EntityInstance project = createProject(thingifier, "Project"); + + ThingWriteRequestMapping mapping = + mapperFor(thingifier) + .mapPost( + String.format("project/%s/tasks", project.getPrimaryKeyValue()), + parserFor(thingifier, "title", "New task")); + + Assertions.assertFalse(mapping.isError()); + Assertions.assertTrue(mapping.getCommand() instanceof CreateAndConnectRelationshipCommand); + } + + @Test + public void mapsDeleteInstanceToDeleteCommand() { + Thingifier thingifier = taskProjectThingifier(); + EntityInstance task = createTask(thingifier, "Task"); + + ThingWriteRequestMapping mapping = + mapperFor(thingifier) + .mapDelete(String.format("task/%s", task.getPrimaryKeyValue())); + + Assertions.assertFalse(mapping.isError()); + Assertions.assertTrue(mapping.getCommand() instanceof DeleteThingCommand); + } + + @Test + public void mapsDeleteRelationshipPathToDisconnectCommand() { + Thingifier thingifier = taskProjectThingifier(); + ThingStore store = storeFor(thingifier); + EntityInstance task = createTask(thingifier, "Task"); + EntityInstance project = createProject(thingifier, "Project"); + store.relationships().connect(project, "tasks", task); + + ThingWriteRequestMapping mapping = + mapperFor(thingifier) + .mapDelete( + String.format( + "project/%s/tasks/%s", + project.getPrimaryKeyValue(), task.getPrimaryKeyValue())); + + Assertions.assertFalse(mapping.isError()); + Assertions.assertTrue(mapping.getCommand() instanceof DisconnectRelationshipCommand); + } + + @Test + public void mapsUnrecognisedPostToBadRequest() { + Thingifier thingifier = taskProjectThingifier(); + + ThingWriteRequestMapping mapping = + mapperFor(thingifier) + .mapPost("not-understood", parserFor(thingifier, "title", "Nope")); + + Assertions.assertTrue(mapping.isError()); + Assertions.assertEquals(400, mapping.getErrorResponse().getStatusCode()); + } + + private Thingifier taskProjectThingifier() { + Thingifier thingifier = new Thingifier(); + EntityDefinition task = thingifier.defineThing("task", "tasks"); + task.addAsPrimaryKeyField(Field.is("guid", FieldType.AUTO_GUID)); + task.addField(Field.is("title", FieldType.STRING)); + + EntityDefinition project = thingifier.defineThing("project", "projects"); + project.addAsPrimaryKeyField(Field.is("guid", FieldType.AUTO_GUID)); + project.addField(Field.is("title", FieldType.STRING)); + + thingifier + .defineRelationship(task, project, "task-of", Cardinality.ONE_TO_ONE()) + .whenReversed(Cardinality.ONE_TO_MANY(), "tasks"); + return thingifier; + } + + private Thingifier stringKeyThingifier() { + Thingifier thingifier = new Thingifier(); + EntityDefinition note = thingifier.defineThing("note", "notes"); + note.addAsPrimaryKeyField(Field.is("key", FieldType.STRING)); + note.addField(Field.is("title", FieldType.STRING)); + return thingifier; + } + + private EntityInstance createTask(final Thingifier thingifier, final String title) { + EntityDefinition task = thingifier.getDefinitionNamed("task"); + return storeFor(thingifier) + .entities() + .create(EntityInstanceDraft.forEntity(task).withField("title", title)); + } + + private EntityInstance createProject(final Thingifier thingifier, final String title) { + EntityDefinition project = thingifier.getDefinitionNamed("project"); + return storeFor(thingifier) + .entities() + .create(EntityInstanceDraft.forEntity(project).withField("title", title)); + } + + private ThingWriteRequestMapper mapperFor(final Thingifier thingifier) { + return new ThingWriteRequestMapper(thingifier, EntityRelModel.DEFAULT_DATABASE_NAME); + } + + private ThingStore storeFor(final Thingifier thingifier) { + return thingifier.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); + } + + private BodyParser parserFor( + final Thingifier thingifier, final String fieldName, final String fieldValue) { + Map body = new HashMap<>(); + body.put(fieldName, fieldValue); + return parserFor(thingifier, body); + } + + private BodyParser parserFor(final Thingifier thingifier, final Map body) { + HttpApiRequest request = new HttpApiRequest("/path").setBody(new Gson().toJson(body)); + return new BodyParser(request, thingifier.getThingNames()); + } +} diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/ThingCommandServiceTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/ThingCommandServiceTest.java new file mode 100644 index 00000000..43a586fa --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/ThingCommandServiceTest.java @@ -0,0 +1,253 @@ +package uk.co.compendiumdev.thingifier.application; + +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.application.command.AmendThingCommand; +import uk.co.compendiumdev.thingifier.application.command.ConnectExistingRelationshipCommand; +import uk.co.compendiumdev.thingifier.application.command.CreateAndConnectRelationshipCommand; +import uk.co.compendiumdev.thingifier.application.command.CreateThingCommand; +import uk.co.compendiumdev.thingifier.application.command.DeleteThingCommand; +import uk.co.compendiumdev.thingifier.application.command.DisconnectRelationshipCommand; +import uk.co.compendiumdev.thingifier.core.EntityRelModel; +import uk.co.compendiumdev.thingifier.core.domain.definitions.Cardinality; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; +import uk.co.compendiumdev.thingifier.core.domain.definitions.relationship.Optionality; +import uk.co.compendiumdev.thingifier.core.domain.definitions.relationship.RelationshipDefinition; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; +import uk.co.compendiumdev.thingifier.core.repository.ThingStore; + +public class ThingCommandServiceTest { + + @Test + public void createCommandCreatesThingAndConnectsRelationships() { + Thingifier thingifier = taskProjectThingifier(); + ThingStore store = storeFor(thingifier); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + EntityDefinition project = thingifier.getDefinitionNamed("project"); + EntityInstance projectInstance = + store.entities() + .create( + EntityInstanceDraft.forEntity(project) + .withField("title", "Project")); + + ThingCommandResult result = + new ThingCommandService(store) + .execute( + new CreateThingCommand( + EntityInstanceDraft.forEntity(task) + .withField("title", "Task"), + List.of( + new RelationshipConnection( + "task-of", projectInstance)), + true)); + + Assertions.assertTrue(result.isSuccessful()); + Assertions.assertEquals("Task", result.getInstance().getFieldValue("title").asString()); + Assertions.assertEquals( + projectInstance, + store.relationships().listRelated(result.getInstance(), "task-of").get(0)); + } + + @Test + public void amendCommandRollsBackFieldsAndRelationshipsOnValidationFailure() { + Thingifier thingifier = mandatoryTaskProjectThingifier(); + ThingStore store = storeFor(thingifier); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + EntityDefinition project = thingifier.getDefinitionNamed("project"); + EntityInstance taskInstance = + store.entities() + .create( + EntityInstanceDraft.forEntity(task) + .withField("title", "Original title")); + EntityInstance projectInstance = + store.entities() + .create( + EntityInstanceDraft.forEntity(project) + .withField("title", "Original project")); + store.relationships().connect(taskInstance, "task-of", projectInstance); + + ThingCommandResult result = + new ThingCommandService(store) + .execute( + new AmendThingCommand( + taskInstance, + EntityInstanceDraft.forEntity(task) + .withField("title", "Rejected title"), + true, + List.of())); + + EntityInstance restored = + store.entityQueries() + .findByQueryIdentifier(task, taskInstance.getPrimaryKeyValue()); + Assertions.assertTrue(result.isError()); + Assertions.assertEquals("Original title", restored.getFieldValue("title").asString()); + Assertions.assertEquals( + projectInstance, store.relationships().listRelated(restored, "task-of").get(0)); + } + + @Test + public void deleteCommandDeletesInstance() { + Thingifier thingifier = taskProjectThingifier(); + ThingStore store = storeFor(thingifier); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + EntityInstance taskInstance = + store.entities() + .create( + EntityInstanceDraft.forEntity(task) + .withField("title", "Delete me")); + + ThingCommandResult result = + new ThingCommandService(store).execute(new DeleteThingCommand(taskInstance)); + + Assertions.assertTrue(result.isSuccessful()); + Assertions.assertNull( + store.entityQueries() + .findByQueryIdentifier(task, taskInstance.getPrimaryKeyValue())); + } + + @Test + public void disconnectRelationshipCommandRemovesOnlyRelationship() { + Thingifier thingifier = taskProjectThingifier(); + ThingStore store = storeFor(thingifier); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + EntityDefinition project = thingifier.getDefinitionNamed("project"); + EntityInstance taskInstance = + store.entities() + .create(EntityInstanceDraft.forEntity(task).withField("title", "Task")); + EntityInstance projectInstance = + store.entities() + .create( + EntityInstanceDraft.forEntity(project) + .withField("title", "Project")); + store.relationships().connect(projectInstance, "tasks", taskInstance); + + ThingCommandResult result = + new ThingCommandService(store) + .execute( + new DisconnectRelationshipCommand( + projectInstance, taskInstance, "tasks")); + + Assertions.assertTrue(result.isSuccessful()); + Assertions.assertTrue( + store.relationships().listRelated(projectInstance, "tasks").isEmpty()); + Assertions.assertNotNull( + store.entityQueries() + .findByQueryIdentifier(task, taskInstance.getPrimaryKeyValue())); + } + + @Test + public void failedConnectExistingRelationshipDoesNotDeleteExistingChild() { + Thingifier thingifier = taskProjectThingifierWithMandatoryCategory(); + ThingStore store = storeFor(thingifier); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + EntityDefinition project = thingifier.getDefinitionNamed("project"); + EntityInstance taskInstance = + store.entities() + .create( + EntityInstanceDraft.forEntity(task) + .withField("title", "Existing task")); + EntityInstance projectInstance = + store.entities() + .create( + EntityInstanceDraft.forEntity(project) + .withField("title", "Project")); + + ThingCommandResult result = + new ThingCommandService(store) + .execute( + new ConnectExistingRelationshipCommand( + projectInstance, "tasks", taskInstance)); + + Assertions.assertTrue(result.isError()); + Assertions.assertNotNull( + store.entityQueries() + .findByQueryIdentifier(task, taskInstance.getPrimaryKeyValue())); + Assertions.assertTrue( + store.relationships().listRelated(projectInstance, "tasks").isEmpty()); + } + + @Test + public void failedCreateAndConnectRelationshipRollsBackCreatedChild() { + Thingifier thingifier = taskProjectThingifierWithMandatoryCategory(); + ThingStore store = storeFor(thingifier); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + EntityDefinition project = thingifier.getDefinitionNamed("project"); + EntityInstance projectInstance = + store.entities() + .create( + EntityInstanceDraft.forEntity(project) + .withField("title", "Project")); + int taskCount = store.entityQueries().count(task); + + ThingCommandResult result = + new ThingCommandService(store) + .execute( + new CreateAndConnectRelationshipCommand( + projectInstance, + "tasks", + EntityInstanceDraft.forEntity(task) + .withField("title", "Rolled back"), + List.of())); + + Assertions.assertTrue(result.isError()); + Assertions.assertTrue(result.rolledBackCreatedInstance()); + Assertions.assertEquals(taskCount, store.entityQueries().count(task)); + Assertions.assertTrue( + store.relationships().listRelated(projectInstance, "tasks").isEmpty()); + } + + private Thingifier taskProjectThingifier() { + Thingifier thingifier = new Thingifier(); + EntityDefinition task = thingifier.defineThing("task", "tasks"); + task.addAsPrimaryKeyField(Field.is("guid", FieldType.AUTO_GUID)); + task.addField(Field.is("title", FieldType.STRING)); + + EntityDefinition project = thingifier.defineThing("project", "projects"); + project.addAsPrimaryKeyField(Field.is("guid", FieldType.AUTO_GUID)); + project.addField(Field.is("title", FieldType.STRING)); + + thingifier + .defineRelationship(task, project, "task-of", Cardinality.ONE_TO_ONE()) + .whenReversed(Cardinality.ONE_TO_MANY(), "tasks"); + return thingifier; + } + + private Thingifier mandatoryTaskProjectThingifier() { + Thingifier thingifier = new Thingifier(); + EntityDefinition task = thingifier.defineThing("task", "tasks"); + task.addAsPrimaryKeyField(Field.is("guid", FieldType.AUTO_GUID)); + task.addField(Field.is("title", FieldType.STRING)); + + EntityDefinition project = thingifier.defineThing("project", "projects"); + project.addAsPrimaryKeyField(Field.is("guid", FieldType.AUTO_GUID)); + project.addField(Field.is("title", FieldType.STRING)); + + RelationshipDefinition relationship = + thingifier.defineRelationship(task, project, "task-of", Cardinality.ONE_TO_ONE()); + relationship.getFromRelationship().setOptionality(Optionality.MANDATORY_RELATIONSHIP); + relationship.whenReversed(Cardinality.ONE_TO_MANY(), "tasks"); + return thingifier; + } + + private Thingifier taskProjectThingifierWithMandatoryCategory() { + Thingifier thingifier = taskProjectThingifier(); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + EntityDefinition category = thingifier.defineThing("category", "categories"); + category.addAsPrimaryKeyField(Field.is("guid", FieldType.AUTO_GUID)); + category.addField(Field.is("title", FieldType.STRING)); + thingifier + .defineRelationship(task, category, "category", Cardinality.ONE_TO_ONE()) + .getFromRelationship() + .setOptionality(Optionality.MANDATORY_RELATIONSHIP); + return thingifier; + } + + private ThingStore storeFor(final Thingifier thingifier) { + return thingifier.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); + } +} From 74c57d193969e203699890f550de4b9696ef4965 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Mon, 13 Jul 2026 18:16:20 +0100 Subject: [PATCH 2/3] Move read handling to application queries --- .../core/query/QueryFiltersBooleanTest.java | 50 ++-- .../core/query/QueryFiltersFloatTest.java | 71 ++---- .../core/query/QueryFiltersIdTest.java | 22 +- .../core/query/QueryFiltersIntegerTest.java | 92 ++----- .../core/query/QueryFiltersStringTest.java | 71 ++---- .../core/query/RepositoryUrlQuery.java | 137 ----------- .../query/SortingViaQueryFiltersTest.java | 52 ++-- .../query/TodoManagerQueryEngineTest.java | 231 ++++-------------- .../repository/ThingStoreContractTest.java | 10 +- .../restapihandlers/RepositoryRouteQuery.java | 141 ----------- .../restapihandlers/RestApiGetHandler.java | 63 +---- .../ThingReadRequestMapper.java | 61 +++++ .../ThingReadRequestMapping.java | 35 +++ .../ThingReadResultApiMapper.java | 46 ++++ .../application/ThingQueryService.java | 39 +++ .../query/ReadCollectionQuery.java | 25 ++ .../application/query/ReadInstanceQuery.java | 34 +++ .../query/ReadRelationshipQuery.java | 41 ++++ .../application/query/ThingReadQuery.java | 24 ++ .../RelationshipBodyCommandParserTest.java | 21 +- .../RestApiGetHandlerTest.java | 163 ++++++++++++ .../ThingReadRequestMapperTest.java | 155 ++++++++++++ .../application/ThingQueryServiceTest.java | 210 ++++++++++++++++ 23 files changed, 1004 insertions(+), 790 deletions(-) delete mode 100644 ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/RepositoryUrlQuery.java delete mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RepositoryRouteQuery.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingReadRequestMapper.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingReadRequestMapping.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingReadResultApiMapper.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/ThingQueryService.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/query/ReadCollectionQuery.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/query/ReadInstanceQuery.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/query/ReadRelationshipQuery.java create mode 100644 thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/query/ThingReadQuery.java create mode 100644 thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiGetHandlerTest.java create mode 100644 thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingReadRequestMapperTest.java create mode 100644 thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/ThingQueryServiceTest.java diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersBooleanTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersBooleanTest.java index 22622000..c0e7c109 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersBooleanTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersBooleanTest.java @@ -62,12 +62,7 @@ public void canFilterBooleanMatchesTrue() { QueryFilterParams params = new QueryFilterParams(); params.put("truefalse", "true"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -80,12 +75,7 @@ public void canFilterBooleanMatchesFalse() { QueryFilterParams params = new QueryFilterParams(); params.put("truefalse", "false"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -97,12 +87,7 @@ public void canFilterBooleanMatchesNotFalse() { QueryFilterParams params = new QueryFilterParams(); params.put("truefalse", "!false"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -114,12 +99,7 @@ public void canFilterBooleanMatchesNotTrue() { QueryFilterParams params = new QueryFilterParams(); params.put("truefalse", "!true"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -131,12 +111,7 @@ public void canSortBooleanMatchesAsc() { QueryFilterParams params = new QueryFilterParams(); params.put("sortBy", "+truefalse"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -153,12 +128,7 @@ public void canSortBooleanMatchesDesc() { QueryFilterParams params = new QueryFilterParams(); params.put("sortBy", "-truefalse"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -169,4 +139,12 @@ public void canSortBooleanMatchesDesc() { Assertions.assertEquals(true, instances.get(2).getFieldValue("truefalse").asBoolean()); Assertions.assertEquals(false, instances.get(3).getFieldValue("truefalse").asBoolean()); } + + private RepositoryQuery queryThings(final QueryFilterParams params) { + return new RepositoryQuery( + erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), + RepositoryQuerySpec.collection( + erModel.getSchema().getEntityDefinitionNamed("thing"))) + .performQuery(params); + } } diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersFloatTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersFloatTest.java index 73738366..dce839c8 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersFloatTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersFloatTest.java @@ -53,12 +53,7 @@ public void canFilterFloatEquals() { QueryFilterParams params = new QueryFilterParams(); params.put("float", "1.1"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); Assertions.assertEquals(1, instances.size(), "expected 1 value"); @@ -72,12 +67,7 @@ public void canFilterFloatNotEquals() { params.put("float", "!1.1"); params.put("sortby", "+float"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); Assertions.assertEquals(3, instances.size(), "expected 3 value"); @@ -95,12 +85,7 @@ public void canFilterFloatCombinationOfConditions() { params.put("float", "!3.3"); // and not equal to 3 params.put("sortby", "+float"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); Assertions.assertEquals(2, instances.size(), "expected 2 value"); @@ -114,12 +99,7 @@ public void canFilterFloatGreaterThan() { params.put("float", ">1.1"); params.put("sortby", "+float"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -135,12 +115,7 @@ public void canFilterFloatLessThan() { params.put("float", "<2.0"); params.put("sortby", "+float"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -154,12 +129,7 @@ public void canFilterFloatLessThanNotMatching() { params.put("float", "<1.1"); params.put("sortby", "+float"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -172,12 +142,7 @@ public void canFilterFloatGreaterThanEquals() { params.put("float", ">=3.3"); params.put("sortby", "+float"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -192,12 +157,7 @@ public void canFilterFloatLessThanEquals() { params.put("float", "<=3.3"); params.put("sortby", "+float"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -213,12 +173,7 @@ public void canFilterFloatLessThanEqualsSortDesc() { params.put("float", "<=3.3"); params.put("sortby", "-float"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -227,4 +182,12 @@ public void canFilterFloatLessThanEqualsSortDesc() { Assertions.assertEquals(2.2F, instances.get(1).getFieldValue("float").asFloat()); Assertions.assertEquals(1.1F, instances.get(2).getFieldValue("float").asFloat()); } + + private RepositoryQuery queryThings(final QueryFilterParams params) { + return new RepositoryQuery( + erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), + RepositoryQuerySpec.collection( + erModel.getSchema().getEntityDefinitionNamed("thing"))) + .performQuery(params); + } } diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersIdTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersIdTest.java index 46126fa8..14c16540 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersIdTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersIdTest.java @@ -56,12 +56,7 @@ public void canFilterAndSortIdAsc() { params.put("id", ">=3"); params.put("sortBy", "+id"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -77,12 +72,7 @@ public void canFilterAndSortIdDesc() { params.put("id", "<3"); params.put("sortBy", "-id"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -90,4 +80,12 @@ public void canFilterAndSortIdDesc() { Assertions.assertEquals(2, instances.get(0).getFieldValue("fakeid").asInteger()); Assertions.assertEquals(1, instances.get(1).getFieldValue("fakeid").asInteger()); } + + private RepositoryQuery queryThings(final QueryFilterParams params) { + return new RepositoryQuery( + erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), + RepositoryQuerySpec.collection( + erModel.getSchema().getEntityDefinitionNamed("thing"))) + .performQuery(params); + } } diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersIntegerTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersIntegerTest.java index dbcfc633..f066aabc 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersIntegerTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersIntegerTest.java @@ -58,12 +58,7 @@ public void canFilterIntegerEquals() { QueryFilterParams params = new QueryFilterParams(); params.put("int", "1"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); Assertions.assertEquals(1, instances.size(), "expected 1 value"); @@ -77,12 +72,7 @@ public void canFilterIntegerNotEquals() { params.put("int", "!1"); params.put("sortby", "+int"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); Assertions.assertEquals(3, instances.size(), "expected 3 value"); @@ -99,12 +89,7 @@ public void canFilterIntegerCombinationOfConditions() { params.put("int", "!3"); // and not equal to 3 params.put("sortby", "+int"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); Assertions.assertEquals(2, instances.size(), "expected 2 value"); @@ -118,12 +103,7 @@ public void canFilterIntegerGreaterThan() { params.put("int", ">1"); params.put("sortby", "+int"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -139,12 +119,7 @@ public void canFilterIntegerLessThan() { params.put("int", "<2"); params.put("sortby", "+int"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -158,12 +133,7 @@ public void canFilterIntegerLessThanNotMatching() { params.put("int", "<1"); params.put("sortby", "+int"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -176,12 +146,7 @@ public void canFilterIntegerGreaterThanEquals() { params.put("int", ">=3"); params.put("sortby", "+int"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -196,12 +161,7 @@ public void canFilterIntegerLessThanEquals() { params.put("int", "<=3"); params.put("sortby", "+int"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -217,12 +177,7 @@ public void canFilterIntegerLessThanSortedDesc() { params.put("int", "<3"); params.put("sortby", "-int"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -236,12 +191,7 @@ public void canIntegerSortedDesc() { QueryFilterParams params = new QueryFilterParams(); params.put("sortby", "-int"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -257,12 +207,7 @@ public void canIntegerSortedAsc() { QueryFilterParams params = new QueryFilterParams(); params.put("sortby", "+int"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -279,12 +224,7 @@ public void canRegexFilterInteger() { params.put("int", "~=[1,2]"); params.put("sortby", "+int"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -292,4 +232,12 @@ public void canRegexFilterInteger() { Assertions.assertEquals(1, instances.get(0).getFieldValue("int").asInteger()); Assertions.assertEquals(2, instances.get(1).getFieldValue("int").asInteger()); } + + private RepositoryQuery queryThings(final QueryFilterParams params) { + return new RepositoryQuery( + erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), + RepositoryQuerySpec.collection( + erModel.getSchema().getEntityDefinitionNamed("thing"))) + .performQuery(params); + } } diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersStringTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersStringTest.java index efb29b66..ba1283dd 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersStringTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/QueryFiltersStringTest.java @@ -53,12 +53,7 @@ public void canSortStringAsc() { QueryFilterParams params = new QueryFilterParams(); params.put("sortBy", "+string"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -75,12 +70,7 @@ public void canSortStringDesc() { QueryFilterParams params = new QueryFilterParams(); params.put("sortBy", "-string"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -98,12 +88,7 @@ public void canFilterStringEquals() { QueryFilterParams params = new QueryFilterParams(); params.put("string", "=one"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -118,12 +103,7 @@ public void canFilterAndSortStringUsingRegexAsc() { params.put("string", "~=.*o.*"); params.put("sortBy", "+string"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -141,12 +121,7 @@ public void canFilterAndSortStringUsingRegexDesc() { params.put("string", "~=.*o.*"); params.put("sortBy", "-string"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -164,12 +139,7 @@ public void canFilterAndSortStringUsingWildcardAsc() { params.put("string", "*=*e"); params.put("sortBy", "+string"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -186,12 +156,7 @@ public void canFilterAndSortStringUsingWildcardAscForT() { params.put("string", "*=t*"); params.put("sortBy", "+string"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -208,12 +173,7 @@ public void canFilterAndSortStringUsingWildcardAscForTBit() { params.put("string", "*=t?*"); params.put("sortBy", "+string"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -230,12 +190,7 @@ public void canFilterAndSortStringUsingWildcardAscForTBitO() { params.put("string", "*=t?o"); params.put("sortBy", "+string"); - RepositoryUrlQuery queryResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery queryResults = queryThings(params); Assertions.assertTrue(queryResults.isResultACollection(), "result should be a collection"); List instances = queryResults.getListEntityInstances(); @@ -243,4 +198,12 @@ public void canFilterAndSortStringUsingWildcardAscForTBitO() { Assertions.assertEquals("two", instances.get(0).getFieldValue("string").asString()); } + + private RepositoryQuery queryThings(final QueryFilterParams params) { + return new RepositoryQuery( + erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), + RepositoryQuerySpec.collection( + erModel.getSchema().getEntityDefinitionNamed("thing"))) + .performQuery(params); + } } diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/RepositoryUrlQuery.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/RepositoryUrlQuery.java deleted file mode 100644 index 603ecbd2..00000000 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/RepositoryUrlQuery.java +++ /dev/null @@ -1,137 +0,0 @@ -package uk.co.compendiumdev.thingifier.core.query; - -import java.util.ArrayList; -import java.util.List; -import uk.co.compendiumdev.thingifier.core.domain.definitions.ERSchema; -import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; -import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; -import uk.co.compendiumdev.thingifier.core.repository.ThingStore; - -public class RepositoryUrlQuery implements RepositoryQueryResult { - - private final ERSchema schema; - private final ThingStore store; - private final String route; - private RepositoryQuery queryResults; - - public RepositoryUrlQuery(final ERSchema schema, final ThingStore store, final String route) { - this.schema = schema; - this.store = store; - this.route = route; - } - - public static boolean canHandle(final ERSchema schema, final String route) { - String[] terms = termsFrom(route); - if (terms.length == 0 || terms.length > 3) { - return false; - } - - EntityDefinition entity = entityForTerm(schema, terms[0]); - if (entity == null) { - return false; - } - - if (terms.length == 1) { - return true; - } - - String identifierCandidate = terms[1]; - if (schema.hasRelationshipNamed(identifierCandidate) - || entityForTerm(schema, identifierCandidate) != null) { - return false; - } - - if (terms.length == 2) { - return true; - } - - return entity.related().hasRelationship(terms[2]); - } - - public RepositoryUrlQuery performQuery() { - return performQuery(new QueryFilterParams()); - } - - public RepositoryUrlQuery performQuery(final QueryFilterParams queryParams) { - queryResults = new RepositoryQuery(store, specFromRoute()).performQuery(queryParams); - return this; - } - - @Override - public boolean wasQueryIntendedToMatchAnInstance() { - return queryResults().wasQueryIntendedToMatchAnInstance(); - } - - @Override - public boolean isResultACollection() { - return queryResults().isResultACollection(); - } - - @Override - public List getListEntityInstances() { - return new ArrayList<>(queryResults().getListEntityInstances()); - } - - @Override - public EntityInstance getLastInstance() { - return queryResults().getLastInstance(); - } - - @Override - public boolean lastMatchWasInstance() { - return queryResults().lastMatchWasInstance(); - } - - @Override - public boolean lastMatchWasNothing() { - return queryResults().lastMatchWasNothing(); - } - - @Override - public EntityDefinition resultContainsDefn() { - return queryResults().resultContainsDefn(); - } - - private RepositoryQuery queryResults() { - if (queryResults == null) { - throw new IllegalStateException("Query has not been performed"); - } - return queryResults; - } - - private RepositoryQuerySpec specFromRoute() { - String[] terms = termsFrom(route); - EntityDefinition entity = entityForTerm(schema, terms[0]); - if (terms.length == 1) { - return RepositoryQuerySpec.collection(entity); - } - if (terms.length == 2) { - return RepositoryQuerySpec.instance(entity, terms[1]); - } - return RepositoryQuerySpec.relationship(entity, terms[1], terms[2]); - } - - private static EntityDefinition entityForTerm(final ERSchema schema, final String term) { - if (schema.hasEntityNamed(term)) { - return schema.getEntityDefinitionNamed(term); - } - if (schema.hasEntityWithPluralNamed(term)) { - return schema.getEntityDefinitionWithPluralNamed(term); - } - return null; - } - - private static String[] termsFrom(final String route) { - String normalized = route == null ? "" : route.trim(); - while (normalized.startsWith("/")) { - normalized = normalized.substring(1); - } - while (normalized.endsWith("/")) { - normalized = normalized.substring(0, normalized.length() - 1); - } - if (normalized.isEmpty()) { - return new String[0]; - } - return normalized.split("/"); - } -} diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/SortingViaQueryFiltersTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/SortingViaQueryFiltersTest.java index c3064f11..04e2c935 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/SortingViaQueryFiltersTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/SortingViaQueryFiltersTest.java @@ -51,12 +51,7 @@ public void canSortIntViaAQuery() { QueryFilterParams params = new QueryFilterParams(); params.put("sortBy", "-int"); - RepositoryUrlQuery ascSortedResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery ascSortedResults = queryCollection(erModel, thing, params); Assertions.assertTrue( ascSortedResults.isResultACollection(), "result should be a collection"); @@ -70,12 +65,7 @@ public void canSortIntViaAQuery() { params = new QueryFilterParams(); params.put("sortBy", "+int"); - RepositoryUrlQuery descSortedResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery descSortedResults = queryCollection(erModel, thing, params); final List descInstances = descSortedResults.getListEntityInstances(); Assertions.assertEquals(3, descInstances.size(), "expected 3 values"); @@ -87,12 +77,7 @@ public void canSortIntViaAQuery() { params = new QueryFilterParams(); params.put("sortBy", "int"); - RepositoryUrlQuery defaultSortedResults = - new RepositoryUrlQuery( - erModel.getSchema(), - erModel.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery defaultSortedResults = queryCollection(erModel, thing, params); final List defaultSortedInstances = defaultSortedResults.getListEntityInstances(); @@ -128,12 +113,7 @@ public void canSortViaAQuery() { QueryFilterParams params = new QueryFilterParams(); params.put("sortBy", "-truefalse"); - RepositoryUrlQuery ascSortedResults = - new RepositoryUrlQuery( - aThingifier.getSchema(), - aThingifier.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery ascSortedResults = queryCollection(aThingifier, thing, params); Assertions.assertTrue( ascSortedResults.isResultACollection(), "result should be a collection"); @@ -147,12 +127,7 @@ public void canSortViaAQuery() { params = new QueryFilterParams(); params.put("sortBy", "+truefalse"); - RepositoryUrlQuery descSortedResults = - new RepositoryUrlQuery( - aThingifier.getSchema(), - aThingifier.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery descSortedResults = queryCollection(aThingifier, thing, params); final List descInstances = descSortedResults.getListEntityInstances(); Assertions.assertEquals(2, descInstances.size(), "expected 2 values"); @@ -163,12 +138,7 @@ public void canSortViaAQuery() { params = new QueryFilterParams(); params.put("sortBy", "truefalse"); - RepositoryUrlQuery defaultSortedResults = - new RepositoryUrlQuery( - aThingifier.getSchema(), - aThingifier.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "things") - .performQuery(params); + RepositoryQuery defaultSortedResults = queryCollection(aThingifier, thing, params); final List defaultSortedInstances = defaultSortedResults.getListEntityInstances(); @@ -176,4 +146,14 @@ public void canSortViaAQuery() { Assertions.assertEquals(falseThing, defaultSortedInstances.get(0)); Assertions.assertEquals(trueThing, defaultSortedInstances.get(1)); } + + private RepositoryQuery queryCollection( + final EntityRelModel model, + final EntityDefinition entity, + final QueryFilterParams params) { + return new RepositoryQuery( + model.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), + RepositoryQuerySpec.collection(entity)) + .performQuery(params); + } } diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/TodoManagerQueryEngineTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/TodoManagerQueryEngineTest.java index 6caa71e4..ea5d8812 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/TodoManagerQueryEngineTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/TodoManagerQueryEngineTest.java @@ -15,21 +15,21 @@ import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; import uk.co.compendiumdev.thingifier.core.repository.ThingStore; -/** Repository-backed URL query coverage for API-style entity reads. */ +/** Repository-backed query coverage for entity and relationship reads. */ public class TodoManagerQueryEngineTest { private EntityRelModel todoManager; - EntityInstance paperwork; - EntityInstance filework; - EntityInstance officeCategory; + private EntityDefinition todo; private EntityDefinition project; + private EntityDefinition category; + private EntityInstance paperwork; + private EntityInstance filework; + private EntityInstance officeCategory; - // todo: simplify setup and move this test into core @BeforeEach public void createDefinitions() { - todoManager = new EntityRelModel(); - final EntityDefinition todo = + todo = todoManager .createEntityDefinition("todo", "todos") .addFields(Field.is("title", STRING)); @@ -41,7 +41,7 @@ public void createDefinitions() { .addFields(Field.is("title", STRING)); project.addAsPrimaryKeyField(Field.is("guid", FieldType.AUTO_GUID)); - final EntityDefinition category = + category = todoManager .createEntityDefinition("category", "categories") .addFields(Field.is("title", STRING)); @@ -60,74 +60,37 @@ public void createDefinitions() { todoManager.createRelationshipDefinition( todo, category, "categories", Cardinality.ONE_TO_MANY()); - ThingStore repository = todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); - paperwork = - repository - .entities() + store().entities() .create( EntityInstanceDraft.forEntity(todo) .withField("title", "scan paperwork")); - // System.out.println(new Gson().toJson(JsonThing.asJsonObject(paperwork))); - filework = - repository - .entities() + store().entities() .create( EntityInstanceDraft.forEntity(todo) .withField("title", "file paperwork")); officeCategory = - repository - .entities() + store().entities() .create( EntityInstanceDraft.forEntity(category) .withField("title", "Office")); - repository - .entities() + store().entities() .create(EntityInstanceDraft.forEntity(category).withField("title", "Home")); - repository.relationships().connect(paperwork, "categories", officeCategory); - } - - /* - API Prototype backend query engine - */ - - @Test - public void canGetListOfEntityInstancesViaName() { - // to do - - final RepositoryUrlQuery query = - new RepositoryUrlQuery( - todoManager.getSchema(), - todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "todo"); - - List queryResults = query.performQuery().getListEntityInstances(); - - Assertions.assertTrue(query.isResultACollection()); - - Assertions.assertEquals(2, queryResults.size()); - Assertions.assertTrue(queryResults.contains(paperwork)); - Assertions.assertTrue(queryResults.contains(filework)); + store().relationships().connect(paperwork, "categories", officeCategory); } @Test - public void canGetListOfEntityInstancesViaPluralName() { - // todos - final RepositoryUrlQuery query = - new RepositoryUrlQuery( - todoManager.getSchema(), - todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "todos"); + public void canGetListOfEntityInstances() { + final RepositoryQuery query = query(RepositoryQuerySpec.collection(todo)); - List queryResults = query.performQuery().getListEntityInstances(); + List queryResults = query.getListEntityInstances(); Assertions.assertTrue(query.isResultACollection()); - Assertions.assertEquals(2, queryResults.size()); Assertions.assertTrue(queryResults.contains(paperwork)); Assertions.assertTrue(queryResults.contains(filework)); @@ -135,46 +98,12 @@ public void canGetListOfEntityInstancesViaPluralName() { @Test public void canGetSpecificEntityInstanceUsingGUID() { + final RepositoryQuery query = + query(RepositoryQuerySpec.instance(todo, paperwork.getPrimaryKeyValue())); - List queryResults; - - // to do/_GUID_ - - final RepositoryUrlQuery query = - new RepositoryUrlQuery( - todoManager.getSchema(), - todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "todo/" + paperwork.getPrimaryKeyValue()); - - queryResults = query.performQuery().getListEntityInstances(); - - Assertions.assertFalse( - query.isResultACollection()); // it can still be returned as a collection but is - // valid to return as a single - - Assertions.assertEquals(1, queryResults.size()); - Assertions.assertTrue(queryResults.contains(paperwork)); - Assertions.assertFalse(queryResults.contains(filework)); - } - - @Test - public void canGetSpecificEntityInstanceUsingGUIDOnPlural() { - - List queryResults; - - // to do/_GUID_ - - final RepositoryUrlQuery query = - new RepositoryUrlQuery( - todoManager.getSchema(), - todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "todos/" + paperwork.getPrimaryKeyValue()); - - queryResults = query.performQuery().getListEntityInstances(); + List queryResults = query.getListEntityInstances(); - Assertions.assertTrue(query.wasQueryIntendedToMatchAnInstance()); Assertions.assertFalse(query.isResultACollection()); - Assertions.assertEquals(1, queryResults.size()); Assertions.assertTrue(queryResults.contains(paperwork)); Assertions.assertFalse(queryResults.contains(filework)); @@ -182,121 +111,53 @@ public void canGetSpecificEntityInstanceUsingGUIDOnPlural() { @Test public void cannotGetGuidThatDoesNotExist() { + final RepositoryQuery query = + query(RepositoryQuerySpec.instance(todo, paperwork.getPrimaryKeyValue() + "bob")); - List queryResults; - - // to do/_GUID_ - - final RepositoryUrlQuery query = - new RepositoryUrlQuery( - todoManager.getSchema(), - todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "todo/" + paperwork.getPrimaryKeyValue() + "bob"); - - queryResults = query.performQuery().getListEntityInstances(); - - // even though it does not match anything I should know what type of thing this empty - // collection is - Assertions.assertTrue(query.wasQueryIntendedToMatchAnInstance()); - Assertions.assertTrue(query.lastMatchWasNothing()); - Assertions.assertEquals( - todoManager.getSchema().getEntityDefinitionNamed("todo"), - query.resultContainsDefn()); - - Assertions.assertEquals(0, queryResults.size()); - } - - @Test - public void cannotGetGuidThatDoesNotExistWithPlural() { - - List queryResults; - - // to do/_GUID_ + List queryResults = query.getListEntityInstances(); - final RepositoryUrlQuery query = - new RepositoryUrlQuery( - todoManager.getSchema(), - todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - "todos/" + paperwork.getPrimaryKeyValue() + "bob"); - - queryResults = query.performQuery().getListEntityInstances(); - - // even though it does not match anything I should know what type of thing this empty - // collection is Assertions.assertTrue(query.wasQueryIntendedToMatchAnInstance()); Assertions.assertTrue(query.lastMatchWasNothing()); - Assertions.assertEquals( - todoManager.getSchema().getEntityDefinitionNamed("todo"), - query.resultContainsDefn()); - + Assertions.assertEquals(todo, query.resultContainsDefn()); Assertions.assertEquals(0, queryResults.size()); } @Test - public void connectionTesting() { - - // stuff we could get for free from backend - - List queryResults; - - // - ThingStore repository = todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); + public void canQueryRelationships() { EntityInstance officeWork = - repository - .entities() + store().entities() .create( EntityInstanceDraft.forEntity(project) .withField("title", "Office Work")); - repository.relationships().connect(officeWork, "tasks", paperwork); - repository.relationships().connect(officeWork, "tasks", filework); + store().relationships().connect(officeWork, "tasks", paperwork); + store().relationships().connect(officeWork, "tasks", filework); - // match on relationships - // project/_GUID_/tasks - - queryResults = - new RepositoryUrlQuery( - todoManager.getSchema(), - todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - String.format("project/%s/tasks", officeWork.getPrimaryKeyValue())) - .performQuery() + List tasksForProject = + query( + RepositoryQuerySpec.relationship( + project, officeWork.getPrimaryKeyValue(), "tasks")) .getListEntityInstances(); - Assertions.assertEquals(2, queryResults.size()); - Assertions.assertTrue(queryResults.contains(paperwork)); - Assertions.assertTrue(queryResults.contains(filework)); + Assertions.assertEquals(2, tasksForProject.size()); + Assertions.assertTrue(tasksForProject.contains(paperwork)); + Assertions.assertTrue(tasksForProject.contains(filework)); - // should be able to get projects for a task - - queryResults = - new RepositoryUrlQuery( - todoManager.getSchema(), - todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME), - String.format("todo/%s/task-of", paperwork.getPrimaryKeyValue())) - .performQuery() + List projectsForTask = + query( + RepositoryQuerySpec.relationship( + todo, paperwork.getPrimaryKeyValue(), "task-of")) .getListEntityInstances(); - Assertions.assertEquals(1, queryResults.size()); - Assertions.assertTrue(queryResults.contains(officeWork)); - // Repository URL query handles explicit relationship names, not legacy - // entity-type traversal or multi-hop guesses. - Assertions.assertFalse( - RepositoryUrlQuery.canHandle( - todoManager.getSchema(), - String.format("project/%s/todo", officeWork.getPrimaryKeyValue()))); - - Assertions.assertFalse( - RepositoryUrlQuery.canHandle( - todoManager.getSchema(), - String.format( - "project/%s/todo/category", officeWork.getPrimaryKeyValue()))); + Assertions.assertEquals(1, projectsForTask.size()); + Assertions.assertTrue(projectsForTask.contains(officeWork)); + } - // invalid query should match nothing there is no entity called task - // project/_GUID_/task + private RepositoryQuery query(final RepositoryQuerySpec spec) { + return new RepositoryQuery(store(), spec).performQuery(); + } - Assertions.assertFalse( - RepositoryUrlQuery.canHandle( - todoManager.getSchema(), - String.format("project/%s/task", officeWork.getPrimaryKeyValue()))); + private ThingStore store() { + return todoManager.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); } } diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreContractTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreContractTest.java index e2ce94d6..d9b69a64 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreContractTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/repository/ThingStoreContractTest.java @@ -16,7 +16,8 @@ import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams; -import uk.co.compendiumdev.thingifier.core.query.RepositoryUrlQuery; +import uk.co.compendiumdev.thingifier.core.query.RepositoryQuery; +import uk.co.compendiumdev.thingifier.core.query.RepositoryQuerySpec; import uk.co.compendiumdev.thingifier.core.reporting.ERModelReport; import uk.co.compendiumdev.thingifier.core.reporting.RepositoryJsonExporter; import uk.co.compendiumdev.thingifier.core.repository.inmemory.InMemoryThingStore; @@ -331,8 +332,11 @@ public void sqliteRelationshipReadsWorkAfterReopen() { Assertions.assertEquals( 2, reopened.relationships().listRelated(project, "tasks").size()); - RepositoryUrlQuery query = - new RepositoryUrlQuery(schema, reopened, "project/1/tasks") + RepositoryQuery query = + new RepositoryQuery( + reopened, + RepositoryQuerySpec.relationship( + projectDefinition, "1", "tasks")) .performQuery(params); Assertions.assertTrue(query.isResultACollection()); diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RepositoryRouteQuery.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RepositoryRouteQuery.java deleted file mode 100644 index d19d6cea..00000000 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RepositoryRouteQuery.java +++ /dev/null @@ -1,141 +0,0 @@ -package uk.co.compendiumdev.thingifier.api.restapihandlers; - -import java.util.ArrayList; -import java.util.List; -import uk.co.compendiumdev.thingifier.core.domain.definitions.ERSchema; -import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; -import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; -import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams; -import uk.co.compendiumdev.thingifier.core.query.RepositoryQuery; -import uk.co.compendiumdev.thingifier.core.query.RepositoryQueryResult; -import uk.co.compendiumdev.thingifier.core.query.RepositoryQuerySpec; -import uk.co.compendiumdev.thingifier.core.repository.ThingStore; - -public class RepositoryRouteQuery implements RepositoryQueryResult { - - private final ERSchema schema; - private final ThingStore store; - private final String route; - private RepositoryQuery queryResults; - - public RepositoryRouteQuery(final ERSchema schema, final ThingStore store, final String route) { - this.schema = schema; - this.store = store; - this.route = route; - } - - public static boolean canHandle(final ERSchema schema, final String route) { - String[] terms = termsFrom(route); - if (terms.length == 0 || terms.length > 3) { - return false; - } - - EntityDefinition entity = entityForTerm(schema, terms[0]); - if (entity == null) { - return false; - } - - if (terms.length == 1) { - return true; - } - - String identifierCandidate = terms[1]; - if (schema.hasRelationshipNamed(identifierCandidate) - || entityForTerm(schema, identifierCandidate) != null) { - return false; - } - - if (terms.length == 2) { - return true; - } - - return entity.related().hasRelationship(terms[2]); - } - - public RepositoryRouteQuery performQuery() { - return performQuery(new QueryFilterParams()); - } - - public RepositoryRouteQuery performQuery(final QueryFilterParams queryParams) { - queryResults = new RepositoryQuery(store, specFromRoute()).performQuery(queryParams); - return this; - } - - @Override - public boolean wasQueryIntendedToMatchAnInstance() { - return queryResults().wasQueryIntendedToMatchAnInstance(); - } - - @Override - public boolean isResultACollection() { - return queryResults().isResultACollection(); - } - - @Override - public List getListEntityInstances() { - return new ArrayList<>(queryResults().getListEntityInstances()); - } - - @Override - public EntityInstance getLastInstance() { - return queryResults().getLastInstance(); - } - - @Override - public boolean lastMatchWasInstance() { - return queryResults().lastMatchWasInstance(); - } - - @Override - public boolean lastMatchWasNothing() { - return queryResults().lastMatchWasNothing(); - } - - @Override - public EntityDefinition resultContainsDefn() { - return queryResults().resultContainsDefn(); - } - - private RepositoryQuery queryResults() { - if (queryResults == null) { - throw new IllegalStateException("Query has not been performed"); - } - return queryResults; - } - - private RepositoryQuerySpec specFromRoute() { - String[] terms = termsFrom(route); - EntityDefinition entity = entityForTerm(schema, terms[0]); - if (terms.length == 1) { - return RepositoryQuerySpec.collection(entity); - } - if (terms.length == 2) { - return RepositoryQuerySpec.instance(entity, terms[1]); - } - return RepositoryQuerySpec.relationship(entity, terms[1], terms[2]); - } - - private static EntityDefinition entityForTerm(final ERSchema schema, final String term) { - if (schema.hasEntityNamed(term)) { - return schema.getEntityDefinitionNamed(term); - } - if (schema.hasEntityWithPluralNamed(term)) { - return schema.getEntityDefinitionWithPluralNamed(term); - } - return null; - } - - private static String[] termsFrom(final String route) { - String normalized = route == null ? "" : route.trim(); - while (normalized.startsWith("/")) { - normalized = normalized.substring(1); - } - while (normalized.endsWith("/")) { - normalized = normalized.substring(0, normalized.length() - 1); - } - if (normalized.isEmpty()) { - return new String[0]; - } - return normalized.split("/"); - } -} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiGetHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiGetHandler.java index 7bc9b4be..9ab408df 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiGetHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiGetHandler.java @@ -1,18 +1,19 @@ package uk.co.compendiumdev.thingifier.api.restapihandlers; -import java.util.List; import uk.co.compendiumdev.thingifier.Thingifier; import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; import uk.co.compendiumdev.thingifier.api.response.ApiResponse; -import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; +import uk.co.compendiumdev.thingifier.application.ThingQueryService; import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams; import uk.co.compendiumdev.thingifier.core.query.RepositoryQueryResult; public class RestApiGetHandler { private final Thingifier thingifier; + private final ThingQueryService queryService; public RestApiGetHandler(final Thingifier aThingifier) { this.thingifier = aThingifier; + this.queryService = new ThingQueryService(); } public ApiResponse handle( @@ -37,59 +38,15 @@ public ApiResponse handle( QueryFilterParams effectiveQueryParams = allowFiltering ? queryParams : new QueryFilterParams(); - if (RepositoryRouteQuery.canHandle(thingifier.getERmodel().getSchema(), url)) { - queryResults = - new RepositoryRouteQuery( - thingifier.getERmodel().getSchema(), - thingifier.getStore(instanceDatabaseName), - url) - .performQuery(effectiveQueryParams); - } else { - return ApiResponse.error404(String.format("Could not find an instance with %s", url)); + ThingReadRequestMapping mapping = + new ThingReadRequestMapper(thingifier).map(url, effectiveQueryParams); + if (mapping.isError()) { + return mapping.getErrorResponse(); } - // TODO: we should support pagination through query params - // TODO: api config should also support defining sorting for specific end points - List queryItems = queryResults.getListEntityInstances(); + queryResults = + queryService.execute(mapping.getQuery(), thingifier.getStore(instanceDatabaseName)); - // return a 404 if it doesn't match anything - if (queryResults.lastMatchWasNothing() - || (queryResults.lastMatchWasInstance() && queryItems.isEmpty())) { - // if query list was empty then return a 404 - return ApiResponse.error404(String.format("Could not find an instance with %s", url)); - } - - if (queryResults.lastMatchWasInstance()) { - - boolean asCollection = false; - // if(queryResults.wasQueryIntendedToMatchAnInstance() && - // !thingifier.apiConfig().willReturnSingleGetItemsAsCollection()){ - // asCollection = false; - // } - - if (queryResults.wasQueryIntendedToMatchAnInstance() - && thingifier.apiConfig().willReturnSingleGetItemsAsCollection()) { - asCollection = true; - } - - if (queryResults.isResultACollection() - && !queryResults.wasQueryIntendedToMatchAnInstance()) { - asCollection = true; - } - - if (asCollection) { - // if we asked for /projects then we should always return a collection - return ApiResponse.success() - .returnInstanceCollection(queryResults.getListEntityInstances()); - } else { - return ApiResponse.success().returnSingleInstance(queryResults.getLastInstance()); - } - - } else { - - return ApiResponse.success() - .returnInstanceCollection(queryItems) - .resultContainsType(queryResults.resultContainsDefn()); - } + return new ThingReadResultApiMapper(thingifier.apiConfig()).map(url, queryResults); } } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingReadRequestMapper.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingReadRequestMapper.java new file mode 100644 index 00000000..3ca27be2 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingReadRequestMapper.java @@ -0,0 +1,61 @@ +package uk.co.compendiumdev.thingifier.api.restapihandlers; + +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.application.query.ReadCollectionQuery; +import uk.co.compendiumdev.thingifier.application.query.ReadInstanceQuery; +import uk.co.compendiumdev.thingifier.application.query.ReadRelationshipQuery; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams; + +public final class ThingReadRequestMapper { + + private final Thingifier thingifier; + + public ThingReadRequestMapper(final Thingifier thingifier) { + this.thingifier = thingifier; + } + + public ThingReadRequestMapping map(final String url, final QueryFilterParams queryParams) { + String[] terms = EntityUrlMatcher.parts(url); + if (terms.length == 0 || terms.length > 3) { + return notFound(url); + } + + EntityDefinition entity = entityForTerm(terms[0]); + if (entity == null) { + return notFound(url); + } + + if (terms.length == 1) { + return ThingReadRequestMapping.query(new ReadCollectionQuery(entity, queryParams)); + } + + String identifierCandidate = terms[1]; + if (thingifier.getERmodel().getSchema().hasRelationshipNamed(identifierCandidate) + || entityForTerm(identifierCandidate) != null) { + return notFound(url); + } + + if (terms.length == 2) { + return ThingReadRequestMapping.query( + new ReadInstanceQuery(entity, identifierCandidate, queryParams)); + } + + if (!entity.related().hasRelationship(terms[2])) { + return notFound(url); + } + + return ThingReadRequestMapping.query( + new ReadRelationshipQuery(entity, identifierCandidate, terms[2], queryParams)); + } + + private ThingReadRequestMapping notFound(final String url) { + return ThingReadRequestMapping.error( + ApiResponse.error404(String.format("Could not find an instance with %s", url))); + } + + private EntityDefinition entityForTerm(final String term) { + return thingifier.getERmodel().getSchema().getDefinitionWithSingularOrPluralNamed(term); + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingReadRequestMapping.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingReadRequestMapping.java new file mode 100644 index 00000000..ee804cdb --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingReadRequestMapping.java @@ -0,0 +1,35 @@ +package uk.co.compendiumdev.thingifier.api.restapihandlers; + +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.application.query.ThingReadQuery; + +public final class ThingReadRequestMapping { + + private final ThingReadQuery query; + private final ApiResponse errorResponse; + + private ThingReadRequestMapping(final ThingReadQuery query, final ApiResponse errorResponse) { + this.query = query; + this.errorResponse = errorResponse; + } + + public static ThingReadRequestMapping query(final ThingReadQuery query) { + return new ThingReadRequestMapping(query, null); + } + + public static ThingReadRequestMapping error(final ApiResponse errorResponse) { + return new ThingReadRequestMapping(null, errorResponse); + } + + public boolean isError() { + return errorResponse != null; + } + + public ThingReadQuery getQuery() { + return query; + } + + public ApiResponse getErrorResponse() { + return errorResponse; + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingReadResultApiMapper.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingReadResultApiMapper.java new file mode 100644 index 00000000..bfdcf0cf --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingReadResultApiMapper.java @@ -0,0 +1,46 @@ +package uk.co.compendiumdev.thingifier.api.restapihandlers; + +import java.util.List; +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfig; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; +import uk.co.compendiumdev.thingifier.core.query.RepositoryQueryResult; + +public final class ThingReadResultApiMapper { + + private final ThingifierApiConfig apiConfig; + + public ThingReadResultApiMapper(final ThingifierApiConfig apiConfig) { + this.apiConfig = apiConfig; + } + + public ApiResponse map(final String url, final RepositoryQueryResult queryResults) { + List queryItems = queryResults.getListEntityInstances(); + + if (queryResults.lastMatchWasNothing() + || (queryResults.lastMatchWasInstance() && queryItems.isEmpty())) { + return ApiResponse.error404(String.format("Could not find an instance with %s", url)); + } + + if (queryResults.lastMatchWasInstance()) { + if (shouldReturnInstanceAsCollection(queryResults)) { + return ApiResponse.success().returnInstanceCollection(queryItems); + } + return ApiResponse.success().returnSingleInstance(queryResults.getLastInstance()); + } + + return ApiResponse.success() + .returnInstanceCollection(queryItems) + .resultContainsType(queryResults.resultContainsDefn()); + } + + private boolean shouldReturnInstanceAsCollection(final RepositoryQueryResult queryResults) { + if (queryResults.wasQueryIntendedToMatchAnInstance() + && apiConfig.willReturnSingleGetItemsAsCollection()) { + return true; + } + + return queryResults.isResultACollection() + && !queryResults.wasQueryIntendedToMatchAnInstance(); + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/ThingQueryService.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/ThingQueryService.java new file mode 100644 index 00000000..55dd333e --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/ThingQueryService.java @@ -0,0 +1,39 @@ +package uk.co.compendiumdev.thingifier.application; + +import uk.co.compendiumdev.thingifier.application.query.ReadCollectionQuery; +import uk.co.compendiumdev.thingifier.application.query.ReadInstanceQuery; +import uk.co.compendiumdev.thingifier.application.query.ReadRelationshipQuery; +import uk.co.compendiumdev.thingifier.application.query.ThingReadQuery; +import uk.co.compendiumdev.thingifier.core.query.RepositoryQuery; +import uk.co.compendiumdev.thingifier.core.query.RepositoryQueryResult; +import uk.co.compendiumdev.thingifier.core.query.RepositoryQuerySpec; +import uk.co.compendiumdev.thingifier.core.repository.ThingStore; + +public final class ThingQueryService { + + public RepositoryQueryResult execute(final ThingReadQuery query, final ThingStore store) { + return new RepositoryQuery(store, specFor(query)).performQuery(query.getQueryParams()); + } + + private RepositoryQuerySpec specFor(final ThingReadQuery query) { + if (query instanceof ReadRelationshipQuery) { + ReadRelationshipQuery relationship = (ReadRelationshipQuery) query; + return RepositoryQuerySpec.relationship( + relationship.getEntity(), + relationship.getIdentifier(), + relationship.getRelationshipName()); + } + + if (query instanceof ReadInstanceQuery) { + ReadInstanceQuery instance = (ReadInstanceQuery) query; + return RepositoryQuerySpec.instance(instance.getEntity(), instance.getIdentifier()); + } + + if (query instanceof ReadCollectionQuery) { + return RepositoryQuerySpec.collection(query.getEntity()); + } + + throw new IllegalArgumentException( + String.format("Unsupported query %s", query.getClass().getSimpleName())); + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/query/ReadCollectionQuery.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/query/ReadCollectionQuery.java new file mode 100644 index 00000000..23fc6f58 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/query/ReadCollectionQuery.java @@ -0,0 +1,25 @@ +package uk.co.compendiumdev.thingifier.application.query; + +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams; + +public final class ReadCollectionQuery implements ThingReadQuery { + + private final EntityDefinition entity; + private final QueryFilterParams queryParams; + + public ReadCollectionQuery(final EntityDefinition entity, final QueryFilterParams queryParams) { + this.entity = entity; + this.queryParams = ThingReadQuery.copyOf(queryParams); + } + + @Override + public EntityDefinition getEntity() { + return entity; + } + + @Override + public QueryFilterParams getQueryParams() { + return ThingReadQuery.copyOf(queryParams); + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/query/ReadInstanceQuery.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/query/ReadInstanceQuery.java new file mode 100644 index 00000000..de941991 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/query/ReadInstanceQuery.java @@ -0,0 +1,34 @@ +package uk.co.compendiumdev.thingifier.application.query; + +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams; + +public final class ReadInstanceQuery implements ThingReadQuery { + + private final EntityDefinition entity; + private final String identifier; + private final QueryFilterParams queryParams; + + public ReadInstanceQuery( + final EntityDefinition entity, + final String identifier, + final QueryFilterParams queryParams) { + this.entity = entity; + this.identifier = identifier; + this.queryParams = ThingReadQuery.copyOf(queryParams); + } + + @Override + public EntityDefinition getEntity() { + return entity; + } + + public String getIdentifier() { + return identifier; + } + + @Override + public QueryFilterParams getQueryParams() { + return ThingReadQuery.copyOf(queryParams); + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/query/ReadRelationshipQuery.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/query/ReadRelationshipQuery.java new file mode 100644 index 00000000..c1818790 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/query/ReadRelationshipQuery.java @@ -0,0 +1,41 @@ +package uk.co.compendiumdev.thingifier.application.query; + +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams; + +public final class ReadRelationshipQuery implements ThingReadQuery { + + private final EntityDefinition entity; + private final String identifier; + private final String relationshipName; + private final QueryFilterParams queryParams; + + public ReadRelationshipQuery( + final EntityDefinition entity, + final String identifier, + final String relationshipName, + final QueryFilterParams queryParams) { + this.entity = entity; + this.identifier = identifier; + this.relationshipName = relationshipName; + this.queryParams = ThingReadQuery.copyOf(queryParams); + } + + @Override + public EntityDefinition getEntity() { + return entity; + } + + public String getIdentifier() { + return identifier; + } + + public String getRelationshipName() { + return relationshipName; + } + + @Override + public QueryFilterParams getQueryParams() { + return ThingReadQuery.copyOf(queryParams); + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/query/ThingReadQuery.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/query/ThingReadQuery.java new file mode 100644 index 00000000..da209abd --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/query/ThingReadQuery.java @@ -0,0 +1,24 @@ +package uk.co.compendiumdev.thingifier.application.query; + +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.query.FilterBy; +import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams; + +public interface ThingReadQuery { + + EntityDefinition getEntity(); + + QueryFilterParams getQueryParams(); + + static QueryFilterParams copyOf(final QueryFilterParams queryParams) { + QueryFilterParams copy = new QueryFilterParams(); + if (queryParams == null) { + return copy; + } + + for (FilterBy filterBy : queryParams.toList()) { + copy.put(filterBy.fieldName, filterBy.filterOperation + filterBy.fieldValue); + } + return copy; + } +} diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RelationshipBodyCommandParserTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RelationshipBodyCommandParserTest.java index e8f04ed4..ca90956d 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RelationshipBodyCommandParserTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RelationshipBodyCommandParserTest.java @@ -17,6 +17,7 @@ import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; +import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams; public class RelationshipBodyCommandParserTest { @@ -65,14 +66,20 @@ public void routeParserRecognisesRelationshipRoutesAtRestBoundary() { EntityInstanceDraft.forEntity(project) .withField("title", "Project")); - Assertions.assertTrue( - RepositoryRouteQuery.canHandle( - thingifier.getERmodel().getSchema(), - String.format("project/%s/tasks", projectInstance.getPrimaryKeyValue()))); + ThingReadRequestMapper mapper = new ThingReadRequestMapper(thingifier); + Assertions.assertFalse( - RepositoryRouteQuery.canHandle( - thingifier.getERmodel().getSchema(), - String.format("project/%s/task", projectInstance.getPrimaryKeyValue()))); + mapper.map( + String.format( + "project/%s/tasks", projectInstance.getPrimaryKeyValue()), + new QueryFilterParams()) + .isError()); + Assertions.assertTrue( + mapper.map( + String.format( + "project/%s/task", projectInstance.getPrimaryKeyValue()), + new QueryFilterParams()) + .isError()); } private Thingifier taskProjectThingifier() { diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiGetHandlerTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiGetHandlerTest.java new file mode 100644 index 00000000..2398ca86 --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiGetHandlerTest.java @@ -0,0 +1,163 @@ +package uk.co.compendiumdev.thingifier.api.restapihandlers; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; +import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.core.EntityRelModel; +import uk.co.compendiumdev.thingifier.core.domain.definitions.Cardinality; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; +import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams; +import uk.co.compendiumdev.thingifier.core.repository.ThingStore; + +public class RestApiGetHandlerTest { + + @Test + public void canReturnSingleInstanceAsInstance() { + Thingifier thingifier = taskProjectThingifier(); + EntityInstance task = createTask(thingifier, "Task"); + + ApiResponse response = + thingifier.api().get("task/" + task.getPrimaryKeyValue(), params(), headers()); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertFalse(response.isCollection()); + Assertions.assertEquals(task, response.getReturnedInstance()); + } + + @Test + public void canReturnSingleInstanceAsCollectionWhenConfigured() { + Thingifier thingifier = taskProjectThingifier(); + thingifier.apiConfig().setReturnSingleGetItemsAsCollection(true); + EntityInstance task = createTask(thingifier, "Task"); + + ApiResponse response = + thingifier.api().get("task/" + task.getPrimaryKeyValue(), params(), headers()); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertTrue(response.isCollection()); + Assertions.assertEquals(1, response.getReturnedInstanceCollection().size()); + Assertions.assertEquals(task, response.getReturnedInstanceCollection().get(0)); + } + + @Test + public void relationshipReadPreservesResultTypeMetadata() { + Thingifier thingifier = taskProjectThingifier(); + ThingStore store = storeFor(thingifier); + EntityDefinition project = thingifier.getDefinitionNamed("project"); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + EntityInstance taskInstance = createTask(thingifier, "Task"); + EntityInstance projectInstance = + store.entities() + .create( + EntityInstanceDraft.forEntity(project) + .withField("title", "Project")); + store.relationships().connect(projectInstance, "tasks", taskInstance); + + ApiResponse response = + thingifier + .api() + .get( + "project/" + projectInstance.getPrimaryKeyValue() + "/tasks", + params(), + headers()); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertTrue(response.isCollection()); + Assertions.assertEquals(task, response.getTypeOfThingReturned()); + } + + @Test + public void missingInstanceMapsToNotFound() { + Thingifier thingifier = taskProjectThingifier(); + + ApiResponse response = thingifier.api().get("task/missing", params(), headers()); + + Assertions.assertEquals(404, response.getStatusCode()); + Assertions.assertTrue( + response.getErrorMessages() + .contains("Could not find an instance with task/missing")); + } + + @Test + public void rejectsQueryParamsWhenFilteringIsEnforcedButDisabled() { + Thingifier thingifier = taskProjectThingifier(); + thingifier.apiConfig().forParams().setAllowFilteringThroughUrlParams(false); + thingifier.apiConfig().forParams().setEnforceFilteringThroughUrlParams(true); + QueryFilterParams queryParams = new QueryFilterParams(); + queryParams.put("title", "=Task"); + + ApiResponse response = thingifier.api().get("tasks", queryParams, headers()); + + Assertions.assertEquals(400, response.getStatusCode()); + Assertions.assertTrue( + response.getErrorMessages().contains("Can not use query parameters with tasks")); + } + + @Test + public void ignoresQueryParamsWhenFilteringIsDisabledButNotEnforced() { + Thingifier thingifier = taskProjectThingifier(); + thingifier.apiConfig().forParams().setAllowFilteringThroughUrlParams(false); + thingifier.apiConfig().forParams().setEnforceFilteringThroughUrlParams(false); + createTask(thingifier, "Keep"); + createTask(thingifier, "Also returned"); + QueryFilterParams queryParams = new QueryFilterParams(); + queryParams.put("title", "=Keep"); + + ApiResponse response = thingifier.api().get("tasks", queryParams, headers()); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertEquals(2, response.getReturnedInstanceCollection().size()); + } + + @Test + public void headUsesGetMappingAndClearsBody() { + Thingifier thingifier = taskProjectThingifier(); + createTask(thingifier, "Task"); + + ApiResponse response = thingifier.api().head("tasks", params(), headers()); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertFalse(response.hasABody()); + } + + private Thingifier taskProjectThingifier() { + Thingifier thingifier = new Thingifier(); + EntityDefinition task = thingifier.defineThing("task", "tasks"); + task.addAsPrimaryKeyField(Field.is("guid", FieldType.AUTO_GUID)); + task.addField(Field.is("title", FieldType.STRING)); + + EntityDefinition project = thingifier.defineThing("project", "projects"); + project.addAsPrimaryKeyField(Field.is("guid", FieldType.AUTO_GUID)); + project.addField(Field.is("title", FieldType.STRING)); + + thingifier + .defineRelationship(project, task, "tasks", Cardinality.ONE_TO_MANY()) + .whenReversed(Cardinality.ONE_TO_MANY(), "task-of"); + return thingifier; + } + + private EntityInstance createTask(final Thingifier thingifier, final String title) { + EntityDefinition task = thingifier.getDefinitionNamed("task"); + return storeFor(thingifier) + .entities() + .create(EntityInstanceDraft.forEntity(task).withField("title", title)); + } + + private ThingStore storeFor(final Thingifier thingifier) { + return thingifier.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); + } + + private QueryFilterParams params() { + return new QueryFilterParams(); + } + + private HttpHeadersBlock headers() { + return new HttpHeadersBlock(); + } +} diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingReadRequestMapperTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingReadRequestMapperTest.java new file mode 100644 index 00000000..1e310721 --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/ThingReadRequestMapperTest.java @@ -0,0 +1,155 @@ +package uk.co.compendiumdev.thingifier.api.restapihandlers; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.application.query.ReadCollectionQuery; +import uk.co.compendiumdev.thingifier.application.query.ReadInstanceQuery; +import uk.co.compendiumdev.thingifier.application.query.ReadRelationshipQuery; +import uk.co.compendiumdev.thingifier.core.domain.definitions.Cardinality; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; +import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams; + +public class ThingReadRequestMapperTest { + + @Test + public void mapsCollectionRoute() { + Thingifier thingifier = taskProjectThingifier(); + + ThingReadRequestMapping mapping = mapperFor(thingifier).map("task", params()); + + Assertions.assertFalse(mapping.isError()); + Assertions.assertTrue(mapping.getQuery() instanceof ReadCollectionQuery); + Assertions.assertEquals( + thingifier.getDefinitionNamed("task"), mapping.getQuery().getEntity()); + } + + @Test + public void mapsInstanceRoute() { + Thingifier thingifier = taskProjectThingifier(); + + ThingReadRequestMapping mapping = mapperFor(thingifier).map("task/abc", params()); + + Assertions.assertFalse(mapping.isError()); + ReadInstanceQuery query = (ReadInstanceQuery) mapping.getQuery(); + Assertions.assertEquals("abc", query.getIdentifier()); + } + + @Test + public void mapsRelationshipRoute() { + Thingifier thingifier = taskProjectThingifier(); + + ThingReadRequestMapping mapping = mapperFor(thingifier).map("project/p1/tasks", params()); + + Assertions.assertFalse(mapping.isError()); + ReadRelationshipQuery query = (ReadRelationshipQuery) mapping.getQuery(); + Assertions.assertEquals("p1", query.getIdentifier()); + Assertions.assertEquals("tasks", query.getRelationshipName()); + } + + @Test + public void mapsPluralEntityNames() { + Thingifier thingifier = taskProjectThingifier(); + + ThingReadRequestMapping mapping = mapperFor(thingifier).map("tasks", params()); + + Assertions.assertFalse(mapping.isError()); + Assertions.assertTrue(mapping.getQuery() instanceof ReadCollectionQuery); + Assertions.assertEquals( + thingifier.getDefinitionNamed("task"), mapping.getQuery().getEntity()); + } + + @Test + public void rejectsInvalidRoot() { + ThingReadRequestMapping mapping = + mapperFor(taskProjectThingifier()).map("missing", params()); + + Assertions.assertTrue(mapping.isError()); + Assertions.assertEquals(404, mapping.getErrorResponse().getStatusCode()); + } + + @Test + public void rejectsRelationshipNameInIdentifierPosition() { + ThingReadRequestMapping mapping = + mapperFor(taskProjectThingifier()).map("project/tasks", params()); + + Assertions.assertTrue(mapping.isError()); + Assertions.assertEquals(404, mapping.getErrorResponse().getStatusCode()); + } + + @Test + public void rejectsEntityNameInIdentifierPosition() { + ThingReadRequestMapping mapping = + mapperFor(taskProjectThingifier()).map("project/task", params()); + + Assertions.assertTrue(mapping.isError()); + Assertions.assertEquals(404, mapping.getErrorResponse().getStatusCode()); + } + + @Test + public void rejectsEmptyRoute() { + ThingReadRequestMapping mapping = mapperFor(taskProjectThingifier()).map("", params()); + + Assertions.assertTrue(mapping.isError()); + Assertions.assertEquals(404, mapping.getErrorResponse().getStatusCode()); + } + + @Test + public void rejectsTooDeepRoute() { + ThingReadRequestMapping mapping = + mapperFor(taskProjectThingifier()).map("project/p1/tasks/t1", params()); + + Assertions.assertTrue(mapping.isError()); + Assertions.assertEquals(404, mapping.getErrorResponse().getStatusCode()); + } + + @Test + public void normalizesLeadingAndTrailingSlashes() { + Thingifier thingifier = taskProjectThingifier(); + + ThingReadRequestMapping mapping = mapperFor(thingifier).map("/projects/", params()); + + Assertions.assertFalse(mapping.isError()); + Assertions.assertTrue(mapping.getQuery() instanceof ReadCollectionQuery); + Assertions.assertEquals( + thingifier.getDefinitionNamed("project"), mapping.getQuery().getEntity()); + } + + @Test + public void preservesQueryParamsOnMappedQuery() { + Thingifier thingifier = taskProjectThingifier(); + QueryFilterParams params = new QueryFilterParams(); + params.put("title", "=Task"); + + ThingReadRequestMapping mapping = mapperFor(thingifier).map("tasks", params); + + Assertions.assertFalse(mapping.isError()); + Assertions.assertEquals(1, mapping.getQuery().getQueryParams().size()); + } + + private Thingifier taskProjectThingifier() { + Thingifier thingifier = new Thingifier(); + EntityDefinition task = thingifier.defineThing("task", "tasks"); + task.addAsPrimaryKeyField(Field.is("guid", FieldType.AUTO_GUID)); + task.addField(Field.is("title", FieldType.STRING)); + + EntityDefinition project = thingifier.defineThing("project", "projects"); + project.addAsPrimaryKeyField(Field.is("guid", FieldType.AUTO_GUID)); + project.addField(Field.is("title", FieldType.STRING)); + + thingifier + .defineRelationship(project, task, "tasks", Cardinality.ONE_TO_MANY()) + .whenReversed(Cardinality.ONE_TO_MANY(), "task-of"); + return thingifier; + } + + private ThingReadRequestMapper mapperFor(final Thingifier thingifier) { + return new ThingReadRequestMapper(thingifier); + } + + private QueryFilterParams params() { + return new QueryFilterParams(); + } +} diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/ThingQueryServiceTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/ThingQueryServiceTest.java new file mode 100644 index 00000000..e011cef9 --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/ThingQueryServiceTest.java @@ -0,0 +1,210 @@ +package uk.co.compendiumdev.thingifier.application; + +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.application.query.ReadCollectionQuery; +import uk.co.compendiumdev.thingifier.application.query.ReadInstanceQuery; +import uk.co.compendiumdev.thingifier.application.query.ReadRelationshipQuery; +import uk.co.compendiumdev.thingifier.core.EntityRelModel; +import uk.co.compendiumdev.thingifier.core.domain.definitions.Cardinality; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; +import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; +import uk.co.compendiumdev.thingifier.core.query.QueryFilterParams; +import uk.co.compendiumdev.thingifier.core.query.RepositoryQueryResult; +import uk.co.compendiumdev.thingifier.core.repository.ThingStore; +import uk.co.compendiumdev.thingifier.core.repository.sqlite.SqliteThingStore; + +public class ThingQueryServiceTest { + + @Test + public void collectionReadReturnsAllInstances() { + Thingifier thingifier = taskProjectThingifier(); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + EntityInstance first = createTask(storeFor(thingifier), task, "First", "3"); + EntityInstance second = createTask(storeFor(thingifier), task, "Second", "1"); + + RepositoryQueryResult result = + new ThingQueryService() + .execute( + new ReadCollectionQuery(task, new QueryFilterParams()), + storeFor(thingifier)); + + Assertions.assertTrue(result.isResultACollection()); + Assertions.assertEquals(2, result.getListEntityInstances().size()); + Assertions.assertTrue(result.getListEntityInstances().contains(first)); + Assertions.assertTrue(result.getListEntityInstances().contains(second)); + } + + @Test + public void instanceReadReturnsMatchingInstance() { + Thingifier thingifier = taskProjectThingifier(); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + EntityInstance instance = createTask(storeFor(thingifier), task, "Only", "1"); + + RepositoryQueryResult result = + new ThingQueryService() + .execute( + new ReadInstanceQuery( + task, + instance.getPrimaryKeyValue(), + new QueryFilterParams()), + storeFor(thingifier)); + + Assertions.assertTrue(result.wasQueryIntendedToMatchAnInstance()); + Assertions.assertTrue(result.lastMatchWasInstance()); + Assertions.assertEquals(instance, result.getLastInstance()); + } + + @Test + public void missingInstanceReadReportsNoMatch() { + Thingifier thingifier = taskProjectThingifier(); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + + RepositoryQueryResult result = + new ThingQueryService() + .execute( + new ReadInstanceQuery(task, "missing", new QueryFilterParams()), + storeFor(thingifier)); + + Assertions.assertTrue(result.wasQueryIntendedToMatchAnInstance()); + Assertions.assertTrue(result.lastMatchWasNothing()); + Assertions.assertTrue(result.getListEntityInstances().isEmpty()); + } + + @Test + public void relationshipReadReturnsRelatedInstances() { + Thingifier thingifier = taskProjectThingifier(); + ThingStore store = storeFor(thingifier); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + EntityDefinition project = thingifier.getDefinitionNamed("project"); + EntityInstance taskInstance = createTask(store, task, "Related", "1"); + EntityInstance projectInstance = + store.entities() + .create( + EntityInstanceDraft.forEntity(project) + .withField("title", "Project")); + store.relationships().connect(projectInstance, "tasks", taskInstance); + + RepositoryQueryResult result = + new ThingQueryService() + .execute( + new ReadRelationshipQuery( + project, + projectInstance.getPrimaryKeyValue(), + "tasks", + new QueryFilterParams()), + store); + + Assertions.assertTrue(result.isResultACollection()); + Assertions.assertEquals(List.of(taskInstance), result.getListEntityInstances()); + Assertions.assertEquals(task, result.resultContainsDefn()); + } + + @Test + public void missingRelationshipSourceReportsNoMatch() { + Thingifier thingifier = taskProjectThingifier(); + EntityDefinition project = thingifier.getDefinitionNamed("project"); + + RepositoryQueryResult result = + new ThingQueryService() + .execute( + new ReadRelationshipQuery( + project, "missing", "tasks", new QueryFilterParams()), + storeFor(thingifier)); + + Assertions.assertTrue(result.lastMatchWasNothing()); + Assertions.assertTrue(result.getListEntityInstances().isEmpty()); + } + + @Test + public void collectionReadAppliesFilters() { + Thingifier thingifier = taskProjectThingifier(); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + createTask(storeFor(thingifier), task, "Keep", "1"); + createTask(storeFor(thingifier), task, "Drop", "2"); + QueryFilterParams params = new QueryFilterParams(); + params.put("title", "=Keep"); + + RepositoryQueryResult result = + new ThingQueryService() + .execute(new ReadCollectionQuery(task, params), storeFor(thingifier)); + + Assertions.assertEquals(1, result.getListEntityInstances().size()); + Assertions.assertEquals( + "Keep", result.getListEntityInstances().get(0).getFieldValue("title").asString()); + } + + @Test + public void collectionReadAppliesSorting() { + Thingifier thingifier = taskProjectThingifier(); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + createTask(storeFor(thingifier), task, "Last", "3"); + createTask(storeFor(thingifier), task, "First", "1"); + QueryFilterParams params = new QueryFilterParams(); + params.put("sortBy", "+priority"); + + RepositoryQueryResult result = + new ThingQueryService() + .execute(new ReadCollectionQuery(task, params), storeFor(thingifier)); + + Assertions.assertEquals( + "First", result.getListEntityInstances().get(0).getFieldValue("title").asString()); + Assertions.assertEquals( + "Last", result.getListEntityInstances().get(1).getFieldValue("title").asString()); + } + + @Test + public void sqliteBackedStoreExecutesReadQuery() { + Thingifier thingifier = taskProjectThingifier(); + EntityDefinition task = thingifier.getDefinitionNamed("task"); + + try (ThingStore store = SqliteThingStore.inMemory(EntityRelModel.DEFAULT_DATABASE_NAME)) { + store.administration().initializeFrom(thingifier.getERmodel().getSchema()); + EntityInstance instance = createTask(store, task, "SQLite", "1"); + + RepositoryQueryResult result = + new ThingQueryService() + .execute(new ReadCollectionQuery(task, new QueryFilterParams()), store); + + Assertions.assertEquals(List.of(instance), result.getListEntityInstances()); + } + } + + private Thingifier taskProjectThingifier() { + Thingifier thingifier = new Thingifier(); + EntityDefinition task = thingifier.defineThing("task", "tasks"); + task.addAsPrimaryKeyField(Field.is("guid", FieldType.AUTO_GUID)); + task.addField(Field.is("title", FieldType.STRING)); + task.addField(Field.is("priority", FieldType.INTEGER)); + + EntityDefinition project = thingifier.defineThing("project", "projects"); + project.addAsPrimaryKeyField(Field.is("guid", FieldType.AUTO_GUID)); + project.addField(Field.is("title", FieldType.STRING)); + + thingifier + .defineRelationship(project, task, "tasks", Cardinality.ONE_TO_MANY()) + .whenReversed(Cardinality.ONE_TO_MANY(), "task-of"); + return thingifier; + } + + private EntityInstance createTask( + final ThingStore store, + final EntityDefinition task, + final String title, + final String priority) { + return store.entities() + .create( + EntityInstanceDraft.forEntity(task) + .withField("title", title) + .withField("priority", priority)); + } + + private ThingStore storeFor(final Thingifier thingifier) { + return thingifier.getStore(EntityRelModel.DEFAULT_DATABASE_NAME); + } +} From af46248f0dfd224606be2c65c6267a34fa5f74b3 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Mon, 13 Jul 2026 18:22:43 +0100 Subject: [PATCH 3/3] Run Spotless formatting on Java 21 --- .github/workflows/run-juit-tests.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-juit-tests.yml b/.github/workflows/run-juit-tests.yml index 4311b186..16b95116 100644 --- a/.github/workflows/run-juit-tests.yml +++ b/.github/workflows/run-juit-tests.yml @@ -8,9 +8,24 @@ on: jobs: + format: + runs-on: ubuntu-latest + name: Java formatting + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: maven + - name: Check Java formatting + run: mvn -B spotless:check + build: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: # list of Java versions #java: [ 8, 8.0.192, 9.0.x, 10, 11.0.x, 11.0.3, 12, 13 ] @@ -24,7 +39,5 @@ jobs: distribution: temurin java-version: ${{ matrix.java }} cache: maven - - name: Check Java formatting - run: mvn -B spotless:check - name: Build with Maven run: mvn -B clean test