diff --git a/CodenameOne/src/com/codename1/annotations/Bind.java b/CodenameOne/src/com/codename1/annotations/Bind.java new file mode 100644 index 0000000000..8c2a31f913 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Bind.java @@ -0,0 +1,90 @@ +/* + * 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 com.codename1.binding.BindAttr; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Pairs a `@Bindable` field with the component it should mirror. +/// +/// `name` is the value the target component returns from +/// `Component#getName()`. `attr` picks the attribute to write: text, UIID, +/// selected state, visible / hidden, the icon name, or the component name +/// itself. +/// +/// One-way bindings push from the model to the component on +/// `Binders.bind`. Two-way bindings additionally listen for user input on +/// text fields, text areas, and check boxes so changes flow back into the +/// model. +/// +/// #### Accessor resolution +/// +/// The annotation processor decides how to read and write the field in +/// this order: +/// +/// 1. **Explicit accessor names** -- `@Bind(getter="getX", setter="setX")`. +/// Use this when the JavaBeans naming convention doesn't match (a +/// fluent setter, a renamed boolean, ...). +/// 2. **JavaBeans accessors** when both `getter` and `setter` are blank: +/// `getFoo()` / `isFoo()` for the getter, `setFoo(T)` for the setter. +/// Detected from the project's compiled bytecode -- no runtime +/// reflection. +/// 3. **Direct public-field access** as a last resort. The processor +/// fails the build with a clear error when the field is private and +/// no usable accessor exists. +/// +/// For two-way bindings the build-time processor instruments the resolved +/// setter to call `Binders.notifyChanged(this)` at every return point. +/// Application code can mutate the model through the setter from anywhere +/// and the bound component refreshes automatically; see +/// `Annotation-Component-Binding.asciidoc` for the loop-guard details. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface Bind { + /// `Component#getName()` of the target component. + String name(); + + /// Which property of the component the field mirrors. Default: `TEXT`. + BindAttr attr() default BindAttr.TEXT; + + /// When false the binding is one-way (model -> component only). + /// Default `true` for `TEXT` against editable components and for + /// `SELECTED`; for all other attributes the binding is implicitly + /// one-way regardless of this flag. + boolean twoWay() default true; + + /// Explicit getter method name. Default: empty -- the processor uses + /// JavaBeans `get` / `is` discovery, then falls back to + /// direct public-field access. + String getter() default ""; + + /// Explicit setter method name. Default: empty -- the processor uses + /// JavaBeans `set` discovery, then falls back to direct + /// public-field assignment. The resolved setter (whether explicit or + /// detected) is instrumented for two-way bindings. + String setter() default ""; +} diff --git a/CodenameOne/src/com/codename1/annotations/Bindable.java b/CodenameOne/src/com/codename1/annotations/Bindable.java new file mode 100644 index 0000000000..edccddf6e5 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Bindable.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; + +/// Marks a POJO or `PropertyBusinessObject` as a target for the component +/// binding processor. The Codename One Maven plugin generates a `Binder` next +/// to the class that copies the marked fields into the matching components of +/// a `Container` -- and back, for two-way bindings against `TextField`, +/// `TextArea`, `CheckBox`, and friends. +/// +/// ```java +/// @Bindable +/// public class LoginModel { +/// @Bind(name="userField", attr=BindAttr.TEXT) +/// public Property user = new Property<>("user"); +/// +/// @Bind(name="rememberMe", attr=BindAttr.SELECTED) +/// public boolean remember; +/// } +/// +/// LoginModel model = new LoginModel(); +/// Binders.bind(model, form); +/// // user types into userField -- model.user.get() observes the change +/// // model.user.set("alice") -- userField re-renders with the new text +/// ``` +/// +/// Lookup happens through `Component#getComponentForm().findByName(name)` so +/// the form's GUI builder names line up with the model field names. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface Bindable { +} diff --git a/CodenameOne/src/com/codename1/annotations/Column.java b/CodenameOne/src/com/codename1/annotations/Column.java new file mode 100644 index 0000000000..7680f1d33c --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Column.java @@ -0,0 +1,48 @@ +/* + * 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; + +/// Renames or constrains an `@Entity` field's column. Optional -- when absent, +/// the column name defaults to the field name and the type is inferred from +/// the Java field type (`String -> TEXT`, `int/long -> INTEGER`, +/// `float/double -> REAL`, `boolean -> INTEGER`, `byte[] -> BLOB`, +/// `java.util.Date -> INTEGER`). +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface Column { + /// Column name. Defaults to the field name when blank. + String name() default ""; + + /// When false the column gets a `NOT NULL` constraint at table-create time. + boolean nullable() default true; + + /// Optional explicit SQL type. Use the SQLite type names (`TEXT`, + /// `INTEGER`, `REAL`, `BLOB`, `NUMERIC`). When blank the processor infers + /// the type from the field's Java type. + String type() default ""; +} diff --git a/CodenameOne/src/com/codename1/annotations/DbTransient.java b/CodenameOne/src/com/codename1/annotations/DbTransient.java new file mode 100644 index 0000000000..02872edbd2 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/DbTransient.java @@ -0,0 +1,35 @@ +/* + * 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; + +/// Excludes an `@Entity` field from the generated table. Useful for computed +/// fields, caches, or fields shared only with the JSON / XML projection. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface DbTransient { +} diff --git a/CodenameOne/src/com/codename1/annotations/Entity.java b/CodenameOne/src/com/codename1/annotations/Entity.java new file mode 100644 index 0000000000..27bb000ad4 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Entity.java @@ -0,0 +1,60 @@ +/* + * 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 POJO or `PropertyBusinessObject` as a target for the SQLite ORM +/// processor. +/// +/// At build time the Codename One Maven plugin generates a reflection-free +/// `Dao` next to the class with `createTable`, `insert`, `update`, `delete`, +/// `findById`, `findAll`, and `find(where, params)` methods. Application code +/// reaches the generated dao through `com.codename1.orm.EntityManager`: +/// +/// ```java +/// @Entity(table="users") +/// public class User { +/// @Id(autoIncrement=true) public long id; +/// @Column(name="full_name") public String name; +/// public int age; +/// } +/// +/// EntityManager em = EntityManager.open("MyDB"); +/// Dao users = em.dao(User.class); +/// users.createTable(); +/// users.insert(new User()); +/// ``` +/// +/// The dao uses the same prepared-statement protocol as the existing +/// `com.codename1.db.Database`; no `Class.forName` lookups, so the binding +/// survives ParparVM rename / R8 obfuscation in shipped builds. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface Entity { + /// SQL table name. Defaults to the simple class name when blank. + String table() default ""; +} diff --git a/CodenameOne/src/com/codename1/annotations/Id.java b/CodenameOne/src/com/codename1/annotations/Id.java new file mode 100644 index 0000000000..1c88635ab0 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Id.java @@ -0,0 +1,40 @@ +/* + * 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; + +/// Designates the primary-key field of an `@Entity`. Exactly one field per +/// entity must carry `@Id`. The field type may be `long`, `int`, `String`, or +/// a `Property` wrapper of one of those. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface Id { + /// When true (the default) the underlying column is declared + /// `INTEGER PRIMARY KEY AUTOINCREMENT`. Set false when the application + /// assigns its own keys (UUIDs, server-issued ids, ...). + boolean autoIncrement() default true; +} diff --git a/CodenameOne/src/com/codename1/annotations/JsonIgnore.java b/CodenameOne/src/com/codename1/annotations/JsonIgnore.java new file mode 100644 index 0000000000..5e4c4495dc --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/JsonIgnore.java @@ -0,0 +1,35 @@ +/* + * 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; + +/// Excludes a `@Mapped` field from the JSON projection. The same field still +/// participates in XML mapping unless `@XmlTransient` is also present. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface JsonIgnore { +} diff --git a/CodenameOne/src/com/codename1/annotations/JsonProperty.java b/CodenameOne/src/com/codename1/annotations/JsonProperty.java new file mode 100644 index 0000000000..d3e477349d --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/JsonProperty.java @@ -0,0 +1,38 @@ +/* + * 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; + +/// Renames a `@Mapped` field in the JSON projection. The default JSON key is +/// the field name; `@JsonProperty` lets a field map to `snake_case` or any +/// alternative spelling without touching the Java identifier. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface JsonProperty { + /// The JSON key. + String value(); +} diff --git a/CodenameOne/src/com/codename1/annotations/Mapped.java b/CodenameOne/src/com/codename1/annotations/Mapped.java new file mode 100644 index 0000000000..2ae14db34f --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Mapped.java @@ -0,0 +1,64 @@ +/* + * 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 POJO or `PropertyBusinessObject` as a target for the build-time +/// JSON / XML mapping processor. +/// +/// The Codename One Maven plugin scans every `@Mapped` class at build time and +/// emits a reflection-free `Mapper` next to it. Application code reaches the +/// generated mapper through `com.codename1.mapping.Mappers`: +/// +/// ```java +/// @Mapped +/// public class User { +/// @JsonProperty("first_name") +/// public String firstName; +/// public int age; +/// @JsonIgnore +/// transient String passwordHash; +/// } +/// +/// String json = Mappers.toJson(new User()); +/// User u = Mappers.fromJson(json, User.class); +/// ``` +/// +/// Either public fields *or* JavaBeans-style accessors (`getX` / `setX`, +/// `isX` for booleans) are walked. `com.codename1.properties.Property` fields +/// are routed through `Property#get` / `Property#set` so the same class can +/// expose either programming model. Nothing on the runtime side uses +/// reflection or `Class.forName` -- every read and write is a direct symbol +/// reference that the iOS `Class.forName` ban and ParparVM rename pass leave +/// intact. +/// +/// The `@XmlRoot` / `@XmlElement` / `@XmlAttribute` / `@XmlTransient` +/// annotations control the XML projection on top of the same fields. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface Mapped { +} diff --git a/CodenameOne/src/com/codename1/annotations/XmlAttribute.java b/CodenameOne/src/com/codename1/annotations/XmlAttribute.java new file mode 100644 index 0000000000..be4fd5d20c --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/XmlAttribute.java @@ -0,0 +1,38 @@ +/* + * 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; + +/// Promotes a `@Mapped` field to an XML attribute on the parent element rather +/// than a nested child element. Only valid on scalar fields (String, primitive +/// wrappers, enums, `Property` of one of those). +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface XmlAttribute { + /// The attribute name. Defaults to the field name when blank. + String value() default ""; +} diff --git a/CodenameOne/src/com/codename1/annotations/XmlElement.java b/CodenameOne/src/com/codename1/annotations/XmlElement.java new file mode 100644 index 0000000000..4a46986820 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/XmlElement.java @@ -0,0 +1,38 @@ +/* + * 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; + +/// Renames a `@Mapped` field in the XML projection. The default element name +/// is the field name; use `@XmlElement` when the XML schema disagrees with the +/// Java identifier. Apply `@XmlAttribute` instead to lift a value onto the +/// parent element as an attribute. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface XmlElement { + String value(); +} diff --git a/CodenameOne/src/com/codename1/annotations/XmlRoot.java b/CodenameOne/src/com/codename1/annotations/XmlRoot.java new file mode 100644 index 0000000000..f7f3a5e1b1 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/XmlRoot.java @@ -0,0 +1,38 @@ +/* + * 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; + +/// Names the root XML element produced for a `@Mapped` class. When absent the +/// element name defaults to the class's simple name with a lowercase first +/// character (`User` -> `user`). +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface XmlRoot { + /// The root element name. + String value(); +} diff --git a/CodenameOne/src/com/codename1/annotations/XmlTransient.java b/CodenameOne/src/com/codename1/annotations/XmlTransient.java new file mode 100644 index 0000000000..d72ccf240f --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/XmlTransient.java @@ -0,0 +1,35 @@ +/* + * 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; + +/// Excludes a `@Mapped` field from the XML projection. The same field still +/// participates in JSON mapping unless `@JsonIgnore` is also present. +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface XmlTransient { +} diff --git a/CodenameOne/src/com/codename1/binding/BindAttr.java b/CodenameOne/src/com/codename1/binding/BindAttr.java new file mode 100644 index 0000000000..a01533ffad --- /dev/null +++ b/CodenameOne/src/com/codename1/binding/BindAttr.java @@ -0,0 +1,49 @@ +/* + * 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.binding; + +/// Picks which property of a `Component` a `@Bind` field mirrors. Only TEXT +/// and SELECTED participate in two-way bindings; the rest are write-only from +/// model to component. +public enum BindAttr { + /// `getText` / `setText` on `Label`, `TextField`, `TextArea`, `Button`, + /// `SpanLabel`, `SpanButton`. Two-way for editable text inputs. + TEXT, + /// `getUIID` / `setUIID`. Write-only. + UIID, + /// `isVisible` / `setVisible`. Inverted -- `true` hides the component. + /// Write-only. + HIDDEN, + /// `isVisible` / `setVisible`. Write-only. + VISIBLE, + /// `isEnabled` / `setEnabled`. Write-only. + ENABLED, + /// `isSelected` / `setSelected` on `CheckBox`, `RadioButton`, and any + /// other selectable component. Two-way. + SELECTED, + /// `getIcon` -- accepts a String resource name; the binder runs it + /// through `Resources.getGlobalResources().getImage(name)`. Write-only. + ICON_NAME, + /// `getName` / `setName`. Write-only. + NAME +} diff --git a/CodenameOne/src/com/codename1/binding/Binder.java b/CodenameOne/src/com/codename1/binding/Binder.java new file mode 100644 index 0000000000..6bcca40c62 --- /dev/null +++ b/CodenameOne/src/com/codename1/binding/Binder.java @@ -0,0 +1,45 @@ +/* + * 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.binding; + +import com.codename1.ui.Container; + +/// Runtime contract a build-time-generated component binder implements for one +/// `@Bindable` class. +/// +/// Application code rarely references `Binder` directly -- it goes through +/// `Binders#bind`. The interface exists so generated code has a single +/// ServiceLoader-friendly shape and hand-written extensions can sit on the +/// same type. +public interface Binder { + + /// The class this binder handles. + Class type(); + + /// Pushes every `@Bind` field on `model` into the matching component in + /// `container`. Components are located by name via a recursive scan that + /// matches `Component#getName()` against `@Bind(name=...)`. Wires up + /// two-way listeners on editable text fields and toggle buttons so + /// subsequent user input updates the model. + Binding bind(T model, Container container); +} diff --git a/CodenameOne/src/com/codename1/binding/Binders.java b/CodenameOne/src/com/codename1/binding/Binders.java new file mode 100644 index 0000000000..45be4d3778 --- /dev/null +++ b/CodenameOne/src/com/codename1/binding/Binders.java @@ -0,0 +1,258 @@ +/* + * 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.binding; + +import com.codename1.ui.Container; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/// Public entry point for the build-time component binding framework. +/// +/// `@Bindable` classes get a generated binder at build time. Each binder's +/// static initializer self-registers with this registry. The registry stays +/// empty until something triggers each generated class's ``: +/// +/// - **iOS / Android** -- the build server probes the project zip for +/// `cn1app.BinderBootstrap` and splices a +/// `new cn1app.BinderBootstrap();` into the per-build application stub +/// before `Display.init`. +/// - **JavaSE simulator + desktop** -- `JavaSEPort#postInit` calls +/// `Class.forName("cn1app.BinderBootstrap")` so the registry is +/// populated on the same boundary. +/// - **Unit tests / manual init** -- application code can call +/// `Binders.register(...)` directly. +/// +/// ## Two-way bindings and the change-notification contract +/// +/// A binding declared `twoWay = true` flows in both directions: +/// +/// 1. **Component -> model.** The generated binder installs a listener on +/// the editable component (`TextField`, `CheckBox`, etc.). When the +/// user mutates the component, the binder calls the model's setter (or +/// writes the public field directly), and the setter's +/// instrumented exit calls `notifyChanged(this)`. +/// 2. **Model -> component.** Application code that mutates the model +/// through a setter (instrumented by the build-time processor) causes +/// `notifyChanged(this)` to fire, which walks every live binding for +/// that model and pushes the new value into the matching component. +/// +/// The two paths together would loop forever if left to themselves -- the +/// model setter fires a change event, which refreshes the component, which +/// fires its own change event, which calls the model setter again. To +/// break the loop, every framework-initiated mutation runs inside an +/// "update region" guarded by a thread-local flag. While the flag is set, +/// `notifyChanged` is a no-op, and component listeners short-circuit. +/// +/// Concretely: +/// +/// - `Binders.bind(model, container)` enters an update region for the +/// initial model -> component push. +/// - `Binding#refresh()` enters an update region for every subsequent +/// model -> component push. +/// - `Binding#commit()` enters an update region for the component -> model +/// pull. +/// - The generated component listener enters an update region before +/// calling the setter. +/// +/// **Limitation:** when a setter synchronously mutates a *second* bound +/// field (e.g. `setFirstName` also calls `setFullName`), the second field's +/// notification is also suppressed -- the user must call +/// `binding.refresh()` explicitly if they want the second component to +/// catch up. See `Annotation-Component-Binding.asciidoc` for details. +public final class Binders { + + private static final Map> BY_NAME = new HashMap>(); + + /// Active bindings keyed by `model.getClass().getName()` so + /// `notifyChanged(model)` can iterate them in O(matches). Each live + /// binding registers itself in `Binders#registerBinding` from inside + /// `Binder#bind`; the binding removes itself on `disconnect`. + private static final Map> LIVE_BINDINGS = + new HashMap>(); + + private static final ThreadLocal IN_UPDATE = new ThreadLocal(); + + private Binders() { + } + + /// Installs `binder` under `binder.type().getName()`. The generated + /// per-class binder's static initializer calls this; hand-written + /// binders for classes outside the build's annotation scan call it + /// explicitly. + public static void register(Binder binder) { + if (binder == null) { + throw new IllegalArgumentException("binder is null"); + } + BY_NAME.put(binder.type().getName(), binder); + } + + /// Looks up the binder for `type` (by `type.getName()`) or null when + /// none is registered. + @SuppressWarnings("unchecked") + public static Binder get(Class type) { + if (type == null) { + return null; + } + return (Binder) BY_NAME.get(type.getName()); + } + + /// Pushes the model values into the matching components in + /// `container`, wiring up the two-way listeners declared on its + /// `@Bind` fields. Throws `IllegalStateException` when no binder is + /// registered for `model.getClass()`. + @SuppressWarnings("unchecked") + public static Binding bind(T model, Container container) { + if (model == null) { + throw new IllegalArgumentException("model is null"); + } + if (container == null) { + throw new IllegalArgumentException("container is null"); + } + Binder binder = (Binder) BY_NAME.get(model.getClass().getName()); + if (binder == null) { + throw new IllegalStateException("No binder registered for " + + model.getClass().getName() + ". Add @Bindable and ensure the " + + "cn1:process-annotations Mojo ran during build, then re-run -- the " + + "generated BinderBootstrap populates this registry at startup."); + } + return binder.bind(model, container); + } + + // --------------------------------------------------------------- + // Binding-aware change notification + // --------------------------------------------------------------- + + /// Called from the build-time-instrumented setter at every return + /// point. Walks the live bindings for `model.getClass().getName()` + /// and refreshes those whose source is `model`. Short-circuits when + /// the calling thread is already inside an update region, breaking + /// the model -> component -> model loop. + /// + /// Application code rarely calls this directly; the build-time + /// instrumentation wires it into generated setters automatically. + public static void notifyChanged(Object model) { + if (model == null) { + return; + } + if (isInUpdate()) { + return; + } + List bindings = LIVE_BINDINGS.get(model.getClass().getName()); + if (bindings == null || bindings.isEmpty()) { + return; + } + // Snapshot so a refresh that disconnects/reinstalls bindings + // doesn't break the iteration. + NotifiableBinding[] snapshot; + synchronized (LIVE_BINDINGS) { + snapshot = bindings.toArray(new NotifiableBinding[0]); + } + for (NotifiableBinding b : snapshot) { + if (b.matches(model)) { + enterUpdate(); + try { + b.refresh(); + } finally { + exitUpdate(); + } + } + } + } + + /// Generated binders call this to enroll a new live binding in the + /// per-class registry. Call `unregisterBinding` from `disconnect` to + /// remove it. + public static void registerBinding(NotifiableBinding binding) { + if (binding == null) { + return; + } + synchronized (LIVE_BINDINGS) { + List list = LIVE_BINDINGS.get(binding.modelTypeName()); + if (list == null) { + list = new ArrayList(); + LIVE_BINDINGS.put(binding.modelTypeName(), list); + } + list.add(binding); + } + } + + /// Inverse of `registerBinding`. Called from + /// `Binding#disconnect`. + public static void unregisterBinding(NotifiableBinding binding) { + if (binding == null) { + return; + } + synchronized (LIVE_BINDINGS) { + List list = LIVE_BINDINGS.get(binding.modelTypeName()); + if (list == null) { + return; + } + for (Iterator it = list.iterator(); it.hasNext(); ) { + NotifiableBinding b = it.next(); + if (b == binding) { //NOPMD CompareObjectsWithEquals -- identity dedup + it.remove(); + break; + } + } + if (list.isEmpty()) { + LIVE_BINDINGS.remove(binding.modelTypeName()); + } + } + } + + /// Enter an update region. Generated binder code calls this around + /// every framework-initiated mutation -- model->component pushes and + /// component->model pulls -- so the setter notification and the + /// component change listener both short-circuit while we're inside. + public static void enterUpdate() { + int[] depth = IN_UPDATE.get(); + if (depth == null) { + depth = new int[]{0}; + IN_UPDATE.set(depth); + } + depth[0]++; + } + + /// Exit an update region. Call once per `enterUpdate`. + public static void exitUpdate() { + int[] depth = IN_UPDATE.get(); + if (depth == null) { + return; + } + depth[0]--; + if (depth[0] <= 0) { + IN_UPDATE.remove(); + } + } + + /// True while the calling thread is inside a binding update region. + /// Generated component listeners check this and bail out early. + public static boolean isInUpdate() { + int[] depth = IN_UPDATE.get(); + return depth != null && depth[0] > 0; + } +} diff --git a/CodenameOne/src/com/codename1/binding/Binding.java b/CodenameOne/src/com/codename1/binding/Binding.java new file mode 100644 index 0000000000..a1c08b82cb --- /dev/null +++ b/CodenameOne/src/com/codename1/binding/Binding.java @@ -0,0 +1,41 @@ +/* + * 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.binding; + +/// 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. +public interface Binding { + + /// Pushes the current model values into every bound component. + void refresh(); + + /// Pulls current component values back into the model. Useful before + /// validating / submitting a form when none of the bindings is two-way. + void commit(); + + /// Removes every listener the binder added so the form can be garbage- + /// collected without keeping the model alive. + void disconnect(); +} diff --git a/CodenameOne/src/com/codename1/binding/NotifiableBinding.java b/CodenameOne/src/com/codename1/binding/NotifiableBinding.java new file mode 100644 index 0000000000..62a9964dba --- /dev/null +++ b/CodenameOne/src/com/codename1/binding/NotifiableBinding.java @@ -0,0 +1,45 @@ +/* + * 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.binding; + +/// Internal contract every generated `Binding` implementation provides so +/// `Binders#notifyChanged(Object)` can route a model mutation to the +/// bindings that observe it. Application code never references this +/// directly -- the `Binding` interface remains the public handle returned +/// from `Binders.bind`. +public interface NotifiableBinding extends Binding { + + /// `model.getClass().getName()` -- the registry key + /// `Binders.notifyChanged` uses to find the bindings that care about + /// this model class. Stored at bind time so it survives obfuscation: + /// the value is whatever `getName()` returned at bind, which is + /// guaranteed to match `notifyChanged`'s lookup within a single + /// execution. + String modelTypeName(); + + /// True when this binding's source object IS `model` (identity, not + /// equality). The notification fan-out uses identity so multiple + /// independent instances of the same `@Bindable` class don't refresh + /// each other. + boolean matches(Object model); +} diff --git a/CodenameOne/src/com/codename1/binding/package-info.java b/CodenameOne/src/com/codename1/binding/package-info.java new file mode 100644 index 0000000000..46e3b548a3 --- /dev/null +++ b/CodenameOne/src/com/codename1/binding/package-info.java @@ -0,0 +1,26 @@ +/* + * 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. + */ +/// Runtime entry points for the build-time component-binding framework. +/// `@Bindable` classes get a generated `Binder` at build time; application +/// code reaches it via `Binders.bind`. +package com.codename1.binding; diff --git a/CodenameOne/src/com/codename1/mapping/Mapper.java b/CodenameOne/src/com/codename1/mapping/Mapper.java new file mode 100644 index 0000000000..08e83644bd --- /dev/null +++ b/CodenameOne/src/com/codename1/mapping/Mapper.java @@ -0,0 +1,64 @@ +/* + * 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.mapping; + +import com.codename1.xml.Element; + +import java.util.Map; + +/// Runtime contract a build-time-generated mapper implements for one +/// `@Mapped` class. Application code rarely references `Mapper` directly -- +/// it goes through the typed entry points on `Mappers`. The interface exists +/// so generated code has a single ServiceLoader-friendly shape and so +/// extensions (custom converters, plug-in serializers) can sit on the same +/// type. +public interface Mapper { + + /// The class this mapper handles. The instance registry on `Mappers` is + /// keyed on this value; generated mappers never call `Class.forName`. + Class type(); + + /// Serializes `instance` to the JSON map representation that + /// `com.codename1.io.JSONParser` produces in reverse. Sub-objects that + /// have their own registered `Mapper` are emitted as nested maps; scalars + /// (`String`, boxed primitives, `null`) go in as-is. + Map toMap(T instance); + + /// Inverse of `#toMap`. Receives a `Map` as produced by + /// `JSONParser` and populates a fresh `T`. + T fromMap(Map map); + + /// XML root element name (`@XmlRoot.value`, falling back to the class + /// simple name with a lowercase first character). + String xmlRootName(); + + /// Serializes `instance` into the given `Element`. Implementations append + /// child elements / attributes; the root element is supplied by the caller + /// so the same mapper can also be invoked from inside a parent element. + void writeXml(T instance, Element root); + + /// Inverse of `#writeXml`. Receives an `Element` (the root that + /// `Mappers.toXml` produced or that a parser delivered) and populates a + /// fresh `T`. + T readXml(Element root); +} diff --git a/CodenameOne/src/com/codename1/mapping/Mappers.java b/CodenameOne/src/com/codename1/mapping/Mappers.java new file mode 100644 index 0000000000..10a69cc876 --- /dev/null +++ b/CodenameOne/src/com/codename1/mapping/Mappers.java @@ -0,0 +1,277 @@ +/* + * 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.mapping; + +import com.codename1.io.JSONParser; +import com.codename1.util.regex.StringReader; +import com.codename1.xml.Element; +import com.codename1.xml.XMLParser; +import com.codename1.xml.XMLWriter; + +import java.io.IOException; +import java.io.Reader; +import java.util.HashMap; +import java.util.Map; + +/// Public entry point for the build-time JSON / XML mapping framework. +/// +/// `@Mapped` classes get a generated mapper at build time. The generated +/// mapper's static initializer self-registers with this registry. The +/// registry stays empty until something triggers each generated class's +/// ``: +/// +/// - **iOS / Android** -- the build server probes the project zip for +/// `cn1app.MapperBootstrap`, and when present splices a +/// `new cn1app.MapperBootstrap();` into the per-build application stub +/// before `Display.init`. That constructor references every generated +/// mapper, triggering their static initializers. +/// - **JavaSE simulator + desktop** -- `JavaSEPort#postInit` calls +/// `Class.forName("cn1app.MapperBootstrap")` so the registry is populated +/// on the same boundary. Classloading is the legitimate path here: +/// JavaSE runs unobfuscated. +/// - **Unit tests / manual init** -- application code can call +/// `Mappers.register(...)` directly to install a hand-written mapper for +/// a class the build can't annotate. +/// +/// Typical use after init: +/// +/// ```java +/// String json = Mappers.toJson(user); +/// User u = Mappers.fromJson(json, User.class); +/// +/// String xml = Mappers.toXml(user); +/// User u = Mappers.fromXml(xml, User.class); +/// ``` +/// +/// The registry is keyed on `getClass().getName()` so it survives ParparVM +/// rename and R8 obfuscation: both the registration site and the lookup +/// site see the same renamed name within a single execution. The map keys +/// are never persisted, so the renaming has no observable effect on +/// behavior. +public final class Mappers { + + private static final Map> BY_NAME = new HashMap>(); + + private Mappers() { + } + + /// Installs `mapper` under `mapper.type().getName()`. The generated + /// per-class mapper's static initializer calls this; hand-written + /// mappers for classes outside the build's annotation scan call it + /// explicitly. + public static void register(Mapper mapper) { + if (mapper == null) { + throw new IllegalArgumentException("mapper is null"); + } + BY_NAME.put(mapper.type().getName(), mapper); + } + + /// Looks up the mapper for `type` (by `type.getName()`) or null when + /// none is registered. + @SuppressWarnings("unchecked") + public static Mapper get(Class type) { + if (type == null) { + return null; + } + return (Mapper) BY_NAME.get(type.getName()); + } + + /// Serializes `instance` to JSON. Throws `IllegalStateException` when + /// no mapper is registered for its concrete class; that always points + /// at a missing `@Mapped` annotation or a build that ran without the + /// process-annotations Mojo. + public static String toJson(Object instance) { + if (instance == null) { + return "null"; + } + @SuppressWarnings("unchecked") + Mapper m = (Mapper) BY_NAME.get(instance.getClass().getName()); + if (m == null) { + throw missing(instance.getClass()); + } + Map root = m.toMap(instance); + StringBuilder sb = new StringBuilder(); + writeJson(sb, root); + return sb.toString(); + } + + /// Inverse of `#toJson`. Parses the JSON text and hands the resulting + /// Map to the registered mapper. + public static T fromJson(String json, Class type) { + if (json == null) { + return null; + } + Mapper m = get(type); + if (m == null) { + throw missing(type); + } + try { + JSONParser p = new JSONParser(); + Map root = p.parseJSON(new StringReader(json)); + return m.fromMap(root); + } catch (IOException ioe) { + throw new RuntimeException("Mappers.fromJson failed: " + ioe.getMessage(), ioe); + } + } + + /// Parses JSON read from a `Reader` (file, network response, ...) without + /// fully buffering it into a String first. + public static T fromJson(Reader json, Class type) { + if (json == null) { + return null; + } + Mapper m = get(type); + if (m == null) { + throw missing(type); + } + try { + JSONParser p = new JSONParser(); + Map root = p.parseJSON(json); + return m.fromMap(root); + } catch (IOException ioe) { + throw new RuntimeException("Mappers.fromJson failed: " + ioe.getMessage(), ioe); + } + } + + /// Serializes `instance` to XML. + public static String toXml(Object instance) { + if (instance == null) { + return ""; + } + @SuppressWarnings("unchecked") + Mapper m = (Mapper) BY_NAME.get(instance.getClass().getName()); + if (m == null) { + throw missing(instance.getClass()); + } + Element root = new Element(m.xmlRootName()); + m.writeXml(instance, root); + return new XMLWriter(true).toXML(root); + } + + /// Inverse of `#toXml`. Parses the XML text and hands the resulting + /// Element to the registered mapper. + public static T fromXml(String xml, Class type) { + if (xml == null) { + return null; + } + Mapper m = get(type); + if (m == null) { + throw missing(type); + } + XMLParser p = new XMLParser(); + Element root = p.parse(new StringReader(xml)); + return m.readXml(root); + } + + /// Parses XML read from a `Reader` without fully buffering it first. + public static T fromXml(Reader xml, Class type) { + if (xml == null) { + return null; + } + Mapper m = get(type); + if (m == null) { + throw missing(type); + } + XMLParser p = new XMLParser(); + Element root = p.parse(xml); + return m.readXml(root); + } + + private static IllegalStateException missing(Class type) { + return new IllegalStateException("No mapper registered for " + + type.getName() + ". Add @Mapped and ensure the cn1:process-annotations " + + "Mojo ran during build, then re-run -- the generated MapperBootstrap " + + "populates this registry at startup."); + } + + // --------------------------------------------------------------- + // Tiny JSON writer + // --------------------------------------------------------------- + + static void writeJson(StringBuilder sb, Object value) { + if (value == null) { + sb.append("null"); + return; + } + if (value instanceof Map) { + sb.append('{'); + boolean first = true; + Map map = (Map) value; + for (Map.Entry e : map.entrySet()) { + if (!first) { + sb.append(','); + } + first = false; + writeJsonString(sb, String.valueOf(e.getKey())); + sb.append(':'); + writeJson(sb, e.getValue()); + } + sb.append('}'); + return; + } + if (value instanceof java.util.Collection) { + sb.append('['); + boolean first = true; + for (Object item : (java.util.Collection) value) { + if (!first) { + sb.append(','); + } + first = false; + writeJson(sb, item); + } + sb.append(']'); + return; + } + if (value instanceof Number || value instanceof Boolean) { + sb.append(value.toString()); + return; + } + writeJsonString(sb, value.toString()); + } + + private static void writeJsonString(StringBuilder sb, String s) { + sb.append('"'); + int len = s.length(); + for (int i = 0; i < len; i++) { + char c = s.charAt(i); + switch (c) { + case '"': sb.append("\\\""); break; + case '\\': sb.append("\\\\"); break; + case '\b': sb.append("\\b"); break; + case '\f': sb.append("\\f"); break; + case '\n': sb.append("\\n"); break; + case '\r': sb.append("\\r"); break; + case '\t': sb.append("\\t"); break; + default: + if (c < 0x20) { + sb.append("\\u00"); + sb.append("0123456789abcdef".charAt((c >> 4) & 0xF)); + sb.append("0123456789abcdef".charAt(c & 0xF)); + } else { + sb.append(c); + } + } + } + sb.append('"'); + } +} diff --git a/CodenameOne/src/com/codename1/mapping/package-info.java b/CodenameOne/src/com/codename1/mapping/package-info.java new file mode 100644 index 0000000000..a654e34295 --- /dev/null +++ b/CodenameOne/src/com/codename1/mapping/package-info.java @@ -0,0 +1,27 @@ +/* + * 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. + */ +/// Runtime entry points for the build-time JSON / XML mapping framework. +/// `@Mapped` classes get a generated `Mapper` at build time; application code +/// reaches it via `Mappers.toJson` / `Mappers.fromJson` / `Mappers.toXml` / +/// `Mappers.fromXml`. +package com.codename1.mapping; diff --git a/CodenameOne/src/com/codename1/orm/Dao.java b/CodenameOne/src/com/codename1/orm/Dao.java new file mode 100644 index 0000000000..586f092f99 --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/Dao.java @@ -0,0 +1,82 @@ +/* + * 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.orm; + +import com.codename1.db.Database; + +import java.io.IOException; +import java.util.List; + +/// Runtime contract a build-time-generated `@Entity` data access object +/// implements. Application code rarely references `Dao` by name -- the typed +/// dao is reached through `EntityManager.dao(EntityClass.class)`. +/// +/// All methods throw `IOException` for the same reasons `com.codename1.db` +/// does: the underlying SQLite driver propagates IO failures, locking +/// failures, and constraint violations through `IOException`. +public interface Dao { + + /// The entity class this dao handles. + Class type(); + + /// The SQL table name. Defaults to the simple class name; an explicit + /// `@Entity(table="...")` wins. + String tableName(); + + /// Connects this dao to a `Database` instance. Called by + /// `EntityManager.dao(Class)`; the same dao is reused for the lifetime of + /// the entity manager. + void attach(Database db); + + /// `CREATE TABLE IF NOT EXISTS ...`. Idempotent. + void createTable() throws IOException; + + /// `DROP TABLE IF EXISTS ...`. + void dropTable() throws IOException; + + /// `INSERT INTO ...`. When the entity has an auto-increment `@Id`, the + /// generated id is written back into the instance via + /// `SELECT last_insert_rowid()`. + void insert(T entity) throws IOException; + + /// `UPDATE ... WHERE id = ?`. + void update(T entity) throws IOException; + + /// `DELETE ... WHERE id = ?`. + void delete(T entity) throws IOException; + + /// `SELECT ... WHERE id = ?`. Returns `null` when no row matches. + T findById(Object id) throws IOException; + + /// `SELECT * FROM ...`. Returns every row mapped to an instance of the + /// entity class. + List findAll() throws IOException; + + /// Free-form WHERE clause; `where` is appended verbatim after `WHERE` + /// and `params` fills the `?` placeholders. + /// + /// ```java + /// users.find("age > ? AND city = ?", 18, "Berlin"); + /// ``` + List find(String where, Object... params) throws IOException; +} diff --git a/CodenameOne/src/com/codename1/orm/EntityManager.java b/CodenameOne/src/com/codename1/orm/EntityManager.java new file mode 100644 index 0000000000..38f29e29cf --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/EntityManager.java @@ -0,0 +1,152 @@ +/* + * 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.orm; + +import com.codename1.db.Database; +import com.codename1.ui.Display; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/// Public entry point for the build-time SQLite ORM. +/// +/// `@Entity` classes get a generated dao at build time. Each dao's static +/// initializer self-registers with this registry. The registry stays empty +/// until something triggers each generated class's ``: +/// +/// - **iOS / Android** -- the build server probes the project zip for +/// `cn1app.DaoBootstrap` and splices a `new cn1app.DaoBootstrap();` into +/// the per-build application stub before `Display.init`. +/// - **JavaSE simulator + desktop** -- `JavaSEPort#postInit` calls +/// `Class.forName("cn1app.DaoBootstrap")` so the registry is populated +/// on the same boundary. +/// - **Unit tests / manual init** -- application code can call +/// `EntityManager.registerDao(...)` directly. +/// +/// ```java +/// EntityManager em = EntityManager.open("MyApp.db"); +/// Dao users = em.dao(User.class); +/// users.createTable(); +/// users.insert(new User("alice")); +/// for (User u : users.findAll()) { ... } +/// em.close(); +/// ``` +/// +/// The registry is keyed on `getClass().getName()` so it survives ParparVM +/// rename and R8 obfuscation: both the registration site and the lookup +/// site see the same renamed name within a single execution. +public final class EntityManager { + + private static final Map> BY_NAME = new HashMap>(); + + private final Database db; + private boolean closed; + + private EntityManager(Database db) { + this.db = db; + } + + /// Opens (or creates) the SQLite file `databaseName` via + /// `Display.openOrCreate`. Throws `IOException` when the platform + /// refuses to provide a SQLite database. + public static EntityManager open(String databaseName) throws IOException { + Database db = Display.getInstance().openOrCreate(databaseName); + if (db == null) { + throw new IOException("Platform does not support SQLite: " + + Display.getInstance().getPlatformName()); + } + return open(db); + } + + /// Wraps an existing `Database` (e.g. one opened with a custom path) + /// in an `EntityManager`. The caller retains ownership; `close()` + /// closes the database. + public static EntityManager open(Database db) { + if (db == null) { + throw new IllegalArgumentException("database is null"); + } + return new EntityManager(db); + } + + /// Installs `dao` under `dao.type().getName()`. The generated + /// per-class dao's static initializer calls this; hand-written daos + /// for classes outside the build's annotation scan call it + /// explicitly. + public static void registerDao(Dao dao) { + if (dao == null) { + throw new IllegalArgumentException("dao is null"); + } + BY_NAME.put(dao.type().getName(), dao); + } + + /// Returns the dao for `entityClass`, freshly attached to this + /// entity manager's `Database`. Throws `IllegalStateException` when + /// no dao was generated for the class -- typically because `@Entity` + /// is missing or the process-annotations Mojo did not run. + @SuppressWarnings("unchecked") + public Dao dao(Class entityClass) { + if (entityClass == null) { + throw new IllegalArgumentException("entityClass is null"); + } + Dao d = (Dao) BY_NAME.get(entityClass.getName()); + if (d == null) { + throw new IllegalStateException("No dao registered for " + + entityClass.getName() + ". Add @Entity and ensure the " + + "cn1:process-annotations Mojo ran during build, then re-run -- " + + "the generated DaoBootstrap populates this registry at startup."); + } + d.attach(db); + return d; + } + + /// The underlying `Database`. Use it for raw SQL when the dao surface + /// isn't enough. + public Database database() { + return db; + } + + /// Begin a transaction. Equivalent to `database().beginTransaction()`. + public void beginTransaction() throws IOException { + db.beginTransaction(); + } + + /// Commit the current transaction. + public void commitTransaction() throws IOException { + db.commitTransaction(); + } + + /// Roll back the current transaction. + public void rollbackTransaction() throws IOException { + db.rollbackTransaction(); + } + + /// Close the underlying database. Idempotent. + public void close() throws IOException { + if (closed) { + return; + } + closed = true; + db.close(); + } +} diff --git a/CodenameOne/src/com/codename1/orm/package-info.java b/CodenameOne/src/com/codename1/orm/package-info.java new file mode 100644 index 0000000000..e6e0d1918e --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/package-info.java @@ -0,0 +1,26 @@ +/* + * 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. + */ +/// Runtime entry points for the build-time SQLite ORM. `@Entity` classes get +/// a generated `Dao` at build time; application code reaches it through +/// `EntityManager.open(dbName).dao(EntityClass.class)`. +package com.codename1.orm; diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 171e3b061c..765cdc3748 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -7089,6 +7089,24 @@ public void postInit() { } catch (Throwable t) { com.codename1.io.Log.e(t); } + // Install build-time annotation-framework bootstraps. Each + // bootstrap class lives at a fixed FQN under cn1app.* and is + // generated only when the project actually uses the + // corresponding annotations -- ClassNotFoundException is the + // "feature not used" signal. JavaSE is the legitimate place + // for Class.forName here (matches the @Route pattern above). + for (String bootstrap : new String[] { + "cn1app.MapperBootstrap", + "cn1app.BinderBootstrap", + "cn1app.DaoBootstrap"}) { + try { + Class.forName(bootstrap).newInstance(); + } catch (ClassNotFoundException ignored) { + // Feature not used by this project. + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } } protected void sizeChanged(int w, int h) { diff --git a/docs/developer-guide/Annotation-Component-Binding.asciidoc b/docs/developer-guide/Annotation-Component-Binding.asciidoc new file mode 100644 index 0000000000..eb7d0e481f --- /dev/null +++ b/docs/developer-guide/Annotation-Component-Binding.asciidoc @@ -0,0 +1,232 @@ +== Component Binding + +[[annotation-binding-section,Annotation Binding Section]] +The component binding framework wires a model field to the matching +component in a `Form` or `Container` by name. The Maven plugin generates +a `Cn1Binder` per `@Bindable` class at build time, so the +wiring happens through direct symbol references -- no reflection, no +listener bookkeeping in application code. + +It's a thin alternative to the imperative `UiBinding` API +(`com.codename1.properties.UiBinding`). Both can be used together. + +=== Annotate the model + +[source,java] +---- +package com.example; + +import com.codename1.annotations.*; +import com.codename1.binding.BindAttr; +import com.codename1.properties.Property; + +@Bindable +public class LoginModel { + + @Bind(name = "userField", attr = BindAttr.TEXT) + private String user; + public String getUser() { return user; } + public void setUser(String u) { this.user = u; } // <1> + + @Bind(name = "rememberMe", attr = BindAttr.SELECTED) + public boolean remember; // <2> + + @Bind(name = "banner", attr = BindAttr.UIID, twoWay = false) + public String bannerStyle; + + @Bind(name = "fullName", + attr = BindAttr.TEXT, + getter = "computeFullName", + setter = "applyFullName") // <3> + private String fullName; + public String computeFullName() { return fullName.toUpperCase(); } + public void applyFullName(String f){ this.fullName = f.trim(); } +} +---- +<1> JavaBeans `getUser` / `setUser` are detected automatically when the + field is private. The processor instruments `setUser` to fire a + change notification for two-way bindings. +<2> Public field still works -- direct field access falls back when no + accessor matches the JavaBeans convention. +<3> Explicit `getter` / `setter` override the JavaBeans search. Use + when the convention doesn't apply (renamed accessors, transforms, + fluent setters). + +Every `@Bind(name=...)` is looked up against `Component#getName()`, +walking the container's children recursively until a match is found. +The default lookup matches GUI-builder names exactly. + +==== Attribute kinds + +[cols="1,3", options="header"] +|=== +| `BindAttr` | Mirrors +| `TEXT` | `getText` / `setText` on `Label`, `Button`, + `TextField`, `TextArea`, `SpanLabel`, `SpanButton`. + Two-way for `TextArea` / `TextField`. +| `UIID` | `getUIID` / `setUIID`. One-way. +| `VISIBLE` | `isVisible` / `setVisible`. One-way. +| `HIDDEN` | `isHidden` / `setHidden`. One-way. +| `ENABLED` | `isEnabled` / `setEnabled`. One-way. +| `SELECTED` | `isSelected` / `setSelected` on `CheckBox`, + `RadioButton`. Two-way. +| `ICON_NAME` | Calls `Resources.getGlobalResources().getImage(name)` + and writes the result via `Label#setIcon`. One-way. +| `NAME` | `setName`. One-way. +|=== + +=== Bind at runtime + +[source,java] +---- +import com.codename1.binding.Binders; +import com.codename1.binding.Binding; + +Form f = (Form) Resources.getGlobalResources().getForm("LoginForm"); +LoginModel model = new LoginModel(); +Binding b = Binders.bind(model, f); + +// The two-way bindings push every keystroke / toggle back into the model. +// Mutate the model through the setter and the bound component refreshes +// automatically: +model.setUser("alice"); + +// Or pull pending edits into the model before submit: +b.commit(); + +// On form dispose: +b.disconnect(); // remove every installed listener +---- + +`Binding` is the handle the binder returns: + +[cols="1,3", options="header"] +|=== +| Method | Purpose +| `refresh()` | Push the current model values into every bound + component. Use after mutating the model outside the + form, or to re-sync after `commit()`. +| `commit()` | Pull current component values back into the model. + Useful before validating / submitting when none of + the bindings is two-way. +| `disconnect()`| Remove every listener the binder installed and + unregister the binding from the change-notification + fan-out. +|=== + +=== Two-way bindings and the change-notification contract + +[[annotation-binding-loop,Loop guard]] +A binding declared `twoWay = true` flows in both directions: + +. **Component -> model.** The generated binder installs a listener on + the editable component (`TextField`, `CheckBox`, etc.). When the user + mutates the component, the binder calls the model's setter (or + assigns the public field). The setter's instrumented exit calls + `Binders.notifyChanged(this)`. +. **Model -> component.** Application code that mutates the model + through a setter causes `Binders.notifyChanged(this)` to fire, which + walks every live binding for that model and pushes the new value + into the matching component. + +The two paths together would loop forever if left alone -- the model +setter fires a change event, which refreshes the component, which +fires its own change event, which calls the model setter again, ... +To break the loop, every framework-initiated mutation runs inside an +**update region** guarded by a thread-local depth counter. While the +counter is positive, `notifyChanged` is a no-op and component +listeners short-circuit. + +Concretely: + +* `Binders.bind(model, container)` enters an update region for the + initial model -> component push. +* `Binding#refresh()` enters an update region for every subsequent + model -> component push. +* `Binding#commit()` enters an update region for the component -> + model pull. +* The generated component listener enters an update region before + calling the setter. + +==== Setter instrumentation + +For every two-way `@Bind` field whose write accessor is a method +(whether you wrote `@Bind(setter="setName")` or the processor detected +`setName(String)` via the JavaBeans convention), the +`cn1:process-annotations` Mojo reads the original `.class` file with +ASM and inserts the equivalent of: + +[source,java] +---- +public void setName(String name) { + this.name = name; + com.codename1.binding.Binders.notifyChanged(this); // injected +} +---- + +The injection lands before every `return` point of the setter. The +instrumented setter is written back to `target/classes`, replacing the +original. Application code that calls `model.setName(...)` -- from any +thread, any code path -- now triggers the binding fan-out +automatically. + +==== Limitation -- cross-field setter chains + +When a setter synchronously mutates a *second* bound field +(`setFirstName` also calls `setFullName`, for example), the second +field's `notifyChanged` lands inside the same update region as the +first and is suppressed. The bound component for `fullName` won't +catch up until something exits the region and a separate event drives +the refresh. If you need the cross-field update to propagate, call +`binding.refresh()` explicitly after the setter chain completes, or +restructure the model so the cross-field mutation happens outside the +setter's call frame -- for example via a `Display.callSerially`. + +=== POJOs versus Property objects + +A `String`, `int`, or `boolean` field is read and written through the +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 + +The annotation processor 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 + the annotation must name an explicit `getter`. +* A two-way `@Bind` field on a `TEXT` or `SELECTED` attr has no + writable accessor. Set `twoWay=false` to keep the binding one-way, + or add a setter / explicit `setter=`. +* `@Bind(name=...)` is empty. +* The field's static type isn't supported (raw collections, opaque + references that aren't `@Bindable` themselves, ...). + +Errors are accumulated so a single build run reports every offending +field at once. + +=== How the plumbing works + +`cn1:process-annotations` writes one +`Cn1Binder` per `@Bindable` class in the source class's +package, plus a single `cn1app.BinderBootstrap` whose constructor calls +`UserCn1Binder.register()`, `LoginModelCn1Binder.register()`, ... for +every accepted `@Bindable` class. At app start: + +* On **iOS / Android** the build server probes the project zip for + `cn1app/BinderBootstrap.class` and splices + `new cn1app.BinderBootstrap();` into the per-build application stub + before `Display.init`. ParparVM rename and R8 obfuscation rewrite + the direct symbol reference together with the generated class so + the binding still resolves after the pass. +* On **JavaSE** `JavaSEPort#postInit` loads the bootstrap via + `Class.forName("cn1app.BinderBootstrap")` -- the unobfuscated + class-loading path. + +Projects with no `@Bindable` classes produce no bootstrap; the build +server probe falls through and the registry stays empty. + +The runtime registry is keyed on `Class#getName()`; obfuscation renames +the call sites and the registered keys together within a single +execution. diff --git a/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc b/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc new file mode 100644 index 0000000000..0d12c3da48 --- /dev/null +++ b/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc @@ -0,0 +1,195 @@ +== JSON / XML Object Mapping + +[[annotation-mapping-section,Annotation Mapping Section]] +Codename One includes a build-time POJO mapping framework that turns a +plain Java class -- or a `PropertyBusinessObject` -- into JSON or XML +without any runtime reflection. The Maven plugin scans the project's +compiled bytecode at build time, generates a typed `Mapper` next to each +`@Mapped` class, and ships it alongside the source. The runtime entry +point is two methods on `com.codename1.mapping.Mappers`. + +The framework sits next to the existing `JSONParser`, `XMLParser` and +`PropertyIndex.toJSON` / `fromJSON` helpers -- it doesn't replace them. +Use the imperative APIs when you want to read a foreign JSON shape +without modelling it; use the annotation framework when you have a model +class and want a one-liner round-trip. + +=== Annotate the model + +Apply `@Mapped` to the class. The processor accepts POJOs with public +fields, `PropertyBusinessObject`-style classes with `Property` fields, +or any mix: + +[source,java] +---- +package com.example; + +import com.codename1.annotations.*; +import com.codename1.properties.*; + +@Mapped +@XmlRoot("user") +public class User { + + @JsonProperty("first_name") + @XmlElement("first") + public String firstName; + + public int age; + + // Renders as in XML; omitted from JSON. + @XmlAttribute + @JsonIgnore + public String role; + + public User() { } // <1> +} +---- +<1> Every `@Mapped` class must declare a public no-arg constructor -- + the generated mapper calls it from `fromJson` / `fromXml`. + +For `PropertyBusinessObject`-style classes the same annotations work on +`Property` fields. The processor reads through `Property#get` / +`Property#set` so subscribers (`addChangeListener`) still see the +mutation: + +[source,java] +---- +@Mapped +public class Item implements PropertyBusinessObject { + public final Property name = new Property<>("name"); + public final Property qty = new Property<>("qty"); + + private final PropertyIndex idx = + new PropertyIndex(this, "Item", name, qty); + + public PropertyIndex getPropertyIndex() { return idx; } + + public Item() { } +} +---- + +==== Field-level annotations + +[cols="1,3", options="header"] +|=== +| Annotation | Purpose + +| `@JsonProperty("k")` | Rename the JSON key. Default: the field name. +| `@JsonIgnore` | Omit the field from JSON. +| `@XmlElement("name")` | Rename the XML element. Default: the field name. +| `@XmlAttribute("name")`| Lift the field into an XML attribute rather + than a nested child element. Only valid for + scalar or `Property` fields. +| `@XmlTransient` | Omit the field from XML. +|=== + +==== Supported field types + +[cols="1,4", options="header"] +|=== +| Kind | Detail +| Scalars | `String`, all primitives, boxed primitives, + `java.util.Date`, `byte[]` (base64 in JSON / + XML). +| `Property` | `T` must be a scalar or another `@Mapped` type. +| `ListProperty` | Same rules as `Property`. +| `java.util.List` | Read from the field's generic signature. +| Nested `@Mapped` ref. | Recursive serialization through the registered + nested mapper. +|=== + +=== Round-trip + +[source,java] +---- +import com.codename1.mapping.Mappers; + +User u = new User(); +u.firstName = "Alice"; +u.age = 30; + +String json = Mappers.toJson(u); +// -> {"first_name":"Alice","age":30} + +User restored = Mappers.fromJson(json, User.class); + +String xml = Mappers.toXml(u); +// -> Alice30 + +User fromXml = Mappers.fromXml(xml, User.class); +---- + +`Mappers.fromJson` and `Mappers.fromXml` both accept either a `String` +or a `java.io.Reader`, so streamed responses (network, file, resource) +don't need to be fully buffered first. + +=== How the plumbing works + +At build time: + +. `cn1:process-annotations` (PROCESS_CLASSES phase) scans `target/classes` + for every class carrying `@Mapped`. +. The processor validates each one (concrete, public no-arg constructor, + supported field types) and fails the build on the first offender -- + every offending class shows up in the same error report. +. A `Cn1Mapper` class is generated for every `@Mapped` type + in the **same package as the source class** (e.g. + `com.example.UserCn1Mapper` next to `com.example.User`). Each + generated mapper exposes a public `static register()` hook. +. A single `cn1app.MapperBootstrap` is generated whose constructor calls + `UserCn1Mapper.register()`, `ItemCn1Mapper.register()`, ... for every + accepted `@Mapped` class. + +At app start: + +* On **iOS / Android**, the build server probes the project zip for + `cn1app/MapperBootstrap.class`. When found it splices + `new cn1app.MapperBootstrap();` into the per-build application stub + right before `Display.init`. ParparVM rename and R8 obfuscation + rewrite the call site and the generated class together, so the + direct symbol reference stays valid after the pass. +* On the **JavaSE simulator and desktop run**, `JavaSEPort#postInit` + loads the bootstrap via `Class.forName("cn1app.MapperBootstrap")`. + Class-loading is the legitimate path here -- JavaSE runs unobfuscated + and the same `Class.forName` pattern is used by the @Route dispatcher. + +Projects that ship no `@Mapped` classes produce no bootstrap, the build +server probe falls through, and `JavaSEPort` catches the +`ClassNotFoundException` -- the registry stays empty and `Mappers.toJson` +throws a clear "no mapper registered" error. + +The runtime registry is keyed on `Class#getName()`. The map keys survive +obfuscation because both the registration site and the lookup site see +the same renamed name within a single execution -- the keys are never +persisted across builds. + +=== Custom mappers + +Sometimes a class lives in a third-party JAR the build can't annotate. +Hand-write a `Mapper` and register it at startup: + +[source,java] +---- +Mappers.register(new Mapper() { + public Class type() { return UUID.class; } + public Map toMap(UUID u) { + Map m = new LinkedHashMap<>(); + m.put("uuid", u.toString()); + return m; + } + public UUID fromMap(Map m) { + return UUID.fromString((String) m.get("uuid")); + } + public String xmlRootName() { return "uuid"; } + public void writeXml(UUID u, Element root) { + root.addChild(new Element(u.toString(), true)); + } + public UUID readXml(Element root) { + return UUID.fromString(textOf(root)); + } +}); +---- + +Hand-written mappers take precedence over generated ones for the same +class. diff --git a/docs/developer-guide/Annotation-SQLite-ORM.asciidoc b/docs/developer-guide/Annotation-SQLite-ORM.asciidoc new file mode 100644 index 0000000000..d1e48ca154 --- /dev/null +++ b/docs/developer-guide/Annotation-SQLite-ORM.asciidoc @@ -0,0 +1,167 @@ +== SQLite ORM + +[[annotation-orm-section,Annotation ORM Section]] +The SQLite ORM framework turns a plain Java class into a typed data +access object at build time. The Maven plugin reads `@Entity` / +`@Id` / `@Column` annotations from the project's compiled bytecode, +generates one `Cn1Dao` per entity in the source class's +package, and hands the dao to `com.codename1.orm.EntityManager` for +typed retrieval. The dao issues prepared statements through +`com.codename1.db.Database`, the same surface every cn1 SQLite port +exposes. + +The framework is a JPA-inspired, simplified alternative to the existing +imperative `SQLMap`. It doesn't replace `SQLMap`; both can be used side +by side. + +=== Annotate the entity + +[source,java] +---- +package com.example; + +import com.codename1.annotations.*; + +@Entity(table = "users") +public class User { + + @Id(autoIncrement = true) + public long id; + + @Column(name = "full_name", nullable = false) + public String name; + + public int age; + + public java.util.Date createdAt; + + @DbTransient + public String cacheKey; // <1> + + public User() { } +} +---- +<1> Excluded from the generated table. + +`Property` fields work the same way; the dao calls `Property#get` and +`Property#set` for read and write. + +==== Field-level annotations + +[cols="1,3", options="header"] +|=== +| Annotation | Purpose +| `@Id` | Marks the primary-key field. Exactly one per entity. + `autoIncrement=true` (the default) emits + `INTEGER PRIMARY KEY AUTOINCREMENT` and back-fills + the field after `insert`. +| `@Column(name)` | Rename the column. Default: the field name. +| `@Column(type)` | Override the SQL type. Default: inferred from the + Java type (`String -> TEXT`, `int/long -> INTEGER`, + `float/double -> REAL`, `boolean -> INTEGER`, + `java.util.Date -> INTEGER` (epoch millis), + `byte[] -> BLOB`). +| `@Column(nullable=false)`| Adds `NOT NULL` to the column declaration. +| `@DbTransient` | Excludes the field from the table. +|=== + +=== Use the dao + +[source,java] +---- +import com.codename1.orm.EntityManager; +import com.codename1.orm.Dao; + +EntityManager em = EntityManager.open("MyApp.db"); +Dao users = em.dao(User.class); + +users.createTable(); // <1> + +User u = new User(); +u.name = "Alice"; +u.age = 30; +users.insert(u); // <2> + +User found = users.findById(u.id); // <3> + +for (User x : users.find("age > ?", 18)) { // <4> + // ... +} + +u.age = 31; +users.update(u); // <5> +users.delete(u); +em.close(); +---- +<1> Idempotent -- `CREATE TABLE IF NOT EXISTS`. +<2> The autoincrement `id` is filled in via + `SELECT last_insert_rowid()` after the insert. +<3> `findById` returns `null` when no row matches. +<4> Free-form `WHERE` clause; positional `?` parameters bind in order. +<5> `update` and `delete` key off the entity's `@Id` value. + +`EntityManager` is a thin façade over `Database`. The underlying +connection is reachable through `em.database()` for raw SQL when the +dao surface isn't enough. Transactions: + +[source,java] +---- +em.beginTransaction(); +try { + users.insert(u1); + users.insert(u2); + em.commitTransaction(); +} catch (IOException e) { + em.rollbackTransaction(); + throw e; +} +---- + +=== Relationships + +Relationship annotations (`@OneToMany`, `@ManyToOne`, ...) aren't yet +supported. An entity field that references another entity or a +`java.util.List` of entities fails the build with a clear error -- +mark such fields `@DbTransient` and persist the foreign key (id) as a +separate scalar column for now. + +=== Validation + +The annotation processor fails the build when: + +* `@Entity` lands on an abstract class or interface. +* The class has no public no-arg constructor. +* No field carries `@Id`, or more than one does. +* A field's static type isn't supported (relationships, unsupported + reference types). + +Errors are accumulated so the first build run reports every offending +entity at once. + +=== How the plumbing works + +`cn1:process-annotations` writes one +`Cn1Dao` per `@Entity` in the source class's package +(`com.example.UserCn1Dao` next to `com.example.User`), plus a single +`cn1app.DaoBootstrap` whose constructor calls +`UserCn1Dao.register()`, `OrderCn1Dao.register()`, ... for every +accepted `@Entity` class. At app start: + +* On **iOS / Android** the build server probes the project zip for + `cn1app/DaoBootstrap.class` and splices + `new cn1app.DaoBootstrap();` into the per-build application stub + before `Display.init`. ParparVM rename and R8 obfuscation rewrite + the call site and the generated dao together, so the direct symbol + reference stays valid after the pass. +* On the **JavaSE simulator and desktop run** `JavaSEPort#postInit` + loads the bootstrap via `Class.forName("cn1app.DaoBootstrap")`. + Class-loading is the legitimate path here -- JavaSE runs + unobfuscated. + +Projects with no `@Entity` classes produce no bootstrap; the build +server probe falls through and the registry stays empty. + +The runtime registry is keyed on `Class#getName()`; obfuscation renames +the call sites and the registered keys together within a single +execution. The keys are never persisted across builds, so the renaming +has no observable effect on behavior. diff --git a/docs/developer-guide/developer-guide.asciidoc b/docs/developer-guide/developer-guide.asciidoc index 1bb480d2a9..22a3fdab94 100644 --- a/docs/developer-guide/developer-guide.asciidoc +++ b/docs/developer-guide/developer-guide.asciidoc @@ -90,6 +90,12 @@ include::Authentication-And-Identity.asciidoc[] include::Deep-Links-Routing.asciidoc[] +include::Annotation-JSON-XML-Mapping.asciidoc[] + +include::Annotation-Component-Binding.asciidoc[] + +include::Annotation-SQLite-ORM.asciidoc[] + include::Near-Field-Communication.asciidoc[] include::Network-Connectivity.asciidoc[] diff --git a/docs/developer-guide/languagetool-accept.txt b/docs/developer-guide/languagetool-accept.txt index c748481b10..fb2bb66105 100644 --- a/docs/developer-guide/languagetool-accept.txt +++ b/docs/developer-guide/languagetool-accept.txt @@ -523,3 +523,10 @@ IdP webauthn [Pp]asskey [Pp]asskeys + +# Build-time POJO annotation framework terminology used in the +# Annotation-{JSON-XML-Mapping,Component-Binding,SQLite-ORM} chapters. +# `dao` (data access object) and `daos` aren't in LanguageTool's en_US +# dictionary. +[Dd]ao +[Dd]aos diff --git a/maven/codenameone-maven-plugin/pom.xml b/maven/codenameone-maven-plugin/pom.xml index e21576715e..c8b561d6dc 100644 --- a/maven/codenameone-maven-plugin/pom.xml +++ b/maven/codenameone-maven-plugin/pom.xml @@ -344,6 +344,12 @@ true ${project.build.directory} ${project.basedir}/spotbugs-exclude.xml + + 1536 diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 2bcde9ccb7..109692366d 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -2844,8 +2844,10 @@ public void usesClassMethod(String cls, String method) { // InstallSource for the conditional emission and obfuscation // reasoning. String installRoutes = routeDispatcherInstallSource(sourceZip, " "); + String installFrameworks = annotationFrameworksInstallSource(sourceZip, " "); - String reinitCode0 = installRoutes + " AndroidImplementation.startContext(this);\n"; + String reinitCode0 = installRoutes + installFrameworks + + " AndroidImplementation.startContext(this);\n"; String reinitCode = "Display.init(this);\n"; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java index a56d4e1702..56a3604b7f 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/Executor.java @@ -2015,4 +2015,51 @@ protected static String routeDispatcherInstallSource(File sourceZip, String inde } return indent + "new com.codename1.router.generated.Routes();\n"; } + + /// Stub-source fragment to splice into a generated application stub + /// right before `Display.init(...)` to install the build-time-generated + /// JSON / XML mapper index, the component binder index, and the SQLite + /// dao index -- but only when the project actually uses each feature. + /// + /// The annotation processor emits `cn1app.MapperBootstrap` / + /// `BinderBootstrap` / `DaoBootstrap` only when there are `@Mapped` / + /// `@Bindable` / `@Entity` classes to register. Each bootstrap's + /// constructor references every generated per-class mapper / binder / + /// dao by direct symbol (`new com.example.UserCn1Mapper();` etc.), so + /// ParparVM iOS / R8 Android rename the call sites and the generated + /// classes together. + /// + /// We probe the project zip for each bootstrap and emit the + /// instantiation only when the class is present, so a project that + /// uses only `@Mapped` (no `@Bindable`, no `@Entity`) gets just the + /// mapper bootstrap line and nothing else. cn1-core does not ship a + /// stub: an absent feature leaves the registries empty. + protected static String annotationFrameworksInstallSource(File sourceZip, String indent) { + StringBuilder sb = new StringBuilder(); + if (projectHasBootstrap(sourceZip, "cn1app/MapperBootstrap.class")) { + sb.append(indent).append("new cn1app.MapperBootstrap();\n"); + } + if (projectHasBootstrap(sourceZip, "cn1app/BinderBootstrap.class")) { + sb.append(indent).append("new cn1app.BinderBootstrap();\n"); + } + if (projectHasBootstrap(sourceZip, "cn1app/DaoBootstrap.class")) { + sb.append(indent).append("new cn1app.DaoBootstrap();\n"); + } + return sb.toString(); + } + + /// Returns true when `sourceZip` (the project's + /// `jar-with-dependencies`) contains `entryPath`. Used to gate the + /// per-feature bootstrap install lines so projects that don't use + /// every annotation framework still produce a clean stub. + protected static boolean projectHasBootstrap(File sourceZip, String entryPath) { + if (sourceZip == null || !sourceZip.isFile()) { + return false; + } + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(sourceZip)) { + return zf.getEntry(entryPath) != null; + } catch (IOException e) { + return false; + } + } } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index bb4aeba0e6..256956fe08 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -1208,6 +1208,7 @@ public void usesClassMethod(String cls, String method) { + " com.codename1.impl.ios.IOSImplementation.setMainClass(stub.i);\n" + " com.codename1.impl.ios.IOSImplementation.setIosMode(\"" + iosMode + "\");\n" + routeDispatcherInstallSource(sourceZip, " ") + + annotationFrameworksInstallSource(sourceZip, " ") + " Display.init(stub);\n" + " }\n" + "}\n"; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ClassScanner.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ClassScanner.java index a215db65f3..394a1dac14 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ClassScanner.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/ClassScanner.java @@ -200,6 +200,7 @@ public FieldVisitor visitField(int access, String name, String descriptor, final int fAccess = access; final String fName = name; final String fDesc = descriptor; + final String fSig = signature; final Map fAnnotations = new LinkedHashMap(); return new FieldVisitor(API) { @@ -212,7 +213,7 @@ public AnnotationVisitor visitAnnotation(String d, boolean v) { @Override public void visitEnd() { - fields.add(new FieldInfo(fName, fDesc, fAccess, fAnnotations)); + fields.add(new FieldInfo(fName, fDesc, fSig, fAccess, fAnnotations)); } }; } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/FieldInfo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/FieldInfo.java index 428c8f19db..864c192777 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/FieldInfo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/annotations/FieldInfo.java @@ -34,12 +34,15 @@ public final class FieldInfo { private final String name; private final String descriptor; + private final String signature; private final int access; private final Map annotations; - FieldInfo(String name, String descriptor, int access, Map annotations) { + FieldInfo(String name, String descriptor, String signature, int access, + Map annotations) { this.name = name; this.descriptor = descriptor; + this.signature = signature; this.access = access; this.annotations = (annotations == null) ? Collections.emptyMap() @@ -48,6 +51,15 @@ public final class FieldInfo { public String getName() { return name; } public String getDescriptor() { return descriptor; } + + /// The JVM generic-type signature (e.g. + /// `Lcom/codename1/properties/Property;`) + /// when one is recorded in the class file. Null for fields whose static + /// type carries no parameterization. Processors that need to inspect + /// type arguments (`Property`, `ListProperty`, `List`) parse this + /// string; for declarative use the descriptor still wins. + public String getSignature() { return signature; } + public int getAccess() { return access; } public boolean isPublic() { return (access & Opcodes.ACC_PUBLIC) != 0; } 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 new file mode 100644 index 0000000000..68bea3c264 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BindingAnnotationProcessor.java @@ -0,0 +1,842 @@ +/* + * 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.maven.processors; + +import com.codename1.maven.annotations.AbstractAnnotationProcessor; +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.AnnotationValues; +import com.codename1.maven.annotations.FieldInfo; +import com.codename1.maven.annotations.JavaSourceCompiler; +import com.codename1.maven.annotations.MethodInfo; +import com.codename1.maven.annotations.ProcessingException; +import com.codename1.maven.annotations.ProcessorContext; + +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/// Build-time `@Bindable` processor. Two passes: +/// +/// 1. **Source generation.** For every `@Bindable` class the processor +/// emits a `Cn1Binder` Java class in the source class's +/// package, plus a single `cn1app.BinderBootstrap` whose constructor +/// references every generated binder. The build server probes the +/// project zip for `cn1app.BinderBootstrap` and splices +/// `new cn1app.BinderBootstrap();` into the iOS / Android per-build +/// application stub before `Display.init`; `JavaSEPort#postInit` +/// loads it via `Class.forName`. The bootstrap's constructor calls +/// `XxxCn1Binder.register()` (a public static hook) on each generated +/// binder, which installs an instance in `Binders`. +/// +/// 2. **Setter instrumentation.** For every two-way `@Bind` field that +/// resolves through a setter method (whether the user wrote +/// `@Bind(setter="setName")` or the processor detected +/// `setName(String)` via JavaBeans convention), the processor reads +/// the original `.class` file, walks its bytecode with ASM, and +/// inserts `ALOAD 0; INVOKESTATIC com/codename1/binding/Binders. +/// notifyChanged (Ljava/lang/Object;)V` before every `XRETURN` +/// opcode. The instrumented setter is emitted back through +/// `ProcessorContext#emitClass`, overwriting the original. At +/// runtime, mutations to the model through the setter automatically +/// refresh every active binding for that model -- unless the calling +/// thread is already inside `Binders#enterUpdate` / `exitUpdate` (the +/// binder uses this to break the model -> component -> model loop). +/// +/// #### Accessor resolution +/// +/// Each `@Bind` field resolves a (read, write) accessor pair in this +/// order: +/// +/// - `@Bind(getter="...", setter="...")` -- explicit override. Each +/// string is the method name on the model class. The setter is +/// instrumented if `twoWay` is true. +/// - JavaBeans convention -- `getFoo()` / `isFoo()` for read, +/// `setFoo(T)` for write. The processor scans the class's bytecode +/// for matching public instance methods. The setter is instrumented +/// when found. +/// - Direct public-field access -- the legacy path for `public String +/// foo;`-style models. No setter to instrument; two-way bindings on +/// such fields still flow component -> model through the listener +/// the binder installs, but the model -> component direction has to +/// be triggered explicitly via `Binding#refresh()`. +/// +/// The processor fails the build when none of the three resolves. +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;"; + + static final String BOOTSTRAP_BINARY = "cn1app.BinderBootstrap"; + static final String BOOTSTRAP_SIMPLE = "BinderBootstrap"; + static final String BOOTSTRAP_PACKAGE = "cn1app"; + + private static final int ASM_API = Opcodes.ASM9; + + private static final Set DESCRIPTORS; + static { + Set s = new LinkedHashSet(); + s.add(BINDABLE_DESC); + DESCRIPTORS = Collections.unmodifiableSet(s); + } + + private final TreeMap accepted = new TreeMap(); + + @Override + public Set getAnnotationDescriptors() { + return DESCRIPTORS; + } + + @Override + public void start(ProcessorContext ctx) throws ProcessingException { + accepted.clear(); + } + + @Override + public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws ProcessingException { + if (cls.isSynthetic()) { + return; + } + if (cls.getClassAnnotation(BINDABLE_DESC) == null) { + return; + } + if (cls.isAbstract() || cls.isInterface()) { + ctx.error(cls, "@Bindable requires a concrete class; " + cls.getBinaryName() + + " is abstract or an interface"); + return; + } + + BindableClass bc = new BindableClass(); + bc.binaryName = cls.getBinaryName(); + bc.internalName = cls.getInternalName(); + bc.classFile = cls.getClassFile(); + bc.simpleName = simpleName(cls.getBinaryName()); + bc.packageName = packageOf(cls.getBinaryName()); + bc.binderSimpleName = bc.simpleName + "Cn1Binder"; + bc.binderBinaryName = (bc.packageName.length() == 0) + ? bc.binderSimpleName + : bc.packageName + "." + bc.binderSimpleName; + + for (FieldInfo f : cls.getFields()) { + if (f.isStatic()) { + continue; + } + AnnotationValues bind = f.getAnnotation(BIND_DESC); + if (bind == null) { + continue; + } + BoundField bf = new BoundField(); + bf.fieldName = f.getName(); + bf.componentName = bind.getString("name"); + bf.attr = readAttr(bind); + bf.twoWay = bind.getBoolOrDefault("twoWay", true); + bf.kind = PropertyTypeKind.of(f); + + if (bf.componentName == null || bf.componentName.length() == 0) { + ctx.error(cls, "@Bind on " + bc.binaryName + "." + f.getName() + + " requires name() to identify the target component"); + continue; + } + if (bf.kind.kind == PropertyTypeKind.Kind.UNSUPPORTED) { + ctx.error(cls, "@Bind field " + bc.binaryName + "." + f.getName() + + " has an unsupported type (descriptor " + f.getDescriptor() + ")"); + continue; + } + + String explicitGetter = bind.getStringOrDefault("getter", ""); + String explicitSetter = bind.getStringOrDefault("setter", ""); + if (!resolveAccessors(bf, f, cls, explicitGetter, explicitSetter, ctx)) { + continue; + } + bc.fields.add(bf); + } + // @Bindable with no @Bind fields is accepted -- the generated + // binder is a no-op, the registration still happens. + accepted.put(bc.binaryName, bc); + } + + private static BindAttrName readAttr(AnnotationValues bind) { + Object v = bind.get("attr"); + if (v instanceof String[]) { + String name = ((String[]) v)[1]; + for (BindAttrName candidate : BindAttrName.values()) { + if (candidate.name().equals(name)) { + return candidate; + } + } + } + return BindAttrName.TEXT; + } + + /// Walks the class's methods looking for a matching getter / setter + /// for `field`. Falls back to direct field access only when the field + /// is public and no accessor is required by the explicit annotation + /// members. Returns false (and reports an error) when nothing usable + /// is found. + private static boolean resolveAccessors(BoundField bf, FieldInfo field, AnnotatedClass cls, + String explicitGetter, String explicitSetter, + ProcessorContext ctx) { + String desc = field.getDescriptor(); + String simpleField = field.getName(); + // For Property fields the read/write descriptors are + // Property; the field-level accessor goes through .get()/.set(). + // Honour explicit overrides verbatim in that case too. + + // Getter. + if (explicitGetter.length() > 0) { + MethodInfo m = findMethod(cls, explicitGetter, null); + if (m == null || !m.isPublic() || m.isStatic()) { + ctx.error(cls, "@Bind getter='" + explicitGetter + "' on " + + cls.getBinaryName() + "." + simpleField + + " must name a public instance method"); + return false; + } + bf.getter = m.getName(); + bf.getterDescriptor = m.getDescriptor(); + } else { + MethodInfo m = findJavaBeansGetter(cls, simpleField, desc); + if (m != null) { + bf.getter = m.getName(); + bf.getterDescriptor = m.getDescriptor(); + } else if (field.isPublic()) { + bf.getter = null; // direct field access + bf.getterDescriptor = null; + } else { + ctx.error(cls, "@Bind on " + cls.getBinaryName() + "." + simpleField + + ": no readable accessor. Make the field public, add a JavaBeans " + + "get/is accessor, or use @Bind(getter=\"...\")."); + return false; + } + } + + // Setter. + if (explicitSetter.length() > 0) { + MethodInfo m = findMethod(cls, explicitSetter, null); + if (m == null || !m.isPublic() || m.isStatic()) { + ctx.error(cls, "@Bind setter='" + explicitSetter + "' on " + + cls.getBinaryName() + "." + simpleField + + " must name a public instance method"); + return false; + } + bf.setter = m.getName(); + bf.setterDescriptor = m.getDescriptor(); + } else { + MethodInfo m = findJavaBeansSetter(cls, simpleField, desc); + if (m != null) { + bf.setter = m.getName(); + bf.setterDescriptor = m.getDescriptor(); + } else if (field.isPublic()) { + bf.setter = null; // direct field assignment + bf.setterDescriptor = null; + } else { + // Allowed to be missing for one-way bindings. + if (bf.twoWay && bf.attr.isTwoWayCapable()) { + ctx.error(cls, "@Bind two-way on " + cls.getBinaryName() + "." + simpleField + + ": no writable accessor. Make the field public, add a JavaBeans " + + "set accessor, or use @Bind(setter=\"...\", twoWay=false)."); + return false; + } + bf.setter = null; + bf.setterDescriptor = null; + } + } + return true; + } + + private static MethodInfo findMethod(AnnotatedClass cls, String name, String descriptor) { + for (MethodInfo m : cls.getMethods()) { + if (!m.getName().equals(name)) { + continue; + } + if (descriptor != null && !m.getDescriptor().equals(descriptor)) { + continue; + } + return m; + } + return null; + } + + private static MethodInfo findJavaBeansGetter(AnnotatedClass cls, String field, String fieldDesc) { + String cap = capitalize(field); + String getDesc = "()" + fieldDesc; + String isDesc = "()Z"; + MethodInfo m = findMethod(cls, "get" + cap, getDesc); + if (m != null && m.isPublic() && !m.isStatic()) { + return m; + } + if ("Z".equals(fieldDesc)) { + m = findMethod(cls, "is" + cap, isDesc); + if (m != null && m.isPublic() && !m.isStatic()) { + return m; + } + } + return null; + } + + private static MethodInfo findJavaBeansSetter(AnnotatedClass cls, String field, String fieldDesc) { + String cap = capitalize(field); + String setName = "set" + cap; + // Prefer void(field) first; fall back to any setX(field) shape. + for (MethodInfo m : cls.getMethods()) { + if (!m.getName().equals(setName) || !m.isPublic() || m.isStatic()) { + continue; + } + String d = m.getDescriptor(); + // Single-arg method whose param descriptor matches the field. + if (d != null && d.startsWith("(") && d.contains(")")) { + int close = d.indexOf(')'); + String params = d.substring(1, close); + if (params.equals(fieldDesc)) { + return m; + } + } + } + return null; + } + + private static String capitalize(String s) { + if (s == null || s.length() == 0) { + return s; + } + return Character.toUpperCase(s.charAt(0)) + s.substring(1); + } + + // --------------------------------------------------------------- + // finish: emit binder sources + instrument setters + // --------------------------------------------------------------- + + @Override + public void finish(ProcessorContext ctx) throws ProcessingException { + if (ctx.hasErrors()) { + return; + } + if (accepted.isEmpty()) { + return; + } + + // 1. Source generation. + Map sources = new LinkedHashMap(); + for (BindableClass bc : accepted.values()) { + sources.put(bc.binderBinaryName, generateBinderSource(bc)); + } + sources.put(BOOTSTRAP_BINARY, generateBootstrapSource(accepted.values())); + try { + List cp = new ArrayList(); + cp.add(ctx.getOutputClassDir()); + JavaSourceCompiler.compile(sources, ctx.getOutputClassDir(), cp); + } catch (IOException ioe) { + throw new ProcessingException("Could not compile generated binder sources: " + + ioe.getMessage(), ioe); + } + + // 2. Setter instrumentation. + for (BindableClass bc : accepted.values()) { + instrumentSetters(bc, ctx); + } + + ctx.getLog().info("cn1: generated " + accepted.size() + + " @Bindable binder(s) + " + BOOTSTRAP_BINARY); + } + + /// Re-reads the source class's bytecode, walks its methods, and for + /// every method that matches a resolved setter for a two-way bound + /// field, inserts a `Binders.notifyChanged(this)` call before every + /// `XRETURN` opcode. The modified bytes go through + /// `ProcessorContext#emitClass`, overwriting the original. + private void instrumentSetters(BindableClass bc, ProcessorContext ctx) + throws ProcessingException { + // Collect the (name, descriptor) pairs to instrument. + final Set targets = new LinkedHashSet(); + for (BoundField bf : bc.fields) { + if (!bf.twoWay || !bf.attr.isTwoWayCapable() || bf.setter == null) { + continue; + } + targets.add(bf.setter + bf.setterDescriptor); + } + if (targets.isEmpty()) { + return; + } + if (bc.classFile == null || !bc.classFile.isFile()) { + ctx.error(null, "Cannot instrument setters on " + bc.binaryName + + ": class file is missing"); + return; + } + + byte[] original; + try { + original = Files.readAllBytes(bc.classFile.toPath()); + } catch (IOException ioe) { + throw new ProcessingException("Could not read " + bc.classFile + ": " + + ioe.getMessage(), ioe); + } + + ClassReader reader = new ClassReader(original); + ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_MAXS); + reader.accept(new ClassVisitor(ASM_API, writer) { + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, + String signature, String[] exceptions) { + MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions); + if (mv == null) { + return null; + } + if (!targets.contains(name + descriptor)) { + return mv; + } + return new MethodVisitor(ASM_API, mv) { + @Override + public void visitInsn(int opcode) { + if (opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN) { + super.visitVarInsn(Opcodes.ALOAD, 0); + super.visitMethodInsn(Opcodes.INVOKESTATIC, + "com/codename1/binding/Binders", + "notifyChanged", + "(Ljava/lang/Object;)V", + false); + } + super.visitInsn(opcode); + } + }; + } + }, 0); + + ctx.emitClass(bc.internalName, writer.toByteArray()); + } + + // --------------------------------------------------------------- + // Source generation + // --------------------------------------------------------------- + + private static String generateBinderSource(BindableClass bc) { + StringBuilder sb = new StringBuilder(3072); + if (bc.packageName.length() > 0) { + sb.append("package ").append(bc.packageName).append(";\n\n"); + } + sb.append("// Auto-generated by cn1:process-annotations. Do not edit.\n"); + sb.append("@SuppressWarnings({\"all\"})\n"); + sb.append("public final class ").append(bc.binderSimpleName) + .append(" implements com.codename1.binding.Binder<").append(bc.binaryName).append("> {\n\n"); + + sb.append(" public static void register() {\n"); + sb.append(" com.codename1.binding.Binders.register(new ").append(bc.binderSimpleName).append("());\n"); + sb.append(" }\n\n"); + + sb.append(" public ").append(bc.binderSimpleName).append("() {\n"); + sb.append(" }\n\n"); + + sb.append(" public Class<").append(bc.binaryName).append("> type() {\n"); + sb.append(" return ").append(bc.binaryName).append(".class;\n"); + sb.append(" }\n\n"); + + 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"); + + // Resolve each component once. + for (int i = 0; i < bc.fields.size(); i++) { + BoundField f = bc.fields.get(i); + sb.append(" final com.codename1.ui.Component _c").append(i) + .append(" = _findByName(container, \"").append(escape(f.componentName)).append("\");\n"); + } + + // 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"); + sb.append(" try {\n"); + for (int i = 0; i < bc.fields.size(); i++) { + emitRefreshOne(sb, bc.fields.get(i), i); + } + sb.append(" } finally { com.codename1.binding.Binders.exitUpdate(); }\n"); + sb.append(" }};\n"); + sb.append(" _refresh.run();\n"); + + // commit() pulls components -> model via the setters, again + // inside an update region. + sb.append(" final Runnable _commit = new Runnable() { public void run() {\n"); + sb.append(" com.codename1.binding.Binders.enterUpdate();\n"); + sb.append(" try {\n"); + for (int i = 0; i < bc.fields.size(); i++) { + BoundField f = bc.fields.get(i); + if (!f.twoWay || !f.attr.isTwoWayCapable()) { + continue; + } + emitCommitOne(sb, f, i); + } + sb.append(" } finally { com.codename1.binding.Binders.exitUpdate(); }\n"); + sb.append(" }};\n"); + + // Per-field live listeners. + for (int i = 0; i < bc.fields.size(); i++) { + BoundField f = bc.fields.get(i); + if (!f.twoWay || !f.attr.isTwoWayCapable()) { + continue; + } + emitListenerInstall(sb, f, i); + } + + // Register the binding for notifyChanged dispatch. + sb.append(" final String _typeName = ").append(bc.binaryName).append(".class.getName();\n"); + sb.append(" final ").append(bc.binaryName).append(" _modelRef = model;\n"); + sb.append(" com.codename1.binding.NotifiableBinding _binding = new com.codename1.binding.NotifiableBinding() {\n"); + sb.append(" public void refresh() { _refresh.run(); }\n"); + sb.append(" public void commit() { _commit.run(); }\n"); + sb.append(" public void disconnect() {\n"); + sb.append(" com.codename1.binding.Binders.unregisterBinding(this);\n"); + 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 String modelTypeName() { return _typeName; }\n"); + sb.append(" public boolean matches(Object o) { return o == _modelRef; }\n"); + sb.append(" };\n"); + sb.append(" com.codename1.binding.Binders.registerBinding(_binding);\n"); + sb.append(" return _binding;\n"); + sb.append(" }\n\n"); + + // Recursive name lookup. + sb.append(" private static com.codename1.ui.Component _findByName(com.codename1.ui.Container c, String name) {\n"); + sb.append(" if (c == null || name == null) return null;\n"); + sb.append(" if (name.equals(c.getName())) return c;\n"); + sb.append(" int n = c.getComponentCount();\n"); + sb.append(" for (int i = 0; i < n; i++) {\n"); + sb.append(" com.codename1.ui.Component child = c.getComponentAt(i);\n"); + sb.append(" if (name.equals(child.getName())) return child;\n"); + sb.append(" if (child instanceof com.codename1.ui.Container) {\n"); + sb.append(" com.codename1.ui.Component found = _findByName((com.codename1.ui.Container) child, name);\n"); + sb.append(" if (found != null) return found;\n"); + sb.append(" }\n"); + sb.append(" }\n"); + sb.append(" return null;\n"); + sb.append(" }\n"); + sb.append("}\n"); + return sb.toString(); + } + + private static String generateBootstrapSource(Iterable classes) { + StringBuilder sb = new StringBuilder(1024); + sb.append("package ").append(BOOTSTRAP_PACKAGE).append(";\n\n"); + sb.append("// Auto-generated by cn1:process-annotations. Do not edit.\n"); + sb.append("///\n"); + sb.append("/// Component binding bootstrap. The iOS / Android per-build\n"); + sb.append("/// application stub instantiates this class before Display.init\n"); + sb.append("/// (the build server probes the project zip for it and emits the\n"); + sb.append("/// install line conditionally); JavaSEPort.postInit picks it up\n"); + sb.append("/// via Class.forName for the simulator and desktop runs.\n"); + sb.append("@SuppressWarnings({\"all\"})\n"); + sb.append("public final class ").append(BOOTSTRAP_SIMPLE).append(" {\n"); + sb.append(" public ").append(BOOTSTRAP_SIMPLE).append("() {\n"); + for (BindableClass bc : classes) { + sb.append(" ").append(bc.binderBinaryName).append(".register();\n"); + } + sb.append(" }\n"); + sb.append("}\n"); + return sb.toString(); + } + + // --------------------------------------------------------------- + // Per-attribute code generation + // --------------------------------------------------------------- + + /// Generates the `model.fieldOrAccessor.get()` expression to read the + /// field, honouring Property unwrapping and accessor resolution. + private static String readExpr(BoundField f) { + if (f.kind.kind == PropertyTypeKind.Kind.PROPERTY) { + // Property fields: always go through .get() regardless of + // whether an accessor was resolved (the accessor returns the + // Property wrapper, then we still call .get()). + if (f.getter != null) { + return "model." + f.getter + "().get()"; + } + return "model." + f.fieldName + ".get()"; + } + if (f.getter != null) { + return "model." + f.getter + "()"; + } + return "model." + f.fieldName; + } + + private static void emitRefreshOne(StringBuilder sb, BoundField f, int i) { + sb.append(" if (_c").append(i).append(" != null) {\n"); + String modelExpr = readExpr(f); + switch (f.attr) { + case TEXT: + sb.append(" String _v = ").append(modelExpr).append(" == null ? \"\" : String.valueOf(").append(modelExpr).append(");\n"); + sb.append(" if (_c").append(i).append(" instanceof com.codename1.ui.TextArea) ((com.codename1.ui.TextArea) _c").append(i).append(").setText(_v);\n"); + sb.append(" else if (_c").append(i).append(" instanceof com.codename1.ui.Label) ((com.codename1.ui.Label) _c").append(i).append(").setText(_v);\n"); + sb.append(" else if (_c").append(i).append(" instanceof com.codename1.ui.Button) ((com.codename1.ui.Button) _c").append(i).append(").setText(_v);\n"); + break; + case UIID: + sb.append(" String _v = ").append(modelExpr).append(" == null ? \"\" : String.valueOf(").append(modelExpr).append(");\n"); + sb.append(" _c").append(i).append(".setUIID(_v);\n"); + break; + case HIDDEN: + sb.append(" _c").append(i).append(".setHidden(").append(boolExpr(f, modelExpr)).append(");\n"); + break; + case VISIBLE: + sb.append(" _c").append(i).append(".setVisible(").append(boolExpr(f, modelExpr)).append(");\n"); + break; + case ENABLED: + sb.append(" _c").append(i).append(".setEnabled(").append(boolExpr(f, modelExpr)).append(");\n"); + break; + case SELECTED: + sb.append(" if (_c").append(i).append(" instanceof com.codename1.ui.RadioButton) ((com.codename1.ui.RadioButton) _c").append(i).append(").setSelected(").append(boolExpr(f, modelExpr)).append(");\n"); + sb.append(" else if (_c").append(i).append(" instanceof com.codename1.ui.CheckBox) ((com.codename1.ui.CheckBox) _c").append(i).append(").setSelected(").append(boolExpr(f, modelExpr)).append(");\n"); + break; + case ICON_NAME: + sb.append(" String _v = ").append(modelExpr).append(" == null ? null : String.valueOf(").append(modelExpr).append(");\n"); + sb.append(" if (_v != null && _c").append(i).append(" instanceof com.codename1.ui.Label) {\n"); + sb.append(" com.codename1.ui.util.Resources _r = com.codename1.ui.util.Resources.getGlobalResources();\n"); + sb.append(" if (_r != null) ((com.codename1.ui.Label) _c").append(i).append(").setIcon(_r.getImage(_v));\n"); + sb.append(" }\n"); + break; + case NAME: + sb.append(" _c").append(i).append(".setName(String.valueOf(").append(modelExpr).append("));\n"); + break; + } + sb.append(" }\n"); + } + + private static void emitCommitOne(StringBuilder sb, BoundField f, int i) { + sb.append(" if (_c").append(i).append(" != null) {\n"); + switch (f.attr) { + case TEXT: + sb.append(" String _v = null;\n"); + sb.append(" if (_c").append(i).append(" instanceof com.codename1.ui.TextArea) _v = ((com.codename1.ui.TextArea) _c").append(i).append(").getText();\n"); + sb.append(" else if (_c").append(i).append(" instanceof com.codename1.ui.Label) _v = ((com.codename1.ui.Label) _c").append(i).append(").getText();\n"); + sb.append(" if (_v != null) {\n"); + emitWriteFromString(sb, f, "_v"); + sb.append(" }\n"); + break; + case SELECTED: + sb.append(" boolean _v = false;\n"); + sb.append(" if (_c").append(i).append(" instanceof com.codename1.ui.RadioButton) _v = ((com.codename1.ui.RadioButton) _c").append(i).append(").isSelected();\n"); + sb.append(" else if (_c").append(i).append(" instanceof com.codename1.ui.CheckBox) _v = ((com.codename1.ui.CheckBox) _c").append(i).append(").isSelected();\n"); + emitWriteFromBoolean(sb, f, "_v"); + break; + default: + break; + } + sb.append(" }\n"); + } + + private static void emitListenerInstall(StringBuilder sb, BoundField f, int i) { + if (f.attr == BindAttrName.TEXT) { + sb.append(" if (_c").append(i).append(" instanceof com.codename1.ui.TextArea) {\n"); + sb.append(" final com.codename1.ui.TextArea _ta = (com.codename1.ui.TextArea) _c").append(i).append(";\n"); + sb.append(" final com.codename1.ui.events.DataChangedListener _l = new com.codename1.ui.events.DataChangedListener() {\n"); + sb.append(" public void dataChanged(int type, int index) {\n"); + sb.append(" if (com.codename1.binding.Binders.isInUpdate()) return;\n"); + sb.append(" com.codename1.binding.Binders.enterUpdate();\n"); + sb.append(" try {\n"); + sb.append(" String _v = _ta.getText();\n"); + emitWriteFromString(sb, f, "_v"); + sb.append(" } finally { com.codename1.binding.Binders.exitUpdate(); }\n"); + sb.append(" }\n"); + sb.append(" };\n"); + sb.append(" _ta.addDataChangedListener(_l);\n"); + sb.append(" _disposers.add(new com.codename1.ui.events.ActionListener() {\n"); + sb.append(" public void actionPerformed(com.codename1.ui.events.ActionEvent _e) { _ta.removeDataChangedListener(_l); }\n"); + sb.append(" });\n"); + sb.append(" }\n"); + } else if (f.attr == BindAttrName.SELECTED) { + sb.append(" if (_c").append(i).append(" instanceof com.codename1.ui.Button) {\n"); + sb.append(" final com.codename1.ui.Button _b = (com.codename1.ui.Button) _c").append(i).append(";\n"); + sb.append(" final com.codename1.ui.events.ActionListener _l = new com.codename1.ui.events.ActionListener() {\n"); + sb.append(" public void actionPerformed(com.codename1.ui.events.ActionEvent _e) {\n"); + sb.append(" if (com.codename1.binding.Binders.isInUpdate()) return;\n"); + sb.append(" com.codename1.binding.Binders.enterUpdate();\n"); + sb.append(" try {\n"); + sb.append(" boolean _v = false;\n"); + sb.append(" if (_b instanceof com.codename1.ui.RadioButton) _v = ((com.codename1.ui.RadioButton) _b).isSelected();\n"); + sb.append(" else if (_b instanceof com.codename1.ui.CheckBox) _v = ((com.codename1.ui.CheckBox) _b).isSelected();\n"); + emitWriteFromBoolean(sb, f, "_v"); + sb.append(" } finally { com.codename1.binding.Binders.exitUpdate(); }\n"); + sb.append(" }\n"); + sb.append(" };\n"); + sb.append(" _b.addActionListener(_l);\n"); + sb.append(" _disposers.add(new com.codename1.ui.events.ActionListener() {\n"); + sb.append(" public void actionPerformed(com.codename1.ui.events.ActionEvent _e) { _b.removeActionListener(_l); }\n"); + sb.append(" });\n"); + sb.append(" }\n"); + } + } + + private static String boolExpr(BoundField f, String modelExpr) { + if (f.kind.kind == PropertyTypeKind.Kind.PROPERTY + && "java.lang.Boolean".equals(f.kind.elementBinaryName)) { + return "Boolean.TRUE.equals(" + modelExpr + ")"; + } + if (f.kind.kind == PropertyTypeKind.Kind.BOOLEAN) { + return modelExpr; + } + return modelExpr + " != null && Boolean.parseBoolean(String.valueOf(" + modelExpr + "))"; + } + + private static void emitWriteFromString(StringBuilder sb, BoundField f, String src) { + // Property fields: always through .set() on the Property wrapper. + if (f.kind.kind == PropertyTypeKind.Kind.PROPERTY) { + String elem = f.kind.elementBinaryName; + String propRead = f.getter != null + ? "model." + f.getter + "()" + : "model." + f.fieldName; + if ("java.lang.String".equals(elem)) { + sb.append(" ").append(propRead).append(".set(").append(src).append(");\n"); + } else if ("java.lang.Integer".equals(elem)) { + sb.append(" try { ").append(propRead).append(".set(Integer.valueOf(").append(src).append(")); } catch (NumberFormatException _nfe) {}\n"); + } else if ("java.lang.Long".equals(elem)) { + sb.append(" try { ").append(propRead).append(".set(Long.valueOf(").append(src).append(")); } catch (NumberFormatException _nfe) {}\n"); + } else if ("java.lang.Double".equals(elem)) { + sb.append(" try { ").append(propRead).append(".set(Double.valueOf(").append(src).append(")); } catch (NumberFormatException _nfe) {}\n"); + } else { + sb.append(" ").append(propRead).append(".set((").append(elem).append(") ").append(src).append(");\n"); + } + return; + } + // Scalars: via setter if resolved, else direct field assignment. + String assignLhs; + if (f.setter != null) { + assignLhs = "model." + f.setter + "("; + } else { + assignLhs = "model." + f.fieldName + " = "; + } + String suffix = f.setter != null ? ")" : ""; + + switch (f.kind.kind) { + case STRING: + sb.append(" ").append(assignLhs).append(src).append(suffix).append(";\n"); + break; + case INT: + sb.append(" try { ").append(assignLhs).append("Integer.parseInt(").append(src).append(")").append(suffix).append("; } catch (NumberFormatException _nfe) {}\n"); + break; + case LONG: + sb.append(" try { ").append(assignLhs).append("Long.parseLong(").append(src).append(")").append(suffix).append("; } catch (NumberFormatException _nfe) {}\n"); + break; + case DOUBLE: + sb.append(" try { ").append(assignLhs).append("Double.parseDouble(").append(src).append(")").append(suffix).append("; } catch (NumberFormatException _nfe) {}\n"); + break; + case FLOAT: + sb.append(" try { ").append(assignLhs).append("Float.parseFloat(").append(src).append(")").append(suffix).append("; } catch (NumberFormatException _nfe) {}\n"); + break; + default: + break; + } + } + + private static void emitWriteFromBoolean(StringBuilder sb, BoundField f, String src) { + if (f.kind.kind == PropertyTypeKind.Kind.PROPERTY + && "java.lang.Boolean".equals(f.kind.elementBinaryName)) { + String propRead = f.getter != null + ? "model." + f.getter + "()" + : "model." + f.fieldName; + sb.append(" ").append(propRead).append(".set(Boolean.valueOf(").append(src).append("));\n"); + return; + } + if (f.kind.kind != PropertyTypeKind.Kind.BOOLEAN) { + return; + } + if (f.setter != null) { + sb.append(" model.").append(f.setter).append("(").append(src).append(");\n"); + } else { + sb.append(" model.").append(f.fieldName).append(" = ").append(src).append(";\n"); + } + } + + private static String simpleName(String binary) { + int dot = binary.lastIndexOf('.'); + return dot < 0 ? binary : binary.substring(dot + 1); + } + + private static String packageOf(String binary) { + int dot = binary.lastIndexOf('.'); + return dot < 0 ? "" : binary.substring(0, dot); + } + + private static String escape(String s) { + if (s == null) { + return ""; + } + StringBuilder b = new StringBuilder(s.length() + 4); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '"' || c == '\\') { + b.append('\\'); + } + b.append(c); + } + return b.toString(); + } + + // --------------------------------------------------------------- + // Accumulator types + // --------------------------------------------------------------- + + enum BindAttrName { + TEXT(true), UIID(false), HIDDEN(false), VISIBLE(false), ENABLED(false), + SELECTED(true), ICON_NAME(false), NAME(false); + + private final boolean twoWayCapable; + + BindAttrName(boolean twoWayCapable) { + this.twoWayCapable = twoWayCapable; + } + + boolean isTwoWayCapable() { + return twoWayCapable; + } + } + + static final class BindableClass { + String binaryName; + String internalName; + File classFile; + String packageName; + String simpleName; + String binderBinaryName; + String binderSimpleName; + final List fields = new ArrayList(); + } + + static final class BoundField { + String fieldName; + String componentName; + BindAttrName attr; + boolean twoWay; + PropertyTypeKind kind; + String getter; // null means direct field access + String getterDescriptor; + String setter; // null means direct field assignment + String setterDescriptor; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java new file mode 100644 index 0000000000..3e056c4afa --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java @@ -0,0 +1,815 @@ +/* + * 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.maven.processors; + +import com.codename1.maven.annotations.AbstractAnnotationProcessor; +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.AnnotationValues; +import com.codename1.maven.annotations.FieldInfo; +import com.codename1.maven.annotations.JavaSourceCompiler; +import com.codename1.maven.annotations.MethodInfo; +import com.codename1.maven.annotations.ProcessingException; +import com.codename1.maven.annotations.ProcessorContext; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/// Build-time `@Mapped` processor. Scans the project's compiled classes for +/// `@Mapped` types, validates each one (concrete class, public no-arg +/// constructor, supported field types), then generates: +/// +/// 1. One `Cn1Mapper` Java class per `@Mapped` type, in the +/// **same package as the source class** (so the generated artifact +/// lives alongside the model it describes), implementing +/// `com.codename1.mapping.Mapper`. Each has a public +/// `static register()` method that installs an instance in +/// `Mappers`. +/// 2. A single `cn1app.MapperBootstrap` whose no-arg constructor calls +/// `UserCn1Mapper.register()`, `ItemCn1Mapper.register()`, ... for every +/// accepted `@Mapped` class. The build server probes the project zip +/// for this class and splices `new cn1app.MapperBootstrap();` into the +/// iOS / Android per-build application stub before `Display.init`; +/// `JavaSEPort#postInit` loads it via `Class.forName`. Direct symbol +/// references survive ParparVM rename and R8 obfuscation; the JavaSE +/// classloading path is the legitimate exception (unobfuscated run). +public final class MappingAnnotationProcessor extends AbstractAnnotationProcessor { + + public static final String MAPPED_DESC = "Lcom/codename1/annotations/Mapped;"; + public static final String JSON_PROPERTY_DESC = "Lcom/codename1/annotations/JsonProperty;"; + public static final String JSON_IGNORE_DESC = "Lcom/codename1/annotations/JsonIgnore;"; + public static final String XML_ROOT_DESC = "Lcom/codename1/annotations/XmlRoot;"; + public static final String XML_ELEMENT_DESC = "Lcom/codename1/annotations/XmlElement;"; + public static final String XML_ATTRIBUTE_DESC = "Lcom/codename1/annotations/XmlAttribute;"; + public static final String XML_TRANSIENT_DESC = "Lcom/codename1/annotations/XmlTransient;"; + + static final String BOOTSTRAP_BINARY = "cn1app.MapperBootstrap"; + static final String BOOTSTRAP_SIMPLE = "MapperBootstrap"; + static final String BOOTSTRAP_PACKAGE = "cn1app"; + + private static final Set DESCRIPTORS; + static { + Set s = new LinkedHashSet(); + s.add(MAPPED_DESC); + DESCRIPTORS = Collections.unmodifiableSet(s); + } + + /// Accepted classes keyed by binary name. TreeMap so the emitted index is + /// deterministic regardless of scan order. + private final TreeMap accepted = new TreeMap(); + + @Override + public Set getAnnotationDescriptors() { + return DESCRIPTORS; + } + + @Override + public void start(ProcessorContext ctx) throws ProcessingException { + accepted.clear(); + } + + @Override + public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws ProcessingException { + if (cls.isSynthetic()) return; + if (cls.getClassAnnotation(MAPPED_DESC) == null) return; + if (cls.isAbstract() || cls.isInterface()) { + ctx.error(cls, "@Mapped requires a concrete class; " + + cls.getBinaryName() + " is abstract or an interface"); + return; + } + if (!hasPublicNoArgConstructor(cls)) { + ctx.error(cls, "@Mapped class " + cls.getBinaryName() + + " must declare a public no-arg constructor for fromJson / fromXml"); + return; + } + + MappedClass mc = new MappedClass(); + mc.binaryName = cls.getBinaryName(); + mc.simpleName = simpleName(cls.getBinaryName()); + mc.packageName = packageOf(cls.getBinaryName()); + mc.mapperSimpleName = mc.simpleName + "Cn1Mapper"; + mc.mapperBinaryName = (mc.packageName.length() == 0) + ? mc.mapperSimpleName + : mc.packageName + "." + mc.mapperSimpleName; + + AnnotationValues xmlRoot = cls.getClassAnnotation(XML_ROOT_DESC); + if (xmlRoot != null) { + String v = xmlRoot.getString("value"); + mc.xmlRootName = (v == null || v.length() == 0) ? deriveXmlRoot(mc.simpleName) : v; + } else { + mc.xmlRootName = deriveXmlRoot(mc.simpleName); + } + + for (FieldInfo f : cls.getFields()) { + if (f.isStatic()) continue; + if (f.getName().startsWith("this$")) continue; // inner-class outer ref + if (!f.isPublic()) { + // Skip silently. JavaBeans-style accessors are a v2 enhancement. + continue; + } + MappedField mf = new MappedField(); + mf.name = f.getName(); + mf.kind = PropertyTypeKind.of(f); + mf.jsonName = mf.name; + mf.xmlName = mf.name; + mf.xmlAttribute = false; + mf.includeInJson = true; + mf.includeInXml = true; + + AnnotationValues jp = f.getAnnotation(JSON_PROPERTY_DESC); + if (jp != null) { + String v = jp.getString("value"); + if (v != null && v.length() > 0) mf.jsonName = v; + } + if (f.getAnnotation(JSON_IGNORE_DESC) != null) { + mf.includeInJson = false; + } + AnnotationValues xe = f.getAnnotation(XML_ELEMENT_DESC); + if (xe != null) { + String v = xe.getString("value"); + if (v != null && v.length() > 0) mf.xmlName = v; + } + AnnotationValues xa = f.getAnnotation(XML_ATTRIBUTE_DESC); + if (xa != null) { + mf.xmlAttribute = true; + String v = xa.getString("value"); + if (v != null && v.length() > 0) mf.xmlName = v; + } + if (f.getAnnotation(XML_TRANSIENT_DESC) != null) { + mf.includeInXml = false; + } + if (mf.kind.kind == PropertyTypeKind.Kind.UNSUPPORTED) { + ctx.error(cls, "@Mapped field " + mf.name + " on " + mc.binaryName + + " has an unsupported type (descriptor " + f.getDescriptor() + ")"); + continue; + } + if (mf.xmlAttribute && !canBeAttribute(mf.kind)) { + ctx.error(cls, "@XmlAttribute on " + mc.binaryName + "." + mf.name + + " requires a scalar / Property field"); + continue; + } + mc.fields.add(mf); + } + + accepted.put(mc.binaryName, mc); + } + + @Override + public void finish(ProcessorContext ctx) throws ProcessingException { + if (ctx.hasErrors()) return; + if (accepted.isEmpty()) return; + + Map sources = new LinkedHashMap(); + for (MappedClass mc : accepted.values()) { + sources.put(mc.mapperBinaryName, generateMapperSource(mc)); + } + sources.put(BOOTSTRAP_BINARY, generateBootstrapSource(accepted.values())); + + try { + // The output directory holds the application's @Mapped types -- + // we reference them by direct symbol in the generated mappers, so + // it has to be on the compile classpath. + java.util.List cp = new java.util.ArrayList(); + cp.add(ctx.getOutputClassDir()); + JavaSourceCompiler.compile(sources, ctx.getOutputClassDir(), cp); + } catch (IOException ioe) { + throw new ProcessingException("Could not compile generated mapper sources: " + + ioe.getMessage(), ioe); + } + ctx.getLog().info("cn1: generated " + accepted.size() + + " @Mapped mapper(s) + " + BOOTSTRAP_BINARY); + } + + // --------------------------------------------------------------- + // Source generation + // --------------------------------------------------------------- + + private static String generateMapperSource(MappedClass mc) { + StringBuilder sb = new StringBuilder(2048); + if (mc.packageName.length() > 0) { + sb.append("package ").append(mc.packageName).append(";\n\n"); + } + sb.append("// Auto-generated by cn1:process-annotations. Do not edit.\n"); + sb.append("@SuppressWarnings({\"all\"})\n"); + sb.append("public final class ").append(mc.mapperSimpleName) + .append(" implements com.codename1.mapping.Mapper<").append(mc.binaryName).append("> {\n\n"); + + // Public static register() hook. The bootstrap class invokes + // this once per generated mapper at app start; the call + // triggers this class's and installs the mapper in + // the Mappers registry. + sb.append(" public static void register() {\n"); + sb.append(" com.codename1.mapping.Mappers.register(new ").append(mc.mapperSimpleName).append("());\n"); + sb.append(" }\n\n"); + + sb.append(" public ").append(mc.mapperSimpleName).append("() {\n"); + sb.append(" }\n\n"); + + // type() + sb.append(" public Class<").append(mc.binaryName).append("> type() {\n"); + sb.append(" return ").append(mc.binaryName).append(".class;\n"); + sb.append(" }\n\n"); + + // xmlRootName() + sb.append(" public String xmlRootName() {\n"); + sb.append(" return \"").append(escape(mc.xmlRootName)).append("\";\n"); + sb.append(" }\n\n"); + + // toMap() + sb.append(" public java.util.Map toMap(").append(mc.binaryName).append(" o) {\n"); + sb.append(" java.util.LinkedHashMap m = new java.util.LinkedHashMap();\n"); + sb.append(" if (o == null) return m;\n"); + for (MappedField f : mc.fields) { + if (!f.includeInJson) continue; + emitFieldToMap(sb, f); + } + sb.append(" return m;\n"); + sb.append(" }\n\n"); + + // fromMap() + sb.append(" public ").append(mc.binaryName) + .append(" fromMap(java.util.Map m) {\n"); + sb.append(" ").append(mc.binaryName).append(" o = new ").append(mc.binaryName).append("();\n"); + sb.append(" if (m == null) return o;\n"); + for (MappedField f : mc.fields) { + if (!f.includeInJson) continue; + emitFieldFromMap(sb, f); + } + sb.append(" return o;\n"); + sb.append(" }\n\n"); + + // writeXml() + sb.append(" public void writeXml(").append(mc.binaryName) + .append(" o, com.codename1.xml.Element root) {\n"); + sb.append(" if (o == null) return;\n"); + for (MappedField f : mc.fields) { + if (!f.includeInXml) continue; + emitFieldToXml(sb, f); + } + sb.append(" }\n\n"); + + // readXml() + sb.append(" public ").append(mc.binaryName) + .append(" readXml(com.codename1.xml.Element root) {\n"); + sb.append(" ").append(mc.binaryName).append(" o = new ").append(mc.binaryName).append("();\n"); + sb.append(" if (root == null) return o;\n"); + for (MappedField f : mc.fields) { + if (!f.includeInXml) continue; + emitFieldFromXml(sb, f); + } + sb.append(" return o;\n"); + sb.append(" }\n\n"); + + // textOf helper -- inlined per-mapper to keep each generated class + // self-contained (no shared runtime dependency beyond Mappers). + sb.append(" private static String textOf(com.codename1.xml.Element e) {\n"); + sb.append(" if (e == null) return null;\n"); + sb.append(" if (e.isTextElement()) return e.getText();\n"); + sb.append(" int n = e.getNumChildren();\n"); + sb.append(" for (int i = 0; i < n; i++) {\n"); + sb.append(" com.codename1.xml.Element c = e.getChildAt(i);\n"); + sb.append(" if (c.isTextElement()) return c.getText();\n"); + sb.append(" }\n"); + sb.append(" return null;\n"); + sb.append(" }\n"); + + sb.append("}\n"); + return sb.toString(); + } + + private static String generateBootstrapSource(Iterable classes) { + StringBuilder sb = new StringBuilder(1024); + sb.append("package ").append(BOOTSTRAP_PACKAGE).append(";\n\n"); + sb.append("// Auto-generated by cn1:process-annotations. Do not edit.\n"); + sb.append("///\n"); + sb.append("/// JSON / XML mapper bootstrap. The iOS / Android per-build\n"); + sb.append("/// application stub instantiates this class before Display.init\n"); + sb.append("/// (the build server probes the project zip for it and emits the\n"); + sb.append("/// install line conditionally); JavaSEPort.postInit picks it up\n"); + sb.append("/// via Class.forName for the simulator and desktop runs.\n"); + sb.append("@SuppressWarnings({\"all\"})\n"); + sb.append("public final class ").append(BOOTSTRAP_SIMPLE).append(" {\n"); + sb.append(" public ").append(BOOTSTRAP_SIMPLE).append("() {\n"); + for (MappedClass mc : classes) { + sb.append(" ").append(mc.mapperBinaryName).append(".register();\n"); + } + sb.append(" }\n"); + sb.append("}\n"); + return sb.toString(); + } + + private static String packageOf(String binary) { + int dot = binary.lastIndexOf('.'); + return dot < 0 ? "" : binary.substring(0, dot); + } + + // --------------------------------------------------------------- + // toMap field-emit helpers + // --------------------------------------------------------------- + + private static void emitFieldToMap(StringBuilder sb, MappedField f) { + String key = "\"" + escape(f.jsonName) + "\""; + switch (f.kind.kind) { + case STRING: case INT: case LONG: case SHORT: case BYTE: case CHAR: + case DOUBLE: case FLOAT: case BOOLEAN: + sb.append(" m.put(").append(key).append(", o.").append(f.name).append(");\n"); + return; + case DATE: + sb.append(" m.put(").append(key).append(", o.").append(f.name) + .append(" == null ? null : Long.valueOf(o.").append(f.name).append(".getTime()));\n"); + return; + case BYTE_ARRAY: + sb.append(" m.put(").append(key).append(", o.").append(f.name) + .append(" == null ? null : com.codename1.util.Base64.encode(o.").append(f.name).append("));\n"); + return; + case PROPERTY: + sb.append(" m.put(").append(key).append(", o.").append(f.name).append(".get());\n"); + return; + case LIST: case LIST_PROPERTY: + sb.append(" {\n"); + sb.append(" java.util.ArrayList _l = new java.util.ArrayList();\n"); + if (f.kind.kind == PropertyTypeKind.Kind.LIST) { + sb.append(" java.util.List _src = o.").append(f.name).append(";\n"); + } else { + sb.append(" java.util.List _src = o.").append(f.name).append(".asList();\n"); + } + sb.append(" if (_src != null) {\n"); + sb.append(" for (Object _e : _src) {\n"); + if (isScalarBinary(f.kind.elementBinaryName)) { + sb.append(" _l.add(_e);\n"); + } else if ("java.util.Date".equals(f.kind.elementBinaryName)) { + sb.append(" _l.add(_e == null ? null : Long.valueOf(((java.util.Date) _e).getTime()));\n"); + } else { + sb.append(" com.codename1.mapping.Mapper _nm = com.codename1.mapping.Mappers.get(").append(f.kind.elementBinaryName).append(".class);\n"); + sb.append(" _l.add(_nm == null || _e == null ? _e : _nm.toMap(_e));\n"); + } + sb.append(" }\n"); + sb.append(" }\n"); + sb.append(" m.put(").append(key).append(", _l);\n"); + sb.append(" }\n"); + return; + case REFERENCE: + sb.append(" {\n"); + sb.append(" Object _v = o.").append(f.name).append(";\n"); + sb.append(" if (_v == null) { m.put(").append(key).append(", null); }\n"); + sb.append(" else {\n"); + sb.append(" com.codename1.mapping.Mapper _nm = com.codename1.mapping.Mappers.get(").append(f.kind.binaryName).append(".class);\n"); + sb.append(" m.put(").append(key).append(", _nm == null ? _v.toString() : _nm.toMap(_v));\n"); + sb.append(" }\n"); + sb.append(" }\n"); + return; + default: + return; + } + } + + // --------------------------------------------------------------- + // fromMap field-emit helpers + // --------------------------------------------------------------- + + private static void emitFieldFromMap(StringBuilder sb, MappedField f) { + String key = "\"" + escape(f.jsonName) + "\""; + sb.append(" {\n"); + sb.append(" Object _v = m.get(").append(key).append(");\n"); + sb.append(" if (_v != null) {\n"); + switch (f.kind.kind) { + case STRING: + sb.append(" o.").append(f.name).append(" = _v.toString();\n"); + break; + case INT: + sb.append(" o.").append(f.name).append(" = ((Number) _v).intValue();\n"); + break; + case LONG: + sb.append(" o.").append(f.name).append(" = ((Number) _v).longValue();\n"); + break; + case SHORT: + sb.append(" o.").append(f.name).append(" = ((Number) _v).shortValue();\n"); + break; + case BYTE: + sb.append(" o.").append(f.name).append(" = ((Number) _v).byteValue();\n"); + break; + case CHAR: + sb.append(" o.").append(f.name).append(" = _v.toString().length() == 0 ? '\\0' : _v.toString().charAt(0);\n"); + break; + case DOUBLE: + sb.append(" o.").append(f.name).append(" = ((Number) _v).doubleValue();\n"); + break; + case FLOAT: + sb.append(" o.").append(f.name).append(" = ((Number) _v).floatValue();\n"); + break; + case BOOLEAN: + sb.append(" o.").append(f.name).append(" = (_v instanceof Boolean) ? ((Boolean) _v).booleanValue() : Boolean.parseBoolean(_v.toString());\n"); + break; + case DATE: + sb.append(" o.").append(f.name).append(" = new java.util.Date(((Number) _v).longValue());\n"); + break; + case BYTE_ARRAY: + sb.append(" o.").append(f.name).append(" = com.codename1.util.Base64.decode(_v.toString().getBytes());\n"); + break; + case PROPERTY: + emitPropertySetFromJsonValue(sb, f); + break; + case REFERENCE: + sb.append(" com.codename1.mapping.Mapper _nm = com.codename1.mapping.Mappers.get(").append(f.kind.binaryName).append(".class);\n"); + sb.append(" if (_nm != null && _v instanceof java.util.Map) {\n"); + sb.append(" o.").append(f.name).append(" = (").append(f.kind.binaryName).append(") _nm.fromMap((java.util.Map) _v);\n"); + sb.append(" }\n"); + break; + case LIST: case LIST_PROPERTY: + sb.append(" if (_v instanceof java.util.List) {\n"); + if (isScalarBinary(f.kind.elementBinaryName)) { + if (f.kind.kind == PropertyTypeKind.Kind.LIST) { + sb.append(" java.util.ArrayList<").append(f.kind.elementBinaryName).append("> _l = new java.util.ArrayList<").append(f.kind.elementBinaryName).append(">();\n"); + sb.append(" for (Object _e : (java.util.List) _v) { _l.add((").append(f.kind.elementBinaryName).append(") _e); }\n"); + sb.append(" o.").append(f.name).append(" = _l;\n"); + } else { + sb.append(" o.").append(f.name).append(".clear();\n"); + sb.append(" for (Object _e : (java.util.List) _v) { o.").append(f.name).append(".add((").append(f.kind.elementBinaryName).append(") _e); }\n"); + } + } else if ("java.util.Date".equals(f.kind.elementBinaryName)) { + if (f.kind.kind == PropertyTypeKind.Kind.LIST) { + sb.append(" java.util.ArrayList _l = new java.util.ArrayList();\n"); + sb.append(" for (Object _e : (java.util.List) _v) { _l.add(_e == null ? null : new java.util.Date(((Number) _e).longValue())); }\n"); + sb.append(" o.").append(f.name).append(" = _l;\n"); + } else { + sb.append(" o.").append(f.name).append(".clear();\n"); + sb.append(" for (Object _e : (java.util.List) _v) { o.").append(f.name).append(".add(_e == null ? null : new java.util.Date(((Number) _e).longValue())); }\n"); + } + } else { + sb.append(" com.codename1.mapping.Mapper _nm = com.codename1.mapping.Mappers.get(").append(f.kind.elementBinaryName).append(".class);\n"); + if (f.kind.kind == PropertyTypeKind.Kind.LIST) { + sb.append(" java.util.ArrayList<").append(f.kind.elementBinaryName).append("> _l = new java.util.ArrayList<").append(f.kind.elementBinaryName).append(">();\n"); + sb.append(" for (Object _e : (java.util.List) _v) {\n"); + sb.append(" if (_nm != null && _e instanceof java.util.Map) { _l.add((").append(f.kind.elementBinaryName).append(") _nm.fromMap((java.util.Map) _e)); }\n"); + sb.append(" }\n"); + sb.append(" o.").append(f.name).append(" = _l;\n"); + } else { + sb.append(" o.").append(f.name).append(".clear();\n"); + sb.append(" for (Object _e : (java.util.List) _v) {\n"); + sb.append(" if (_nm != null && _e instanceof java.util.Map) { o.").append(f.name).append(".add((").append(f.kind.elementBinaryName).append(") _nm.fromMap((java.util.Map) _e)); }\n"); + sb.append(" }\n"); + } + } + sb.append(" }\n"); + break; + default: + break; + } + sb.append(" }\n"); + sb.append(" }\n"); + } + + private static void emitPropertySetFromJsonValue(StringBuilder sb, MappedField f) { + String elem = f.kind.elementBinaryName; + if ("java.lang.String".equals(elem)) { + sb.append(" o.").append(f.name).append(".set(_v.toString());\n"); + } else if ("java.lang.Integer".equals(elem)) { + sb.append(" o.").append(f.name).append(".set(Integer.valueOf(((Number) _v).intValue()));\n"); + } else if ("java.lang.Long".equals(elem)) { + sb.append(" o.").append(f.name).append(".set(Long.valueOf(((Number) _v).longValue()));\n"); + } else if ("java.lang.Double".equals(elem)) { + sb.append(" o.").append(f.name).append(".set(Double.valueOf(((Number) _v).doubleValue()));\n"); + } else if ("java.lang.Float".equals(elem)) { + sb.append(" o.").append(f.name).append(".set(Float.valueOf(((Number) _v).floatValue()));\n"); + } else if ("java.lang.Boolean".equals(elem)) { + sb.append(" o.").append(f.name).append(".set((_v instanceof Boolean) ? (Boolean) _v : Boolean.valueOf(_v.toString()));\n"); + } else if ("java.util.Date".equals(elem)) { + sb.append(" o.").append(f.name).append(".set(new java.util.Date(((Number) _v).longValue()));\n"); + } else { + // Nested Property where T is another mapped type. + sb.append(" com.codename1.mapping.Mapper _nm = com.codename1.mapping.Mappers.get(").append(elem).append(".class);\n"); + sb.append(" if (_nm != null && _v instanceof java.util.Map) {\n"); + sb.append(" o.").append(f.name).append(".set((").append(elem).append(") _nm.fromMap((java.util.Map) _v));\n"); + sb.append(" }\n"); + } + } + + // --------------------------------------------------------------- + // XML helpers + // --------------------------------------------------------------- + + private static void emitFieldToXml(StringBuilder sb, MappedField f) { + if (f.xmlAttribute) { + sb.append(" {\n"); + sb.append(" String _s = "); + emitScalarToString(sb, f); + sb.append(";\n"); + sb.append(" if (_s != null) root.setAttribute(\"").append(escape(f.xmlName)).append("\", _s);\n"); + sb.append(" }\n"); + return; + } + switch (f.kind.kind) { + case STRING: case INT: case LONG: case SHORT: case BYTE: case CHAR: + case DOUBLE: case FLOAT: case BOOLEAN: case DATE: case BYTE_ARRAY: + case PROPERTY: + sb.append(" {\n"); + sb.append(" String _s = "); + emitScalarToString(sb, f); + sb.append(";\n"); + sb.append(" if (_s != null) {\n"); + sb.append(" com.codename1.xml.Element _e = new com.codename1.xml.Element(\"").append(escape(f.xmlName)).append("\");\n"); + sb.append(" com.codename1.xml.Element _txt = new com.codename1.xml.Element(_s, true);\n"); + sb.append(" _e.addChild(_txt);\n"); + sb.append(" root.addChild(_e);\n"); + sb.append(" }\n"); + sb.append(" }\n"); + return; + case REFERENCE: + sb.append(" if (o.").append(f.name).append(" != null) {\n"); + sb.append(" com.codename1.mapping.Mapper _nm = com.codename1.mapping.Mappers.get(").append(f.kind.binaryName).append(".class);\n"); + sb.append(" if (_nm != null) {\n"); + sb.append(" com.codename1.xml.Element _e = new com.codename1.xml.Element(\"").append(escape(f.xmlName)).append("\");\n"); + sb.append(" _nm.writeXml(o.").append(f.name).append(", _e);\n"); + sb.append(" root.addChild(_e);\n"); + sb.append(" }\n"); + sb.append(" }\n"); + return; + case LIST: case LIST_PROPERTY: + sb.append(" {\n"); + if (f.kind.kind == PropertyTypeKind.Kind.LIST) { + sb.append(" java.util.List _src = o.").append(f.name).append(";\n"); + } else { + sb.append(" java.util.List _src = o.").append(f.name).append(".asList();\n"); + } + sb.append(" if (_src != null) {\n"); + sb.append(" for (Object _e : _src) {\n"); + sb.append(" com.codename1.xml.Element _el = new com.codename1.xml.Element(\"").append(escape(f.xmlName)).append("\");\n"); + if (isScalarBinary(f.kind.elementBinaryName) || "java.util.Date".equals(f.kind.elementBinaryName)) { + sb.append(" if (_e != null) {\n"); + if ("java.util.Date".equals(f.kind.elementBinaryName)) { + sb.append(" _el.addChild(new com.codename1.xml.Element(String.valueOf(((java.util.Date) _e).getTime()), true));\n"); + } else { + sb.append(" _el.addChild(new com.codename1.xml.Element(String.valueOf(_e), true));\n"); + } + sb.append(" }\n"); + } else { + sb.append(" com.codename1.mapping.Mapper _nm = com.codename1.mapping.Mappers.get(").append(f.kind.elementBinaryName).append(".class);\n"); + sb.append(" if (_nm != null && _e != null) _nm.writeXml(_e, _el);\n"); + } + sb.append(" root.addChild(_el);\n"); + sb.append(" }\n"); + sb.append(" }\n"); + sb.append(" }\n"); + return; + default: + return; + } + } + + private static void emitFieldFromXml(StringBuilder sb, MappedField f) { + if (f.xmlAttribute) { + sb.append(" {\n"); + sb.append(" String _s = root.getAttribute(\"").append(escape(f.xmlName)).append("\");\n"); + sb.append(" if (_s != null) {\n"); + emitScalarFromString(sb, f, "_s"); + sb.append(" }\n"); + sb.append(" }\n"); + return; + } + sb.append(" {\n"); + sb.append(" java.util.Vector _kids = root.getChildrenByTagName(\"").append(escape(f.xmlName)).append("\");\n"); + switch (f.kind.kind) { + case LIST: + sb.append(" java.util.ArrayList<").append(f.kind.elementBinaryName).append("> _l = new java.util.ArrayList<").append(f.kind.elementBinaryName).append(">();\n"); + sb.append(" for (int _i = 0; _i < _kids.size(); _i++) {\n"); + sb.append(" com.codename1.xml.Element _ch = (com.codename1.xml.Element) _kids.elementAt(_i);\n"); + emitListElementFromXml(sb, f, "_ch", "_l"); + sb.append(" }\n"); + sb.append(" o.").append(f.name).append(" = _l;\n"); + break; + case LIST_PROPERTY: + sb.append(" o.").append(f.name).append(".clear();\n"); + sb.append(" for (int _i = 0; _i < _kids.size(); _i++) {\n"); + sb.append(" com.codename1.xml.Element _ch = (com.codename1.xml.Element) _kids.elementAt(_i);\n"); + emitListElementFromXml(sb, f, "_ch", "o." + f.name); + sb.append(" }\n"); + break; + default: + sb.append(" if (_kids.size() > 0) {\n"); + sb.append(" com.codename1.xml.Element _e = (com.codename1.xml.Element) _kids.elementAt(0);\n"); + if (f.kind.kind == PropertyTypeKind.Kind.REFERENCE) { + sb.append(" com.codename1.mapping.Mapper _nm = com.codename1.mapping.Mappers.get(").append(f.kind.binaryName).append(".class);\n"); + sb.append(" if (_nm != null) o.").append(f.name).append(" = (").append(f.kind.binaryName).append(") _nm.readXml(_e);\n"); + } else { + sb.append(" String _s = textOf(_e);\n"); + sb.append(" if (_s != null) {\n"); + emitScalarFromString(sb, f, "_s"); + sb.append(" }\n"); + } + sb.append(" }\n"); + break; + } + sb.append(" }\n"); + } + + private static void emitListElementFromXml(StringBuilder sb, MappedField f, String elemVar, String sink) { + String elem = f.kind.elementBinaryName; + if (isScalarBinary(elem)) { + sb.append(" String _s = textOf(").append(elemVar).append(");\n"); + sb.append(" if (_s != null) {\n"); + if ("java.lang.String".equals(elem)) { + sb.append(" ").append(sink).append(".add(_s);\n"); + } else if ("java.lang.Integer".equals(elem)) { + sb.append(" ").append(sink).append(".add(Integer.valueOf(_s));\n"); + } else if ("java.lang.Long".equals(elem)) { + sb.append(" ").append(sink).append(".add(Long.valueOf(_s));\n"); + } else if ("java.lang.Double".equals(elem)) { + sb.append(" ").append(sink).append(".add(Double.valueOf(_s));\n"); + } else if ("java.lang.Float".equals(elem)) { + sb.append(" ").append(sink).append(".add(Float.valueOf(_s));\n"); + } else if ("java.lang.Boolean".equals(elem)) { + sb.append(" ").append(sink).append(".add(Boolean.valueOf(_s));\n"); + } else { + sb.append(" ").append(sink).append(".add(_s);\n"); + } + sb.append(" }\n"); + } else if ("java.util.Date".equals(elem)) { + sb.append(" String _s = textOf(").append(elemVar).append(");\n"); + sb.append(" if (_s != null) ").append(sink).append(".add(new java.util.Date(Long.parseLong(_s)));\n"); + } else { + sb.append(" com.codename1.mapping.Mapper _nm = com.codename1.mapping.Mappers.get(").append(elem).append(".class);\n"); + sb.append(" if (_nm != null) ").append(sink).append(".add((").append(elem).append(") _nm.readXml(").append(elemVar).append("));\n"); + } + } + + private static void emitScalarToString(StringBuilder sb, MappedField f) { + switch (f.kind.kind) { + case STRING: + sb.append("o.").append(f.name); break; + case INT: case LONG: case SHORT: case BYTE: case CHAR: + case DOUBLE: case FLOAT: case BOOLEAN: + sb.append("String.valueOf(o.").append(f.name).append(")"); break; + case DATE: + sb.append("o.").append(f.name).append(" == null ? null : String.valueOf(o.").append(f.name).append(".getTime())"); break; + case BYTE_ARRAY: + sb.append("o.").append(f.name).append(" == null ? null : com.codename1.util.Base64.encode(o.").append(f.name).append(")"); break; + case PROPERTY: + sb.append("o.").append(f.name).append(".get() == null ? null : "); + String elem = f.kind.elementBinaryName; + if ("java.util.Date".equals(elem)) { + sb.append("String.valueOf(((java.util.Date) o.").append(f.name).append(".get()).getTime())"); + } else { + sb.append("String.valueOf(o.").append(f.name).append(".get())"); + } + break; + default: + sb.append("null"); + } + } + + private static void emitScalarFromString(StringBuilder sb, MappedField f, String src) { + switch (f.kind.kind) { + case STRING: + sb.append(" o.").append(f.name).append(" = ").append(src).append(";\n"); break; + case INT: + sb.append(" o.").append(f.name).append(" = Integer.parseInt(").append(src).append(");\n"); break; + case LONG: + sb.append(" o.").append(f.name).append(" = Long.parseLong(").append(src).append(");\n"); break; + case SHORT: + sb.append(" o.").append(f.name).append(" = Short.parseShort(").append(src).append(");\n"); break; + case BYTE: + sb.append(" o.").append(f.name).append(" = Byte.parseByte(").append(src).append(");\n"); break; + case CHAR: + sb.append(" o.").append(f.name).append(" = ").append(src).append(".length() == 0 ? '\\0' : ").append(src).append(".charAt(0);\n"); break; + case DOUBLE: + sb.append(" o.").append(f.name).append(" = Double.parseDouble(").append(src).append(");\n"); break; + case FLOAT: + sb.append(" o.").append(f.name).append(" = Float.parseFloat(").append(src).append(");\n"); break; + case BOOLEAN: + sb.append(" o.").append(f.name).append(" = Boolean.parseBoolean(").append(src).append(");\n"); break; + case DATE: + sb.append(" o.").append(f.name).append(" = new java.util.Date(Long.parseLong(").append(src).append("));\n"); break; + case BYTE_ARRAY: + sb.append(" o.").append(f.name).append(" = com.codename1.util.Base64.decode(").append(src).append(".getBytes());\n"); break; + case PROPERTY: { + String elem = f.kind.elementBinaryName; + if ("java.lang.String".equals(elem)) { + sb.append(" o.").append(f.name).append(".set(").append(src).append(");\n"); + } else if ("java.lang.Integer".equals(elem)) { + sb.append(" o.").append(f.name).append(".set(Integer.valueOf(").append(src).append("));\n"); + } else if ("java.lang.Long".equals(elem)) { + sb.append(" o.").append(f.name).append(".set(Long.valueOf(").append(src).append("));\n"); + } else if ("java.lang.Double".equals(elem)) { + sb.append(" o.").append(f.name).append(".set(Double.valueOf(").append(src).append("));\n"); + } else if ("java.lang.Float".equals(elem)) { + sb.append(" o.").append(f.name).append(".set(Float.valueOf(").append(src).append("));\n"); + } else if ("java.lang.Boolean".equals(elem)) { + sb.append(" o.").append(f.name).append(".set(Boolean.valueOf(").append(src).append("));\n"); + } else if ("java.util.Date".equals(elem)) { + sb.append(" o.").append(f.name).append(".set(new java.util.Date(Long.parseLong(").append(src).append(")));\n"); + } else { + sb.append(" o.").append(f.name).append(".set((").append(elem).append(") ").append(src).append(");\n"); + } + break; + } + default: + break; + } + } + + // --------------------------------------------------------------- + // Misc + // --------------------------------------------------------------- + + private static boolean isScalarBinary(String binary) { + return "java.lang.String".equals(binary) + || "java.lang.Integer".equals(binary) + || "java.lang.Long".equals(binary) + || "java.lang.Short".equals(binary) + || "java.lang.Byte".equals(binary) + || "java.lang.Character".equals(binary) + || "java.lang.Double".equals(binary) + || "java.lang.Float".equals(binary) + || "java.lang.Boolean".equals(binary); + } + + private static boolean canBeAttribute(PropertyTypeKind k) { + if (k.isScalar()) return true; + if (k.kind == PropertyTypeKind.Kind.PROPERTY) { + return k.elementBinaryName != null + && (isScalarBinary(k.elementBinaryName) + || "java.util.Date".equals(k.elementBinaryName)); + } + return false; + } + + private static boolean hasPublicNoArgConstructor(AnnotatedClass cls) { + for (MethodInfo m : cls.getMethods()) { + if (m.isConstructor() && m.isPublic() && "()V".equals(m.getDescriptor())) { + return true; + } + } + return false; + } + + private static String simpleName(String binary) { + int dot = binary.lastIndexOf('.'); + return dot < 0 ? binary : binary.substring(dot + 1); + } + + private static String deriveXmlRoot(String simpleName) { + if (simpleName.length() == 0) return simpleName; + return Character.toLowerCase(simpleName.charAt(0)) + simpleName.substring(1); + } + + private static String escape(String s) { + if (s == null) return ""; + StringBuilder b = new StringBuilder(s.length() + 4); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '"' || c == '\\') b.append('\\'); + b.append(c); + } + return b.toString(); + } + + // --------------------------------------------------------------- + // Accumulator types + // --------------------------------------------------------------- + + static final class MappedClass { + String binaryName; + String packageName; + String simpleName; + String mapperBinaryName; + String mapperSimpleName; + String xmlRootName; + final List fields = new ArrayList(); + } + + static final class MappedField { + String name; + PropertyTypeKind kind; + String jsonName; + String xmlName; + boolean xmlAttribute; + boolean includeInJson; + boolean includeInXml; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java new file mode 100644 index 0000000000..eccc9bd592 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java @@ -0,0 +1,669 @@ +/* + * 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.maven.processors; + +import com.codename1.maven.annotations.AbstractAnnotationProcessor; +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.AnnotationValues; +import com.codename1.maven.annotations.FieldInfo; +import com.codename1.maven.annotations.JavaSourceCompiler; +import com.codename1.maven.annotations.MethodInfo; +import com.codename1.maven.annotations.ProcessingException; +import com.codename1.maven.annotations.ProcessorContext; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/// Build-time `@Entity` processor. For every entity class it generates one +/// `XxxDao` and registers it with `EntityManager` through a generated +/// `DaosIndex`. Generated daos issue prepared SQL through +/// `com.codename1.db.Database` and read columns through `Row` / `Cursor` -- +/// the same surface `SQLMap` uses internally, but without runtime +/// `putClientProperty` plumbing. +public final class OrmAnnotationProcessor extends AbstractAnnotationProcessor { + + public static final String ENTITY_DESC = "Lcom/codename1/annotations/Entity;"; + public static final String ID_DESC = "Lcom/codename1/annotations/Id;"; + public static final String COLUMN_DESC = "Lcom/codename1/annotations/Column;"; + public static final String DB_TRANSIENT_DESC = "Lcom/codename1/annotations/DbTransient;"; + + static final String BOOTSTRAP_BINARY = "cn1app.DaoBootstrap"; + static final String BOOTSTRAP_SIMPLE = "DaoBootstrap"; + static final String BOOTSTRAP_PACKAGE = "cn1app"; + + private static final Set DESCRIPTORS; + static { + Set s = new LinkedHashSet(); + s.add(ENTITY_DESC); + DESCRIPTORS = Collections.unmodifiableSet(s); + } + + private final TreeMap accepted = new TreeMap(); + + @Override + public Set getAnnotationDescriptors() { + return DESCRIPTORS; + } + + @Override + public void start(ProcessorContext ctx) throws ProcessingException { + accepted.clear(); + } + + @Override + public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws ProcessingException { + if (cls.isSynthetic()) return; + AnnotationValues entityAnn = cls.getClassAnnotation(ENTITY_DESC); + if (entityAnn == null) return; + if (cls.isAbstract() || cls.isInterface()) { + ctx.error(cls, "@Entity requires a concrete class; " + cls.getBinaryName() + + " is abstract or an interface"); + return; + } + if (!hasPublicNoArgConstructor(cls)) { + ctx.error(cls, "@Entity class " + cls.getBinaryName() + + " must declare a public no-arg constructor"); + return; + } + + EntityClass ec = new EntityClass(); + ec.binaryName = cls.getBinaryName(); + ec.simpleName = simpleName(cls.getBinaryName()); + ec.packageName = packageOf(cls.getBinaryName()); + ec.daoSimpleName = ec.simpleName + "Cn1Dao"; + ec.daoBinaryName = (ec.packageName.length() == 0) + ? ec.daoSimpleName + : ec.packageName + "." + ec.daoSimpleName; + String table = entityAnn.getString("table"); + ec.tableName = (table == null || table.length() == 0) ? ec.simpleName : table; + + for (FieldInfo f : cls.getFields()) { + if (f.isStatic()) continue; + if (f.getName().startsWith("this$")) continue; + if (f.getAnnotation(DB_TRANSIENT_DESC) != null) continue; + if (!f.isPublic()) continue; // accessor-style entities are v2 + PersistedField pf = new PersistedField(); + pf.fieldName = f.getName(); + pf.kind = PropertyTypeKind.of(f); + if (pf.kind.kind == PropertyTypeKind.Kind.REFERENCE + || pf.kind.kind == PropertyTypeKind.Kind.LIST + || pf.kind.kind == PropertyTypeKind.Kind.LIST_PROPERTY) { + // Relationships are out of scope for v1; flag once and move on. + ctx.error(cls, "@Entity field " + ec.binaryName + "." + f.getName() + + " maps to a nested object or list; relationships are not yet " + + "supported. Use @DbTransient and persist the foreign key manually."); + continue; + } + if (pf.kind.kind == PropertyTypeKind.Kind.UNSUPPORTED) { + ctx.error(cls, "@Entity field " + ec.binaryName + "." + f.getName() + + " has an unsupported type (descriptor " + f.getDescriptor() + ")"); + continue; + } + AnnotationValues col = f.getAnnotation(COLUMN_DESC); + String colName = null; + String colType = null; + boolean nullable = true; + if (col != null) { + colName = col.getString("name"); + colType = col.getString("type"); + nullable = col.getBoolOrDefault("nullable", true); + } + pf.columnName = (colName == null || colName.length() == 0) ? pf.fieldName : colName; + pf.sqlType = (colType == null || colType.length() == 0) ? defaultSqlType(pf.kind) : colType; + pf.nullable = nullable; + + AnnotationValues idAnn = f.getAnnotation(ID_DESC); + if (idAnn != null) { + pf.isId = true; + pf.autoIncrement = idAnn.getBoolOrDefault("autoIncrement", true); + if (ec.idField != null) { + ctx.error(cls, "@Entity " + ec.binaryName + + " has more than one @Id field"); + continue; + } + ec.idField = pf; + } + ec.fields.add(pf); + } + + if (ec.idField == null) { + ctx.error(cls, "@Entity " + ec.binaryName + " requires exactly one @Id field"); + return; + } + accepted.put(ec.binaryName, ec); + } + + @Override + public void finish(ProcessorContext ctx) throws ProcessingException { + if (ctx.hasErrors()) return; + if (accepted.isEmpty()) return; + + Map sources = new LinkedHashMap(); + for (EntityClass ec : accepted.values()) { + sources.put(ec.daoBinaryName, generateDaoSource(ec)); + } + sources.put(BOOTSTRAP_BINARY, generateBootstrapSource(accepted.values())); + try { + java.util.List cp = new java.util.ArrayList(); + cp.add(ctx.getOutputClassDir()); + JavaSourceCompiler.compile(sources, ctx.getOutputClassDir(), cp); + } catch (IOException ioe) { + throw new ProcessingException("Could not compile generated dao sources: " + + ioe.getMessage(), ioe); + } + ctx.getLog().info("cn1: generated " + accepted.size() + + " @Entity dao(s) + " + BOOTSTRAP_BINARY); + } + + // --------------------------------------------------------------- + // Source generation + // --------------------------------------------------------------- + + private static String generateDaoSource(EntityClass ec) { + StringBuilder sb = new StringBuilder(4096); + if (ec.packageName.length() > 0) { + sb.append("package ").append(ec.packageName).append(";\n\n"); + } + sb.append("// Auto-generated by cn1:process-annotations. Do not edit.\n"); + sb.append("@SuppressWarnings({\"all\"})\n"); + sb.append("public final class ").append(ec.daoSimpleName) + .append(" implements com.codename1.orm.Dao<").append(ec.binaryName).append("> {\n\n"); + + // Public static register() hook -- the bootstrap class calls + // this once per generated dao at app start; the call triggers + // this class's and installs the dao in EntityManager. + sb.append(" public static void register() {\n"); + sb.append(" com.codename1.orm.EntityManager.registerDao(new ").append(ec.daoSimpleName).append("());\n"); + sb.append(" }\n\n"); + + sb.append(" public ").append(ec.daoSimpleName).append("() {\n"); + sb.append(" }\n\n"); + + sb.append(" private com.codename1.db.Database db;\n\n"); + + sb.append(" public Class<").append(ec.binaryName).append("> type() {\n"); + sb.append(" return ").append(ec.binaryName).append(".class;\n"); + sb.append(" }\n\n"); + + sb.append(" public String tableName() {\n"); + sb.append(" return \"").append(escape(ec.tableName)).append("\";\n"); + sb.append(" }\n\n"); + + sb.append(" public void attach(com.codename1.db.Database db) {\n"); + sb.append(" this.db = db;\n"); + sb.append(" }\n\n"); + + // createTable + sb.append(" public void createTable() throws java.io.IOException {\n"); + sb.append(" db.execute(\"CREATE TABLE IF NOT EXISTS ").append(escape(ec.tableName)).append(" (") + .append(buildCreateColumnsSql(ec)).append(")\");\n"); + sb.append(" }\n\n"); + + sb.append(" public void dropTable() throws java.io.IOException {\n"); + sb.append(" db.execute(\"DROP TABLE IF EXISTS ").append(escape(ec.tableName)).append("\");\n"); + sb.append(" }\n\n"); + + // insert + sb.append(" public void insert(").append(ec.binaryName).append(" e) throws java.io.IOException {\n"); + List insertCols = new ArrayList(); + for (PersistedField f : ec.fields) { + // Skip auto-increment id column so SQLite assigns the key. + if (f.isId && f.autoIncrement) continue; + insertCols.add(f); + } + StringBuilder cols = new StringBuilder(); + StringBuilder qmarks = new StringBuilder(); + for (int i = 0; i < insertCols.size(); i++) { + if (i > 0) { cols.append(", "); qmarks.append(", "); } + cols.append(insertCols.get(i).columnName); + qmarks.append("?"); + } + sb.append(" Object[] _p = new Object[").append(insertCols.size()).append("];\n"); + for (int i = 0; i < insertCols.size(); i++) { + sb.append(" _p[").append(i).append("] = "); + emitFieldRead(sb, insertCols.get(i), "e"); + sb.append(";\n"); + } + sb.append(" db.execute(\"INSERT INTO ").append(escape(ec.tableName)) + .append(" (").append(escape(cols.toString())).append(") VALUES (") + .append(qmarks).append(")\", _p);\n"); + // Auto-id back-fill. + if (ec.idField.autoIncrement) { + sb.append(" com.codename1.db.Cursor _c = db.executeQuery(\"SELECT last_insert_rowid()\");\n"); + sb.append(" try {\n"); + sb.append(" if (_c.next()) {\n"); + sb.append(" long _id = _c.getRow().getLong(0);\n"); + emitIdAssign(sb, ec.idField, "e", "_id"); + sb.append(" }\n"); + sb.append(" } finally { _c.close(); }\n"); + } + sb.append(" }\n\n"); + + // update + sb.append(" public void update(").append(ec.binaryName).append(" e) throws java.io.IOException {\n"); + List updateCols = new ArrayList(); + for (PersistedField f : ec.fields) { + if (f.isId) continue; + updateCols.add(f); + } + StringBuilder setSql = new StringBuilder(); + for (int i = 0; i < updateCols.size(); i++) { + if (i > 0) setSql.append(", "); + setSql.append(updateCols.get(i).columnName).append(" = ?"); + } + sb.append(" Object[] _p = new Object[").append(updateCols.size() + 1).append("];\n"); + for (int i = 0; i < updateCols.size(); i++) { + sb.append(" _p[").append(i).append("] = "); + emitFieldRead(sb, updateCols.get(i), "e"); + sb.append(";\n"); + } + sb.append(" _p[").append(updateCols.size()).append("] = "); + emitFieldRead(sb, ec.idField, "e"); + sb.append(";\n"); + sb.append(" db.execute(\"UPDATE ").append(escape(ec.tableName)).append(" SET ") + .append(escape(setSql.toString())).append(" WHERE ").append(ec.idField.columnName) + .append(" = ?\", _p);\n"); + sb.append(" }\n\n"); + + // delete + sb.append(" public void delete(").append(ec.binaryName).append(" e) throws java.io.IOException {\n"); + sb.append(" db.execute(\"DELETE FROM ").append(escape(ec.tableName)).append(" WHERE ") + .append(ec.idField.columnName).append(" = ?\", new Object[]{ "); + emitFieldRead(sb, ec.idField, "e"); + sb.append(" });\n"); + sb.append(" }\n\n"); + + // findById + sb.append(" public ").append(ec.binaryName).append(" findById(Object id) throws java.io.IOException {\n"); + sb.append(" com.codename1.db.Cursor _c = db.executeQuery(\"SELECT * FROM ") + .append(escape(ec.tableName)).append(" WHERE ").append(ec.idField.columnName) + .append(" = ?\", new Object[]{ id });\n"); + sb.append(" try {\n"); + sb.append(" if (_c.next()) return readRow(_c);\n"); + sb.append(" return null;\n"); + sb.append(" } finally { _c.close(); }\n"); + sb.append(" }\n\n"); + + // findAll + sb.append(" public java.util.List<").append(ec.binaryName).append("> findAll() throws java.io.IOException {\n"); + sb.append(" com.codename1.db.Cursor _c = db.executeQuery(\"SELECT * FROM ") + .append(escape(ec.tableName)).append("\");\n"); + sb.append(" java.util.ArrayList<").append(ec.binaryName).append("> _out = new java.util.ArrayList<").append(ec.binaryName).append(">();\n"); + sb.append(" try {\n"); + sb.append(" while (_c.next()) _out.add(readRow(_c));\n"); + sb.append(" } finally { _c.close(); }\n"); + sb.append(" return _out;\n"); + sb.append(" }\n\n"); + + // find(where, params) + sb.append(" public java.util.List<").append(ec.binaryName).append("> find(String where, Object... params) throws java.io.IOException {\n"); + sb.append(" String _sql = \"SELECT * FROM ").append(escape(ec.tableName)).append("\";\n"); + sb.append(" if (where != null && where.length() > 0) _sql = _sql + \" WHERE \" + where;\n"); + sb.append(" com.codename1.db.Cursor _c = db.executeQuery(_sql, params);\n"); + sb.append(" java.util.ArrayList<").append(ec.binaryName).append("> _out = new java.util.ArrayList<").append(ec.binaryName).append(">();\n"); + sb.append(" try {\n"); + sb.append(" while (_c.next()) _out.add(readRow(_c));\n"); + sb.append(" } finally { _c.close(); }\n"); + sb.append(" return _out;\n"); + sb.append(" }\n\n"); + + // readRow helper + sb.append(" private ").append(ec.binaryName).append(" readRow(com.codename1.db.Cursor _c) throws java.io.IOException {\n"); + sb.append(" ").append(ec.binaryName).append(" e = new ").append(ec.binaryName).append("();\n"); + sb.append(" com.codename1.db.Row _r = _c.getRow();\n"); + for (PersistedField f : ec.fields) { + sb.append(" try {\n"); + sb.append(" int _idx = _c.getColumnIndex(\"").append(escape(f.columnName)).append("\");\n"); + sb.append(" if (_idx >= 0) {\n"); + emitFieldWrite(sb, f, "e", "_r", "_idx"); + sb.append(" }\n"); + sb.append(" } catch (java.io.IOException _ex) { /* column missing -- skip */ }\n"); + } + sb.append(" return e;\n"); + sb.append(" }\n"); + + sb.append("}\n"); + return sb.toString(); + } + + private static String generateBootstrapSource(Iterable classes) { + StringBuilder sb = new StringBuilder(1024); + sb.append("package ").append(BOOTSTRAP_PACKAGE).append(";\n\n"); + sb.append("// Auto-generated by cn1:process-annotations. Do not edit.\n"); + sb.append("///\n"); + sb.append("/// SQLite dao bootstrap. The iOS / Android per-build application\n"); + sb.append("/// stub instantiates this class before Display.init (the build\n"); + sb.append("/// server probes the project zip for it and emits the install line\n"); + sb.append("/// conditionally); JavaSEPort.postInit picks it up via\n"); + sb.append("/// Class.forName for the simulator and desktop runs.\n"); + sb.append("@SuppressWarnings({\"all\"})\n"); + sb.append("public final class ").append(BOOTSTRAP_SIMPLE).append(" {\n"); + sb.append(" public ").append(BOOTSTRAP_SIMPLE).append("() {\n"); + for (EntityClass ec : classes) { + sb.append(" ").append(ec.daoBinaryName).append(".register();\n"); + } + sb.append(" }\n"); + sb.append("}\n"); + return sb.toString(); + } + + private static String packageOf(String binary) { + int dot = binary.lastIndexOf('.'); + return dot < 0 ? "" : binary.substring(0, dot); + } + + private static String buildCreateColumnsSql(EntityClass ec) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < ec.fields.size(); i++) { + PersistedField f = ec.fields.get(i); + if (i > 0) sb.append(", "); + sb.append(f.columnName).append(' '); + if (f.isId) { + if (f.autoIncrement) { + sb.append("INTEGER PRIMARY KEY AUTOINCREMENT"); + } else { + sb.append(f.sqlType).append(" PRIMARY KEY"); + } + } else { + sb.append(f.sqlType); + if (!f.nullable) sb.append(" NOT NULL"); + } + } + return sb.toString(); + } + + private static String defaultSqlType(PropertyTypeKind k) { + if (k.kind == PropertyTypeKind.Kind.PROPERTY) { + return defaultSqlTypeForBinary(k.elementBinaryName); + } + switch (k.kind) { + case INT: case LONG: case SHORT: case BYTE: case BOOLEAN: case DATE: + return "INTEGER"; + case DOUBLE: case FLOAT: + return "REAL"; + case BYTE_ARRAY: + return "BLOB"; + case CHAR: case STRING: + default: + return "TEXT"; + } + } + + private static String defaultSqlTypeForBinary(String binary) { + if ("java.lang.String".equals(binary)) return "TEXT"; + if ("java.lang.Integer".equals(binary) || "java.lang.Long".equals(binary) + || "java.lang.Short".equals(binary) || "java.lang.Byte".equals(binary) + || "java.lang.Boolean".equals(binary) || "java.util.Date".equals(binary)) { + return "INTEGER"; + } + if ("java.lang.Double".equals(binary) || "java.lang.Float".equals(binary)) { + return "REAL"; + } + return "TEXT"; + } + + private static void emitFieldRead(StringBuilder sb, PersistedField f, String inst) { + switch (f.kind.kind) { + case STRING: case BYTE_ARRAY: + // Strings go through Database#execute(String, Object...) as + // String params; byte[] is passed through unchanged for the + // platforms that support blob binding (the others raise the + // documented Database "Blobs aren't supported" error). + sb.append(inst).append('.').append(f.fieldName); + return; + case INT: case LONG: case SHORT: case BYTE: + sb.append("Long.valueOf(").append(inst).append('.').append(f.fieldName).append(")"); + return; + case CHAR: + sb.append("String.valueOf(").append(inst).append('.').append(f.fieldName).append(")"); + return; + case DOUBLE: case FLOAT: + sb.append("Double.valueOf(").append(inst).append('.').append(f.fieldName).append(")"); + return; + case BOOLEAN: + sb.append("Long.valueOf(").append(inst).append('.').append(f.fieldName).append(" ? 1L : 0L)"); + return; + case DATE: + sb.append(inst).append('.').append(f.fieldName).append(" == null ? null : Long.valueOf(") + .append(inst).append('.').append(f.fieldName).append(".getTime())"); + return; + case PROPERTY: + emitPropertyRead(sb, f, inst); + return; + default: + sb.append("null"); + } + } + + private static void emitPropertyRead(StringBuilder sb, PersistedField f, String inst) { + String elem = f.kind.elementBinaryName; + if ("java.lang.String".equals(elem)) { + sb.append(inst).append('.').append(f.fieldName).append(".get()"); + } else if ("java.lang.Integer".equals(elem) || "java.lang.Long".equals(elem) + || "java.lang.Short".equals(elem) || "java.lang.Byte".equals(elem)) { + sb.append(inst).append('.').append(f.fieldName).append(".get() == null ? null : Long.valueOf(((Number) ") + .append(inst).append('.').append(f.fieldName).append(".get()).longValue())"); + } else if ("java.lang.Double".equals(elem) || "java.lang.Float".equals(elem)) { + sb.append(inst).append('.').append(f.fieldName).append(".get() == null ? null : Double.valueOf(((Number) ") + .append(inst).append('.').append(f.fieldName).append(".get()).doubleValue())"); + } else if ("java.lang.Boolean".equals(elem)) { + sb.append(inst).append('.').append(f.fieldName).append(".get() == null ? null : Long.valueOf(Boolean.TRUE.equals(") + .append(inst).append('.').append(f.fieldName).append(".get()) ? 1L : 0L)"); + } else if ("java.util.Date".equals(elem)) { + sb.append(inst).append('.').append(f.fieldName).append(".get() == null ? null : Long.valueOf(((java.util.Date) ") + .append(inst).append('.').append(f.fieldName).append(".get()).getTime())"); + } else { + sb.append(inst).append('.').append(f.fieldName).append(".get() == null ? null : String.valueOf(") + .append(inst).append('.').append(f.fieldName).append(".get())"); + } + } + + private static void emitFieldWrite(StringBuilder sb, PersistedField f, String inst, + String row, String idx) { + switch (f.kind.kind) { + case STRING: + sb.append(" ").append(inst).append('.').append(f.fieldName) + .append(" = ").append(row).append(".getString(").append(idx).append(");\n"); + return; + case INT: + sb.append(" ").append(inst).append('.').append(f.fieldName) + .append(" = ").append(row).append(".getInteger(").append(idx).append(");\n"); + return; + case LONG: + sb.append(" ").append(inst).append('.').append(f.fieldName) + .append(" = ").append(row).append(".getLong(").append(idx).append(");\n"); + return; + case SHORT: + sb.append(" ").append(inst).append('.').append(f.fieldName) + .append(" = ").append(row).append(".getShort(").append(idx).append(");\n"); + return; + case BYTE: + sb.append(" ").append(inst).append('.').append(f.fieldName) + .append(" = (byte) ").append(row).append(".getInteger(").append(idx).append(");\n"); + return; + case CHAR: + sb.append(" String _s = ").append(row).append(".getString(").append(idx).append(");\n"); + sb.append(" ").append(inst).append('.').append(f.fieldName) + .append(" = (_s == null || _s.length() == 0) ? '\\0' : _s.charAt(0);\n"); + return; + case DOUBLE: + sb.append(" ").append(inst).append('.').append(f.fieldName) + .append(" = ").append(row).append(".getDouble(").append(idx).append(");\n"); + return; + case FLOAT: + sb.append(" ").append(inst).append('.').append(f.fieldName) + .append(" = ").append(row).append(".getFloat(").append(idx).append(");\n"); + return; + case BOOLEAN: + sb.append(" ").append(inst).append('.').append(f.fieldName) + .append(" = ").append(row).append(".getInteger(").append(idx).append(") != 0;\n"); + return; + case DATE: + sb.append(" ").append(inst).append('.').append(f.fieldName) + .append(" = new java.util.Date(").append(row).append(".getLong(").append(idx).append("));\n"); + return; + case BYTE_ARRAY: + // Most cn1 ports do not support getBlob universally; fall back + // to base64 over getString -- the insert path uses execute() + // which throws on byte[] anyway. Future enhancement. + sb.append(" String _b64 = ").append(row).append(".getString(").append(idx).append(");\n"); + sb.append(" ").append(inst).append('.').append(f.fieldName) + .append(" = (_b64 == null) ? null : com.codename1.util.Base64.decode(_b64.getBytes());\n"); + return; + case PROPERTY: + emitPropertyWrite(sb, f, inst, row, idx); + return; + default: + return; + } + } + + private static void emitPropertyWrite(StringBuilder sb, PersistedField f, String inst, + String row, String idx) { + String elem = f.kind.elementBinaryName; + if ("java.lang.String".equals(elem)) { + sb.append(" ").append(inst).append('.').append(f.fieldName).append(".set(") + .append(row).append(".getString(").append(idx).append("));\n"); + } else if ("java.lang.Integer".equals(elem)) { + sb.append(" ").append(inst).append('.').append(f.fieldName).append(".set(Integer.valueOf(") + .append(row).append(".getInteger(").append(idx).append(")));\n"); + } else if ("java.lang.Long".equals(elem)) { + sb.append(" ").append(inst).append('.').append(f.fieldName).append(".set(Long.valueOf(") + .append(row).append(".getLong(").append(idx).append(")));\n"); + } else if ("java.lang.Short".equals(elem)) { + sb.append(" ").append(inst).append('.').append(f.fieldName).append(".set(Short.valueOf(") + .append(row).append(".getShort(").append(idx).append(")));\n"); + } else if ("java.lang.Byte".equals(elem)) { + sb.append(" ").append(inst).append('.').append(f.fieldName).append(".set(Byte.valueOf((byte) ") + .append(row).append(".getInteger(").append(idx).append(")));\n"); + } else if ("java.lang.Double".equals(elem)) { + sb.append(" ").append(inst).append('.').append(f.fieldName).append(".set(Double.valueOf(") + .append(row).append(".getDouble(").append(idx).append(")));\n"); + } else if ("java.lang.Float".equals(elem)) { + sb.append(" ").append(inst).append('.').append(f.fieldName).append(".set(Float.valueOf(") + .append(row).append(".getFloat(").append(idx).append(")));\n"); + } else if ("java.lang.Boolean".equals(elem)) { + sb.append(" ").append(inst).append('.').append(f.fieldName).append(".set(Boolean.valueOf(") + .append(row).append(".getInteger(").append(idx).append(") != 0));\n"); + } else if ("java.util.Date".equals(elem)) { + sb.append(" ").append(inst).append('.').append(f.fieldName).append(".set(new java.util.Date(") + .append(row).append(".getLong(").append(idx).append(")));\n"); + } else { + sb.append(" ").append(inst).append('.').append(f.fieldName).append(".set((") + .append(elem).append(") ").append(row).append(".getString(").append(idx).append("));\n"); + } + } + + private static void emitIdAssign(StringBuilder sb, PersistedField id, String inst, String idVar) { + switch (id.kind.kind) { + case LONG: + sb.append(" ").append(inst).append('.').append(id.fieldName) + .append(" = ").append(idVar).append(";\n"); + return; + case INT: + sb.append(" ").append(inst).append('.').append(id.fieldName) + .append(" = (int) ").append(idVar).append(";\n"); + return; + case SHORT: + sb.append(" ").append(inst).append('.').append(id.fieldName) + .append(" = (short) ").append(idVar).append(";\n"); + return; + case STRING: + sb.append(" ").append(inst).append('.').append(id.fieldName) + .append(" = String.valueOf(").append(idVar).append(");\n"); + return; + case PROPERTY: + String elem = id.kind.elementBinaryName; + if ("java.lang.Long".equals(elem)) { + sb.append(" ").append(inst).append('.').append(id.fieldName) + .append(".set(Long.valueOf(").append(idVar).append("));\n"); + } else if ("java.lang.Integer".equals(elem)) { + sb.append(" ").append(inst).append('.').append(id.fieldName) + .append(".set(Integer.valueOf((int) ").append(idVar).append("));\n"); + } else if ("java.lang.String".equals(elem)) { + sb.append(" ").append(inst).append('.').append(id.fieldName) + .append(".set(String.valueOf(").append(idVar).append("));\n"); + } + return; + default: + return; + } + } + + private static boolean hasPublicNoArgConstructor(AnnotatedClass cls) { + for (MethodInfo m : cls.getMethods()) { + if (m.isConstructor() && m.isPublic() && "()V".equals(m.getDescriptor())) { + return true; + } + } + return false; + } + + private static String simpleName(String binary) { + int dot = binary.lastIndexOf('.'); + return dot < 0 ? binary : binary.substring(dot + 1); + } + + private static String escape(String s) { + if (s == null) return ""; + StringBuilder b = new StringBuilder(s.length() + 4); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '"' || c == '\\') b.append('\\'); + b.append(c); + } + return b.toString(); + } + + // --------------------------------------------------------------- + // Accumulator types + // --------------------------------------------------------------- + + static final class EntityClass { + String binaryName; + String packageName; + String simpleName; + String daoBinaryName; + String daoSimpleName; + String tableName; + PersistedField idField; + final List fields = new ArrayList(); + } + + static final class PersistedField { + String fieldName; + String columnName; + String sqlType; + boolean nullable; + boolean isId; + boolean autoIncrement; + PropertyTypeKind kind; + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/PropertyTypeKind.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/PropertyTypeKind.java new file mode 100644 index 0000000000..70f42de71c --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/PropertyTypeKind.java @@ -0,0 +1,208 @@ +/* + * 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.maven.processors; + +import com.codename1.maven.annotations.FieldInfo; + +/// Shared field-type classifier used by every annotation processor that has +/// to walk POJO / Property mixed types -- the JSON/XML mapper, the component +/// binder, and the ORM dao. Keeping the rules in one place means a new type +/// category (added later, say `OptionalProperty`) gets honoured by every +/// generator without three out-of-sync forks. +/// +/// The classifier never reflects: it only looks at the field descriptor and +/// the optional generic signature recorded in the class file. +public final class PropertyTypeKind { + + /// What flavour of value the field exposes. + public enum Kind { + STRING, INT, LONG, SHORT, BYTE, CHAR, DOUBLE, FLOAT, BOOLEAN, + DATE, BYTE_ARRAY, ENUM, + /// A `com.codename1.properties.Property` field. `elementBinaryName` + /// holds the dot-form of T. + PROPERTY, + /// A `com.codename1.properties.ListProperty` field. `elementBinaryName` + /// holds the dot-form of T. + LIST_PROPERTY, + /// `java.util.List` (or `ArrayList`). + LIST, + /// Another @Mapped / @Entity / @Bindable class -- nested object. + REFERENCE, + /// Anything else. The processor must emit a clear validation error. + UNSUPPORTED + } + + public final Kind kind; + public final String binaryName; + public final String elementBinaryName; + + private PropertyTypeKind(Kind kind, String binaryName, String elementBinaryName) { + this.kind = kind; + this.binaryName = binaryName; + this.elementBinaryName = elementBinaryName; + } + + public static PropertyTypeKind of(FieldInfo field) { + String desc = field.getDescriptor(); + if (desc == null || desc.length() == 0) { + return new PropertyTypeKind(Kind.UNSUPPORTED, "?", null); + } + // Primitives. + if (desc.length() == 1) { + switch (desc.charAt(0)) { + case 'I': return new PropertyTypeKind(Kind.INT, "int", null); + case 'J': return new PropertyTypeKind(Kind.LONG, "long", null); + case 'S': return new PropertyTypeKind(Kind.SHORT, "short", null); + case 'B': return new PropertyTypeKind(Kind.BYTE, "byte", null); + case 'C': return new PropertyTypeKind(Kind.CHAR, "char", null); + case 'D': return new PropertyTypeKind(Kind.DOUBLE, "double", null); + case 'F': return new PropertyTypeKind(Kind.FLOAT, "float", null); + case 'Z': return new PropertyTypeKind(Kind.BOOLEAN, "boolean", null); + default: return new PropertyTypeKind(Kind.UNSUPPORTED, "?", null); + } + } + if ("[B".equals(desc)) { + return new PropertyTypeKind(Kind.BYTE_ARRAY, "byte[]", null); + } + if (desc.startsWith("L") && desc.endsWith(";")) { + String binary = desc.substring(1, desc.length() - 1).replace('/', '.'); + // Boxed scalars. + if ("java.lang.String".equals(binary)) { + return new PropertyTypeKind(Kind.STRING, binary, null); + } + if ("java.lang.Integer".equals(binary)) { + return new PropertyTypeKind(Kind.INT, binary, null); + } + if ("java.lang.Long".equals(binary)) { + return new PropertyTypeKind(Kind.LONG, binary, null); + } + if ("java.lang.Short".equals(binary)) { + return new PropertyTypeKind(Kind.SHORT, binary, null); + } + if ("java.lang.Byte".equals(binary)) { + return new PropertyTypeKind(Kind.BYTE, binary, null); + } + if ("java.lang.Character".equals(binary)) { + return new PropertyTypeKind(Kind.CHAR, binary, null); + } + if ("java.lang.Double".equals(binary)) { + return new PropertyTypeKind(Kind.DOUBLE, binary, null); + } + if ("java.lang.Float".equals(binary)) { + return new PropertyTypeKind(Kind.FLOAT, binary, null); + } + if ("java.lang.Boolean".equals(binary)) { + return new PropertyTypeKind(Kind.BOOLEAN, binary, null); + } + if ("java.util.Date".equals(binary)) { + return new PropertyTypeKind(Kind.DATE, binary, null); + } + // Property / ListProperty. + if ("com.codename1.properties.Property".equals(binary) + || "com.codename1.properties.StringProperty".equals(binary) + || "com.codename1.properties.IntProperty".equals(binary) + || "com.codename1.properties.LongProperty".equals(binary) + || "com.codename1.properties.DoubleProperty".equals(binary) + || "com.codename1.properties.FloatProperty".equals(binary) + || "com.codename1.properties.BooleanProperty".equals(binary) + || "com.codename1.properties.ByteProperty".equals(binary) + || "com.codename1.properties.CharProperty".equals(binary)) { + String elem = firstGenericArg(field.getSignature()); + if (elem == null) { + // Pre-erasure-only inference for typed subclasses: + if ("com.codename1.properties.StringProperty".equals(binary)) elem = "java.lang.String"; + else if ("com.codename1.properties.IntProperty".equals(binary)) elem = "java.lang.Integer"; + else if ("com.codename1.properties.LongProperty".equals(binary)) elem = "java.lang.Long"; + else if ("com.codename1.properties.DoubleProperty".equals(binary)) elem = "java.lang.Double"; + else if ("com.codename1.properties.FloatProperty".equals(binary)) elem = "java.lang.Float"; + else if ("com.codename1.properties.BooleanProperty".equals(binary)) elem = "java.lang.Boolean"; + else if ("com.codename1.properties.ByteProperty".equals(binary)) elem = "java.lang.Byte"; + else if ("com.codename1.properties.CharProperty".equals(binary)) elem = "java.lang.Character"; + else elem = "java.lang.String"; + } + return new PropertyTypeKind(Kind.PROPERTY, binary, elem); + } + if ("com.codename1.properties.ListProperty".equals(binary) + || "com.codename1.properties.SetProperty".equals(binary)) { + String elem = firstGenericArg(field.getSignature()); + if (elem == null) elem = "java.lang.String"; + return new PropertyTypeKind(Kind.LIST_PROPERTY, binary, elem); + } + if ("java.util.List".equals(binary) || "java.util.ArrayList".equals(binary)) { + String elem = firstGenericArg(field.getSignature()); + if (elem == null) elem = "java.lang.String"; + return new PropertyTypeKind(Kind.LIST, binary, elem); + } + // Anything else: treat as a reference to another mapped type. + // The caller still validates that the referenced class actually + // carries @Mapped / @Entity / @Bindable. + return new PropertyTypeKind(Kind.REFERENCE, binary, null); + } + return new PropertyTypeKind(Kind.UNSUPPORTED, "?", null); + } + + /// Extracts the first type argument from a generic signature string. The + /// signature for `Property` is + /// `Lcom/codename1/properties/Property;`. + /// Returns the dot-form of the first type or null when the signature is + /// missing / malformed. + static String firstGenericArg(String signature) { + if (signature == null) return null; + int lt = signature.indexOf('<'); + if (lt < 0) return null; + // Scan to the first balanced `;` at depth 1 (we entered `<`). + int depth = 1; + int i = lt + 1; + if (i >= signature.length()) return null; + char first = signature.charAt(i); + if (first != 'L') { + // Wildcards, type variables (T...), primitives -- bail out; the + // caller picks a sensible default. + return null; + } + int start = i + 1; + for (i = start; i < signature.length(); i++) { + char c = signature.charAt(i); + if (c == '<') depth++; + else if (c == '>') depth--; + else if (c == ';' && depth == 1) { + return signature.substring(start, i).replace('/', '.'); + } + } + return null; + } + + /// True when the value can be emitted directly into JSON / SQL with no + /// further mapping (it has a printable form and a primitive-typed parser + /// path). + public boolean isScalar() { + switch (kind) { + case STRING: case INT: case LONG: case SHORT: case BYTE: + case CHAR: case DOUBLE: case FLOAT: case BOOLEAN: + case DATE: case BYTE_ARRAY: case ENUM: + return true; + default: + return false; + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/resources/META-INF/services/com.codename1.maven.annotations.AnnotationProcessor b/maven/codenameone-maven-plugin/src/main/resources/META-INF/services/com.codename1.maven.annotations.AnnotationProcessor index 06a4f87ed3..be46f92207 100644 --- a/maven/codenameone-maven-plugin/src/main/resources/META-INF/services/com.codename1.maven.annotations.AnnotationProcessor +++ b/maven/codenameone-maven-plugin/src/main/resources/META-INF/services/com.codename1.maven.annotations.AnnotationProcessor @@ -1 +1,4 @@ com.codename1.maven.processors.RouteAnnotationProcessor +com.codename1.maven.processors.MappingAnnotationProcessor +com.codename1.maven.processors.BindingAnnotationProcessor +com.codename1.maven.processors.OrmAnnotationProcessor 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 new file mode 100644 index 0000000000..0d331a7970 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BindingAnnotationProcessorTest.java @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + */ +package com.codename1.maven.processors; + +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.ClassScanner; +import com.codename1.maven.annotations.JavaSourceCompiler; +import com.codename1.maven.annotations.ProcessorContext; + +import org.apache.maven.plugin.logging.SystemStreamLog; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.Opcodes; + +import java.io.File; +import java.net.URL; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/// Compiles a `@Bindable` POJO, runs the processor, and asserts the generated +/// `LoginModelBinder` class file is structurally sound (implements the +/// `Binder` interface, has `bind` and `type` methods). Listener-installation +/// and live binding behavior are exercised through the simulator at runtime +/// (out of scope for plugin unit tests). +public class BindingAnnotationProcessorTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void generatesBinderWithExpectedShape() throws Exception { + File classes = compileFixture( + "com.example.LoginModel", + "package com.example;\n" + + "import com.codename1.annotations.*;\n" + + "import com.codename1.binding.BindAttr;\n" + + "@Bindable\n" + + "public class LoginModel {\n" + + " @Bind(name=\"user\", attr=BindAttr.TEXT) public String user;\n" + + " @Bind(name=\"remember\", attr=BindAttr.SELECTED) public boolean remember;\n" + + " @Bind(name=\"banner\", attr=BindAttr.UIID, twoWay=false) public String bannerStyle;\n" + + " public LoginModel() {}\n" + + "}\n"); + runProcessorOrFail(classes); + + File binderFile = new File(classes, "com/example/LoginModelCn1Binder.class"); + assertTrue("generated binder file should exist: " + binderFile, binderFile.exists()); + File bootstrapFile = new File(classes, "cn1app/BinderBootstrap.class"); + assertTrue("BinderBootstrap should exist", bootstrapFile.exists()); + + Shape shape = readShape(binderFile); + assertTrue("binder should implement com.codename1.binding.Binder", + shape.interfaces.contains("com/codename1/binding/Binder")); + assertTrue("binder should expose type()", shape.methodNames.contains("type")); + assertTrue("binder should expose bind()", shape.methodNames.contains("bind")); + assertTrue("binder should expose register() static hook", + shape.methodNames.contains("register")); + } + + @Test + public void resolvesJavaBeansAccessorsOnPrivateField() throws Exception { + File classes = compileFixture( + "com.example.PrivateBean", + "package com.example;\n" + + "import com.codename1.annotations.*;\n" + + "@Bindable\n" + + "public class PrivateBean {\n" + + " @Bind(name=\"u\") private String user;\n" + + " public String getUser() { return user; }\n" + + " public void setUser(String u) { this.user = u; }\n" + + " public PrivateBean() {}\n" + + "}\n"); + runProcessorOrFail(classes); + assertTrue(new File(classes, "com/example/PrivateBeanCn1Binder.class").exists()); + } + + @Test + public void rejectsBindOnPrivateFieldWithoutAccessor() throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.Bad", + "package com.example;\n" + + "import com.codename1.annotations.*;\n" + + "@Bindable public class Bad {\n" + + " @Bind(name=\"x\") private String x;\n" + + " public Bad() {}\n" + + "}\n"), + classes, Arrays.asList(testClassesDir())); + ProcessorContext ctx = runProcessor(classes); + assertTrue("expected validation error on private field without accessor", ctx.hasErrors()); + } + + @Test + public void instrumentsSetterWithNotifyChanged() throws Exception { + File classes = compileFixture( + "com.example.NotifyBean", + "package com.example;\n" + + "import com.codename1.annotations.*;\n" + + "@Bindable\n" + + "public class NotifyBean {\n" + + " @Bind(name=\"u\") private String user;\n" + + " public String getUser() { return user; }\n" + + " public void setUser(String u) { this.user = u; }\n" + + " public NotifyBean() {}\n" + + "}\n"); + runProcessorOrFail(classes); + + // The setter bytes should now contain an INVOKESTATIC of + // Binders.notifyChanged before the void RETURN. + File beanFile = new File(classes, "com/example/NotifyBean.class"); + assertTrue(beanFile.exists()); + byte[] bytes = java.nio.file.Files.readAllBytes(beanFile.toPath()); + final boolean[] found = new boolean[1]; + new org.objectweb.asm.ClassReader(bytes).accept(new org.objectweb.asm.ClassVisitor(org.objectweb.asm.Opcodes.ASM9) { + @Override + public org.objectweb.asm.MethodVisitor visitMethod(int access, String name, String descriptor, + String signature, String[] exceptions) { + if (!"setUser".equals(name)) { + return null; + } + return new org.objectweb.asm.MethodVisitor(org.objectweb.asm.Opcodes.ASM9) { + @Override + public void visitMethodInsn(int opcode, String owner, String mname, + String desc, boolean iface) { + if (opcode == org.objectweb.asm.Opcodes.INVOKESTATIC + && "com/codename1/binding/Binders".equals(owner) + && "notifyChanged".equals(mname)) { + found[0] = true; + } + } + }; + } + }, 0); + assertTrue("setUser should be instrumented with Binders.notifyChanged", found[0]); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + private File compileFixture(String fqn, String src) throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource(fqn, src), + classes, + Arrays.asList(testClassesDir())); + return classes; + } + + private void runProcessorOrFail(File classesDir) throws Exception { + ProcessorContext ctx = runProcessor(classesDir); + if (ctx.hasErrors()) { + StringBuilder sb = new StringBuilder("processor reported errors:\n"); + for (ProcessorContext.ProcessingError e : ctx.getErrors()) sb.append(' ').append(e).append('\n'); + fail(sb.toString()); + } + } + + private ProcessorContext runProcessor(File classesDir) throws Exception { + Map index = ClassScanner.scan(classesDir); + BindingAnnotationProcessor proc = new BindingAnnotationProcessor(); + ProcessorContext ctx = new ProcessorContext(classesDir, tmp.newFolder(), + index, new SystemStreamLog()); + proc.start(ctx); + for (AnnotatedClass cls : index.values()) { + if (!cls.getClassAnnotations().isEmpty()) proc.processClass(cls, ctx); + } + proc.finish(ctx); + // Mirror ProcessAnnotationsMojo's flush step: write emitted + // bytecode back to disk so the modified class file overlays the + // original on subsequent file reads. + for (java.util.Map.Entry e : ctx.getEmittedClasses().entrySet()) { + File target = new File(classesDir, e.getKey() + ".class"); + File parent = target.getParentFile(); + if (parent != null && !parent.exists()) { + parent.mkdirs(); + } + java.nio.file.Files.write(target.toPath(), e.getValue()); + } + return ctx; + } + + private static File testClassesDir() throws Exception { + URL url = BindingAnnotationProcessorTest.class.getProtectionDomain() + .getCodeSource().getLocation(); + return new File(url.toURI()); + } + + private static Shape readShape(File classFile) throws Exception { + final Shape shape = new Shape(); + byte[] bytes = Files.readAllBytes(classFile.toPath()); + new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public void visit(int version, int access, String name, String signature, + String superName, String[] interfaces) { + if (interfaces != null) { + for (String i : interfaces) shape.interfaces.add(i); + } + } + + @Override + public org.objectweb.asm.MethodVisitor visitMethod(int access, String name, + String descriptor, String signature, + String[] exceptions) { + shape.methodNames.add(name); + return null; + } + }, ClassReader.SKIP_CODE); + return shape; + } + + private static final class Shape { + final Set interfaces = new LinkedHashSet(); + final Set methodNames = new LinkedHashSet(); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java new file mode 100644 index 0000000000..e9b85b8c59 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + */ +package com.codename1.maven.processors; + +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.ClassScanner; +import com.codename1.maven.annotations.JavaSourceCompiler; +import com.codename1.maven.annotations.ProcessorContext; + +import org.apache.maven.plugin.logging.SystemStreamLog; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/// End-to-end test for `MappingAnnotationProcessor`. Compiles a `@Mapped` +/// POJO + a `@Mapped` `PropertyBusinessObject`, runs the processor, loads the +/// emitted mappers in a child classloader, and exercises the JSON and XML +/// round-trips via reflection (we can't reference the generated types +/// directly here because they don't exist until the processor runs). +public class MappingAnnotationProcessorTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void pojoRoundTripsThroughGeneratedMapper() throws Exception { + File classes = compileFixture( + "com.example.User", + "package com.example;\n" + + "import com.codename1.annotations.*;\n" + + "@Mapped @XmlRoot(\"user\")\n" + + "public class User {\n" + + " @JsonProperty(\"first_name\") @XmlElement(\"first\")\n" + + " public String firstName;\n" + + " public int age;\n" + + " @XmlAttribute @JsonIgnore\n" + + " public String role;\n" + + " public User() {}\n" + + "}\n"); + runProcessorOrFail(classes); + + assertTrue(new File(classes, "com/example/UserCn1Mapper.class").exists()); + assertTrue(new File(classes, "cn1app/MapperBootstrap.class").exists()); + + // Load both the fixture and the generated mapper through a single child + // classloader so the generic bound on Mapper resolves against the + // same User class. + try (URLClassLoader cl = childLoader(classes)) { + Class userCls = cl.loadClass("com.example.User"); + Class mapperCls = cl.loadClass("com.example.UserCn1Mapper"); + Object mapper = mapperCls.newInstance(); + + Object user = userCls.newInstance(); + userCls.getField("firstName").set(user, "Alice"); + userCls.getField("age").setInt(user, 30); + userCls.getField("role").set(user, "admin"); + + // toMap excludes JsonIgnore field (role). + Method toMap = mapperCls.getMethod("toMap", userCls); + @SuppressWarnings("unchecked") + Map json = (Map) toMap.invoke(mapper, user); + assertEquals("Alice", json.get("first_name")); + assertEquals(Integer.valueOf(30), json.get("age")); + assertTrue("@JsonIgnore field 'role' should not appear in toMap output", + !json.containsKey("role")); + + // fromMap restores both fields. + Map back = new LinkedHashMap(); + back.put("first_name", "Bob"); + back.put("age", Integer.valueOf(42)); + Method fromMap = mapperCls.getMethod("fromMap", Map.class); + Object restored = fromMap.invoke(mapper, back); + assertEquals("Bob", userCls.getField("firstName").get(restored)); + assertEquals(42, userCls.getField("age").getInt(restored)); + } + } + + @Test + public void propertyFieldRoundTripsThroughJsonAndXml() throws Exception { + File classes = compileFixture( + "com.example.Item", + "package com.example;\n" + + "import com.codename1.annotations.*;\n" + + "import com.codename1.properties.*;\n" + + "@Mapped\n" + + "public class Item implements PropertyBusinessObject {\n" + + " public final Property name = new Property(\"name\");\n" + + " public final Property qty = new Property(\"qty\");\n" + + " private final PropertyIndex idx = new PropertyIndex(this, \"Item\", name, qty);\n" + + " public PropertyIndex getPropertyIndex() { return idx; }\n" + + " public Item() {}\n" + + "}\n"); + runProcessorOrFail(classes); + + try (URLClassLoader cl = childLoader(classes)) { + Class itemCls = cl.loadClass("com.example.Item"); + Class mapperCls = cl.loadClass("com.example.ItemCn1Mapper"); + Object mapper = mapperCls.newInstance(); + + // Create item and populate via the generated mapper's fromMap. + Map in = new LinkedHashMap(); + in.put("name", "Widget"); + in.put("qty", Integer.valueOf(7)); + Method fromMap = mapperCls.getMethod("fromMap", Map.class); + Object item = fromMap.invoke(mapper, in); + assertNotNull(item); + // Read back: name.get() / qty.get() on the Property fields. + Object nameProp = itemCls.getField("name").get(item); + Object qtyProp = itemCls.getField("qty").get(item); + Object name = nameProp.getClass().getMethod("get").invoke(nameProp); + Object qty = qtyProp.getClass().getMethod("get").invoke(qtyProp); + assertEquals("Widget", name); + assertEquals(Integer.valueOf(7), qty); + + // toMap round-trips back. + Method toMap = mapperCls.getMethod("toMap", itemCls); + @SuppressWarnings("unchecked") + Map out = (Map) toMap.invoke(mapper, item); + assertEquals("Widget", out.get("name")); + assertEquals(Integer.valueOf(7), out.get("qty")); + } + } + + @Test + public void rejectsAbstractMappedClass() throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.Bad", + "package com.example;\n" + + "import com.codename1.annotations.Mapped;\n" + + "@Mapped public abstract class Bad { public String name; }\n"), + classes, Arrays.asList(testClassesDir())); + ProcessorContext ctx = runProcessor(classes); + assertTrue("expected validation error on abstract @Mapped class", ctx.hasErrors()); + } + + @Test + public void rejectsMappedClassMissingNoArgConstructor() throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.NoArg", + "package com.example;\n" + + "import com.codename1.annotations.Mapped;\n" + + "@Mapped public class NoArg {\n" + + " public String name;\n" + + " public NoArg(String n) { this.name = n; }\n" + + "}\n"), + classes, Arrays.asList(testClassesDir())); + ProcessorContext ctx = runProcessor(classes); + assertTrue("expected validation error when no public no-arg constructor exists", + ctx.hasErrors()); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + private File compileFixture(String fqn, String src) throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource(fqn, src), + classes, + Arrays.asList(testClassesDir())); + return classes; + } + + private void runProcessorOrFail(File classesDir) throws Exception { + ProcessorContext ctx = runProcessor(classesDir, /*expectNoErrors*/ true); + if (ctx.hasErrors()) { + StringBuilder sb = new StringBuilder("processor reported errors:\n"); + for (ProcessorContext.ProcessingError e : ctx.getErrors()) sb.append(' ').append(e).append('\n'); + fail(sb.toString()); + } + } + + private ProcessorContext runProcessor(File classesDir) throws Exception { + return runProcessor(classesDir, false); + } + + private ProcessorContext runProcessor(File classesDir, boolean expectNoErrors) throws Exception { + Map index = ClassScanner.scan(classesDir); + MappingAnnotationProcessor proc = new MappingAnnotationProcessor(); + ProcessorContext ctx = new ProcessorContext(classesDir, tmp.newFolder(), + index, new SystemStreamLog()); + proc.start(ctx); + for (AnnotatedClass cls : index.values()) { + if (!cls.getClassAnnotations().isEmpty()) proc.processClass(cls, ctx); + } + proc.finish(ctx); + return ctx; + } + + private URLClassLoader childLoader(File classesDir) throws Exception { + URL[] urls = new URL[] { + classesDir.toURI().toURL(), + testClassesDir().toURI().toURL() + }; + return new URLClassLoader(urls, getClass().getClassLoader()); + } + + private static File testClassesDir() throws Exception { + URL url = MappingAnnotationProcessorTest.class.getProtectionDomain() + .getCodeSource().getLocation(); + return new File(url.toURI()); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/OrmAnnotationProcessorTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/OrmAnnotationProcessorTest.java new file mode 100644 index 0000000000..03982d3592 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/OrmAnnotationProcessorTest.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + */ +package com.codename1.maven.processors; + +import com.codename1.maven.annotations.AnnotatedClass; +import com.codename1.maven.annotations.ClassScanner; +import com.codename1.maven.annotations.JavaSourceCompiler; +import com.codename1.maven.annotations.ProcessorContext; + +import org.apache.maven.plugin.logging.SystemStreamLog; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.Opcodes; + +import java.io.File; +import java.net.URL; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/// Verifies the `@Entity` processor produces a structurally sound dao for a +/// simple POJO entity, and that the negative cases (missing @Id, relationship +/// fields) surface validation errors instead of silently emitting bad SQL. +public class OrmAnnotationProcessorTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void generatesDaoWithExpectedShape() throws Exception { + File classes = compileFixture( + "com.example.User", + "package com.example;\n" + + "import com.codename1.annotations.*;\n" + + "@Entity(table=\"users\")\n" + + "public class User {\n" + + " @Id(autoIncrement=true) public long id;\n" + + " @Column(name=\"full_name\", nullable=false) public String name;\n" + + " public int age;\n" + + " @DbTransient public String tempCache;\n" + + " public User() {}\n" + + "}\n"); + runProcessorOrFail(classes); + + File daoFile = new File(classes, "com/example/UserCn1Dao.class"); + assertTrue("generated dao file should exist: " + daoFile, daoFile.exists()); + File bootstrapFile = new File(classes, "cn1app/DaoBootstrap.class"); + assertTrue("DaoBootstrap should exist", bootstrapFile.exists()); + + Shape shape = readShape(daoFile); + assertTrue("dao should implement com.codename1.orm.Dao", + shape.interfaces.contains("com/codename1/orm/Dao")); + assertTrue(shape.methodNames.contains("createTable")); + assertTrue(shape.methodNames.contains("insert")); + assertTrue(shape.methodNames.contains("update")); + assertTrue(shape.methodNames.contains("delete")); + assertTrue(shape.methodNames.contains("findById")); + assertTrue(shape.methodNames.contains("findAll")); + assertTrue(shape.methodNames.contains("find")); + assertTrue(shape.methodNames.contains("dropTable")); + assertTrue(shape.methodNames.contains("attach")); + } + + @Test + public void rejectsEntityMissingIdField() throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.NoId", + "package com.example;\n" + + "import com.codename1.annotations.*;\n" + + "@Entity public class NoId {\n" + + " public String name;\n" + + " public NoId() {}\n" + + "}\n"), + classes, Arrays.asList(testClassesDir())); + ProcessorContext ctx = runProcessor(classes); + assertTrue("expected validation error when @Id is missing", ctx.hasErrors()); + } + + @Test + public void rejectsEntityWithRelationshipField() throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.Order", + "package com.example;\n" + + "import com.codename1.annotations.*;\n" + + "@Entity public class Order {\n" + + " @Id public long id;\n" + + " public java.util.List tags;\n" + + " public Order() {}\n" + + "}\n"), + classes, Arrays.asList(testClassesDir())); + ProcessorContext ctx = runProcessor(classes); + assertTrue("expected validation error on relationship field", ctx.hasErrors()); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + private File compileFixture(String fqn, String src) throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource(fqn, src), + classes, + Arrays.asList(testClassesDir())); + return classes; + } + + private void runProcessorOrFail(File classesDir) throws Exception { + ProcessorContext ctx = runProcessor(classesDir); + if (ctx.hasErrors()) { + StringBuilder sb = new StringBuilder("processor reported errors:\n"); + for (ProcessorContext.ProcessingError e : ctx.getErrors()) sb.append(' ').append(e).append('\n'); + fail(sb.toString()); + } + } + + private ProcessorContext runProcessor(File classesDir) throws Exception { + Map index = ClassScanner.scan(classesDir); + OrmAnnotationProcessor proc = new OrmAnnotationProcessor(); + ProcessorContext ctx = new ProcessorContext(classesDir, tmp.newFolder(), + index, new SystemStreamLog()); + proc.start(ctx); + for (AnnotatedClass cls : index.values()) { + if (!cls.getClassAnnotations().isEmpty()) proc.processClass(cls, ctx); + } + proc.finish(ctx); + return ctx; + } + + private static File testClassesDir() throws Exception { + URL url = OrmAnnotationProcessorTest.class.getProtectionDomain() + .getCodeSource().getLocation(); + return new File(url.toURI()); + } + + private static Shape readShape(File classFile) throws Exception { + final Shape shape = new Shape(); + byte[] bytes = Files.readAllBytes(classFile.toPath()); + new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public void visit(int version, int access, String name, String signature, + String superName, String[] interfaces) { + if (interfaces != null) { + for (String i : interfaces) shape.interfaces.add(i); + } + } + + @Override + public org.objectweb.asm.MethodVisitor visitMethod(int access, String name, + String descriptor, String signature, + String[] exceptions) { + shape.methodNames.add(name); + return null; + } + }, ClassReader.SKIP_CODE); + return shape; + } + + private static final class Shape { + final Set interfaces = new LinkedHashSet(); + final Set methodNames = new LinkedHashSet(); + } +}