diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/projection/DocumentProjector.java b/src/main/java/io/stargate/sgv2/jsonapi/service/projection/DocumentProjector.java index c0c366e700..d9a0813d22 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/projection/DocumentProjector.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/projection/DocumentProjector.java @@ -14,8 +14,11 @@ * various {@code find} commands. */ public class DocumentProjector { - /** Pseudo-projector that makes no modifications to documents */ - private static final DocumentProjector IDENTITY_PROJECTOR = new DocumentProjector(null, true); + /** + * No-op projector that does not modify documents. Considered "exclusion" projector since "no + * exclusions" is conceptually what happens ("no inclusions" would drop all content) + */ + private static final DocumentProjector IDENTITY_PROJECTOR = new DocumentProjector(null, false); private final ProjectionLayer rootLayer; diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindOneAndUpdateIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindOneAndUpdateIntegrationTest.java index 13ad60d9ad..70792dd47d 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindOneAndUpdateIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindOneAndUpdateIntegrationTest.java @@ -140,27 +140,15 @@ public void emptyOptionsAllowed() { @Test public void byIdReturnDocumentAfter() { - String document = + insertDoc( """ { "_id": "afterDoc3", "username": "afterUser3", "active_user" : true } - """; - insertDoc(document); - - String json = - """ - { - "findOneAndUpdate": { - "filter" : {"_id" : "afterDoc3"}, - "update" : {"$set" : {"active_user": false}}, - "options" : {"returnDocument" : "after"} - } - } - """; - String expected = + """); + final String expected = """ { "_id":"afterDoc3", @@ -171,7 +159,16 @@ public void byIdReturnDocumentAfter() { given() .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) .contentType(ContentType.JSON) - .body(json) + .body( + """ + { + "findOneAndUpdate": { + "filter" : {"_id" : "afterDoc3"}, + "update" : {"$set" : {"active_user": false}}, + "options" : {"returnDocument" : "after"} + } + } + """) .when() .post(CollectionResource.BASE_PATH, keyspaceId.asInternal(), collectionName) .then() @@ -182,23 +179,88 @@ public void byIdReturnDocumentAfter() { .body("errors", is(nullValue())); // assert state after update - json = + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body( + """ + { + "find": { + "filter" : {"_id" : "afterDoc3"} + } + } + """) + .when() + .post(CollectionResource.BASE_PATH, keyspaceId.asInternal(), collectionName) + .then() + .statusCode(200) + .body("errors", is(nullValue())) + .body("data.docs[0]", jsonEquals(expected)); + } + + @Test + public void byIdReturnDocumentBefore() { + final String docBefore = """ { - "find": { - "filter" : {"_id" : "afterDoc3"} - } + "_id": "beforeDoc3", + "username": "beforeUser3", + "active_user": true } """; + insertDoc(docBefore); + final String docAfter = + """ + { + "_id":"beforeDoc3", + "username":"beforeUser3", + "active_user":false, + "hits": 1 + } + """; given() .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) .contentType(ContentType.JSON) - .body(json) + .body( + """ + { + "findOneAndUpdate": { + "filter" : {"_id" : "beforeDoc3"}, + "update" : { + "$set" : {"active_user": false}, + "$inc" : {"hits": 1} + }, + "options" : {"returnDocument" : "before"} + } + } + """) .when() .post(CollectionResource.BASE_PATH, keyspaceId.asInternal(), collectionName) .then() .statusCode(200) - .body("data.docs[0]", jsonEquals(expected)); + .body("data.docs[0]", jsonEquals(docBefore)) + .body("status.matchedCount", is(1)) + .body("status.modifiedCount", is(1)) + .body("errors", is(nullValue())); + + // assert state after update + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body( + """ + { + "find": { + "filter" : {"_id" : "beforeDoc3"} + } + } + """) + .when() + .post(CollectionResource.BASE_PATH, keyspaceId.asInternal(), collectionName) + .then() + .statusCode(200) + .body("errors", is(nullValue())) + .body("data.docs[0]", jsonEquals(docAfter)); } @Test @@ -1156,6 +1218,190 @@ public void byIdUpsertAndAddOnInsert() { } } + @Nested + class FindOneAndUpdateWithProjection { + @Test + public void projectionAfterUpdate() { + String document = + """ + { + "_id": "update_doc_projection_after", + "x": 1, + "y": 2, + "z": 3, + "subdoc": { + "a": 4, + "b": 5, + "c": 6 + } + } + """; + insertDoc(document); + + String updateQuery = + """ + { + "findOneAndUpdate": { + "filter" : {"_id" : "update_doc_projection_after"}, + "options" : {"returnDocument" : "after"}, + "projection" : { "x":0, "subdoc.c":0 }, + "update" : { + "$unset" : { + "subdoc.a": 1, + "z": 1 + } + } + } + } + """; + // assert that returned document shows doc AFTER update WITH given projection + String expectedFiltered = + """ + { + "_id": "update_doc_projection_after", + "y": 2, + "subdoc": { + "b": 5 + } + } + """; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(updateQuery) + .when() + .post(CollectionResource.BASE_PATH, keyspaceId.asInternal(), collectionName) + .then() + .statusCode(200) + .body("status.matchedCount", is(1)) + .body("status.modifiedCount", is(1)) + .body("errors", is(nullValue())) + .body("data.docs[0]", jsonEquals(expectedFiltered)); + + // But also that update itself worked ($unset "z" and "subdoc.a") + String expectedUpdated = + """ + { + "_id": "update_doc_projection_after", + "x": 1, + "y": 2, + "subdoc": { + "b": 5, + "c": 6 + } + } + """; + + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body( + """ + { + "find": { + "filter" : {"_id" : "update_doc_projection_after"} + } + } + """) + .when() + .post(CollectionResource.BASE_PATH, keyspaceId.asInternal(), collectionName) + .then() + .statusCode(200) + .body("data.docs[0]", jsonEquals(expectedUpdated)); + } + + @Test + public void projectionBeforeUpdate() { + String document = + """ + { + "_id": "update_doc_projection_before", + "a": 1, + "b": 2, + "c": 3, + "subdoc": { + "x": 4, + "y": 5, + "z": 6 + } + } + """; + insertDoc(document); + + String updateQuery = + """ + { + "findOneAndUpdate": { + "filter" : {"_id" : "update_doc_projection_before"}, + "options" : {"returnDocument" : "before"}, + "projection" : { "a":0, "subdoc.z":0 }, + "update" : { + "$unset" : { + "subdoc.x": 1, + "c": 1 + } + } + } + } + """; + // assert state before update, with given projection (so unsets not visible) + String expectedFiltered = + """ + { + "_id": "update_doc_projection_before", + "b": 2, + "c": 3, + "subdoc": { + "x": 4, + "y": 5 + } + } + """; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body(updateQuery) + .when() + .post(CollectionResource.BASE_PATH, keyspaceId.asInternal(), collectionName) + .then() + .statusCode(200) + .body("status.matchedCount", is(1)) + .body("status.modifiedCount", is(1)) + .body("errors", is(nullValue())) + .body("data.docs[0]", jsonEquals(expectedFiltered)); + + // And with updates $unset of c and subdoc.x, but no Projection + String expectedUpdated = + """ + { + "_id": "update_doc_projection_before", + "a": 1, + "b": 2, + "subdoc": { + "y": 5, + "z": 6 + } + } + """; + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, getAuthToken()) + .contentType(ContentType.JSON) + .body( + """ + { + "find": { + "filter" : {"_id" : "update_doc_projection_before"} + } + } + """) + .when() + .post(CollectionResource.BASE_PATH, keyspaceId.asInternal(), collectionName) + .then() + .statusCode(200) + .body("data.docs[0]", jsonEquals(expectedUpdated)); + } + } + @AfterEach public void cleanUpData() { deleteAllDocuments(); diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/projection/DocumentProjectorTest.java b/src/test/java/io/stargate/sgv2/jsonapi/service/projection/DocumentProjectorTest.java index 7e4d9f10b2..b50aaaccd9 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/service/projection/DocumentProjectorTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/service/projection/DocumentProjectorTest.java @@ -265,7 +265,7 @@ public void testSimpleIncludeInArray() throws Exception { @Nested class ProjectorApplyExclusions { @Test - public void testExcludeWithIdIncluded() throws Exception { + public void excludeWithIdIncluded() throws Exception { final JsonNode doc = objectMapper.readTree( """ @@ -315,7 +315,7 @@ public void testExcludeWithIdIncluded() throws Exception { } @Test - public void testExcludeWithIdExcluded() throws Exception { + public void excludeWithIdExcluded() throws Exception { final JsonNode doc = objectMapper.readTree( """ @@ -361,7 +361,7 @@ public void testExcludeWithIdExcluded() throws Exception { } @Test - public void testSimpleExcludeInArray() throws Exception { + public void excludeInArray() throws Exception { JsonNode doc = objectMapper.readTree( """ @@ -395,7 +395,7 @@ public void testSimpleExcludeInArray() throws Exception { } @Test - public void testExcludeSubDoc() throws Exception { + public void excludeInSubDoc() throws Exception { JsonNode doc = objectMapper.readTree( """ @@ -428,5 +428,25 @@ public void testExcludeSubDoc() throws Exception { } """)); } + + // "Empty" Projection is not really inclusion or exclusion, but technically + // let's consider it exclusion for sake of consistency (empty list to exclude + // is same as no Projection applied; empty inclusion would produce no output) + @Test + public void emptyProjectionAsExclude() throws Exception { + final String docJson = + """ + { + "_id": "doc5", + "value": 4 + } + """; + JsonNode doc = objectMapper.readTree(docJson); + DocumentProjector projection = + DocumentProjector.createFromDefinition(objectMapper.readTree("{}")); + assertThat(projection.isInclusion()).isFalse(); + projection.applyProjection(doc); + assertThat(doc).isEqualTo(objectMapper.readTree(docJson)); + } } }