Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions changelog.d/query-builder-select-entry.added.md
Original file line number Diff line number Diff line change
@@ -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))
7 changes: 5 additions & 2 deletions vendor/wheels/model/onmissingmethod.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions vendor/wheels/model/query/ScopeChain.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
69 changes: 69 additions & 0 deletions vendor/wheels/tests/specs/model/queryBuilderSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Loading