Schema Discovery — how Palmyra reads your database#
The Mental Model page says “the database schema is the source of truth.” This page is the under-the-hood version — what Palmyra actually reads, when, and how that drives request-time behaviour. Useful when debugging why a field doesn’t resolve or why a join looks different from what you expect.
What gets read, and when#
At Spring context startup, Palmyra builds an in-memory Schema object backed by a SchemaProvider. The default SchemaProvider reads:
| Source | Supplies |
|---|---|
DatabaseMetaData.getTables() |
Table list (scoped to the configured schema/catalog) |
DatabaseMetaData.getColumns() |
Column names, types, nullability, size |
DatabaseMetaData.getPrimaryKeys() |
PK fields |
DatabaseMetaData.getImportedKeys() |
Foreign keys — source column, target table, target column |
DatabaseMetaData.getIndexInfo() |
Unique indexes → secondary unique keys |
@PalmyraType on model classes |
Logical name + table binding |
@PalmyraField on model fields |
Which attributes are exposed + their flags (search, sort, mandatory) |
@PalmyraMappingConfig on model classes |
Overrides / additions on top of the DB-read set — for app-managed FKs/UKs |
Key insight: the DB is consulted before the annotations. Model POJOs layer routing + presentation on top of what’s already there. Attributes that don’t map to a DB column are silent no-ops. FKs that are in the DB need no annotation at all.
TupleType — the in-memory shape#
Each table becomes a com.zitlab.palmyra.sqlstore.base.dbmeta.TupleType:
TupleType {
String name // logical type name ("MrcpPatient")
String table // "mrcp_patient"
String schema
List<TupleAttribute> primaryKey
Map<String, TupleAttribute> fieldList // columns
Map<String, UniqueKey> uniqueKeyMap
Map<String, ForeignKey> foreignKeyMap // FK alias → target TupleType
Map<String, TupleRelation> relations
Map<String, TupleChild> children
}foreignKeyMap is keyed by the FK alias — for DB-discovered FKs, this is the source column name (e.g. gender, facility). Each ForeignKey carries a pointer back to the target TupleType — this is the thing that makes nested-object responses possible.
Request-time: tupleType.getReference(field)#
When a model field is typed as another model (e.g. private MrcpPatientModel patient in an Exam model), Palmyra’s DataFormatValidatorImpl calls:
TupleType subType = tupleType.getReference(field.getAttribute());
if (subType == null && !field.isVirtual())
throw new RuntimeException(field.getAttribute() + " subtype not found in " + tupleType.getName());getReference("patient") walks foreignKeyMap looking for an alias patient. No match → the exception above.
This is why the runtime error says “subtype not found” when your DB lacks the FK — Palmyra’s schema never saw the relationship, so there’s nothing to join against.
Three ways a reference gets into foreignKeyMap#
- DB-level
FOREIGN KEYconstraint —getImportedKeys()returns it, Palmyra registers it under the source column name. Zero annotations required. @PalmyraMappingConfig.foreignKeys— for app-managed FKs (shared / read-only schemas). Registered under@PalmyraForeignKey.name.- Custom
SchemaProvider— override the bean to pull frominformation_schema, a cache, or a config file. Rare, but possible.
The first two are the production paths. If you’ve declared neither and the request 500s with “subtype not found”, your schema really is missing the FK — add the constraint (or declare the mapping) and restart.
preferredKey and identity lookups#
@PalmyraType(preferredKey = "loginName") names a unique key to consult first during:
- pre-insert “does this already exist?” checks on
CreateHandler - the
SaveHandlerupsert lookup
Without preferredKey, Palmyra runs an OR-combined SELECT across the primary key and every registered unique key. Not wrong, just slower (wider index span) and non-deterministic when two unique keys could match different rows.
What happens when the DB and annotations disagree#
| Case | Effect |
|---|---|
| Column in DB, field in model | Exposed — read + write go through |
Column in DB, no @PalmyraField |
Hidden at the Palmyra layer, still present in SQL |
@PalmyraField with no matching column |
Silent no-op — mapping layer has nothing to bind |
FK in DB, no @PalmyraForeignKey |
Discovered — nested reads resolve automatically |
@PalmyraForeignKey with no DB FK |
Registered as app-managed — nested reads resolve if source/target fields actually exist |
Debugging the in-memory schema#
Dump it via the TupleType introspection endpoint shipped with palmyra-rest:
GET {prefix}/tables/{typeName}Returns the TupleType as JSON — every column, every FK alias, every unique key. If an expected relation is missing here, the DB doesn’t have it (or the schema provider didn’t pick it up).
Schema provider lifecycle#
- Built once per application context. Changes to the DB schema after startup are not observed — add/remove an FK in the DB and you need to restart for Palmyra to notice.
- Test profiles that use testcontainers get a fresh schema per test class / method —
ddl-auto: create-dropplus Hibernate’s entity set defines the shape.
When to write a custom SchemaProvider#
You don’t, usually. Real reasons to override:
- The live DB is read-only and you want to ship a static metadata file with your artifact.
- You want to unit-test schema-aware logic without a real JDBC connection.
- You’re backing Palmyra with a non-JDBC store (extension territory).
For the garden-variety SpringBoot + MariaDB/PostgreSQL case, let the default SchemaProvider run; your only authored input is @PalmyraType / @PalmyraField / (when needed) @PalmyraMappingConfig.
See also#
- Mental Model — the consumer-facing version of this page
@PalmyraMappingConfig— override syntax for app-managed FK/UK@PalmyraType—preferredKeyexplanation