From e9de62e1e65be90bb3281e1bdad8b0aa27dd7116 Mon Sep 17 00:00:00 2001 From: Lalit Yadav Date: Wed, 18 Mar 2026 18:42:24 -0500 Subject: [PATCH 1/3] Add TableRowMatchers with strict type-aware equality for BigQuery --- .../BigQueryTableRowEqualityTest.java | 202 ++++++++++++++++++ .../sdk/io/gcp/bigquery/TableRowMatchers.java | 187 ++++++++++++++++ 2 files changed, 389 insertions(+) create mode 100644 sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryTableRowEqualityTest.java create mode 100644 sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/TableRowMatchers.java diff --git a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryTableRowEqualityTest.java b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryTableRowEqualityTest.java new file mode 100644 index 000000000000..5b8a095a3d09 --- /dev/null +++ b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/BigQueryTableRowEqualityTest.java @@ -0,0 +1,202 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.beam.sdk.io.gcp.bigquery; + +import static org.apache.beam.sdk.io.gcp.bigquery.TableRowMatchers.isTableRowEqualTo; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.not; + +import com.google.api.services.bigquery.model.TableRow; +import java.util.Arrays; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Tests for the {@link TableRowMatchers} class. */ +@RunWith(JUnit4.class) +public class BigQueryTableRowEqualityTest { + + @Test + public void testIdenticalRows() { + TableRow row1 = new TableRow().set("count", 1).set("name", "Alice"); + TableRow row2 = new TableRow().set("count", 1).set("name", "Alice"); + assertThat(row1, isTableRowEqualTo(row2)); + } + + @Test + public void testEmptyRows() { + TableRow row1 = new TableRow(); + TableRow row2 = new TableRow(); + assertThat(row1, isTableRowEqualTo(row2)); + } + + @Test + public void testIntegerVsString() { + TableRow rowWithInteger = new TableRow().set("count", 1); + TableRow rowWithString = new TableRow().set("count", "1"); + assertThat(rowWithInteger, not(isTableRowEqualTo(rowWithString))); + } + + @Test + public void testDoubleVsInteger() { + TableRow rowWithDouble = new TableRow().set("value", 1.0); + TableRow rowWithInteger = new TableRow().set("value", 1); + assertThat(rowWithDouble, not(isTableRowEqualTo(rowWithInteger))); + } + + @Test + public void testBooleanVsString() { + TableRow rowWithBoolean = new TableRow().set("active", true); + TableRow rowWithString = new TableRow().set("active", "true"); + assertThat(rowWithBoolean, not(isTableRowEqualTo(rowWithString))); + } + + @Test + public void testBothFieldsNull() { + TableRow row1 = new TableRow().set("name", null); + TableRow row2 = new TableRow().set("name", null); + assertThat(row1, isTableRowEqualTo(row2)); + } + + @Test + public void testNullVsNonNull() { + TableRow rowWithNull = new TableRow().set("name", null); + TableRow rowWithValue = new TableRow().set("name", "Alice"); + assertThat(rowWithNull, not(isTableRowEqualTo(rowWithValue))); + } + + @Test + public void testEmptyStringVsNull() { + TableRow rowWithEmptyString = new TableRow().set("name", ""); + TableRow rowWithNull = new TableRow().set("name", null); + assertThat(rowWithEmptyString, not(isTableRowEqualTo(rowWithNull))); + } + + @Test + public void testWhitespaceDifference() { + TableRow rowWithoutSpace = new TableRow().set("name", "Alice"); + TableRow rowWithLeadingSpace = new TableRow().set("name", " Alice"); + assertThat(rowWithoutSpace, not(isTableRowEqualTo(rowWithLeadingSpace))); + } + + @Test + public void testDifferentFieldCount() { + TableRow rowWithTwoFields = new TableRow().set("a", 1).set("b", 2); + TableRow rowWithOneField = new TableRow().set("a", 1); + assertThat(rowWithTwoFields, not(isTableRowEqualTo(rowWithOneField))); + } + + @Test + public void testMissingField() { + TableRow rowWithFieldB = new TableRow().set("a", 1).set("b", 2); + TableRow rowWithFieldC = new TableRow().set("a", 1).set("c", 2); + assertThat(rowWithFieldB, not(isTableRowEqualTo(rowWithFieldC))); + } + + @Test + public void testDifferentInsertionOrder() { + TableRow row1 = new TableRow().set("a", 1).set("b", 2); + TableRow row2 = new TableRow().set("b", 2).set("a", 1); + assertThat(row1, isTableRowEqualTo(row2)); + } + + @Test + public void testIdenticalNestedRows() { + TableRow innerRow1 = new TableRow().set("id", 42); + TableRow innerRow2 = new TableRow().set("id", 42); + TableRow outerRow1 = new TableRow().set("nested", innerRow1); + TableRow outerRow2 = new TableRow().set("nested", innerRow2); + assertThat(outerRow1, isTableRowEqualTo(outerRow2)); + } + + @Test + public void testNestedRowsWithTypeMismatch() { + TableRow innerRowWithInteger = new TableRow().set("id", 42); + TableRow innerRowWithString = new TableRow().set("id", "42"); + TableRow outerRow1 = new TableRow().set("nested", innerRowWithInteger); + TableRow outerRow2 = new TableRow().set("nested", innerRowWithString); + assertThat(outerRow1, not(isTableRowEqualTo(outerRow2))); + } + + @Test + public void testDeeplyNestedRowsWithTypeMismatch() { + TableRow level3WithInteger = new TableRow().set("val", 1); + TableRow level3WithString = new TableRow().set("val", "1"); + TableRow level2Row1 = new TableRow().set("l2", level3WithInteger); + TableRow level2Row2 = new TableRow().set("l2", level3WithString); + TableRow level1Row1 = new TableRow().set("l1", level2Row1); + TableRow level1Row2 = new TableRow().set("l1", level2Row2); + assertThat(level1Row1, not(isTableRowEqualTo(level1Row2))); + } + + @Test + public void testIdenticalListFields() { + TableRow row1 = new TableRow().set("tags", Arrays.asList("a", "b")); + TableRow row2 = new TableRow().set("tags", Arrays.asList("a", "b")); + assertThat(row1, isTableRowEqualTo(row2)); + } + + @Test + public void testListFieldsWithDifferentOrder() { + TableRow row1 = new TableRow().set("tags", Arrays.asList("a", "b")); + TableRow row2 = new TableRow().set("tags", Arrays.asList("b", "a")); + assertThat(row1, not(isTableRowEqualTo(row2))); + } + + @Test + public void testZeroIntegerVsZeroDouble() { + TableRow rowWithZeroInteger = new TableRow().set("value", 0); + TableRow rowWithZeroDouble = new TableRow().set("value", 0.0); + assertThat(rowWithZeroInteger, not(isTableRowEqualTo(rowWithZeroDouble))); + } + + @Test + public void testNegativeNumbers() { + TableRow row1 = new TableRow().set("temp", -10); + TableRow row2 = new TableRow().set("temp", -10); + assertThat(row1, isTableRowEqualTo(row2)); + } + + @Test + public void testLongVsInteger() { + TableRow rowWithLong = new TableRow().set("count", 1L); + TableRow rowWithInteger = new TableRow().set("count", 1); + assertThat(rowWithLong, not(isTableRowEqualTo(rowWithInteger))); + } + + @Test + public void testTrueVsFalse() { + TableRow rowWithTrue = new TableRow().set("active", true); + TableRow rowWithFalse = new TableRow().set("active", false); + assertThat(rowWithTrue, not(isTableRowEqualTo(rowWithFalse))); + } + + @Test + public void testLargeIntegerVsLong() { + TableRow rowWithInteger = new TableRow().set("big", Integer.MAX_VALUE); + TableRow rowWithLong = new TableRow().set("big", (long) Integer.MAX_VALUE); + assertThat(rowWithInteger, not(isTableRowEqualTo(rowWithLong))); + } + + @Test + public void testMultipleFieldsWithOneTypeMismatch() { + TableRow row1 = new TableRow().set("id", 1).set("name", "Alice").set("score", 99); + TableRow row2 = new TableRow().set("id", 1).set("name", "Alice").set("score", "99"); + assertThat(row1, not(isTableRowEqualTo(row2))); + } +} diff --git a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/TableRowMatchers.java b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/TableRowMatchers.java new file mode 100644 index 000000000000..942e38d7ced4 --- /dev/null +++ b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/TableRowMatchers.java @@ -0,0 +1,187 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.beam.sdk.io.gcp.bigquery; + +import com.google.api.services.bigquery.model.TableRow; +import java.util.stream.Collectors; +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; + +public class TableRowMatchers { + + public static Matcher isTableRowEqualTo(TableRow expected) { + return new TypeSafeMatcher() { + + @Override + protected boolean matchesSafely(TableRow actual) { + return rowsMatch(expected, actual); + } + + @Override + public void describeTo(Description description) { + description.appendText("TableRow (strict) "); + description.appendText(formatValue(expected, 0)); + } + + @Override + protected void describeMismatchSafely(TableRow actual, Description mismatch) { + describeRowMismatch(expected, actual, mismatch); + } + }; + } + + private static boolean rowsMatch(TableRow expected, TableRow actual) { + if (expected == null && actual == null) return true; + + if (expected == null || actual == null) return false; + + if (actual.size() != expected.size()) return false; + + for (String key : expected.keySet()) { + if (!actual.containsKey(key)) { + return false; + } + + Object expectedVal = expected.get(key); + Object actualVal = actual.get(key); + + if (expectedVal == null && actualVal == null) continue; + if (expectedVal == null || actualVal == null) return false; + + // recursively compare nested TableRows + if (expectedVal instanceof TableRow && actualVal instanceof TableRow) { + if (!rowsMatch((TableRow) expectedVal, (TableRow) actualVal)) { + return false; + } + continue; + } + + if (!expectedVal.getClass().equals(actualVal.getClass())) { + return false; + } + + if (!expectedVal.equals(actualVal)) { + return false; + } + } + return true; + } + + private static void describeRowMismatch( + TableRow expected, TableRow actual, Description mismatch) { + + // size mismatch + if (actual.size() != expected.size()) { + mismatch.appendText( + String.format( + "had %d field(s) %s but expected %d field(s) %s", + actual.size(), actual.keySet(), expected.size(), expected.keySet())); + return; + } + + // missing field + for (String key : expected.keySet()) { + if (!actual.containsKey(key)) { + mismatch.appendText(String.format("missing field '%s'", key)); + return; + } + } + + // value/type mismatch + for (String key : expected.keySet()) { + Object expectedVal = expected.get(key); + Object actualVal = actual.get(key); + + if (expectedVal == null && actualVal == null) continue; + + if (expectedVal == null) { + mismatch.appendText( + String.format( + "field '%s': expected null but was %s(%s)", + key, actualVal.getClass().getSimpleName(), formatValue(actualVal, 0))); + return; + } + + if (actualVal == null) { + mismatch.appendText( + String.format( + "field '%s': expected %s(%s) but was null", + key, expectedVal.getClass().getSimpleName(), formatValue(expectedVal, 0))); + return; + } + + // recurse into nested TableRows + if (expectedVal instanceof TableRow && actualVal instanceof TableRow) { + if (!rowsMatch((TableRow) expectedVal, (TableRow) actualVal)) { + mismatch.appendText(String.format("field '%s': ", key)); + describeRowMismatch((TableRow) expectedVal, (TableRow) actualVal, mismatch); + return; + } + continue; + } + + // type mismatch + if (!expectedVal.getClass().equals(actualVal.getClass())) { + mismatch.appendText( + String.format( + "field '%s': expected %s(%s) but was %s(%s)", + key, + expectedVal.getClass().getSimpleName(), + formatValue(expectedVal, 0), + actualVal.getClass().getSimpleName(), + formatValue(actualVal, 0))); + return; + } + + // value mismatch + if (!expectedVal.equals(actualVal)) { + mismatch.appendText( + String.format( + "field '%s': expected value (%s) but was (%s)", + key, formatValue(expectedVal, 0), formatValue(actualVal, 0))); + return; + } + } + } + + // recursive formatter + private static String formatValue(Object val, int depth) { + if (val == null) return "null"; + + // safety net against infinite recursion + if (depth > 10) return "..."; + if (val instanceof TableRow) { + TableRow row = (TableRow) val; + String fields = + row.keySet().stream() + .map( + k -> { + Object v = row.get(k); + String typeName = + v == null + ? "null" + : v instanceof TableRow ? "TableRow" : v.getClass().getSimpleName(); + return String.format("%s(%s)=%s", k, typeName, formatValue(v, depth + 1)); + }) + .collect(Collectors.joining(", ")); + return "TableRow{" + fields + "}"; + } + return String.valueOf(val); + } +} From e559a75e8fbabe02a5d0ca561c88034d94c27c3a Mon Sep 17 00:00:00 2001 From: Lalit Yadav Date: Wed, 18 Mar 2026 19:14:30 -0500 Subject: [PATCH 2/3] Fix checkstyle NeedBraces violations --- .../sdk/io/gcp/bigquery/TableRowMatchers.java | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/TableRowMatchers.java b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/TableRowMatchers.java index 942e38d7ced4..ff6a9afc3c14 100644 --- a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/TableRowMatchers.java +++ b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/TableRowMatchers.java @@ -47,11 +47,17 @@ protected void describeMismatchSafely(TableRow actual, Description mismatch) { } private static boolean rowsMatch(TableRow expected, TableRow actual) { - if (expected == null && actual == null) return true; + if (expected == null && actual == null) { + return true; + } - if (expected == null || actual == null) return false; + if (expected == null || actual == null) { + return false; + } - if (actual.size() != expected.size()) return false; + if (actual.size() != expected.size()) { + return false; + } for (String key : expected.keySet()) { if (!actual.containsKey(key)) { @@ -61,8 +67,12 @@ private static boolean rowsMatch(TableRow expected, TableRow actual) { Object expectedVal = expected.get(key); Object actualVal = actual.get(key); - if (expectedVal == null && actualVal == null) continue; - if (expectedVal == null || actualVal == null) return false; + if (expectedVal == null && actualVal == null) { + continue; + } + if (expectedVal == null || actualVal == null) { + return false; + } // recursively compare nested TableRows if (expectedVal instanceof TableRow && actualVal instanceof TableRow) { @@ -108,7 +118,10 @@ private static void describeRowMismatch( Object expectedVal = expected.get(key); Object actualVal = actual.get(key); - if (expectedVal == null && actualVal == null) continue; + if (expectedVal == null && actualVal == null) { + continue; + } + ; if (expectedVal == null) { mismatch.appendText( @@ -162,10 +175,14 @@ private static void describeRowMismatch( // recursive formatter private static String formatValue(Object val, int depth) { - if (val == null) return "null"; + if (val == null) { + return "null"; + } // safety net against infinite recursion - if (depth > 10) return "..."; + if (depth > 10) { + return "..."; + } if (val instanceof TableRow) { TableRow row = (TableRow) val; String fields = From 32fac5fbc646130b289342be3553ec7ce42d9320 Mon Sep 17 00:00:00 2001 From: Lalit Yadav Date: Thu, 19 Mar 2026 09:44:16 -0500 Subject: [PATCH 3/3] Remove public modifier from TableRowMatchers to fix GcpApiSurfaceTest --- .../org/apache/beam/sdk/io/gcp/bigquery/TableRowMatchers.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/TableRowMatchers.java b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/TableRowMatchers.java index ff6a9afc3c14..988b1bd0842c 100644 --- a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/TableRowMatchers.java +++ b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/bigquery/TableRowMatchers.java @@ -23,9 +23,9 @@ import org.hamcrest.Matcher; import org.hamcrest.TypeSafeMatcher; -public class TableRowMatchers { +class TableRowMatchers { - public static Matcher isTableRowEqualTo(TableRow expected) { + static Matcher isTableRowEqualTo(TableRow expected) { return new TypeSafeMatcher() { @Override