diff --git a/MODEL_BENCH_MARK.md b/MODEL_BENCH_MARK.md new file mode 100644 index 00000000000..78dd2374453 --- /dev/null +++ b/MODEL_BENCH_MARK.md @@ -0,0 +1,143 @@ +# What registering additional models costs + +Measured comparison of the two ways a component can hold models beside its default model: + +| Variant | Extra models held in | Detached by | +| --- | --- | --- | +| `FIELDS` | one field each | a hand-written `onDetach()` | +| `REGISTERED` | one field each | `addAdditionalModel` | +| `REGISTERED_NO_FIELDS` | nothing | `addAdditionalModel` | + +All three carry the same default model and the same extra models, so every difference below +is bookkeeping, never the models themselves. + +**Environment:** OpenJDK 24.0.1, `-Xmx1g`, 1,000 children per tree, JOL 0.17, JMH 1.37. +JMH run with `-f 1 -wi 3 -i 3 -r 1 -w 1` — quick mode, so treat `ns/op` as indicative and +`B/op` as exact (allocation is counted, not sampled). + +Reproduce with `wicket-benchmarks/run-model-benchmarks.sh`. + +--- + +## Memory — retained heap, bytes per component + +Deltas against the same component carrying only a default model. + +| Extra models | `FIELDS` | `REGISTERED` | `REGISTERED_NO_FIELDS` | registering costs | +| ---: | ---: | ---: | ---: | ---: | +| 1 | 64.0 | 136.0 | 120.0 | **+72.0** | +| 2 | 128.0 | 200.0 | 184.0 | **+72.0** | +| 3 | 192.0 | 272.0 | 256.0 | **+80.0** | + +With `-XX:+UseCompactObjectHeaders` (every object 4 bytes smaller): + +| Extra models | `FIELDS` | `REGISTERED` | `REGISTERED_NO_FIELDS` | registering costs | +| ---: | ---: | ---: | ---: | ---: | +| 1 | 63.9 | 119.9 | 111.9 | **+56.0** | +| 2 | 127.8 | 191.8 | 183.8 | **+64.0** | +| 3 | 191.8 | 255.8 | 247.8 | **+64.0** | + +## Memory — serialized bytes per component + +What the page store pays. + +| Extra models | `FIELDS` | `REGISTERED` | `REGISTERED_NO_FIELDS` | registering costs | +| ---: | ---: | ---: | ---: | ---: | +| 1 | 13.9 | 47.2 | 40.2 | **+33.3** | +| 2 | 27.8 | 66.1 | 55.1 | **+38.3** | +| 3 | 41.7 | 85.0 | 70.0 | **+43.3** | + +Unaffected by compact headers, as expected — the wire format does not carry object headers. + +## Where the bytes go + +JOL breakdown for `REGISTERED` with 3 extra models, 1,000 components: + +| Object | Count | Avg | Sum | +| --- | ---: | ---: | ---: | +| `AdditionalModelsShapes$RegisteredModels` | 1000 | 72 | 72,000 | +| `[Lorg.apache.wicket.model.IModel;` | 1000 | 32 | 32,000 | +| `org.apache.wicket.ComponentState` | 1000 | 24 | 24,000 | +| `org.apache.wicket.MetaDataEntry` | 1000 | 24 | 24,000 | +| `org.apache.wicket.model.Model` | 4000 | 16 | 64,000 | + +The three objects a registered component pays for are exactly `ComponentState` (24) + +`MetaDataEntry` (24) + the `IModel[]` (32) = **80 bytes** — the measured `+80.0` at three +models. The array is the only part that grows: 24 bytes at one model, 32 at three. + +## Time and allocation + +`build` — constructing the component, where registering does its extra work: + +| Extra models | Variant | ns/op | B/op | +| ---: | --- | ---: | ---: | +| 1 | `FIELDS` | 26.2 ± 2.2 | 152 | +| 1 | `REGISTERED` | 33.9 ± 12.6 | 224 | +| 1 | `REGISTERED_NO_FIELDS` | 32.2 ± 2.0 | 208 | +| 2 | `FIELDS` | 35.6 ± 2.0 | 216 | +| 2 | `REGISTERED` | 54.0 ± 4.7 | 312 | +| 2 | `REGISTERED_NO_FIELDS` | 54.4 ± 3.9 | 296 | +| 3 | `FIELDS` | 41.9 ± 1.9 | 280 | +| 3 | `REGISTERED` | 75.6 ± 10.7 | 408 | +| 3 | `REGISTERED_NO_FIELDS` | 76.5 ± 15.8 | 392 | + +`buildAndDetach` — the whole per-request cycle: + +| Extra models | Variant | ns/op | B/op | +| ---: | --- | ---: | ---: | +| 1 | `FIELDS` | 31.8 ± 2.8 | 152 | +| 1 | `REGISTERED` | 39.8 ± 11.0 | 224 | +| 1 | `REGISTERED_NO_FIELDS` | 38.8 ± 1.2 | 208 | +| 2 | `FIELDS` | 39.1 ± 2.0 | 216 | +| 2 | `REGISTERED` | 60.4 ± 1.7 | 312 | +| 2 | `REGISTERED_NO_FIELDS` | 61.2 ± 2.1 | 296 | +| 3 | `FIELDS` | 47.0 ± 5.6 | 280 | +| 3 | `REGISTERED` | 82.5 ± 5.4 | 408 | +| 3 | `REGISTERED_NO_FIELDS` | 88.4 ± 3.6 | 392 | + +--- + +## Conclusions + +**1. The documented "50 to 80 bytes" is right, and slightly conservative at the top end.** +Retained heap costs **72 to 80 bytes per component** with default headers, **56 to 64** with +compact ones. The guide and the `addAdditionalModel` javadoc can stand as they are. + +**2. The cost is near-flat in the number of models, as claimed — but not exactly flat.** +72, 72, 80 bytes for one, two and three models. The fixed part is the `MetaDataEntry` plus +the `ComponentState` a component needs once it has more than one kind of state; only the +`IModel[]` grows, and it grows by 8 bytes per few models, not per model. Wording like +"whatever their number" is fair for heap. It is weaker for the wire: **+33, +38, +43 +bytes**, about 5 bytes per model, because serialization writes the array and its class +descriptor. + +**3. Serialized cost is the one that bites.** A registered component is **3.4× to 2.0×** +the serialized size of the hand-detached one (47.2 vs 13.9 bytes at one model). Small in +absolute terms, but the page store pays it on every page write, for every instance. + +**4. Construction is measurably slower, and that is the real per-instance price.** +`build` goes from 41.9 to 75.6 ns/op at three models — roughly **+80%**, well outside the +error bars. Allocation confirms it exactly: **+128 B/op** at three models. This is the +number to weigh for a component rendered in the thousands, and it is a stronger argument +than the heap figure that the components shipped with Wicket should keep detaching their +own models. + +**5. Detaching itself is cheaper when registered, which partly offsets construction.** +`buildAndDetach` minus `build` is ~5 ns for `FIELDS` against ~4–7 ns for `REGISTERED`: the +framework's loop over the model array costs about what three hand-written null-checked +`detach()` calls cost. Registering buys its convenience at construction time, not at detach +time. + +**6. Dropping the fields is worth 16 bytes of heap and 11–15 bytes on the wire.** +`REGISTERED_NO_FIELDS` beats `REGISTERED` at every model count, and the time difference is +within the noise. A component that only passes models to its children should use the +`(String, IModel, IModel...)` constructor rather than keeping fields it never reads — an +option the hand-detached approach cannot offer at all, since it needs the fields to reach +the models from `onDetach()`. + +**Where the trade stops paying.** At ~80 bytes of heap and ~43 of wire per component, a +panel used 50 times on a page costs 4KB of heap — irrelevant. The same feature on a cell +rendered 10,000 times in a `DataTable` costs 800KB of heap, 430KB per serialized page, and +roughly 0.34ms of extra construction per render. The existing guidance — register in +application components, detach by hand in components rendered in the thousands — is +supported by these numbers. diff --git a/README.md b/README.md index fb2234a0475..83f6fc50eda 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,13 @@ You will find the source code here: |-- wicket |-- wicket-auth-roles |-- wicket-bean-validation + |-- wicket-benchmarks |-- wicket-cdi |-- wicket-cdi-tests |-- wicket-core |-- wicket-tester |-- wicket-core-tests + |-- wicket-coverage |-- wicket-devutils |-- wicket-eclipse-settings |-- wicket-examples @@ -79,11 +81,13 @@ You will find the source code here: | |-- wicket-metrics | |-- wicket-http2 |-- wicket-extensions + |-- wicket-extensions-tester |-- wicket-guice |-- wicket-ioc |-- wicket-jmx + |-- wicket-migration |-- wicket-native-websocket - |-- wicket-objectssizeof-agent + |-- wicket-objectsizeof-agent |-- wicket-request |-- wicket-spring |-- wicket-util @@ -100,7 +104,7 @@ Here is a list of projects in the distribution and what they do. - wicket-auth-roles: a basic authorization package based on roles; - wicket-jmx: registers JMX beans for managing things like your Wicket configuration and markup cache; - - wicket-objectssizeof-agent: utility for making better estimates of object + - wicket-objectsizeof-agent: utility for making better estimates of object sizes in the JVM - most people probably never need this; - wicket-ioc: base project for IoC (aka DI) implementations such as Spring and Guice; @@ -129,6 +133,14 @@ Here is a list of projects in the distribution and what they do. - wicket-user-guide: the user guide of wicket - wicket-metrics: collects data of a running wicket application - wicket-http2: http/2 push support + - wicket-extensions-tester: contains test cases for the wicket-extensions module; + - wicket-migration: OpenRewrite recipes which migrate an application to the current + version of wicket; + - wicket-benchmarks: JMH benchmarks used during development. Never released and it + contains no unit tests; it stays in the build so the benchmarks keep compiling + against the current API; + - wicket-coverage: aggregates the JaCoCo coverage of the other modules into a single + report. It produces no artifact of its own. Dependencies ------------ diff --git a/wicket-benchmarks/run-model-benchmarks.sh b/wicket-benchmarks/run-model-benchmarks.sh new file mode 100755 index 00000000000..9a191cba116 --- /dev/null +++ b/wicket-benchmarks/run-model-benchmarks.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Runs the additional-models footprint and JMH benchmarks and prints the raw output. +# bash run-model-benchmarks.sh quick (~4 min, 1 fork) +# bash run-model-benchmarks.sh full full rigor (~15 min, 3 forks) + +# re-exec under bash when started with sh: dash has no pipefail +if [ -z "${BASH_VERSION:-}" ]; then exec bash "$0" "$@"; fi + +set -euo pipefail +cd "$(dirname "$(readlink -f "$0")")/.." + +MODE="${1:-quick}" +OUT=target/model-benchmarks.txt +mkdir -p target + +echo "### building (quiet) ..." >&2 +mvn -o -q clean -pl wicket-core +mvn -o -q -pl wicket-benchmarks -am compile +# Copy the dependency jars into the module, rather than referencing ~/.m2: a confined +# process (Claude Code's snap) can read the repo but not hidden directories under $HOME. +if [ ! -d wicket-benchmarks/target/deps ]; then + mvn -o -q -pl wicket-benchmarks dependency:copy-dependencies \ + -DoutputDirectory=wicket-benchmarks/target/deps \ + || mvn -q -pl wicket-benchmarks dependency:copy-dependencies \ + -DoutputDirectory=wicket-benchmarks/target/deps +fi + +CP="wicket-benchmarks/target/classes:wicket-core/target/classes:wicket-util/target/classes:\ +wicket-request/target/classes:wicket-tester/target/classes:wicket-benchmarks/target/deps/*" + +{ + echo "===== JOL FOOTPRINT, default headers =====" + java --add-opens java.base/java.lang=ALL-UNNAMED -cp "$CP" \ + org.apache.wicket.benchmarks.AdditionalModelsFootprint + + echo + echo "===== JOL FOOTPRINT, -XX:+UseCompactObjectHeaders =====" + java -XX:+UseCompactObjectHeaders --add-opens java.base/java.lang=ALL-UNNAMED -cp "$CP" \ + org.apache.wicket.benchmarks.AdditionalModelsFootprint 2>&1 || \ + echo "(compact object headers not supported by this JDK)" + + echo + echo "===== JMH, mode=$MODE =====" + if [ "$MODE" = full ]; then + java -cp "$CP" org.openjdk.jmh.Main AdditionalModelsBenchmark -prof gc -jvmArgs "-Xmx1g" + else + java -cp "$CP" org.openjdk.jmh.Main AdditionalModelsBenchmark \ + -prof gc -jvmArgs "-Xmx1g" -f 1 -wi 3 -i 3 -r 1 -w 1 + fi +} 2>&1 | tee "$OUT" + +echo >&2 +echo "### raw output also saved to $OUT" >&2 diff --git a/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/AdditionalModelsBenchmark.java b/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/AdditionalModelsBenchmark.java new file mode 100644 index 00000000000..c4096d9d204 --- /dev/null +++ b/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/AdditionalModelsBenchmark.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.wicket.benchmarks; + +import java.util.concurrent.TimeUnit; + +import org.apache.wicket.Component; +import org.apache.wicket.benchmarks.AdditionalModelsShapes.Variant; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * What it costs in time and allocation to let {@code Component} detach a component's extra models + * instead of detaching them by hand, for the shapes of {@link AdditionalModelsShapes}. + *

+ * Two operations, because they answer different questions: + *

+ * Always run with {@code -prof gc}: {@code gc.alloc.rate.norm} is the number that decides whether + * the convenience is affordable for a component rendered in large numbers, and it is far steadier + * than throughput. For the memory a component keeps, rather than the garbage it makes, see + * {@link AdditionalModelsFootprint}. + */ +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Fork(3) +@Threads(1) +@Warmup(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS) +@State(Scope.Thread) +public class AdditionalModelsBenchmark +{ + @Param + public Variant variant; + + @Param({ "1", "2", "3" }) + public int extraModels; + + @Setup + public void setUp() + { + WicketContext.attach(); + } + + @TearDown + public void tearDown() + { + WicketContext.detach(); + } + + @Benchmark + public Component build() + { + return variant.newComponent("c", extraModels); + } + + @Benchmark + public void buildAndDetach(Blackhole blackhole) + { + Component component = variant.newComponent("c", extraModels); + component.detach(); + blackhole.consume(component); + } +} diff --git a/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/AdditionalModelsFootprint.java b/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/AdditionalModelsFootprint.java new file mode 100644 index 00000000000..a4a901d5b78 --- /dev/null +++ b/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/AdditionalModelsFootprint.java @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.wicket.benchmarks; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectOutputStream; + +import org.apache.wicket.Component; +import org.apache.wicket.benchmarks.AdditionalModelsShapes.Variant; +import org.apache.wicket.markup.html.WebMarkupContainer; +import org.openjdk.jol.info.GraphLayout; + +/** + * What it costs to let {@code Component} detach a component's extra models instead of detaching + * them by hand, in bytes. + *

+ * Three ways of holding the same extra models are measured against the same component carrying + * only a default model, so the difference is the bookkeeping and not the models: + *

+ * Run for one, two and three extra models: the interesting question is not only how much the meta + * data costs but whether it grows with the number of models, which is what decides how the two + * approaches compare for a component holding several. + *

+ * Reports retained heap via JOL, what a live page costs in the page cache, and serialized bytes, + * what it costs in the page store. Run it twice, with and without + * {@code -XX:+UseCompactObjectHeaders}. + *

+ * Not a JMH benchmark - it measures size, not time, so it is a plain main. For the time side see + * {@link AdditionalModelsBenchmark}. + */ +public final class AdditionalModelsFootprint +{ + private static final int CHILDREN = 1_000; + + private static final int[] EXTRA_MODELS = { 1, 2, 3 }; + + private AdditionalModelsFootprint() + { + } + + public static void main(String[] args) throws Exception + { + WicketContext.attach(); + try + { + System.out.printf("Additional models footprint, %d children per tree%n", CHILDREN); + System.out.printf("compact object headers: %s%n%n", compactHeaders()); + + long baseHeap = retained(tree(Variant.FIELDS, 0)); + long baseWire = serialized(tree(Variant.FIELDS, 0)); + System.out.printf("baseline, default model only: heap %d, wire %d%n%n", baseHeap, + baseWire); + + System.out.printf("%-22s %6s %12s %12s %10s %12s %12s %10s%n", "variant", "extra", + "heap", "heap-Δ", "Δ/comp", "wire", "wire-Δ", "Δ/comp"); + System.out.println("-".repeat(104)); + + for (int extra : EXTRA_MODELS) + { + for (Variant variant : Variant.values()) + { + long heap = retained(tree(variant, extra)); + long wire = serialized(tree(variant, extra)); + System.out.printf("%-22s %6d %12d %12d %10.1f %12d %12d %10.1f%n", variant, + extra, heap, heap - baseHeap, (heap - baseHeap) / (double)CHILDREN, wire, + wire - baseWire, (wire - baseWire) / (double)CHILDREN); + } + System.out.println(); + } + + System.out.printf("%nWhere the bytes are, REGISTERED with %d extra models:%n%n", + EXTRA_MODELS[EXTRA_MODELS.length - 1]); + System.out.println(GraphLayout + .parseInstance(tree(Variant.REGISTERED, EXTRA_MODELS[EXTRA_MODELS.length - 1])) + .toFootprint()); + } + finally + { + WicketContext.detach(); + } + } + + /** A parent with {@link #CHILDREN} children, each holding its extra models the given way. */ + private static WebMarkupContainer tree(Variant variant, int extraModels) + { + WebMarkupContainer parent = new WebMarkupContainer("parent"); + for (int i = 0; i < CHILDREN; i++) + { + parent.add(variant.newComponent("c" + i, extraModels)); + } + return parent; + } + + private static long retained(Object root) + { + return GraphLayout.parseInstance(root).totalSize(); + } + + private static long serialized(Component root) throws IOException + { + root.detach(); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ObjectOutputStream out = new ObjectOutputStream(bytes)) + { + out.writeObject(root); + } + return bytes.size(); + } + + private static String compactHeaders() + { + // a plain Object is 16 bytes with 12-byte headers, 8 with compact ones + return GraphLayout.parseInstance(new Object()).totalSize() <= 8 ? "on" : "off"; + } +} diff --git a/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/AdditionalModelsShapes.java b/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/AdditionalModelsShapes.java new file mode 100644 index 00000000000..1da68dafea9 --- /dev/null +++ b/wicket-benchmarks/src/main/java/org/apache/wicket/benchmarks/AdditionalModelsShapes.java @@ -0,0 +1,157 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.wicket.benchmarks; + +import org.apache.wicket.Component; +import org.apache.wicket.markup.html.WebMarkupContainer; +import org.apache.wicket.model.IModel; +import org.apache.wicket.model.Model; + +/** + * The component shapes compared by {@link AdditionalModelsFootprint} and + * {@link AdditionalModelsBenchmark}: the same component holding the same extra models, once with a + * hand written {@code onDetach()} and once letting {@code Component} detach them. + *

+ * Every variant carries a default model and the given number of extra models, all of them the same + * kind of model, so a difference between two variants is the bookkeeping alone. The components are + * static classes with no reference to anything outside, because they are serialized. + */ +public final class AdditionalModelsShapes +{ + private AdditionalModelsShapes() + { + } + + /** The models are all of this kind, in every variant. */ + static IModel model(String value) + { + return Model.of(value); + } + + /** How a component holds the models beside its default model. */ + public enum Variant + { + /** A field per model, detached by a hand written onDetach. The way before Wicket 11. */ + FIELDS + { + @Override + Component newComponent(String id, int extraModels) + { + return new FieldModels(id, extraModels); + } + }, + /** A field per model, each registered, no onDetach. */ + REGISTERED + { + @Override + Component newComponent(String id, int extraModels) + { + return new RegisteredModels(id, extraModels); + } + }, + /** Registered and never referenced again, so the component needs no fields at all. */ + REGISTERED_NO_FIELDS + { + @Override + Component newComponent(String id, int extraModels) + { + return new RegisteredModelsNoFields(id, extraModels); + } + }; + + abstract Component newComponent(String id, int extraModels); + } + + /** + * Keeps its extra models in fields and detaches them itself. Three fields, because a field is + * what a component needs to reach a model from onDetach, and unused ones cost what they cost. + */ + static class FieldModels extends WebMarkupContainer + { + private static final long serialVersionUID = 1L; + + private final IModel first; + + private final IModel second; + + private final IModel third; + + FieldModels(String id, int extraModels) + { + super(id, model(id)); + + first = extraModels > 0 ? model(id + "-1") : null; + second = extraModels > 1 ? model(id + "-2") : null; + third = extraModels > 2 ? model(id + "-3") : null; + } + + @Override + protected void onDetach() + { + if (first != null) + { + first.detach(); + } + if (second != null) + { + second.detach(); + } + if (third != null) + { + third.detach(); + } + super.onDetach(); + } + } + + /** Keeps its extra models in fields and lets Component detach them. */ + static class RegisteredModels extends WebMarkupContainer + { + private static final long serialVersionUID = 1L; + + private final IModel first; + + private final IModel second; + + private final IModel third; + + RegisteredModels(String id, int extraModels) + { + super(id, model(id)); + + first = extraModels > 0 ? addAdditionalModel(model(id + "-1")) : null; + second = extraModels > 1 ? addAdditionalModel(model(id + "-2")) : null; + third = extraModels > 2 ? addAdditionalModel(model(id + "-3")) : null; + } + } + + /** Registers its extra models and keeps no reference to them. */ + static class RegisteredModelsNoFields extends WebMarkupContainer + { + private static final long serialVersionUID = 1L; + + RegisteredModelsNoFields(String id, int extraModels) + { + super(id, model(id)); + + for (int i = 1; i <= extraModels; i++) + { + addAdditionalModel(model(id + "-" + i)); + } + } + } +} diff --git a/wicket-core-tests/src/test/java/org/apache/wicket/ComponentModelsTest.java b/wicket-core-tests/src/test/java/org/apache/wicket/ComponentModelsTest.java new file mode 100644 index 00000000000..6e374767fb5 --- /dev/null +++ b/wicket-core-tests/src/test/java/org/apache/wicket/ComponentModelsTest.java @@ -0,0 +1,281 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.wicket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.Serializable; +import java.util.Arrays; + +import org.apache.wicket.markup.ComponentTag; +import org.apache.wicket.markup.html.WebComponent; +import org.apache.wicket.markup.html.WebMarkupContainer; +import org.apache.wicket.model.CompoundPropertyModel; +import org.apache.wicket.model.IComponentAssignedModel; +import org.apache.wicket.model.IModel; +import org.apache.wicket.model.IWrapModel; +import org.apache.wicket.model.LoadableDetachableModel; +import org.apache.wicket.model.Model; +import org.apache.wicket.util.tester.WicketTestCase; +import org.junit.jupiter.api.Test; + +/** + * Tests a component with models beside its default model. + */ +class ComponentModelsTest extends WicketTestCase +{ + @Test + void constructorAddsAdditionalModels() + { + IModel first = Model.of("first"); + IModel second = Model.of("second"); + IModel third = Model.of("third"); + + TestComponent component = new TestComponent("c", first, second, null, third); + + assertSame(first, component.getDefaultModel()); + assertEquals(Arrays.asList(first, second, third), component.getModels()); + } + + @Test + void defaultModelIsStillInheritedWithAdditionalModels() + { + WebMarkupContainer parent = new WebMarkupContainer("parent", + new CompoundPropertyModel<>(new Bean())); + IModel additional = Model.of("additional"); + TestComponent child = new TestComponent("name", null, additional); + parent.add(child); + + assertEquals(Arrays.asList(additional), child.getModels()); + assertEquals("bean", child.getDefaultModelObject()); + } + + @Test + void allModelsAreDetachedAtTheEndOfTheRequest() + { + CountingModel defaultModel = new CountingModel(); + CountingModel additional = new CountingModel(); + TestComponent component = new TestComponent("c", defaultModel); + component.additional = component.addAdditionalModel(additional); + + tester.startComponentInPage(component); + + assertEquals(1, defaultModel.loads); + assertEquals(1, additional.loads); + assertFalse(defaultModel.isAttached()); + assertFalse(additional.isAttached()); + } + + @Test + void addingTwiceAddsOnce() + { + IModel model = Model.of("model"); + TestComponent component = new TestComponent("c", null); + + assertSame(model, component.addAdditionalModel(model)); + assertSame(model, component.addAdditionalModel(model)); + + assertEquals(Arrays.asList(model), component.getModels()); + } + + @Test + void addingNullAddsNothing() + { + TestComponent component = new TestComponent("c", null); + + assertNull(component.addAdditionalModel(null)); + + assertTrue(component.getModels().isEmpty()); + } + + @Test + void registeringReturnsTheGivenModelWithItsType() + { + TestComponent component = new TestComponent("c", null); + + CountingModel model = component.addAdditionalModel(new CountingModel()); + + assertEquals(Arrays.asList(model), component.getModels()); + assertSame(model, component.removeAdditionalModel(model)); + assertTrue(component.getModels().isEmpty()); + } + + @Test + void registeredModelIsNotWrapped() + { + AssignedModel assigned = new AssignedModel(); + TestComponent component = new TestComponent("c", null); + + assertSame(assigned, component.addAdditionalModel(assigned)); + + assertNull(assigned.component); + assertEquals(Arrays.asList(assigned), component.getModels()); + } + + @Test + void wrappedModelIsRegisteredAsTheWrapper() + { + AssignedModel assigned = new AssignedModel(); + TestComponent component = new TestComponent("c", null); + + IModel wrapped = component.addAdditionalModel(component.wrap(assigned)); + + assertSame(component, assigned.component); + assertSame(assigned, component.addAdditionalModel(assigned)); + assertEquals(Arrays.asList(wrapped), component.getModels()); + + assertSame(assigned, component.removeAdditionalModel(assigned)); + assertTrue(component.getModels().isEmpty()); + } + + @Test + void replaceDetachesAndRemovesThePreviousModel() + { + CountingModel previous = new CountingModel(); + IModel other = Model.of("other"); + TestComponent component = new TestComponent("c", null); + component.addAdditionalModel(previous); + component.addAdditionalModel(other); + previous.getObject(); + + IModel next = Model.of("next"); + assertSame(next, component.replaceAdditionalModel(previous, next)); + + assertFalse(previous.isAttached()); + assertEquals(Arrays.asList(other, next), component.getModels()); + } + + @Test + void replaceWithTheSameModelKeepsIt() + { + TestComponent component = new TestComponent("c", null); + CountingModel model = component.addAdditionalModel(new CountingModel()); + model.getObject(); + + assertSame(model, component.replaceAdditionalModel(model, model)); + + assertTrue(model.isAttached()); + assertEquals(Arrays.asList(model), component.getModels()); + } + + @Test + void replaceWithNullRemoves() + { + IModel model = Model.of("model"); + TestComponent component = new TestComponent("c", null); + component.addAdditionalModel(model); + + assertNull(component.replaceAdditionalModel(model, null)); + + assertTrue(component.getModels().isEmpty()); + } + + @Test + void removingAModelThatWasNotAddedHasNoEffect() + { + IModel model = Model.of("model"); + TestComponent component = new TestComponent("c", null); + component.addAdditionalModel(model); + + component.removeAdditionalModel(Model.of("other")); + component.removeAdditionalModel(null); + + assertEquals(Arrays.asList(model), component.getModels()); + } + + private static class TestComponent extends WebComponent + { + private static final long serialVersionUID = 1L; + + private IModel additional; + + TestComponent(String id, IModel model, IModel... additionalModels) + { + super(id, model, additionalModels); + } + + @Override + protected void onComponentTag(ComponentTag tag) + { + super.onComponentTag(tag); + + getDefaultModelObject(); + additional.getObject(); + } + } + + private static class AssignedModel implements IComponentAssignedModel + { + private static final long serialVersionUID = 1L; + + private Component component; + + @Override + public String getObject() + { + return null; + } + + @Override + public IWrapModel wrapOnAssignment(Component component) + { + this.component = component; + return new IWrapModel<>() + { + private static final long serialVersionUID = 1L; + + @Override + public IModel getWrappedModel() + { + return AssignedModel.this; + } + + @Override + public String getObject() + { + return null; + } + }; + } + } + + private static class CountingModel extends LoadableDetachableModel + { + private static final long serialVersionUID = 1L; + + private int loads; + + @Override + protected String load() + { + loads++; + return "loaded"; + } + } + + private static class Bean implements Serializable + { + private static final long serialVersionUID = 1L; + + @SuppressWarnings("unused") + private final String name = "bean"; + } +} diff --git a/wicket-core/src/main/java/org/apache/wicket/Component.java b/wicket-core/src/main/java/org/apache/wicket/Component.java index 9bef4432b03..15ac584aae3 100644 --- a/wicket-core/src/main/java/org/apache/wicket/Component.java +++ b/wicket-core/src/main/java/org/apache/wicket/Component.java @@ -17,7 +17,10 @@ package org.apache.wicket; import java.io.Serializable; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Locale; @@ -143,8 +146,9 @@ * Component becomes immutable. Attempts to alter the Component will result in a * WicketRuntimeException. *

  • Detachment - Each request cycle finishes by detaching all touched components. - * Subclasses should clean up their state by overriding {@link #onDetach()} or more specifically - * {@link #detachModels()} if they keep references to models beside the default model.
  • + * Subclasses should clean up their state by overriding {@link #onDetach()}. Models beside the + * default model are detached automatically when they are registered with + * {@link #addAdditionalModel(IModel)}. * * *
  • Visibility - If a component is not visible (see {@link #setVisible(boolean)}) it will @@ -163,7 +167,12 @@ * The component's model can be passed in the constructor or set via * {@link Component#setDefaultModel(IModel)}. In neither case a model can be created on demand with * {@link #initModel()}.
    - * Note that a component can have more models besides its default model.
  • + * A component can use further models beside its default model. Such a model is registered with + * {@link #addAdditionalModel(IModel)}, or passed to + * {@link #Component(String, IModel, IModel...)}, and is then detached together with the default + * model at the end of each request; {@link #getModels()} returns all of them. Registering costs + * memory, so a component rendered in large numbers is better off detaching its models itself, as + * the components shipped with Wicket do. *
  • Behaviors - You can add multiple {@link Behavior}s to any component if you need to * dynamically alter the behavior of components, e.g. manipulate attributes of the markup tag to * which a Component is attached. Behaviors take part in the component's lifecycle through various @@ -288,6 +297,12 @@ public abstract class Component private static final long serialVersionUID = 1L; }; + /** meta data for the models registered with {@link #addAdditionalModel(IModel)} */ + private static final MetaDataKey[]> ADDITIONAL_MODELS_KEY = new MetaDataKey<>() + { + private static final long serialVersionUID = 1L; + }; + /** meta data for user specified markup id */ private static final MetaDataKey FEEDBACK_KEY = new MetaDataKey<>() { @@ -549,6 +564,35 @@ public Component(final String id, final IModel model) } } + /** + * Constructor. All components have names. A component's id cannot be null. This constructor + * includes the default model and any number of additional models, which are registered as with + * {@link #addAdditionalModel(IModel)}. All of them are detached at the end of each request. + * + * @param id + * The non-null id of this component + * @param model + * The component's default model, may be null + * @param additionalModels + * The component's additional models, any of them may be null + * + * @throws WicketRuntimeException + * Thrown if the component has been given a null id. + * @since 11.0.0 + */ + public Component(final String id, final IModel model, final IModel... additionalModels) + { + this(id, model); + + if (additionalModels != null) + { + for (IModel additionalModel : additionalModels) + { + addAdditionalModel(additionalModel); + } + } + } + /** * Let subclasses initialize this instance, before constructors are executed.
    * This method is intentionally not declared protected, to limit overriding to classes in @@ -1046,12 +1090,23 @@ private void internalDetach() } /** - * Detaches all models + * Detaches all models: the default model, see {@link #detachModel()}, and the models registered + * with {@link #addAdditionalModel(IModel)}. When a registered model is an {@link IWrapModel}, + * the model it wraps is detached as well. */ public void detachModels() { // Detach any detachable model from this component detachModel(); + + IModel[] additionalModels = getMetaData(ADDITIONAL_MODELS_KEY); + if (additionalModels != null) + { + for (IModel model : additionalModels) + { + detachModel(model, true); + } + } } /** @@ -2772,6 +2827,188 @@ public Component setDefaultModel(final IModel model) return this; } + /** + * Registers a model beside the default model, so that it is detached at the end of each request + * together with the default model. A component keeping further models in fields does not have + * to detach them itself: + * + *
    +	 * private final IModel<Foo> foo = addAdditionalModel(new FooModel());
    +	 * 
    + * + * The model is registered as given; unlike the default model it is not wrapped for this + * component. A component using an {@link IComponentAssignedModel} passes + * {@link #wrap(IModel) wrap(model)} instead. Registering a model that is already registered, or + * the model wrapped by a registered {@link IWrapModel}, has no effect. + *

    + * Convenience at a price: a component with registered models costs roughly 50 to 80 bytes more + * than one detaching the same models by hand in {@link #onDetach()}, whatever the number of + * models, because they are kept as component meta data. That is worth it for a component used + * a few dozen times on a page and not for one rendered in the thousands, which is why the + * components shipped with Wicket keep detaching their models themselves. + * + * @param + * the type of the model + * @param model + * the model to register, may be null + * @return the given model, so that it can be assigned in the same statement + * @see #replaceAdditionalModel(IModel, IModel) + * @see #removeAdditionalModel(IModel) + * @since 11.0.0 + */ + protected final > M addAdditionalModel(final M model) + { + if (model == null) + { + return null; + } + IModel[] additionalModels = getMetaData(ADDITIONAL_MODELS_KEY); + if (indexOfAdditionalModel(additionalModels, model) >= 0) + { + return model; + } + + if (additionalModels == null) + { + additionalModels = new IModel[] { model }; + } + else + { + additionalModels = Arrays.copyOf(additionalModels, additionalModels.length + 1); + additionalModels[additionalModels.length - 1] = model; + } + setMetaData(ADDITIONAL_MODELS_KEY, additionalModels); + return model; + } + + /** + * Replaces a model registered with {@link #addAdditionalModel(IModel)}, as a setter of a model + * field would: + * + *

    +	 * this.foo = replaceAdditionalModel(this.foo, foo);
    +	 * 
    + * + * Unless both are the same model, the previous model is detached and unregistered, see + * {@link #removeAdditionalModel(IModel)}, and the given model is registered. + * + * @param + * the type of the model + * @param previous + * the registered model to replace, may be null + * @param model + * the model to register, may be null to only unregister the previous model + * @return the given model, so that it can be assigned in the same statement + * @since 11.0.0 + */ + protected final > M replaceAdditionalModel(final IModel previous, + final M model) + { + if (previous != model) + { + removeAdditionalModel(previous); + addAdditionalModel(model); + } + return model; + } + + /** + * Detaches and unregisters a model registered with {@link #addAdditionalModel(IModel)}. Given + * the model wrapped by a registered {@link IWrapModel}, the wrapper is unregistered. Removing a + * model that is not registered has no effect. + * + * @param + * the type of the model + * @param model + * the model to unregister, may be null + * @return the given model + * @since 11.0.0 + */ + protected final > M removeAdditionalModel(final M model) + { + IModel[] additionalModels = getMetaData(ADDITIONAL_MODELS_KEY); + int index = indexOfAdditionalModel(additionalModels, model); + if (index < 0) + { + return model; + } + detachModel(additionalModels[index], true); + + IModel[] remainingModels = null; + if (additionalModels.length > 1) + { + remainingModels = new IModel[additionalModels.length - 1]; + System.arraycopy(additionalModels, 0, remainingModels, 0, index); + System.arraycopy(additionalModels, index + 1, remainingModels, index, + remainingModels.length - index); + } + setMetaData(ADDITIONAL_MODELS_KEY, remainingModels); + return model; + } + + /** + * Gets all models of this component: the default model first, if there is one, followed by + * the models registered with {@link #addAdditionalModel(IModel)} in the order they were + * registered. + * Getting the models does not initialize a default model, see {@link #initModel()}. + * + * @return an unmodifiable collection of the models + * @since 11.0.0 + */ + public final Collection> getModels() + { + IModel defaultModel = getModelImpl(); + IModel[] additionalModels = getMetaData(ADDITIONAL_MODELS_KEY); + if (additionalModels == null) + { + return defaultModel == null ? Collections.emptyList() + : Collections.singletonList(defaultModel); + } + List> models = new ArrayList<>(additionalModels.length + 1); + if (defaultModel != null) + { + models.add(defaultModel); + } + Collections.addAll(models, additionalModels); + return Collections.unmodifiableList(models); + } + + /** + * Finds a registered model, either the given model itself or an {@link IWrapModel} wrapping it. + * + * @param additionalModels + * the registered models, may be null + * @param model + * the model to find, may be null + * @return the index of the registered model, or -1 if it is not registered + */ + private static int indexOfAdditionalModel(final IModel[] additionalModels, + final IModel model) + { + if (additionalModels != null && model != null) + { + for (int index = 0; index < additionalModels.length; index++) + { + IModel additionalModel = additionalModels[index]; + if (additionalModel == model || unwrap(additionalModel) == model) + { + return index; + } + } + } + return -1; + } + + /** + * @param model + * a model + * @return the model wrapped by the given {@link IWrapModel}, or the given model otherwise + */ + private static IModel unwrap(final IModel model) + { + return model instanceof IWrapModel ? ((IWrapModel)model).getWrappedModel() : model; + } + /** * @return model */ @@ -3376,16 +3613,33 @@ protected void checkHierarchyChange(final Component component) */ protected void detachModel() { - IModel model = getModelImpl(); + detachModel(getModelImpl(), !getFlag(FLAG_INHERITABLE_MODEL)); + } + + /** + * Detaches a model and, optionally, the model it wraps. + * + * @param model + * the model to detach, may be null + * @param detachWrappedModel + * whether the wrapped model of an {@link IWrapModel} is detached too; an inherited + * model is wrapped around the parent's model, which the parent detaches itself + */ + private static void detachModel(IModel model, boolean detachWrappedModel) + { if (model != null) { model.detach(); } // also detach the wrapped model of a component assigned wrap (not // inherited) - if (model instanceof IWrapModel && !getFlag(FLAG_INHERITABLE_MODEL)) + if (model instanceof IWrapModel && detachWrappedModel) { - ((IWrapModel)model).getWrappedModel().detach(); + IModel wrappedModel = ((IWrapModel)model).getWrappedModel(); + if (wrappedModel != null) + { + wrappedModel.detach(); + } } } diff --git a/wicket-core/src/main/java/org/apache/wicket/MarkupContainer.java b/wicket-core/src/main/java/org/apache/wicket/MarkupContainer.java index ad876b36e66..e7bd65b5776 100644 --- a/wicket-core/src/main/java/org/apache/wicket/MarkupContainer.java +++ b/wicket-core/src/main/java/org/apache/wicket/MarkupContainer.java @@ -181,6 +181,16 @@ public MarkupContainer(final String id, IModel model) super(id, model); } + /** + * @see Component#Component(String, IModel, IModel...) + * @since 11.0.0 + */ + public MarkupContainer(final String id, final IModel model, + final IModel... additionalModels) + { + super(id, model, additionalModels); + } + /** * Adds the child component(s) to this container. * diff --git a/wicket-core/src/main/java/org/apache/wicket/markup/html/WebComponent.java b/wicket-core/src/main/java/org/apache/wicket/markup/html/WebComponent.java index 3d7ac261c05..99cfe010c7b 100644 --- a/wicket-core/src/main/java/org/apache/wicket/markup/html/WebComponent.java +++ b/wicket-core/src/main/java/org/apache/wicket/markup/html/WebComponent.java @@ -54,6 +54,16 @@ public WebComponent(final String id, final IModel model) super(id, model); } + /** + * @see Component#Component(String, IModel, IModel...) + * @since 11.0.0 + */ + public WebComponent(final String id, final IModel model, + final IModel... additionalModels) + { + super(id, model, additionalModels); + } + @Override protected void onRender() { diff --git a/wicket-core/src/main/java/org/apache/wicket/markup/html/WebMarkupContainer.java b/wicket-core/src/main/java/org/apache/wicket/markup/html/WebMarkupContainer.java index 7be47165641..f70828f4bf2 100644 --- a/wicket-core/src/main/java/org/apache/wicket/markup/html/WebMarkupContainer.java +++ b/wicket-core/src/main/java/org/apache/wicket/markup/html/WebMarkupContainer.java @@ -53,6 +53,16 @@ public WebMarkupContainer(final String id, IModel model) super(id, model); } + /** + * @see Component#Component(String, IModel, IModel...) + * @since 11.0.0 + */ + public WebMarkupContainer(final String id, final IModel model, + final IModel... additionalModels) + { + super(id, model, additionalModels); + } + /** * A convenience method to return the WebPage. Same as getPage(). * diff --git a/wicket-core/src/main/java/org/apache/wicket/markup/html/panel/GenericPanel.java b/wicket-core/src/main/java/org/apache/wicket/markup/html/panel/GenericPanel.java index 9d7c4012e48..972fb0f4daf 100644 --- a/wicket-core/src/main/java/org/apache/wicket/markup/html/panel/GenericPanel.java +++ b/wicket-core/src/main/java/org/apache/wicket/markup/html/panel/GenericPanel.java @@ -48,4 +48,20 @@ public GenericPanel(final String id, final IModel model) { super(id, model); } + + /** + * @param id + * the component id + * @param model + * the component model + * @param additionalModels + * the component's additional models + * @see org.apache.wicket.Component#Component(String, IModel, IModel...) + * @since 11.0.0 + */ + public GenericPanel(final String id, final IModel model, + final IModel... additionalModels) + { + super(id, model, additionalModels); + } } diff --git a/wicket-core/src/main/java/org/apache/wicket/markup/html/panel/Panel.java b/wicket-core/src/main/java/org/apache/wicket/markup/html/panel/Panel.java index ba3d9d9945c..4a1e56648ff 100644 --- a/wicket-core/src/main/java/org/apache/wicket/markup/html/panel/Panel.java +++ b/wicket-core/src/main/java/org/apache/wicket/markup/html/panel/Panel.java @@ -75,6 +75,15 @@ public Panel(final String id, final IModel model) super(id, model); } + /** + * @see org.apache.wicket.Component#Component(String, IModel, IModel...) + * @since 11.0.0 + */ + public Panel(final String id, final IModel model, final IModel... additionalModels) + { + super(id, model, additionalModels); + } + /** * {@inheritDoc} */ diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/compref/Customer.java b/wicket-examples/src/main/java/org/apache/wicket/examples/compref/Customer.java new file mode 100644 index 00000000000..08e49cb6c0b --- /dev/null +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/compref/Customer.java @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.wicket.examples.compref; + +import java.io.Serializable; + +/** + * A customer of the {@link MultipleModelsPage} example. + * + * @author reiern70 + */ +public class Customer implements Serializable +{ + private static final long serialVersionUID = 1L; + + private final long id; + + private final String name; + + /** + * Construct. + * + * @param id + * the customer id + * @param name + * the customer name + */ + public Customer(long id, String name) + { + this.id = id; + this.name = name; + } + + /** + * @return the customer id + */ + public long getId() + { + return id; + } + + /** + * @return the customer name + */ + public String getName() + { + return name; + } +} diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/compref/CustomerCardPanel.html b/wicket-examples/src/main/java/org/apache/wicket/examples/compref/CustomerCardPanel.html new file mode 100644 index 00000000000..7271219849f --- /dev/null +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/compref/CustomerCardPanel.html @@ -0,0 +1,57 @@ + + + + + Wicket Examples - customer card + + + + +

    + title: + customer name +

    + + + + + + + + + + + + +
    OrderAmountStatus
    numberamountstatus
    + +

    + Unpaid total: + 0 +

    + +

    how often the orders were loaded

    + +

    getModels() of this panel:

    +
      +
    • model
    • +
    + +
    + + diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/compref/CustomerCardPanel.java b/wicket-examples/src/main/java/org/apache/wicket/examples/compref/CustomerCardPanel.java new file mode 100644 index 00000000000..5a21be1ad00 --- /dev/null +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/compref/CustomerCardPanel.java @@ -0,0 +1,154 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.wicket.examples.compref; + +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.wicket.markup.html.basic.Label; +import org.apache.wicket.markup.html.list.ListItem; +import org.apache.wicket.markup.html.list.ListView; +import org.apache.wicket.markup.html.panel.GenericPanel; +import org.apache.wicket.model.IModel; +import org.apache.wicket.model.LoadableDetachableModel; +import org.apache.wicket.model.ResourceModel; +import org.apache.wicket.model.StringResourceModel; + +/** + * A panel that works with four models: the customer it is given as its default model, and three + * models registered beside it with {@link #addAdditionalModel(IModel)}. The registered models are + * detached together with the default model at the end of each request, so this panel does not + * override {@link #onDetach()} at all. + * + * @author reiern70 + */ +public class CustomerCardPanel extends GenericPanel +{ + private static final long serialVersionUID = 1L; + + /** the title, an IComponentAssignedModel and therefore registered wrapped */ + private final IModel titleModel; + + /** the orders shown, replaceable through {@link #setOrdersModel(IModel)} */ + private IModel> ordersModel; + + /** derived from {@link #ordersModel}, loaded at most once per request */ + private final IModel unpaidTotalModel; + + /** counts how often the orders were loaded, to show that they are detached every request */ + private int orderLoads; + + /** + * Construct. + * + * @param id + * component id + * @param customer + * the customer to show, the default model + */ + public CustomerCardPanel(String id, IModel customer) + { + super(id, customer); + + // A ResourceModel is an IComponentAssignedModel: unlike the default model an additional + // model is registered as it is given, so it is wrap()ed here to bind it to this panel. + titleModel = addAdditionalModel(wrap(new ResourceModel("customer.card.title"))); + + // Each model is registered in the statement that assigns it, addAdditionalModel returns + // what it is given. + ordersModel = addAdditionalModel(new LoadableDetachableModel>() + { + private static final long serialVersionUID = 1L; + + @Override + protected List load() + { + orderLoads++; + return CustomerRepository.getOrders(getModelObject().getId()); + } + }); + + unpaidTotalModel = addAdditionalModel(LoadableDetachableModel + .of(() -> ordersModel.getObject() + .stream() + .filter(order -> !order.isPaid()) + .mapToInt(Order::getAmount) + .sum())); + } + + /** + * Replaces the orders shown by this panel. {@link #replaceAdditionalModel(IModel, IModel)} + * detaches and unregisters the previous model before it registers the given one. + * + * @param ordersModel + * the model of the orders to show + */ + public void setOrdersModel(IModel> ordersModel) + { + this.ordersModel = replaceAdditionalModel(this.ordersModel, ordersModel); + } + + @Override + protected void onInitialize() + { + super.onInitialize(); + + add(new Label("title", titleModel)); + add(new Label("name", () -> getModelObject().getName())); + + // An Integer is written with the converter of the current locale, so the amounts need + // no resource of their own. + add(new Label("unpaidTotal", unpaidTotalModel)); + + IModel orderLoadsModel = () -> orderLoads; + add(new Label("orderLoads", + new StringResourceModel("order.loads", this).setParameters(orderLoadsModel))); + + // Reads through the field, so a model set with setOrdersModel() is picked up. + add(new ListView("orders", () -> ordersModel.getObject()) + { + private static final long serialVersionUID = 1L; + + @Override + protected void populateItem(ListItem item) + { + item.add(new Label("number", () -> item.getModelObject().getNumber())); + item.add(new Label("amount", () -> item.getModelObject().getAmount())); + // The key is taken from the model: order.status.true or order.status.false. + item.add(new Label("status", + new StringResourceModel("order.status.${paid}", item.getModel()))); + } + }); + + // getModels() returns the default model followed by the registered ones, without + // initializing a model that is not there yet. + add(new ListView("models", + () -> CustomerCardPanel.this.getModels() + .stream() + .map(model -> model.getClass().getSimpleName()) + .collect(Collectors.toList())) + { + private static final long serialVersionUID = 1L; + + @Override + protected void populateItem(ListItem item) + { + item.add(new Label("model", item.getModel())); + } + }); + } +} diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/compref/CustomerCardPanel.properties b/wicket-examples/src/main/java/org/apache/wicket/examples/compref/CustomerCardPanel.properties new file mode 100644 index 00000000000..1d3aae1fb97 --- /dev/null +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/compref/CustomerCardPanel.properties @@ -0,0 +1,26 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +customer.card.title=Customer card +orders.number=Order +orders.amount=Amount +orders.status=Status +order.status.true=paid +order.status.false=open +unpaid.total=Unpaid total: +models.header=getModels() of this panel: +order.loads=The orders model registered in the constructor was loaded {0} time(s) so far: \ +once per request it is used, because it is detached at the end of every request without this \ +panel doing anything for it. Replacing it below stops this counter, since that model is then \ +detached and unregistered. diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/compref/CustomerRepository.java b/wicket-examples/src/main/java/org/apache/wicket/examples/compref/CustomerRepository.java new file mode 100644 index 00000000000..c4ee842c4a0 --- /dev/null +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/compref/CustomerRepository.java @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.wicket.examples.compref; + +import java.util.Arrays; +import java.util.List; + +/** + * Stands in for the data store of the {@link MultipleModelsPage} example. + * + * @author reiern70 + */ +public class CustomerRepository +{ + private static final List CUSTOMERS = Arrays.asList( + new Customer(1, "Wile E. Coyote"), new Customer(2, "Road Runner")); + + private CustomerRepository() + { + } + + /** + * @return all customers + */ + public static List getCustomers() + { + return CUSTOMERS; + } + + /** + * @param id + * the customer id + * @return the customer with the given id + */ + public static Customer getCustomer(long id) + { + return CUSTOMERS.stream() + .filter(customer -> customer.getId() == id) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("no customer " + id)); + } + + /** + * @param customerId + * the customer id + * @return the orders of the given customer + */ + public static List getOrders(long customerId) + { + if (customerId == 1) + { + return Arrays.asList(new Order("ACME-1", 120, true), new Order("ACME-2", 45, false), + new Order("ACME-3", 980, false)); + } + return Arrays.asList(new Order("RR-7", 15, true)); + } +} diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/compref/Index.html b/wicket-examples/src/main/java/org/apache/wicket/examples/compref/Index.html index 3a1003ae358..b86ce8e383e 100644 --- a/wicket-examples/src/main/java/org/apache/wicket/examples/compref/Index.html +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/compref/Index.html @@ -34,6 +34,7 @@

    Component reference

  • wicket.markup.html.border.Border
  • wicket.markup.html.tabs.TabbedPanel (wicket-extensions)
  • wicket.markup.html.panel.Fragment
  • +
  • a component with more than one model
  • diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/compref/MultipleModelsPage.html b/wicket-examples/src/main/java/org/apache/wicket/examples/compref/MultipleModelsPage.html new file mode 100644 index 00000000000..bb4c269ba85 --- /dev/null +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/compref/MultipleModelsPage.html @@ -0,0 +1,47 @@ + + + + + Wicket Examples - component reference + + + + +

    A component with more than one model

    + [back to the reference] + +

    What this page shows.

    + +
    customer card
    + +

    + Show the card of: + customer +

    + +

    + Replace the orders model: + unpaid orders only | + all orders +

    + +
    explanation goes here
    + +
    + + diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/compref/MultipleModelsPage.java b/wicket-examples/src/main/java/org/apache/wicket/examples/compref/MultipleModelsPage.java new file mode 100644 index 00000000000..78ab2851eec --- /dev/null +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/compref/MultipleModelsPage.java @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.wicket.examples.compref; + +import java.util.stream.Collectors; + +import org.apache.wicket.examples.WicketExamplePage; +import org.apache.wicket.markup.html.link.Link; +import org.apache.wicket.markup.html.list.ListItem; +import org.apache.wicket.markup.html.list.ListView; +import org.apache.wicket.model.LoadableDetachableModel; + +/** + * Page with an example of a component using more models than its default model, see + * {@link CustomerCardPanel}. Its models are registered with + * {@link org.apache.wicket.Component#addAdditionalModel(org.apache.wicket.model.IModel)} and + * detached by the component itself. + * + * @author reiern70 + */ +public class MultipleModelsPage extends WicketExamplePage +{ + private static final long serialVersionUID = 1L; + + private long customerId = 1; + + /** + * Constructor + */ + public MultipleModelsPage() + { + CustomerCardPanel card = new CustomerCardPanel("card", + LoadableDetachableModel.of(() -> CustomerRepository.getCustomer(customerId))); + add(card); + + add(new ListView("customers", CustomerRepository.getCustomers()) + { + private static final long serialVersionUID = 1L; + + @Override + protected void populateItem(ListItem item) + { + item.add(new Link("select") + { + private static final long serialVersionUID = 1L; + + @Override + public void onClick() + { + customerId = item.getModelObject().getId(); + } + }.setBody(() -> item.getModelObject().getName())); + } + }); + + add(new Link("unpaidOnly") + { + private static final long serialVersionUID = 1L; + + @Override + public void onClick() + { + // replaceAdditionalModel detaches and unregisters the model the panel used + // before, so this link can be clicked as often as one likes. + card.setOrdersModel(LoadableDetachableModel.of( + () -> CustomerRepository.getOrders(customerId) + .stream() + .filter(order -> !order.isPaid()) + .collect(Collectors.toList()))); + } + }); + + add(new Link("allOrders") + { + private static final long serialVersionUID = 1L; + + @Override + public void onClick() + { + card.setOrdersModel( + LoadableDetachableModel.of(() -> CustomerRepository.getOrders(customerId))); + } + }); + } + + @Override + protected void explain() + { + String html = "
    customer card
    "; + String code = "    public CustomerCardPanel(String id, IModel<Customer> customer)\n" + + "    {\n" + + "        super(id, customer);\n" + + "\n" + + "        titleModel = addAdditionalModel(wrap(new ResourceModel(\"customer.card.title\")));\n" + + "        ordersModel = addAdditionalModel(LoadableDetachableModel.of(...));\n" + + "        unpaidTotalModel = addAdditionalModel(LoadableDetachableModel.of(...));\n" + + "    }\n" + + "\n" + + "    public void setOrdersModel(IModel<List<Order>> ordersModel)\n" + + "    {\n" + + "        this.ordersModel = replaceAdditionalModel(this.ordersModel, ordersModel);\n" + + "    }\n" + + "\n" + + "    // no onDetach(): the registered models are detached with the default model"; + add(new ExplainPanel(html, code)); + } +} diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/compref/MultipleModelsPage.properties b/wicket-examples/src/main/java/org/apache/wicket/examples/compref/MultipleModelsPage.properties new file mode 100644 index 00000000000..619c443aab1 --- /dev/null +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/compref/MultipleModelsPage.properties @@ -0,0 +1,24 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +page.title=A component with more than one model +page.intro=A component has one default model, which the framework detaches at the end of every \ +request. Since Wicket 11 a component can register further models next to it with \ +addAdditionalModel, and they are detached together with the default model. The panel below uses \ +four models and does not override onDetach at all. +select.customer=Show the card of: +replace.orders=Replace the orders model through a setter, which uses replaceAdditionalModel to \ +detach and unregister the previous one: +link.unpaidOnly=unpaid orders only +link.allOrders=all orders diff --git a/wicket-examples/src/main/java/org/apache/wicket/examples/compref/Order.java b/wicket-examples/src/main/java/org/apache/wicket/examples/compref/Order.java new file mode 100644 index 00000000000..a39007dbe9e --- /dev/null +++ b/wicket-examples/src/main/java/org/apache/wicket/examples/compref/Order.java @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.wicket.examples.compref; + +import java.io.Serializable; + +/** + * An order of a {@link Customer}, used by the {@link MultipleModelsPage} example. + * + * @author reiern70 + */ +public class Order implements Serializable +{ + private static final long serialVersionUID = 1L; + + private final String number; + + private final int amount; + + private final boolean paid; + + /** + * Construct. + * + * @param number + * the order number + * @param amount + * the order amount, in whole euros + * @param paid + * whether the order has been paid + */ + public Order(String number, int amount, boolean paid) + { + this.number = number; + this.amount = amount; + this.paid = paid; + } + + /** + * @return the order number + */ + public String getNumber() + { + return number; + } + + /** + * @return the order amount, in whole euros + */ + public int getAmount() + { + return amount; + } + + /** + * @return whether the order has been paid + */ + public boolean isPaid() + { + return paid; + } +} diff --git a/wicket-examples/src/test/java/org/apache/wicket/examples/compref/MultipleModelsPageTest.java b/wicket-examples/src/test/java/org/apache/wicket/examples/compref/MultipleModelsPageTest.java new file mode 100644 index 00000000000..c262a447057 --- /dev/null +++ b/wicket-examples/src/test/java/org/apache/wicket/examples/compref/MultipleModelsPageTest.java @@ -0,0 +1,149 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.wicket.examples.compref; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import org.apache.wicket.markup.html.list.ListView; +import org.apache.wicket.model.IModel; +import org.apache.wicket.model.LoadableDetachableModel; +import org.apache.wicket.util.tester.WicketTestCase; +import org.junit.jupiter.api.Test; + +/** + * Tests the component reference page of a component using models beside its default model. + * + * @author reiern70 + */ +public class MultipleModelsPageTest extends WicketTestCase +{ + /** + * The panel has its default model plus the three it registers. + */ + @Test + void panelRegistersItsModels() + { + tester.startPage(MultipleModelsPage.class); + tester.assertRenderedPage(MultipleModelsPage.class); + + assertEquals(4, card().getModels().size()); + } + + /** + * The registered models are detached with the default model at the end of the request, + * although the panel overrides no onDetach. + */ + @Test + void modelsAreDetachedAfterTheRequest() + { + tester.startPage(MultipleModelsPage.class); + + for (IModel model : card().getModels()) + { + if (model instanceof LoadableDetachableModel) + { + assertFalse(((LoadableDetachableModel)model).isAttached(), + "still attached: " + model); + } + } + } + + /** + * The texts come from CustomerCardPanel.properties, the order status through a key built + * from the model. + */ + @Test + void textsComeFromTheBundle() + { + tester.startPage(MultipleModelsPage.class); + + tester.assertLabel("card:title", "Customer card"); + tester.assertLabel("card:name", "Wile E. Coyote"); + tester.assertLabel("card:orders:0:number", "ACME-1"); + tester.assertLabel("card:orders:0:status", "paid"); + tester.assertLabel("card:orders:1:status", "open"); + } + + /** + * The derived model reads through the orders model. + */ + @Test + void unpaidTotalIsDerivedFromTheOrders() + { + tester.startPage(MultipleModelsPage.class); + + // ACME-2 and ACME-3 are unpaid + tester.assertModelValue("card:unpaidTotal", 45 + 980); + } + + /** + * Selecting another customer reloads the models built on the default model. + */ + @Test + void selectingACustomerReloadsTheDerivedModels() + { + tester.startPage(MultipleModelsPage.class); + tester.clickLink("customers:1:select"); + + tester.assertLabel("card:name", "Road Runner"); + assertEquals(1, orders().getViewSize()); + tester.assertModelValue("card:unpaidTotal", 0); + } + + /** + * The setter replaces the orders model and shows only the unpaid ones. + */ + @Test + void replacingTheOrdersModelChangesWhatIsShown() + { + tester.startPage(MultipleModelsPage.class); + assertEquals(3, orders().getViewSize()); + + tester.clickLink("unpaidOnly"); + assertEquals(2, orders().getViewSize()); + + tester.clickLink("allOrders"); + assertEquals(3, orders().getViewSize()); + } + + /** + * replaceAdditionalModel unregisters the model it replaces, so repeatedly replacing does not + * pile models up on the component. + */ + @Test + void replacingAModelDoesNotRegisterItTwice() + { + tester.startPage(MultipleModelsPage.class); + + tester.clickLink("unpaidOnly"); + tester.clickLink("allOrders"); + tester.clickLink("unpaidOnly"); + + assertEquals(4, card().getModels().size()); + } + + private CustomerCardPanel card() + { + return (CustomerCardPanel)tester.getComponentFromLastRenderedPage("card"); + } + + private ListView orders() + { + return (ListView)tester.getComponentFromLastRenderedPage("card:orders"); + } +} diff --git a/wicket-user-guide/src/main/asciidoc/modelsforms/modelsforms_8.adoc b/wicket-user-guide/src/main/asciidoc/modelsforms/modelsforms_8.adoc index fddc250796d..d1a96df5716 100644 --- a/wicket-user-guide/src/main/asciidoc/modelsforms/modelsforms_8.adoc +++ b/wicket-user-guide/src/main/asciidoc/modelsforms/modelsforms_8.adoc @@ -1,12 +1,14 @@ +Sometimes our custom components may need to use more than a single model to work properly. Every model besides the default one must be detached at the end of the request as well. Until Wicket 11 this was entirely up to us: we had to keep every additional model in a field and detach it by overriding _onDetach()_. Nothing reminded us to do so, and forgetting was silent — the page still rendered, only a _LoadableDetachableModel_ stayed loaded and was serialized with the page, dragging its data along for as long as the page lived in the session. +Since Wicket 11 a component can register such models with _addAdditionalModel_, and _Component_ then detaches them together with its default model. The method returns the model it was given, so it can be registered in the same statement that assigns it to a field. The following is the generic code of a component that uses two models, before and after. -Sometimes our custom components may need to use more than a single model to work properly. In such a case we must manually detach the additional models used by our components. In order to do this we can override the Component's onDetach method that is called at the end of the current request. The following is the generic code of a component that uses two models: +Before, detaching the additional model by hand: [source,java] ---- /** - * + * * fooModel is used as main model while beeModel must be manually detached * */ @@ -21,12 +23,266 @@ public class ComponentTwoModels extends Component{ @Override public void onDetach() { - if(beeModel != null) - beeModel.detach(); - - super.onDetach(); + if(beeModel != null) + beeModel.detach(); + + super.onDetach(); + } +} +---- + +After, letting the component detach it for us: + +[source,java] +---- +/** + * + * fooModel is used as main model while beeModel is registered as an additional model + * + */ +public class ComponentTwoModels extends Component{ + + private IModel beeModel; + + public ComponentTwoModels(String id, IModel fooModel, IModel beeModel) { + super(id, fooModel); + this.beeModel = addAdditionalModel(beeModel); } } ---- +Registering a null model, or registering the same model twice, has no effect, so a component that is not sure whether a model has already been registered can simply register it again. + +=== Models the component builds itself + +The most common reason for a component to use several models is that it derives them from its default model. Before, every model created in the constructor had to be matched by a line in _onDetach()_, and the two had to be kept in sync by hand: + +[source,java] +---- +public class CustomerCardPanel extends GenericPanel { + + private final IModel> ordersModel; + private final IModel unpaidCountModel; + + public CustomerCardPanel(String id, IModel customer, OrderService orders) { + super(id, customer); + + this.ordersModel = LoadableDetachableModel.of( + () -> orders.findByCustomer(getModelObject())); + + this.unpaidCountModel = LoadableDetachableModel.of( + () -> (int)ordersModel.getObject().stream().filter(Order::isUnpaid).count()); + } + + @Override + protected void onDetach() { + ordersModel.detach(); + unpaidCountModel.detach(); + + super.onDetach(); + } +} +---- + +After, the registration happens where the model is created and _onDetach()_ disappears: + +[source,java] +---- +public class CustomerCardPanel extends GenericPanel { + + private final IModel> ordersModel; + private final IModel unpaidCountModel; + + public CustomerCardPanel(String id, IModel customer, OrderService orders) { + super(id, customer); + + this.ordersModel = addAdditionalModel(LoadableDetachableModel.of( + () -> orders.findByCustomer(getModelObject()))); + + this.unpaidCountModel = addAdditionalModel(LoadableDetachableModel.of( + () -> (int)ordersModel.getObject().stream().filter(Order::isUnpaid).count())); + } +} +---- + +=== Models the component keeps no reference to + +When a model is only passed on to a child component and never used again, before we still needed a field for it, for no other reason than to give _onDetach()_ something to reach: + +[source,java] +---- +public class InvoiceHeaderPanel extends GenericPanel { + + private final IModel issuerModel; + private final IModel recipientModel; + + public InvoiceHeaderPanel(String id, IModel invoice, + IModel issuer, IModel recipient) { + super(id, invoice); + + this.issuerModel = issuer; + this.recipientModel = recipient; + + add(new CompanyPanel("issuer", issuer)); + add(new CustomerPanel("recipient", recipient)); + } + + @Override + protected void onDetach() { + issuerModel.detach(); + recipientModel.detach(); + + super.onDetach(); + } +} +---- + +After, we pass the models to the constructor, which registers all of them. _Component_, _MarkupContainer_, _WebComponent_, _WebMarkupContainer_, _Panel_ and _GenericPanel_ all have such a constructor: + +[source,java] +---- +public class InvoiceHeaderPanel extends GenericPanel { + + public InvoiceHeaderPanel(String id, IModel invoice, + IModel issuer, IModel recipient) { + super(id, invoice, issuer, recipient); + + add(new CompanyPanel("issuer", issuer)); + add(new CustomerPanel("recipient", recipient)); + } +} +---- + +=== Replacing and removing a model + +A field holding an additional model needs care in its setter: the model being dropped has to be detached, but only when it is really being replaced. Before: + +[source,java] +---- +public void setBeeModel(IModel beeModel) { + if(this.beeModel != null && this.beeModel != beeModel) + this.beeModel.detach(); + + this.beeModel = beeModel; +} +---- + +After, _replaceAdditionalModel_ does both halves — it detaches and unregisters the previous model, registers the new one and returns it — and does nothing at all when both are the same model: + +[source,java] +---- +public void setBeeModel(IModel beeModel) { + this.beeModel = replaceAdditionalModel(this.beeModel, beeModel); +} +---- + +_removeAdditionalModel_ detaches and unregisters a model without registering another one in its place. + +=== Components that extend other components + +Additional models are tracked without an index, so a subclass does not need to know how many models its superclass has registered. Before, both classes had an _onDetach()_ and the subclass had to remember to call _super.onDetach()_ — forgetting it silently stopped the superclass' models from being detached: + +[source,java] +---- +public class BaseCardPanel extends GenericPanel { + + protected final IModel brandingModel; + + public BaseCardPanel(String id, IModel model) { + super(id, model); + + this.brandingModel = LoadableDetachableModel.of(BrandingService::current); + } + + @Override + protected void onDetach() { + brandingModel.detach(); + + super.onDetach(); + } +} + +public class CustomerCardPanel extends BaseCardPanel { + + private final IModel> ordersModel; + + //... + + @Override + protected void onDetach() { + ordersModel.detach(); + + super.onDetach(); + } +} +---- + +After, each class registers its own models and neither knows about the other's: + +[source,java] +---- +public class BaseCardPanel extends GenericPanel { + + protected final IModel brandingModel; + + public BaseCardPanel(String id, IModel model) { + super(id, model); + + this.brandingModel = addAdditionalModel( + LoadableDetachableModel.of(BrandingService::current)); + } +} + +public class CustomerCardPanel extends BaseCardPanel { + + private final IModel> ordersModel; + + public CustomerCardPanel(String id, IModel customer, OrderService orders) { + super(id, customer); + + this.ordersModel = addAdditionalModel(LoadableDetachableModel.of( + () -> orders.findByCustomer(getModelObject()))); + } +} +---- + +=== Models that must be wrapped + +Unlike the default model, an additional model is registered as it is given, which is what allows the methods to return the very model we passed them. If it implements _IComponentAssignedModel_ (like _ResourceModel_, _StringResourceModel_ or _CompoundPropertyModel_), the component must wrap it itself so that the model knows which component it belongs to: + +[source,java] +---- +this.titleModel = addAdditionalModel(wrap(new ResourceModel("customer.card.title"))); +---- + +For a plain model — _Model_, _LoadableDetachableModel_, a lambda model — there is nothing to wrap. + +=== Looking at the models of a component + +_getModels()_ returns the default model, if there is one, followed by all additional models in the order they were registered. It does not trigger model inheritance, so calling it never creates a model as a side effect and it is safe to use in tests and debugging tools: + +[source,java] +---- +Collection> models = panel.getModels(); +---- + +=== When to keep detaching by hand + +Registering models is a convenience with a price: the models are kept as component meta data, which costs a component roughly 50 to 80 bytes more than detaching the same models by hand, whatever their number. That is a good trade for a component used a few dozen times on a page, and a bad one for a component rendered in the thousands, such as a cell inside a large table or an item of a long list. This is why the components shipped with Wicket detach their additional models themselves. + +With older versions of Wicket, for detachable state that is not a model, or when the memory of every single component instance counts, we must detach it manually. In order to do this we can override the Component's onDetach method that is called at the end of the current request: + +[source,java] +---- +@Override +public void onDetach() { + if(beeModel != null) + beeModel.detach(); + + super.onDetach(); +} +---- + When we override onDetach we must call the super class implementation of this method, usually as last line in our custom implementation. + +NOTE: A running example of a component with several registered models is available in the component reference of the Wicket examples, under _org.apache.wicket.examples.compref.MultipleModelsPage_.