Skip to content
/ ezy-query Public

Convert Your Sql Query To A Queryable Java API/Code.. think of A Queryable View In Your Code Using Java

License

Notifications You must be signed in to change notification settings

kayr/ezy-query

Repository files navigation

EzyQuery: Bring back the power of raw SQL in Java.

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.

Download

Maven Central

Add the following to your build.gradle file.

buildscript {
    repositories {
        mavenCentral()
    }
}

plugins {
    id 'io.github.kayr.gradle.ezyquery' version '0.0.26'
}

Features

  1. Flexible Query fluent API e.g where(Q.CUSTOMER_NAME.eq("John").and(Q.CUSTOMER_EMAIL.isNotNull())).
  2. 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.
  3. Named parameters. You can add named parameters to static parts of your SQL query and these will recognize.
  4. Support for CTEs.
  5. If your sql is too complex or uses unsupported features you can still use it to generate APIs for your named params.
  6. All generated sql queries are parameterized to avoid sql injection.
  7. Automatic mapping of sql result to java pojo.
  8. The same query is used to count and list data. Which makes building pagination easy and prevents the need to write two queries.
  9. Sort by any field in the query. e.g orderBy(GET_CUSTOMERS.CUSTOMER_NAME.asc()).
  10. You can sort using a string expression. e.g customerName asc, customerEmail desc.
  11. Gradle plugin to generate the java code from your sql files.
  12. Maven plugin

How it works

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

  1. Write your sql query in a file ending with .ez.sql. in the directory src/main/ezyquery. For better organisation consider adding the files in the similar package structure and your sources code.
    -- 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;
    The above query has two types of queries dynamic and static.
    • 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 customers then 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
  2. Run ./gradlew ezyBuild to 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 class CustomerQueries will 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 {
             ...
             }
        }
    }
  3. 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))
  4. 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 John and 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")

Usage

Other features

Working with spring boot

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

Filtering

You have multiple options to filter provide filters to your query:

  1. 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
  2. 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)))
  3. 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>
  4. 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.isNotNull

            var 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

  5. 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"))

Sorting and pagination

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 pagination

Sort using Sort Object

        var Q = CustomerQueries.getAllCustomers()
        var sql = ezySql.from(Q)
                .orderBy(Sort.by("customerName", Sort.DIR.ASC))

Named Parameters

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 = :membership

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

Executing other types of queries(INSERT/UPDATE/DELETE etc)

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 > 0

Specifying a custom result mapper

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

Adding a default where clause to a generate query

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.

Adding data types to the generated pojo.

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:

  • int
  • long
  • double
  • float
  • string
  • boolean
  • date
  • time
  • decimal
  • bigint
  • byte
  • object

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

Then 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;
  ...

Overriding default type mappings e.g for newer JDBC drivers.

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

Optionally selecting fields to be returned.

        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 null

Using on older versions of Gradle.

In 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"
        }
    }
}

About

Convert Your Sql Query To A Queryable Java API/Code.. think of A Queryable View In Your Code Using Java

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published