1+ import json
12from collections .abc import Iterable
23from dataclasses import dataclass
34from datetime import UTC , datetime
@@ -135,6 +136,28 @@ def _is_blank(value: object) -> bool:
135136 return value is None or value == ""
136137
137138
139+ CUSTOM_METADATA_MAX_KEYS = 50
140+ CUSTOM_METADATA_MAX_BYTES = 10_240
141+
142+
143+ def validate_custom_metadata (value : object ) -> str | None :
144+ """Return an error message if ``value`` is not a valid ``custom_metadata`` payload, else ``None``.
145+
146+ ``custom_metadata`` must be a JSON object (key-value pairs), bounded in key count and serialized
147+ size. Shared by every write path — ``TestDefinition.validate`` (UI/CLI/MCP) and the
148+ ``TestDefinitionExport`` import schema — so the rule has a single definition.
149+ """
150+ if value is None :
151+ return None
152+ if not isinstance (value , dict ):
153+ return "must be a JSON object of key-value pairs"
154+ if len (value ) > CUSTOM_METADATA_MAX_KEYS :
155+ return f"must have at most { CUSTOM_METADATA_MAX_KEYS } keys"
156+ if len (json .dumps (value )) > CUSTOM_METADATA_MAX_BYTES :
157+ return f"must be at most { CUSTOM_METADATA_MAX_BYTES } bytes when serialized as JSON"
158+ return None
159+
160+
138161class ParamFieldsMixin :
139162 """Parsed access to default_parm_columns/prompts/help metadata.
140163
@@ -224,6 +247,8 @@ class TestDefinitionSummary(TestTypeSummary):
224247 prediction : dict [str , dict [str , float ]] | None
225248 flagged : bool
226249 impact_dimension : str | None
250+ external_url : str | None
251+ custom_metadata : dict | None
227252
228253 @property
229254 def display_name (self ) -> str :
@@ -397,6 +422,8 @@ class TestDefinition(Entity):
397422 flagged : bool = Column (Boolean , default = False , nullable = False )
398423 external_id : UUID | None = Column (postgresql .UUID (as_uuid = True ))
399424 impact_dimension : str | None = Column (String , nullable = True )
425+ external_url : str | None = Column (NullIfEmptyString )
426+ custom_metadata : dict | None = Column (postgresql .JSONB )
400427
401428 _default_order_by = (
402429 asc (func .lower (schema_name )),
@@ -555,14 +582,16 @@ def select_page(
555582 # Fields editable on every test type regardless of param_columns.
556583 EDITABLE_BASE_FIELDS : ClassVar [frozenset [str ]] = frozenset ({
557584 "test_active" , "severity" , "lock_refresh" , "flagged" , "test_description" ,
585+ "external_url" , "custom_metadata" ,
558586 })
559587
560588 def editable_fields (self , test_type : TestType ) -> set [str ]:
561589 """Fields a caller may set or change on this test definition under the given test type."""
562590 fields = self .EDITABLE_BASE_FIELDS | test_type .param_columns
563- # column_name is meaningful for column-scoped tests (the column under test) and
564- # custom-scoped tests (a "Test Focus" label). Other scopes don't use it.
565- if test_type .test_scope in ("column" , "custom" ):
591+ # column_name is meaningful for column-scoped tests (the column under test),
592+ # custom-scoped tests (a "Test Focus" label), and referential tests (the aggregate
593+ # expression or categorical column list under test). Table-scoped tests don't use it.
594+ if test_type .test_scope in ("column" , "custom" , "referential" ):
566595 fields = fields | {"column_name" }
567596 # impact_dimension is overridable only for user-defined-semantic scopes
568597 # (custom-scope = user-authored SQL; referential-scope = comparison-based tests).
@@ -588,9 +617,10 @@ def validate(self, test_type: TestType) -> None:
588617 f"(got `{ self .severity } `)"
589618 )
590619
591- # column_name applies to column-scoped tests (the column under test) and
592- # custom-scoped tests (a "Test Focus" label). Other scopes don't use it.
593- if test_type .test_scope not in ("column" , "custom" ) and not _is_blank (self .column_name ):
620+ # column_name applies to column-scoped tests (the column under test),
621+ # custom-scoped tests (a "Test Focus" label), and referential tests (the aggregate
622+ # expression or categorical column list under test). Table-scoped tests don't use it.
623+ if test_type .test_scope not in ("column" , "custom" , "referential" ) and not _is_blank (self .column_name ):
594624 errors ["column_name" ] = (
595625 f"test type `{ test_type .test_type } ` has scope `{ test_type .test_scope } `; "
596626 f"column_name does not apply to this scope"
@@ -601,6 +631,10 @@ def validate(self, test_type: TestType) -> None:
601631 f"test type `{ test_type .test_type } ` does not accept a custom query"
602632 )
603633
634+ metadata_error = validate_custom_metadata (self .custom_metadata )
635+ if metadata_error :
636+ errors ["custom_metadata" ] = metadata_error
637+
604638 for required in _required_fields_for (test_type ):
605639 if _is_blank (getattr (self , required , None )):
606640 errors [required ] = f"required for test type `{ test_type .test_type } `"
0 commit comments