Table Validation⚓︎
Use validate_records() for rules that need the whole parsed table.
Record validation checks one record at a time. Table validation checks the collection: duplicates, required combinations, aggregate limits, ordering, cross-record relationships, and policy that only makes sense after every record has been parsed.
Given the user roster exists
| email | role | primary |
| a@example.com | admin | true |
| b@example.com | viewer | false |
Use the smallest useful validation scope
Put one-cell syntax in parsers, one-record rules in validate_record(),
and cross-record rules in validate_records(). The smaller the scope, the
easier the failure is to explain.
Add a Table Validator⚓︎
Define validate_records(cls, records, context) as a class method on the
schema. Talika calls it after records are parsed, after local references are
resolved, and after each record has passed validate_record().
from talika import RowTable, TableError, boolean, field
class UserRoster(RowTable):
email = field("email", required=True)
role = field("role", default="viewer")
primary = field("primary", parser=boolean(), default=False)
@classmethod
def validate_records(cls, records, context):
seen = {}
for record in records:
if record.email in seen:
raise TableError.from_cell(
"Duplicate email",
record.source_for("email"),
schema=cls,
field="email",
hint="Each user row must use a unique email address.",
)
seen[record.email] = record
if not any(record.primary for record in records):
raise ValueError("At least one primary user is required")
domain = context.user_data["email_domain"]
for record in records:
if not record.email.endswith(f"@{domain}"):
raise TableError.from_cell(
f"Email must belong to {domain}",
record.source_for("email"),
schema=cls,
field="email",
)
This table validator checks three whole-table rules:
- no two records may share the same email
- at least one user must be marked primary
- every email must belong to the configured domain
users = UserRoster.parse(
[
["email", "role", "primary"],
["a@example.com", "admin", "true"],
["b@example.com", "viewer", "false"],
],
context={"email_domain": "example.com"},
)
assert users[0].primary is True
assert users[1].primary is False
>> [user.as_dict() for user in users]
[
{'email': 'a@example.com', 'role': 'admin', 'primary': True},
{'email': 'b@example.com', 'role': 'viewer', 'primary': False},
]
The records argument contains parsed schema records. Field parsers and
defaults have already run, so primary is a boolean and role may come from a
default.
The hook validates the collection
validate_records() should return None when the table is valid. Raise an
exception when the collection is invalid.
Use Parse Context for Table Policy⚓︎
The context argument is the same parse context used by parsers, defaults, and
record validators. It carries read-only user_data from the parse call.
class SeenContext(RowTable):
email = field("email")
seen = None
@classmethod
def validate_records(cls, records, context):
cls.seen = {
"emails": [record.email for record in records],
"domain": context.user_data["domain"],
}
SeenContext.parse(
[
["email"],
["a@example.com"],
["b@example.com"],
],
context={"domain": "example.com"},
)
assert SeenContext.seen == {
"emails": ["a@example.com", "b@example.com"],
"domain": "example.com",
}
>> SeenContext.seen
{'emails': ['a@example.com', 'b@example.com'], 'domain': 'example.com'}
Use context for policies that change by test setup: allowed domains, minimum counts, publication limits, scenario mode, known external IDs, or service objects used by validation.
Keep context explicit
Avoid reading mutable global state inside table validation. Passing policy
through parse(..., context={...}) makes the rule visible at the call site
and keeps tests easier to reason about.
Detect Duplicates with Source-Aware Errors⚓︎
Duplicate checks are a common table-level rule. The validator must remember what it has already seen, then report the later cell that introduced the duplicate.
UserRoster.parse(
[
["email", "primary"],
["a@example.com", "true"],
["a@example.com", "false"],
],
context={"email_domain": "example.com"},
)
Duplicate email
(code=table_error, schema=UserRoster, field='email',
row=3, column=1, value='a@example.com').
Hint: Each user row must use a unique email address.
The diagnostic points to row 3, column 1, because that is the second occurrence of the duplicate email. The first occurrence is useful for comparison, but the second occurrence is the cell the author usually changes.
Point at the actionable cell
When a table-level rule can identify one offending cell, use
TableError.from_cell(...) with record.source_for("field_name"). That
gives the author a precise place to edit.
Raise Plain Errors for Whole-Table Problems⚓︎
Some table rules do not belong to one cell. For example, "at least one primary user is required" is a property of the collection.
UserRoster.parse(
[
["email", "primary"],
["a@example.com", "false"],
["b@example.com", "false"],
],
context={"email_domain": "example.com"},
)
Table validation failed: At least one primary user is required
(code=table_validation_failed, schema=UserRoster)
Talika wraps ordinary exceptions as table_validation_failed. The diagnostic
names the schema, but it does not claim a row or column because no single cell
caused the problem.
class PlainTableValidation(RowTable):
email = field("email")
@classmethod
def validate_records(cls, records, context):
raise ValueError("table policy unavailable")
PlainTableValidation.parse(
[
["email"],
["a@example.com"],
]
)
Table validation failed: table policy unavailable
(code=table_validation_failed, schema=PlainTableValidation)
Use ordinary exceptions for collection-level failures
If the problem is "the table as a whole does not satisfy this rule", raise a normal exception with a clear message. Let Talika wrap it as a table validation failure.
Validate Policy Against Specific Cells⚓︎
Sometimes a policy is table-wide but the failure still belongs to one cell. In
the roster example, the allowed email domain comes from parse context and
applies to every record. The failing value is still the email cell.
UserRoster.parse(
[
["email", "primary"],
["a@other.test", "true"],
],
context={"email_domain": "example.com"},
)
Email must belong to example.com
(code=table_error, schema=UserRoster, field='email',
row=2, column=1, value='a@other.test')
This pattern is useful when a shared rule scans all records but can point to
the exact value that broke the rule. Use TableError.from_cell(...) for the
specific cell and keep the message focused on the policy.
Do not hide table policy inside parsers
A parser should not need to know every other record. If the rule needs the
collection or a configured table policy, keep it in validate_records().
Column Table Validation⚓︎
validate_records() works the same way for ColumnTable. The records are
still a sequence of parsed schema records, but each record came from an item
column.
Given the content schedule exists
| IDs | A-1 | P-1 |
| Type | Article | Poll |
| Publish | true | true |
from talika import ColumnTable, id_field
class ContentSchedule(ColumnTable):
id = id_field("IDs")
content_type = field("Type")
publish = field("Publish", parser=boolean(), default=False)
@classmethod
def validate_records(cls, records, context):
published = [record for record in records if record.publish]
limit = context.user_data["publish_limit"]
if len(published) > limit:
extra = published[limit]
raise TableError.from_cell(
f"Only {limit} item may be published in this scenario",
extra.source_for("publish"),
schema=cls,
field="Publish",
item_id=extra.id,
)
This validator enforces a table-level publication limit. The rule needs all records because one item being published is valid, but two published items break the scenario policy.
ContentSchedule.parse(
[
["IDs", "A-1", "P-1"],
["Type", "Article", "Poll"],
["Publish", "true", "true"],
],
context={"publish_limit": 1},
)
Only 1 item may be published in this scenario
(code=table_error, schema=ContentSchedule, field='Publish',
row=3, column=3, item_id='P-1', value='true')
The diagnostic points to the second published item. It includes both
item_id='P-1' and the source row/column for that item's Publish cell.
Records are orientation-neutral
Inside validate_records(), row and column records are both normal schema
records. The main difference is the source metadata attached to each
record.
Cross-Record Reference Checks⚓︎
A common use case for whole-table validation is relational integrity between
records. For example, an organizational table may have a manager id field
that should point to another record's user id.
This is not a field parser problem. The manager id cell can be syntactically
valid and still refer to a user that does not exist. It is also not a
single-record validation problem, because one record cannot know every ID in
the table.
Because table validation runs after all records have been parsed, it can collect the valid IDs first and then check every reference against that collection.
class OrgChart(RowTable):
user_id = id_field("user id")
manager_id = field("manager id", default="")
@classmethod
def validate_records(cls, records, context):
all_ids = {r.user_id for r in records}
for r in records:
if r.manager_id and r.manager_id not in all_ids:
raise TableError.from_cell(
f"Manager {r.manager_id} not found in org chart",
r.source_for("manager_id"),
schema=cls,
field="manager id",
item_id=r.user_id,
)
try:
OrgChart.parse(
[
["user id", "manager id"],
["U-1", ""],
["U-2", "U-999"],
]
)
except TableError as exc:
print(exc)
Manager U-999 not found in org chart
(code=table_error, schema=OrgChart, field='manager id',
row=3, column=2, item_id='U-2', value='U-999')
The validator should point to the referencing cell, not the missing target. In
this example, the authored manager id is the value the feature author can
change, so the diagnostic belongs there.
References can be checked before test setup
Whole-table validation lets you reject broken relationships before the test creates users, content, or other domain objects from the parsed records.
Choose Table Validation Deliberately⚓︎
Table validation is powerful because it can inspect everything. That also makes it easy to put too much logic there. Keep it for rules that genuinely need the collection.
Good table-validation rules include:
- no duplicate emails
- at least one primary user exists
- no more than one item is published in a scenario
- start and end rows form a complete set
- every record belongs to a configured domain
- totals across all rows balance
Rules that usually belong elsewhere include:
- one cell must parse as an integer
- one user's age must be at least 18
- one email must contain
@ - one status token must be recognized
- one field should default when omitted
Make the failing scope match the rule
If the rule is about one cell, use a parser. If it is about one record, use record validation. If it is about the collection, use table validation.