|
| 1 | +# Copyright 2025, 2026 Query Farm LLC - https://query.farm |
| 2 | + |
| 3 | +"""Worker-side unit tests for CopyFromFunction and the example_lines format.""" |
| 4 | + |
| 5 | +from __future__ import annotations |
| 6 | + |
| 7 | +import tempfile |
| 8 | + |
| 9 | +import pyarrow as pa |
| 10 | +import pytest |
| 11 | + |
| 12 | +from vgi._test_fixtures.copy_from import ExampleLinesCopyFromArgs, ExampleLinesCopyFromFunction |
| 13 | +from vgi._test_fixtures.worker import ExampleCatalog |
| 14 | +from vgi.arguments import Arguments |
| 15 | +from vgi.invocation import FunctionType |
| 16 | +from vgi.protocol import BindRequest, CopyFromContext |
| 17 | + |
| 18 | + |
| 19 | +class _CollectOut: |
| 20 | + """Minimal OutputCollector stand-in for read().""" |
| 21 | + |
| 22 | + def __init__(self) -> None: |
| 23 | + self.batches: list[pa.RecordBatch] = [] |
| 24 | + |
| 25 | + def emit(self, batch: pa.RecordBatch, **_kwargs: object) -> None: |
| 26 | + self.batches.append(batch) |
| 27 | + |
| 28 | + def finish(self) -> None: # pragma: no cover - read() never calls finish itself |
| 29 | + pass |
| 30 | + |
| 31 | + |
| 32 | +def _write(text: str) -> str: |
| 33 | + """Write ``text`` to a throwaway file and return its path.""" |
| 34 | + with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as fp: |
| 35 | + fp.write(text) |
| 36 | + return fp.name |
| 37 | + |
| 38 | + |
| 39 | +EXPECTED = pa.schema([("a", pa.int64()), ("b", pa.string())]) |
| 40 | + |
| 41 | + |
| 42 | +def test_on_bind_binds_to_expected_schema() -> None: |
| 43 | + """on_bind binds output to the COPY target schema.""" |
| 44 | + cf = CopyFromContext(format="example_lines", file_path="/x", expected_schema=EXPECTED) |
| 45 | + br = BindRequest( |
| 46 | + function_name="example_lines_copy_reader", |
| 47 | + arguments=Arguments(named={"null_string": pa.scalar("NA")}), |
| 48 | + function_type=FunctionType.TABLE, |
| 49 | + copy_from=cf, |
| 50 | + ) |
| 51 | + resp = ExampleLinesCopyFromFunction.bind(br) |
| 52 | + assert resp.output_schema.equals(EXPECTED) |
| 53 | + |
| 54 | + |
| 55 | +def test_on_bind_without_copy_from_context_raises() -> None: |
| 56 | + """on_bind rejects a non-COPY invocation.""" |
| 57 | + br = BindRequest( |
| 58 | + function_name="example_lines_copy_reader", |
| 59 | + arguments=Arguments(named={"null_string": pa.scalar("NA")}), |
| 60 | + function_type=FunctionType.TABLE, |
| 61 | + ) |
| 62 | + with pytest.raises(ValueError, match="COPY FROM format reader"): |
| 63 | + ExampleLinesCopyFromFunction.bind(br) |
| 64 | + |
| 65 | + |
| 66 | +def test_read_parses_and_coerces_with_null_string() -> None: |
| 67 | + """read() parses, null-maps, and casts to the target schema.""" |
| 68 | + path = _write("1,foo\n2,NA\n3,baz\n") |
| 69 | + out = _CollectOut() |
| 70 | + ExampleLinesCopyFromFunction.read( |
| 71 | + path=path, |
| 72 | + options=ExampleLinesCopyFromArgs(null_string="NA"), |
| 73 | + expected_schema=EXPECTED, |
| 74 | + params=None, |
| 75 | + out=out, |
| 76 | + ) |
| 77 | + table = pa.Table.from_batches(out.batches) |
| 78 | + assert table.schema.equals(EXPECTED) |
| 79 | + assert table.to_pydict() == {"a": [1, 2, 3], "b": ["foo", None, "baz"]} |
| 80 | + |
| 81 | + |
| 82 | +def test_read_custom_delimiter_and_skip_rows() -> None: |
| 83 | + """read() honors delimiter and skip_rows options.""" |
| 84 | + path = _write("# header\n1|a\n2|b\n") |
| 85 | + out = _CollectOut() |
| 86 | + ExampleLinesCopyFromFunction.read( |
| 87 | + path=path, |
| 88 | + options=ExampleLinesCopyFromArgs(null_string="NA", delimiter="|", skip_rows=1), |
| 89 | + expected_schema=EXPECTED, |
| 90 | + params=None, |
| 91 | + out=out, |
| 92 | + ) |
| 93 | + assert pa.Table.from_batches(out.batches).to_pydict() == {"a": [1, 2], "b": ["a", "b"]} |
| 94 | + |
| 95 | + |
| 96 | +def test_read_on_error_fail_vs_skip() -> None: |
| 97 | + """on_error 'fail' raises; 'skip' drops the bad row.""" |
| 98 | + path = _write("1,a\nBADROW\n3,c\n") |
| 99 | + with pytest.raises(ValueError, match="example_lines: row has"): |
| 100 | + ExampleLinesCopyFromFunction.read( |
| 101 | + path=path, |
| 102 | + options=ExampleLinesCopyFromArgs(null_string="NA"), # on_error defaults to "fail" |
| 103 | + expected_schema=EXPECTED, |
| 104 | + params=None, |
| 105 | + out=_CollectOut(), |
| 106 | + ) |
| 107 | + |
| 108 | + out = _CollectOut() |
| 109 | + ExampleLinesCopyFromFunction.read( |
| 110 | + path=path, |
| 111 | + options=ExampleLinesCopyFromArgs(null_string="NA", on_error="skip"), |
| 112 | + expected_schema=EXPECTED, |
| 113 | + params=None, |
| 114 | + out=out, |
| 115 | + ) |
| 116 | + assert pa.Table.from_batches(out.batches).num_rows == 2 |
| 117 | + |
| 118 | + |
| 119 | +def test_catalog_advertises_copy_format() -> None: |
| 120 | + """The example catalog advertises the example_lines format.""" |
| 121 | + formats = ExampleCatalog().copy_from_formats(attach_opaque_data=b"", transaction_opaque_data=None) |
| 122 | + by_name = {f.format_name: f for f in formats} |
| 123 | + assert "example_lines" in by_name |
| 124 | + fmt = by_name["example_lines"] |
| 125 | + assert fmt.handler == "example_lines_copy_reader" |
| 126 | + assert fmt.direction == "from" |
| 127 | + assert fmt.comment == "Toy delimited-text reader for tests" |
| 128 | + assert fmt.tags.get("category") == "copy_from" |
| 129 | + opt_schema = pa.ipc.read_schema(pa.py_buffer(fmt.options)) |
| 130 | + assert set(opt_schema.names) == {"delimiter", "null_string", "skip_rows", "on_error"} |
| 131 | + assert opt_schema.field("null_string").metadata[b"vgi_doc"] == b"Token parsed as SQL NULL" |
| 132 | + |
| 133 | + |
| 134 | +def test_bind_request_copy_from_wire_roundtrip() -> None: |
| 135 | + """copy_from survives a BindRequest wire round-trip.""" |
| 136 | + cf = CopyFromContext(format="example_lines", file_path="/p", expected_schema=EXPECTED) |
| 137 | + br = BindRequest( |
| 138 | + function_name="h", |
| 139 | + arguments=Arguments(named={"null_string": pa.scalar("NA")}), |
| 140 | + function_type=FunctionType.TABLE, |
| 141 | + copy_from=cf, |
| 142 | + ) |
| 143 | + restored = BindRequest.deserialize_from_bytes(br.serialize_to_bytes()) |
| 144 | + assert restored.copy_from is not None |
| 145 | + assert restored.copy_from.format == "example_lines" |
| 146 | + assert restored.copy_from.file_path == "/p" |
| 147 | + assert restored.copy_from.expected_schema.equals(EXPECTED) |
0 commit comments