| # 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. |
| |
| """Tests for the Spark-compatible function bindings.""" |
| |
| import pyarrow as pa |
| import pytest |
| from datafusion import SessionContext, col, lit |
| from datafusion import functions as f |
| from datafusion.functions import spark |
| |
| |
| @pytest.fixture |
| def df(): |
| ctx = SessionContext() |
| batch = pa.RecordBatch.from_arrays( |
| [ |
| pa.array(["hello", "WORLD", "abc"]), |
| pa.array([1, 2, 3]), |
| pa.array([-5, 0, 7]), |
| pa.array([1.0, 2.5, 3.0]), |
| pa.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]), |
| ], |
| names=["s", "i", "n", "f", "a"], |
| ) |
| return ctx.create_dataframe([[batch]]) |
| |
| |
| def _val(df, expr): |
| return df.select(expr.alias("v")).collect_column("v")[0].as_py() |
| |
| |
| def _ts(): |
| import datetime as _d |
| |
| naive = _d.datetime(2020, 1, 15, 14, 30, 45) # noqa: DTZ001 |
| return lit(pa.scalar(naive, type=pa.timestamp("us"))) |
| |
| |
| def _dt(*args): |
| import datetime as _d |
| |
| return _d.datetime(*args) # noqa: DTZ001 |
| |
| |
| # --------------------------------------------------------------------------- |
| # Math |
| # --------------------------------------------------------------------------- |
| |
| |
| @pytest.mark.parametrize( |
| ("expr_factory", "expected"), |
| [ |
| (lambda: spark.abs(lit(-5)), 5), |
| (lambda: spark.ceil(lit(1.2)), 2), |
| (lambda: spark.floor(lit(1.8)), 1), |
| (lambda: spark.bin(lit(7)), "111"), |
| (lambda: spark.hex(lit(255)), "FF"), |
| (lambda: spark.modulus(lit(10), lit(3)), 1), |
| (lambda: spark.pmod(lit(-1), lit(3)), 2), |
| (lambda: spark.rint(lit(2.5)), 2.0), |
| (lambda: spark.round(lit(2.5), lit(0)), 3.0), |
| (lambda: spark.negative(lit(3)), -3), |
| ], |
| ) |
| def test_math(df, expr_factory, expected): |
| assert _val(df, expr_factory()) == expected |
| |
| |
| def test_factorial(df): |
| # factorial wants Int32; lit(int) is Int64 by default. |
| expr = spark.factorial(lit(pa.scalar(5, type=pa.int32()))) |
| assert _val(df, expr) == 120 |
| |
| |
| # --------------------------------------------------------------------------- |
| # String |
| # --------------------------------------------------------------------------- |
| |
| |
| @pytest.mark.parametrize( |
| ("expr_factory", "expected"), |
| [ |
| (lambda: spark.ascii(lit("A")), 65), |
| (lambda: spark.char(lit(65)), "A"), |
| (lambda: spark.length(lit("hello")), 5), |
| (lambda: spark.like(lit("hello"), lit("h%")), True), |
| (lambda: spark.ilike(lit("HELLO"), lit("h%")), True), |
| (lambda: spark.substring(lit("hello"), lit(1), lit(3)), "hel"), |
| (lambda: spark.soundex(lit("Robert")), "R163"), |
| (lambda: spark.is_valid_utf8(lit("hi")), True), |
| (lambda: spark.concat(lit("a"), lit("b")), "ab"), |
| (lambda: spark.elt(lit(2), lit("a"), lit("b")), "b"), |
| ], |
| ) |
| def test_string(df, expr_factory, expected): |
| assert _val(df, expr_factory()) == expected |
| |
| |
| def test_space(df): |
| expr = spark.space(lit(pa.scalar(3, type=pa.int32()))) |
| assert _val(df, expr) == " " |
| |
| |
| # --------------------------------------------------------------------------- |
| # Hash |
| # --------------------------------------------------------------------------- |
| |
| |
| @pytest.mark.parametrize( |
| ("expr_factory", "expected"), |
| [ |
| ( |
| lambda: spark.sha1(lit("hello")), |
| "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d", |
| ), |
| ( |
| lambda: spark.sha2(lit("hello"), lit(256)), |
| "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", |
| ), |
| (lambda: spark.crc32(lit("ABC")), 2743272264), |
| ], |
| ) |
| def test_hash(df, expr_factory, expected): |
| assert _val(df, expr_factory()) == expected |
| |
| |
| # --------------------------------------------------------------------------- |
| # Bitwise |
| # --------------------------------------------------------------------------- |
| |
| |
| @pytest.mark.parametrize( |
| ("expr_factory", "expected"), |
| [ |
| (lambda: spark.bit_count(lit(7)), 3), |
| (lambda: spark.bit_get(lit(5), lit(0)), 1), |
| (lambda: spark.bitwise_not(lit(0)), -1), |
| (lambda: spark.shiftleft(lit(1), lit(3)), 8), |
| (lambda: spark.shiftright(lit(8), lit(2)), 2), |
| (lambda: spark.shiftrightunsigned(lit(8), lit(2)), 2), |
| ], |
| ) |
| def test_bitwise(df, expr_factory, expected): |
| assert _val(df, expr_factory()) == expected |
| |
| |
| # --------------------------------------------------------------------------- |
| # Array / collection / conditional |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_array_and_size(df): |
| arr = spark.array(lit(1), lit(2), lit(3)) |
| assert _val(df, arr) == [1, 2, 3] |
| assert _val(df, spark.size(arr)) == 3 |
| assert _val(df, spark.array_contains(arr, lit(2))) is True |
| |
| |
| def test_slice(df): |
| arr = spark.array(lit(1), lit(2), lit(3), lit(4)) |
| assert _val(df, spark.slice(arr, lit(2), lit(2))) == [2, 3] |
| |
| |
| def test_array_repeat(df): |
| assert _val(df, spark.array_repeat(lit("a"), lit(3))) == ["a", "a", "a"] |
| |
| |
| def test_if(df): |
| assert _val(df, spark.if_(lit(True), lit("yes"), lit("no"))) == "yes" |
| assert _val(df, spark.if_(lit(False), lit("yes"), lit("no"))) == "no" |
| |
| |
| # --------------------------------------------------------------------------- |
| # Aggregate |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_aggregate(df): |
| r = df.aggregate( |
| [], |
| [ |
| spark.avg(col("f")).alias("avg"), |
| spark.try_sum(col("i")).alias("sum"), |
| ], |
| ).collect() |
| rec = pa.Table.from_batches(r) |
| assert rec.column("avg")[0].as_py() == pytest.approx(2.1666666, rel=1e-3) |
| assert rec.column("sum")[0].as_py() == 6 |
| |
| |
| def test_collect_list_set(df): |
| r = df.aggregate( |
| [], |
| [ |
| spark.collect_list(col("i")).alias("cl"), |
| spark.collect_set(col("i")).alias("cs"), |
| ], |
| ).collect() |
| rec = pa.Table.from_batches(r) |
| assert sorted(rec.column("cl")[0].as_py()) == [1, 2, 3] |
| assert sorted(rec.column("cs")[0].as_py()) == [1, 2, 3] |
| |
| |
| # --------------------------------------------------------------------------- |
| # Spark-semantics conflicts vs DataFusion defaults |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_concat_null_propagates(): |
| """Spark concat returns NULL on any NULL input; default skips NULLs.""" |
| ctx = SessionContext() |
| df = ctx.from_pydict({"x": [1]}) |
| default_out = ( |
| df.select(f.concat(lit("a"), lit(None), lit("b")).alias("v")) |
| .collect_column("v")[0] |
| .as_py() |
| ) |
| spark_out = _val(df, spark.concat(lit("a"), lit(None), lit("b"))) |
| assert default_out == "ab" |
| assert spark_out is None |
| |
| |
| def test_round_half_up(): |
| """Spark round uses HALF_UP rounding (2.5 → 3).""" |
| ctx = SessionContext() |
| df = ctx.from_pydict({"x": [1]}) |
| assert _val(df, spark.round(lit(2.5), lit(0))) == 3.0 |
| |
| |
| # --------------------------------------------------------------------------- |
| # Optional parameter defaults / NotImplementedError |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_round_scale_default(): |
| """spark.round defaults scale to 0.""" |
| ctx = SessionContext() |
| df = ctx.from_pydict({"x": [1]}) |
| assert _val(df, spark.round(lit(2.5))) == 3.0 |
| |
| |
| def test_make_dt_interval_defaults(): |
| """spark.make_dt_interval with no args returns a zero day-time interval.""" |
| import datetime as dt |
| |
| ctx = SessionContext() |
| df = ctx.from_pydict({"x": [1]}) |
| assert _val(df, spark.make_dt_interval()) == dt.timedelta(0) |
| |
| |
| def test_make_interval_defaults(): |
| """spark.make_interval with no args returns a zero interval.""" |
| ctx = SessionContext() |
| df = ctx.from_pydict({"x": [1]}) |
| assert _val(df, spark.make_interval()).months == 0 |
| |
| |
| def test_str_to_map_defaults(): |
| """spark.str_to_map defaults delimiters to ',' and ':'.""" |
| ctx = SessionContext() |
| df = ctx.from_pydict({"x": [1]}) |
| assert _val(df, spark.str_to_map(lit("a:1,b:2"))) == [("a", "1"), ("b", "2")] |
| |
| |
| def test_shuffle_seed_raises(): |
| """spark.shuffle(seed=...) raises NotImplementedError until Rust supports it.""" |
| with pytest.raises(NotImplementedError, match="seed"): |
| spark.shuffle(spark.array(lit(1), lit(2)), seed=1) |
| |
| |
| def test_like_escape_raises(): |
| """spark.like/ilike escapeChar raises NotImplementedError until Rust supports.""" |
| with pytest.raises(NotImplementedError, match="escapeChar"): |
| spark.like(lit("a"), lit("a"), escapeChar="\\") |
| with pytest.raises(NotImplementedError, match="escapeChar"): |
| spark.ilike(lit("a"), lit("a"), escapeChar="\\") |
| |
| |
| def test_parse_url_three_arg(): |
| """parse_url(url, partToExtract, key=...) extracts query parameters.""" |
| ctx = SessionContext() |
| df = ctx.from_pydict({"x": [1]}) |
| url = lit("http://example.com/p?q=hello&n=1") |
| assert _val(df, spark.parse_url(url, lit("QUERY"), key=lit("q"))) == "hello" |
| assert _val(df, spark.try_parse_url(url, lit("QUERY"), key=lit("n"))) == "1" |
| |
| |
| def test_format_string_plain_str_format(): |
| """format_string accepts a plain str format that is auto-promoted to lit.""" |
| ctx = SessionContext() |
| df = ctx.from_pydict({"x": [1]}) |
| assert _val(df, spark.format_string("%d-%s", lit(42), lit("hi"))) == "42-hi" |
| |
| |
| @pytest.mark.parametrize( |
| ("native_expr", "wrapped_expr", "expected"), |
| [ |
| # int literals coerced internally |
| ( |
| lambda: spark.array_repeat(lit("a"), 3), |
| lambda: spark.array_repeat(lit("a"), lit(3)), |
| ["a", "a", "a"], |
| ), |
| ( |
| lambda: spark.shiftleft(lit(1), 3), |
| lambda: spark.shiftleft(lit(1), lit(3)), |
| 8, |
| ), |
| ( |
| lambda: spark.sha2(lit("hello"), 256), |
| lambda: spark.sha2(lit("hello"), lit(256)), |
| "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", |
| ), |
| ( |
| lambda: spark.round(lit(2.345), 2), |
| lambda: spark.round(lit(2.345), lit(2)), |
| 2.35, |
| ), |
| ( |
| lambda: spark.substring(lit("hello"), 1, 3), |
| lambda: spark.substring(lit("hello"), lit(1), lit(3)), |
| "hel", |
| ), |
| ( |
| lambda: spark.width_bucket(lit(5.0), lit(0.0), lit(10.0), 5), |
| lambda: spark.width_bucket(lit(5.0), lit(0.0), lit(10.0), lit(5)), |
| 3, |
| ), |
| # str literals coerced internally |
| ( |
| lambda: spark.date_trunc("month", _ts()), |
| lambda: spark.date_trunc(lit("month"), _ts()), |
| _dt(2020, 1, 1), |
| ), |
| ( |
| lambda: spark.spark_cast(lit(1579098645), "timestamp"), |
| lambda: spark.spark_cast(lit(1579098645), lit("timestamp")), |
| None, |
| ), # compared separately |
| # int32-requiring args coerced to int32 internally |
| ( |
| lambda: spark.space(3), |
| lambda: spark.space(lit(pa.scalar(3, type=pa.int32()))), |
| " ", |
| ), |
| # Any-typed value coerced internally |
| ( |
| lambda: spark.array_contains(spark.array(lit(1), lit(2)), 1), |
| lambda: spark.array_contains(spark.array(lit(1), lit(2)), lit(1)), |
| True, |
| ), |
| ], |
| ) |
| def test_native_literal_coercion(df, native_expr, wrapped_expr, expected): |
| """Native Python literals produce the same result as explicit lit() wrapping.""" |
| if expected is None: |
| # spark_cast returns a timestamp; just assert native == wrapped. |
| assert _val(df, native_expr()) == _val(df, wrapped_expr()) |
| return |
| assert _val(df, native_expr()) == expected |
| assert _val(df, wrapped_expr()) == expected |
| |
| |
| def test_native_literal_int32_datetime(df): |
| """add_months/date_add/date_sub accept native ints (coerced to int32).""" |
| import datetime as dt |
| |
| d = lit(pa.scalar(dt.date(2020, 1, 15), type=pa.date32())) |
| assert _val(df, spark.add_months(d, 2)) == dt.date(2020, 3, 15) |
| assert _val(df, spark.date_add(d, 5)) == dt.date(2020, 1, 20) |
| assert _val(df, spark.date_sub(d, 5)) == dt.date(2020, 1, 10) |
| assert _val(df, spark.next_day(d, "Mon")) == dt.date(2020, 1, 20) |
| |
| |
| def test_native_literal_make_interval(df): |
| """make_dt_interval/make_interval accept native int/float parts.""" |
| import datetime as dt |
| |
| assert _val( |
| df, spark.make_dt_interval(days=1, hours=2, mins=3, secs=4.5) |
| ) == dt.timedelta(days=1, seconds=7384, microseconds=500000) |
| assert _val(df, spark.make_interval(years=1)).months == 12 |
| |
| |
| def test_str_to_map_pyspark_param_names(df): |
| """str_to_map uses pyspark parameter names (pairDelim/keyValueDelim).""" |
| out = _val( |
| df, spark.str_to_map(lit("a=1;b=2"), pairDelim=lit(";"), keyValueDelim=lit("=")) |
| ) |
| assert out == [("a", "1"), ("b", "2")] |
| |
| |
| def test_column_or_name_str_is_column_ref(): |
| """ColumnOrName args treat a bare str as a column name (matching pyspark).""" |
| ctx = SessionContext() |
| df = ctx.from_pydict({"s": ["hello", "world"], "pat": ["h%", "z%"]}) |
| # pattern given as a column name -> per-row pattern from column "pat" |
| out = ( |
| df.select(spark.like(col("s"), "pat").alias("v")) |
| .collect_column("v") |
| .to_pylist() |
| ) |
| assert out == [True, False] |
| |
| |
| def test_aggregate_positional_compat(): |
| """Pyspark-style positional calls still work after the rename to ``col``.""" |
| ctx = SessionContext() |
| df = ctx.from_pydict({"a": [1.0, 2.0, 3.0]}) |
| out = df.aggregate( |
| [], |
| [ |
| spark.avg(col("a")).alias("av"), |
| spark.try_sum(col("a")).alias("ts"), |
| spark.collect_list(col("a")).alias("cl"), |
| spark.collect_set(col("a")).alias("cs"), |
| ], |
| ).collect() |
| rec = pa.Table.from_batches(out) |
| assert rec.column("av")[0].as_py() == 2.0 |
| assert rec.column("ts")[0].as_py() == 6.0 |
| |
| |
| # --------------------------------------------------------------------------- |
| # SQL path via enable_spark_functions |
| # --------------------------------------------------------------------------- |
| |
| |
| def test_sql_requires_enable(): |
| """Spark-only function (xxhash64) is not in SQL namespace by default.""" |
| ctx = SessionContext() |
| with pytest.raises(Exception, match=r"(?i)xxhash64|Invalid function"): |
| ctx.sql("SELECT xxhash64('hello')").collect() |
| |
| |
| def test_sql_after_enable(): |
| ctx = SessionContext() |
| ctx.enable_spark_functions() |
| out = ctx.sql("SELECT sha2('hello', 256) AS h").collect_column("h")[0].as_py() |
| assert out == ("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824") |
| |
| |
| def test_sql_concat_semantics_override(): |
| """After enable, SQL concat propagates NULL like Spark.""" |
| ctx = SessionContext() |
| default_out = ( |
| ctx.sql("SELECT concat('a', NULL, 'b') AS c").collect_column("c")[0].as_py() |
| ) |
| assert default_out == "ab" |
| |
| ctx2 = SessionContext() |
| ctx2.enable_spark_functions() |
| spark_out = ( |
| ctx2.sql("SELECT concat('a', NULL, 'b') AS c").collect_column("c")[0].as_py() |
| ) |
| assert spark_out is None |