Files
maskanx_crm_backend/app/properties.py
T
AFFAANhandClaude Opus 5 9d6aa64ad6 feat(properties): add custom fields without a migration
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>
2026-08-04 11:07:50 +05:30

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