@@ -176,6 +176,55 @@ class CatalogInfo(ArrowSerializableDataclass):
176176 source_url : str | None = None
177177
178178
179+ @dataclass (frozen = True )
180+ class AttachCatalogInfo (ArrowSerializableDataclass ):
181+ """A companion catalog the client should ATTACH when this VGI catalog attaches.
182+
183+ Advertised via :attr:`CatalogAttachResult.attach_catalogs`. The VGI DuckDB
184+ extension attaches each entry at VGI-attach time (into the client's
185+ DatabaseManager) so that multi-branch catalog-table branches — and direct
186+ user queries — can resolve tables in a companion lakehouse (DuckLake /
187+ iceberg / postgres / …) without the client hand-attaching anything.
188+
189+ Trust: companion attach is remote-influenced, so the extension applies a
190+ scheme allowlist and a never-clobber conflict policy (it will never replace
191+ a catalog it did not itself create). See ``docs/companion_catalogs.md``.
192+
193+ Attributes:
194+ alias: Catalog name to attach as. Also the ``source_catalog`` a
195+ catalog-table :class:`ScanBranch` references. **Namespace this by
196+ your catalog identity** (e.g. ``"acme_lake"``) so two workers don't
197+ both claim ``"lake"`` on the same client — collisions are rejected,
198+ never silently merged.
199+ target: The ATTACH target — a path or DSN, e.g.
200+ ``"ducklake:sqlite:/data/meta.sqlite"`` or
201+ ``"postgres:dbname=… host=…"``.
202+ db_type: DuckDB storage/db type (e.g. ``"ducklake"``, ``"postgres"``).
203+ Empty ⇒ the extension infers it from the ``target`` scheme prefix.
204+ options: Extra ATTACH options forwarded verbatim (e.g. DuckLake
205+ ``DATA_PATH``). Keys are matched case-insensitively by DuckDB.
206+ hidden: When true, attach hidden (excluded from ``duckdb_databases()``);
207+ still resolvable by qualified name and by branches. Use for
208+ branch-only companions the user shouldn't see.
209+ required: When true, a failure to attach (unreachable / conflict) fails
210+ the whole VGI ATTACH loudly. When false, the failure is logged and
211+ skipped; branches referencing it then error at bind.
212+ secret_ref: Optional name of a credential to pre-resolve (via the
213+ catalog's Orchard secret provider) and inject into the companion's
214+ ATTACH options — for metadata connections (e.g. a postgres DSN)
215+ where the catch-all path-keyed secret lookup isn't enough. Empty ⇒
216+ rely on the automatic catch-all provider for data-file creds.
217+ """
218+
219+ alias : str
220+ target : str
221+ db_type : str = ""
222+ options : dict [str , str ] = field (default_factory = dict )
223+ hidden : bool = False
224+ required : bool = False
225+ secret_ref : str = ""
226+
227+
179228@dataclass (frozen = True )
180229class CatalogAttachResult (ArrowSerializableDataclass ):
181230 """Result from attaching to a catalog.
@@ -199,6 +248,11 @@ class CatalogAttachResult(ArrowSerializableDataclass):
199248 Each ExtensionOption is serialized as bytes for Arrow compatibility.
200249 secret_types: Secret types registered with DuckDB's SecretManager. Each
201250 SecretTypeSpec is serialized as bytes for Arrow compatibility.
251+ attach_catalogs: Companion catalogs the client should ATTACH alongside
252+ this VGI catalog (lakehouse federation). Each :class:`AttachCatalogInfo`
253+ is serialized as bytes for Arrow compatibility. Attached at
254+ VGI-attach time; detached (refcounted) on DETACH. See
255+ ``AttachCatalogInfo`` and ``docs/companion_catalogs.md``.
202256 comment: Optional comment describing this catalog/database.
203257 tags: Optional key-value tags associated with this catalog/database.
204258 supports_column_statistics: Whether any tables in this catalog can
@@ -221,6 +275,7 @@ class CatalogAttachResult(ArrowSerializableDataclass):
221275 default_schema : str = "main"
222276 settings : list [bytes ] = field (default_factory = list )
223277 secret_types : list [bytes ] = field (default_factory = list )
278+ attach_catalogs : list [bytes ] = field (default_factory = list )
224279 comment : str | None = None
225280 tags : dict [str , str ] = field (default_factory = dict )
226281 supports_column_statistics : bool = False
@@ -858,11 +913,33 @@ def deserialize(cls, batch: pa.RecordBatch) -> Self:
858913class ScanBranch :
859914 """One physical source backing a multi-branch scan.
860915
916+ A branch is one of two **kinds**:
917+
918+ * **Function branch** (the default) — ``function_name`` names a DuckDB table
919+ function bound with ``positional_arguments``/``named_arguments``.
920+ * **Catalog-table branch** — ``function_name`` is empty (``""``) and
921+ ``source_table`` is set; the branch scans the base table
922+ ``source_catalog.source_schema.source_table`` in an *attached* catalog
923+ (typically an :class:`AttachCatalogInfo` companion, e.g. a DuckLake
924+ table). The extension binds it via the catalog's own scan function, so a
925+ companion's snapshot/pruning semantics are honored.
926+
927+ The discriminator is simply "``source_table`` present ⇒ catalog-table kind".
928+
861929 Attributes:
862- function_name: The DuckDB function to call for this branch
930+ function_name: The DuckDB function to call for a *function* branch
863931 (e.g., ``"read_parquet"``, ``"iceberg_scan"``, or a VGI
864932 table function). The C++ rewriter resolves this name against
865- DuckDB's function catalog and binds it at optimize time.
933+ DuckDB's function catalog and binds it at optimize time. Empty
934+ string for a catalog-table branch.
935+ source_catalog: Catalog-table branch only — the attached catalog name
936+ (matches an :attr:`AttachCatalogInfo.alias`). ``None`` for function
937+ branches.
938+ source_schema: Catalog-table branch only — the schema of the source
939+ table. ``None`` for function branches.
940+ source_table: Catalog-table branch only — the base table name; its
941+ presence selects the catalog-table kind. ``None`` for function
942+ branches.
866943 positional_arguments: Positional arguments as PyArrow scalars,
867944 passed through to the function's ``bind``.
868945 named_arguments: Named arguments as PyArrow scalars.
@@ -892,13 +969,19 @@ class ScanBranch:
892969 named_arguments : dict [str , pa .Scalar ] # type: ignore[type-arg]
893970 branch_filter : str | None = None
894971 writable : bool = False
972+ source_catalog : str | None = None
973+ source_schema : str | None = None
974+ source_table : str | None = None
895975
896976 ARROW_SCHEMA : ClassVar [pa .Schema ] = pa .schema (
897977 [
898978 pa .field ("function_name" , pa .string (), nullable = False ),
899979 pa .field ("arguments" , pa .binary (), nullable = False ),
900980 pa .field ("branch_filter" , pa .string (), nullable = True ),
901981 pa .field ("writable" , pa .bool_ (), nullable = False ),
982+ pa .field ("source_catalog" , pa .string (), nullable = True ),
983+ pa .field ("source_schema" , pa .string (), nullable = True ),
984+ pa .field ("source_table" , pa .string (), nullable = True ),
902985 ] # type: ignore[arg-type]
903986 )
904987
@@ -925,6 +1008,9 @@ def to_row_dict(self) -> dict[str, Any]:
9251008 "arguments" : serialize_record_batch_bytes (argument_batch ),
9261009 "branch_filter" : self .branch_filter ,
9271010 "writable" : self .writable ,
1011+ "source_catalog" : self .source_catalog ,
1012+ "source_schema" : self .source_schema ,
1013+ "source_table" : self .source_table ,
9281014 }
9291015
9301016 def serialize (self ) -> bytes :
@@ -966,6 +1052,9 @@ def deserialize(cls, batch: pa.RecordBatch) -> Self:
9661052 branch_filter = cast ("str | None" , branch_filter_value ) if branch_filter_value is not None else None ,
9671053 # writable is non-nullable on the wire — trust the schema.
9681054 writable = bool (row ["writable" ]),
1055+ source_catalog = cast ("str | None" , row .get ("source_catalog" )),
1056+ source_schema = cast ("str | None" , row .get ("source_schema" )),
1057+ source_table = cast ("str | None" , row .get ("source_table" )),
9691058 )
9701059
9711060
@@ -2258,6 +2347,7 @@ class ReadOnlyCatalogInterface(CatalogInterface):
22582347 settings : list ["SettingSpec" ] = []
22592348 secret_types : list ["SecretTypeSpec" ] = []
22602349 attach_option_specs : list ["AttachOptionSpec" ] = []
2350+ attach_catalogs : list ["AttachCatalogInfo" ] = []
22612351
22622352 # NEW: Optional Catalog object for declarative definition
22632353 catalog : "Catalog | None" = None
@@ -2427,6 +2517,7 @@ def catalog_attach(
24272517 # Serialize settings and secret types for the attach result
24282518 serialized_settings = [s .serialize () for s in self .settings ]
24292519 serialized_secret_types = [st .serialize () for st in self .secret_types ]
2520+ serialized_attach_catalogs = [c .serialize_to_bytes () for c in self .attach_catalogs ]
24302521
24312522 # Auto-derive supports_time_travel and supports_column_statistics from tables
24322523 self ._build_registries ()
@@ -2444,6 +2535,7 @@ def catalog_attach(
24442535 default_schema = self ._default_schema_name ,
24452536 settings = serialized_settings ,
24462537 secret_types = serialized_secret_types ,
2538+ attach_catalogs = serialized_attach_catalogs ,
24472539 comment = self .catalog .comment if self .catalog is not None else None ,
24482540 tags = dict (self .catalog .tags ) if self .catalog is not None else {},
24492541 supports_column_statistics = has_column_statistics ,
0 commit comments