blob: a204f7a6611ba20e067e13c363c466cddcac58ad [file]
################################################################################
# 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.
################################################################################
import unittest
from datetime import datetime
from unittest.mock import Mock, patch
from typing import NamedTuple
import pandas as pd
import pyarrow as pa
import pyflink.dataframe as pf
import pyflink.dataframe.convert as dataframe_convert
from pyflink.table.types import BigIntType, RowType
class _Point(NamedTuple):
x: int
y: str
class _OtherPoint(NamedTuple):
x: int
z: str
class FromRecordsTests(unittest.TestCase):
def test_rejects_scalar_sequence_data(self):
for data in ["ab", b"ab", bytearray(b"ab"), memoryview(b"ab")]:
with self.subTest(data_type=type(data)):
with self.assertRaisesRegex(
TypeError,
"data must be a sequence of records, such as a list or tuple",
):
pf.from_records(data, schema=["value"])
def test_rejects_empty_data(self):
with self.assertRaisesRegex(ValueError, "data must not be empty"):
pf.from_records([], schema=["id"])
def test_rejects_empty_schema(self):
with self.assertRaisesRegex(ValueError, "schema must not be empty"):
pf.from_records([(1,)], schema=[])
def test_rejects_schema_that_is_not_a_list_of_strings(self):
for schema in [0, False, "", (), ("id",), [1]]:
with self.subTest(schema=schema):
with self.assertRaisesRegex(
TypeError, "schema must be a list of strings"
):
pf.from_records([(1,)], schema=schema)
def test_rejects_empty_schema_field_name(self):
with self.assertRaisesRegex(ValueError, "schema field names must not be empty"):
pf.from_records([(1,)], schema=[""])
def test_rejects_duplicate_schema_field_names(self):
with self.assertRaisesRegex(ValueError, "schema field names must be unique"):
pf.from_records([(1, 2)], schema=["id", "id"])
def test_requires_schema_for_sequence_records(self):
with self.assertRaisesRegex(ValueError, "schema is required for sequence records"):
pf.from_records([(1,)])
def test_rejects_unsupported_record_type(self):
with self.assertRaises(TypeError) as error:
pf.from_records([1], schema=["id"])
self.assertEqual(str(error.exception), "invalid record at index 0")
self.assertEqual(
str(error.exception.__cause__),
"record must be a mapping or a sequence of values, "
"such as a list or tuple",
)
def test_rejects_scalar_sequence_records(self):
for value in ["ab", b"ab", bytearray(b"ab"), memoryview(b"ab")]:
for index, records, cause in [
(
0,
[value],
"record must be a mapping or a sequence of values, "
"such as a list or tuple",
),
(1, [(1, 2), value], "record must be a sequence"),
]:
with self.subTest(value_type=type(value), index=index):
with self.assertRaises(TypeError) as error:
pf.from_records(records, schema=["left", "right"])
self.assertEqual(
str(error.exception), f"invalid record at index {index}"
)
self.assertEqual(str(error.exception.__cause__), cause)
def test_rejects_record_with_wrong_arity(self):
with self.assertRaises(ValueError) as error:
pf.from_records([(1, "Alice"), (2,)], schema=["id", "name"])
self.assertEqual(str(error.exception), "invalid record at index 1")
self.assertEqual(
str(error.exception.__cause__),
"record has 1 values but schema has 2 fields",
)
def test_rejects_mapping_records_with_different_keys(self):
records_with_different_keys = [
(
[{"a": 1}, {"a": 2, "b": 3}],
"record has fields not present in schema: ['b']",
),
(
[{"a": 1, "b": 2}, {"a": 3}],
"record is missing schema field 'b'",
),
]
for records, cause in records_with_different_keys:
with self.subTest(records=records):
with self.assertRaises(ValueError) as error:
pf.from_records(records)
self.assertEqual(str(error.exception), "invalid record at index 1")
self.assertEqual(str(error.exception.__cause__), cause)
def test_rejects_mixed_mapping_and_named_tuple_records_with_index(self):
invalid_records = [
(
[{"id": 1}, (2,)],
["id"],
"record must be a mapping",
),
(
[_Point(1, "a"), {"x": 2, "y": "b"}],
["x", "y"],
"record must be a named tuple",
),
]
for records, schema, cause in invalid_records:
with self.subTest(records=records):
with self.assertRaises(TypeError) as error:
pf.from_records(records, schema=schema)
self.assertEqual(str(error.exception), "invalid record at index 1")
self.assertEqual(str(error.exception.__cause__), cause)
def test_rejects_schema_that_renames_named_tuple_fields(self):
with self.assertRaises(ValueError) as error:
pf.from_records([_Point(1, "a")], schema=["a", "b"])
self.assertEqual(str(error.exception), "invalid record at index 0")
self.assertEqual(
str(error.exception.__cause__), "record is missing schema field 'a'"
)
def test_rejects_different_inferred_named_tuple_fields(self):
with self.assertRaises(ValueError) as error:
pf.from_records([_Point(1, "a"), _OtherPoint(2, "b")])
self.assertEqual(str(error.exception), "invalid record at index 1")
self.assertEqual(
str(error.exception.__cause__),
"record is missing schema field 'y'",
)
def test_rejects_schema_field_missing_from_mapping_records(self):
with self.assertRaises(ValueError) as error:
pf.from_records(
[{"id": 1, "name": "Alice"}, {"id": 2}],
schema=["id", "name"],
)
self.assertEqual(str(error.exception), "invalid record at index 1")
self.assertEqual(
str(error.exception.__cause__), "record is missing schema field 'name'"
)
class FromDictTests(unittest.TestCase):
def test_rejects_data_that_is_not_a_mapping(self):
for data in [[], [("id", [1])]]:
with self.subTest(data=data):
with self.assertRaisesRegex(TypeError, "data must be a mapping"):
pf.from_dict(data)
def test_rejects_scalar_sequence_column_values(self):
for values in ["ab", b"ab", bytearray(b"ab"), memoryview(b"ab")]:
with self.subTest(value_type=type(values)):
with self.assertRaisesRegex(
TypeError,
"column 'value' values must be a sequence, "
"such as a list or tuple",
):
pf.from_dict({"value": values})
def test_rejects_empty_data(self):
with self.assertRaisesRegex(ValueError, "data must not be empty"):
pf.from_dict({})
def test_rejects_zero_rows(self):
with self.assertRaisesRegex(ValueError, "data must contain at least one row"):
pf.from_dict({"id": []})
def test_rejects_columns_with_different_lengths(self):
with self.assertRaisesRegex(ValueError, "columns must have equal lengths"):
pf.from_dict({"id": [1, 2], "name": ["Alice"]})
def test_rejects_schema_column_missing_from_data(self):
with self.assertRaisesRegex(ValueError, "column 'name' is not present in data"):
pf.from_dict({"id": [1]}, schema=["id", "name"])
def test_rejects_schema_that_is_not_a_list_of_strings(self):
for schema in [0, False, "", (), ("id",), [1]]:
with self.subTest(schema=schema):
with self.assertRaisesRegex(
TypeError, "schema must be a list of strings"
):
pf.from_dict({"id": [1]}, schema=schema)
def test_rejects_empty_schema_field_name(self):
with self.assertRaisesRegex(ValueError, "schema field names must not be empty"):
pf.from_dict({"id": [1]}, schema=[""])
def test_rejects_duplicate_schema_field_names(self):
with self.assertRaisesRegex(ValueError, "schema field names must be unique"):
pf.from_dict({"id": [1]}, schema=["id", "id"])
class CreationValidationTests(unittest.TestCase):
def test_parses_watermark_into_semantic_specification(self):
watermark = dataframe_convert._WatermarkSpec.parse(
("ts", "ts - INTERVAL '5' SECOND")
)
self.assertEqual(watermark.column, "ts")
self.assertEqual(watermark.expression, "ts - INTERVAL '5' SECOND")
def test_watermark_spec_unpacks_column_before_expression(self):
watermark = dataframe_convert._WatermarkSpec(
"ts", "ts - INTERVAL '5' SECOND"
)
self.assertEqual(tuple(watermark), ("ts", "ts - INTERVAL '5' SECOND"))
def test_rejects_invalid_watermarks(self):
invalid_watermarks = [
("ts", "watermark must be a tuple"),
(("ts",), "watermark must be a tuple"),
(("ts", "ts", "extra"), "watermark must be a tuple"),
(("", "ts"), "must be non-empty strings"),
(("ts", ""), "must be non-empty strings"),
((1, "ts"), "must be non-empty strings"),
]
for watermark, message in invalid_watermarks:
with self.subTest(watermark=watermark):
with self.assertRaisesRegex(TypeError, message):
pf.from_dict(
{"ts": [datetime(2026, 1, 1)]}, watermark=watermark
)
def test_pandas_and_arrow_reject_invalid_positional_schemas(self):
inputs = [
(pf.from_pandas, pd.DataFrame({"left": [1], "right": [2]})),
(pf.from_arrow, pa.table({"left": [1], "right": [2]})),
]
invalid_schemas = [
("names", TypeError, "schema must be a list of strings"),
(["left", 2], TypeError, "schema must be a list of strings"),
(["left"], ValueError, "schema has 1 fields but data has 2 columns"),
(["left", "left"], ValueError, "schema field names must be unique"),
]
for creator, data in inputs:
for schema, error_type, message in invalid_schemas:
with self.subTest(creator=creator.__name__, schema=schema):
with self.assertRaisesRegex(error_type, message):
creator(data, schema=schema)
def test_pandas_rejects_duplicate_columns_without_schema(self):
pdf = pd.DataFrame([[1, 2]], columns=["value", "value"])
with self.assertRaisesRegex(ValueError, "schema field names must be unique"):
pf.from_pandas(pdf)
def test_rejects_columnar_fields_containing_null_type(self):
inputs = [
(
lambda: pf.from_pandas(pd.DataFrame({"value": [None]})),
"columns with Arrow null types: 'value'",
),
(
lambda: pf.from_arrow(
pa.table({"left": pa.nulls(1), "right": pa.nulls(1)})
),
"columns with Arrow null types: 'left', 'right'",
),
(
lambda: pf.from_arrow(
pa.table(
{
"value": pa.array(
[[None]], type=pa.list_(pa.null())
)
}
)
),
"columns with Arrow null types: 'value'",
),
]
for creator, message in inputs:
with self.subTest(creator=creator):
with self.assertRaisesRegex(TypeError, message):
creator()
def test_rejects_invalid_table_and_columnar_inputs(self):
invalid_inputs = [
(pf.from_table, object(), "pyflink.table.Table"),
(pf.from_pandas, object(), "pandas.DataFrame"),
(pf.from_arrow, object(), "pyarrow.Table"),
]
for creator, data, message in invalid_inputs:
with self.subTest(creator=creator.__name__):
with self.assertRaisesRegex(TypeError, message):
creator(data)
class RangeTests(unittest.TestCase):
def test_matches_python_range_and_preserves_bigint_schema_when_empty(self):
cases = [
((4,), [(0,), (1,), (2,), (3,)]),
((4, -1, -2), [(4,), (2,), (0,)]),
((2, 2), []),
((2**63 - 1, 2**63), [(2**63 - 1,)]),
((-(2**63), -(2**63) + 1), [(-(2**63),)]),
]
for arguments, expected_rows in cases:
table_environment = Mock()
table_environment._from_elements.return_value = object()
with self.subTest(arguments=arguments), patch(
"pyflink.dataframe.convert.get_or_create_table_environment",
return_value=table_environment,
):
pf.range(*arguments)
rows, row_type = table_environment._from_elements.call_args.args[:2]
self.assertEqual([row[1:] for row in rows], expected_rows)
self.assertIsInstance(row_type, RowType)
self.assertEqual(row_type.field_names(), ["id"])
self.assertIsInstance(row_type.field_types()[0], BigIntType)
def test_rejects_values_outside_bigint_bounds(self):
invalid_ranges = [
(2**63, 2**63 + 1),
(2**63 - 1, 2**63 + 2),
(-(2**63) - 1, -(2**63) - 2, -1),
(-(2**63), -(2**63) - 3, -1),
]
table_environment = Mock()
for arguments in invalid_ranges:
with self.subTest(arguments=arguments), patch(
"pyflink.dataframe.convert.get_or_create_table_environment",
return_value=table_environment,
) as get_table_environment:
with self.assertRaisesRegex(
ValueError, "range values must fit in signed BIGINT"
):
pf.range(*arguments)
get_table_environment.assert_not_called()
def test_rejects_invalid_arguments(self):
invalid_arguments = [
((1.5,), TypeError, "start_or_end must be an integer"),
((0, 1.5), TypeError, "end must be an integer"),
((0, 1, 1.5), TypeError, "step must be an integer"),
((0, 1, 0), ValueError, "step must not be zero"),
]
for arguments, error_type, message in invalid_arguments:
with self.subTest(arguments=arguments):
with self.assertRaisesRegex(error_type, message):
pf.range(*arguments)
if __name__ == "__main__":
unittest.main()