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 the preSave lifecycle 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.
  • departmentCode via parentRef gives 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.
  • pattern on loginName enforces 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 / updatedBy to both the entity and the model, and tag them with DropMode.INCOMING so 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 preProcessRaw hook, 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 fullName on the way out without storing it in the table — override onQueryResult:

    @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.