diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/common/config/SearchProperties.kt b/search-service/src/main/kotlin/com/egm/stellio/search/common/config/SearchProperties.kt index 9c7528233..c6954b053 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/common/config/SearchProperties.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/common/config/SearchProperties.kt @@ -5,5 +5,6 @@ import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties("search") data class SearchProperties( val payloadMaxBodySize: Int, - var onOwnerDeleteCascadeEntities: Boolean + val onOwnerDeleteCascadeEntities: Boolean, + val timezoneForTimeBuckets: String = "GMT" ) 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 7f41a4a33..77b9a105a 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 @@ -4,6 +4,7 @@ import arrow.core.Either import arrow.core.left import arrow.core.raise.either import arrow.core.right +import com.egm.stellio.search.common.config.SearchProperties import com.egm.stellio.search.common.util.* import com.egm.stellio.search.entity.model.* import com.egm.stellio.search.entity.model.Attribute.AttributeValueType @@ -29,7 +30,8 @@ import java.time.ZonedDateTime @Service class ScopeService( - private val databaseClient: DatabaseClient + private val databaseClient: DatabaseClient, + private val searchProperties: SearchProperties ) { @Transactional @@ -150,7 +152,7 @@ class ScopeService( val computedOrigin = origin ?: temporalQuery.timeAt """ SELECT entity_id, - public.time_bucket('$aggrPeriodDuration', time, TIMESTAMPTZ '${computedOrigin!!}') as start, + public.time_bucket('$aggrPeriodDuration', time, '${searchProperties.timezoneForTimeBuckets}', TIMESTAMPTZ '${computedOrigin!!}') as start, $allAggregates """ } else diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/temporal/service/AttributeInstanceService.kt b/search-service/src/main/kotlin/com/egm/stellio/search/temporal/service/AttributeInstanceService.kt index ba6b53544..c8f4f69d8 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/temporal/service/AttributeInstanceService.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/temporal/service/AttributeInstanceService.kt @@ -5,6 +5,7 @@ import arrow.core.left import arrow.core.raise.either import arrow.core.right import arrow.fx.coroutines.parMap +import com.egm.stellio.search.common.config.SearchProperties import com.egm.stellio.search.common.util.* import com.egm.stellio.search.entity.model.Attribute import com.egm.stellio.search.entity.model.AttributeMetadata @@ -28,7 +29,8 @@ import java.util.UUID @Service class AttributeInstanceService( - private val databaseClient: DatabaseClient + private val databaseClient: DatabaseClient, + private val searchProperties: SearchProperties ) { private val attributesInstancesTables = listOf("attribute_instance", "attribute_instance_audit") @@ -223,7 +225,7 @@ class AttributeInstanceService( val computedOrigin = origin ?: temporalQuery.timeAt """ SELECT temporal_entity_attribute, - public.time_bucket('$aggrPeriodDuration', time, TIMESTAMPTZ '${computedOrigin!!}') as start, + public.time_bucket('$aggrPeriodDuration', time, '${searchProperties.timezoneForTimeBuckets}', TIMESTAMPTZ '${computedOrigin!!}') as start, $allAggregates """.trimIndent() } else diff --git a/search-service/src/main/resources/application.properties b/search-service/src/main/resources/application.properties index ddfee6a89..57d650dd1 100644 --- a/search-service/src/main/resources/application.properties +++ b/search-service/src/main/resources/application.properties @@ -15,3 +15,7 @@ server.port = 8083 search.payload-max-body-size = 2048000 search.on-owner-delete-cascade-entities = false +# by default, bucket start times are aligned at 00:00:00UTC +# https://docs.timescale.com/use-timescale/latest/time-buckets/about-time-buckets/#timezones +# this property allows to align them different timezones which is useful for weekly or monthly aggregates for instance +search.timezone-for-time-buckets = GMT diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/temporal/service/AggregatedTemporalQueryServiceTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/temporal/service/AggregatedTemporalQueryServiceTests.kt index 859aca541..e3355efef 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/temporal/service/AggregatedTemporalQueryServiceTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/temporal/service/AggregatedTemporalQueryServiceTests.kt @@ -1,11 +1,14 @@ package com.egm.stellio.search.temporal.service +import com.egm.stellio.search.common.config.SearchProperties import com.egm.stellio.search.entity.model.Attribute import com.egm.stellio.search.entity.service.EntityAttributeService import com.egm.stellio.search.support.* import com.egm.stellio.search.temporal.model.* import com.egm.stellio.shared.model.OperationNotSupportedException import com.egm.stellio.shared.util.* +import com.ninjasquad.springmockk.MockkBean +import io.mockk.every import kotlinx.coroutines.test.runTest import org.assertj.core.api.AbstractObjectAssert import org.assertj.core.api.Assertions @@ -13,6 +16,7 @@ import org.assertj.core.api.InstanceOfAssertFactories import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource @@ -39,10 +43,18 @@ class AggregatedTemporalQueryServiceTests : WithTimescaleContainer, WithKafkaCon @Autowired private lateinit var r2dbcEntityTemplate: R2dbcEntityTemplate + @MockkBean(relaxed = true) + private lateinit var searchProperties: SearchProperties + private val now = ngsiLdDateTime() private val attributeUuid = UUID.randomUUID() private val entityId = "urn:ngsi-ld:BeeHive:${UUID.randomUUID()}".toUri() + @BeforeEach + fun mockSearchProperties() { + every { searchProperties.timezoneForTimeBuckets } returns "GMT" + } + @AfterEach fun clearAttributesInstances() { r2dbcEntityTemplate.delete(AttributeInstance::class.java) @@ -428,6 +440,36 @@ class AggregatedTemporalQueryServiceTests : WithTimescaleContainer, WithKafkaCon } } + @Test + fun `it should aggregate using the specified timezone`() = runTest { + // set the timezone to Europe/Paris to have all the results aggregated on January 2024 + every { searchProperties.timezoneForTimeBuckets } returns "Europe/Paris" + + val attribute = createAttribute(Attribute.AttributeValueType.NUMBER) + val startTimestamp = ZonedDateTime.parse("2023-12-31T23:00:00Z") + (0..9).forEach { i -> + val attributeInstance = gimmeNumericPropertyAttributeInstance(attributeUuid) + .copy( + time = startTimestamp.plusHours(i.toLong()), + measuredValue = 1.0 + ) + attributeInstanceService.create(attributeInstance) + } + + val temporalEntitiesQuery = createTemporalEntitiesQuery("sum", "P1M") + attributeInstanceService.search( + temporalEntitiesQuery.copy( + temporalQuery = temporalEntitiesQuery.temporalQuery.copy(timeAt = startTimestamp) + ), + attribute, + startTimestamp + ) + .shouldSucceedWith { results -> + assertEquals(1, results.size) + assertEquals(10.0, (results[0] as AggregatedAttributeInstanceResult).values[0].value) + } + } + private suspend fun createAttribute( attributeValueType: Attribute.AttributeValueType ): Attribute { 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 44998267f..1e59a071d 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 @@ -1,5 +1,6 @@ package com.egm.stellio.search.temporal.service +import com.egm.stellio.search.common.config.SearchProperties import com.egm.stellio.search.entity.model.Attribute import com.egm.stellio.search.entity.model.AttributeMetadata import com.egm.stellio.search.entity.service.EntityAttributeService @@ -61,6 +62,9 @@ class AttributeInstanceServiceTests : WithTimescaleContainer, WithKafkaContainer @Autowired private lateinit var databaseClient: DatabaseClient + @Autowired + private lateinit var searchProperties: SearchProperties + @Autowired private lateinit var r2dbcEntityTemplate: R2dbcEntityTemplate @@ -562,7 +566,10 @@ class AttributeInstanceServiceTests : WithTimescaleContainer, WithKafkaContainer @Test fun `it should create an attribute instance if it has a non null value`() = runTest { - val attributeInstanceService = spyk(AttributeInstanceService(databaseClient), recordPrivateCalls = true) + val attributeInstanceService = spyk( + AttributeInstanceService(databaseClient, searchProperties), + recordPrivateCalls = true + ) val attributeMetadata = AttributeMetadata( measuredValue = 550.0, value = null, @@ -621,7 +628,10 @@ class AttributeInstanceServiceTests : WithTimescaleContainer, WithKafkaContainer @Test fun `it should create an attribute instance with boolean value`() = runTest { - val attributeInstanceService = spyk(AttributeInstanceService(databaseClient), recordPrivateCalls = true) + val attributeInstanceService = spyk( + AttributeInstanceService(databaseClient, searchProperties), + recordPrivateCalls = true + ) val attributeMetadata = AttributeMetadata( measuredValue = null, value = false.toString(),