diff --git a/common/src/main/kotlin/org/imdc/extensions/common/DatasetExtensions.kt b/common/src/main/kotlin/org/imdc/extensions/common/DatasetExtensions.kt index c8962ed..b927a7d 100644 --- a/common/src/main/kotlin/org/imdc/extensions/common/DatasetExtensions.kt +++ b/common/src/main/kotlin/org/imdc/extensions/common/DatasetExtensions.kt @@ -4,6 +4,7 @@ import com.inductiveautomation.ignition.common.Dataset import com.inductiveautomation.ignition.common.TypeUtilities import com.inductiveautomation.ignition.common.script.PyArgParser import com.inductiveautomation.ignition.common.script.builtin.KeywordArgs +import com.inductiveautomation.ignition.common.script.hints.ScriptArg import com.inductiveautomation.ignition.common.script.hints.ScriptFunction import com.inductiveautomation.ignition.common.util.DatasetBuilder import org.apache.poi.ss.usermodel.CellType.BOOLEAN @@ -342,4 +343,66 @@ object DatasetExtensions { return dataset.build() } } + + @Suppress("unused") + @ScriptFunction(docBundlePrefix = "DatasetExtensions") + fun equals( + @ScriptArg("dataset1") ds1: Dataset, + @ScriptArg("dataset2") ds2: Dataset, + ): Boolean { + return ds1 === ds2 || (columnsEqual(ds1, ds2) && valuesEqual(ds1, ds2)) + } + + @Suppress("unused") + @ScriptFunction(docBundlePrefix = "DatasetExtensions") + fun valuesEqual( + @ScriptArg("dataset1") ds1: Dataset, + @ScriptArg("dataset2") ds2: Dataset, + ): Boolean { + if (ds1 === ds2) { + return true + } + if (ds1.rowCount != ds2.rowCount || ds1.columnCount != ds2.columnCount) { + return false + } + return ds1.rowIndices.all { row -> + ds1.columnIndices.all { col -> + ds1[row, col] == ds2[row, col] + } + } + } + + @Suppress("unused") + @ScriptFunction(docBundlePrefix = "DatasetExtensions") + @JvmOverloads + fun columnsEqual( + @ScriptArg("dataset1") ds1: Dataset, + @ScriptArg("dataset2") ds2: Dataset, + @ScriptArg("ignoreCase") ignoreCase: Boolean = false, + @ScriptArg("includeTypes") includeTypes: Boolean = true, + ): Boolean { + if (ds1 === ds2) { + return true + } + if (ds1.columnCount != ds2.columnCount) { + return false + } + + val columnNames = ds1.columnNames zip ds2.columnNames + val columnNamesMatch = columnNames.all { (left, right) -> + left.equals(right, ignoreCase) + } + if (!columnNamesMatch) { + return false + } + + if (!includeTypes) { + return true + } + + val columnTypes = ds1.columnTypes.asSequence() zip ds2.columnTypes.asSequence() + return columnTypes.all { (left, right) -> + left == right + } + } } diff --git a/common/src/main/resources/org/imdc/extensions/common/DatasetExtensions.properties b/common/src/main/resources/org/imdc/extensions/common/DatasetExtensions.properties index a4deb76..b7048a9 100644 --- a/common/src/main/resources/org/imdc/extensions/common/DatasetExtensions.properties +++ b/common/src/main/resources/org/imdc/extensions/common/DatasetExtensions.properties @@ -24,3 +24,20 @@ fromExcel.param.lastRow=The last row (zero-indexed) in the Excel document to ret fromExcel.param.firstColumn=The first column (zero-indexed) in the Excel document to retrieve data from. If not supplied, the first non-empty column will be used. fromExcel.param.lastColumn=The last column (zero-indexed) in the Excel document to retrieve data from. If not supplied, the last non-empty column will be used. fromExcel.returns=A Dataset created from the Excel document. Types are assumed based on the first row of input data. + +equals.desc=Compares two datasets for structural equality. +equals.param.dataset1=The first dataset. Must not be null. +equals.param.dataset2=The second dataset. Must not be null. +equals.returns=True if the two datasets have the same number of columns, with the same types, in the same order, with the same data in each row. + +valuesEqual.desc=Compares two datasets' content. +valuesEqual.param.dataset1=The first dataset. Must not be null. +valuesEqual.param.dataset2=The second dataset. Must not be null. +valuesEqual.returns=True if the two datasets have the same values. + +columnsEqual.desc=Compares two datasets' column definitions. +columnsEqual.param.dataset1=The first dataset. Must not be null. +columnsEqual.param.dataset2=The second dataset. Must not be null. +columnsEqual.param.ignoreCase=Pass True if the column names should be compared case-insensitive. Defaults to False. +columnsEqual.param.includeTypes=Pass True if the column types must match as well. Defaults to True. +columnsEqual.returns=True if the two datasets have the same columns, per additional parameters. diff --git a/common/src/test/kotlin/org/imdc/extensions/common/DSBuilder.kt b/common/src/test/kotlin/org/imdc/extensions/common/DSBuilder.kt new file mode 100644 index 0000000..9dcc1cf --- /dev/null +++ b/common/src/test/kotlin/org/imdc/extensions/common/DSBuilder.kt @@ -0,0 +1,38 @@ +package org.imdc.extensions.common + +import com.inductiveautomation.ignition.common.BasicDataset +import com.inductiveautomation.ignition.common.Dataset + +class DSBuilder { + data class Column(val name: String, val rows: List<*>, val type: Class<*>) + + val columns = mutableListOf() + + inline fun column(name: String, data: List) { + columns.add(Column(name, data, T::class.java)) + } + + inline fun column(name: String, builder: MutableList.() -> Unit) { + column(name, buildList(builder)) + } + + fun build(): Dataset { + val colCount = columns.size + val rowCount = columns.maxOf { it.rows.size } + val data = Array(colCount) { arrayOfNulls(rowCount) } + + for (c in 0 until colCount) { + for (r in 0 until rowCount) { + data[c][r] = columns.getOrNull(c)?.rows?.getOrNull(r) + } + } + + return BasicDataset(columns.map { it.name }, columns.map { it.type }, data) + } + + companion object { + fun dataset(block: DSBuilder.() -> Unit): Dataset { + return DSBuilder().apply(block).build() + } + } +} diff --git a/common/src/test/kotlin/org/imdc/extensions/common/DatasetEqualityTests.kt b/common/src/test/kotlin/org/imdc/extensions/common/DatasetEqualityTests.kt new file mode 100644 index 0000000..a9cfd00 --- /dev/null +++ b/common/src/test/kotlin/org/imdc/extensions/common/DatasetEqualityTests.kt @@ -0,0 +1,156 @@ +package org.imdc.extensions.common + +import com.inductiveautomation.ignition.common.BasicDataset +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import org.imdc.extensions.common.DSBuilder.Companion.dataset +import java.util.Date + +class DatasetEqualityTests : FunSpec( + { + val dataset1 = dataset { + column("a", listOf(1, 2, 3)) + column("b", listOf(3.14, 2.18, 4.96)) + column("c", listOf("string", "strung", "strang")) + } + val copyOfDs1 = BasicDataset(dataset1) + val ds1WithCaps = BasicDataset(listOf("A", "B", "C"), dataset1.columnTypes, dataset1) + val ds1WithOtherNames = BasicDataset(listOf("ant", "bat", "cat"), dataset1.columnTypes, dataset1) + val dataset2 = dataset { + column("j", listOf(3, 2, 1)) + column("k", listOf(1.0, 2.0, 3.0)) + column("l", listOf("chess", "chass", "chuss")) + } + val copyOfDs2 = BasicDataset(dataset2) + val ds2WithCaps = BasicDataset(listOf("J", "K", "L"), dataset2.columnTypes, dataset2) + val ds2WithOtherNames = BasicDataset(listOf("joe", "kim", "lee"), dataset2.columnTypes, dataset2) + + val dataset3 = dataset { + column("t_stamp", listOf(1667605391000, 1667605392000, 1667605393000, 1667605394000).map(::Date)) + column("tag_1", listOf(1.0, 2.0, 3.0, 4.0)) + column("tag_2", listOf(true, false, true, false)) + } + val copyOfDs3 = BasicDataset(dataset3) + val ds3WithAliases = BasicDataset(listOf("timestamp", "doubleTag", "boolTag"), dataset3.columnTypes, dataset3) + + context("General equality") { + test("Same dataset") { + DatasetExtensions.equals(dataset1, dataset1) shouldBe true + DatasetExtensions.equals(dataset2, dataset2) shouldBe true + DatasetExtensions.equals(dataset3, dataset3) shouldBe true + } + + test("Copied dataset") { + DatasetExtensions.equals(dataset1, copyOfDs1) shouldBe true + DatasetExtensions.equals(dataset2, copyOfDs2) shouldBe true + DatasetExtensions.equals(dataset3, copyOfDs3) shouldBe true + } + + test("Different datasets") { + DatasetExtensions.equals(dataset1, dataset2) shouldBe false + DatasetExtensions.equals(dataset1, dataset3) shouldBe false + DatasetExtensions.equals(dataset2, dataset3) shouldBe false + } + + test("Datasets with same structure, but different columns") { + DatasetExtensions.equals(dataset1, ds1WithCaps) shouldBe false + DatasetExtensions.equals(dataset1, ds1WithOtherNames) shouldBe false + DatasetExtensions.equals(dataset2, ds2WithCaps) shouldBe false + DatasetExtensions.equals(dataset2, ds2WithOtherNames) shouldBe false + DatasetExtensions.equals(dataset3, ds3WithAliases) shouldBe false + } + } + + context("Value equality") { + test("Same dataset") { + DatasetExtensions.valuesEqual(dataset1, dataset1) shouldBe true + DatasetExtensions.valuesEqual(dataset2, dataset2) shouldBe true + DatasetExtensions.valuesEqual(dataset3, dataset3) shouldBe true + } + + test("Copied dataset") { + DatasetExtensions.valuesEqual(dataset1, copyOfDs1) shouldBe true + DatasetExtensions.valuesEqual(dataset2, copyOfDs2) shouldBe true + DatasetExtensions.valuesEqual(dataset3, copyOfDs3) shouldBe true + } + + test("Different datasets") { + DatasetExtensions.valuesEqual(dataset1, dataset2) shouldBe false + DatasetExtensions.valuesEqual(dataset1, dataset3) shouldBe false + DatasetExtensions.valuesEqual(dataset2, dataset3) shouldBe false + } + + test("Datasets with same structure, but different columns") { + DatasetExtensions.valuesEqual(dataset1, ds1WithCaps) shouldBe true + DatasetExtensions.valuesEqual(dataset1, ds1WithOtherNames) shouldBe true + DatasetExtensions.valuesEqual(dataset2, ds2WithCaps) shouldBe true + DatasetExtensions.valuesEqual(dataset2, ds2WithOtherNames) shouldBe true + DatasetExtensions.valuesEqual(dataset3, ds3WithAliases) shouldBe true + } + } + + context("Column equality") { + test("Same dataset") { + DatasetExtensions.columnsEqual(dataset1, dataset1) shouldBe true + DatasetExtensions.columnsEqual(dataset2, dataset2) shouldBe true + DatasetExtensions.columnsEqual(dataset3, dataset3) shouldBe true + } + + test("Copied dataset") { + DatasetExtensions.columnsEqual(dataset1, copyOfDs1) shouldBe true + DatasetExtensions.columnsEqual(dataset2, copyOfDs2) shouldBe true + DatasetExtensions.columnsEqual(dataset3, copyOfDs3) shouldBe true + } + + test("Different datasets") { + DatasetExtensions.columnsEqual(dataset1, dataset2) shouldBe false + DatasetExtensions.columnsEqual(dataset1, dataset3) shouldBe false + DatasetExtensions.columnsEqual(dataset2, dataset3) shouldBe false + } + + test("Datasets with same structure, but different columns") { + DatasetExtensions.columnsEqual(dataset1, ds1WithCaps) shouldBe false + DatasetExtensions.columnsEqual(dataset1, ds1WithCaps, ignoreCase = true) shouldBe true + DatasetExtensions.columnsEqual(dataset1, ds1WithOtherNames) shouldBe false + DatasetExtensions.columnsEqual(dataset2, ds2WithCaps) shouldBe false + DatasetExtensions.columnsEqual(dataset2, ds2WithCaps, ignoreCase = true) shouldBe true + DatasetExtensions.columnsEqual(dataset2, ds2WithOtherNames) shouldBe false + DatasetExtensions.columnsEqual(dataset3, ds3WithAliases) shouldBe false + + val ds1WithDifferentTypes = BasicDataset( + dataset1.columnNames, + listOf(String::class.java, Int::class.java, Boolean::class.java), + dataset1, + ) + DatasetExtensions.columnsEqual(dataset1, ds1WithDifferentTypes) shouldBe false + DatasetExtensions.columnsEqual(dataset1, ds1WithDifferentTypes, ignoreCase = true) shouldBe false + DatasetExtensions.columnsEqual(dataset1, ds1WithDifferentTypes, includeTypes = false) shouldBe true + DatasetExtensions.columnsEqual( + dataset1, + ds1WithDifferentTypes, + includeTypes = false, + ignoreCase = true, + ) shouldBe true + + val ds1WithDifferentTypesAndCase = BasicDataset( + listOf("A", "B", "C"), + dataset1.columnTypes, + dataset1, + ) + DatasetExtensions.columnsEqual(dataset1, ds1WithDifferentTypesAndCase) shouldBe false + DatasetExtensions.columnsEqual(dataset1, ds1WithDifferentTypesAndCase, ignoreCase = true) shouldBe true + DatasetExtensions.columnsEqual( + dataset1, + ds1WithDifferentTypesAndCase, + includeTypes = false, + ) shouldBe false + DatasetExtensions.columnsEqual( + dataset1, + ds1WithDifferentTypesAndCase, + ignoreCase = true, + includeTypes = false, + ) shouldBe true + } + } + }, +)