<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Java Tech Blogger]]></title><description><![CDATA[Java Tech Blogger]]></description><link>https://fredfeng.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 23 Sep 2026 19:09:37 GMT</lastBuildDate><atom:link href="https://fredfeng.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[EasyJPA: Type-Safe Dynamic Queries in Three Lines, Not Thirty]]></title><description><![CDATA[Every query is a lambda. Every join has a name. Not a single JPQL string.

EasyJPA is a Spring Boot starter that puts a fluent, lambda-driven API over the JPA Criteria API. You keep the type safety an]]></description><link>https://fredfeng.hashnode.dev/easyjpa-type-safe-dynamic-queries-in-three-lines-not-thirty</link><guid isPermaLink="true">https://fredfeng.hashnode.dev/easyjpa-type-safe-dynamic-queries-in-three-lines-not-thirty</guid><dc:creator><![CDATA[Fred Feng]]></dc:creator><pubDate>Sun, 20 Sep 2026 13:43:37 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p><strong>Every query is a lambda. Every join has a name. Not a single JPQL string.</strong></p>
</blockquote>
<p><strong>EasyJPA</strong> is a Spring Boot starter that puts a fluent, lambda-driven API over the JPA Criteria API. You keep the type safety and the dynamic query building that Criteria gives you, and you drop the <code>CriteriaBuilder</code> / <code>Root</code> / <code>Predicate[]</code> ceremony that makes it unreadable. Joins, subqueries, grouping, pagination, updates, deletes and native SQL — all of it in one chain you can read top to bottom.</p>
<p>Here is the whole pitch in one screenshot's worth of code.</p>
<p><strong>The Criteria API:</strong></p>
<pre><code class="language-java">CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery&lt;User&gt; cq = cb.createQuery(User.class);
Root&lt;User&gt; root = cq.from(User.class);
List&lt;Predicate&gt; predicates = new ArrayList&lt;&gt;();
predicates.add(cb.equal(root.get("username"), "Jack"));
predicates.add(cb.equal(root.get("password"), "123456"));
cq.select(root).where(cb.and(predicates.toArray(new Predicate[0])));
User user = em.createQuery(cq).getSingleResult();
</code></pre>
<p><strong>EasyJPA:</strong></p>
<pre><code class="language-java">User user = userDao.query()
        .filter(new FilterList().eq(User::getUsername, "Jack").eq(User::getPassword, "123456"))
        .selectThis().one();
</code></pre>
<p>Same query. Same type safety. Same generated SQL.</p>
<hr />
<h2>Install it in two steps</h2>
<p><strong>Step 1</strong> — add the dependency.</p>
<pre><code class="language-xml">&lt;dependency&gt;
    &lt;groupId&gt;com.github.paganini2008&lt;/groupId&gt;
    &lt;artifactId&gt;easyjpa-spring-boot-starter&lt;/artifactId&gt;
    &lt;version&gt;2.0.0-SNAPSHOT&lt;/version&gt;  &lt;!-- Spring Boot 4; on Spring Boot 3 take 1.0.0-SNAPSHOT --&gt;
&lt;/dependency&gt;
</code></pre>
<p>The current version is <code>2.0.0-SNAPSHOT</code>, which lives in the snapshot repository of the Central Portal, so name that repository too:</p>
<pre><code class="language-xml">&lt;repositories&gt;
    &lt;repository&gt;
        &lt;id&gt;central-portal-snapshots&lt;/id&gt;
        &lt;url&gt;https://central.sonatype.com/repository/maven-snapshots/&lt;/url&gt;
        &lt;releases&gt;&lt;enabled&gt;false&lt;/enabled&gt;&lt;/releases&gt;
        &lt;snapshots&gt;&lt;enabled&gt;true&lt;/enabled&gt;&lt;/snapshots&gt;
    &lt;/repository&gt;
&lt;/repositories&gt;
</code></pre>
<p><strong>Step 2</strong> — point Spring Data at EasyJPA's repository implementation.</p>
<pre><code class="language-java">@EntityScan(basePackages = {"com.example.entity"})
@EnableJpaRepositories(repositoryFactoryBeanClass = HibernateEntityDaoFactoryBean.class,
        basePackages = {"com.example.dao"})
@Configuration(proxyBeanMethods = false)
public class JpaConfig {
}
</code></pre>
<p>That's it. Now every DAO extends <code>EntityDao</code> instead of <code>JpaRepository</code>:</p>
<pre><code class="language-java">public interface UserDao extends EntityDao&lt;User, Long&gt; {
}
</code></pre>
<p><code>EntityDao</code> <strong>is</strong> a <code>JpaRepository</code> — <code>save</code>, <code>findById</code>, <code>deleteAll</code> and the rest are all still there. EasyJPA just adds the query builders on top.</p>
<hr />
<h2>The model used below</h2>
<p>Every example in this post is lifted from EasyJPA's own test suite, which runs against a small e-commerce schema:</p>
<pre><code class="language-plaintext">User  ──&lt; Order ──&lt; OrderProduct &gt;── Product
                                         │
                                       Stock
</code></pre>
<p><code>User</code> has <code>username</code>, <code>email</code>, <code>vip</code>. <code>Order</code> has <code>totalPrice</code>, <code>orderDate</code>, <code>status</code>. <code>Product</code> has <code>name</code>, <code>price</code>, <code>discount</code>, <code>location</code>. Nothing surprising.</p>
<hr />
<h2>Filtering</h2>
<p><code>Restrictions</code> builds a single condition. <code>FilterList</code> chains several.</p>
<pre><code class="language-java">userDao.count(Restrictions.eq(User::getVip, true));
userDao.count(Restrictions.isNull(User::getEmail));
userDao.count(Restrictions.in(User::getUsername, List.of("Jack", "Petter", "Nobody")));
userDao.count(Restrictions.like(User::getEmail, "jpatest"));
</code></pre>
<p>Every one of them takes a method reference, so a renamed field is a compile error rather than a runtime surprise.</p>
<p>Negation is a method, not a different class:</p>
<pre><code class="language-java">Restrictions.in(User::getUsername, usernames).not()          // not in
Restrictions.notLike(User::getEmail, "00")
    .or(Restrictions.eq(User::getUsername, "Jack"))          // or
</code></pre>
<p>And conditions nest the way you'd write them on a whiteboard — <code>vip or (username in ('Scott','Lee') and email is not null)</code>:</p>
<pre><code class="language-java">List&lt;User&gt; users = userDao.query()
        .filter(Restrictions.eq(User::getVip, true)
                .or(new FilterList().in(User::getUsername, List.of("Scott", "Lee"))
                        .and().notNull(User::getEmail)))
        .sort(JpaSort.asc(User::getUsername))
        .selectThis().list();
</code></pre>
<hr />
<h2>Joining</h2>
<p>Join by a lambda and EasyJPA works out which table you're growing from. Give every join a short alias — that's the name you'll use later.</p>
<pre><code class="language-java">orderProductDao.customPage()
        .join(OrderProduct::getOrder, "o", null)      // OrderProduct -&gt; Order
        .join(Order::getUser, "u", null)              // Order        -&gt; User
        .join(OrderProduct::getProduct, "p", null)    // OrderProduct -&gt; Product, a second branch
</code></pre>
<pre><code class="language-sql">from example_order_product op1_0
join example_order o1_0 on o1_0.id = op1_0.order_id
join example_user u1_0 on u1_0.id = o1_0.user_id
join example_product p1_0 on p1_0.id = op1_0.product_id
</code></pre>
<p>Notice the third join branches back off <code>OrderProduct</code> rather than continuing from <code>User</code>. The lambda carries its own entity, so the tree comes out the way it reads.</p>
<p><code>leftJoin</code>, <code>rightJoin</code> and <code>crossJoin</code> are all there too, and an <code>on</code> condition is just the third argument:</p>
<pre><code class="language-java">orderDao.customQuery().leftJoin(Order::getOrderProducts, "op",
                                Restrictions.gt("op", "amount", 10))
</code></pre>
<hr />
<h2>Grouping, aggregating, and mapping to a VO</h2>
<pre><code class="language-java">List&lt;UserOrderVo&gt; dataList = userDao.customQuery()
        .leftJoin(User::getOrders, "o", null)
        .groupBy(new FieldList(User::getUsername))
        .sort(JpaSort.asc(User::getUsername))
        .select(new ColumnList().addColumns(User::getUsername)
                .addColumns(Fields.count(Order::getId).as("orderAmount"),
                            Fields.sum(Order::getTotalPrice).as("totalPrice"),
                            Fields.max(Order::getTotalPrice).as("maxPrice")))
        .setTransformer(Transformers.asBean(UserOrderVo.class))
        .list();
</code></pre>
<p>The alias you give a computed column (<code>.as("orderAmount")</code>) is the VO property it lands in. Same rule for map keys.</p>
<p>Don't want a VO? Pick another shape:</p>
<pre><code class="language-java">.setTransformer(Transformers.asMap())                   // Map&lt;String, Object&gt;
.setTransformer(Transformers.asCaseInsensitiveMap())    // keys are case insensitive
.setTransformer(Transformers.asList())                  // List&lt;Object&gt;
.setTransformer(Transformers.asBean(SalesVo.class))     // a VO
</code></pre>
<p><code>having</code> filters the groups:</p>
<pre><code class="language-java">.having(Restrictions.gt(Fields.count(Order::getId), 0L))
</code></pre>
<hr />
<h2>Computed columns</h2>
<p><code>Fields</code> gives you the arithmetic and the functions:</p>
<pre><code class="language-java">Fields.multiply(Property.forName(Product::getPrice),
                Property.forName(OrderProduct::getAmount)).as("subtotal")

Fields.concat(Fields.upper(User::getUsername), "!")
Fields.countDistinct(Property.forName("this", "order.id")).as("orderAmount")
Fields.month(Order::getOrderDate).as("month")     // rendered per database
</code></pre>
<p><code>IfExpression</code> is <code>CASE WHEN</code>:</p>
<pre><code class="language-java">IfExpression&lt;String, String&gt; area = new IfExpression&lt;String, String&gt;("location")
        .when("China", "Asia")
        .otherwise("Other");

productDao.customQuery()
        .select(new ColumnList().addColumns(area.as("area")))
        .list();
</code></pre>
<hr />
<h2>Pagination that counts correctly</h2>
<p>This is where most Criteria code goes wrong. A pagination is a listing query <strong>plus</strong> a counting query, so EasyJPA builds it once and hands you both:</p>
<pre><code class="language-java">JpaPageResultSet&lt;Tuple&gt; resultSet = orderDao.customPage()
        .join(Order::getUser, "u", null)
        .filter(Restrictions.gt(Order::getTotalPrice, BigDecimal.valueOf(1000)))
        .select(new ColumnList().addColumns(Order::getId).addColumns(User::getUsername));

long total = resultSet.rowCount();          // one statement, no rows fetched
List&lt;Tuple&gt; rows = resultSet.list(10, 0);   // the first 10 rows
</code></pre>
<p>And when the query groups, <code>rowCount()</code> counts <strong>groups</strong>, not rows — through a derived table, in one statement, <code>having</code> clause included. That's the bug you don't have to find:</p>
<pre><code class="language-java">JpaPageResultSet&lt;Tuple&gt; resultSet = orderProductDao.customPage()
        .join(OrderProduct::getProduct, "p", null)
        .groupBy(new FieldList().addFields(Product::getName))
        .having(Restrictions.gt(Fields.count(OrderProduct::getId), 1L))
        .select(new ColumnList().addColumns(Product::getName)
                .addColumns(Fields.sum(OrderProduct::getAmount).as("soldAmount")));

resultSet.rowCount();   // the number of products, not the number of order lines
</code></pre>
<p>Walking pages is a small API of its own:</p>
<pre><code class="language-java">PageResponse&lt;Map&lt;String, Object&gt;&gt; page = userDao.customPage()
        .sort(JpaSort.asc(User::getId))
        .select(new ColumnList(User::getId, User::getUsername, User::getVip))
        .setTransformer(Transformers.asCaseInsensitiveMap())
        .paginate(PageRequest.of(2));       // 2 rows per page

page.getTotalRecords();
page.getTotalPages();
page.hasNextPage();
page.nextPage().getContent();
page.lastPage().isLastPage();
</code></pre>
<p>Or stream every page:</p>
<pre><code class="language-java">resultSet.setTransformer(Transformers.asCaseInsensitiveMap())
        .paginate(PageRequest.of(10))
        .forEachPage(eachPage -&gt; eachPage.getContent().forEach(this::handle));
</code></pre>
<hr />
<h2>Subqueries</h2>
<p>Build the subquery <strong>from the query that uses it</strong> — that's what correlates the two.</p>
<p><strong>exists</strong> — the users who have ever ordered:</p>
<pre><code class="language-java">JpaQuery&lt;User, User&gt; query = userDao.query();
JpaSubQuery&lt;Order, Long&gt; subQuery = query.subQuery(Order.class, "o", Long.class)
        .filter(Restrictions.eq(Order::getUser, User::getId))
        .select(Order::getId);

List&lt;User&gt; customers = query.filter(Restrictions.exists(subQuery)).selectThis().list();
</code></pre>
<p><strong>not exists</strong> — the products nobody ever bought:</p>
<pre><code class="language-java">query.filter(Restrictions.exists(subQuery).not()).selectThis().list();
</code></pre>
<p><strong>in, with a grouping subquery</strong> — the repeat customers:</p>
<pre><code class="language-java">JpaQuery&lt;User, User&gt; query = userDao.query();
JpaSubQuery&lt;Order, Long&gt; subQuery = query.subQuery(Order.class, "o", Long.class);
subQuery.groupBy(new FieldList().addFields(Order::getUser))
        .having(Restrictions.gt(Fields.count(Order::getId), 1L))
        .select(Property.forName("o", "user.id", Long.class));

query.filter(Restrictions.in(Property.forName(User::getId), subQuery)).selectThis().list();
</code></pre>
<p><strong>As a selected column</strong> — every product with its stock alongside:</p>
<pre><code class="language-java">JpaQuery&lt;Product, Tuple&gt; query = productDao.customQuery();
JpaSubQuery&lt;Stock, Long&gt; stock = query.subQuery(Stock.class, "s", Long.class)
        .filter(Restrictions.eq(Stock::getProductId, Product::getId))
        .select(Fields.max(Stock::getAmount));

query.select(new ColumnList().addColumns(Product::getName)
                .addColumns(Column.forSubQuery(stock, "stockAmount")))
     .list();
</code></pre>
<p>Subqueries nest, join, group and distinct exactly like ordinary queries do.</p>
<hr />
<h2>Joining a derived table</h2>
<p>When every row needs an aggregate of another table, join the aggregate once instead of running a correlated subquery per row:</p>
<pre><code class="language-java">JpaQuery&lt;Product, Tuple&gt; query = productDao.customQuery();
JpaSubQuery&lt;OrderProduct, Tuple&gt; sales = query.subQuery(OrderProduct.class, "op", Tuple.class);
sales.groupBy(new FieldList().addFields(Property.forName("op", "product.id")))
     .select(new ColumnList()
             .addColumns(Property.forName("op", "product.id").as("productId"))
             .addColumns(Fields.sum("op", "amount", Integer.class).as("soldAmount")));

List&lt;Map&lt;String, Object&gt;&gt; dataList = query
        .joinSubQuery(sales, "s", Restrictions.eq(Property.forName("s", "productId"),
                                                  Property.forName("this", "id")))
        .sort(JpaSort.desc(Property.forName("s", "soldAmount")))
        .select(new ColumnList().addColumns(Product::getName)
                .addColumns(Property.forName("s", "soldAmount").as("soldAmount")))
        .setTransformer(Transformers.asCaseInsensitiveMap())
        .list();
</code></pre>
<hr />
<h2>Updates and deletes</h2>
<pre><code class="language-java">userDao.update().set(User::getVip, true)
       .filter(Restrictions.eq(User::getVip, false))
       .execute();

userDao.update().set(User::getPassword, "654321", User::getEmail, "nobody@jpatest.com")
       .filter(Restrictions.eq(User::getUsername, "Jack"))
       .execute();
</code></pre>
<p>Set one column from another, or from an expression:</p>
<pre><code class="language-java">userDao.update().setProperty("email", "username")                        // email = username
       .filter(Restrictions.eq(User::getUsername, "Terry")).execute();

userDao.update().setField(User::getUsername, Fields.concat(Fields.upper(User::getUsername), "!"))
       .filter(Restrictions.eq(User::getUsername, "Lee")).execute();

stockDao.update().setField(Stock::getAmount, Fields.minusValue(Stock::getAmount, 1L))
        .filter(Restrictions.gt(Stock::getAmount, 0L)).execute();
</code></pre>
<p>Deletes correlate subqueries just the same — here, every user who never ordered:</p>
<pre><code class="language-java">JpaDelete&lt;User&gt; delete = userDao.delete();
JpaSubQuery&lt;Order, Order&gt; subQuery = delete.subQuery(Order.class)
        .filter(Restrictions.eq(Order::getUser, User::getId));

int rows = delete.filter(Restrictions.exists(subQuery).not()).execute();
</code></pre>
<hr />
<h2>Fetch joins</h2>
<p>An association read after the query costs one SELECT per entity. Fetch it along instead:</p>
<pre><code class="language-java">orderDao.query().fetch(Order::getUser).selectThis().list();                  // to-one
userDao.query().leftFetch(User::getOrders).distinct().selectThis().list();   // collection
</code></pre>
<p>Pagination fetches on the listing query and never on the counting one, so this stays one extra join rather than N+1:</p>
<pre><code class="language-java">JpaPageResultSet&lt;Order&gt; resultSet = orderDao.page().fetch(Order::getUser)
        .filter(Restrictions.ne(Order::getStatus, OrderStatus.CANCELLED))
        .selectThis();
</code></pre>
<hr />
<h2>Native SQL, with pagination intact</h2>
<p>Whatever Criteria can't express is one call away, and the result is mapped case-insensitively:</p>
<pre><code class="language-java">List&lt;Map&lt;String, Object&gt;&gt; dataList = userDao.queryForMap(
        "select u.username as username, count(o.id) as order_amount"
                + " from example_user u left join example_order o on o.user_id = u.id"
                + " group by u.username order by u.username",
        new Object[0]).list();
</code></pre>
<p>It returns a <code>PageableQuery</code>, so <code>rowCount()</code> and <code>paginate(...)</code> work exactly as above.</p>
<hr />
<h2>Pick your entry point</h2>
<p>The whole API surface fits in one table:</p>
<table>
<thead>
<tr>
<th>What you need</th>
<th>Entry point</th>
<th>What you get</th>
</tr>
</thead>
<tbody><tr>
<td>The entities themselves</td>
<td><code>dao.query()</code></td>
<td>the entity type</td>
</tr>
<tr>
<td>Some columns mapped to a VO</td>
<td><code>dao.query(Vo.class)</code></td>
<td>the given type</td>
</tr>
<tr>
<td>Any columns at all</td>
<td><code>dao.customQuery()</code></td>
<td>a <code>Tuple</code></td>
</tr>
<tr>
<td>The same, with a total count</td>
<td><code>dao.page()</code> / <code>dao.customPage()</code></td>
<td>a <code>JpaPageResultSet</code></td>
</tr>
<tr>
<td>An update or a delete</td>
<td><code>dao.update()</code> / <code>dao.delete()</code></td>
<td>the affected rows</td>
</tr>
<tr>
<td>Something Criteria can't reach</td>
<td><code>dao.queryForMap(sql, args)</code></td>
<td>a <code>PageableQuery</code></td>
</tr>
</tbody></table>
<hr />
<h2>Which provider, which database</h2>
<p>Hibernate is the default and reaches every feature. EclipseLink and the plain Criteria API are supported as far as they go — and where they fall short, EasyJPA tells you at runtime instead of failing somewhere down in a stack trace:</p>
<pre><code class="language-java">if (JpaProviders.getProvider().supportsDerivedTable()) {
    ...
}
</code></pre>
<p>The same test suite runs against <strong>H2, PostgreSQL, MySQL, SQL Server, SQLite and Oracle</strong>, on all three providers. The README documents exactly which combinations skip what, and why.</p>
<hr />
<h2>Try it</h2>
<pre><code class="language-java">userDao.query()
       .filter(Restrictions.eq(User::getVip, true))
       .sort(JpaSort.asc(User::getUsername))
       .selectThis()
       .list();
</code></pre>
<p>If that reads like the query you meant, you already know the API.</p>
<p><strong>GitHub:</strong> <a href="https://github.com/paganini2008/easyjpa">paganini2008/easyjpa</a> — MIT licensed. Spring Boot 3 users take the <code>1.0.x</code> line, Spring Boot 4 users the <code>2.0.x</code> line. <code>2.0.0</code> is on its way to Maven Central; until then <code>2.0.0-SNAPSHOT</code> is the one to use.</p>
]]></content:encoded></item><item><title><![CDATA[Stop hand-writing cron: build and parse it in Java]]></title><description><![CDATA[Quick, what does this fire?
0 0 18 ? * FRIL

Last Friday of every month, 6 PM. You probably got the 6 PM part. The FRIL is the kind of thing you paste into a comment and hope nobody asks about later.
]]></description><link>https://fredfeng.hashnode.dev/java-cron-expression-builder-parser</link><guid isPermaLink="true">https://fredfeng.hashnode.dev/java-cron-expression-builder-parser</guid><category><![CDATA[cron]]></category><category><![CDATA[cronjob]]></category><category><![CDATA[antlr]]></category><category><![CDATA[task scheduler]]></category><category><![CDATA[year based]]></category><dc:creator><![CDATA[Fred Feng]]></dc:creator><pubDate>Sun, 20 Sep 2026 08:46:18 GMT</pubDate><content:encoded><![CDATA[<p>Quick, what does this fire?</p>
<pre><code class="language-plaintext">0 0 18 ? * FRIL
</code></pre>
<p>Last Friday of every month, 6 PM. You probably got the 6 PM part. The <code>FRIL</code> is the kind of thing you paste into a comment and hope nobody asks about later.</p>
<p>I got tired of that guessing game, so I've been using <a href="https://github.com/paganini2008/cronsmith">cronsmith</a>, a small Java library that lets you <em>build</em> a cron expression by describing it, <em>parse</em> one back when you inherit it from someone else, and print it in whichever flavor your scheduler speaks. It also ships a year-based dialect (YCRON) for the schedules that a month-based line just can't say.</p>
<p>Here's the whole thing in about five minutes.</p>
<h2>Build it instead of typing it</h2>
<p>The entry point is <code>CronBuilder</code>. You chain the schedule the way you'd say it out loud, and call <code>toString()</code> when you want the string.</p>
<pre><code class="language-java">new CronBuilder().everySecond(5).toString();
// "*/5 * * * * ?"   -&gt; every 5 seconds

new CronBuilder().everyDay().at(9, 30).toString();
// "0 30 9 * * ?"    -&gt; every day at 09:30

new CronBuilder().everyMonth().everyWeek().Mon().toFri().at(15, 10).toString();
// "0 10 15 ? * MON-FRI"   -&gt; weekdays at 15:10
</code></pre>
<p>Nothing surprising yet. The point of building rather than typing shows up the moment you need the awkward stuff, the exact expressions people get wrong:</p>
<pre><code class="language-java">// Last Friday of the month at 18:00
new CronBuilder().everyMonth().lastDayOfWeek(DayOfWeek.FRIDAY.getValue()).at(18, 0).toString();
// "0 0 18 ? * FRIL"

// 3rd Saturday of the month, every 2 hours
new CronBuilder().everyMonth().dayOfWeek(3, DayOfWeek.SATURDAY).everyHour(2).toString();
// "0 0 */2 ? * SAT#3"

// 3rd-to-last day of the month at 23:30
new CronBuilder().everyMonth().lastDay(3).at(23, 30).toString();
// "0 30 23 L-3 * ?"

// Nearest weekday to the 15th, 09:00 (skip the weekend if the 15th lands on one)
new CronBuilder().everyMonth().latestWeekday(15).at(9, 0).toString();
// "0 0 9 15W * ?"
</code></pre>
<p>You didn't have to remember that <code>L</code>, <code>#</code>, <code>W</code>, and <code>FRIL</code> even existed. You described the rule; the string is a byproduct. And ranges compose the same way you'd read them:</p>
<pre><code class="language-java">new CronBuilder().everyMinute(5).second(5)
    .andSecond(10).toSecond(30).andSecond(32).toSecond(59, 2).toString();
// "5,10-30,32/2 */5 * * * ?"
</code></pre>
<h2>Read one back</h2>
<p>Half the time you're not writing a cron string; you're staring at one that's already in a config file. <code>CRON.parse</code> turns it into the same object you'd have built, so it normalizes as it goes:</p>
<pre><code class="language-java">CRON.parse("0 0 12 ? * FRIL").toString();   // "0 0 12 ? * FRIL"
CRON.parse("0 0 12 ? * TUE#2").toString();  // 2nd Tuesday
CRON.parse("0 0 12 LW * ?").toString();     // last weekday of the month
</code></pre>
<p>Feed it a 5-field Unix line and it fills in the seconds and canonicalizes the day names for you:</p>
<pre><code class="language-java">CRON.parse("*/5 * * * *").toString();   // "0 */5 * * * ?"
CRON.parse("0 9 * * 1-5").toString();   // "0 0 9 ? * MON-FRI"
CRON.parse("0 0 12 ? * 1").toString();  // "0 0 12 ? * SUN"
</code></pre>
<p>That last one is the reason I stopped trusting my own eyes: was <code>1</code> Sunday or Monday? Now I just parse it and read the answer.</p>
<h2>One schedule, four flavors</h2>
<p>Quartz, Spring, AWS EventBridge, and classic Unix all disagree about field counts and syntax. Build once, print for each:</p>
<pre><code class="language-java">CronExpression daily = new CronBuilder().everyDay().at(9, 30);

CRON.toQuartzString(daily);  // "0 30 9 * * ?"
CRON.toSpringString(daily);  // "0 30 9 * * ?"
CRON.toAwsString(daily);     // "30 9 * * ? *"
CRON.toUnixString(daily);    // "30 9 * * *"
</code></pre>
<p>Some schedules only exist in some flavors. <code>L</code>, <code>#</code>, and seconds have no Unix equivalent, and cronsmith tells you so instead of quietly emitting something wrong. The conversion throws rather than lies.</p>
<h2>When a month is the wrong unit: YCRON</h2>
<p>Standard cron thinks in months. Plenty of real schedules don't: "the 100th day of the year", "Monday of ISO week 20", "every other year". That's what YCRON is for. It's a separate, year-based line with seven fields:</p>
<pre><code class="language-plaintext">&lt;sec&gt; &lt;min&gt; &lt;hour&gt; &lt;day-of-week&gt; &lt;week-of-year&gt; &lt;day-of-year&gt; [&lt;year&gt;]
</code></pre>
<p>You pick the date one of two ways, and the field you're not using becomes <code>?</code>:</p>
<ul>
<li><p><strong>day-of-week + week-of-year</strong> together, as in "Monday of week 20". Day-of-year is then <code>?</code>.</p>
</li>
<li><p><strong>day-of-year</strong> alone, as in "the 100th day". Day-of-week and week-of-year are then <code>?</code>.</p>
</li>
</ul>
<p>Build it with the same <code>CronBuilder</code>, just starting from a year:</p>
<pre><code class="language-java">// The 100th day of 2026, at noon
new CronBuilder().year(2026).day(100).at(12, 0, 0);

// Monday of ISO week 20, 2026, at 09:00
new CronBuilder().year(2026).week(20).Mon().at(9, 0, 0);

// Every year, week 40, every day, midnight
new CronBuilder().everyYear().week(40).everyDay().at(0, 0, 0);
</code></pre>
<p>Parsing has its own front door, <code>YCRON.parse</code>, so the traditional path stays untouched:</p>
<pre><code class="language-java">CronExpression a = YCRON.parse("0 0 12 ? ? 100 2026");  // day 100 of 2026, noon
CronExpression b = YCRON.parse("0 0 9 MON 20 ? 2026");  // Monday of week 20, 2026, 09:00

a.getCronType();  // CronType.YCRON
</code></pre>
<p>Leave the year off (or make it <code>*</code>) and it means every year, exactly like the other fields.</p>
<h2>Actually fire it</h2>
<p>An expression isn't much use if you can't ask it <em>when</em>. Every <code>CronExpression</code> computes its own next times, no scheduler required:</p>
<pre><code class="language-java">CronExpression cron = new CronBuilder().everyMonth().latestWeekday(15).at(9, 0);

cron.getNextFiredDateTime();          // the next LocalDateTime it fires

cron.consume(System.out::println, 5); // print the next 5 fire times
// 2027-01-15T09:00
// 2027-02-15T09:00
// 2027-03-15T09:00
// 2027-04-15T09:00
// 2027-05-14T09:00   &lt;- the 15th is a Saturday, so it slides to Friday the 14th
</code></pre>
<p>That "W" behavior isn't a footnote in a spec anymore; you can watch it happen.</p>
<h2>Get it</h2>
<p>One dependency:</p>
<pre><code class="language-xml">&lt;dependency&gt;
    &lt;groupId&gt;com.github.paganini2008&lt;/groupId&gt;
    &lt;artifactId&gt;cronsmith&lt;/artifactId&gt;
    &lt;version&gt;1.0.0&lt;/version&gt;
&lt;/dependency&gt;
</code></pre>
<p>And your first line:</p>
<pre><code class="language-java">System.out.println(new CronBuilder().everyDay().at(9, 0).getNextFiredDateTime());
</code></pre>
<p>That's the loop I want with cron: describe the rule, get a string I can commit, and get a datetime I can trust, without ever mentally parsing <code>FRIL</code> again. Source and full syntax reference are on <a href="https://github.com/paganini2008/cronsmith">GitHub</a>.</p>
]]></content:encoded></item></channel></rss>