Skip to content

Commit

Permalink
feat: add support for the format parameter (#1299)
Browse files Browse the repository at this point in the history
* feat: add support for the format parameter

* Update search-service/src/main/kotlin/com/egm/stellio/search/temporal/util/TemporalQueryUtils.kt

Co-authored-by: Thomas Bousselin <[email protected]>

* Update search-service/src/main/kotlin/com/egm/stellio/search/temporal/util/TemporalQueryUtils.kt

Co-authored-by: Thomas Bousselin <[email protected]>

* fix: commit suggestion

* fix: PR comments + other changes

* Update search-service/src/test/kotlin/com/egm/stellio/search/temporal/web/TemporalEntityHandlerTests.kt

Co-authored-by: Benoit Orihuela <[email protected]>

* fix: change temporal representations to an enum

* Update search-service/src/main/kotlin/com/egm/stellio/search/temporal/util/TemporalQueryUtils.kt

Co-authored-by: Benoit Orihuela <[email protected]>

* feat: return exceptions for invalid format and options

* fix: resolve conflicts with remote

* fix: more conflicts with develop

* Update shared/src/test/kotlin/com/egm/stellio/shared/model/NgsiLdDataRepresentationTests.kt

Co-authored-by: Benoit Orihuela <[email protected]>

* fix: PR comments + suggestions

* fix: new conflicts with develop

* fix: new conflicts with develop

* fix detekt and sonar issues

* remove unnecessary default value

---------

Co-authored-by: Thomas Bousselin <[email protected]>
Co-authored-by: Benoit Orihuela <[email protected]>
  • Loading branch information
3 people authored Jan 10, 2025
1 parent a6b8b37 commit ffb4fc0
Show file tree
Hide file tree
Showing 36 changed files with 620 additions and 236 deletions.
5 changes: 2 additions & 3 deletions search-service/config/detekt/baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,13 @@
<ID>LongMethod:EnabledAuthorizationServiceTests.kt$EnabledAuthorizationServiceTests$@Test fun `it should return serialized access control entities with other rigths if user is owner`()</ID>
<ID>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&lt;String&gt;, @AllowedParameters @RequestParam queryParams: MultiValueMap&lt;String, String&gt; ): ResponseEntity&lt;*&gt;</ID>
<ID>LongMethod:EntityEventService.kt$EntityEventService$private fun publishAttributeChangeEvent( sub: String?, tenantName: String, entityId: URI, entityTypesAndPayload: Pair&lt;List&lt;ExpandedTerm&gt;, String&gt;, attributeOperationResult: SucceededAttributeOperationResult )</ID>
<ID>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&lt;String, String&gt; ): ResponseEntity&lt;*&gt;</ID>
<ID>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&lt;String, String&gt; ): ResponseEntity&lt;*&gt;</ID>
<ID>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.FORMAT, QP.TYPE, QP.ATTRS, QP.GEOMETRY_PROPERTY, QP.LANG, QP.CONTAINED_BY, QP.JOIN, QP.JOIN_LEVEL, QP.DATASET_ID, ], notImplemented = [QP.PICK, QP.OMIT, QP.ENTITY_MAP, QP.LOCAL, QP.VIA] ) @RequestParam queryParams: MultiValueMap&lt;String, String&gt; ): ResponseEntity&lt;*&gt;</ID>
<ID>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.FORMAT, 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.PICK, QP.OMIT, QP.EXPAND_VALUES, QP.CSF, QP.ENTITY_MAP, QP.LOCAL, QP.VIA] ) @RequestParam queryParams: MultiValueMap&lt;String, String&gt; ): ResponseEntity&lt;*&gt;</ID>
<ID>LongMethod:LinkedEntityServiceTests.kt$LinkedEntityServiceTests$@Test fun `it should inline entities up to the asked 2nd level`()</ID>
<ID>LongMethod:PatchAttributeTests.kt$PatchAttributeTests.Companion$@JvmStatic fun mergePatchProvider(): Stream&lt;Arguments&gt;</ID>
<ID>LongMethod:PatchAttributeTests.kt$PatchAttributeTests.Companion$@JvmStatic fun partialUpdatePatchProvider(): Stream&lt;Arguments&gt;</ID>
<ID>LongMethod:TemporalQueryServiceTests.kt$TemporalQueryServiceTests$@Test fun `it should query temporal entities as requested by query params`()</ID>
<ID>LongMethod:TemporalQueryServiceTests.kt$TemporalQueryServiceTests$@Test fun `it should return an empty list for an attribute if it has no temporal values`()</ID>
<ID>LongMethod:TemporalScopeBuilderTests.kt$TemporalScopeBuilderTests$@Test fun `it should build an aggregated temporal representation of scopes`()</ID>
<ID>LongMethod:V0_29__JsonLd_migration.kt$V0_29__JsonLd_migration$override fun migrate(context: Context)</ID>
<ID>LongParameterList:AttributeInstance.kt$AttributeInstance.Companion$( attributeUuid: UUID, instanceId: URI = generateRandomInstanceId(), timeAndProperty: Pair&lt;ZonedDateTime, TemporalProperty&gt;, value: Triple&lt;String?, Double?, WKTCoordinates?&gt;, payload: ExpandedAttributeInstance, sub: String? )</ID>
<ID>LongParameterList:AttributeInstance.kt$AttributeInstance.Companion$( attributeUuid: UUID, instanceId: URI = generateRandomInstanceId(), timeProperty: TemporalProperty? = TemporalProperty.OBSERVED_AT, modifiedAt: ZonedDateTime? = null, attributeMetadata: AttributeMetadata, payload: ExpandedAttributeInstance, time: ZonedDateTime, sub: String? = null )</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ class EntityAccessControlHandler(

val compactedEntities = compactEntities(entities, contexts)

val ngsiLdDataRepresentation = parseRepresentations(queryParams, mediaType)
val ngsiLdDataRepresentation = parseRepresentations(queryParams, mediaType).bind()
buildQueryResponse(
compactedEntities.toFinalRepresentation(ngsiLdDataRepresentation),
count,
Expand Down Expand Up @@ -150,7 +150,7 @@ class EntityAccessControlHandler(

val compactedEntities = compactEntities(entities, contexts)

val ngsiLdDataRepresentation = parseRepresentations(params, mediaType)
val ngsiLdDataRepresentation = parseRepresentations(params, mediaType).bind()
buildQueryResponse(
compactedEntities.toFinalRepresentation(ngsiLdDataRepresentation),
count,
Expand Down Expand Up @@ -196,7 +196,7 @@ class EntityAccessControlHandler(

val compactedEntities = compactEntities(entities, contexts)

val ngsiLdDataRepresentation = parseRepresentations(params, mediaType)
val ngsiLdDataRepresentation = parseRepresentations(params, mediaType).bind()
buildQueryResponse(
compactedEntities.toFinalRepresentation(ngsiLdDataRepresentation),
count,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,11 +194,11 @@ class EntityHandler(
@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.OPTIONS, QP.FORMAT, 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]
notImplemented = [QP.PICK, QP.OMIT, QP.EXPAND_VALUES, QP.CSF, QP.ENTITY_MAP, QP.LOCAL, QP.VIA]
)
@RequestParam queryParams: MultiValueMap<String, String>
): ResponseEntity<*> = either {
Expand Down Expand Up @@ -264,7 +264,7 @@ class EntityHandler(
mergedEntities ?: emptyList()
}

val ngsiLdDataRepresentation = parseRepresentations(queryParams, mediaType)
val ngsiLdDataRepresentation = parseRepresentations(queryParams, mediaType).bind()
buildQueryResponse(
mergedEntities.toFinalRepresentation(ngsiLdDataRepresentation),
maxCount,
Expand All @@ -288,10 +288,10 @@ class EntityHandler(
@PathVariable entityId: URI,
@AllowedParameters(
implemented = [
QP.OPTIONS, QP.TYPE, QP.ATTRS, QP.GEOMETRY_PROPERTY,
QP.OPTIONS, QP.FORMAT, 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]
notImplemented = [QP.PICK, QP.OMIT, QP.ENTITY_MAP, QP.LOCAL, QP.VIA]
)
@RequestParam queryParams: MultiValueMap<String, String>
): ResponseEntity<*> = either {
Expand Down Expand Up @@ -358,7 +358,7 @@ class EntityHandler(
val mergedEntityWithLinkedEntities =
linkedEntityService.processLinkedEntities(mergedEntity, entitiesQuery, sub.getOrNull()).bind()

val ngsiLdDataRepresentation = parseRepresentations(queryParams, mediaType)
val ngsiLdDataRepresentation = parseRepresentations(queryParams, mediaType).bind()
prepareGetSuccessResponseHeaders(mediaType, contexts)
.let {
val body = if (mergedEntityWithLinkedEntities.size == 1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ class EntityOperationHandler(

val compactedEntities = compactEntities(filteredEntities, contexts)

val ngsiLdDataRepresentation = parseRepresentations(queryParams, mediaType)
val ngsiLdDataRepresentation = parseRepresentations(queryParams, mediaType).bind()
.copy(languageFilter = query.lang)

buildQueryResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ 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
import com.egm.stellio.search.temporal.util.TemporalRepresentation
import com.egm.stellio.search.temporal.util.WHOLE_TIME_RANGE_DURATION
import com.egm.stellio.search.temporal.util.composeAggregationSelectClause
import com.egm.stellio.shared.model.APIException
Expand Down Expand Up @@ -136,7 +137,7 @@ class ScopeService(

if (temporalEntitiesQuery.isAggregatedWithDefinedDuration())
sqlQueryBuilder.append(" GROUP BY entity_id, start")
else if (temporalEntitiesQuery.withAggregatedValues)
else if (temporalEntitiesQuery.temporalRepresentation == TemporalRepresentation.AGGREGATED_VALUES)
sqlQueryBuilder.append(" GROUP BY entity_id")
if (temporalQuery.hasLastN())
// in order to get first or last instances, need to order by time
Expand All @@ -161,7 +162,7 @@ class ScopeService(
temporalEntitiesQuery: TemporalEntitiesQuery,
origin: ZonedDateTime?
): String = when {
temporalEntitiesQuery.withAggregatedValues -> {
temporalEntitiesQuery.temporalRepresentation == TemporalRepresentation.AGGREGATED_VALUES -> {
val temporalQuery = temporalEntitiesQuery.temporalQuery
val aggrPeriodDuration = temporalQuery.aggrPeriodDuration
val allAggregates = temporalQuery.aggrMethods?.composeAggregationSelectClause(AttributeValueType.ARRAY)
Expand Down Expand Up @@ -215,7 +216,7 @@ class ScopeService(
row: Map<String, Any>,
temporalEntitiesQuery: TemporalEntitiesQuery
): ScopeInstanceResult =
if (temporalEntitiesQuery.withAggregatedValues) {
if (temporalEntitiesQuery.temporalRepresentation == TemporalRepresentation.AGGREGATED_VALUES) {
val startDateTime = toZonedDateTime(row["start"])
val endDateTime =
if (!temporalEntitiesQuery.isAggregatedWithDefinedDuration())
Expand All @@ -231,7 +232,7 @@ class ScopeService(
entityId = toUri(row["entity_id"]),
values = values
)
} else if (temporalEntitiesQuery.withTemporalValues) {
} else if (temporalEntitiesQuery.temporalRepresentation == TemporalRepresentation.TEMPORAL_VALUES) {
SimplifiedScopeInstanceResult(
entityId = toUri(row["entity_id"]),
scopes = toList(row["value"]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.egm.stellio.search.scope
import com.egm.stellio.search.entity.model.Entity
import com.egm.stellio.search.temporal.model.TemporalEntitiesQuery
import com.egm.stellio.search.temporal.model.TemporalQuery
import com.egm.stellio.search.temporal.util.TemporalRepresentation
import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_LIST
import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_TYPE
import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_VALUE
Expand All @@ -27,12 +28,12 @@ object TemporalScopeBuilder {
// if no history but entity has a scope, add an empty scope list (no history in the given time range)
else if (scopeInstances.isEmpty())
mapOf(NGSILD_SCOPE_PROPERTY to emptyList<String>())
else if (temporalEntitiesQuery.withAggregatedValues)
else if (temporalEntitiesQuery.temporalRepresentation == TemporalRepresentation.AGGREGATED_VALUES)
buildScopeAggregatedRepresentation(
scopeInstances,
temporalEntitiesQuery.temporalQuery.aggrMethods!!
)
else if (temporalEntitiesQuery.withTemporalValues)
else if (temporalEntitiesQuery.temporalRepresentation == TemporalRepresentation.TEMPORAL_VALUES)
buildScopeSimplifiedRepresentation(scopeInstances)
else
buildScopeFullRepresentation(scopeInstances)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@ package com.egm.stellio.search.temporal.model
import com.egm.stellio.search.entity.model.EntitiesQuery
import com.egm.stellio.search.entity.model.EntitiesQueryFromGet
import com.egm.stellio.search.entity.model.EntitiesQueryFromPost
import com.egm.stellio.search.temporal.util.TemporalRepresentation
import java.time.Duration
import java.time.Period
import java.time.temporal.TemporalAmount

sealed class TemporalEntitiesQuery(
open val entitiesQuery: EntitiesQuery,
open val temporalQuery: TemporalQuery,
open val withTemporalValues: Boolean,
open val withAudit: Boolean,
open val withAggregatedValues: Boolean
open val temporalRepresentation: TemporalRepresentation,
open val withAudit: Boolean
) {
fun isAggregatedWithDefinedDuration(): Boolean =
withAggregatedValues &&
temporalRepresentation == TemporalRepresentation.AGGREGATED_VALUES &&
temporalQuery.aggrPeriodDuration != null &&
temporalQuery.aggrPeriodDuration != "PT0S"

Expand All @@ -36,15 +36,13 @@ sealed class TemporalEntitiesQuery(
data class TemporalEntitiesQueryFromGet(
override val entitiesQuery: EntitiesQueryFromGet,
override val temporalQuery: TemporalQuery,
override val withTemporalValues: Boolean,
override val withAudit: Boolean,
override val withAggregatedValues: Boolean
) : TemporalEntitiesQuery(entitiesQuery, temporalQuery, withTemporalValues, withAudit, withAggregatedValues)
override val temporalRepresentation: TemporalRepresentation,
override val withAudit: Boolean
) : TemporalEntitiesQuery(entitiesQuery, temporalQuery, temporalRepresentation, withAudit)

data class TemporalEntitiesQueryFromPost(
override val entitiesQuery: EntitiesQueryFromPost,
override val temporalQuery: TemporalQuery,
override val withTemporalValues: Boolean,
override val withAudit: Boolean,
override val withAggregatedValues: Boolean
) : TemporalEntitiesQuery(entitiesQuery, temporalQuery, withTemporalValues, withAudit, withAggregatedValues)
override val temporalRepresentation: TemporalRepresentation,
override val withAudit: Boolean
) : TemporalEntitiesQuery(entitiesQuery, temporalQuery, temporalRepresentation, withAudit)
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import com.egm.stellio.search.temporal.model.SimplifiedAttributeInstanceResult
import com.egm.stellio.search.temporal.model.TemporalEntitiesQuery
import com.egm.stellio.search.temporal.model.TemporalQuery
import com.egm.stellio.search.temporal.model.TemporalQuery.Timerel
import com.egm.stellio.search.temporal.util.TemporalRepresentation
import com.egm.stellio.search.temporal.util.WHOLE_TIME_RANGE_DURATION
import com.egm.stellio.search.temporal.util.composeAggregationSelectClause
import com.egm.stellio.shared.model.APIException
Expand Down Expand Up @@ -174,7 +175,7 @@ class AttributeInstanceService(

sqlQueryBuilder.append(composeSearchSelectStatement(temporalQuery, attributes, origin))

if (!temporalEntitiesQuery.withTemporalValues && !temporalEntitiesQuery.withAggregatedValues)
if (temporalEntitiesQuery.temporalRepresentation == TemporalRepresentation.NORMALIZED)
sqlQueryBuilder.append(", payload")

if (temporalQuery.timeproperty == OBSERVED_AT)
Expand Down Expand Up @@ -204,7 +205,7 @@ class AttributeInstanceService(

if (temporalEntitiesQuery.isAggregatedWithDefinedDuration())
sqlQueryBuilder.append(" GROUP BY temporal_entity_attribute, start")
else if (temporalEntitiesQuery.withAggregatedValues)
else if (temporalEntitiesQuery.temporalRepresentation == TemporalRepresentation.AGGREGATED_VALUES)
sqlQueryBuilder.append(" GROUP BY temporal_entity_attribute")

if (temporalQuery.hasLastN())
Expand Down Expand Up @@ -319,7 +320,7 @@ class AttributeInstanceService(
row: Map<String, Any>,
temporalEntitiesQuery: TemporalEntitiesQuery
): AttributeInstanceResult =
if (temporalEntitiesQuery.withAggregatedValues) {
if (temporalEntitiesQuery.temporalRepresentation == TemporalRepresentation.AGGREGATED_VALUES) {
val startDateTime = toZonedDateTime(row["start"])
val endDateTime =
if (!temporalEntitiesQuery.isAggregatedWithDefinedDuration())
Expand All @@ -335,7 +336,7 @@ class AttributeInstanceService(
attributeUuid = toUuid(row["temporal_entity_attribute"]),
values = values
)
} else if (temporalEntitiesQuery.withTemporalValues)
} else if (temporalEntitiesQuery.temporalRepresentation == TemporalRepresentation.TEMPORAL_VALUES)
SimplifiedAttributeInstanceResult(
attributeUuid = toUuid(row["temporal_entity_attribute"]),
// the type of the value of a property may have changed in the history (e.g., from number to string)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.egm.stellio.search.temporal.model.AttributeInstanceResult
import com.egm.stellio.search.temporal.model.TemporalEntitiesQuery
import com.egm.stellio.search.temporal.model.TemporalQuery
import com.egm.stellio.search.temporal.util.AttributesWithInstances
import com.egm.stellio.search.temporal.util.TemporalRepresentation
import java.time.ZonedDateTime

typealias Range = Pair<ZonedDateTime, ZonedDateTime>
Expand Down Expand Up @@ -47,12 +48,13 @@ object TemporalPaginationService {
val temporalQuery = query.temporalQuery

val attributesTimeRanges = attributeInstancesWhoReachedLimit.map {
it.first().getComparableTime() to if (query.withAggregatedValues) {
val lastInstance = it.last() as AggregatedAttributeInstanceResult
lastInstance.values.first().endDateTime
} else {
it.last().getComparableTime()
}
it.first().getComparableTime() to
if (query.temporalRepresentation == TemporalRepresentation.AGGREGATED_VALUES) {
val lastInstance = it.last() as AggregatedAttributeInstanceResult
lastInstance.values.first().endDateTime
} else {
it.last().getComparableTime()
}
}

if (temporalQuery.hasLastN()) {
Expand Down
Loading

0 comments on commit ffb4fc0

Please sign in to comment.