Record Validation⚓︎
Use validate_record() for rules that need one complete parsed record.
Field parsers answer "how should this cell become a Python value?" Record
validation answers "is this parsed record acceptable?" That difference matters.
A parser should not need to know every other field in the row. A record
validator can read self.age, self.role, self.email, defaults, references,
and parse-time context together.
Given the account users exist
| username | age | role |
| alice | 34 | admin |
| bob | 21 | viewer |
Use record validation after parsing
If a rule needs two or more fields from the same record, put it in
validate_record(). If the rule only converts one cell, keep it as a
parser.
Add a Record Validator⚓︎
Define validate_record(self, context) on the schema class. Talika calls it
after the record has been parsed and populated.
from talika import RowTable, field, integer
class AccountRows(RowTable):
username = field("username", required=True)
age = field("age", parser=integer())
role = field("role", default="viewer")
def validate_record(self, context):
if self.age < 18:
raise ValueError(f"{self.username} must be at least 18")
if self.role not in context.user_data["allowed_roles"]:
raise ValueError(f"role {self.role!r} is not allowed")
This validator checks two record-level rules:
- a user must be at least 18
- a user role must be part of the allowed role set passed in parse context
users = AccountRows.parse(
[
["username", "age", "role"],
["alice", "34", "admin"],
["bob", "21", "viewer"],
],
context={"allowed_roles": {"admin", "editor", "viewer"}},
)
assert users[0].age == 34
assert users[0].role == "admin"
assert users[1].role == "viewer"
>> [user.as_dict() for user in users]
[
{'username': 'alice', 'age': 34, 'role': 'admin'},
{'username': 'bob', 'age': 21, 'role': 'viewer'},
]
The values inside validate_record() are already parsed. age is an integer
because its field parser has run, and role is present even when it came from
a default.
Validation returns None
A validator should return None when the record is valid. Raise an
exception when the record is invalid.
Validation Runs After Parsers and Defaults⚓︎
Record validation happens after field parsing and default handling. That lets a validator work with the same values your test code will see.
class ValidationOrder(RowTable):
score = field("score", parser=integer())
enabled = field("enabled", default=True)
def validate_record(self, context):
if self.enabled and self.score < context.user_data["minimum"]:
raise ValueError(f"score must be at least {context.user_data['minimum']}")
The table below omits enabled, so the default is applied before validation:
record = ValidationOrder.parse(
[
["score"],
["12"],
],
context={"minimum": 10},
)[0]
assert record.score == 12
assert record.enabled is True
If the parsed score violates the context policy, the record validator raises:
ValidationOrder.parse(
[
["score"],
["7"],
],
context={"minimum": 10},
)
Record validation failed: score must be at least 10
(code=record_validation_failed, schema=ValidationOrder, row=2)
Do not duplicate parser checks
Avoid re-parsing strings in validate_record(). If a field should be an
integer, parse it as an integer on the field and let validation inspect the
integer value.
Use Parse Context for Runtime Policy⚓︎
The context argument is a ParseContext. It carries the read-only
user_data mapping supplied to parse(..., context={...}).
That is useful when the rule changes by test environment, fixture, tenant, scenario, or project configuration. In the user example, allowed roles are runtime policy. The schema owns the rule shape, while the parse call supplies the allowed set.
AccountRows.parse(
[
["username", "age", "role"],
["maya", "25", "owner"],
],
context={"allowed_roles": {"admin", "editor", "viewer"}},
)
Record validation failed: role 'owner' is not allowed
(code=record_validation_failed, schema=AccountRows, row=2)
The error includes the record source row. It does not include a column because
ordinary ValueError from validate_record() describes the whole record, not
one specific field cell.
Pass policy, not mutable state
Prefer small context values such as allowed sets, limits, feature flags, or service handles. Keep the validator deterministic for the current parse call.
Raise Ordinary Exceptions for Record-Level Errors⚓︎
For rules that describe the whole record, raise a normal exception with a clear message.
AccountRows.parse(
[
["username", "age", "role"],
["kai", "16", "viewer"],
],
context={"allowed_roles": {"admin", "editor", "viewer"}},
)
Record validation failed: kai must be at least 18
(code=record_validation_failed, schema=AccountRows, row=2)
Talika wraps the exception as record_validation_failed and attaches the
record location. For row tables, that usually means the source row. For column
tables, that usually means the item column and item ID.
This is enough for many validation errors. A message such as kai must be at
least 18 clearly describes the record-level problem, and the diagnostic points
to the row that needs attention.
The original exception is preserved
Talika keeps the original exception as the cause. The user-facing diagnostic gets table context, while debugging can still inspect the underlying exception.
Point at One Field Cell⚓︎
Sometimes a record rule should point at a specific authored cell. For example,
an invalid email is a record rule because the check happens after parsing, but
the fix belongs to the email cell.
Use self.source_for("field_name") with TableError.from_cell(...):
from talika import TableError
class SourceAwareAccounts(RowTable):
username = field("username", required=True)
age = field("age", parser=integer())
email = field("email", required=True)
def validate_record(self, context):
if "@" not in self.email:
raise TableError.from_cell(
"Email must contain @",
self.source_for("email"),
schema=type(self),
field="email",
hint="Use a complete email address in the table.",
)
SourceAwareAccounts.parse(
[
["username", "age", "email"],
["alice", "34", "not-an-email"],
]
)
Email must contain @
(code=table_error, schema=SourceAwareAccounts, field='email',
row=2, column=3, value='not-an-email').
Hint: Use a complete email address in the table.
This diagnostic points to row 2, column 3, the exact cell that supplied
email. It also carries the original value and a hint for the table author.
source_for needs a source cell
source_for("email") only works when email came from the table. If the
value came from a default for a missing field, there is no authored cell to
point at.
Defaults May Not Have Source Cells⚓︎
Defaults are schema-owned values. When a field is missing and Talika supplies a default, there is no table cell behind that value.
class DefaultEmail(RowTable):
name = field("name")
email = field("email", default="unknown")
def validate_record(self, context):
if self.email == "unknown":
self.source_for("email")
DefaultEmail.parse(
[
["name"],
["Alice"],
]
)
Record validation failed: "No source cell is available for field 'email'"
(code=record_validation_failed, schema=DefaultEmail, row=2)
In real validators, check whether the problem belongs to a source cell before
calling source_for(...). If the problem belongs to a defaulted value, raise a
record-level error instead.
Use the most helpful location
If one table cell caused the problem, use TableError.from_cell(...). If
the whole record is invalid, raise a normal exception and let Talika report
the record location.
Column Records⚓︎
Record validation works the same way for ColumnTable schemas. The record is
one item column, and diagnostics identify the item column rather than a row.
Given the content exists
| IDs | A-1 | P-1 |
| Type | Article | Poll |
| Headline | Market brief | Choose one |
from talika import ColumnTable, id_field
class ContentColumns(ColumnTable):
id = id_field("IDs")
content_type = field("Type")
headline = field("Headline")
def validate_record(self, context):
if self.content_type == "Poll" and not self.headline.endswith("?"):
raise ValueError("Poll headline must end with a question mark")
Here the rule is local to one content item: if the item is a poll, its headline should be phrased as a question.
ContentColumns.parse(
[
["IDs", "A-1", "P-1"],
["Type", "Article", "Poll"],
["Headline", "Market brief", "Choose one"],
]
)
Record validation failed: Poll headline must end with a question mark
(code=record_validation_failed, schema=ContentColumns, column=3, item_id='P-1')
The diagnostic includes item_id='P-1' and column=3, because the invalid
record is the third item column in the authored table.
Conditional Fields Validation⚓︎
Record validation is a good place for conditional field rules. These are rules where one parsed value changes what another field means.
For example, a content item may allow an empty publication date while it is a
draft. Once the status becomes Published, the same empty cell is no longer
valid. A field parser cannot make that decision by looking at the publication
date alone; the rule needs the parsed status too.
class ArticlePublishSchema(RowTable):
status = field("status", required=True)
pub_date = field("publication date", default="")
def validate_record(self, context):
if self.status.lower() == "published" and not self.pub_date:
raise TableError.from_cell(
"Publication date is required when status is Published",
self.source_for("pub_date"),
schema=self.__class__,
field="publication date",
)
# This raises TableError because pub_date is blank but status is published
try:
ArticlePublishSchema.parse(
[
["status", "publication date"],
["published", ""],
]
)
except TableError as exc:
# Diagnostic output shown below
print(exc)
Publication date is required when status is Published
(code=table_error, schema=ArticlePublishSchema, field='publication date',
row=2, column=2, value='')
The validator points to the empty publication date cell because that is the cell the author should fix. The rule is about the relationship between two fields, but the diagnostic should still land on the most actionable source cell.
Point to the field that needs action
Conditional validation often reads two or three fields. When it fails, choose the source cell that the feature author should edit first.
Keep Record Validation Local⚓︎
Record validation should only inspect self and parse context. If a rule needs
to compare multiple records, such as duplicate emails or "at least one primary
user", it belongs in whole-table validation.
Good record-validation rules include:
- age must be at least 18 for this user
- a poll headline must end with a question mark
- an email cell must contain
@ - a role must be allowed for this parse context
- a start date must be before an end date on the same record
Rules that usually do not belong in validate_record() include:
- no two records may share the same email
- at least one record must be primary
- every child record must refer to a parent record in another row
- totals across all records must balance
Validate at the smallest useful scope
Put single-cell syntax in parsers, one-record rules in
validate_record(), and cross-record rules in table validation. That keeps
failures easier to explain and easier to locate.