Mongo-flex is a lightweight MongoDB toolkit offering three query paths that converge into MongoDB Bson:
| Path | Mechanism | Use Case |
|---|---|---|
| Repository methods | MongoRepository<T,ID> interface |
CRUD by ID, entity example, lambda field queries |
| Lambda type-safe queries | LambdaQueryWrapper<T> + operators |
Type-safe dynamic queries, 21 operators |
| Annotation-driven JSON | @Find / @Count / @Delete / @Update |
Complex / ad-hoc JSON queries |
| Aggregation | @Aggregate / MongoOps + AggregationWrapper<T> |
$lookup / $group pipelines, typed DTO or raw Map output |
MongoRepository<T, ID> — single interface covering all operations:
- Basic CRUD — insert, insertMany, findById, findAll, count, deleteOneById, deleteAll, updateOneById
- by entity — findOneByEntity, findListByEntity, findPageByEntity, countByEntity, deleteByEntity
- by lambda reference — findOne, count, updateOne, updateMany, deleteOne, deleteMany (SFunction)
- by LambdaQueryWrapper — findOne, findList, findPage, count, update, delete
Compiled to Java 8 bytecode — compatible with JDK 8+ and Spring Boot 2.7.x / 3.x.
Mongo-flex is compiled to Java 8 bytecode and is compatible with JDK 8+ and Spring Boot 2.7.x / 3.x.
Important: MongoDB driver and Spring Boot starter are declared as optional in mongo-flex's pom. This means they will NOT be pulled into your project automatically. You need to ensure the following dependencies are present (usually via your own Spring Boot starter):
spring-boot-starter(orspring-boot-autoconfigure) — provided by your Spring Boot projectmongodb-driver-sync— version depends on your Spring Boot version:
| Spring Boot | MongoDB driver version |
|---|---|
| 2.7.x | 4.11.x |
| 3.x | 5.x (managed by BOM) |
For Spring Boot 3.x users: No extra config needed — SB3 already manages MongoDB driver 5.x.
For Spring Boot 2.7.x / JDK 8 users: Add the MongoDB driver explicitly:
<dependency>
<groupId>org.mongodb</groupId>
<artifactId>mongodb-driver-sync</artifactId>
<version>4.11.1</version>
</dependency>Coexists with Spring Data MongoDB: mongo-flex only excludes MongoAutoConfiguration (preventing Spring Boot from auto-connecting to localhost:27017) and intentionally leaves MongoDataAutoConfiguration (Spring Data MongoDB's auto-configuration) untouched. If you include Spring Data MongoDB and provide a MongoClient bean, both frameworks work together in the same project.
<dependency>
<groupId>io.github.eacryo</groupId>
<artifactId>mongo-flex</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>mongo-flex:
uri: mongodb://localhost:27017/mydbWithout multi-tenancy, a default MongoClient is created from mongo-flex.uri.
@CollectionName("character")
@Data
public class Character {
@CollectionId(IdType.ULID)
private String id;
// Available ID strategies:
// IdType.OBJECT_ID — MongoDB native ObjectId (default, stored as 24-char hex String)
// IdType.ULID — 26-char sortable unique ID, recommended for new projects
// IdType.UUID — standard UUID v4
// IdType.INPUT — custom IdGenerator implementation
private String name;
@CollectionField("c_area")
private String area;
@CreateDate
private Date createAt;
@UpdateDate
private Date updateAt;
}Choose the interface level you need:
@MRepository
public interface CharacterRepository extends MongoRepository<Character, String> {
@Find("{name: #{name}}")
List<Character> findByName(@Param("name") String name);
@Find(value = "{area: #{area}}", skip = 0, limit = 10)
List<Character> findTop10ByArea(@Param("area") String area);
@Update(value = "{name: #{name}}", update = "{$set: {level: #{level}}}")
long updateLevelByName(@Param("name") String name, @Param("level") int level);
}@Autowired
private CharacterRepository repo;
// insert
Character c = new Character();
c.setName("Furina");
c.setArea("Fontaine");
repo.insert(c);
// find by id
Character found = repo.findById(c.getId());
// find with lambda query wrapper
LambdaQueryWrapper<Character> wrapper = new LambdaQueryWrapper<>(Character.class);
wrapper.eq(Character::getName, "Furina");
List<Character> list = repo.findList(wrapper);
// isNull / isNotNull — aligned with SQL IS NULL / IS NOT NULL semantics
// isNull → { field: null }, matching both null-valued and missing fields
wrapper.isNull(Character::getPhone);
// isNotNull → { field: { $ne: null } }, matching existing + non-null fields
wrapper.isNotNull(Character::getVision);
// find all
List<Character> all = repo.findAll();
// count
long total = repo.count();
// delete all (explicit opt-in)
long deleted = repo.deleteAll();
// entity example queries
Character probe = new Character();
probe.setName("Furina");
Character one = repo.findOneByEntity(probe); // find first match
List<Character> list = repo.findListByEntity(probe); // find all matching
// pagination with Lambda sort
PageDTO<Character> page = new PageDTO<>();
page.setCurrentPage(1L);
page.setPageSize(10L);
LambdaQueryWrapper<Character> wrapper = new LambdaQueryWrapper<>(Character.class);
wrapper.eq(Character::getArea, "Fontaine")
.orderByAsc(Character::getName);
PageDTO<Character> result = repo.findPage(wrapper, page);
result.getRecords(); // current page data
result.getTotal(); // total matching documents
// updateOne — update first match
Character ganyu = new Character();
ganyu.setId("some-id");
ganyu.setArea("Liyue");
repo.updateOneById(ganyu); // update by _id
// updateMany — update all matches
repo.updateMany(Character::getName, "Ganyu", ganyu); // update by field
// upsert — update if exists, insert if not (pass upsert=true)
repo.updateOneById(ganyu, true); // upsert by _id
repo.updateMany(Character::getName, "Ganyu", ganyu, true); // upsert by field
// delete
repo.deleteOneById("some-id"); // delete one by _id
repo.deleteMany(Character::getName, "Ganyu"); // delete all matches by field
// OR queries
wrapper.or(w -> w.eq(Character::getArea, "Liyue")
.eq(Character::getArea, "Fontaine"));
List<Character> orResult = repo.findList(wrapper);LambdaQueryWrapper.fromEntity(entity) automatically builds query conditions from a populated entity object. Each non-null field becomes an eq() condition, and static/transient fields are ignored:
Character probe = new Character();
probe.setArea("Liyue");
// probe → wrapper.eq(Character::getArea, "Liyue")
List<Character> result = repo.findList(
LambdaQueryWrapper.fromEntity(probe)
.include(Character::getName, Character::getLevel)
.orderByAsc(Character::getName)
);Limit returned fields using include() and exclude():
// Return only name and age (plus _id by default)
wrapper.include(Character::getName, Character::getAge);
// Exclude sensitive fields
wrapper.exclude(Character::getPassword, Character::getLargeData);
// Combine: include fields but suppress _id
wrapper.include(Character::getName, Character::getAge)
.exclude(Character::getId); // _id is the only field allowed in mixed modeMongoDB does not allow mixing
includeandexcludeon non-_idfields. Ifinclude()is called first,exclude()can only suppress_id.
Lambda query wrappers use method references to extract field names. The resolution follows the MyBatis PropertyNamer convention (JavaBeans standard):
| Method Reference | Resolved Field | Rule |
|---|---|---|
Entity::getName |
name |
Strip get prefix, lowercase first char |
Entity::isActive |
active |
Strip is prefix, lowercase first char |
Entity::getURL |
URL |
Acronym: second char uppercase → keep first char uppercase |
Known Limitation: A method like
isActive()is inherently ambiguous — it could be the getter forboolean activeorboolean isActive. The resolver always assumes the former (JavaBeans convention). If your field is literally namedisActive, use@CollectionField("is_active")to override the MongoDB field mapping, or rename your Java field toactive.
MongoDB addresses fields inside nested subdocuments with dot notation (address.city). Each query path supports it as follows:
Lambda path — type-safe FieldPath chained method references:
public class Character {
@CollectionField("home_region")
private Region region; // nested object
}
public class Region {
private String nation;
@CollectionField("main_city")
private String mainCity;
private Integer altitude;
}
// two-level path → renders {"home_region.main_city": "Liyue Harbor"}
wrapper.eq(FieldPath.of(Character::getRegion, Region::getMainCity), "Liyue Harbor");
// fluent form, arbitrary depth via then()
wrapper.gt(FieldPath.of(Character::getRegion).then(Region::getAltitude), 1000);
// also works with sorting and projection
wrapper.orderByDesc(FieldPath.of(Character::getRegion, Region::getAltitude))
.include(FieldPath.of(Character::getRegion, Region::getMainCity));Every path segment honors @CollectionField mapping (region → home_region, mainCity → main_city), and List segments traverse transparently into their element type. All filter operators (eq/gt/between/exists/...) have FieldPath overloads.
Annotation path — raw JSON dot keys work natively:
@Find("{'home_region.main_city': #{city}}")
List<Character> findByRegionCity(@Param("city") String city);Raw JSON uses MongoDB field names (after
@CollectionFieldmapping), not Java field names.
Entity path (*ByEntity) — single-layer semantics:
Entity example queries match a nested object field as an exact subdocument — the whole nested object must be identical (all fields, including field order). They are not flattened into per-field dot notation. When you need per-field nested matching, use FieldPath or @Find dot keys instead.
Character probe = new Character();
probe.setRegion(new Region("Liyue", "Liyue Harbor", 500));
repo.findListByEntity(probe); // matches only documents whose whole subdocument is identicalUse @CreateDate and @UpdateDate to auto-fill timestamps on insert and update. No configuration needed — just annotate the field.
@CreateDate
private LocalDateTime createAt; // auto-filled on insert
@UpdateDate
private LocalDateTime updateAt; // auto-filled on every insert & updateBuilt-in type support:
| Field Type | Generated Value |
|---|---|
java.util.Date |
new Date() |
String |
LocalDateTime.now().format(pattern) |
LocalDateTime |
LocalDateTime.now() |
LocalDate |
LocalDate.now() |
Instant |
Instant.now() |
Long / long |
System.currentTimeMillis() |
Custom pattern for String fields:
@CreateDate(pattern = "yyyy/MM/dd HH:mm")
private String createTime; // → "2026/07/12 14:30"Per-field custom provider:
// 1. Implement DateValueProvider
public class MyZonedProvider implements DateValueProvider {
@Override
public Object generateCurrentDate(Class<?> fieldType, String pattern) {
if (fieldType == ZonedDateTime.class) return ZonedDateTime.now();
return null; // let built-ins handle other types
}
}
// 2. Reference on the field
@CreateDate(providerClass = MyZonedProvider.class)
private ZonedDateTime createAt;Global provider (applies to all fields):
Register a DateValueProvider Spring bean — it serves as the default fallback before the built-in type table.
Resolution order:
providerClasson annotation → globalDateValueProviderbean → built-in type table.
Mongo-flex follows the MyBatis-Plus style of explicit type binding: a Repository<T> works with exactly T — no more, no less.
// Parent entity
@CollectionName("character")
public class Character {
@CollectionId(IdType.ULID)
private String id;
private String name;
private String vision;
}
// Child entity with extra fields
public class LiyueCharacter extends Character {
private String title; // 称号
@CollectionField("is_adeptus")
private Boolean isAdeptus; // 是否仙人
}Key rule: Use a dedicated Repository for each type you want to fully read/write.
// ✅ Read parent fields via parent Repository
@MRepository
public interface CharacterRepository extends MongoRepository<Character, String> {}
Character c = characterRepo.findById(id);
c.getVision(); // ✅ works
// ✅ Read all fields (parent + child) via child Repository
@MRepository
public interface LiyueCharacterRepository extends MongoRepository<LiyueCharacter, String> {}
LiyueCharacter lc = liyueRepo.findById(id);
lc.getVision(); // ✅ inherited field
lc.getTitle(); // ✅ child field
lc.getIsAdeptus(); // ✅ child field, @CollectionField("is_adeptus") honored// ❌ Don't expect child fields through parent Repository
Character c = characterRepo.findById(id);
c.getTitle(); // ❌ compile error — Character has no getTitle()
c instanceof LiyueCharacter; // ❌ always false — read() returns Character, never LiyueCharacterWhy not auto-polymorphism like Spring Data MongoDB? Spring Data stores a _class discriminator and automatically instantiates the subclass — but this means repo.findById(id) can silently return a LiyueCharacter when you declared Character. It's flexible but requires instanceof guards. Mongo-flex chooses explicit type binding: insert stores all fields (runtime type), but read returns only what the Repository interface declares (compile-time type). No surprises.
mongo-flex:
enable-multi-tenants: true
tenants:
- name: tenantA
uri: mongodb://localhost:27017/db_aSet the active tenant before each operation:
TenantContext.set("tenantA"); // preferred — ThreadLocal + MDC mirroring
repo.insert(c);
TenantContext.clear();Legacy MDC.put(MongoFlexConstant.TENANT, "tenantA") still works — TenantContext.get() falls back to MDC when the ThreadLocal is empty. Prefer TenantContext: it mirrors into MDC so logs keep the tenant, and always clear() in a finally block on pooled threads.
If multi-tenancy is not enabled, a default MongoClient is created using the mongo-flex.uri property.
java.time (Instant / LocalDateTime / LocalDate / ZonedDateTime / LocalTime), BigDecimal (↔ Decimal128) and Set fields are supported out of the box. Register your own converter as a Spring bean to override or extend — later registrations win over the built-in defaults:
@Bean
MongoValueConverter moneyConverter() {
return new MongoValueConverter() {
public boolean supports(Class<?> t) { return Money.class.equals(t); }
public Object toBson(Object v) { return ((Money) v).toPlainString(); }
public Object fromBson(Object b, Class<?> t) { return Money.parse((String) b); }
};
}Destructive writes with empty conditions are rejected by default. Full-collection operations are still available — each condition style declares its intent explicitly:
// by entity — named All methods / 实体路径——显式命名的 All 方法
repo.deleteAll();
repo.updateAll(patch); // $set patch fields on every document
// by wrapper — matchAll() marker at the construction site / wrapper 路径——构造处的 matchAll() 标记
repo.deleteMany(new LambdaQueryWrapper<Character>().matchAll());
repo.updateMany(new LambdaQueryWrapper<Character>().matchAll(), patch);
// by annotation — declaration-site opt-in / 注解路径——声明处显式声明
@Delete(value = "{}", allowEmptyFilter = true)
long purgeAll();
@Update(value = "{}", update = "{$set: {status: 'archived'}}", multi = true, allowEmptyFilter = true)
long archiveAll();Three entry points share the same pipeline execution, differing only in how the pipeline is declared:
Annotation path — @Aggregate on a repository method (JSON array pipeline, #{param} placeholders supported, results mapped to the declared DTO):
@MRepository
public interface CharacterRepository extends MongoRepository<Character, String> {
@Aggregate("[{$lookup: {from: 'weapon', localField: '_id', foreignField: 'character_id', as: 'weapon'}}, "
+ "{$unwind: '$weapon'}]")
List<CharacterWithWeapon> lookupWeapons();
@Aggregate("[{$match: {vision: #{vision}}}, {$group: {_id: '$vision', count: {$sum: 1}}}, {$sort: {count: -1}}]")
List<VisionStats> statsByVision(@Param("vision") String vision);
}Pipeline JSON uses MongoDB field names (after @CollectionField mapping), same as @Find.
Type-safe builder — AggregationWrapper + MongoOps ($match reuses the full lambda operator set; sortAsc/sortDesc take MongoDB field names):
@Autowired
private MongoOps mongoOps;
List<VisionStats> stats = mongoOps.aggregate(
new AggregationWrapper<>(Character.class)
.match(w -> w.eq(Character::getArea, "Liyue"))
.group(Character::getVision) // group key; group() with no args = global group
.count("count")
.avg("avgLevel", Character::getLevel)
.end() // close the group, back to the pipeline
.sortDesc("count")
.limit(5),
VisionStats.class);Available stages: match / lookup / group / sortAsc / sortDesc / limit / skip / unwind(field) / unwind(field, preserveNullAndEmptyArrays) / include / exclude. Group accumulators: count / sum / avg / min / max / push / addToSet / first / last.
Raw JSON pipeline — MongoOps.aggregate(Class, String, Class) (plain JSON, no placeholder substitution — build the string yourself), plus raw Map output via the aggregateRaw variants when you don't want a DTO:
List<Map<String, Object>> rows = mongoOps.aggregateRaw(Character.class,
"[{$match: {area: 'Liyue'}}, {$count: 'n'}]");