Skip to content

Repository files navigation

mongo-flex

中文

What is it?

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.

Compatibility

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 (or spring-boot-autoconfigure) — provided by your Spring Boot project
  • mongodb-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.

Quick Start

1. Add dependency

<dependency>
    <groupId>io.github.eacryo</groupId>
    <artifactId>mongo-flex</artifactId>
    <version>1.0.0-SNAPSHOT</version>
</dependency>

2. Configure connection

mongo-flex:
  uri: mongodb://localhost:27017/mydb

Without multi-tenancy, a default MongoClient is created from mongo-flex.uri.

3. Define your entity

@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;
}

4. Create a repository interface

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);
}

5. Use it

@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);

Entity-to-Wrapper Factory

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)
);

Field Projection (include / exclude)

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 mode

MongoDB does not allow mixing include and exclude on non-_id fields. If include() is called first, exclude() can only suppress _id.

Field Name Resolution from Lambda Methods

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 for boolean active or boolean isActive. The resolver always assumes the former (JavaBeans convention). If your field is literally named isActive, use @CollectionField("is_active") to override the MongoDB field mapping, or rename your Java field to active.

Nested Field Queries (dot notation)

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 (regionhome_region, mainCitymain_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 @CollectionField mapping), 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 identical

Auto-fill Date/Time Fields

Use @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 & update

Built-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: providerClass on annotation → global DateValueProvider bean → built-in type table.

6. Entity Inheritance

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 LiyueCharacter

Why 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.

7. Multi-tenancy (optional)

mongo-flex:
  enable-multi-tenants: true
  tenants:
    - name: tenantA
      uri: mongodb://localhost:27017/db_a

Set 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.

8. Value type converters (pluggable)

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); }
    };
}

9. Whole-collection operations (explicit escape hatches)

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();

10. Aggregation (@Aggregate / MongoOps / AggregationWrapper)

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'}]");

About

No description, website, or topics provided.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages