© 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
-
Don’t create a new world — just map the real world. Respect database standards and dialect differences.
-
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 byArmyMetaModelDomainProcessor, 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.
-
Executor —
io.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 |
|---|---|---|
|
Required |
SessionFactory name, must be non-empty. |
|
Required |
JDBC |
|
Required |
List of packages where domain classes (annotated with |
|
Required |
Environment configuration passed via
|
|
Optional |
Default: |
|
Optional |
Default |
|
Optional |
Default |
|
Optional |
Custom JSON/XML codec. |
|
Optional |
Custom field generator factory. |
ArmyKey environment entries — only two are truly mandatory, all others have sensible defaults:
| ArmyKey | Required? | Notes |
|---|---|---|
|
Yes |
|
|
Yes |
|
|
No |
Timezone offset. When omitted, the framework uses database defaults. |
|
No |
Default: |
|
No |
Default: |
|
No |
Default: |
All other keys |
No |
Default values are defined in |
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)");
}
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);
}
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 |
|---|---|---|
|
Maps a class to a database table (name, comment, indexes) |
Class |
|
Maps a field to a database column (name, type, nullability, default) |
Field |
|
Specifies how a field’s value is generated (e.g., Snowflake ID) |
Field |
|
When generation happens:
|
Enum |
|
A name-value pair passed to
a |
Annotation member |
|
Binds a field name to
parameter overrides (inside |
Annotation member |
|
Overrides generator parameters of inherited fields |
Class |
|
Defines a database index (name, fields, uniqueness, type) |
Class (inside
|
|
Per-column index configuration (collation, opclass, sort) |
Class (inside
|
|
Index column sort:
|
Enum |
|
Index column NULL ordering:
|
Enum |
|
Custom Java↔SQL type mapping |
Field |
|
Marks the root of a single-table inheritance hierarchy |
Class |
|
Discriminator enum value for a child class in inheritance |
Class |
|
Marks a base class whose fields are inherited by subclasses |
Class |
|
Controls DDL generation:
|
Enum |
|
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 |
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@Indexdefinitions. If no index covers theidfield, a unique primary key index is created automatically. -
immutable(optional, defaultfalse) — Whentrue, allUPDATEDML on this table is disallowed. Parent tables with@Inheritancecannot be marked as immutable — Army detects this at startup and throwsMetaException. -
allColumnNotNull(optional, defaultfalse) — Whentrue, every column getsNOT NULLin DDL. Note: reserved fields (id,createTime,updateTime,version,visible), discriminator fields, and primitive-typed fields are alwaysNOT NULLregardless of this setting. -
ddlMode(optional, defaultCREATE) — Controls DDL generation. See@DdlMode. -
tableOptions(optional) — Raw SQL fragment appended toCREATE 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 |
|---|---|---|
|
|
|
|
Auto-generated for reserved fields |
|
|
By Java type |
|
|
By Java type |
|
|
By Java type |
Numeric
types→ |
|
|
|
|
Empty — no collation clause |
Set explicitly only when
needed, e.g., |
|
For business fields (non-reserved), if you don’t provide a |
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 |
|---|---|---|---|
|
Always |
Value from |
Never updatable |
|
Always |
Auto-set to current time |
Never updatable |
|
Always |
Auto-set to current time |
Always updatable, auto-refreshed on every UPDATE |
|
Always |
Auto-set to
|
Always updatable, auto-incremented (optimistic lock) |
|
Always |
Auto-set to
|
Always updatable (soft delete toggle) |
|
Reserved fields never need |
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 throwMetaException. Supports${DEFAULT}and${RUNTIME}. -
notNull(optional, defaultfalse) — DeclaresNOT NULL. Forcedtruewhen: the field is reserved, a discriminator, primitive-typed, orTable.allColumnNotNullistrue. -
insertable(optional, defaulttrue) — Whether included inINSERT. Overridden for reserved fields:createTime/updateTime/version/visibleare always insertable; fields withPRECEDEgenerators are always insertable;idwithPOSTgenerator is insertable only on child tables. -
updatable(optional, defaulttrue) — Whether included inUPDATE. Overridden:id/createTime/discriminator are never updatable;updateTime/version/visibleare always updatable; fields onimmutabletables are never updatable. -
precision(optional, default-1) — Total digit count or length. Defaults by type (see table above). UseColumn.DEFAULT_EXP(-8150) to resolve fromMETA-INF/army/TableMeta.properties. -
scale(optional, default-1) — Fractional digit count. Defaults by type (see table above). UseColumn.DEFAULT_EXPto 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 |
|---|---|
|
Application generates
the value before INSERT. Use for Snowflake IDs, UUIDs, or custom ID
generation. The value from your generator is written into the |
|
Database auto-increment
column. No code runs — the DB assigns the value during INSERT, and Army reads it back
afterward. Only supported on the |
|
Defer the decision to
|
|
Let the framework
decide. For child tables in an |
|
Discriminator fields (the field named in |
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, defaultPRECEDE) — TheGeneratorTypeenum value. -
value(optional) — Fully-qualified class name of theFieldGeneratorimplementation. Required forPRECEDE. Ignored forPOST(sincePOSTuses the database, not application code). -
params(optional) — Array of@Paramname-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
SETor PostgreSQLENUM -
Mapping collection types (List, Set) to JSON/JSONB
-
Overriding the framework’s type choice (e.g., forcing
CHARfor a hash)
3.6.1. Attributes
-
value(optional) — Fully-qualified class name of theMappingTypeimplementation. -
type(optional) —Classreference of theMappingType. Takes precedence overvalue. -
params(optional) — Extra parameters for the mapping type, e.g., the enum type name for PostgreSQLENUM. -
func(optional) — Java method reference for constructing/creating instances of the mapped type. Format:"ClassName::new"(constructor) or"ClassName::methodName"(static factory). Consumed byPgSingleRangeType.fromMethod,PgMultiRangeType.fromMethod, and their array variants viaPgRangeType.createRangeFunction. Resolved by_ArmyPgRangeType.loadConstructor/loadFactoryMethod. -
charset(optional) — Character set name. Required whenvalueis aTextMappingTyperepresenting binary data (e.g.,InputStream,Path). -
elements(optional) — Element type(s) for collection mappings. Required forElementMappingTypeimplementations like MySQLSET. 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 overfieldListwhen both are specified. -
unique(optional, defaultfalse) — Whether the index enforces uniqueness. The primary key index is always unique. -
type(optional) — Index access method. SupportsBTREE,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 |
|---|---|
|
|
|
Same as above, but
consults |
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 |
|---|---|
|
Java field name of the indexed column |
|
Column collation, e.g.,
|
|
Operator class for
PostgreSQL, e.g., |
|
Sort order: |
|
NULL ordering: |
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
-
A
@MappedSuperclassnever maps to a table — only@Tablesubclasses do. -
All
@Columnannotations from every@MappedSuperclassin the chain are inherited by the concrete subclass. -
Fields without
@Columnare not persisted. -
You can nest
@MappedSuperclassto build reusable inheritance chains. -
For small projects, skip
@MappedSuperclassand 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 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 |
|---|---|
|
Always generate |
|
Never generate DDL. Use for tables managed externally (e.g., by Flyway or Liquibase) |
|
Check |
// 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 |
|---|---|---|
|
|
Check |
|
|
Must be
in |
|
|
Check |
|
|
Skip |
|
|
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
|
|
Convert class simple
name to snake_case: `_MetaBridge.camelToLowerCase()`
→ |
|
Throw |
|
Table
comment
|
|
Convert class simple
name to spaced comment: `_MetaBridge.camelToComment()`
→ |
|
Throw |
|
Catalog
/ Schema
|
|
Empty string → database default |
|
Throw |
|
Column
name
|
|
Convert field name
camelCase→snake_case: `_MetaBridge.camelToLowerCase()`
→ |
|
Throw |
|
Column
comment
|
|
Reserved
fields (id/createTime/…) → auto-comment; Enum fields → |
|
Throw |
|
(empty string) |
(no |
|
Same as
|
Column
precision
|
|
By Java type: |
|
|
Throw |
Column
scale
|
|
By type: |
|
|
Throw |
Column
default
|
|
Type-appropriate zero:
number→ |
|
|
Throw |
Generator
type
|
|
Throw |
|
|
|
Child tables in |
|
|
(literal enum) |
Always used as-is — no placeholder involved |
Index
name
|
|
Always auto-generated
(ignores properties):
Unique → |
|
|
|
Falls back to |
|
|
|
|
Index
type
|
|
Empty string → database default index type |
|
|
|
|
Throw |
|
|
|
IndexField
collation/opclass
|
|
Empty string → no collation/opclass |
|
|
|
Throw |
DDL
mode
|
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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Property keys are case-sensitive.
Keyword components — The |
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:
-
Which Java type it handles — returned by
javaType(). -
Which SQL type it maps to — resolved by
map(ServerMeta), which is database-dialect-aware. -
How values are serialized before binding to a prepared statement —
beforeBind(). -
How values are deserialized after fetching from a
ResultSet—afterGet().
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) |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|---|---|---|---|
|
(plain |
|
Simple enums where
|
|
|
|
Enum values need a
display text different from the Java constant name (e.g., |
|
|
|
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:
VARCHARby default; becomes a nativeCREATE TYPE … AS ENUMif 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 |
|
|
Rename safety |
Renaming the Java constant breaks data |
Renaming the Java
constant is safe — only |
Database readability |
Stored as Java-style
identifiers ( |
Stored as
human-readable text ( |
Special enum support |
Handles
|
No special enum handling |
|
✓ (PostgreSQL native
|
✓ (PostgreSQL native
|
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()).
Month — NameEnumType.afterGet() accepts:
-
Integer(1–12) →Month.of(n)— covers MySQLMONTH()/ PostgreSQLEXTRACT(MONTH FROM …) -
Long(1–12) →Month.of((int) n) -
String("JANUARY","FEBRUARY", …) →Month.valueOf(name.toUpperCase())— coversMONTHNAME() -
LocalDate,YearMonth,MonthDay,LocalDateTime,OffsetDateTime,ZonedDateTime→Month.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
DayOfWeek — NameEnumType.afterGet() accepts:
-
Integer— on MySQL,1maps toSUNDAY(MySQLDAYOFWEEK()convention); otherwiseDayOfWeek.of(n) -
Long→ same as integer -
String("MONDAY","TUESDAY", …) →DayOfWeek.valueOf(name.toUpperCase()) -
LocalDate,LocalDateTime,OffsetDateTime,ZonedDateTime→DayOfWeek.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:
-
If
type()is set (notvoid.class), it takes precedence overvalue(). -
If
value()is"${DEFAULT}", Army checksTableMeta.properties; if not configured, falls back to default type inference. -
If
value()is"${RUNTIME}", the property must be configured inTableMeta.propertiesor aMetaExceptionis thrown. -
Otherwise,
value()is a fully-qualified class name resolved viaClass.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→ PostgreSQLINTEGER[] -
long[]/Long[]→LongArrayType→ PostgreSQLBIGINT[] -
String[]→StringArrayType→ PostgreSQLTEXT[] -
BigDecimal[]→BigDecimalArrayType→ PostgreSQLNUMERIC[] -
LocalDate[]→LocalDateArrayType→ PostgreSQLDATE[] -
Boolean[]→BooleanArrayType→ PostgreSQLBOOLEAN[]
// Auto-inferred: Integer[] → IntegerArrayType → INTEGER[]
@Column(name = "tag_ids")
private Integer[] tagIds;
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,…).
|
Additionally:
|
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: aCurrencyPair("USD", 7.25)becomes the literal('USD',7.25) -
afterGet: the text('USD',7.25)is parsed back toCurrencyPair("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 |
|---|---|---|
|
|
Always maps to vanilla
|
|
|
Maps to |
|
|
Strictly maps to
|
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 |
|---|---|---|
|
|
Short to medium strings
(names, codes, hashes) — precision comes from |
|
|
Long, unbounded text (article content, message body, document text) |
|
|
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:
-
Custom type lookup — First checks if a
DefinedTypeMapFuncis registered (for user-defined database types like PostgreEnum). -
Named type lookup — Checks the dialect’s internal type registry (e.g.,
"vector"→PgType.VECTOR). -
Array detection — If the type name contains
[(e.g.,"integer[]"), it resolves the element type then wraps it viaarrayTypeOfThis(). -
Fallback — If no match is found, returns the
defaultType(theMappingType.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 |
A custom
|
Array Java type |
Extend |
A custom |
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 The problem arises when Army needs to know the semantic category of your type. For example:
In short: your type will work without a marker, but it may
fail in DDL generation or type-checking scenarios.
Implementing the correct |
Common marker interfaces:
| Category | Interface | When to Use |
|---|---|---|
Number |
|
Maps to |
Number |
|
Maps to
|
String |
|
Maps to
|
String |
|
Maps to
|
Binary |
|
Maps to
|
JSON |
|
Maps to
|
Extension |
|
Maps to pgvector |
Time |
|
Maps to
|
|
The full list of marker interfaces is in
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 |
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 |
4.11.5. Quick Checklist
Before writing your custom type, verify:
-
Non-array type → extends
UserMappingType; Array type → extendsUserArrayType -
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()andequals(Object) -
For array types, also overrides
underlyingJavaType(),elementType(), andarrayTypeOfThis() -
(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
|
|
2. Dialect Resolution |
|
|
3. TypeHandler Finalization |
|
|
4. Value Binding |
|
|
5. Value Fetching |
|
|
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 |
|
|
Statement specs |
|
|
Standard functions |
||
Window functions |
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();
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:
-
WITH … AS— CTE prefix (viawith(name).as(…)) -
SELECT—select(…)/selectDistinct(…) -
FROM—from(table, AS, alias)/ nestedfrom(function) -
JOIN—join(…) / leftJoin(…) / rightJoin(…) / crossJoin(…) -
WHERE—where(predicate).and(predicate)… -
GROUP BY—groupBy(…) -
HAVING—having(predicate) -
WINDOW—window(name).as(…)(named window definitions) -
ORDER BY—orderBy(…) -
LIMIT / OFFSET—limit(n).offset(n) -
FOR UPDATE—lock(…)for row-level locking -
UNION / INTERSECT / EXCEPT— set operations viaunion(…), etc. -
asQuery()— terminal method, returnsSelect
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();
-- (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 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();
-- 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
| Method | Signature |
|---|---|
|
First pair; |
|
First pair with
binding: |
|
Conditional first
pair — skipped when |
|
Conditional first pair with binding |
|
Subsequent pair |
|
Subsequent pair with binding |
|
Conditional subsequent pair |
|
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();
-- 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();
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();
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')
Assignments extends InsertStatement._StaticAssignmentSetClause:
| Method | Signature |
|---|---|
|
|
|
|
|
|
|
|
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 |
|
|
dynamic columns |
|
|
pre-built subquery |
|
|
inline via function |
|
|
bare
|
|
|
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();
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();
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();
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();
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();
-- 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 |
INSERT Options
| Option | Interface (from
InsertStatement)
|
Chain position |
|---|---|---|
|
|
before |
|
|
before |
|
|
before |
|
|
before |
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();
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.
Internally it calls StandardUpdates#simpleDomain / StandardUpdates#batchDomain.
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();
-- 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
-- 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);
-- 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
-- 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();
UPDATE stock s
SET name = ?,
update_time = ?,
version = s.version + 1
WHERE s.id = ? -- param: 100
| Entry Point | Description |
|---|---|
|
Standard UPDATE with
table alias; |
|
Domain UPDATE —
primarily for child tables; accepts |
|
Batch domain UPDATE;
returns |
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);
-- 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();
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.
Internally it calls StandardDeletes#domainDelete / StandardDeletes#batchDomainDelete.
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();
-- 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
-- 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);
-- 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
-- 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();
DELETE FROM stock AS s
WHERE s.visible = ? -- param: false (0)
All base interfaces for DELETE are defined in DeleteStatement:
| Clause | Interface |
|---|---|
Single-table |
|
Parent table |
|
Child table |
|
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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 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();
-- 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 functions — acos, asin, atan,
atan2, ceil, cos, floor,
ln, log, mod, power,
round, sign, sqrt, truncate, etc.
String functions — length, lower,
upper, trim, substring, etc. (also
concat via dialect API)
Datetime functions — extract, currentDate,
currentTime, etc. (available via dialect API)
Conditional functions — coalesce, nullif
(available via dialect API)
CASE expression — SQLs.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 |
|---|---|---|
|
|
|
|
(use dialect API) |
— |
|
|
|
|
|
|
|
|
|
|
|
Same as
|
|
|
Same as
|
|
|
Same as
|
|
|
Same as
|
|
|
Same as
|
|
|
Same as
|
|
|
Same as
|
|
|
Same as
|
|
|
Same as
|
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: |
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
Expressionobjects (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
"status" = ? -- param: NORMAL
"name" = "exchange"
|
The right-hand value is implicitly parameterized via
|
notEqual
The <> operator.
Stock_.code.notEqual("000001")
"code" <> ? -- param: '000001'
|
The right-hand value is implicitly parameterized via
|
nullSafeEqual
IS NOT DISTINCT FROM — treats NULL as a comparable value
(@Support({MySQL, PostgreSQL, H2})).
Stock_.offerPrice.nullSafeEqual(SQLs.NULL)
"offer_price" IS NOT DISTINCT FROM NULL
|
|
offer_price <=> NULL
less
The < operator.
Stock_.offerPrice.less(new BigDecimal("100.00"))
"offer_price" < ? -- param: 100.00
|
The right-hand value is implicitly parameterized via
|
lessEqual
The ⇐ operator.
Stock_.version.lessEqual(10)
"version" <= ? -- param: 10
|
The right-hand value is implicitly parameterized via
|
greater
The > operator.
Stock_.offerPrice.greater(new BigDecimal("100.00"))
"offer_price" > ? -- param: 100.00
|
The right-hand value is implicitly parameterized via
|
greaterEqual
The >= operator.
Stock_.listingDate.greaterEqual(LocalDate.of(2024, 1, 1))
"listing_date" >= ? -- param: 2024-01-01
|
The right-hand value is implicitly parameterized via
|
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"))
"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 |
notBetween
NOT BETWEEN … AND … range exclusion.
Also supports SYMMETRIC / ASYMMETRIC modifiers.
Stock_.offerPrice.notBetween(new BigDecimal("10.00"), SQLs.AND, new BigDecimal("100.00"))
"offer_price" NOT BETWEEN ? AND ? -- params: 10.00, 100.00
|
Both bound values are implicitly parameterized via |
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))
EXISTS (SELECT ...) IS TRUE
"name" IS DISTINCT FROM ? -- param: null
|
|
isNot
IS NOT boolean test — the negated counterpart of is.
SQLs.exists(subQuery).isNot(SQLs.FALSE)
EXISTS (SELECT ...) IS NOT FALSE
|
|
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, "\\")
"name" LIKE ? -- param: '%Apple%'
"code" LIKE ? ESCAPE ? -- params: '100\_%', '\'
|
The right-hand value is implicitly parameterized via
|
notLike
NOT LIKE negated pattern matching.
Also supports ESCAPE.
Stock_.name.notLike("%invalid%")
"name" NOT LIKE ? -- param: '%invalid%'
|
The right-hand value is implicitly parameterized via
|
similarTo
PostgreSQL SIMILAR TO — SQL-standard regex matching (@Support({PostgreSQL})).
Supports ESCAPE clause.
Stock_.code.similarTo("(SH\\|SZ)[0-9]{6}")
"code" SIMILAR TO ? -- param: '(SH|SZ)[0-9]{6}'
|
The right-hand value is implicitly parameterized via
|
notSimilarTo
PostgreSQL NOT SIMILAR TO (@Support({PostgreSQL})).
Stock_.name.notSimilarTo("%invalid%", SQLs.ESCAPE, "\\")
"name" NOT SIMILAR TO ? ESCAPE ? -- params: '%invalid%', '\'
|
The right-hand value is implicitly parameterized via
|
equalAny
= ANY(…) — true if equal to any row in the subquery.
Stock_.offerPrice.equalAny(subQuery)
"offer_price" = ANY (SELECT ...)
equalSome
= SOME(…) — semantically identical to equalAny.
Stock_.offerPrice.equalSome(subQuery)
"offer_price" = SOME (SELECT ...)
equalAll
= ALL(…) — true if equal to every row in the subquery.
Stock_.offerPrice.equalAll(subQuery)
"offer_price" = ALL (SELECT ...)
notEqualAny
<> ANY(…).
Stock_.offerPrice.notEqualAny(subQuery)
"offer_price" <> ANY (SELECT ...)
notEqualSome
<> SOME(…) — semantically identical to
notEqualAny.
Stock_.offerPrice.notEqualSome(subQuery)
"offer_price" <> SOME (SELECT ...)
notEqualAll
<> ALL(…).
Stock_.offerPrice.notEqualAll(subQuery)
"offer_price" <> ALL (SELECT ...)
lessAny
< ANY(…).
Stock_.offerPrice.lessAny(subQuery)
"offer_price" < ANY (SELECT ...)
lessSome
< SOME(…) — semantically identical to lessAny.
Stock_.offerPrice.lessSome(subQuery)
"offer_price" < SOME (SELECT ...)
lessAll
< ALL(…).
Stock_.offerPrice.lessAll(subQuery)
"offer_price" < ALL (SELECT ...)
lessEqualAny
⇐ ANY(…).
Stock_.offerPrice.lessEqualAny(subQuery)
"offer_price" <= ANY (SELECT ...)
lessEqualSome
⇐ SOME(…).
Stock_.offerPrice.lessEqualSome(subQuery)
"offer_price" <= SOME (SELECT ...)
lessEqualAll
⇐ ALL(…).
Stock_.offerPrice.lessEqualAll(subQuery)
"offer_price" <= ALL (SELECT ...)
greaterAny
> ANY(…).
Stock_.offerPrice.greaterAny(subQuery)
"offer_price" > ANY (SELECT ...)
greaterSome
> SOME(…) — semantically identical to
greaterAny.
Stock_.offerPrice.greaterSome(subQuery)
"offer_price" > SOME (SELECT ...)
greaterAll
> ALL(…).
Stock_.offerPrice.greaterAll(subQuery)
"offer_price" > ALL (SELECT ...)
greaterEqualAny
>= ANY(…).
Stock_.offerPrice.greaterEqualAny(subQuery)
"offer_price" >= ANY (SELECT ...)
greaterEqualSome
>= SOME(…).
Stock_.offerPrice.greaterEqualSome(subQuery)
"offer_price" >= SOME (SELECT ...)
greaterEqualAll
>= ALL(…).
Stock_.offerPrice.greaterEqualAll(subQuery)
"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))
"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)
"id" NOT IN (SELECT id FROM ...)
mod
% modulo operation.
Stock_.version.mod(10)
"version" % ? -- param: 10
|
The right-hand value is implicitly parameterized via
|
times
* multiplication.
Stock_.offerPrice.times(new BigDecimal("1.1"))
"offer_price" * ? -- param: 1.1
|
The right-hand value is implicitly parameterized via
|
plus
+ addition.
Stock_.offerPrice.plus(new BigDecimal("5"))
"offer_price" + ? -- param: 5
|
The right-hand value is implicitly parameterized via
|
minus
- subtraction.
Stock_.offerPrice.minus(new BigDecimal("1.00"))
"offer_price" - ? -- param: 1.00
|
The right-hand value is implicitly parameterized via
|
divide
/ division.
Stock_.offerPrice.divide(new BigDecimal("2"))
"offer_price" / ? -- param: 2
|
The right-hand value is implicitly parameterized via
|
bitwiseAnd
& bitwise AND.
Returns a BigInteger typed expression.
Stock_.version.bitwiseAnd(0xFF)
"version" & ? -- param: 255
|
The right-hand value is implicitly parameterized via
|
bitwiseOr
| bitwise OR.
Stock_.version.bitwiseOr(0x0F)
"version" | ? -- param: 15
|
The right-hand value is implicitly parameterized via
|
bitwiseXor
# bitwise XOR (PostgreSQL XOR operator).
Stock_.version.bitwiseXor(0x55)
"version" # ? -- param: 85
|
The right-hand value is implicitly parameterized via
|
rightShift
>> right bit shift.
Returns a BigInteger typed expression.
Stock_.version.rightShift(2)
"version" >> ? -- param: 2
|
The right-hand value is implicitly parameterized via
|
leftShift
<< left bit shift.
Stock_.version.leftShift(3)
"version" << ? -- param: 3
|
The right-hand value is implicitly parameterized via
|
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)
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))
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")
(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"
(((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")
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']
expr['config']['timeout']
l1Distance
PostgreSQL pgvector L1 distance (Manhattan distance) (@Support({PostgreSQL})).
embedding.l1Distance(queryVector)
embedding <+> ? -- param: query vector
|
The right-hand value is implicitly parameterized via
|
l2Distance
PostgreSQL pgvector L2 distance (Euclidean distance) (@Support({PostgreSQL})).
embedding.l2Distance(queryVector)
embedding <-> ? -- param: query vector
|
The right-hand value is implicitly parameterized via
|
cosineDistance
PostgreSQL pgvector cosine distance (@Support({PostgreSQL})).
embedding.cosineDistance(queryVector)
embedding <=> ? -- param: query vector
|
The right-hand value is implicitly parameterized via
|
hammingDistance
PostgreSQL pgvector Hamming distance (binary vector) (@Support({PostgreSQL})).
embedding.hammingDistance(queryVector)
embedding <~> ? -- param: query vector
|
The right-hand value is implicitly parameterized via
|
jaccardDistance
PostgreSQL pgvector Jaccard distance (binary vector) (@Support({PostgreSQL})).
embedding.jaccardDistance(queryVector)
embedding <%> ? -- param: query vector
|
The right-hand value is implicitly parameterized via
|
negDot
PostgreSQL pgvector negative dot product <#> (@Support({PostgreSQL})).
embedding.negDot(queryVector)
embedding <#> ? -- param: query vector
|
The right-hand value is implicitly parameterized via
|
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)
-- 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))
-- Does not change SQL output
some_expr
castTo
SQL CAST — generates CAST(expr AS type) (@Support({PostgreSQL})).
Stock_.version.castTo(IntegerType.INSTANCE)
CAST("version" AS INTEGER)
castToArray
SQL CAST to array — generates CAST(expr AS type[]).
someExpr.castToArray(ArrayMappingType.of(IntegerType.INSTANCE))
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)
"offer_price" ASC
ascSpace
Ascending + NULL ordering control.
Accepts SQLs.NULLS_FIRST / SQLs.NULLS_LAST or
null.
Stock_.listingDate.ascSpace(SQLs.NULLS_FIRST)
"listing_date" ASC NULLS FIRST
descSpace
Descending + NULL ordering control.
Stock_.offerPrice.descSpace(SQLs.NULLS_LAST)
"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)
-- 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
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)
"name" = ? -- param: 'ABC'
"region_type" = 1 -- literal: PROVINCE (enum ordinal)
notEqual
<> operator with explicit binding.
Stock_.code.notEqual(SQLs::param, "000001")
"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"))
"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"))
"offer_price" < ? -- param: 100.00
lessEqual
⇐ operator with explicit binding.
Stock_.version.lessEqual(SQLs::param, 10)
"version" <= ? -- param: 10
greater
> operator with explicit binding.
Stock_.offerPrice.greater(SQLs::param, new BigDecimal("100.00"))
"offer_price" > ? -- param: 100.00
greaterEqual
>= operator with explicit binding.
Stock_.listingDate.greaterEqual(SQLs::param, LocalDate.of(2024, 1, 1))
"listing_date" >= ? -- param: 2024-01-01
between
BETWEEN … AND … with per-bound binding
control.
Four overloads:
-
Single
BiFunctionfor both bounds. -
Independent
BiFunctionper bound — mix param and literal. 3-4. Same as above plusSQLs.SYMMETRIC/SQLs.ASYMMETRICmodifier (@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"))
"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"))
"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)
"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"))
"offer_price" IS NOT DISTINCT FROM ? -- param: 100.00
in
IN (…) with explicit binding.
Two overloads:
-
Collection-based —
BiFunctionconverts the collection to aRowExpression. -
Named-params —
TeNamedParamsFuncfor 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)
"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))
"status" NOT IN (?) -- param: DELISTED
mod
% (modulo) — returns Expression.
Stock_.version.mod(SQLs::param, 10)
"version" % ? -- param: 10
times
* (multiplication) — returns Expression.
Stock_.offerPrice.times(SQLs::param, new BigDecimal("2"))
"offer_price" * ? -- param: 2
plus
+ (addition) — returns Expression.
Stock_.offerPrice.plus(SQLs::param, new BigDecimal("10.00"))
"offer_price" + ? -- param: 10.00
minus
- (subtraction) — returns Expression.
Stock_.offerPrice.minus(SQLs::param, new BigDecimal("5.00"))
"offer_price" - ? -- param: 5.00
divide
/ (division) — returns Expression.
Stock_.offerPrice.divide(SQLs::param, new BigDecimal("2"))
"offer_price" / ? -- param: 2
bitwiseAnd
& (bitwise AND) — returns Expression
(BigInteger).
Stock_.version.bitwiseAnd(SQLs::param, 0xFF)
"version" & ? -- param: 255
bitwiseOr
| (bitwise OR) — returns Expression (BigInteger).
Stock_.version.bitwiseOr(SQLs::param, 0x01)
"version" | ? -- param: 1
bitwiseXor
# (bitwise XOR) — returns Expression (BigInteger).
Stock_.version.bitwiseXor(SQLs::param, 0x0F)
"version" # ? -- param: 15
rightShift
>> (right shift) — returns Expression
(BigInteger).
Stock_.version.rightShift(SQLs::param, 4)
"version" >> ? -- param: 4
leftShift
<< (left shift) — returns Expression
(BigInteger).
Stock_.version.leftShift(SQLs::param, 4)
"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, '\\')
"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%")
"name" NOT LIKE ? -- param: '%DELETED%'
similarTo
SIMILAR TO (@Support({PostgreSQL})).
Two overloads: basic and with ESCAPE.
Stock_.name.similarTo(SQLs::param, "%(A|B)%")
"name" SIMILAR TO ? -- param: '%(A|B)%'
notSimilarTo
NOT SIMILAR TO (@Support({PostgreSQL})).
Same two overloads.
Stock_.name.notSimilarTo(SQLs::param, "%(X|Y)%")
"name" NOT SIMILAR TO ? -- param: '%(X|Y)%'
l1Distance
<+> (L1 distance) for pgvector (@Support({PostgreSQL})).
embedding.l1Distance(SQLs::param, queryVector)
"embedding" <+> ? -- param: vector
l2Distance
<→ (L2 distance) for pgvector (@Support({PostgreSQL})).
embedding.l2Distance(SQLs::param, queryVector)
"embedding" <-> ? -- param: vector
cosineDistance
<⇒ (cosine distance) for pgvector
(@Support({PostgreSQL})).
embedding.cosineDistance(SQLs::param, queryVector)
"embedding" <=> ? -- param: vector
hammingDistance
<~> (Hamming distance) for pgvector binary vectors (@Support({PostgreSQL})).
embedding.hammingDistance(SQLs::param, queryVector)
"embedding" <~> ? -- param: vector
jaccardDistance
<%> (Jaccard distance) for pgvector binary vectors (@Support({PostgreSQL})).
embedding.jaccardDistance(SQLs::param, queryVector)
"embedding" <%> ? -- param: vector
negDot
<#> (negative inner product) for pgvector (@Support({PostgreSQL})).
embedding.negDot(SQLs::param, queryVector)
"embedding" <#> ? -- param: vector
space
Dialect operator entry point — three overloads:
-
space(DualOperator, funcRef, value)— returnsExpression. -
space(BiOperator, funcRef, value)— returnsIPredicate. -
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 This is the implementation pattern (from
The
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)
spaceNullSafeEqual
Batch-compatible IS NOT DISTINCT FROM.
Stock_.offerPrice.spaceNullSafeEqual(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:
-
BiFunction<TypedField, String, RowExpression>— fornamedRowLiteral/namedRowConst. -
TeNamedParamsFunc<TypedField>— fornamedRowParamwith 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:
-
spaceSpace(DualOperator, namedOperator)— returnsExpression. -
spaceSpace(BiOperator, namedOperator)— returnsIPredicate. -
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_.idis aTableFieldwhen used inStock_.id.equal(1) -
Stock_.idis aFieldMetawhen the framework asksisPrimaryField(),comment(),generator(), or needs the column’sdefaultValue()for DDL generation
FieldMeta extends TypedTableField<T> (which extends
TableField), so it inherits all expression-side methods plus adds metadata
methods below.
generator
If this field represents _MetaBridge.ID, returns the
GeneratorMeta for ID generation; otherwise returns
null.
@Nullable
GeneratorMeta generator()
GeneratorMeta itself declares:
-
field()— the owningFieldMeta -
javaType()— the generated ID’s Java type -
params()— an immutable map of generator parameters (e.g.startTimefor 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()
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 INwith 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()
Related: SQLs.row()
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
|
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():
Related: 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 |
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 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. ExtendsIPredicatewithout adding any additional methods. -
LogicalPredicate— a predicate resulting from a logical operator likeNOTor dialect-specific operators. Extends bothIPredicateandSimplePredicate. -
CompoundPredicate— a predicate that is a compound of multiple sub-predicates (e.g.AND/ORchains). ExtendsIPredicate.
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)
"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"))
)
"code" = ?
OR (
"status" = ?
AND "version" > ?
AND "offer_price" < ?
)
-- params: '000001', 'NORMAL', 1, 200
|
|
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
"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)
"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
"code" = ? AND "status" = ?
-- params: '000001', 'NORMAL'
|
|
Related: SQLs.not()
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" = ?))
NOT ("status" = ?)
-- param: 'NORMAL'
Related: SQLs.bracket()
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" > ?)
("code" = ? OR "code" = ?)
-- params: '000001', '000002'
|
Unlike |
Related: SQLs.exists() / SQLs.notExists()
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()
)
-- 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()
-- 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 |
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)
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))
-- 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
-- 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
-- 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?
-
Step 1 — Call
equal(Object)Stock_.statusis anOperationTypedField(extendsOperationTypedExpression, which extendsOperationExpression). It callsOperationExpression#equal(Object):return Expressions.biPredicate(this, DualBooleanOperator.EQUAL, operand); -
Step 2 —
biPredicate→wrapRightExpressions#biPredicatecallsExpressions#wrapRight(leftto decide how to wrap the right operandStockStatus.NORMAL. -
Step 3 —
wrapRightcheckslefttypeBecause
left(i.e.Stock_.status) implementsTypedExpression,wrapRightenters:else if (left instanceof TypedExpression) { rightExp = wrapValue((TypedExpression) left, right); }It passes the left operand’s
MappingTypetowrapValue. -
Step 4 —
wrapValuechecksLiteralModeBy default (
LiteralMode.DEFAULT), it creates a parameterized value:case DEFAULT: valueExp = SQLs.param(infer, value); // → JDBC ? -
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 |
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 |
|---|---|---|
|
|
Always safe. Recommended for 99% of predicates. Prevents SQL injection. |
|
|
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. |
|
|
Rarely needed. Use when literal type coercion is not required. |
|
Numeric/Boolean →
|
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 |
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 |
|---|---|---|
|
|
|
|
|
|
|
When should you care about explicit binding? In everyday application code with normal domain fields (like 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.
|
|
The |
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
TypeInferis provided by theTypedExpressionitself — the field expression on the left side of the dot (e.g.,Stock_.offerPrice), whoseMappingTypethe framework passes in, ensuring type matching -
Second parameter
Objectis 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 providesTypeInfer, andfieldName()becomes the batch key -
In WHERE clauses: use
field.spaceEqual(SQLs::namedParam)— returnsIPredicate, passed towhere(IPredicate) -
.set()alone cannot acceptSQLs::namedParam:set()accepts(field, Object)or(field, BiFunction, value), neither matchesBiFunction<F, String, Expression>. ThesetSpace()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 |
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
Conclusion: You can safely use
|
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
TypeInferis provided by theTypedExpressionitself — theMappingTypeof the field -
Second parameter
Objectis 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)
|
If you need named values in batch UPDATE/DELETE, use |
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
FieldMetais provided byStock_.code— supplies theTypeInferand table mapping info -
Second parameter
Stringis 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 time — columnSize() 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.
+
|
If you need named multi-value placeholders in batch UPDATE/DELETE, use
|
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() |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.
+
|
|
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
TypeInferis provided by theTypedExpressionitself — theMappingTypeof the field -
Second parameter
Objectis 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)
|
If you need named values in batch UPDATE/DELETE, use |
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
FieldMetais provided byStock_.code— supplies theTypeInferand table mapping info -
Second parameter
Stringis 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.
+
|
If you need named multi-value placeholders in batch UPDATE/DELETE, use
|
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)
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
-
with("name")orwithRecursive("name")— names the CTE and opens the definition chain. -
parens(…)— optionally provides column aliases for the CTE result. If omitted, column names are derived from the sub-statement’sSELECTlist. -
as(fn)— supplies the CTE body via aFunctionthat receives a sub-statement builder. The function returns aItemwhose type carries the next CTE orspace()/comma()hook. -
comma("name")— adds another CTE to the chain. Repeat for multiple CTEs. -
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();
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 |
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();
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();
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— forSQLs.*/ standard dialect -
MySQLCtes— forMySQLs.*/ MySQL dialect -
PostgreCtes— forPostgres.*/ 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 aSELECTstatement (all dialects) -
subValues(String name)— CTE backed by aVALUESclause (MySQL) -
subSingleInsert(String name)— CTE backed by anINSERT(PostgreSQL) -
subSingleUpdate(String name)— CTE backed by anUPDATE(PostgreSQL) -
subSingleDelete(String name)— CTE backed by aDELETE(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();
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 |
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:
-
Non-recursive part — the base case, executed once
-
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();
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
|
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();
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
|
When the left operand has no type (e.g.,
|
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();
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();
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();
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();
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
|
Complete API:
|
5.4.8. Static vs Dynamic — When to Use Which
| Scenario | Static Style | Dynamic Style |
|---|---|---|
Single, fixed CTE |
|
|
Multiple, fixed CTEs |
|
|
Conditional CTE inclusion |
Not available — all CTEs in the chain are mandatory. |
|
Programmatic CTE generation |
Awkward — must construct the chain programmatically. |
Natural — build the CTE list inside the consumer with loops and conditionals. |
Need |
Batch domain
UPDATE/DELETE:
|
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 |
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()
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()
SELECT s.code
FROM stock AS s
WHERE s.offer_price > (SELECT AVG(s2.offer_price)
FROM stock AS s2)
|
The final In contrast, the final |
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 |
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 inFROMandJOIN -
Query— the subquery itself is a query statement -
SubStatement— marks it as a sub-statement (not top-level) -
SQLValueList— allows usage withIN/= ANYpredicates -
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
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()
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()
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()
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:
WITH → SELECT → FROM → JOIN →
WHERE → GROUP BY → HAVING → WINDOW → ORDER
BY → LIMIT → UNION → asQuery()
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 |
|
Delay |
Compile-time (known) |
Required (refField) |
|
Dynamic |
Runtime (conditional) |
Required or not |
|
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()
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()
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("us","one") isn't allowed in static SELECT clause for ...
You must switch to the delay form to use |
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()
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()
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:
|
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 |
|
|
Derived Table |
|
|
CTE |
|
|
Table Function |
|
|
Nested |
|
|
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
(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();
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
-
RETURNINGclause — Return field values directly after INSERT/UPDATE -
ON CONFLICTclause — Update or ignore on conflict -
WITHclause (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();
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
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.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;
}
}
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.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.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.
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.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
}
}
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.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;
}
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
12. Advanced Features
12.1. Reserved Field Mechanism
Army reserves the following field names with framework-managed behavior:
| Field | Type | Behavior |
|---|---|---|
|
Numeric |
Primary key; automatic NOT NULL; non-updatable |
|
Temporal |
Auto-populated on creation; non-updatable |
|
Temporal |
Auto-populated on update |
|
Integer |
Optimistic lock version; auto-incremented on update |
|
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 |
|---|---|---|
|
|
|
|
|
Inline literal value |
|
|
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_MODE—OFF/DEBUG/INFO -
SQL_LOG_DYNAMIC— Dynamic logging toggle -
SQL_LOG_EXECUTION_TAKE_TIME— Execution-time logging
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
-
Retrieve actual schema information from the database
-
Compare against the Army metadata model
-
Generate a diff report
-
Generate idempotent DDL statements
14. Best Practices
14.1. Domain Class Design
-
Use generics to support inheritance mapping
-
Add comments to all fields
-
Use reserved fields judiciously (
id,createTime,updateTime,version,visible) -
Configure generators correctly (
PRECEDEvsPOST) -
Externalize environment-specific configuration through
TableMeta.properties
14.2. Criteria API Usage
-
Prefer the standard Criteria API (
SQLs) -
Use dialect APIs (
MySQLs/Postgres) only when database-specific features are needed -
Choose parameter binding (
param) vs literal binding (literal) wisely -
Be aware of the performance implications of CTEs and subqueries
14.3. Transaction Management
-
Use declarative transactions (
@Transactional) -
Set appropriate transaction isolation levels
-
Be mindful of the performance impact of long-running transactions
-
Use savepoints for fine-grained control
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
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.4. Related Resources
-
GitHub: https://github.com/PillArmy/army
-
Documentation: https://army.qinarmy.io
-
Organization: https://qinarmy.io