In the previous article we created a simple entity and performed CRUD operations. Now let's look at how entity mapping actually works under the hood, and how to handle more realistic scenarios like nullable fields and optimistic locking.
The mapping attributes
Every Trysil entity needs at least two class-level attributes:
[TTable('Contacts')] // which table
[TSequence('ContactsID')] // how the PK is generated
TTContact = class
And every mapped field needs a [TColumn] attribute:
[TColumn('Firstname')]
FFirstname: String;
Here is the full set of mapping attributes:
| Attribute | Level | Purpose |
|---|---|---|
TTable |
Class | Maps the class to a database table |
TSequence |
Class | Names the sequence for the PK |
TPrimaryKey |
Field | Marks the primary key field |
TColumn |
Field | Maps a field to a column |
TVersionColumn |
Field | Enables optimistic locking |
TRelation |
Class | Declares a parent-child relationship (covered in a later article) |
Core types
Trysil defines three foundational types in Trysil.Types:
TTPrimaryKey
[TPrimaryKey]
[TColumn('ID')]
FID: TTPrimaryKey;
TTPrimaryKey is an alias for Int32. Every entity must have exactly one primary key field of this type. The value is auto-generated by the database.
The ID property is read-only — you never set it manually:
property ID: TTPrimaryKey read FID;
TTVersion
[TColumn('VersionID')]
[TVersionColumn]
FVersionID: TTVersion;
TTVersion is also an Int32. When you mark a field with [TVersionColumn], Trysil includes it in the WHERE clause of UPDATE and DELETE statements:
UPDATE Contacts SET ... WHERE ID = :ID AND VersionID = :VersionID
If the version in the database does not match the version in your object, the update affects zero rows and Trysil raises an exception. This is optimistic locking — it prevents two users from silently overwriting each other's changes.
After a successful update, Trysil increments the version automatically.
TTNullable<T>
Database columns are often nullable, but Delphi value types (Integer, TDateTime, Double) cannot be null. Trysil solves this with a generic record:
[TColumn('BirthDate')]
FBirthDate: TTNullable<TDateTime>;
[TColumn('Score')]
FScore: TTNullable<Integer>;
TTNullable<T> has no default constructor. When you declare a field of this type and do not assign a value, it is null:
var
LDate: TTNullable<TDateTime>;
begin
// LDate is null here
if LDate.IsNull then
WriteLn('No date set');
// Assign a value
LDate := TTNullable<TDateTime>.Create(Now);
WriteLn(DateTimeToStr(LDate.Value));
end;
In the database, a null TTNullable<T> field is stored as SQL NULL. When reading, SQL NULL maps back to the null state.
A more realistic entity
Let's build a TTProduct entity that uses all of these types:
unit Product.Model;
{$WARN UNKNOWN_CUSTOM_ATTRIBUTE ERROR}
interface
uses
System.SysUtils,
Trysil.Types,
Trysil.Attributes,
Trysil.Validation.Attributes;
type
[TTable('Products')]
[TSequence('ProductsID')]
TTProduct = class
strict private
[TPrimaryKey]
[TColumn('ID')]
FID: TTPrimaryKey;
[TRequired]
[TMaxLength(100)]
[TColumn('Description')]
FDescription: String;
[TGreater(0.00)]
[TColumn('Price')]
FPrice: Double;
[TColumn('DiscountedPrice')]
FDiscountedPrice: TTNullable<Double>;
[TColumn('AvailableFrom')]
FAvailableFrom: TTNullable<TDateTime>;
[TColumn('VersionID')]
[TVersionColumn]
FVersionID: TTVersion;
public
property ID: TTPrimaryKey read FID;
property Description: String read FDescription write FDescription;
property Price: Double read FPrice write FPrice;
property DiscountedPrice: TTNullable<Double>
read FDiscountedPrice write FDiscountedPrice;
property AvailableFrom: TTNullable<TDateTime>
read FAvailableFrom write FAvailableFrom;
property VersionID: TTVersion read FVersionID;
end;
The matching SQL table:
CREATE TABLE Products (
ID INTEGER PRIMARY KEY,
Description TEXT NOT NULL,
Price REAL NOT NULL,
DiscountedPrice REAL,
AvailableFrom TEXT,
VersionID INTEGER NOT NULL DEFAULT 1
);
Note how DiscountedPrice and AvailableFrom are nullable in both the Delphi entity and the SQL schema.
Working with nullable fields
var
LProduct: TTProduct;
begin
LProduct := LContext.CreateEntity<TTProduct>();
LProduct.Description := 'Mechanical keyboard';
LProduct.Price := 149.99;
// DiscountedPrice and AvailableFrom are null — we simply don't set them
LContext.Insert<TTProduct>(LProduct);
// Later, set a discounted price
LProduct.DiscountedPrice := TTNullable<Double>.Create(119.99);
LContext.Update<TTProduct>(LProduct);
end;
How the mapping cache works
The first time you use an entity type with TTContext, Trysil reads its attributes via RTTI and builds a TTTableMap — an internal representation of the table name, columns, primary key, version column, and relationships.
This mapping is cached globally in TTMapper.Instance (a singleton). Subsequent operations on the same entity type skip the RTTI scan entirely. This means the reflection cost is paid only once per entity type for the lifetime of your application.
Compile-time safety
Always add this directive at the top of your model units:
{$WARN UNKNOWN_CUSTOM_ATTRIBUTE ERROR}
Without it, a typo like [TColum('Name')] compiles silently and the field is never mapped. With the directive, the compiler flags it as an error immediately.
What is next
We now know how to map classes to tables, handle nullable fields, and rely on optimistic locking for safe concurrent updates. In the next article we will explore validation — how Trysil checks your data before it hits the database.
Trysil is open-source and available on GitHub. Feedback and contributions are welcome!
Top comments (0)