Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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))
9 changes: 7 additions & 2 deletions vendor/wheels/model/onmissingmethod.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,13 @@ 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: 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
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