JSON Deserialization¶
Deserialize JSON strings and objects into Trysil entity instances and lists.
The body sets more than you may expect
Deserialization reads into the entity every mapped column it finds in the
JSON, including the primary key, the [TVersionColumn], every
foreign key behind a TTLazy<T> and every [TDetailColumn] collection.
It is deliberate: Update<T> composes its WHERE from the key and the
version the entity holds, and on a fresh entity the body is the only place
they can come from.
So a body can set the version and any foreign key, a tenant column
included. Trysil does not decide which of them a body may set: mark the
fields it must never set with [TJSonIgnoreDeserialize], or deserialize
into a fresh instance and copy across only the fields you accept.
Change tracking columns are the exception: no entry point reads them from JSON. Which fields to mark, and which two to leave alone, is below.
JSON String to Entity¶
var LPerson := LContext.EntityFromJSon<TPerson>(LJsonString);
try
// LPerson is a new entity populated from JSON
LContext.Insert<TPerson>(LPerson);
finally
LContext.FreeEntity<TPerson>(LPerson);
end;
The returned entity is a newly created instance, and the caller owns it: a JSON context refuses the identity map, so nothing else will free it. FreeEntity<T> is the way to free what a context read or created, whether or not a map is in play; a clone from CloneEntity or OldEntity is freed with FreeClone<T>, and an entity handed to a session's Insert is the session's. See who frees what.
TJSonValue to Entity¶
When you already have a parsed JSON value (e.g., from an HTTP request body):
var LPerson := LContext.EntityFromJSonObject<TPerson>(LJsonValue);
try
// Work with the entity
finally
LContext.FreeEntity<TPerson>(LPerson);
end;
Onto an Already Loaded Entity¶
Both entry points have an overload that fills an entity you already own instead of creating a fresh one. This is the shape to use for an update endpoint:
var LEntity := LContext.Get<TPerson>(AID);
try
LContext.EntityFromJSonObject<TPerson>(LJsonValue, LEntity);
LContext.Update<TPerson>(LEntity);
finally
LContext.FreeEntity<TPerson>(LEntity);
end;
Why it matters: with a fresh entity every column absent from the body is blank,
and Update<T> writes the whole row, so an absent column is written back
blank. Filling a loaded entity keeps the stored values for what the body does
not mention.
Three things to know:
- The identity map is forbidden in a
TTJSonContext, soGet<T>returns an entity nobody owns: thetry..finallyis not optional. - The semantics are replace-all, not merge. A
Stringcolumn absent from the body keeps its loaded value, but aTTNullablecolumn absent from the body is set to NULL. - The
idin the body still wins over the entity you loaded. Compare it with the one resolved from the route if that matters to you.
Detail collections already loaded are destroyed
If the entity carries a TTLazyList<T> detail and you deserialize onto it
a body that carries the collection, the list is cleared before being
refilled from the body, and in a
TTJSonContext the list owns its items: every detail entity read from the
database is freed. Any pointer you took from LEntity.Details.List
before the call is dangling once the list has been cleared, even if the
call then raises.
The same holds for a TTLazy<T> whose master you had already read: if the
body carries a different id, the entity in the cache is handed to
FreeEntity<T> and the relation reloads on the next read. It happens
column by column, so it happens even when a later column raises.
Take the references you need after deserializing, not before.
Change tracking never comes from JSON
[TCreatedAt], [TCreatedBy], [TUpdatedAt], [TUpdatedBy],
[TDeletedAt] and [TDeletedBy] are skipped by every deserialization
entry point, on both overloads. A value for them in a request body is
ignored. See Change Tracking.
Restricting What the Body May Write¶
[TJSonIgnoreDeserialize] is read on every mapped member, a TTLazy<T>
foreign key and a [TDetailColumn] collection included, so the entity is
where a field the client must never write gets closed:
[TTable('Orders')]
[TRelation('OrderDetails', 'OrderID', True)]
TOrder = class
strict private
[TColumn('ID')]
[TPrimaryKey]
FID: TTPrimaryKey;
[TColumn('TenantID')]
[TJSonIgnoreDeserialize]
FTenant: TTLazy<TTenant>;
[TDetailColumn('ID', 'OrderID')]
[TJSonIgnoreDeserialize]
FDetail: TTLazyList<TOrderDetail>;
[TColumn('VersionID')]
[TVersionColumn]
FVersionID: TTVersion;
end;
The tenant is still serialized, and never read from the body. Without the
attribute a body carrying {"tenantId": 2} sets the foreign key to 2 in the
entity, and Update<T> writes it like any other column: neither the
[TWhereClause] nor the filter that scopes the reads applies to a write, and
the deserializer cannot know which of your columns decides who may see the
row.
On a detail collection the attribute keeps the rows in the body out of the entity: without it, a body that carries the collection replaces it in memory.
Do not mark the primary key or the version column
The key says which row Update<T> writes. On a fresh entity the id
in the body is the only key it carries. Ignore it and the key stays 0:
Update<T> affects no row, unless a row carries key 0, and raises
ETConcurrentUpdateException.
The version is what optimistic locking compares. On the overload that
fills a loaded entity, ignoring it leaves the version Get<T> has just
read, so the WHERE of Update<T> always matches and a stale version is
never detected. On a fresh entity the version stays 0: Update<T>
overwrites a row never updated since its insert, and raises
ETConcurrentUpdateException on any other.
With both read from the body, a body can carry a stale version, which
Update<T> refuses with ETConcurrentUpdateException, or the key of a row
the caller may not touch, which the deserializer does not check.
JSON String to List¶
var LPersons := LContext.CreateEntityList<TPerson>();
try
LContext.ListFromJSon<TPerson>(LJsonString, LPersons);
// LPersons now contains all deserialized entities
for var LPerson in LPersons do
WriteLn(LPerson.Firstname);
finally
LPersons.Free;
end;
The list must be created before calling ListFromJSon. Deserialized entities are added to the existing list.
TJSonArray to List¶
var LPersons := LContext.CreateEntityList<TPerson>();
try
LContext.ListFromJSonArray<TPerson>(LJsonArray, LPersons);
finally
LPersons.Free;
end;
Typical REST Workflow¶
A common pattern in HTTP controllers is deserializing a request body, performing an operation, and returning the result:
procedure TPersonController.Insert;
var
LPerson: TPerson;
LConfig: TTJSonSerializerConfig;
begin
LConfig := TTJSonSerializerConfig.Create(-1, False);
LPerson := FContext.Context.EntityFromJSon<TPerson>(FRequest.Content);
try
FContext.Context.Insert<TPerson>(LPerson);
FResponse.Content := FContext.Context.EntityToJSon<TPerson>(LPerson, LConfig);
finally
FContext.Context.FreeEntity<TPerson>(LPerson);
end;
end;
A Deserializer of Your Own¶
A member of a type Trysil does not know needs a deserializer, registered once at startup next to the other Register* calls - and, to go out, a serializer registered on TTJSonSerializers the same way. A type Trysil already handles cannot be registered a second time.
type
TVatCode = type String;
TVatCodeDeserializer = class(TTJSonAbstractDeserializer)
public
function FromJSon(const AJSon: TJSonValue): TTValue; override;
end;
function TVatCodeDeserializer.FromJSon(const AJSon: TJSonValue): TTValue;
var
LValue: String;
begin
LValue := AJSon.Value;
if LValue.Length <> 11 then
raise ETJSonException.Create('A VAT code has eleven digits.');
result := TTValue.From<TVatCode>(TVatCode(LValue));
end;
// at startup
TTJSonDeserializers.Instance.Register<TVatCode>(TVatCodeDeserializer);
The instance is one per type, shared by every request thread: keep no state in it between calls.
What FromJSon raises decides whose fault the failure is:
- The value is wrong: raise
ETJSonException, which the listener answers400with your message. A conversion of the RTL that fails -EJSONException,EConvertError,EDateTimeException,EArgumentException,ERangeError,EIntOverflow,EMathError,EVariantError- is turned into anETJSonExceptionnaming the field, and answers400too. - Something is wrong on the server - a lookup table that is not loaded, a setting that is missing: raise
ETJSonServerException, which answers500and goes to the error log.
A defect in your code can answer 400
The eight classes above become a 400 whatever raised them, a bug in
your deserializer included: an index out of range, or a StrToInt on a
setting, tells the client that its value is not valid. The client cannot
tell the difference, but the log can: the line of that 400 carries the
class of the exception behind it - the class, not its message, which
quotes the value the client sent. The message is still on the exception
of the 400, in InnerException, in NestedExceptionMessage and in
ToString: a host that logs any of them writes the value of the client. The message of the 400 itself is
written as it is: for JSON that does not parse, handed over as a string,
it is the parser's message, which names the path of the keys the client
sent. When a failure is
yours, raise ETJSonServerException yourself.