EzyQuery brings the best of both worlds: SQL's expressiveness and Java's compile-time safety, making database access both powerful and reliable. It is a Java library that bridges the gap between raw SQL and compile-time type-safe code.
It is lightweight with zero runtime dependencies.
Add the following to your build.gradle file.
buildscript {
repositories {
mavenCentral()
}
}
plugins {
id 'io.github.kayr.gradle.ezyquery' version '0.0.26'
}- Flexible Query fluent API e.g
where(Q.CUSTOMER_NAME.eq("John").and(Q.CUSTOMER_EMAIL.isNotNull())). - Query Expressions e.g
.where(Cnd.expr("customerName = 'John' and customerEmail is not null")). Ideally if you are building a Rest-API then clients get a powerful filtering API by passing the expressions as a parameter. The query is parsed and converted to a parameterized sql query. - Named parameters. You can add named parameters to static parts of your SQL query and these will recognize.
- Support for CTEs.
- If your sql is too complex or uses unsupported features you can still use it to generate APIs for your named params.
- All generated sql queries are parameterized to avoid sql injection.
- Automatic mapping of sql result to java pojo.
- The same query is used to count and list data. Which makes building pagination easy and prevents the need to write two queries.
- Sort by any field in the query. e.g
orderBy(GET_CUSTOMERS.CUSTOMER_NAME.asc()). - You can sort using a string expression. e.g
customerName asc, customerEmail desc. - Gradle plugin to generate the java code from your sql files.
- Maven plugin
Converts your SQL query to A queryable Java API/Code... think of A Queryable View In Your Code Using Java
This will work for most sql queries in the
format WITH ... <CTE> ... SELECT ... FROM ... WHERE ... JOIN ... ORDER BY ... LIMIT ... OFFSET ...
- Write your sql query in a file ending with
.ez.sql. in the directorysrc/main/ezyquery. For better organisation consider adding the files in the similar package structure and your sources code.The above query has two types of queries dynamic and static.-- file: customer-queries.sql -- ## dynamic:get all customers SELECT c.id as customerId_long, lower(c.customerName) as customerName_string, c.email as customerEmail_string, c.phone as customerPhone, c.score as customerScore FROM customers c; -- ##dynamic:get orders SELECT o.id as customerId_long, c.cutomerName as customerName_string, c.email as customerEmail, o.item as item, o.price as price_double, o.quantity as quantity FROM orders o inner join customers c on c.id = o.customerId and c.membership = :membership WHERE c.membership = :membership; -- ## static: update customer score update customers c set c.score = :score where email = :email;
- Dynamic queries: These are pre-processed by ezy-query to allow for advanced filtering, sorting, and
pagination. If you prefix your query name with dynamic e.g
-- ## dynamic:get all customersthen ezy query will pre-process to allow for advance feature. Only select statements can be dynamic queries. - Static queries: These only get basic preprocessing to extract the named parameters and mostly left untouched.
These are useful for other types of queries for example insert, delete, update statements or any other types of
sql. EzyQuery will not try to validate the sql syntax like the case for dynamic queries. These are creating by
prefixing the name with static: e.g.
## static: update customer status
- Dynamic queries: These are pre-processed by ezy-query to allow for advanced filtering, sorting, and
pagination. If you prefix your query name with dynamic e.g
- Run
./gradlew ezyBuildto convert your SQL query file to a java class. This will generate a java class for you. The class name will be generated based on your file name. The classCustomerQuerieswill contain two query classes that can be accessed via the static methods e.g.CustomerQueries.getAllCustomers(). Below is a snippet of what will be generated.//This is a snippet of the generated class public class CustomerQueries { public static GetAllCustomers getAllCustomers() {...} public static GetOrders getOrders() {...} //this is generated by the get all get all customers query public static class GetAllCustomers implements EzyQueryWithResult<GetAllCustomers.Result> { ... public static class Result implements DynamicFieldSetter { ... } } //this is generated by the get orders query public static class GetOrders implements EzyQueryWithResult<GetOrders.Result> { ... public static class Result implements DynamicFieldSetter { ... } } }
- Setup up EzySql instance that will act as an entry point to using EzyQuery. Below is an example with Hikari
var config = new HikariConfig(); //... EzySql ezySql = EzySql.withDataSource(new HikariDataSource(config))
- You can now use the generated class to query your database with a flexible easy to use api.
The example below that fetches/filters/sorts customers from the db whose name is
Johnand email is not null;var Q = CustomerQueries.getAllCustomers() var result = ezySql.from(Q) .where(Q.CUSTOMER_NAME.eq("john").and(Q.CUSTOMER_EMAIL.isNotNull())) .orderBy(Q.CUSTOMER_NAME.asc(), Q.CUSTOMER_EMAIL.desc()) .offset(0) .limit(10) .listAndCount() assert result.getCount() > 0 assert !result.getList().isEmpty() assert result.getList().get(0).getCustomerName().equals("john")
- Other features
- Working with spring boot
- Filtering
- Sorting and pagination
- Named Parameters
- Executing other types of queries(INSERT/UPDATE/DELETE etc)
- Specifying a custom result mapper
- Adding a default where clause to a generate query
- Adding data types to the generated pojo.
- Overriding default type mappings e.g for newer JDBC drivers.
- Optionally select fields to be returned.
- Using on older versions of Gradle.
If you are using spring boot, you can do this by creating a bean of type EzySql in your spring configuration. Then
inject the bean
into your code using the @Autowired annotation.
@Bean
public EzySql ezyQuery(DataSource dataSource) {
return EzySql.withDataSource(dataSource);
}You have multiple options to filter provide filters to your query:
-
Through the fluent API
var Q = CustomerQueries.getAllCustomers() var sql = ezySql.from(Q) .where(Q.CUSTOMER_NAME.eq("John").and(Q.CUSTOMER_EMAIL.isNotNull()))
The above will generate the below sql query. Notice how it replaces the customer name with the full function call
lower(c.customerName). Expressions can be as complex as you need them to be.SELECT c.id as "customerId", lower(c.customerName) as "customerName", c.email as "customerEmail", c.phone as "customerPhone", c.score as "customerScore" FROM customers c WHERE (lower(c.customerName) = ? AND c.email IS NOT NULL) LIMIT 50 OFFSET 0
-
Filtering with the condition API: This one is similar to building an AST with and offers alot more predictability of the resulting filter at the cost of readability(lispy style).
var Q = CustomerQueries.getAllCustomers() var sql = ezySql.from(Q) .where(Cnd.and(Cnd.eq(Q.CUSTOMER_NAME, "John"), Cnd.isNotNull(Q.CUSTOMER_EMAIL)))
-
Filtering with the ezy-query expression: This allows you pass filter strings directly to ezy-query. This can be useful where you want to allow client code to dynamically filter the data from the frontend.
var Q = CustomerQueries.getAllCustomers() var sql = ezySql.from(Q) .where(Cnd.expr("customerName = 'John' and customerEmail is not null"))
NOTE: To use this feature you will need to add JSQLParser to your classpath. If you are using gradle you can add it like this
dependency { implementation 'com.github.jsqlparser:jsqlparser:4.8' }or maven
<dependency> <groupId>com.github.jsqlparser</groupId> <artifactId>jsqlparser</artifactId> <version>4.8</version> </dependency>
-
Filtering with maps: This is where you can use the java maps to build filters for a query. This is useful for example when you need to pass query params from an http call directly to your query: e.g
http://example.com?customerName.eg=John&customerEmail.isNotNullvar Q = CustomerQueries.getAllCustomers() var sql = ezySql.from(Q) .where(Cnd.fromMap( Map.of("customerName.eq", "John", "customerEmail.isnotnull", Optional.empty())))//use optional empty or null to show that we have no value here
Most http server libraries use multivalue maps so there is alternate method for creating a criteria from a multivalue map.
var Q = CustomerQueries.getAllCustomers() def filterMap = new HashMap<String, List<?>>() filterMap.put("customerName.eq", List.of("John")) filterMap.put("customerEmail.isnotnull", Collections.emptyList())//empty list to show we have no values here var sql = ezySql.from(Q) .where(Cnd.fromMvMap(filterMap))
*Supported map operators for a map include:"
eq,neq,like,notlike,gt,gte,lt,lte,in,notin,between,notbetween,isnull,isnotnull -
Filtering with native sql: Sometimes there will be operations not supported by ezy-query for this case you can always fall back to using native sql. Be careful with this method, always paremetirize your variables to avoid sql injection
var Q = CustomerQueries.getAllCustomers() var sql = ezySql.from(Q) .where(Cnd.sql("c.customerName = ? AND c.email IS NOT NULL", "John"))
Sort using fields
var Q = CustomerQueries.getAllCustomers()
var sql = ezySql.from(Q)
.orderBy(Q.CUSTOMER_NAME.asc(), Q.CUSTOMER_EMAIL.desc())
.limit(10)
.offset(20)Sort using strings expression
var Q = CustomerQueries.getAllCustomers()
var sql = ezySql.from(Q)
.orderBy("customerName asc, customerEmail desc")
.limit(10, 20) //another alternative for paginationSort using Sort Object
var Q = CustomerQueries.getAllCustomers()
var sql = ezySql.from(Q)
.orderBy(Sort.by("customerName", Sort.DIR.ASC))You can add named parameters to static parts of your sql query and pass them at runtime. This is useful when some parts of the query are not necessarily dynamic e.g if you have an sql query that has derived tables that need named params.
Name parameters are supported in the where clause, join conditions and order by clauses.
Given the following sql query.
-- file: get-customers.sql
SELECT o.id as customerId,
c.name as customerName,
c.email as customerEmail,
o.item as item,
o.price as price,
o.quantity as quantity
FROM orders o
inner join customers c on c.id = o.customerId and c.membership = :membership
WHERE c.membership = :membershipYou can pass the named parameter :membership at runtime as follows.
var Q = CustomerQueries.getOrders()
var P = CustomerQueries.GetOrders.PARAMS
var sql = ezySql.from(Q)
.where(Q.PRICE.gt(100).and(Q.QUANTITY.lt(10)))
.setParam(P.MEMBERSHIP, "GOLD")This will generate the following sql query along with the params.
SELECT o.id as "customerId",
c.cutomerName as "customerName",
c.email as "customerEmail",
o.item as "item",
o.price as "price",
o.quantity as "quantity"
FROM orders o
INNER JOIN customers c ON c.id = o.customerId AND c.membership = ?
WHERE (c.membership = ?)
AND (o.price > ? AND o.quantity < ?)
LIMIT 50 OFFSET 0
-- params=[GOLD, GOLD, 100, 10]}You can see that the GOLD param has been added to the list of params.
Ezyquery was mainly built to provide dynamically ways to filter your data at runtime using a relatively safe and easy to use API. However, you can also use it to execute any arbitrary sql query like inserts, deletes or updates. Parameters are extracted and made available as method calls in your query.
Example:
When you have the query below.
-- ## static: update customer
update customers c
set c.score = :score
where email = :email;Below is how you would execute it using the ezy query sql utility classes.
//set up the datasource
DataSource ds = new HikariDataSource(config)
//create the Zql instance which is a convenient api around jdbc
var zql = new Zql(ConnectionProvider.of(ds)
//var zql = ezySql.getZql() // or you can get it from EzySql instance
var Q = CustomerQueries.updateCustomer()
var updateCount = zql.update(
Q.email("john@example.com") //these functions are generated from the query param in the sql query
.score(10)
.getQuery())
assert updateCount > 0You can specify a custom mapper to control how you want the results to be returned or mapped from the database. E.g Instead of returning a list of pojos you can return a list of maps. Here is an example.
We already have a built-in mapper that converts the result to a map. You can use it as follows.
var Q = CustomerQueries.getAllCustomers()
List<Map> result = ezySql.from(Q)
.mapTo(Mappers.toMap())
.list()For illustration purposes we will create a custom mapper that converts the result to a map. See code below.
var Q = CustomerQueries.getAllCustomers()
List<Map> result = ezySql.from(Q)
.mapTo((rowIndex, columns, resultSet) -> {
Map<String, Object> map = new HashMap<>();
for (ColumnInfo column : columns) {
map.put(column.getLabel(), resultSet.getObject(column.getLabel()));
}
return map;
})
.list();Just add a default where clause to your base sql query, then all your queries will have this where clause. An example use-case is where you always want to fetch active customers
-- file: get-customer.sql
SELECT c.id as customerId,
c.name as customerName,
c.email as customerEmail,
c.score as customerScore
FROM customers c
WHERE c.status = 'active'The above will add the where clause c.status = 'active' to all queries generated from the above query.
The generated result pojo by default will have all fields as Object.
You can add a data type to the generated pojo by adding a suffix to the field name aliases like below.
-- file: get-customer.sql
SELECT c.id as customerId_int,
c.name as customerName_string,
c.score as customerScore_double, ....With the above sql,the generated pojo will have the following fields.
... // code ommited for brevity
private Integer customerId;
private String customerName;
private Double customerScore;
...The supported data types are:
intlongdoublefloatstringbooleandatetimedecimalbigintbyteobject
If these types are not enough for you, you can add your own custom types by specifying custom type mappings in
the ezy-query.properties file.
In the root of the ezy-query source directory, create a file called ezy-query.properties and add the following.
# file: ezy-query.properties
#add your custom type mappings here
#the format is type.<type>=<java type>
#e.g
type.customtype=java.time.LocalDate
type.vector=java.util.VectorThen in your sql file you can use the custom type as follows.
-- file: get-customer.sql
SELECT c.id as customerId_customtype, -- specify the custom type
c.name as customerName_string,
c.score as customerTags_vector, -- specify the custom vector type
....The generated pojo will have the following fields.
//.... code ommited for brevity
private LocalDate customerId;
private String customerName;
private Vector customerTags;
...Some JDBC drivers may return types that are not supported by default. e.g newer mysql drivers
return java.time.LocalDate or java.util.LocalTime for date and time types respectively. You can override the
default mappings by specifying your own custom mappings.
# file: ezy-query.properties
type.date=java.time.LocalDate
type.time=java.time.LocalTime var Q = CustomerQueries.getAllCustomers()
var result = ezySql.from(Q)
.select(Q.CUSTOMER_NAME, Q.CUSTOMER_EMAIL)
.list()
assert result.size() > 0
assert result.get(0).customerName != null
assert result.get(0).customerId == null//we did not select this.. so it will be nullIn the future, we will support older versions. For older versions
add the script below as a workaround.
The script adds the necessary tasks to your build.gradle file.
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath "io.github.kayr:ezy-query-codegen:<version>" //see the latest above
}
}
task("ezyBuild") {
def input = file("src/main/ezyquery").toPath()
def output = file("build/generated/ezy/main").toPath()
doLast {
if (input.toFile().exists()) {
Files.createDirectories(output)
BatchQueryGen.generate(input, output)
}
}
}
task("ezyClean") {
doLast {
project.delete("build/generated/ezy/")
}
}
sourceSets {
main {
java {
srcDir "build/generated/ezy/main"
}
}
test {
java {
srcDir "build/generated/ezy/test"
}
}
}