Field Types
Fullfinity provides a rich set of field types for modeling your data.
Static Typing
Section titled “Static Typing”Fields are generic typing descriptors, so editors and pyright resolve record.<field> to its real Python type instead of Any — both inside the model’s own methods (self.name) and on references elsewhere:
class Product(Model): name = Char(max_length=255) # record.name → str price = Float() # record.price → float active = Boolean() # record.active → bool
async def label(self) -> str: return self.name.upper() # type-checked: self.name is strScalar fields map to their obvious types (Char/Text/Selection → str, Integer → int, Float/Monetary → float, Boolean → bool, Date → date, Datetime → datetime, Binary → bytes); JSON/File and relational fields (ManyToOne, OneToMany, …) resolve to Any (relations are awaited/lazy). This is typing-only — at runtime Model.__getattribute__ serves values from the record’s data, so the descriptors add no behavior. Class-level access (Product.name) still returns the Field instance for introspection.
Run the checker with the project’s pyrightconfig.json.
Common Field Options
Section titled “Common Field Options”All fields support these options:
| Option | Type | Default | Description |
|---|---|---|---|
required | bool | False | NOT NULL constraint |
default | any | None | Default value (can be callable) |
description | str | None | Human-readable label |
hint | str | None | Help text/tooltip |
readonly | bool | False | Field cannot be edited |
index | bool | False | Create database index |
unique | bool | False | Enforce uniqueness at database level |
clone | bool | True | Include field value when duplicating a record |
store | bool | True | Store in database |
calculate | str | None | Compute method name |
setter | str | None | Method to call when setting a calculated field value |
calculate_elevated | bool | False | Bypass access rights during calculation |
company_scoped | bool | False | Store value per-company in CompanyConfig |
groups | list[str] | [] | Gate read/write of this field by group at the API boundary (see below) |
validators | list | [] | List of validation functions (see Validators) |
prefix_field | str | None | Dynamic field path for prefix display (e.g., "currency__symbol") |
suffix_field | str | None | Dynamic field path for suffix display (e.g., "uom__name") |
Field-Level Group Gating (groups)
Section titled “Field-Level Group Gating (groups)”groups restricts who can read or write a single field to members of the listed
groups:
cost_price = Float(groups=["inventory_manager"]) # only managers see/edit it- Read — the serializer omits the field from API output for users not in any listed group. They never see the value (the column/data is untouched).
- Write — the create/update endpoints raise
AccessErrorif such a user tries to set the field. - Admin bypass — members of
core_adminalways have access. - Boundary-only — this is enforced at the API serializer and create/update endpoints, not in-process. Direct ORM access inside server code is intentionally ungated, so your own model logic can always read/write the field.
Text Fields
Section titled “Text Fields”Single-line text with max length:
name = Char(max_length=255, required=True)email = Char(max_length=200, index=True)sku = Char(max_length=50, description="Stock Keeping Unit")
# With validationphone = Char(max_length=20, min_length=10)code = Char(max_length=10, regex=r"^[A-Z]{2}-\d{4}$")| Property | Type | Description |
|---|---|---|
max_length | int | Maximum character length (required) |
min_length | int | Minimum character length |
regex | str | Regular expression pattern for validation |
Unlimited multi-line text:
description = Text()notes = Text(description="Internal Notes")Encrypted
Section titled “Encrypted”A credential field that is encrypted at rest. The value is plaintext in
Python but stored as Fernet ciphertext in the column (keyed off the configured
SECRET_KEY). Use it for API keys, OAuth tokens, and other secrets.
api_secret = Encrypted(description="API Secret")access_token = Encrypted(description="Access Token")- Reads/writes are transparent — assign and read the field as a normal string;
encryption/decryption happen in
to_db_value/from_db_value. - Backed by
VARCHAR(sized for ciphertext viamax_length, default 2048), so converting an existingCharsecret toEncryptedis a safe in-place column widening — no data migration needed. - Legacy un-encrypted values (stored before conversion) are detected by the
absence of the
enc:v1:prefix and returned unchanged on read, then encrypted on the next write. - Filtering by exact value won’t work (the column holds ciphertext);
isnotnulland similar presence checks do.
:::caution Key rotation makes existing values unreadable
The Fernet key is derived deterministically from SECRET_KEY. Rotating
SECRET_KEY makes all previously-stored ciphertext undecryptable. On a decrypt
failure the field does not raise — it returns the stored token unchanged (the
prefix-stripped ciphertext), so reads never crash and no data is lost, but the
plaintext is gone until you restore the original key. If SECRET_KEY is unset
entirely, reading/writing an Encrypted field raises ConfigurationError. Plan a
re-encryption step if you ever rotate the key.
:::
Numeric Fields
Section titled “Numeric Fields”Integer
Section titled “Integer”Whole numbers:
quantity = Integer(default=1)sequence = Integer(default=10)age = Integer(required=True, min_value=0, max_value=150)| Property | Type | Description |
|---|---|---|
min_value | int | Minimum allowed value |
max_value | int | Maximum allowed value |
Floating-point numbers with configurable precision:
# Static precision (2 decimal places)weight = Float(precision=2, default=0.0)percentage = Float(precision=4, min_value=0, max_value=100)
# Dynamic precision from field on same modelrate = Float(precision="decimal_places")
# Dynamic precision from related model (one level only)quantity = Float(precision="uom__rounding")| Property | Type | Description |
|---|---|---|
precision | int/str | Decimal places: static int (default: 2) or field path string |
min_value | float | Minimum allowed value |
max_value | float | Maximum allowed value |
:::info Default Value
Float fields default to 0.0 if no default is specified.
:::
Monetary
Section titled “Monetary”Currency values with precision from currency field:
# Precision from currency.rounding (recommended)price = Monetary(currency_field="currency")
# Without currency field - defaults to 2 decimal placesdiscount = Monetary(min_value=0, max_value=10000)| Property | Type | Description |
|---|---|---|
currency_field | str | Field name containing the currency (precision from currency.rounding) |
min_value | float | Minimum allowed value |
max_value | float | Maximum allowed value |
:::info Default Value
Monetary fields default to 0.0 if no default is specified.
:::
:::tip Rounding Utilities For precise float/monetary rounding in calculations, use the engine utilities:
from fullfinity.engine.utils import round_float, is_zero, compare
# Round to 2 decimal placesamount = round_float(100.125, digits=2) # 100.13
# Round using currency rounding factoramount = round_float(100.125, rounding=0.01) # 100.13
# Check if effectively zeroif is_zero(0.001, rounding=0.01): # True pass
# Compare floats safelyif compare(a, b, rounding=0.01) == 0: # Equal after rounding pass:::
:::tip Currency on Line Items For line items where currency is on the parent model, use a related field:
class OrderLine(Model): order = ManyToOne("Order") currency = ManyToOne("Currency", related_field="order__currency", store=False) unit_price = Monetary(currency_field="currency"):::
Precision Field Paths (Float only)
Section titled “Precision Field Paths (Float only)”Float fields support dynamic precision using field path strings:
| Syntax | Description | Example |
|---|---|---|
precision=4 | Static integer | Fixed 4 decimal places |
precision="rounding" | Same model field | Uses self.rounding |
precision="uom__rounding" | Related model field | Uses self.uom.rounding |
:::warning No Deep Nesting
Deep paths like company__currency__rounding are not allowed. Only one level of relation traversal is supported.
:::
Prefix and Suffix Fields
Section titled “Prefix and Suffix Fields”Any field can display a dynamic prefix or suffix by defining field paths on the model:
# Display UOM name as suffix (e.g., "10 kg")qty_on_hand = Float( precision=3, suffix_field="uom__name")
# Display currency symbol as prefix (e.g., "$ 100")amount = Float( prefix_field="currency__symbol")| Property | Type | Description |
|---|---|---|
prefix_field | str | Field path for dynamic prefix (e.g., "uom__symbol", "currency__symbol") |
suffix_field | str | Field path for dynamic suffix (e.g., "uom__name") |
The referenced field is automatically extracted and serialized with the record. The frontend displays the value from the resolved field path.
:::tip Model vs View
- Model: Use
prefix_field/suffix_fieldfor dynamic values from related fields - View: Use
prefix/suffixfor static strings only (e.g.,"%","kg")
Model’s dynamic fields take priority over view’s static values. :::
:::info Monetary Fields
For currency values, use Monetary with currency_field instead. It automatically handles symbol positioning (before/after) based on currency settings.
:::
Boolean
Section titled “Boolean”True/False values:
active = Boolean(default=True)is_published = Boolean(default=False)Date and Time
Section titled “Date and Time”Date without time:
birth_date = Date()due_date = Date(description="Due Date")Datetime
Section titled “Datetime”Date with time (stored in UTC):
created_at = Datetime(default=lambda: datetime.now())scheduled_at = Datetime()Selection
Section titled “Selection”Predefined choices:
status = Selection( choices=["Draft", "Confirmed", "Done", "Cancelled"], default="Draft", max_length=200)
priority = Selection( choices=["Low", "Medium", "High", "Urgent"], default="Medium")
type = Selection( choices=["Individual", "Company"], required=True)| Property | Type | Description |
|---|---|---|
choices | list | List of allowed string values (required) |
max_length | int | Maximum length for values (default: 200) |
:::tip Extending Choices
Extensions can add or remove Selection choices via _selection_add and _selection_remove. See Selection Field Inheritance.
:::
Structured Data
Section titled “Structured Data”Store JSON/dict data:
metadata = JSON(default=dict)settings = JSON(description="User Settings")extra_data = JSON()Usage:
record.metadata = {"key": "value", "nested": {"a": 1}}await record.save()Binary Data
Section titled “Binary Data”Binary
Section titled “Binary”Raw binary data:
data = Binary(description="Binary Data")File attachments (stored as attachment ID):
logo = File(description="Company Logo")document = File(description="Attached Document")public_brochure = File(public=True, description="Brochure") # publicly accessibleUsage:
# Upload: base64 string with format: "filename;content_type;base64,data"record.logo = "logo.png;image/png;base64,iVBORw0KGgo..."await record.save()| Option | Type | Default | Description |
|---|---|---|---|
public | bool | False | When True, the underlying attachment is created with is_public=True, so it is reachable without an authenticated session (e.g. logos, website assets). Leave False for anything access-controlled. |
track | bool | False | Record changes to this file in the change log. |
Relational Fields
Section titled “Relational Fields”ManyToOne
Section titled “ManyToOne”Foreign key (many records point to one):
customer = ManyToOne( "Contact", # Related model name related_name="orders", # Reverse relation name on_delete="CASCADE", # CASCADE, SET NULL, RESTRICT required=True)
category = ManyToOne( "Category", related_name="products", on_delete="SET NULL")OneToMany
Section titled “OneToMany”Reverse of ManyToOne (automatically created):
# Defined on the "one" side, points to "many"orders = OneToMany("Order", related_name="customer")Usually you don’t define this explicitly - it’s created from related_name.
OneToOne
Section titled “OneToOne”One-to-one relationship (foreign key with UNIQUE constraint):
profile = OneToOne( "UserProfile", related_name="user", # Reverse returns single object, not list on_delete="CASCADE")
settings = OneToOne( "UserSettings", related_name="user", on_delete="SET NULL")Similar to ManyToOne but:
- Has a UNIQUE constraint on the foreign key column
- The reverse accessor returns a single object instead of a list
ManyToMany
Section titled “ManyToMany”Many-to-many relationship:
tags = ManyToMany( "Tag", related_name="products", through="fkproducttag" # Through table name)
groups = ManyToMany( "Group", related_name="users", through="fkusergroup"):::note Relational defaults
ManyToMany and OneToMany also support default=, but the value must be
written in command notation ([["L", id]], [["C", {vals}]]), not a bare
list of instances. See Relational field defaults.
:::
Computed Fields
Section titled “Computed Fields”Fields calculated from other fields:
total = Monetary( calculate="_compute_total", store=True # Store result in database)
display_name = Char( max_length=255, calculate="get_display_name", store=False # Calculate on-the-fly)See Computed Fields for details.
Related Fields
Section titled “Related Fields”Auto-compute from related model:
company_name = Char( max_length=255, related_field="company__name", store=False)
country_code = Char( max_length=10, related_field="address__country__code", store=False)Field Examples by Use Case
Section titled “Field Examples by Use Case”Contact Model
Section titled “Contact Model”class Contact(Model): # Identity name = Char(max_length=255, required=True) email = Char(max_length=255, index=True) phone = Char(max_length=50)
# Type type = Selection(choices=["Individual", "Company"], default="Individual") is_customer = Boolean(default=False) is_supplier = Boolean(default=False)
# Relations company = ManyToOne("Contact", related_name="contacts") tags = ManyToMany("ContactTag", related_name="contacts", through="fkcontacttag")
# Media image = File(description="Photo")
# Timestamps created_at = Datetime(default=lambda: datetime.now())Invoice Model
Section titled “Invoice Model”class Invoice(Model): # Reference name = Char(max_length=100, required=True) reference = Char(max_length=100)
# Dates invoice_date = Date(default=lambda: datetime.now().date()) due_date = Date()
# Relations customer = ManyToOne("Contact", required=True) currency = ManyToOne("Currency")
# Status status = Selection( choices=["Draft", "Sent", "Paid", "Cancelled"], default="Draft" )
# Financials subtotal = Monetary(calculate="_compute_totals", store=True) tax_total = Monetary(calculate="_compute_totals", store=True) total = Monetary(calculate="_compute_totals", store=True)
# Notes notes = Text() internal_notes = Text(description="Internal Notes")Company-Scoped Fields
Section titled “Company-Scoped Fields”Fields with company_scoped=True store their values in the CompanyConfig table instead of the model’s own table. This enables per-company configuration for the same record.
class ProductCategory(Model): name = Char(max_length=255)
# Different income account per company income_account = ManyToOne( "Account", description="Income Account", company_scoped=True # Stored in CompanyConfig )
# Different expense account per company expense_account = ManyToOne( "Account", description="Expense Account", company_scoped=True )Key Behaviors
Section titled “Key Behaviors”- Storage: Values are stored in
CompanyConfigwith key format:Model.record_id.field_name - Transient Models: For transient models (wizards/configuration), key is just
field_name - Hydration: ManyToOne/ManyToMany fields return fully hydrated records, not just IDs
- Orphan Cleanup: Orphaned references are automatically cleaned up on load
- Migrations: Fields are excluded from database migrations (
store=Falseimplied)
Supported Field Types
Section titled “Supported Field Types”- Scalar types (Char, Text, Boolean, Integer, Float, etc.)
- ManyToOne (stores single ID)
- ManyToMany (stores list of IDs)
Limitations
Section titled “Limitations”- Cannot filter on company_scoped fields:
filter(income_account=x)won’t work - Cannot sort on company_scoped fields
- No database-level uniqueness constraints
Example: Configuration Model
Section titled “Example: Configuration Model”class ConfigurationCRM(Model): __inherit__ = "Configuration"
crm_default_stage = ManyToOne( "CrmStage", description="Default Lead Stage", company_scoped=True )
crm_auto_assign = Boolean( default=False, description="Auto-assign Leads", company_scoped=True )Validators
Section titled “Validators”Fields support custom validators for advanced validation logic. Pass a list of validators to the validators parameter.
Built-in Validators
Section titled “Built-in Validators”from fullfinity.engine.fields import ( EmailValidator, URLValidator, RegexValidator, MinValueValidator, MaxValueValidator, MinLengthValidator, MaxLengthValidator,)
class Contact(Model): email = Char( max_length=255, validators=[EmailValidator("Please enter a valid email")] )
website = Char( max_length=255, validators=[URLValidator("Please enter a valid URL")] )
code = Char( max_length=20, validators=[ RegexValidator(r"^[A-Z]{3}-\d{4}$", "Code must be XXX-0000 format") ] )
age = Integer( validators=[ MinValueValidator(0, "Age cannot be negative"), MaxValueValidator(150, "Age cannot exceed 150") ] )
username = Char( max_length=50, validators=[ MinLengthValidator(3, "Username must be at least 3 characters"), MaxLengthValidator(50, "Username cannot exceed 50 characters") ] )Available Validators
Section titled “Available Validators”| Validator | Description |
|---|---|
EmailValidator(message) | Validates email format |
URLValidator(message) | Validates URL format (http/https) |
RegexValidator(pattern, message) | Validates against regex pattern |
MinValueValidator(min, message) | Validates minimum numeric value |
MaxValueValidator(max, message) | Validates maximum numeric value |
MinLengthValidator(min, message) | Validates minimum string length |
MaxLengthValidator(max, message) | Validates maximum string length |
Custom Validators
Section titled “Custom Validators”Create custom validators by subclassing Validator or using a callable:
from fullfinity.engine.fields import Validator, ValidationError
# Class-based validatorclass PhoneValidator(Validator): def __init__(self, message="Invalid phone number"): self.message = message
def __call__(self, value): if value and not value.replace("-", "").replace(" ", "").isdigit(): raise ValidationError(self.message)
# Function-based validatordef validate_positive(value): if value is not None and value < 0: raise ValidationError("Value must be positive")
class Order(Model): phone = Char(max_length=20, validators=[PhoneValidator()]) quantity = Integer(validators=[validate_positive])Next Steps
Section titled “Next Steps”- Relationships - Working with related data
- Computed Fields - Automatic calculations
- Querying Data - Fetch and filter records