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
1 change: 1 addition & 0 deletions changelog.d/3252-include-calculated.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added an `includeCalculated` argument to `findAll()`, `findOne()`, and `findByKey()` for additively opting a `select=false` calculated SQL property back into a single finder — e.g. `model("User").findAll(includeCalculated="fullName")`. Unlike `select`, it merges the named calculated properties on top of the default column list rather than replacing it, so the rest of the record is still returned. Unknown names throw `Wheels.CalculatedPropertyNotFound` in development/testing and are ignored in production (#3252)
12 changes: 10 additions & 2 deletions vendor/wheels/model/read.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ component {
* @order Maps to the `ORDER` BY clause of the query. You do not need to specify the table name(s); Wheels will do that for you.
* @group Maps to the `GROUP BY` clause of the query. You do not need to specify the table name(s); Wheels will do that for you.
* @select Determines how the `SELECT` clause for the query used to return data will look. You can pass in a list of the properties (which map to columns) that you want returned from your table(s). If you don't set this argument at all, Wheels will select all properties from your table(s). If you specify a table name (e.g. `users.email`) or alias a column (e.g. `fn AS firstName`) in the list, then the entire list will be passed through unchanged and used in the `SELECT` clause of the query. By default, all column names in tables joined via the `include` argument will be prepended with the singular version of the included table name.
* @includeCalculated List of calculated property names (declared via `property(name="...", sql="...", select=false)`) to additively opt into this finder's `SELECT` clause. Unlike `select`, this does not replace the default column list — the named calculated properties are merged on top of all default columns, so the rest of the record is still returned. Useful for pulling a `select=false` computed property back in on a single finder without spelling out every other column. Unknown names throw `Wheels.CalculatedPropertyNotFound` in `development`/`testing` and are ignored in `production`.
* @distinct Whether to add the `DISTINCT` keyword to your `SELECT` clause. Wheels will, when necessary, add this automatically (when using pagination and a `hasMany` association is used in the `include` argument, to name one example).
* @include Associations that should be included in the query using `INNER` or `LEFT OUTER` joins (which join type that is used depends on how the association has been set up in your model). If all included associations are set on the current model, you can specify them in a list (e.g. `department,addresses,emails`). You can build more complex include strings by using parentheses when the association is set on an included model, like `album(artist(genre))`, for example. These complex `include` strings only work when `returnAs` is set to `query` though.
* @maxRows Maximum number of records to retrieve. Passed on to the `maxRows` `cfquery` attribute. The default, `-1`, means that all records will be retrieved.
Expand All @@ -32,6 +33,7 @@ component {
string order,
string group,
string select = "",
string includeCalculated = "",
boolean distinct = "false",
string include = "",
numeric maxRows = "-1",
Expand Down Expand Up @@ -214,7 +216,8 @@ component {
include = arguments.include,
includeSoftDeletes = arguments.includeSoftDeletes,
list = arguments.select,
returnAs = arguments.returnAs
returnAs = arguments.returnAs,
includeCalculated = arguments.includeCalculated
);
// Strip dialect quotes: $createSQLFieldList now quotes identifiers; the bare-identifier regex below requires unquoted input.
local.columns = variables.wheels.class.adapter.$stripIdentifierQuotes(local.columns);
Expand Down Expand Up @@ -247,7 +250,8 @@ component {
select = arguments.select,
include = arguments.include,
includeSoftDeletes = arguments.includeSoftDeletes,
returnAs = arguments.returnAs
returnAs = arguments.returnAs,
includeCalculated = arguments.includeCalculated
)
);
ArrayAppend(
Expand Down Expand Up @@ -407,6 +411,7 @@ component {
*
* @key Primary key value(s) of the record. Separate with comma if passing in multiple primary key values. Accepts a string, list, or a numeric value.
* @select [see:findAll].
* @includeCalculated [see:findAll].
* @include [see:findAll].
* @handle Handle to use for the query. This is used to set the name of the query in the debug output (which otherwise defaults to `userFindOneQuery` for example).
* @cache [see:findAll].
Expand All @@ -420,6 +425,7 @@ component {
public any function findByKey(
required any key,
string select = "",
string includeCalculated = "",
string include = "",
string handle = "query",
any cache = "",
Expand Down Expand Up @@ -457,6 +463,7 @@ component {
* @where [see:findAll].
* @order [see:findAll].
* @select [see:findAll].
* @includeCalculated [see:findAll].
* @include [see:findAll].
* @handle [see:findByKey].
* @cache [see:findAll].
Expand All @@ -471,6 +478,7 @@ component {
string where = "",
string order = "",
string select = "",
string includeCalculated = "",
string include = "",
string handle = "query",
any cache = "",
Expand Down
39 changes: 36 additions & 3 deletions vendor/wheels/model/sql.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -411,14 +411,16 @@ component {
required string select,
required string include,
boolean includeSoftDeletes = "false",
required string returnAs
required string returnAs,
string includeCalculated = ""
) {
local.rv = $createSQLFieldList(
clause = "select",
list = arguments.select,
include = arguments.include,
includeSoftDeletes = arguments.includeSoftDeletes,
returnAs = arguments.returnAs
returnAs = arguments.returnAs,
includeCalculated = arguments.includeCalculated
);

// Look for " AS " followed by text containing multiple dots (namespaced aliases)
Expand Down Expand Up @@ -471,7 +473,8 @@ component {
required string include,
required string returnAs,
boolean includeSoftDeletes = "false",
boolean useExpandedColumnAliases = "#application.wheels.useExpandedColumnAliases#"
boolean useExpandedColumnAliases = "#application.wheels.useExpandedColumnAliases#",
string includeCalculated = ""
) {
// setup an array containing class info for current class and all the ones that should be included
local.classes = [];
Expand Down Expand Up @@ -504,6 +507,36 @@ component {
}
}

// Additively opt in any calculated properties named via `includeCalculated` (issue #3252).
// These are typically declared `select=false`, so they are absent from the default list
// above; merging them here keeps every base column in place (additive, never replacing).
if (Len(arguments.includeCalculated)) {
local.calcArray = ListToArray(arguments.includeCalculated);
local.calcEnd = ArrayLen(local.calcArray);
for (local.c = 1; local.c <= local.calcEnd; local.c++) {
local.calcName = Trim(local.calcArray[local.c]);
if (!Len(local.calcName)) {
continue;
}
if (!StructKeyExists(variables.wheels.class.calculatedProperties, local.calcName)) {
// Dev/testing fail loud on a typo; no-op in production (mirrors existing
// dev-only validation such as Wheels.PaginationNav.InvalidArgument).
if (ListFindNoCase("development,testing", get("environment"))) {
Throw(
type = "Wheels.CalculatedPropertyNotFound",
message = "The calculated property `#local.calcName#` was not found on the `#variables.wheels.class.modelName#` model.",
extendedInfo = "The `includeCalculated` argument only accepts the names of calculated properties declared via `property(name=""..."", sql=""..."")` in the model's `config()`. Declared calculated properties: #StructKeyList(variables.wheels.class.calculatedProperties)#."
);
}
continue;
}
// dedup: $createSQLFieldList already de-duplicates, but skip obvious repeats
if (!ListFindNoCase(arguments.list, local.calcName)) {
arguments.list = ListAppend(arguments.list, local.calcName);
}
}
}

// go through the properties and map them to the database unless the developer passed in a table name or an alias in which case we assume they know what they're doing and leave the select clause as is

/* To fix the issue below:
Expand Down
72 changes: 72 additions & 0 deletions vendor/wheels/tests/specs/model/includeCalculatedSpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
component extends="wheels.WheelsTest" {

function run() {

g = application.wo;

describe("includeCalculated finder argument (issue ##3252)", () => {

it("is additive in the generated SELECT — opts in a select=false calculated property without dropping base columns", () => {
// `titleAlias` is declared `select=false` on Post, so it is absent by default.
baseClause = g.model("post").$selectClause(
select = "",
include = "",
returnAs = "query"
);
expect(baseClause).notToInclude("AS titleAlias");

// Opting it in must ADD it on top of the default columns, not replace them.
optedIn = g.model("post").$selectClause(
select = "",
include = "",
returnAs = "query",
includeCalculated = "titleAlias"
);
expect(optedIn).toInclude("AS titleAlias");
// base columns are still present (additive, not replacing)
expect(optedIn).toInclude("title");
});

it("supports a comma list of calculated property names", () => {
clause = g.model("post").$selectClause(
select = "",
include = "",
returnAs = "query",
includeCalculated = "titleAlias,createdAtAlias"
);
expect(clause).toInclude("AS titleAlias");
expect(clause).toInclude("AS createdAtAlias");
});

it("populates the opted-in property on a real finder while base columns remain", () => {
post = g.model("post").findOne(includeCalculated = "titleAlias");
expect(IsObject(post)).toBeTrue();
// base property still present
expect(StructKeyExists(post, "title")).toBeTrue();
// the opted-in calculated property is now populated and mirrors `title`
expect(StructKeyExists(post, "titleAlias")).toBeTrue();
expect(post.titleAlias).toBe(post.title);
});

it("leaves the opted-in property off the default finder", () => {
post = g.model("post").findOne();
expect(IsObject(post)).toBeTrue();
expect(StructKeyExists(post, "titleAlias")).toBeFalse();
});

it("throws Wheels.CalculatedPropertyNotFound for an unknown name in development/testing", () => {
expect(() => {
g.model("post").$selectClause(
select = "",
include = "",
returnAs = "query",
includeCalculated = "thisDoesNotExist"
);
}).toThrow(type = "Wheels.CalculatedPropertyNotFound");
});

});

}

}
Loading