DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • How To Work Effectively With JDBC in Java Scripts
  • Java EE 6 Pet Catalog with GlassFish and MySQL
  • Keep Your Application Secrets Secret
  • Implementing Infinite Scroll in jOOQ

Trending

  • Getting Started With Agentic Workflows in Java and Quarkus
  • 7 Technology Waves I’ve Seen in 30 Years of Software — Will AI Be the Next Real Transformation?
  • From 24 Hours to 2 Hours: How We Fixed a Broken BI System With Apache Airflow
  • Building AI-Powered Java Applications With Jakarta EE and LangChain4j
  1. DZone
  2. Coding
  3. Java
  4. Native SQL in Java Without JDBC Boilerplate — Meet Ujorm3

Native SQL in Java Without JDBC Boilerplate — Meet Ujorm3

Ujorm3 eliminates JDBC boilerplate without a full ORM. Write native SQL with named parameters, get objects back — including nested relations.

By 
Pavel Ponec user avatar
Pavel Ponec
·
Jun. 11, 26 · Tutorial
Likes (0)
Comment
Save
Tweet
Share
247 Views

Join the DZone community and get the full member experience.

Join For Free

If you've ever written raw JDBC, you know what's coming. Open a connection, create a PreparedStatement, set parameters by index (hope you counted right), iterate a ResultSet, close everything in a finally block, declare SQLException on every method signature… It's a lot of ceremony for "give me some rows."

I've been experimenting with Ujorm3, a new lightweight ORM library for Java 17+. Here's a realistic example — a JOIN query that maps results including a nested relation:

Java
 
static final ResultSetMapper<Employee> EMPLOYEE_MAPPER =
        ResultSetMapper.of(Employee.class);

List<Employee> findEmployees(Connection connection, Long minId) {
    return SqlQuery.run(connection, query -> query
            .sql("""
                    SELECT e.id, e.name, c.name AS "city.name"
                    FROM employee e
                    JOIN city c ON c.id = e.city_id
                    WHERE e.id >= :minId
                    """)
            .bind("minId", minId)
            .toStream(EMPLOYEE_MAPPER.mapper())
            .toList());
}


Let me walk through what makes this tick.

Fluent API

The whole operation is one readable chain. No juggling Statement objects, no passing things between methods — you declare the SQL, bind parameters, specify the mapper, and collect. Done.

Named Parameters Instead of Positional ?

Classic JDBC:

Java
 
stmt.setLong(1, minId); // hope you counted correctly


Ujorm3:

Java
 
.bind("minId", minId)


You reference parameters by name in the SQL (:minId) and bind them by name. No counting, no off-by-one errors when you insert a new parameter in the middle of a query, and the SQL stays readable.

No Checked Exceptions

SQLException is a checked exception, so vanilla JDBC forces you to handle or rethrow it everywhere — even when there's nothing useful to say. Ujorm3 wraps these internally, so your methods stay clean:

Java
 
// JDBC — forced to declare or catch
List<Employee> findEmployees(Connection c, Long minId) throws SQLException { ... }

// Ujorm3 — nothing to declare
List<Employee> findEmployees(Connection connection, Long minId) { ... }


Smart Object Mapping — Including Relations

ResultSetMapper is a thread-safe class that prepares its mapping model on first use and reuses it across all subsequent calls. This significantly reduces overhead when processing a large number of queries.

Mapping is inferred automatically by default. You can optionally annotate your domain classes with standard jakarta.persistence annotations (@Table, @Column, @Id) for explicit control, but they're not required.

The interesting bit is how it handles relations. The aliased column "city.name" uses dot notation to map directly into a nested object — no extra configuration needed:

SQL
 
-- maps to employee.getCity().getName() automatically
c.name AS "city.name"


The library supports M:1 relations. 1:M collections are intentionally left out — a deliberate design choice to avoid hidden queries and N+1 problems.

Want Compile-Time Safety? There's a Metamodel for That

The string-based alias approach works great for getting started, but if you want the compiler to catch typos in column mappings, the optional APT plugin generates Meta* classes from your domain objects. The query then looks like this:

Java
 
List<Employee> findEmployees(Connection connection, Long minId) {
    return SqlQuery.run(connection, query -> query
            .sql("""
                    SELECT e.id  AS ${e.id}
                    , e.name     AS ${e.name}
                    , c.name     AS ${c.name}
                    FROM employee e
                    JOIN city c ON c.id = e.city_id
                    WHERE e.id >= :id
                    """)
            .label("e.id",   MetaEmployee.id)
            .label("e.name", MetaEmployee.name)
            .label("c.name", MetaEmployee.city, MetaCity.name)
            .bind("id", minId)
            .toStream(EMPLOYEE_MAPPER.mapper())
            .toList());
}


The ${placeholder} syntax in the SQL template and the label() method work together — the metamodel keys are type-parameterized descriptors that resolve column labels at runtime and carry full type information.

Automatic Resource Management

SqlQuery.run(...) handles closing the underlying PreparedStatement and ResultSet for you. No try-with-resources, no resource leaks if mapping throws partway through.

There's More Than Just SqlQuery

The library offers three levels of abstraction — pick what fits your use case:

  • EntityManager – the fastest path for CRUD on a single table using a primary key; generates the SQL itself.
  • SelectQuery – for fetching data including relations; supports type-safe Criterion filters composable with AND/OR operators; JOIN type (INNER vs LEFT) is inferred automatically from the nullable property of @Column.
  • SqlQuery – low-level, full native SQL control; what we've been looking at above.

SelectQuery in Action

In many cases, the full SELECT statement — columns, JOINs, and WHERE clause — can be generated automatically by SelectQuery from the metamodel, so you don't have to write SQL at all. You still get the same object mapping under the hood.

First, set up the shared context and entity manager (once, typically as static fields):

Java
 
// EntityContext controls SQL logging; false = no param values in logs
static final EntityContext CTX = EntityContext.ofSqlInfoWithParams(false);
static final EntityManager<Employee, Long> EMPLOYEE_EM = CTX.entityManager(Employee.class);


Then the query itself:

Java
 
List<Employee> findEmployees(Connection connection, Long minId) {
    return SelectQuery.run(connection, EMPLOYEE_EM, query -> query
            .columns(true)                            // select all columns, including foreign keys
            .column(MetaEmployee.city, MetaCity.name) // add the city.name JOIN column
            .where(MetaEmployee.id.whereGe(minId))    // WHERE id >= minId
            .tail("ORDER BY", MetaEmployee.id)        // append raw SQL fragment at the end
            .toList()
    );
}


A few things worth noting:

  • .columns(true) expands to all mapped columns of Employee, including foreign key values (e.g. city_id). The true argument does not affect JOIN generation yet — that is driven by the next call.
  • .column(MetaEmployee.city, MetaCity.name) adds a specific column from a related entity. The library resolves which JOIN to emit based on the metamodel.
  • .where(...) takes a type-safe Criterion. Conditions compose naturally with .and() / .or(), and because they're built from metamodel descriptors, a typo in an attribute name is a compile error, not a runtime surprise.
  • .tail("ORDER BY", MetaEmployee.id) appends a raw SQL fragment after the generated WHERE clause — a handy escape hatch for ORDER BY, LIMIT, window hints, or anything else the query builder doesn't cover.

The result mapping works exactly the same way as in the SqlQuery examples above — same ResultSetMapper machinery, same dot-notation for nested objects.

Performance

Instead of reflection, the library generates and compiles its own bytecode at runtime for reading and writing domain object fields — performance comparable to handwritten code. In benchmark comparisons against Hibernate, Jdbi, MyBatis, and others (running on PostgreSQL and H2) it performs very well. The entire compiled module, including Ujorm3 itself, is under 3 MB, which is nice for microservices.

What This Is NOT

Not Hibernate. No entity scanning, no session factory, no proxy objects, no lazy
loading surprises. You write SQL, you get objects back.

Not jOOQ either — there's no Java DSL for building queries. You write plain SQL strings, which means you get full access to any database-specific syntax: window functions, CTEs, vendor extensions, whatever your DB supports.

Getting Started

Java 17+, final version 3.0.0 available on Maven Central:

XML
 
<dependency>
    <groupId>org.ujorm</groupId>
    <artifactId>ujo-core</artifactId>
    <version>3.0.3</version>
</dependency>
<dependency>
    <groupId>org.ujorm</groupId>
    <artifactId>ujorm-orm</artifactId>
    <version>3.0.3</version>
</dependency>


Optional APT plugin for metamodel generation:

XML
 
<annotationProcessorPaths>
    <path>
        <groupId>org.ujorm</groupId>
        <artifactId>ujorm-meta-processor</artifactId>
        <version>3.0.3</version>
    </path>
</annotationProcessorPaths>


Integration tests cover PostgreSQL, MySQL, MariaDB, Oracle, and MS SQL Server (all via Docker).

When Does This Make Sense?

If you need JPA portability across databases or your company mandates a standard ORM, use Hibernate. If you want full SQL control, transparent behavior, and no hidden magic — and you'd rather not write raw JDBC — this hits a nice sweet spot.

Useful links:

  • Project homepage
  • PetStore demo
  • Benchmark tests
  • JavaDoc
  • More examples as JUnit tests

Curious whether others are using similar lightweight wrappers, or if you've landed on a different approach for native SQL without going full ORM.

Java Database Connectivity MySQL Java (programming language) sql

Published at DZone with permission of Pavel Ponec. See the original article here.

Opinions expressed by DZone contributors are their own.

Related

  • How To Work Effectively With JDBC in Java Scripts
  • Java EE 6 Pet Catalog with GlassFish and MySQL
  • Keep Your Application Secrets Secret
  • Implementing Infinite Scroll in jOOQ

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook