Modelled on HubSpot's custom properties: the definition lives in a table, the values live in each record's existing JSON attributes. Adding a field is an INSERT, so it can happen mid-conversation through MCP and the next record can use it immediately. The cost of that is that the database enforces nothing about the values, so data_type is enforced in application code and every write path has to come through it — otherwise the type on a definition is decoration. Values are coerced rather than merely checked, because callers are agents and HTTP clients and "42" is a number expressed loosely; what is rejected is genuinely ambiguous, like "quite large" for a number. A value whose property has no definition is rejected rather than stored. A typo sitting in the database looks exactly like data, and a registry whose set of fields is not actually the set of fields is worse than none. Workspaces with nothing defined keep the old free-form behaviour, so this does not break attributes already in use. name and data_type are not updatable: renaming orphans every value stored under the old key, and retyping leaves values that no longer satisfy the type. Deleting a definition leaves existing values alone rather than rewriting every record, so undoing a mistaken delete is just recreating the property. Two ways in, sharing one validator: a logged-in person, and an integration key for MaskanX. An agent gets no shortcut around the rules a person is held to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
174 lines
6.0 KiB
Python
174 lines
6.0 KiB
Python
"""Validation for custom property definitions and their values.
|
|
|
|
Custom property values live in a JSON column, so the database enforces
|
|
nothing about them. Everything that writes one has to come through here, or
|
|
the `data_type` on the definition is decoration.
|
|
|
|
Pure functions: no session, no I/O, so the rules can be tested directly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date, datetime
|
|
from typing import Any
|
|
|
|
# A property name becomes a key inside a record's `attributes` JSON and is
|
|
# referenced by agents and API callers, so it is restricted to something
|
|
# that survives being a JSON key, a query parameter and a column header.
|
|
NAME_PATTERN = r"^[a-z][a-z0-9_]{1,63}$"
|
|
|
|
DATA_TYPES = ("string", "number", "boolean", "date", "enumeration")
|
|
|
|
OBJECT_TYPES = ("contact", "lead", "organization", "campaign")
|
|
|
|
|
|
class PropertyError(ValueError):
|
|
"""Raised when a definition or a value is not usable."""
|
|
|
|
|
|
def validate_definition(
|
|
*,
|
|
name: str,
|
|
object_type: str,
|
|
data_type: str,
|
|
options: list[str] | None,
|
|
) -> None:
|
|
"""Check a property definition before it is stored.
|
|
|
|
Rejects an enumeration with no options: a field whose only valid values
|
|
are none of them cannot ever be filled in, and an agent creating one by
|
|
mistake would produce a form nobody can submit.
|
|
"""
|
|
import re
|
|
|
|
if object_type not in OBJECT_TYPES:
|
|
raise PropertyError(
|
|
f"Unknown object type {object_type!r}. "
|
|
f"Available: {', '.join(OBJECT_TYPES)}.",
|
|
)
|
|
if data_type not in DATA_TYPES:
|
|
raise PropertyError(
|
|
f"Unknown data type {data_type!r}. Available: {', '.join(DATA_TYPES)}.",
|
|
)
|
|
if not re.match(NAME_PATTERN, name or ""):
|
|
raise PropertyError(
|
|
f"{name!r} is not a usable property name. Use lowercase letters, "
|
|
f"digits and underscores, starting with a letter, e.g. "
|
|
f"'budget_range'.",
|
|
)
|
|
if data_type == "enumeration" and not options:
|
|
raise PropertyError(
|
|
"An enumeration needs at least one option; otherwise no value "
|
|
"could ever be valid for it.",
|
|
)
|
|
if data_type != "enumeration" and options:
|
|
raise PropertyError(
|
|
f"Options only apply to an enumeration, not to a {data_type}.",
|
|
)
|
|
|
|
|
|
def coerce_value(definition: Any, value: Any) -> Any:
|
|
"""Return `value` in the shape its definition calls for.
|
|
|
|
Coerces rather than merely checking, because callers are agents and
|
|
HTTP clients: a number arriving as the string "42" is a well-formed
|
|
intention expressed loosely, and rejecting it would be pedantry. What
|
|
is rejected is anything genuinely ambiguous — "quite large" is not a
|
|
number by any reading.
|
|
|
|
`None` clears the value and is always allowed here; whether a property
|
|
may be empty is `is_required`'s business, checked separately, because
|
|
the two questions have different answers on a partial update.
|
|
"""
|
|
if value is None:
|
|
return None
|
|
|
|
data_type = definition.data_type
|
|
|
|
if data_type == "string":
|
|
return str(value)
|
|
|
|
if data_type == "number":
|
|
try:
|
|
number = float(value)
|
|
except (TypeError, ValueError) as exc:
|
|
raise PropertyError(
|
|
f"{definition.label!r} expects a number, not {value!r}.",
|
|
) from exc
|
|
# Keep whole numbers whole: 3.0 stored for a count reads oddly and
|
|
# round-trips into JSON as 3.0 forever.
|
|
return int(number) if number.is_integer() else number
|
|
|
|
if data_type == "boolean":
|
|
if isinstance(value, bool):
|
|
return value
|
|
text = str(value).strip().lower()
|
|
if text in ("true", "yes", "1"):
|
|
return True
|
|
if text in ("false", "no", "0"):
|
|
return False
|
|
raise PropertyError(
|
|
f"{definition.label!r} expects true or false, not {value!r}.",
|
|
)
|
|
|
|
if data_type == "date":
|
|
if isinstance(value, (date, datetime)):
|
|
return value.isoformat()
|
|
try:
|
|
return date.fromisoformat(str(value)[:10]).isoformat()
|
|
except ValueError as exc:
|
|
raise PropertyError(
|
|
f"{definition.label!r} expects a date as YYYY-MM-DD, "
|
|
f"not {value!r}.",
|
|
) from exc
|
|
|
|
if data_type == "enumeration":
|
|
text = str(value)
|
|
if text not in (definition.options or []):
|
|
raise PropertyError(
|
|
f"{text!r} is not one of the allowed values for "
|
|
f"{definition.label!r}: "
|
|
f"{', '.join(definition.options or []) or 'none'}.",
|
|
)
|
|
return text
|
|
|
|
raise PropertyError(f"Unknown data type {data_type!r}.")
|
|
|
|
|
|
def apply_values(
|
|
definitions: list[Any],
|
|
current: dict[str, Any],
|
|
incoming: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""Merge validated custom property values into a record's attributes.
|
|
|
|
Only keys that have a definition are touched. An unknown key is
|
|
rejected rather than stored: silently accepting one would let a typo
|
|
("budjet_range") sit in the database looking like data, and the whole
|
|
reason for a registry is that the set of fields is knowable.
|
|
|
|
Required properties are enforced only against what is being written,
|
|
not against the merged result — a partial update that does not mention
|
|
a required field is not an attempt to clear it.
|
|
"""
|
|
by_name = {definition.name: definition for definition in definitions}
|
|
|
|
unknown = sorted(set(incoming) - set(by_name))
|
|
if unknown:
|
|
known = ", ".join(sorted(by_name)) or "none"
|
|
raise PropertyError(
|
|
f"No such property: {', '.join(unknown)}. Defined here: {known}.",
|
|
)
|
|
|
|
merged = dict(current)
|
|
for name, raw in incoming.items():
|
|
definition = by_name[name]
|
|
coerced = coerce_value(definition, raw)
|
|
if coerced is None and definition.is_required:
|
|
raise PropertyError(f"{definition.label!r} is required and cannot be empty.")
|
|
if coerced is None:
|
|
merged.pop(name, None)
|
|
else:
|
|
merged[name] = coerced
|
|
return merged
|