| ################################################################################ | |
| # 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() |