55from __future__ import annotations
66
77import re
8+ from dataclasses import asdict
89from typing import Any
910
11+ import pyarrow as pa
1012import pytest
1113
1214from vgi ._test_fixtures .catalog import InMemoryCatalog , SchemaData
3234 SchemaInfo ,
3335 SchemaObjectType ,
3436)
37+ from vgi .catalog .contents_codec import CATEGORY_TYPES , decode_inventory
3538from vgi .meta_worker import MetaWorker
36- from vgi .protocol import CatalogAttachRequest , CatalogContentsResponse , SchemaContents
39+ from vgi .protocol import CatalogAttachRequest , CatalogContentsResponse , SchemaContents , _item_ipc_bytes
3740from vgi .worker import Worker , catalog_contents_digest
3841
3942_KINDS = (
@@ -74,6 +77,17 @@ def _roundtrip(response: CatalogContentsResponse) -> CatalogContentsResponse:
7477 return CatalogContentsResponse .deserialize_from_bytes (response .serialize_to_bytes ())
7578
7679
80+ def _entries (response : CatalogContentsResponse ) -> list [SchemaContents ]:
81+ return [
82+ SchemaContents (
83+ path = list (info .schema .path ),
84+ schema = _item_ipc_bytes (info .schema ),
85+ ** {kind : [_item_ipc_bytes (item ) for item in getattr (info , kind )] for kind in _KINDS },
86+ )
87+ for info in decode_inventory (response )
88+ ]
89+
90+
7791# ---------------------------------------------------------------------------
7892# Wire shape
7993# ---------------------------------------------------------------------------
@@ -82,12 +96,18 @@ def _roundtrip(response: CatalogContentsResponse) -> CatalogContentsResponse:
8296class TestShape :
8397 """The typed response: a struct per schema, items byte-identical to the per-schema RPCs."""
8498
85- def test_schemas_is_a_list_of_structs (self ) -> None :
86- """``schemas`` is ``list<struct>`` with ``path`` first, not a list of IPC blobs."""
87- field = CatalogContentsResponse .ARROW_SCHEMA .field ("schemas" )
88- assert field .type .value_type .num_fields == 10
89- assert [f .name for f in field .type .value_type ] == ["path" , "schema" , * _KINDS ]
90- assert CatalogContentsResponse .ARROW_SCHEMA .names == ["catalog_version" , "etag" , "not_modified" , "schemas" ]
99+ def test_categories_are_nullable_binary_streams (self ) -> None :
100+ """Every category is one nullable typed collection stream."""
101+ assert CatalogContentsResponse .ARROW_SCHEMA .names == [
102+ "catalog_version" ,
103+ "etag" ,
104+ "not_modified" ,
105+ * CATEGORY_TYPES ,
106+ ]
107+ for category in CATEGORY_TYPES :
108+ field = CatalogContentsResponse .ARROW_SCHEMA .field (category )
109+ assert field .nullable and field .type == pa .binary ()
110+ assert "attach_opaque_data" not in SchemaInfo .ARROW_SCHEMA .names
91111
92112 def test_items_match_the_per_schema_rpcs (self ) -> None :
93113 """Every kind of every schema is byte-identical to its per-schema RPC answer."""
@@ -96,15 +116,15 @@ def test_items_match_the_per_schema_rpcs(self) -> None:
96116 response = _roundtrip (worker .catalog_contents (attach ))
97117 assert response .etag is None
98118 assert not response .not_modified
99- assert [entry .path for entry in response . schemas ] == [["main" ], ["extra" ]]
119+ assert [entry .path for entry in _entries ( response ) ] == [["main" ], ["extra" ]]
100120 schema_items = worker .catalog_schemas (attach ).items
101- assert [entry .schema for entry in response . schemas ] == schema_items
102- for entry in response . schemas :
121+ assert [entry .schema for entry in _entries ( response ) ] == schema_items
122+ for entry in _entries ( response ) :
103123 assert SchemaInfo .deserialize_from_bytes (entry .schema ).path == entry .path
104124 expected = _per_schema_items (worker , attach , entry .path )
105125 for kind in _KINDS :
106126 assert getattr (entry , kind ) == expected [kind ], (entry .path , kind )
107- main = response . schemas [0 ]
127+ main = _entries ( response ) [0 ]
108128 # Every seeded kind is present in the fixture.
109129 for kind in _KINDS [:- 1 ]:
110130 assert getattr (main , kind ), kind
@@ -126,8 +146,8 @@ class NestedWorker(ContentsProbeWorker):
126146
127147 worker = NestedWorker ()
128148 response = _roundtrip (worker .catalog_contents (_attach (worker , CATALOG_PROBE )))
129- assert [entry .path for entry in response . schemas ] == [["a" ], ["a" , "b" ], ["a" , "b" , "c" ]]
130- for entry in response . schemas :
149+ assert [entry .path for entry in _entries ( response ) ] == [["a" ], ["a" , "b" ], ["a" , "b" , "c" ]]
150+ for entry in _entries ( response ) :
131151 assert SchemaInfo .deserialize_from_bytes (entry .schema ).path == entry .path
132152
133153 @pytest .mark .parametrize (
@@ -186,14 +206,14 @@ def test_cheap_validator_short_circuits_without_building(self, monkeypatch: pyte
186206 full = worker .catalog_contents (attach )
187207 assert full .etag == "gen-1"
188208 assert not full .not_modified
189- assert [entry .path for entry in full . schemas ] == [["main" ]]
209+ assert [entry .path for entry in _entries ( full ) ] == [["main" ]]
190210
191211 with monkeypatch .context () as m :
192212 _forbid_build (m )
193213 hit = _roundtrip (worker .catalog_contents (attach , if_none_match = "gen-1" ))
194214 assert hit .not_modified
195215 assert hit .etag == "gen-1"
196- assert hit .schemas == []
216+ assert hit .schemas is None
197217 assert hit .catalog_version == 1
198218
199219 # A different etag gets the full contents.
@@ -215,7 +235,7 @@ def test_ddl_changes_the_validator(self) -> None:
215235 assert not response .not_modified
216236 assert response .etag == "gen-2"
217237 assert response .catalog_version == 2
218- assert [entry .path for entry in response . schemas ] == [["main" ], ["other" ]]
238+ assert [entry .path for entry in _entries ( response ) ] == [["main" ], ["other" ]]
219239
220240 def test_no_etag_ignores_if_none_match (self ) -> None :
221241 """A catalog without an etag always answers in full, with etag null."""
@@ -224,7 +244,7 @@ def test_no_etag_ignores_if_none_match(self) -> None:
224244 response = worker .catalog_contents (attach , if_none_match = "anything" )
225245 assert response .etag is None
226246 assert not response .not_modified
227- assert [entry .path for entry in response . schemas ] == [["main" ]]
247+ assert [entry .path for entry in _entries ( response ) ] == [["main" ]]
228248 # contents_memory reports version 0: exercises the client's version-0 rule.
229249 assert response .catalog_version == 0
230250
@@ -245,7 +265,7 @@ class BuiltWorker(ContentsProbeWorker):
245265 attach = _attach (worker , CATALOG_PROBE )
246266 response = worker .catalog_contents (attach , if_none_match = "v7" )
247267 assert response .not_modified
248- assert response .schemas == []
268+ assert response .schemas is None
249269 assert response .etag == "v7"
250270
251271 @pytest .mark .parametrize (
@@ -317,11 +337,11 @@ def test_hash_is_deterministic_and_revalidates(self) -> None:
317337 second = worker .catalog_contents (attach )
318338 assert second .etag == first .etag
319339 assert second .schemas == first .schemas
320- assert catalog_contents_digest (first . schemas ) == first .etag
340+ assert catalog_contents_digest (first ) == first .etag
321341
322342 hit = worker .catalog_contents (attach , if_none_match = first .etag )
323343 assert hit .not_modified
324- assert hit .schemas == []
344+ assert hit .schemas is None
325345 assert hit .etag == first .etag
326346
327347 cat = worker ._get_catalog ()
@@ -330,7 +350,7 @@ def test_hash_is_deterministic_and_revalidates(self) -> None:
330350 changed = worker .catalog_contents (attach , if_none_match = first .etag )
331351 assert not changed .not_modified
332352 assert changed .etag != first .etag
333- assert [entry .path for entry in changed . schemas ] == [["main" ], ["other" ]]
353+ assert [entry .path for entry in _entries ( changed ) ] == [["main" ], ["other" ]]
334354
335355 def test_off_by_default (self ) -> None :
336356 """Without the opt-in a catalog gets no etag."""
@@ -357,9 +377,9 @@ class OwnWorker(ContentsProbeWorker):
357377
358378 def test_digest_separates_fields (self ) -> None :
359379 """Moving bytes between fields changes the digest."""
360- a = [ SchemaContents ( path = [ "s" ], schema = b"x" , tables = [ b"ab" ])]
361- b = [ SchemaContents ( path = [ "s" ], schema = b"x " , tables = [ b"a" , b"b" ])]
362- c = [ SchemaContents ( path = [ "s" ], schema = b"x" , views = [ b"ab" ])]
380+ a = CatalogContentsResponse ( catalog_version = 1 , schemas = b"x" , tables = b"ab" )
381+ b = CatalogContentsResponse ( catalog_version = 1 , schemas = b"xa " , tables = b"b" )
382+ c = CatalogContentsResponse ( catalog_version = 1 , schemas = b"x" , views = b"ab" )
363383 assert len ({catalog_contents_digest (a ), catalog_contents_digest (b ), catalog_contents_digest (c )}) == 3
364384
365385
@@ -397,12 +417,7 @@ def test_cached_bytes_equal_a_fresh_encoding(self) -> None:
397417 """The pre-serialized response is byte-identical to encoding it normally."""
398418 worker = ContentsProbeWorker ()
399419 cached = worker .catalog_contents (_attach (worker , CATALOG_PROBE ))
400- fresh = CatalogContentsResponse (
401- catalog_version = cached .catalog_version ,
402- etag = cached .etag ,
403- not_modified = cached .not_modified ,
404- schemas = list (cached .schemas ),
405- )
420+ fresh = CatalogContentsResponse (** asdict (cached ))
406421 assert cached .serialize_to_bytes () == fresh .serialize_to_bytes ()
407422 assert _roundtrip (cached ) == fresh
408423
@@ -436,7 +451,7 @@ class HashedWorker(ContentsProbeWorker):
436451 assert full .etag is not None
437452 hit = worker .catalog_contents (attach , if_none_match = full .etag )
438453 assert hit .not_modified
439- assert hit .schemas == []
454+ assert hit .schemas is None
440455 assert worker .catalog_contents (attach , if_none_match = "stale" ) is full
441456 assert len (calls ) == 1
442457
0 commit comments