Skip to content

Commit b61fd9d

Browse files
rustyconoverclaude
andcommitted
feat(copy-from): worker-side COPY ... FROM custom format support
Add the protocol surface and worker API for catalogs to advertise custom COPY ... FROM formats (FROM only): - protocol.py: CopyFromContext on BindRequest (additive, no version bump); catalog_copy_from_formats RPC + CopyFromFormatsResponse. - catalog_interface.py: CopyFromFormatInfo + copy_from_formats() (default empty on the base; ReadOnlyCatalogInterface introspects registered CopyFromFunction subclasses, reusing the function-argument schema for options). - copy_from_function.py: CopyFromFunction base (producer-mode reader binding to the COPY target schema; reads file_path from the copy_from context). - worker.py / meta_worker.py: catalog_copy_from_formats handler + dispatch entry. - _test_fixtures/copy_from.py: example_lines toy delimited reader. - codegen/_common.py: register CopyFromFormatInfo for schema generation. - tests + protocol-inventory allowlist entry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7f30117 commit b61fd9d

10 files changed

Lines changed: 582 additions & 0 deletions

File tree

‎tests/conformance/test_protocol_inventory.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,15 @@ class NotExposed:
180180
"catalog_schema_contents_views": ("schema_contents",),
181181
"catalog_schema_contents_functions": ("schema_contents",),
182182
"catalog_schema_contents_macros": ("schema_contents",),
183+
"catalog_copy_from_formats": NotExposed(
184+
reason=(
185+
"COPY ... FROM format discovery. Consumed today by the C++ extension, "
186+
"which calls this at ATTACH to register a DuckDB CopyFunction per "
187+
"advertised format; covered by C++ integration/copy_from/*.test. Client "
188+
"wrappers for the other VGI languages are planned to follow the "
189+
"Python/C++ implementation."
190+
)
191+
),
183192
"catalog_schema_contents_indexes": NotExposed(
184193
reason=(
185194
"DuckDB-only metadata path. Indexes are catalog-planner territory; "

‎tests/test_copy_from_function.py‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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)

‎vgi/_test_fixtures/copy_from.py‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Copyright 2025, 2026 Query Farm LLC - https://query.farm
2+
3+
"""Fixture ``COPY ... FROM`` format reader for VGI integration tests.
4+
5+
``ExampleLinesCopyFromFunction`` registers the SQL format ``example_lines`` — a
6+
toy delimited-text reader. It exercises the full COPY-FROM path plus the option
7+
machinery: a defaulted option (``delimiter``), an ``INTEGER`` option with a range
8+
constraint (``skip_rows``), a required option (``null_string``), and an
9+
enum/``choices`` option (``on_error``).
10+
11+
Usage::
12+
13+
CREATE TABLE t (a INTEGER, b VARCHAR);
14+
COPY t FROM '/path/data.txt' (FORMAT example_lines, null_string 'NA');
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from dataclasses import dataclass
20+
from typing import TYPE_CHECKING, Annotated, ClassVar
21+
22+
import pyarrow as pa
23+
24+
from vgi.arguments import Arg
25+
from vgi.copy_from_function import CopyFromFunction
26+
27+
if TYPE_CHECKING:
28+
from vgi.table_function import ProcessParams
29+
from vgi_rpc.rpc import OutputCollector
30+
31+
__all__ = ["ExampleLinesCopyFromFunction"]
32+
33+
34+
@dataclass(slots=True, frozen=True, kw_only=True)
35+
class ExampleLinesCopyFromArgs:
36+
"""Options for the ``example_lines`` COPY format."""
37+
38+
null_string: Annotated[str, Arg("null_string", doc="Token parsed as SQL NULL")]
39+
delimiter: Annotated[str, Arg("delimiter", default=",", doc="Field separator")] = ","
40+
skip_rows: Annotated[
41+
int, Arg("skip_rows", default=0, ge=0, doc="Leading lines to skip before data")
42+
] = 0
43+
on_error: Annotated[
44+
str,
45+
Arg(
46+
"on_error",
47+
default="fail",
48+
choices=["fail", "skip"],
49+
doc="Behavior on a row whose column count does not match the target",
50+
),
51+
] = "fail"
52+
53+
54+
class ExampleLinesCopyFromFunction(CopyFromFunction[ExampleLinesCopyFromArgs]):
55+
"""Toy delimited-text ``COPY ... FROM`` reader (test fixture)."""
56+
57+
COPY_FROM_FORMAT: ClassVar[str] = "example_lines"
58+
COPY_FROM_COMMENT: ClassVar[str | None] = "Toy delimited-text reader for tests"
59+
60+
class Meta:
61+
name = "example_lines_copy_reader"
62+
description = "Read a delimited text file into the COPY target table"
63+
categories = ["copy", "test"]
64+
tags = {"category": "copy_from", "stability": "test"}
65+
66+
@classmethod
67+
def read(
68+
cls,
69+
*,
70+
path: str,
71+
options: ExampleLinesCopyFromArgs,
72+
expected_schema: pa.Schema,
73+
params: ProcessParams[ExampleLinesCopyFromArgs],
74+
out: OutputCollector,
75+
) -> None:
76+
"""Parse ``path`` line-by-line and emit one batch matching ``expected_schema``."""
77+
with open(path, encoding="utf-8") as fh:
78+
lines = fh.read().splitlines()
79+
lines = lines[options.skip_rows :]
80+
81+
ncols = len(expected_schema)
82+
rows: list[list[str]] = []
83+
for line in lines:
84+
if line == "":
85+
continue
86+
cells = line.split(options.delimiter)
87+
if len(cells) != ncols:
88+
if options.on_error == "skip":
89+
continue
90+
raise ValueError(
91+
f"example_lines: row has {len(cells)} fields, expected {ncols}: {line!r}"
92+
)
93+
rows.append(cells)
94+
95+
# Column-major string arrays, NULL where the cell equals null_string,
96+
# then cast each column to the target type (DuckDB inserts no cast).
97+
columns = list(zip(*rows)) if rows else [() for _ in range(ncols)]
98+
arrays = []
99+
for idx, field in enumerate(expected_schema):
100+
raw = [None if v == options.null_string else v for v in columns[idx]]
101+
arrays.append(pa.array(raw, type=pa.string()).cast(field.type))
102+
out.emit(pa.RecordBatch.from_arrays(arrays, schema=expected_schema))

‎vgi/_test_fixtures/worker.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
SlowCancellableFunction,
6060
SlowCancellableInOutFunction,
6161
)
62+
from vgi._test_fixtures.copy_from import ExampleLinesCopyFromFunction
6263
from vgi._test_fixtures.nest_tensor import NestTensorFunction, UnnestTensorFunction, UnnestTensorRowsFunction
6364
from vgi._test_fixtures.scalar import (
6465
AddValuesFunction,
@@ -349,6 +350,8 @@ def _build_enum_stats() -> dict[str, ColumnStatisticsInput]:
349350
EchoBufferingFunction,
350351
BufferEmitWideFunction,
351352
SlowCancellableBufferingFunction,
353+
# CopyFromFunction - custom COPY ... FROM format reader
354+
ExampleLinesCopyFromFunction,
352355
# TableFunctionGenerator - generate output without input
353356
ConstantColumnsFunction,
354357
SlowCancellableFunction,

0 commit comments

Comments
 (0)