diff --git a/udfs/migration/snowflake/README.md b/udfs/migration/snowflake/README.md index 71a9bf725..d780d5c3b 100644 --- a/udfs/migration/snowflake/README.md +++ b/udfs/migration/snowflake/README.md @@ -13,12 +13,31 @@ SELECT bqutil.sf.factorial(0) ## UDFs +* [array_equal](#array_equal) * [factorial](#factorial) * [flatten](#flatten) ## Documentation +### [array_equal(a ARRAY, b ARRAY)](array_equal.sqlx) +Compares two arrays of JSON for equality, emulating Snowflake's `=` operator for untyped arrays. +* Returns `true` if arrays are of equal length and all corresponding elements are equal. +* Returns `false` if arrays are of different lengths or any corresponding elements are not equal. +* Returns `null` if either input array is `null`. +* Objects are compared recursively, ensuring they have the same keys and equal values (order of keys does not matter). +* Nested arrays are compared recursively. +* Null elements within arrays are treated as equal to other null elements. + +```sql +SELECT bqutil.sf.array_equal([JSON '1', JSON '2'], [JSON '1', JSON '2']) as eq1, + bqutil.sf.array_equal([JSON '{"a": 1}'], [JSON '{"a": 1}']) as eq2, + bqutil.sf.array_equal([JSON '[1, 2]'], [JSON '[1, 3]']) as eq3; +``` + +eq1|eq2|eq3 +---|---|--- +true|true|false ### [factorial(integer_expr INT64)](factorial.sqlx) Computes the factorial of its input. The input argument must be an integer expression in the range of `0` to `27`. Due to data type differences, the maximum input value in BigQuery is smaller than in Snowflake. [Snowflake docs](https://docs.snowflake.com/en/sql-reference/functions/factorial.html) ```sql diff --git a/udfs/migration/snowflake/array_equal.sqlx b/udfs/migration/snowflake/array_equal.sqlx new file mode 100644 index 000000000..f538d5d45 --- /dev/null +++ b/udfs/migration/snowflake/array_equal.sqlx @@ -0,0 +1,63 @@ +config { hasOutput: true } +/* + * Copyright 2026 Google LLC + * + * Licensed 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. + */ + +CREATE OR REPLACE FUNCTION ${self()}(a ARRAY, b ARRAY) +RETURNS BOOLEAN +LANGUAGE js +OPTIONS ( + description = "Compares two arrays of JSON for equality, emulating Snowflake's = operator for untyped arrays." +) AS r""" + if (a === null || b === null) return null; + if (a.length !== b.length) return false; + + const isEqual = (x, y) => { + if (x === null) return y === null; + if (y === null) return false; + if (x === undefined) return y === undefined; + if (y === undefined) return false; + if (typeof x !== typeof y) return false; + + + if (Array.isArray(x)) { + if (!Array.isArray(y)) return false; + if (x.length !== y.length) return false; + for (let i = 0; i < x.length; i++) { + if (!isEqual(x[i], y[i])) return false; + } + return true; + } + + if (typeof x === 'object') { + if (Array.isArray(y)) return false; + const keysX = Object.keys(x); + const keysY = Object.keys(y); + if (keysX.length !== keysY.length) return false; + for (const key of keysX) { + if (!Object.hasOwn(y, key)) return false; + if (!isEqual(x[key], y[key])) return false; + } + return true; + } + + return x === y; + }; + + for (let i = 0; i < a.length; i++) { + if (!isEqual(a[i], b[i])) return false; + } + return true; +"""; diff --git a/udfs/migration/snowflake/test_cases.js b/udfs/migration/snowflake/test_cases.js index d23ad24a1..008fbcad5 100644 --- a/udfs/migration/snowflake/test_cases.js +++ b/udfs/migration/snowflake/test_cases.js @@ -148,3 +148,64 @@ generate_udaf_test("object_agg", } ); +generate_udf_test("array_equal", [ + { + inputs: [`[JSON '1', JSON '2']`, `[JSON '1', JSON '2']`], + expected_output: `true` + }, + { + inputs: [`[JSON '1', JSON '2']`, `[JSON '1', JSON '3']`], + expected_output: `false` + }, + { + inputs: [`[JSON '1', JSON '2']`, `[JSON '1']`], + expected_output: `false` + }, + { + inputs: [`[JSON '1', CAST(NULL AS JSON)]`, `[JSON '1', CAST(NULL AS JSON)]`], + expected_output: `true` + }, + { + inputs: [`[JSON '1']`, `[JSON '"1"']`], + expected_output: `false` + }, + { + inputs: [`[JSON '{"a": 1}']`, `[JSON '{"a": 1}']`], + expected_output: `true` + }, + { + inputs: [`[JSON '{"a": 1}']`, `[JSON '{"b": 1}']`], + expected_output: `false` + }, + { + inputs: [`[JSON '{"a": 1}']`, `[JSON '{"a": 2}']`], + expected_output: `false` + }, + { + inputs: [`[JSON '{"a": {"b": 1}}']`, `[JSON '{"a": {"b": 1}}']`], + expected_output: `true` + }, + { + inputs: [`[JSON '{"a": {"b": 1}}']`, `[JSON '{"a": {"b": 2}}']`], + expected_output: `false` + }, + { + inputs: [`[JSON '[1, 2]']`, `[JSON '[1, 2]']`], + expected_output: `true` + }, + { + inputs: [`[JSON '[1, 2]']`, `[JSON '[1, 3]']`], + expected_output: `false` + }, + { + inputs: [`CAST(NULL AS ARRAY)`, `CAST(NULL AS ARRAY)`], + expected_output: `CAST(NULL AS BOOLEAN)` + }, + { + inputs: [`[JSON '1']`, `CAST(NULL AS ARRAY)`], + expected_output: `CAST(NULL AS BOOLEAN)` + } +]); + + +