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#

  1. DB-level FOREIGN KEY constraintgetImportedKeys() returns it, Palmyra registers it under the source column name. Zero annotations required.
  2. @PalmyraMappingConfig.foreignKeys — for app-managed FKs (shared / read-only schemas). Registered under @PalmyraForeignKey.name.
  3. Custom SchemaProvider — override the bean to pull from information_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 SaveHandler upsert 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-drop plus 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#