diff --git a/CodenameOne/src/com/codename1/annotations/Email.java b/CodenameOne/src/com/codename1/annotations/Email.java new file mode 100644 index 0000000000..0ca2675624 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Email.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Requires the component value to be a syntactically valid e-mail address. +/// The processor emits `RegexConstraint.validEmail(message)` into the +/// `Validator` returned by `Binding#getValidator()`. +/// +/// ```java +/// @Bind(name="emailField") @Required @Email +/// private String email; +/// ``` +/// +/// Stacks well with `@Required` -- the standard email regex accepts the +/// empty string as "not yet an address," so combine with `@Required` when +/// the field is mandatory. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface Email { + /// Override the default error message ("Invalid Email Address"). + String message() default ""; +} diff --git a/CodenameOne/src/com/codename1/annotations/ExistIn.java b/CodenameOne/src/com/codename1/annotations/ExistIn.java new file mode 100644 index 0000000000..40471c1d1a --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/ExistIn.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Requires the component value to be one of an allowed list of strings. The +/// processor emits +/// `com.codename1.ui.validation.ExistInConstraint(value, caseSensitive, message)` +/// into the `Validator` returned by `Binding#getValidator()`. +/// +/// ```java +/// @Bind(name="roleField") @ExistIn({"admin", "editor", "viewer"}) +/// private String role; +/// ``` +/// +/// Use it to gate free-text fields that should accept only a known +/// vocabulary, or to gate `Picker` selections against an authoritative list +/// known at compile time. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface ExistIn { + /// The allowed values. The component must equal one of them. + String[] value(); + + /// `true` to compare values with case-sensitivity. Default: false. + boolean caseSensitive() default false; + + /// Override the default error message + /// (`ExistInConstraint` derives one from the value list when blank). + String message() default ""; +} diff --git a/CodenameOne/src/com/codename1/annotations/Length.java b/CodenameOne/src/com/codename1/annotations/Length.java new file mode 100644 index 0000000000..89caf1cec5 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Length.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Requires the component value to have at least `min` characters. The +/// processor wires this into the `Validator` returned by +/// `Binding#getValidator()` as a +/// `com.codename1.ui.validation.LengthConstraint(min, message)`. +/// +/// ```java +/// @Bindable +/// public class SignupModel { +/// @Bind(name="passwordField") @Length(min = 8) +/// private String password; +/// } +/// ``` +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface Length { + /// Minimum number of characters. Use `1` for "non-empty" -- or just write + /// `@Required` which is shorter and reads better. + int min(); + + /// Override the default error message + /// (`"Input must be at least characters"`). + String message() default ""; +} diff --git a/CodenameOne/src/com/codename1/annotations/Numeric.java b/CodenameOne/src/com/codename1/annotations/Numeric.java new file mode 100644 index 0000000000..2277f056f3 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Numeric.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Requires the component value to parse as a number, optionally within a +/// closed range. The processor emits +/// `com.codename1.ui.validation.NumericConstraint(decimal, min, max, message)` +/// into the `Validator` returned by `Binding#getValidator()`. +/// +/// ```java +/// @Bind(name="ageField") @Numeric(min = 0, max = 150) +/// private int age; +/// +/// @Bind(name="priceField") @Numeric(decimal = true, min = 0.01) +/// private double price; +/// ``` +/// +/// Bounds are inclusive. Omitting `min` / `max` removes that side of the +/// range (the defaults are negative / positive infinity). +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface Numeric { + /// `true` allows a decimal value (parsed via `Double.parseDouble`); + /// `false` requires an integer (parsed via `Integer.parseInt`). + boolean decimal() default false; + + /// Inclusive lower bound. Default: no lower bound. + double min() default Double.NEGATIVE_INFINITY; + + /// Inclusive upper bound. Default: no upper bound. + double max() default Double.POSITIVE_INFINITY; + + /// Override the default error message + /// (`NumericConstraint` derives one from the bounds when blank). + String message() default ""; +} diff --git a/CodenameOne/src/com/codename1/annotations/Regex.java b/CodenameOne/src/com/codename1/annotations/Regex.java new file mode 100644 index 0000000000..01c7a3e24e --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Regex.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Requires the component value to match a regular expression. The processor +/// emits a `com.codename1.ui.validation.RegexConstraint(pattern, message)` +/// into the `Validator` returned by `Binding#getValidator()`. +/// +/// ```java +/// @Bind(name="phoneField") +/// @Regex(pattern="^[0-9+\\-]+$", message="Digits, plus and dash only") +/// private String phone; +/// ``` +/// +/// Use `@Email` or `@Url` for the two most common patterns -- they reuse the +/// expressions already vetted in `RegexConstraint.validEmail()` / +/// `validURL()`. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface Regex { + /// The regular expression. Same dialect as + /// `com.codename1.util.regex.RE` (a Codename One subset of standard + /// regex that works on every supported runtime). + String pattern(); + + /// Override the default error message. Required because + /// `RegexConstraint` has no canned message for arbitrary patterns. + String message() default "Invalid value"; +} diff --git a/CodenameOne/src/com/codename1/annotations/Required.java b/CodenameOne/src/com/codename1/annotations/Required.java new file mode 100644 index 0000000000..1748197bea --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Required.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Marks a `@Bind` field whose component must hold a non-empty value. The +/// binder generated by the Codename One Maven plugin installs a +/// `com.codename1.ui.validation.LengthConstraint(1)` on the matching component +/// and wires it into the `Validator` returned by `Binding#getValidator()`. +/// +/// ```java +/// @Bindable +/// public class SignupModel { +/// @Bind(name="userField") @Required +/// private String user; +/// } +/// ``` +/// +/// Stack with other validation annotations -- the constraints are combined +/// (AND) via `Validator.addConstraint(Component, Constraint...)`. The first +/// failing constraint's message is shown. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface Required { + /// Override the default error message ("A value is required"). + String message() default ""; +} diff --git a/CodenameOne/src/com/codename1/annotations/Url.java b/CodenameOne/src/com/codename1/annotations/Url.java new file mode 100644 index 0000000000..f0cd6b002e --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Url.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Requires the component value to be a syntactically valid URL (http, https, +/// ftp, or file scheme). The processor emits `RegexConstraint.validURL(message)` +/// into the `Validator` returned by `Binding#getValidator()`. +/// +/// ```java +/// @Bind(name="homepageField") @Url +/// private String homepage; +/// ``` +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface Url { + /// Override the default error message ("Invalid URL"). + String message() default ""; +} diff --git a/CodenameOne/src/com/codename1/annotations/Validate.java b/CodenameOne/src/com/codename1/annotations/Validate.java new file mode 100644 index 0000000000..942016df76 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Validate.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Escape hatch for the validation annotation set: points the component at a +/// hand-written `com.codename1.ui.validation.Constraint` implementation. The +/// referenced class must be public and expose a public no-argument +/// constructor; the generated binder calls `new YourConstraint()` at bind +/// time and registers it against the `Validator` exposed via +/// `Binding#getValidator()`. +/// +/// ```java +/// public final class PhoneConstraint implements Constraint { +/// public boolean isValid(Object value) { ... } +/// public String getDefaultFailMessage() { return "Bad phone number"; } +/// } +/// +/// @Bind(name="phoneField") @Validate(PhoneConstraint.class) +/// private String phone; +/// ``` +/// +/// Stacks with the canned annotations -- `@Required @Validate(MyExtra.class)` +/// composes them under a single `GroupConstraint` (first failure wins). Use +/// it when the built-ins aren't enough; reach for the canned annotations +/// first. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface Validate { + /// The `Constraint` implementation to instantiate. Must have a public + /// no-argument constructor. + Class value(); +} diff --git a/CodenameOne/src/com/codename1/binding/Binding.java b/CodenameOne/src/com/codename1/binding/Binding.java index a1c08b82cb..11cf1c59b1 100644 --- a/CodenameOne/src/com/codename1/binding/Binding.java +++ b/CodenameOne/src/com/codename1/binding/Binding.java @@ -22,10 +22,15 @@ */ package com.codename1.binding; +import com.codename1.ui.validation.Validator; + /// Handle returned by `Binders.bind`. Lets the caller refresh the components /// from the model (e.g. after the model was mutated outside the form), -/// re-read the model from the components (e.g. before a save), or tear down -/// the listeners installed for two-way bindings. +/// re-read the model from the components (e.g. before a save), tear down +/// the listeners installed for two-way bindings, or reach into the +/// `Validator` the binder configured from the model's `@Required` / +/// `@Length` / `@Regex` / `@Email` / `@Url` / `@Numeric` / `@ExistIn` / +/// `@Validate` annotations. public interface Binding { /// Pushes the current model values into every bound component. @@ -38,4 +43,21 @@ public interface Binding { /// Removes every listener the binder added so the form can be garbage- /// collected without keeping the model alive. void disconnect(); + + /// The `Validator` populated from the model's validation annotations. + /// + /// Each `@Bind` field that carries one or more of `@Required`, + /// `@Length`, `@Regex`, `@Email`, `@Url`, `@Numeric`, `@ExistIn`, + /// `@Validate` contributes a `com.codename1.ui.validation.Constraint` + /// against the matching component. Multiple annotations on the same + /// field are combined under a `GroupConstraint` (first failure wins), + /// matching the behaviour of `Validator.addConstraint(Component, + /// Constraint...)`. + /// + /// The returned validator is never null -- call `addSubmitButtons` to + /// gate a submit `Button` until every constraint passes, set + /// `setValidateOnEveryKey` for live feedback, or call `isValid()` to + /// gate an action programmatically. When the model has no validation + /// annotations the validator is empty and `isValid()` returns true. + Validator getValidator(); } diff --git a/docs/developer-guide/Annotation-Component-Binding.asciidoc b/docs/developer-guide/Annotation-Component-Binding.asciidoc index eb7d0e481f..d8c7efa69a 100644 --- a/docs/developer-guide/Annotation-Component-Binding.asciidoc +++ b/docs/developer-guide/Annotation-Component-Binding.asciidoc @@ -189,9 +189,114 @@ resolved accessors. A `Property` field is read through `get()` and written through `set()` -- so existing `PropertyChangeListener` subscribers fire as expected. Both styles can sit on the same class. -=== Validation +[[annotation-binding-validation,Validation Annotations]] +=== Validation annotations -The annotation processor fails the build when: +The same `@Bind` field can carry validation annotations that are wired +into a `com.codename1.ui.validation.Validator`. The validator is built +by the generated binder at the moment you call `Binders.bind(...)` and +is reachable via `Binding#getValidator()` -- so the same generated +plumbing that pushes model values into components also installs the +constraints that gate them. + +[cols="1,2,3", options="header"] +|=== +| Annotation | Maps to | Notes +| `@Required` | `LengthConstraint(1)` | "Field must be non-empty." +| `@Length(min = N)` | `LengthConstraint(N, message)` | Minimum string length. +| `@Regex(pattern = ..., message = ...)` | `RegexConstraint(pattern, message)` | Same dialect as `com.codename1.util.regex.RE`. +| `@Email` | `RegexConstraint.validEmail(message)` | The standard email regex (also accepts empty -- stack with `@Required`). +| `@Url` | `RegexConstraint.validURL(message)` | `http` / `https` / `ftp` / `file` schemes. +| `@Numeric(decimal = ..., min = ..., max = ..., message = ...)` | `NumericConstraint(...)` | Bounds are inclusive; default range is unbounded. +| `@ExistIn({...})` | `ExistInConstraint(values, caseSensitive, message)` | Whitelist of allowed string values. +| `@Validate(MyConstraint.class)` | `new MyConstraint()` | Escape hatch -- the class must have a public no-arg constructor and implement `Constraint`. +|=== + +Multiple annotations on the same field are combined into a +`GroupConstraint` (first failure wins), matching the behaviour of +`Validator.addConstraint(Component, Constraint...)`. + +[source,java] +---- +package com.example; + +import com.codename1.annotations.*; +import com.codename1.binding.BindAttr; + +@Bindable +public class SignupModel { + + @Bind(name = "userField") + @Required + @Length(min = 3, message = "User name too short") + private String user; + + @Bind(name = "emailField") + @Required + @Email // <1> + private String email; + + @Bind(name = "ageField") + @Numeric(min = 13, max = 120, message = "Age 13-120") // <2> + private String age; // <3> + + @Bind(name = "roleField") + @ExistIn({ "admin", "editor", "viewer" }) + private String role; + + @Bind(name = "phoneField") + @Validate(PhoneConstraint.class) // <4> + private String phone; + + // ... getters / setters omitted for brevity +} +---- +<1> `@Required @Email` is the canonical "mandatory address" pair. The + standard email regex accepts the empty string as "not yet an + address," so `@Required` is what enforces presence. +<2> Inclusive bounds. Omit either side to remove that side of the + range (defaults are negative / positive infinity). +<3> The field's static type is `String` because the user types the + digits into a `TextField` -- the binder runs `Integer.parseInt` + only when committing the value back through the JavaBeans setter + for an `int` target. +<4> `PhoneConstraint` is a hand-written + `com.codename1.ui.validation.Constraint` with a public no-argument + constructor. The generated binder instantiates it once per + `bind()` call. + +After binding, drive validation through the returned handle: + +[source,java] +---- +LoginModel model = new LoginModel(); +Binding b = Binders.bind(model, form); + +// Auto-disable a submit button until everything is valid. +Button submit = (Button) form.findByName("submitButton"); +b.getValidator().addSubmitButtons(submit); + +// Live feedback as the user types (static toggle on the Validator +// class -- one switch flips the behaviour for every validator in the +// app): +Validator.setValidateOnEveryKey(true); + +// Programmatic gate before saving: +if (b.getValidator().isValid()) { + repository.save(model); +} +---- + +`Binding#getValidator()` never returns `null` -- when the model has no +validation annotations the validator is empty and `isValid()` returns +`true`. The validator owns the listener and emblem plumbing on the +matching components; calling `addSubmitButtons` after `bind` is the +typical pattern. + +=== Build-time validation + +In addition to the runtime constraints above, the annotation processor +itself fails the build when: * `@Bind` is applied to a field with no accessible read path -- the field must be public, declare a JavaBeans `getX` / `isX` getter, or @@ -202,6 +307,7 @@ The annotation processor fails the build when: * `@Bind(name=...)` is empty. * The field's static type isn't supported (raw collections, opaque references that aren't `@Bindable` themselves, ...). +* `@Regex` is missing a pattern, or `@ExistIn` is missing values. Errors are accumulated so a single build run reports every offending field at once. diff --git a/docs/developer-guide/The-Components-Of-Codename-One.asciidoc b/docs/developer-guide/The-Components-Of-Codename-One.asciidoc index 6ad3204325..38f5cd7591 100644 --- a/docs/developer-guide/The-Components-Of-Codename-One.asciidoc +++ b/docs/developer-guide/The-Components-Of-Codename-One.asciidoc @@ -1156,6 +1156,14 @@ v.addSubmitButtons(submit); .Validation & Regular Expressions image::img/validation-regex-masking-1.png[Validation and Regular Expressions,scaledwidth=20%] +When the form components are bound to a model with `@Bindable`, the +same constraints can be expressed as field annotations -- `@Required`, +`@Length`, `@Regex`, `@Email`, `@Url`, `@Numeric`, `@ExistIn`, +`@Validate` -- and the generated binder wires them into a `Validator` +exposed through `Binding#getValidator()`. See the +<> section in the +component binding chapter for the full reference. + === InfiniteProgress The https://www.codenameone.com/javadoc/com/codename1/components/InfiniteProgress.html[InfiniteProgress] indicator spins an image infinitely to show that a background process is still working. diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BindingAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BindingAnnotationProcessor.java index 68bea3c264..c883716b27 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BindingAnnotationProcessor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BindingAnnotationProcessor.java @@ -36,6 +36,7 @@ import org.objectweb.asm.ClassWriter; import org.objectweb.asm.MethodVisitor; import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; import java.io.File; import java.io.IOException; @@ -95,11 +96,34 @@ /// be triggered explicitly via `Binding#refresh()`. /// /// The processor fails the build when none of the three resolves. +/// +/// #### Validation annotations +/// +/// Alongside `@Bind`, a field may carry any of `@Required`, `@Length`, +/// `@Regex`, `@Email`, `@Url`, `@Numeric`, `@ExistIn`, `@Validate`. The +/// generator builds a `com.codename1.ui.validation.Validator` in the +/// `bind()` method, calls `addConstraint(component, constraints...)` once +/// per annotated field, and exposes the validator through +/// `Binding#getValidator()`. Multiple validation annotations on the same +/// field are combined under a `GroupConstraint` (first failure wins), so +/// `@Required @Email` on a single field reads naturally. public final class BindingAnnotationProcessor extends AbstractAnnotationProcessor { public static final String BINDABLE_DESC = "Lcom/codename1/annotations/Bindable;"; public static final String BIND_DESC = "Lcom/codename1/annotations/Bind;"; + /// Field-level validation annotations consumed by the binder generator. + /// Each one maps to a single `com.codename1.ui.validation.Constraint` + /// installed on the matching component via `Validator.addConstraint`. + static final String REQUIRED_DESC = "Lcom/codename1/annotations/Required;"; + static final String LENGTH_DESC = "Lcom/codename1/annotations/Length;"; + static final String REGEX_DESC = "Lcom/codename1/annotations/Regex;"; + static final String EMAIL_DESC = "Lcom/codename1/annotations/Email;"; + static final String URL_DESC = "Lcom/codename1/annotations/Url;"; + static final String NUMERIC_DESC = "Lcom/codename1/annotations/Numeric;"; + static final String EXIST_IN_DESC = "Lcom/codename1/annotations/ExistIn;"; + static final String VALIDATE_DESC = "Lcom/codename1/annotations/Validate;"; + static final String BOOTSTRAP_BINARY = "cn1app.BinderBootstrap"; static final String BOOTSTRAP_SIMPLE = "BinderBootstrap"; static final String BOOTSTRAP_PACKAGE = "cn1app"; @@ -181,6 +205,7 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces if (!resolveAccessors(bf, f, cls, explicitGetter, explicitSetter, ctx)) { continue; } + collectValidationAnnotations(bf, f, cls, ctx); bc.fields.add(bf); } // @Bindable with no @Bind fields is accepted -- the generated @@ -276,6 +301,119 @@ private static boolean resolveAccessors(BoundField bf, FieldInfo field, Annotate return true; } + /// Reads each validation annotation (`@Required`, `@Length`, `@Regex`, + /// `@Email`, `@Url`, `@Numeric`, `@ExistIn`, `@Validate`) off `field` and + /// appends a `Validation` spec to `bf` for every one found. The order is + /// preserved -- the generated binder hands them to + /// `Validator.addConstraint(Component, Constraint...)` in declaration + /// order, and a `GroupConstraint` makes the first failing constraint + /// win. + private static void collectValidationAnnotations(BoundField bf, FieldInfo field, + AnnotatedClass cls, ProcessorContext ctx) { + AnnotationValues a; + + a = field.getAnnotation(REQUIRED_DESC); + if (a != null) { + Validation v = new Validation(ValidationKind.REQUIRED); + v.message = a.getStringOrDefault("message", ""); + bf.validations.add(v); + } + + a = field.getAnnotation(LENGTH_DESC); + if (a != null) { + Validation v = new Validation(ValidationKind.LENGTH); + v.intArg = a.getIntOrDefault("min", 1); + v.message = a.getStringOrDefault("message", ""); + bf.validations.add(v); + } + + a = field.getAnnotation(REGEX_DESC); + if (a != null) { + String pattern = a.getStringOrDefault("pattern", ""); + if (pattern.length() == 0) { + ctx.error(cls, "@Regex on " + cls.getBinaryName() + "." + field.getName() + + " requires a non-empty pattern"); + } else { + Validation v = new Validation(ValidationKind.REGEX); + v.stringArg = pattern; + v.message = a.getStringOrDefault("message", "Invalid value"); + bf.validations.add(v); + } + } + + a = field.getAnnotation(EMAIL_DESC); + if (a != null) { + Validation v = new Validation(ValidationKind.EMAIL); + v.message = a.getStringOrDefault("message", ""); + bf.validations.add(v); + } + + a = field.getAnnotation(URL_DESC); + if (a != null) { + Validation v = new Validation(ValidationKind.URL); + v.message = a.getStringOrDefault("message", ""); + bf.validations.add(v); + } + + a = field.getAnnotation(NUMERIC_DESC); + if (a != null) { + Validation v = new Validation(ValidationKind.NUMERIC); + v.boolArg = a.getBoolOrDefault("decimal", false); + v.doubleMin = doubleOrDefault(a, "min", Double.NEGATIVE_INFINITY); + v.doubleMax = doubleOrDefault(a, "max", Double.POSITIVE_INFINITY); + v.message = a.getStringOrDefault("message", ""); + bf.validations.add(v); + } + + a = field.getAnnotation(EXIST_IN_DESC); + if (a != null) { + List values = new ArrayList(); + Object raw = a.get("value"); + if (raw instanceof List) { + for (Object item : (List) raw) { + if (item instanceof String) { + values.add((String) item); + } + } + } + if (values.isEmpty()) { + ctx.error(cls, "@ExistIn on " + cls.getBinaryName() + "." + field.getName() + + " requires at least one allowed value"); + } else { + Validation v = new Validation(ValidationKind.EXIST_IN); + v.stringArrayArg = values.toArray(new String[0]); + v.boolArg = a.getBoolOrDefault("caseSensitive", false); + v.message = a.getStringOrDefault("message", ""); + bf.validations.add(v); + } + } + + a = field.getAnnotation(VALIDATE_DESC); + if (a != null) { + Object raw = a.get("value"); + String binaryName = null; + if (raw instanceof Type) { + binaryName = ((Type) raw).getClassName(); + } + if (binaryName == null || binaryName.length() == 0) { + ctx.error(cls, "@Validate on " + cls.getBinaryName() + "." + field.getName() + + " must name a Constraint class"); + } else { + Validation v = new Validation(ValidationKind.CUSTOM); + v.stringArg = binaryName; + bf.validations.add(v); + } + } + } + + private static double doubleOrDefault(AnnotationValues a, String key, double defaultValue) { + Object v = a.get(key); + if (v instanceof Number) { + return ((Number) v).doubleValue(); + } + return defaultValue; + } + private static MethodInfo findMethod(AnnotatedClass cls, String name, String descriptor) { for (MethodInfo m : cls.getMethods()) { if (!m.getName().equals(name)) { @@ -464,6 +602,7 @@ private static String generateBinderSource(BindableClass bc) { sb.append(" public com.codename1.binding.Binding bind(final ").append(bc.binaryName) .append(" model, final com.codename1.ui.Container container) {\n"); sb.append(" final java.util.ArrayList _disposers = new java.util.ArrayList();\n"); + sb.append(" final com.codename1.ui.validation.Validator _validator = new com.codename1.ui.validation.Validator();\n"); // Resolve each component once. for (int i = 0; i < bc.fields.size(); i++) { @@ -472,6 +611,18 @@ private static String generateBinderSource(BindableClass bc) { .append(" = _findByName(container, \"").append(escape(f.componentName)).append("\");\n"); } + // Wire validation annotations into the Validator. Constraints are + // added in declaration order; multiple constraints on a single + // component compose into a GroupConstraint via the varargs overload + // (first failure wins). + for (int i = 0; i < bc.fields.size(); i++) { + BoundField f = bc.fields.get(i); + if (f.validations.isEmpty()) { + continue; + } + emitValidationWireUp(sb, f, i); + } + // refresh() pushes model -> components inside an update region. sb.append(" final Runnable _refresh = new Runnable() { public void run() {\n"); sb.append(" com.codename1.binding.Binders.enterUpdate();\n"); @@ -518,6 +669,7 @@ private static String generateBinderSource(BindableClass bc) { sb.append(" for (com.codename1.ui.events.ActionListener _d : _disposers) _d.actionPerformed(null);\n"); sb.append(" _disposers.clear();\n"); sb.append(" }\n"); + sb.append(" public com.codename1.ui.validation.Validator getValidator() { return _validator; }\n"); sb.append(" public String modelTypeName() { return _typeName; }\n"); sb.append(" public boolean matches(Object o) { return o == _modelRef; }\n"); sb.append(" };\n"); @@ -693,6 +845,97 @@ private static void emitListenerInstall(StringBuilder sb, BoundField f, int i) { } } + /// Emits validator wire-up for the component at index `i`. Generates a + /// guarded block that constructs each `Constraint` and hands them to + /// `Validator.addConstraint(Component, Constraint...)`. + private static void emitValidationWireUp(StringBuilder sb, BoundField f, int i) { + sb.append(" if (_c").append(i).append(" != null) {\n"); + sb.append(" _validator.addConstraint(_c").append(i); + for (Validation v : f.validations) { + sb.append(", "); + emitConstraintExpr(sb, v); + } + sb.append(");\n"); + sb.append(" }\n"); + } + + private static void emitConstraintExpr(StringBuilder sb, Validation v) { + switch (v.kind) { + case REQUIRED: + // LengthConstraint(1, message) -- value required (non-empty). + if (v.message.length() == 0) { + sb.append("new com.codename1.ui.validation.LengthConstraint(1)"); + } else { + sb.append("new com.codename1.ui.validation.LengthConstraint(1, \"") + .append(escape(v.message)).append("\")"); + } + break; + case LENGTH: + if (v.message.length() == 0) { + sb.append("new com.codename1.ui.validation.LengthConstraint(") + .append(v.intArg).append(")"); + } else { + sb.append("new com.codename1.ui.validation.LengthConstraint(") + .append(v.intArg).append(", \"").append(escape(v.message)).append("\")"); + } + break; + case REGEX: + sb.append("new com.codename1.ui.validation.RegexConstraint(\"") + .append(escape(v.stringArg)).append("\", \"") + .append(escape(v.message)).append("\")"); + break; + case EMAIL: + if (v.message.length() == 0) { + sb.append("com.codename1.ui.validation.RegexConstraint.validEmail()"); + } else { + sb.append("com.codename1.ui.validation.RegexConstraint.validEmail(\"") + .append(escape(v.message)).append("\")"); + } + break; + case URL: + if (v.message.length() == 0) { + sb.append("com.codename1.ui.validation.RegexConstraint.validURL()"); + } else { + sb.append("com.codename1.ui.validation.RegexConstraint.validURL(\"") + .append(escape(v.message)).append("\")"); + } + break; + case NUMERIC: + String msg = v.message.length() == 0 ? "null" : "\"" + escape(v.message) + "\""; + sb.append("new com.codename1.ui.validation.NumericConstraint(") + .append(v.boolArg).append(", ") + .append(doubleLiteral(v.doubleMin)).append(", ") + .append(doubleLiteral(v.doubleMax)).append(", ") + .append(msg).append(")"); + break; + case EXIST_IN: + String msgX = v.message.length() == 0 ? "null" : "\"" + escape(v.message) + "\""; + sb.append("new com.codename1.ui.validation.ExistInConstraint(new String[]{"); + for (int k = 0; k < v.stringArrayArg.length; k++) { + if (k > 0) { + sb.append(", "); + } + sb.append('"').append(escape(v.stringArrayArg[k])).append('"'); + } + sb.append("}, ").append(v.boolArg).append(", ").append(msgX).append(")"); + break; + case CUSTOM: + // v.stringArg is the binary name of the Constraint implementation. + sb.append("new ").append(v.stringArg).append("()"); + break; + } + } + + private static String doubleLiteral(double d) { + if (Double.isNaN(d)) { + return "Double.NaN"; + } + if (Double.isInfinite(d)) { + return d > 0 ? "Double.POSITIVE_INFINITY" : "Double.NEGATIVE_INFINITY"; + } + return Double.toString(d); + } + private static String boolExpr(BoundField f, String modelExpr) { if (f.kind.kind == PropertyTypeKind.Kind.PROPERTY && "java.lang.Boolean".equals(f.kind.elementBinaryName)) { @@ -838,5 +1081,30 @@ static final class BoundField { String getterDescriptor; String setter; // null means direct field assignment String setterDescriptor; + final List validations = new ArrayList(); + } + + /// The kind of constraint a validation annotation maps to. Each kind + /// drives one `case` of the generator switch in `emitConstraintExpr`. + enum ValidationKind { + REQUIRED, LENGTH, REGEX, EMAIL, URL, NUMERIC, EXIST_IN, CUSTOM + } + + /// Parsed view of a single validation annotation occurrence on a + /// `@Bind` field. Only the fields relevant to the chosen `kind` are + /// populated; the others stay at their defaults. + static final class Validation { + final ValidationKind kind; + String message = ""; + int intArg; + boolean boolArg; + double doubleMin; + double doubleMax; + String stringArg; + String[] stringArrayArg; + + Validation(ValidationKind kind) { + this.kind = kind; + } } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BindingAnnotationProcessorTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BindingAnnotationProcessorTest.java index 0d331a7970..b7da665714 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BindingAnnotationProcessorTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BindingAnnotationProcessorTest.java @@ -101,6 +101,196 @@ public void rejectsBindOnPrivateFieldWithoutAccessor() throws Exception { assertTrue("expected validation error on private field without accessor", ctx.hasErrors()); } + @Test + public void generatesValidatorForAnnotatedFields() throws Exception { + File classes = compileFixture( + "com.example.SignupModel", + "package com.example;\n" + + "import com.codename1.annotations.*;\n" + + "@Bindable\n" + + "public class SignupModel {\n" + + " @Bind(name=\"emailField\") @Required @Email\n" + + " public String email;\n" + + " @Bind(name=\"ageField\") @Numeric(min = 13, max = 120)\n" + + " public String age;\n" + + " @Bind(name=\"phoneField\") @Regex(pattern=\"^[0-9]+$\", message=\"digits only\")\n" + + " public String phone;\n" + + " @Bind(name=\"siteField\") @Url\n" + + " public String site;\n" + + " @Bind(name=\"roleField\") @ExistIn({\"admin\", \"viewer\"})\n" + + " public String role;\n" + + " @Bind(name=\"lengthField\") @Length(min = 8)\n" + + " public String secret;\n" + + " public SignupModel() {}\n" + + "}\n"); + runProcessorOrFail(classes); + + // The binder source is generated then compiled in-memory. Probe + // the constant pool of the resulting .class for the type / method + // references each constraint variant should emit. + String pool = readClassConstantPool(classes, + "com/example/SignupModelCn1Binder"); + assertTrue("validator type referenced", + pool.contains("com/codename1/ui/validation/Validator")); + assertTrue("addConstraint method referenced", + pool.contains("addConstraint")); + // getValidator() lives on the anonymous NotifiableBinding subclass + // (`$1.class`), not on the outer binder. Scan the binder + // class + every anonymous inner. + String allPool = readBinderConstantPool(classes, "com/example/SignupModelCn1Binder"); + assertTrue("getValidator method emitted on Binding impl", + allPool.contains("getValidator")); + assertTrue("@Required / @Length both reference LengthConstraint", + pool.contains("com/codename1/ui/validation/LengthConstraint")); + assertTrue("@Email / @Regex reference RegexConstraint", + pool.contains("com/codename1/ui/validation/RegexConstraint")); + assertTrue("@Email reaches the validEmail factory", + pool.contains("validEmail")); + assertTrue("@Url reaches the validURL factory", + pool.contains("validURL")); + assertTrue("@Numeric references NumericConstraint", + pool.contains("com/codename1/ui/validation/NumericConstraint")); + assertTrue("@Regex pattern survives into constant pool", + pool.contains("^[0-9]+$")); + assertTrue("@ExistIn references ExistInConstraint", + pool.contains("com/codename1/ui/validation/ExistInConstraint")); + assertTrue("@ExistIn vocabulary survives into constant pool", + pool.contains("admin") && pool.contains("viewer")); + } + + @Test + public void emptyValidatorWhenNoConstraintAnnotations() throws Exception { + File classes = compileFixture( + "com.example.NoValidation", + "package com.example;\n" + + "import com.codename1.annotations.*;\n" + + "@Bindable\n" + + "public class NoValidation {\n" + + " @Bind(name=\"x\") public String x;\n" + + " public NoValidation() {}\n" + + "}\n"); + runProcessorOrFail(classes); + String pool = readClassConstantPool(classes, + "com/example/NoValidationCn1Binder"); + // The validator must still be there so getValidator() never + // returns null, but no constraint type should be referenced. + assertTrue("validator type still referenced", + pool.contains("com/codename1/ui/validation/Validator")); + String allPool = readBinderConstantPool(classes, "com/example/NoValidationCn1Binder"); + assertTrue("getValidator method still emitted", allPool.contains("getValidator")); + assertTrue("no LengthConstraint when no @Required/@Length present", + !pool.contains("com/codename1/ui/validation/LengthConstraint")); + assertTrue("no RegexConstraint when no @Regex/@Email/@Url present", + !pool.contains("com/codename1/ui/validation/RegexConstraint")); + } + + @Test + public void rejectsEmptyRegexPattern() throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.BadRegex", + "package com.example;\n" + + "import com.codename1.annotations.*;\n" + + "@Bindable public class BadRegex {\n" + + " @Bind(name=\"x\") @Regex(pattern=\"\") public String x;\n" + + " public BadRegex() {}\n" + + "}\n"), + classes, Arrays.asList(testClassesDir())); + ProcessorContext ctx = runProcessor(classes); + assertTrue("expected error on empty @Regex pattern", ctx.hasErrors()); + } + + @Test + public void rejectsEmptyExistInList() throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.BadExistIn", + "package com.example;\n" + + "import com.codename1.annotations.*;\n" + + "@Bindable public class BadExistIn {\n" + + " @Bind(name=\"x\") @ExistIn({}) public String x;\n" + + " public BadExistIn() {}\n" + + "}\n"), + classes, Arrays.asList(testClassesDir())); + ProcessorContext ctx = runProcessor(classes); + assertTrue("expected error on empty @ExistIn value list", ctx.hasErrors()); + } + + @Test + public void customValidateAnnotationEmitsNewExpression() throws Exception { + // Two top-level fixtures in the same package so the generated binder + // can reference the custom constraint by binary name. + File classes = tmp.newFolder("classes"); + java.util.Map sources = new java.util.LinkedHashMap(); + sources.put("com.example.PhoneConstraint", + "package com.example;\n" + + "import com.codename1.ui.validation.Constraint;\n" + + "public class PhoneConstraint implements Constraint {\n" + + " public boolean isValid(Object v) { return true; }\n" + + " public String getDefaultFailMessage() { return \"\"; }\n" + + "}\n"); + sources.put("com.example.PhoneHolder", + "package com.example;\n" + + "import com.codename1.annotations.*;\n" + + "@Bindable public class PhoneHolder {\n" + + " @Bind(name=\"phone\") @Validate(PhoneConstraint.class)\n" + + " public String phone;\n" + + " public PhoneHolder() {}\n" + + "}\n"); + JavaSourceCompiler.compile(sources, classes, Arrays.asList(testClassesDir())); + runProcessorOrFail(classes); + String pool = readClassConstantPool(classes, + "com/example/PhoneHolderCn1Binder"); + // The Validate annotation's class literal is captured as a Type ref + // and emitted as a direct `new` expression on that class. + assertTrue("custom constraint type referenced", + pool.contains("com/example/PhoneConstraint")); + } + + /// Returns the concatenation of every UTF-8 entry in the class file's + /// constant pool. The .class binary embeds type / method / String + /// references as UTF-8 records, so substring matching against this + /// blob is sufficient for "does the generated binder reference + /// `com/codename1/ui/validation/LengthConstraint`?" style checks. + private static String readClassConstantPool(File classesRoot, String internalName) throws Exception { + File classFile = new File(classesRoot, internalName + ".class"); + if (!classFile.exists()) { + throw new IllegalStateException("Generated binder class missing: " + classFile); + } + byte[] bytes = java.nio.file.Files.readAllBytes(classFile.toPath()); + // ISO-8859-1 round-trips the raw bytes 1:1, so UTF-8 entries inside + // the constant pool show up as ASCII substrings without needing the + // full constant-pool walker -- enough for the assertions here. + return new String(bytes, java.nio.charset.StandardCharsets.ISO_8859_1); + } + + /// Returns the union of every UTF-8 constant pool entry across the + /// binder class and any inner classes that share its prefix. The + /// generated binder splits methods across the outer class and one or + /// more anonymous inner classes (the NotifiableBinding subclass, the + /// listeners, the disposers); methods like `getValidator` live on the + /// inner classes so a substring check against just the outer class + /// would miss them. + private static String readBinderConstantPool(File classesRoot, String binderInternalName) throws Exception { + File parent = new File(classesRoot, binderInternalName).getParentFile(); + String prefix = binderInternalName.substring(binderInternalName.lastIndexOf('/') + 1); + StringBuilder out = new StringBuilder(); + File[] files = parent.listFiles(); + if (files != null) { + for (File f : files) { + if (f.getName().endsWith(".class") + && (f.getName().equals(prefix + ".class") + || f.getName().startsWith(prefix + "$"))) { + out.append(new String( + java.nio.file.Files.readAllBytes(f.toPath()), + java.nio.charset.StandardCharsets.ISO_8859_1)); + out.append('\n'); + } + } + } + return out.toString(); + } + @Test public void instrumentsSetterWithNotifyChanged() throws Exception { File classes = compileFixture( diff --git a/scripts/initializr/common/src/main/resources/skill/SKILL.md b/scripts/initializr/common/src/main/resources/skill/SKILL.md index d6f730b088..aeb5d76d8d 100644 --- a/scripts/initializr/common/src/main/resources/skill/SKILL.md +++ b/scripts/initializr/common/src/main/resources/skill/SKILL.md @@ -24,6 +24,7 @@ This skill teaches you how to write code for a Codename One (CN1) cross-platform - `references/build-hints.md` — Curated index of `codename1.arg.*` build hints (iOS, Android, push, web). - `references/java-api-subset.md` — How to inspect the supported Java API subset, IO (`Storage`, `FileSystemStorage`), networking (`ConnectionRequest`, `Rest`), concurrency, dates, SQLite. **Read this whenever the compliance check fails or when you reach for a `java.*` API.** - `references/ui-components.md` — Form, Toolbar, Container layouts (Border/Box/Flow/Grid/Layered), common components, navigation, dialogs. +- `references/binding-and-validation.md` — `@Bindable` / `@Bind` annotation binding **and** annotation-driven validation (`@Required`, `@Length`, `@Regex`, `@Email`, `@Url`, `@Numeric`, `@ExistIn`, `@Validate`). Read this whenever you see one of those annotations, wire a model to a form, or need to gate a submit button on validation. - `references/css.md` — CSS capabilities and (important) **limitations**. Selectors, supported properties, 9-patch borders, theme constants. - `references/swing-comparison.md` — Mapping Swing concepts and code to Codename One. Read this when porting Swing code. - `references/html-css-cheatsheet.md` — Converting common HTML/CSS snippets to CN1 components + CSS. @@ -281,6 +282,7 @@ If you cannot run the simulator (e.g. headless environment), **say so explicitly | If the user asks for... | Open this reference | | --- | --- | | "Add a screen with a list / form / dialog" | `references/ui-components.md` | +| "Wire this form to a model" / "Validate this form" / `@Bindable`, `@Required`, `@Email`, ... | `references/binding-and-validation.md` | | "Make this look like X" / CSS tweaks | `references/css.md` | | "Port this from Swing" / Swing idioms | `references/swing-comparison.md` | | "I have HTML/CSS, convert it" | `references/html-css-cheatsheet.md` | diff --git a/scripts/initializr/common/src/main/resources/skill/references/binding-and-validation.md b/scripts/initializr/common/src/main/resources/skill/references/binding-and-validation.md new file mode 100644 index 0000000000..4e141477d4 --- /dev/null +++ b/scripts/initializr/common/src/main/resources/skill/references/binding-and-validation.md @@ -0,0 +1,229 @@ +# Component Binding and Validation Reference + +The Codename One Maven plugin ships an annotation-driven binding framework +that wires a model POJO to the components on a `Form` / `Container` at +build time. The same annotations also drive validation: when you bind the +model, the generated binder configures a `Validator` whose constraints +come from `@Required` / `@Length` / `@Regex` / `@Email` / `@Url` / +`@Numeric` / `@ExistIn` / `@Validate` on the model fields. + +This file is the agent's cheat-sheet for both. Open it when you see +`@Bindable`, `@Bind`, or any of the validation annotations -- or when the +user asks for "wire a form to a POJO," "auto-validate this screen," +"disable the submit button until everything is valid," or similar. + +## The two annotations that drive binding + +```java +package com.example; + +import com.codename1.annotations.*; +import com.codename1.binding.BindAttr; + +@Bindable // generates a XxxCn1Binder +public class LoginModel { + + @Bind(name = "userField", attr = BindAttr.TEXT) + private String user; + + @Bind(name = "rememberMe", attr = BindAttr.SELECTED) + private boolean remember; + + @Bind(name = "banner", attr = BindAttr.UIID, twoWay = false) + public String bannerStyle; + + public String getUser() { return user; } + public void setUser(String u) { this.user = u; } + public boolean isRemember() { return remember; } + public void setRemember(boolean r) { this.remember = r; } +} +``` + +- `@Bindable` marks a class for binder generation. The Maven plugin emits + `Cn1Binder` in the same package + a single + `cn1app.BinderBootstrap` that registers them all. +- `@Bind(name = ...)` matches `Component#getName()` on the form (the GUI + builder name). The binder walks the container recursively. +- `attr` picks the component property the field mirrors. Default is + `TEXT`. Other values: `UIID`, `HIDDEN`, `VISIBLE`, `ENABLED`, + `SELECTED`, `ICON_NAME`, `NAME`. +- `twoWay = true` (the default for `TEXT` and `SELECTED`) installs a + listener on the component so user input flows back into the model. The + setter is bytecode-instrumented to call `Binders.notifyChanged(this)`, + which fans out to every active binding when the model is mutated from + any code path. Other attrs are write-only. + +Accessor resolution order: + +1. `@Bind(getter="...", setter="...")` -- explicit method names. +2. JavaBeans `getFoo()` / `isFoo()` / `setFoo(T)` detected from + bytecode. +3. Direct public-field access (only when the field is `public`). + +The build fails when none of the three resolves. Don't add `@Bind` to a +private field without a JavaBeans setter -- the error message is clear, +but it's faster to write the accessors up front. + +## Bind at runtime + +```java +import com.codename1.binding.Binders; +import com.codename1.binding.Binding; + +Form form = (Form) Resources.getGlobalResources().getForm("LoginForm"); +LoginModel model = new LoginModel(); +Binding binding = Binders.bind(model, form); + +// Two-way bindings flow automatically. Mutate through the setter: +model.setUser("alice"); // userField text updates + +// Pull pending edits into the model before submit: +binding.commit(); + +// On form dispose: +binding.disconnect(); // remove every listener the binder added +``` + +The `Binding` handle exposes `refresh()`, `commit()`, `disconnect()`, +**and** `getValidator()`. + +## Validation annotations + +Stack any of these on a `@Bind` field. Each maps to a constraint in +`com.codename1.ui.validation.*`. Multiple annotations on a single field +combine into a `GroupConstraint` (first failure wins). + +| Annotation | Maps to | When to use | +| --- | --- | --- | +| `@Required` | `LengthConstraint(1, msg)` | Mandatory non-empty field. | +| `@Length(min = N, message = ...)` | `LengthConstraint(N, msg)` | Minimum string length. | +| `@Regex(pattern = ..., message = ...)` | `RegexConstraint(pat, msg)` | Arbitrary pattern; pattern is the Codename One `RE` dialect. | +| `@Email(message = ...)` | `RegexConstraint.validEmail(msg)` | Vetted email regex. Stack with `@Required` -- it accepts empty. | +| `@Url(message = ...)` | `RegexConstraint.validURL(msg)` | `http` / `https` / `ftp` / `file` schemes. | +| `@Numeric(decimal = ..., min = ..., max = ..., message = ...)` | `NumericConstraint(...)` | Integer or decimal, optional inclusive bounds. | +| `@ExistIn({"a","b"}, caseSensitive = ..., message = ...)` | `ExistInConstraint(...)` | Whitelist of allowed strings. | +| `@Validate(MyConstraint.class)` | `new MyConstraint()` | Escape hatch -- public no-arg constructor that implements `Constraint`. | + +### Annotated model + use site + +```java +@Bindable +public class SignupModel { + @Bind(name = "userField") + @Required @Length(min = 3, message = "At least 3 characters") + private String user; + + @Bind(name = "emailField") + @Required @Email + private String email; + + @Bind(name = "ageField") + @Numeric(min = 13, max = 120, message = "Age 13-120") + private String age; + + @Bind(name = "roleField") + @ExistIn({ "admin", "editor", "viewer" }) + private String role; + + @Bind(name = "siteField") + @Url + private String site; + + @Bind(name = "phoneField") + @Validate(PhoneConstraint.class) + private String phone; + + // getters / setters omitted +} +``` + +```java +SignupModel model = new SignupModel(); +Binding b = Binders.bind(model, form); + +// Gate the submit button: +Button submit = (Button) form.findByName("submitButton"); +b.getValidator().addSubmitButtons(submit); + +// Programmatic check before save: +if (b.getValidator().isValid()) { + repository.save(model); +} + +// Live feedback (global toggle): +Validator.setValidateOnEveryKey(true); +``` + +### Custom constraint class (`@Validate`) + +```java +package com.example; +import com.codename1.ui.validation.Constraint; + +public class PhoneConstraint implements Constraint { + @Override public boolean isValid(Object value) { + if (value == null) return false; + String s = value.toString(); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (!(c >= '0' && c <= '9') && c != '+' && c != '-') return false; + } + return s.length() > 0; + } + @Override public String getDefaultFailMessage() { + return "Must be a valid phone number"; + } +} +``` + +The class must be `public` with a public no-argument constructor. The +generated binder instantiates it once per `bind()` call. + +## Things to know + +- `Binding#getValidator()` is never `null`. When the model has no + validation annotations the validator is empty and `isValid()` returns + `true` -- this is a safe call site for `addSubmitButtons`. +- `@Required` and `@Email` combine naturally -- the email regex accepts + the empty string, so without `@Required` an empty address is "valid." +- `@Numeric` operates on the component's text value (parsed via + `Integer.parseInt` / `Double.parseDouble`). The bound field can still + be a `String` in the model. +- `@ExistIn` is case-insensitive by default. Set `caseSensitive = true` + when matching identifiers like enum values. +- The generated binder uses `Binders.enterUpdate()` / `exitUpdate()` to + break model -> component -> model loops. If a setter synchronously + mutates a second bound field, call `binding.refresh()` to push the + derived value out (the loop guard suppresses the in-region + notification by design). +- Don't implement `Binding` yourself. The framework's only implementer + is the generated binder; the interface may grow over time. + +## When to reach for `UiBinding` instead + +The imperative `com.codename1.properties.UiBinding` API still ships and +works alongside `@Bindable`. Use `UiBinding` when: + +- the model isn't an `@Bindable` POJO (third-party class, generic + property bag); +- you need a non-default converter (date formatting, currency, ...); +- the binding shape is dynamic / runtime-driven. + +Use `@Bindable` for everything else -- it's terser, validates at build +time, and gives you `getValidator()` for free. + +## Common pitfalls + +- **`Binders.bind(...)` throws `IllegalStateException`**: the + `cn1app.BinderBootstrap` didn't load. Either the `@Bindable` class + lives outside the scan root, or you bypassed the standard `Lifecycle` + start-up. In test code, call `XxxCn1Binder.register()` directly. +- **`@Bind` field has no accessor**: make the field `public` or add a + JavaBeans `setX` (for two-way) and `getX` (always). +- **`@Validate(MyConstraint.class)` blows up at runtime**: make sure the + class is reachable from the build classpath and exposes a public + no-arg constructor. +- **Validation emblem never shows**: the `Validator` only paints emblems + when at least one constraint has been added. Calling + `addSubmitButtons` before any constraints is harmless but the buttons + start in an enabled state until the first constraint registers.