© 2023-present The original authors.

Copies of this document may be made for your own use and for distribution to others, provided that you do not charge any fee for such copies and further provided that each copy contains this Copyright Notice, whether distributed in print or electronically.

1. Preface

1.1. Introduction

Army is a better Java SQL framework offering the following core capabilities:

  • A better way to write SQL in Java

  • A better blocking ORM framework

  • A higher-level database driver

1.2. Design Philosophy

  1. Don’t create a new world — just map the real world. Respect database standards and dialect differences.

  2. We need standards, and we need dialects. That’s how the real world works.

1.3. Project Conventions

  • Interfaces or classes whose names begin with an underscore (_) are private to the Army framework and should not be directly depended upon in user code.

1.4. Core Concepts at a Glance

Using Army follows a clear pipeline:

Domain classes (annotated)
→ Annotation processor generates metamodel classes at compile time
→ Criteria API builds SQL statements
→ Dialect parser generates native SQL
→ Session API executes statements
→ Executor (StmtExecutor) drives JDBC execution
  • Domain Class — A POJO annotated with @Table, @Column, etc., describing the mapping between Java objects and database tables.

  • Metamodel Class — A _-suffixed class auto-generated at compile time by ArmyMetaModelDomainProcessor, providing type-safe field references.

  • Criteria API — A Java DSL for building SQL statements (SELECT, INSERT, UPDATE, DELETE).

  • Session API — A unified entry point for executing statements and managing transactions.

  • Dialect Parser — Translates standard Criteria into native SQL for each database.

  • Executorio.army.executor.StmtExecutor, responsible for JDBC statement execution, parameter binding, and result set processing.

2. Quick Start

2.1. Maven Dependency Configuration

2.1.1. Core Dependency

<dependencies>
    <dependency>
        <groupId>io.qinarmy</groupId>
        <artifactId>army-jdbc</artifactId>
        <version>0.6.8-SNAPSHOT</version>
    </dependency>
    <dependency>
        <groupId>io.qinarmy</groupId>
        <artifactId>army-postgre</artifactId>
        <version>0.6.8-SNAPSHOT</version>
    </dependency>
</dependencies>

2.1.2. Annotation Processor Configuration

Configure the annotation processor in the Maven module that contains your domain classes:

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
                <annotationProcessors>io.army.modelgen.ArmyMetaModelDomainProcessor</annotationProcessors>
            </configuration>
        </plugin>
    </plugins>
</build>

2.2. Your First Domain Class

Map a Java class to a database table using annotations:

@Table(name = "stock",
       indexes = @Index(name = "uni_stock_exchange_code", fieldList = {"exchange", "code"}, unique = true),
       comment = "stock")
public class Stock {

    //auto-increment primary key use below:
    //  @Generator(type = GeneratorType.POST)
    @Generator(value = "io.army.generator.snowflake.Snowflake8Generator",
               params = @Param(name = "startTime", value = "1779012232202"))
    @Column
    public long id;

    @Column
    public LocalDateTime createTime;

    @Column
    public LocalDateTime updateTime;

    @Column(comment = "version")
    public int version;

    @Column(notNull = true, updatable = false, precision = 5, comment = "exchange code")
    public String exchange;

    @Column(notNull = true, updatable = false, precision = 15, comment = "stock code")
    public String code;

    @Column(notNull = true, precision = 130, comment = "company name")
    public String name;

    @Column(notNull = true, precision = 10, defaultValue = "'NORMAL'", comment = "listing status")
    public StockStatus status;

    @Column(precision = 10, scale = 2, defaultValue = "0.00", comment = "offer price")
    public BigDecimal offerPrice;

    @Column(defaultValue = "DATE '1970-01-01'", comment = "listing date")
    public LocalDate listingDate;

}

2.3. Compile-Time Metamodel Generation

ArmyMetaModelDomainProcessor automatically processes @Table-annotated classes at compile time and generates a static metamodel class Stock_:

@Generated(value = "io.army.modelgen.ArmyMetaModelDomainProcessor",
        date = "2024-01-02 07:13:54.605524+08:00",
        comments = "stock")
public abstract class Stock_ {

        private Stock_(){
                throw new UnsupportedOperationException();
        }

        public static final SimpleTableMeta<Stock> T;

        static {
                T = _TableMetaFactory.getSimpleTableMeta(Stock.class);

                final int fieldSize = T.fieldList().size();
                if(fieldSize != 10){
                        throw _TableMetaFactory.tableFiledSizeError(Stock.class,fieldSize);
                }
        }

    // Field name constants (type-safe references)
    public static final String NAME = "name";
    public static final String OFFER_PRICE = "offerPrice";
    public static final String EXCHANGE = "exchange";
    public static final String CODE = "code";
    // ... other fields

    // Field metadata
    public static final FieldMeta<Stock> name = T.field(NAME);
    public static final FieldMeta<Stock> offerPrice = T.field(OFFER_PRICE);
    public static final PrimaryFieldMeta<Stock> id = T.id();
    // ... other field metadata
}
Metamodel classes are the key to building type-safe Criteria statements. All field references go through metamodel constants, eliminating string-based typos.

2.4. Creating a SessionFactory

2.4.1. Blocking Mode

public void createSessionFactory() {
    final Map<String, Object> envMap = new HashMap<>();
    envMap.put(ArmyKey.DATABASE.name, Database.MySQL);
    envMap.put(ArmyKey.DIALECT.name, MySQLDialect.MySQL80);

    final DataSource dataSource = createDataSource();
    sessionFactory = SyncFactoryBuilder.builder()
            .name("army-test")
            .packagesToScan(List.of("io.army.example.stock.domain"))
            .datasource(dataSource)
            .environment(StandardEnvironment.from(envMap))
            .build();
}

2.4.2. What’s Required vs. Optional

FactoryBuilder methods come with clear markers in the source code. Here’s the breakdown:

Method Status Notes

name(String)

Required

SessionFactory name, must be non-empty.

datasource(Object)

Required

JDBC DataSource instance. Can be a ReadWriteSplittingDataSource for read/write splitting.

packagesToScan(List)

Required

List of packages where domain classes (annotated with @Table) reside.

environment(ArmyEnvironment)

Required

Environment configuration passed via StandardEnvironment.from(map). Must contain at least DATABASE and DIALECT (see below).

classLoader(ClassLoader)

Optional

Default: Thread.currentThread().getContextClassLoader()

loadStaticModel(boolean)

Optional

Default false. Enable to load static metamodel classes after TableMeta creation for extra safety.

validateOnStartup(boolean)

Optional

Default false. Enable for stronger consistency validation at startup.

jsonCodec(JsonCodec) / xmlCodec(XmlCodec)

Optional

Custom JSON/XML codec.

fieldGeneratorFactory(FieldGeneratorFactory)

Optional

Custom field generator factory.

ArmyKey environment entries — only two are truly mandatory, all others have sensible defaults:

ArmyKey Required? Notes

DATABASE

Yes

Database.MySQL / Database.PostgreSQL / Database.SQLite. No default — framework needs to know which database to operate against.

DIALECT

Yes

MySQLDialect.MySQL80 / PostgreDialect.POSTGRE15 / SQLiteDialect.SQLite34. No default — must match your database version.

ZONE_OFFSET

No

Timezone offset. When omitted, the framework uses database defaults.

DDL_MODE

No

Default: DdlMode.UPDATE (create tables if absent, update columns if changed).

VISIBLE_MODE

No

Default: AllowMode.NEVER (disable soft-delete visible field).

SQL_LOG_MODE

No

Default: SqlLogMode.OFF. Set to SqlLogMode.BEAUTIFY_DEBUG during development.

All other keys

No

Default values are defined in ArmyKey / SyncKey. See the source for the complete list.

That means the minimal environment setup looks like the example above — just DATABASE and DIALECT. In the test example at FactoryUtils.java, extra keys like SQL_LOG_MODE and VISIBLE_MODE are set for development convenience, but they are not required for production.

If you forget DATABASE or DIALECT, the framework throws IllegalStateException with a clear message — no silent failures.

2.5. Your First Insert

// 1. Build the insert statement
final Insert stmt;
stmt = SQLs.singleInsert()
        .insertInto(Stock_.T)
        .values(stockList)  // List<Stock> — id & managed fields are auto-generated
        .asInsert();

// 2. Execute the insert
try (SyncLocalSession session = sessionFactory.localSession()) {
    long rows;
    rows = session.update(stmt);
    System.out.println("Inserted " + rows + " row(s)");
}
Generated SQL (PostgreSQL)
INSERT INTO stock (id, create_time, update_time, version, exchange,
                   code, name, status, offer_price, listing_date)
VALUES (?, ?, ?, ?, ?,
        ?, ?, ?, ?, ?)
     , (?, ?, ?, ?, ?,
        ?, ?, ?, ?, ?)  -- one row per Stock object
     , ...

2.6. Your First Query

// 1. Build the query statement
final Select stmt;
    stmt = SQLs.query()
        .select(Stock_.id, Stock_.name, Stock_.offerPrice)
        .from(Stock_.T, AS, "s")
        .where(Stock_.name.equal("Tesla"))
        .asQuery();

// 2. Execute the query
try (SyncLocalSession session = sessionFactory.localSession()) {
    Stock stock ;
    stock = session.queryOne(stmt, Stock.class);
    System.out.println(stock);
}
Generated SQL (PostgreSQL)
SELECT s.id, s.name, s.offer_price
FROM stock AS s
WHERE s.name = ?  -- param: 'Tesla'

3. Domain Model Definition

Domain model definition is the starting point of Army. This chapter walks you through how to use annotations to tell the framework: "this Java class maps to that database table, this field maps to that column, this field needs a Snowflake ID generator…​" — all in plain Java, no XML.

Behind the scenes, Army’s TableMetaUtils and FieldMetaUtils read these annotations at runtime to build TableMeta objects, which are the immutable metadata blueprints that drive SQL generation. The entry point is DefaultTableMeta#getTableMeta(Class, MetaContext), which reads @Table, walks the class hierarchy, creates FieldMeta instances via TableFieldMeta#createFieldMeta, and caches the result — so each domain class is parsed only once.

3.1. Annotation Overview

Here’s a quick reference of all Army annotations. Don’t worry about memorizing them now — each is explained in detail with real examples below.

Annotation Purpose Target

@Table

Maps a class to a database table (name, comment, indexes)

Class

@Column

Maps a field to a database column (name, type, nullability, default)

Field

@Generator

Specifies how a field’s value is generated (e.g., Snowflake ID)

Field

@GeneratorType

When generation happens: PRECEDE / POST / RUNTIME / DEFAULT

Enum

@Param

A name-value pair passed to a FieldGenerator

Annotation member

@FieldParam

Binds a field name to parameter overrides (inside @OverrideParams)

Annotation member

@OverrideParams

Overrides generator parameters of inherited fields

Class

@Index

Defines a database index (name, fields, uniqueness, type)

Class (inside @Table)

@IndexField

Per-column index configuration (collation, opclass, sort)

Class (inside @Index)

@SortOrder

Index column sort: ASC / DESC / DEFAULT

Enum

@NullsOrder

Index column NULL ordering: NULLS_FIRST / NULLS_LAST / DEFAULT

Enum

@Mapping

Custom Java↔SQL type mapping

Field

@Inheritance

Marks the root of a single-table inheritance hierarchy

Class

@DiscriminatorValue

Discriminator enum value for a child class in inheritance

Class

@MappedSuperclass

Marks a base class whose fields are inherited by subclasses

Class

@DdlMode

Controls DDL generation: CREATE / NONE / DEFAULT

Enum

@Codec

Marker for future custom encoding/decoding

Field

Many annotation attributes support placeholders (${DEFAULT}, ${RUNTIME}, ${OPTIONAL}, ${DEFAULT_VALUE}) that are resolved at runtime from the configuration file META-INF/army/TableMeta.properties on the classpath. See [TableMeta.properties — Runtime Configuration] for details.

3.2. @Table

@Table is placed on a Java class to tell Army: "this class maps to a database table." It controls the table name, index definitions, DDL behavior, and more. At runtime, TableMetaUtils.tableName and TableMetaUtils.tableComment resolve its values; TableMetaUtils.schemaMeta handles catalog/schema, and TableMetaUtils.tableCreateDdl determines whether DDL is generated.

Both name and comment are required attributes. If you omit them (or use empty strings), the framework throws MetaException at startup. This is intentional — Army refuses to guess silently.

3.2.1. Attributes

  • name (required) — The database table name. Must not be in camelCase. Supports placeholders: ${DEFAULT} auto-converts the class simple name to snake_case (e.g., StockQuotes"stock_quotes"); ${RUNTIME} requires a property entry.

  • comment (required) — Table comment for DDL. Supports ${DEFAULT} (auto-derive from class simple name) and ${RUNTIME}.

  • catalog (optional) — Catalog name, defaults to the database default. Property key: {className}.Table.catalog.

  • schema (optional) — Schema name, defaults to the user’s default schema. Property key: {className}.Table.schema.

  • indexes (optional) — Array of @Index definitions. If no index covers the id field, a unique primary key index is created automatically.

  • immutable (optional, default false) — When true, all UPDATE DML on this table is disallowed. Parent tables with @Inheritance cannot be marked as immutable — Army detects this at startup and throws MetaException.

  • allColumnNotNull (optional, default false) — When true, every column gets NOT NULL in DDL. Note: reserved fields (id, createTime, updateTime, version, visible), discriminator fields, and primitive-typed fields are always NOT NULL regardless of this setting.

  • ddlMode (optional, default CREATE) — Controls DDL generation. See @DdlMode.

  • tableOptions (optional) — Raw SQL fragment appended to CREATE TABLE, e.g., "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4".

  • partitionOptions (optional) — SQL partition clause, e.g., "PARTITION BY RANGE (YEAR(create_time))".

3.2.2. Examples from stock-data

// Stock: table with a unique composite index
@Table(name = "stock",
       indexes = @Index(name = "${DEFAULT_VALUE}", unique = true,
                        fieldList = {"exchange", "code"}), // auto-generates: uni_stock_exchange_code
       comment = "stock")
public class Stock { }

// StockQuotes: table with unique index on (stockId, date)
@Table(name = "stock_quotes",
       indexes = @Index(name = "${DEFAULT_VALUE}", unique = true,
                        fieldList = {"stockId", "date"}), // auto-generates: uni_stock_quotes_stockId_date
       comment = "daily stock quotes")
public class StockQuotes { }

// UploadRecord: table with multiple non-unique indexes
@Table(name = "upload_record",
       indexes = {
           @Index(name = "${DEFAULT_VALUE}", fieldList = "userId"),
           @Index(name = "${DEFAULT_VALUE}", fieldList = "fileHash")
       },
       comment = "uploaded document record")
public class UploadRecord { }
Use ${DEFAULT_VALUE} as the index name to let the framework auto-generate uni_{table}_{col} or idx_{table}_{col}. Use ${DEFAULT} only when you want to override the generated name via META-INF/army/TableMeta.properties. For most cases, ${DEFAULT_VALUE} is the right choice.

3.3. @Column

@Column maps a Java field to a database column. Without @Column, a field is just a plain Java field — not persisted, not included in any SQL, not part of the table schema. The framework’s TableMetaUtils.columnName resolves the column name, TableMetaUtils.columnPrecision/columnScale resolve numeric attributes, and FieldMetaUtils.columnComment resolves the comment text.

3.3.1. Built-in Conventions (Zero-Config)

Army provides smart defaults for many @Column attributes — you only need to specify what differs from the convention:

Attribute Convention How It Works

name (not set)

camelCasesnake_case

offerPrice"offer_price", stockId"stock_id"

comment (not set)

Auto-generated for reserved fields

id"primary key", createTime"create time", updateTime"update time", version"version that’s update counter of row", visible"visible for soft delete"

precision (not set)

By Java type

String255, BigDecimal16, Enum→max label length + 5, UUID36, Vector1024

scale (not set)

By Java type

BigDecimal3, temporal types (LocalDateTime/OffsetDateTime/LocalTime/OffsetTime)→6 (microsecond precision)

defaultValue (not set)

By Java type

Numeric types→"0", String"''“, `LocalTime→”'00:00:00'“, LocalDate→”'1970-01-01'“, LocalDateTime→”'1970-01-01 00:00:00'"`

notNull (not set)

false — except for reserved fields

id/createTime/updateTime/version/visible and discriminator fields are always NOT NULL

collation (not set)

Empty — no collation clause

Set explicitly only when needed, e.g., "en_US.utf8"

For business fields (non-reserved), if you don’t provide a comment, Army throws MetaException. Reserved fields get automatic comments, but your business columns must be documented.

3.3.2. Reserved Fields

Army recognizes five field names as reserved — they are auto-managed by the framework and have special behavior:

Field Name NOT NULL INSERT Behavior UPDATE Behavior

id

Always

Value from @Generator

Never updatable

createTime

Always

Auto-set to current time

Never updatable

updateTime

Always

Auto-set to current time

Always updatable, auto-refreshed on every UPDATE

version

Always

Auto-set to 0

Always updatable, auto-incremented (optimistic lock)

visible

Always

Auto-set to true

Always updatable (soft delete toggle)

Reserved fields never need notNull — Army enforces it automatically. Reserved fields (except id) must not have @Generator — Army throws MetaException if you try.

3.3.3. Attributes

  • name (optional) — Column name. If empty, camelCase→snake_case. Supports ${DEFAULT} and ${RUNTIME}. Property key: {className}.{fieldName}.Column.name.

  • comment (optional) — Column comment for DDL. Reserved fields auto-generate. Enum fields with ${DEFAULT} auto-generate "enum, @see {EnumClass}". Business fields without a value throw MetaException. Supports ${DEFAULT} and ${RUNTIME}.

  • notNull (optional, default false) — Declares NOT NULL. Forced true when: the field is reserved, a discriminator, primitive-typed, or Table.allColumnNotNull is true.

  • insertable (optional, default true) — Whether included in INSERT. Overridden for reserved fields: createTime/updateTime/version/visible are always insertable; fields with PRECEDE generators are always insertable; id with POST generator is insertable only on child tables.

  • updatable (optional, default true) — Whether included in UPDATE. Overridden: id/createTime/discriminator are never updatable; updateTime/version/visible are always updatable; fields on immutable tables are never updatable.

  • precision (optional, default -1) — Total digit count or length. Defaults by type (see table above). Use Column.DEFAULT_EXP (-8150) to resolve from META-INF/army/TableMeta.properties.

  • scale (optional, default -1) — Fractional digit count. Defaults by type (see table above). Use Column.DEFAULT_EXP to resolve from properties.

  • defaultValue (optional) — SQL expression for column default. String values must include quotes: "'NORMAL'"DEFAULT 'NORMAL'. Date values use SQL syntax: "DATE '1970-01-01'". Supports ${DEFAULT} for type-appropriate zero values.

  • collation (optional) — Column collation, e.g., "en_US.utf8". Property key: {className}.{fieldName}.Column.collation.

3.3.4. Override via TableMeta.properties

The following @Column attributes support placeholder expressions. Place the file at META-INF/army/TableMeta.properties on the classpath:

META-INF/army/TableMeta.properties
# File: META-INF/army/TableMeta.properties

# Override column name
# Used when @Column(name = "${DEFAULT}") or @Column(name = "${RUNTIME}")
com.example.domain.Stock.userName.Column.name=user_name

# Override column comment
com.example.domain.Stock.status.Column.comment=custom status

# Override column precision (e.g., increase VARCHAR length to 512)
com.example.domain.Stock.name.Column.precision=512

# Override column scale (e.g., increase decimal places to 4)
com.example.domain.Stock.offerPrice.Column.scale=4

# Override column default value (SQL expression)
com.example.domain.Stock.status.Column.defaultValue='ACTIVE'

# Override column collation
com.example.domain.Stock.name.Column.collation=en_US.utf8
Property keys are case-sensitive. The path component Column (with capital C) is a literal keyword — it must appear exactly as shown.

3.3.5. Examples from stock-data

// Simple column: auto name (exchange → exchange), NOT NULL, updatable only on INSERT
@Column(notNull = true, updatable = false, precision = 5, comment = "exchange code")
public String exchange;

// Decimal column with precision and scale → DDL: DECIMAL(10, 2)
@Column(precision = 10, scale = 2, defaultValue = "0.00", comment = "offer price")
public BigDecimal offerPrice;

// Enum column with a string default → DDL includes: DEFAULT 'NORMAL'
@Column(notNull = true, precision = 10, defaultValue = "'NORMAL'", comment = "listing status")
public StockStatus status;

// Date column with SQL default expression
@Column(defaultValue = "DATE '1970-01-01'", comment = "listing date")
public LocalDate listingDate;

// Managed field: auto-set by the framework on INSERT, never updatable
@Column
public LocalDateTime createTime;

3.4. @Generator

@Generator controls how a field’s value is produced — before INSERT (application-side, like Snowflake ID) or after INSERT (database auto-increment). @GeneratorType is the enum that tells the framework when to generate.

At runtime, TableFieldMeta’s constructor reads `@Generator and @GeneratorType to create GeneratorMeta instances. If the type is RUNTIME or DEFAULT, it calls FieldMetaUtils.loadGeneratorStrategy to look up GeneratorStrategy from META-INF/army/TableMeta.properties.

3.4.1. Generator Types

Type When to Use

PRECEDE (default)

Application generates the value before INSERT. Use for Snowflake IDs, UUIDs, or custom ID generation. The value from your generator is written into the INSERT statement.

POST

Database auto-increment column. No code runs — the DB assigns the value during INSERT, and Army reads it back afterward. Only supported on the id field, and the Java type must be int, long, Integer, Long, String, or BigInteger.

RUNTIME

Defer the decision to META-INF/army/TableMeta.properties. Army reads the key {className}.{fieldName}.Generator.type and loads a GeneratorStrategy. Property value format: POST, PRECEDE, or DEFAULT.

DEFAULT

Let the framework decide. For child tables in an @Inheritance hierarchy, defaults to POST; otherwise, behaves like PRECEDE.

Discriminator fields (the field named in @Inheritance) and reserved fields except id (createTime, updateTime, version, visible) must not have @Generator — Army throws MetaException at startup.

3.4.2. Override via TableMeta.properties

When @Generator(type = GeneratorType.RUNTIME) is used, the actual generator strategy is loaded from META-INF/army/TableMeta.properties. Two keys are supported:

META-INF/army/TableMeta.properties
# File: META-INF/army/TableMeta.properties

# Option 1: Switch generator type at runtime (POST, PRECEDE, or DEFAULT)
com.example.domain.Stock.id.Generator.type=POST

# Option 2: Load a custom GeneratorStrategy (takes precedence over .Generator.type)
# Format: fully.qualified.StrategyClass[:jsonParams]
com.example.domain.Stock.id.GeneratorStrategy=io.army.generator.Snowflake8GeneratorStrategy:{"startTime":1779111192831}

# Override SQL type mapping for the id column (optional)
com.example.domain.Stock.id.Mapping.value=io.army.mapping.SqlBigIntType
The GeneratorStrategy value contains a colon (:) separating the class name from optional JSON parameters. The JSON is parsed by the strategy’s static create(String) factory method.

3.4.3. Attributes

  • type (optional, default PRECEDE) — The GeneratorType enum value.

  • value (optional) — Fully-qualified class name of the FieldGenerator implementation. Required for PRECEDE. Ignored for POST (since POST uses the database, not application code).

  • params (optional) — Array of @Param name-value pairs passed to the generator at initialization.

3.4.4. Examples from stock-data

Each entity uses the Snowflake-8 generator with a unique startTime to produce non-overlapping ID ranges across tables:

// Stock entity: startTime=1779012232202
@Generator(value = "io.army.generator.snowflake.Snowflake8Generator",
           params = @Param(name = "startTime", value = "1779012232202"))
@Column
public long id;

// StockQuotes entity: different startTime for a different ID space
@Generator(value = "io.army.generator.snowflake.Snowflake8Generator",
           params = @Param(name = "startTime", value = "1779111192831"))
@Column
public long id;
Each table should use a different startTime so Snowflake produces non-colliding ID ranges. This prevents primary key conflicts when joining data across tables.

3.5. @Param

These three annotations work together to pass and override generator configuration. FieldMetaUtils.getOrcreateFieldParmMap creates the parameter map; FieldMetaUtils.overrideParamMapIfNeed applies @OverrideParams overrides.

@Param — A simple name-value pair passed to a generator as @Generator(params = …​). The generator parses the string value into its needed type:

// Snowflake8Generator reads "startTime" as a long
@Generator(value = "io.army.generator.snowflake.Snowflake8Generator",
           params = @Param(name = "startTime", value = "1779012232202"))
@Column
public long id;

@FieldParam — Binds a field name to replacement parameters inside @OverrideParams:

@FieldParam(name = "id", params = @Param(name = "startTime", value = "1600000000000"))

@OverrideParams — Class-level annotation for subclasses to change generator parameters of inherited fields without rewriting the @Generator annotation:

@OverrideParams(fields = {
    @FieldParam(name = "id", params = @Param(name = "startTime", value = "1600000000000")),
    @FieldParam(name = "uid", params = @Param(name = "startTime", value = "1600000000000"))
})
public class ChildEntity extends ParentEntity { }
@OverrideParams is the recommended way to give subclasses different Snowflake ID spaces without redeclaring @Generator.

3.6. @Mapping

@Mapping overrides Army’s default Java↔SQL type inference. Armey’s _MappingFactory automatically maps common types (String, Long, BigDecimal, LocalDateTime, etc.) to sensible SQL types. You only need @Mapping when:

  • Storing binary data in a text column with a specific charset

  • Using database-specific types like MySQL SET or PostgreSQL ENUM

  • Mapping collection types (List, Set) to JSON/JSONB

  • Overriding the framework’s type choice (e.g., forcing CHAR for a hash)

3.6.1. Attributes

  • value (optional) — Fully-qualified class name of the MappingType implementation.

  • type (optional) — Class reference of the MappingType. Takes precedence over value.

  • params (optional) — Extra parameters for the mapping type, e.g., the enum type name for PostgreSQL ENUM.

  • func (optional) — Java method reference for constructing/creating instances of the mapped type. Format: "ClassName::new" (constructor) or "ClassName::methodName" (static factory). Consumed by PgSingleRangeType.fromMethod, PgMultiRangeType.fromMethod, and their array variants via PgRangeType.createRangeFunction. Resolved by _ArmyPgRangeType.loadConstructor / loadFactoryMethod.

  • charset (optional) — Character set name. Required when value is a TextMappingType representing binary data (e.g., InputStream, Path).

  • elements (optional) — Element type(s) for collection mappings. Required for ElementMappingType implementations like MySQL SET. Not needed for array types — the component type is obtained via reflection.

3.6.2. Examples from stock-data

// SHA-256 hash → fixed-length CHAR(44) instead of VARCHAR
@Column(precision = 44, comment = "file hash (SHA-256, standard base64)")
@Mapping("io.army.mapping.SqlCharType")
public String fileHash;

// List<Long> → JSONB (PostgreSQL) or JSON (MySQL), defaulting to '[]'
@Column(notNull = true, defaultValue = "'[]'", comment = "vector ID list")
@Mapping("io.army.mapping.PreferredJsonbType")
private List<Long> vectorIdList;

3.7. @Index

@Index defines a database index within @Table#indexes. At runtime, TableMetaUtils.createIndexList reads all @Index annotations, resolves index names and types, and builds IndexMeta objects. If no index covers the id field, a unique primary key index is appended automatically.

3.7.1. Attributes

  • name (required) — Index name. Supports all placeholders. See the naming convention below.

  • fieldList (optional) — Simple list of Java field names. Each name refers to a field in the domain class and is resolved to its column name at runtime.

  • fields (optional) — @IndexField[] with per-column settings (collation, opclass, sort order). Takes precedence over fieldList when both are specified.

  • unique (optional, default false) — Whether the index enforces uniqueness. The primary key index is always unique.

  • type (optional) — Index access method. Supports BTREE, HASH, FULLTEXT, SPATIAL (MySQL); btree, hash, gist, gin, brin, hnsw (PostgreSQL). Empty string (default) = database default. Supports ${DEFAULT}, ${RUNTIME}, ${OPTIONAL} placeholders.

3.7.2. Index Name Convention

Placeholder Generated Name

${DEFAULT_VALUE}

uni_{table}_{col1}_{col2} (unique) or idx_{table}_{col1}_{col2} (non-unique)

${DEFAULT}

Same as above, but consults META-INF/army/TableMeta.properties first; falls back to DEFAULT_VALUE behavior

Literal string

Used as-is

3.7.3. Examples from stock-data

// Stock: unique composite index — no duplicate (exchange, code) pairs
@Table(name = "stock",
       indexes = @Index(name = "${DEFAULT_VALUE}", unique = true,
                        fieldList = {"exchange", "code"})) // → uni_stock_exchange_code

// StockQuotes: unique index — one quote record per stock per trading day
@Table(name = "stock_quotes",
       indexes = @Index(name = "${DEFAULT_VALUE}", unique = true,
                        fieldList = {"stockId", "date"})) // → uni_stock_quotes_stockId_date

// UploadRecord: two non-unique indexes for fast lookup
@Table(name = "upload_record",
       indexes = {
           @Index(name = "${DEFAULT_VALUE}", fieldList = "userId"),  // → idx_upload_record_userId
           @Index(name = "${DEFAULT_VALUE}", fieldList = "fileHash") // → idx_upload_record_fileHash
       })

3.7.4. @IndexField — Per-Column Index Configuration

For scenarios requiring per-column settings (collation, opclass, sort order), use @IndexField instead of simple fieldList:

// Standard B-tree index with column collation
@Index(name = "${DEFAULT_VALUE}", unique = true, type = "btree",
    fields = {
        @IndexField(name = "exchange"),
        @IndexField(name = "code", collation = "en_US.utf8")
    })

// PostgreSQL HNSW vector index for embedding similarity search
@Index(name = "${DEFAULT_VALUE}", type = "hnsw",
    fields = @IndexField(name = "embedding", opclass = "vector_cosine_ops"))
Attribute Description

name (required)

Java field name of the indexed column

collation (optional)

Column collation, e.g., "en_US.utf8". PostgreSQL only. Supports ${DEFAULT} and ${OPTIONAL}

opclass (optional)

Operator class for PostgreSQL, e.g., "vector_cosine_ops", "jsonb_path_ops". Supports ${DEFAULT} and ${OPTIONAL}

order (optional)

Sort order: SortOrder.ASC / DESC / DEFAULT

nulls (optional)

NULL ordering: NullsOrder.NULLS_FIRST / NULLS_LAST / DEFAULT

3.8. @MappedSuperclass

@MappedSuperclass marks a class whose @Column-annotated fields are inherited by subclasses, but which does not map to any database table. It’s purely a field-template mechanism.

At startup, TableMetaUtils.mappedClassPair walks the class hierarchy upward from the domain class: it collects every superclass with @MappedSuperclass or @Table (reversing the list so the topmost superclass comes first), then createFieldMetaList collects all @Column fields from each class in order.

Unlike @Inheritance, a @MappedSuperclass has no discriminator column and cannot be queried directly.

3.8.1. The Inheritance Hierarchy

The army-example-stock-data module uses a three-level @MappedSuperclass chain:

MinBaseDomain<T>           → @Column createTime         (auto-set on INSERT, never updatable)
  └─ BaseDomain<T>         → + @Column updateTime, version  (auto-set on INSERT/UPDATE; version auto-incremented)
      └─ StockBaseDomain<T> → + @Column stockId, date        (foreign key + trading date)
          ├── Stock              → table: stock               (exchange, code, name, status...)
          ├── StockQuotes        → table: stock_quotes        (open, high, low, volume...)
          ├── StockCandles       → table: stock_candles       (OHLCV data)
          └── StockRankDaily     → table: stock_rank_daily    (daily ranking)

Only the concrete classes at the bottom have @Table. The @MappedSuperclass levels above add fields that every concrete entity inherits.

3.8.2. Code Example

// Level 1: Every entity needs a creation timestamp
@MappedSuperclass
public abstract class MinBaseDomain<T extends MinBaseDomain<T>> {

    @Column
    public LocalDateTime createTime;   // Auto-set by the framework on INSERT
}

// Level 2: Add update tracking and optimistic locking
@MappedSuperclass
public abstract class BaseDomain<T extends BaseDomain<T>> extends MinBaseDomain<T> {

    @Column
    public LocalDateTime updateTime;   // Auto-refreshed on every INSERT / UPDATE

    @Column
    public int version;                 // Auto-incremented on UPDATE (optimistic lock)
}

// Level 3: Add fields shared by all stock-related entities
@MappedSuperclass
public abstract class StockBaseDomain<T extends StockBaseDomain<T>> extends BaseDomain<T> {

    @Column(notNull = true, updatable = false, comment = "stock id")
    public Long stockId;

    @Column(notNull = true, updatable = false, comment = "trading date")
    public LocalDate date;
}

// Concrete entity: inherits createTime/updateTime/version/stockId/date from the chain
@Table(name = "stock_quotes", comment = "daily stock quotes",
       indexes = @Index(name = "${DEFAULT_VALUE}", unique = true,
                        fieldList = {"stockId", "date"}))
public class StockQuotes extends StockBaseDomain<StockQuotes> {

    @Generator(value = "io.army.generator.snowflake.Snowflake8Generator",
               params = @Param(name = "startTime", value = "1779111192831"))
    @Column
    public long id;

    @Column(precision = 9, scale = 3, comment = "open price")
    public BigDecimal open;
    // ... more fields
}

3.8.3. Key Points

  1. A @MappedSuperclass never maps to a table — only @Table subclasses do.

  2. All @Column annotations from every @MappedSuperclass in the chain are inherited by the concrete subclass.

  3. Fields without @Column are not persisted.

  4. You can nest @MappedSuperclass to build reusable inheritance chains.

  5. For small projects, skip @MappedSuperclass and declare all fields directly — it’s entirely optional.

3.9. @Inheritance

While @MappedSuperclass shares field definitions, @Inheritance implements the single-table inheritance strategy: a parent class and its subclasses all live in one database table, with a discriminator column telling them apart.

At startup, TableMetaUtils.discriminator locates the field named in @Inheritance and verifies it is an Enum type. TableMetaUtils.discriminatorValue reads @DiscriminatorValue on each child class and validates it against the enum constants.

// Parent: the root of the inheritance tree
@Table(name = "u_user", comment = "User entity")
@Inheritance("status")  // "status" field acts as the discriminator
public abstract class User extends BaseDomain<User> {

    @Column(comment = "User status")
    public UserStatus status;  // Must be an enum implementing CodeEnum
}

// Child: identified by discriminator value "ADMIN"
@DiscriminatorValue("ADMIN")
public class Admin extends User { }

// Child: identified by discriminator value "MEMBER"
@DiscriminatorValue("MEMBER")
public class Member extends User { }

The discriminator field must be an Enum implementing io.army.struct.CodeEnum. The framework uses this column to filter rows by entity subtype in queries.

Only one @Inheritance is allowed per class hierarchy. If multiple superclasses have @Inheritance, Army throws MetaException. The parent table with @Inheritance cannot be marked as immutable.

@Inheritance and @MappedSuperclass serve different purposes. Use @MappedSuperclass when you just want to share field definitions. Use @Inheritance when you want polymorphic queries — storing different entity types in one table and filtering by discriminator.

3.10. @DdlMode

Controls whether Army generates CREATE TABLE DDL during schema initialization. At runtime, TableMetaUtils.tableCreateDdl reads @Table.ddlMode():

Value Behavior

CREATE (default)

Always generate CREATE TABLE DDL

NONE

Never generate DDL. Use for tables managed externally (e.g., by Flyway or Liquibase)

DEFAULT

Check META-INF/army/TableMeta.properties key {className}.Table.ddlMode; fallback to CREATE

// Prevent DDL generation for a legacy table
@Table(name = "legacy_data", comment = "Legacy data", ddlMode = DdlMode.NONE)
public class LegacyData { }

3.11. @Codec

@Codec is a marker annotation with no current runtime effect. The TableFieldMeta constructor records its presence in CODEC_MAP, but the framework does not act on it yet. It is reserved as an extension point for future custom encode/decode interceptors.

3.12. TableMeta.properties

TableMeta.properties is the runtime override hub of Army. Place this file at META-INF/army/TableMeta.properties on the classpath (typically src/main/resources/META-INF/army/TableMeta.properties). Army scans all JARs and directories on the classpath and merges entries from every location — this lets you change annotation attributes without recompiling.

3.12.1. Placeholder Expressions Reference

Many annotation attributes accept placeholder expressions instead of literal values. These placeholders are resolved against META-INF/army/TableMeta.properties at startup. They are defined as constants in TableMetaUtils:

Placeholder Constant Behavior

${DEFAULT}

TableMetaUtils.DEFAULT_EXP

Check META-INF/army/TableMeta.properties first; if not found, use the framework’s built-in convention (e.g., camelCase→snake_case for column names)

${RUNTIME}

TableMetaUtils.RUNTIME_EXP

Must be in META-INF/army/TableMeta.properties; throws MetaException if missing. Use when the value depends on the deployment environment

${OPTIONAL}

TableMetaUtils.OPTIONAL_EXP

Check META-INF/army/TableMeta.properties first; if not found, silently skip the element (used mainly for index types). Not all attributes support ${OPTIONAL}

${DEFAULT_VALUE}

TableMetaUtils.DEFAULT_VALUE_EXP

Skip META-INF/army/TableMeta.properties entirely; always use the convention-based default (e.g., auto-generated index names)

${DEFAULT} and ${DEFAULT_VALUE} are NOT the same: * ${DEFAULT} — allows override via properties, falls back to convention if not found * ${DEFAULT_VALUE} — bypasses properties entirely, always uses the framework default Use ${DEFAULT_VALUE} when you never want the value accidentally overridden.

3.12.2. What Happens When Not Configured

When you use a placeholder in your annotation, the framework enters a resolution chain. Here is the exact fallback behavior for every attribute — what happens when the property is not configured in META-INF/army/TableMeta.properties:

Attribute (Placeholder) Annotation Behavior When NOT Found

Table name ${DEFAULT}

@Table(name = "${DEFAULT}")

Convert class simple name to snake_case: `_MetaBridge.camelToLowerCase()` → StockQuotes"stock_quotes"UserAccount"user_account"

@Table(name = "${RUNTIME}")

Throw MetaException — no fallback

Table comment ${DEFAULT}

@Table(comment = "${DEFAULT}")

Convert class simple name to spaced comment: `_MetaBridge.camelToComment()` → StockQuotes"Stock Quotes"UserOrder"User Order"

@Table(comment = "${RUNTIME}")

Throw MetaException — no fallback

Catalog / Schema ${DEFAULT}

@Table(catalog = "${DEFAULT}")

Empty string → database default

@Table(catalog = "${RUNTIME}")

Throw MetaException — no fallback

Column name ${DEFAULT}

@Column(name = "${DEFAULT}")

Convert field name camelCase→snake_case: `_MetaBridge.camelToLowerCase()` → userName"user_name"createTime"create_time"

@Column(name = "${RUNTIME}")

Throw MetaException — no fallback

Column comment ${DEFAULT}

@Column(comment = "${DEFAULT}")

Reserved fields (id/createTime/…) → auto-comment; Enum fields"enum, @see {EnumClass}"; Business fields → throw MetaException

@Column(comment = "${RUNTIME}")

Throw MetaException — no fallback

(empty string)

(no comment value)

Same as ${DEFAULT} — reserved → auto, business → error

Column precision DEFAULT_EXP

@Column(precision = Column.DEFAULT_EXP)

By Java type: String255, BigDecimal16, Enum→max label+5, UUID36, Vector1024

RUNTIME_EXP

Throw MetaException — no fallback

Column scale DEFAULT_EXP

@Column(scale = Column.DEFAULT_EXP)

By type: BigDecimal3, temporal types→6 (microsecond); otherwise -1

RUNTIME_EXP

Throw MetaException — no fallback

Column default ${DEFAULT}

@Column(defaultValue = "${DEFAULT}")

Type-appropriate zero: number→"0", string→"''“, boolean→`"false", LocalDate→”'1970-01-01'“, LocalDateTime→”'1970-01-01 00:00:00'"`

RUNTIME_EXP

Throw MetaException — no fallback

Generator type RUNTIME

@Generator(type = GeneratorType.RUNTIME)

Throw MetaException — no fallback; property must provide Generator.type or GeneratorStrategy

DEFAULT

@Generator(type = GeneratorType.DEFAULT)

Child tables in @InheritancePOST; Otherwise → PRECEDE

PRECEDE/POST

(literal enum)

Always used as-is — no placeholder involved

Index name ${DEFAULT_VALUE}

@Index(name = "${DEFAULT_VALUE}")

Always auto-generated (ignores properties): Unique → uni_{table}_{col1}_{col2} Non-unique → idx_{table}_{col1}_{col2}

${DEFAULT}

@Index(name = "${DEFAULT}")

Falls back to ${DEFAULT_VALUE} behavior (auto-generated name)

${OPTIONAL}

@Index(name = "${OPTIONAL}")

nullindex is silently skipped

Index type ${DEFAULT}

@Index(type = "${DEFAULT}")

Empty string → database default index type

${RUNTIME}

@Index(type = "${RUNTIME}")

Throw MetaException — no fallback

${OPTIONAL}

@Index(type = "${OPTIONAL}")

nullentire index is silently skipped

IndexField collation/opclass ${DEFAULT}

@IndexField(collation = "${DEFAULT}")

Empty string → no collation/opclass

${RUNTIME}

@IndexField(collation = "${RUNTIME}")

Throw MetaException — no fallback

DDL mode DEFAULT

3.12.3. Usage Examples

// ── Table name: auto-derive from class name, allow override via properties ──
// Without properties: StockQuotes → "stock_quotes"
// With properties: com.example.domain.StockQuotes.Table.name=stock_v2
@Table(name = "${DEFAULT}", comment = "stock data")

// ── Table name: MUST be in properties, startup fails otherwise ──
// Used when team A defines the entity but team B controls deployment name
@Table(name = "${RUNTIME}", comment = "cross-team table")

// ── Column name: camelCase → snake_case by default, configurable ──
// Without properties: userName → "user_name"
// With properties: com.example.Stock.userName.Column.name=login_name
@Column(name = "${DEFAULT}")
public String userName;

// ── Column precision: must be configured at startup — varies by deployment ──
// Without properties: throws MetaException
// With properties: com.example.Stock.embedding.Column.precision=768
@Column(precision = Column.RUNTIME_EXP)
public String embedding;

// ── Index name: always auto-generate, never override ──
// @Table(name = "stock") + @Index(fieldList = "userId")
// → unique: uni_stock_user_id, non-unique: idx_stock_user_id
@Index(name = "${DEFAULT_VALUE}", fieldList = "userId")

// ── Index name: default generation but allow override via properties ──
// Without properties: same as ${DEFAULT_VALUE}
// With properties: com.example.IndexMeta[0].name=my_index
@Index(name = "${DEFAULT}", fieldList = "userId")

// ── Optional index: skip entirely on staging, keep in production ──
// Without properties: index is silently skipped
// With properties: com.example.IndexMeta[0].type=btree → index is created
@Index(name = "idx_stock", type = "${OPTIONAL}", fieldList = "userId")

// ── Column default value: type-appropriate zero ──
// LocalDateTime → '1970-01-01 00:00:00'
// BigDecimal → 0
// String → ''
@Column(defaultValue = "${DEFAULT}", comment = "upload complete time")
public LocalDateTime uploadCompleteTime;

// ── Generator type: defer to deployment config ──
// Development: POST (auto-increment), Production: PRECEDE (Snowflake)
@Generator(type = GeneratorType.RUNTIME)
@Column
public long id;

3.12.4. Property Key Format

Every property key follows this pattern:

{fully.qualified.ClassName}.{fieldName|Table}.{AttributeCategory}.{attributeName}
Category Example Key Example Value

Table

com.example.domain.Stock.Table.name

stock_v2

Table.comment

com.example.domain.Stock.Table.comment

stock data v2

Table.catalog

com.example.domain.Stock.Table.catalog

production

Table.schema

com.example.domain.Stock.Table.schema

public

Table.ddlMode

com.example.domain.Stock.Table.ddlMode

NONE

Column.name

com.example.domain.Stock.userName.Column.name

user_name

Column.comment

com.example.domain.Stock.status.Column.comment

custom status

Column.precision

com.example.domain.Stock.name.Column.precision

512

Column.scale

com.example.domain.Stock.offerPrice.Column.scale

4

Column.defaultValue

com.example.domain.Stock.status.Column.defaultValue

'ACTIVE'

Column.collation

com.example.domain.Stock.name.Column.collation

en_US.utf8

Generator.type

com.example.domain.Stock.id.Generator.type

POST

GeneratorStrategy

com.example.domain.Stock.id.GeneratorStrategy

io.army.generator.Snowflake8GeneratorStrategy:{"startTime":1779111192831}

Mapping.value

com.example.domain.Stock.id.Mapping.value

io.army.mapping.SqlBigIntType

IndexMeta[n].name

com.example.domain.Stock.IndexMeta[0].name

idx_stock_custom

IndexMeta[n].type

com.example.domain.Stock.IndexMeta[0].type

btree

IndexMeta[n].field.X.collation

com.example.domain.Stock.IndexMeta[0].field.code.collation

en_US.utf8

IndexMeta[n].field.X.opclass

com.example.domain.Stock.IndexMeta[0].field.embedding.opclass

vector_cosine_ops

Property keys are case-sensitive. Keyword components — Table, Column, Generator, GeneratorStrategy, Mapping, IndexMeta — must appear exactly with that capitalization.

The IndexMeta[n] syntax uses a 0-based index: [0] refers to the first @Index in @Table.indexes array, [1] to the second, etc. The field.{fieldName} segment uses the Java field name (not the column name).

The GeneratorStrategy value format is fully.qualified.StrategyClass[:jsonParams]. The optional JSON parameters after the colon are parsed by the strategy’s static create(String) factory method.

3.12.5. Complete Configuration Example

Here is a realistic META-INF/army/TableMeta.properties file showing all categories in one place:

# File: META-INF/army/TableMeta.properties
# Place in: src/main/resources/META-INF/army/TableMeta.properties

# ── @Table ──
com.example.domain.Stock.Table.name=stock_v2
com.example.domain.Stock.Table.comment=stock data v2
com.example.domain.Stock.Table.schema=public
com.example.domain.Stock.Table.ddlMode=NONE

# ── @Column ──
com.example.domain.Stock.userName.Column.name=user_name
com.example.domain.Stock.status.Column.comment=custom status
com.example.domain.Stock.name.Column.precision=512
com.example.domain.Stock.offerPrice.Column.scale=4
com.example.domain.Stock.status.Column.defaultValue='ACTIVE'
com.example.domain.Stock.name.Column.collation=en_US.utf8

# ── @Generator ──
com.example.domain.Stock.id.Generator.type=POST
com.example.domain.Stock.id.GeneratorStrategy=io.army.generator.Snowflake8GeneratorStrategy:{"startTime":1779111192831}

# ── @Mapping ──
com.example.domain.Stock.id.Mapping.value=io.army.mapping.SqlBigIntType

# ── @Index ──
com.example.domain.Stock.IndexMeta[0].name=idx_stock_custom
com.example.domain.Stock.IndexMeta[0].type=btree

# Skip an optional index (empty value = skip)
com.example.domain.Stock.IndexMeta[1].type=

# ── @IndexField (per-column index settings) ──
com.example.domain.Stock.IndexMeta[0].field.code.collation=en_US.utf8
com.example.domain.Stock.IndexMeta[0].field.embedding.opclass=vector_cosine_ops

# ── @DdlMode ──
com.example.domain.LegacyData.Table.ddlMode=NONE
Start with an empty TableMeta.properties file and add entries only when you need to override defaults for a specific deployment environment. Most projects need only 2–5 entries (e.g., switching generator types, adjusting precisions, disabling DDL).

4. MappingType

Every domain field in Army must declare its type mapping — the rule that converts between a Java type and a database column type. This mapping is defined either implicitly (via default type inference) or explicitly (via the @Mapping annotation). The MappingType interface is the core abstraction that orchestrates this conversion.

4.1. Overview: MappingType Interface

public sealed interface MappingType permits AbstractMappingType, ArrayMappingType {

    /// The Java type that this mapping represents.
    Class<?> javaType();

    /// Resolve to the concrete SQL data type for the current database dialect.
    DataType map(ServerMeta meta);

    /// Convert a Java value → SQL parameter value (before binding).
    Object beforeBind(DataType dataType, MappingEnv env, Object source);

    /// Convert a database value → Java object (after fetching).
    Object afterGet(DataType dataType, MappingEnv env, Object source);

    /// Return the corresponding array MappingType for this scalar type.
    MappingType arrayTypeOfThis();
}

A MappingType implementation defines four things:

  1. Which Java type it handles — returned by javaType().

  2. Which SQL type it maps to — resolved by map(ServerMeta), which is database-dialect-aware.

  3. How values are serialized before binding to a prepared statement — beforeBind().

  4. How values are deserialized after fetching from a ResultSetafterGet().

For example, io.army.mapping.StringType maps java.lang.String → MySQL VARCHAR / PostgreSQL VARCHAR / Oracle VARCHAR2, and during beforeBind() it can convert Number, Boolean, Enum, LocalDateTime, etc. into their string representations automatically.

4.2. Default Type Inference

When a domain field has no @Mapping annotation, Army infers the MappingType from the field’s Java type via io.army.criteria.impl._MappingFactory.getDefaultIfMatch().

4.2.1. Built-in Java-to-MappingType Lookup

Java Type MappingType SQL Type (PostgreSQL / MySQL)

String

StringType

VARCHAR / VARCHAR

boolean / Boolean

BooleanType

BOOLEAN / TINYINT(1)

int / Integer

IntegerType

INTEGER / INT

long / Long

LongType

BIGINT / BIGINT

float / Float

FloatType

REAL / FLOAT

double / Double

DoubleType

DOUBLE PRECISION / DOUBLE

short / Short

ShortType

SMALLINT / SMALLINT

byte / Byte

ByteType

SMALLINT / TINYINT

char / Character

SqlCharType

CHAR(1) / CHAR(1)

BigInteger

BigIntegerType

NUMERIC / DECIMAL

BigDecimal

BigDecimalType

NUMERIC / DECIMAL

LocalDateTime

LocalDateTimeType

TIMESTAMP / DATETIME

LocalDate

LocalDateType

DATE / DATE

LocalTime

LocalTimeType

TIME / TIME

OffsetDateTime

OffsetDateTimeType

TIMESTAMPTZ / (n/a)

ZonedDateTime

ZonedDateTimeType

TIMESTAMPTZ / TIMESTAMP

Instant

InstantType

TIMESTAMPTZ / TIMESTAMP

Year

YearType

SMALLINT / SMALLINT

YearMonth

YearMonthType

DATE / DATE

MonthDay

MonthDayType

DATE / DATE

UUID

UUIDType

UUID / VARCHAR(36)

byte[]

VarBinaryType

BYTEA / VARBINARY

BitSet

BitSetType

VARBIT / BIT

ZoneId

ZoneIdType

VARCHAR / VARCHAR

4.2.2. Enum Type Inference

When a domain field’s Java type is an enum, Army has three distinct mapping strategies:

Strategy Implements/Extends Stored in DB as Use Case

NameEnumType

(plain enum, no marker interface)

VARCHAR (or PostgreSQL native ENUM with @DefinedType)

Simple enums where name() is human-readable enough (e.g., NORMAL, DELISTED)

LabelEnumType

implements TextEnum

VARCHAR (or PostgreSQL native ENUM with @DefinedType)

Enum values need a display text different from the Java constant name (e.g., ZORO"Roronoa Zoro")

CodeEnumType

implements CodeEnum

INTEGER

Fixed, stable integer codes for maximum storage efficiency and ABI compatibility

NameEnumType — Default Enum Strategy

A plain enum with no marker interface uses NameEnumType. The stored value is Enum.name(), which is the Java constant identifier.

public enum StockStatus {
    NORMAL,     // → stored as "NORMAL"
    DELISTED,   // → stored as "DELISTED"
    STOPT,      // → stored as "STOPT"
    UNKNOWN     // → stored as "UNKNOWN"
}

@Column(notNull = true, defaultValue = "'NORMAL'", comment = "stock market status")
public StockStatus status;

// Default inference: StockStatus → NameEnumType → VARCHAR column

Under the hood, NameEnumType.beforeBind() calls ((Enum<?>) source).name(), and afterGet() calls Enum.valueOf(enumClass, string). The mapping to SQL types is dialect-dependent:

  • MySQL: native ENUM('NORMAL','DELISTED','STOPT','UNKNOWN')

  • PostgreSQL: VARCHAR by default; becomes a native CREATE TYPE …​ AS ENUM if the enum class is annotated with @DefinedType

  • H2: native ENUM

  • SQLite: VARCHAR

A plain enum mapped via NameEnumType is safe for adding new constants at the end of the list, but renaming or reordering existing constants will break existing data (the stored string won’t match).
TextEnumType — Text-Based Enum with Custom Label

NameEnumType stores Enum.name() — but name() is constrained to valid Java identifiers. What if you need the database to store a more descriptive text, like "Roronoa Zoro" instead of "ZORO"?

That’s what LabelEnumType is for. By implementing io.army.struct.LabelEnum, the enum provides a text() method that returns the value to be stored:

public enum QinArmy implements TextEnum {

    ZORO("Roronoa Zoro"),         // stored as "Roronoa Zoro"
    ANZAI("Mitsuyoshi Anzai");    // stored as "Mitsuyoshi Anzai"

    private final String text;

    QinArmy(String text) {
        this.text = text;
    }

    @Override
    public String text() {
        return this.text;
    }
}

@Column(comment = "swordsman name")
public QinArmy swordsman;  // → TextEnumType, stored as VARCHAR

TextEnumType.beforeBind() calls ((TextEnum) source).text() instead of name(), and afterGet() reads the stored text and looks up the enum instance via an internal Map<String, TextEnum> built at initialization time.

TextEnumType vs NameEnumType:

Aspect NameEnumType LabelEnumType

Stored value source

Enum.name() (Java constant identifier)

TextEnum.text() (arbitrary string)

Rename safety

Renaming the Java constant breaks data

Renaming the Java constant is safe — only text() matters

Database readability

Stored as Java-style identifiers (NORMAL)

Stored as human-readable text ("Roronoa Zoro")

Special enum support

Handles Month and DayOfWeek (see SpecialEnums)

No special enum handling

@DefinedType support

✓ (PostgreSQL native ENUM)

✓ (PostgreSQL native ENUM)

CodeEnumType — Integer-Code Enum

When the enum values have stable, semantically-meaningful integer codes, CodeEnumType is the best choice. It stores only the integer, which is the most storage-efficient option and is immune to refactoring (the code stays unchanged even if the Java constant is renamed).

public enum BankAccountType implements CodeEnum {

    BANK(0),
    LENDER(100),
    BORROWER(200),
    PARTNER(300),
    LENDER_BUSINESS(400),
    BORROWER_BUSINESS(500),
    GUARANTOR(600);

    private final short code;

    BankAccountType(short code) {
        this.code = code;
    }

    @Override
    public final int code() {
        return this.code;
    }
}

@Column(comment = "account type")
public BankAccountType accountType;  // → CodeEnumType, stored as INT

CodeEnumType.beforeBind() returns ((CodeEnum) source).code() as an Integer. afterGet() is flexible — it can deserialize from Integer, Long, Short, Byte, BigInteger, or String.

CodeEnumType is mapped to an INTEGER column on all dialects. It is the most robust strategy for long-lived enums whose semantics are encoded in numbers (e.g., protocol codes, status codes, wire-format constants).

Never use consecutive integer codes (0, 1, 2, 3, …​) for CodeEnum. Always leave gaps between codes — use increments of 10 (e.g., 0, 10, 20, 30) or 100 (e.g., 0, 100, 200, 300). Consecutive values make it impossible to insert a new constant between existing ones without renumbering, which would corrupt existing data. With spaced codes, you can always insert a new value — e.g., between LENDER(100) and BORROWER(200) you can add LENDER_PREMIUM(150).
Never implement both CodeEnum and LabelEnum on the same enum class. Army will reject it at startup with an IllegalArgumentException.
PostgreSQL Native ENUM via @DefinedType

Both NameEnumType and LabelEnumType support PostgreSQL native ENUM types through the io.army.struct.DefinedType annotation. When applied to the enum class, Army generates a CREATE TYPE DDL and maps the field to the named PostgreSQL enum type instead of VARCHAR:

@DefinedType(name = "stock_status_enum", category = TypeCategory.ENUM)
public enum StockStatus {
    NORMAL,
    DELISTED,
    STOPT,
    UNKNOWN
}

@Column(comment = "stock listing status")
public StockStatus status;

// Without @DefinedType → VARCHAR
// With @DefinedType(name = "stock_status_enum") → PostgreSQL: CREATE TYPE stock_status_enum AS ENUM (...)

This is especially useful when the enum type needs to be shared across multiple tables, or when you want the database to enforce value constraints at the schema level.

Special Enums: Month and DayOfWeek

NameEnumType has built-in intelligent handling for java.time.Month and java.time.DayOfWeek. These enums are commonly returned as integers by database functions (e.g., MONTH(), DAYOFWEEK()) and as strings by others (MONTHNAME(), DAYNAME()).

MonthNameEnumType.afterGet() accepts:

  • Integer (1–12) → Month.of(n) — covers MySQL MONTH() / PostgreSQL EXTRACT(MONTH FROM …​)

  • Long (1–12) → Month.of((int) n)

  • String ("JANUARY", "FEBRUARY", …​) → Month.valueOf(name.toUpperCase()) — covers MONTHNAME()

  • LocalDate, YearMonth, MonthDay, LocalDateTime, OffsetDateTime, ZonedDateTimeMonth.from(temporal)

// MySQL: SELECT MONTH(create_time) m, MONTHNAME(create_time) mn FROM ...
// Both m (integer) and mn (string) can be read into a Month field
@Column
public Month birthMonth;  // → NameEnumType + Month special handling

DayOfWeekNameEnumType.afterGet() accepts:

  • Integer — on MySQL, 1 maps to SUNDAY (MySQL DAYOFWEEK() convention); otherwise DayOfWeek.of(n)

  • Long → same as integer

  • String ("MONDAY", "TUESDAY", …​) → DayOfWeek.valueOf(name.toUpperCase())

  • LocalDate, LocalDateTime, OffsetDateTime, ZonedDateTimeDayOfWeek.from(temporal)

// MySQL: SELECT DAYOFWEEK(create_time) dow, DAYNAME(create_time) dn FROM ...
// In MySQL, DAYOFWEEK returns 1=Sunday, 2=Monday, ..., 7=Saturday
// NameEnumType handles this MySQL-specific mapping automatically
@Column
public DayOfWeek tradingDay;  // → NameEnumType + DayOfWeek special handling

4.3. Navigation: stock-data Example

The army-example-stock-data module contains a complete stock market application demonstrating all mapping patterns discussed in this chapter. Browse its domain/ package to see real @Mapping usages across Stock, StockQuotes, StockChatConversation, UploadRecord, and vector store entities.

4.4. @Mapping Annotation: Explicit Mapping Control

When the default inference is insufficient — or when you need a non-standard database type — use the io.army.annotation.Mapping annotation.

4.4.1. Annotation Definition

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Mapping {

    /// Fully-qualified class name of the MappingType implementation.
    /// Used as a string to avoid compile-time dependencies on dialect-specific classes.
    String value() default "";

    /// The MappingType class itself (takes precedence over value()).
    Class<?> type() default void.class;

    /// Extra configuration parameters (e.g., PostgreSQL enum type name).
    String[] params() default {};

    /// Java method reference for constructing or creating instances of the mapped type.
    /// Format: "ClassName::new" (constructor) or "ClassName::methodName" (static factory).
    /// Consumed by PgSingleRangeType.fromMethod / PgMultiRangeType.fromMethod and array variants
    /// via PgRangeType.createRangeFunction; resolved by _ArmyPgRangeType.loadConstructor / loadFactoryMethod.
    /// For non-range types, dispatched via _MappingFactory.doMap.
    String func() default "";

    /// Character set for text-based binary mappings (e.g., LongBlobType + InputStream).
    String charset() default "";

    /// Element types for collection mappings (e.g., MySQL SET).
    Class<?>[] elements() default {};
}

4.4.2. Resolution Priority

The @Mapping value follows the same placeholder mechanism as other annotations:

  1. If type() is set (not void.class), it takes precedence over value().

  2. If value() is "${DEFAULT}", Army checks TableMeta.properties; if not configured, falls back to default type inference.

  3. If value() is "${RUNTIME}", the property must be configured in TableMeta.properties or a MetaException is thrown.

  4. Otherwise, value() is a fully-qualified class name resolved via Class.forName().

4.4.3. Example: Mapping a String to TEXT (Long Content)

The StockChatConversation.firstContent field stores a Java String but needs a PostgreSQL TEXT column (not VARCHAR). This is achieved by explicitly specifying TextType:

@Column(notNull = true, updatable = false, comment = "first chat message content")
@Mapping("io.army.mapping.TextType")
private String firstContent;

Similarly, SpringAiChatMemory.content and SpringAiVectorStore.content both use @Mapping("io.army.mapping.TextType") to store potentially long message text in a TEXT column:

// From SpringAiChatMemory (chat memory module)
@Column(name = "${DEFAULT}", notNull = true, comment = "${DEFAULT}")
@Mapping("io.army.mapping.TextType")
private String content;

// From SpringAiVectorStore (vector store module)
@Column(name = "${DEFAULT}", comment = "${DEFAULT}")
@Mapping("io.army.mapping.TextType")
private String content;

4.4.4. Example: Mapping a String to CHAR (Fixed-Length)

The UploadRecord.fileHash field stores a SHA-256 Base64 hash (always 44 characters). Using SqlCharType enforces a CHAR(44) column:

@Column(precision = 44, comment = "file hash, SHA-256 algorithm, standard base64")
@Mapping("io.army.mapping.SqlCharType")
public String fileHash;

4.4.5. Example: Runtime-Configurable Mapping

The SpringAiChatVectorStore.conversationId uses "${DEFAULT}" to allow deployment-time type selection:

///  Mapping#value() should be one of below:
///  - SqlBigIntType
///  - StringType
///  - UUIDType
@Column(name = "${DEFAULT}", notNull = true, precision = Column.DEFAULT_EXP)
@Mapping("${DEFAULT}")
private String conversationId;

In TableMeta.properties, you can choose:

# Use Long-based conversation ID
com.example.stock.domain.StockChatVectorStore.conversationId.Mapping.value=io.army.mapping.SqlBigIntType

# Or use UUID-based
# com.example.stock.domain.StockChatVectorStore.conversationId.Mapping.value=io.army.mapping.UUIDType

4.5. Array Types

Army supports PostgreSQL native arrays (integer[], text[], etc.) through the array subpackage of io.army.mapping. Each scalar MappingType has a corresponding array variant.

4.5.1. Default Array Inference

When a Java array field has no @Mapping, the framework infers the array MappingType from the element type via _MappingFactory.getDefaultIfMatch():

  • int[] / Integer[]IntegerArrayType → PostgreSQL INTEGER[]

  • long[] / Long[]LongArrayType → PostgreSQL BIGINT[]

  • String[]StringArrayType → PostgreSQL TEXT[]

  • BigDecimal[]BigDecimalArrayType → PostgreSQL NUMERIC[]

  • LocalDate[]LocalDateArrayType → PostgreSQL DATE[]

  • Boolean[]BooleanArrayType → PostgreSQL BOOLEAN[]

// Auto-inferred: Integer[] → IntegerArrayType → INTEGER[]
@Column(name = "tag_ids")
private Integer[] tagIds;

4.5.2. Custom Array Mapping

For custom element types (enums, composites), use @Mapping to specify an array type explicitly:

// PostgreSQL ENUM array — element type inferred from field component type via reflection
@Mapping("io.army.mapping.array.NameEnumArrayType")
private Gender[] genders;

4.6. Composite Type (PostgreSQL Row Types)

io.army.mapping.CompositeType maps POJOs annotated with @DefinedType to PostgreSQL composite (row) types. The POJO’s fields (annotated with @Column) become the composite type’s attributes, serialized as (val1,val2,…​).

io.army.struct.DefinedType#fieldOrder() is mandatory because PostgreSQL composite types require a fixed attribute order. The parser must know which position each field occupies in the tuple to correctly serialize and deserialize. Without it, Army cannot determine the mapping between Java field names and tuple positions.

Additionally:

  • @DefinedType.name() defines the PostgreSQL composite type name (e.g., currency_pair). Army uses it for DDL generation and type resolution.

  • The POJO must provide a no-arg constructor or a constructor matching field types for deserialization.

  • CompositeType is PostgreSQL-only. For arrays of composite types, use io.army.mapping.array.CompositeArrayType.

First, define the composite type POJO:

/// @see io.army.struct.DefinedType
@DefinedType(name = "currency_pair", fieldOrder = {"code", "rate"})
public final class CurrencyPair {

    @Column
    private final String code;

    @Column
    private final BigDecimal rate;

    public CurrencyPair(String code, BigDecimal rate) {
        this.code = code;
        this.rate = rate;
    }

    public String code() {
        return this.code;
    }

    public BigDecimal rate() {
        return this.rate;
    }

    @Override
    public int hashCode() {
        return Objects.hash(this.code, this.rate);
    }

    @Override
    public boolean equals(Object obj) {
        final boolean match;
        if (obj == this) {
            match = true;
        } else if (obj instanceof CurrencyPair o) {
            match = this.code.equals(o.code) && this.rate.equals(o.rate);
        } else {
            match = false;
        }
        return match;
    }

}

Then reference CompositeType in the @Mapping annotation:

@Table(name = "exchange_rate")
public class ExchangeRate {

    @Id
    @Column
    @GeneratedValue
    private Long id;

    @Column
    @Mapping(io.army.mapping.CompositeType.class)  // or "io.army.mapping.CompositeType"
    private CurrencyPair pair;

}

Serialization example:

  • beforeBind: a CurrencyPair("USD", 7.25) becomes the literal ('USD',7.25)

  • afterGet: the text ('USD',7.25) is parsed back to CurrencyPair("USD", 7.25)

4.7. Vector Type (pgvector Embeddings)

For AI/ML embedding storage, Army provides io.army.mapping.VectorType — a MappingType for the PostgreSQL pgvector extension’s VECTOR type.

public final class VectorType extends _ArmyNoInjectionType
        implements MappingType.SqlExtension {

    public static VectorType from(Class<?> javaType) {
        // Only float[] is supported
        return INSTANCE;
    }

    @Override
    public DataType map(ServerMeta meta) {
        if (meta.serverDatabase() != Database.PostgreSQL) {
            throw mapError(this, meta);  // VECTOR is PostgreSQL-only
        }
        return PgType.VECTOR;
    }

    // beforeBind: float[] → "[0.1,0.2,...,0.9]" SQL string
    // afterGet:   SQL string → float[]
}

4.7.1. Vector Store Example (army-spring-ai-vector-store)

The io.army.spring.ai.vectorstore.SpringAiVectorStore base class uses VectorType for its embedding column:

/// Java type: float[] → MappingType: VectorType → PostgreSQL: VECTOR
@Column(name = "${DEFAULT}", notNull = true,
        precision = Column.DEFAULT_EXP,  // required: embed dimension configured at runtime
        comment = "${DEFAULT}")
@Mapping("io.army.mapping.VectorType")
private float[] embedding;
The precision = Column.DEFAULT_EXP (which equates to Integer.MIN_VALUE) signals that the vector dimension must be configured at runtime. Without precision configured (via TableMeta.properties or the @Column annotation), Army throws a MetaException.

4.7.2. Vector Store Domain Subclass Example

Domain entities extend the parent class to define their table and indexes:

// Long-term chat memory vector store — uses HNSW index with cosine similarity
@Table(name = "stock_chat_vector_store",
        indexes = {
                @Index(name = "${DEFAULT_VALUE}", type = "hnsw",
                       fields = @IndexField(name = "embedding",
                                            opclass = "vector_cosine_ops")),
                @Index(name = "${DEFAULT_VALUE}", fieldList = "conversationId"),
                @Index(name = "${DEFAULT_VALUE}", type = "gin", fieldList = "metadata")
        },
        comment = "stock chat long-term memory vector store")
public class StockChatVectorStore extends SpringAiChatVectorStore {
}

// Document vector store — uses HNSW index with L2 (Euclidean) distance
@Table(name = "stock_document_vector_store",
        indexes = {
                @Index(name = "${DEFAULT_VALUE}", type = "hnsw",
                       fields = @IndexField(name = "embedding",
                                            opclass = "vector_l2_ops")),
                @Index(name = "${DEFAULT_VALUE}", unique = true, fieldList = "documentId"),
                @Index(name = "${DEFAULT_VALUE}", type = "gin", fieldList = "metadata")
        },
        comment = "stock document vector store")
public class StockDocumentVectorStore extends SpringAiVectorStore {
}

4.8. JSON / JSONB Types

Army provides several JSON mapping types for document-oriented storage:

MappingType Java Types Behavior

JsonType

String, Map, List, Set,POJO

Always maps to vanilla JSON (MySQL JSON, PostgreSQL JSON)

PreferredJsonbType

String, Map, List, Set,POJO

Maps to JSONB on PostgreSQL, falls back to JSON on MySQL

JsonbType

String, Map, List, Set,POJO

Strictly maps to JSONB (PostgreSQL-only, throws on other dialects)

PreferredJsonbType is the most commonly used because it provides optimal storage and indexing (jsonb) on PostgreSQL while gracefully degrading to json on MySQL.

4.8.1. Metadata Storage (army-spring-ai-vector-store)

The io.army.spring.ai.vectorstore.SpringAiVectorStore.metadata field uses PreferredJsonbType to store a Map<String, Object>:

@Column(name = "${DEFAULT}", notNull = true, defaultValue = "'{}'", comment = "${DEFAULT}")
@Mapping("io.army.mapping.PreferredJsonbType")
private Map<String, Object> metadata;

On PostgreSQL this produces a JSONB column with a GIN index for efficient containment queries; on MySQL it produces a JSON column.

4.8.2. Specialized Data Storage (army-spring-ai-model-chat-memory)

The io.army.spring.ai.chat.memory.SpringAiChatMemory.specializedData field stores tool call data or tool response data as JSON:

/// store one of below :
/// 1. AssistantMessage.getToolCalls()
/// 2. ToolResponseMessage.getResponses()
@Column(name = "${DEFAULT}", notNull = true, defaultValue = "'[]'",
        comment = "${DEFAULT}")
@Mapping("io.army.mapping.PreferredJsonbType")
private String specializedData;

The ArmyMessageChatMemory.add() method uses JsonCodec to serialize tool calls/responses before inserting:

// Inside ArmyMessageChatMemory.copyMessageInfo()
if (message instanceof AssistantMessage a) {
    o.setSpecializedData(codec.encode(a.getToolCalls()));
} else if (message instanceof ToolResponseMessage t) {
    o.setSpecializedData(codec.encode(t.getResponses()));
}

4.8.3. List Storage in JSONB (UploadRecord)

The io.army.example.stock.domain.UploadRecord.vectorIdList field stores a List<Long> of vector IDs as JSONB:

@Column(notNull = true, defaultValue = "'[]'", comment = "vector ID list")
@Mapping("io.army.mapping.PreferredJsonbType")
private List<Long> vectorIdList;

4.9. Text vs Varchar — Choosing the Right String Mapping

Army distinguishes between three string storage strategies:

MappingType SQL Type Use Case

StringType (default for String)

VARCHAR(precision)

Short to medium strings (names, codes, hashes) — precision comes from @Column(precision=N)

TextType

TEXT

Long, unbounded text (article content, message body, document text)

SqlCharType

CHAR(N)

Fixed-length strings (SHA-256 Base64 hashes, ISO country codes)

// VARCHAR — default inference for String, precision from @Column
@Column(notNull = true, precision = 130, comment = "stock company name")
public String name;  // → StringType → VARCHAR(130)

// CHAR — fixed-length, used for deterministic-length hashes
@Column(precision = 44, comment = "file hash")
@Mapping("io.army.mapping.SqlCharType")
public String fileHash;  // → SqlCharType → CHAR(44)

// TEXT — unbounded, for long content
@Column(notNull = true, comment = "first chat message content")
@Mapping("io.army.mapping.TextType")
private String firstContent;  // → TextType → TEXT

4.10. How TypeMappingHandler Resolves to SQL Types

After Army determines a field’s MappingType, the io.army.dialect.MappingHandler interface finalizes the concrete DataType for the target database:

public sealed interface TypeMappingHandler permits TypeMappingHandlerSupport {

    /// For a given custom type name and MappingType array, return the concrete DataType.
    DataType apply(String typeName, MappingType[] typeArray, int index);
}

The implementation io.army.dialect.TypeMappingHandlerSupport performs this resolution:

  1. Custom type lookup — First checks if a DefinedTypeMapFunc is registered (for user-defined database types like PostgreEnum).

  2. Named type lookup — Checks the dialect’s internal type registry (e.g., "vector"PgType.VECTOR).

  3. Array detection — If the type name contains [ (e.g., "integer[]"), it resolves the element type then wraps it via arrayTypeOfThis().

  4. Fallback — If no match is found, returns the defaultType (the MappingType.map() result).

// Simplified resolution logic in TypeMappingHandlerSupport
final MappingType handleDefinedType(final String typeName, final MappingType defaultType) {
    // 1. Try user-defined type function
    MappingType type = func.apply(typeName, serverMeta, typeMap);
    if (type != null) return type;

    // 2. Check if it's an array type (e.g., "integer[]")
    final int index = typeName.indexOf('[');
    if (index < 0) {
        type = this.typeMap.getOrDefault(typeName, defaultType);
    } else {
        type = this.typeMap.get(typeName.substring(0, index));  // element type
        if (type != null) {
            type = ((MappingType.SqlArray) type.arrayTypeOfThis()).unlimited();
        } else {
            type = defaultType;
        }
    }
    return type;
}

This design means the MappingType hierarchy is database-agnostic — each MappingType declares its target DataType per-dialect in map(), and MappingHandler provides the final resolution for custom/composite/array types. In most cases, developers interact only with the @Mapping annotation; the handler chain operates transparently behind the scenes.

4.11. Custom MappingType — Extending the Type System

When the built-in mapping types don’t fit your domain, Army provides two abstract base classes for creating custom types.

4.11.1. Step 1: Choose the Right Base Class

Scenario Base Class Example

Non-array Java type

Extend io.army.mapping.UserMappingType

A custom Currency type mapped to NUMERIC(10,2)

Array Java type

Extend io.army.mapping.UserArrayType

A custom IntRange[] type mapped to PostgreSQL INT4RANGE[]

UserMappingType extends AbstractMappingType, which already provides beforeBind() / afterGet() defaults (pass-through to String). UserArrayType extends UserMappingType and additionally implements ArrayMappingType.

4.11.2. Step 2: Implement the Corresponding Sql* Marker Interface

Every mapping type should declare what kind of SQL value it represents by implementing one of `MappingType’s inner marker interfaces. The Army framework uses these markers for validation and type compatibility checks.

The map(), beforeBind(), and afterGet() methods work regardless of whether you implement a Sql* marker — the mapping will execute correctly in basic read/write scenarios.

The problem arises when Army needs to know the semantic category of your type. For example:

  • Generating DDL — Army must decide INT vs VARCHAR vs TIMESTAMP column type. Without a marker, it cannot infer the SQL type family.

  • Type compatibility checks — when comparing two columns or assigning a parameter, Army verifies that both sides are in the same type family (e.g., both are SqlNumber).

  • Array element inference — when creating an array type automatically, Army uses the marker to determine the element SQL type.

In short: your type will work without a marker, but it may fail in DDL generation or type-checking scenarios. Implementing the correct Sql* interface is strongly recommended.

Common marker interfaces:

Category Interface When to Use

Number

MappingType.SqlInteger

Maps to INT. Your type handles integer values.

Number

MappingType.SqlDecimal

Maps to NUMERIC. Your type handles decimal values.

String

MappingType.SqlText

Maps to TEXT. Your type handles long unbounded strings.

String

MappingType.SqlString

Maps to VARCHAR. Your type handles short bounded strings.

Binary

MappingType.SqlBlob

Maps to BYTEA / BLOB. Your type handles binary data.

JSON

MappingType.SqlJson

Maps to JSON. Your type stores JSON documents.

Extension

MappingType.SqlVector

Maps to pgvector VECTOR type. Combine with MappingType.SqlExtension to auto-create the extension.

Time

MappingType.SqlLocalDateTime

Maps to TIMESTAMP. Your type handles date-time values.

The full list of marker interfaces is in io.army.mapping.MappingType:

  • BooleanSqlBoolean

  • NumericSqlInteger, SqlFloat, SqlDecimal

  • StringSqlString, SqlText

  • BinarySqlBinary, SqlBlob, SqlBit

  • JSONSqlJson, SqlJsonb

  • TimeSqlLocalDate, SqlLocalTime, SqlLocalDateTime, SqlOffsetDateTime, SqlDuration

  • ExtensionSqlVector, SqlExtension

Pick the one that most closely describes your target SQL type — each has a single unambiguous semantic meaning.

4.11.3. Step 3: Follow the Implementation Pattern

All built-in mapping types follow a consistent pattern. Your custom type must adhere to it for framework compatibility:

/// Custom mapping: maps a Java `Currency` record to PostgreSQL `NUMERIC(10,2)`.
public final class CurrencyType extends UserMappingType   // io.army.mapping.UserMappingType
        implements MappingType.SqlDecimal {

    // 1. Static factory method — required for @Mapping resolution
    public static CurrencyType from(final Class<?> javaType) {
        if (javaType != Currency.class) {
            throw errorJavaType(CurrencyType.class, javaType);
        }
        return INSTANCE;
    }

    public static final CurrencyType INSTANCE = new CurrencyType();

    // 2. Constructor must be non-public (private or package-private)
    private CurrencyType() {
    }

    // 3. Declare the Java type this mapping handles
    @Override
    public Class<?> javaType() {
        return Currency.class;
    }

    // 4. Map to the concrete SQL DataType per database
    @Override
    public DataType map(final ServerMeta meta) {
        final DataType dataType;
        switch (meta.serverDatabase()) {
            case PostgreSQL:
                dataType = PgType.NUMERIC;
                break;
            case MySQL:
                dataType = MySQLType.DECIMAL;
                break;
            default:
                throw mapError(this, meta);
        }
        return dataType;
    }

    // 5. Serialize Java value → SQL parameter
    @Override
    public BigDecimal beforeBind(DataType dataType, MappingEnv env, Object source) {
        if (!(source instanceof Currency c)) {
            throw paramError(this, dataType, source, null);
        }
        return c.value();
    }

    // 6. Deserialize database value → Java object
    @Override
    public Currency afterGet(DataType dataType, MappingEnv env, Object source) {
        final Currency value;
        if (source instanceof Currency c) {
            value = c;
        } else if (source instanceof BigDecimal bd) {
            value = new Currency(bd);
        } else if (source instanceof String s) {
            value = new Currency(new BigDecimal(s));
        } else {
            throw dataAccessError(this, dataType, source, null);
        }
        return value;
    }

    // 7. Required: override hashCode/equals for singleton types
    @Override
    public int hashCode() {
        return System.identityHashCode(this);
    }

    @Override
    public boolean equals(Object obj) {
        return obj instanceof CurrencyType;
    }
}

The from(Class) static factory method is how _MappingFactory.doMap reflectively creates your MappingType. Without it, the framework cannot instantiate your type. The constructor must be non-public — all built-in types use private constructors and expose a singleton INSTANCE.

4.11.4. Array Type Example

For array types, extend UserArrayType and implement MappingType.SqlArray:

/// Custom mapping: maps `Currency[]` to PostgreSQL `NUMERIC[]`.
public final class CurrencyArrayType extends UserArrayType
        implements MappingType.SqlArray {

    public static CurrencyArrayType from(final Class<?> javaType) {
        final CurrencyArrayType instance;
        if (javaType == Currency[].class) {
            instance = LINEAR;
        } else if (javaType == Object.class) {
            instance = UNLIMITED;
        } else if (!javaType.isArray()) {
            throw errorJavaType(CurrencyArrayType.class, javaType);
        } else if (ArrayUtils.underlyingComponent(javaType) == Currency.class) {
            instance = new CurrencyArrayType(javaType);
        } else {
            throw errorJavaType(CurrencyArrayType.class, javaType);
        }
        return instance;
    }

    /// Used for default array parsing when the database returns an array
    /// of unknown dimensions. Typically consumed by {@code io.army.dialect.MappingHandler}
    /// implementations.
    public static final CurrencyArrayType UNLIMITED = new CurrencyArrayType(Object.class);

    public static final CurrencyArrayType LINEAR = new CurrencyArrayType(Currency[].class);

    private final Class<?> javaType;

    /// private constructor
    private CurrencyArrayType(Class<?> javaType) {
        this.javaType = javaType;
    }

    @Override
    public final Class<?> javaType() {
        return this.javaType;
    }

    @Override
    public final Class<?> underlyingJavaType() {
        return Currency.class;
    }

    @Override
    public final DataType map(final ServerMeta meta) {
        final DataType dataType;
        switch (meta.serverDatabase()) {
            case PostgreSQL:
                dataType = PgType.NUMERIC_ARRAY;
                break;
            default:
                throw mapError(this, meta);
        }
        return dataType;
    }

    @Override
    public final MappingType elementType() {
        final MappingType instance;
        final Class<?> javaType = this.javaType;
        if (javaType == Object.class) { // unlimited dimension array
            instance = this;
        } else if (javaType == Currency[].class) {
            instance = CurrencyType.INSTANCE;
        } else {
            instance = from(javaType.getComponentType());
        }
        return instance;
    }

    @Override
    public MappingType arrayTypeOfThis() throws CriteriaException {
        final Class<?> javaType = this.javaType;
        if (javaType == Object.class) { // unlimited dimension array
            return this;
        }
        return from(ArrayUtils.arrayClassOf(javaType));
    }

    // beforeBind / afterGet omitted for brevity — see built-in array types for reference

    @Override
    public int hashCode() {
        return this.javaType.hashCode();
    }

    @Override
    public boolean equals(Object obj) {
        final boolean match;
        if (obj == this) {
            match = true;
        } else if (obj instanceof CurrencyArrayType o) {
            match = o.javaType == this.javaType;
        } else {
            match = false;
        }
        return match;
    }
}

For array types, the element type is obtained from the field’s component type via reflection — you do NOT need @Mapping(elements = …​). The elements attribute is only for generic collections (List<Foo>, Set<Bar>) where type erasure loses the element type.

4.11.5. Quick Checklist

Before writing your custom type, verify:

  • Non-array type → extends UserMappingType; Array type → extends UserArrayType

  • Implements the correct Sql* marker interface (e.g., SqlDecimal, SqlVector)

  • Provides a public static …​ from(Class) factory method

  • Constructor is private (or package-private)

  • Overrides javaType(), map(), beforeBind(), afterGet()

  • Overrides hashCode() and equals(Object)

  • For array types, also overrides underlyingJavaType(), elementType(), and arrayTypeOfThis()

  • (Optional) If the type needs a PostgreSQL extension, implements MappingType.SqlExtension

4.12. Full Mapping Chain Summary

Stage What Happens Example

1. Annotation Processing

Army reads @Mapping (or infers default) to determine MappingType

@Mapping("io.army.mapping.optional.VectorType")VectorType

2. Dialect Resolution

MappingType.map(ServerMeta) returns database-specific DataType

VectorType.map()PgType.VECTOR (PostgreSQL only)

3. TypeHandler Finalization

TypeMappingHandler.apply() resolves custom/composite/array types

"vector"PgType.VECTOR; "integer[]"PgType.INTEGER_ARRAY

4. Value Binding

MappingType.beforeBind() serializes Java value to SQL parameter

VectorType.beforeBind(): float[]{0.1,0.2}"[0.1,0.2]"

5. Value Fetching

MappingType.afterGet() deserializes database value to Java object

VectorType.afterGet(): "[0.1,0.2]"float[]{0.1,0.2}

5. Criteria API — Writing SQL

The Criteria API is Army’s SQL construction layer. It generates a database-agnostic statement tree through a type-safe Java DSL, which the dialect parser then translates into native SQL.

5.1. Standard Criteria API

The Standard Criteria API is Army’s database-agnostic SQL construction layer. It generates an internal statement tree through a type-safe Java DSL, which the dialect parser then translates into native SQL for your target database.

Concept Interface (design) Implementation

Entry point

SQLs (utility class)

criteria/impl/SQLs.java

Statement specs

standard/StandardInsert
standard/StandardQuery
standard/StandardUpdate
standard/StandardDelete

impl/StandardInserts
impl/StandardQueries
impl/StandardUpdates
impl/StandardDeletes

Standard functions

standard/SQLFunction

impl/Functions

Window functions

dialect/Window

impl/Windows

5.1.1. The SQLs Utility Class

SQLs (../army-core/src/main/java/io/army/criteria/impl/SQLs.java) is the unified entry point. Internally, its static factory methods delegate to StandardInserts, StandardQueries, StandardUpdates, and StandardDeletes:

// SELECT entry
Select stmt = SQLs.query()...asQuery();

// INSERT entry
Insert stmt = SQLs.singleInsert()...asInsert();

// UPDATE entries
Update stmt = SQLs.singleUpdate()...asUpdate();   // single table
Update stmt = SQLs.domainUpdate()...asUpdate();   // domain (primarily child table)

// DELETE entries
Delete stmt = SQLs.singleDelete()...asDelete();   // single table
Delete stmt = SQLs.domainDelete()...asDelete();   // domain (primarily child table)

// Batch domain variants
BatchUpdate bStmt = SQLs.batchDomainUpdate()...asUpdate().namedParamList(paramList);
BatchDelete bDel = SQLs.batchDomainDelete()...asDelete().namedParamList(paramList);

Each returns a type-safe chain that exposes only the clauses valid for that statement type.

5.1.2. SELECT Statements

SQLs.query() enters the SELECT chain. Internally it calls StandardQueries#simpleQuery, which returns a StandardQuery.WithSpec.

Basic SELECT
final Select stmt;
stmt = SQLs.query()
        .select(Stock_.id, Stock_.name, Stock_.offerPrice)
        .from(Stock_.T, AS, "s")
        .where(Stock_.name.equal("Tesla"))
        .and(Stock_.createTime.less(LocalDateTime.now().minusDays(1)))
        .limit(1)
        .asQuery();
Generated SQL (PostgreSQL)
SELECT s.id, s.name, s.offer_price
FROM stock AS s
WHERE s.name = ?  -- param: 'Tesla'
AND s.create_time < ?  -- param: now - 1 day
LIMIT 1
Full Clause Chain

The SELECT chain follows SQL clause order:

  1. WITH …​ AS — CTE prefix (via with(name).as(…​))

  2. SELECTselect(…​) / selectDistinct(…​)

  3. FROMfrom(table, AS, alias) / nested from(function)

  4. JOINjoin(…​) / leftJoin(…​) / rightJoin(…​) / crossJoin(…​)

  5. WHEREwhere(predicate).and(predicate)…​

  6. GROUP BYgroupBy(…​)

  7. HAVINGhaving(predicate)

  8. WINDOWwindow(name).as(…​) (named window definitions)

  9. ORDER BYorderBy(…​)

  10. LIMIT / OFFSETlimit(n).offset(n)

  11. FOR UPDATElock(…​) for row-level locking

  12. UNION / INTERSECT / EXCEPT — set operations via union(…​), etc.

  13. asQuery() — terminal method, returns Select

Each clause method returns the next narrow interface in the chain, so the compiler enforces correct SQL clause ordering.

5.1.3. INSERT Statements

Single Table INSERT

SQLs.singleInsert() returns StandardInsert._PrimaryOptionSpec<Insert>. Internally it calls StandardInserts#singleInsert.

Domain mode — pass domain objects directly. Defined in InsertStatement._DomainValuesClause
InsertStatement._DomainValueClause
_ValuesColumnDefaultSpec:

// (A) Single domain object
//     <TS extends T> R value(TS domain)  →  returns _DmlInsertClause<Insert>
final Insert stmt1;
stmt1 = SQLs.singleInsert()
        .insertInto(Stock_.T)                               // → _ColumnListSpec
        .value(new Stock("GOOGL", "Alphabet"))               // → _DmlInsertClause<Insert>
        .asInsert();                                        // → Insert

// (B) List of domain objects (multi-row)
//     <TS extends T> R values(List<TS> domainList)
final Insert stmt2;
stmt2 = SQLs.singleInsert()
        .insertInto(Stock_.T)
        .defaultValue(Stock_.status, StockStatus.NORMAL)    // optional column default
        .values(stockList)                                   // → _DmlInsertClause<Insert>
        .asInsert();
Generated SQL (PostgreSQL)
-- (A) Single domain: columns follow field declaration order in the document's Stock:
--     id(PRECEDE), create_time, update_time, version, exchange,
--     code, name, status, offer_price, listing_date
INSERT INTO stock (id, create_time, update_time, version, exchange,
                   code, name, status, offer_price, listing_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

-- (B) List with defaultValue: status per-row;
--     when Stock.status is null, 'NORMAL' is substituted
INSERT INTO stock (id, create_time, update_time, version, exchange,
                   code, name, status, offer_price, listing_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
       (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Method Interface signature Returns

value(TS domain)

<TS extends T> R value(TS domain) in _DomainValueClause<T,R>

_DmlInsertClause<I>.asInsert()

values(List<TS>)

<TS extends T> R values(List<TS> domainList) in _DomainValuesClause<T,R>

_DmlInsertClause<I>.asInsert()

Value mode — Static (single row / multi-row)

Requires .values() (no-arg) first — this bridges _ColumnListSpec via _StaticValuesClause to StandardInsert._StandardValuesParensClause, which extends InsertStatement._ValuesParensClause.

Each parens() call adds one VALUES row. All columns are referenced via FieldMeta constants.

Single row:

// .values() → _StandardValuesParensClause
// .parens(Consumer<_StaticValueSpaceClause>) → _ValuesParensCommaSpec → .asInsert()
final Insert stmt;
stmt = SQLs.singleInsert()
        .insertInto(Stock_.T)
        .values()                                           // _StaticValuesClause.values() no-arg
        .parens(row -> row                                  // Consumer<_StaticValueSpaceClause<Stock>>
                .space(Stock_.exchange, "NYSE")             //   space(FieldMeta<T>, @Nullable Object)
                .comma(Stock_.code, "AAPL")                 //   comma(FieldMeta<T>, @Nullable Object)
                .comma(Stock_.name, SQLs::param, "Alphabet")//   comma(FieldMeta, BiFunction, E) — binding
                .ifComma(Stock_.offerPrice, offerValue)     //   conditional comma — skipped when null
        )
        .asInsert();
Generated SQL (PostgreSQL)
-- Value mode always inserts ALL table columns; fields not explicitly set
-- use DEFAULT (NullMode.DEFAULT→INSERT_DEFAULT for PostgreSQL)
-- id, create_time, update_time, version are managed by Army
INSERT INTO stock (id, create_time, update_time, version, exchange,
                   code, name, status, offer_price, listing_date)
VALUES (?, ?, ?, ?, ?, ?, ?, DEFAULT, ?,          -- offer_price uses DEFAULT when offerValue is null
        DEFAULT)           -- listing_date uses DEFAULT
-- params: id(snowflake), 'NYSE', 'AAPL', ?(param: 'Alphabet'), offerValue
Table 1. _StaticValueSpaceClause methods:
Method Signature

space(field, value)

First pair; value can be literal, Expression, null, or SQLs.DEFAULT

space(field, funcRef, value)

First pair with binding: SQLs::param

spaceIf(field, value)

Conditional first pair — skipped when value is null

spaceIf(field, funcRef, value)

Conditional first pair with binding

comma(field, value)

Subsequent pair

comma(field, funcRef, value)

Subsequent pair with binding

ifComma(field, value)

Conditional subsequent pair

ifComma(field, funcRef, value)

Conditional subsequent pair with binding

Multi-row — chain via .comma():

// .parens() → _ValuesParensCommaSpec  →  .comma() loops back
// _ValuesParensCommaSpec extends _CommaClause<_StandardValuesParensClause>
final Insert stmt;
stmt = SQLs.singleInsert()
        .insertInto(Stock_.T)
        .values()
        .parens(r -> r                                   // row 1
                .space(Stock_.exchange, "NYSE")
                .comma(Stock_.code, "AAPL")
                .comma(Stock_.name, "Apple Inc.")
        )
        .comma()                                           // → back to _StandardValuesParensClause
        .parens(r -> r                                    // row 2
                .space(Stock_.exchange, "NASDAQ")
                .comma(Stock_.code, "GOOGL")
                .comma(Stock_.name, SQLs::param, "Alphabet")
        )
        .comma()
        .parens(r -> r                                    // row 3 (conditional first column)
                .spaceIf(Stock_.exchange, exchangeValue)   // skipped if exchangeValue == null
                .comma(Stock_.code, "MSFT")
                .comma(Stock_.name, "Microsoft")
        )
        .asInsert();
Generated SQL (PostgreSQL)
-- Default mode: all table columns appear in the INSERT list.
-- id, create_time, update_time, version managed by Army;
-- unset nullable fields → DEFAULT (NullMode.DEFAULT→INSERT_DEFAULT for PostgreSQL)
INSERT INTO stock (id, create_time, update_time, version, exchange,
                   code, name, status, offer_price, listing_date)
VALUES (?, ?, ?, ?, ?, ?, ?, DEFAULT, DEFAULT,
        DEFAULT),         -- row 1: 'NYSE', 'AAPL', 'Apple Inc.'
       (?, ?, ?, ?, ?, ?, ?, DEFAULT, DEFAULT,
        DEFAULT),         -- row 2: 'NASDAQ', 'GOOGL', ? (param: 'Alphabet')
       (?, ?, ?, ?, ?, ?, ?, DEFAULT, DEFAULT,
        DEFAULT)          -- row 3: exchangeValue non-null→param, null→DEFAULT; 'MSFT', 'Microsoft'

Value mode — Dynamic (multi-row via ValuesConstructor)

Defined in InsertStatement._DynamicValuesClause: values(Consumer<ValuesConstructor<T>>). Returns _DmlInsertClause<I>.

ValuesConstructor<T> extends _ValuesParensClause<T. Each parens() returns a comma clause; .comma() chains back to ValuesConstructor.

Inside parens(), there are two sub-modes:

Static row — type-safe via FieldMeta (same _StaticValueSpaceClause as above):

final Insert stmt;
stmt = SQLs.singleInsert()
        .insertInto(Stock_.T)
        .values(rows -> rows                                // Consumer<ValuesConstructor<Stock>>
                .parens(r -> r                           // Consumer<_StaticValueSpaceClause<Stock>>
                        .space(Stock_.exchange, "NYSE")    //   space(FieldMeta<T>, Object)
                        .comma(Stock_.code, "AAPL")
                        .comma(Stock_.name, "Apple Inc.")
                )
                .comma()                                    // → back to ValuesConstructor
               .parens(SQLs.SPACE,s-> {   // row 2   dynamic column
                        s.set(Stock_.exchange, "NASDAQ");
                        s.set(Stock_.code, "GOOGL");
                        s.set(Stock_.name, SQLs::param, "Alphabet"); // set with binding
                })
        )
        .asInsert();
Generated SQL (PostgreSQL)
INSERT INTO stock (id, create_time, update_time, version, exchange,
                   code, name, status, offer_price, listing_date)
VALUES (?, ?, ?, ?, ?, ?, ?, DEFAULT, DEFAULT,
        DEFAULT),        -- row 1: 'NYSE', 'AAPL', 'Apple Inc.'
       (?, ?, ?, ?, ?, ?, ?, DEFAULT, DEFAULT,
        DEFAULT)         -- row 2: 'NASDAQ', 'GOOGL', ? (param: 'Alphabet')

Dynamic row — via Assignments (uses set() instead of space()/comma()):

final Insert stmt;
stmt = SQLs.singleInsert()
        .insertInto(Stock_.T)
        .values(rows -> rows                                // Consumer<ValuesConstructor<Stock>>
                .parens(s -> s                  // Consumer<Assignments<Stock>>
                        .space(Stock_.exchange, "NYSE")       //   space(FieldMeta<T>, @Nullable Object)
                        .comma(Stock_.code, "AAPL")           //   each set() returns Assignments<T>
                        .comma(Stock_.name, "Apple Inc.")
                        .ifComma(Stock_.offerPrice, offer)    //   conditional — skipped when null
                )
                .comma()
                .parens(SQLs.SPACE,s-> {   // row 2   dynamic column
                        s.set(Stock_.exchange, "NASDAQ");
                        s.set(Stock_.code, "GOOGL");
                        s.set(Stock_.name, SQLs::param, "Alphabet"); // set with binding
                })
        )
        .asInsert();
Generated SQL (PostgreSQL)
INSERT INTO stock (id, create_time, update_time, version, exchange,
                   code, name, status, offer_price, listing_date)
VALUES (?, ?, ?, ?, ?, ?, ?, DEFAULT, ?,           -- row 1: offer_price uses DEFAULT when offer is null
        DEFAULT),
       (?, ?, ?, ?, ?, ?, ?, DEFAULT, DEFAULT,
        DEFAULT)
-- row 1: 'NYSE', 'AAPL', 'Apple Inc.', offer
-- row 2: 'NASDAQ', 'GOOGL', ? (param: 'Alphabet')
Method Signature

set(field, value)

R set(FieldMeta<T>, @Nullable Object) — literal, Expression, or null

set(field, funcRef, value)

<E> R set(FieldMeta<T>, BiFunction<…​,Expression>, @Nullable E) — with binding

ifSet(field, value)

R ifSet(FieldMeta<T>, @Nullable Object) — conditional, skipped when null

ifSet(field, funcRef, value)

<E> R ifSet(FieldMeta<T>, BiFunction<…​,Expression>, @Nullable E) — conditional with binding

The Static row sub-mode uses the same StaticValueSpaceClause lambdas as the non-Dynamic path. The _Dynamic row sub-mode uses Assignments.set() which is useful when columns are added in a loop or conditionally.

Query mode — insert from a subquery. Defined in InsertStatement._QueryInsertSpaceClause
InsertStatement._ColumnListParensClause.

_ColumnListParensClause has two parens() overloads for specifying the target column list. _QueryInsertSpaceClause has three space() overloads for providing the subquery source.

Signal Method Returns

static columns

parens(Consumer<_StaticColumnSpaceClause<T>>)

_ComplexColumnDefaultSpec

dynamic columns

parens(SQLs.SymbolSpace, Consumer<Consumer<FieldMeta<T>>>)

_ComplexColumnDefaultSpec

pre-built subquery

space(Supplier<SubQuery>)

_DmlInsertClause<I>

inline via function

space(Function<SelectSpec, _DmlInsertClause<I>>)

_DmlInsertClause<I>

bare space()

T space()

SelectSpec<_DmlInsertClause<I>> → chain .asInsert()

Form 1 — Static columns + pre-built SubQuery:

// _StaticColumnSpaceClause: space(f1,f2) — 1/2/4 column params supported
final Insert stmt;
stmt = SQLs.singleInsert()
        .insertInto(Stock_.T)
        .parens(cols -> cols                                // Consumer<_StaticColumnSpaceClause<Stock>>
                .space(Stock_.exchange, Stock_.code)        //   space(FieldMeta, FieldMeta) → _StaticColumnDualClause
                .comma(Stock_.name)                         //   comma(FieldMeta)
        )
        .space(subQuery)                                    // SQLs.subQuery()...asQuery() → subQuery
        .asInsert();
Generated SQL (PostgreSQL)
INSERT INTO stock (exchange, code, name, id, create_time,
                   update_time, version)
SELECT ...  -- subQuery SQL; append  Army-managed fields (id, create_time, update_time, version)

Form 2 — Dynamic columns + pre-built SubQuery:

// Dynamic column list: each accept(field) adds one column
final Insert stmt;
stmt = SQLs.singleInsert()
        .insertInto(Stock_.T)
        .parens(SQLs.SPACE, list -> {                       // Consumer<Consumer<FieldMeta<Stock>>>
                list.accept(Stock_.exchange);                //   accept(FieldMeta<T>)
                list.accept(Stock_.code);
                list.accept(Stock_.name);
        })
        .space(subQuery)                              // SQLs.subQuery()...asQuery()
        .asInsert();
Generated SQL (PostgreSQL)
INSERT INTO stock (exchange, code, name, id, create_time,
                   update_time, version)
SELECT ...  -- subQuery SQL; Army-managed fields (id, create_time, update_time, version)
-- are appended to column list; all column values come from the subquery

Form 3 — No column list (subquery determines columns):

// Direct .space(subQuery) on _ComplexColumnDefaultSpec — no parens() needed
final Insert stmt;
stmt = SQLs.singleInsert()
        .insertInto(Stock_.T)
        .space(subQuery)                                    // _QueryInsertSpaceClause.space(Supplier)
        .asInsert();
Generated SQL (PostgreSQL)
INSERT INTO stock
SELECT ...  -- subQuery determines all columns

Form 4 — No-arg space() returns SelectSpec (advanced):

// space() no-arg → SelectSpec<_DmlInsertClause<Insert>>
// Build subquery inline, chain ends with .asInsert()
final Insert stmt;
stmt = SQLs.singleInsert()
        .insertInto(Stock_.T)
        .parens(cols -> cols.space(Stock_.exchange, Stock_.code, Stock_.name, Stock_.status))
        .space()                                            // T space() → SelectSpec
        .select(Stock_.exchange, Stock_.code, Stock_.name)
        .from(NasdaqTable_.T, AS, "n")
        .where(NasdaqTable_.listed.equal(true))
        .asInsert();
Generated SQL (PostgreSQL)
INSERT INTO stock (exchange, code, name, status, id, create_time,
                   update_time, version)
SELECT n.exchange, n.code, n.name, DEFAULT, DEFAULT, DEFAULT,
       DEFAULT, DEFAULT
FROM nasdaq_table AS n
WHERE n.listed = ?  -- param: true
-- status: DB column default (not in user SELECT)
-- id, create_time, update_time, version: appended by Army, all values from the subquery
Child INSERT (Parent-Child Hierarchy)

When using @Inheritance single-table inheritance, a parent insert must be followed by a child insert. This is enforced at the interface level: insertInto(ParentTableMeta) returns a chain that ends with child() to enter the child table’s insert.

Internally, StandardInserts handles this via _ChildInsertIntoClause and InsertStatement._ParentInsert20:

// Parent insert → child insert chain
final Insert stmt;
stmt = SQLs.singleInsert()
        .insertInto(ParentUser_.T)         // ParentTableMeta — returns _ParentInsert20
        .defaultValue(ParentUser_.status, UserStatus.ADMIN)
        .values(parentUserList)
        .child()                            // enters child insert context
        .insertInto(Admin_.T)              // ChildTableMeta — returns _ColumnListSpec
        .values(adminUserList)
        .asInsert();
Generated SQL (PostgreSQL)
-- Statement 1: parent rows (no discriminator column)
--     Columns follow field declaration order in ParentUser class:
--     1. Army managed fields: id, create_time, update_time, version
--     2. Parent business fields: name
INSERT INTO u_parent_user (id, create_time, update_time, version, name)
VALUES (?, ?, ?, ?, ?),
       ...

-- Statement 2: child rows (includes discriminator column)
--     Columns follow field declaration order in Admin class:
--     1. id (mandatory, first position; child id always managed by Army)
--     2. Child-specific fields: admin_level, role
--     discriminator 'ADMIN' from @DiscriminatorValue is auto-injected
INSERT INTO u_admin (id, admin_level, role, status)
VALUES (?, ?, ?, ?),
       ...

-- NOTE: For parent/simple tables, when id GeneratorType is POST (database
--       auto-increment), the id column is NOT included in the column list.
--       Child tables ALWAYS include id regardless of GeneratorType.

The child() method is only available when the previous insertInto() received a ParentTableMeta. Army generates separate INSERT statements for parent and child tables internally.

INSERT Options
Option Interface (from InsertStatement) Chain position

literalMode(mode)

_PreferLiteralClause — reduce placeholders for large batches

before insertInto()

nullMode(mode)

_NullOptionClause — control NULL handling

before insertInto()

migration()

_MigrationOptionClause — migration mode for schema transfer

before insertInto()

ignoreReturnIds()

_IgnoreReturnIdsOptionClause — skip ID return for conflict clauses

before insertInto()

5.1.4. UPDATE Statements

Single Table UPDATE

SQLs.singleUpdate() enters the UPDATE chain. Internally it calls StandardUpdates#singleUpdate.

final Update stmt;
stmt = SQLs.singleUpdate()
        .update(Stock_.T, AS, "s")         // SingleTableMeta — requires alias
        .set(Stock_.name, "Tesla")
        .set(Stock_.offerPrice, SQLs::plusEqual, addPrice)
        .where(Stock_.id.between(map.get("firstId"), SQLs.AND, map.get("secondId")))
        .asUpdate();
Generated SQL (PostgreSQL)
UPDATE stock AS s
SET name = ?,
    offer_price = s.offer_price + ?,
    update_time = ?,
    version = s.version + 1
WHERE s.id BETWEEN ? AND ?

The set() method comes from UpdateStatement._StaticSetClause. It supports value operators like SQLs::plusEqual, SQLs::minusEqual, etc., and setRow() for multi-column subquery assignment.

Domain UPDATE

SQLs.domainUpdate() is primarily designed for child tables in @Inheritance single-table inheritance hierarchies. It accepts ChildTableMeta — allowing updates to a domain object’s fields including those inherited from parent classes, all in a single UPDATE statement against the shared table. It also works with SingleTableMeta for plain single-table domains.

SQLs.batchDomainUpdate() is the batch variant, returning _DomainUpdateClause<_BatchUpdateParamSpec> — chain .namedParamList(paramList) after .asUpdate() to get BatchUpdate.

Child table domain UPDATE (primary use case):

// Admin child table inherits from User (see @Inheritance / @DiscriminatorValue)
// Admin_.T is ChildTableMeta — mapped to u_admin
final Update stmt;
stmt = SQLs.domainUpdate()
        .update(Admin_.T, AS, "a")         // ChildTableMeta — can reference parent fields
        .set(Admin_.adminLevel, 5)              // child-specific field: triggers CTE/multi-table path
        .set(ParentUser_.name, "Admin User")    // parent field: name
        .set(ParentUser_.visible, false)         // parent field: visible (soft delete)
        .where(Admin_.id.equal(1L))
        .asUpdate();
Generated SQL (PostgreSQL)
-- Domain UPDATE on child table Admin_.T:
-- PostgreSQL uses CTE-based two-stage update:
--   1. CTE: UPDATE child table child-specific fields + RETURNING id
--   2. Main: UPDATE parent table fields FROM cte
WITH a_update_cte AS (
    UPDATE u_admin AS a
    SET admin_level = ?
    FROM u_parent_user AS p_of_a
    WHERE a.id = p_of_a.id         -- join condition (first)
    AND p_of_a.id = ?  -- param: 1
    AND p_of_a.status = 'ADMIN'  -- discriminator auto-injected
    AND p_of_a.visible = true    -- visible auto-filtered
    RETURNING a.id AS id
)
UPDATE u_parent_user AS p_of_a
SET name = ?,
    visible = ?,
    update_time = ?,
    version = p_of_a.version + 1
FROM a_update_cte
WHERE p_of_a.id = a_update_cte.id
Generated SQL (MySQL)
-- Domain UPDATE on child table Admin_.T:
-- MySQL uses multi-table UPDATE syntax:
--   UPDATE <child> JOIN <parent> ON ... SET ...
UPDATE u_admin AS a
JOIN u_parent_user AS p_of_a ON a.id = p_of_a.id
SET p_of_a.name = ?,
    p_of_a.visible = ?,
    a.admin_level = ?,
    p_of_a.update_time = ?,
    p_of_a.version = p_of_a.version + 1
WHERE p_of_a.id = ?  -- param: 1
AND p_of_a.status = 'ADMIN'  -- discriminator auto-injected
AND p_of_a.visible = true    -- visible auto-filtered

Batch domain UPDATE (child table):

final BatchUpdate stmt;
List<Map<String, Object>> paramList = List.of(
    Map.of("name", "Alice", "adminLevel", 5, "id", 1L),
    Map.of("name", "Bob", "adminLevel", 3, "id", 2L)
);
stmt = SQLs.batchDomainUpdate()
        .update(Admin_.T, AS, "a")
        .sets(pairs -> pairs
                .setSpace(ParentUser_.name, SQLs::namedParam)
                .setSpace(Admin_.adminLevel, SQLs::namedParam)   -- child field  triggers CTE/multi-table
        )
        .where(Admin_.id.spaceEqual(SQLs::namedParam))
        .asUpdate()
        .namedParamList(paramList);

long rows = session.update(stmt);
Generated SQL (PostgreSQL)
-- Batch domain UPDATE on child table Admin_.T:
-- PostgreSQL uses CTE-based two-stage update
WITH a_update_cte AS (
    UPDATE u_admin AS a
    SET admin_level = ?              -- batch param: adminLevel
    FROM u_parent_user AS p_of_a
    WHERE a.id = p_of_a.id           -- join condition (first)
    AND p_of_a.id = ?              -- batch param: id (user WHERE before Army managed)
    AND p_of_a.status = 'ADMIN'  -- discriminator auto-injected (appended)
    AND p_of_a.visible = true      -- visible auto-filtered (appended)
    RETURNING a.id AS id
)
UPDATE u_parent_user AS p_of_a
SET name = ?,                       -- batch param: name
    update_time = ?,
    version = p_of_a.version + 1
FROM a_update_cte
WHERE p_of_a.id = a_update_cte.id
Generated SQL (MySQL)
-- Batch domain UPDATE on child table Admin_.T:
-- MySQL uses multi-table UPDATE syntax
UPDATE u_admin AS a
JOIN u_parent_user AS p_of_a ON a.id = p_of_a.id
SET p_of_a.name = ?,               -- batch param: name
    a.admin_level = ?,              -- batch param: adminLevel
    p_of_a.update_time = ?,
    p_of_a.version = p_of_a.version + 1
WHERE p_of_a.id = ?                  -- batch param: id (user WHERE before Army managed)
AND p_of_a.status = 'ADMIN'      -- discriminator auto-injected (appended)
AND p_of_a.visible = true          -- visible auto-filtered (appended)

Single-table domain UPDATE (also supported):

final Update stmt;
stmt = SQLs.domainUpdate()
        .update(Stock_.T, "s")              // TableMeta — no AS keyword
        .set(Stock_.name, "Tesla")
        .where(Stock_.id.equal(100L))
        .asUpdate();
Generated SQL (PostgreSQL)
UPDATE stock s
SET name = ?,
    update_time = ?,
    version = s.version + 1
WHERE s.id = ?  -- param: 100
Entry Point Description

SQLs.singleUpdate()

Standard UPDATE with table alias; update(SingleTableMeta, AS, alias) — single table only

SQLs.domainUpdate()

Domain UPDATE — primarily for child tables; accepts ChildTableMeta / TableMeta / SingleTableMeta

SQLs.batchDomainUpdate()

Batch domain UPDATE; returns _DomainUpdateClause<_BatchUpdateParamSpec> — chain .namedParamList()

Batch Single Table UPDATE

For batch operations on a single table with named parameters:

final BatchUpdate stmt;
List<Map<String, Object>> paramList = List.of(
    Map.of("name", "Tesla", "offerPrice", 250, "id", 1),
    Map.of("name", "Apple", "offerPrice", 180, "id", 2)
);
stmt = SQLs.batchSingleUpdate()
        .update(Stock_.T, AS, "s")
        .sets(pairs -> pairs
                .setSpace(Stock_.name, SQLs::namedParam)      // named param placeholder
                .setSpace(Stock_.offerPrice, SQLs::namedParam) // named param placeholder
        )
        .where(Stock_.id.spaceEqual(SQLs::namedParam))       // space* methods for named params
        .asUpdate()
        .namedParamList(paramList);

// Execute batch
long rows = session.update(stmt);
Generated SQL (PostgreSQL)
-- Batch 1: name='Tesla', offerPrice=250, id=1
UPDATE stock AS s
SET name = ?,
    offer_price = ?,
    update_time = ?,
    version = s.version + 1
WHERE s.id = ?

-- Batch 2: name='Apple', offerPrice=180, id=2
-- (same SQL structure, different parameter values)

5.1.5. DELETE Statements

Single Table DELETE

SQLs.singleDelete() enters the DELETE chain. Internally it calls StandardDeletes#singleDelete.

final Delete stmt;
stmt = SQLs.singleDelete()
        .deleteFrom(Stock_.T, AS, "s")      // SingleTableMeta — requires alias
        .where(Stock_.status.equal(StockStatus.DELISTED))
        .asDelete();
Generated SQL (PostgreSQL)
DELETE FROM stock AS s
WHERE s.status = ?  -- param: 'DELISTED'
Domain DELETE

SQLs.domainDelete() is primarily designed for child tables in @Inheritance single-table inheritance hierarchies. It accepts TableMeta (including ChildTableMeta) — so you can delete domain objects from a child table, with the discriminator value automatically filtered. It also works with SingleTableMeta for plain single-table domains.

SQLs.batchDomainDelete() is the batch variant, returning _DomainDeleteClause<_BatchDeleteParamSpec> — chain .namedParamList(paramList) after .asDelete() to get BatchDelete.

Child table domain DELETE (primary use case):

// Admin child table inherits from User — Admin_.T is ChildTableMeta — mapped to u_admin
final Delete stmt;
stmt = SQLs.domainDelete()
        .deleteFrom(Admin_.T, AS, "a")     // ChildTableMeta
        .where(ParentUser_.visible.equal(false))  // parent field: visible
        .asDelete();
Generated SQL (PostgreSQL)
-- Domain DELETE on child table Admin_.T:
-- PostgreSQL uses CTE-based two-stage delete:
--   1. CTE: DELETE child rows + RETURNING child IDs
--   2. Main: DELETE parent rows matched by returned IDs
-- discriminator is applied in CTE WHERE on parent alias "p_of_a"
WITH a_delete_cte AS (
    DELETE FROM u_admin AS a
    USING u_parent_user AS p_of_a
    WHERE a.id = p_of_a.id         -- join condition (first)
    AND p_of_a.visible = ?  -- param: false (0)
    AND p_of_a.status = 'ADMIN'  -- discriminator auto-injected
    AND p_of_a.visible = true    -- visible auto-filtered
    RETURNING a.id AS id
)
DELETE FROM u_parent_user AS p_of_a
USING a_delete_cte
WHERE p_of_a.id = a_delete_cte.id
Generated SQL (MySQL)
-- Domain DELETE on child table Admin_.T:
-- MySQL uses multi-table DELETE syntax:
--   DELETE <child_alias>, <parent_alias> FROM <child> JOIN <parent> WHERE ...
-- discriminator is applied in WHERE on parent alias "p_of_a"
DELETE a, p_of_a
FROM u_admin AS a
JOIN u_parent_user AS p_of_a ON a.id = p_of_a.id
WHERE p_of_a.visible = ?  -- param: false (0)
AND p_of_a.status = 'ADMIN'  -- discriminator auto-injected
AND p_of_a.visible = true    -- visible auto-filtered

Batch domain DELETE (child table):

final BatchDelete stmt;
List<Map<String, Object>> paramList = List.of(
    Map.of("id", 1L),
    Map.of("id", 2L)
);
stmt = SQLs.batchDomainDelete()
        .deleteFrom(Admin_.T, AS, "a")
        .where(Admin_.id.spaceEqual(SQLs::namedParam))
        .asDelete()
        .namedParamList(paramList);

long rows = session.update(stmt);
Generated SQL (PostgreSQL)
-- Batch domain DELETE on child table Admin_.T:
-- PostgreSQL uses CTE-based two-stage delete
-- user WHERE condition contains named parameter "id"
WITH a_delete_cte AS (
    DELETE FROM u_admin AS a
    USING u_parent_user AS p_of_a
    WHERE a.id = p_of_a.id           -- join condition (first)
    AND a.id = ?                   -- batch param: id (user WHERE before Army managed)
    AND p_of_a.status = 'ADMIN'  -- discriminator auto-injected (appended)
    AND p_of_a.visible = true      -- visible auto-filtered (appended)
    RETURNING a.id AS id
)
DELETE FROM u_parent_user AS p_of_a
USING a_delete_cte
WHERE p_of_a.id = a_delete_cte.id
Generated SQL (MySQL)
-- Batch domain DELETE on child table Admin_.T:
-- MySQL uses multi-table DELETE syntax
-- user WHERE condition contains named parameter "id"
DELETE a, p_of_a
FROM u_admin AS a
JOIN u_parent_user AS p_of_a ON a.id = p_of_a.id
WHERE a.id = ?                   -- batch param: id (user WHERE before Army managed)
AND p_of_a.status = 'ADMIN'  -- discriminator auto-injected (appended)
AND p_of_a.visible = true      -- visible auto-filtered (appended)

Single-table domain DELETE (also supported):

final Delete stmt;
stmt = SQLs.domainDelete()
        .deleteFrom(Stock_.T, AS, "s")      // TableMeta — any table type
        .where(Stock_.visible.equal(false))
        .asDelete();
Generated SQL (PostgreSQL)
DELETE FROM stock AS s
WHERE s.visible = ?  -- param: false (0)

All base interfaces for DELETE are defined in DeleteStatement:

Clause Interface

Single-table

_SingleDeleteFromClause / _SingleDeleteClause

Parent table

_DeleteParentClause

Child table

_DeleteChildClause

5.1.6. Standard Functions

Army provides standard SQL functions through the Functions utility class (impl/Functions.java). All standard function interfaces are declared in standard/SQLFunction.java.

Functions can be used anywhere an Expression is expected — in SELECT, WHERE, ORDER BY, GROUP BY, and HAVING.

Aggregate Functions
Function Signature

COUNT(*)

SQLs.count(SQLs.ASTERISK)

COUNT(expr)

SQLs.count(expr)

COUNT(DISTINCT expr)

SQLs.count(SQLs.DISTINCT, expr)

SUM(expr)

SQLs.sum(expr), SQLs.sum(SQLs.DISTINCT, expr)

AVG(expr)

SQLs.avg(expr), SQLs.avg(SQLs.DISTINCT, expr)

MIN(expr)

SQLs.min(expr)

MAX(expr)

SQLs.max(expr)

// Aggregate in SELECT
stmt = SQLs.query()
        .select(Stock_.exchange, SQLs.count(SQLs.ASTERISK))
        .from(Stock_.T, AS, "s")
        .groupBy(Stock_.exchange)
        .asQuery();

// Aggregate in HAVING
stmt = SQLs.query()
        .select(Stock_.exchange, SQLs.count(SQLs.ASTERISK))
        .from(Stock_.T, AS, "s")
        .groupBy(Stock_.exchange)
        .having(SQLs.count(SQLs.ASTERISK).greater(100))
        .asQuery();
Generated SQL (PostgreSQL)
-- Aggregate in SELECT
SELECT s.exchange, COUNT(*)
FROM stock AS s
GROUP BY s.exchange

-- Aggregate in HAVING
SELECT s.exchange, COUNT(*)
FROM stock AS s
GROUP BY s.exchange
HAVING COUNT(*) > 100
Scalar Functions

Army provides the full range of standard SQL scalar functions:

Math functionsacos, asin, atan, atan2, ceil, cos, floor, ln, log, mod, power, round, sign, sqrt, truncate, etc.

String functionslength, lower, upper, trim, substring, etc. (also concat via dialect API)

Datetime functionsextract, currentDate, currentTime, etc. (available via dialect API)

Conditional functionscoalesce, nullif (available via dialect API)

CASE expressionSQLs.cases() / SQLs.cases(expr) + .when(…​).then(…​).elseValue(…​).end(type)

// Math: ROUND(offer_price, 2) > 100
Stock_.offerPrice.greater(SQLs.round(Stock_.offerPrice, 2))

// String: LENGTH(code)
SQLs.length(Stock_.code)

// Math: POWER(offer_price, 2)
SQLs.pow(Stock_.offerPrice, 2)

// CASE: price category
SQLs.cases()
         .when(Stock_.offerPrice.greater(1000)).then("HIGH")
         .when(Stock_.offerPrice.greater(100)).then("MID")
         .elseValue("LOW")
         .end(StandardInfer.STRING)

For the complete function reference, see Functions.java.

5.1.7. Standard Window Functions

Window functions are provided through the Windows utility class (impl/Windows.java), implementing dialect/Window.

Unlike aggregate functions, window functions require an OVER clause which specifies partitioning, ordering, and framing.

Function Signature Return Type

ROW_NUMBER()

Windows.rowNumber()

LongType

RANK()

(use dialect API)

DENSE_RANK()

Windows.denseRank()

LongType

NTILE(n)

Windows.ntile(n)

LongType

CUME_DIST()

Windows.cumeDist()

DoubleType

LAG(expr)

Windows.lag(expr)

Same as expr

LAG(expr, n)

Windows.lag(expr, n)

Same as expr

LAG(expr, n, default)

Windows.lag(expr, n, default)

Same as expr

LEAD(expr)

Windows.lead(expr)

Same as expr

LEAD(expr, n)

Windows.lead(expr, n)

Same as expr

LEAD(expr, n, default)

Windows.lead(expr, n, default)

Same as expr

FIRST_VALUE(expr)

Windows.firstValue(expr)

Same as expr

LAST_VALUE(expr)

Windows.lastValue(expr)

Same as expr

NTH_VALUE(expr, n)

Windows.nthValue(expr, n)

Same as expr

Each window function returns a Windows._OverSpec (or Windows._WindowAggSpec for aggregate windows), which exposes the over() methods defined by Window._OverWindowClause:

// Simple OVER()
Windows.rowNumber().over()

// OVER with named window definition
Windows.rowNumber().over("w")

// OVER with inline PARTITION BY + ORDER BY
Windows.rowNumber().over(w -> w
        .partitionBy(Stock_.exchange)
        .orderBy(Stock_.offerPrice.desc())
)

// LAG with offset and default
Windows.lag(Stock_.offerPrice, 1, 0)
     .over(w -> w.partitionBy(Stock_.exchange).orderBy(Stock_.createTime.asc()))

// Aggregate window: SUM over a sliding frame
SQLs.sum(Stock_.offerPrice)
         .over(w -> w
                 .partitionBy(Stock_.exchange)
                 .orderBy(Stock_.createTime.asc())
                 .rowsBetween(
                         Window._FrameUnit.UNBOUNDED_PRECEDING,
                         Window._FrameUnit.CURRENT_ROW
                 )
         )

Aggregate functions as window functions: SQLs.sum(expr), SQLs.avg(expr), etc. can also be used with .over(…​) — they become window aggregates. When used without .over(), they remain standard aggregates (require GROUP BY).

5.2. Expression(s)

In Army, "everything" is an Expression — a design philosophy where every participant in SQL statement construction shares this single interface. Whether it is a literal value, a bound parameter, a table column, a function call, or a subquery, they all conform to the Expression contract, enabling uniform composition, type inference, and SQL generation.

The following all implement Expression:

  • Plain Expression objects (the interface itself) — the polymorphic root

  • JDBC ? parameters (SQLs.param(field, value)) — see [Parameter Binding]

  • Literal values (SQLs.literalValue(100), SQLs.constValue("text")) — see [Literal Binding]

  • SQL functions (SQLs.sum(expr), SQLs.length(expr)) — see Scalar Functions

  • Identifiers (SQLs.identifier("alias"))

  • Table columns — metamodel fields such as Stock_.offerPrice

  • Composite type fields (compositeExpr.dot("field_name"))

  • Row (SQLs.row())

  • Array expressions (scores.slice(1, SQLs.COLON, 5))

  • Scalar subqueries (SQLs.scalarSubQuery())

The Expression interface declares all operations a SQL expression can participate in — comparison, arithmetic, bitwise, pattern matching, array slicing, type casting, sorting, and more.

5.2.1. Expression

equal

The = operator. The right side can be a Java literal, another Expression, or null.

Stock_.status.equal(StockStatus.NORMAL)
Stock_.name.equal(Stock_.exchange)     // field-to-field comparison
Generated SQL (PostgreSQL)
"status" = ?              -- param: NORMAL
"name" = "exchange"

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

notEqual

The <> operator.

Stock_.code.notEqual("000001")
Generated SQL (PostgreSQL)
"code" <> ?               -- param: '000001'

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

nullSafeEqual

IS NOT DISTINCT FROM — treats NULL as a comparable value (@Support({MySQL, PostgreSQL, H2})).

Stock_.offerPrice.nullSafeEqual(SQLs.NULL)
Generated SQL (PostgreSQL)
"offer_price" IS NOT DISTINCT FROM NULL

SQLs.NULL is an Expression — no implicit wrapping occurs. For null-as-parameter use field.nullSafeEqual(SQLs::param, null) (TypedExpression). See [parameter()] for parameterized null binding.

Generated SQL (MySQL)
offer_price <=> NULL
less

The < operator.

Stock_.offerPrice.less(new BigDecimal("100.00"))
Generated SQL (PostgreSQL)
"offer_price" < ?             -- param: 100.00

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

lessEqual

The operator.

Stock_.version.lessEqual(10)
Generated SQL (PostgreSQL)
"version" <= ?             -- param: 10

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

greater

The > operator.

Stock_.offerPrice.greater(new BigDecimal("100.00"))
Generated SQL (PostgreSQL)
"offer_price" > ?             -- param: 100.00

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

greaterEqual

The >= operator.

Stock_.listingDate.greaterEqual(LocalDate.of(2024, 1, 1))
Generated SQL (PostgreSQL)
"listing_date" >= ?            -- param: 2024-01-01

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

between

BETWEEN …​ AND …​ range comparison. PostgreSQL additionally supports SYMMETRIC / ASYMMETRIC modifier overloads.

// Basic usage
Stock_.offerPrice.between(new BigDecimal("10.00"), SQLs.AND, new BigDecimal("100.00"))

// PostgreSQL SYMMETRIC — auto-swaps bounds so lower <= upper
Stock_.offerPrice.between(SQLs.SYMMETRIC, new BigDecimal("100.00"),
        SQLs.AND, new BigDecimal("10.00"))
Generated SQL (PostgreSQL)
"offer_price" BETWEEN ? AND ?            -- params: 10.00, 100.00
"offer_price" BETWEEN SYMMETRIC ? AND ?  -- params: 100.00, 10.00 (auto-swapped)

Both bound values are implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing JDBC ? placeholders. See [parameter()], literalValue(), constValue() for explicit binding modes.

notBetween

NOT BETWEEN …​ AND …​ range exclusion. Also supports SYMMETRIC / ASYMMETRIC modifiers.

Stock_.offerPrice.notBetween(new BigDecimal("10.00"), SQLs.AND, new BigDecimal("100.00"))
Generated SQL (PostgreSQL)
"offer_price" NOT BETWEEN ? AND ?         -- params: 10.00, 100.00

Both bound values are implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing JDBC ? placeholders. See [parameter()], literalValue(), constValue() for explicit binding modes.

is

IS boolean test — accepts BoolTestWord (TRUE/FALSE/UNKNOWN/NULL) or PostgreSQL’s IsComparisonWord (DISTINCT FROM).

// Boolean test
SQLs.exists(subQuery).is(SQLs.TRUE)

// PostgreSQL: IS DISTINCT FROM
Stock_.name.is(SQLs.DISTINCT_FROM, SQLs.parameter(null))
Generated SQL (PostgreSQL)
EXISTS (SELECT ...) IS TRUE
"name" IS DISTINCT FROM ?    -- param: null

BoolTestWord values (TRUE/FALSE/UNKNOWN/NULL) are Expression instances — no implicit wrapping occurs. IsComparisonWord operands (e.g. SQLs.DISTINCT_FROM + value) go through wrapRight; for null use SQLs.NULL instead of SQLs.parameter(null). See [parameter()], literalValue(), constValue() for explicit binding modes.

isNot

IS NOT boolean test — the negated counterpart of is.

SQLs.exists(subQuery).isNot(SQLs.FALSE)
Generated SQL (PostgreSQL)
EXISTS (SELECT ...) IS NOT FALSE

BoolTestWord operands are already Expression instances — no implicit wrapping. See [parameter()] for explicit parameter binding.

isNull

IS NULL.

Stock_.offerPrice.isNull()
Generated SQL (PostgreSQL)
"offer_price" IS NULL
isNotNull

IS NOT NULL.

Stock_.name.isNotNull()
Generated SQL (PostgreSQL)
"name" IS NOT NULL
like

LIKE pattern matching. Supports ESCAPE clause for escaping wildcards.

// Basic fuzzy match
Stock_.name.like("%Apple%")

// With ESCAPE
Stock_.code.like("100\\_%", SQLs.ESCAPE, "\\")
Generated SQL (PostgreSQL)
"name" LIKE ?               -- param: '%Apple%'
"code" LIKE ? ESCAPE ?      -- params: '100\_%', '\'

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

notLike

NOT LIKE negated pattern matching. Also supports ESCAPE.

Stock_.name.notLike("%invalid%")
Generated SQL (PostgreSQL)
"name" NOT LIKE ?           -- param: '%invalid%'

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

similarTo

PostgreSQL SIMILAR TO — SQL-standard regex matching (@Support({PostgreSQL})). Supports ESCAPE clause.

Stock_.code.similarTo("(SH\\|SZ)[0-9]{6}")
Generated SQL (PostgreSQL)
"code" SIMILAR TO ?         -- param: '(SH|SZ)[0-9]{6}'

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

notSimilarTo

PostgreSQL NOT SIMILAR TO (@Support({PostgreSQL})).

Stock_.name.notSimilarTo("%invalid%", SQLs.ESCAPE, "\\")
Generated SQL (PostgreSQL)
"name" NOT SIMILAR TO ? ESCAPE ?  -- params: '%invalid%', '\'

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

equalAny

= ANY(…​) — true if equal to any row in the subquery.

Stock_.offerPrice.equalAny(subQuery)
Generated SQL (PostgreSQL)
"offer_price" = ANY (SELECT ...)
equalSome

= SOME(…​) — semantically identical to equalAny.

Stock_.offerPrice.equalSome(subQuery)
Generated SQL (PostgreSQL)
"offer_price" = SOME (SELECT ...)
equalAll

= ALL(…​) — true if equal to every row in the subquery.

Stock_.offerPrice.equalAll(subQuery)
Generated SQL (PostgreSQL)
"offer_price" = ALL (SELECT ...)
notEqualAny

<> ANY(…​).

Stock_.offerPrice.notEqualAny(subQuery)
Generated SQL (PostgreSQL)
"offer_price" <> ANY (SELECT ...)
notEqualSome

<> SOME(…​) — semantically identical to notEqualAny.

Stock_.offerPrice.notEqualSome(subQuery)
Generated SQL (PostgreSQL)
"offer_price" <> SOME (SELECT ...)
notEqualAll

<> ALL(…​).

Stock_.offerPrice.notEqualAll(subQuery)
Generated SQL (PostgreSQL)
"offer_price" <> ALL (SELECT ...)
lessAny

< ANY(…​).

Stock_.offerPrice.lessAny(subQuery)
Generated SQL (PostgreSQL)
"offer_price" < ANY (SELECT ...)
lessSome

< SOME(…​) — semantically identical to lessAny.

Stock_.offerPrice.lessSome(subQuery)
Generated SQL (PostgreSQL)
"offer_price" < SOME (SELECT ...)
lessAll

< ALL(…​).

Stock_.offerPrice.lessAll(subQuery)
Generated SQL (PostgreSQL)
"offer_price" < ALL (SELECT ...)
lessEqualAny

⇐ ANY(…​).

Stock_.offerPrice.lessEqualAny(subQuery)
Generated SQL (PostgreSQL)
"offer_price" <= ANY (SELECT ...)
lessEqualSome

⇐ SOME(…​).

Stock_.offerPrice.lessEqualSome(subQuery)
Generated SQL (PostgreSQL)
"offer_price" <= SOME (SELECT ...)
lessEqualAll

⇐ ALL(…​).

Stock_.offerPrice.lessEqualAll(subQuery)
Generated SQL (PostgreSQL)
"offer_price" <= ALL (SELECT ...)
greaterAny

> ANY(…​).

Stock_.offerPrice.greaterAny(subQuery)
Generated SQL (PostgreSQL)
"offer_price" > ANY (SELECT ...)
greaterSome

> SOME(…​) — semantically identical to greaterAny.

Stock_.offerPrice.greaterSome(subQuery)
Generated SQL (PostgreSQL)
"offer_price" > SOME (SELECT ...)
greaterAll

> ALL(…​).

Stock_.offerPrice.greaterAll(subQuery)
Generated SQL (PostgreSQL)
"offer_price" > ALL (SELECT ...)
greaterEqualAny

>= ANY(…​).

Stock_.offerPrice.greaterEqualAny(subQuery)
Generated SQL (PostgreSQL)
"offer_price" >= ANY (SELECT ...)
greaterEqualSome

>= SOME(…​).

Stock_.offerPrice.greaterEqualSome(subQuery)
Generated SQL (PostgreSQL)
"offer_price" >= SOME (SELECT ...)
greaterEqualAll

>= ALL(…​).

Stock_.offerPrice.greaterEqualAll(subQuery)
Generated SQL (PostgreSQL)
"offer_price" >= ALL (SELECT ...)
in

IN (…​) inclusion operator. The right-hand side is a SQLValueList, typically provided via SQLs.rowParam() or a subquery.

Stock_.status.in(SQLs::rowParam, List.of(StockStatus.NORMAL, StockStatus.CLOSED))
Generated SQL (PostgreSQL)
"status" IN (?, ?)                -- params: NORMAL, CLOSED
notIn

NOT IN (…​). The right-hand side is a SQLValueList, typically provided via SQLs.rowParam() or a subquery.

Stock_.id.notIn(subQuery)
Generated SQL (PostgreSQL)
"id" NOT IN (SELECT id FROM ...)
mod

% modulo operation.

Stock_.version.mod(10)
Generated SQL (PostgreSQL)
"version" % ?                 -- param: 10

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

times

* multiplication.

Stock_.offerPrice.times(new BigDecimal("1.1"))
Generated SQL (PostgreSQL)
"offer_price" * ?             -- param: 1.1

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

plus

+ addition.

Stock_.offerPrice.plus(new BigDecimal("5"))
Generated SQL (PostgreSQL)
"offer_price" + ?             -- param: 5

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

minus

- subtraction.

Stock_.offerPrice.minus(new BigDecimal("1.00"))
Generated SQL (PostgreSQL)
"offer_price" - ?             -- param: 1.00

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

divide

/ division.

Stock_.offerPrice.divide(new BigDecimal("2"))
Generated SQL (PostgreSQL)
"offer_price" / ?             -- param: 2

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

bitwiseAnd

& bitwise AND. Returns a BigInteger typed expression.

Stock_.version.bitwiseAnd(0xFF)
Generated SQL (PostgreSQL)
"version" & ?      -- param: 255

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

bitwiseOr

| bitwise OR.

Stock_.version.bitwiseOr(0x0F)
Generated SQL (PostgreSQL)
"version" | ?      -- param: 15

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

bitwiseXor

# bitwise XOR (PostgreSQL XOR operator).

Stock_.version.bitwiseXor(0x55)
Generated SQL (PostgreSQL)
"version" # ?      -- param: 85

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

rightShift

>> right bit shift. Returns a BigInteger typed expression.

Stock_.version.rightShift(2)
Generated SQL (PostgreSQL)
"version" >> ?     -- param: 2

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

leftShift

<< left bit shift.

Stock_.version.leftShift(3)
Generated SQL (PostgreSQL)
"version" << ?     -- param: 3

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

slice

Array slicing — accesses a slice of a PostgreSQL/H2 array (@Support({H2, PostgreSQL})). Multiple overloads support 1D and 2D slicing; bounds can be expressions, literals, or SQLs.ABSENT (omitted bound).

// 1D slice: arr[lower:upper]
scores.slice(1, SQLs.COLON, 5)

// Omit lower: arr[:5]
scores.slice(SQLs.COLON, 5)

// Omit both ends: arr[:]
scores.slice(SQLs.COLON)

// 2D slice: arr[lower:upper][lower1:upper1]
matrix.slice(1, SQLs.COLON, 3, 1, SQLs.COLON, 3)
Generated SQL (PostgreSQL)
scores[1:5]
scores[:5]
scores[:]
matrix[1:3][1:3]
sliceAtSubs

Array slicing via a List specifying nD slice bounds (@Support({H2, PostgreSQL})).

// nD slice: even-sized List of expression/literal/ABSENT elements
expr.sliceAtSubs(List.of(1, SQLs.COLON, 5, 1, SQLs.COLON, 3))
Generated SQL (PostgreSQL)
expr[1:5][1:3]
dot

Dot operator — accesses fields of a composite type (Row Type), generating (expr).fieldName syntax. Multiple overloads support 1–5 levels of nesting.

// Single-level access
compositeExpr.dot("field_name")

// Nested access
compositeExpr.dot("level1", "level2")
Generated SQL (PostgreSQL)
(composite_expr)."field_name"
((composite_expr)."level1")."level2"
dots

Dot operator via a List for multi-level nesting.

expr.dots(List.of("a", "b", "c"))  // → (((expr)."a")."b")."c"
Generated SQL (PostgreSQL)
(((expr)."a")."b")."c"
bracket

Bracket operator — JSONB subscript access (@Support({H2, PostgreSQL})). bracket(key) generates ['key']. Multiple overloads support 1–5 levels of nesting and BiFunction binding.

// Single-level JSONB access
jsonbExpr.bracket("name")

// Nested access
jsonbExpr.bracket("address", "city")
Generated SQL (PostgreSQL)
jsonb_column['name']
jsonb_column['address']['city']
brackets

Bracket operator via a List for multi-level JSONB paths, supporting BiFunction binding.

expr.brackets(List.of("config", "timeout"))  // → ['config']['timeout']
Generated SQL (PostgreSQL)
expr['config']['timeout']
l1Distance

PostgreSQL pgvector L1 distance (Manhattan distance) (@Support({PostgreSQL})).

embedding.l1Distance(queryVector)
Generated SQL (PostgreSQL)
embedding <+> ?       -- param: query vector

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

l2Distance

PostgreSQL pgvector L2 distance (Euclidean distance) (@Support({PostgreSQL})).

embedding.l2Distance(queryVector)
Generated SQL (PostgreSQL)
embedding <-> ?       -- param: query vector

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

cosineDistance

PostgreSQL pgvector cosine distance (@Support({PostgreSQL})).

embedding.cosineDistance(queryVector)
Generated SQL (PostgreSQL)
embedding <=> ?       -- param: query vector

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

hammingDistance

PostgreSQL pgvector Hamming distance (binary vector) (@Support({PostgreSQL})).

embedding.hammingDistance(queryVector)
Generated SQL (PostgreSQL)
embedding <~> ?       -- param: query vector

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

jaccardDistance

PostgreSQL pgvector Jaccard distance (binary vector) (@Support({PostgreSQL})).

embedding.jaccardDistance(queryVector)
Generated SQL (PostgreSQL)
embedding <%> ?       -- param: query vector

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

negDot

PostgreSQL pgvector negative dot product <#> (@Support({PostgreSQL})).

embedding.negDot(queryVector)
Generated SQL (PostgreSQL)
embedding <#> ?       -- param: query vector

The right-hand value is implicitly parameterized via wrapRightSQLs.param(field, value) (DEFAULT mode), producing a JDBC ? placeholder. See [parameter()], literalValue(), constValue() for explicit binding modes.

mapTo

Maps the expression to a known MappingType — does not change SQL output, only informs the Army type system of the target type.

SQLs.scalarSubQuery(subQuery).mapTo(IntegerType.INSTANCE)
Generated SQL (PostgreSQL)
-- Does not change SQL output; only affects Army's internal type inference
(SELECT count(*) FROM ...)
mapToArray

Maps the expression to an array type.

someExpr.mapToArray(ArrayMappingType.of(StringType.INSTANCE))
Generated SQL (PostgreSQL)
-- Does not change SQL output
some_expr
castTo

SQL CAST — generates CAST(expr AS type) (@Support({PostgreSQL})).

Stock_.version.castTo(IntegerType.INSTANCE)
Generated SQL (PostgreSQL)
CAST("version" AS INTEGER)
castToArray

SQL CAST to array — generates CAST(expr AS type[]).

someExpr.castToArray(ArrayMappingType.of(IntegerType.INSTANCE))
Generated SQL (PostgreSQL)
CAST(some_expr AS INTEGER[])
asSortItem

Returns the expression itself as a SortItem (identity operation), for use in ORDER BY.

Stock_.offerPrice.asSortItem()       // equivalent to asc() (default ascending)
Generated SQL (PostgreSQL)
"offer_price" ASC
asc

Ascending sort ASC.

Stock_.offerPrice.asc()
Generated SQL (PostgreSQL)
"offer_price" ASC
desc

Descending sort DESC.

Stock_.name.desc()
Generated SQL (PostgreSQL)
"name" DESC
ascSpace

Ascending + NULL ordering control. Accepts SQLs.NULLS_FIRST / SQLs.NULLS_LAST or null.

Stock_.listingDate.ascSpace(SQLs.NULLS_FIRST)
Generated SQL (PostgreSQL)
"listing_date" ASC NULLS FIRST
descSpace

Descending + NULL ordering control.

Stock_.offerPrice.descSpace(SQLs.NULLS_LAST)
Generated SQL (PostgreSQL)
"offer_price" DESC NULLS LAST
space

Dialect operator entry point — allows calling dual and binary operators beyond standard SQL. The three-parameter overload supports modifiers.

// DualOperator overload — returns Expression
expr.space(someDualOperator, rightExpr)

// BiOperator overload — returns IPredicate
expr.space(someBiOperator, rightExpr)

// With modifier overload
expr.space(someBiOperator, rightExpr, modifier, optionalExpr)
Generated SQL (PostgreSQL)
-- SQL output depends on the dialect operator definition; shown here for illustration only
expr <dialect_op> right_expr
expr <dialect_op> right_expr
expr <dialect_op> right_expr MODIFIER optional_expr

5.2.2. TypedExpression

public interface TypedExpression extends Expression, TypeInfer {

TypedExpression extends Expression AND TypeInfer — it carries a known MappingType, which enables Army to infer the SQL type of the right operand without value-class auto-detection.

Every metamodel field (e.g. Stock_.name) and every predicate (IPredicate) is a TypedExpression in practice.

Every method below takes BiFunction<TypedExpression, T, Expression> as its first parameter — a method reference to one of:

  • SQLs::param — JDBC ? placeholder (safe default)

  • SQLs::literal — embeds value in SQL with type annotation (e.g. DATE '…​')

  • SQLs::constant — embeds value without type prefix

  • SQLs::namedParam, SQLs::namedLiteral, SQLs::namedConst — named variants for batch DML

See [Parameter Binding] and [Literal Binding] for full details.

equal

= operator with explicit binding control — always produces the chosen binding regardless of the global LiteralMode.

Stock_.name.equal(SQLs::param, "ABC")
Stock_.regionType.equal(SQLs::literal, RegionType.PROVINCE)
Generated SQL (PostgreSQL)
"name" = ?              -- param: 'ABC'
"region_type" = 1        -- literal: PROVINCE (enum ordinal)
notEqual

<> operator with explicit binding.

Stock_.code.notEqual(SQLs::param, "000001")
Generated SQL (PostgreSQL)
"code" <> ?              -- param: '000001'
nullSafeEqual

IS NOT DISTINCT FROM with explicit binding — the only safe way to pass null. Expression.nullSafeEqual(null) throws because the single-arg Object overload cannot infer the type of a bare null. TypedExpression.nullSafeEqual(SQLs::param, null) works because Army knows the MappingType from the left operand.

Stock_.offerPrice.nullSafeEqual(SQLs::param, null)
Stock_.offerPrice.nullSafeEqual(SQLs::param, new BigDecimal("100.00"))
Generated SQL (PostgreSQL)
"offer_price" IS NOT DISTINCT FROM ?      -- param: null
"offer_price" IS NOT DISTINCT FROM ?      -- param: 100.00
less

< operator with explicit binding.

Stock_.offerPrice.less(SQLs::param, new BigDecimal("100.00"))
Generated SQL (PostgreSQL)
"offer_price" < ?        -- param: 100.00
lessEqual

operator with explicit binding.

Stock_.version.lessEqual(SQLs::param, 10)
Generated SQL (PostgreSQL)
"version" <= ?           -- param: 10
greater

> operator with explicit binding.

Stock_.offerPrice.greater(SQLs::param, new BigDecimal("100.00"))
Generated SQL (PostgreSQL)
"offer_price" > ?        -- param: 100.00
greaterEqual

>= operator with explicit binding.

Stock_.listingDate.greaterEqual(SQLs::param, LocalDate.of(2024, 1, 1))
Generated SQL (PostgreSQL)
"listing_date" >= ?      -- param: 2024-01-01
between

BETWEEN …​ AND …​ with per-bound binding control. Four overloads:

  1. Single BiFunction for both bounds.

  2. Independent BiFunction per bound — mix param and literal. 3-4. Same as above plus SQLs.SYMMETRIC / SQLs.ASYMMETRIC modifier (@Support({PostgreSQL, H2})).

// Single binding for both
Stock_.offerPrice.between(SQLs::param,
        new BigDecimal("10.00"), SQLs.AND, new BigDecimal("100.00"))

// Different binding per bound
Stock_.offerPrice.between(SQLs::literal, new BigDecimal("10.00"),
        SQLs.AND, SQLs::param, new BigDecimal("100.00"))

// With SYMMETRIC modifier (PostgreSQL/H2)
Stock_.offerPrice.between(SQLs.SYMMETRIC, SQLs::param,
        new BigDecimal("100.00"), SQLs.AND, new BigDecimal("10.00"))
Generated SQL (PostgreSQL)
"offer_price" BETWEEN ? AND ?              -- params: 10.00, 100.00
"offer_price" BETWEEN 10.00 AND ?           -- literal: 10.00 | param: 100.00
"offer_price" BETWEEN SYMMETRIC ? AND ?     -- params: 100.00, 10.00 (auto-swapped)
notBetween

NOT BETWEEN …​ AND …​ — same four overloads as between.

Stock_.offerPrice.notBetween(SQLs::param,
        new BigDecimal("200.00"), SQLs.AND, new BigDecimal("500.00"))
Generated SQL (PostgreSQL)
"offer_price" NOT BETWEEN ? AND ?          -- params: 200.00, 500.00
is

IS with an SQLs.IsComparisonWord (e.g. SQLs.DISTINCT_FROM) and explicit binding (@Support({PostgreSQL, H2})).

Stock_.offerPrice.is(SQLs.DISTINCT_FROM, SQLs::param, null)
Generated SQL (PostgreSQL)
"offer_price" IS DISTINCT FROM ?           -- param: null
isNot

IS NOT with an SQLs.IsComparisonWord (@Support({PostgreSQL, H2})).

Stock_.offerPrice.isNot(SQLs.DISTINCT_FROM, SQLs::param, new BigDecimal("100.00"))
Generated SQL (PostgreSQL)
"offer_price" IS NOT DISTINCT FROM ?       -- param: 100.00
in

IN (…​) with explicit binding. Two overloads:

  1. Collection-based — BiFunction converts the collection to a RowExpression.

  2. Named-params — TeNamedParamsFunc for batch DML with a fixed parameter count.

// Collection binding
Stock_.status.in(SQLs::param, List.of(StockStatus.NORMAL, StockStatus.SUSPENDED))
Stock_.status.in(SQLs::literal, List.of(StockStatus.NORMAL, StockStatus.SUSPENDED))

// Named params for batch (size = 2)
Stock_.status.in(SQLs::namedParam, "statusList", 2)
Generated SQL (PostgreSQL)
"status" IN (?, ?)          -- params: NORMAL, SUSPENDED
"status" IN (0, 1)           -- literals: enum ordinals
"status" IN (:statusList)    -- named: statusList (batch)
notIn

NOT IN (…​) — same two overloads as in.

Stock_.status.notIn(SQLs::param, List.of(StockStatus.DELISTED))
Generated SQL (PostgreSQL)
"status" NOT IN (?)          -- param: DELISTED
mod

% (modulo) — returns Expression.

Stock_.version.mod(SQLs::param, 10)
Generated SQL (PostgreSQL)
"version" % ?              -- param: 10
times

* (multiplication) — returns Expression.

Stock_.offerPrice.times(SQLs::param, new BigDecimal("2"))
Generated SQL (PostgreSQL)
"offer_price" * ?          -- param: 2
plus

+ (addition) — returns Expression.

Stock_.offerPrice.plus(SQLs::param, new BigDecimal("10.00"))
Generated SQL (PostgreSQL)
"offer_price" + ?          -- param: 10.00
minus

- (subtraction) — returns Expression.

Stock_.offerPrice.minus(SQLs::param, new BigDecimal("5.00"))
Generated SQL (PostgreSQL)
"offer_price" - ?          -- param: 5.00
divide

/ (division) — returns Expression.

Stock_.offerPrice.divide(SQLs::param, new BigDecimal("2"))
Generated SQL (PostgreSQL)
"offer_price" / ?          -- param: 2
bitwiseAnd

& (bitwise AND) — returns Expression (BigInteger).

Stock_.version.bitwiseAnd(SQLs::param, 0xFF)
Generated SQL (PostgreSQL)
"version" & ?              -- param: 255
bitwiseOr

| (bitwise OR) — returns Expression (BigInteger).

Stock_.version.bitwiseOr(SQLs::param, 0x01)
Generated SQL (PostgreSQL)
"version" | ?              -- param: 1
bitwiseXor

# (bitwise XOR) — returns Expression (BigInteger).

Stock_.version.bitwiseXor(SQLs::param, 0x0F)
Generated SQL (PostgreSQL)
"version" # ?              -- param: 15
rightShift

>> (right shift) — returns Expression (BigInteger).

Stock_.version.rightShift(SQLs::param, 4)
Generated SQL (PostgreSQL)
"version" >> ?             -- param: 4
leftShift

<< (left shift) — returns Expression (BigInteger).

Stock_.version.leftShift(SQLs::param, 4)
Generated SQL (PostgreSQL)
"version" << ?             -- param: 4
like

LIKE with explicit binding. Two overloads: basic and with ESCAPE.

Stock_.name.like(SQLs::param, "%ABC%")
Stock_.name.like(SQLs::literal, "%ABC%")
Stock_.name.like(SQLs::param, "%100\\%%", SQLs.ESCAPE, '\\')
Generated SQL (PostgreSQL)
"name" LIKE ?                -- param: '%ABC%'
"name" LIKE '%ABC%'          -- literal
"name" LIKE ? ESCAPE '\\'    -- param: '%100\\%%'
notLike

NOT LIKE — same two overloads as like.

Stock_.name.notLike(SQLs::param, "%DELETED%")
Generated SQL (PostgreSQL)
"name" NOT LIKE ?           -- param: '%DELETED%'
similarTo

SIMILAR TO (@Support({PostgreSQL})). Two overloads: basic and with ESCAPE.

Stock_.name.similarTo(SQLs::param, "%(A|B)%")
Generated SQL (PostgreSQL)
"name" SIMILAR TO ?         -- param: '%(A|B)%'
notSimilarTo

NOT SIMILAR TO (@Support({PostgreSQL})). Same two overloads.

Stock_.name.notSimilarTo(SQLs::param, "%(X|Y)%")
Generated SQL (PostgreSQL)
"name" NOT SIMILAR TO ?     -- param: '%(X|Y)%'
l1Distance

<+> (L1 distance) for pgvector (@Support({PostgreSQL})).

embedding.l1Distance(SQLs::param, queryVector)
Generated SQL (PostgreSQL)
"embedding" <+> ?           -- param: vector
l2Distance

<→ (L2 distance) for pgvector (@Support({PostgreSQL})).

embedding.l2Distance(SQLs::param, queryVector)
Generated SQL (PostgreSQL)
"embedding" <-> ?           -- param: vector
cosineDistance

<⇒ (cosine distance) for pgvector (@Support({PostgreSQL})).

embedding.cosineDistance(SQLs::param, queryVector)
Generated SQL (PostgreSQL)
"embedding" <=> ?           -- param: vector
hammingDistance

<~> (Hamming distance) for pgvector binary vectors (@Support({PostgreSQL})).

embedding.hammingDistance(SQLs::param, queryVector)
Generated SQL (PostgreSQL)
"embedding" <~> ?           -- param: vector
jaccardDistance

<%> (Jaccard distance) for pgvector binary vectors (@Support({PostgreSQL})).

embedding.jaccardDistance(SQLs::param, queryVector)
Generated SQL (PostgreSQL)
"embedding" <%> ?           -- param: vector
negDot

<#> (negative inner product) for pgvector (@Support({PostgreSQL})).

embedding.negDot(SQLs::param, queryVector)
Generated SQL (PostgreSQL)
"embedding" <#> ?           -- param: vector
space

Dialect operator entry point — three overloads:

  1. space(DualOperator, funcRef, value) — returns Expression.

  2. space(BiOperator, funcRef, value) — returns IPredicate.

  3. space(BiOperator, funcRef, value, modifier, optionalExp) — with modifier.

expr.space(someDialectDualOp, SQLs::param, rightValue)
expr.space(someDialectBiOp, SQLs::param, rightValue)
expr.space(someDialectBiOp, SQLs::param, rightValue, SQLs.ESCAPE, '\\')

5.2.3. TypedField

public interface TypedField extends SqlField, TypedExpression, SimpleExpression {

TypedField is the base interface for all typed fields — it inherits from SqlField (carries a field name), TypedExpression (carries a MappingType), and SimpleExpression.

Its two main implementation families:

  • TableField — metamodel fields bound to real table columns (e.g. Stock_.name, Stock_.price)

  • TypedDerivedField — derived fields from subqueries, expressions, functions

Why space prefix

TypedField inherits from Expression (via TypedExpression), which already declares methods like equal(Object), less(Object), like(Object), and so on. If TypedField were to declare equal(BiFunction<TypedField, String, Expression>) directly, the JVM would face ambiguous overload resolution — the parameter types of the inherited and newly-declared methods overlap in ways that defeat Java’s method dispatch.

By prefixing every batch-oriented method with space, TypedField avoids overloading the inherited Expression methods entirely, guaranteeing correct JVM method resolution regardless of how lambdas or method references are passed at the call site.

Designed for batch DML

All space* methods are designed for batch insert, batch update, and batch delete statements. In a batch context, each row in the domain collection supplies its own values — the named binding (e.g. SQLs::namedParam) acts as a per-row resolver rather than a single static value.

Every space* method takes BiFunction<TypedField, String, Expression> as its first parameter. The String is the field’s own name, auto-supplied by the framework.

This is the implementation pattern (from TypedField javadoc):

// spaceEqual is equivalent to:
IPredicate spaceEqual(BiFunction<TypedField, String, Expression> namedOperator) {
    return this.equal(namedOperator.apply(this, this.fieldName()));
}

The namedOperator argument is a method reference to a named binding factory:

  • SQLs::namedParam — named JDBC ? parameter

  • SQLs::namedLiteral — named literal with type annotation

  • SQLs::namedConst — named literal without type prefix

  • SQLs::namedRowLiteral, SQLs::namedRowConst, SQLs::namedRowParam — named row bindings for spaceIn/spaceNotIn

The name becomes the key for value resolution in batch contexts — each domain object supplies its own value for that named binding.

spaceEqual

Batch-compatible = comparison.

Stock_.name.spaceEqual(SQLs::namedParam)
Stock_.regionType.spaceEqual(SQLs::namedLiteral)
spaceNotEqual

Batch-compatible <> comparison.

Stock_.code.spaceNotEqual(SQLs::namedParam)
spaceNullSafeEqual

Batch-compatible IS NOT DISTINCT FROM.

Stock_.offerPrice.spaceNullSafeEqual(SQLs::namedParam)
spaceLess

Batch-compatible < comparison.

Stock_.offerPrice.spaceLess(SQLs::namedParam)
spaceLessEqual

Batch-compatible comparison.

Stock_.version.spaceLessEqual(SQLs::namedParam)
spaceGreater

Batch-compatible > comparison.

Stock_.offerPrice.spaceGreater(SQLs::namedParam)
spaceGreaterEqual

Batch-compatible >= comparison.

Stock_.listingDate.spaceGreaterEqual(SQLs::namedParam)
spaceLike

Batch-compatible LIKE. Two overloads: basic and with ESCAPE.

Stock_.name.spaceLike(SQLs::namedParam)
Stock_.name.spaceLike(SQLs::namedLiteral, SQLs.ESCAPE, '\\')
spaceNotLike

Batch-compatible NOT LIKE. Same two overloads.

Stock_.name.spaceNotLike(SQLs::namedParam)
spaceSimilarTo

Batch-compatible SIMILAR TO (@Support({PostgreSQL})). Two overloads.

Stock_.name.spaceSimilarTo(SQLs::namedParam)
Stock_.name.spaceSimilarTo(SQLs::namedLiteral, SQLs.ESCAPE, '\\')
spaceNotSimilarTo

Batch-compatible NOT SIMILAR TO (@Support({PostgreSQL})). Two overloads.

Stock_.name.spaceNotSimilarTo(SQLs::namedParam)
spaceIn

Batch-compatible IN (…​) with named row binding. Two overloads:

  1. BiFunction<TypedField, String, RowExpression> — for namedRowLiteral / namedRowConst.

  2. TeNamedParamsFunc<TypedField> — for namedRowParam with explicit parameter count.

// Named row literal — values resolved per row
Stock_.status.spaceIn(SQLs::namedRowLiteral)

// Named row param — fixed-size param list per row
Stock_.status.spaceIn(SQLs::namedRowParam, 3)
spaceNotIn

Batch-compatible NOT IN (…​) — same two overloads as spaceIn.

Stock_.status.spaceNotIn(SQLs::namedRowLiteral)
spaceMod

Batch-compatible % (modulo) — returns Expression.

Stock_.version.spaceMod(SQLs::namedParam)
spacePlus

Batch-compatible + (addition) — returns Expression.

Stock_.offerPrice.spacePlus(SQLs::namedParam)
spaceMinus

Batch-compatible - (subtraction) — returns Expression.

Stock_.offerPrice.spaceMinus(SQLs::namedParam)
spaceTimes

Batch-compatible * (multiplication) — returns Expression.

Stock_.offerPrice.spaceTimes(SQLs::namedParam)
spaceDivide

Batch-compatible / (division) — returns Expression.

Stock_.offerPrice.spaceDivide(SQLs::namedParam)
spaceBitwiseAnd

Batch-compatible & (bitwise AND) — returns Expression.

Stock_.version.spaceBitwiseAnd(SQLs::namedParam)
spaceBitwiseOr

Batch-compatible | (bitwise OR) — returns Expression.

Stock_.version.spaceBitwiseOr(SQLs::namedParam)
spaceXor

Batch-compatible # (bitwise XOR) — returns Expression.

Stock_.version.spaceXor(SQLs::namedParam)
spaceRightShift

Batch-compatible >> (right shift) — returns Expression.

Stock_.version.spaceRightShift(SQLs::namedParam)
spaceLeftShift

Batch-compatible << (left shift) — returns Expression.

Stock_.version.spaceLeftShift(SQLs::namedParam)
spaceL1Distance

Batch-compatible <+> (L1 distance) for pgvector (@Support({PostgreSQL})).

embedding.spaceL1Distance(SQLs::namedParam)
spaceL2Distance

Batch-compatible <→ (L2 distance) for pgvector (@Support({PostgreSQL})).

embedding.spaceL2Distance(SQLs::namedParam)
spaceCosineDistance

Batch-compatible <⇒ (cosine distance) for pgvector (@Support({PostgreSQL})).

embedding.spaceCosineDistance(SQLs::namedParam)
spaceHammingDistance

Batch-compatible <~> (Hamming distance) for pgvector binary vectors (@Support({PostgreSQL})).

embedding.spaceHammingDistance(SQLs::namedParam)
spaceJaccardDistance

Batch-compatible <%> (Jaccard distance) for pgvector binary vectors (@Support({PostgreSQL})).

embedding.spaceJaccardDistance(SQLs::namedParam)
spaceNegDot

Batch-compatible <#> (negative inner product) for pgvector (@Support({PostgreSQL})).

embedding.spaceNegDot(SQLs::namedParam)
spaceSpace

Batch-compatible dialect operator entry point — three overloads:

  1. spaceSpace(DualOperator, namedOperator) — returns Expression.

  2. spaceSpace(BiOperator, namedOperator) — returns IPredicate.

  3. spaceSpace(BiOperator, namedOperator, modifier, optionalExp) — with modifier.

expr.spaceSpace(someDialectDualOp, SQLs::namedParam)
expr.spaceSpace(someDialectBiOp, SQLs::namedParam)
expr.spaceSpace(someDialectBiOp, SQLs::namedParam, SQLs.ESCAPE, '\\')

5.2.4. FieldMeta

public interface FieldMeta<T> extends TypedTableField<T>, DatabaseObject.FieldObject {

FieldMeta is the runtime metadata interface for a domain class field mapped to a table column. Unlike TableField (which represents the field in Criteria API expressions), FieldMeta carries structural metadata — constraints, defaults, comments, generator configuration — consumed by the framework’s DDL generation, codec registration, and schema introspection pipelines.

Every army metamodel field implements both TableField (for expression usage) and FieldMeta (for metadata access):

  • Stock_.id is a TableField when used in Stock_.id.equal(1)

  • Stock_.id is a FieldMeta when the framework asks isPrimaryField(), comment(), generator(), or needs the column’s defaultValue() for DDL generation

FieldMeta extends TypedTableField<T> (which extends TableField), so it inherits all expression-side methods plus adds metadata methods below.

isPrimaryField

Returns true if this field is the primary key column.

boolean isPrimaryField()
isUniqueField

Returns true if this field has a UNIQUE constraint.

boolean isUniqueField()
isIndexField

Returns true if this field participates in at least one index.

boolean isIndexField()
generator

If this field represents _MetaBridge.ID, returns the GeneratorMeta for ID generation; otherwise returns null.

@Nullable
GeneratorMeta generator()

GeneratorMeta itself declares:

  • field() — the owning FieldMeta

  • javaType() — the generated ID’s Java type

  • params() — an immutable map of generator parameters (e.g. startTime for Snowflake)

dependField

Returns a depended-on field, or null. Used when a field’s value is derived from another field (e.g., a generator may depend on a sequence column, or a computed column may reference a source column).

@Nullable
FieldMeta<?> dependField()
defaultValue

Returns the column’s SQL DEFAULT value expression as a string. For example:

  • Number fields → "0"

  • String fields → "''"

  • LocalDate"'1970-01-01'"

  • LocalDateTime"'1970-01-01 00:00:00'"

String defaultValue()
elementTypes

If javaType() is a collection type (List, Set, Collection, Map), returns the element Java types; otherwise returns void.class.

List<Class<?>> elementTypes()
isSerial

Returns true if the column is a SERIAL / BIGSERIAL (auto-incrementing integer) column, commonly used in PostgreSQL.

boolean isSerial()
comment

Returns the column comment text.

String comment()

5.2.5. RowExpression

public interface RowExpression extends Expression, SQLValueList {

RowExpression represents a SQL row constructor — a parenthesized list of values treated as a single row value. In SQL standards, row constructors are used in:

  • Row-wise comparison: WHERE (a, b) = (1, 2)

  • INSERT …​ VALUES ROW(…​)

  • IN / NOT IN with multi-column subqueries

RowExpression extends Expression (so it participates in all expression operations) and SQLValueList (marking it as a multi-value right operand for comparisons).

columnSize

Returns the number of columns in this row expression. Returns a negative value if the size is unknown (e.g., for named row literal/const expressions whose value set is resolved at batch execution time).

/// @return negative : unknown
int columnSize()

Three overloads construct RowExpression instances:

Empty row (@Support({PostgreSQL})) — PostgreSQL supports ROW() as a comparison placeholder; MySQL does not support empty row constructors.

SQLs.row()                         // ROW()

Row from subquery — wraps a SubQuery as a single-element row.

SQLs.row(subQuery)                 // ROW( SELECT ... )

Row from column list — the most common form; creates a row from any mix of Expression, SubQuery, or RowElement elements.

SQLs.row(List.of(column1, column2))
// generates:  ROW(col1, col2)

Army validates column count at parse time: when a RowExpression is compared against another RowExpression or SubQuery, both sides must have matching column counts (unless one side is a named expression whose size is resolved later).

5.2.6. ArrayExpression

public interface ArrayExpression extends TypedExpression, SQLValueList {

ArrayExpression represents a SQL array constructor — an ordered collection of values that can be used as a single expression value. It extends TypedExpression (carrying a MappingType for the array element type) and SQLValueList.

ArrayExpression itself declares no additional methods; it is a marker interface whose type semantics are defined by its parent interfaces and the factory methods that produce it.

In Army, array expressions are PostgreSQL-specific (standard SQL does not have an ARRAY[…​] syntax). They are constructed via Postgres.array():

Three overloads:

Empty array — must be type-cast before use, or the database will reject it.

Postgres.array()                              // ARRAY[]
Postgres.array().castTo(PostgresType.INTEGER) // ARRAY[]::integer[]

Array from element list — each element can be an Expression or a literal value.

Postgres.array(List.of(SQLs.literalValue(1),
                       SQLs.literalValue(2)))
// generates:  ARRAY[1, 2]

Array from subquery — produces an array from the result of a scalar subquery.

Postgres.array(subQuery)
// generates:  ARRAY( SELECT ... )

5.2.7. IPredicate

IPredicate represents a SQL predicate — the boolean-valued expression that appears in WHERE or HAVING clauses. All comparison methods on Expression (e.g. equal(), greater(), like()) return IPredicate, which then provides logical combination operators (or/and) to build compound conditions.

The interface is named IPredicate rather than Predicate to avoid a naming conflict with java.util.function.Predicate.

public interface IPredicate extends TypedExpression, Statement._WhereAndClause<IPredicate> {

    // typeMeta() always returns BooleanType.INSTANCE
    BooleanType typeMeta();

    // ── OR operators ──
    IPredicate or(IPredicate predicate);
    <T> IPredicate or(Function<T, IPredicate> expOperator, T operand);

    // ── OR-group (parenthesized) ──
    IPredicate orGroup(Consumer<Consumer<IPredicate>> consumer);
    IPredicate orGroup(IPredicate t, IPredicate u);
    IPredicate orGroup(IPredicate t, IPredicate u, IPredicate v);

    // ── Conditional OR ──
    IPredicate ifOrGroup(Consumer<Consumer<IPredicate>> consumer);
    IPredicate ifOr(Supplier<IPredicate> supplier);
    <T> IPredicate ifOr(Function<T, IPredicate> expOperator, @Nullable T value);

    // ── AND / ifAnd ── inherited from Statement._WhereAndClause
    // IPredicate and(IPredicate predicate);
    // IPredicate ifAnd(Supplier<IPredicate> supplier);
    // <T> IPredicate and(Function<T, IPredicate> expOperator, T operand);
    // <T> IPredicate ifAnd(Function<T, IPredicate> expOperator, @Nullable T value);
}

IPredicate itself extends TypedExpression, so it is an Expression of BooleanType. This means you can pass an IPredicate wherever an Expression is expected — for example, in CASE WHEN or as a SELECT column.

IPredicate has two sub-interfaces that classify predicates by their structural role:

  • SimplePredicate — a leaf or simple predicate: TRUE, FALSE, bracket-wrapped sub-predicates ((a > 1 OR b < 2)), and function-call predicates. Extends IPredicate without adding any additional methods.

  • LogicalPredicate — a predicate resulting from a logical operator like NOT or dialect-specific operators. Extends both IPredicate and SimplePredicate.

  • CompoundPredicate — a predicate that is a compound of multiple sub-predicates (e.g. AND/OR chains). Extends IPredicate.

or

Combine two predicates with OR. or(Function, T) is the lambda form — the function receives T and returns an IPredicate.

// Direct predicate
Stock_.status.equal(StockStatus.NORMAL).or(Stock_.version.greater(10))

// Lambda form
Stock_.status.equal(StockStatus.NORMAL)
        .or(Stock_.status::equal, StockStatus.CLOSED)
Generated SQL (PostgreSQL)
"status" = ? OR "status" = ?
-- params: 'NORMAL', 'CLOSED'
orGroup

Wrap a group of predicates in parentheses and OR-join them to the current predicate: p OR (q1 AND q2 AND …​). Three overloads:

  • orGroup(Consumer<Consumer<IPredicate>>) — dynamic, pass predicates via a consumer callback.

  • orGroup(IPredicate t, IPredicate u) — exactly two predicates.

  • orGroup(IPredicate t, IPredicate u, IPredicate v) — exactly three predicates.

// Dynamic — use when predicates are conditional
Stock_.status.equal(StockStatus.NORMAL)
        .orGroup(g -> {
            g.accept(Stock_.offerPrice.greater(new BigDecimal("100")));
            g.accept(Stock_.version.less(5));
        })

// Fixed two-predicate
Stock_.code.equal("000001")
        .orGroup(
            Stock_.status.equal(StockStatus.NORMAL),
            Stock_.version.greater(1)
        )

// Fixed three-predicate
Stock_.code.equal("000001")
        .orGroup(
            Stock_.status.equal(StockStatus.NORMAL),
            Stock_.version.greater(1),
            Stock_.offerPrice.less(new BigDecimal("200"))
        )
Generated SQL (PostgreSQL)
"code" = ?
OR (
    "status" = ?
    AND "version" > ?
    AND "offer_price" < ?
)
-- params: '000001', 'NORMAL', 1, 200

orGroup always puts the sub-predicates inside parentheses connected by AND. Use and() before orGroup() to control left-associativity.

ifOrGroup

Conditional variant of orGroup — if the consumer produces an empty group, the original predicate is returned unchanged (no OR (…​) clause appended).

Stock_.code.equal("000001")
        .ifOrGroup(g -> {
            if (includeOfferPrice) {
                g.accept(Stock_.offerPrice.greater(new BigDecimal("100")));
            }
            if (includeStatus) {
                g.accept(Stock_.status.equal(StockStatus.NORMAL));
            }
        })
// If no condition is set, the group is silently omitted.
ifOr

Conditional OR — if the supplied value (or supplier result) is null, the original predicate is returned unchanged.

// Supplier form — most common
Stock_.status.equal(StockStatus.NORMAL)
        .ifOr(() -> {
            return condition ? Stock_.version.greater(10) : null;
        })

// Function form — null value triggers omission
Stock_.status.equal(StockStatus.NORMAL)
        .ifOr(Stock_.status::equal, nullableStatus)
// If nullableStatus is null → no OR clause appended
Generated SQL (PostgreSQL) — when supplier returns non-null
"status" = ? OR "version" > ?
-- params: 'NORMAL', 10
and

Combine two predicates with AND. Inherited from Statement._WhereAndClause.

// Direct predicate
Stock_.code.equal("000001").and(Stock_.status.equal(StockStatus.NORMAL))

// Lambda form
Stock_.code.equal("000001")
        .and(Stock_.status::equal, StockStatus.NORMAL)
Generated SQL (PostgreSQL)
"code" = ? AND "status" = ?
-- params: '000001', 'NORMAL'
ifAnd

Conditional AND — if the supplier returns null (or the function value is null), the original predicate is returned unchanged.

// Supplier form
Stock_.code.equal("000001")
        .ifAnd(() -> {
            return optionalCondition ? Stock_.status.equal(StockStatus.NORMAL) : null;
        })

// Function form
Stock_.code.equal("000001")
        .ifAnd(Stock_.status::equal, nullableStatus)
// If nullableStatus is null → no AND clause appended
Generated SQL (PostgreSQL) — when supplier returns non-null
"code" = ? AND "status" = ?
-- params: '000001', 'NORMAL'

ifAnd / ifOr are particularly useful in dynamic query construction where some filter conditions depend on user input. Instead of building if-else chains outside the query expression, embed the logic directly in the fluent API.

SQLs.not(IPredicate) wraps a predicate with the NOT logical operator. Returns a LogicalPredicate.

// Source definition
/// @param predicate non-null
public static IPredicate not(IPredicate predicate) {
    return OperationPredicate.notPredicate(predicate);
}
// Invert a predicate
SQLs.not(Stock_.status.equal(StockStatus.NORMAL))
// SQL: NOT ("status" = ?)

// Double NOT — cancels out (parenthesized)
SQLs.not(SQLs.not(Stock_.code.equal("000001")))
// SQL: NOT (NOT ("code" = ?))
Generated SQL (PostgreSQL)
NOT ("status" = ?)
-- param: 'NORMAL'

SQLs.bracket(IPredicate) wraps a predicate in parentheses, making it a grouped sub-predicate. Returns a SimplePredicate. Useful for controlling operator precedence without changing logical semantics.

// Source definition
/// @param predicate non-null
public static SimplePredicate bracket(IPredicate predicate) {
    return OperationPredicate.bracketPredicate(predicate);
}
// Explicit grouping
SQLs.bracket(
    Stock_.code.equal("000001").or(Stock_.code.equal("000002"))
)
// SQL: ("code" = ? OR "code" = ?)

// Combine with NOT
SQLs.not(SQLs.bracket(
    Stock_.status.equal(StockStatus.NORMAL)
        .and(Stock_.version.greater(5))
))
// SQL: NOT ("status" = ? AND "version" > ?)
Generated SQL (PostgreSQL)
("code" = ? OR "code" = ?)
-- params: '000001', '000002'

Unlike orGroup which adds OR to the current predicate, bracket() is a standalone wrapper. Use bracket() when you need explicit parentheses (e.g. already inside an orGroup callback or for visual clarity in complex expressions).

SQLs.exists(SubQuery) and SQLs.notExists(SubQuery) produce EXISTS and NOT EXISTS predicates from a subquery. Both return IPredicate.

// Source definitions
/// @param subQuery non-null
public static IPredicate exists(SubQuery subQuery) {
    return Expressions.existsPredicate(false, subQuery);
}

/// @param subQuery non-null
public static IPredicate notExists(SubQuery subQuery) {
    return Expressions.existsPredicate(true, subQuery);
}
// EXISTS — check if a subquery returns any rows
SQLs.exists(SQLs.subQuery()
    .select(s -> s.space(SQLs.LITERAL_1))
    .from(Stock_.T, AS, "s")
    .where(Stock_.code.equal("000001"))
    .asQuery()
)

// NOT EXISTS — check if a subquery returns no rows
SQLs.notExists(SQLs.subQuery()
    .select(s -> s.space(SQLs.LITERAL_1))
    .from(Stock_.T, AS, "s")
    .where(Stock_.code.equal("000001"))
    .asQuery()
)
Generated SQL (PostgreSQL)
-- exists()
EXISTS (SELECT 1 FROM "stock" AS s WHERE s."code" = ?)

-- notExists()
NOT EXISTS (SELECT 1 FROM "stock" AS s WHERE s."code" = ?)

5.2.8. special expression

SQLs provides a set of special constant expressions that represent SQL keywords and symbols. These are singletons (or functionally equivalent) that can be used wherever an Expression is expected — in WHERE, HAVING, VALUES, SET clauses, and as function arguments.

Unlike regular expressions built from field comparisons, special expressions carry no type metadata from domain fields and are rendered directly as their corresponding SQL keyword or symbol.

SQLs.NULL

SQLs.NULL represents the SQL NULL keyword. It is an Expression of NullType and also serves as a BoolTestWord (usable in IS NULL/IS NOT NULL boolean tests) and a NullOption.

// Source definition
public static final WordNull NULL = NonOperationExpression.nullWord();
// Factory method (returns singleton)
/// @see SQLs#NULL
static SQLs.WordNull nullWord() {
    return NullWord.INSTANCE;
}

WordNull is a sealed interface extending BoolTestWord, Expression, and NullOption:

public sealed interface WordNull extends BoolTestWord, Expression, NullOption
        permits NonOperationExpression.NullWord {
}

The implementation NullWord is a private singleton that renders as ` NULL` and throws an error for any operator call:

@Override
String operationErrorMessage() {
    return "SQL key word NULL don't support operator";
}
// Use as right-hand operand — already an Expression, no wrapping needed
Stock_.offerPrice.nullSafeEqual(SQLs.NULL)

// Use in VALUES clause
SQLs.singleInsert()
    .insertInto(Stock_.T)
    .values()
    .parens(r -> r
        .space(Stock_.exchange, "NYSE")
        .comma(Stock_.code, "AAPL")
        .comma(Stock_.offerPrice, SQLs.NULL)  // explicit NULL
    )
    .asInsert()
Generated SQL (PostgreSQL)
-- nullSafeEqual
"offer_price" IS NOT DISTINCT FROM NULL

-- explicit NULL in VALUES
INSERT INTO stock (...) VALUES (?, ?, NULL)
-- params: 'NYSE', 'AAPL'

When you need to compare a field against NULL, use the dedicated methods isNull() / isNotNull() instead of equal(SQLs.NULL). SQLs.NULL is primarily for places where Expression is expected as a value (e.g. VALUES, UPDATE SET, function arguments).

SQLs.DEFAULT

SQLs.DEFAULT represents the SQL DEFAULT keyword. It is an Expression used in INSERT VALUES and UPDATE SET clauses to instruct the database to use the column’s default value.

// Source definition
public static final WordDefault DEFAULT = new DefaultWord();

WordDefault is a simple interface extending Expression:

public interface WordDefault extends Expression {
}

The implementation DefaultWord extends NonOperationExpression and renders as ` DEFAULT`. Note that unlike NULL, DEFAULT does not implement SingleAnonymousValue — it is purely a keyword expression.

// Use in VALUES — let database apply the column default
SQLs.singleInsert()
    .insertInto(Stock_.T)
    .values()
    .parens(r -> r
        .space(Stock_.exchange, "NYSE")
        .comma(Stock_.code, "AAPL")
        .comma(Stock_.offerPrice, SQLs.DEFAULT)
    )
    .asInsert()
// SQL: INSERT INTO stock (...) VALUES (?, ?, DEFAULT)
Generated SQL (PostgreSQL)
INSERT INTO stock (...) VALUES (?, ?, DEFAULT)
-- params: 'NYSE', 'AAPL'
SQLs.ASTERISK

SQLs.ASTERISK represents the SQL symbol. It is an Expression used primarily as the argument to COUNT( ) in aggregate and window functions.

// Source definition
public static final SymbolAsterisk ASTERISK = new LiteralSymbolAsterisk();

SymbolAsterisk is a sealed interface extending Expression and SQLToken:

public sealed interface SymbolAsterisk extends Expression, SQLToken
        permits LiteralSymbolAsterisk {
}

The implementation LiteralSymbolAsterisk extends NonOperationExpression and also implements FunctionArg.SingleFunctionArg, allowing it to be used as a function argument. It renders as ` *`.

// COUNT(*) — the primary use case
SQLs.query()
    .select(Stock_.exchange, SQLs.count(SQLs.ASTERISK))
    .from(Stock_.T, AS, "s")
    .groupBy(Stock_.exchange)
    .asQuery()

// COUNT(*) in HAVING
SQLs.query()
    .select(Stock_.exchange, SQLs.count(SQLs.ASTERISK))
    .from(Stock_.T, AS, "s")
    .groupBy(Stock_.exchange)
    .having(SQLs.count(SQLs.ASTERISK).greater(100))
    .asQuery()

// COUNT(*) as window function
Windows.count().over(w -> w.partitionBy(Stock_.exchange))
Generated SQL (PostgreSQL)
-- COUNT(*) in SELECT
SELECT s.exchange, COUNT(*)
FROM stock AS s
GROUP BY s.exchange

-- COUNT(*) in HAVING
SELECT s.exchange, COUNT(*)
FROM stock AS s
GROUP BY s.exchange
HAVING COUNT(*) > 100

-- COUNT(*) window function
COUNT(*) OVER (PARTITION BY "exchange")
SQLs.TRUE

SQLs.TRUE is a constant SimplePredicate representing the SQL boolean literal TRUE. It can be used directly in WHERE or HAVING clauses, or as the second operand of IS/IS NOT boolean tests.

// Source definition
public static final WordBoolean TRUE = OperationPredicate.booleanWord(true);

SQLs.TRUE is declared as type WordBoolean — a sealed interface that extends BoolTestWord, SimplePredicate, and LiteralExpression:

public sealed interface WordBoolean extends BoolTestWord, SimplePredicate, LiteralExpression
        permits OperationPredicate.BooleanWord {
}
// Use TRUE as a standalone WHERE clause (matches all rows)
SQLs.query()
    .from(Stock_.T)
    .where(SQLs.TRUE)
    .asQuery()

// Use in IS boolean test
Stock_.status.is(SQLs.TRUE)
// SQL: "status" IS TRUE

// Use in a compound condition
Stock_.code.equal("000001").and(SQLs.TRUE)
// SQL: "code" = ? AND TRUE
Generated SQL (PostgreSQL)
-- standalone WHERE TRUE
SELECT * FROM "stock" WHERE TRUE

-- IS TRUE
"status" IS TRUE

-- compound
"code" = ? AND TRUE
-- param: '000001'
SQLs.FALSE

SQLs.FALSE is a constant SimplePredicate representing the SQL boolean literal FALSE. It is the counterpart of SQLs.TRUE.

// Source definition
public static final WordBoolean FALSE = OperationPredicate.booleanWord(false);

The booleanWord factory method creates the corresponding singleton:

/// @see SQLs#TRUE
/// @see SQLs#FALSE
static SQLs.WordBoolean booleanWord(final boolean value) {
    return value ? BooleanWord.TRUE : BooleanWord.FALSE;
}

Internally, BooleanWord is a private OperationSimplePredicate subclass that renders as ` TRUE` or ` FALSE` (with a leading space for SQL formatting).

// Exclude all rows with FALSE in WHERE
SQLs.query()
    .from(Stock_.T)
    .where(SQLs.FALSE)
    .asQuery()

// IS NOT FALSE boolean test
Stock_.status.isNot(SQLs.FALSE)
// SQL: "status" IS NOT FALSE
Generated SQL (PostgreSQL)
-- standalone WHERE FALSE
SELECT * FROM "stock" WHERE FALSE

-- IS NOT FALSE
"status" IS NOT FALSE

5.3. Param Binding

5.3.1. Implicit Binding

When you write predicates like field.equal(value), Army automatically wraps the right-hand value into a parameterized SQL expression. Here’s how it works under the hood.

The default path: field.equal(value)

Take this simple predicate as an example:

Stock_.status.equal(StockStatus.NORMAL)

What happens inside Army when this line executes?

  1. Step 1 — Call equal(Object)

    Stock_.status is an OperationTypedField (extends OperationTypedExpression, which extends OperationExpression). It calls OperationExpression#equal(Object):

    return Expressions.biPredicate(this, DualBooleanOperator.EQUAL, operand);
  2. Step 2 — biPredicatewrapRight

    Expressions#biPredicate calls Expressions#wrapRight(left to decide how to wrap the right operand StockStatus.NORMAL.

  3. Step 3 — wrapRight checks left type

    Because left (i.e. Stock_.status) implements TypedExpression, wrapRight enters:

    else if (left instanceof TypedExpression) {
        rightExp = wrapValue((TypedExpression) left, right);
    }

    It passes the left operand’s MappingType to wrapValue.

  4. Step 4 — wrapValue checks LiteralMode

    By default (LiteralMode.DEFAULT), it creates a parameterized value:

    case DEFAULT:
        valueExp = SQLs.param(infer, value);  // → JDBC ?
  5. Step 5 — Result

    The generated SQL looks like:

    status = ?   -- with bound value: StockStatus.NORMAL

In the default path, Army never writes the value directly into the SQL string — it always uses JDBC ? parameters. This is the safe, recommended default for nearly all application code.

What happens with different value types

wrapRight has more branches. Let’s see what happens when you pass different kinds of right operands.

Right operand is already an Expression
// Passing a field reference — already an Expression, used as-is
Stock_.offerPrice.greater(Stock_.price)
// SQL:  offer_price > price

// Passing a subquery — already an Expression, used as-is
Stock_.id.in(subQueryExpr)
// SQL:  id IN (SELECT ...)

When the right operand is already an Expression instance (e.g. another field, a subquery, a scalar expression), wrapRight simply passes it through without any wrapping.

Left operand has no type information
// CTE ref-field — refField returns a raw SqlField, not a TypedExpression
SQLs.refField("normal_stock", "offer_price").greater(new BigDecimal("100.00"))
//                                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//                                    This value will be wrapped via wrapNonNull(right)
//                                    Auto-detection: BigDecimal → numeric MappingType → SQLs.param()

When the left operand is a raw SqlField (e.g. from CTE references via SQLs.refField()), wrapRight falls through to wrapNonNull(right), which auto-detects the MappingType from right.getClass():

// Expressions#wrapNonNull
static ArmyExpression wrapNonNull(final Object value) {
    final MappingType type;
    type = _MappingFactory.getDefaultIfMatch(value.getClass());
    // BigDecimal → NumericMappingType
    // String     → StringType
    // Integer    → IntegerType
    // ...
    return wrapValue(type, value);
}

This is why SQLs.refField(…​).greater(new BigDecimal("100.00")) still works — Army matches the Java class to a built-in MappingType.

Right operand could trigger an error
// ✅ null — allowed when left IS TypedExpression (Army knows the type)
//    wrapValue(Stock_.status, null) → SQLs.param(Stock_.status, null) → "status = ?" (param: null)
Stock_.status.equal(null)  // outputs: status = ?   -- param: null

// ❌ null — throws "right operand must non-null" when left is NOT TypedExpression
//    Army cannot infer the type of a bare null, so wrapRight throws at line 87 of Expressions.java
SQLs.refField("cte", "col").equal(null)  // ERROR: type unknown for null

// ❌ SQLs.ABSENT — wrapRight throws NPE
Stock_.offerPrice.greater(SQLs.ABSENT)  // right operand couldn't be absent
Controlling the binding mode: LiteralMode

Once wrapValue is called, a global LiteralMode configuration (property key expression.literal.mode) determines whether the value is rendered as ? or written directly into the SQL string.

The four modes and their effect:

Mode Generated SQL When to use

DEFAULT (default)

status = ? ,br (the actual value is sent separately as a JDBC parameter)

Always safe. Recommended for 99% of predicates. Prevents SQL injection.

LITERAL

listingDate = DATE '1970-01-01' ,br (the value is embedded together with its literal type — e.g. DATE, TIME, TIMESTAMP in standard SQL; PostgreSQL additionally supports ::type cast syntax, e.g. 0::decimal)

Use when you control the value and want to ensure the database interprets it with the correct type. Avoid for user-provided values — SQL injection risk.

CONST

listingDate = '1970-01-01' ,br (the raw value is embedded without any literal type prefix — same as LITERAL minus the type annotation)

Rarely needed. Use when literal type coercion is not required.

PREFERENCE

Numeric/Boolean → price = 100.00 (literal) ,br String → status = ? (param)

Automatically inlines safe types (numbers, booleans) and parameterizes string types. A middle ground between safety and readability.

Here’s how each mode translates a real predicate:

// Java code:  Stock_.offerPrice.greater(new BigDecimal("100.00"))
//             Stock_.status.equal(StockStatus.NORMAL)

LiteralMode.DEFAULT:
  offer_price > ?   -- param: 100.00
  status = ?          -- param: NORMAL

LiteralMode.LITERAL:
  offer_price > 100.00               -- numeric literal with type
  status = 'NORMAL'                  -- string literal with type
  listingDate = DATE '1970-01-01'    -- with DATE literal type prefix

LiteralMode.CONST:
  offer_price > 100.00               -- raw value, no type annotation
  status = 'NORMAL'                  -- raw value, no type annotation
  listingDate = '1970-01-01'         -- without DATE prefix

LiteralMode.PREFERENCE:
  offer_price > 100.00   -- BigDecimal  _ArmyNoInjectionType  literal, safe
  status = ?              -- enum/string  param, safe

The LiteralMode applies globally to ALL expressions built in the same JVM — it is read once from army.properties at class-load time. You cannot mix modes within a single query.

Explicit binding with BiFunction overloads

If you need to override the default binding for a specific predicate — regardless of the global LiteralMode — use the two-argument overloads that accept a BiFunction.

These overloads are defined in OperationTypedExpression and OperationTypedField. They bypass wrapRight entirely by letting you specify the wrapping function directly:

// ① Named-parameter binding — the BiFunction is called with (leftField, value)
//    Useful when left operand has no type (CTE ref-field).
SQLs.refField("normal_stock", "offer_price").greaterEqual(SQLs::param, 5)
// SQL:  normal_stock.offer_price >= ?   -- param name is derived from field

// ② Force literal for a specific predicate, even if the global mode is DEFAULT.
Stock_.status.equal(SQLs::literal, StockStatus.NORMAL)
// SQL:  status = 'NORMAL'   -- embedded in SQL

// ③ Use a custom BiFunction to compute the right operand.
//    For example, wrap a collection for an IN clause.
Stock_.status.in(SQLs::namedParam, Set.of(StockStatus.NORMAL, StockStatus.CLOSED))
// SQL:  status IN (?, ?)

The BiFunction protocol:

Overload context BiFunction signature Example

field.equal(func, value)

(TypedExpression, T) → Expression

SQLs::param, SQLs::literal

field.spaceEqual(func)

(TypedField, String) → Expression

SQLs::namedParam, SQLs::namedLiteral — uses TypedField#fieldName() as the named parameter key

When should you care about explicit binding?

In everyday application code with normal domain fields (like Stock_.status), you almost never need explicit binding — the DEFAULT mode is safe and correct.

Explicit binding becomes useful in edge cases: - CTE ref-fields where the left operand has no type information - Force-literal for infrastructure tables (e.g. sys_config.code) where injection is not a concern - Custom BiFunction implementations for special parameter conventions

The space* prefix on spaceEqual, spaceIn, etc. avoids method overload conflicts with the base interface at the JVM level — these methods are designed for batch operations (e.g. batchUpdate) where each row’s parameter must carry a unique name. They only accept named parameters or named literals (SQLs::namedParam, SQLs::namedLiteral) because TypedField#fieldName() serves as the named parameter key. In normal query operations, prefer the non-space overloads (e.g. equal(value)).

5.3.2. parameter

When you need to pass a value as a JDBC ? placeholder (the most common pattern in traditional ORMs like JPA/Hibernate), Army provides SQLs.parameter(), SQLs.param(), and SQLs.namedParam(). These methods always generate a ? in the SQL — the value is sent separately via JDBC, guaranteeing SQL injection safety.

parameter()

SQLs.parameter(Object) Auto-detect MappingType from the value’s Java class — the simplest form, similar to JPA setParameter("name", value).

Source: SQLSyntax.java.

// Auto-detect type: BigDecimal → BigDecimalType
Stock_.offerPrice.greater(SQLs.parameter(BigDecimal.valueOf(100)))
// SQL (Standard):      offer_price > ?   -- param: BigDecimal(100)
// SQL (PostgreSQL):    "offer_price" > ?   -- param: BigDecimal(100)
// SQL (MySQL):         offer_price > ?   -- param: BigDecimal(100)

// Any Java type works — Army has built-in MappingType for common types
Stock_.status.equal(SQLs.parameter(StockStatus.NORMAL))   // enum → EnumType
Stock_.name.equal(SQLs.parameter("hello"))                 // String → StringType
Stock_.listingDate.greater(SQLs.parameter(LocalDate.now()))  // LocalDate → DateType

How it works: SQLs.parameter(Object) calls _MappingFactory.getDefaultIfMatch(value.getClass()) to find the matching MappingType, then wraps the value as a ParamExpression that outputs ?.

param()

SQLs.param(TypeInfer,Object) explicitly specify the MappingType — useful when Army cannot auto-detect the type (e.g. raw Integer in a CTE context) or when you want explicit control.

Source: SQLSyntax.java.

// Explicit type — guarantees the database receives an INTEGER parameter
Stock_.offerPrice.greater(SQLs.param(IntegerType.INSTANCE, 100))
// SQL:  offer_price > ?   -- param: Integer(100)

// Use with CTE ref-field (no type info on left side):
// SQLs.refField("cte", "offer_price").greater(SQLs.param(IntegerType.INSTANCE, 100))

// param with explicit type
Stock_.name.equal(SQLs.param(StringType.INSTANCE, randomString()))
// SQL:  name = ?   -- param: generated string

When to use param over parameter: - Left operand has no type info (CTE ref-field, SQLs.refField()) - You need to override the default type inference

Method Reference Usage — with TypedExpression BiFunction overloads:

SQLs.param(TypeInfer, Object) can be used via method reference (SQLs::param) with TypedExpression’s BiFunction overloads to force a single predicate to use explicit parameterization (overriding the global `LiteralMode):

  • First parameter TypeInfer is provided by the TypedExpression itself — the field expression on the left side of the dot (e.g., Stock_.offerPrice), whose MappingType the framework passes in, ensuring type matching

  • Second parameter Object is supplied by the user — the value inside parentheses on the right side of the dot

// Force param mode (even when global mode is LITERAL)
Stock_.offerPrice.greater(SQLs::param, new BigDecimal("100.00"))
// Equivalent to: Stock_.offerPrice.greater(SQLs.param(Stock_.offerPrice, new BigDecimal("100.00")))
// SQL:  offer_price > ?   -- param: 100.00

// Also applies to other comparison operations
Stock_.status.equal(SQLs::param, StockStatus.NORMAL)
// SQL:  status = ?   -- param: NORMAL
namedParam()

SQLs.namedParam(TypeInfer, String) designed for batch operations (INSERT … VALUES, batch UPDATE/DELETE) where each row carries different values. Similar to MyBatis' #{name}.

Source: SQLSyntax.java.

// namedParam in batch VALUES INSERT — each row provides its own value
// The "code" key reads from each batch row's Map/bean getter
SQLs.singleInsert()
        .insertInto(Stock_.T)
        .values(stockList)
        .asInsert();
// With default values:
//   .defaultValue(Stock_.code, SQLs.namedParam(StringType.INSTANCE, "code"))
// SQL:  INSERT INTO stock(...) VALUES (?, ?, ...)  -- each ? resolved from batch row

// As a standalone expression — outputs ? at generation, resolves at execution
SQLs.namedParam(IntegerType.INSTANCE, "price")
// SQL:  ?   -- param reads from batch row's "price" field

// Note: At SQL generation level, namedParam always outputs "?".
// The "name" is only used at execution time to read from the row data.

namedParam vs param vs parameter: - parameter(Object) — auto-detect type, single-row - param(TypeInfer, Object) — explicit type, single-row - namedParam(TypeInfer, String) — named key for batch rows

Method Reference Usage — setSpace() for SET clauses, spaceEqual() for WHERE clauses:

SQLs.namedParam(TypeInfer, String) can be used via method reference (SQLs::namedParam) with batch operations (batchUpdate/batchDelete) where each row carries independent parameter values:

  • In SET clauses: use _StaticBatchSetClause.setSpace(field, SQLs::namedParam) — the field itself provides TypeInfer, and fieldName() becomes the batch key

  • In WHERE clauses: use field.spaceEqual(SQLs::namedParam) — returns IPredicate, passed to where(IPredicate)

  • .set() alone cannot accept SQLs::namedParam: set() accepts (field, Object) or (field, BiFunction, value), neither matches BiFunction<F, String, Expression>. The setSpace() method is purpose-built for named parameters.

// batch UPDATE: each row carries independent params via fieldName as key
// setSpace internally calls namedOperator.apply(field, field.fieldName())
//   → SQLs.namedParam(Stock_.code, "code")  // TypeInfer=Stock_.code, String="code"
// spaceEqual internally calls namedOperator.apply(this, this.fieldName())
//   → SQLs.namedParam(Stock_.id, "id")      // TypeInfer=Stock_.id, String="id"
batchUpdate.setSpace(Stock_.code, SQLs::namedParam)
.setSpace(Stock_.name, SQLs::namedParam)
.where(Stock_.id.spaceEqual(SQLs::namedParam))
.asUpdate();
// SQL:  UPDATE stock SET code = ?, name = ? WHERE id = ?
//       each ? corresponds to the named key "code", "name", "id" in the batch row
rowParam()

SQLs.rowParam(TypeInfer, Collection<?>) creates a multi-value parameter expression — outputs multiple ? placeholders, one for each element in the collection. Designed for IN / NOT IN operators where you have a list of values to match.

Source: SQLSyntax.java.

// Standalone: outputs ? , ? , ? ... (each corresponds to one list element)
SQLs.rowParam(IntegerType.INSTANCE, List.of(1, 2, 3))
// outputs: ? , ? , ?   -- params: 1, 2, 3

// Most common: method reference with IN operator
// ChinaRegion_.id.in(SQLs::rowParam, idList)  →  SQLs.rowParam(ChinaRegion_.id, idList)
ChinaRegion_.id.in(SQLs::rowParam, idList)
// SQL:  id IN (?, ?, ? ...)   -- each ? maps to one element from idList

// With explicit type — useful when left has no type info (CTE ref-field)
SQLs.refField("cte", "col").in(SQLs.rowParam(IntegerType.INSTANCE, List.of(5, 10, 15)))
// SQL:  col IN (?, ?, ?)   -- params: 5, 10, 15

How it works: SQLs.rowParam(type, values) calls ArmyRowParamExpression.multi(type, values), which stores an immutable copy (List.copyOf(values)) internally. When the IN operator renders, ArmyRowParamExpression.appendSql() delegates to context.appendParam(this), which iterates the stored list and appends ? for each element. The columnSize() returns the element count.

+ Constraints: - values must be non-null and non-empty — throws CriteriaException if empty - type must not return a codec TableField — throws if type.typeMeta() is a codec field

When to use rowParam over multiple param calls: - You have a Collection/List of values for IN/NOT IN - You want a single expression representing multiple placeholders (not manually chaining OR) - Method reference SQLs::rowParam is shorter and cleaner than SQLs::param with IN

namedRowParam()

SQLs.namedRowParam(TypeInfer, String, int) creates a named non-null multi-value parameter expression for batch operations. Unlike namedParam (single ?), this outputs multiple ? placeholders — used when a batch row contains a Collection field that needs multiple binding positions.

Source: SQLSyntax.java.

// Standalone: named multi-parameter — outputs ? , ? , ? ..., keyed by name + index
SQLs.namedRowParam(IntegerType.INSTANCE, "scores", 3)
// outputs: ?:scores[0] , ?:scores[1] , ?:scores[2]
// At execution, each ?[i] reads from batch row's "scores" collection at position i

// Batch UPDATE example: scores is a Collection field in each batch row
// namedRowParam has 3 params (type, name, size) — call directly, pass result to set(field, Object)
batchUpdate.set(Student_.scores, SQLs.namedRowParam(IntegerType.INSTANCE, "scores", 3))
.where(Student_.id.spaceEqual(SQLs::namedParam))
.asUpdate();
// SQL:  UPDATE student SET scores = ?, ?, ? WHERE id = ?
//       scores ? placeholders indexed as scores[0], scores[1], scores[2] in each row

// Batch VALUES INSERT with a collection-valued batch row field
// namedRowParam has 3 params — call directly, pass result to comma(field, Object)
SQLs.singleInsert()
        .insertInto(Article_.T)
        .values()
        .parens(s -> s.space(Article_.id, SQLs::namedParam, "id")
                .comma(Article_.tag, SQLs.namedRowParam(StringType.INSTANCE, "tags", 2))
        )
        .asInsert();
// For row {"id":1, "tags":["java","spring"]}:
//   INSERT INTO article(id, tag) VALUES (?, ?, ?)

How it works: SQLs.namedRowParam(type, name, size) calls ArmyRowParamExpression.named(type, name, size), which creates an ArmyNamedRowParam storing the type, name, and element count. At execution time, the context reads the batch row data by name + index (?:name[0], ?:name[1], …​). columnSize() returns the size parameter — fixed at creation time.

+ Constraints: - name must have text — throws CriteriaException if empty or null - size must be >= 1 — throws if less than 1 - type must not return a codec TableField

namedRowParam vs namedParam: - namedParam(TypeInfer, String) — single ?, one value per batch row field - namedRowParam(TypeInfer, String, int) — multiple ?, collection field in batch row

UPDATE_TIME_PLACEHOLDER

SQLs.UPDATE_TIME_PLACEHOLDER is designed for PostgreSQL-like UPDATE/DELETE statements that use CTE to update child tables, where the main statement itself has no other SET fields besides updateTime.

Source: SQLs.java.

// Source definition
public static final Expression UPDATE_TIME_PLACEHOLDER = NonOperationExpression.updateTimePlaceHolder();

Correct usage — only the updateTime field is updated in the main statement:

// CTE updates child table; main statement only refreshes parent's updateTime
Postgres.singleUpdate()
        .with("child_cte").as(sw -> sw.update(ChinaProvince_.T, AS, "p")
                .set(ChinaProvince_.governor, SQLs::param, randomPerson())
                .from(ChinaRegion_.T, AS, "c")
                .where(ChinaProvince_.id::equal, ChinaRegion_.id)
                .returning(ChinaProvince_.id)
                .asReturningUpdate()
        ).space()
        .update(ChinaRegion_.T, AS, "c")
        .set(ChinaRegion_.updateTime, UPDATE_TIME_PLACEHOLDER)  // ← the only SET
        .from("child_cte")
        .where(ChinaRegion_.id::equal, SQLs.refField("child_cte", ChinaRegion_.ID))
        .asUpdate();
// SQL:  WITH child_cte AS ( UPDATE china_province ... RETURNING id )
//       UPDATE china_region SET update_time = ? FROM child_cte WHERE id = child_cte.id

Using UPDATE_TIME_PLACEHOLDER outside this specific CTE scenario (e.g. in a plain single-table UPDATE with other SET fields) has no meaning — use the automatic Domain UPDATE mechanism instead.

Army decides whether to output update_time = ? (param) or update_time = CURRENT_TIMESTAMP (literal) based on whether there are other params in the statement.

BATCH_NO_PARAM

For batch operations where you need the current batch row number as a JDBC parameter.

Source: SQLs.java.

// Source definition
public static final ParamExpression BATCH_NO_PARAM = SQLs.namedParam(IntegerType.INSTANCE, "$ARMY_BATCH_NO$");
// Common pattern: use batch row number in a scalar subquery's WHERE clause
SQLs.singleInsert()
        .with("cte").as(s -> s.select(ChinaRegion_.id, Windows.rowNumber().over().as("rowNumber"))
                .from(ChinaRegion_.T, AS, "t")
                .where(ChinaRegion_.id.in(SQLs::rowParam, regionIdList))
                .orderBy(ChinaRegion_.id)
                .asQuery()
        ).space()
        .insertInto(ChinaRegion_.T)
        .parens(s -> s.space(ChinaRegion_.name, ChinaRegion_.regionGdp)
                .comma(ChinaRegion_.parentId)
        )
        .defaultValue(ChinaRegion_.parentId, SQLs.scalarSubQuery()
                .select(s -> s.space(SQLs.refField("cte", ChinaRegion_.ID)))
                .from("cte")
                .where(SQLs.refField("cte", "rowNumber").equal(SQLs.BATCH_NO_PARAM))
                .asQuery()
        )
        .values(regionList)
        .asInsert();
// For each batch row, BATCH_NO_PARAM resolves to the current row number (1-based),
// matching the CTE's rowNumber to pick the correct parent ID.

5.3.3. literal

While parameter generates ?, the literal family embeds the value directly into the SQL string. This is like writing WHERE price > 100.00 (not WHERE price > ?).

literalValue()

SQLs.literalValue(Object) auto-detect MappingType from the value’s Java class, then embed the value with its SQL type annotation directly in the SQL string.

Source: SQLSyntax.java.

// Auto-detect type — value embedded with type prefix in SQL
Stock_.offerPrice.greater(SQLs.literalValue(100))
// PostgreSQL:  "offer_price" > 100::INTEGER
// MySQL:        offer_price > 100
// Standard SQL: offer_price > 100

Stock_.status.equal(SQLs.literalValue(StockStatus.NORMAL))
// PostgreSQL:  status = 'NORMAL'::VARCHAR
// MySQL:        status = 'NORMAL'

Stock_.listingDate.greater(SQLs.literalValue(LocalDate.of(2024, 1, 1)))
// PostgreSQL:  listing_date > DATE '2024-01-01'
// MySQL:        listing_date > DATE '2024-01-01'

How it works: SQLs.literalValue(Object) calls ArmyLiteralExpression.from(value, true) — the true flag (typeName) tells the dialect to include the SQL type prefix (e.g. Postgre ::TYPE, MySQL DATE/TIME/TIMESTAMP prefix).

Army literals are safe — they are NOT simple string concatenation.

Beginners often mistake "literal embedding in SQL" for direct string concatenation like WHERE price  + userInput (which leads to SQL injection attacks). In Army, things are completely different — literals are secured through the following mechanisms:

  1. Type mapping protection: Every value is checked by MappingType. Numeric types (Integer, BigDecimal, Long, etc.) extend _ArmyNoInjectionType, which eliminates SQL injection at the type level

  2. ArmyParser.safeLiteral() security method: All literals ultimately pass through io.army.dialect.ArmyParser#safeLiteral(TypeMeta, Object, boolean, StringBuilder) when output to SQL; string values undergo proper quote escaping, special characters are safely encoded, and SQL structure is never compromised

  3. Not string concatenation: Army’s literal generation follows an AST (Abstract Syntax Tree) → SQL path; values are appended to a StringBuilder, not built via string template concatenation

Conclusion: You can safely use literal/const for system constants, enum labels, configuration keys, and similar scenarios — they are just as safe as ? parameterization, with the only difference being whether the value is embedded in SQL or passed via JDBC parameters. For ordinary business data, the default parameterized mode (DEFAULT) is recommended for better database execution plan caching.

literal()

SQLs.literal(TypeInfer, Object) explicitly specify the MappingType, and embed the value with its SQL type annotation.

Source: SQLSyntax.java.

// Explicit type — value embedded with type prefix
Stock_.offerPrice.greater(SQLs.literal(Stock_.offerPrice, new BigDecimal("100.00")))
// PostgreSQL:  "offer_price" > 100.00::DECIMAL
// MySQL:        offer_price > 100.00

Method Reference Usage — with TypedExpression BiFunction overloads:

SQLs.literal(TypeInfer, Object) can be used via method reference (SQLs::literal) with TypedExpression’s BiFunction overloads to force a single predicate to use literal mode (overriding the global `LiteralMode):

  • First parameter TypeInfer is provided by the TypedExpression itself — the MappingType of the field

  • Second parameter Object is supplied by the user — the value to embed in SQL (with type prefix)

// Force literal mode (even when global mode is DEFAULT)
Stock_.status.equal(SQLs::literal, StockStatus.NORMAL)
// Equivalent to: Stock_.status.equal(SQLs.literal(Stock_.status, StockStatus.NORMAL))
// PostgreSQL:  status = 'NORMAL'::VARCHAR
// MySQL:        status = 'NORMAL'
namedLiteral()

SQLs.namedLiteral(TypeInfer, String) for batch VALUES INSERT where each row’s value is embedded directly in SQL (not as ?). The value is resolved at SQL generation time from the batch row data.

Source: SQLSyntax.java.

// namedLiteral in batch VALUES INSERT — value embedded directly, with type prefix
SQLs.singleInsert()
        .insertInto(Stock_.T)
        .values()
        .parens(s -> s.space(Stock_.code, SQLs.namedLiteral(StringType.INSTANCE, "code"))
                .comma(Stock_.name, SQLs.namedLiteral(StringType.INSTANCE, "name"))
        )
        .asInsert();
// For each row, namedLiteral reads "code" from the row data and embeds it:
//   INSERT INTO stock(code, name) VALUES ('AAPL', 'Apple Inc.')
// PostgreSQL with type prefix: VALUES ('AAPL'::VARCHAR, 'Apple Inc.'::VARCHAR)

// As a standalone expression
SQLs.namedLiteral(StringType.INSTANCE, "code")
// Resolved at generation time from batch row → 'AAPL' (or 'AAPL'::VARCHAR in Postgre)

namedLiteral is designed exclusively for VALUES INSERT statements. Using it in a batch UPDATE or batch DELETE statement throws CriteriaException at runtime because named literals cannot resolve values from batch row data in non-INSERT contexts.

If you need named values in batch UPDATE/DELETE, use SQLs.namedParam() instead — it outputs ? placeholders that accept values from batch row data just like named literals, but via JDBC parameter binding.

namedLiteral vs namedParam: - namedParam — outputs ?, value sent via JDBC (safe, parameterized) - namedLiteral — outputs the actual value directly in SQL (with type prefix)

Method Reference Usage — in VALUES row space* methods:

SQLs.namedLiteral(TypeInfer, String) can be used via method reference (SQLs::namedLiteral) with _StaticValueSpaceClause.space()’s BiFunction overload for VALUES INSERT rows where each row’s value is embedded directly in SQL (with type prefix). Internally calls `funcRef.apply(field, key)SQLs.namedLiteral(field, key):

  • First parameter FieldMeta is provided by Stock_.code — supplies the TypeInfer and table mapping info

  • Second parameter String is supplied by the user as "code" — the batch data’s named key

// batch VALUES INSERT: embed each row value in SQL with type prefix
SQLs.singleInsert()
        .insertInto(Stock_.T)
        .values()
        .parens(s -> s.space(Stock_.code, SQLs::namedLiteral, "code")  // funcRef.apply(field, "code") → TypeInfer=field, key="code"
                .comma(Stock_.name, SQLs::namedLiteral, "name")         // funcRef.apply(field, "name") → TypeInfer=field, key="name"
        )
        .asInsert();
// PostgreSQL: INSERT INTO stock(code, name) VALUES ('AAPL'::VARCHAR, 'Apple Inc.'::VARCHAR)
// MySQL:      INSERT INTO stock(code, name) VALUES ('AAPL', 'Apple Inc.')
rowLiteral()

SQLs.rowLiteral(TypeInfer, Collection<?>) creates a multi-value literal expression — each element in the collection is embedded directly in SQL with its type annotation prefix. The counterpart of rowParam but for literal mode: values become part of the SQL string instead of ? placeholders.

Source: SQLSyntax.java.

// Standalone: outputs LITERAL , LITERAL , LITERAL ... (with type prefix)
SQLs.rowLiteral(Stock_.id, List.of(1, 2, 3))
// PostgreSQL:  (1::INTEGER, 2::INTEGER, 3::INTEGER)
// MySQL:       (1, 2, 3)

// Most common: method reference with IN operator
// ChinaRegion_.id.in(SQLs::rowLiteral, idList)  →  SQLs.rowLiteral(ChinaRegion_.id, idList)
ChinaRegion_.id.in(SQLs::rowLiteral, idList)
// SQL:  id IN (1, 2, 3, ...)   -- values embedded directly in SQL, with type prefix

// With explicit type
SQLs.refField("cte", "col").in(SQLs.rowLiteral(StringType.INSTANCE, List.of("A", "B", "C")))
// PostgreSQL:  col IN ('A'::VARCHAR, 'B'::VARCHAR, 'C'::VARCHAR)
// MySQL:       col IN ('A', 'B', 'C')

How it works: SQLs.rowLiteral(type, values) calls ArmyRowLiteralExpression.multi(type, values, true) — the true flag is typeName, the same flag that controls type annotation in literal()/literalValue(). Internally stores List.copyOf(values). When rendering, AnonymousRowLiteral.appendSql() iterates values and calls context.appendLiteral(type, value, true) for each, embedding each element with its type prefix.

+ Constraints: - values must be non-null and non-empty — throws CriteriaException if empty - type must not return a codec TableField

rowLiteral vs rowParam: - rowParam — outputs ? , ? , ? …​, values sent via JDBC (safe, parameterized) - rowLiteral — outputs actual values directly in SQL with type prefix

namedRowLiteral()

SQLs.namedRowLiteral(TypeInfer, String) creates a named non-null multi-value literal expression — for batch VALUES INSERT where each batch row contains a Collection field whose elements are embedded directly in SQL with type prefix. Unlike namedRowParam, there is no size parameter: the element count is resolved at SQL generation time from the batch row data.

Source: SQLSyntax.java.

// Standalone: named multi-literal — columnSize() returns -1 (unknown at creation)
SQLs.namedRowLiteral(StringType.INSTANCE, "tags")
// Resolved at generation from batch row → 'java' , 'spring' , 'army' (with type prefix)

// Batch VALUES INSERT: each row's "tags" List field embedded as multiple literals
SQLs.singleInsert()
        .insertInto(Article_.T)
        .values()
        .parens(s -> s.space(Article_.id, SQLs::namedLiteral, "id")
                .comma(Article_.tag, SQLs::namedRowLiteral, "tags")  // Collection field → multi literals
        )
        .asInsert();
// For row {"id":1, "tags":["java","spring"]}:
//   INSERT INTO article(id, tag) VALUES (1, 'java', 'spring')
// PostgreSQL: VALUES (1::INTEGER, 'java'::VARCHAR, 'spring'::VARCHAR)

How it works: SQLs.namedRowLiteral(type, name) calls ArmyRowLiteralExpression.named(type, name, true) — the true flag means type prefix is included. Unlike anonymous rowLiteral, the named version has no stored values at creation timecolumnSize() returns -1. At SQL generation, ArmyNamedRowLiteral.appendSql() delegates to context.appendLiteral(this, true), which reads the batch row’s collection by name, iterates elements, and embeds each with type prefix.

+

namedRowLiteral is designed exclusively for VALUES INSERT statements. Using it in a batch UPDATE or batch DELETE statement throws CriteriaException at runtime — the named row literal cannot resolve multi-value collection data from batch rows in non-INSERT SQL contexts. This restriction is stated in the Javadoc of SQLSyntax.java.

If you need named multi-value placeholders in batch UPDATE/DELETE, use SQLs.namedRowParam(type, name, size) instead — it outputs ? placeholders and accepts the size parameter for explicit element count.

namedRowLiteral vs namedRowParam: - namedRowParam(TypeInfer, String, int) — outputs ? placeholders, needs size param, used in batch UPDATE - namedRowLiteral(TypeInfer, String) — embeds values in SQL with type prefix, no size param, only VALUES INSERT

Literal SQL output across dialects
Value Postgre literalValue() MySQL literalValue()

Intgeer(100)

100::INTEGER

100

BigDecimal("0.00")

0.00::DECIMAL

0.00

"hello"

'hello'::VARCHAR

'hello'

LocalDate

DATE '2024-01-01'

DATE '2024-01-01'

LocalDateTime

TIMESTAMP '2024-01-01 12:00:00'

TIMESTAMP '2024-01-01 12:00:00'

PostgreSQL appends ::TYPE cast syntax for numbers and strings, while MySQL only adds explicit type prefixes for date/time types.

See PostgreParser.java and MySQLParser.java for the full bindLiteral implementation.

UPDATE_TIME_PLACEHOLDER

Same SQLs.UPDATE_TIME_PLACEHOLDER described in UPDATE_TIME_PLACEHOLDER — designed for PostgreSQL CTE scenarios where the main statement has no other SET fields besides updateTime. Army decides between ? and CURRENT_TIMESTAMP automatically.

BATCH_NO_LITERAL

For batch operations where you need the current batch row number embedded directly as a literal (with type prefix).

Source: SQLs.java.

// Source definition
public static final LiteralExpression BATCH_NO_LITERAL = SQLs.namedLiteral(IntegerType.INSTANCE, "$ARMY_BATCH_NO$");
// Use BATCH_NO_LITERAL to embed batch row number in a subquery's WHERE clause
SQLs.singleInsert()
        .with("cte").as(s -> s.select(ChinaRegion_.id, Windows.rowNumber().over().as("rowNumber"))
                .from(ChinaRegion_.T, AS, "t")
                .where(ChinaRegion_.id.in(SQLs::rowParam, regionIdList))
                .orderBy(ChinaRegion_.id)
                .asQuery()
        ).space()
        .insertInto(ChinaRegion_.T)
        .defaultValue(ChinaRegion_.parentId, SQLs.scalarSubQuery()
                .select(s -> s.space(SQLs.refField("cte", ChinaRegion_.ID)))
                .from("cte")
                .where(SQLs.refField("cte", "rowNumber")::equal, BATCH_NO_LITERAL)
                .asQuery()
        )
        .values(regionList)
        .asInsert();
// Batch row 1 → WHERE rowNumber = 1, batch row 2 → WHERE rowNumber = 2, ...
// BATCH_NO_LITERAL outputs the row number directly in SQL (no ? placeholder)

5.3.4. const

The const family is identical to literal in every way except one: it does NOT output the type annotation prefix in SQL.

constValue()

SQLs.constValue(Object) auto-detect MappingType from the value’s Java class, then embed the value without any SQL type annotation.

Source: SQLSyntax.java.

// Auto-detect type — value embedded directly, NO type annotation
Stock_.offerPrice.greater(SQLs.constValue(100))
// PostgreSQL:  "offer_price" > 100          ← no ::INTEGER
// MySQL:        offer_price > 100          ← same as literal
// Standard SQL: offer_price > 100          ← same as literal

Stock_.status.equal(SQLs.constValue(StockStatus.NORMAL))
// PostgreSQL:  status = 'NORMAL'            ← no ::VARCHAR
// MySQL:        status = 'NORMAL'

Stock_.listingDate.greater(SQLs.constValue(LocalDate.of(2024, 1, 1)))
// PostgreSQL:  listing_date > '2024-01-01'  ← no DATE prefix
// MySQL:        listing_date > '2024-01-01' ← no DATE prefix

How it works: SQLs.constValue(Object) calls ArmyLiteralExpression.from(value, false) — the false flag (typeName) tells the dialect to omit the type prefix.

+

const and literal share the same security mechanism: all literals ultimately pass through io.army.dialect.ArmyParser#safeLiteral() when output to SQL — not simple string concatenation. String values are properly escaped, and numeric types are inherently incapable of injecting SQL statements. See [literal] for the full security explanation.

constant()

SQLs.constant(TypeInfer, Object) explicitly specify the MappingType, and embed the value without any SQL type annotation.

Source: SQLSyntax.java.

// Explicit type — value embedded directly, NO type annotation
Stock_.offerPrice.greater(SQLs.constant(Stock_.offerPrice, new BigDecimal("100.00")))
// PostgreSQL:  "offer_price" > 100.00        ← no ::DECIMAL
// MySQL:        offer_price > 100.00       ← same as literal

Method Reference Usage — with TypedExpression BiFunction overloads:

SQLs.constant(TypeInfer, Object) can be used via method reference (SQLs::constant) with TypedExpression’s BiFunction overloads to force a single predicate to use const mode (overriding the global `LiteralMode):

  • First parameter TypeInfer is provided by the TypedExpression itself — the MappingType of the field

  • Second parameter Object is supplied by the user — the value to embed in SQL (without type prefix)

// Force const mode (even when global mode is DEFAULT)
Stock_.offerPrice.greater(SQLs::constant, new BigDecimal("100.00"))
// Equivalent to: Stock_.offerPrice.greater(SQLs.constant(Stock_.offerPrice, new BigDecimal("100.00")))
// SQL:  offer_price > 100.00   -- no ::DECIMAL in PostgreSQL
namedConst()

SQLs.namedConst(TypeInfer, String) for batch VALUES INSERT where each row’s value is embedded directly in SQL, without type annotation.

Source: SQLSyntax.java.

// namedConst in batch VALUES INSERT — value embedded directly, NO type prefix
SQLs.singleInsert()
        .insertInto(Stock_.T)
        .values()
        .parens(s -> s.space(Stock_.code, SQLs.namedConst(StringType.INSTANCE, "code"))
                .comma(Stock_.name, SQLs.namedConst(StringType.INSTANCE, "name"))
        )
        .asInsert();
// For each row, namedConst reads "code" from the row data and embeds it:
//   INSERT INTO stock(code, name) VALUES ('AAPL', 'Apple Inc.')
// No ::VARCHAR in PostgreSQL — just raw values

// As a standalone expression
SQLs.namedConst(StringType.INSTANCE, "code")
// Resolved at generation time from batch row → 'AAPL' (no ::VARCHAR even in Postgre)

namedConst is designed exclusively for VALUES INSERT statements — just like namedLiteral. Using it in a batch UPDATE or batch DELETE statement throws CriteriaException at runtime because named consts (like named literals) cannot resolve values from batch row data in non-INSERT contexts.

If you need named values in batch UPDATE/DELETE, use SQLs.namedParam() instead.

Method Reference Usage — in VALUES row space\* methods:

SQLs.namedConst(TypeInfer, String) can be used via method reference (SQLs::namedConst) with _StaticValueSpaceClause.space()’s BiFunction overload for VALUES INSERT rows where each row’s value is embedded directly in SQL (without type prefix). Internally calls `funcRef.apply(field, key)SQLs.namedConst(field, key):

  • First parameter FieldMeta is provided by Stock_.code — supplies the TypeInfer and table mapping info

  • Second parameter String is supplied by the user as "code" — the batch data’s named key

// batch VALUES INSERT: embed each row value in SQL without type prefix
SQLs.singleInsert()
        .insertInto(Stock_.T)
        .values()
        .parens(s -> s.space(Stock_.code, SQLs::namedConst, "code")   // funcRef.apply(field, "code") → TypeInfer=field, key="code"
                .comma(Stock_.name, SQLs::namedConst, "name")          // funcRef.apply(field, "name") → TypeInfer=field, key="name"
        )
        .asInsert();
// INSERT INTO stock(code, name) VALUES ('AAPL', 'Apple Inc.')
// Consistent across all dialects, no ::VARCHAR or other type prefixes
rowConst()

SQLs.rowConst(TypeInfer, Collection<?>) creates a multi-value const expression — each element in the collection is embedded directly in SQL without any type annotation prefix. This is the multi-value counterpart of constValue/constant.

Source: SQLSyntax.java.

// Standalone: outputs value , value , value ... (no type prefix)
SQLs.rowConst(Stock_.id, List.of(1, 2, 3))
// PostgreSQL:  (1, 2, 3)              ← no ::INTEGER
// MySQL:       (1, 2, 3)

// Method reference with IN operator
ChinaRegion_.id.in(SQLs::rowConst, idList)
// SQL:  id IN (1, 2, 3, ...)   -- values embedded, no type prefix anywhere

// With explicit type — NO type prefix in any dialect
SQLs.refField("cte", "col").in(SQLs.rowConst(StringType.INSTANCE, List.of("A", "B", "C")))
// PostgreSQL:  col IN ('A', 'B', 'C')      ← no ::VARCHAR (unlike rowLiteral)
// MySQL:       col IN ('A', 'B', 'C')      ← same as rowLiteral

How it works: SQLs.rowConst(type, values) calls ArmyRowLiteralExpression.multi(type, values, false) — the false flag means typeName is off, so no type annotation. Internally stores List.copyOf(values). When rendering, AnonymousRowLiteral.appendSql() iterates and calls context.appendLiteral(type, value, false) for each element, omitting the type prefix.

+ Constraints: - values must be non-null and non-empty - type must not return a codec TableField

rowConst vs rowLiteral: - rowLiteral — embeds with type prefix (e.g. 1::INTEGER in Postgre) - rowConst — embeds without type prefix (plain 1 everywhere), relying on implicit type coercion

namedRowConst()

SQLs.namedRowConst(TypeInfer, String) creates a named non-null multi-value const expression — for batch VALUES INSERT where each batch row contains a Collection field whose elements are embedded directly in SQL without type prefix. The const counterpart of namedRowLiteral.

Source: SQLSyntax.java.

// Standalone: named multi-const — columnSize() returns -1 (unknown at creation)
SQLs.namedRowConst(StringType.INSTANCE, "tags")
// Resolved at generation from batch row → 'java' , 'spring' , 'army' (no type prefix)

// Batch VALUES INSERT: each row's "tags" List field embedded as multiple literals, no type prefix
SQLs.singleInsert()
        .insertInto(Article_.T)
        .values()
        .parens(s -> s.space(Article_.id, SQLs::namedConst, "id")
                .comma(Article_.tag, SQLs::namedRowConst, "tags")  // Collection field → multi consts
        )
        .asInsert();
// For row {"id":1, "tags":["java","spring"]}:
//   INSERT INTO article(id, tag) VALUES (1, 'java', 'spring')
// No ::VARCHAR in PostgreSQL — consistent raw values across all dialects

How it works: SQLs.namedRowConst(type, name) calls ArmyRowLiteralExpression.named(type, name, false) — the false flag means no type prefix. Like namedRowLiteral, this named version has no stored values at creation (columnSize() returns -1). At SQL generation, values are read from batch row data by name and embedded without type annotation.

+

namedRowConst is designed exclusively for VALUES INSERT statements — just like namedRowLiteral. Using it in a batch UPDATE or batch DELETE statement throws CriteriaException at runtime because named row consts (like named row literals) cannot resolve multi-value collection data from batch rows in non-INSERT SQL contexts.

If you need named multi-value placeholders in batch UPDATE/DELETE, use SQLs.namedRowParam(type, name, size) instead.

namedRowConst vs namedRowLiteral: - namedRowLiteral(TypeInfer, String) — embeds with type prefix (Postgre ::TYPE) - namedRowConst(TypeInfer, String) — embeds without type prefix, consistent across dialects

literal vs const: the SQL difference
Value literal (with type prefix) const (without type prefix)

Integer(100)

100::INTEGER (Postgre) / 100 (MySQL)

100 (all databases)

BigDecimal("0.00")

0.00::DECIMAL (Postgre) / 0.00 (MySQL)

0.00 (all databases)

LocalDate

DATE '2024-01-01'

'2024-01-01'

LocalDateTime

TIMESTAMP '2024-01-01 …​'

'2024-01-01T…​'

String

'hello'::VARCHAR (Postgre) / 'hello' (MySQL)

'hello' (all databases)

The practical implication: literal guarantees the database interprets the value with the correct type (useful when type inference is ambiguous). const outputs the raw value and relies on the database’s implicit type coercion.

Naming design

Why constValue instead of just const? Because const is a Java reserved keyword — it cannot be used as a method name.

Why constant instead of reusing constValue? Because these two methods have different parameter structures: - constValue(Object) — auto-detect type, single parameter - constant(TypeInfer, Object) — explicit type, two parameters

Overloading them with the same name would cause ambiguity at call sites. The distinct names make the API self-documenting.

Similarly, namedConst is a concise abbreviation of namedConstant — it mirrors the naming pattern of namedLiteral while keeping the method name short.

BATCH_NO_CONST

For batch operations where you need the current batch row number embedded directly as a literal, without type annotation prefix.

Source: SQLs.java.

// Source definition
public static final LiteralExpression BATCH_NO_CONST = SQLs.namedConst(IntegerType.INSTANCE, "$ARMY_BATCH_NO$");
// Use BATCH_NO_CONST to embed batch row number (no type prefix)
SQLs.singleInsert()
        .with("cte").as(s -> s.select(ChinaRegion_.id, Windows.rowNumber().over().as("rowNumber"))
                .from(ChinaRegion_.T, AS, "t")
                .where(ChinaRegion_.id.in(SQLs::rowParam, regionIdList))
                .orderBy(ChinaRegion_.id)
                .asQuery()
        ).space()
        .insertInto(ChinaRegion_.T)
        .defaultValue(ChinaRegion_.parentId, SQLs.scalarSubQuery()
                .select(s -> s.space(SQLs.refField("cte", ChinaRegion_.ID)))
                .from("cte")
                .where(SQLs.refField("cte", "rowNumber").equal(SQLs.BATCH_NO_CONST))
                .asQuery()
        )
        .values(regionList)
        .asInsert();
// Same as BATCH_NO_LITERAL but without type prefix:
//   WHERE rowNumber = 1  (not 1::INTEGER in Postgre)

5.4. CTE

A Common Table Expression (CTE) is a named temporary result set that exists within the scope of a single SQL statement. CTEs make complex queries more readable by breaking them into logical steps, and they are also required for recursive queries.

Army provides full CTE support for SELECT, INSERT, UPDATE, DELETE, and MERGE statements via two usage styles:

  • Static WITH clause — fluent, chained syntax for one or more CTEs. Best for a fixed number of CTEs known at compile time.

  • Dynamic WITH clause — callback-based syntax that receives a CteBuilder. Best for programmatic CTE construction.

Both styles support with() for non-recursive CTEs and withRecursive() for recursive CTEs, plus conditional variants ifWith() / ifWithRecursive().

5.4.1. Static WITH Clause

The static style follows a declarative chain:

SQLs.query()
    .with("cte_name")                  // (1) start CTE
        .parens("col1", "col2")        // (2) optional column aliases
        .as(fn)                        // (3) sub-statement → returns comma/space
    .comma("another_cte")              // (4) more CTEs (optional)
        .parens("col")
        .as(fn)
    .space()                           // (5) end WITH clause → main query
    // ... main query ...

Each step:

  1. with("name") or withRecursive("name") — names the CTE and opens the definition chain.

  2. parens(…​) — optionally provides column aliases for the CTE result. If omitted, column names are derived from the sub-statement’s SELECT list.

  3. as(fn) — supplies the CTE body via a Function that receives a sub-statement builder. The function returns a Item whose type carries the next CTE or space()/comma() hook.

  4. comma("name") — adds another CTE to the chain. Repeat for multiple CTEs.

  5. space() — terminates the WITH clause and returns to the main statement builder.

Static CTE — Query
final Select stmt;
stmt = SQLs.query()
        .with("normal_stock").as(ws -> ws
                .select(Stock_.id, Stock_.name, Stock_.offerPrice)
                .from(Stock_.T, AS, "s")
                .where(Stock_.status.equal(StockStatus.NORMAL))
                .asQuery()
        ).space()
        .select(s -> s.space("normal_stock", PERIOD, ASTERISK))
        .from("normal_stock")
        .asQuery();
Generated SQL (PostgreSQL)
WITH normal_stock AS (
    SELECT s.id, s.name, s.offer_price
    FROM stock AS s
    WHERE s.status = ?  -- param: 'NORMAL'
)
SELECT normal_stock.id, normal_stock.name, normal_stock.status, ...
FROM normal_stock

After with(name).as(fn), the chain returns a type with comma(name) and space(). Calling comma(name) adds another CTE; calling space() closes the WITH clause. In static mode, comma(name) creates the next CTE in line, and you must add it via .parens(…​).as(fn) before calling space().

Static CTE — UPDATE
final Update stmt;
stmt = MySQLs.singleUpdate()
        .with("cte").as(c -> c.select(ChinaRegion_.id)
                .from(ChinaRegion_.T, AS, "c")
                .where(ChinaRegion_.id.in(SQLs::rowParam, idList))
                .and(ChinaRegion_.regionType.equal(SQLs::param, RegionType.NONE))
                .asQuery()
        ).space()
        .update(ChinaRegion_.T, AS, "c")
        .set(ChinaRegion_.regionGdp, SQLs::plusEqual, SQLs::param, gdpAmount)
        .where(ChinaRegion_.id::in, SQLs.subQuery()
                .select(s -> s.space(SQLs.refField("cte", ChinaRegion_.ID)))
                .from("cte")
                .asQuery()
        )
        .and(ChinaRegion_.createTime.between(SQLs::param, start, AND, end))
        .asUpdate();
Generated SQL (MySQL)
WITH cte AS (
    SELECT c.id
    FROM china_region AS c
    WHERE c.id IN ( ?, ?, ?, ... )
      AND c.region_type = ?  -- param: 'NONE'
)
UPDATE china_region AS c
SET c.region_gdp = c.region_gdp + ?  -- param: gdpAmount
WHERE c.id IN (
    SELECT cte.id FROM cte
)
  AND c.create_time BETWEEN ? AND ?
Static CTE — Multiple CTEs
final Select stmt;
stmt = SQLs.query()
        .with("first_cte").as(s -> s.select(Stock_.id, Stock_.name)
                .from(Stock_.T, AS, "s")
                .where(Stock_.status.equal(StockStatus.NORMAL))
                .asQuery()
        ).comma("second_cte").as(s -> s.select(Stock_.id, Stock_.offerPrice)
                .from(Stock_.T, AS, "t")
                .where(Stock_.offerPrice.greater(new BigDecimal("100.00")))
                .asQuery()
        ).space()
        .select(s -> s.space("first_cte", PERIOD, ASTERISK))
        .from("first_cte")
        .join("second_cte").on(
                SQLs.refField("first_cte", Stock_.ID)::equal,
                SQLs.refField("second_cte", Stock_.ID)
        )
        .asQuery();
Generated SQL (PostgreSQL)
WITH first_cte AS (
    SELECT s.id, s.name
    FROM stock AS s
    WHERE s.status = ?  -- param: 'NORMAL'
),
second_cte AS (
    SELECT t.id, t.offer_price
    FROM stock AS t
    WHERE t.offer_price > ?  -- param: 100.00
)
SELECT first_cte.id, first_cte.name, ...
FROM first_cte
JOIN second_cte ON first_cte.id = second_cte.id

5.4.2. Dynamic WITH Clause

The dynamic style uses a Consumer<B> callback where B is dialect-specific:

  • StandardCtes — for SQLs.* / standard dialect

  • MySQLCtes — for MySQLs.* / MySQL dialect

  • PostgreCtes — for Postgres.* / PostgreSQL dialect

SQLs.query()
    .with(cteBuilder -> cteBuilder.subQuery("cte_name")
            .as(fn)
    )
    // ... main query ...

Each Ctes builder provides factory methods that start a new CTE:

  • subQuery(String name) — CTE backed by a SELECT statement (all dialects)

  • subValues(String name) — CTE backed by a VALUES clause (MySQL)

  • subSingleInsert(String name) — CTE backed by an INSERT (PostgreSQL)

  • subSingleUpdate(String name) — CTE backed by an UPDATE (PostgreSQL)

  • subSingleDelete(String name) — CTE backed by a DELETE (PostgreSQL)

Dynamic CTE — Equivalent to Static Style

This is the dynamic equivalent of the static UPDATE example above:

final Update stmt;
stmt = MySQLs.singleUpdate()
        .with(w -> w.subQuery("cte")
                .as(s -> s.select(ChinaRegion_.id)
                        .from(ChinaRegion_.T, AS, "c")
                        .where(ChinaRegion_.id.in(SQLs::rowParam, idList))
                        .and(ChinaRegion_.regionType.equal(SQLs::param, RegionType.NONE))
                        .asQuery()
                )
        )
        .update(ChinaRegion_.T, AS, "c")
        .set(ChinaRegion_.regionGdp, SQLs::plusEqual, SQLs::param, gdpAmount)
        .where(ChinaRegion_.id::in, SQLs.subQuery()
                .select(s -> s.space(SQLs.refField("cte", ChinaRegion_.ID)))
                .from("cte")
                .asQuery()
        )
        .asUpdate();
Generated SQL (MySQL)
WITH cte AS (
    SELECT c.id
    FROM china_region AS c
    WHERE c.id IN ( ?, ?, ?, ... )
      AND c.region_type = ?  -- param: 'NONE'
)
UPDATE china_region AS c
SET c.region_gdp = c.region_gdp + ?  -- param: gdpAmount
WHERE c.id IN (
    SELECT cte.id FROM cte
)
Dynamic CTE — Multiple with Conditional
final Select stmt;
stmt = SQLs.query()
        .with(c -> {
            c.subQuery("cte1").as(s -> s.select(Stock_.id)
                    .from(Stock_.T, AS, "s")
                    .asQuery()
            );
            if (includeSecondCte) {
                c.subQuery("cte2").as(s -> s.select(Stock_.offerPrice)
                        .from(Stock_.T, AS, "t")
                        .asQuery()
                );
            }
        })
        .select(s -> s.space(Stock_.id))
        .from("cte1")
        .asQuery();

Use ifWith(consumer) / ifWithRecursive(consumer) when you need to conditionally add a WITH clause — the consumer is only executed when you’ve added at least one CTE. If the consumer adds zero CTEs, the entire WITH clause is silently omitted from the final SQL. In contrast, with(consumer) / withRecursive(consumer) throws an error if no CTE is added.

5.4.3. Recursive CTE

A recursive CTE is a CTE that references itself, used for hierarchical data (org charts, category trees) or iterative computation (number series, graph traversal). It consists of two parts:

  1. Non-recursive part — the base case, executed once

  2. Recursive part — references the CTE’s own name via UNION / UNION ALL

In Army, recursive CTEs are declared with withRecursive() (or .withRecursive("name") in the static style). Within the as(fn) body, reference the CTE columns using SQLs.refField(cteName, "column").

Recursive CTE — Number Series
final Select stmt;
stmt = SQLs.query()
        .withRecursive("cte").parens("n").as(s ->
                // Non-recursive part — base case
                s.select(SQLs.literalValue(1).as("n"))
                 .union()
                 // Recursive part — references itself via refField
                 .select(cs -> cs.space(
                         SQLs.refField("cte", "n").plus(SQLs.literalValue(1)).as("n"))
                 )
                 .from("cte")
                 .where(SQLs.refField("cte", "n").less(SQLs.literalValue(10)))
                 .asQuery()
        ).space()
        .select(s -> s.space(SQLs.refField("cte", "n")))
        .from("cte")
        .asQuery();
Generated SQL (PostgreSQL)
WITH RECURSIVE cte(n) AS (
    SELECT 1 AS n
    UNION
    SELECT cte.n + 1 AS n
    FROM cte
    WHERE cte.n < 10
)
SELECT cte.n
FROM cte

A recursive CTE must contain at least one non-recursive part (base case). Army validates this at parse time — if the recursive part appears without a non-recursive anchor, a NotExistsNonRecursiveException is thrown.

Recursive CTE — Hierarchical Query
final Select stmt;
stmt = SQLs.query()
        .withRecursive("category_tree").parens("id", "parent_id", "name", "level")
        .as(s ->
                // Non-recursive part — root nodes (parent_id IS NULL)
                s.select(Category_.id, Category_.parentId, Category_.name,
                         SQLs.literalValue(1).as("level"))
                 .from(Category_.T, AS, "c")
                 .where(Category_.parentId.isNull())
                 .asQuery()
        ).comma("union", s -> s.select(Category_.id, Category_.parentId,
                                       Category_.name,
                                       SQLs.refField("category_tree", "level")
                                           .plus(SQLs.literalValue(1)).as("level"))
                 .from(Category_.T, AS, "c")
                 .join("category_tree").on(
                         Category_.parentId::equal,
                         SQLs.refField("category_tree", Category_.ID)
                 )
                 .asQuery()
        ).space()
        .select(s -> s.space("category_tree", PERIOD, ASTERISK))
        .from("category_tree")
        .orderBy(SQLs.refField("category_tree", "level")::asc,
                 SQLs.refField("category_tree", Category_.ID)::asc)
        .asQuery();
Generated SQL (PostgreSQL)
WITH RECURSIVE category_tree(id, parent_id, name, level) AS (
    SELECT c.id, c.parent_id, c.name, 1 AS level
    FROM category AS c
    WHERE c.parent_id IS NULL
    UNION ALL
    SELECT c.id, c.parent_id, c.name, category_tree.level + 1 AS level
    FROM category AS c
    JOIN category_tree ON c.parent_id = category_tree.id
)
SELECT category_tree.id, category_tree.name, category_tree.level, ...
FROM category_tree
ORDER BY category_tree.level ASC, category_tree.id ASC

5.4.4. Referencing CTEs

Once a CTE is defined, there are two ways to reference it in the main statement:

FROM / JOIN a CTE

Use the CTE name directly in the FROM or JOIN clause — just like a table name:

.from("cte_name")                           // bare CTE
.from("cte_name", AS, "alias")               // CTE with alias
.from(SQLs.MATERIALIZED, "cte_name", AS, "a") // CTE with materialization hint (PostgreSQL)
.join("cte_name").on(...)                    // JOIN CTE
.leftJoin("cte_name", AS, "alias").on(...)   // LEFT JOIN CTE with alias
Accessing CTE Columns via refField

Use SQLs.refField(cteAlias, selectionAlias) to reference a specific CTE column in SELECT, WHERE, JOIN …​ ON, or ORDER BY:

// Reference a CTE column by its selection alias (string form)
SQLs.refField("cte", "id")       // cte.id

// Reference a CTE column by its metamodel field name
SQLs.refField("cte", ChinaRegion_.ID)  // cte.id, with type info

SQLs.refField() returns a DerivedField (no type info) or TypedDerivedField (with full MappingType). Use SQLs.refTypedField() to get a typed reference when you need TypedExpression methods.

When the left operand has no type (e.g., SQLs.refField("cte", "id")), prefer the two-argument form of expression operators to explicitly provide the type:

SQLs.refField("cte", "n").less(SQLs::param, 10)   // ✅ explicit type
SQLs.refField("cte", "n").less(10)                // ❌ may fail at parse time

5.4.5. CTE with VALUES

A CTE can also be defined from a VALUES clause, useful for providing inline data to a query without a real table:

final Select stmt;
stmt = SQLs.query()
        .with("data").as(sw -> sw.values()
                .row(s -> s.space(1))
                .comma()
                .row(s -> s.space(2))
                .comma()
                .row(s -> s.space(3))
                .asValues()
        ).space()
        .select(Stock_.id, Stock_.name)
        .from(Stock_.T, AS, "s")
        .crossJoin("data")
        .where(Stock_.id.in(SQLs::rowLiteral, List.of(1, 2, 3)))
        .asQuery();
Generated SQL (PostgreSQL)
WITH data AS (
    VALUES (1),
           (2),
           (3)
)
SELECT s.id, s.name
FROM stock AS s
CROSS JOIN data
WHERE s.id IN ( 1, 2, 3 )

5.4.6. CTE in DML — DELETE with CTE

final Delete stmt;
stmt = MySQLs.singleDelete()
        .with("idListCte").as(c -> c.select(ChinaRegion_.id)
                .from(ChinaRegion_.T, AS, "t")
                .where(ChinaRegion_.id.in(SQLs::rowParam, idList))
                .and(ChinaRegion_.regionType.equal(SQLs::param, RegionType.NONE))
                .asQuery()
        ).space()
        .deleteFrom(ChinaRegion_.T, AS, "c")
        .where(ChinaRegion_.id::in, SQLs.subQuery()
                .select(s -> s.space(SQLs.refField("cte", ChinaRegion_.ID)))
                .from("idListCte", AS, "cte")
                .asQuery()
        )
        .and(ChinaRegion_.createTime.between(SQLs::param, start, AND, end))
        .asDelete();
Generated SQL (MySQL)
WITH idListCte AS (
    SELECT t.id
    FROM china_region AS t
    WHERE t.id IN ( ?, ?, ?, ... )
      AND t.region_type = ?  -- param: 'NONE'
)
DELETE FROM china_region AS c
WHERE c.id IN (
    SELECT cte.id FROM idListCte AS cte
)
  AND c.create_time BETWEEN ? AND ?

5.4.7. PostgreSQL-Specific CTE Features

PostgreSQL extends standard CTE syntax with materialization control and recursive search/cycle clauses.

MATERIALIZED / NOT_MATERIALIZED

Control whether PostgreSQL materializes (caches) the CTE result or merges it into the main query each time it’s referenced:

// Force materialization — useful when the CTE is expensive and referenced multiple times
Postgres.query()
    .with("cte").as(SQLs.MATERIALIZED, ws -> ws
            .select(Stock_.id)
            .from(Stock_.T, AS, "s")
            .asQuery()
    ).space()
    .select(s -> s.space("cte", PERIOD, ASTERISK))
    .from("cte")
    .asQuery();
Generated SQL (PostgreSQL)
WITH cte AS MATERIALIZED (
    SELECT s.id
    FROM stock AS s
)
SELECT cte.id, cte.name, ...
FROM cte

Use SQLs.NOT_MATERIALIZED to prefer inline merging.

SEARCH and CYCLE

PostgreSQL supports SEARCH BREADTH FIRST / SEARCH DEPTH FIRST and CYCLE clauses for recursive CTEs. These are available via the Postgres.* API:

final Select stmt;
stmt = Postgres.query()
        .withRecursive("tree").parens("id", "parent_id", "depth")
        .as(s -> s.select(Category_.id, Category_.parentId,
                          SQLs.literalValue(0).as("depth"))
                .from(Category_.T, AS, "c")
                .where(Category_.parentId.isNull())
                .unionAll()
                .select(c -> c.space(Category_.id, Category_.parentId,
                                     SQLs.refField("tree", "depth")
                                             .plus(SQLs.literalValue(1)).as("depth")))
                .from(Category_.T, AS, "c")
                .join("tree").on(
                        Category_.parentId::equal,
                        SQLs.refField("tree", Category_.ID)
                )
                .asQuery()
        ).search(s -> s.depthFirst().by(Category_.id).set("search_seq"))
        .cycle(Category_.id).set("is_cycle").using("cycle_path")
        .space()
        .select(s -> s.space("tree", PERIOD, ASTERISK))
        .from("tree")
        .where(SQLs.refField("tree", "is_cycle").equal(false))
        .asQuery();
Generated SQL (PostgreSQL)
WITH RECURSIVE tree(id, parent_id, depth) AS (
    SELECT c.id, c.parent_id, 0 AS depth
    FROM category AS c
    WHERE c.parent_id IS NULL
    UNION ALL
    SELECT c.id, c.parent_id, tree.depth + 1 AS depth
    FROM category AS c
    JOIN tree ON c.parent_id = tree.id
) SEARCH DEPTH FIRST BY id SET search_seq
  CYCLE id SET is_cycle USING cycle_path
SELECT tree.id, tree.name, tree.is_cycle, ...
FROM tree
WHERE tree.is_cycle = FALSE

search() and cycle() are available in both static and dynamic WITH styles for PostgreSQL. In the static style, the search/cycle clauses appear between .as(fn) and .space(). Use ifSearch() / ifCycle() for conditional application.

Complete API:

  • .search(s → s.breadthFirst().by(col).set("seqCol")) — breadth-first

  • .search(s → s.depthFirst().by(col).set("seqCol")) — depth-first

  • .cycle(col1, col2).set("mark").using("path") — cycle detection

  • .ifSearch(…​) / .ifCycle(…​) — conditional application

5.4.8. Static vs Dynamic — When to Use Which

Scenario Static Style Dynamic Style

Single, fixed CTE

.with("cte").as(fn).space() + Short and readable.

.with(c → c.subQuery("cte").as(fn)) + Slightly more verbose.

Multiple, fixed CTEs

.with("c1").as(f1).comma("c2").as(f2).space() + Natural chaining.

.with(c → { c.subQuery("c1").as(f1); c.subQuery("c2").as(f2); }) + Grouped in one callback.

Conditional CTE inclusion

Not available — all CTEs in the chain are mandatory.

.ifWith(c → { if (cond) c.subQuery("c1").as(f1); }) + CTE silently omitted when cond is false.

Programmatic CTE generation

Awkward — must construct the chain programmatically.

Natural — build the CTE list inside the consumer with loops and conditionals.

Need space bindings in CTE*

Batch domain UPDATE/DELETE: .with("cte").as(sw → sw.select(…​).where(field::spaceEqual, SQLs::namedParam))

Same as static.

5.5. Subqueries

5.5.1. Scalar Subquery

A scalar subquery returns a single column and at most one row — it acts as a scalar Expression in SELECT, WHERE, or HAVING clauses.

A SubQuery cannot exist independently — it must always be embedded within an outer statement (a top-level SELECT/INSERT/UPDATE/DELETE). The outer context is captured at construction time via ContextStack.peek(). Without an outer statement, ContextStack.peek() throws a CriteriaException.

SQLs.scalarSubQuery() is the standard entry point:

// Source definition — SQLs.java
public static StandardQuery.WithSpec<Expression> scalarSubQuery() {
    return StandardQueries.subQuery(StandardDialect.STANDARD20,
            ContextStack.peek(), SCALAR_SUB_QUERY);
}

Dialect entry points provide the same API but bind to a specific database dialect, enabling dialect-specific features (e.g. FOR UPDATE, LIMIT, modifiers) within the subquery:

// MySQL dialect scalar subquery
MySQLs.scalarSubQuery()   // → MySQLQuery.WithSpec<Expression>

// PostgreSQL dialect scalar subquery
Postgres.scalarSubQuery() // → PostgreQuery.WithSpec<Expression>

The SCALAR_SUB_QUERY constant wraps the subquery result via Expressions.scalarExpression:

// SQLs.java
static final Function<SubQuery, Expression> SCALAR_SUB_QUERY
        = Expressions::scalarExpression;

Expressions.scalarExpression validates that the subquery has exactly one selection and returns a ScalarExpression wrapper:

// Expressions.java
static SimpleExpression scalarExpression(final SubQuery subQuery) {
    validateScalarSubQuery(subQuery);  // throws if selection count != 1
    return new ScalarExpression(subQuery);
}

Scalar subqueries are most commonly used in SELECT clauses to embed a lookup value:

// Embed a scalar subquery in SELECT — returns Expression, not SubQuery
SQLs.query()
    .select(SQLs.scalarSubQuery()
            .select(PillUser_.nickName)
            .from(PillUser_.T, AS, "u")
            .where(PillUser_.id.equal(SQLs::param, 1))
            .asQuery()      // → Expression (not SubQuery)
            .as("r")
    )
    .asQuery()
Generated SQL (PostgreSQL)
SELECT (SELECT u.nick_name
        FROM pill_user AS u
        WHERE u.id = ?) AS r
-- param: 1

Scalar subqueries can also appear in WHERE clauses:

// Scalar subquery in WHERE — compare a field against a subquery result
SQLs.query()
    .select(Stock_.code)
    .from(Stock_.T, AS, "s")
    .where(Stock_.offerPrice.greater(
        SQLs.scalarSubQuery()
            .select(SQLs.avg(Stock_.offerPrice))
            .from(Stock_.T, AS, "s2")
            .asQuery()
    ))
    .asQuery()
Generated SQL (PostgreSQL)
SELECT s.code
FROM stock AS s
WHERE s.offer_price > (SELECT AVG(s2.offer_price)
                       FROM stock AS s2)

The final asQuery() at the end of the SQLs.scalarSubQuery() chain returns an io.army.criteria.Expression instance, not a SubQuery. It is used in contexts that require a scalar expression, such as SELECT, WHERE, and HAVING clauses.

In contrast, the final asQuery() at the end of the SQLs.subQuery() chain returns a SubQuery, which is used in FROM clauses or with IN / EXISTS predicates.

5.5.2. Table Subquery

A table subquery (or derived table) returns a result set that can be used in FROM clauses, JOIN sources, or as the operand of IN / EXISTS / = ANY predicates.

A SubQuery cannot exist independently — it must always be embedded within an outer statement (a top-level SELECT/INSERT/UPDATE/DELETE). The outer context is captured at construction time via ContextStack.peek(). Without an outer statement, ContextStack.peek() throws a CriteriaException.

SQLs.subQuery() is the standard entry point:

// Source definition — SQLs.java
public static StandardQuery.WithSpec<SubQuery> subQuery() {
    return StandardQueries.subQuery(StandardDialect.STANDARD20,
            ContextStack.peek(), SUB_QUERY_IDENTITY);
}

Dialect entry points provide the same API but bind to a specific database dialect, enabling dialect-specific clauses (e.g. FOR UPDATE, LIMIT) within the subquery:

// MySQL dialect table subquery
MySQLs.subQuery()   // → MySQLQuery.WithSpec<SubQuery>

// PostgreSQL dialect table subquery
Postgres.subQuery() // → PostgreQuery.WithSpec<SubQuery>

The SUB_QUERY_IDENTITY constant is simply the identity function:

// SQLs.java
static final UnaryOperator<SubQuery> SUB_QUERY_IDENTITY = SQLs::identity;

SubQuery extends multiple interfaces to support diverse usage contexts:

// SubQuery.java
public interface SubQuery extends DerivedTable, Query, SubStatement,
                                   SQLValueList, RowElement {
}
  • DerivedTable — allows the subquery to appear in FROM and JOIN

  • Query — the subquery itself is a query statement

  • SubStatement — marks it as a sub-statement (not top-level)

  • SQLValueList — allows usage with IN / = ANY predicates

  • RowElement — allows row-level operations

Table subqueries appear in three main contexts: FROM/JOIN, EXISTS, and IN.

In FROM clause (derived table):

// Select all columns from a filtered subquery
SQLs.subQuery()
    .select("s", PERIOD, Stock_.T)
    .from(Stock_.T, AS, "s")
    .where(Stock_.status.equal(StockStatus.NORMAL))
    .asQuery()      // → SubQuery
Generated SQL (PostgreSQL)
SELECT s.id, s.name, s.status, ...
FROM stock AS s
WHERE s.status = ?
-- param: 'NORMAL'
-- asQuery() returns SubQuery, usable in FROM / JOIN / IN / EXISTS

In JOIN as a derived table:

// Join a subquery as a derived table source
SQLs.query()
    .select("bu", PERIOD, PillUser_.T)
    .from(PillUser_.T, AS, "bu")
    .join(SQLs.subQuery()
            .select(ChinaProvince_.id)
            .from(ChinaProvince_.T, AS, "p")
            .asQuery()
    ).as("ps").on(PillUser_.id.equal(SQLs.refField("ps", ChinaProvince_.ID)))
    .asQuery()
Generated SQL (PostgreSQL)
SELECT bu.id, bu.name, bu.email, ...
FROM pill_user AS bu
JOIN (SELECT p.id
      FROM china_province AS p) AS ps
  ON bu.id = ps.id

In EXISTS predicate:

// EXISTS — check whether a related row exists
SQLs.query()
    .select("u", PERIOD, PillUser_.T)
    .from(PillUser_.T, AS, "u")
    .where(PillUser_.nickName.equal(SQLs::param, "蛮吉"))
    .and(SQLs::exists, SQLs.subQuery()
            .select(ChinaProvince_.id)
            .from(ChinaProvince_.T, AS, "p")
            .join(ChinaRegion_.T, AS, "r")
                .on(ChinaProvince_.id::equal, ChinaRegion_.id)
            .where(ChinaProvince_.governor::equal,
                   SQLs.field("u", PillUser_.nickName))
            .asQuery()
    )
    .asQuery()
Generated SQL (PostgreSQL)
SELECT u.id, u.name, u.email, ...
FROM pill_user AS u
WHERE u.nick_name = ?
  AND EXISTS (SELECT p.id
              FROM china_province AS p
              JOIN china_region AS r ON p.id = r.id
              WHERE p.governor = u.nick_name)
-- param: '蛮吉'

In IN predicate:

// IN — filter by a subquery result set
SQLs.query()
    .select("p", PERIOD, PillPerson_.T)
    .from(PillPerson_.T, AS, "p")
    .join(PillUser_.T, AS, "u")
        .on(PillUser_.id::equal, PillPerson_.id)
    .where(PillPerson_.id.equal(SQLs::literal, 1))
    .and(PillUser_.id::in, SQLs.subQuery()
            .select(RegisterRecord_.userId)
            .from(RegisterRecord_.T, AS, "r")
            .where(RegisterRecord_.createTime.between(
                SQLs::literal, LocalDateTime.now().minusDays(1),
                AND, LocalDateTime.now()))
            .asQuery()
    )
    .asQuery()
Generated SQL (PostgreSQL)
SELECT p.id, p.name, p.age, ...
FROM pill_person AS p
JOIN pill_user AS u ON u.id = p.id
WHERE p.id = 1
  AND u.id IN (SELECT r.user_id
               FROM register_record AS r
               WHERE r.create_time BETWEEN '2026-06-04T...'::timestamp AND '2026-06-05T...'::timestamp)

5.6. Clause(s)

Army’s query API is built on a strict fluent chain where each clause method returns the next narrow interface, enforcing correct SQL clause ordering at compile time. The full chain order is:

WITHSELECTFROMJOINWHEREGROUP BYHAVINGWINDOWORDER BYLIMITUNIONasQuery()

5.6.1. SELECT

The select() method defines the output columns. Army provides three forms of SELECT clause, each suited to a different scenario:

Form Selection Timing Derived Table Reference API Entry

Static

Compile-time (fixed)

Not needed

select(Selection), comma(Selection)

Delay

Compile-time (known)

Required (refField)

select(Consumer<_DeferSelectSpaceClause>)

Dynamic

Runtime (conditional)

Required or not

selects(Consumer<SelectionConsumer>)

Static Select

When selections are known at compile time and do not reference derived table columns, use the static form: pass Selection objects directly to select() or comma().

// Query._StaticSelectClause (Query.java L73-L97) — via StandardQuery.SelectSpec
interface _StaticSelectClause<SR> {
    SR select(Selection selection);                     // single
    SR select(Selection s1, Selection s2);              // two
    SR select(SqlField f1, SqlField f2, SqlField f3);  // three fields
    SR select(String alias, SQLs.SymbolDot dot,
              TableMeta<?> table);                      // wildcard
    ...
}

How it works: The Selection objects (fields, expressions, wildcards) are captured immediately as the SELECT clause is built. No deferred resolution is needed because the selections don’t depend on any FROM/JOIN aliases.

Single selection (wildcard):

SQLs.query()
    .select("u", PERIOD, PillUser_.T)     // u.*  — domain wildcard
    .from(PillUser_.T, AS, "u")
    .asQuery()
Generated SQL (PostgreSQL)
SELECT u.id, u.name, u.email, ...
FROM pill_user AS u

Multiple selections with comma():

SQLs.query()
    .select(ChinaRegion_.population.plus(1).as("populationPlus"))
    .comma("c", PERIOD, ChinaRegion_.T)
    .comma("p", PERIOD, ChinaProvince_.T)
    .from(ChinaProvince_.T, AS, "p")
    .join(ChinaRegion_.T, AS, "c")
        .on(ChinaProvince_.id.equal(ChinaRegion_.id))
    .asQuery()
Generated SQL (PostgreSQL)
SELECT c.population + ? AS "populationPlus",
       c.id, c.name, c.population, ...,
       p.id, p.name, p.province_code, ...
FROM china_province AS p
JOIN china_region AS c ON p.id = c.id
Defer Select

When selections are known at compile time but need to reference derived table columns (e.g., a subquery’s output columns via refField()), you must use the delay form. Pass a Consumer<_DeferSelectSpaceClause> lambda — the lambda is not executed immediately. Instead, it is registered as a deferred command and executed after the FROM clause has been processed, when derived table aliases are fully resolved.

// Query._DynamicSelectClause (Query.java L55-L61)
interface _DynamicSelectClause<SD> {
    SD select(Consumer<_DeferSelectSpaceClause> consumer);
    SD selects(Consumer<SelectionConsumer> consumer);
}

// Implementation — SimpleQueries.java L229
public final SD select(Consumer<_DeferSelectSpaceClause> consumer) {
    this.context.registerDeferCommandClause(
        () -> consumer.accept(new SelectionConsumerImpl(this.context))
    );
    return (SD) this;
}

SQLs.refField(derivedAlias, fieldName) is not allowed in static SELECT clauses. If you try, you’ll get:

SQLs.refField("us","one") isn't allowed in static SELECT clause for ...

You must switch to the delay form to use refField().

Example — delay form referencing a derived table alias:

// SELECT references "us.one" and "us.*" — derived table alias `us` requires delay
SQLs.query()
    .select(s -> s.space(SQLs.refField("us", "one"))
            .comma("us", PERIOD, ASTERISK)
    )
    .from(SQLs.subQuery()
            .select(SQLs.literalValue(1).as("one"))
            .comma("u", PERIOD, PillUser_.T)
            .from(PillUser_.T, AS, "u")
            .limit(SQLs::literal, 0, 10)
            .asQuery()
    ).as("us")
    .where(SQLs.refField("us", "one").equal(1))
    .asQuery()
Generated SQL (PostgreSQL)
SELECT us."one", us.*
FROM (SELECT 1 AS "one", u.id, u.name, u.email, ...
      FROM pill_user AS u
      LIMIT 10 OFFSET 0) AS us
WHERE us."one" = ?
-- param: 1
Dynamic Select

When selections are determined at runtime (e.g., different columns based on conditions), use selects(Consumer<SelectionConsumer>). SelectionConsumer is a more flexible builder that allows conditional branching inside the lambda.

// Query._DynamicSelectClause (Query.java L59)
SD selects(Consumer<SelectionConsumer> consumer);

// SelectionConsumer.java
public interface SelectionConsumer extends Statement._DeferContextSpec {
    SelectionConsumer selection(Selection selection);
    SelectionConsumer selection(Selection s1, Selection s2);
    SelectionConsumer selection(SqlField f1, SqlField f2, SqlField f3);
    SelectionConsumer selection(String alias, SQLs.SymbolDot dot,
                                TableMeta<?> table);
    ...
}

How it works: Like the delay form, the lambda is registered as a deferred command. Inside the lambda, you call consumer.selection(…​) repeatedly — each call appends a column. You can use if/else to dynamically decide which columns to include.

Example — conditional column selection:

// Dynamically pick columns based on distance type
SQLs.query()
    .selects(s -> {
        s.selection(this.documentId, this.content, this.metadata);
        if (distanceType == NEG_DOT) {
            s.selection(SQLs.LITERAL_1.plus(
                this.embedding.space(distanceOp, queryEmbedding)
            ).as("distance"));
        } else {
            s.selection(this.embedding.space(
                distanceOp, queryEmbedding
            ).as("distance"));
        }
    })
    .from(this.tableMeta, AS, "t")
    .orderBy(SQLs.refSelection("distance"))
    .limit(topK)
    .asQuery()
Generated SQL (PostgreSQL) — NEG_DOT branch
SELECT t.document_id, t.content, t.metadata,
       1 + (t.embedding <-> ?) AS "distance"
FROM doc_store AS t
ORDER BY "distance"
LIMIT 5
-- param: {embedding vector}

When to use which form:

  • Static — you know the list of columns and they’re all from SqlField references or literal expressions (most common case).

  • Delay — you know the list of columns but some come from a derived table (subquery) alias, so you need refField("alias", "col").

  • Dynamic — the column list depends on runtime conditions (different branches add different columns), or you need the SelectionConsumer API for programmatic column building.

5.6.2. FROM

The from() method specifies the data source. Army provides five forms of FROM clause, each corresponding to a different SQL FROM source kind:

Form SQL Equivalent Core Interface

Table

FROM table_name AS alias

_FromClause.from(TableMeta, AS, alias)

Derived Table

FROM (SELECT …​) AS alias

_FromClause.from(DerivedTable)

CTE

FROM cte_name / FROM cte_name AS alias

_FromCteClause.from(String)

Table Function

FROM func(args) AS alias(col_def)

_FromUndoneFunctionClause.from(UndoneFunction)

Nested

FROM (table1, table2, …​)

_FromNestedClause.from(Function)

Each form supports an optional modifier:

  • SQLs.TableModifier — e.g. SQLs.ONLY (PostgreSQL ONLY for inheritance)

  • SQLs.DerivedModifier — e.g. SQLs.LATERAL (lateral subquery/function)

Source Interfaces

The API exposes the following interface hierarchy in the SELECT chain (StandardQuery._FromSpec):

// _FromClause — table & derived table (Statement.java L267-L275)
interface _FromClause<FT, FS> {
    FT from(TableMeta<?> table, SQLs.WordAs as, String tableAlias);
    FS from(DerivedTable derivedTable);
    <T extends DerivedTable> FS from(Supplier<T> supplier);
}

// _FromModifierTabularClause — derived table modifier (Statement.java L277-L282)
interface _FromModifierTabularClause<FT, FS> extends _FromClause<FT, FS> {
    FS from(@Nullable SQLs.DerivedModifier modifier, DerivedTable derivedTable);
    <T extends DerivedTable> FS from(@Nullable SQLs.DerivedModifier modifier, Supplier<T> supplier);
}

// _FromModifierClause — table modifier (Statement.java L284-L288)
interface _FromModifierClause<FT, FS> extends _FromModifierTabularClause<FT, FS> {
    FT from(@Nullable SQLs.TableModifier modifier, TableMeta<?> table,
            SQLs.WordAs as, String tableAlias);
}

// _FromCteClause — CTE by name (Statement.java L307-L313)
interface _FromCteClause<R> {
    R from(String cteName);
    R from(String cteName, SQLs.WordAs as, String alias);
}

// _FromUndoneFunctionClause — table function (Statement.java L323-L326)
interface _FromUndoneFunctionClause<R> {
    R from(UndoneFunction func);                  // e.g. json_to_recordset()
}

// _FromNestedClause — comma-separated items in parentheses (Statement.java L290-L294)
interface _FromNestedClause<T extends Item, R extends Item> {
    R from(Function<T, R> function);              // lambda wrapping multiple items
}
Table

Pass a TableMeta constant with an alias via AS:

SQLs.query()
    .select("s", PERIOD, Stock_.T)
    .from(Stock_.T, AS, "s")
    .asQuery()
Generated SQL (PostgreSQL)
SELECT s.id, s.name, s.offer_price, ...
FROM stock AS s

With SQLs.TableModifier (e.g. PostgreSQL ONLY):

// PostgreSQL: ONLY skips descendant tables in inheritance hierarchy
// Note: Only Postgres.query() supports TableModifier, not SQLs.query()
Postgres.query(PillPerson_.id)
    .from(SQLs.ONLY, PillPerson_.T, AS, "p")
    .asQuery()
Generated SQL (PostgreSQL)
SELECT p.id
FROM ONLY pill_person AS p
Derived Table

Pass a DerivedTable (from subQuery()) directly, or use a Supplier to access local variables in lambda:

Supplier is NOT for "lazy" construction; it exists to let you declare/use local variables in lambda. Keep lambdas simple to avoid losing readability.

Direct subquery:

SQLs.query()
    .select(s -> s.space(SQLs.refField("us", "one"))
            .comma("us", PERIOD, ASTERISK)
    )
    .from(SQLs.subQuery()
            .select(SQLs.literalValue(1).as("one"))
            .comma("u", PERIOD, PillUser_.T)
            .from(PillUser_.T, AS, "u")
            .limit(SQLs::literal, 0, 10)
            .asQuery()
    ).as("us")
    .where(SQLs.refField("us", "one").equal(1))
    .asQuery()
Generated SQL (PostgreSQL)
SELECT us."one", us.*
FROM (SELECT 1 AS "one", u.id, u.name, u.email, ...
      FROM pill_user AS u
      LIMIT 10 OFFSET 0) AS us
WHERE us."one" = ?
-- param: 1

Subquery with local variables (Supplier):

// from(Supplier<DerivedTable>) — use lambda to access local variables
// NOTE: This is NOT for "lazy" construction; the purpose is to let you declare/use local variables in lambda
// Keep lambdas simple to avoid losing readability
SQLs.query()
    .select("bu", PERIOD, PillUser_.T)
    .from(PillUser_.T, AS, "bu")
    .join(() -> {
        // 可以在这里声明和使用局部变量
        int someValue = 100;
        return SQLs.subQuery()
            .select(ChinaProvince_.id)
            .from(ChinaProvince_.T, AS, "p")
            .asQuery();
    }).as("ps")
        .on(PillUser_.id::equal, SQLs.refField("ps", ChinaProvince_.ID))
    .asQuery()
Generated SQL (PostgreSQL)
SELECT bu.id, bu.name, bu.email, ...
FROM pill_user AS bu
JOIN (SELECT p.id
      FROM china_province AS p) AS ps
  ON bu.id = ps.id

With SQLs.LATERAL modifier:

// PostgreSQL LATERAL subquery — references outer table columns
Postgres.query()
    .select(s -> s.space("u", PERIOD, PillUser_.T)
            .comma("latest", PERIOD, ASTERISK))
    .from(PillUser_.T, AS, "u")
    .join(SQLs.LATERAL, SQLs.subQuery()
            .select(BankAccount_.id, BankAccount_.balance)
            .from(BankAccount_.T, AS, "a")
            .where(BankAccount_.userId::equal, SQLs.refField("u", PillUser_.ID))
            .orderBy(BankAccount_.createTime::desc)
            .limit(SQLs::literal, 1)
            .asQuery()
    ).as("latest")
        .on(SQLs.TRUE)
    .asQuery()
Generated SQL (PostgreSQL)
SELECT u.id, u.name, u.email, ..., latest.id, latest.balance
FROM pill_user AS u
JOIN LATERAL (SELECT a.id, a.balance
              FROM bank_account AS a
              WHERE a.user_id = u.id
              ORDER BY a.create_time DESC
              LIMIT 1) AS latest ON TRUE
CTE

Reference a named CTE defined earlier in the WITH clause:

// from(String cteName) — reference "active_stocks" CTE
SQLs.query()
    .with("active_stocks").as(w -> w.query()
            .select(Stock_.id, Stock_.name, Stock_.offerPrice)
            .from(Stock_.T, AS, "s")
            .where(Stock_.status.equal(SQLs::param, StockStatus.ACTIVE))
            .asQuery()
    )
    .select(s -> s.space("ats", PERIOD, ASTERISK))
    .from("active_stocks", AS, "ats")
    .where(SQLs.refField("ats", "offer_price")
            .greater(SQLs::param, new BigDecimal("100.00")))
    .asQuery()
Generated SQL (PostgreSQL)
WITH active_stocks AS (
    SELECT s.id, s.name, s.offer_price
    FROM stock AS s
    WHERE s.status = ?
)
SELECT ats.id, ats.name, ats.offer_price, ...
FROM active_stocks AS ats
WHERE ats.offer_price > ?
-- params: ACTIVE, 100.00
Table Function

PostgreSQL supports set-returning functions (SRF) in the FROM clause. UndoneFunction represents a function whose output columns are defined separately via a .parens(…​) clause after .as(alias):

// _FromUndoneFunctionClause — PostgreSQL dialect only
// PostgreStatement.java, via PostgreQuery._FromSpec
interface _FromUndoneFunctionClause<R> {
    R from(UndoneFunction func);
}

// UndoneFunction.java
interface UndoneFunction extends TabularItem, SQLFunction {
}

Example — json_to_recordset():

// jsonToRecordSet() returns UndoneFunction;
// .as("jtr").parens(...) defines output column types
Postgres.query()
    .select("jtr", PERIOD, ASTERISK)
    .from(jsonToRecordSet(SQLs.literal(JsonType.TEXT,
            "[{\"a\":1,\"b\":\"foo\"}, {\"a\":2,\"b\":\"bar\"}]")))
    .as("jtr").parens(c -> c.space("a", IntegerType.INSTANCE)
            .comma("b", TextType.INSTANCE)
    )
    .asQuery()
Generated SQL (PostgreSQL)
SELECT jtr.*
FROM json_to_recordset('[{"a":1,"b":"foo"},{"a":2,"b":"bar"}]'::json) AS jtr("a"::INTEGER, "b"::TEXT)

Other table functions include generate_series(), jsonb_each(), jsonb_populate_recordset(), jsonb_path_query(), etc. All are available as UndoneFunction-returning static methods on dialect entry classes (e.g. Postgres.generateSeries(…​), Postgres.jsonbEach(…​)).

Nested

When you need a comma-separated list of tables/joins in parentheses — i.e. FROM (t1, t2 JOIN t3 ON …​) — use the from(Function) lambda form. Inside the lambda, leftParen(…​) starts the parenthesized group and rightParen() closes it:

// _FromNestedClause.from(Function) wraps multiple items in (...)
SQLs.query()
    .select("up", PERIOD, ASTERISK, "u", PERIOD, ASTERISK)
    .from(s -> s.leftParen(BankPerson_.T, AS, "up")
            .join(BankUser_.T, AS, "u")
                .on(BankPerson_.id::equal, BankUser_.id)
            .rightParen()
    )
    .asQuery()
Generated SQL (PostgreSQL)
SELECT up.id, up.name, ..., u.id, u.name, ...
FROM (bank_person AS up
JOIN bank_user AS u ON up.id = u.id)

You can also nest further levels via nested leftParen(…​) calls, creating deeply parenthesized join trees.

5.6.3. JOIN

The join() method adds an INNER JOIN source. After specifying the join target, .on() defines the join condition.

// Statement._JoinClause (Statement.java L429-L433)
interface _JoinClause<JT, JS> {
    JT join(TableMeta<?> table, SQLs.WordAs as, String alias);
    JS join(DerivedTable derivedTable);
    <T extends DerivedTable> JS join(Supplier<T> supplier);
}

Join a table:

// INNER JOIN with ON clause
SQLs.query()
    .select("u", PERIOD, PillUser_.T, "p", PERIOD, PillPerson_.T)
    .from(PillPerson_.T, AS, "p")
    .join(PillUser_.T, AS, "u")
        .on(PillUser_.id::equal, PillPerson_.id)
    .asQuery()
Generated SQL (PostgreSQL)
SELECT u.id, u.name, u.email, ..., p.id, p.name, p.age, ...
FROM pill_person AS p
JOIN pill_user AS u ON u.id = p.id

Join a derived table (subquery) with local variables (Supplier):

// JOIN a subquery as a derived table
// NOTE: Use Supplier to access local variables in lambda; it's NOT for "lazy" construction
// Keep lambdas simple to avoid losing readability
SQLs.query()
    .select("bu", PERIOD, PillUser_.T)
    .from(PillUser_.T, AS, "bu")
    .join(() -> {
        // 可以在这里声明和使用局部变量
        int someValue = 100;
        return SQLs.subQuery()
            .select(ChinaProvince_.id)
            .from(ChinaProvince_.T, AS, "p")
            .asQuery();
    }).as("ps")
        .on(PillUser_.id::equal, SQLs.refField("ps", ChinaProvince_.ID))
    .asQuery()
Generated SQL (PostgreSQL)
SELECT bu.id, bu.name, bu.email, ...
FROM pill_user AS bu
JOIN (SELECT p.id
      FROM china_province AS p) AS ps
  ON bu.id = ps.id

5.6.4. LEFT JOIN

leftJoin() produces a LEFT OUTER JOIN. The API signature mirrors join().

// Statement._JoinClause (Statement.java L423-L427)
interface _JoinClause<JT, JS> {
    JT leftJoin(TableMeta<?> table, SQLs.WordAs as, String alias);
    JS leftJoin(DerivedTable derivedTable);
    <T extends DerivedTable> JS leftJoin(Supplier<T> supplier);
}
// LEFT JOIN — preserve all rows from left table
SQLs.query()
    .select("u", PERIOD, PillUser_.T,
           "a", PERIOD, BankAccount_.T)
    .from(PillUser_.T, AS, "u")
    .leftJoin(BankAccount_.T, AS, "a")
        .on(PillUser_.id::equal, BankAccount_.userId)
    .asQuery()
Generated SQL (PostgreSQL)
SELECT u.id, u.name, u.email, ..., a.id, a.balance, a.user_id, ...
FROM pill_user AS u
LEFT JOIN bank_account AS a ON u.id = a.user_id

5.6.5. RIGHT JOIN

rightJoin() produces a RIGHT OUTER JOIN.

// Statement._JoinClause (Statement.java L435-L440)
JT rightJoin(TableMeta<?> table, SQLs.WordAs as, String alias);
JS rightJoin(DerivedTable derivedTable);
<T extends DerivedTable> JS rightJoin(Supplier<T> supplier);
// RIGHT JOIN — preserve all rows from right table
SQLs.query()
    .select("u", PERIOD, PillUser_.T,
           "a", PERIOD, BankAccount_.T)
    .from(PillUser_.T, AS, "u")
    .rightJoin(BankAccount_.T, AS, "a")
        .on(PillUser_.id::equal, BankAccount_.userId)
    .asQuery()
Generated SQL (PostgreSQL)
SELECT u.id, u.name, u.email, ..., a.id, a.balance, a.user_id, ...
FROM pill_user AS u
RIGHT JOIN bank_account AS a ON u.id = a.user_id

5.6.6. FULL JOIN

fullJoin() produces a FULL OUTER JOIN.

// Statement._JoinClause (Statement.java L442-L446)
JT fullJoin(TableMeta<?> table, SQLs.WordAs as, String alias);
JS fullJoin(DerivedTable derivedTable);
<T extends DerivedTable> JS fullJoin(Supplier<T> supplier);
// FULL JOIN — preserve all rows from both sides
SQLs.query()
    .select("u", PERIOD, PillUser_.T,
           "a", PERIOD, BankAccount_.T)
    .from(PillUser_.T, AS, "u")
    .fullJoin(BankAccount_.T, AS, "a")
        .on(PillUser_.id::equal, BankAccount_.userId)
    .asQuery()
Generated SQL (PostgreSQL)
SELECT u.id, u.name, u.email, ..., a.id, a.balance, a.user_id, ...
FROM pill_user AS u
FULL JOIN bank_account AS a ON u.id = a.user_id

5.6.7. WHERE

The where() method adds a filter predicate. After where(), and() appends additional conditions with AND.

// Statement._WhereClause (Statement.java L792-L816)
interface _WhereClause<WR, WA> {
    WA where(IPredicate predicate);                         // single predicate → and chain
    <T> WA where(Function<T, IPredicate> expOp, T value);  // method reference style
    WR where(Consumer<Consumer<IPredicate>> consumer);     // dynamic multi-predicate
}

// Statement._WhereAndClause (Statement.java L851-L875)
interface _WhereAndClause<WA> {
    WA and(IPredicate predicate);
    <T> WA and(Function<T, IPredicate> expOp, T value);
}
// WHERE with method-reference style and chained AND
SQLs.query()
    .select("u", PERIOD, PillUser_.T, "p", PERIOD, PillPerson_.T)
    .from(PillPerson_.T, AS, "p")
    .join(PillUser_.T, AS, "u")
        .on(PillUser_.id::equal, PillPerson_.id)
    .where(PillPerson_.id.equal(SQLs::literal, 1))
    .and(PillUser_.nickName.equal(SQLs::param, "脉兽秀秀"))
    .and(PillUser_.createTime.notBetween(
        SQLs::literal, LocalDateTime.now().minusDays(1),
        AND, LocalDateTime.now()))
    .asQuery()
Generated SQL (PostgreSQL)
SELECT u.id, u.name, u.email, ..., p.id, p.name, p.age, ...
FROM pill_person AS p
JOIN pill_user AS u ON u.id = p.id
WHERE p.id = 1
  AND u.nick_name = ?
  AND u.create_time NOT BETWEEN '2026-06-05T...'::timestamp AND ?
-- params: '脉兽秀秀', '2026-06-06T...'

5.6.8. GROUP BY

The groupBy() method adds a GROUP BY clause. It accepts GroupByItem objects (which are also Expression instances, as Expression extends GroupByItem).

// Query._StaticGroupByClause (Query.java L285-L296)
interface _StaticGroupByClause<R> {
    R groupBy(GroupByItem item);
    R groupBy(GroupByItem item1, GroupByItem item2);
    R groupBy(GroupByItem item1, GroupByItem item2, GroupByItem item3);
    R groupBy(GroupByItem item1, ..., GroupByItem item4);
}
// GROUP BY a single column
SQLs.query()
    .select(ChinaRegion_.id, ChinaRegion_.name)
    .from(ChinaRegion_.T, AS, "c")
    .groupBy(ChinaRegion_.regionType)
    .asQuery()
Generated SQL (PostgreSQL)
SELECT c.id, c.name
FROM china_region AS c
GROUP BY c.region_type

5.6.9. HAVING

The having() method adds a HAVING clause — a filter applied after aggregation. Like where(), it accepts IPredicate and can chain with and().

// StandardQuery._HavingSpec
interface _HavingSpec<I> extends _StaticHavingClause<_HavingAndSpec<I>>,
        _DynamicHavingClause<_WindowSpec<I>>, _WindowSpec<I> { ... }
// GROUP BY + HAVING with aggregate function
SQLs.query()
    .select(ChinaRegion_.id, ChinaRegion_.name)
    .from(ChinaRegion_.T, AS, "c")
    .groupBy(ChinaRegion_.regionType)
    .having(SQLs.min(ChinaRegion_.regionGdp)
            .greater(SQLs.literalValue(new BigDecimal("88888.66"))))
    .spaceAnd(ChinaRegion_.createTime.between(
        SQLs::literal, now.minusDays(49), AND, now))
    .asQuery()
Generated SQL (PostgreSQL)
SELECT c.id, c.name
FROM china_region AS c
GROUP BY c.region_type
HAVING MIN(c.region_gdp) > 88888.66
   AND c.create_time BETWEEN '2026-04-17T...' AND '2026-06-05T...'

5.6.10. WINDOW

The window() method defines a named window for use by window functions in SELECT or ORDER BY clauses.

// StandardQuery._WindowSpec (StandardQuery.java L163-L168)
interface _WindowSpec<I> extends _OrderBySpec<I>,
        Window._DynamicWindowClause<Window._StandardPartitionBySpec, _OrderBySpec<I>> {

    Window._WindowAsClause<..., _WindowCommaSpec<I>> window(String windowName);
}
// Named window used by SUM() in SELECT
SQLs.query()
    .select(Windows.sum(ChinaRegion_.regionGdp).over("w").as("gdpSum"))
    .from(ChinaRegion_.T, AS, "c")
    .window("w").as(s -> s
        .partitionBy(ChinaRegion_.name)
        .orderBy(ChinaRegion_.regionGdp::desc)
        .rows(UNBOUNDED_PRECEDING)
    )
    .asQuery()
Generated SQL (PostgreSQL)
SELECT SUM(c.region_gdp) OVER w AS "gdpSum"
FROM china_region AS c
WINDOW w AS (PARTITION BY c.name
             ORDER BY c.region_gdp DESC
             ROWS UNBOUNDED PRECEDING)

5.6.11. ORDER BY

The orderBy() method adds an ORDER BY clause. It accepts SortItem objects: fields (default ASC), method references like Field::desc, or SortOrder pairs.

// Statement._StaticOrderByClause (Statement.java L897-L908)
interface _StaticOrderByClause<R> {
    R orderBy(SortItem sortItem);
    R orderBy(SortItem sortItem1, SortItem sortItem2);
    R orderBy(SortItem sortItem1, SortItem sortItem2, SortItem sortItem3);
    R orderBy(SortItem sortItem1, ..., SortItem sortItem4);
}
// ORDER BY with mixed sort directions
SQLs.query()
    .select("u", PERIOD, PillUser_.T)
    .from(PillUser_.T, AS, "u")
    .orderBy(PillUser_.id::desc)
    .asQuery()
Generated SQL (PostgreSQL)
SELECT u.id, u.name, u.email, ...
FROM pill_user AS u
ORDER BY u.id DESC

Multiple sort items can be combined:

// Multiple ORDER BY items
SQLs.query()
    .select("c", PERIOD, ChinaRegion_.T)
    .from(ChinaRegion_.T, AS, "c")
    .orderBy(ChinaRegion_.createTime::desc,
             ChinaRegion_.version::asc)
    .asQuery()
Generated SQL (PostgreSQL)
SELECT c.id, c.name, c.create_time, c.version, ...
FROM china_region AS c
ORDER BY c.create_time DESC, c.version ASC

5.6.12. LIMIT

The limit() method restricts the number of returned rows. An offset can be specified inline.

// Statement._LimitClause (Statement.java L1184-L1203)
interface _LimitClause<LR> extends _RowCountLimitClause<LR> {
    LR limit(Expression offset, Expression rowCount);
    LR limit(BiFunction<MappingType, Number, Expression> op,
             long offset, long rowCount);           // literal/param inline
}
// LIMIT with offset using method reference (literal value)
SQLs.query()
    .select("u", PERIOD, PillUser_.T)
    .from(PillUser_.T, AS, "u")
    .orderBy(PillUser_.id::desc)
    .limit(SQLs::literal, 0, 10)   // offset=0, rowCount=10
    .asQuery()
Generated SQL (PostgreSQL)
SELECT u.id, u.name, u.email, ...
FROM pill_user AS u
ORDER BY u.id DESC
LIMIT 10 OFFSET 0

limit() also supports dynamic value lookup via Supplier:

// LIMIT with Supplier for dynamic offset/rowCount
Map<String, Object> criteria = new HashMap<>();
criteria.put("offset", 0L);
criteria.put("rowCount", 100L);

SQLs.query()
    .select("u", PERIOD, PillUser_.T)
    .from(PillUser_.T, AS, "u")
    .limit(SQLs::literal, criteria::get, "offset", "rowCount")
    .asQuery()
Generated SQL (PostgreSQL)
SELECT u.id, u.name, u.email, ...
FROM pill_user AS u
LIMIT 100 OFFSET 0

5.6.13. UNION

The union() / unionAll() / unionDistinct() methods combine result sets from multiple queries. They return a SelectSpec so the chain can continue with another select()/from() or finish with asQuery().

// StandardQuery._UnionClause (StandardQuery.java L32-L52)
interface _UnionClause<I> extends _StaticUnionClause<SelectSpec<I>> {
    // union(), unionAll(), unionDistinct()
    // intersect(), intersectAll(), intersectDistinct()
    // except(), exceptAll(), exceptDistinct(), minus(), minusAll()
}
// UNION — combine two result sets, removing duplicates
SQLs.query()
    .select("c", PERIOD, ChinaRegion_.T)
    .from(ChinaRegion_.T, AS, "c")
    .where(ChinaRegion_.id.in(SQLs::rowParam, firstIdList))
    .union()
    .parens(s -> s.select("c2", PERIOD, ChinaRegion_.T)
            .from(ChinaRegion_.T, AS, "c2")
            .where(ChinaRegion_.id.in(SQLs::rowParam, secondIdList))
            .asQuery()
    )
    .orderBy(SQLs.refSelection(ChinaRegion_.ID))
    .asQuery()
Generated SQL (PostgreSQL)
SELECT c.id, c.name, c.create_time, c.version, ...
FROM china_region AS c
WHERE c.id IN (?, ?, ?)
UNION
(SELECT c2.id, c2.name, c2.create_time, c2.version, ...
 FROM china_region AS c2
 WHERE c2.id IN (?, ?, ?))
ORDER BY id
-- params: 1, 2, 3, 4, 5, 6

unionAll() preserves duplicates and is typically more performant:

// UNION ALL — combine without deduplication
SQLs.query()
    .parens(s -> s.select(PillUser_.id)
            .from(PillUser_.T, AS, "p")
            .where(PillUser_.id.equal(SQLs::literal, 1))
            .asQuery()
    )
    .unionAll()
    .parens(s -> s.select(PillUser_.id)
            .from(PillUser_.T, AS, "p")
            .where(PillUser_.id.equal(SQLs::literal, 2))
            .asQuery()
    )
    .orderBy(PillUser_.id::desc)
    .limit(SQLs::literal, 0, 10)
    .asQuery()
Generated SQL (PostgreSQL)
(SELECT p.id
 FROM pill_user AS p
 WHERE p.id = 1)
UNION ALL
(SELECT p.id
 FROM pill_user AS p
 WHERE p.id = 2)
ORDER BY id DESC
LIMIT 10 OFFSET 0

5.7. MySQL API

5.7.1. The MySQLs Utility Class

MySQL-specific SQL construction entry point: MySQLs.singleInsert(), MySQLs.query(), etc.

5.7.2. MySQL-Specific Features

  • INSERT IGNORE

  • INSERT ON DUPLICATE KEY UPDATE

  • REPLACE INTO

  • LOAD DATA INFILE

  • MySQL-specific functions

final Insert stmt;
stmt = MySQLs.singleInsert()
        .insertInto(Stock_.T)
        .values(stockList)
        .onDuplicateKeyUpdate()
        .set(Stock_.offerPrice, MySQLs::values, Stock_.offerPrice)
        .asInsert();
Generated SQL (MySQL)
INSERT INTO stock (id, create_time, update_time, version, exchange,
                   code, name, status, offer_price, listing_date)
VALUES (?, ?, ?, ?, ?,
        ?, ?, ?, ?, ?),
       ...
ON DUPLICATE KEY UPDATE
    offer_price = VALUES(offer_price),
    update_time = VALUES(update_time),
    version = version + 1

5.8. PostgreSQL API

5.8.1. The Postgres Utility Class

PostgreSQL-specific SQL construction entry point: Postgres.singleInsert(), Postgres.query(), etc.

5.8.2. PostgreSQL-Specific Features

  • RETURNING clause — Return field values directly after INSERT/UPDATE

  • ON CONFLICT clause — Update or ignore on conflict

  • WITH clause (CTE) — Complex chained operations

  • Array operations — ANY, ALL, array functions

  • JSON/JSONB operations — Field extraction, containment queries

  • Composite types — Custom composite type reads and writes

final Insert stmt;
stmt = Postgres.singleInsert()
        .insertInto(Stock_.T)
        .values(stockList)
        .onConflict(Stock_.name)
        .doUpdate()
        .set(Stock_.offerPrice, Postgres::excluded, Stock_.offerPrice)
        .returning(Stock_.id, Stock_.name)
        .asInsert();
Generated SQL (PostgreSQL)
INSERT INTO stock (id, create_time, update_time, version, exchange,
                   code, name, status, offer_price, listing_date)
VALUES (?, ?, ?, ?, ?,
        ?, ?, ?, ?, ?),
       ...
ON CONFLICT (name)
DO UPDATE SET
    offer_price = EXCLUDED.offer_price,
    update_time = ?,
    version = version + 1
RETURNING id, name

6. Session API — Executing SQL

The Session API is Army’s data operation execution layer, providing a unified entry point for blocking operations.

6.1. SessionFactory Configuration

6.1.1. Configuration Options

  • name — SessionFactory name

  • packagesToScan — List of packages to scan for domain classes

  • datasource — Data source (blocking mode)

  • environment — Environment configuration (database type, dialect version, etc.)

  • fieldGeneratorFactory — Field generator factory

  • schemaMeta — Schema metadata

6.2. Session Operations

6.2.1. Obtaining a Session

try (SyncLocalSession session = sessionFactory.localSession()) {
    // Automatic transaction and resource management
}
Regular Session
SyncSession session = sessionFactory.session();
try {
    // Use the session
} finally {
    session.close();
}

6.2.2. Query Operations

// Query a list of objects
List<Stock> stocks = session.query(stmt, Stock.class);

// Query a single object
Stock stock = session.queryOne(stmt, Stock.class);

// Query with custom mapping
session.queryObject(stmt, RowMaps::hashMap)
        .forEach(map -> LOG.debug("{}", map));

6.2.3. Update Operations

// Insert
long rows = session.update(insertStmt);

// Update
long rows = session.update(updateStmt);

// Delete
long rows = session.update(deleteStmt);

6.2.4. Batch Operations

long[] rows = session.batchUpdate(batchUpdateStmt);

6.3. Transaction Management

6.3.1. Blocking-Mode Transactions

try (SyncLocalSession session = sessionFactory.localSession()) {
    session.startTransaction();
    try {
        session.update(stmt);
        session.commit();
    } catch (Exception e) {
        session.rollback();
        throw e;
    }
}

6.3.2. Savepoints

session.startTransaction();
try {
    session.update(stmt1);
    session.setSavepoint("sp1");
    session.update(stmt2);
    session.commit();
} catch (Exception e) {
    session.rollbackToSavepoint("sp1");
    session.update(stmt3);
    session.commit();
}

6.3.3. Transaction Isolation Levels

  • READ_UNCOMMITTED / READ_COMMITTED / REPEATABLE_READ / SERIALIZABLE

7. Core Architecture

Understanding Army’s internal architecture helps with advanced customization and troubleshooting.

7.1. Module Breakdown

The Army framework adopts a modular design with the following core modules:

7.1.1. army-core (Core Module)

Standard Criteria API implementation, SQL parsing and generation, metadata management, session management, transaction management, type mapping, and executor support.

7.1.2. army-annotation (Annotation Module)

Provides all domain object mapping annotations (@Table, @Column, @Generator, etc.) and the ArmyMetaModelDomainProcessor annotation processor for compile-time metamodel class generation.

7.1.3. army-mysql (MySQL Dialect Module)

MySQL-specific Criteria API extensions, dialect parser, and DDL parser.

7.1.4. army-postgre (PostgreSQL Dialect Module)

PostgreSQL-specific Criteria API extensions, dialect parser, and composite/array type support.

7.1.5. army-oracle (Oracle Dialect Module)

(To be completed)

7.1.6. army-sqlite (SQLite Dialect Module)

SQLite dialect support.

7.1.7. army-jdbc (JDBC Executor Module)

JDBC-based executor implementations (MySQLExecutor, PostgreExecutor, SQLiteExecutor).

7.1.8. army-spring (Spring Integration Module)

SessionFactory bean support and Spring transaction manager integration.

7.1.9. army-sync (Blocking Mode Module)

Blocking synchronous API implementation.

7.1.10. army-struct (Foundation Structures Module)

Foundation data structures (such as CodeEnum) and language utilities.

7.1.11. army-spring-ai-model-chat-memory / army-spring-ai-vector-store

Army implementations of Spring AI chat memory and vector store — bringing database capabilities into the AI ecosystem.

7.2. Architectural Layers

Presentation Layer (Criteria API)
  ↓ Generates Statement tree
Parsing Layer (Dialect Parser)
  ↓ Dialect-specific SQL + parameter binding
Metadata Layer (Meta)
  ↓ Table / field / index metadata queries
Execution Layer (Executor)
  ↓ JDBC driver
Session Layer (Session)
  ↓ Transaction context, lifecycle management
Type Mapping Layer (Mapping)
  ↓ Java ↔ SQL bidirectional type conversion

7.2.1. Presentation Layer (Criteria API)

SQLs (standard), MySQLs (MySQL dialect), Postgres (PostgreSQL dialect) — builds standardized Statement trees using a type-safe DSL.

7.2.2. Parsing Layer (Dialect Parser)

Dialect parsers (ArmyParser / MySQLParser / PostgreParser) convert Statement trees into native SQL for each database and bind parameters.

7.2.3. Metadata Layer (Meta)

  • TableMeta — Table metadata (table name, schema, field chain, primary key fields, discriminator fields)

  • FieldMeta — Field metadata (Java type, mapping type, generator metadata)

  • PrimaryFieldMeta — Primary key field metadata

  • IndexMeta — Index metadata (index name, field list, uniqueness)

  • ServerMeta — Server metadata (database type, version, dialect information)

  • SchemaMeta — Schema metadata registry

7.2.4. Execution Layer (Executor)

The executor interface provides JDBC-based result set processing and batch operations.

7.2.5. Session Layer (Session)

The Session / SessionFactory interfaces manage transactions, session context, and lifecycle.

7.2.6. Type Mapping Layer (Mapping)

The type mapping handler is responsible for bidirectional Java ↔ SQL type conversion, supporting both built-in types and custom extensions.

8. Type Mapping System

8.1. Built-in Type Mappings

8.1.1. Numeric Types

  • Byte / byte · Short / short · Integer / int · Long / long

  • Float / float · Double / double

  • BigInteger · BigDecimal

8.1.2. String Types

  • String · Character / char

8.1.3. Date-Time Types

  • LocalDate · LocalTime · LocalDateTime

  • OffsetDateTime · ZonedDateTime · Instant

  • Year · YearMonth · MonthDay

8.1.4. Other Types

  • Boolean / boolean · UUID · BitSet · ZoneId

8.2. Array Types

  • PostgreSQL native array type support: String[], Integer[], Long[], etc.

8.3. JSON Types

  • ArmyJsonType — JSON type base class, supporting generic mapping (List / Set / Map)

8.4. Custom Type Mappings

8.4.1. Implementing the MappingType Interface

public class MyCustomType implements MappingType {
    @Override
    public Class<?> javaType() { return MyCustomClass.class; }

    @Override
    public Object beforeBind(DataType dataType, MappingEnv env, Object source) {
        // Convert before writing to the database
    }

    @Override
    public Object afterGet(DataType dataType, MappingEnv env, Object source) {
        // Convert after reading from the database
    }
}

8.4.2. Registering Custom Types

Register custom type mappings through MappingHandler.

8.5. JSON / XML Codecs

  • DefaultJacksonCodec — Jackson-based JSON codec

  • DefaultGsonCodec — Gson-based JSON codec

  • XML codec — To be implemented

9. Field Generators

9.1. Snowflake Generator

A distributed ID generator based on the Snowflake algorithm.

9.1.1. Configuration Parameters

  • startTime — Start timestamp

  • workerId — Worker node ID

  • datacenterId — Data center ID

@Generator(type = GeneratorType.PRECEDE,
           value = "io.army.generator.snowflake.Snowflake8Generator",
           params = {
               @Param(name = "startTime", value = "1580224449498")
           })
@Column
private Long id;

9.2. UUID Generator

A UUID-based ID generator.

9.3. Custom Generators

9.3.1. Implementing the FieldGenerator Interface

public class MyCustomGenerator implements FieldGenerator {
    @Override
    public Object next(FieldMeta<?> field, ReadWrapper domain) {
        // Generation logic
    }
}

9.3.2. Registering a Generator Factory

public class MyFieldGeneratorFactory implements FieldGeneratorFactory {
    @Override
    public FieldGenerator get(FieldMeta<?> field) {
        GeneratorMeta meta = field.generator();
        if (meta.javaType() == MyCustomGenerator.class) {
            return MyCustomGenerator.create(field);
        }
        throw new IllegalArgumentException("Unsupported generator");
    }
}

10. Spring Integration

10.1. SessionFactory Bean Configuration

10.1.1. Blocking Mode

@Bean
public ArmySyncSessionFactoryBean sessionFactory(DataSource dataSource) {
    ArmySyncSessionFactoryBean bean = new ArmySyncSessionFactoryBean();
    bean.setDataSource(dataSource);
    bean.setPackagesToScan(Arrays.asList("io.army.example.domain"));
    bean.setFactoryName("army-session-factory");
    return bean;
}

10.2. Spring Transaction Management

10.2.1. Blocking Transaction Manager

@Bean
public PlatformTransactionManager transactionManager(ArmySyncSessionFactoryBean sessionFactory) {
    return new ArmySyncLocalTransactionManager(sessionFactory);
}

10.3. Declarative Transactions

@Transactional
public void createUser(User user) {
    session.update(insertStmt);
}

10.4. DataSource Integration

Supports HikariCP, Druid, and all standard DataSource implementations.

11. Database Dialect Support Matrix

11.1. MySQL

  • Supported versions: 5.7, 8.0

  • Blocking: Supported

  • Special features: INSERT ON DUPLICATE KEY UPDATE, REPLACE INTO, LOAD DATA INFILE, MySQL-specific functions, JSON type

11.2. PostgreSQL

  • Supported versions: 12+

  • Blocking: Supported

  • Special features: RETURNING, ON CONFLICT, CTE, array types, JSON/JSONB, composite types, window functions, HNSW vector indexes

11.3. SQLite

  • Blocking: Supported

11.4. Oracle

  • To be completed

12. Advanced Features

12.1. Reserved Field Mechanism

Army reserves the following field names with framework-managed behavior:

Field Type Behavior

id

Numeric

Primary key; automatic NOT NULL; non-updatable

createTime

Temporal

Auto-populated on creation; non-updatable

updateTime

Temporal

Auto-populated on update

version

Integer

Optimistic lock version; auto-incremented on update

visible

Boolean

Soft-delete flag; auto-filtered

12.2. Soft Deletes

Use the visible field to implement logical deletion. Rows marked as invisible are automatically filtered out in queries.

@Column
private Boolean visible;

Visibility mode configuration: SUPPORT (enable filtering), NEVER (no filtering), WHITE_LIST (whitelist mode).

12.3. Optimistic Locking

Use the version field for optimistic concurrency control: UPDATE statements automatically validate the version in the WHERE clause; the update fails if the version does not match.

@Column
private Integer version;

12.4. Inheritance Mapping in Depth

Army uses the single-table inheritance strategy: the parent and all child classes share one database table, with the discriminator column (@Inheritance) distinguishing which subtype each row belongs to.

Child classes use @DiscriminatorValue to specify their own enum value. The framework automatically filters by the discriminator value in queries.

12.5. Parameter Binding Modes

Mode Syntax SQL Output

param

SQLs::param

? parameter placeholder (prepared statement)

literal

SQLs::literal

Inline literal value

namedParam

SQLs::namedParam

Named parameter binding

12.6. SQL Logging

envMap.put(ArmyKey.SQL_LOG_MODE.name, SqlLogMode.INFO);
envMap.put(ArmyKey.SQL_LOG_DYNAMIC.name, true);
envMap.put(ArmyKey.SQL_LOG_EXECUTION_TAKE_TIME.name, true);
  • SQL_LOG_MODEOFF / DEBUG / INFO

  • SQL_LOG_DYNAMIC — Dynamic logging toggle

  • SQL_LOG_EXECUTION_TAKE_TIME — Execution-time logging

12.7. Batch Operation Optimization

  • Batch insert / batch update / batch delete

  • Multi-statement batching

12.8. Result Set Mapping (RowMaps)

session.queryObject(stmt, RowMaps::hashMap)
        .forEach(map -> {
            Long id = map.getLong("id");
            String name = map.getString("name");
        });

13. Schema Comparison & Synchronization

13.1. SchemaComparer

The Schema Comparer compares the actual database schema against the Army metadata model to detect differences.

13.2. Comparison Process

  1. Retrieve actual schema information from the database

  2. Compare against the Army metadata model

  3. Generate a diff report

  4. Generate idempotent DDL statements

13.3. Supported Comparison Items

  • Table structure, columns (type / length / precision / nullability), indexes, constraints

13.4. PostgreSQL Composite Type Synchronization

Automatically detect and synchronize composite type definitions at startup, generating idempotent DDL (safe to execute repeatedly without errors).

14. Best Practices

14.1. Domain Class Design

  1. Use generics to support inheritance mapping

  2. Add comments to all fields

  3. Use reserved fields judiciously (id, createTime, updateTime, version, visible)

  4. Configure generators correctly (PRECEDE vs POST)

  5. Externalize environment-specific configuration through TableMeta.properties

14.2. Criteria API Usage

  1. Prefer the standard Criteria API (SQLs)

  2. Use dialect APIs (MySQLs / Postgres) only when database-specific features are needed

  3. Choose parameter binding (param) vs literal binding (literal) wisely

  4. Be aware of the performance implications of CTEs and subqueries

14.3. Transaction Management

  1. Use declarative transactions (@Transactional)

  2. Set appropriate transaction isolation levels

  3. Be mindful of the performance impact of long-running transactions

  4. Use savepoints for fine-grained control

14.4. Performance Optimization

  1. Use batch operations instead of looping single-row operations

  2. Use indexes wisely (create them during DDL generation via @Index)

  3. Avoid N+1 query problems

  4. Use appropriate parameter binding strategies

14.5. Type Mapping

  1. Prefer built-in type mappings

  2. Use custom type mappings with caution

  3. Configure JSON/XML codecs correctly

  4. Pay attention to timezone handling

15. FAQ

15.1. Metamodel Classes Not Generated by the Annotation Processor

Check: 1. Whether annotationProcessors is configured correctly 2. Whether the domain class uses the @Table annotation 3. Whether the compile classpath includes the army-annotation dependency

15.2. Snowflake Generator Duplicate startTime Warning

Issue: Using the same startTime parameter across multiple domain classes risks ID collisions.

Solution: 1. Use distinct startTime values for different entities 2. Suppress the warning with -Aarmy.snowflakeStartTimeWarning=false

15.3. Transaction Exception (IllegalTransactionStateException)

Solution: 1. Verify that a transaction has been properly started 2. Check the transaction propagation behavior configuration 3. Ensure database operations are executed within a transaction context

15.4. Type Mapping Errors

Solution: 1. Check whether the field type matches the database type 2. Configure the correct MappingType 3. Verify the JSON/XML codec configuration

16. Appendix

16.1. Reserved Fields Reference

  • id — Primary key

  • createTime — Creation timestamp

  • updateTime — Update timestamp

  • version — Optimistic lock version number

  • visible — Soft-delete visibility flag

16.2. Database Support Matrix

Database Version Blocking Special Features

MySQL

5.7, 8.0

INSERT ON DUPLICATE KEY UPDATE, LOAD DATA

PostgreSQL

12+

RETURNING, ON CONFLICT, array types, composite types, HNSW

SQLite

Oracle

Planned

16.3. Version History

16.3.1. 0.6.8-SNAPSHOT (Current)

  • Under active development

16.3.2. 0.6.6

  • Stable release

  • Improved MySQL and PostgreSQL support

16.3.3. 0.6.0

  • Major architecture overhaul

  • Introduced the new Criteria API

16.5. License

Apache License 2.0