matrei commented on PR #16322:
URL: https://github.com/apache/grails-core/pull/16322#issuecomment-5677678800
## Review Findings, round 2
Head `22163f7f11`, base `8.0.x` at `3067d0a855`. The branch now contains the
base head, so the merge-base is the base itself and there is nothing to merge.
All three P2s from the first round are addressed: model values are converted
with `DefaultTypeTransformation.castToType`, the static compilation guide says
what a mismatched value does, the welcome page is compiled statically by
`gsp-compile-static`, and the scaffolded `index.gsp` count declaration has a
spec. The scaffolding guide nit is fixed too. The listener sort hunk is
unchanged, which is fine.
What I ran on the head, all green:
- `:grails-gsp-core:test --tests GspCompileStaticSpec`: 40 tests, 0 failures.
- `:grails-scaffolding:test --tests ScaffoldedIndexViewModelSpec`: 3 tests,
0 failures.
- `:grails-test-examples-gsp-compile-static:integrationTest`: 7 tests, 0
failures. The staged directory holds the app's five pages plus the welcome
page, `views.properties` lists `/WEB-INF/grails-app/views/index.gsp`, and the
compiled class extends `CompileStaticGroovyPage`.
- `:grails-gsp-core:codeStyle` and `:grails-scaffolding:codeStyle`: no
violations. The modules have no PMD or SpotBugs tasks.
One new finding, in the value-change guard the PR adds on top of the
coercion, and one small one next to it.
### [P2] The value-change guard rejects a `Float` widened to a `Double` field
References:
- `grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java:198` (the
guard)
- `grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java:211-219`
(`sameNumericValue`)
- `grails-doc/src/en/guide/theWebLayer/gsp/gspStaticCompilation.adoc:56`
`sameNumericValue` compares the two numbers through `new
BigDecimal(n.toString())`. `Float.toString` prints the shortest decimal that
rounds back to the float, `Double.toString` prints the shortest decimal that
rounds back to the double, and those differ for the same binary value: `0.1f`
prints `0.1`, and the same value as a `double` prints `0.10000000149011612`.
The widening itself is exact, every `float` is representable as a `double`, but
the decimal forms are not equal, so the guard throws.
I rendered `@{ model="Double v"}${v}` with `[v: 0.1f]` through
`GroovyPagesTemplateEngine` on the head:
```
GroovyPagesException: Model field 'v' is declared as java.lang.Double, which
cannot hold the java.lang.Float 0.1 the model supplied without changing it.
```
Same for `double v` with `1.1f`. Plain Groovy assigns `double d = 0.1f`
without complaint, so the guide's sentence at line 56, "converted to the
declared type the way a Groovy assignment converts it", is not true for the one
conversion that loses nothing. The same run showed the reverse direction,
`Float v` with `0.1d`, rendering `0.1`: the narrowing that does drop bits
passes because both sides print `0.1`. The guard is backwards for the float
family.
A `Double` model field fed a `Float` is not exotic: a domain property
declared `Float` rendered through a page whose author wrote `Double`, or
`BigDecimal` for that matter, which works, so the `Double` failure will read as
arbitrary.
The rule the doc states, "a conversion that would change the value fails",
holds up if the comparison is done at the precision of the value that came in
rather than through decimal strings. One shape that gives that: convert
`converted` back to the original's class and compare it to the original, using
`compareTo` when the original is a `BigDecimal` so that scale does not matter.
```java
Number back = (Number) DefaultTypeTransformation.castToType(converted,
original.getClass());
return original instanceof BigDecimal
? ((BigDecimal) original).compareTo((BigDecimal) back) == 0
: original.equals(back);
```
Against the cases in the spec plus the ones above: `0.1f` to `Double` comes
back as `0.1f` and passes; `0.1d` to `Float` comes back as
`0.10000000149011612d` and fails, which is the lossy one; `3_000_000_000L` to
`Integer` comes back as `-1294967296L` and fails; `42.0G` to `Integer` comes
back as `42` and compares equal; `42.9G` fails; `NaN` to `Integer` comes back
as `0.0` and fails; `Long.MAX_VALUE` to `Double` comes back as `Long.MAX_VALUE`
because `Double.longValue` saturates, so it passes the way Java's own `long` to
`double` widening does. The one thing to guard is a `Number` subclass Groovy
cannot cast back to, `AtomicLong` say, where `castToType` throws; falling back
to the current comparison for that is enough. Whichever rule is chosen, the
spec's "converted the way a Groovy assignment converts it" table at
`GspCompileStaticSpec.groovy:264` should carry a `'Double' | 0.1f` row, since
that is the row that fails today.
For the record, two other rejections from the same probe are consistent with
the doc sentence and I do not count them as defects: `Long.MAX_VALUE` into a
`Double` field and `16_777_217` into a `Float` field both throw, and both
really do lose precision.
### [P3] `NaN` into a `BigDecimal` or `BigInteger` field escapes as a raw
`NumberFormatException`
References:
- `grails-gsp/core/src/main/groovy/org/grails/gsp/GroovyPage.java:192-196`
`castToType(Double.NaN, BigDecimal.class)` throws `NumberFormatException`,
not `GroovyCastException`, so it passes the `catch` at line 193 and surfaces
without the field name:
```
NumberFormatException: Character N is neither a decimal digit number,
decimal point, nor "e" notation exponential mark.
```
Every other failure in this method is a `GroovyPagesException` naming the
field. Catching `GroovyCastException | NumberFormatException` (or
`RuntimeException`, since `castToType` has no checked failure mode) at line 193
keeps that promise for the infinities and `NaN` too. The `Double.compare`
fallback at line 217 already anticipates these values for the guard; this is
the same case one step earlier.
### Nit: mirror the plugin's duplicate handling in the staging task
References:
- `grails-test-examples/gsp-compile-static/build.gradle:56-60`
The Gradle plugin's own view staging (`GroovyPagePlugin.stageGroovyPages`)
sets `duplicatesStrategy = DuplicatesStrategy.EXCLUDE` with the application's
views listed first, so the app's page wins. The PR's `Sync` lists the app's
views first too but leaves the strategy at the default, which in Gradle 9 fails
the task on a duplicate path. Nothing collides today, because the app has no
root `index.gsp`; the day someone adds one, the message will be about `Sync`
rather than about the page. One line keeps it consistent with the plugin, and
the comment above the task already explains why the welcome page is there.
### Confirmed
- Setting `source` on `compileGroovyPages` to a `Directory` also sets
`srcDir` (`GroovyPageForkCompileTask.setSource`), so the staged pages keep
their relative names: the welcome page is registered as
`/WEB-INF/grails-app/views/index.gsp`, not under `build/generated`. The plugin
sets `source` inside the task's registration action, so the build script's
`tasks.named` block runs after it and is not overwritten.
- The example app maps `/` to `demo/index`, so the precompiled welcome page
sits in `views.properties` unused at runtime. Compiling it is the whole point,
and it changes nothing the integration tests request.
- `ScaffoldedIndexViewModelSpec` expands the template with
`GStringTemplateEngine` the way `ScaffoldingViewResolver` does, and a model
directive sets `compileStaticModeSetting` in `GroovyPageParser`, so the
declaration really becomes a field. The `3_000_000_000L` row is the one that
would fail if the template went back to `Integer`; the `3` and `3L` rows pass
under either declaration now that values are coerced, which the `where:` label
says.
- `GroovyPagesTemplateEngine.createTemplate(String, String)` caches by name,
and the spec names the `3` and `3L` iterations both `scaffoldedIndex3`, but
each iteration builds a fresh engine and the source is identical, so nothing is
reused across rows.
- `Boolean showAll` fed the `String` `"false"` now renders `true`. That is
Groovy truth, `Boolean b = "false"` gives the same, and it matches the doc
sentence. Before the PR it threw an `IllegalArgumentException` from
`Field.set`. Noting it so nobody reads it as a regression later.
- The `42.0G` to `Integer` row passes through the guard because
`sameNumericValue` compares `BigDecimal` values with `compareTo`, so scale is
ignored. Good.
- `List` declared, `Set` supplied throws a `GroovyCastException`, same as a
plain Groovy assignment, and is reported through the new message with both
types.
- Import order in the spec (`org.codehaus.groovy` between
`org.grails.core.gsp` and `org.grails.taglib`) passes CodeNarc.
- The stale `book/index.gsp` classes from my round-one hand test were still
in the example app's `build/gsp-classes` and were listed in the regenerated
`views.properties`; I removed them. The compile task does not clear stale
output, but that is pre-existing and not touched here.
## Verification
- `./gradlew :grails-gsp-core:test --tests
org.grails.gsp.GspCompileStaticSpec :grails-scaffolding:test --tests
grails.plugin.scaffolding.ScaffoldedIndexViewModelSpec
:grails-test-examples-gsp-compile-static:integrationTest
:grails-gsp-core:checkstyleMain --continue`: BUILD SUCCESSFUL, 40 + 3 + 7
tests, 0 failures.
- `./gradlew :grails-gsp-core:codeStyle :grails-scaffolding:codeStyle`:
BUILD SUCCESSFUL.
- `DefaultTypeTransformation.castToType` probed directly with Groovy 5.1.2
for 23 declared/supplied pairs, and 10 of them rendered through
`GroovyPagesTemplateEngine` on the head via a throwaway spec (deleted
afterwards). The `Double`/`Float`, `NaN`/`BigDecimal` and `Boolean`/`"false"`
results above come from that run.
- `git status` shows no tracked changes.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]