@@ -613,3 +613,118 @@ class TestFunc(TableInOutFunction): # type: ignore[type-arg]
613613
614614 meta = resolve_metadata (TestFunc )
615615 assert meta .function_type == CatalogFunctionType .TABLE
616+
617+
618+ class TestFunctionTags :
619+ """A worker can attach arbitrary free-form tags to a function's metadata.
620+
621+ These flow ``Meta.tags`` -> ``ResolvedMetadata.tags`` -> ``FunctionInfo.tags``
622+ -> DuckDB's per-function ``tags`` map, which is what the ``vgi-lint-check``
623+ metadata linter reads (e.g. VGI307 requires ``vgi.columns_md`` on a
624+ dynamic-schema table function).
625+ """
626+
627+ def test_meta_tags_resolved (self ) -> None :
628+ """Arbitrary Meta.tags land on ResolvedMetadata.tags verbatim."""
629+
630+ class TaggedFunction (TableInOutFunction ): # type: ignore[type-arg]
631+ class Meta :
632+ name = "tagged_func"
633+ tags = {
634+ "vgi.columns_md" : "| col | type |\n | --- | --- |\n | id | BIGINT |" ,
635+ "custom.team" : "data" ,
636+ }
637+
638+ data : TableInput = Arg [TableInput ](0 , doc = "Input table" ) # type: ignore[assignment]
639+
640+ meta = resolve_metadata (TaggedFunction )
641+ assert meta .tags == {
642+ "vgi.columns_md" : "| col | type |\n | --- | --- |\n | id | BIGINT |" ,
643+ "custom.team" : "data" ,
644+ }
645+
646+ def test_tags_survive_arrow_roundtrip (self ) -> None :
647+ """Function tags survive the metadata Arrow wire roundtrip (map column)."""
648+
649+ class TaggedFunction (TableInOutFunction ): # type: ignore[type-arg]
650+ class Meta :
651+ name = "tagged_func"
652+ tags = {"vgi.columns_md" : "| col |" , "k" : "v" }
653+
654+ data : TableInput = Arg [TableInput ](0 , doc = "Input table" ) # type: ignore[assignment]
655+
656+ restored = arrow_to_functions (functions_to_arrow ([TaggedFunction ]))[0 ]
657+ assert restored .tags == {"vgi.columns_md" : "| col |" , "k" : "v" }
658+
659+ def test_tags_and_examples_are_independent (self ) -> None :
660+ """Worker tags do not clobber the structured examples, and vice versa.
661+
662+ ``examples`` is its own structured channel (serialized to the
663+ ``examples`` struct column / ``FunctionInfo.examples``); it is never
664+ folded into the ``tags`` map. A worker that *also* sets a
665+ ``vgi.example_queries`` tag (the tag-encoded examples convention the
666+ linter understands) keeps both — the tag is preserved untouched and the
667+ structured examples remain separate.
668+ """
669+
670+ class BothFunction (TableInOutFunction ): # type: ignore[type-arg]
671+ class Meta :
672+ name = "both_func"
673+ examples = ["SELECT * FROM both_func((SELECT 1))" ]
674+ tags = {
675+ "vgi.example_queries" : '[{"description":"x","sql":"SELECT tag"}]' ,
676+ "vgi.columns_md" : "| col |" ,
677+ }
678+
679+ data : TableInput = Arg [TableInput ](0 , doc = "Input table" ) # type: ignore[assignment]
680+
681+ meta = resolve_metadata (BothFunction )
682+ # Worker-provided tags untouched (example_queries tag not derived, not dropped).
683+ assert meta .tags == {
684+ "vgi.example_queries" : '[{"description":"x","sql":"SELECT tag"}]' ,
685+ "vgi.columns_md" : "| col |" ,
686+ }
687+ # Structured examples live independently and are not merged into tags.
688+ assert [ex .sql for ex in meta .examples ] == ["SELECT * FROM both_func((SELECT 1))" ]
689+
690+ def test_tags_flow_into_function_info (self ) -> None :
691+ """Tags reach ``FunctionInfo.tags`` (catalog -> DuckDB -> linter path).
692+
693+ Also asserts that the structured ``examples`` and the worker-set
694+ ``vgi.example_queries`` tag coexist on ``FunctionInfo`` without either
695+ clobbering the other.
696+ """
697+ from vgi .catalog .catalog_interface import (
698+ FunctionInfo ,
699+ ReadOnlyCatalogInterface ,
700+ )
701+ from vgi .table_function import TableFunctionGenerator
702+
703+ class DynamicTable (TableFunctionGenerator ): # type: ignore[type-arg]
704+ class Meta :
705+ name = "dynamic_table"
706+ examples = ["SELECT * FROM dynamic_table()" ]
707+ tags = {
708+ "vgi.columns_md" : "| col | type |\n | --- | --- |\n | id | BIGINT |" ,
709+ "vgi.example_queries" : '[{"description":"x","sql":"SELECT 1"}]' ,
710+ "custom.team" : "data" ,
711+ }
712+
713+ FIXED_SCHEMA = pa .schema ([pa .field ("id" , pa .int64 ())])
714+
715+ def process (self , params ): # type: ignore[no-untyped-def]
716+ yield pa .record_batch ({"id" : [1 ]})
717+
718+ class Cat (ReadOnlyCatalogInterface ):
719+ catalog_name = "functions"
720+ functions = [DynamicTable ]
721+
722+ info = Cat ()._function_to_info (DynamicTable , "main" )
723+ assert isinstance (info , FunctionInfo )
724+ assert info .tags == {
725+ "vgi.columns_md" : "| col | type |\n | --- | --- |\n | id | BIGINT |" ,
726+ "vgi.example_queries" : '[{"description":"x","sql":"SELECT 1"}]' ,
727+ "custom.team" : "data" ,
728+ }
729+ # Structured examples are carried separately from the tag-encoded ones.
730+ assert [ex .sql for ex in info .examples ] == ["SELECT * FROM dynamic_table()" ]
0 commit comments