A lightweight Java ORM inspired by Entity Framework (Entity Framework for Java = Entity4j)
Entity4j is a minimal, type-safe object relational mapper for Java. It lets you define entities with annotations, map them to database tables, and build queries with lambda expressions in a concise way. It also provides helpers to automatically create tables from annotated classes, with support for multiple database dialects including MySQL, PostgreSQL, SQL Server, and SQLite.
Built and tested against Java 1.8
- Quick Start
- Fluent Mappings
- Filters API
- Complex Query Example
- Column Selection
- CRUD Operations
- Debugging and SQL Output
- Advanced Features
- License
Entity4j is published to Maven Central, which is available from the default repository in both Gradle and Maven — no extra repository configuration is needed.
dependencies {
implementation 'org.oldskooler:Entity4j:1.0.0'
}<dependencies>
<dependency>
<groupId>org.oldskooler</groupId>
<artifactId>Entity4j</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>@Entity(table = "users")
publicclassUser {
@Id(auto = true)
privateLongid;
@Column(name = "full_name", length = 100, nullable = false)
privateStringname;
@Column(precision = 5, scale = 2)
privatejava.math.BigDecimalrating;
privateBooleanactive;
@NotMappedprivateStringcachedDisplayName;
// getters and setters...
}Entity4j supports fluent mappings like below, first you must extend DbContext. The the basic mapping first argument is the name of the field, the second argument is column name, or else you can add complexity by using column(...) seen below.
Fluent mappings will always take priority over annotations, if both are mapped.
publicclassUsersDbContextextendsDbContext {
publicUserDbContext(Connectionconnection, SqlDialectTypedialectType) {
super(connection, dialectType);
}
@OverrideprotectedvoidonModelCreating(ModelBuildermodel) {
model.entity(User.class)
.toTable("users")
.hasId("id", true) // auto-generated PK// basic mapping for defaults
.map("active", "is_active") // or even just: map("active")// advanced mapping
.column("name", c -> c
.name("full_name")
.length(100)
.nullable(false))
.column("rating", c -> c
.type("DECIMAL") // optional: or let dialect infer
.precision(5)
.scale(2)
.nullable(true)) // default true unless you want NOT NULL// @NotMapped
.ignore("cachedDisplayName")
// And finished mapping User!
.done();
}
}Entity4j supports MySQL, PostgreSQL, SQL Server, and SQLite. It can auto-detect the database dialect from the connection, but explicitly setting the dialect is recommended for reliability:
// Explicit dialect configuration (recommended)try (Connectionconn = DriverManager.getConnection(...);
DbContextctx = newDbContext(conn, SqlDialectType.MYSQL)) {
// Explicitly set to MySQL
}
// Auto-detection (Entity4j will detect dialect from connection) (not recommended)try (Connectionconn = DriverManager.getConnection(...);
DbContextctx = newDbContext(conn)) {
// Entity4j automatically detects the database type
}
// Other supported dialects:// SqlDialectType.POSTGRESQL// SqlDialectType.SQLSERVER // SqlDialectType.SQLITEThis will create the table and insert the user.
try (Connectionconn = DriverManager.getConnection(...);
DbContextctx = newDbContext(conn)) {
ctx.createTable(User.class);
ctx.insert(newUser("Ada Lovelace", 36, true));
}This example shows updating, querying, and deleting.
// UpdateUserada = ctx.from(User.class)
.filter(f -> f.equals(User::getName, "Ada Lovelace"))
.first()
.orElseThrow();
ada.setRating(newjava.math.BigDecimal("4.95"));
ctx.update(ada);
// Query with orderingList<User> results = ctx.from(User.class)
.filter(f -> f.equals(User::getActive, true))
.orderBy(o -> o
.col(User::getRating).desc())
.limit(5)
.toList();
// Deletectx.delete(ada);| Method | SQL | Example |
|---|---|---|
equals | = | f.equals(User::getName, "Ada Lovelace") |
notEquals | <> | f.notEquals(User::getAge, 40) |
greater | > | f.greater(User::getAge, 18) |
greaterOrEquals | >= | f.greaterOrEquals(User::getAge, 21) |
less | < | f.less(User::getAge, 65) |
lessOrEquals | <= | f.lessOrEquals(User::getAge, 100) |
like | LIKE | f.like(User::getName, "%Ada%") |
in | IN (...) | f.in(User::getAge, List.of(18, 21, 25)) |
and()→ANDor()→ORopen()→(close()→)
List<User> advanced = ctx.from(User.class)
.filter(f -> f.open()
.greaterOrEquals(User::getAge, 30)
.and()
.less(User::getAge, 60)
.close()
.or()
.open()
.equals(User::getActive, true)
.and()
.like(User::getName, "%Ada%")
.close())
.orderBy(o -> o
.col(User::getRating).desc())
.limit(10)
.toList();Generated SQL:
SELECT*FROM users WHERE (age >= ? AND age < ?) OR (active = ? AND full_name LIKE ?)
ORDER BY rating DESCLIMIT10[Params] ?1=30, ?2=60, ?3=TRUE, ?4='%Ada%'
This finds users between ages 30 and 60 or active users whose names contain "Ada", ordered by rating descending.
Entity4j provides powerful column selection capabilities that allow you to project only the columns you need, improving query performance and enabling you to shape your data exactly as needed.
Instead of selecting all columns with SELECT *, you can specify exactly which columns to retrieve:
// Select only specific columns from UserQuery<User> query = ctx.from(User.class)
.select(s -> s
.col(User::getId).as("user_id")
.col(User::getName).as("name")
.col(User::getRating).as("rating"))
.filter(f -> f.equals(User::getStatus, "ACTIVE"))
.orderBy(o -> o
.col(User::getName).asc());
// Get results as maps (no class binding required)List<Map<String, Object>> maps = query.toMapList();
// Or bind to a custom DTO classList<UserSummaryDto> summaries = query.toList(UserSummaryDto.class);Generated SQL:
SELECT id AS user_id, name AS name, rating AS rating
FROM users WHERE status = ?
ORDER BY name ASCEntity4j supports computed (derived) columns in queries. Computed columns allow you to define SQL expressions directly in the select builder while still keeping the type-safe, fluent Entity4j API.
This is useful for calculated fields (e.g., multipliers, concatenation, arithmetic operations) or any SQL expression that does not map directly to an entity property.
Queryquery = ctx.from(User.class).as("u")
.select(s -> s
.col(User::getId).as("user_id")
.col(User::getName).as("user_name")
.col(User::getRating).as("rating")
.computed(() -> s.columnName(User::getRating) + " * 2")
.as("double_rating")
);Generated SQL (dialect-dependent, simplified):
SELECTu.idAS user_id,
u.nameAS user_name,
u.ratingAS rating,
u.rating*2AS double_rating
FROM user ucol(...)adds a regular mapped column from an entity getter.computed(...)adds a raw SQL expression to the SELECT list.- The computed expression receives access to the same selector
s, so you can safely reference columns using:
s.columnName(User::getRating)This keeps expressions consistent with your mapping configuration and table aliases.
- Computed columns do not require a property on the entity.
- They can use any valid SQL expression supported by the active dialect.
- Use
.as("alias")to give the computed column a name, just like regular columns. - Computed columns work with aggregations, CASE expressions, and can be combined with entity-mapped columns.
Create a custom DTO class to hold your projected data:
publicclassUserSummaryDto {
privateLonguserId;
privateStringname;
privateDoublerating;
// getters and setters...
}Then select specific columns and map them to your DTO.
If it does not map to the correct column, you can explicitly set which column to map by using fluent mapping on the entity, or using @Column(name='user_id') above the field.
List<UserSummaryDto> summaries = ctx.from(User.class)
.select(s -> s
.col(User::getId).as("user_id") // Maps to DTO's userId field
.col(User::getName).as("name") // Maps to DTO's name field
.col(User::getRating).as("rating")) // Maps to DTO's rating field
.filter(f -> f.equals(User::getStatus, "ACTIVE"))
.orderBy(o -> o
.col(User::getName).asc())
.toList(UserSummaryDto.class);Important Note for Joined Entities: When selecting columns from joined tables, you must specify the entity class for the column reference:
// WRONG - This won't work for joined entities
.col(Order::getTotal).as("total")
// CORRECT - Specify Order.class for joined entity columns
.col(Order.class, Order::getTotal).as("total")
// Main entity doesn't need class specification
.col(User::getName).as("name")When you don't want to create a specific class, use toMapList() to get results as a list of maps:
List<Map<String, Object>> results = ctx.from(User.class)
.select(s -> s
.col(User::getId).as("id")
.col(User::getName).as("name")
.col(User::getRating).as("rating"))
.filter(f -> f.greater(User::getRating, 4.0))
.toMapList();
// Access the datafor (Map<String, Object> row : results) {
Longid = (Long) row.get("id");
Stringname = (String) row.get("name");
Doublerating = (Double) row.get("rating");
System.out.println(name + " has rating: " + rating);
}ctx.insert(newUser("Ada Lovelace", 36, true));
ctx.update(existingUser);
ctx.delete(existingUser);You can update one or more specific columns in bulk by filtering a query and providing a column setter. This avoids loading entities into memory.
ctx.from(User.class)
.filter(f -> f.equals(User::getStatus, "ACTIVE"))
.update(s -> s.set(User::getStatus, "temp"));Generated SQL (typical):
UPDATE users SET status = ? WHERE status = ?Use the lambda to chain additional
set(...)calls for multi-column updates if needed.
Delete rows directly with a filter, without fetching entities:
ctx.from(User.class)
.filter(f -> f.equals(User::getName, "temp"))
.delete();Generated SQL (typical):
DELETEFROM users WHERE name = ?Combine multiple conditions with the Filters API for precise targeting.
System.out.println(
ctx.from(User.class)
.filter(f -> f.equals(User::getName, "Ada Lovelace"))
.toSqlWithParams()
);Example entities.
@Entity(table = "users")
publicclassUser {
@Id(auto = true) privateLongid;
privateStringname;
privateStringstatus;
privateLocalDatecreatedAt;
// getters/setters
}
@Entity(table = "orders")
publicclassOrder {
@Id(auto = true) privateLongid;
privateLonguserId;
privateDoubletotal;
privateLocalDateTimeplacedAt;
// getters/setters
}List<User> users = ctx.from(User.class)
.orderBy(o -> o
.col(User::getStatus).asc()
.col(User::getCreatedAt).desc())
.toList();This produces SQL like:
SELECT*FROM users
ORDER BY status ASC, created_at DESCList<User> page = ctx.from(User.class)
.orderBy(o -> o
.col(User::getCreatedAt).desc()) // newest first
.offset(20) // skip first 20
.limit(10) // take next 10
.toList();SQL (Postgres/MySQL/SQLite dialects):
SELECT*FROM users
ORDER BY created_at DESCLIMIT10 OFFSET 20SQL (SQL Server dialect):
SELECT*FROM users
ORDER BY created_at DESC
OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLYList<User> richActiveUsers = ctx.from(User.class).as("u")
.leftJoin(Order.class, "o", on -> on.eq(User::getId, Order::getUserId)) // ON u.id = o.user_id
.filter(f -> f.equals(User::getStatus, "ACTIVE")
.and()
.greater(Order.class, Order::getTotal, 1000.0)) // o.total > 1000
.orderBy(o -> o
.col(User::getName).asc()
.col(Order.class, Order::getPlacedAt).desc())
.limit(50)
.toList();Output (Postgres/MySQL/SQLite style):
SELECT u.*FROM users u
LEFT JOIN orders o ONu.id=o.user_idWHEREu.status= ? ANDo.total> ?
ORDER BYu.nameASC, o.placed_atDESCLIMIT50Params would be [?1=ACTIVE, ?2=1000.0].
Here's a more complete example showing joins with column selection into a custom DTO:
publicclassUserOrderDto {
privateLongorderId;
privateLonguserId;
privateStringname;
privateDoubletotal;
// getters and setters...
}
// Query with join and column selectionList<UserOrderDto> results = ctx.from(User.class).as("u")
.innerJoin(Order.class, "o", j -> j.eq(User::getId, Order::getUserId))
.select(s -> s
.col(Order.class, Order::getId).as("order_id") // Note: Order.class required
.col(User::getId).as("user_id") // Main entity doesn't need class
.col(User::getName).as("name")
.col(Order.class, Order::getTotal).as("total")) // Note: Order.class required
.filter(f -> f.equals(User::getStatus, "ACTIVE"))
.orderBy(o -> o
.col(User::getName).asc()
.col(Order.class, Order::getPlacedAt).desc())
.toList(UserOrderDto.class);Generated SQL:
SELECTo.idAS order_id, u.idAS user_id, u.nameAS name, o.totalAS total
FROM users u
INNER JOIN orders o ONu.id=o.user_idWHEREu.status= ?
ORDER BYu.nameASC, o.placed_atDESCEntity4j is released under the Apache 2.0 license.