diff --git a/search-service/config/detekt/baseline.xml b/search-service/config/detekt/baseline.xml index b2b802489..f467d3c62 100644 --- a/search-service/config/detekt/baseline.xml +++ b/search-service/config/detekt/baseline.xml @@ -9,9 +9,9 @@ LongMethod:AttributeInstanceService.kt$AttributeInstanceService$@Transactional suspend fun create(attributeInstance: AttributeInstance): Either<APIException, Unit> LongMethod:EnabledAuthorizationServiceTests.kt$EnabledAuthorizationServiceTests$@Test fun `it should return serialized access control entities with other rigths if user is owner`() LongMethod:EntityAccessControlHandler.kt$EntityAccessControlHandler$@PostMapping("/{subjectId}/attrs", consumes = [MediaType.APPLICATION_JSON_VALUE, JSON_LD_CONTENT_TYPE]) suspend fun addRightsOnEntities( @RequestHeader httpHeaders: HttpHeaders, @PathVariable subjectId: String, @RequestBody requestBody: Mono<String>, @AllowedParameters @RequestParam queryParams: MultiValueMap<String, String> ): ResponseEntity<*> + LongMethod:EntityEventService.kt$EntityEventService$private fun publishAttributeChangeEvent( sub: String?, tenantName: String, entityId: URI, entityTypesAndPayload: Pair<List<ExpandedTerm>, String>, attributeOperationResult: SucceededAttributeOperationResult ) LongMethod:EntityHandler.kt$EntityHandler$@GetMapping("/{entityId}", produces = [APPLICATION_JSON_VALUE, JSON_LD_CONTENT_TYPE, GEO_JSON_CONTENT_TYPE]) suspend fun getByURI( @RequestHeader httpHeaders: HttpHeaders, @PathVariable entityId: URI, @AllowedParameters( implemented = [ QP.OPTIONS, QP.TYPE, QP.ATTRS, QP.GEOMETRY_PROPERTY, QP.LANG, QP.CONTAINED_BY, QP.JOIN, QP.JOIN_LEVEL, QP.DATASET_ID, ], notImplemented = [QP.FORMAT, QP.PICK, QP.OMIT, QP.ENTITY_MAP, QP.LOCAL, QP.VIA] ) @RequestParam queryParams: MultiValueMap<String, String> ): ResponseEntity<*> LongMethod:EntityHandler.kt$EntityHandler$@GetMapping(produces = [APPLICATION_JSON_VALUE, JSON_LD_CONTENT_TYPE, GEO_JSON_CONTENT_TYPE]) suspend fun getEntities( @RequestHeader httpHeaders: HttpHeaders, @AllowedParameters( implemented = [ QP.OPTIONS, QP.COUNT, QP.OFFSET, QP.LIMIT, QP.ID, QP.TYPE, QP.ID_PATTERN, QP.ATTRS, QP.Q, QP.GEOMETRY, QP.GEOREL, QP.COORDINATES, QP.GEOPROPERTY, QP.GEOMETRY_PROPERTY, QP.LANG, QP.SCOPEQ, QP.CONTAINED_BY, QP.JOIN, QP.JOIN_LEVEL, QP.DATASET_ID, ], notImplemented = [QP.FORMAT, QP.PICK, QP.OMIT, QP.EXPAND_VALUES, QP.CSF, QP.ENTITY_MAP, QP.LOCAL, QP.VIA] ) @RequestParam queryParams: MultiValueMap<String, String> ): ResponseEntity<*> - LongMethod:EntityEventService.kt$EntityEventService$private fun publishAttributeChangeEvent( updatedDetails: UpdatedDetails, sub: String?, tenantName: String, entityId: URI, entityTypesAndPayload: Pair<List<ExpandedTerm>, String>, serializedAttribute: Pair<ExpandedTerm, String>, overwrite: Boolean ) LongMethod:LinkedEntityServiceTests.kt$LinkedEntityServiceTests$@Test fun `it should inline entities up to the asked 2nd level`() LongMethod:PatchAttributeTests.kt$PatchAttributeTests.Companion$@JvmStatic fun mergePatchProvider(): Stream<Arguments> LongMethod:PatchAttributeTests.kt$PatchAttributeTests.Companion$@JvmStatic fun partialUpdatePatchProvider(): Stream<Arguments> @@ -28,7 +28,6 @@ LongParameterList:EntityAttributeService.kt$EntityAttributeService$( entityId: URI, attributeName: ExpandedTerm, attributeMetadata: AttributeMetadata, createdAt: ZonedDateTime, attributePayload: ExpandedAttributeInstance, sub: Sub? ) LongParameterList:EntityAttributeService.kt$EntityAttributeService$( entityUri: URI, ngsiLdAttributes: List<NgsiLdAttribute>, expandedAttributes: ExpandedAttributes, createdAt: ZonedDateTime, observedAt: ZonedDateTime?, sub: Sub? ) LongParameterList:EntityAttributeService.kt$EntityAttributeService$( entityUri: URI, ngsiLdAttributes: List<NgsiLdAttribute>, expandedAttributes: ExpandedAttributes, disallowOverwrite: Boolean, createdAt: ZonedDateTime, sub: Sub? ) - LongParameterList:EntityEventService.kt$EntityEventService$( updatedDetails: UpdatedDetails, sub: String?, tenantName: String, entityId: URI, entityTypesAndPayload: Pair<List<ExpandedTerm>, String>, serializedAttribute: Pair<ExpandedTerm, String>, overwrite: Boolean ) LongParameterList:TemporalEntityHandler.kt$TemporalEntityHandler$( @RequestHeader httpHeaders: HttpHeaders, @PathVariable entityId: URI, @PathVariable attrId: String, @PathVariable instanceId: URI, @RequestBody requestBody: Mono<String>, @AllowedParameters(notImplemented = [QP.LOCAL, QP.VIA]) @RequestParam queryParams: MultiValueMap<String, String> ) LongParameterList:V0_29__JsonLd_migration.kt$V0_29__JsonLd_migration$( entityId: URI, attributeName: ExpandedTerm, datasetId: URI?, attributePayload: ExpandedAttributeInstance, ngsiLdAttributeInstance: NgsiLdAttributeInstance, defaultCreatedAt: ZonedDateTime ) NestedBlockDepth:V0_29__JsonLd_migration.kt$V0_29__JsonLd_migration$override fun migrate(context: Context) diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/authorization/web/EntityAccessControlHandler.kt b/search-service/src/main/kotlin/com/egm/stellio/search/authorization/web/EntityAccessControlHandler.kt index 7de1a6867..cbbdf667c 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/authorization/web/EntityAccessControlHandler.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/authorization/web/EntityAccessControlHandler.kt @@ -4,10 +4,11 @@ import arrow.core.left import arrow.core.raise.either import com.egm.stellio.search.authorization.service.AuthorizationService import com.egm.stellio.search.authorization.service.EntityAccessRightsService +import com.egm.stellio.search.entity.model.FailedAttributeOperationResult import com.egm.stellio.search.entity.model.NotUpdatedDetails -import com.egm.stellio.search.entity.model.UpdateAttributeResult -import com.egm.stellio.search.entity.model.UpdateOperationResult -import com.egm.stellio.search.entity.model.updateResultFromDetailedResult +import com.egm.stellio.search.entity.model.OperationStatus +import com.egm.stellio.search.entity.model.SucceededAttributeOperationResult +import com.egm.stellio.search.entity.model.UpdateResult import com.egm.stellio.search.entity.util.composeEntitiesQueryFromGet import com.egm.stellio.shared.config.ApplicationProperties import com.egm.stellio.shared.model.AccessDeniedException @@ -257,24 +258,24 @@ class EntityAccessControlHandler( AccessRight.forAttributeName(ngsiLdRel.name).getOrNull()!! ).fold( ifLeft = { apiException -> - UpdateAttributeResult( + FailedAttributeOperationResult( ngsiLdRel.name, ngsiLdRelInstance.datasetId, - UpdateOperationResult.FAILED, + OperationStatus.FAILED, apiException.message ) }, ifRight = { - UpdateAttributeResult( + SucceededAttributeOperationResult( ngsiLdRel.name, ngsiLdRelInstance.datasetId, - UpdateOperationResult.APPENDED, - null + OperationStatus.APPENDED, + emptyMap() ) } ) } - val appendResult = updateResultFromDetailedResult(results) + val appendResult = UpdateResult(results) if (invalidAttributes.isEmpty() && unauthorizedInstances.isEmpty()) ResponseEntity.status(HttpStatus.NO_CONTENT).build() diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/entity/listener/ObservationEventListener.kt b/search-service/src/main/kotlin/com/egm/stellio/search/entity/listener/ObservationEventListener.kt index cb9a6f115..db1a4a7c9 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/entity/listener/ObservationEventListener.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/entity/listener/ObservationEventListener.kt @@ -3,6 +3,8 @@ package com.egm.stellio.search.entity.listener import arrow.core.Either import arrow.core.left import arrow.core.raise.either +import com.egm.stellio.search.entity.model.OperationStatus +import com.egm.stellio.search.entity.model.SucceededAttributeOperationResult import com.egm.stellio.search.entity.service.EntityEventService import com.egm.stellio.search.entity.service.EntityService import com.egm.stellio.shared.model.APIException @@ -120,9 +122,14 @@ class ObservationEventListener( entityEventService.publishAttributeChangeEvents( observationEvent.sub, observationEvent.entityId, - expandedAttribute.toExpandedAttributes(), - it, - false + listOf( + SucceededAttributeOperationResult( + observationEvent.attributeName, + observationEvent.datasetId, + OperationStatus.UPDATED, + expandedAttribute.toExpandedAttributes() + ) + ) ) } } @@ -143,7 +150,7 @@ class ObservationEventListener( entityService.appendAttributes( observationEvent.entityId, expandedAttribute.toExpandedAttributes(), - !observationEvent.overwrite, + false, observationEvent.sub ).map { if (it.notUpdated.isNotEmpty()) { @@ -157,9 +164,14 @@ class ObservationEventListener( entityEventService.publishAttributeChangeEvents( observationEvent.sub, observationEvent.entityId, - expandedAttribute.toExpandedAttributes(), - it, - observationEvent.overwrite + listOf( + SucceededAttributeOperationResult( + observationEvent.attributeName, + observationEvent.datasetId, + OperationStatus.APPENDED, + expandedAttribute.toExpandedAttributes() + ) + ) ) } } diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/entity/model/Attribute.kt b/search-service/src/main/kotlin/com/egm/stellio/search/entity/model/Attribute.kt index f3d4474af..d776b3572 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/entity/model/Attribute.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/entity/model/Attribute.kt @@ -7,6 +7,7 @@ import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_OBJECT import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_TYPE_TERM import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_VALUE import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_VALUE_TERM +import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_DATASET_ID_PROPERTY import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_GEOPROPERTY_TYPE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_GEOPROPERTY_VALUE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_GEOPROPERTY_VALUES @@ -27,6 +28,7 @@ import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_RELATIONSHIP_TYPE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_VOCABPROPERTY_TYPE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_VOCABPROPERTY_VALUE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_VOCABPROPERTY_VALUES +import com.egm.stellio.shared.util.JsonLdUtils.buildNonReifiedPropertyValue import com.egm.stellio.shared.util.JsonUtils.serializeObject import io.r2dbc.postgresql.codec.Json import org.springframework.data.annotation.Id @@ -107,7 +109,7 @@ data class Attribute( VocabProperty -> NGSILD_VOCABPROPERTY_VALUES } - fun toNullCompactedRepresentation(): Map = + fun toNullCompactedRepresentation(datasetId: URI? = null): Map = when (this) { Property, GeoProperty, JsonProperty, VocabProperty -> mapOf( @@ -124,6 +126,12 @@ data class Attribute( JSONLD_TYPE_TERM to this.name, JSONLD_LANGUAGEMAP_TERM to mapOf(NGSILD_NONE_TERM to NGSILD_NULL) ) + }.let { nullAttrRepresentation -> + if (datasetId != null) + nullAttrRepresentation.plus( + NGSILD_DATASET_ID_PROPERTY to buildNonReifiedPropertyValue(datasetId.toString()) + ) + else nullAttrRepresentation } fun toNullValue(): String = diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/entity/model/Entity.kt b/search-service/src/main/kotlin/com/egm/stellio/search/entity/model/Entity.kt index f54601471..9461f2860 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/entity/model/Entity.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/entity/model/Entity.kt @@ -23,6 +23,7 @@ data class Entity( val scopes: List? = null, val createdAt: ZonedDateTime, val modifiedAt: ZonedDateTime? = null, + val deletedAt: ZonedDateTime? = null, val payload: Json, val specificAccessPolicy: SpecificAccessPolicy? = null ) { @@ -47,17 +48,19 @@ data class Entity( return resultEntity } - companion object { - - fun toExpandedDeletedEntity( - entityId: URI, - deletedAt: ZonedDateTime - ): ExpandedEntity = - ExpandedEntity( - members = mapOf( - JSONLD_ID to entityId, - NGSILD_DELETED_AT_PROPERTY to buildNonReifiedTemporalValue(deletedAt) - ) - ) - } + fun toExpandedDeletedEntity( + deletedAt: ZonedDateTime + ): ExpandedEntity = + ExpandedEntity( + members = mapOf( + JSONLD_ID to entityId, + JSONLD_TYPE to types, + NGSILD_CREATED_AT_PROPERTY to buildNonReifiedTemporalValue(createdAt), + NGSILD_DELETED_AT_PROPERTY to buildNonReifiedTemporalValue(deletedAt), + ).run { + if (modifiedAt != null) + this.plus(NGSILD_MODIFIED_AT_PROPERTY to buildNonReifiedTemporalValue(modifiedAt)) + else this + } + ) } diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/entity/model/UpdateResult.kt b/search-service/src/main/kotlin/com/egm/stellio/search/entity/model/UpdateResult.kt index b854ede7d..a34eef570 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/entity/model/UpdateResult.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/entity/model/UpdateResult.kt @@ -1,64 +1,72 @@ package com.egm.stellio.search.entity.model +import com.egm.stellio.shared.model.ExpandedAttributeInstance import com.fasterxml.jackson.annotation.JsonIgnore -import com.fasterxml.jackson.annotation.JsonValue import java.net.URI +/** + * UpdateResult datatype as defined in 5.2.18 + */ data class UpdateResult( - val updated: List, + val updated: List, val notUpdated: List ) { @JsonIgnore fun isSuccessful(): Boolean = - notUpdated.isEmpty() && - updated.all { it.updateOperationResult.isSuccessResult() } + notUpdated.isEmpty() - @JsonIgnore - fun mergeWith(other: UpdateResult): UpdateResult = - UpdateResult( - updated = this.updated.plus(other.updated), - notUpdated = this.notUpdated.plus(other.notUpdated) - ) + companion object { - @JsonIgnore - fun hasSuccessfulUpdate(): Boolean = - this.updated.isNotEmpty() + operator fun invoke(operationsResults: List): UpdateResult = + operationsResults.map { + when (it) { + is SucceededAttributeOperationResult -> it.attributeName + is FailedAttributeOperationResult -> NotUpdatedDetails(it.attributeName, it.errorMessage) + } + }.let { + UpdateResult( + it.filterIsInstance(), + it.filterIsInstance() + ) + } + } } val EMPTY_UPDATE_RESULT: UpdateResult = UpdateResult(emptyList(), emptyList()) +/** + * NotUpdatedDetails as defined in 5.2.19 + */ data class NotUpdatedDetails( val attributeName: String, val reason: String ) -data class UpdatedDetails( - @JsonValue - val attributeName: String, - @JsonIgnore - val datasetId: URI?, - @JsonIgnore - val updateOperationResult: UpdateOperationResult +/** + * Internal structure used to convey the result of an operation (update, delete...) + */ +sealed class AttributeOperationResult( + open val attributeName: String, + open val datasetId: URI? = null, + open val operationStatus: OperationStatus ) -data class UpdateAttributeResult( - val attributeName: String, - val datasetId: URI? = null, - val updateOperationResult: UpdateOperationResult, - val errorMessage: String? = null -) { - fun isSuccessfullyUpdated() = - this.updateOperationResult in listOf( - UpdateOperationResult.APPENDED, - UpdateOperationResult.REPLACED, - UpdateOperationResult.UPDATED, - UpdateOperationResult.DELETED, - UpdateOperationResult.IGNORED - ) -} +data class SucceededAttributeOperationResult( + override val attributeName: String, + override val datasetId: URI? = null, + override val operationStatus: OperationStatus, + val newExpandedValue: ExpandedAttributeInstance, +) : AttributeOperationResult(attributeName, datasetId, operationStatus) + +data class FailedAttributeOperationResult( + override val attributeName: String, + override val datasetId: URI? = null, + override val operationStatus: OperationStatus, + val errorMessage: String +) : AttributeOperationResult(attributeName, datasetId, operationStatus) -enum class UpdateOperationResult { +enum class OperationStatus { APPENDED, REPLACED, UPDATED, @@ -66,15 +74,15 @@ enum class UpdateOperationResult { IGNORED, FAILED; - fun isSuccessResult(): Boolean = listOf(APPENDED, REPLACED, UPDATED, DELETED).contains(this) -} + fun isSuccessResult(): Boolean = getSuccessStatuses().contains(this) -fun updateResultFromDetailedResult(updateStatuses: List): UpdateResult { - val updated = updateStatuses.filter { it.isSuccessfullyUpdated() } - .map { UpdatedDetails(it.attributeName, it.datasetId, it.updateOperationResult) } + companion object { + fun getSuccessStatuses(): List = listOf(APPENDED, REPLACED, UPDATED, DELETED, IGNORED) + } +} - val notUpdated = updateStatuses.filter { !it.isSuccessfullyUpdated() } - .map { NotUpdatedDetails(it.attributeName, it.errorMessage!!) } +fun List.hasSuccessfulResult(): Boolean = + this.any { it is SucceededAttributeOperationResult } - return UpdateResult(updated, notUpdated) -} +fun List.getSucceededOperations(): List = + this.filterIsInstance() diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityAttributeService.kt b/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityAttributeService.kt index 72dc47c7b..223cec0e4 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityAttributeService.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityAttributeService.kt @@ -22,11 +22,11 @@ import com.egm.stellio.search.common.util.valueToDoubleOrNull import com.egm.stellio.search.common.util.valueToStringOrNull import com.egm.stellio.search.entity.model.Attribute import com.egm.stellio.search.entity.model.AttributeMetadata +import com.egm.stellio.search.entity.model.AttributeOperationResult import com.egm.stellio.search.entity.model.EntitiesQuery -import com.egm.stellio.search.entity.model.UpdateAttributeResult -import com.egm.stellio.search.entity.model.UpdateOperationResult -import com.egm.stellio.search.entity.model.UpdateResult -import com.egm.stellio.search.entity.model.updateResultFromDetailedResult +import com.egm.stellio.search.entity.model.FailedAttributeOperationResult +import com.egm.stellio.search.entity.model.OperationStatus +import com.egm.stellio.search.entity.model.SucceededAttributeOperationResult import com.egm.stellio.search.entity.util.guessAttributeValueType import com.egm.stellio.search.entity.util.hasNgsiLdNullValue import com.egm.stellio.search.entity.util.mergePatch @@ -48,6 +48,7 @@ import com.egm.stellio.shared.model.NgsiLdAttribute import com.egm.stellio.shared.model.NgsiLdEntity import com.egm.stellio.shared.model.ResourceNotFoundException import com.egm.stellio.shared.model.WKTCoordinates +import com.egm.stellio.shared.model.addSysAttrs import com.egm.stellio.shared.model.flatOnInstances import com.egm.stellio.shared.model.getAttributeFromExpandedAttributes import com.egm.stellio.shared.model.getDatasetId @@ -83,7 +84,6 @@ import org.springframework.transaction.annotation.Transactional import java.net.URI import java.time.ZonedDateTime import java.util.* -import kotlin.collections.map @Service class EntityAttributeService( @@ -204,40 +204,39 @@ class EntityAttributeService( createdAt: ZonedDateTime, attributePayload: ExpandedAttributeInstance, sub: Sub? - ): Either = - either { - logger.debug("Adding attribute {} to entity {}", attributeName, entityId) - val attribute = Attribute( - entityId = entityId, - attributeName = attributeName, - attributeType = attributeMetadata.type, - attributeValueType = attributeMetadata.valueType, - datasetId = attributeMetadata.datasetId, - createdAt = createdAt, - payload = Json.of(serializeObject(attributePayload)) - ) - create(attribute).bind() + ): Either = either { + logger.debug("Adding attribute {} to entity {}", attributeName, entityId) + val attribute = Attribute( + entityId = entityId, + attributeName = attributeName, + attributeType = attributeMetadata.type, + attributeValueType = attributeMetadata.valueType, + datasetId = attributeMetadata.datasetId, + createdAt = createdAt, + payload = Json.of(serializeObject(attributePayload)) + ) + create(attribute).bind() + + val attributeInstance = AttributeInstance( + attributeUuid = attribute.id, + timeProperty = AttributeInstance.TemporalProperty.CREATED_AT, + time = createdAt, + attributeMetadata = attributeMetadata, + payload = attributePayload, + sub = sub + ) + attributeInstanceService.create(attributeInstance).bind() - val attributeInstance = AttributeInstance( + if (attributeMetadata.observedAt != null) { + val attributeObservedAtInstance = AttributeInstance( attributeUuid = attribute.id, - timeProperty = AttributeInstance.TemporalProperty.CREATED_AT, - time = createdAt, + time = attributeMetadata.observedAt, attributeMetadata = attributeMetadata, - payload = attributePayload, - sub = sub + payload = attributePayload ) - attributeInstanceService.create(attributeInstance).bind() - - if (attributeMetadata.observedAt != null) { - val attributeObservedAtInstance = AttributeInstance( - attributeUuid = attribute.id, - time = attributeMetadata.observedAt, - attributeMetadata = attributeMetadata, - payload = attributePayload - ) - attributeInstanceService.create(attributeObservedAtInstance).bind() - } + attributeInstanceService.create(attributeObservedAtInstance).bind() } + } @Transactional suspend fun replaceAttribute( @@ -310,53 +309,64 @@ class EntityAttributeService( datasetId: URI?, deleteAll: Boolean = false, deletedAt: ZonedDateTime - ): Either = either { + ): Either> = either { logger.debug("Deleting attribute {} from entity {} (all: {})", attributeName, entityId, deleteAll) val attributesToDelete = if (deleteAll) getForEntity(entityId, setOf(attributeName), emptySet()) else listOf(getForEntityAndAttribute(entityId, attributeName, datasetId).bind()) + deleteSelectedAttributes(attributesToDelete, deletedAt).bind() + .map { expandedAttributeInstance -> + SucceededAttributeOperationResult( + attributeName, + datasetId, + OperationStatus.DELETED, + expandedAttributeInstance + ) + } } @Transactional internal suspend fun deleteSelectedAttributes( attributesToDelete: List, deletedAt: ZonedDateTime - ): Either = either { - if (attributesToDelete.isEmpty()) return Unit.right() + ): Either> = either { + if (attributesToDelete.isEmpty()) return emptyList().right() val attributesToDeleteWithPayload = attributesToDelete.map { - Triple( + Pair( it, - deletedAt, JsonLdUtils.expandAttribute( it.attributeName, - it.attributeType.toNullCompactedRepresentation(), + it.attributeType.toNullCompactedRepresentation(it.datasetId), listOf(applicationProperties.contexts.core) ).second[0] ) } - databaseClient.sql( + val teasTimestamps = databaseClient.sql( """ UPDATE temporal_entity_attribute SET deleted_at = new.deleted_at, payload = new.payload FROM (VALUES :values) AS new(uuid, deleted_at, payload) WHERE temporal_entity_attribute.id = new.uuid + RETURNING id, created_at, modified_at, new.deleted_at """.trimIndent() ) - .bind("values", attributesToDeleteWithPayload.map { arrayOf(it.first.id, it.second, it.third.toJson()) }) - .allToMappedList { - Triple( - toUuid(it["id"]), - Attribute.AttributeType.valueOf(it["attribute_type"] as String), - it["attribute_name"] as String + .bind("values", attributesToDeleteWithPayload.map { arrayOf(it.first.id, deletedAt, it.second.toJson()) }) + .allToMappedList { row -> + mapOf( + toUuid(row["id"]) to Triple( + toZonedDateTime(row["created_at"]), + toOptionalZonedDateTime(row["modified_at"]), + toZonedDateTime(row["deleted_at"]) + ) ) } - attributesToDeleteWithPayload.forEach { (attribute, deletedAt, expandedAttributePayload) -> + attributesToDeleteWithPayload.forEach { (attribute, expandedAttributePayload) -> attributeInstanceService.addDeletedAttributeInstance( attributeUuid = attribute.id, value = attribute.attributeType.toNullValue(), @@ -364,6 +374,11 @@ class EntityAttributeService( attributeValues = expandedAttributePayload ).bind() } + + attributesToDeleteWithPayload.map { (attribute, expandedAttributeInstance) -> + val teaTimestamps = teasTimestamps.find { it.containsKey(attribute.id) }!!.values.first() + expandedAttributeInstance.addSysAttrs(true, teaTimestamps.first, teaTimestamps.second, teaTimestamps.third) + } } @Transactional @@ -588,7 +603,7 @@ class EntityAttributeService( disallowOverwrite: Boolean, createdAt: ZonedDateTime, sub: Sub? - ): Either = either { + ): Either> = either { val attributeInstances = ngsiLdAttributes.flatOnInstances() attributeInstances.parMap { (ngsiLdAttribute, ngsiLdAttributeInstance) -> logger.debug("Appending attribute {} in entity {}", ngsiLdAttribute.name, entityUri) @@ -609,20 +624,20 @@ class EntityAttributeService( attributePayload, sub ).map { - UpdateAttributeResult( + SucceededAttributeOperationResult( ngsiLdAttribute.name, ngsiLdAttributeInstance.datasetId, - UpdateOperationResult.APPENDED + OperationStatus.APPENDED, + attributePayload ) }.bind() } else if (disallowOverwrite) { - val message = "Attribute already exists on $entityUri and overwrite is not allowed, ignoring" - logger.info(message) - UpdateAttributeResult( + logger.info("Attribute already exists on $entityUri and overwrite is not allowed, ignoring") + SucceededAttributeOperationResult( ngsiLdAttribute.name, ngsiLdAttributeInstance.datasetId, - UpdateOperationResult.IGNORED, - message + OperationStatus.IGNORED, + attributePayload ).right().bind() } else { replaceAttribute( @@ -633,15 +648,16 @@ class EntityAttributeService( attributePayload, sub ).map { - UpdateAttributeResult( + SucceededAttributeOperationResult( ngsiLdAttribute.name, ngsiLdAttributeInstance.datasetId, - UpdateOperationResult.REPLACED + OperationStatus.REPLACED, + attributePayload ) }.bind() } } - }.fold({ it.left() }, { updateResultFromDetailedResult(it).right() }) + }.fold({ it.left() }, { it.right() }) @Transactional suspend fun updateAttributes( @@ -650,7 +666,7 @@ class EntityAttributeService( expandedAttributes: ExpandedAttributes, createdAt: ZonedDateTime, sub: Sub? - ): Either = either { + ): Either> = either { val attributeInstances = ngsiLdAttributes.flatOnInstances() attributeInstances.parMap { (ngsiLdAttribute, ngsiLdAttributeInstance) -> logger.debug("Updating attribute {} in entity {}", ngsiLdAttribute.name, entityUri) @@ -672,10 +688,11 @@ class EntityAttributeService( attributePayload, sub ).map { - UpdateAttributeResult( + SucceededAttributeOperationResult( ngsiLdAttribute.name, ngsiLdAttributeInstance.datasetId, - UpdateOperationResult.APPENDED + OperationStatus.APPENDED, + attributePayload ) }.bind() } else if (hasNgsiLdNullValue(attributePayload, currentAttribute.attributeType)) { @@ -685,29 +702,26 @@ class EntityAttributeService( ngsiLdAttributeInstance.datasetId, false, createdAt - ).map { - UpdateAttributeResult( - ngsiLdAttribute.name, - ngsiLdAttributeInstance.datasetId, - UpdateOperationResult.DELETED - ) - }.bind() + ).bind().first() } else { - partialUpdateAttribute( - entityUri, - Pair(ngsiLdAttribute.name, listOf(attributePayload)), + replaceAttribute( + currentAttribute, + ngsiLdAttribute, + attributeMetadata, createdAt, + attributePayload, sub ).map { - UpdateAttributeResult( + SucceededAttributeOperationResult( ngsiLdAttribute.name, ngsiLdAttributeInstance.datasetId, - UpdateOperationResult.REPLACED + OperationStatus.REPLACED, + attributePayload ) }.bind() } } - }.fold({ it.left() }, { updateResultFromDetailedResult(it).right() }) + }.fold({ it.left() }, { it.right() }) @Transactional suspend fun partialUpdateAttribute( @@ -715,19 +729,19 @@ class EntityAttributeService( expandedAttribute: ExpandedAttribute, modifiedAt: ZonedDateTime, sub: Sub? - ): Either = either { + ): Either = either { val attributeName = expandedAttribute.first val attributeValues = expandedAttribute.second[0] logger.debug("Partial updating attribute {} in entity {}", attributeName, entityId) val datasetId = attributeValues.getDatasetId() val currentAttribute = getForEntityAndAttribute(entityId, attributeName, datasetId).fold({ null }, { it }) - val updateAttributeResult = + val attributeOperationResult = if (currentAttribute == null) { - UpdateAttributeResult( + FailedAttributeOperationResult( attributeName, datasetId, - UpdateOperationResult.FAILED, + OperationStatus.FAILED, "Unknown attribute $attributeName with datasetId $datasetId in entity $entityId" ) } else if (hasNgsiLdNullValue(attributeValues, currentAttribute.attributeType)) { @@ -737,13 +751,7 @@ class EntityAttributeService( datasetId, false, modifiedAt - ).map { - UpdateAttributeResult( - attributeName, - datasetId, - UpdateOperationResult.DELETED - ) - }.bind() + ).bind().first() } else { // first update payload in temporal entity attribute val attribute = getForEntityAndAttribute(entityId, attributeName, datasetId).bind() @@ -768,14 +776,15 @@ class EntityAttributeService( ) attributeInstanceService.create(attributeInstance).bind() - UpdateAttributeResult( + SucceededAttributeOperationResult( attributeName, datasetId, - UpdateOperationResult.UPDATED + OperationStatus.UPDATED, + updatedAttributeInstance ) } - updateResultFromDetailedResult(listOf(updateAttributeResult)) + attributeOperationResult } @Transactional @@ -829,7 +838,7 @@ class EntityAttributeService( createdAt: ZonedDateTime, observedAt: ZonedDateTime?, sub: Sub? - ): Either = either { + ): Either> = either { val attributeInstances = ngsiLdAttributes.flatOnInstances() attributeInstances.parMap { (ngsiLdAttribute, ngsiLdAttributeInstance) -> logger.debug("Merging attribute {} in entity {}", ngsiLdAttribute.name, entityUri) @@ -851,10 +860,11 @@ class EntityAttributeService( attributePayload, sub ).map { - UpdateAttributeResult( + SucceededAttributeOperationResult( ngsiLdAttribute.name, ngsiLdAttributeInstance.datasetId, - UpdateOperationResult.APPENDED + OperationStatus.APPENDED, + attributePayload ) }.bind() else if (hasNgsiLdNullValue(attributePayload, currentAttribute.attributeType)) @@ -864,13 +874,7 @@ class EntityAttributeService( ngsiLdAttributeInstance.datasetId, false, createdAt - ).map { - UpdateAttributeResult( - ngsiLdAttribute.name, - ngsiLdAttributeInstance.datasetId, - UpdateOperationResult.DELETED - ) - }.bind() + ).bind().first() else mergeAttribute( currentAttribute, @@ -881,14 +885,15 @@ class EntityAttributeService( attributePayload, sub ).map { - UpdateAttributeResult( + SucceededAttributeOperationResult( ngsiLdAttribute.name, ngsiLdAttributeInstance.datasetId, - UpdateOperationResult.UPDATED + OperationStatus.UPDATED, + attributePayload ) }.bind() } - }.fold({ it.left() }, { updateResultFromDetailedResult(it).right() }) + }.fold({ it.left() }, { it.right() }) @Transactional suspend fun replaceAttribute( @@ -897,19 +902,19 @@ class EntityAttributeService( expandedAttribute: ExpandedAttribute, replacedAt: ZonedDateTime, sub: Sub? - ): Either = either { + ): Either = either { val ngsiLdAttributeInstance = ngsiLdAttribute.getAttributeInstances()[0] val attributeName = ngsiLdAttribute.name val datasetId = ngsiLdAttributeInstance.datasetId val currentTea = getForEntityAndAttribute(entityId, attributeName, datasetId).fold({ null }, { it }) val attributeMetadata = ngsiLdAttributeInstance.toAttributeMetadata().bind() - val updateAttributeResult = + val attributeOperationResult = if (currentTea == null) { - UpdateAttributeResult( + FailedAttributeOperationResult( attributeName, datasetId, - UpdateOperationResult.FAILED, + OperationStatus.FAILED, "Unknown attribute $attributeName with datasetId $datasetId in entity $entityId" ) } else { @@ -922,14 +927,15 @@ class EntityAttributeService( sub ).bind() - UpdateAttributeResult( + SucceededAttributeOperationResult( ngsiLdAttribute.name, ngsiLdAttributeInstance.datasetId, - UpdateOperationResult.REPLACED + OperationStatus.REPLACED, + expandedAttribute.second.first() ) } - updateResultFromDetailedResult(listOf(updateAttributeResult)) + attributeOperationResult } suspend fun getValueFromPartialAttributePayload( diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityEventService.kt b/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityEventService.kt index 33d424f90..830a91a7b 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityEventService.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityEventService.kt @@ -2,12 +2,10 @@ package com.egm.stellio.search.entity.service import arrow.core.Either import com.egm.stellio.search.entity.model.Entity -import com.egm.stellio.search.entity.model.UpdateOperationResult -import com.egm.stellio.search.entity.model.UpdateResult -import com.egm.stellio.search.entity.model.UpdatedDetails +import com.egm.stellio.search.entity.model.OperationStatus +import com.egm.stellio.search.entity.model.SucceededAttributeOperationResult import com.egm.stellio.shared.model.APIException import com.egm.stellio.shared.model.AttributeAppendEvent -import com.egm.stellio.shared.model.AttributeDeleteAllInstancesEvent import com.egm.stellio.shared.model.AttributeDeleteEvent import com.egm.stellio.shared.model.AttributeReplaceEvent import com.egm.stellio.shared.model.AttributeUpdateEvent @@ -16,10 +14,10 @@ import com.egm.stellio.shared.model.EntityDeleteEvent import com.egm.stellio.shared.model.EntityEvent import com.egm.stellio.shared.model.EntityReplaceEvent import com.egm.stellio.shared.model.EventsType -import com.egm.stellio.shared.model.ExpandedAttributes +import com.egm.stellio.shared.model.ExpandedAttributeInstance +import com.egm.stellio.shared.model.ExpandedEntity import com.egm.stellio.shared.model.ExpandedTerm -import com.egm.stellio.shared.model.getAttributeFromExpandedAttributes -import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_TYPE +import com.egm.stellio.shared.util.JsonUtils.deserializeAsMap import com.egm.stellio.shared.util.JsonUtils.serializeObject import com.egm.stellio.shared.util.getTenantFromContext import kotlinx.coroutines.CoroutineScope @@ -84,18 +82,20 @@ class EntityEventService( suspend fun publishEntityDeleteEvent( sub: String?, - entity: Entity + previousEntity: Entity, + deletedEntityPayload: ExpandedEntity ): Job { val tenantName = getTenantFromContext() return coroutineScope.launch { - logger.debug("Sending delete event for entity {} in tenant {}", entity.entityId, tenantName) + logger.debug("Sending delete event for entity {} in tenant {}", previousEntity.entityId, tenantName) publishEntityEvent( EntityDeleteEvent( sub, tenantName, - entity.entityId, - entity.types, - entity.payload.asString(), + previousEntity.entityId, + previousEntity.types, + previousEntity.payload.asString(), + serializeObject(deletedEntityPayload.members), emptyList() ) ) @@ -105,27 +105,19 @@ class EntityEventService( suspend fun publishAttributeChangeEvents( sub: String?, entityId: URI, - jsonLdAttributes: Map, - updateResult: UpdateResult, - overwrite: Boolean + attributesOperationsResults: List ): Job { val tenantName = getTenantFromContext() val entity = getSerializedEntity(entityId) return coroutineScope.launch { - logger.debug("Sending attributes change events for entity {} in tenant {}", entityId, tenantName) entity.onRight { - updateResult.updated.forEach { updatedDetails -> - val attributeName = updatedDetails.attributeName - val serializedAttribute = - getSerializedAttribute(jsonLdAttributes, attributeName, updatedDetails.datasetId) + attributesOperationsResults.forEach { attributeOperationResult -> publishAttributeChangeEvent( - updatedDetails, sub, tenantName, entityId, it, - serializedAttribute, - overwrite + attributeOperationResult ) } }.logAttributeEvent("Attribute Change", entityId, tenantName) @@ -133,79 +125,85 @@ class EntityEventService( } private fun publishAttributeChangeEvent( - updatedDetails: UpdatedDetails, sub: String?, tenantName: String, entityId: URI, entityTypesAndPayload: Pair, String>, - serializedAttribute: Pair, - overwrite: Boolean + attributeOperationResult: SucceededAttributeOperationResult ) { - when (updatedDetails.updateOperationResult) { - UpdateOperationResult.APPENDED -> + val attributeName = attributeOperationResult.attributeName + val (types, payload) = entityTypesAndPayload + logger.debug( + "Sending {} event for attribute {} of entity {} in tenant {}", + attributeOperationResult.operationStatus, + attributeName, + entityId, + tenantName + ) + when (attributeOperationResult.operationStatus) { + OperationStatus.APPENDED -> publishEntityEvent( AttributeAppendEvent( sub, tenantName, entityId, - entityTypesAndPayload.first, - serializedAttribute.first, - updatedDetails.datasetId, - overwrite, - serializedAttribute.second, - entityTypesAndPayload.second, + types, + attributeOperationResult.attributeName, + attributeOperationResult.datasetId, + serializeObject(attributeOperationResult.newExpandedValue), + payload, emptyList() ) ) - UpdateOperationResult.REPLACED -> + OperationStatus.REPLACED -> publishEntityEvent( AttributeReplaceEvent( sub, tenantName, entityId, - entityTypesAndPayload.first, - serializedAttribute.first, - updatedDetails.datasetId, - serializedAttribute.second, - entityTypesAndPayload.second, + types, + attributeOperationResult.attributeName, + attributeOperationResult.datasetId, + serializeObject(attributeOperationResult.newExpandedValue), + payload, emptyList() ) ) - UpdateOperationResult.UPDATED -> + OperationStatus.UPDATED -> publishEntityEvent( AttributeUpdateEvent( sub, tenantName, entityId, - entityTypesAndPayload.first, - serializedAttribute.first, - updatedDetails.datasetId, - serializedAttribute.second, - entityTypesAndPayload.second, + types, + attributeOperationResult.attributeName, + attributeOperationResult.datasetId, + serializeObject(attributeOperationResult.newExpandedValue), + payload, emptyList() ) ) - UpdateOperationResult.DELETED -> + OperationStatus.DELETED -> publishEntityEvent( AttributeDeleteEvent( sub, tenantName, entityId, - entityTypesAndPayload.first, - serializedAttribute.first, - updatedDetails.datasetId, - serializedAttribute.second, + types, + attributeOperationResult.attributeName, + attributeOperationResult.datasetId, + injectDeletedAttribute(payload, attributeName, attributeOperationResult.newExpandedValue), emptyList() ) ) else -> logger.warn( - "Received an unexpected result (${updatedDetails.updateOperationResult} " + - "for entity $entityId and attribute ${updatedDetails.attributeName}" + "Received an unexpected result (${attributeOperationResult.operationStatus} " + + "for entity $entityId and attribute ${attributeOperationResult.attributeName}" ) } } @@ -213,11 +211,10 @@ class EntityEventService( suspend fun publishAttributeDeleteEvent( sub: String?, entityId: URI, - attributeName: ExpandedTerm, - datasetId: URI? = null, - deleteAll: Boolean + attributeOperationResult: SucceededAttributeOperationResult ): Job { val tenantName = getTenantFromContext() + val attributeName = attributeOperationResult.attributeName val entity = getSerializedEntity(entityId) return coroutineScope.launch { logger.debug( @@ -227,31 +224,18 @@ class EntityEventService( tenantName ) entity.onRight { - if (deleteAll) - publishEntityEvent( - AttributeDeleteAllInstancesEvent( - sub, - tenantName, - entityId, - it.first, - attributeName, - it.second, - emptyList() - ) - ) - else - publishEntityEvent( - AttributeDeleteEvent( - sub, - tenantName, - entityId, - it.first, - attributeName, - datasetId, - it.second, - emptyList() - ) + publishEntityEvent( + AttributeDeleteEvent( + sub, + tenantName, + entityId, + it.first, + attributeName, + attributeOperationResult.datasetId, + injectDeletedAttribute(it.second, attributeName, attributeOperationResult.newExpandedValue), + emptyList() ) + ) }.logAttributeEvent("Attribute Delete", entityId, tenantName) } } @@ -264,21 +248,19 @@ class EntityEventService( Pair(it.types, it.payload.asString()) } - private fun getSerializedAttribute( - jsonLdAttributes: Map, + internal fun injectDeletedAttribute( + entityPayload: String, attributeName: ExpandedTerm, - datasetId: URI? - ): Pair = - if (attributeName == JSONLD_TYPE) { - Pair(JSONLD_TYPE, serializeObject(jsonLdAttributes[JSONLD_TYPE]!!)) - } else { - val extractedPayload = (jsonLdAttributes as ExpandedAttributes).getAttributeFromExpandedAttributes( - attributeName, - datasetId - )!! - Pair(attributeName, serializeObject(extractedPayload)) + attributeInstance: ExpandedAttributeInstance + ): String { + val entityPayload = entityPayload.deserializeAsMap().toMutableMap() + entityPayload.merge(attributeName, listOf(attributeInstance)) { currentValue, newValue -> + (currentValue as List).plus(newValue as List) } + return serializeObject(entityPayload) + } + private fun Either.logEntityEvent(eventsType: EventsType, entityId: URI, tenantName: String) = this.fold({ logger.error("Error sending {} event for entity {} in tenant {}: {}", eventsType, entityId, tenantName, it) diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityQueryService.kt b/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityQueryService.kt index d97eaadae..782b433df 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityQueryService.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityQueryService.kt @@ -38,11 +38,10 @@ class EntityQueryService( entityId: URI, sub: Sub? = null ): Either = either { - checkEntityExistence(entityId).bind() + val entity = retrieve(entityId).bind() authorizationService.userCanReadEntity(entityId, sub.toOption()).bind() - val entityPayload = retrieve(entityId).bind() - toJsonLdEntity(entityPayload) + toJsonLdEntity(entity) } suspend fun queryEntities( @@ -228,15 +227,16 @@ class EntityQueryService( return entitySelectorFilter?.joinToString(separator = " OR ") ?: " 1 = 1 " } - suspend fun retrieve(entityId: URI): Either = + suspend fun retrieve(entityId: URI, allowDeleted: Boolean = false): Either = databaseClient.sql( """ SELECT * from entity_payload WHERE entity_id = :entity_id + ${if (!allowDeleted) " and deleted_at is null " else ""} """.trimIndent() ) .bind("entity_id", entityId) - .oneToResult { it.rowToEntity() } + .oneToResult(ResourceNotFoundException(entityNotFoundMessage(entityId.toString()))) { it.rowToEntity() } suspend fun retrieve(entitiesIds: List): List = databaseClient.sql( diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityService.kt b/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityService.kt index cac3a7d27..0f5acd87b 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityService.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityService.kt @@ -13,17 +13,19 @@ import com.egm.stellio.search.common.util.execute import com.egm.stellio.search.common.util.oneToResult import com.egm.stellio.search.common.util.toZonedDateTime import com.egm.stellio.search.entity.model.Attribute -import com.egm.stellio.search.entity.model.EMPTY_UPDATE_RESULT +import com.egm.stellio.search.entity.model.AttributeOperationResult import com.egm.stellio.search.entity.model.Entity +import com.egm.stellio.search.entity.model.FailedAttributeOperationResult +import com.egm.stellio.search.entity.model.OperationStatus import com.egm.stellio.search.entity.model.OperationType import com.egm.stellio.search.entity.model.OperationType.APPEND_ATTRIBUTES import com.egm.stellio.search.entity.model.OperationType.APPEND_ATTRIBUTES_OVERWRITE_ALLOWED import com.egm.stellio.search.entity.model.OperationType.MERGE_ENTITY import com.egm.stellio.search.entity.model.OperationType.UPDATE_ATTRIBUTES -import com.egm.stellio.search.entity.model.UpdateAttributeResult -import com.egm.stellio.search.entity.model.UpdateOperationResult +import com.egm.stellio.search.entity.model.SucceededAttributeOperationResult import com.egm.stellio.search.entity.model.UpdateResult -import com.egm.stellio.search.entity.model.updateResultFromDetailedResult +import com.egm.stellio.search.entity.model.getSucceededOperations +import com.egm.stellio.search.entity.model.hasSuccessfulResult import com.egm.stellio.search.entity.util.prepareAttributes import com.egm.stellio.search.entity.util.rowToEntity import com.egm.stellio.search.scope.ScopeService @@ -37,7 +39,6 @@ import com.egm.stellio.shared.model.ExpandedEntity import com.egm.stellio.shared.model.ExpandedTerm import com.egm.stellio.shared.model.NgsiLdEntity import com.egm.stellio.shared.model.addSysAttrs -import com.egm.stellio.shared.model.toExpandedAttributes import com.egm.stellio.shared.model.toNgsiLdAttributes import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_EXPANDED_ENTITY_SPECIFIC_MEMBERS import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_ID @@ -154,8 +155,8 @@ class EntityService( val mergedAt = ngsiLdDateTime() logger.debug("Merging entity {}", entityId) - val coreUpdateResult = updateCoreAttributes(entityId, coreAttrs, mergedAt, MERGE_ENTITY).bind() - val attrsUpdateResult = entityAttributeService.mergeAttributes( + val coreOperationResult = updateCoreAttributes(entityId, coreAttrs, mergedAt, MERGE_ENTITY).bind() + val attrsOperationResult = entityAttributeService.mergeAttributes( entityId, otherAttrs.toMap().toNgsiLdAttributes().bind(), expandedAttributes, @@ -164,24 +165,20 @@ class EntityService( sub ).bind() - val updateResult = coreUpdateResult.mergeWith(attrsUpdateResult) + val operationResult = coreOperationResult.plus(attrsOperationResult) // update modifiedAt in entity if at least one attribute has been merged - if (updateResult.hasSuccessfulUpdate()) { + if (operationResult.hasSuccessfulResult()) { val attributes = entityAttributeService.getForEntity(entityId, emptySet(), emptySet()) updateState(entityId, mergedAt, attributes).bind() - } - if (updateResult.updated.isNotEmpty()) { entityEventService.publishAttributeChangeEvents( sub, entityId, - expandedAttributes, - updateResult, - true + operationResult.getSucceededOperations() ) } - updateResult + UpdateResult(operationResult) } @Transactional @@ -264,7 +261,7 @@ class EntityService( coreAttrs: List>, modifiedAt: ZonedDateTime, operationType: OperationType - ): Either = either { + ): Either> = either { coreAttrs.map { (expandedTerm, expandedAttributeInstances) -> when (expandedTerm) { JSONLD_TYPE -> @@ -273,11 +270,14 @@ class EntityService( scopeService.update(entityId, expandedAttributeInstances, modifiedAt, operationType).bind() else -> { logger.warn("Ignoring unhandled core property: {}", expandedTerm) - EMPTY_UPDATE_RESULT.right().bind() + FailedAttributeOperationResult( + attributeName = expandedTerm, + operationStatus = OperationStatus.IGNORED, + errorMessage = "Ignoring unhandled core property: $expandedTerm" + ).right().bind() } } - }.ifEmpty { listOf(EMPTY_UPDATE_RESULT) } - .reduce { acc, cur -> acc.mergeWith(cur) } + } } @Transactional @@ -286,12 +286,16 @@ class EntityService( newTypes: List, modifiedAt: ZonedDateTime, allowEmptyListOfTypes: Boolean = true - ): Either = either { + ): Either = either { val entityPayload = entityQueryService.retrieve(entityId).bind() val currentTypes = entityPayload.types // when dealing with an entity update, list of types can be empty if no change of type is requested if (currentTypes.sorted() == newTypes.sorted() || newTypes.isEmpty() && allowEmptyListOfTypes) - return@either UpdateResult(emptyList(), emptyList()) + return@either SucceededAttributeOperationResult( + attributeName = JSONLD_TYPE, + operationStatus = OperationStatus.APPENDED, + newExpandedValue = mapOf(JSONLD_TYPE to currentTypes.toList()) + ) val updatedTypes = currentTypes.union(newTypes) val updatedPayload = entityPayload.payload.deserializeExpandedPayload() @@ -316,13 +320,10 @@ class EntityService( .bind("payload", Json.of(serializeObject(updatedPayload))) .execute() .map { - updateResultFromDetailedResult( - listOf( - UpdateAttributeResult( - attributeName = JSONLD_TYPE, - updateOperationResult = UpdateOperationResult.APPENDED - ) - ) + SucceededAttributeOperationResult( + attributeName = JSONLD_TYPE, + operationStatus = OperationStatus.APPENDED, + newExpandedValue = mapOf(JSONLD_TYPE to updatedTypes.toList()) ) }.bind() } @@ -344,8 +345,8 @@ class EntityService( val operationType = if (disallowOverwrite) APPEND_ATTRIBUTES else APPEND_ATTRIBUTES_OVERWRITE_ALLOWED - val coreUpdateResult = updateCoreAttributes(entityId, coreAttrs, createdAt, operationType).bind() - val attrsUpdateResult = entityAttributeService.appendAttributes( + val coreOperationResult = updateCoreAttributes(entityId, coreAttrs, createdAt, operationType).bind() + val attrsOperationResult = entityAttributeService.appendAttributes( entityId, otherAttrs.toMap().toNgsiLdAttributes().bind(), expandedAttributes, @@ -354,24 +355,10 @@ class EntityService( sub ).bind() - val updateResult = coreUpdateResult.mergeWith(attrsUpdateResult) - // update modifiedAt in entity if at least one attribute has been added - if (updateResult.hasSuccessfulUpdate()) { - val attributes = entityAttributeService.getForEntity(entityId, emptySet(), emptySet()) - updateState(entityId, createdAt, attributes).bind() - } - - if (updateResult.hasSuccessfulUpdate()) { - entityEventService.publishAttributeChangeEvents( - sub, - entityId, - expandedAttributes, - updateResult, - true - ) - } + val operationResult = coreOperationResult.plus(attrsOperationResult) + handleSuccessOperationActions(operationResult, entityId, createdAt, sub).bind() - updateResult + UpdateResult(operationResult) } @Transactional @@ -387,8 +374,8 @@ class EntityService( expandedAttributes.toList().partition { JSONLD_EXPANDED_ENTITY_SPECIFIC_MEMBERS.contains(it.first) } val createdAt = ngsiLdDateTime() - val coreUpdateResult = updateCoreAttributes(entityId, coreAttrs, createdAt, UPDATE_ATTRIBUTES).bind() - val attrsUpdateResult = entityAttributeService.updateAttributes( + val coreOperationResult = updateCoreAttributes(entityId, coreAttrs, createdAt, UPDATE_ATTRIBUTES).bind() + val attrsOperationResult = entityAttributeService.updateAttributes( entityId, otherAttrs.toMap().toNgsiLdAttributes().bind(), expandedAttributes, @@ -396,24 +383,10 @@ class EntityService( sub ).bind() - val updateResult = coreUpdateResult.mergeWith(attrsUpdateResult) - // update modifiedAt in entity if at least one attribute has been added - if (updateResult.hasSuccessfulUpdate()) { - val attributes = entityAttributeService.getForEntity(entityId, emptySet(), emptySet()) - updateState(entityId, createdAt, attributes).bind() - } + val operationResult = coreOperationResult.plus(attrsOperationResult) + handleSuccessOperationActions(operationResult, entityId, createdAt, sub).bind() - if (updateResult.updated.isNotEmpty()) { - entityEventService.publishAttributeChangeEvents( - sub, - entityId, - expandedAttributes, - updateResult, - true - ) - } - - updateResult + UpdateResult(operationResult) } @Transactional @@ -427,28 +400,16 @@ class EntityService( val modifiedAt = ngsiLdDateTime() - val updateResult = entityAttributeService.partialUpdateAttribute( + val operationResult = entityAttributeService.partialUpdateAttribute( entityId, expandedAttribute, modifiedAt, sub - ).bind() + ).bind().let { listOf(it) } - if (updateResult.isSuccessful()) { - val attributes = entityAttributeService.getForEntity(entityId, emptySet(), emptySet()) - updateState(entityId, modifiedAt, attributes).bind() - } + handleSuccessOperationActions(operationResult, entityId, modifiedAt, sub).bind() - if (updateResult.updated.isNotEmpty()) - entityEventService.publishAttributeChangeEvents( - sub, - entityId, - expandedAttribute.toExpandedAttributes(), - updateResult, - false - ) - - updateResult + UpdateResult(operationResult) } @Transactional @@ -491,30 +452,37 @@ class EntityService( val ngsiLdAttribute = listOf(expandedAttribute).toMap().toNgsiLdAttributes().bind()[0] val replacedAt = ngsiLdDateTime() - val updateResult = entityAttributeService.replaceAttribute( + val operationResult = entityAttributeService.replaceAttribute( entityId, ngsiLdAttribute, expandedAttribute, replacedAt, sub - ).bind() + ).bind().let { listOf(it) } + + handleSuccessOperationActions(operationResult, entityId, replacedAt, sub).bind() + UpdateResult(operationResult) + } + + @Transactional + internal suspend fun handleSuccessOperationActions( + operationResult: List, + entityId: URI, + createdAt: ZonedDateTime, + sub: Sub? = null + ): Either = either { // update modifiedAt in entity if at least one attribute has been added - if (updateResult.hasSuccessfulUpdate()) { + if (operationResult.hasSuccessfulResult()) { val attributes = entityAttributeService.getForEntity(entityId, emptySet(), emptySet()) - updateState(entityId, replacedAt, attributes).bind() - } + updateState(entityId, createdAt, attributes).bind() - if (updateResult.updated.isNotEmpty()) entityEventService.publishAttributeChangeEvents( sub, entityId, - expandedAttribute.toExpandedAttributes(), - updateResult, - false + operationResult.getSucceededOperations() ) - - updateResult + } } @Transactional @@ -564,57 +532,71 @@ class EntityService( @Transactional suspend fun deleteEntity(entityId: URI, sub: Sub? = null): Either = either { - entityQueryService.checkEntityExistence(entityId).bind() + val currentEntity = entityQueryService.retrieve(entityId).bind() authorizationService.userCanAdminEntity(entityId, sub.toOption()).bind() val deletedAt = ngsiLdDateTime() - val entity = deleteEntityPayload(entityId, deletedAt).bind() + val deletedEntityPayload = currentEntity.toExpandedDeletedEntity(deletedAt) + val previousEntity = deleteEntityPayload(entityId, deletedAt, deletedEntityPayload).bind() entityAttributeService.deleteAttributes(entityId, deletedAt).bind() scopeService.addHistoryEntry(entityId, emptyList(), TemporalProperty.DELETED_AT, deletedAt, sub).bind() - entityEventService.publishEntityDeleteEvent(sub, entity) + entityEventService.publishEntityDeleteEvent(sub, previousEntity, deletedEntityPayload) } @Transactional - suspend fun deleteEntityPayload(entityId: URI, deletedAt: ZonedDateTime): Either = either { - val expandedDeletedEntity = Entity.toExpandedDeletedEntity(entityId, deletedAt) - val entity = databaseClient.sql( + suspend fun deleteEntityPayload( + entityId: URI, + deletedAt: ZonedDateTime, + deletedEntityPayload: ExpandedEntity + ): Either = either { + databaseClient.sql( """ - UPDATE entity_payload - SET deleted_at = :deleted_at, - payload = :payload, - scopes = null, - specific_access_policy = null, - types = '{}' - WHERE entity_id = :entity_id - RETURNING * + WITH entity_before_delete AS ( + SELECT * + FROM entity_payload + WHERE entity_id = :entity_id + ), + update_entity AS ( + UPDATE entity_payload + SET deleted_at = :deleted_at, + payload = :payload, + scopes = null, + specific_access_policy = null, + types = '{}' + WHERE entity_id = :entity_id + ) + SELECT * FROM entity_before_delete """.trimIndent() ) .bind("entity_id", entityId) .bind("deleted_at", deletedAt) - .bind("payload", Json.of(serializeObject(expandedDeletedEntity.members))) + .bind("payload", Json.of(serializeObject(deletedEntityPayload.members))) .oneToResult { it.rowToEntity() } .bind() - entity } @Transactional suspend fun permanentlyDeleteEntity(entityId: URI, sub: Sub? = null): Either = either { - entityQueryService.checkEntityExistence(entityId, true).bind() + val currentEntity = entityQueryService.retrieve(entityId, true).bind() authorizationService.userCanAdminEntity(entityId, sub.toOption()).bind() - val entity = permanentyDeleteEntityPayload(entityId).bind() + val previousEntity = permanentyDeleteEntityPayload(entityId).bind() entityAttributeService.permanentlyDeleteAttributes(entityId).bind() authorizationService.removeRightsOnEntity(entityId).bind() - entityEventService.publishEntityDeleteEvent(sub, entity) + if (currentEntity.deletedAt == null) { + // only send a notification if entity was not already previously deleted + val deletedEntityPayload = currentEntity.toExpandedDeletedEntity(ngsiLdDateTime()) + entityEventService.publishEntityDeleteEvent(sub, previousEntity, deletedEntityPayload) + } } @Transactional suspend fun permanentyDeleteEntityPayload(entityId: URI): Either = either { - val entity = databaseClient.sql( + databaseClient.sql( """ DELETE FROM entity_payload WHERE entity_id = :entity_id @@ -626,7 +608,6 @@ class EntityService( it.rowToEntity() } .bind() - entity } @Transactional @@ -639,7 +620,7 @@ class EntityService( ): Either = either { authorizationService.userCanUpdateEntity(entityId, sub.toOption()).bind() - if (attributeName == NGSILD_SCOPE_PROPERTY) { + val deleteAttributeResults = if (attributeName == NGSILD_SCOPE_PROPERTY) { scopeService.delete(entityId).bind() } else { entityAttributeService.checkEntityAndAttributeExistence( @@ -662,13 +643,10 @@ class EntityService( entityAttributeService.getForEntity(entityId, emptySet(), emptySet()) ).bind() - entityEventService.publishAttributeDeleteEvent( - sub, - entityId, - attributeName, - datasetId, - deleteAll - ) + deleteAttributeResults.filterIsInstance() + .forEach { + entityEventService.publishAttributeDeleteEvent(sub, entityId, it) + } } @Transactional diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/entity/util/EntityUtils.kt b/search-service/src/main/kotlin/com/egm/stellio/search/entity/util/EntityUtils.kt index 09af4abf5..7e5c40b05 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/entity/util/EntityUtils.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/entity/util/EntityUtils.kt @@ -17,6 +17,7 @@ fun Map.rowToEntity(): Entity = scopes = toOptionalList(this["scopes"]), createdAt = toZonedDateTime(this["created_at"]), modifiedAt = toOptionalZonedDateTime(this["modified_at"]), + deletedAt = toOptionalZonedDateTime(this["deleted_at"]), payload = toJson(this["payload"]), specificAccessPolicy = toOptionalEnum(this["specific_access_policy"]) ) diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/scope/ScopeService.kt b/search-service/src/main/kotlin/com/egm/stellio/search/scope/ScopeService.kt index 8cb37778b..9eacfef87 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/scope/ScopeService.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/scope/ScopeService.kt @@ -16,12 +16,11 @@ import com.egm.stellio.search.common.util.toOptionalZonedDateTime import com.egm.stellio.search.common.util.toUri import com.egm.stellio.search.common.util.toZonedDateTime import com.egm.stellio.search.entity.model.Attribute.AttributeValueType -import com.egm.stellio.search.entity.model.NotUpdatedDetails +import com.egm.stellio.search.entity.model.AttributeOperationResult +import com.egm.stellio.search.entity.model.FailedAttributeOperationResult +import com.egm.stellio.search.entity.model.OperationStatus import com.egm.stellio.search.entity.model.OperationType -import com.egm.stellio.search.entity.model.UpdateAttributeResult -import com.egm.stellio.search.entity.model.UpdateOperationResult -import com.egm.stellio.search.entity.model.UpdateResult -import com.egm.stellio.search.entity.model.updateResultFromDetailedResult +import com.egm.stellio.search.entity.model.SucceededAttributeOperationResult import com.egm.stellio.search.temporal.model.AttributeInstance.TemporalProperty import com.egm.stellio.search.temporal.model.TemporalEntitiesQuery import com.egm.stellio.search.temporal.model.TemporalQuery @@ -255,7 +254,7 @@ class ScopeService( modifiedAt: ZonedDateTime, operationType: OperationType, sub: Sub? = null - ): Either = either { + ): Either = either { val scopes = mapOf(NGSILD_SCOPE_PROPERTY to expandedAttributeInstances).getScopes()!! val (currentScopes, currentPayload) = retrieve(entityId).bind() @@ -264,14 +263,10 @@ class ScopeService( if (currentScopes != null) { val updatedPayload = currentPayload.replaceScopeValue(expandedAttributeInstances) Pair(scopes, updatedPayload) - } else return@either UpdateResult( - updated = emptyList(), - notUpdated = listOf( - NotUpdatedDetails( - NGSILD_SCOPE_PROPERTY, - "Attribute does not exist and operation does not allow creating it" - ) - ) + } else return@either FailedAttributeOperationResult( + attributeName = NGSILD_SCOPE_PROPERTY, + operationStatus = OperationStatus.FAILED, + errorMessage = "Scope does not exist and operation does not allow creating it" ) } OperationType.APPEND_ATTRIBUTES, OperationType.MERGE_ENTITY -> { @@ -291,7 +286,7 @@ class ScopeService( } updatedScopes?.let { - val updateResult = + val operationResult = performUpdate(entityId, updatedScopes, modifiedAt, serializeObject(updatedPayload)).bind() val temporalPropertyToAdd = if (currentScopes == null) TemporalProperty.CREATED_AT @@ -302,10 +297,11 @@ class ScopeService( // change from the Core API, the observedAt sub-Property should be set as a copy of the modifiedAt // sub-Property addHistoryEntry(entityId, it, TemporalProperty.OBSERVED_AT, modifiedAt, sub).bind() - updateResult - } ?: UpdateResult( - emptyList(), - listOf(NotUpdatedDetails(NGSILD_SCOPE_PROPERTY, "Unrecognized operation type: $operationType")) + operationResult + } ?: FailedAttributeOperationResult( + attributeName = NGSILD_SCOPE_PROPERTY, + operationStatus = OperationStatus.FAILED, + errorMessage = "Unrecognized operation type on scope: $operationType" ) } @@ -315,7 +311,7 @@ class ScopeService( scopes: List, modifiedAt: ZonedDateTime, payload: String - ): Either = either { + ): Either = either { databaseClient.sql( """ UPDATE entity_payload @@ -331,13 +327,10 @@ class ScopeService( .bind("payload", Json.of(payload)) .execute() .map { - updateResultFromDetailedResult( - listOf( - UpdateAttributeResult( - attributeName = NGSILD_SCOPE_PROPERTY, - updateOperationResult = UpdateOperationResult.APPENDED - ) - ) + SucceededAttributeOperationResult( + attributeName = NGSILD_SCOPE_PROPERTY, + operationStatus = OperationStatus.APPENDED, + newExpandedValue = mapOf(NGSILD_SCOPE_PROPERTY to scopes.toList()) ) }.bind() } @@ -348,12 +341,11 @@ class ScopeService( createdAt: ZonedDateTime, sub: Sub? = null ): Either = either { - delete(ngsiLdEntity.id).bind() createHistory(ngsiLdEntity, createdAt, sub).bind() } @Transactional - suspend fun delete(entityId: URI): Either = either { + suspend fun delete(entityId: URI): Either> = either { databaseClient.sql( """ UPDATE entity_payload @@ -372,6 +364,15 @@ class ScopeService( TemporalProperty.DELETED_AT, ngsiLdDateTime(), getSubFromSecurityContext().getOrNull() + ).bind() + + listOf( + SucceededAttributeOperationResult( + NGSILD_SCOPE_PROPERTY, + null, + OperationStatus.DELETED, + mapOf(NGSILD_SCOPE_PROPERTY to listOf()) + ) ) } diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/temporal/service/TemporalQueryService.kt b/search-service/src/main/kotlin/com/egm/stellio/search/temporal/service/TemporalQueryService.kt index 52cf57f83..8f0e742ef 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/temporal/service/TemporalQueryService.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/temporal/service/TemporalQueryService.kt @@ -43,7 +43,7 @@ class TemporalQueryService( temporalEntitiesQuery: TemporalEntitiesQuery, sub: Sub? = null ): Either> = either { - entityQueryService.checkEntityExistence(entityId).bind() + val entity = entityQueryService.retrieve(entityId).bind() authorizationService.userCanReadEntity(entityId, sub.toOption()).bind() val attrs = temporalEntitiesQuery.entitiesQuery.attrs @@ -56,7 +56,6 @@ class TemporalQueryService( else it.right() }.bind() - val entityPayload = entityQueryService.retrieve(entityId).bind() val origin = calculateOldestTimestamp(entityId, temporalEntitiesQuery, attributes) val scopeHistory = @@ -76,7 +75,7 @@ class TemporalQueryService( fillWithAttributesWithEmptyInstances(attributes, paginatedAttributesWithInstances) TemporalEntityBuilder.buildTemporalEntity( - EntityTemporalResult(entityPayload, scopeHistory, attributesWithInstances), + EntityTemporalResult(entity, scopeHistory, attributesWithInstances), temporalEntitiesQuery ) to range } diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/entity/listener/ObservationEventListenerTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/entity/listener/ObservationEventListenerTests.kt index a464ec6c9..f9b6a6bc3 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/entity/listener/ObservationEventListenerTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/entity/listener/ObservationEventListenerTests.kt @@ -2,26 +2,23 @@ package com.egm.stellio.search.entity.listener import arrow.core.right import com.egm.stellio.search.entity.model.NotUpdatedDetails -import com.egm.stellio.search.entity.model.UpdateOperationResult +import com.egm.stellio.search.entity.model.OperationStatus import com.egm.stellio.search.entity.model.UpdateResult -import com.egm.stellio.search.entity.model.UpdatedDetails import com.egm.stellio.search.entity.service.EntityEventService import com.egm.stellio.search.entity.service.EntityService -import com.egm.stellio.shared.model.ExpandedEntity -import com.egm.stellio.shared.model.NgsiLdEntity import com.egm.stellio.shared.util.BEEHIVE_TYPE import com.egm.stellio.shared.util.TEMPERATURE_PROPERTY import com.egm.stellio.shared.util.loadSampleData import com.egm.stellio.shared.util.toUri import com.ninjasquad.springmockk.MockkBean import io.mockk.called +import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every -import io.mockk.mockkClass import io.mockk.verify import kotlinx.coroutines.Job import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertDoesNotThrow import org.springframework.beans.factory.annotation.Autowired @@ -35,21 +32,26 @@ class ObservationEventListenerTests { @Autowired private lateinit var observationEventListener: ObservationEventListener - @MockkBean(relaxed = true) + @MockkBean private lateinit var entityService: EntityService - @MockkBean(relaxed = true) + @MockkBean private lateinit var entityEventService: EntityEventService private val expectedEntityId = "urn:ngsi-ld:BeeHive:01".toUri() private val expectedTemperatureDatasetId = "urn:ngsi-ld:Dataset:WeatherApi".toUri() + @BeforeEach + fun clearMocks() { + clearAllMocks() + } + @Test fun `it should parse and transmit an ENTITY_CREATE event`() = runTest { val observationEvent = loadSampleData("events/entity/entityCreateEvent.json") coEvery { - entityService.createEntity(any(), any(), any()) + entityService.createEntity(any(), any(), any()) } returns Unit.right() coEvery { entityEventService.publishEntityCreateEvent(any(), any(), any()) } returns Job() @@ -57,7 +59,7 @@ class ObservationEventListenerTests { coVerify { entityService.createEntity( - any(), + any(), any(), eq("0123456789-1234-5678-987654321") ) @@ -79,23 +81,17 @@ class ObservationEventListenerTests { coEvery { entityService.partialUpdateAttribute(any(), any(), any()) } returns UpdateResult( - updated = arrayListOf( - UpdatedDetails( - TEMPERATURE_PROPERTY, - expectedTemperatureDatasetId, - UpdateOperationResult.UPDATED - ) - ), - notUpdated = arrayListOf() + updated = listOf(TEMPERATURE_PROPERTY), + notUpdated = emptyList() ).right() coEvery { - entityEventService.publishAttributeChangeEvents(any(), any(), any(), any(), any()) + entityEventService.publishAttributeChangeEvents(any(), any(), any()) } returns Job() observationEventListener.dispatchObservationMessage(observationEvent) - coVerify { + coVerify(timeout = 1000L) { entityService.partialUpdateAttribute( expectedEntityId, match { it.first == TEMPERATURE_PROPERTY }, @@ -106,14 +102,12 @@ class ObservationEventListenerTests { entityEventService.publishAttributeChangeEvents( null, eq(expectedEntityId), - match { it.containsKey(TEMPERATURE_PROPERTY) }, match { - it.updated.size == 1 && - it.updated[0].attributeName == TEMPERATURE_PROPERTY && - it.updated[0].datasetId == expectedTemperatureDatasetId && - it.updated[0].updateOperationResult == UpdateOperationResult.UPDATED - }, - eq(false) + it.size == 1 && + it[0].attributeName == TEMPERATURE_PROPERTY && + it[0].datasetId == expectedTemperatureDatasetId && + it[0].operationStatus == OperationStatus.UPDATED + } ) } } @@ -131,6 +125,9 @@ class ObservationEventListenerTests { observationEventListener.dispatchObservationMessage(observationEvent) + coVerify { + entityService.partialUpdateAttribute(any(), any(), any()) + } verify { entityEventService wasNot called } } @@ -141,19 +138,11 @@ class ObservationEventListenerTests { coEvery { entityService.appendAttributes(any(), any(), any(), any()) } returns UpdateResult( - listOf( - UpdatedDetails( - TEMPERATURE_PROPERTY, - expectedTemperatureDatasetId, - UpdateOperationResult.APPENDED - ) - ), + listOf(TEMPERATURE_PROPERTY), emptyList() ).right() - val mockedExpandedEntity = mockkClass(ExpandedEntity::class, relaxed = true) - every { mockedExpandedEntity.types } returns listOf(BEEHIVE_TYPE) coEvery { - entityEventService.publishAttributeChangeEvents(any(), any(), any(), any(), any()) + entityEventService.publishAttributeChangeEvents(any(), any(), any()) } returns Job() observationEventListener.dispatchObservationMessage(observationEvent) @@ -171,15 +160,11 @@ class ObservationEventListenerTests { null, eq(expectedEntityId), match { - it.containsKey(TEMPERATURE_PROPERTY) - }, - match { - it.updated.size == 1 && - it.updated[0].updateOperationResult == UpdateOperationResult.APPENDED && - it.updated[0].attributeName == TEMPERATURE_PROPERTY && - it.updated[0].datasetId == expectedTemperatureDatasetId - }, - eq(true) + it.size == 1 && + it[0].operationStatus == OperationStatus.APPENDED && + it[0].attributeName == TEMPERATURE_PROPERTY && + it[0].datasetId == expectedTemperatureDatasetId + } ) } } diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/entity/model/UpdateResultTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/entity/model/UpdateResultTests.kt index d46951752..da0933c18 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/entity/model/UpdateResultTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/entity/model/UpdateResultTests.kt @@ -1,6 +1,5 @@ package com.egm.stellio.search.entity.model -import com.egm.stellio.shared.util.toUri import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -9,15 +8,15 @@ class UpdateResultTests { @Test fun `it should find the successful update operation results`() { - assertTrue(UpdateOperationResult.UPDATED.isSuccessResult()) - assertTrue(UpdateOperationResult.APPENDED.isSuccessResult()) - assertTrue(UpdateOperationResult.REPLACED.isSuccessResult()) + assertTrue(OperationStatus.UPDATED.isSuccessResult()) + assertTrue(OperationStatus.APPENDED.isSuccessResult()) + assertTrue(OperationStatus.REPLACED.isSuccessResult()) + assertTrue(OperationStatus.IGNORED.isSuccessResult()) } @Test fun `it should find the failed update operation results`() { - assertFalse(UpdateOperationResult.FAILED.isSuccessResult()) - assertFalse(UpdateOperationResult.IGNORED.isSuccessResult()) + assertFalse(OperationStatus.FAILED.isSuccessResult()) } @Test @@ -25,9 +24,7 @@ class UpdateResultTests { val updateResult = UpdateResult( notUpdated = emptyList(), - updated = listOf( - UpdatedDetails("attributeName", "urn:ngsi-ld:Entity:01".toUri(), UpdateOperationResult.UPDATED) - ) + updated = listOf("attributeName") ) assertTrue(updateResult.isSuccessful()) @@ -37,12 +34,8 @@ class UpdateResultTests { fun `it should find a failed update result if there is one not updated attribute`() { val updateResult = UpdateResult( - notUpdated = listOf( - NotUpdatedDetails("attributeName", "attribute is malformed") - ), - updated = listOf( - UpdatedDetails("attributeName", "urn:ngsi-ld:Entity:01".toUri(), UpdateOperationResult.UPDATED) - ) + notUpdated = listOf(NotUpdatedDetails("attributeName", "attribute is malformed")), + updated = listOf("attributeName") ) assertFalse(updateResult.isSuccessful()) @@ -52,11 +45,10 @@ class UpdateResultTests { fun `it should find a failed update result if an attribute update has failed`() { val updateResult = UpdateResult( - notUpdated = emptyList(), - updated = listOf( - UpdatedDetails("attributeName", "urn:ngsi-ld:Entity:01".toUri(), UpdateOperationResult.UPDATED), - UpdatedDetails("attributeName", "urn:ngsi-ld:Entity:01".toUri(), UpdateOperationResult.FAILED) - ) + notUpdated = listOf( + NotUpdatedDetails("failedAttributeName", "attribute does not exist") + ), + updated = listOf("succeededAttributeName") ) assertFalse(updateResult.isSuccessful()) diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityAttributeServiceTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityAttributeServiceTests.kt index 654d4a8b2..75ca618e1 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityAttributeServiceTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityAttributeServiceTests.kt @@ -4,7 +4,9 @@ import arrow.core.right import com.egm.stellio.search.entity.model.Attribute import com.egm.stellio.search.entity.model.AttributeMetadata import com.egm.stellio.search.entity.model.Entity -import com.egm.stellio.search.entity.model.UpdateOperationResult +import com.egm.stellio.search.entity.model.FailedAttributeOperationResult +import com.egm.stellio.search.entity.model.OperationStatus +import com.egm.stellio.search.entity.model.getSucceededOperations import com.egm.stellio.search.support.EMPTY_JSON_PAYLOAD import com.egm.stellio.search.support.WithKafkaContainer import com.egm.stellio.search.support.WithTimescaleContainer @@ -392,12 +394,12 @@ class EntityAttributeServiceTests : WithTimescaleContainer, WithKafkaContainer() createdAt, null, null - ).shouldSucceedWith { updateResult -> - val updatedDetails = updateResult.updated - assertEquals(6, updatedDetails.size) - assertEquals(4, updatedDetails.filter { it.updateOperationResult == UpdateOperationResult.UPDATED }.size) - assertEquals(2, updatedDetails.filter { it.updateOperationResult == UpdateOperationResult.APPENDED }.size) - val newAttributes = updatedDetails.filter { it.updateOperationResult == UpdateOperationResult.APPENDED } + ).shouldSucceedWith { operationResults -> + val successfulOperations = operationResults.getSucceededOperations() + assertEquals(6, successfulOperations.size) + assertEquals(4, successfulOperations.filter { it.operationStatus == OperationStatus.UPDATED }.size) + assertEquals(2, successfulOperations.filter { it.operationStatus == OperationStatus.APPENDED }.size) + val newAttributes = successfulOperations.filter { it.operationStatus == OperationStatus.APPENDED } .map { it.attributeName } assertTrue(newAttributes.containsAll(listOf(OUTGOING_PROPERTY, TEMPERATURE_PROPERTY))) } @@ -459,10 +461,10 @@ class EntityAttributeServiceTests : WithTimescaleContainer, WithKafkaContainer() createdAt, observedAt, null - ).shouldSucceedWith { updateResult -> - val updatedDetails = updateResult.updated - assertEquals(1, updatedDetails.size) - assertEquals(1, updatedDetails.filter { it.updateOperationResult == UpdateOperationResult.UPDATED }.size) + ).shouldSucceedWith { operationResults -> + val successfulOperations = operationResults.getSucceededOperations() + assertEquals(1, successfulOperations.size) + assertEquals(1, successfulOperations.filter { it.operationStatus == OperationStatus.UPDATED }.size) } coVerify(exactly = 1) { @@ -499,10 +501,10 @@ class EntityAttributeServiceTests : WithTimescaleContainer, WithKafkaContainer() createdAt, null, null - ).shouldSucceedWith { updateResult -> - val updatedDetails = updateResult.updated - assertEquals(1, updatedDetails.size) - assertEquals(1, updatedDetails.filter { it.updateOperationResult == UpdateOperationResult.DELETED }.size) + ).shouldSucceedWith { operationResults -> + val successfulOperations = operationResults.getSucceededOperations() + assertEquals(1, successfulOperations.size) + assertEquals(1, successfulOperations.filter { it.operationStatus == OperationStatus.DELETED }.size) } coVerify(exactly = 1) { @@ -545,10 +547,10 @@ class EntityAttributeServiceTests : WithTimescaleContainer, WithKafkaContainer() expandedAttributes, createdAt, null - ).shouldSucceedWith { updateResult -> - val updatedDetails = updateResult.updated - assertEquals(1, updatedDetails.size) - assertEquals(1, updatedDetails.filter { it.updateOperationResult == UpdateOperationResult.DELETED }.size) + ).shouldSucceedWith { operationResults -> + val successfulOperations = operationResults.getSucceededOperations() + assertEquals(1, successfulOperations.size) + assertEquals(1, successfulOperations.filter { it.operationStatus == OperationStatus.DELETED }.size) } coVerify(exactly = 1) { @@ -589,10 +591,8 @@ class EntityAttributeServiceTests : WithTimescaleContainer, WithKafkaContainer() expandedAttribute, createdAt, null - ).shouldSucceedWith { updateResult -> - val updatedDetails = updateResult.updated - assertEquals(1, updatedDetails.size) - assertEquals(1, updatedDetails.filter { it.updateOperationResult == UpdateOperationResult.DELETED }.size) + ).shouldSucceedWith { operationResult -> + assertEquals(OperationStatus.DELETED, operationResult.operationStatus) } coVerify(exactly = 1) { @@ -671,11 +671,9 @@ class EntityAttributeServiceTests : WithTimescaleContainer, WithKafkaContainer() expandedAttribute, replacedAt, null - ).shouldSucceedWith { - assertTrue(it.updated.isEmpty()) - assertEquals(1, it.notUpdated.size) - val notUpdatedDetails = it.notUpdated.first() - assertEquals(NGSILD_DEFAULT_VOCAB + "unknown", notUpdatedDetails.attributeName) + ).shouldSucceedWith { operationResult -> + assertInstanceOf(FailedAttributeOperationResult::class.java, operationResult) + assertEquals(NGSILD_DEFAULT_VOCAB + "unknown", operationResult.attributeName) } } diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityEventServiceTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityEventServiceTests.kt index dd2914563..907eabb63 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityEventServiceTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityEventServiceTests.kt @@ -2,12 +2,10 @@ package com.egm.stellio.search.entity.service import arrow.core.right import com.egm.stellio.search.entity.model.Entity -import com.egm.stellio.search.entity.model.UpdateOperationResult -import com.egm.stellio.search.entity.model.UpdateResult -import com.egm.stellio.search.entity.model.UpdatedDetails +import com.egm.stellio.search.entity.model.OperationStatus +import com.egm.stellio.search.entity.model.SucceededAttributeOperationResult import com.egm.stellio.search.support.EMPTY_PAYLOAD import com.egm.stellio.shared.model.AttributeAppendEvent -import com.egm.stellio.shared.model.AttributeDeleteAllInstancesEvent import com.egm.stellio.shared.model.AttributeDeleteEvent import com.egm.stellio.shared.model.AttributeReplaceEvent import com.egm.stellio.shared.model.AttributeUpdateEvent @@ -16,8 +14,8 @@ import com.egm.stellio.shared.model.EntityEvent import com.egm.stellio.shared.model.EventsType import com.egm.stellio.shared.model.ExpandedAttribute import com.egm.stellio.shared.model.ExpandedAttributeInstance +import com.egm.stellio.shared.model.ExpandedEntity import com.egm.stellio.shared.model.ExpandedTerm -import com.egm.stellio.shared.model.toExpandedAttributes import com.egm.stellio.shared.util.AQUAC_COMPOUND_CONTEXT import com.egm.stellio.shared.util.JsonLdUtils.expandAttribute import com.egm.stellio.shared.util.JsonLdUtils.expandAttributes @@ -32,6 +30,7 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify +import io.r2dbc.postgresql.codec.Json import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.springframework.boot.test.context.SpringBootTest @@ -135,7 +134,7 @@ class EntityEventServiceTests { } every { kafkaTemplate.send(any(), any(), any()) } returns CompletableFuture() - entityEventService.publishEntityDeleteEvent(null, entity).join() + entityEventService.publishEntityDeleteEvent(null, entity, ExpandedEntity(emptyMap())).join() verify { kafkaTemplate.send("cim.entity._CatchAll", breedingServiceUri.toString(), any()) } } @@ -151,12 +150,13 @@ class EntityEventServiceTests { entityEventService.publishAttributeChangeEvents( "sub", breedingServiceUri, - expandedAttribute.toExpandedAttributes(), - UpdateResult( - listOf(UpdatedDetails(fishNumberProperty, null, UpdateOperationResult.APPENDED)), - emptyList() - ), - true + listOf( + SucceededAttributeOperationResult( + attributeName = fishNumberProperty, + operationStatus = OperationStatus.APPENDED, + newExpandedValue = expandedAttribute.second[0] + ) + ) ).join() verify { @@ -186,12 +186,13 @@ class EntityEventServiceTests { entityEventService.publishAttributeChangeEvents( null, breedingServiceUri, - expandedAttribute.toExpandedAttributes(), - UpdateResult( - listOf(UpdatedDetails(fishNumberProperty, null, UpdateOperationResult.REPLACED)), - emptyList() - ), - true + listOf( + SucceededAttributeOperationResult( + attributeName = fishNumberProperty, + operationStatus = OperationStatus.REPLACED, + newExpandedValue = expandedAttribute.second[0] + ) + ) ).join() verify { @@ -222,24 +223,24 @@ class EntityEventServiceTests { } """.trimIndent() val jsonLdAttributes = expandAttributes(attributesPayload, listOf(AQUAC_COMPOUND_CONTEXT)) - val appendResult = UpdateResult( - listOf( - UpdatedDetails(fishNumberProperty, null, UpdateOperationResult.APPENDED), - UpdatedDetails(fishNameProperty, fishName1DatasetUri, UpdateOperationResult.REPLACED) + val operationResult = listOf( + SucceededAttributeOperationResult( + attributeName = fishNumberProperty, + operationStatus = OperationStatus.APPENDED, + newExpandedValue = jsonLdAttributes[fishNumberProperty]!![0] ), - emptyList() + SucceededAttributeOperationResult( + attributeName = fishNameProperty, + datasetId = fishName1DatasetUri, + operationStatus = OperationStatus.REPLACED, + newExpandedValue = jsonLdAttributes[fishNameProperty]!![0] + ) ) coEvery { entityQueryService.retrieve(breedingServiceUri) } returns entity.right() every { entity.types } returns listOf(breedingServiceType) - entityEventService.publishAttributeChangeEvents( - null, - breedingServiceUri, - jsonLdAttributes, - appendResult, - true - ).join() + entityEventService.publishAttributeChangeEvents(null, breedingServiceUri, operationResult).join() verify { entityEventService["publishEntityEvent"]( @@ -287,12 +288,18 @@ class EntityEventServiceTests { } """.trimIndent() val jsonLdAttributes = expandAttributes(attributesPayload, listOf(AQUAC_COMPOUND_CONTEXT)) - val updateResult = UpdateResult( - updated = arrayListOf( - UpdatedDetails(fishNameProperty, fishName1DatasetUri, UpdateOperationResult.REPLACED), - UpdatedDetails(fishNumberProperty, null, UpdateOperationResult.REPLACED) + val operationResult = listOf( + SucceededAttributeOperationResult( + attributeName = fishNumberProperty, + operationStatus = OperationStatus.REPLACED, + newExpandedValue = jsonLdAttributes[fishNumberProperty]!![0] ), - notUpdated = arrayListOf() + SucceededAttributeOperationResult( + attributeName = fishNameProperty, + datasetId = fishName1DatasetUri, + operationStatus = OperationStatus.REPLACED, + newExpandedValue = jsonLdAttributes[fishNameProperty]!![0] + ) ) coEvery { entityQueryService.retrieve(breedingServiceUri) } returns entity.right() @@ -301,9 +308,7 @@ class EntityEventServiceTests { entityEventService.publishAttributeChangeEvents( null, breedingServiceUri, - jsonLdAttributes, - updateResult, - true + operationResult ).join() verify { @@ -346,24 +351,25 @@ class EntityEventServiceTests { } """.trimIndent() val jsonLdAttributes = expandAttributes(attributePayload, listOf(AQUAC_COMPOUND_CONTEXT)) - val updateResult = UpdateResult( - updated = arrayListOf( - UpdatedDetails(fishNameProperty, fishName1DatasetUri, UpdateOperationResult.REPLACED), - UpdatedDetails(fishNameProperty, fishName2DatasetUri, UpdateOperationResult.REPLACED) + val operationResult = listOf( + SucceededAttributeOperationResult( + attributeName = fishNameProperty, + datasetId = fishName1DatasetUri, + operationStatus = OperationStatus.REPLACED, + newExpandedValue = jsonLdAttributes[fishNameProperty]!![0] ), - notUpdated = arrayListOf() + SucceededAttributeOperationResult( + attributeName = fishNameProperty, + datasetId = fishName2DatasetUri, + operationStatus = OperationStatus.REPLACED, + newExpandedValue = jsonLdAttributes[fishNameProperty]!![1] + ) ) coEvery { entityQueryService.retrieve(breedingServiceUri) } returns entity.right() every { entity.types } returns listOf(breedingServiceType) - entityEventService.publishAttributeChangeEvents( - null, - breedingServiceUri, - jsonLdAttributes, - updateResult, - true - ).join() + entityEventService.publishAttributeChangeEvents(null, breedingServiceUri, operationResult).join() verify { entityEventService["publishEntityEvent"]( @@ -398,8 +404,13 @@ class EntityEventServiceTests { fishNameAttributeFragment, listOf(AQUAC_COMPOUND_CONTEXT) ) - val updatedDetails = listOf( - UpdatedDetails(fishNameProperty, fishName1DatasetUri, UpdateOperationResult.UPDATED) + val operationResult = listOf( + SucceededAttributeOperationResult( + attributeName = fishNameProperty, + datasetId = fishName1DatasetUri, + operationStatus = OperationStatus.UPDATED, + newExpandedValue = expandedAttribute.second[0] + ) ) coEvery { entityQueryService.retrieve(breedingServiceUri) } returns entity.right() @@ -408,9 +419,7 @@ class EntityEventServiceTests { entityEventService.publishAttributeChangeEvents( null, breedingServiceUri, - expandedAttribute.toExpandedAttributes(), - UpdateResult(updatedDetails, emptyList()), - false + operationResult ).join() verify { @@ -429,39 +438,47 @@ class EntityEventServiceTests { } @Test - fun `it should publish ATTRIBUTE_DELETE_ALL_INSTANCE event if all instances of an attribute are deleted`() = - runTest { - val entity = mockk(relaxed = true) - - coEvery { entityQueryService.retrieve(breedingServiceUri) } returns entity.right() - every { entity.types } returns listOf(breedingServiceType) + fun `it should publish ATTRIBUTE_DELETE event if an attribute has been deleted as part of an update `() = runTest { + val entity = mockk(relaxed = true).apply { + every { payload } returns Json.of("{}") + every { types } returns listOf(breedingServiceType) + } + coEvery { entityQueryService.retrieve(breedingServiceUri) } returns entity.right() - entityEventService.publishAttributeDeleteEvent( - null, - breedingServiceUri, - fishNameProperty, - null, - true - ).join() + entityEventService.publishAttributeChangeEvents( + null, + breedingServiceUri, + listOf( + SucceededAttributeOperationResult( + fishNameProperty, + fishName1DatasetUri, + OperationStatus.DELETED, + emptyMap() + ) + ) + ).join() - verify { - entityEventService["publishEntityEvent"]( - match { entityEvent -> - listOf(entityEvent).all { - it.operationType == EventsType.ATTRIBUTE_DELETE_ALL_INSTANCES && - it.entityId == breedingServiceUri && - it.entityTypes == listOf(breedingServiceType) && - it.attributeName == fishNameProperty && - it.contexts.isEmpty() - } + verify { + entityEventService["publishEntityEvent"]( + match { entityEvent -> + listOf(entityEvent).all { + it.operationType == EventsType.ATTRIBUTE_DELETE && + it.entityId == breedingServiceUri && + it.entityTypes == listOf(breedingServiceType) && + it.attributeName == fishNameProperty && + it.datasetId == fishName1DatasetUri && + it.contexts.isEmpty() } - ) - } + } + ) } + } @Test fun `it should publish ATTRIBUTE_DELETE event if an instance of an attribute is deleted`() = runTest { - val entity = mockk(relaxed = true) + val entity = mockk(relaxed = true).apply { + every { payload } returns Json.of("{}") + } coEvery { entityQueryService.retrieve(breedingServiceUri) } returns entity.right() every { entity.types } returns listOf(breedingServiceType) @@ -469,9 +486,12 @@ class EntityEventServiceTests { entityEventService.publishAttributeDeleteEvent( null, breedingServiceUri, - fishNameProperty, - fishName1DatasetUri, - false + SucceededAttributeOperationResult( + fishNameProperty, + fishName1DatasetUri, + OperationStatus.DELETED, + emptyMap() + ) ).join() verify { diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityServiceTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityServiceTests.kt index 54b86b4c5..5c079f2b8 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityServiceTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityServiceTests.kt @@ -5,11 +5,9 @@ import arrow.core.right import arrow.core.toOption import com.egm.stellio.search.authorization.service.AuthorizationService import com.egm.stellio.search.common.util.deserializeAsMap -import com.egm.stellio.search.entity.model.EMPTY_UPDATE_RESULT import com.egm.stellio.search.entity.model.Entity -import com.egm.stellio.search.entity.model.UpdateOperationResult -import com.egm.stellio.search.entity.model.UpdateResult -import com.egm.stellio.search.entity.model.UpdatedDetails +import com.egm.stellio.search.entity.model.OperationStatus +import com.egm.stellio.search.entity.model.SucceededAttributeOperationResult import com.egm.stellio.search.support.WithKafkaContainer import com.egm.stellio.search.support.WithTimescaleContainer import com.egm.stellio.shared.model.AccessDeniedException @@ -28,6 +26,7 @@ import com.egm.stellio.shared.util.JsonLdUtils.expandAttribute import com.egm.stellio.shared.util.JsonLdUtils.expandAttributes import com.egm.stellio.shared.util.JsonUtils.deserializeExpandedPayload import com.egm.stellio.shared.util.OUTGOING_PROPERTY +import com.egm.stellio.shared.util.loadAndExpandDeletedEntity import com.egm.stellio.shared.util.loadAndPrepareSampleData import com.egm.stellio.shared.util.loadMinimalEntity import com.egm.stellio.shared.util.loadSampleData @@ -187,7 +186,7 @@ class EntityServiceTests : WithTimescaleContainer, WithKafkaContainer() { now ) - entityService.deleteEntityPayload(entity01Uri, ngsiLdDateTime()) + entityService.deleteEntityPayload(entity01Uri, ngsiLdDateTime(), loadAndExpandDeletedEntity(entity01Uri)) .shouldSucceedWith { assertEquals(entity01Uri, it.entityId) assertNotNull(it.payload) @@ -215,7 +214,7 @@ class EntityServiceTests : WithTimescaleContainer, WithKafkaContainer() { now ) - entityService.deleteEntityPayload(entity01Uri, ngsiLdDateTime()) + entityService.deleteEntityPayload(entity01Uri, ngsiLdDateTime(), loadAndExpandDeletedEntity(entity01Uri)) .shouldSucceedWith { assertEquals(entity01Uri, it.entityId) assertNotNull(it.payload) @@ -238,9 +237,10 @@ class EntityServiceTests : WithTimescaleContainer, WithKafkaContainer() { now ).shouldSucceed() - entityService.deleteEntityPayload(entity01Uri, ngsiLdDateTime()).shouldSucceed() + entityService.deleteEntityPayload(entity01Uri, ngsiLdDateTime(), loadAndExpandDeletedEntity(entity01Uri)) + .shouldSucceed() - entityQueryService.retrieve(entity01Uri) + entityQueryService.retrieve(entity01Uri, true) .shouldSucceedWith { entity -> val payload = entity.payload.deserializeAsMap() assertThat(payload) @@ -258,9 +258,8 @@ class EntityServiceTests : WithTimescaleContainer, WithKafkaContainer() { } returns Unit.right() coEvery { entityAttributeService.mergeAttributes(any(), any(), any(), any(), any(), any()) - } returns UpdateResult( - listOf(UpdatedDetails(INCOMING_PROPERTY, null, UpdateOperationResult.APPENDED)), - emptyList() + } returns listOf( + SucceededAttributeOperationResult(INCOMING_PROPERTY, null, OperationStatus.APPENDED, emptyMap()), ).right() coEvery { entityAttributeService.getForEntity(any(), any(), any()) } returns emptyList() coEvery { authorizationService.createOwnerRight(any(), any()) } returns Unit.right() @@ -324,9 +323,8 @@ class EntityServiceTests : WithTimescaleContainer, WithKafkaContainer() { } returns Unit.right() coEvery { entityAttributeService.mergeAttributes(any(), any(), any(), any(), any(), any()) - } returns UpdateResult( - listOf(UpdatedDetails(INCOMING_PROPERTY, null, UpdateOperationResult.APPENDED)), - emptyList() + } returns listOf( + SucceededAttributeOperationResult(INCOMING_PROPERTY, null, OperationStatus.APPENDED, emptyMap()) ).right() coEvery { entityAttributeService.getForEntity(any(), any(), any()) } returns emptyList() coEvery { authorizationService.createOwnerRight(any(), any()) } returns Unit.right() @@ -367,13 +365,9 @@ class EntityServiceTests : WithTimescaleContainer, WithKafkaContainer() { } returns Unit.right() coEvery { entityAttributeService.mergeAttributes(any(), any(), any(), any(), any(), any()) - } returns UpdateResult( - listOf(UpdatedDetails(INCOMING_PROPERTY, null, UpdateOperationResult.APPENDED)), - emptyList() + } returns listOf( + SucceededAttributeOperationResult(INCOMING_PROPERTY, null, OperationStatus.APPENDED, emptyMap()) ).right() - coEvery { - entityAttributeService.partialUpdateAttribute(any(), any(), any(), any()) - } returns EMPTY_UPDATE_RESULT.right() coEvery { entityAttributeService.getForEntity(any(), any(), any()) } returns emptyList() coEvery { authorizationService.createOwnerRight(any(), any()) } returns Unit.right() @@ -468,9 +462,10 @@ class EntityServiceTests : WithTimescaleContainer, WithKafkaContainer() { coEvery { entityAttributeService.getForEntity(any(), any(), any()) } returns emptyList() coEvery { entityAttributeService.replaceAttribute(any(), any(), any(), any(), any()) - } returns UpdateResult( - updated = listOf(UpdatedDetails(INCOMING_PROPERTY, null, UpdateOperationResult.REPLACED)), - notUpdated = emptyList() + } returns SucceededAttributeOperationResult( + attributeName = INCOMING_PROPERTY, + operationStatus = OperationStatus.REPLACED, + newExpandedValue = emptyMap() ).right() val (jsonLdEntity, ngsiLdEntity) = loadSampleData().sampleDataToNgsiLdEntity().shouldSucceedAndResult() @@ -485,8 +480,7 @@ class EntityServiceTests : WithTimescaleContainer, WithKafkaContainer() { .shouldSucceedWith { it.updated.size == 1 && it.notUpdated.isEmpty() && - it.updated[0].attributeName == INCOMING_PROPERTY && - it.updated[0].updateOperationResult == UpdateOperationResult.REPLACED + it.updated[0] == INCOMING_PROPERTY } } @@ -503,11 +497,9 @@ class EntityServiceTests : WithTimescaleContainer, WithKafkaContainer() { entityService.updateTypes(beehiveTestCId, listOf(BEEHIVE_TYPE, APIARY_TYPE), ngsiLdDateTime(), false) .shouldSucceedWith { - assertTrue(it.isSuccessful()) - assertEquals(1, it.updated.size) - val updatedDetails = it.updated[0] - assertEquals(JSONLD_TYPE, updatedDetails.attributeName) - assertEquals(UpdateOperationResult.APPENDED, updatedDetails.updateOperationResult) + assertInstanceOf(SucceededAttributeOperationResult::class.java, it) + assertEquals(JSONLD_TYPE, it.attributeName) + assertEquals(OperationStatus.APPENDED, it.operationStatus) } entityQueryService.retrieve(beehiveTestCId) diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/entity/web/EntityHandlerTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/entity/web/EntityHandlerTests.kt index aefc5d192..3b89c8169 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/entity/web/EntityHandlerTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/entity/web/EntityHandlerTests.kt @@ -10,9 +10,7 @@ import com.egm.stellio.search.csr.service.ContextSourceCaller import com.egm.stellio.search.csr.service.ContextSourceRegistrationService import com.egm.stellio.search.entity.model.EntitiesQueryFromGet import com.egm.stellio.search.entity.model.NotUpdatedDetails -import com.egm.stellio.search.entity.model.UpdateOperationResult import com.egm.stellio.search.entity.model.UpdateResult -import com.egm.stellio.search.entity.model.UpdatedDetails import com.egm.stellio.search.entity.service.EntityQueryService import com.egm.stellio.search.entity.service.EntityService import com.egm.stellio.search.entity.service.LinkedEntityService @@ -1290,7 +1288,7 @@ class EntityHandlerTests { NGSILDWarning.HEADER_NAME, "199 urn:ngsi-ld:ContextSourceRegistration:test \"message with line breaks\"", "199 urn:ngsi-ld:ContextSourceRegistration:test \"message\"" - ).expectHeader().valueEquals(RESULTS_COUNT_HEADER, "0",) + ).expectHeader().valueEquals(RESULTS_COUNT_HEADER, "0") coVerify(exactly = 2) { contextSourceRegistrationService.updateContextSourceStatus(any(), false) } } @@ -1407,13 +1405,7 @@ class EntityHandlerTests { val jsonLdFile = ClassPathResource("/ngsild/aquac/fragments/BreedingService_newProperty.json") val entityId = "urn:ngsi-ld:BreedingService:0214".toUri() val appendResult = UpdateResult( - listOf( - UpdatedDetails( - fishNumberAttribute, - null, - UpdateOperationResult.APPENDED - ) - ), + listOf(fishNumberAttribute), emptyList() ) @@ -1444,13 +1436,7 @@ class EntityHandlerTests { val jsonLdFile = ClassPathResource("/ngsild/aquac/fragments/BreedingService_twoNewProperties.json") val entityId = "urn:ngsi-ld:BreedingService:0214".toUri() val appendResult = UpdateResult( - listOf( - UpdatedDetails( - fishNumberAttribute, - null, - UpdateOperationResult.APPENDED - ) - ), + listOf(fishNumberAttribute), listOf(NotUpdatedDetails(fishSizeAttribute, "overwrite disallowed")) ) @@ -1489,7 +1475,7 @@ class EntityHandlerTests { val jsonLdFile = ClassPathResource("/ngsild/aquac/fragments/BreedingService_newType.json") val entityId = "urn:ngsi-ld:BreedingService:0214".toUri() val appendTypeResult = UpdateResult( - listOf(UpdatedDetails(JSONLD_TYPE, null, UpdateOperationResult.APPENDED)), + listOf(JSONLD_TYPE), emptyList() ) @@ -1519,18 +1505,16 @@ class EntityHandlerTests { fun `append entity attribute should return a 207 if types or attributes could not be appended`() { val jsonLdFile = ClassPathResource("/ngsild/aquac/fragments/BreedingService_newInvalidTypeAndAttribute.json") val entityId = "urn:ngsi-ld:BreedingService:0214".toUri() - val appendTypeResult = UpdateResult( - emptyList(), - listOf(NotUpdatedDetails(JSONLD_TYPE, "Append operation has unexpectedly failed")) - ) - val appendResult = UpdateResult( - listOf(UpdatedDetails(fishNumberAttribute, null, UpdateOperationResult.APPENDED)), - listOf(NotUpdatedDetails(fishSizeAttribute, "overwrite disallowed")) - ) coEvery { entityService.appendAttributes(any(), any(), any(), any()) - } returns appendTypeResult.mergeWith(appendResult).right() + } returns UpdateResult( + updated = listOf(fishNumberAttribute), + notUpdated = listOf( + NotUpdatedDetails(JSONLD_TYPE, "Append operation has unexpectedly failed"), + NotUpdatedDetails(fishSizeAttribute, "overwrite disallowed") + ) + ).right() webClient.post() .uri("/ngsi-ld/v1/entities/$entityId/attrs") @@ -1649,10 +1633,8 @@ class EntityHandlerTests { val entityId = "urn:ngsi-ld:DeadFishes:019BN".toUri() val attrId = "fishNumber" val updateResult = UpdateResult( - updated = arrayListOf( - UpdatedDetails(fishNumberAttribute, "urn:ngsi-ld:Dataset:1".toUri(), UpdateOperationResult.UPDATED) - ), - notUpdated = arrayListOf() + updated = listOf(fishNumberAttribute), + notUpdated = emptyList() ) coEvery { @@ -1755,18 +1737,7 @@ class EntityHandlerTests { val jsonLdFile = ClassPathResource("/ngsild/aquac/fragments/DeadFishes_mergeEntity.json") val entityId = "urn:ngsi-ld:DeadFishes:019BN".toUri() val updateResult = UpdateResult( - updated = arrayListOf( - UpdatedDetails( - fishNumberAttribute, - null, - UpdateOperationResult.REPLACED - ), - UpdatedDetails( - fishSizeAttribute, - null, - UpdateOperationResult.APPENDED - ) - ), + updated = listOf(fishNumberAttribute, fishSizeAttribute), notUpdated = emptyList() ) @@ -1792,18 +1763,7 @@ class EntityHandlerTests { val jsonLdFile = ClassPathResource("/ngsild/aquac/fragments/DeadFishes_mergeEntity.json") val entityId = "urn:ngsi-ld:DeadFishes:019BN".toUri() val updateResult = UpdateResult( - updated = arrayListOf( - UpdatedDetails( - fishNumberAttribute, - null, - UpdateOperationResult.REPLACED - ), - UpdatedDetails( - fishSizeAttribute, - null, - UpdateOperationResult.APPENDED - ) - ), + updated = listOf(fishNumberAttribute, fishSizeAttribute), notUpdated = emptyList() ) @@ -1931,13 +1891,7 @@ class EntityHandlerTests { val jsonLdFile = ClassPathResource("/ngsild/aquac/fragments/DeadFishes_updateEntityAttribute.json") val entityId = "urn:ngsi-ld:DeadFishes:019BN".toUri() val updateResult = UpdateResult( - updated = arrayListOf( - UpdatedDetails( - fishNumberAttribute, - null, - UpdateOperationResult.REPLACED - ) - ), + updated = listOf(fishNumberAttribute), notUpdated = emptyList() ) @@ -1969,10 +1923,8 @@ class EntityHandlerTests { coEvery { entityService.updateAttributes(any(), any(), any()) } returns UpdateResult( - updated = arrayListOf( - UpdatedDetails(fishNumberAttribute, null, UpdateOperationResult.REPLACED) - ), - notUpdated = arrayListOf(notUpdatedAttribute) + updated = listOf(fishNumberAttribute), + notUpdated = listOf(notUpdatedAttribute) ).right() webClient.patch() @@ -2354,9 +2306,7 @@ class EntityHandlerTests { coEvery { entityService.replaceAttribute(any(), any(), any()) } returns UpdateResult( - updated = arrayListOf( - UpdatedDetails(INCOMING_PROPERTY, null, UpdateOperationResult.REPLACED) - ), + updated = listOf(INCOMING_PROPERTY), notUpdated = emptyList() ).right() diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/temporal/service/AttributeInstanceServiceTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/temporal/service/AttributeInstanceServiceTests.kt index 5cca2a169..37f9eef69 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/temporal/service/AttributeInstanceServiceTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/temporal/service/AttributeInstanceServiceTests.kt @@ -742,7 +742,7 @@ class AttributeInstanceServiceTests : WithTimescaleContainer, WithKafkaContainer AttributeInstanceService(databaseClient, searchProperties), recordPrivateCalls = true ) - val deletedAt = ngsiLdDateTime() + val deletedAt = ZonedDateTime.parse("2025-01-02T11:20:30.000001Z") val attributeValues = mapOf( NGSILD_DELETED_AT_PROPERTY to listOf( mapOf( @@ -767,14 +767,14 @@ class AttributeInstanceServiceTests : WithTimescaleContainer, WithKafkaContainer verify { attributeInstanceService["create"]( match { - it.time == deletedAt && + it.time.toString() == "2025-01-02T11:20:30.000001Z" && it.value == "urn:ngsi-ld:null" && it.measuredValue == null && it.payload.asString().matchContent( """ { "https://uri.etsi.org/ngsi-ld/deletedAt":[{ - "@value":"$deletedAt", + "@value":"2025-01-02T11:20:30.000001Z", "@type":"https://uri.etsi.org/ngsi-ld/DateTime" }], "https://uri.etsi.org/ngsi-ld/hasValue":[{ diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/temporal/service/TemporalQueryServiceTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/temporal/service/TemporalQueryServiceTests.kt index 21303c96b..2a3cc15d9 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/temporal/service/TemporalQueryServiceTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/temporal/service/TemporalQueryServiceTests.kt @@ -83,7 +83,7 @@ class TemporalQueryServiceTests { @Test fun `it should return an API exception if the entity does not exist`() = runTest { coEvery { - entityQueryService.checkEntityExistence(any()) + entityQueryService.retrieve(any(), any()) } returns ResourceNotFoundException(entityNotFoundMessage(entityUri.toString())).left() temporalQueryService.queryTemporalEntity( @@ -116,10 +116,9 @@ class TemporalQueryServiceTests { ) } - coEvery { entityQueryService.checkEntityExistence(any()) } returns Unit.right() + coEvery { entityQueryService.retrieve(any()) } returns gimmeEntityPayload().right() coEvery { authorizationService.userCanReadEntity(any(), any()) } returns Unit.right() coEvery { entityAttributeService.getForEntity(any(), any(), any(), any()) } returns attributes - coEvery { entityQueryService.retrieve(any()) } returns gimmeEntityPayload().right() coEvery { scopeService.retrieveHistory(any(), any()) } returns emptyList().right() coEvery { attributeInstanceService.search(any(), any>()) @@ -147,7 +146,7 @@ class TemporalQueryServiceTests { ) coVerify { - entityQueryService.checkEntityExistence(entityUri) + entityQueryService.retrieve(entityUri) authorizationService.userCanReadEntity(entityUri, None) entityAttributeService.getForEntity(entityUri, emptySet(), emptySet(), false) attributeInstanceService.search( diff --git a/shared/config/detekt/baseline.xml b/shared/config/detekt/baseline.xml index c359f0fb8..849a3ca3e 100644 --- a/shared/config/detekt/baseline.xml +++ b/shared/config/detekt/baseline.xml @@ -7,7 +7,6 @@ LongMethod:QueryUtils.kt$private fun transformQQueryToSqlJsonPath( mainAttributePath: List<ExpandedTerm>, trailingAttributePath: List<ExpandedTerm>, operator: String, value: String ) LongParameterList:ApiResponses.kt$( body: String, count: Int, resourceUrl: String, paginationQuery: PaginationQuery, requestParams: MultiValueMap<String, String>, mediaType: MediaType, contexts: List<String> ) LongParameterList:ApiResponses.kt$( entities: Any, count: Int, resourceUrl: String, paginationQuery: PaginationQuery, requestParams: MultiValueMap<String, String>, mediaType: MediaType, contexts: List<String> ) - SpreadOperator:EntityEvent.kt$EntityEvent$( *[ JsonSubTypes.Type(value = EntityCreateEvent::class), JsonSubTypes.Type(value = EntityReplaceEvent::class), JsonSubTypes.Type(value = EntityDeleteEvent::class), JsonSubTypes.Type(value = AttributeAppendEvent::class), JsonSubTypes.Type(value = AttributeReplaceEvent::class), JsonSubTypes.Type(value = AttributeUpdateEvent::class), JsonSubTypes.Type(value = AttributeDeleteEvent::class), JsonSubTypes.Type(value = AttributeDeleteAllInstancesEvent::class) ] ) TooManyFunctions:JsonLdUtils.kt$JsonLdUtils diff --git a/shared/src/main/kotlin/com/egm/stellio/shared/model/CompactedEntity.kt b/shared/src/main/kotlin/com/egm/stellio/shared/model/CompactedEntity.kt index 6eb784a24..cf9e8d562 100644 --- a/shared/src/main/kotlin/com/egm/stellio/shared/model/CompactedEntity.kt +++ b/shared/src/main/kotlin/com/egm/stellio/shared/model/CompactedEntity.kt @@ -6,6 +6,7 @@ import com.egm.stellio.shared.model.AttributeCompactedType.LANGUAGEPROPERTY import com.egm.stellio.shared.model.AttributeCompactedType.PROPERTY import com.egm.stellio.shared.model.AttributeCompactedType.RELATIONSHIP import com.egm.stellio.shared.model.AttributeCompactedType.VOCABPROPERTY +import com.egm.stellio.shared.model.AttributeCompactedType.entries import com.egm.stellio.shared.queryparameter.QueryParameter import com.egm.stellio.shared.util.FEATURES_PROPERTY_TERM import com.egm.stellio.shared.util.FEATURE_COLLECTION_TYPE @@ -23,6 +24,7 @@ import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_VOCAB_TERM import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_CREATED_AT_TERM import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_DATASET_ID_TERM import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_DATASET_TERM +import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_DELETED_AT_TERM import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_ENTITY_TERM import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_GEOPROPERTY_TERM import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_JSONPROPERTY_TERM @@ -39,8 +41,7 @@ import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_VOCABPROPERTY_TERM import com.egm.stellio.shared.util.PROPERTIES_PROPERTY_TERM import com.egm.stellio.shared.util.toUri import java.net.URI -import java.util.Locale -import kotlin.collections.Map +import java.util.* typealias CompactedEntity = Map typealias CompactedAttributeInstance = Map @@ -220,7 +221,8 @@ fun CompactedEntity.withoutSysAttrs(sysAttrToKeep: String?): Map { } return this.filter { - !sysAttrsToRemove.contains(it.key) + // deletedAt has to be kept at entity level (but not in attributes), see 5.8.6 + !sysAttrsToRemove.minus(NGSILD_DELETED_AT_TERM).contains(it.key) }.mapValues { when (it.value) { is Map<*, *> -> removeSysAttrsFromAttrInstance(it.value as Map<*, *>) diff --git a/shared/src/main/kotlin/com/egm/stellio/shared/model/EntityEvent.kt b/shared/src/main/kotlin/com/egm/stellio/shared/model/EntityEvent.kt index 1de2e0332..9005ce8ae 100644 --- a/shared/src/main/kotlin/com/egm/stellio/shared/model/EntityEvent.kt +++ b/shared/src/main/kotlin/com/egm/stellio/shared/model/EntityEvent.kt @@ -9,15 +9,14 @@ import java.net.URI @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "operationType") @JsonSubTypes( - *[ + value = [ JsonSubTypes.Type(value = EntityCreateEvent::class), JsonSubTypes.Type(value = EntityReplaceEvent::class), JsonSubTypes.Type(value = EntityDeleteEvent::class), JsonSubTypes.Type(value = AttributeAppendEvent::class), JsonSubTypes.Type(value = AttributeReplaceEvent::class), JsonSubTypes.Type(value = AttributeUpdateEvent::class), - JsonSubTypes.Type(value = AttributeDeleteEvent::class), - JsonSubTypes.Type(value = AttributeDeleteAllInstancesEvent::class) + JsonSubTypes.Type(value = AttributeDeleteEvent::class) ] ) sealed class EntityEvent( @@ -74,10 +73,11 @@ data class EntityDeleteEvent( override val entityId: URI, override val entityTypes: List, // null only when in the case of an IAM event (previous state is not known) - val deletedEntity: String?, + val previousEntity: String?, + val updatedEntity: String, override val contexts: List ) : EntityEvent(EventsType.ENTITY_DELETE, sub, tenantName, entityId, entityTypes, contexts) { - override fun getEntity() = this.deletedEntity + override fun getEntity() = this.previousEntity } @JsonTypeName("ATTRIBUTE_APPEND") @@ -88,7 +88,6 @@ data class AttributeAppendEvent( override val entityTypes: List, val attributeName: ExpandedTerm, val datasetId: URI?, - val overwrite: Boolean = true, val operationPayload: String, val updatedEntity: String, override val contexts: List @@ -144,20 +143,6 @@ data class AttributeDeleteEvent( override fun getAttribute() = this.attributeName } -@JsonTypeName("ATTRIBUTE_DELETE_ALL_INSTANCES") -data class AttributeDeleteAllInstancesEvent( - override val sub: String?, - override val tenantName: String = DEFAULT_TENANT_NAME, - override val entityId: URI, - override val entityTypes: List, - val attributeName: ExpandedTerm, - val updatedEntity: String, - override val contexts: List -) : EntityEvent(EventsType.ATTRIBUTE_DELETE_ALL_INSTANCES, sub, tenantName, entityId, entityTypes, contexts) { - override fun getEntity() = this.updatedEntity - override fun getAttribute() = this.attributeName -} - enum class EventsType { ENTITY_CREATE, ENTITY_REPLACE, @@ -165,8 +150,7 @@ enum class EventsType { ATTRIBUTE_APPEND, ATTRIBUTE_REPLACE, ATTRIBUTE_UPDATE, - ATTRIBUTE_DELETE, - ATTRIBUTE_DELETE_ALL_INSTANCES + ATTRIBUTE_DELETE } fun unhandledOperationType(operationType: EventsType): String = "Entity event $operationType not handled." diff --git a/shared/src/main/kotlin/com/egm/stellio/shared/model/ExpandedMembers.kt b/shared/src/main/kotlin/com/egm/stellio/shared/model/ExpandedMembers.kt index 7e53f6226..08242e58f 100644 --- a/shared/src/main/kotlin/com/egm/stellio/shared/model/ExpandedMembers.kt +++ b/shared/src/main/kotlin/com/egm/stellio/shared/model/ExpandedMembers.kt @@ -11,6 +11,7 @@ import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_CREATED_AT_PROPERTY import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_DATASET_ID_PROPERTY import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_DATE_TIME_TYPE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_DATE_TYPE +import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_DELETED_AT_PROPERTY import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_MODIFIED_AT_PROPERTY import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_PROPERTY_VALUE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_RELATIONSHIP_OBJECT @@ -91,8 +92,9 @@ fun ExpandedAttributeInstances.getSingleEntry(): ExpandedAttributeInstance { fun ExpandedAttributeInstance.addSysAttrs( withSysAttrs: Boolean, createdAt: ZonedDateTime, - modifiedAt: ZonedDateTime? -): Map = + modifiedAt: ZonedDateTime? = null, + deletedAt: ZonedDateTime? = null +): ExpandedAttributeInstance = if (withSysAttrs) this.plus(NGSILD_CREATED_AT_PROPERTY to buildNonReifiedTemporalValue(createdAt)) .let { @@ -100,6 +102,11 @@ fun ExpandedAttributeInstance.addSysAttrs( it.plus(NGSILD_MODIFIED_AT_PROPERTY to buildNonReifiedTemporalValue(modifiedAt)) else it } + .let { + if (deletedAt != null) + it.plus(NGSILD_DELETED_AT_PROPERTY to buildNonReifiedTemporalValue(deletedAt)) + else it + } else this /** diff --git a/shared/src/main/kotlin/com/egm/stellio/shared/util/JsonLdUtils.kt b/shared/src/main/kotlin/com/egm/stellio/shared/util/JsonLdUtils.kt index ffaeb7ad3..58681c694 100644 --- a/shared/src/main/kotlin/com/egm/stellio/shared/util/JsonLdUtils.kt +++ b/shared/src/main/kotlin/com/egm/stellio/shared/util/JsonLdUtils.kt @@ -120,7 +120,7 @@ object JsonLdUtils { const val NGSILD_CREATED_AT_TERM = "createdAt" const val NGSILD_MODIFIED_AT_TERM = "modifiedAt" - val NGSILD_SYSATTRS_TERMS = setOf(NGSILD_CREATED_AT_TERM, NGSILD_MODIFIED_AT_TERM) + val NGSILD_SYSATTRS_TERMS = setOf(NGSILD_CREATED_AT_TERM, NGSILD_MODIFIED_AT_TERM, NGSILD_DELETED_AT_TERM) const val NGSILD_CREATED_AT_PROPERTY = "https://uri.etsi.org/ngsi-ld/$NGSILD_CREATED_AT_TERM" const val NGSILD_MODIFIED_AT_PROPERTY = "https://uri.etsi.org/ngsi-ld/$NGSILD_MODIFIED_AT_TERM" val NGSILD_SYSATTRS_PROPERTIES = setOf(NGSILD_CREATED_AT_PROPERTY, NGSILD_MODIFIED_AT_PROPERTY) diff --git a/shared/src/test/kotlin/com/egm/stellio/shared/model/ExpandedMembersTests.kt b/shared/src/test/kotlin/com/egm/stellio/shared/model/ExpandedMembersTests.kt index 51e49eee4..d91d68a38 100644 --- a/shared/src/test/kotlin/com/egm/stellio/shared/model/ExpandedMembersTests.kt +++ b/shared/src/test/kotlin/com/egm/stellio/shared/model/ExpandedMembersTests.kt @@ -2,6 +2,7 @@ package com.egm.stellio.shared.model import com.egm.stellio.shared.util.JsonLdUtils import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_CREATED_AT_PROPERTY +import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_DELETED_AT_PROPERTY import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_MODIFIED_AT_PROPERTY import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_RELATIONSHIP_OBJECT import com.egm.stellio.shared.util.JsonLdUtils.buildExpandedPropertyValue @@ -30,6 +31,7 @@ class ExpandedMembersTests { assertThat(attrPayloadWithSysAttrs) .containsKey(NGSILD_CREATED_AT_PROPERTY) .doesNotContainKey(NGSILD_MODIFIED_AT_PROPERTY) + .doesNotContainKey(NGSILD_DELETED_AT_PROPERTY) } @Test @@ -41,6 +43,20 @@ class ExpandedMembersTests { assertThat(attrPayloadWithSysAttrs) .containsKey(NGSILD_CREATED_AT_PROPERTY) .containsKey(NGSILD_MODIFIED_AT_PROPERTY) + .doesNotContainKey(NGSILD_DELETED_AT_PROPERTY) + } + + @Test + fun `it should add createdAt, modifiedAt and deletedAt information into an attribute`() { + val attrPayload = mapOf("attribute" to buildExpandedPropertyValue(12.0)) + + val attrPayloadWithSysAttrs = + attrPayload.addSysAttrs(true, ngsiLdDateTime(), ngsiLdDateTime(), ngsiLdDateTime()) + + assertThat(attrPayloadWithSysAttrs) + .containsKey(NGSILD_CREATED_AT_PROPERTY) + .containsKey(NGSILD_MODIFIED_AT_PROPERTY) + .containsKey(NGSILD_DELETED_AT_PROPERTY) } @Test diff --git a/shared/src/test/kotlin/com/egm/stellio/shared/util/JsonUtilsTests.kt b/shared/src/test/kotlin/com/egm/stellio/shared/util/JsonUtilsTests.kt index ddad6db9b..5c3563fcb 100644 --- a/shared/src/test/kotlin/com/egm/stellio/shared/util/JsonUtilsTests.kt +++ b/shared/src/test/kotlin/com/egm/stellio/shared/util/JsonUtilsTests.kt @@ -1,6 +1,5 @@ package com.egm.stellio.shared.util -import com.egm.stellio.shared.model.AttributeDeleteAllInstancesEvent import com.egm.stellio.shared.model.AttributeDeleteEvent import com.egm.stellio.shared.model.AttributeReplaceEvent import com.egm.stellio.shared.model.AttributeUpdateEvent @@ -19,6 +18,7 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +import java.time.ZonedDateTime class JsonUtilsTests { @@ -114,14 +114,6 @@ class JsonUtilsTests { Assertions.assertTrue(parsedEvent is AttributeDeleteEvent) } - @Test - fun `it should parse an event of type ATTRIBUTE_DELETE_ALL_INSTANCES`() { - val parsedEvent = deserializeAs( - loadSampleData("events/entity/attributeDeleteAllInstancesEvent.json") - ) - Assertions.assertTrue(parsedEvent is AttributeDeleteAllInstancesEvent) - } - @Test fun `it should serialize an event of type ENTITY_CREATE`() = runTest { val event = mapper.writeValueAsString( @@ -146,6 +138,13 @@ class JsonUtilsTests { entityId, listOf(BEEHIVE_TYPE), serializeObject(expandJsonLdFragment(entityPayload, APIC_COMPOUND_CONTEXTS)), + serializeObject( + loadAndExpandDeletedEntity( + entityId, + ZonedDateTime.parse("2024-12-23T17:01:02Z"), + APIC_COMPOUND_CONTEXTS + ).members + ), emptyList() ) ) diff --git a/shared/src/testFixtures/kotlin/com/egm/stellio/shared/util/TestUtils.kt b/shared/src/testFixtures/kotlin/com/egm/stellio/shared/util/TestUtils.kt index 167737ce4..69723ecd9 100644 --- a/shared/src/testFixtures/kotlin/com/egm/stellio/shared/util/TestUtils.kt +++ b/shared/src/testFixtures/kotlin/com/egm/stellio/shared/util/TestUtils.kt @@ -14,6 +14,7 @@ import com.egm.stellio.shared.util.JsonLdUtils.expandJsonLdEntity import com.egm.stellio.shared.util.JsonUtils.deserializeAsMap import org.springframework.core.io.ClassPathResource import java.net.URI +import java.time.ZonedDateTime fun loadSampleData(filename: String = "beehive.jsonld"): String { val sampleData = ClassPathResource("/ngsild/$filename") @@ -86,6 +87,21 @@ suspend fun loadAndExpandMinimalEntity( contexts ) +suspend fun loadAndExpandDeletedEntity( + entityId: URI, + deletedAt: ZonedDateTime? = ngsiLdDateTime(), + contexts: List = APIC_COMPOUND_CONTEXTS +): ExpandedEntity = + expandJsonLdEntity( + """ + { + "id": "$entityId", + "deletedAt": "$deletedAt" + } + """.trimIndent(), + contexts + ) + suspend fun String.sampleDataToNgsiLdEntity(): Either> { val expandedEntity = expandJsonLdEntity(this) return when (val ngsiLdEntity = expandedEntity.toNgsiLdEntity()) { diff --git a/shared/src/testFixtures/resources/ngsild/events/authorization/RightAddOnEntity.json b/shared/src/testFixtures/resources/ngsild/events/authorization/RightAddOnEntity.json index 119a873df..bc05edeab 100644 --- a/shared/src/testFixtures/resources/ngsild/events/authorization/RightAddOnEntity.json +++ b/shared/src/testFixtures/resources/ngsild/events/authorization/RightAddOnEntity.json @@ -8,6 +8,5 @@ "contexts": [ "https://easy-global-market.github.io/ngsild-api-data-models/authorization/jsonld-contexts/authorization-compound.jsonld" ], - "overwrite": true, "operationType": "ATTRIBUTE_APPEND" } diff --git a/shared/src/testFixtures/resources/ngsild/events/authorization/SpecificAccessPolicyAddOnEntity.json b/shared/src/testFixtures/resources/ngsild/events/authorization/SpecificAccessPolicyAddOnEntity.json index 1fd771f3d..624312332 100644 --- a/shared/src/testFixtures/resources/ngsild/events/authorization/SpecificAccessPolicyAddOnEntity.json +++ b/shared/src/testFixtures/resources/ngsild/events/authorization/SpecificAccessPolicyAddOnEntity.json @@ -8,6 +8,5 @@ "contexts": [ "https://easy-global-market.github.io/ngsild-api-data-models/authorization/jsonld-contexts/authorization-compound.jsonld" ], - "overwrite": true, "operationType": "ATTRIBUTE_APPEND" } diff --git a/shared/src/testFixtures/resources/ngsild/events/authorization/UserDeleteEvent.json b/shared/src/testFixtures/resources/ngsild/events/authorization/UserDeleteEvent.json index 918acee3a..349e09b01 100644 --- a/shared/src/testFixtures/resources/ngsild/events/authorization/UserDeleteEvent.json +++ b/shared/src/testFixtures/resources/ngsild/events/authorization/UserDeleteEvent.json @@ -3,6 +3,7 @@ "tenantName": "urn:ngsi-ld:tenant:default", "entityId": "urn:ngsi-ld:User:6ad19fe0-fc11-4024-85f2-931c6fa6f7e0", "entityTypes": ["User"], + "updatedEntity": "{ \"https://uri.etsi.org/ngsi-ld/deletedAt\": [{ \"@type\": \"https://uri.etsi.org/ngsi-ld/DateTime\",\"@value\": \"2024-12-29T00:00:00Z\"} ], \"@id\": \"urn:ngsi-ld:User:6ad19fe0-fc11-4024-85f2-931c6fa6f7e0\" }]", "contexts": [ "https://easy-global-market.github.io/ngsild-api-data-models/authorization/jsonld-contexts/authorization-compound.jsonld" ] diff --git a/shared/src/testFixtures/resources/ngsild/events/entity/attributeDeleteAllInstancesEvent.json b/shared/src/testFixtures/resources/ngsild/events/entity/attributeDeleteAllInstancesEvent.json deleted file mode 100644 index f74eabc89..000000000 --- a/shared/src/testFixtures/resources/ngsild/events/entity/attributeDeleteAllInstancesEvent.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "tenantName": "urn:ngsi-ld:tenant:default", - "entityId" : "urn:ngsi-ld:BeeHive:01", - "entityTypes" : [ "https://ontology.eglobalmark.com/apic#BeeHive" ], - "attributeName" : "https://ontology.eglobalmark.com/apic#temperature", - "updatedEntity" : "{\"@id\":\"urn:ngsi-ld:BeeHive:01\",\"@type\":[\"https://ontology.eglobalmark.com/apic#BeeHive\"],\"https://ontology.eglobalmark.com/apic#humidity\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/Property\"],\"https://ontology.eglobalmark.com/egm#observedBy\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/Relationship\"],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\",\"@value\":\"2022-02-13T08:02:50.011609Z\"}],\"https://uri.etsi.org/ngsi-ld/hasObject\":[{\"@id\":\"urn:ngsi-ld:Sensor:02\"}]}],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\",\"@value\":\"2022-02-13T08:02:49.982113Z\"}],\"https://uri.etsi.org/ngsi-ld/hasValue\":[{\"@value\":60}],\"https://uri.etsi.org/ngsi-ld/observedAt\":[{\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\",\"@value\":\"2019-10-26T21:32:52.986010Z\"}],\"https://uri.etsi.org/ngsi-ld/unitCode\":[{\"@value\":\"P1\"}]}],\"https://ontology.eglobalmark.com/apic#luminosity\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/Property\"],\"https://ontology.eglobalmark.com/egm#observedBy\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/Relationship\"],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\",\"@value\":\"2022-02-13T08:02:57.017715Z\"}],\"https://uri.etsi.org/ngsi-ld/hasObject\":[{\"@id\":\"urn:ngsi-ld:Sensor:02\"}]}],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\",\"@value\":\"2022-02-13T08:02:56.987108Z\"}],\"https://uri.etsi.org/ngsi-ld/hasValue\":[{\"@value\":120}],\"https://uri.etsi.org/ngsi-ld/observedAt\":[{\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\",\"@value\":\"2022-02-02T21:32:52.986010Z\"}],\"https://uri.etsi.org/ngsi-ld/unitCode\":[{\"@value\":\"LUX\"}]}],\"https://ontology.eglobalmark.com/egm#belongs\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/Relationship\"],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\",\"@value\":\"2022-02-13T08:02:49.793453Z\"}],\"https://uri.etsi.org/ngsi-ld/hasObject\":[{\"@id\":\"urn:ngsi-ld:Apiary:01\"}]}],\"https://ontology.eglobalmark.com/egm#createdBy\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/Relationship\"],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\",\"@value\":\"2022-02-13T08:07:08.429628Z\"}],\"https://uri.etsi.org/ngsi-ld/hasObject\":[{\"@id\":\"urn:ngsi-ld:Craftman:01\"}]}],\"https://ontology.eglobalmark.com/egm#managedBy\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/Relationship\"],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\",\"@value\":\"2022-02-13T14:00:45.114035Z\"}],\"https://uri.etsi.org/ngsi-ld/hasObject\":[{\"@id\":\"urn:ngsi-ld:Beekeeper:02\"}],\"https://uri.etsi.org/ngsi-ld/modifiedAt\":[{\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\",\"@value\":\"2022-02-13T14:10:16.963869Z\"}]}],\"https://schema.org/name\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/Property\"],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\",\"@value\":\"2022-02-13T14:14:03.758379Z\"}],\"https://uri.etsi.org/ngsi-ld/hasValue\":[{\"@value\":\"Beehive - Biot\"}]}],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\",\"@value\":\"2022-02-13T08:02:49.359316Z\"}],\"https://uri.etsi.org/ngsi-ld/location\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/GeoProperty\"],\"https://uri.etsi.org/ngsi-ld/hasValue\":[{\"@value\":\"POINT (24.30623 60.07966)\"}]}],\"https://uri.etsi.org/ngsi-ld/modifiedAt\":[{\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\",\"@value\":\"2022-02-13T14:14:03.812339Z\"}]}", - "contexts" : [], - "operationType" : "ATTRIBUTE_DELETE_ALL_INSTANCES" -} diff --git a/shared/src/testFixtures/resources/ngsild/events/entity/entityDeleteEvent.json b/shared/src/testFixtures/resources/ngsild/events/entity/entityDeleteEvent.json index ed92fc60c..5b381132a 100644 --- a/shared/src/testFixtures/resources/ngsild/events/entity/entityDeleteEvent.json +++ b/shared/src/testFixtures/resources/ngsild/events/entity/entityDeleteEvent.json @@ -3,7 +3,8 @@ "tenantName": "urn:ngsi-ld:tenant:default", "entityId" : "urn:ngsi-ld:BeeHive:01", "entityTypes" : [ "https://ontology.eglobalmark.com/apic#BeeHive" ], - "deletedEntity":"{\"@id\":\"urn:ngsi-ld:BeeHive:01\",\"@type\":[\"https://ontology.eglobalmark.com/apic#BeeHive\"],\"https://ontology.eglobalmark.com/apic#humidity\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/Property\"],\"https://ontology.eglobalmark.com/egm#observedBy\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/Relationship\"],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@value\":\"2022-02-12T08:36:59.455870Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}],\"https://uri.etsi.org/ngsi-ld/hasObject\":[{\"@id\":\"urn:ngsi-ld:Sensor:02\"}]}],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@value\":\"2022-02-12T08:36:59.448205Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}],\"https://uri.etsi.org/ngsi-ld/hasValue\":[{\"@value\":60}],\"https://uri.etsi.org/ngsi-ld/observedAt\":[{\"@value\":\"2019-10-26T21:32:52.986010Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}],\"https://uri.etsi.org/ngsi-ld/unitCode\":[{\"@value\":\"P1\"}]}],\"https://ontology.eglobalmark.com/apic#temperature\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/Property\"],\"https://ontology.eglobalmark.com/egm#observedBy\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/Relationship\"],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@value\":\"2022-02-12T08:36:59.473904Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}],\"https://uri.etsi.org/ngsi-ld/hasObject\":[{\"@id\":\"urn:ngsi-ld:Sensor:01\"}]}],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@value\":\"2022-02-12T08:36:59.465937Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}],\"https://uri.etsi.org/ngsi-ld/hasValue\":[{\"@value\":22.2}],\"https://uri.etsi.org/ngsi-ld/observedAt\":[{\"@value\":\"2019-10-26T21:32:52.986010Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}],\"https://uri.etsi.org/ngsi-ld/unitCode\":[{\"@value\":\"CEL\"}]}],\"https://ontology.eglobalmark.com/egm#belongs\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/Relationship\"],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@value\":\"2022-02-12T08:36:59.389815Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}],\"https://uri.etsi.org/ngsi-ld/hasObject\":[{\"@id\":\"urn:ngsi-ld:Apiary:01\"}]}],\"https://ontology.eglobalmark.com/egm#managedBy\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/Relationship\"],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@value\":\"2022-02-12T08:36:59.417938Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}],\"https://uri.etsi.org/ngsi-ld/hasObject\":[{\"@id\":\"urn:ngsi-ld:Beekeeper:01\"}]}],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@value\":\"2022-02-12T08:36:59.179446Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}],\"https://uri.etsi.org/ngsi-ld/location\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/GeoProperty\"],\"https://uri.etsi.org/ngsi-ld/hasValue\":[{\"@value\":\"POINT (24.30623 60.07966)\"}]}],\"https://uri.etsi.org/ngsi-ld/modifiedAt\":[{\"@value\":\"2022-02-12T08:36:59.218595Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}]}", + "previousEntity":"{\"@id\":\"urn:ngsi-ld:BeeHive:01\",\"@type\":[\"https://ontology.eglobalmark.com/apic#BeeHive\"],\"https://ontology.eglobalmark.com/apic#humidity\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/Property\"],\"https://ontology.eglobalmark.com/egm#observedBy\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/Relationship\"],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@value\":\"2022-02-12T08:36:59.455870Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}],\"https://uri.etsi.org/ngsi-ld/hasObject\":[{\"@id\":\"urn:ngsi-ld:Sensor:02\"}]}],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@value\":\"2022-02-12T08:36:59.448205Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}],\"https://uri.etsi.org/ngsi-ld/hasValue\":[{\"@value\":60}],\"https://uri.etsi.org/ngsi-ld/observedAt\":[{\"@value\":\"2019-10-26T21:32:52.986010Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}],\"https://uri.etsi.org/ngsi-ld/unitCode\":[{\"@value\":\"P1\"}]}],\"https://ontology.eglobalmark.com/apic#temperature\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/Property\"],\"https://ontology.eglobalmark.com/egm#observedBy\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/Relationship\"],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@value\":\"2022-02-12T08:36:59.473904Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}],\"https://uri.etsi.org/ngsi-ld/hasObject\":[{\"@id\":\"urn:ngsi-ld:Sensor:01\"}]}],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@value\":\"2022-02-12T08:36:59.465937Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}],\"https://uri.etsi.org/ngsi-ld/hasValue\":[{\"@value\":22.2}],\"https://uri.etsi.org/ngsi-ld/observedAt\":[{\"@value\":\"2019-10-26T21:32:52.986010Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}],\"https://uri.etsi.org/ngsi-ld/unitCode\":[{\"@value\":\"CEL\"}]}],\"https://ontology.eglobalmark.com/egm#belongs\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/Relationship\"],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@value\":\"2022-02-12T08:36:59.389815Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}],\"https://uri.etsi.org/ngsi-ld/hasObject\":[{\"@id\":\"urn:ngsi-ld:Apiary:01\"}]}],\"https://ontology.eglobalmark.com/egm#managedBy\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/Relationship\"],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@value\":\"2022-02-12T08:36:59.417938Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}],\"https://uri.etsi.org/ngsi-ld/hasObject\":[{\"@id\":\"urn:ngsi-ld:Beekeeper:01\"}]}],\"https://uri.etsi.org/ngsi-ld/createdAt\":[{\"@value\":\"2022-02-12T08:36:59.179446Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}],\"https://uri.etsi.org/ngsi-ld/location\":[{\"@type\":[\"https://uri.etsi.org/ngsi-ld/GeoProperty\"],\"https://uri.etsi.org/ngsi-ld/hasValue\":[{\"@value\":\"POINT (24.30623 60.07966)\"}]}],\"https://uri.etsi.org/ngsi-ld/modifiedAt\":[{\"@value\":\"2022-02-12T08:36:59.218595Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}]}", + "updatedEntity":"{\"@id\":\"urn:ngsi-ld:BeeHive:01\",\"https://uri.etsi.org/ngsi-ld/deletedAt\":[{\"@value\":\"2024-12-23T17:01:02Z\",\"@type\":\"https://uri.etsi.org/ngsi-ld/DateTime\"}]}", "contexts" : [], "operationType" : "ENTITY_DELETE" } diff --git a/subscription-service/src/main/kotlin/com/egm/stellio/subscription/listener/EntityEventListenerService.kt b/subscription-service/src/main/kotlin/com/egm/stellio/subscription/listener/EntityEventListenerService.kt index d4891cd73..d815ec65e 100644 --- a/subscription-service/src/main/kotlin/com/egm/stellio/subscription/listener/EntityEventListenerService.kt +++ b/subscription-service/src/main/kotlin/com/egm/stellio/subscription/listener/EntityEventListenerService.kt @@ -4,7 +4,6 @@ import arrow.core.Either import arrow.core.raise.either import com.egm.stellio.shared.model.APIException import com.egm.stellio.shared.model.AttributeAppendEvent -import com.egm.stellio.shared.model.AttributeDeleteAllInstancesEvent import com.egm.stellio.shared.model.AttributeDeleteEvent import com.egm.stellio.shared.model.AttributeReplaceEvent import com.egm.stellio.shared.model.AttributeUpdateEvent @@ -19,7 +18,6 @@ import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_EXPANDED_ENTITY_CORE_MEMBE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_SYSATTRS_PROPERTIES import com.egm.stellio.shared.util.JsonUtils.deserializeAs import com.egm.stellio.shared.util.JsonUtils.deserializeAsMap -import com.egm.stellio.shared.util.JsonUtils.serializeObject import com.egm.stellio.shared.web.NGSILD_TENANT_HEADER import com.egm.stellio.subscription.model.NotificationTrigger import com.egm.stellio.subscription.service.NotificationService @@ -63,51 +61,45 @@ class EntityEventListenerService( is EntityCreateEvent -> handleEntityEvent( tenantName, entityEvent.operationPayload.getUpdatedAttributes(), - entityEvent.getEntity(), + Pair(entityEvent.getEntity(), entityEvent.getEntity()), NotificationTrigger.ENTITY_CREATED ) is EntityReplaceEvent -> entityEvent.operationPayload.getUpdatedAttributes().forEach { attribute -> handleEntityEvent( tenantName, setOf(attribute), - entityEvent.getEntity(), + Pair(entityEvent.getEntity(), entityEvent.getEntity()), NotificationTrigger.ATTRIBUTE_CREATED ) } is EntityDeleteEvent -> handleEntityEvent( tenantName, - entityEvent.deletedEntity?.getUpdatedAttributes() ?: emptySet(), - entityEvent.getEntity() ?: serializeObject(emptyMap()), + emptySet(), + Pair(entityEvent.getEntity()!!, entityEvent.updatedEntity), NotificationTrigger.ENTITY_DELETED ) is AttributeAppendEvent -> handleEntityEvent( tenantName, setOf(entityEvent.attributeName), - entityEvent.getEntity(), + Pair(entityEvent.getEntity(), entityEvent.getEntity()), NotificationTrigger.ATTRIBUTE_CREATED ) is AttributeReplaceEvent -> handleEntityEvent( tenantName, setOf(entityEvent.attributeName), - entityEvent.getEntity(), + Pair(entityEvent.getEntity(), entityEvent.getEntity()), NotificationTrigger.ATTRIBUTE_UPDATED ) is AttributeUpdateEvent -> handleEntityEvent( tenantName, setOf(entityEvent.attributeName), - entityEvent.getEntity(), + Pair(entityEvent.getEntity(), entityEvent.getEntity()), NotificationTrigger.ATTRIBUTE_UPDATED ) is AttributeDeleteEvent -> handleEntityEvent( tenantName, setOf(entityEvent.attributeName), - entityEvent.getEntity(), - NotificationTrigger.ATTRIBUTE_DELETED - ) - is AttributeDeleteAllInstancesEvent -> handleEntityEvent( - tenantName, - setOf(entityEvent.attributeName), - entityEvent.getEntity(), + Pair(entityEvent.getEntity(), entityEvent.getEntity()), NotificationTrigger.ATTRIBUTE_DELETED ) } @@ -119,14 +111,15 @@ class EntityEventListenerService( private suspend fun handleEntityEvent( tenantName: String, updatedAttributes: Set, - entityPayload: String, + previousAndUpdatedPayloads: Pair, notificationTrigger: NotificationTrigger ): Either = either { logger.debug("Attributes considered in the event: {}", updatedAttributes) - val expandedEntity = ExpandedEntity(entityPayload.deserializeAsMap()) + val expandedEntityForMatching = ExpandedEntity(previousAndUpdatedPayloads.first.deserializeAsMap()) + val expandedEntityForNotification = ExpandedEntity(previousAndUpdatedPayloads.second.deserializeAsMap()) mono { notificationService.notifyMatchingSubscribers( - expandedEntity, + Pair(expandedEntityForMatching, expandedEntityForNotification), updatedAttributes, notificationTrigger ) @@ -139,10 +132,8 @@ class EntityEventListenerService( else logger.error("Error when trying to notifiy subscribers: {}", it.message, it) }, { results -> - val totalNotifications = results.size - val succeeded = results.count { it.third } - val failed = results.count { !it.third } - logger.debug("Notified $totalNotifications subscribers (success : $succeeded / failure : $failed)") + val (succeeded, failed) = results.partition { it.third }.let { Pair(it.first.size, it.second.size) } + logger.debug("Notified ${succeeded + failed} subscribers (success : $succeeded / failure : $failed)") }) } } diff --git a/subscription-service/src/main/kotlin/com/egm/stellio/subscription/service/NotificationService.kt b/subscription-service/src/main/kotlin/com/egm/stellio/subscription/service/NotificationService.kt index 1a054858c..ddda29ca1 100644 --- a/subscription-service/src/main/kotlin/com/egm/stellio/subscription/service/NotificationService.kt +++ b/subscription-service/src/main/kotlin/com/egm/stellio/subscription/service/NotificationService.kt @@ -38,13 +38,17 @@ class NotificationService( private val logger = LoggerFactory.getLogger(javaClass) suspend fun notifyMatchingSubscribers( - expandedEntity: ExpandedEntity, + previousAndUpdatedExpandedEntities: Pair, updatedAttributes: Set, notificationTrigger: NotificationTrigger ): Either>> = either { - subscriptionService.getMatchingSubscriptions(expandedEntity, updatedAttributes, notificationTrigger).bind() + subscriptionService.getMatchingSubscriptions( + previousAndUpdatedExpandedEntities.first, + updatedAttributes, + notificationTrigger + ).bind() .map { - val filteredEntity = expandedEntity.filterAttributes( + val filteredEntity = previousAndUpdatedExpandedEntities.second.filterAttributes( it.notification.attributes?.toSet().orEmpty(), it.datasetId?.toSet().orEmpty() ) diff --git a/subscription-service/src/test/kotlin/com/egm/stellio/subscription/service/NotificationServiceTests.kt b/subscription-service/src/test/kotlin/com/egm/stellio/subscription/service/NotificationServiceTests.kt index 90ea23eee..83dc4fd46 100644 --- a/subscription-service/src/test/kotlin/com/egm/stellio/subscription/service/NotificationServiceTests.kt +++ b/subscription-service/src/test/kotlin/com/egm/stellio/subscription/service/NotificationServiceTests.kt @@ -136,7 +136,7 @@ class NotificationServiceTests { ) notificationService.notifyMatchingSubscribers( - expandedEntity, + Pair(expandedEntity, expandedEntity), setOf(NGSILD_NAME_PROPERTY), ATTRIBUTE_UPDATED ).shouldSucceedWith { @@ -179,7 +179,7 @@ class NotificationServiceTests { ) notificationService.notifyMatchingSubscribers( - expandedEntity, + Pair(expandedEntity, expandedEntity), setOf(NGSILD_NAME_PROPERTY), ATTRIBUTE_UPDATED ).shouldSucceedWith { @@ -224,7 +224,7 @@ class NotificationServiceTests { ) notificationService.notifyMatchingSubscribers( - expandedEntity, + Pair(expandedEntity, expandedEntity), setOf(NGSILD_NAME_PROPERTY), ATTRIBUTE_UPDATED ).shouldSucceedWith { notificationResults -> @@ -265,7 +265,7 @@ class NotificationServiceTests { ) notificationService.notifyMatchingSubscribers( - expandedEntity, + Pair(expandedEntity, expandedEntity), setOf(NGSILD_NAME_TERM), ATTRIBUTE_UPDATED ).shouldSucceedWith { notificationResults -> @@ -295,7 +295,7 @@ class NotificationServiceTests { ) notificationService.notifyMatchingSubscribers( - expandedEntity, + Pair(expandedEntity, expandedEntity), setOf(NGSILD_NAME_PROPERTY), ATTRIBUTE_UPDATED ).shouldSucceedWith { @@ -333,7 +333,7 @@ class NotificationServiceTests { ) notificationService.notifyMatchingSubscribers( - expandedEntity, + Pair(expandedEntity, expandedEntity), setOf(NGSILD_NAME_PROPERTY), ATTRIBUTE_DELETED ).shouldSucceedWith { @@ -379,7 +379,7 @@ class NotificationServiceTests { ) notificationService.notifyMatchingSubscribers( - expandedEntity, + Pair(expandedEntity, expandedEntity), setOf(NGSILD_NAME_PROPERTY), ATTRIBUTE_CREATED ).shouldSucceedWith { results -> @@ -540,7 +540,7 @@ class NotificationServiceTests { ) notificationService.notifyMatchingSubscribers( - expandedEntity, + Pair(expandedEntity, expandedEntity), setOf(NGSILD_NAME_PROPERTY), ATTRIBUTE_UPDATED ).shouldSucceedWith { @@ -582,7 +582,7 @@ class NotificationServiceTests { ) notificationService.notifyMatchingSubscribers( - expandedEntity, + Pair(expandedEntity, expandedEntity), setOf(NGSILD_NAME_PROPERTY), ATTRIBUTE_UPDATED ).shouldSucceedWith { @@ -641,7 +641,7 @@ class NotificationServiceTests { ) notificationService.notifyMatchingSubscribers( - expandedEntity, + Pair(expandedEntity, expandedEntity), setOf(FRIENDLYNAME_LANGUAGEPROPERTY), ATTRIBUTE_UPDATED ).shouldSucceedWith { diff --git a/subscription-service/src/test/kotlin/com/egm/stellio/subscription/service/SubscriptionServiceTests.kt b/subscription-service/src/test/kotlin/com/egm/stellio/subscription/service/SubscriptionServiceTests.kt index e886eba82..1e22ab14b 100644 --- a/subscription-service/src/test/kotlin/com/egm/stellio/subscription/service/SubscriptionServiceTests.kt +++ b/subscription-service/src/test/kotlin/com/egm/stellio/subscription/service/SubscriptionServiceTests.kt @@ -70,7 +70,7 @@ import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.TestPropertySource import java.net.URI import java.time.ZonedDateTime -import java.util.UUID +import java.util.* import kotlin.time.Duration @SpringBootTest @@ -347,8 +347,8 @@ class SubscriptionServiceTests : WithTimescaleContainer, WithKafkaContainer() { it.notification.endpoint.uri == URI("http://localhost:8084") && it.notification.endpoint.accept == Endpoint.AcceptType.JSON && it.entities != null && - it.entities!!.size == 1 && - it.entities!!.all { entitySelector -> entitySelector.typeSelection == BEEHIVE_TYPE } && + it.entities.size == 1 && + it.entities.all { entitySelector -> entitySelector.typeSelection == BEEHIVE_TYPE } && it.watchedAttributes == null && it.isActive } @@ -383,10 +383,10 @@ class SubscriptionServiceTests : WithTimescaleContainer, WithKafkaContainer() { it.id == "urn:ngsi-ld:Subscription:1".toUri() && it.subscriptionName == "A subscription with all possible members" && it.description == "A possible description" && it.entities != null && - it.entities!!.size == 3 && - it.entities!!.all { it.typeSelection == BEEHIVE_TYPE } && - it.entities!!.any { it.id == "urn:ngsi-ld:Beehive:1234567890".toUri() } && - it.entities!!.any { it.idPattern == "urn:ngsi-ld:Beehive:1234*" } && + it.entities.size == 3 && + it.entities.all { it.typeSelection == BEEHIVE_TYPE } && + it.entities.any { it.id == "urn:ngsi-ld:Beehive:1234567890".toUri() } && + it.entities.any { it.idPattern == "urn:ngsi-ld:Beehive:1234*" } && it.watchedAttributes == listOf(INCOMING_PROPERTY) && it.notificationTrigger == listOf( ENTITY_CREATED.notificationTrigger, @@ -395,11 +395,11 @@ class SubscriptionServiceTests : WithTimescaleContainer, WithKafkaContainer() { ) && it.timeInterval == null && it.q == "foodQuantity<150;foodName=='dietary fibres'" && it.geoQ != null && - it.geoQ!!.georel == "within" && - it.geoQ!!.geometry == "Polygon" && - it.geoQ!!.coordinates == + it.geoQ.georel == "within" && + it.geoQ.geometry == "Polygon" && + it.geoQ.coordinates == "[[[100.0, 0.0], [101.0, 0.0], [101.0, 1.0], [100.0, 1.0], [100.0, 0.0]]]" && - it.geoQ!!.geoproperty == NGSILD_LOCATION_PROPERTY && + it.geoQ.geoproperty == NGSILD_LOCATION_PROPERTY && it.scopeQ == "/Nantes/+" && it.notification.attributes == listOf(INCOMING_PROPERTY, OUTGOING_PROPERTY) && it.notification.format == FormatType.NORMALIZED && @@ -438,9 +438,9 @@ class SubscriptionServiceTests : WithTimescaleContainer, WithKafkaContainer() { assertThat(persistedSubscription) .matches { it.notification.lastNotification != null && - it.notification.lastNotification!!.isEqual(notifiedAt) && + it.notification.lastNotification.isEqual(notifiedAt) && it.notification.lastSuccess != null && - it.notification.lastSuccess!!.isEqual(notifiedAt) && + it.notification.lastSuccess.isEqual(notifiedAt) && it.notification.lastFailure == null && it.notification.timesSent == 1 && it.notification.status == StatusType.OK @@ -788,6 +788,27 @@ class SubscriptionServiceTests : WithTimescaleContainer, WithKafkaContainer() { } } + @Test + fun `it should retrieve a subscription with entityDeleted trigger matched with an entity delete event`() = + runTest { + val subscription = gimmeSubscriptionFromMembers( + mapOf( + "entities" to listOf(mapOf("type" to BEEHIVE_COMPACT_TYPE)), + "notificationTrigger" to listOf(ENTITY_DELETED.notificationTrigger) + ) + ) + subscriptionService.create(subscription, mockUserSub).shouldSucceed() + + val expandedEntity = loadAndExpandMinimalEntity("urn:ngsi-ld:Beehive:1234567890", BEEHIVE_COMPACT_TYPE) + subscriptionService.getMatchingSubscriptions( + expandedEntity, + emptySet(), + ENTITY_DELETED + ).shouldSucceedWith { + assertEquals(1, it.size) + } + } + @Test fun `it should update a subscription`() = runTest { val subscription = loadAndDeserializeSubscription("subscription_minimal_entities.json") @@ -821,9 +842,9 @@ class SubscriptionServiceTests : WithTimescaleContainer, WithKafkaContainer() { it.watchedAttributes!! == listOf(INCOMING_PROPERTY, TEMPERATURE_PROPERTY) && it.scopeQ == "/A/#,/B" && it.geoQ!!.georel == "equals" && - it.geoQ!!.geometry == "Point" && - it.geoQ!!.coordinates == "[100.0, 0.0]" && - it.geoQ!!.geoproperty == "https://uri.etsi.org/ngsi-ld/observationSpace" && + it.geoQ.geometry == "Point" && + it.geoQ.coordinates == "[100.0, 0.0]" && + it.geoQ.geoproperty == "https://uri.etsi.org/ngsi-ld/observationSpace" && it.throttling == 50 && it.lang == "fr-CH,fr" } @@ -883,21 +904,21 @@ class SubscriptionServiceTests : WithTimescaleContainer, WithKafkaContainer() { assertThat(updatedSubscription) .matches { it.entities != null && - it.entities!!.contains( + it.entities.contains( EntitySelector( id = "urn:ngsi-ld:Beehive:123".toUri(), idPattern = null, typeSelection = BEEHIVE_TYPE ) ) && - it.entities!!.contains( + it.entities.contains( EntitySelector( id = null, idPattern = "urn:ngsi-ld:Beehive:12*", typeSelection = BEEHIVE_TYPE ) ) && - it.entities!!.size == 2 + it.entities.size == 2 } }