Skip to content

Commit 167418d

Browse files
bpamiriclaudegithub-actions[bot]
authored
fix(model): clear error when struct/array mass-assigned to non-association property (#2412) (#2601)
* fix(model): clear error when struct/array mass-assigned to non-association property (#2412) When `model.new(params.user)` or `setProperties()` was called with a struct or array value for a property that wasn't declared as a nested association, `$setProperty` fell through to `this[property] = value` and silently overwrote the model attribute with the bad shape. The cast error then fired much later — inside a user callback like `beforeValidation("normalizeEmail")` doing `LCase(this.email)` — and Lucee reported "Can't cast Complex Object Type Struct to String" pointing at the user's code instead of at the upstream form-data mismatch. The most common trigger is a curl POST whose body uses bracket-nested keys without an `=` separator: --data-urlencode "user[email][test@example.com]" ^ no `=` here ^ Lucee's form parser reads the trailing `[test@example.com]` as a deeper nested-struct path, so `params.user.email` arrives as `{"test@example.com": ""}` instead of a string. Browsers always %-encode `@` and include the `=`, which is why the rendered form works. Fix: when `$setProperty` reaches the assignment branch and the value is still a struct or array (i.e. no `hasOne`/`hasMany`/`belongsTo` with `nestedProperties()` matched it), throw `Wheels.PropertyIsIncorrectType` with a message naming the model and property plus extendedInfo describing the likely root cause. Legitimate nested-attribute assignments are unaffected because they match the existing branches above the new guard. Also corrects the chapter 6 tutorial gotcha note: the failure mode is the missing `=` separator, not `@` encoding per se. The previous text claimed `user[email]=alice@example.com` was enough to trigger the bug, which contradicts the framework's existing `createParamsSpec.cfc`. Note: local `tools/test-local.sh` could not run from this worktree because another worktree already held the wheels CLI's "wheels" server-name lock. CI is authoritative — the new `propertiesSpec.cfc` describe block exercises the four shapes (struct + non-assoc, array + non-assoc, struct + nested-assoc happy path, scalar happy path). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(model): scope #2412 guard to real DB columns to avoid breaking hasMany/control-param paths CI on the first push surfaced 21 regressions across crud, nested- properties, association, and bulk-operations specs. Two patterns: 1. Arrays of model instances being assigned to `hasMany` properties that don't have `nestedProperties()` enabled (e.g. `author.posts = [postObj1, postObj2]` after `findOne(include="posts")`). The original `$setProperty` skips its nested-collection branch when the array contains objects (`!IsObject(arguments.value[1])` check), so the array fell through to direct assignment — which is legitimate for loaded associations. 2. Framework-internal control parameters like `useIndex` (a struct of model→index-name mappings) leaking through `$setProperties` along with finder/calculation arguments. These aren't model properties at all, just args that happen to pass through the same path. Both shapes hit the new guard's `IsStruct || IsArray` check and threw, even though neither is the bug we're trying to catch. Narrow the guard to fire only when the property is a real scalar database column — i.e. `StructKeyExists(variables.wheels.class.properties, arguments.property)`. `class.properties` is populated from schema introspection at class init and contains only actual DB columns (not associations, not transient args). The original bug case (`email` on a User model) still hits the guard; everything else falls through to the existing assignment branch unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(model): address Reviewer A/B consensus findings (round 1) - Trim multi-line comment block in $setProperty to one short line (N1). - Add regression specs for empty array and array-of-objects assigned to a hasMany property with nestedProperties() enabled, confirming the narrowed guard at properties.cfc:580 falls through for both shapes (T1, A's findings for #2601). - Fix tutorial curl example: the previous illustration used `--data-urlencode "user[email][alice@example.com]"`, but curl reads any unescaped `@` in such an argument as the start of a filename (the `name@filename` form), so the example would error on disk before ever reaching Lucee. Switch to a non-`@` bracket key and call out the curl-level pitfall (B's missed-issue addendum). C1 and C2 (the empty-array and array-of-objects regressions on hasMany+nestedProperties) were already addressed by 6bae0e6, which narrowed the guard to fire only when the property is a real scalar DB column. The two new specs lock that behaviour against future regressions. CM1 (Signed-off-by trailer missing on b840c51 and 6bae0e6) cannot be retroactively added by this bot stage — `.claude/commands/_shared-rails.md` forbids any history-rewriting git subcommand. This commit is signed off; the prior two will need a human-driven amend/rebase or a squash merge to satisfy the DCO check. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(model): address Reviewer A/B consensus findings (round 2) - vendor/wheels/tests/specs/model/propertiesSpec.cfc: trim two multi-line comment blocks to single lines per CLAUDE.md ("never write multi-line comment blocks — one short line max"). The 8-line context block at the top of the new describe and the 3-line fixture explanation inside the "still accepts struct values for nested associations" it() are both replaced with one-line references; the issue #2412 background is already in the PR description and commit messages. - CHANGELOG.md: replace the curl example `--data-urlencode "user[email][test@example.com]"` with `--data-urlencode "user[email][badkey]"`. The `@` form would have failed at the curl client (curl reads any unescaped `@` in `--data-urlencode` as the start of a filename, the `name@filename` form), so the example could not actually trigger the Lucee parser behavior the entry is describing. Matches the tutorial correction already applied in this PR. Reviewer-flagged DCO trailers missing from b840c51 and 6bae0e6 remain a human-action item: rewriting published history is forbidden by .claude/commands/_shared-rails.md, so the PR author needs to run `git rebase --signoff develop && git push --force-with-lease` or the PR can be merged via "Squash and merge" so the resulting commit carries one DCO trailer. Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> * fix(test): escape `#` in propertiesSpec it() descriptions (Lucee tag scanner crash) CI from the most recent push returned HTTP 500 with "Invalid Syntax Closing [#] not found" at propertiesSpec.cfc:437. The two new specs added by 72effe4 and shipped through cc7ecaf embed the literal PR reference `#2601` in their `it("... regression for #2601 review")` description STRINGS — and CFML reads `#` inside a string literal as the start of an `#expression#` interpolation. The scanner cannot find the closing `#`, the whole CFC fails to compile, and the test runner crashes for the entire model bundle (taking 800+ specs down). CLAUDE.md ("Lucee Tag Scanner" memory + the "`#` escape gotcha" call- out in the testing section) documents this exact trap. Comments escape the scanner because `//` lines are not evaluated, which is why the trimmed comment blocks in cc7ecaf still containing `#2412` were fine — but the strings inside `it(...)` calls are not comments. Escape both occurrences as `##2601` so the `#` survives as a literal. Reproduced and verified locally against this worktree's Lucee 7 + SQLite server: 810 model specs pass (3445 total core), 0 failures, 0 errors (the 12 remaining errors come from `BrowserLoginSpec` / `BrowserRouteSpec` needing `wheels browser setup` and are unrelated to this PR). Signed-off-by: Peter Amiri <peter@alurium.com> --------- Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
1 parent 1723dd5 commit 167418d

4 files changed

Lines changed: 58 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo
154154

155155
### Fixed
156156

157+
- Model `$setProperty` now throws `Wheels.PropertyIsIncorrectType` when a struct or array value is mass-assigned to a property that isn't declared as a nested association, instead of silently overwriting `this.<property>` and producing a confusing `Can't cast Complex Object Type Struct to String` deep inside a user callback. The most common upstream cause is form data shaped by a curl POST whose body uses bracket-nested keys without an `=` separator (e.g. `--data-urlencode "user[email][badkey]"`); Lucee's form parser turns that into a nested-struct path so `params.user.email` arrives shaped like a struct. Legitimate nested-attribute assignments (`hasOne`/`hasMany`/`belongsTo` with `nestedProperties()` enabled) continue to work unchanged. Also corrects the chapter 6 tutorial's curl gotcha note: the failure mode is the missing `=` separator, not `@` encoding per se. (#2412)
157158
- `wheels test` preamble no longer prints `<base>_test_test` for apps that only declare `coreTestDataSourceName`. `$resolveAppTestDataSource` in `cli/lucli/Module.cfc` searched `config/settings.cfm` with the regex `dataSourceName\s*=\s*"([^"]*)"`, which case-insensitively matched the trailing substring inside `set(coreTestDataSourceName="testappdb_test")` and then re-appended `_test`. The matcher now uses `\bdataSourceName\b` and strips CFML comments before the lookup (matching the pattern already used by `info()`), and guards against re-appending `_test` if the resolved base already ends in `_test`. Extracted the app-runner's `?directory=` regex into a `TestDirectoryResolver` helper alongside `TestDbResolver` so the silent-fallback path (a bare `?directory=models` collapsing to `tests.specs`) is unit-testable instead of HTTP-only. (#2489)
158159
- Core test suite no longer crashes on Adobe ColdFusion 2023/2025 with `java.lang.ArrayStoreException: coldfusion.compiler.ASTcffunction`. `vendor/wheels/tests/specs/middleware/RateLimiterSpec.cfc` passed 12 inline `keyFunction = function(req) { ... }` literals as named arguments to `new wheels.middleware.RateLimiter(...)`; Adobe CF's bytecode generator (`ExprAssembler.invokeNew``generateSetVarCode`) rejects function-AST nodes in that array slot and the failure fires from `getComponentMetadata()`, eagerly crashing every CFC in the bundle directory and forcing every database matrix cell on `adobe2023`/`adobe2025` to HTTP 500. All 12 closures are now hoisted into local `var keyFn = ...` declarations above the constructor call, matching the existing workaround in `SessionStrategySpec.cfc`. No behavior change on Lucee/BoxLang. Trap documented in `.ai/wheels/cross-engine-compatibility.md` and `CLAUDE.md` "Known cross-engine gotchas" list. (#2568, #2599)
159160
- `LICENSE` and `NOTICE` are now bundled into the `wheels-core`, `wheels-cli`, and `wheels-starter-app` release artifacts so every distributed scaffold ships with Apache 2.0 §4(a) license text and §4(d) NOTICE attribution. Previously only the base-template artifact bundled them — derivatives published from the other three prepare scripts left downstream redistributors out of compliance.

vendor/wheels/model/properties.cfc

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,17 @@ component {
577577
value = arguments.value,
578578
association = arguments.associations[arguments.property]
579579
);
580+
} else if (
581+
(IsStruct(arguments.value) || IsArray(arguments.value))
582+
&& StructKeyExists(variables.wheels.class, "properties")
583+
&& StructKeyExists(variables.wheels.class.properties, arguments.property)
584+
) {
585+
// Scoped to real DB columns so loaded hasMany arrays and control-param leakage still pass through. See #2412.
586+
Throw(
587+
type = "Wheels.PropertyIsIncorrectType",
588+
message = "Cannot assign a #(IsArray(arguments.value) ? 'array' : 'struct')# value to scalar column `#arguments.property#` on the `#variables.wheels.class.modelName#` model.",
589+
extendedInfo = "Property `#arguments.property#` is a scalar database column, but `setProperties()` was called with a #(IsArray(arguments.value) ? 'array' : 'struct')# value for it. This usually means upstream form data arrived in an unexpected shape — most commonly a curl POST body using bracket-nested keys without an `=` separator (e.g. `user[email][nested@key]`), which Lucee's form parser turns into a nested-struct path so `params.user.email` ends up shaped like a struct instead of a string. If you actually want to accept structured data here, the property must be declared as an association with `hasOne`, `hasMany`, or `belongsTo` and have mass-assignment enabled via `nestedProperties()`."
590+
);
580591
} else {
581592
this[arguments.property] = arguments.value;
582593
}

vendor/wheels/tests/specs/model/propertiesSpec.cfc

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,51 @@ component extends="wheels.WheelsTest" {
402402
})
403403
})
404404

405+
describe("Defensive guard for struct/array values on non-association properties", () => {
406+
// Regression coverage for issue #2412.
407+
408+
it("throws Wheels.PropertyIsIncorrectType when a struct is mass-assigned to a non-association property", () => {
409+
expect(function() {
410+
g.model("author").new({firstName = {"nested@key" = ""}})
411+
}).toThrow("Wheels.PropertyIsIncorrectType")
412+
})
413+
414+
it("throws Wheels.PropertyIsIncorrectType when an array is mass-assigned to a non-association property", () => {
415+
expect(function() {
416+
g.model("author").new({firstName = ["unexpected", "values"]})
417+
}).toThrow("Wheels.PropertyIsIncorrectType")
418+
})
419+
420+
it("still accepts struct values for properties registered as nested associations", () => {
421+
// Author's hasOne('profile') has nestedProperties enabled.
422+
_author = g.model("author").new({
423+
firstName = "Eve",
424+
lastName = "Tester",
425+
profile = {dateOfBirth = "2000-01-01"}
426+
})
427+
expect(_author.firstName).toBe("Eve")
428+
expect(IsObject(_author.profile)).toBeTrue()
429+
})
430+
431+
it("still accepts scalar values for normal properties (regression guard)", () => {
432+
_author = g.model("author").new({firstName = "Eve", lastName = "Tester"})
433+
expect(_author.firstName).toBe("Eve")
434+
expect(_author.lastName).toBe("Tester")
435+
})
436+
437+
it("still accepts empty arrays for hasMany properties with nestedProperties enabled (regression for PR ##2601 review)", () => {
438+
_gallery = g.model("gallery").new({title = "Empty Photos Gallery", photos = []})
439+
expect(_gallery.title).toBe("Empty Photos Gallery")
440+
})
441+
442+
it("still accepts arrays of model objects for hasMany properties with nestedProperties enabled (regression for PR ##2601 review)", () => {
443+
var _p1 = g.model("photo").new({filename = "p1.jpg", DESCRIPTION1 = "first"})
444+
var _p2 = g.model("photo").new({filename = "p2.jpg", DESCRIPTION1 = "second"})
445+
_gallery = g.model("gallery").new({title = "Object Photos Gallery", photos = [_p1, _p2]})
446+
expect(_gallery.title).toBe("Object Photos Gallery")
447+
})
448+
})
449+
405450
describe("Tests that propertyIsBlank", () => {
406451

407452
it("return false when property is set", () => {

web/sites/guides/src/content/docs/v4-0-0-snapshot/start-here/tutorial/06-authentication.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,7 @@ That's the hand-rolled version. Thirty-ish lines of logic total, no hidden machi
499499
<Aside type="caution" title="Scripting smoke tests with curl? Read this first.">
500500
**CSRF token rotation**: Wheels rotates the CSRF token after each accepted POST. A scraped token is single-use — re-POSTing with the same token fails with `Wheels.InvalidAuthenticityToken`. To script a flow, fetch a fresh form before every write. **Always scrape the token from the layout's `<meta name="csrf-token" content="...">` tag**, not from a form's hidden `authenticityToken` input. The meta tag is in the response on every page render and always carries the current token. Form-helper-emitted hidden inputs work too on signup, but other forms (login, post show with the comments form, the rewritten basecoat views in chapter 8) don't always include the input — scraping the meta tag uniformly avoids the special cases.
501501

502-
**curl `--data-urlencode` and `@`**: build the body with raw `--data` and pre-encode the brackets and the `@`. Lucee's form parser interprets any URL key with brackets as a nested-struct path`user[email]=alice@example.com` arrives in your controller as `params.user.email = {"alice@example.com": ""}`, which then fails with the confusing `Can't cast Complex Object Type Struct to String` when Wheels' `normalizeEmail()` calls `LCase()` on it. The reliable shape is:
502+
**curl bracket-syntax and `@`**: a curl POST body using bracket-nested keys without an `=` separator — for example `--data-urlencode "user[email][badkey]"` — is read by Lucee's form parser as a deeper nested-struct path. The email arrives in your controller as `params.user.email = {"badkey": ""}` (a struct, not a string), and the next assignment via `setProperties()` is rejected with a `Wheels.PropertyIsIncorrectType` error from the model layer. (Don't put a literal `@` inside the bracket key when reproducing this — `--data-urlencode` reads any unescaped `@` as the start of a filename, so curl would error on the example before it ever reached Lucee.) The fix is to keep the `=` separator AND %-encode the brackets and the `@` in the body. The reliable shape is:
503503

504504
```bash title="known-working signup smoke test"
505505
TOKEN=$(curl -s http://localhost:8080/signup | grep -oE 'content="[^"]+" name="csrf-token"' | sed -E 's/.*content="([^"]+)".*/\1/')

0 commit comments

Comments
 (0)