diff --git a/here-naksha-lib-base/src/commonMain/kotlin/naksha/base/NormalizerForm.kt b/here-naksha-lib-base/src/commonMain/kotlin/naksha/base/NormalizerForm.kt index 04ded0e6b..852ba575a 100644 --- a/here-naksha-lib-base/src/commonMain/kotlin/naksha/base/NormalizerForm.kt +++ b/here-naksha-lib-base/src/commonMain/kotlin/naksha/base/NormalizerForm.kt @@ -4,7 +4,7 @@ import kotlin.js.ExperimentalJsExport import kotlin.js.JsExport /** - * Please refer []Unicode Normalization Forms](https://www.unicode.org/reports/tr15/#Norm_Forms) + * Please refer [Unicode Normalization Forms](https://www.unicode.org/reports/tr15/#Norm_Forms) */ @OptIn(ExperimentalJsExport::class) @JsExport diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Tag.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Tag.kt deleted file mode 100644 index 1537e6b78..000000000 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/Tag.kt +++ /dev/null @@ -1,86 +0,0 @@ -@file:Suppress("OPT_IN_USAGE") - -package naksha.model - -import naksha.base.* -import naksha.model.NakshaError.NakshaErrorCompanion.ILLEGAL_ARGUMENT -import kotlin.js.JsExport -import kotlin.js.JsName -import kotlin.js.JsStatic -import kotlin.jvm.JvmStatic - -/** - * An immutable mapper for a split tag. - * @property tag the full tag. - * @property key the key of the tag. - * @property value the value of the tag; _null_, Boolean, String or Double. - */ -@JsExport -class Tag(): AnyObject() { - - @JsName("of") - constructor(tag: String, key: String, value: Any?): this() { - this.tag = tag - this.key = key - this.value = value - } - - companion object Tag_C { - private val TAG = NotNullProperty(String::class) - private val KEY = NotNullProperty(String::class) - private val VALUE = NullableProperty(Any::class) - - @JvmStatic - @JsStatic - fun parse(tag: String): Tag { - val i = tag.indexOf('=') - val key: String - val value: Any? - if (i > 1) { - if (tag[i-1] == ':') { // := - key = tag.substring(0, i-1).trim() - val raw = tag.substring(i + 1).trim() - value = if ("true".equals(raw, ignoreCase = true)) { - true - } else if ("false".equals(raw, ignoreCase = true)) { - false - } else { - raw. toDouble() - } - } else { - key = tag.substring(0, i).trim() - value = tag.substring(i + 1).trim() - } - } else { - key = tag - value = null - } - return Tag(tag, key, value) - } - - @JvmStatic - @JsStatic - fun of(key: String, value: Any?): Tag = when(value) { - // TODO: Fix normalization! - null -> Tag(key, key, null) - is String -> Tag("$key=$value", key, value) - is Boolean, Double -> Tag("$key:=$value", key, value) - is Number -> of(key, value.toDouble()) - is Int64 -> of(key, value.toDouble()) - else -> throw NakshaException(ILLEGAL_ARGUMENT, "Tag values can only be String, Boolean or Double") - } - } - - var tag by TAG - var key by KEY - var value by VALUE - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other is String) return tag == other - if (other is Tag) return tag == other.tag - return false - } - override fun hashCode(): Int = tag.hashCode() - override fun toString(): String = tag -} \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TagList.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TagList.kt index f77d0e299..5a3117751 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TagList.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TagList.kt @@ -3,7 +3,9 @@ package naksha.model import naksha.base.ListProxy -import naksha.model.XyzNs.XyzNsCompanion.normalizeTag +import naksha.base.NormalizerForm +import naksha.base.Platform +import naksha.model.TagNormalizer.TagNormalizer_C.normalizeTag import kotlin.js.JsExport import kotlin.js.JsName import kotlin.js.JsStatic @@ -24,21 +26,6 @@ open class TagList() : ListProxy(String::class) { addTags(listOf(*tags), false) } - companion object TagList_C { - /** - * Create a tag list from the given array; the tags are normalized. - * @param tags the tags. - * @return the tag-list. - */ - @JvmStatic - @JsStatic - fun fromArray(tags: Array): TagList { - val list = TagList() - list.addAndNormalizeTags(*tags) - return list - } - } - /** * Returns 'true' if the tag was removed, 'false' if it was not present. * @@ -161,9 +148,44 @@ open class TagList() : ListProxy(String::class) { return this } + /** * Convert this tag-list into a tag-map. * @return this tag-list as tag-map. */ fun toTagMap(): TagMap = TagMap(this) + + companion object TagList_C { + /** + * Create a tag list from the given array; the tags are normalized. + * @param tags the tags. + * @return the tag-list. + */ + @JvmStatic + @JsStatic + fun fromArray(tags: Array): TagList { + val list = TagList() + list.addAndNormalizeTags(*tags) + return list + } + + /** + * A method to normalize a list of tags. + * + * @param tags a list of tags. + * @return the same list, just that the content is normalized. + */ + @JvmStatic + @JsStatic + fun normalizeTags(tags: TagList?): TagList? { + if (!tags.isNullOrEmpty()) { + for ((idx, tag) in tags.withIndex()) { + if (tag != null) { + tags[idx] = normalizeTag(tag) + } + } + } + return tags + } + } } diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TagMap.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TagMap.kt index dbb734a05..27e2d7e2d 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TagMap.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TagMap.kt @@ -2,25 +2,32 @@ package naksha.model +import naksha.base.Int64 import naksha.base.MapProxy -import naksha.model.request.query.* import kotlin.js.JsExport import kotlin.js.JsName -// TODO: Document me! -// Improve me! - +/** + * Map of tags persisted as (key, value) pairs where values are nullable. + * This class represents the persisted form of [TagList]. + * It is stored as byte_array and can be accessed in PG via `naksha_tags` function. + * + * It is advised to only construct it in one of two ways: + * 1) Via [TagList]-based constructor + * 2) By deserializing byte array fetched from DB + * + * If for some reason, one would like to use it otherwise, it is advised to properly prepare tags upfront + * with use of [TagNormalizer] (that is used for example by [TagList]) + */ @JsExport -open class TagMap() : MapProxy(String::class, Tag::class) { +open class TagMap() : MapProxy(String::class, Any::class) { @Suppress("LeakingThis") @JsName("of") - constructor(tagList: TagList) : this(){ - for (s in tagList) { - if (s == null) continue - val tag = Tag.parse(s) - put(tag.key, tag) - } + constructor(tagList: TagList) : this() { + tagList.filterNotNull() + .map { TagNormalizer.splitNormalizedTag(it) } + .forEach { (key, value) -> put(key, value) } } /** @@ -29,10 +36,28 @@ open class TagMap() : MapProxy(String::class, Tag::class) { */ fun toTagList(): TagList { val list = TagList() - for (e in this) { - val tag = e.value?.tag - if (tag != null) list.add(tag) + forEach { (key, value) -> + list.add(flattenTag(key, value)) } return list } + + /** + * Converts (key, value) pair to String, so it can be part of [TagList]. + * The result depends on the value: + * - Null value is omitted: ('foo', null) -> 'foo' + * - String value is separated with simple '=': ('foo', 'bar') -> 'foo=bar' + * - Numbers and booleans are separated with ':=' -> 'foo:=true', 'foo:=12.34' + */ + private fun flattenTag(key: String, value: Any?): String = + when (value) { + null -> key + is String -> "$key=$value" + is Boolean, is Long, is Int64 -> "$key:=$value" + is Number -> "$key:=${value.toDouble()}" + else -> throw NakshaException( + NakshaError.ILLEGAL_ARGUMENT, + "Tag values can only be String, Boolean or Number" + ) + } } \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TagNormalizer.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TagNormalizer.kt new file mode 100644 index 000000000..b8ea76022 --- /dev/null +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/TagNormalizer.kt @@ -0,0 +1,131 @@ +package naksha.model + +import naksha.base.NormalizerForm +import naksha.base.NormalizerForm.NFD +import naksha.base.NormalizerForm.NFKC +import naksha.base.Platform +import naksha.model.TagNormalizer.TagNormalizer_C.normalizeTag +import naksha.model.TagNormalizer.TagNormalizer_C.splitNormalizedTag +import kotlin.js.JsExport + +/** + * An object used for Tag normalization and splitting. + * + * Process of normalization happens in [normalizeTag] method and includes following steps: + * 1) Always: apply normalization form (see [NormalizerForm]) + * 2) Conditional: lowercase the whole tag + * 3) Conditional: remove all non-ASCII characters + * + * Normalization form used in step #1 and subsequent conditional steps depend on tag prefix. + * + * Process of splitting happens in [splitNormalizedTag] method. + * Note that not all tags can be split, it depends on their prefix. + * + * Summarised per-prefix behavior: + * +----------+------------+-----------+----------+-------+ + * | prefix | norm. form | lowercase | no ASCII | split | + * +----------+------------+-----------+----------+-------+ + * | @ | NFKC | false | false | true | + * | ref_ | NFKC | false | false | false | + * | ~ | NFD | false | true | true | + * | # | NFD | false | true | true | + * | sourceID | NFKC | false | false | false | + * | < ELSE > | NFD | true | true | true | + * +----------+------------+-----------+----------+-------+ + * + * By default, (if no special prefix is found) tag is normalized with NFD, lowercased, cleaned of non-ASCII and splittable. + */ +@JsExport +class TagNormalizer private constructor() { + + companion object TagNormalizer_C { + private val DEFAULT_POLICY = TagProcessingPolicy(NFD, removeNonAscii = true, lowercase = true, split = true) + private val PREFIX_TO_POLICY = mapOf( + "@" to TagProcessingPolicy(NFKC, removeNonAscii = false, lowercase = false, split = true), + "ref_" to TagProcessingPolicy(NFKC, removeNonAscii = false, lowercase = false, split = false), + "sourceID" to TagProcessingPolicy(NFKC, removeNonAscii = false, lowercase = false, split = false), + "~" to TagProcessingPolicy(NFD, removeNonAscii = true, lowercase = false, split = true), + "#" to TagProcessingPolicy(NFD, removeNonAscii = true, lowercase = false, split = true) + ) + + private val AS_IS: CharArray = CharArray(128 - 32) { (it + 32).toChar() } + private val TO_LOWER: CharArray = CharArray(128 - 32) { (it + 32).toChar().lowercaseChar() } + + /** + * Main method for raw tag normalization. See[TagNormalizer] doc for more + */ + fun normalizeTag(tag: String): String { + val policy = policyFor(tag) + val normalized = Platform.normalize(tag, policy.normalizerForm) + return if (policy.lowercase) { + if (policy.removeNonAscii) { + removeNonAscii(normalized, TO_LOWER) + } else { + normalized.lowercase() + } + } else if (policy.removeNonAscii){ + removeNonAscii(normalized, AS_IS) + } else { + normalized + } + } + + private fun removeNonAscii(input: String, outputCharacterSet: CharArray): String { + val sb = StringBuilder() + for (element in input) { + val c = (element.code - 32).toChar() + if (c.code < outputCharacterSet.size) { + sb.append(outputCharacterSet[c.code]) + } + } + return sb.toString() + } + + + /** + * Main method for normalized tag splitting. See[TagNormalizer] doc for more + */ + internal fun splitNormalizedTag(normalizedTag: String): Pair { + if (!policyFor(normalizedTag).split) { + return normalizedTag to null + } + val i = normalizedTag.indexOf('=') + val key: String + val value: Any? + if (i >= 1) { + if (normalizedTag[i - 1] == ':') { // := + key = normalizedTag.substring(0, i - 1).trim() + val raw = normalizedTag.substring(i + 1).trim() + value = if ("true".equals(raw, ignoreCase = true)) { + true + } else if ("false".equals(raw, ignoreCase = true)) { + false + } else { + raw.toDouble() + } + } else { + key = normalizedTag.substring(0, i).trim() + value = normalizedTag.substring(i + 1).trim() + } + } else { + key = normalizedTag + value = null + } + return key to value + } + + private fun policyFor(tag: String): TagProcessingPolicy { + for ((prefix, policy) in PREFIX_TO_POLICY) { + if (tag.startsWith(prefix)) return policy + } + return DEFAULT_POLICY + } + } +} + +private data class TagProcessingPolicy( + val normalizerForm: NormalizerForm, + val removeNonAscii: Boolean, + val lowercase: Boolean, + val split: Boolean +) diff --git a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/XyzNs.kt b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/XyzNs.kt index 1a3a9b256..6b8a28591 100644 --- a/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/XyzNs.kt +++ b/here-naksha-lib-model/src/commonMain/kotlin/naksha/model/XyzNs.kt @@ -3,6 +3,7 @@ package naksha.model import naksha.base.* +import naksha.model.TagNormalizer.TagNormalizer_C.normalizeTag import kotlin.DeprecationLevel.WARNING import kotlin.js.JsExport import kotlin.js.JsStatic @@ -29,66 +30,7 @@ class XyzNs : AnyObject() { private val INT64_NULL = NullableProperty(Int64::class) private val TAGS = NullableProperty(TagList::class) - private var AS_IS: CharArray = CharArray(128 - 32) { (it + 32).toChar() } - private var TO_LOWER: CharArray = CharArray(128 - 32) { (it + 32).toChar().lowercaseChar() } - - /** - * A method to normalize a list of tags. - * - * @param tags a list of tags. - * @return the same list, just that the content is normalized. - */ - @JvmStatic - @JsStatic - fun normalizeTags(tags: TagList?): TagList? { - if (!tags.isNullOrEmpty()) { - for ((idx, tag) in tags.withIndex()) { - if (tag != null) { - tags[idx] = normalizeTag(tag) - } - } - } - return tags - } - /** - * A method to normalize and lower case a tag. - * - * @param tag the tag. - * @return the normalized and lower cased version of it. - */ - @JvmStatic - @JsStatic - fun normalizeTag(tag: String): String { - if (tag.isEmpty()) { - return tag - } - val first = tag[0] - // All tags starting with an at-sign, will not be modified in any way. - if (first == '@') { - return tag - } - - // Normalize the tag. - val normalized: String = Platform.normalize(tag, NormalizerForm.NFD) - - // All tags starting with a tilde, sharp, or the deprecated "ref_" / "sourceID_" prefix will not - // be lower cased. - val MAP: CharArray = - if (first == '~' || first == '#' || normalized.startsWith("ref_") || normalized.startsWith("sourceID_")) - AS_IS - else - TO_LOWER - val sb = StringBuilder(normalized.length) - for (element in normalized) { - // Note: This saves one branch, and the array-size check, because 0 - 32 will become 65504. - val c = (element.code - 32).toChar() - if (c.code < MAP.size) { - sb.append(MAP[c.code]) - } - } - return sb.toString() - } } /** diff --git a/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/TagMapTest.kt b/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/TagMapTest.kt new file mode 100644 index 000000000..1085a0101 --- /dev/null +++ b/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/TagMapTest.kt @@ -0,0 +1,58 @@ +package naksha.model + +import kotlin.test.* + +class TagMapTest { + + @Test + fun shouldBeConvertedFromTagList(){ + // Given: + val tagList = TagList("foo=bar", "no-value", "flag:=true") + + // When: + val tagMap = TagMap(tagList) + + // Then + assertEquals(3, tagList.size) + assertEquals("bar", tagMap["foo"]) + assertTrue(tagMap.contains("no-value")) + assertNull(tagMap["no-value"]) + assertEquals(true, tagMap["flag"]) + } + + @Test + fun shouldBeConvertedToTagList(){ + // Given: + val tagMap = TagMap().apply { + put("foo", "bar") + put("no-value", null) + put("flag", true) + } + + // When: + val tagList = tagMap.toTagList() + + // Then + assertTrue(tagList.containsAll(listOf("foo=bar", "no-value", "flag:=true"))) + } + + @Test + fun shouldFailWhenConvertingToListWithUnsupportedType(){ + // Given: + val tagMap = TagMap().apply { + put("foo", "bar") + put("failure-reason", NotSupportedType) + } + + // When: + val failure = assertFails { + tagMap.toTagList() + } + + // Then: + assertIs(failure) + assertEquals("Tag values can only be String, Boolean or Number", failure.message) + } + + object NotSupportedType +} \ No newline at end of file diff --git a/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/TagNormalizerTest.kt b/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/TagNormalizerTest.kt new file mode 100644 index 000000000..503d7524b --- /dev/null +++ b/here-naksha-lib-model/src/commonTest/kotlin/naksha/model/TagNormalizerTest.kt @@ -0,0 +1,105 @@ +package naksha.model + +import kotlin.test.Test +import kotlin.test.assertEquals + +class TagNormalizerTest { + + @Test + fun shouldRemoveNonAscii() { + val tagsToBeClearedFromAscii = mapOf( + "p®¡©e=100£" to "pe=100", // regular tag + "~twice_¼=single_½" to "~twice_=single_", // starting with '~' + "#some=tµ¶ag" to "#some=tag", // starting with '#' + ) + + tagsToBeClearedFromAscii.forEach { (before, after) -> + assertEquals(after, TagNormalizer.normalizeTag(before)) + } + } + + @Test + fun shouldLeaveNonAsciiAsIs() { + val tagsWithAsciiToBePreserved = listOf( + "@p®¡©e=100£", // starting with '@' + "ref_p®¡©e=100£", // starting with 'ref_' + "sourceIDp®¡©e=100£", // starting with 'sourceID' + ) + + tagsWithAsciiToBePreserved.forEach { tag -> + assertEquals(tag, TagNormalizer.normalizeTag(tag)) + } + } + + @Test + fun shouldLowercase() { + val tag = "Some_Tag:=1235" + assertEquals(tag.lowercase(), TagNormalizer.normalizeTag(tag)) + } + + @Test + fun shouldNotLowercase() { + val tagsNotToBeLowercased = listOf( + "@Some_Tag:=1235", + "ref_Some_Tag:=1235", + "~Some_Tag:=1235", + "#Some_Tag:=1235", + "sourceIDSome_Tag:=1235" + ) + + tagsNotToBeLowercased.forEach { tag -> + assertEquals(tag, TagNormalizer.normalizeTag(tag)) + } + } + + @Test + fun shouldSplit() { + val tagsToBeSplit = listOf( + "@some_tag:=1235", + "~some_tag:=1235", + "#some_tag:=1235", + "some_tag:=1235" + ) + + tagsToBeSplit.forEach { rawTag -> + val expectedKey = rawTag.split(":")[0] + val normalized = TagNormalizer.normalizeTag(rawTag) + val (tagKey, tagValue) = TagNormalizer.splitNormalizedTag(normalized) + + assertEquals(expectedKey, tagKey) + assertEquals(1235.0, tagValue) + } + } + + @Test + fun shouldNotSplit() { + val tagsNotToBeSplit = listOf( + "ref_some_tag:=1235", + "sourceIDsome_tag:=1235" + ) + + tagsNotToBeSplit.forEach { rawTag -> + val normalized = TagNormalizer.normalizeTag(rawTag) + val (tagKey, tagValue) = TagNormalizer.splitNormalizedTag(normalized) + + assertEquals(rawTag, tagKey) + assertEquals(null, tagValue) + } + } + + @Test + fun shouldSplitSingleCharTags() { + // Given + val tagToBeSplit = "a=b" + + // When: + val normalized = TagNormalizer.normalizeTag(tagToBeSplit) + + // And: + val (key, value) = TagNormalizer.splitNormalizedTag(normalized) + + // Then + assertEquals("a", key) + assertEquals("b", value) + } +} diff --git a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/executors/query/WhereClauseBuilder.kt b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/executors/query/WhereClauseBuilder.kt index d3ac140a2..59e5710ee 100644 --- a/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/executors/query/WhereClauseBuilder.kt +++ b/here-naksha-lib-psql/src/commonMain/kotlin/naksha/psql/executors/query/WhereClauseBuilder.kt @@ -21,6 +21,7 @@ class WhereClauseBuilder(private val request: ReadFeatures) { whereVersion() whereMetadata() whereSpatial() + whereTags() return if (where.isBlank()) { null } else { @@ -62,7 +63,8 @@ class WhereClauseBuilder(private val request: ReadFeatures) { } private fun whereSpatial() { - request.query.spatial?.let { spatialQuery -> + val spatialQuery = request.query.spatial + if (spatialQuery != null) { if (where.isNotEmpty()) { where.append(" AND (") } else { @@ -73,7 +75,7 @@ class WhereClauseBuilder(private val request: ReadFeatures) { } } - private tailrec fun whereNestedSpatial(spatial: ISpatialQuery) { + private fun whereNestedSpatial(spatial: ISpatialQuery) { when (spatial) { is SpNot -> not( subClause = spatial.query, @@ -108,7 +110,8 @@ class WhereClauseBuilder(private val request: ReadFeatures) { } private fun whereMetadata() { - request.query.metadata?.let { metaQuery -> + val metaQuery = request.query.metadata + if (metaQuery != null) { if (where.isNotEmpty()) { where.append(" AND (") } else { @@ -119,7 +122,7 @@ class WhereClauseBuilder(private val request: ReadFeatures) { } } - private tailrec fun whereNestedMetadata(metaQuery: IMetaQuery) { + private fun whereNestedMetadata(metaQuery: IMetaQuery) { when (metaQuery) { is MetaNot -> not( subClause = metaQuery.query, @@ -143,8 +146,8 @@ class WhereClauseBuilder(private val request: ReadFeatures) { ) val placeholder = placeholderForArg(metaQuery.value, pgColumn.type) val resolvedQuery = when (val op = metaQuery.op) { - is StringOp -> resolveStringOp(op, pgColumn, placeholder) - is DoubleOp -> resolveDoubleOp(op, pgColumn, placeholder) + is StringOp -> resolveStringOp(op, pgColumn.name, placeholder) + is DoubleOp -> resolveDoubleOp(op, pgColumn.name, placeholder) else -> throw NakshaException( NakshaError.ILLEGAL_ARGUMENT, "Unknown op type: ${op::class.simpleName}" @@ -160,6 +163,84 @@ class WhereClauseBuilder(private val request: ReadFeatures) { } } + private fun whereTags() { + val tagQuery = request.query.tags + if (tagQuery != null) { + if (where.isNotEmpty()) { + where.append(" AND (") + } else { + where.append(" (") + } + whereNestedTags(tagQuery) + where.append(")") + } + } + + private fun whereNestedTags(tagQuery: ITagQuery) { + when (tagQuery) { + is TagNot -> not(tagQuery.query, this::whereNestedTags) + is TagOr -> or(tagQuery.filterNotNull(), this::whereNestedTags) + is TagAnd -> and(tagQuery.filterNotNull(), this::whereNestedTags) + is TagQuery -> resolveSingleTagQuery(tagQuery) + } + } + + private fun resolveSingleTagQuery(tagQuery: TagQuery) { + when (tagQuery) { + is TagExists -> { + val tagNamePlaceholder = placeholderForArg(tagQuery.name, PgType.STRING) + where.append("$tagsAsJsonb ?? $tagNamePlaceholder") + } + + is TagValueIsNull -> { + val tagValuePlaceholder = placeholderForArg(selectTagValue(tagQuery), PgType.STRING) + where.append("$tagValuePlaceholder IS NULL") + } + + is TagValueIsBool -> { + if (tagQuery.value) { + where.append(selectTagValue(tagQuery, PgType.BOOLEAN)) + } else { + where.append("not(${selectTagValue(tagQuery, PgType.BOOLEAN)})") + } + } + + is TagValueIsDouble -> { + val queryValuePlaceholder = placeholderForArg(tagQuery.value, PgType.DOUBLE) + val doubleOp = resolveDoubleOp( + tagQuery.op, + selectTagValue(tagQuery, PgType.DOUBLE), + queryValuePlaceholder + ) + where.append(doubleOp) + } + + is TagValueIsString -> { + val queryValuePlaceholder = placeholderForArg(tagQuery.value, PgType.STRING) + val stringEquals = resolveStringOp( + StringOp.EQUALS, + selectTagValue(tagQuery, PgType.STRING), + queryValuePlaceholder + ) + where.append(stringEquals) + } + + is TagValueMatches -> { + val jsonPathPlaceholder = placeholderForArg("\$.${tagQuery.name} ? (@ like_regex \"${tagQuery.regex}\")", PgType.STRING) + where.append("$tagsAsJsonb @?? $jsonPathPlaceholder::jsonpath") + } + } + } + + private fun selectTagValue(tagQuery: TagQuery, castTo: PgType? = null): String { + val tagKeyPlaceholder = placeholderForArg(tagQuery.name, PgType.STRING) + return when (castTo) { + null -> "$tagsAsJsonb->$tagKeyPlaceholder" + PgType.STRING -> "$tagsAsJsonb->>$tagKeyPlaceholder" + else -> "($tagsAsJsonb->$tagKeyPlaceholder)::${castTo.value}" + } + } + private fun not(subClause: T, subClauseResolver: (T) -> Unit) { where.append(" NOT (") subClauseResolver(subClause) @@ -195,12 +276,12 @@ class WhereClauseBuilder(private val request: ReadFeatures) { private fun resolveStringOp( stringOp: StringOp, - column: PgColumn, - valuePlaceholder: String + leftOperand: String, + rightOperand: String ): String { return when (stringOp) { - StringOp.EQUALS -> "${column.name} = $valuePlaceholder" - StringOp.STARTS_WITH -> "starts_with(${column.name}, $valuePlaceholder)" + StringOp.EQUALS -> "$leftOperand = $rightOperand" + StringOp.STARTS_WITH -> "starts_with($leftOperand, $rightOperand)" else -> throw NakshaException( NakshaError.ILLEGAL_ARGUMENT, "Unknown StringOp: $stringOp" @@ -210,19 +291,23 @@ class WhereClauseBuilder(private val request: ReadFeatures) { private fun resolveDoubleOp( doubleOp: DoubleOp, - column: PgColumn, - valuePlaceholder: String + leftOperand: String, + rightOperand: String ): String { return when (doubleOp) { - DoubleOp.EQ -> "${column.name} = $valuePlaceholder" - DoubleOp.GT -> "${column.name} > $valuePlaceholder" - DoubleOp.GTE -> "${column.name} >= $valuePlaceholder" - DoubleOp.LT -> "${column.name} < $valuePlaceholder" - DoubleOp.LTE -> "${column.name} <= $valuePlaceholder" + DoubleOp.EQ -> "$leftOperand = $rightOperand" + DoubleOp.GT -> "$leftOperand > $rightOperand" + DoubleOp.GTE -> "$leftOperand >= $rightOperand" + DoubleOp.LT -> "$leftOperand < $rightOperand" + DoubleOp.LTE -> "$leftOperand <= $rightOperand" else -> throw NakshaException( NakshaError.ILLEGAL_ARGUMENT, "Unknown DoubleOp: $doubleOp" ) } } + + companion object { + private val tagsAsJsonb = "naksha_tags(${PgColumn.tags}, ${PgColumn.flags})" + } } diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByTagsTest.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByTagsTest.kt new file mode 100644 index 000000000..4db871732 --- /dev/null +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/ReadFeaturesByTagsTest.kt @@ -0,0 +1,246 @@ +package naksha.psql + +import naksha.model.TagList +import naksha.model.objects.NakshaCollection +import naksha.model.objects.NakshaFeature +import naksha.model.request.ReadFeatures +import naksha.model.request.SuccessResponse +import naksha.model.request.query.* +import naksha.psql.base.PgTestBase +import naksha.psql.util.ProxyFeatureGenerator +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ReadFeaturesByTagsTest : PgTestBase(NakshaCollection("read_by_tags_test")) { + + @Test + fun shouldReturnFeaturesWithExistingTag() { + // Given: + val inputFeature = randomFeatureWithTags("sample") + + // When: + insertFeature(feature = inputFeature) + + // And: + val featuresWithFooTag = executeTagsQuery( + TagExists("sample") + ).features + + // Then: + assertEquals(1, featuresWithFooTag.size) + assertEquals(inputFeature.id, featuresWithFooTag[0]!!.id) + } + + @Test + fun shouldNotReturnFeaturesWithMissingTag() { + // Given: + val inputFeature = randomFeatureWithTags("existing") + + // When: + insertFeature(feature = inputFeature) + + // And: + val featuresWithFooTag = executeTagsQuery( + TagExists("non-existing") + ).features + + // Then: + assertEquals(0, featuresWithFooTag.size) + } + + @Test + fun shouldReturnFeaturesWithStringValue() { + // Given: + val inputFeature = randomFeatureWithTags("foo=bar") + + // When: + insertFeature(feature = inputFeature) + + // And: + val featuresWithFooTag = executeTagsQuery( + TagValueIsString(name = "foo", value = "bar") + ).features + + // Then: + assertEquals(1, featuresWithFooTag.size) + assertEquals(inputFeature.id, featuresWithFooTag[0]!!.id) + } + + @Test + fun shouldReturnFeaturesByBoolean(){ + // Given: + val enabledFeatureA = randomFeatureWithTags("flag:=true") + val enabledFeatureB = randomFeatureWithTags("flag:=true") + val disabledFeature = randomFeatureWithTags("flag:=false") + + // When: + insertFeatures(enabledFeatureA, enabledFeatureB, disabledFeature) + + // And: + val enabledFeatures = executeTagsQuery( + TagValueIsBool(name = "flag", value = true) + ).features + + // Then: + assertEquals(2, enabledFeatures.size) + val fetchedIds = enabledFeatures.map { it!!.id } + assertTrue(fetchedIds.containsAll(listOf(enabledFeatureA.id, enabledFeatureB.id))) + } + + @Test + fun shouldReturnFeaturesByTagRegex() { + // Given: + val featureFrom2024 = randomFeatureWithTags("year=2024") + val featureFrom2030 = randomFeatureWithTags("year=2030") + + // When: + insertFeatures(featureFrom2024, featureFrom2030) + + // And: + val featuresFromThisDecade = executeTagsQuery( + TagValueMatches(name = "year", regex = "202[0-9]") + ).features + + // Then: + assertEquals(1, featuresFromThisDecade.size) + assertEquals(featureFrom2024.id, featuresFromThisDecade[0]!!.id) + } + + @Test + fun shouldReturnFeaturesWithDoubleValue() { + // Given: + val inputFeatures = listOf( + randomFeatureWithTags("some_number:=1").apply { id = "one" }, + randomFeatureWithTags("some_number:=5").apply { id = "five" }, + ) + + // When: + insertFeatures(inputFeatures) + + // And: + val featuresGt2 = executeTagsQuery( + TagValueIsDouble("some_number", DoubleOp.GT, 1.0) + ).features + + // Then: + assertEquals(1, featuresGt2.size) + assertEquals("five", featuresGt2[0]!!.id) + + // When + val featuresLte5 = executeTagsQuery( + TagValueIsDouble("some_number", DoubleOp.LTE, 5.0) + ).features + + // Then: + assertEquals(2, featuresLte5.size) + val lte5ids = featuresLte5.map { it!!.id } + assertTrue(lte5ids.containsAll(listOf("one", "five"))) + + // When: + val featuresEq6 = executeTagsQuery( + TagValueIsDouble("some_number", DoubleOp.EQ, 6.0) + ).features + + // Then: + assertTrue(featuresEq6.isEmpty()) + } + + @Test + fun shouldReturnFeaturesForComposedTagQuery() { + // Given: + val activeJohn = randomFeatureWithTags( + "username=john_doe", + "is_active:=true", + ) + val activeNick = randomFeatureWithTags( + "username=nick_foo", + "is_active:=true", + ) + val inactiveJohn = randomFeatureWithTags( + "username=john_bar", + "is_active:=false", + ) + val oldAdmin = randomFeatureWithTags( + "username=some_admin", + "role=admin" + ) + val invalidUserWithoutId = randomFeatureWithTags("is_active:=true") + + // And: + insertFeatures(activeJohn, activeNick, inactiveJohn, oldAdmin, invalidUserWithoutId) + + // When: + val activeJohnsOrAdmin = TagOr( + TagAnd( + TagValueMatches(name = "username", regex = "john.+"), + TagValueIsBool(name = "is_active", value = true) + ), + TagValueIsString(name = "role", value = "admin") + ) + val features = executeTagsQuery(activeJohnsOrAdmin).features + + // Then: + assertEquals(2, features.size) + val featureIds = features.map { it!!.id } + featureIds.containsAll( + listOf( + activeJohn.id, + oldAdmin.id + ) + ) + } + + @Test + fun shouldTreatRefAsValueless() { + // Given: + val feature = randomFeatureWithTags("ref_lorem=ipsum") + insertFeatures(feature) + + // When + val byTagName = executeTagsQuery(TagExists("ref_lorem")).features + + // Then + assertTrue(byTagName.isEmpty()) + + // When + val byFullTag = executeTagsQuery(TagExists("ref_lorem=ipsum")).features + + // Then + assertEquals(1, byFullTag.size) + assertEquals(feature.id, byFullTag[0]!!.id) + } + + @Test + fun shouldTreatSourceIDAsValueless() { + // Given: + val feature = randomFeatureWithTags("sourceID:=123") + insertFeatures(feature) + + // When + val byTagName = executeTagsQuery(TagExists("sourceID")).features + + // Then + assertTrue(byTagName.isEmpty()) + + // When + val byFullTag = executeTagsQuery(TagExists("sourceID:=123")).features + + // Then + assertEquals(1, byFullTag.size) + assertEquals(feature.id, byFullTag[0]!!.id) + } + + private fun randomFeatureWithTags(vararg tags: String): NakshaFeature { + return ProxyFeatureGenerator.generateRandomFeature().apply { + properties.xyz.tags = TagList(*tags) + } + } + + private fun executeTagsQuery(tagQuery: ITagQuery): SuccessResponse { + return executeRead(ReadFeatures().apply { + collectionIds += collection!!.id + query.tags = tagQuery + }) + } +} \ No newline at end of file diff --git a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/base/PgTestBase.kt b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/base/PgTestBase.kt index 23fef9fc2..ed7b1e6d1 100644 --- a/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/base/PgTestBase.kt +++ b/here-naksha-lib-psql/src/commonTest/kotlin/naksha/psql/base/PgTestBase.kt @@ -34,6 +34,9 @@ abstract class PgTestBase(val collection: NakshaCollection? = null) { protected fun insertFeature(feature: NakshaFeature, sessionOptions: SessionOptions? = null) = insertFeatures(listOf(feature), sessionOptions) + protected fun insertFeatures(vararg features: NakshaFeature) = + insertFeatures(listOf(*features)) + protected fun insertFeatures( features: List, sessionOptions: SessionOptions? = null