Architecture

A map of PikaORM’s classes, interfaces, and subsystems, and how they fit together. Each section covers one subsystem and its place in the whole.


1. Top-Level Package Map

edu.montana.pika
├── PikaORM.java                ← Central orchestrator / entry point
├── bean/
│   ├── PikaBean.java ← Active-record base class
│   ├── PikaRecordLifecycle.java← Lifecycle callback interface
│   ├── PikaManyRelation.java   ← One-to-Many relation holder
│   └── PikaManyThroughRelation.java ← Many-to-Many (join table) holder
├── cache/
│   ├── QueryCache.java         ← Per-thread in-memory result cache
│   ├── LoadKey.java            ← Cache key: load(obj, Class)
│   ├── LoadManyKey.java        ← Cache key: loadMany(obj, Class)
│   ├── LoadManyThroughKey.java ← Cache key: loadManyThrough(obj, J, Class)
│   └── LoadReverseKey.java     ← Cache key: loadReverse(obj, Class)
├── logging/
│   ├── PikaLogger.java         ← Logger interface + Level enum
│   └── DefaultLogger.java      ← Stdlib-backed implementation
├── mapping/
│   ├── Mapping.java            ← Class-to-table descriptor
│   ├── FieldMapping.java       ← Field-to-column descriptor
│   ├── ColumnsSpec.java        ← Column inclusion filter
│   ├── Reflector.java          ← Object instantiation interface
│   └── StandardReflector.java  ← Default reflection-based instantiator
├── migrations/
│   └── Migrations.java         ← Abstract migration runner + PikaMigration record
├── query/
│   ├── PikaClassFinder.java    ← High-level typed entry point for lookups
│   ├── PikaClassQuery.java     ← Typed, class-aware query builder
│   ├── PikaQuery.java          ← Generic SQL query builder
│   ├── QueryResult.java        ← Immutable result set wrapper
│   ├── PikaList.java           ← Extended ArrayList with helpers
│   ├── ResultMap.java          ← Loosely-typed row map
│   ├── PikaStreamFinder.java   ← Streaming typed entry point
│   ├── JoinType.java           ← LEFT / INNER / etc.
│   ├── OrderBy.java            ← Column + direction pair
│   └── SortOrder.java          ← ASC / DESC enum
├── session/
│   └── ConnectionSession.java  ← Per-thread JDBC connection wrapper
└── util/
    ├── SQLString.java          ← SQL pretty-printer for logging
    ├── TextTools.java          ← snakeCase, pluralize, humanize helpers
    ├── SafeAutoCloseable.java  ← Closeable that rethrows as RuntimeException
    ├── RunnableWithException.java
    ├── PikaIterable.java       ← Extended Iterable interface
    └── LazyVar.java            ← Lazy-evaluated value holder

2. Full Class Hierarchy Graph

classDiagram
    direction TB

    class PikaORM {
        +Callable~Connection~ connectionSource
        +ConcurrentHashMap~Class,Mapping~ mappings
        +ThreadLocal~ConnectionSession~ CURRENT_SESSION
        +ThreadLocal~QueryCache~ QUERY_CACHE
        +find(Class) PikaClassFinder
        +query(Class) PikaClassQuery
        +queryBuilder(String) PikaQuery
        +stream(Class) PikaStreamFinder
        +insert(Object) Long
        +insertAll(List) void
        +update(Object) boolean
        +delete(Object) boolean
        +select(String, Map, Class) QueryResult
        +inTransaction(Runnable) void
        +startQueryCaching() void
        +getMapping(Class) Mapping
        +coerce(Class, Object) T
    }

    class Mapping {
        +String tableName
        +Map~String,FieldMapping~ fieldNameToMapping
        +Map~String,FieldMapping~ columnToMapping
        +FieldMapping idMapping
        +FieldMapping uuidMapping
        +FieldMapping versionMapping
        +newObjectFromResult(PikaORM, ResultSet, ColumnsSpec) T
        +toDatabaseMap(Object) Map
        +mapField(Field) FieldMapping
        +mapToTable() String
    }

    class FieldMapping {
        +Field field
        +String columnName
        +boolean isId
        +boolean isUUID
        +boolean isVersionProperty
        +getValueFromDatabase(ResultSet) Object
        +setFieldValue(Object, Object) void
        +getFieldValue(Object) Object
        +getValueForDatabaseFrom(Object) Object
        +incrementVersion(Map) Object
    }

    class ConnectionSession {
        +Connection conn
        +int openCount
        +int transactionCount
        +prepareStatement(String, Collection) PreparedStatement
        +execute(PreparedStatement) ResultSet
        +startTransaction() void
        +finishTransaction() void
        +rollBackTransaction() void
        +close() void
    }

    class QueryCache {
        +cache(Object, Supplier) T
        +clear() void
    }

    class PikaClassFinder~T~ {
        +byId(Object) T
        +byKey(String,Object) T
        +all() PikaClassQuery~T~
        +where(String,Map) PikaClassQuery~T~
        +firstWhere(String,Map) T
        +bySQL(String,Map) QueryResult~T~
        +count() long
        +sum(String) Double
        +avg(String) Double
        +page(long) PikaClassQuery~T~
        +join(Class) PikaClassQuery~T~
    }

    class PikaClassQuery~T~ {
        +PikaQuery~T~ query
        +where(String) PikaClassQuery~T~
        +orWhere(String) PikaClassQuery~T~
        +whereIn(String,Collection) PikaClassQuery~T~
        +whereLike(String,String) PikaClassQuery~T~
        +group() PikaClassQuery~T~
        +join(Class) PikaClassQuery~T~
        +orderBy(String,SortOrder) PikaClassQuery~T~
        +page(long) PikaClassQuery~T~
        +pageSize(int) PikaClassQuery~T~
        +fetch() QueryResult~T~
        +fetchFirst() T
        +count() long
        +sum(String) Double
        +totalPages() long
        +stream() Stream~T~
        +explain() QueryResult~ResultMap~
    }

    class PikaQuery~T~ {
        +String baseTable
        +StringBuilder whereClause
        +List~String~ joins
        +List~OrderBy~ orderBys
        +Map~String,Object~ valMap
        +generateSQL() String
        +where(String,Map) PikaQuery~T~
        +orWhere(String,Map) PikaQuery~T~
        +whereIn(String,Collection) PikaQuery~T~
        +whereLike(String,String) PikaQuery~T~
        +join(String) PikaQuery~T~
        +orderBy(String,SortOrder) PikaQuery~T~
        +page(long) PikaQuery~T~
        +fetch() QueryResult~T~
        +totalCount() long
        +sum(String) Double
        +avg(String) Double
        +min(String) Object
        +max(String) Object
        +stream() Stream~T~
        +explain() QueryResult~ResultMap~
    }

    class QueryResult~T~ {
        +PikaList~T~ list
        +first() T
        +toList() PikaList~T~
        +size() int
        +iterator() Iterator~T~
    }

    class PikaList~T~ {
        +firstWhere(Predicate) T
        +lastWhere(Predicate) T
        +hasMatch(Predicate) boolean
        +toString(String) String
        +copy() PikaList~T~
    }

    class ResultMap {
        +get(String) Object
        +asLong(String) Long
        +asDouble(String) Double
        +asBoolean(String) Boolean
        +asDate(String) Date
        +toCaseInsensitiveMap() ResultMap
    }

    class PikaStreamFinder~T~ {
        +where(String,Map) PikaStreamFinder~T~
        +stream() Stream~T~
    }

    class PikaBean {
        +boolean persisted
        +Map~String,Object~ originalValues
        +Map errors
        +insert() Long
        +update() boolean
        +save() boolean
        +saveOrThrow() void
        +delete() boolean
        +reload() void
        +validate() boolean
        +validation() void
        +require(String) void
        +requireUnique(String) void
        +addError(String,String) void
        +hasErrors() boolean
        +load(Class) T
        +loadMany(Class) PikaManyRelation~T~
        +loadManyThrough(Class,Class) PikaManyThroughRelation~J,T~
        +loadReverse(Class) T
        +setFieldsFrom(Map,String[]) T
        +find(Class)$ PikaClassFinder~T~
        +orm()$ PikaORM
    }

    class PikaRecordLifecycle {
        <<interface>>
        +validate() boolean
        +beforeInsert() boolean
        +beforeUpdate(Map) boolean
        +beforeDelete() boolean
        +afterInsert() void
        +afterSelect() void
        +afterUpdate() void
        +afterDelete() void
    }

    class PikaManyRelation~T~ {
        +Object one
        +Class~T~ classOfMany
        +String manyFk
        +add(T) void
        +addAndSave(T) void
        +create() T
        +findById(long) T
        +toQuery() PikaClassQuery~T~
        +size() int
        +reload() void
        +iterator() Iterator~T~
    }

    class PikaManyThroughRelation~J,T~ {
        +Object one
        +Class~J~ joinClass
        +Class~T~ classOfMany
        +add(T) J
        +addAndSave(T) J
        +remove(T) void
        +findById(long) T
        +toQuery() PikaClassQuery~T~
        +size() int
        +reload() void
        +iterator() Iterator~T~
    }

    class Migrations {
        <<abstract>>
        +migrations() void
        +applyAll() void
        +up() void
        +down() void
        +console() void
        +add(PikaMigration) void
        +initialSchema() String
    }

    class PikaMigration {
        +String name
        +String up
        +String down
        +String description
        +MigrationStatus status
        +runUp(PikaORM) void
        +runDown(PikaORM) void
        +isApplied() boolean
        +isPending() boolean
    }

    class PikaLogger {
        <<interface>>
        +log(Level, String, Object[]) void
    }

    class ColumnsSpec {
        +accept(String tableName, String columnName) boolean
    }

    %% Relationships
    PikaORM --> Mapping : "computeIfAbsent per Class"
    PikaORM --> ConnectionSession : "pushes/pops via ThreadLocal"
    PikaORM --> QueryCache : "ThreadLocal per thread"
    PikaORM --> PikaLogger : "delegates all logging"
    PikaORM --> Migrations : "applyAll() on startup"

    PikaORM ..> PikaClassFinder : "find(Class) creates"
    PikaORM ..> PikaClassQuery : "query(Class) creates"
    PikaORM ..> PikaQuery : "queryBuilder(String) creates"
    PikaORM ..> PikaStreamFinder : "stream(Class) creates"

    PikaClassFinder --> PikaClassQuery : "delegates all() / where()"
    PikaClassQuery --> PikaQuery : "wraps and delegates"
    PikaQuery --> QueryResult : "fetch() returns"
    QueryResult --> PikaList : "toList() returns"
    PikaList --|> ArrayList

    Mapping --> FieldMapping : "one per mapped field"
    Mapping --> ColumnsSpec : "uses in newObjectFromResult"

    PikaBean ..|> PikaRecordLifecycle
    PikaBean --> PikaORM : "orm() static accessor"
    PikaBean --> PikaManyRelation : "loadMany() returns"
    PikaBean --> PikaManyThroughRelation : "loadManyThrough() returns"

    PikaManyRelation --> PikaClassQuery : "toQuery() builds"
    PikaManyThroughRelation --> PikaClassQuery : "toQuery() builds"

    Migrations --> PikaMigration : "contains ordered list"
    Migrations --> PikaORM : "exec() / insert() / update()"

    ConnectionSession ..|> SafeAutoCloseable

3. Subsystem Deep-Dives

3.1 — PikaORM: The Central Orchestrator

PikaORM is the single entry point for all database operations. It is never sub-classed; instead it is configured at startup with a builder pattern.

flowchart LR
    subgraph Configuration ["Builder — startup"]
        A1["new PikaORM(connectionSource)"] --> A2["withLogger / withLogLevel"]
        A2 --> A3["withMigrations / applyMigrations"]
        A3 --> A4["withDefaultTableMapping<br/>withDefaultColumnMapping<br/>withDefaultIdField<br/>withDefaultFkColumn<br/>withDefaultVersionColumnName<br/>withDefaultUUIDField"]
        A4 --> A5["withCoercion / withReflector<br/>withOffsetClause<br/>withDefaultPageSize<br/>withSQLiteQuirks"]
        A5 --> A6["makeDefaultORM() — PikaORM.get()"]
    end

3.2 — Connection & Session Layer

PikaORM uses a thread-local connection stack. Each query creates or reuses a ConnectionSession on the current thread. Sessions are reference-counted so nested calls share one physical connection.

flowchart TD
    subgraph ThreadLocal ["ThreadLocal&lt;ConnectionSession&gt;"]
        direction TB
        CS1["ConnectionSession (outer)\n conn, openCount, transactionCount\n UUID for debug logging"]
        CS2["ConnectionSession (inner, nested)\n previous → outer session"]
        CS1 --> CS2
    end

    Q[Query Method\ne.g. select / insert / update] -->|"getOrCreateSession()"| CS1
    CS1 -->|prepareStatement| PS[PreparedStatement]
    PS -->|execute| RS[ResultSet]
    RS -->|"mapping.newObjectFromResult()"| Obj[Domain Object]
    CS1 -->|"close() when openCount == 0"| Closed[Connection Closed\nrestores previous session]

Key behaviours:

  • establishConnection() manually pushes a new session — required before stream().
  • getOrCreateSession() (private) lazily creates a session per query.
  • Closing a session decrements openCount; the underlying JDBC Connection is only closed when it reaches zero.
  • The previous session is automatically restored on close, enabling transparent nesting.

3.3 — Transaction Management

flowchart TD
    subgraph TransactionAPI ["Transaction API Surface"]
        W["inTransaction(Runnable/Callable)"]
        F["forceTransaction(Runnable/Callable)\nopens its own connection"]
        J["joinTransaction(Runnable/Callable)\nrequires active tx"]
        W --> ST[startTransaction]
        ST --> TX["ConnectionSession.startTransaction()\nsetAutoCommit(false) if first"]
        TX -->|success| CM["ConnectionSession.finishTransaction()\nconn.commit() if outermost"]
        TX -->|exception| RB["ConnectionSession.rollBackTransaction()\nconn.rollback()"]
    end

    subgraph Nesting ["Nested Transactions"]
        N1["outer inTransaction()"]
        N2["inner inTransaction() — joins outer"]
        N1 -->|transactionCount++| N2
        N2 -->|transactionCount--| N1
        N1 -->|"transactionCount == 0 → commit"| DB[(Database)]
    end

Key rule: PikaORM uses a transaction counter (transactionCount) inside ConnectionSession. The physical COMMIT fires only when the outermost transaction completes. Nested calls simply join the existing transaction.


3.4 — Mapping Subsystem

The mapping subsystem converts Java classes and their fields into SQL tables and columns, and back again.

flowchart TD
    ORM[PikaORM.getMapping\nClass] -->|computeIfAbsent| MC

    subgraph MC ["Mapping (per class)"]
        direction TB
        M1["mapToTable()\n→ defaultClassToTableMapping\n  or override mapping()"]
        M2["getAllFields(Class)\n→ walks superclass chain"]
        M3["mapField(Field)\n→ defaultMapping(Field)\n  checks shouldIgnore\n  checks columnsInDb"]
        M4["resolveIdMapping()\nresolveUUIDMapping()\nresolveVersionMapping()"]
        M1 --> M2 --> M3 --> M4
    end

    MC --> FM

    subgraph FM ["FieldMapping (per field)"]
        direction TB
        F1["columnName\n→ defaultFieldToColumnMapping\n  or @column annotation"]
        F2["isId / isUUID / isVersionProperty\n→ explicit or by default field name"]
        F3["getValueFromDatabase(ResultSet)\n→ reads JDBC value, coerces"]
        F4["setFieldValue(Object,Object)\n→ reflective write"]
        F5["getValueForDatabaseFrom(Object)\n→ reflective read"]
        F1 --> F2 --> F3
        F3 --> F4
        F3 --> F5
    end

Key behaviours:

Class-to-table default: ClassNamesnake_case → pluralized (e.g. ArtistBeanartist_beans by default; overridden in Chinook test to artists).

Field-to-column default: fieldNamesnake_case (e.g. strValstr_val; overridden in Chinook test to capitalize to match Chinook’s CamelCase columns).

Custom mapping via static method: Any class may declare public static Mapping mapping() to return a fully customised Mapping instance. PikaORM discovers it via reflection.

Read-only records: Java record types are treated as read-only; isReadOnly() returns true and their id field is never written back after insert.


3.5 — Query Pipeline

The query path has three entry points, at different levels:

flowchart LR
    ORM["PikaORM"] -->|"find(Class)"| PCF["PikaClassFinder (T)<br/>typed lookup shortcuts"]
    ORM -->|"query(Class)"| PCQ["PikaClassQuery (T)<br/>typed fluent builder"]
    ORM -->|"queryBuilder(String)"| PQ["PikaQuery (T)<br/>raw fluent builder"]
    ORM -->|"stream(Class)"| PSF["PikaStreamFinder (T)<br/>streaming typed"]

    PCF -->|"all / where"| PCQ
    PCQ -->|"wraps"| PQ
    PSF -->|"delegates"| PQ

    PQ -->|"generateSQL()"| SQL["SQL String"]
    PQ -->|"fetch()"| QR["QueryResult (T)"]
    PQ -->|"stream()"| ST["Stream of T"]
    QR -->|"toList()"| PL["PikaList (T)"]
    QR -->|"first()"| SING["Single Object T"]

    SQL -->|"orm.select()"| EXEC["PikaORM execute"]
    EXEC -->|"Mapping.newObjectFromResult()"| SING

PikaClassFinder — Lookup Shortcuts

Method Description
byId(Object id) SELECT * FROM table WHERE id=? LIMIT 1
byKey(String col, Object val) SELECT * FROM table WHERE col=? LIMIT 1
all() Returns a PikaClassQuery for all rows
allBy(String col, Object val) Returns a PikaClassQuery filtered by a single column
firstWhere(String, Map) Returns first matching row or null
where(String, Map) Returns a PikaClassQuery with an initial WHERE clause
bySQL(String, Map) Raw SQL query mapped to type T
count() / sum() / avg() / min() / max() Aggregate delegates
page(long) / join(Class) Paging and join delegates

PikaClassQuery — Typed Fluent Builder

Delegates all operations to an inner PikaQuery<T>. Extends it with type-safe join(Class) which auto-resolves foreign keys.

Feature Method
Filtering where, orWhere, whereIn, whereNotIn, whereLike, orWhereLike
Grouping group(), orGroup(), endGroup()
Joining join(Class), join(JoinType, Class), thenJoin(Class)
Sorting orderBy(String), orderBy(String, SortOrder)
Paging page(long), pageSize(int)
Aggregates count(), sum(), avg(), min(), max(), totalCount()
Execution fetch(), fetchFirst(), fetchList(), stream()
Pagination helpers isFirstPage(), isLastPage(), nextPageURL(), previousPageURL()
Diagnostics explain(), generateSQL()

PikaQuery — Generic SQL Builder

The lowest-level fluent query API, used directly for raw SQL (e.g. queryBuilder("some_view")). Also the backing implementation for PikaClassQuery.

Named variable substitution: :varName in SQL strings is replaced by ? at execution time. Collections expand to (?, ?, ?).


3.6 — CRUD Execution Flow

flowchart TD
    subgraph INSERT ["insert(Object)"]
        I1[PikaRecordLifecycle.validate] --> I2[PikaRecordLifecycle.beforeInsert]
        I2 --> I3["Mapping.toDatabaseMap()\nremove id column\nauto-generate UUID if absent\nincrement version"]
        I3 --> I4["Build INSERT SQL\n'INSERT INTO table (cols) VALUES (?,...)'\nprepareStatement with generated keys"]
        I4 --> I5["mapping.setId(object, generatedKey)\nmapping.updateVersionValue()"]
        I5 --> I6[PikaRecordLifecycle.afterInsert]
    end

    subgraph BULK ["insertAll(List)"]
        B1["All items same class\nBuild single INSERT with\nmultiple VALUES rows\n(?, ?, ...), (?, ?, ...)"]
        B1 --> B2["prepareStatement\nexecuteUpdate"]
    end

    subgraph UPDATE ["update(Object)"]
        U1[PikaRecordLifecycle.validate] --> U2["Mapping.toDatabaseMap()\nremove id col\ncapture currentVersion / nextVersion"]
        U2 --> U3[PikaRecordLifecycle.beforeUpdate]
        U3 --> U4["Build UPDATE SQL\n'UPDATE table SET col=?,... WHERE id=? AND version=?'"]
        U4 --> U5["mapping.updateVersionValue()\nreturn rowsAffected == 1"]
        U5 --> U6[PikaRecordLifecycle.afterUpdate]
    end

    subgraph DELETE ["delete(Object)"]
        D1[PikaRecordLifecycle.beforeDelete] --> D2["Build DELETE SQL\n'DELETE FROM table WHERE id=?'"]
        D2 --> D3[PikaRecordLifecycle.afterDelete]
    end

    subgraph SELECT ["select(sql, args, Class, ColumnsSpec)"]
        S1["updateSqlVars()\n:name → ?  with val ordering"] --> S2["getOrCreateSession()\nprepareStatement(updatedSql, vals)"]
        S2 --> S3["session.execute(ps)\n→ ResultSet iteration"]
        S3 --> S4["mapping.newObjectFromResult()\n→ reflective construction\n→ field mapping from JDBC"]
        S4 --> S5["PikaRecordLifecycle.afterSelect()\nif applicable"]
        S5 --> S6["add to PikaList\nreturn QueryResult"]
    end

3.7 — Relationship System

flowchart TD
    subgraph ONE_MANY ["One-to-Many: orm.loadMany(one, ManyClass)"]
        OM1["Mapping.getDefaultForeignKeyColumnName()\ne.g. artist → artist_id on albums"]
        OM1 --> OM2["PikaManyRelation(one, classOfMany, manyFk, orm)"]
        OM2 --> OM3["toQuery()\nquery(classOfMany).where('manyFk=:id', id)"]
        OM3 --> OM4["lazy load on iterator()\nor size()"]
    end

    subgraph MANY_THROUGH ["Many-to-Many: orm.loadManyThrough(one, JoinClass, ManyClass)"]
        MT1["Derives oneFk from one's class\nDerives manyFk from ManyClass"]
        MT1 --> MT2["PikaManyThroughRelation(one,oneFk,joinClass,classOfMany,manyFk,orm)"]
        MT2 --> MT3["toQuery()\nquery(classOfMany)\n  .join(joinClass)\n  .thenJoin(one.class)\n  .where(oneTable.id = :id)"]
        MT3 --> MT4["lazy load on iterator()\nor size()"]
    end

    subgraph LOAD_ONE ["load(objectWithFk, TargetClass)"]
        L1["Mapping.getValueForColumn(obj, fkColumn)\n→ reads FK value from object"]
        L1 --> L2["find(TargetClass).byId(fkValue)"]
    end

    subgraph LOAD_REVERSE ["loadReverse(objectWithPk, TargetClass)"]
        LR1["Mapping.getId(objectWithPk)\n→ reads PK value"]
        LR1 --> LR2["find(TargetClass).byKey(fkColumn, pkValue)"]
    end

PikaManyRelation key methods:

Method Description
add(T) Sets the FK field on newMember to one’s PK. Does not save.
addAndSave(T) Calls add() then persists (via save() for PikaBean or insert()/update() otherwise).
create() Creates a new instance with FK pre-set.
findById(long) Finds a specific member within this relation by its PK.
toQuery() Returns a PikaClassQuery for further filtering.
reload() Clears the cached result; next iteration re-queries.

PikaManyThroughRelation key methods:

Method Description
add(T) Creates a join-table instance with both FKs set. Does not save.
addAndSave(T) Calls add() then persists the join object.
remove(T) Deletes matching join-table rows.
toQuery() Returns a PikaClassQuery<T> joining through the join table.

3.8 — PikaBean: Active-Record Layer

PikaBean is an optional base class that wires a domain object directly into PikaORM via the default static PikaORM.get().

flowchart TD
    subgraph PikaBean ["PikaBean"]
        direction LR
        EPB_PERSIST["persisted flag\noriginalValues snapshot"]
        EPB_ERRORS["errors Map\n field → PikaList&lt;String&gt;"]
        EPB_CRUD["insert()\nupdate()\nsave() / saveOrThrow()\ndelete()\nreload()"]
        EPB_VALIDATE["validate()\nvalidation() — override\nrequire(String)\nrequireUnique(String)"]
        EPB_REL["load(Class)\nloadMany(Class)\nloadManyThrough(Class,Class)\nloadReverse(Class)"]
        EPB_UTIL["setFieldsFrom(Map,fields)\ngetOriginalValue(field)\nisIdEquivalent(obj)\nisPersisted()"]
    end

    PikaBean --> PRL[PikaRecordLifecycle]
    PRL -.->|"afterSelect() sets persisted=true\nand snapshots originalValues"| EPB_PERSIST
    EPB_PERSIST -.->|"beforeUpdate() strips unchanged fields\noptimising UPDATE payloads"| EPB_CRUD

    PikaBean -.->|"save() dispatches"| D{"isPersisted?"}
    D -->|yes| UPD["orm().update(this)"]
    D -->|no| INS["orm().insert(this)"]
    UPD -->|"marks persisted again"| EPB_PERSIST
    INS -->|"marks persisted again"| EPB_PERSIST

Dirty-field optimisation: beforeUpdate() compares the current field map against originalValues (captured at select-time or after the last write). Fields whose value is unchanged are silently removed from the UPDATE payload, minimising unnecessary DB writes.

Error API:

Method Description
addError(String field, String msg) Attaches a field-scoped error
addError(String msg) Attaches a general (non-field) error
hasErrors() Returns true if any error is present
getErrors(String field) Returns all errors for a specific field
getGeneralErrors() Returns non-field errors
getAllFieldErrors() Returns a sorted map of all field errors
getErrorString(String field) Joins field errors with ,

3.9 — Lifecycle Callback System

PikaRecordLifecycle is implemented either directly on any POJO or by extending PikaBean.

sequenceDiagram
    participant Caller
    participant ORM as PikaORM
    participant Lifecycle as PikaRecordLifecycle

    Note over ORM,Lifecycle: INSERT flow
    Caller->>ORM: insert(object)
    ORM->>Lifecycle: validate() → false = abort
    ORM->>Lifecycle: beforeInsert() → false = abort
    ORM->>ORM: build + execute INSERT SQL
    ORM->>Lifecycle: afterInsert()

    Note over ORM,Lifecycle: UPDATE flow
    Caller->>ORM: update(object)
    ORM->>Lifecycle: validate() → false = abort
    ORM->>Lifecycle: beforeUpdate(valuesToUpdate) → false = abort
    ORM->>ORM: build + execute UPDATE SQL
    ORM->>Lifecycle: afterUpdate()

    Note over ORM,Lifecycle: DELETE flow
    Caller->>ORM: delete(object)
    ORM->>Lifecycle: beforeDelete() → false = abort
    ORM->>ORM: execute DELETE SQL
    ORM->>Lifecycle: afterDelete()

    Note over ORM,Lifecycle: SELECT flow
    ORM->>ORM: execute SELECT SQL
    ORM->>ORM: Mapping.newObjectFromResult()
    ORM->>Lifecycle: afterSelect()

[!NOTE] Returning false from validate(), beforeInsert(), beforeUpdate(), or beforeDelete() causes the operation to abort silently (returns null or false). No exception is thrown. For PikaBean subclasses, saveOrThrow() will throw IllegalStateException if the operation fails.


3.10 — Coercion System

PikaORM converts JDBC values (which are database-driver dependent) to the target Java field type through a layered coercion pipeline.

flowchart TD
    CV["coerce(Class targetClass, Object value)"]
    CV -->|"value == null"| RN["return null"]
    CV -->|"non-String empty string"| RN
    CV -->|"iterate registered coercers"| UC["User-defined BiFunction list\nwithCoercion(BiFunction)"]
    UC -->|"result != null"| RR["return result\n(NULL_SENTINEL → null)"]
    UC -->|"no match"| DC["defaultCoercions()"]

    DC --> DC1["targetType.isInstance(value) → passthrough"]
    DC --> DC2["Enum → Enum.valueOf(toUpperCase)"]
    DC --> DC3["String conversions\nShort, Integer, Long, Float, Double\nBigInteger, BigDecimal"]
    DC --> DC4["Temporal: LocalDate, LocalDateTime, Date\nmulti-format DATE_TIME_FORMATTER"]
    DC --> DC5["Boolean: null→false, 0→false,\n'false'→false, else true"]

    DC -->|"no match found"| EX["IllegalArgumentException"]

sloppyCoerce() is a tolerant variant: if the primary coercion fails and the value is not already a String, it converts the value to String first and retries. Used internally for web form binding via setFieldsFrom().


3.11 — Query Cache System

flowchart LR
    ORM["PikaORM\nstartQueryCaching()\nendQueryCaching()"] -->|"ThreadLocal"| QC["QueryCache\n(per-thread, per-request)"]

    QC -->|"cache(key, Supplier)"| KC{"key in cache?"}
    KC -->|yes| HIT["return cached value\nlog if logCaching=true"]
    KC -->|no| MISS["supplier.get()\nstore result\nreturn value"]

    ORM -->|"loadMany(one, Many)"| LMK["LoadManyKey\n(one identity + fk + class)"]
    ORM -->|"loadManyThrough(one, J, Many)"| LMTK["LoadManyThroughKey\n(one + joinClass + manyClass)"]
    ORM -->|"load(obj, Class)"| LK["LoadKey\n(fkValue + targetClass + fkColumn)"]
    ORM -->|"loadReverse(obj, Class)"| LRK["LoadReverseKey\n(obj + targetClass + fkColumn)"]

    LMK --> QC
    LMTK --> QC
    LK --> QC
    LRK --> QC

Usage pattern:

orm.startQueryCaching();
try {
    // all loadMany / load / loadReverse calls are cached within this block
    for (Artist artist : artists) {
        artist.getAlbums(); // only queries DB once per artist
    }
} finally {
    orm.endQueryCaching();
}

clearQueryCache() invalidates all entries without ending the caching session, useful after write operations within the same request.


3.12 — Migrations Subsystem

flowchart TD
    subgraph DEF ["User-defined Migrations class"]
        UD["extends Migrations\n\npublic void migrations() {\n  add(makeMigration('001_create_users')\n    .description('...')\n    .up('CREATE TABLE ...')\n    .down('DROP TABLE ...'));\n}"]
    end

    subgraph APPLY ["applyAll()"]
        A1["orm.exec(CREATE TABLE IF NOT EXISTS pika_migrations)"]
        A1 --> A2["loadMigrations(orm)\n→ migrations() fills migrationsMap\n→ merge with persisted PikaMigration rows"]
        A2 --> A3["for each PikaMigration where !isApplied:\n  migration.runUp(orm)"]
        A3 --> A4["runUp(orm):\n  inTransaction → exec each SQL\n  status=APPLIED\n  orm.insert(this) or orm.update(this)"]
    end

    DEF --> APPLY
    APPLY --> DB[(pika_migrations table)]

Migration console commands:

Command Action
show Tabular display of all migrations and their status
all Apply all pending migrations
up Apply the next single pending migration
down Roll back the most recently applied migration
exit / quit Exit the console

3.13 — Optimistic Concurrency Control

When a class has a field named version (or configured via withDefaultVersionColumnName), PikaORM automatically enforces optimistic locking.

sequenceDiagram
    participant A as Thread A
    participant DB as Database
    participant B as Thread B

    A->>DB: SELECT ... → version=1
    B->>DB: SELECT ... → version=1
    A->>DB: UPDATE ... SET version=2 WHERE id=X AND version=1
    DB-->>A: 1 row affected 
    B->>DB: UPDATE ... SET version=2 WHERE id=X AND version=1
    DB-->>B: 0 rows affected X → update() returns false

Version incrementer default: previousValue == null → 1; otherwise ((Long) previousValue) + 1. Fully customisable via withDefaultVersionIncrementer().


3.14 — Logging System

flowchart LR
    ORM["PikaORM\n internalLoggerLevel\n logQueries: boolean\n logCaching: boolean"] --> PL["PikaLogger interface\nlog(Level, String, Object...)"]
    PL --> DL["DefaultLogger\n→ System.out/err"]
    PL --> CL["Custom logger\n(via withLogger())"]

    ORM -->|"logQueries=true"| QL["Query log at INFO\n SQL + args on every operation"]
    ORM -->|"logQueries=false (default)"| QD["Query log at DEBUG\n(silent in most setups)"]
    ORM -->|"suppressQueries()"| SQ["SafeAutoCloseable\nTemporarily sets logQueries=false"]

Log levels: TRACE, DEBUG, INFO, WARN, ERROR


3.15 — Streaming API

The streaming path bypasses PikaList and returns a lazy java.util.stream.Stream<T>. It requires a caller-managed connection.

flowchart TD
    EC["orm.establishConnection()\nreturns SafeAutoCloseable"] --> Session["ConnectionSession pushed"]
    Session --> SF["orm.stream(sql, args, Class)\nor pikaClassQuery.stream()\nor pikaQuery.stream()"]
    SF --> RS["ResultSet opened\nnot pre-fetched"]
    RS -->|"Spliterator.tryAdvance()"| MAP["Mapping.newObjectFromResult()\non demand per row"]
    MAP --> OBJ["T object\nlifecycle.afterSelect()"]
    OBJ -->|"Stream.close() / forEach end"| CLR["ResultSet.close()\nautomatically via Spliterator"]
    Session -->|"try-with-resources closes"| Closed["ConnectionSession closed"]

[!IMPORTANT] You must call orm.establishConnection() and keep the returned SafeAutoCloseable open in a try-with-resources block for the entire duration of stream consumption. The stream holds an open ResultSet that depends on the connection.


3.16 — Paging API

flowchart TD
    subgraph PagingConfig ["Paging Configuration"]
        PC1["page(long page)\npageSize(int pageSize)"]
        PC1 --> PC2["generateSQL()\nappends: LIMIT {pageSize} OFFSET {(page-1)*pageSize}"]
    end

    subgraph PagingHelpers ["Navigation Helpers (PikaQuery / PikaClassQuery)"]
        PH1["totalCount()\n→ SELECT COUNT(*) FROM (inner query) T"]
        PH2["totalPages()\n→ ceilDiv(totalCount, pageSize)"]
        PH3["isFirstPage() / isLastPage()\nhasNextPage() / hasPreviousPage()"]
        PH4["nextPageURL(url) / previousPageURL(url)\n→ updates 'page' query parameter in URL"]
    end

Quick reference

Every operation at a glance: see the Cheat Sheet.