diff --git a/build.gradle.kts b/build.gradle.kts index 064bb25..0337747 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,9 +46,11 @@ publishing { dependencies { // Use the Kotlin JUnit 5 integration. testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") - // Use the JUnit 5 integration. testImplementation("org.junit.jupiter:junit-jupiter-engine:5.9.2") + // Uncomment when needed +// testImplementation("io.mockk:mockk:1.13.8") + testImplementation("io.kotest:kotest-assertions-core:5.7.2") testRuntimeOnly("org.junit.platform:junit-platform-launcher") @@ -58,12 +60,13 @@ dependencies { // This dependency is used internally, and not exposed to consumers on their own compile classpath. implementation("com.google.guava:guava:31.1-jre") implementation("net.swiftzer.semver:semver:1.3.0") + implementation("com.goncalossilva:murmurhash:0.4.0") } // Apply a specific Java toolchain to ease working on different environments. java { toolchain { - languageVersion.set(JavaLanguageVersion.of(8)) + languageVersion.set(JavaLanguageVersion.of(11)) } } diff --git a/src/main/kotlin/com/featurevisor/sdk/Bucket.kt b/src/main/kotlin/com/featurevisor/sdk/Bucket.kt index 71f270b..d99e73d 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Bucket.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Bucket.kt @@ -1,14 +1,16 @@ package com.featurevisor.sdk +import com.goncalossilva.murmurhash.MurmurHash3 + object Bucket { - private const val HASH_SEED = 1 + private const val HASH_SEED = 1u private const val MAX_HASH_VALUE = 4294967296 // 2^32 // 100% * 1000 to include three decimal places in the same integer value private const val MAX_BUCKETED_NUMBER = 100000 fun getBucketedNumber(bucketKey: String): Int { - val hashValue = MurmurHash3().hash32x86(bucketKey.toByteArray()) + val hashValue = MurmurHash3(HASH_SEED).hash32x86(bucketKey.toByteArray()) val ratio = hashValue.toDouble() / MAX_HASH_VALUE return kotlin.math.floor(ratio * MAX_BUCKETED_NUMBER).toInt() diff --git a/src/main/kotlin/com/featurevisor/sdk/Conditions.kt b/src/main/kotlin/com/featurevisor/sdk/Conditions.kt index 1b32022..1e5bb75 100644 --- a/src/main/kotlin/com/featurevisor/sdk/Conditions.kt +++ b/src/main/kotlin/com/featurevisor/sdk/Conditions.kt @@ -1,17 +1,15 @@ package com.featurevisor.sdk import com.featurevisor.types.AttributeValue -import com.featurevisor.types.AttributeValue.BooleanValue -import com.featurevisor.types.AttributeValue.DoubleValue -import com.featurevisor.types.AttributeValue.IntValue -import com.featurevisor.types.AttributeValue.StringValue import com.featurevisor.types.Condition import com.featurevisor.types.Condition.And -import com.featurevisor.types.Condition.Multiple import com.featurevisor.types.Condition.Not import com.featurevisor.types.Condition.Or import com.featurevisor.types.Condition.Plain +import com.featurevisor.types.ConditionValue import com.featurevisor.types.Context +import com.featurevisor.types.Operator.AFTER +import com.featurevisor.types.Operator.BEFORE import com.featurevisor.types.Operator.CONTAINS import com.featurevisor.types.Operator.ENDS_WITH import com.featurevisor.types.Operator.EQUALS @@ -30,22 +28,21 @@ import com.featurevisor.types.Operator.SEMVER_LESS_THAN import com.featurevisor.types.Operator.SEMVER_LESS_THAN_OR_EQUAL import com.featurevisor.types.Operator.SEMVER_NOT_EQUALS import com.featurevisor.types.Operator.STARTS_WITH -import com.featurevisor.types.PlainCondition import net.swiftzer.semver.SemVer object Conditions { - fun conditionIsMatched(condition: PlainCondition, context: Context): Boolean { + fun conditionIsMatched(condition: Plain, context: Context): Boolean { val (attributeKey, operator, conditionValue) = condition val attributeValue = context.getOrDefault(attributeKey, null) ?: return false return when { - attributeValue is StringValue && conditionValue is StringValue -> { + attributeValue is AttributeValue.StringValue && conditionValue is ConditionValue.StringValue -> { when (operator) { EQUALS -> attributeValue.value == conditionValue.value NOT_EQUALS -> attributeValue.value != conditionValue.value CONTAINS -> attributeValue.value.contains(conditionValue.value) NOT_CONTAINS -> - !attributeValue.value.contains(conditionValue.value) + attributeValue.value.contains(conditionValue.value).not() STARTS_WITH -> attributeValue.value.startsWith(conditionValue.value) @@ -59,11 +56,9 @@ object Conditions { SEMVER_LESS_THAN_OR_EQUAL -> compareVersions(attributeValue.value, conditionValue.value) <= 0 else -> false } - - // @TODO: handle semvers } - attributeValue is IntValue && conditionValue is IntValue -> { + attributeValue is AttributeValue.IntValue && conditionValue is ConditionValue.IntValue -> { when (operator) { EQUALS -> attributeValue.value == conditionValue.value NOT_EQUALS -> attributeValue.value != conditionValue.value @@ -75,7 +70,7 @@ object Conditions { } } - attributeValue is DoubleValue && conditionValue is DoubleValue -> { + attributeValue is AttributeValue.DoubleValue && conditionValue is ConditionValue.DoubleValue -> { when (operator) { EQUALS -> attributeValue.value == conditionValue.value NOT_EQUALS -> attributeValue.value != conditionValue.value @@ -87,7 +82,7 @@ object Conditions { } } - attributeValue is BooleanValue && conditionValue is BooleanValue -> { + attributeValue is AttributeValue.BooleanValue && conditionValue is ConditionValue.BooleanValue -> { when (operator) { EQUALS -> attributeValue.value == conditionValue.value NOT_EQUALS -> attributeValue.value != conditionValue.value @@ -95,7 +90,7 @@ object Conditions { } } - attributeValue is StringValue && conditionValue is AttributeValue.ArrayValue -> { + attributeValue is AttributeValue.StringValue && conditionValue is ConditionValue.ArrayValue -> { when (operator) { IN_ARRAY -> attributeValue.value in conditionValue.values NOT_IN_ARRAY -> (attributeValue.value in conditionValue.values).not() @@ -103,7 +98,14 @@ object Conditions { } } - // @TODO: handle dates + attributeValue is AttributeValue.DateValue && conditionValue is ConditionValue.DateTimeValue -> { + when (operator) { + EQUALS -> attributeValue.value == conditionValue.value + BEFORE -> attributeValue.value < conditionValue.value + AFTER -> attributeValue.value > conditionValue.value + else -> false + } + } else -> false } @@ -111,22 +113,18 @@ object Conditions { fun allConditionsAreMatched(condition: Condition, context: Context): Boolean { return when (condition) { - is Plain -> conditionIsMatched(condition.condition, context) - - is Multiple -> condition.conditions.all { - allConditionsAreMatched(condition, context) - } + is Plain -> conditionIsMatched(condition, context) - is And -> condition.condition.and.all { - allConditionsAreMatched(condition, context) + is And -> condition.and.all { + allConditionsAreMatched(it, context) } - is Or -> condition.condition.or.any { - allConditionsAreMatched(condition, context) + is Or -> condition.or.any { + allConditionsAreMatched(it, context) } - is Not -> condition.condition.not.all { - allConditionsAreMatched(condition, context) + is Not -> condition.not.all { + allConditionsAreMatched(it, context) }.not() } } diff --git a/src/main/kotlin/com/featurevisor/sdk/MurmurHash3.kt b/src/main/kotlin/com/featurevisor/sdk/MurmurHash3.kt deleted file mode 100644 index cf7c2cf..0000000 --- a/src/main/kotlin/com/featurevisor/sdk/MurmurHash3.kt +++ /dev/null @@ -1,161 +0,0 @@ -package com.featurevisor.sdk - -/** - * Taken from: https://github.com/goncalossilva/kotlinx-murmurhash - * - * (Copied to make it work, we can resort to using it as a package later) - * - * --- - * - * MIT License - * - * Copyright (c) 2021-2022 Gonçalo Silva - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and - * associated documentation files (the "Software"), to deal in the Software without restriction, - * including without limitation the rights to use, copy, modify, merge, publish, distribute, - * sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all copies or - * substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT - * NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, - * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -public class MurmurHash3(private val seed: UInt = 1.toUInt()) { - public fun hash32x86(key: ByteArray): UInt { - var h = seed - val len = key.size - val nblocks = len / 4 - - for (i in 0 until nblocks * 4 step 4) { - val k = key.getLittleEndianUInt(i) - - h = h xor k.mix(R1_32, C1_32, C2_32) - h = h.rotateLeft(R2_32) - h = h * M_32 + N_32 - } - - val index = nblocks * 4 - val rem = len - index - var k = 0u - if (rem == 3) { - k = k xor (key.getUInt(index + 2) shl 16) - } - if (rem >= 2) { - k = k xor (key.getUInt(index + 1) shl 8) - } - if (rem >= 1) { - k = k xor key.getUInt(index) - h = h xor k.mix(R1_32, C1_32, C2_32) - } - - h = h xor len.toUInt() - - h = h.fmix() - - return h - } - - private fun ByteArray.getLittleEndianUInt(index: Int): UInt { - return this.getUInt(index) or - (this.getUInt(index + 1) shl 8) or - (this.getUInt(index + 2) shl 16) or - (this.getUInt(index + 3) shl 24) - } - - private fun ByteArray.getLittleEndianLong(index: Int): ULong { - return this.getULong(index) or - (this.getULong(index + 1) shl 8) or - (this.getULong(index + 2) shl 16) or - (this.getULong(index + 3) shl 24) or - (this.getULong(index + 4) shl 32) or - (this.getULong(index + 5) shl 40) or - (this.getULong(index + 6) shl 48) or - (this.getULong(index + 7) shl 56) - } - - private fun UInt.mix(r: Int, c1: UInt, c2: UInt): UInt { - var k = this - k *= c1 - k = k.rotateLeft(r) - k *= c2 - return k - } - - private fun ULong.mix(r: Int, c1: ULong, c2: ULong): ULong { - var k = this - k *= c1 - k = k.rotateLeft(r) - k *= c2 - return k - } - - private fun UInt.fmix(): UInt { - var h = this - h = h xor (h shr 16) - h *= 0x85ebca6bu - h = h xor (h shr 13) - h *= 0xc2b2ae35u - h = h xor (h shr 16) - return h - } - - private fun ULong.fmix(): ULong { - var h = this - h = h xor (h shr 33) - h *= 0xff51afd7ed558ccduL - h = h xor (h shr 33) - h *= 0xc4ceb9fe1a85ec53uL - h = h xor (h shr 33) - return h - } - - private fun ByteArray.getUInt(index: Int) = get(index).toUByte().toUInt() - - private fun ByteArray.getULong(index: Int) = get(index).toUByte().toULong() - - private companion object { - private const val C1_32: UInt = 0xcc9e2d51u - private const val C2_32: UInt = 0x1b873593u - - private const val R1_32: Int = 15 - private const val R2_32: Int = 13 - - private const val M_32: UInt = 5u - private const val N_32: UInt = 0xe6546b64u - - private const val C1_128x86: UInt = 0x239b961bu - private const val C2_128x86: UInt = 0xab0e9789u - private const val C3_128x86: UInt = 0x38b34ae5u - private const val C4_128x86: UInt = 0xa1e38b93u - - private const val R1_128x86: Int = 15 - private const val R2_128x86: Int = 16 - private const val R3_128x86: Int = 17 - private const val R4_128x86: Int = 18 - private const val R5_128x86: Int = 19 - private const val R6_128x86: Int = 13 - - private const val M_128x86: UInt = 5u - private const val N1_128x86: UInt = 0x561ccd1bu - private const val N2_128x86: UInt = 0x0bcaa747u - private const val N3_128x86: UInt = 0x96cd1c35u - private const val N4_128x86: UInt = 0x32ac3b17u - - private const val C1_128x64: ULong = 0x87c37b91114253d5uL - private const val C2_128x64: ULong = 0x4cf5ad432745937fuL - - private const val R1_128x64: Int = 31 - private const val R2_128x64: Int = 27 - private const val R3_128x64: Int = 33 - - private const val M_128x64: ULong = 5u - private const val N1_128x64: ULong = 0x52dce729u - private const val N2_128x64: ULong = 0x38495ab5u - } -} diff --git a/src/main/kotlin/com/featurevisor/types/AttributeValue.kt b/src/main/kotlin/com/featurevisor/types/Attribute.kt similarity index 58% rename from src/main/kotlin/com/featurevisor/types/AttributeValue.kt rename to src/main/kotlin/com/featurevisor/types/Attribute.kt index 04338b7..0910aa8 100644 --- a/src/main/kotlin/com/featurevisor/types/AttributeValue.kt +++ b/src/main/kotlin/com/featurevisor/types/Attribute.kt @@ -1,12 +1,21 @@ package com.featurevisor.types +import java.time.LocalDate + +typealias AttributeKey = String + +data class Attribute( + val key: AttributeKey, + val type: String, + val archived: Boolean?, + val capture: Boolean?, +) + sealed class AttributeValue { data class StringValue(val value: String) : AttributeValue() data class IntValue(val value: Int) : AttributeValue() data class DoubleValue(val value: Double) : AttributeValue() data class BooleanValue(val value: Boolean) : AttributeValue() - data class ArrayValue(val values: List) : AttributeValue() - - // @TODO: implement Date + data class DateValue(val value: LocalDate) : AttributeValue() object NullValue : AttributeValue() } diff --git a/src/main/kotlin/com/featurevisor/types/Condition.kt b/src/main/kotlin/com/featurevisor/types/Condition.kt new file mode 100644 index 0000000..bee7b7a --- /dev/null +++ b/src/main/kotlin/com/featurevisor/types/Condition.kt @@ -0,0 +1,25 @@ +package com.featurevisor.types + +import java.time.LocalDate + +sealed class Condition { + data class Plain( + val attributeKey: AttributeKey, + val operator: Operator, + val value: ConditionValue, + ) : Condition() + + data class And(val and: List) : Condition() + data class Or(val or: List) : Condition() + data class Not(val not: List) : Condition() +} + +sealed class ConditionValue { + data class StringValue(val value: String) : ConditionValue() + data class IntValue(val value: Int) : ConditionValue() + data class DoubleValue(val value: Double) : ConditionValue() + data class BooleanValue(val value: Boolean) : ConditionValue() + data class ArrayValue(val values: List) : ConditionValue() + data class DateTimeValue(val value: LocalDate) : ConditionValue() + object NullValue : ConditionValue() +} diff --git a/src/main/kotlin/com/featurevisor/types/Types.kt b/src/main/kotlin/com/featurevisor/types/Types.kt index 7a09757..895a104 100644 --- a/src/main/kotlin/com/featurevisor/types/Types.kt +++ b/src/main/kotlin/com/featurevisor/types/Types.kt @@ -1,42 +1,7 @@ package com.featurevisor.types -typealias AttributeKey = String typealias Context = Map -data class Attribute( - val key: AttributeKey, - val type: String, - val archived: Boolean?, - val capture: Boolean?, -) - -data class PlainCondition( - val attributeKey: AttributeKey, - val operator: Operator, - val value: AttributeValue, -) - -data class AndCondition( - val and: List, -) - -data class OrCondition( - val or: List, -) - -data class NotCondition( - val not: List, -) - -sealed class Condition { - data class Plain(val condition: PlainCondition) : Condition() - data class Multiple(val conditions: List) : Condition() - - data class And(val condition: AndCondition) : Condition() - data class Or(val condition: OrCondition) : Condition() - data class Not(val condition: NotCondition) : Condition() -} - typealias SegmentKey = String data class Segment( diff --git a/src/test/kotlin/com/featurevisor/sdk/ConditionsTest.kt b/src/test/kotlin/com/featurevisor/sdk/ConditionsTest.kt index 3cbab42..0d3732e 100644 --- a/src/test/kotlin/com/featurevisor/sdk/ConditionsTest.kt +++ b/src/test/kotlin/com/featurevisor/sdk/ConditionsTest.kt @@ -1,111 +1,513 @@ package com.featurevisor.sdk import com.featurevisor.types.AttributeValue +import com.featurevisor.types.Condition +import com.featurevisor.types.ConditionValue import com.featurevisor.types.Operator -import com.featurevisor.types.PlainCondition +import io.kotest.matchers.shouldBe +import java.time.LocalDate import kotlin.test.Test -import kotlin.test.assertEquals class ConditionsTest { @Test - fun testEqualsOperatorForStrings() { + fun `EQUALS operator works for strings`() { val condition = - PlainCondition( - "browser_type", - Operator.EQUALS, - AttributeValue.StringValue("chrome") + Condition.Plain( + attributeKey = "browser_type", + operator = Operator.EQUALS, + value = ConditionValue.StringValue("chrome") ) - // match - assertEquals( - true, - Conditions.conditionIsMatched( - condition, - mapOf("browser_type" to AttributeValue.StringValue("chrome")) + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("browser_type" to AttributeValue.StringValue("chrome")) + ) shouldBe true + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("browser_type" to AttributeValue.StringValue("firefox")) + ) shouldBe false + } + + @Test + fun `NOT_EQUALS operator works for strings`() { + val condition = + Condition.Plain( + attributeKey = "browser_type", + operator = Operator.NOT_EQUALS, + value = ConditionValue.StringValue("chrome") ) - ) - // not match - assertEquals( - false, - Conditions.conditionIsMatched( - condition, - mapOf("browser_type" to AttributeValue.StringValue("firefox")) + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("browser_type" to AttributeValue.StringValue("firefox")) + ) shouldBe true + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("browser_type" to AttributeValue.StringValue("chrome")) + ) shouldBe false + } + + @Test + fun `GREATER_THAN operator works for integers`() { + val condition = + Condition.Plain( + attributeKey = "age", + operator = Operator.GREATER_THAN, + value = ConditionValue.IntValue(18) ) - ) + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("age" to AttributeValue.IntValue(19)) + ) shouldBe true + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("age" to AttributeValue.IntValue(17)) + ) shouldBe false } @Test - fun testNotEqualsOperatorForStrings() { + fun `LESS_THAN operator works for integers`() { val condition = - PlainCondition( - "browser_type", - Operator.NOT_EQUALS, - AttributeValue.StringValue("chrome") + Condition.Plain( + attributeKey = "age", + operator = Operator.LESS_THAN, + value = ConditionValue.IntValue(18) ) - // match - assertEquals( - true, - Conditions.conditionIsMatched( - condition, - mapOf("browser_type" to AttributeValue.StringValue("firefox")) + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("age" to AttributeValue.IntValue(17)) + ) shouldBe true + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("age" to AttributeValue.IntValue(19)) + ) shouldBe false + } + + @Test + fun `GREATER_THAN_OR_EQUAL operator works for integers`() { + val condition = + Condition.Plain( + attributeKey = "age", + operator = Operator.GREATER_THAN_OR_EQUAL, + value = ConditionValue.IntValue(18) ) - ) - // not match - assertEquals( - false, - Conditions.conditionIsMatched( - condition, - mapOf("browser_type" to AttributeValue.StringValue("chrome")) + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("age" to AttributeValue.IntValue(17)) + ) shouldBe false + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("age" to AttributeValue.IntValue(18)) + ) shouldBe true + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("age" to AttributeValue.IntValue(19)) + ) shouldBe true + } + + @Test + fun `LESS_THAN_OR_EQUAL operator works for integers`() { + val condition = + Condition.Plain( + attributeKey = "age", + operator = Operator.LESS_THAN_OR_EQUAL, + value = ConditionValue.IntValue(18) ) + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("age" to AttributeValue.IntValue(17)) + ) shouldBe true + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("age" to AttributeValue.IntValue(18)) + ) shouldBe true + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("age" to AttributeValue.IntValue(19)) + ) shouldBe false + } + + @Test + fun `CONTAINS operator works for strings`() { + val condition = Condition.Plain( + attributeKey = "browser_type", + operator = Operator.CONTAINS, + value = ConditionValue.StringValue("hro"), ) + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("browser_type" to AttributeValue.StringValue("chrome")) + ) shouldBe true + + Conditions.conditionIsMatched( + condition = condition.copy(value = ConditionValue.StringValue("hrk")), + context = mapOf("browser_type" to AttributeValue.StringValue("chrome")) + ) shouldBe false } @Test - fun testGreaterThanOperator() { - val condition = PlainCondition("age", Operator.GREATER_THAN, AttributeValue.IntValue(18)) + fun `NOT_CONTAINS operator works for strings`() { + val condition = Condition.Plain( + attributeKey = "browser_type", + operator = Operator.CONTAINS, + value = ConditionValue.StringValue("hro"), + ) - // match - assertEquals( - true, - Conditions.conditionIsMatched( - condition, - mapOf("age" to AttributeValue.IntValue(19)) - ) + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("browser_type" to AttributeValue.StringValue("chrome")) + ) shouldBe true + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("browser_type" to AttributeValue.StringValue("firefox")) + ) shouldBe false + } + + @Test + fun `STARTS_WITH operator works for strings`() { + val condition = Condition.Plain( + attributeKey = "browser_type", + operator = Operator.STARTS_WITH, + value = ConditionValue.StringValue("chr"), ) - // not match - assertEquals( - false, - Conditions.conditionIsMatched( - condition, - mapOf("age" to AttributeValue.IntValue(17)) - ) + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("browser_type" to AttributeValue.StringValue("chrome")) + ) shouldBe true + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("browser_type" to AttributeValue.StringValue("firefox")) + ) shouldBe false + } + + @Test + fun `ENDS_WITH operator works for strings`() { + val condition = Condition.Plain( + attributeKey = "browser_type", + operator = Operator.ENDS_WITH, + value = ConditionValue.StringValue("ome"), ) + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("browser_type" to AttributeValue.StringValue("chrome")) + ) shouldBe true + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("browser_type" to AttributeValue.StringValue("firefox")) + ) shouldBe false } @Test - fun testLessThanOperator() { - val condition = PlainCondition("age", Operator.LESS_THAN, AttributeValue.IntValue(18)) + fun `SEMVER_EQUALS operator works for strings`() { + val condition = Condition.Plain( + attributeKey = "version", + operator = Operator.SEMVER_EQUALS, + value = ConditionValue.StringValue("1.2.3") + ) + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("version" to AttributeValue.StringValue("1.2.3")) + ) shouldBe true + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("version" to AttributeValue.StringValue("1.2.4")) + ) shouldBe false + } + + @Test + fun `SEMVER_NOT_EQUALS operator works for strings`() { + val condition = Condition.Plain( + attributeKey = "version", + operator = Operator.SEMVER_NOT_EQUALS, + value = ConditionValue.StringValue("1.2.3") + ) + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("version" to AttributeValue.StringValue("1.2.3")) + ) shouldBe false + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("version" to AttributeValue.StringValue("1.2.4")) + ) shouldBe true + } + + @Test + fun `SEMVER_GREATER_THAN operator works for strings`() { + val condition = Condition.Plain( + attributeKey = "version", + operator = Operator.SEMVER_GREATER_THAN, + value = ConditionValue.StringValue("1.2.3") + ) + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("version" to AttributeValue.StringValue("1.2.4")) + ) shouldBe true + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("version" to AttributeValue.StringValue("1.2.3")) + ) shouldBe false - // match - assertEquals( - true, - Conditions.conditionIsMatched( - condition, - mapOf("age" to AttributeValue.IntValue(17)) + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("version" to AttributeValue.StringValue("1.2.2")) + ) shouldBe false + } + + @Test + fun `SEMVER_GREATER_THAN_OR_EQUAL operator works for strings`() { + val condition = Condition.Plain( + attributeKey = "version", + operator = Operator.SEMVER_GREATER_THAN_OR_EQUAL, + value = ConditionValue.StringValue("1.2.3") + ) + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("version" to AttributeValue.StringValue("1.2.4")) + ) shouldBe true + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("version" to AttributeValue.StringValue("1.2.3")) + ) shouldBe true + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("version" to AttributeValue.StringValue("1.2.2")) + ) shouldBe false + } + + @Test + fun `SEMVER_LESS_THAN operator works for strings`() { + val condition = Condition.Plain( + attributeKey = "version", + operator = Operator.SEMVER_LESS_THAN, + value = ConditionValue.StringValue("1.2.3") + ) + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("version" to AttributeValue.StringValue("1.2.4")) + ) shouldBe false + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("version" to AttributeValue.StringValue("1.2.3")) + ) shouldBe false + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("version" to AttributeValue.StringValue("1.2.2")) + ) shouldBe true + } + + @Test + fun `SEMVER_LESS_THAN_OR_EQUAL operator works for strings`() { + val condition = Condition.Plain( + attributeKey = "version", + operator = Operator.SEMVER_LESS_THAN_OR_EQUAL, + value = ConditionValue.StringValue("1.2.3") + ) + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("version" to AttributeValue.StringValue("1.2.4")) + ) shouldBe false + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("version" to AttributeValue.StringValue("1.2.3")) + ) shouldBe true + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("version" to AttributeValue.StringValue("1.2.2")) + ) shouldBe true + } + + @Test + fun `BEFORE operator works for strings`() { + val condition = Condition.Plain( + attributeKey = "date", + operator = Operator.BEFORE, + value = ConditionValue.DateTimeValue(LocalDate.of(2023, 10, 5)), + ) + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("date" to AttributeValue.DateValue(LocalDate.of(2023, 10, 4))) + ) shouldBe true + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("date" to AttributeValue.DateValue(LocalDate.of(2023, 10, 5))) + ) shouldBe false + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("date" to AttributeValue.DateValue(LocalDate.of(2023, 10, 6))) + ) shouldBe false + } + + @Test + fun `AFTER operator works for strings`() { + val condition = Condition.Plain( + attributeKey = "date", + operator = Operator.AFTER, + value = ConditionValue.DateTimeValue(LocalDate.of(2023, 10, 5)), + ) + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("date" to AttributeValue.DateValue(LocalDate.of(2023, 10, 4))) + ) shouldBe false + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("date" to AttributeValue.DateValue(LocalDate.of(2023, 10, 5))) + ) shouldBe false + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("date" to AttributeValue.DateValue(LocalDate.of(2023, 10, 6))) + ) shouldBe true + } + + @Test + fun `IN_ARRAY operator works for strings`() { + val condition = Condition.Plain( + attributeKey = "letter", + operator = Operator.IN_ARRAY, + value = ConditionValue.ArrayValue(listOf("a", "b", "c")), + ) + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("letter" to AttributeValue.StringValue("b")), + ) shouldBe true + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("letter" to AttributeValue.StringValue("d")), + ) shouldBe false + } + + @Test + fun `NOT_IN_ARRAY operator works for strings`() { + val condition = Condition.Plain( + attributeKey = "letter", + operator = Operator.NOT_IN_ARRAY, + value = ConditionValue.ArrayValue(listOf("a", "b", "c")), + ) + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("letter" to AttributeValue.StringValue("b")), + ) shouldBe false + + Conditions.conditionIsMatched( + condition = condition, + context = mapOf("letter" to AttributeValue.StringValue("d")), + ) shouldBe true + } + + @Test + fun `multiple conditions work`() { + val startsWithCondition = Condition.Plain( + attributeKey = "browser_type", + operator = Operator.STARTS_WITH, + value = ConditionValue.StringValue("chr"), + ) + + val semVerCondition = Condition.Plain( + attributeKey = "version", + operator = Operator.SEMVER_GREATER_THAN, + value = ConditionValue.StringValue("1.2.3") + ) + + val ageCondition = + Condition.Plain( + attributeKey = "age", + operator = Operator.GREATER_THAN, + value = ConditionValue.IntValue(18) ) + + val beforeCondition = Condition.Plain( + attributeKey = "date", + operator = Operator.BEFORE, + value = ConditionValue.DateTimeValue(LocalDate.of(2023, 10, 5)), + ) + + val inArrayCondition = Condition.Plain( + attributeKey = "letter", + operator = Operator.IN_ARRAY, + value = ConditionValue.ArrayValue(listOf("a", "b", "c")), ) - // not match - assertEquals( - false, - Conditions.conditionIsMatched( - condition, - mapOf("age" to AttributeValue.IntValue(19)) + val condition = Condition.And( + listOf( + Condition.And( + listOf( + startsWithCondition, + semVerCondition, + ) + ), + Condition.Or( + listOf( + ageCondition, + beforeCondition, + ) + ), + Condition.Not( + listOf( + inArrayCondition + ) + ) ) ) + + val context = mapOf( + "browser_type" to AttributeValue.StringValue("chrome"), // true + "version" to AttributeValue.StringValue("1.2.4"), // true + "date" to AttributeValue.DateValue(LocalDate.of(2023, 10, 6)), // false + "letter" to AttributeValue.StringValue("x"), // false + "age" to AttributeValue.IntValue(19), // true + ) + + Conditions.allConditionsAreMatched( + condition = condition, + context = context, + ) shouldBe true + + Conditions.allConditionsAreMatched( + condition = condition, + context = context.toMutableMap().apply { + this["age"] = AttributeValue.IntValue(17) + }, + ) shouldBe false } }