3. Employee schema#
What this step does. Adds the second entity — an Employee with a foreign key to Department — and shows off three Palmyra idioms the first entity didn’t need: nested model fields, flattened columns via
parentRef, and thepreSavelifecycle hook.
Employees carry contact info, a foreign key to Department, a joining date, and a status flag.
JPA entity#
package com.example.empmgmt.entity;
@Entity
@Table(name = "employee")
@Getter @Setter
public class EmployeeEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "login_name", nullable = false, unique = true, length = 128)
private String loginName;
@Column(name = "first_name", nullable = false, length = 64) private String firstName;
@Column(name = "last_name", nullable = false, length = 64) private String lastName;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "department_id")
private DepartmentEntity department;
@Column(name = "joining_date", nullable = false) private LocalDate joiningDate;
@Column(nullable = false, length = 16) private String status; // ACTIVE | INACTIVE | RESIGNED
}Palmyra model#
The interesting bit: the FK to Department is declared as a nested model field. Palmyra resolves the JOIN automatically, and clients can post a partial { "department": { "id": 3 } } to set it.
package com.example.empmgmt.model;
@Getter @Setter
@PalmyraType(type = "Employee", table = "employee", preferredKey = "loginName")
public class EmployeeModel {
@PalmyraField(primaryKey = true)
private Long id;
@PalmyraField(sort = true, search = true, quickSearch = true,
mandatory = Mandatory.ALL,
pattern = "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$")
private String loginName;
@PalmyraField(sort = true, search = true, mandatory = Mandatory.ALL)
private String firstName;
@PalmyraField(sort = true, search = true, mandatory = Mandatory.ALL)
private String lastName;
// Nested FK — Palmyra resolves the JOIN on read and the lookup on write
@PalmyraField(attribute = "department", mandatory = Mandatory.SAVE)
@FetchConfig(fetchMode = FetchMode.PRIMITIVE_OR_KEY_FIELDS)
private DepartmentModel department;
// Flattened shortcut for grid columns that want a plain label
@PalmyraField(parentRef = "department", attribute = "code")
private String departmentCode;
@PalmyraField(sort = true, mandatory = Mandatory.ALL) private LocalDate joiningDate;
@PalmyraField(sort = true) private String status;
}Highlights:
@FetchConfig(PRIMITIVE_OR_KEY_FIELDS)keeps the department sub-object slim — id + primitive columns, no further nested joins. Lists stay small.departmentCodeviaparentRefgives the grid a flat column to show without pulling the full department object. Read-only on the server side — any value the client sends is ignored.patternonloginNameenforces an email shape at the framework level; you don’t need a separate validator.
Handler#
package com.example.empmgmt.handler;
@Component
@CrudMapping(
mapping = "/employee",
type = EmployeeModel.class,
secondaryMapping = "/employee/{id}"
)
public class EmployeeHandler
implements QueryHandler, ReadHandler, SaveHandler, UpdateHandler, DeleteHandler {
@Override
public QueryFilter applyQueryFilter(QueryFilter filter, HandlerContext ctx) {
// sensible default order
if (!filter.hasOrderBy()) {
filter.addOrderAsc("firstName");
filter.addOrderAsc("lastName");
}
return filter;
}
@Override
public Tuple preSave(Tuple tuple, Tuple dbTuple, HandlerContext ctx, MutableAction action) {
// default every new employee to ACTIVE
if (action == MutableAction.INSERT && tuple.get("status") == null) {
tuple.setAttribute("status", "ACTIVE");
}
return tuple;
}
}That’s the full backend surface. Step 4 verifies it against a running database.
Variations#
-
Audit fields kept off the wire. Add
createdAt/createdBy/updatedAt/updatedByto both the entity and the model, and tag them withDropMode.INCOMINGso clients can never set them:@PalmyraField(drop = DropMode.INCOMING) private Instant createdAt; @PalmyraField(drop = DropMode.INCOMING) private String createdBy; @PalmyraField(drop = DropMode.INCOMING) private Instant updatedAt; @PalmyraField(drop = DropMode.INCOMING) private String updatedBy;Then stamp them inside the handler:
@Override public Tuple preSave(Tuple tuple, Tuple dbTuple, HandlerContext ctx, MutableAction action) { Instant now = Instant.now(); String user = auth.getUser(); if (action == MutableAction.INSERT) { tuple.setAttribute("createdAt", now); tuple.setAttribute("createdBy", user); } tuple.setAttribute("updatedAt", now); tuple.setAttribute("updatedBy", user); return tuple; } -
Normalise before save. Lower-case the email and trim whitespace so uniqueness works regardless of client casing — use the
preProcessRawhook, which runs before validation fires:@Override public Tuple preProcessRaw(Tuple tuple, HandlerContext ctx) { String login = (String) tuple.get("loginName"); if (login != null) tuple.setAttribute("loginName", login.trim().toLowerCase()); return tuple; } -
Computed column on read. Derive
fullNameon the way out without storing it in the table — overrideonQueryResult:@Override public Tuple onQueryResult(Tuple tuple, Action action) { tuple.setAttribute("fullName", ((String) tuple.get("firstName")) + " " + tuple.get("lastName")); return tuple; }Add a matching
@PalmyraField(virtual = true) private String fullName;on the model so the attribute is declared but no column is expected. -
Scope to a tenant. If the service is multi-tenant, you rarely want callers to pass the tenant id explicitly — read it from auth and inject it as a non-overridable condition:
@Override public QueryFilter applyQueryFilter(QueryFilter filter, HandlerContext ctx) { filter.addCondition(new SimpleCondition("tenantId", auth.getTenantId())); return filter; }See Custom query filters for the broader pattern.