|
26 | 26 | Catalog, |
27 | 27 | CatalogAttachResult, |
28 | 28 | ForeignKeyDef, |
| 29 | + FunctionInfo, |
29 | 30 | Macro, |
30 | 31 | MacroInfo, |
31 | 32 | MacroType, |
|
41 | 42 | View, |
42 | 43 | ViewInfo, |
43 | 44 | ) |
| 45 | +from vgi.catalog.catalog_interface import FunctionType as CatalogInterfaceFunctionType |
44 | 46 | from vgi.invocation import BindResponse |
45 | 47 | from vgi.schema_utils import schema |
46 | 48 | from vgi.table_function import ( |
@@ -1394,6 +1396,152 @@ def test_catalog_rejects_duplicate_schema_names(self) -> None: |
1394 | 1396 | Catalog(name="myapp", schemas=[s1, s2]) |
1395 | 1397 |
|
1396 | 1398 |
|
| 1399 | +class TestGlobalFunctionsValidation: |
| 1400 | + """Validation for ``Catalog.global_functions`` / ``global_function_prefix``.""" |
| 1401 | + |
| 1402 | + def test_defaults_are_empty(self) -> None: |
| 1403 | + """A catalog publishes nothing globally unless it asks to.""" |
| 1404 | + catalog = Catalog(name="myapp", schemas=[Schema(name="main")]) |
| 1405 | + assert catalog.global_functions == () |
| 1406 | + assert catalog.global_function_prefix is None |
| 1407 | + |
| 1408 | + def test_accepts_schema_resident_function(self) -> None: |
| 1409 | + """The happy path: listed once globally, resident in exactly one schema.""" |
| 1410 | + catalog = Catalog( |
| 1411 | + name="myapp", |
| 1412 | + schemas=[Schema(name="main", functions=[UsersFunction])], |
| 1413 | + global_functions=[UsersFunction], |
| 1414 | + global_function_prefix="myapp", |
| 1415 | + ) |
| 1416 | + assert list(catalog.global_functions) == [UsersFunction] |
| 1417 | + assert catalog.global_function_prefix == "myapp" |
| 1418 | + |
| 1419 | + def test_rejects_function_absent_from_every_schema(self) -> None: |
| 1420 | + """A global function that isn't schema-resident would never dispatch.""" |
| 1421 | + with pytest.raises(ValueError, match="does not appear in any Schema.functions"): |
| 1422 | + Catalog( |
| 1423 | + name="myapp", |
| 1424 | + schemas=[Schema(name="main")], |
| 1425 | + global_functions=[UsersFunction], |
| 1426 | + ) |
| 1427 | + |
| 1428 | + def test_rejects_function_resident_in_multiple_schemas(self) -> None: |
| 1429 | + """Two homes make the global registration ambiguous about dispatch.""" |
| 1430 | + with pytest.raises(ValueError, match="appears in multiple schemas"): |
| 1431 | + Catalog( |
| 1432 | + name="myapp", |
| 1433 | + schemas=[ |
| 1434 | + Schema(name="main", functions=[UsersFunction]), |
| 1435 | + Schema(name="other", functions=[UsersFunction]), |
| 1436 | + ], |
| 1437 | + global_functions=[UsersFunction], |
| 1438 | + ) |
| 1439 | + |
| 1440 | + def test_rejects_duplicate_global_entries(self) -> None: |
| 1441 | + """Listing a class twice would publish the same name twice.""" |
| 1442 | + with pytest.raises(ValueError, match="duplicate entry UsersFunction"): |
| 1443 | + Catalog( |
| 1444 | + name="myapp", |
| 1445 | + schemas=[Schema(name="main", functions=[UsersFunction])], |
| 1446 | + global_functions=[UsersFunction, UsersFunction], |
| 1447 | + ) |
| 1448 | + |
| 1449 | + @pytest.mark.parametrize("prefix", ["9bad", "Bad", "has-dash", "has space", "", "trailing;"]) |
| 1450 | + def test_rejects_non_identifier_prefix(self, prefix: str) -> None: |
| 1451 | + """The prefix is concatenated into a SQL identifier, so it must be safe.""" |
| 1452 | + with pytest.raises(ValueError, match="not a valid SQL identifier prefix"): |
| 1453 | + Catalog( |
| 1454 | + name="myapp", |
| 1455 | + schemas=[Schema(name="main", functions=[UsersFunction])], |
| 1456 | + global_functions=[UsersFunction], |
| 1457 | + global_function_prefix=prefix, |
| 1458 | + ) |
| 1459 | + |
| 1460 | + def test_prefix_validated_even_without_global_functions(self) -> None: |
| 1461 | + """A bad prefix is a bug worth reporting even if nothing uses it yet.""" |
| 1462 | + with pytest.raises(ValueError, match="not a valid SQL identifier prefix"): |
| 1463 | + Catalog(name="myapp", schemas=[Schema(name="main")], global_function_prefix="Bad") |
| 1464 | + |
| 1465 | + |
| 1466 | +class TestGlobalFunctionsAttachResult: |
| 1467 | + """``catalog_attach`` advertises globals so the client can publish them.""" |
| 1468 | + |
| 1469 | + @staticmethod |
| 1470 | + def _attach(catalog_obj: Catalog) -> CatalogAttachResult: |
| 1471 | + class _TestCatalog(ReadOnlyCatalogInterface): |
| 1472 | + catalog = catalog_obj |
| 1473 | + |
| 1474 | + return _TestCatalog().catalog_attach( |
| 1475 | + name=catalog_obj.name, |
| 1476 | + options={}, |
| 1477 | + data_version_spec=None, |
| 1478 | + implementation_version=None, |
| 1479 | + ) |
| 1480 | + |
| 1481 | + def test_absent_when_not_declared(self) -> None: |
| 1482 | + """Catalogs that declare nothing advertise an empty list and no prefix.""" |
| 1483 | + result = self._attach(Catalog(name="myapp", schemas=[Schema(name="main", functions=[UsersFunction])])) |
| 1484 | + assert result.global_functions == [] |
| 1485 | + assert result.global_function_prefix == "" |
| 1486 | + |
| 1487 | + def test_carries_serialized_function_info(self) -> None: |
| 1488 | + """Each global arrives as a FunctionInfo with real dispatch coordinates. |
| 1489 | +
|
| 1490 | + ``name``/``schema_name`` must stay the values bind dispatches on — the |
| 1491 | + prefix is applied client-side, not baked into the name here. |
| 1492 | + """ |
| 1493 | + result = self._attach( |
| 1494 | + Catalog( |
| 1495 | + name="myapp", |
| 1496 | + schemas=[Schema(name="analytics", functions=[UsersFunction])], |
| 1497 | + default_schema="analytics", |
| 1498 | + global_functions=[UsersFunction], |
| 1499 | + global_function_prefix="myapp", |
| 1500 | + ) |
| 1501 | + ) |
| 1502 | + assert result.global_function_prefix == "myapp" |
| 1503 | + assert len(result.global_functions) == 1 |
| 1504 | + |
| 1505 | + info = FunctionInfo.deserialize_from_bytes(result.global_functions[0]) |
| 1506 | + assert info.name == "users" |
| 1507 | + assert info.schema_name == "analytics" |
| 1508 | + assert info.function_type == CatalogInterfaceFunctionType.TABLE |
| 1509 | + |
| 1510 | + def test_prefix_empty_string_when_unset(self) -> None: |
| 1511 | + """``None`` prefix serializes as empty string — the wire type is str.""" |
| 1512 | + result = self._attach( |
| 1513 | + Catalog( |
| 1514 | + name="myapp", |
| 1515 | + schemas=[Schema(name="main", functions=[UsersFunction])], |
| 1516 | + global_functions=[UsersFunction], |
| 1517 | + ) |
| 1518 | + ) |
| 1519 | + assert result.global_function_prefix == "" |
| 1520 | + assert len(result.global_functions) == 1 |
| 1521 | + |
| 1522 | + def test_example_fixture_publishes_one_of_each_function_type(self) -> None: |
| 1523 | + """The shipped fixture covers all four types for the C++ integration suite.""" |
| 1524 | + from vgi._test_fixtures.worker import ExampleCatalog |
| 1525 | + |
| 1526 | + result = ExampleCatalog().catalog_attach( |
| 1527 | + name="example", |
| 1528 | + options={}, |
| 1529 | + data_version_spec=None, |
| 1530 | + implementation_version=None, |
| 1531 | + ) |
| 1532 | + assert result.global_function_prefix == "vgi_example" |
| 1533 | + infos = [FunctionInfo.deserialize_from_bytes(b) for b in result.global_functions] |
| 1534 | + assert {i.function_type for i in infos} == { |
| 1535 | + CatalogInterfaceFunctionType.SCALAR, |
| 1536 | + CatalogInterfaceFunctionType.TABLE, |
| 1537 | + CatalogInterfaceFunctionType.AGGREGATE, |
| 1538 | + CatalogInterfaceFunctionType.TABLE_BUFFERING, |
| 1539 | + } |
| 1540 | + # Every advertised global must be schema-resident, or it could never |
| 1541 | + # be dispatched after the client registers it. |
| 1542 | + assert all(i.schema_name == "main" for i in infos) |
| 1543 | + |
| 1544 | + |
1397 | 1545 | # ============================================================================= |
1398 | 1546 | # ReadOnlyCatalogInterface with Catalog Object Tests |
1399 | 1547 | # ============================================================================= |
|
0 commit comments