From b026ae35415a0d1c63997dabece2d802b1ba823f Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 4 Aug 2026 15:27:15 -0700 Subject: [PATCH 1/2] feat(model): allow select() and friends to start a query-builder chain (#3346) Extends the chain-entry dispatch list in model onMissingMethod with select, include, group, distinct, and forUpdate so every QueryBuilder method can start a chain directly on the model class, matching where() and orderBy(). Adds forUpdate to the ScopeChain builder-transition list for parity. User-defined scopes keep precedence in both dispatchers. Specs pin the issue's exact example (select().where().get()), the returned columnList, include/group/distinct smoke coverage, and the scope-to-forUpdate transition. Guide and CLAUDE.md quick reference now document the entry-position builder methods. Co-Authored-By: Claude Fable 5 Signed-off-by: Peter Amiri --- CLAUDE.md | 4 +- .../query-builder-select-entry.added.md | 1 + vendor/wheels/model/onmissingmethod.cfc | 7 +- vendor/wheels/model/query/ScopeChain.cfc | 6 +- .../tests/specs/model/queryBuilderSpec.cfc | 69 +++++++++++++++++++ .../basics/query-builder-and-scopes.mdx | 18 +++++ 6 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 changelog.d/query-builder-select-entry.added.md diff --git a/CLAUDE.md b/CLAUDE.md index 47e04f0c0a..5063a38bea 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -365,7 +365,9 @@ model("User") .orderBy("name", "ASC") .limit(25) .get(); -// Methods: where, orWhere, whereNull, whereNotNull, whereBetween, whereIn, whereNotIn, orderBy, limit, get +// Methods: where, orWhere, whereNull, whereNotNull, whereBetween, whereIn, whereNotIn, orderBy, +// limit, offset, select, include, group, distinct, forUpdate, get +// Any of these (not just where) can START the chain on the model, e.g. model("User").select("id,name").get() // Batch processing — memory-efficient model("User").findEach(batchSize=1000, callback=function(user) { diff --git a/changelog.d/query-builder-select-entry.added.md b/changelog.d/query-builder-select-entry.added.md new file mode 100644 index 0000000000..905b006ff0 --- /dev/null +++ b/changelog.d/query-builder-select-entry.added.md @@ -0,0 +1 @@ +- `select()`, `include()`, `group()`, `distinct()`, and `forUpdate()` can now start a query-builder chain directly on the model class (e.g. `model("Person").select("id,firstName").where("department", "engineering").get()`), matching `where()` and the other entry-position builder methods. `forUpdate()` is also available when transitioning from a scope chain. ([#3346](https://github.com/wheels-dev/wheels/issues/3346)) diff --git a/vendor/wheels/model/onmissingmethod.cfc b/vendor/wheels/model/onmissingmethod.cfc index 3249d02dfe..cceeacbfbf 100644 --- a/vendor/wheels/model/onmissingmethod.cfc +++ b/vendor/wheels/model/onmissingmethod.cfc @@ -52,8 +52,11 @@ component { } // --- Chainable Query Builder entry points --- - // Allow calling .where(), .orWhere(), .orderBy() etc. directly on a model to start a query builder chain. - if (ListFindNoCase("where,orWhere,whereNull,whereNotNull,whereBetween,whereIn,whereNotIn,orderBy,limit,offset", arguments.missingMethodName)) { + // Allow calling .where(), .select(), .orderBy() etc. directly on a model to start a query builder chain. + // Note: dynamic finders, association setters, and enum checkers above take precedence, and a real model + // method with one of these names bypasses onMissingMethod entirely. Keep this list in sync with the + // scope-to-builder transition list in wheels.model.query.ScopeChain (where user scopes are checked first). + if (ListFindNoCase("where,orWhere,whereNull,whereNotNull,whereBetween,whereIn,whereNotIn,orderBy,limit,offset,select,include,group,distinct,forUpdate", arguments.missingMethodName)) { local.builder = new wheels.model.query.QueryBuilder(modelReference = this); // Delegate the call to the query builder return Invoke(local.builder, arguments.missingMethodName, arguments.missingMethodArguments); diff --git a/vendor/wheels/model/query/ScopeChain.cfc b/vendor/wheels/model/query/ScopeChain.cfc index 37169e9303..ccc7661447 100644 --- a/vendor/wheels/model/query/ScopeChain.cfc +++ b/vendor/wheels/model/query/ScopeChain.cfc @@ -233,8 +233,10 @@ component output="false" { return this; } - // Check if this is a QueryBuilder method — transition from scope chain to query builder - if (ListFindNoCase("where,orWhere,whereNull,whereNotNull,whereBetween,whereIn,whereNotIn,orderBy,limit,offset,select,include,group,distinct", arguments.missingMethodName)) { + // Check if this is a QueryBuilder method — transition from scope chain to query builder. + // User-defined scopes are checked BEFORE this list (above), so a scope named e.g. "select" keeps + // precedence. Keep this list in sync with the chain-entry list in wheels.model.onmissingmethod. + if (ListFindNoCase("where,orWhere,whereNull,whereNotNull,whereBetween,whereIn,whereNotIn,orderBy,limit,offset,select,include,group,distinct,forUpdate", arguments.missingMethodName)) { local.builder = new wheels.model.query.QueryBuilder(modelReference = variables.modelReference, scopeSpecs = variables.specs); return Invoke(local.builder, arguments.missingMethodName, arguments.missingMethodArguments); } diff --git a/vendor/wheels/tests/specs/model/queryBuilderSpec.cfc b/vendor/wheels/tests/specs/model/queryBuilderSpec.cfc index ef38b650ab..c8c0ab8868 100644 --- a/vendor/wheels/tests/specs/model/queryBuilderSpec.cfc +++ b/vendor/wheels/tests/specs/model/queryBuilderSpec.cfc @@ -231,6 +231,75 @@ component extends="wheels.WheelsTest" { }) + describe("chain-entry builder methods on the model", () => { + + it("select() starts a chain and limits the returned columns", () => { + var result = model("author") + .select("id,firstName") + .where("lastName", "Djurner") + .findAll(); + expect(result.recordcount).toBe(1); + expect(ListSort(result.columnList, "textnocase")).toBe("firstName,id"); + }) + + it("select() followed by where() and get() works (issue ##3346 example)", () => { + var result = model("author") + .select("id,firstName,lastName") + .where("lastName", "Djurner") + .get(); + expect(result.recordcount).toBe(1); + expect(result.lastname).toBe("Djurner"); + expect(ListSort(result.columnList, "textnocase")).toBe("firstName,id,lastName"); + }) + + it("include() starts a chain", () => { + var result = model("author") + .include("posts") + .where("lastName", "Djurner") + .findAll(); + expect(result.recordcount).toBeGT(0); + }) + + it("group() starts a chain", () => { + var distinctAuthors = model("post").findAll(select="authorId", group="authorId"); + var result = model("post") + .group("authorId") + .select("authorId") + .findAll(); + expect(result.recordcount).toBe(distinctAuthors.recordcount); + }) + + it("distinct() starts a chain", () => { + var result = model("author") + .distinct() + .where("lastName", "Djurner") + .get(); + expect(result.recordcount).toBe(1); + }) + + it("forUpdate() starts a chain", () => { + // FOR UPDATE is a no-op on SQLite/MSSQL; this pins the chain-entry dispatch, not the locking. + var result = model("author") + .forUpdate() + .where("lastName", "Djurner") + .count(); + expect(result).toBe(1); + }) + + }) + + describe("scope chain to builder transition", () => { + + it("forUpdate() transitions from a scope chain to the query builder", () => { + var result = model("authorScoped") + .withLastNameDjurner() + .forUpdate() + .count(); + expect(result).toBe(1); + }) + + }) + it("handles complex chains", () => { var result = model("author") .where("firstName", "Per") diff --git a/web/sites/guides/src/content/docs/v4-0-0/basics/query-builder-and-scopes.mdx b/web/sites/guides/src/content/docs/v4-0-0/basics/query-builder-and-scopes.mdx index 3fccadd580..221e4f0aba 100644 --- a/web/sites/guides/src/content/docs/v4-0-0/basics/query-builder-and-scopes.mdx +++ b/web/sites/guides/src/content/docs/v4-0-0/basics/query-builder-and-scopes.mdx @@ -58,10 +58,28 @@ The two-argument form of `where` means equality. The three-argument form takes a | `orderBy(column, direction)` | `ORDER BY column direction` (direction defaults to `ASC`) | | `limit(n)` | `LIMIT n` | | `offset(n)` | `OFFSET n` | +| `select(columns)` | Restrict the `SELECT` list to the given columns | +| `include(associations)` | Join the named associations (same as `findAll(include=...)`) | +| `group(columns)` | `GROUP BY columns` | +| `distinct()` | `SELECT DISTINCT` | +| `forUpdate()` | `FOR UPDATE` row locking (needs a transaction; no-op on SQL Server/SQLite) | | `get()` | Executes, returns a query of all matching rows | | `first()` | Executes, returns the first matching row | | `count()` | Executes `COUNT(*)`, returns an integer | +Every builder method can start the chain directly on the model class — you don't have to lead with `where()`. Starting with `select()` reads naturally when you only need a few columns: + +```cfm {test:compile} +component extends="Controller" { + function directory() { + people = model("Person") + .select("id,firstName,lastName") + .where("department", "engineering") + .get(); + } +} +``` + Here's a longer example that exercises several of these: ```cfm {test:compile} From ebeb415fa6487e0f52904b1866b3c8c69e640143 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 4 Aug 2026 15:48:07 -0700 Subject: [PATCH 2/2] fix(model): correct precedence note on the builder chain-entry dispatch list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment added in the #3346 change claimed dynamic finders and association setters take precedence over the chain-entry list, but both branches dispatch AFTER it in onMissingMethod — only user-defined scopes and enum checkers run first. Comment-only change; no behavior change. Co-Authored-By: Claude Fable 5 Signed-off-by: Peter Amiri --- vendor/wheels/model/onmissingmethod.cfc | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/vendor/wheels/model/onmissingmethod.cfc b/vendor/wheels/model/onmissingmethod.cfc index cceeacbfbf..cfeb450581 100644 --- a/vendor/wheels/model/onmissingmethod.cfc +++ b/vendor/wheels/model/onmissingmethod.cfc @@ -53,9 +53,11 @@ component { // --- Chainable Query Builder entry points --- // Allow calling .where(), .select(), .orderBy() etc. directly on a model to start a query builder chain. - // Note: dynamic finders, association setters, and enum checkers above take precedence, and a real model - // method with one of these names bypasses onMissingMethod entirely. Keep this list in sync with the - // scope-to-builder transition list in wheels.model.query.ScopeChain (where user scopes are checked first). + // Note: user-defined scopes and enum checkers above take precedence, and a real model method with one of + // these names bypasses onMissingMethod entirely. The dynamic-finder and association-method branches below + // run AFTER this list, so an association named e.g. "select" resolves to the builder instead. Keep this + // list in sync with the scope-to-builder transition list in wheels.model.query.ScopeChain (where user + // scopes are checked first). if (ListFindNoCase("where,orWhere,whereNull,whereNotNull,whereBetween,whereIn,whereNotIn,orderBy,limit,offset,select,include,group,distinct,forUpdate", arguments.missingMethodName)) { local.builder = new wheels.model.query.QueryBuilder(modelReference = this); // Delegate the call to the query builder