Help Shape the Quick 13 Release

Quick 13 has reached beta, bringing a substantial collection of new persistence, relationship, testing, and performance capabilities—and now it needs some real-world mileage.

New Features and Improvements

Highlights include:

  • Automatic timestamps and built-in soft deletes
  • New upsert(), createAll(), replicate(), and touch() APIs
  • Laravel-inspired model factories for testing
  • Richer belongsToMany pivot models
  • New relationship helpers such as whereBelongsTo() and whereHasValue()
  • Improved entity state tracking, lifecycle hooks, and refresh-on-save support
  • Better compatibility across Lucee, Adobe ColdFusion, and BoxLang
  • An upgrade to qb 14

Performance Improvements

Quick 13 reduces repeated metadata, hydration, and attribute-state work on several frequently used code paths:

  • Qualified-column caching reduced qualification calls from 6 to 3—a 50% reduction in the regression fixture.
  • Hydration now resolves attribute bindings once per result set instead of once per row. For a 1,000-row result, that eliminates 999 repeated resolution passes.
  • A bounded, process-local entity definition registry reduces repeated metadata compilation across instances of the same entity.
  • Additional optimizations reduce allocations during attribute tracking, refresh queries, qualified-column lookups, and runtime attribute overlays.

Focused microbenchmarks measured the following improvements over Quick 12.0.12:

Operation Lucee 6.2.8.20 Adobe ColdFusion 2021.0.23 BoxLang 1.17.0
Entity creation 39.1% faster 28.1% faster 36.0% faster
Single-entity hydration 26.9% faster 31.5% faster 38.2% faster
Hydrating 100 entities 7.1% faster 36.6% faster 44.3% faster
Hydrating 1,000 entities 11.3% faster 42.5% faster 27.2% faster
hasMany relationship construction 64.1% faster 24.6% faster 26.3% faster

Each result is the median elapsed time from 15 samples of 50 iterations after warm-up. These figures describe reductions in repeated internal work, not equivalent reductions in total application request time. Actual improvements will vary by CFML engine, JVM, entity definition, and query shape.

Breaking Changes

As a major release, Quick 13 includes several behavior changes to review while testing:

  • Quick now requires qb 14.
  • Automatic timestamps are enabled by default for declared createdDate and modifiedDate attributes.
  • reset() now clears the cached query builder along with entity and relationship state.
  • Loaded primary keys are now immutable.
  • Duplicate entity property names now throw QuickDuplicateProperty during initialization.
  • Custom casts now receive null values, and BooleanCast@quick preserves null instead of converting it to false.
  • Relationship getters on new entities now return null, a configured default, or an empty collection without querying the database.
  • The second positional argument to appendVirtualAttribute() is now defaultValue; excludeFromMemento has moved to the third argument.

See the Quick 13 Upgrade Guide for examples and upgrade instructions.

We Need You (And Your Apps)

Install the beta, check out the docs, and let us know how it runs for you. Testing existing applications, large hydration workloads, relationship-heavy queries, and custom casts would be especially valuable!

@elpete, I’m your huckleberry. I’ll test this on a bunch of apps I’m actively developing and about 10 others in production that use Quick. It’s my go-to these days.

One feature I’ve been wishing for in Quick for ages is the ability to populate relationships on a new entity before the root entity has been persisted.

For example, I’d love to be able to do this:

post = getInstance( "Post" );

post.fill( {
    title : "Foo"
} );

post.setCategories( rc.categories );

validateOrFail( post );

post.save();

Today, setCategories() needs the Post to already exist because Quick needs its primary key to update the relationship. That means I end up having to save the Post first, populate its relationships, validate it, and wrap the whole thing in a transaction so I can roll everything back if validation fails.

I’ve never loved that pattern because persistence mechanics end up dictating the order of my application logic. In particular, I don’t like having to persist an entity before I can fully populate and validate it.

What I’d love is for relationship setters on a new entity to update or stage the relationship in memory. Then, when save() inserts the root entity and obtains its primary key, Quick could persist any pending relationship changes afterward.

I’m not necessarily suggesting full object-graph persistence where Quick recursively figures out how to save an arbitrary tree of new entities. Even just supporting relationships to already-persisted entities or IDs would cover most of my use cases.

Conceptually, I’d like to be able to fully populate an entity, including its relationships, validate the complete entity, and only then tell Quick to persist it.

This also feels like a natural next step from issue #177, which I opened years ago around making relationships on new/non-loaded entities behave more like relationships on persisted entities. The more I’ve used Quick since then, the more I’ve come to feel that the persistence state of the root entity shouldn’t dictate how much of the entity graph I’m allowed to construct in memory.

I came across a ticket about this, maybe yours, when I was working on Quick 13. The truth is it has always been a design goal of Quick to not deal with object graph persistence in any way. I’d probably recommend writing a module or a fork of Quick to work the way you want. Sorry, and best of luck.

@elpete Loving Quick 13 so far, and thanks for the reply about Quick’s overall vision. I’ll try not to bug you about object graph persistence in the future :wink: I might try taking a stab at it later as an experiment.

In the meantime…

You asked for upgrade feedback, so here’s mine. I moved a CMS module that runs on Adobe CF, Lucee and BoxLang to Quick 12.0.12 / qb 13.3.1. Mostly smooth, but I hit two things that broke.

1. Decimal scale comes through as a Double (Bug)

Issue: [BUG] [13.3.0] [Decimal binding `scale` is a Double: Adobe CF rounds to 0 places, BoxLang throws ClassCastException · Issue #340 · coldbox-modules/qb · GitHub

QueryUtils.extractBinding() adds a scale to floating-point bindings, but calculateNumberOfDecimalDigits() returns max( 0, ... ), which is a java.lang.Double. So the binding gets scale: 2.0 instead of 2.

Inserting 12500.50 into a decimal(12,2) column on SQL Server:

Engine Result
Adobe CF 2023.0.24 Stores 12501.00, no error
BoxLang 1.17.5 Throws java.lang.Double cannot be cast to java.lang.Integer
Lucee 6.2.8 Stores 12500.50

A raw queryExecute with scale: 2 works everywhere. With scale: 2.0 it fails the same way, so it’s the Double. Wrapping the return in javaCast( "int", ... ) should fix it.

It bit me in places I didn’t expect, because Quick’s save() writes every column. Renaming an image re-saved its decimal(4,3) focal point columns, which threw on BoxLang and would have rounded 0.5 to 1 on Adobe.

For now I’m converting the scale to an int in a preQBExecute interceptor.

2. convertEmptyStringsToNull hits WHERE clauses too

This one isn’t a bug, but it caught me. With the new default,

.where( "name", "!=", "" )

becomes name != NULL, which never matches anything. I had two of these.

Passing a param struct gets around it:

.where( "name", "!=", { value: "", cfsqltype: "varchar" } )

It might be worth calling this out in the upgrade guide. Or maybe the conversion could apply only to insert() and update() values? I can’t think of a case where someone writes != "" and means != NULL.

Thanks for all the work on qb and Quick. Happy to test a fix on all three engines.

Can you give me the full stack trace for that? That sounds more like a BoxLang defect than Quick.

Here you go:

BoxLang 1.17.5+64 (bx-compat-cfml), SQL Server. It reproduces with a plain queryExecute() call, with no qb or Quick.

The same call on Lucee 6.2.8 stores 12500.50. Adobe CF 2023.0.24 stores 12501.00 without an error, because it ignores the non-integer scale and uses 0.

Repro

transaction {
    queryExecute( "CREATE TABLE ##bxScaleTrace ( amount decimal(12,2) )" );

    // Works: an Integer scale.
    queryExecute(
        "INSERT INTO ##bxScaleTrace ( amount ) VALUES ( ? )",
        [ { value: "12500.50", cfsqltype: "decimal", scale: javaCast( "int", 2 ) } ]
    );

    // Throws on BoxLang: a Double scale.
    queryExecute(
        "INSERT INTO ##bxScaleTrace ( amount ) VALUES ( ? )",
        [ { value: "12500.50", cfsqltype: "decimal", scale: javaCast( "double", 2 ) } ]
    );

    transaction action="rollback";
}

Full stack trace

java.lang.ClassCastException: class java.lang.Double cannot be cast to class java.lang.Integer (java.lang.Double and java.lang.Integer are in module java.base of loader 'bootstrap')
	at ortus.boxlang.runtime.types.IStruct.getAsInteger(IStruct.java:443)
	at ortus.boxlang.runtime.jdbc.QueryParameter.<init>(QueryParameter.java:108)
	at ortus.boxlang.runtime.jdbc.QueryParameter.fromAny(QueryParameter.java:135)
	at ortus.boxlang.runtime.jdbc.QueryParameter.fromAny(QueryParameter.java:119)
	at ortus.boxlang.runtime.jdbc.PendingQuery.buildParameterList(PendingQuery.java:438)
	at ortus.boxlang.runtime.jdbc.PendingQuery.processBindings(PendingQuery.java:322)
	at ortus.boxlang.runtime.jdbc.PendingQuery.<init>(PendingQuery.java:196)
	at ortus.boxlang.runtime.bifs.global.jdbc.QueryExecute.execute(QueryExecute.java:139)
	at ortus.boxlang.runtime.bifs.global.jdbc.QueryExecute._invoke(QueryExecute.java:119)
	at ortus.boxlang.runtime.bifs.BIF.invoke(BIF.java:131)
	at ortus.boxlang.runtime.bifs.BIFDescriptor.invoke(BIFDescriptor.java:205)
	at ortus.boxlang.runtime.context.ClassBoxContext.invokeFunction(ClassBoxContext.java:397)
	at boxgenerated.templates.Zz__bx__scale__trace$cfm.componentBody_1(D:\Dropbox\Repositories\caffeinecms\test-harness\zz-bx-scale-trace.cfm:48)
	at ortus.boxlang.runtime.components.Component.processBody(Component.java:265)
	at ortus.boxlang.runtime.components.Component.processBody(Component.java:234)
	at ortus.boxlang.runtime.components.jdbc.Transaction._invoke(Transaction.java:166)
	at ortus.boxlang.runtime.components.Component.invoke(Component.java:183)
	at ortus.boxlang.runtime.components.ComponentDescriptor.invoke(ComponentDescriptor.java:284)
	at ortus.boxlang.runtime.context.BaseBoxContext.invokeComponent(BaseBoxContext.java:620)
	at boxgenerated.templates.Zz__bx__scale__trace$cfm._invoke(D:\Dropbox\Repositories\caffeinecms\test-harness\zz-bx-scale-trace.cfm:41)
	at ortus.boxlang.runtime.runnables.BoxTemplate.invoke(BoxTemplate.java:63)
	at ortus.boxlang.runtime.context.BaseBoxContext.includeTemplate(BaseBoxContext.java:826)
	at ortus.boxlang.runtime.context.BaseBoxContext.includeTemplate(BaseBoxContext.java:785)
	at ortus.boxlang.runtime.application.ApplicationClassListener.onRequest(ApplicationClassListener.java:130)
	at ortus.boxlang.web.WebRequestExecutor.execute(WebRequestExecutor.java:166)
	at ortus.boxlang.servlet.BoxLangServlet.service(BoxLangServlet.java:113)
	at io.undertow.servlet.handlers.ServletHandler.handleRequest(ServletHandler.java:74)
	at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:129)
	at runwar.servlet.RegexPathInfoFilter.doFilter(RegexPathInfoFilter.java:59)
	at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:67)
	at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
	at io.undertow.servlet.handlers.FilterHandler.handleRequest(FilterHandler.java:84)
	at io.undertow.servlet.handlers.security.ServletSecurityRoleHandler.handleRequest(ServletSecurityRoleHandler.java:62)
	at io.undertow.servlet.handlers.ServletChain$1.handleRequest(ServletChain.java:68)
	at io.undertow.servlet.handlers.ServletDispatchingHandler.handleRequest(ServletDispatchingHandler.java:36)
	at runwar.undertow.SSLCertHeaderHandler.handleRequest(SSLCertHeaderHandler.java:171)
	at io.undertow.servlet.handlers.RedirectDirHandler.handleRequest(RedirectDirHandler.java:68)
	at io.undertow.servlet.handlers.security.SSLInformationAssociationHandler.handleRequest(SSLInformationAssociationHandler.java:117)
	at io.undertow.servlet.handlers.security.ServletAuthenticationCallHandler.handleRequest(ServletAuthenticationCallHandler.java:57)
	at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
	at io.undertow.security.handlers.AbstractConfidentialityHandler.handleRequest(AbstractConfidentialityHandler.java:46)
	at io.undertow.servlet.handlers.security.ServletConfidentialityConstraintHandler.handleRequest(ServletConfidentialityConstraintHandler.java:64)
	at io.undertow.security.handlers.AuthenticationMechanismsHandler.handleRequest(AuthenticationMechanismsHandler.java:60)
	at io.undertow.servlet.handlers.security.CachedAuthenticatedSessionHandler.handleRequest(CachedAuthenticatedSessionHandler.java:75)
	at io.undertow.security.handlers.AbstractSecurityContextAssociationHandler.handleRequest(AbstractSecurityContextAssociationHandler.java:43)
	at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
	at io.undertow.servlet.handlers.SendErrorPageHandler.handleRequest(SendErrorPageHandler.java:52)
	at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
	at io.undertow.servlet.handlers.ServletInitialHandler.handleFirstRequest(ServletInitialHandler.java:278)
	at io.undertow.servlet.handlers.ServletInitialHandler$1.call(ServletInitialHandler.java:131)
	at io.undertow.servlet.handlers.ServletInitialHandler$1.call(ServletInitialHandler.java:128)
	at io.undertow.servlet.core.ServletRequestContextThreadSetupAction$1.call(ServletRequestContextThreadSetupAction.java:48)
	at io.undertow.servlet.core.ContextClassLoaderSetupAction$1.call(ContextClassLoaderSetupAction.java:43)
	at io.undertow.servlet.handlers.ServletInitialHandler.dispatchRequest(ServletInitialHandler.java:258)
	at io.undertow.servlet.handlers.ServletInitialHandler.handleRequest(ServletInitialHandler.java:178)
	at io.undertow.server.handlers.HttpContinueReadHandler.handleRequest(HttpContinueReadHandler.java:69)
	at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
	at runwar.undertow.WelcomeFileHandler.handleRequest(WelcomeFileHandler.java:49)
	at io.undertow.server.handlers.PathHandler.handleRequest(PathHandler.java:104)
	at runwar.undertow.SiteDeployment$1.handleRequest(SiteDeployment.java:169)
	at io.undertow.predicate.PredicatesHandler.handleRequest(PredicatesHandler.java:141)
	at io.undertow.predicate.PredicatesHandler.handleRequest(PredicatesHandler.java:141)
	at io.undertow.predicate.PredicatesHandler.handleRequest(PredicatesHandler.java:113)
	at io.undertow.server.handlers.DisallowedMethodsHandler.handleRequest(DisallowedMethodsHandler.java:62)
	at io.undertow.predicate.PredicatesHandler.handleRequest(PredicatesHandler.java:113)
	at io.undertow.server.handlers.encoding.EncodingHandler.handleRequest(EncodingHandler.java:66)
	at runwar.undertow.LifecyleHandler.handleRequest(LifecyleHandler.java:150)
	at runwar.undertow.SiteDeployment$4.handleRequest(SiteDeployment.java:360)
	at io.undertow.server.Connectors.executeRootHandler(Connectors.java:369)
	at io.undertow.server.HttpServerExchange$1.run(HttpServerExchange.java:937)
	at org.jboss.threads.ContextHandler$1.runWith(ContextHandler.java:18)
	at org.jboss.threads.EnhancedQueueExecutor$Task.doRunWith(EnhancedQueueExecutor.java:2691)
	at org.jboss.threads.EnhancedQueueExecutor$Task.run(EnhancedQueueExecutor.java:2670)
	at org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1615)
	at org.xnio.XnioWorker$WorkerThreadFactory$1$1.run(XnioWorker.java:1282)
at java.base/java.lang.Thread.run(Unknown Source)

Ahh, that’s an old bug. We fixed that last month in BoxLang.
Commit:

Ticket:
https://ortussolutions.atlassian.net/browse/BL-2676

Update to the 1.18 release

Thanks Brad! I think I figured out why I was still on 1.17.5, though I could be wrong.

My theory is that it’s related to serverHomeDirectory . My server JSON uses "cfengine": "boxlang@1" with a fixed server home. From what I can tell, CommandBox resolves that range when it first installs, then keeps reusing the engine in that folder as long as it still matches @1 .

Mine was installed on September 25, so 1.17.5 still qualifies. That would explain why it never picked up 1.18. Running forgebox version-debug boxlang@1 says a fresh install would get 1.18.6, which seems to support that theory.

My ACF 2023 server appears to be doing the same thing. It’s still on .24 even though .26 is out. I’d just never noticed because Adobe releases updates so much less often.

If that’s right, I’d expect cbGenesis to behave the same way, since it also uses boxlang@1 with serverHomeDirectory . Is that expected behavior? If so, maybe a note about server forget in the template README, or a startup hint when a newer matching version is available, would help people like me catch it sooner.

Once I forgot the 1.17.5 server and restarted, the Boxlang issue went away. Therefore, the QB issue only affects ACF engines. I will update the GitHub issue.

1 Like