The SQLite ORM framework turns a plain Java class into a typed data access object at build time. The Maven plugin reads @Entity / @Id / @Column annotations from the project’s compiled bytecode, generates one <SimpleName>Cn1Dao per entity in the source class’s package, and hands the dao to com.codename1.orm.EntityManager for typed retrieval. The dao issues prepared statements through com.codename1.db.Database, the same surface every cn1 SQLite port exposes.

The framework is a JPA-inspired, simplified alternative to the existing imperative SQLMap. It doesn’t replace SQLMap; both can be used side by side.

Annotate the entity

@Entity(table = "users")
public class User {

    @Id(autoIncrement = true)
    public long id;

    @Column(name = "full_name", nullable = false)
    public String name;

    public int age;

    public java.util.Date createdAt;

    @DbTransient
    public String cacheKey;                            // (1)

    public User() { }
}
  1. Excluded from the generated table.

Property fields work the same way; the dao calls Property#get and Property#set for read and write.

Field-level annotations

AnnotationPurpose

@Id

Marks a primary-key field. The legacy DAO uses one key; managed sessions also support composite assigned keys. autoIncrement=true (the default) emits INTEGER PRIMARY KEY AUTOINCREMENT and back-fills the field after insert.

@Column(name)

Rename the column. Default: the field name.

@Column(type)

Override the SQL type. Default: inferred from the Java type (String → TEXT, int/long → INTEGER, float/double → REAL, boolean → INTEGER, java.util.Date → INTEGER (epoch millis), byte[] → BLOB).

@Column(nullable=false)

Adds NOT NULL to the column declaration.

@DbTransient

Excludes the field from the table.

Use the dao

EntityManager is a thin façade over Database. The underlying connection is reachable through em.database() for raw SQL when the dao surface isn’t enough. Transactions:

em.beginTransaction();
try {
    users.insert(u1);
    users.insert(u2);
} catch (IOException e) {
    em.rollbackTransaction();
    throw e;
}
// outside the catch on purpose: a commit that fails has already ended
// the transaction, so rolling back here would throw "No transaction is
// in progress" over the top of the real failure
em.commitTransaction();

Managed sessions and relationships

The managed ORM layer uses the same entity annotations on the client and the backend. New mappings, including relationships, @Version, and @GeneratedValue, live in com.codename1.annotations.db. The existing @Entity, @Id, @Column, and @DbTransient annotations remain in com.codename1.annotations. Open a com.codename1.orm.session.Session with EntityManager.openSession(). Keep one session per unit of work. A session is not thread-safe; a backend pooled entity manager can still be shared between requests, with a separate session for each request.

The existing scalar Dao API keeps its immediate-write behavior. Entities using relationships, @Version, composite IDs, generated strategies, inheritance, converters, indexes, or embedded values use sessions; asking for a legacy DAO for these mappings throws an error rather than dropping managed behavior.

A managed session maintains one instance per entity identity, detects changes at flush time, and flushes before commit and before queries in a transaction. persist, merge, and remove require an active transaction. merge returns the managed copy; it doesn’t attach the supplied detached instance. Merging an entity already scheduled for removal is rejected.

Session session = em.openSession();
try {
    session.beginTransaction();
    Customer customer = session.find(Customer.class, customerId);
    customer.name = "Updated name";
    session.commitTransaction();
} finally {
    session.close();
}

The public Session, Query, and JpqlQuery interfaces describe application operations. Generated metadata, field-access hooks, and SQL adapters live under com.codename1.impl.orm; application code shouldn’t depend on those classes.

Closing a session rolls back any active transaction. It never commits. A rollback clears the persistence context. An optimistic-lock or database failure requires rollback before the session can continue writing if the database still has an active transaction. When a failed client commit has already ended the underlying transaction, the session detaches its entities and becomes inactive. Check isTransactionActive() before rolling back; an inactive session can start a new transaction. Closing the session doesn’t close its entity manager’s database or pool. Backend Database.beginTransaction() reserves the connection to the initiating thread until commit, rollback, or close. Other threads wait; the initiating thread must finish the transaction.

Association mappings

Use @ManyToOne, @OneToOne, @OneToMany, and @ManyToMany on public entity fields. To-many fields use List<T>, Set<T>, or Map<K,T>. @JoinColumn names an owning foreign-key column. mappedBy identifies the owning field on the other entity. An owning to-many relationship uses a join table; @JoinTable customizes its name and key columns.

To-one relationships default to FetchType.EAGER; to-many relationships default to FetchType.LAZY. Override these defaults with the annotation’s fetch member. CascadeType selects the persistence operations to propagate. orphanRemoval=true removes children dropped from an initialized relationship. Application code maintains both sides of bidirectional object relationships. Removal cascades on inverse to-one and to-many relationships account for pending owning-side moves, including join-table changes, before choosing which children to remove.

The build enhances entity classes and field-access callers so a normal public field read can initialize a lazy relationship. No runtime proxy generator or reflection is needed. session.isLoaded(entity, "relationship") inspects load state without fetching; session.initialize(entity, "relationship") fetches explicitly. An unloaded relationship can’t be read after detaching its entity or closing its session: this throws LazyInitializationException. Generated @Mapped serialization code rejects unloaded associations rather than fetching them during serialization; initialize the relationships needed for the output first.

Fetching is synchronous. On the client, perform database work off the EDT and initialize the data needed by the UI before closing the session. Already-loaded data remains readable after the session closes.

Queries and counters

The session query builder uses Java field paths, including relationship paths:

List<Order> orders = session.query(Order.class)
    .eq("customer.name", "Alice")
    .orderBy("id", true)
    .limit(20)
    .fetch("customer")
    .list();

fetch overrides laziness for a direct relationship. To-one and inverse foreign-key collection fetches with single-column keys are batched. Ordered, map, composite-key, and join-table collections currently use individual loaders. Root pagination happens before loading fetched collections. query.count() counts matching entities without loading them and ignores pagination, ordering, and joins needed only by ordering. session.count(entity, "relationship") counts a relationship without materializing it.

createQuery accepts an explicit JPQL subset: entity or scalar selects, relationship joins, named parameters, comparisons and boolean predicates, aggregates, grouping/having, ordering, nested queries in scalar predicates, and bulk update/delete. String and numeric literals and named values become bound SQL parameters. IN :values and IN (:values) accept an Iterable or object array, including an empty collection. Unsupported syntax is rejected. Multiple scalar projections return Object[]. Entity names, field names, aliases, and named parameters accept Java identifier characters, including supplementary Unicode letters. Comparisons require at least one operand with a known storage type; two untyped parameters can’t be compared directly. IS NULL and IS NOT NULL also require a known operand type; a standalone untyped parameter is rejected. A scalar nested query requires an aggregate without GROUP BY, so it returns at most one row. IN and EXISTS may use queries that return multiple rows. A DISTINCT query can order only by selected expressions. Text range comparisons and LIKE patterns are case-sensitive across supported databases. An explicit LIKE …​ ESCAPE uses a literal or named parameter for both the pattern and the escape character.

List<Order> orders = session.createQuery(
    "select o from Order o where o.customer.name = :name order by o.id",
    Order.class).setParameter("name", "Alice").list();

Bulk mutations use executeUpdate() inside a transaction. Repeated assignment targets are rejected during query creation. Aggregate functions are allowed inside scalar nested queries, but not directly in assignments or bulk predicates. On MySQL and MariaDB, nested queries that read the bulk mutation target table are rejected during query creation. They flush pending managed changes, bypass per-entity callbacks and cascades, and clear the context so later reads can’t reuse stale entities. Bulk assignments to required subtype fields need a non-null literal or named parameter. Other assignment expressions are rejected for those fields. Literal and bound null assignments to primitive Java fields are rejected on both runtimes. Primitive assignments also reject expressions that may return null. Use COALESCE(expression, 0) to supply a non-null fallback for nullable numeric fields, division by a variable or zero, and nullable aggregate results. A scalar aggregate without GROUP BY or HAVING can be assigned directly when its result is non-null, such as COUNT or COALESCE(SUM(…​), 0). Parameters needed to keep an expression non-null are checked before execution. Field-to-field assignments require matching enum types and converter mappings, including fields inside expressions. Integral attributes reject real-valued assignment expressions and floating-point parameter values. Assignments also check the mapped integer range, including computed results and the narrower ranges of byte, short, and char fields. Builder predicates and JPQL parameters also check the mapped integer range. Assignments to float and Float reject finite overflow and nonzero results that would round to zero, including arithmetic expressions and nested query results. Floating-point literals that overflow or underflow double precision are rejected when the query is created.

An int or long field annotated @Version is maintained by the session. Updates and deletes compare its previous value and throw OptimisticLockException when another writer changed or deleted the row. session.increment(Entity.class, id, "counter", delta) performs database arithmetic, advances the version when present, and refreshes an already-managed instance. It returns false for a missing row or an increment outside the counter/version range. It requires a transaction and an int, Integer, long, or Long attribute without a converter, including properties holding those numeric types. Other values stored as integers, such as booleans and dates, aren’t counters. Invalid entity types, fields, or identifiers are rejected before pending changes are flushed.

Scalar field projections return the mapped Java values, including enums, dates, booleans, property values, and converter-backed objects. Integral SUM results use Long on every database, and AVG returns Double. Joined paths may end in embedded fields. LOWER, UPPER, TRIM, and ABS transform stored values. Their results and comparison operands use SQL storage values. These results can’t be assigned directly to a field with a converter. MIN, MAX, COALESCE, and NULLIF preserve compatible converter mappings. COALESCE field operands must share an enum type or converter mapping so the result can be decoded consistently. Field-to-field comparisons also require matching enum types and converter mappings. Scalar projections need a known storage type when the query is created. Bare parameters and expressions such as coalesce(:a,:b) are rejected. Association projections are also rejected; select an explicit field such as order.customer.id or select the relationship target as a root entity. Queries accept at most 999 bound parameters in total, including expanded IN lists. Larger lists are rejected before query execution. Generated key columns also have a combined limit of 3,072 bytes per key, counting up to four bytes per text character. This applies to primary keys, indexes, and collection keys, and is checked before schema creation. Explicit column type declarations remain the application’s schema contract. MySQL and MariaDB reject default binary primary keys and secondary indexes before schema creation; these keys need an explicit bounded column type declaration.

JPQL arithmetic requires numeric operands. Parameters in integral expressions must be integer values; use a real operand such as 1.0 for fractional arithmetic. Division of integral operands truncates toward zero on every database. Division and remainder by zero return null. Remainder accepts integral operands only. Integral addition, subtraction, multiplication, division, and negation fail if the result exceeds the signed 64-bit range, including in bulk assignments. Aggregate queries require each non-aggregate field to be covered by GROUP BY, including fields in HAVING and ORDER BY and outer fields referenced inside correlated nested queries. LENGTH counts characters on every backend.

Values and callbacks

@MappedSuperclass contributes public inherited fields to a concrete entity. @Embedded flattens a public @Embeddable value into columns prefixed with the embedding field name. An all-null embedded value reads as null. Embedded fields can be used in query paths. Enums are stored by name. Mapped query parameters and literals accept the declared enum type, a valid constant name, or null. Invalid names and constants from other enum types are rejected before execution. Fluent and JPQL predicates require Boolean inputs for ordinary Boolean fields; fields with converters accept their declared domain values. Boolean values use numeric storage; native BOOL and BOOLEAN column declarations are rejected.

Public no-argument void methods can receive @PrePersist, @PostPersist, @PreUpdate, @PostUpdate, @PreRemove, @PostRemove, and @PostLoad callbacks. Entities with lifecycle callbacks require openSession(), even without relationships or version fields. Relationships added by @PrePersist, @PostPersist, or @PreUpdate follow PERSIST cascades in the current flush, including newly created collections. Flush rechecks entries changed by later callbacks, including newly inserted rows, until the managed state matches the database. If callbacks keep changing state for 1,000 passes, flush fails and the transaction requires rollback.

Inherited callbacks run before subclass callbacks; overriding a callback method replaces the inherited declaration. Client scalar Property fields participate in dirty checking and counters. Backend entities use plain fields because the backend runtime doesn’t contain the client properties package.

@Convert(converter=CodeConverter.class, storageType=String.class) maps a custom basic value to a SQL scalar. The converter implements com.codename1.orm.session.AttributeConverter<Code,String> and has a public no-argument constructor. It receives null values too, including mapped JPQL NULL literals. Query-builder predicates and JPQL comparison parameters and literals apply the field’s converter. The builder converts values before choosing equality or SQL null predicates. JPQL and builder IS NULL tests also apply the mapped null conversion. A converter that stores null as a non-null sentinel uses a comparison with that sentinel. Values must match the converter’s input type; use a named parameter for custom objects. Identifiers and versions can’t use converters. This includes every component of an embedded identifier.

Identity and inheritance

Keep @Id for identity-column generation. Managed mappings require int, long, Integer, or Long identity fields; client IntProperty and LongProperty fields are supported too. Use @Id(autoIncrement=false) for assigned keys. @GeneratedValue selects UUID (for String IDs), SEQUENCE, or TABLE (for int/long IDs). PostgreSQL uses native sequences; other engines use a transactional generator table. UUIDs are version 4. Identity IDs become available on flush; the other strategies allocate on persist. Production migrations must provision the selected generators. Mappings with a cycle of required owning to-one links and identity-generated keys are rejected during processing: the first insert can’t supply every required key. SQLite and PostgreSQL support required cycles with keys assigned before insertion because their foreign keys are deferred until commit. MySQL and MariaDB enforce foreign keys immediately, so sessions reject required cycles before creating tables or writing data, even when the keys are known.

Identifier components are normalized and checked against their storage types before a cache lookup or SQL query, including the mapped integer range. Incompatible values raise IllegalArgumentException.

For composite IDs, use several @Id(autoIncrement=false) fields or one @EmbeddedId whose type is @Embeddable. Lookup accepts Identifier.of(component1, component2) in declared-field order; an embedded-ID mapping also accepts its key object. Relationships and join tables include all key components. Generated composite IDs are rejected.

@Inheritance on an entity root enables single-table inheritance, including abstract roots and polymorphic queries. @DiscriminatorValue("customer") assigns a stable discriminator to a concrete subtype. A base-class lookup and a subclass lookup return the same managed instance for the same row. A subtype query can’t address fields or relationships declared only by a sibling subtype. The physical table still includes the complete hierarchy for row loading. Use explicit discriminator values when class names may change. Every hierarchy member uses the root’s identifier generator, including its default generator name.

Lists, maps, and owned values

Owning Lists store positions in a list_position join-table column by default, so repeated many-to-many links and reordering survive reload. @OrderColumn can name that column explicitly. @OrderBy("name ASC, id DESC") orders an entity collection by target fields at load time; the position column still keeps repeated links distinct. An inverse mappedBy collection can use @OrderBy.

For an entity map, @MapKey(name="code") derives keys from a basic field on the target entity. The map key type must match that field’s Java type before conversion, using the boxed type for a primitive field. Keys must be non-null and unique in that collection. When changing the target key field, update the Java map keys as well.

@ElementCollection stores owned scalar values in a separate table. It supports List, Set, and Map with String keys. Values may be String, boxed primitive types, or java.util.Date. Lists preserve order and duplicate values. Map columns can be named with @MapKeyColumn; @JoinTable names the collection table. Element-map keys accept at most 255 Unicode code points on every database. query.containsElement("tags", "important") tests membership without loading the collection. Changing a mutable Date value is detected during flush.

Schema and locking

Run session.createTables() outside application transactions for development schemas. It creates missing entity/collection tables, primary and foreign keys, one-to-one uniqueness, and relationship indexes. @Column(unique=true) and @Entity(indexes=@Index(name="by_name", fields={"name"})) declare additional indexes. Generated text keys, indexed fields, and foreign keys accept at most 255 Unicode code points during managed inserts, updates, and bulk assignments on every database. Specify @Column(type=…​) to control the SQL type and its value limits yourself.

session.validateSchema() checks mapped entity columns, storage-type families, whether null values are allowed, and primary-key membership in entity and collection tables. It doesn’t migrate schemas or validate every database-specific constraint. Existing tables are left to explicit migrations. In particular, a legacy client DAO’s text char columns need migration to the managed layer’s numeric UTF-16 storage when moving to a managed schema.

Backend sessions support find(type, id, LockMode.PESSIMISTIC_READ) and PESSIMISTIC_WRITE, or lock(entity, mode), inside a transaction. PostgreSQL and MySQL/MariaDB use their row-lock clauses. SQLite rejects these modes explicitly; it can’t provide row-level pessimistic locks. Invalid or unsupported lock modes are rejected before pending changes are flushed.

Supported boundary

This API provides managed persistence through CN1 annotations; it’s not a JPA provider or a Hibernate binary replacement. Entity access uses public fields. Getter/setter access mappings, property-backed associations, joined/table-per-class inheritance, embeddable element collections, and independently stored entity-map keys aren’t supported. Nested fetch graphs and mixed entity/scalar JPQL projections are also outside the current query subset. Use the builder’s count() for distinct composite identities. Native device execution and each backend database version still require application integration testing.

Validation

The annotation processor fails the build when:

  • @Entity lands on an interface, or an abstract entity has no @Inheritance root.

  • A concrete entity has no public no-arg constructor.

  • No identifier is mapped, or a composite identifier requests generated values.

  • Persistent column/table/index names conflict, or mappedBy doesn’t resolve.

  • A field’s static type or relationship mapping is unsupported.

Errors are accumulated so the first build run reports every offending entity at once.

How the plumbing works

cn1:process-annotations writes one <SimpleName>Cn1Dao per @Entity in the source class’s package (com.example.UserCn1Dao next to com.example.User), plus a single cn1app.DaoBootstrap whose constructor calls UserCn1Dao.register(), OrderCn1Dao.register(), …​ for every accepted @Entity class. At app start:

  • On iOS / Android the build server probes the project zip for cn1app/DaoBootstrap.class and splices new cn1app.DaoBootstrap(); into the per-build application stub before Display.init. ParparVM rename and R8 obfuscation rewrite the call site and the generated dao together, so the direct symbol reference stays valid after the pass.

  • On the JavaSE simulator and desktop run JavaSEPort#postInit loads the bootstrap via Class.forName("cn1app.DaoBootstrap"). Class-loading is the legitimate path here — JavaSE runs unobfuscated.

Projects with no @Entity classes produce no bootstrap; the build server probe falls through and the registry stays empty.

The runtime registry is keyed on Class#getName(); obfuscation renames the call sites and the registered keys together within a single execution. The keys are never persisted across builds, so the renaming has no observable effect on behavior.