From f9ee8589f59baaba8fe720f900470aadf0fa3936 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 27 May 2026 05:46:22 +0300 Subject: [PATCH 01/12] Build-time POJO annotation frameworks: JSON/XML mapping, component binding, SQLite ORM Three new bytecode-driven annotation processors layered on top of the AnnotationProcessor SPI introduced in #5037. Each generates a typed runtime artifact next to the annotated class plus a tiny Index class that registers everything with a public runtime registry. There is no Class.forName, no service loader, and no field reflection: every read and write in the generated code is a direct symbol reference that ParparVM rename and R8 obfuscation rewrite together with the generated class. Application surface (com.codename1.annotations): - @Mapped, @JsonProperty, @JsonIgnore, @XmlRoot, @XmlElement, @XmlAttribute, @XmlTransient -- JSON / XML object mapping. - @Bindable, @Bind -- component binding for Form / Container UI by Component#getName. - @Entity, @Id, @Column, @DbTransient -- SQLite ORM mapped through com.codename1.db.Database. Each works on plain POJOs with public fields and on PropertyBusinessObject-style classes with Property / ListProperty fields in the same class. Runtime entry points (no reflection, no dynamic classloading): - com.codename1.mapping.Mappers#toJson / #fromJson / #toXml / #fromXml. - com.codename1.binding.Binders#bind returning a Binding handle with refresh() / commit() / disconnect(). - com.codename1.orm.EntityManager#open(dbName).dao(EntityClass.class), Dao#createTable / #insert / #update / #delete / #findById / #findAll / #find(where, params). cn1-core ships no-op stub classes for the three generated index types so application code can reference Mappers / Binders / EntityManager at compile time even when the project carries no @Mapped / @Bindable / @Entity classes; the generated index shadows the stub at build time. Build pipeline (maven/codenameone-maven-plugin): - PropertyTypeKind: shared field-type classifier (scalars, Property, ListProperty, List, nested references). Extracts the first generic argument from the class-file signature so the three processors don't need to redo the type inference per call. - MappingAnnotationProcessor: writes one XxxMapper per @Mapped class + a MappersIndex, via JavaSourceCompiler (JSR 199), into target/classes. - BindingAnnotationProcessor: writes one XxxBinder per @Bindable class + a BindersIndex. Generated binders include a recursive Container#getComponentAt walk so name lookup is reflection-free. - OrmAnnotationProcessor: writes one XxxDao per @Entity + a DaosIndex. Daos issue prepared statements through Database, read rows through Cursor / Row, and back-fill auto-increment ids via SELECT last_insert_rowid(). - ClassScanner + FieldInfo now retain the field generic signature so processors can resolve Property and List element types without rereading the .class file. - META-INF/services/AnnotationProcessor: registers the three new processors. Same SPI as the routing processor. Build-server wiring: - Executor#annotationFrameworksInstallSource emits `new com.codename1.mapping.generated.MappersIndex();` + `new com.codename1.binding.generated.BindersIndex();` + `new com.codename1.orm.generated.DaosIndex();` as a stub fragment. - IPhoneBuilder and AndroidGradleBuilder splice the fragment in before the first Display.init -- next to the existing @Route install line. Tests (9 new, 84 -> 93 plugin tests green): - MappingAnnotationProcessorTest: end-to-end JSON round-trip for a POJO (public fields, @JsonIgnore, @JsonProperty rename) and a PropertyBusinessObject with Property + Property. Negative cases: abstract @Mapped class, @Mapped class without a public no-arg constructor. - BindingAnnotationProcessorTest: ASM-introspected shape check on the generated LoginModelBinder. Negative case: @Bind on a private field. - OrmAnnotationProcessorTest: ASM-introspected shape check on the generated UserDao (createTable / insert / update / delete / findById / findAll / find / attach / type / tableName). Negative cases: @Entity without @Id, @Entity field that points to another entity or a List (relationships are out of scope for v1). Docs (docs/developer-guide): - Annotation-JSON-XML-Mapping.asciidoc - Annotation-Component-Binding.asciidoc - Annotation-SQLite-ORM.asciidoc All three are wired into developer-guide.asciidoc right after the Deep-Links-Routing chapter. asciidoctor lint passes on each new page. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/com/codename1/annotations/Bind.java | 54 ++ .../com/codename1/annotations/Bindable.java | 57 ++ .../src/com/codename1/annotations/Column.java | 48 ++ .../codename1/annotations/DbTransient.java | 35 + .../src/com/codename1/annotations/Entity.java | 60 ++ .../src/com/codename1/annotations/Id.java | 40 + .../com/codename1/annotations/JsonIgnore.java | 35 + .../codename1/annotations/JsonProperty.java | 38 + .../src/com/codename1/annotations/Mapped.java | 64 ++ .../codename1/annotations/XmlAttribute.java | 38 + .../com/codename1/annotations/XmlElement.java | 38 + .../com/codename1/annotations/XmlRoot.java | 38 + .../codename1/annotations/XmlTransient.java | 35 + .../src/com/codename1/binding/BindAttr.java | 49 ++ .../src/com/codename1/binding/Binder.java | 45 + .../src/com/codename1/binding/Binders.java | 101 +++ .../src/com/codename1/binding/Binding.java | 41 + .../binding/generated/BindersIndex.java | 32 + .../com/codename1/binding/package-info.java | 26 + .../src/com/codename1/mapping/Mapper.java | 64 ++ .../src/com/codename1/mapping/Mappers.java | 275 ++++++ .../mapping/generated/MappersIndex.java | 38 + .../com/codename1/mapping/package-info.java | 27 + CodenameOne/src/com/codename1/orm/Dao.java | 82 ++ .../src/com/codename1/orm/EntityManager.java | 150 ++++ .../codename1/orm/generated/DaosIndex.java | 32 + .../src/com/codename1/orm/package-info.java | 26 + .../Annotation-Component-Binding.asciidoc | 136 +++ .../Annotation-JSON-XML-Mapping.asciidoc | 178 ++++ .../Annotation-SQLite-ORM.asciidoc | 152 ++++ docs/developer-guide/developer-guide.asciidoc | 6 + .../builders/AndroidGradleBuilder.java | 4 +- .../java/com/codename1/builders/Executor.java | 18 + .../com/codename1/builders/IPhoneBuilder.java | 1 + .../maven/annotations/ClassScanner.java | 3 +- .../maven/annotations/FieldInfo.java | 14 +- .../BindingAnnotationProcessor.java | 453 ++++++++++ .../MappingAnnotationProcessor.java | 783 ++++++++++++++++++ .../processors/OrmAnnotationProcessor.java | 637 ++++++++++++++ .../maven/processors/PropertyTypeKind.java | 208 +++++ ...ame1.maven.annotations.AnnotationProcessor | 3 + .../BindingAnnotationProcessorTest.java | 154 ++++ .../MappingAnnotationProcessorTest.java | 221 +++++ .../OrmAnnotationProcessorTest.java | 175 ++++ 44 files changed, 4711 insertions(+), 3 deletions(-) create mode 100644 CodenameOne/src/com/codename1/annotations/Bind.java create mode 100644 CodenameOne/src/com/codename1/annotations/Bindable.java create mode 100644 CodenameOne/src/com/codename1/annotations/Column.java create mode 100644 CodenameOne/src/com/codename1/annotations/DbTransient.java create mode 100644 CodenameOne/src/com/codename1/annotations/Entity.java create mode 100644 CodenameOne/src/com/codename1/annotations/Id.java create mode 100644 CodenameOne/src/com/codename1/annotations/JsonIgnore.java create mode 100644 CodenameOne/src/com/codename1/annotations/JsonProperty.java create mode 100644 CodenameOne/src/com/codename1/annotations/Mapped.java create mode 100644 CodenameOne/src/com/codename1/annotations/XmlAttribute.java create mode 100644 CodenameOne/src/com/codename1/annotations/XmlElement.java create mode 100644 CodenameOne/src/com/codename1/annotations/XmlRoot.java create mode 100644 CodenameOne/src/com/codename1/annotations/XmlTransient.java create mode 100644 CodenameOne/src/com/codename1/binding/BindAttr.java create mode 100644 CodenameOne/src/com/codename1/binding/Binder.java create mode 100644 CodenameOne/src/com/codename1/binding/Binders.java create mode 100644 CodenameOne/src/com/codename1/binding/Binding.java create mode 100644 CodenameOne/src/com/codename1/binding/generated/BindersIndex.java create mode 100644 CodenameOne/src/com/codename1/binding/package-info.java create mode 100644 CodenameOne/src/com/codename1/mapping/Mapper.java create mode 100644 CodenameOne/src/com/codename1/mapping/Mappers.java create mode 100644 CodenameOne/src/com/codename1/mapping/generated/MappersIndex.java create mode 100644 CodenameOne/src/com/codename1/mapping/package-info.java create mode 100644 CodenameOne/src/com/codename1/orm/Dao.java create mode 100644 CodenameOne/src/com/codename1/orm/EntityManager.java create mode 100644 CodenameOne/src/com/codename1/orm/generated/DaosIndex.java create mode 100644 CodenameOne/src/com/codename1/orm/package-info.java create mode 100644 docs/developer-guide/Annotation-Component-Binding.asciidoc create mode 100644 docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc create mode 100644 docs/developer-guide/Annotation-SQLite-ORM.asciidoc create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BindingAnnotationProcessor.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/PropertyTypeKind.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BindingAnnotationProcessorTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/MappingAnnotationProcessorTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/OrmAnnotationProcessorTest.java diff --git a/CodenameOne/src/com/codename1/annotations/Bind.java b/CodenameOne/src/com/codename1/annotations/Bind.java new file mode 100644 index 0000000000..7c5d8858cb --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Bind.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.annotations; + +import 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. +@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. + boolean twoWay() default true; +} 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..1118bf145e --- /dev/null +++ b/CodenameOne/src/com/codename1/binding/Binders.java @@ -0,0 +1,101 @@ +/* + * 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.HashMap; +import java.util.Map; + +/// Public entry point for the build-time component binding framework. +/// +/// `@Bindable` classes are picked up by the Maven plugin's annotation +/// processor at build time. The generated `Binder` is wired into this +/// registry through a generated `com.codename1.binding.generated.BindersIndex` +/// whose no-arg constructor fires the first time the registry is touched. +/// (Projects with no `@Bindable` classes fall through to an empty no-op +/// stub shipped with cn1-core, so the lookup degrades cleanly.) +/// +/// ```java +/// Form f = (Form) Resources.getGlobalResources().getForm("LoginForm"); +/// LoginModel model = new LoginModel(); +/// Binding b = Binders.bind(model, f); +/// // user types -- model is updated; mutate the model and call b.refresh(). +/// ``` +public final class Binders { + + private static final Map, Binder> BY_TYPE = new HashMap, Binder>(); + private static boolean indexLoaded = false; + + private Binders() { } + + /// Installs a binder for `binder.type()`. Generated binders call this + /// from the `BindersIndex` static initializer; 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_TYPE.put(binder.type(), binder); + } + + /// Looks up the binder for `type` or null when no binder is registered. + @SuppressWarnings("unchecked") + public static Binder get(Class type) { + ensureIndexLoaded(); + return (Binder) BY_TYPE.get(type); + } + + /// 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) get((Class) model.getClass()); + 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."); + } + return binder.bind(model, container); + } + + private static synchronized void ensureIndexLoaded() { + if (indexLoaded) return; + indexLoaded = true; + try { + new com.codename1.binding.generated.BindersIndex(); + } catch (NoClassDefFoundError ignore) { + // No @Bindable types in this project. + } catch (RuntimeException ignore) { + // Index already loaded. + } + } +} 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/generated/BindersIndex.java b/CodenameOne/src/com/codename1/binding/generated/BindersIndex.java new file mode 100644 index 0000000000..6231ecaa2f --- /dev/null +++ b/CodenameOne/src/com/codename1/binding/generated/BindersIndex.java @@ -0,0 +1,32 @@ +/* + * 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.generated; + +/// Compile-time stub for the @Bindable annotation processor output. Projects +/// that ship one or more `@Bindable` classes get a real `BindersIndex` +/// generated under `target/classes`, shadowing this stub at runtime. +public class BindersIndex { + public BindersIndex() { + // No-op. Real implementation generated by cn1:process-annotations. + } +} 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..2cb9e84c67 --- /dev/null +++ b/CodenameOne/src/com/codename1/mapping/Mappers.java @@ -0,0 +1,275 @@ +/* + * 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 are picked up by the Maven plugin's annotation processor +/// at build time. A generated `Mapper` is wired into this registry through a +/// generated `com.codename1.mapping.generated.MappersIndex` whose static +/// initializer fires the first time the registry is touched. +/// +/// Typical use: +/// +/// ```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); +/// ``` +/// +/// `register(...)` is public so application code can install a hand-written +/// mapper for a class the build-time processor cannot see (e.g. classes that +/// live in a dependency JAR). Generated mappers register themselves through +/// the same call. +public final class Mappers { + + private static final Map, Mapper> BY_TYPE = new HashMap, Mapper>(); + private static boolean indexLoaded = false; + + private Mappers() { } + + /// Installs a mapper for `mapper.type()`. Subsequent calls with the same + /// type replace the previously registered mapper. Thread-safe relative to + /// `get` (the registry is a plain HashMap, written from app init and read + /// at steady state; concurrent registration during steady-state lookups + /// is not supported and not needed in practice). + public static void register(Mapper mapper) { + if (mapper == null) { + throw new IllegalArgumentException("mapper is null"); + } + BY_TYPE.put(mapper.type(), mapper); + } + + /// Looks up the mapper for `type`, returning `null` when no mapper is + /// registered. The first call lazily triggers the generated + /// `MappersIndex` static initializer when present, so application code + /// does not need to wire it up explicitly. + @SuppressWarnings("unchecked") + public static Mapper get(Class type) { + ensureIndexLoaded(); + return (Mapper) BY_TYPE.get(type); + } + + /// 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) get(instance.getClass()); + 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) get(instance.getClass()); + 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); + } + + // --------------------------------------------------------------- + // Index bootstrap + // --------------------------------------------------------------- + + /// Lazily instantiates the generated `MappersIndex` class once. We use a + /// direct symbol reference (compiled in only when the build emits the + /// class) rather than `Class.forName`, so ParparVM / R8 rename the call + /// site and the generated class together and the binding survives in + /// shipped builds. The reference is guarded by `try` so projects without + /// the annotation Mojo on their build path still compile and run -- the + /// class is generated only when the project actually has @Mapped types. + private static synchronized void ensureIndexLoaded() { + if (indexLoaded) return; + indexLoaded = true; + try { + new com.codename1.mapping.generated.MappersIndex(); + } catch (NoClassDefFoundError ignore) { + // No @Mapped types in this project -- ignore. + } catch (RuntimeException ignore) { + // Index already loaded by another path. + } + } + + 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."); + } + + // --------------------------------------------------------------- + // Tiny JSON writer + // --------------------------------------------------------------- + // + // Hand-rolled rather than reusing PropertyIndex / Result so this class + // works on plain POJO maps without any cn1.properties dependency. We only + // emit the subset that JSONParser can read back: strings, numbers, + // booleans, null, nested maps, lists. + + 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(Character.forDigit((c >> 4) & 0xF, 16)); + sb.append(Character.forDigit(c & 0xF, 16)); + } else { + sb.append(c); + } + } + } + sb.append('"'); + } +} diff --git a/CodenameOne/src/com/codename1/mapping/generated/MappersIndex.java b/CodenameOne/src/com/codename1/mapping/generated/MappersIndex.java new file mode 100644 index 0000000000..3a192e748b --- /dev/null +++ b/CodenameOne/src/com/codename1/mapping/generated/MappersIndex.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.mapping.generated; + +/// Compile-time stub for the @Mapped annotation processor output. Projects +/// that ship one or more `@Mapped` classes get a real `MappersIndex` +/// generated under `target/classes`, shadowing this stub at runtime; projects +/// with no `@Mapped` classes fall through to this no-op and the +/// `com.codename1.mapping.Mappers` registry stays empty. +/// +/// Application code never references this class directly -- the lazy +/// instantiation happens inside `Mappers#ensureIndexLoaded`, by direct symbol +/// reference, so the iOS `Class.forName` ban does not apply. +public class MappersIndex { + public MappersIndex() { + // No-op. Real implementation generated by cn1:process-annotations. + } +} 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..2b1190ad07 --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/EntityManager.java @@ -0,0 +1,150 @@ +/* + * 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 are picked up by the Maven plugin's annotation processor +/// at build time. The generated `Dao` is wired into an internal registry via +/// `com.codename1.orm.generated.DaosIndex` whose static initializer fires the +/// first time `EntityManager.open` is called. +/// +/// Typical use: +/// +/// ```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(); +/// ``` +public final class EntityManager { + + private static final Map, Dao> BY_TYPE = new HashMap, Dao>(); + private static boolean indexLoaded = false; + + 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 already opened with a custom + /// path) in an `EntityManager`. The caller retains ownership; `close()` + /// will still close the database. + public static EntityManager open(Database db) { + if (db == null) { + throw new IllegalArgumentException("database is null"); + } + return new EntityManager(db); + } + + /// Registers a hand-written dao. The build-time-generated `DaosIndex` + /// uses the same call; explicit registration is only needed for entity + /// classes that live in a dependency JAR the annotation Mojo cannot scan. + public static void registerDao(Dao dao) { + if (dao == null) { + throw new IllegalArgumentException("dao is null"); + } + BY_TYPE.put(dao.type(), 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) { + ensureIndexLoaded(); + Dao d = (Dao) BY_TYPE.get(entityClass); + if (d == null) { + throw new IllegalStateException("No dao registered for " + + entityClass.getName() + ". Add @Entity and ensure the " + + "cn1:process-annotations Mojo ran during build."); + } + d.attach(db); + return d; + } + + /// The underlying `Database`. Use it for raw SQL when the dao surface is + /// not enough. + public Database database() { + return db; + } + + /// Begin a transaction on the underlying database. Equivalent to + /// `database().beginTransaction()`. + public void beginTransaction() throws IOException { + db.beginTransaction(); + } + + /// Commit a transaction on the underlying database. + public void commitTransaction() throws IOException { + db.commitTransaction(); + } + + /// Roll back a transaction on the underlying database. + public void rollbackTransaction() throws IOException { + db.rollbackTransaction(); + } + + /// Closes the underlying database. Idempotent. + public void close() throws IOException { + if (closed) return; + closed = true; + db.close(); + } + + private static synchronized void ensureIndexLoaded() { + if (indexLoaded) return; + indexLoaded = true; + try { + new com.codename1.orm.generated.DaosIndex(); + } catch (NoClassDefFoundError ignore) { + // No @Entity types in this project. + } catch (RuntimeException ignore) { + // Already loaded. + } + } +} diff --git a/CodenameOne/src/com/codename1/orm/generated/DaosIndex.java b/CodenameOne/src/com/codename1/orm/generated/DaosIndex.java new file mode 100644 index 0000000000..55620cf035 --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/generated/DaosIndex.java @@ -0,0 +1,32 @@ +/* + * 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.generated; + +/// Compile-time stub for the @Entity annotation processor output. Projects +/// that ship one or more `@Entity` classes get a real `DaosIndex` generated +/// under `target/classes`, shadowing this stub at runtime. +public class DaosIndex { + public DaosIndex() { + // No-op. Real implementation generated by cn1:process-annotations. + } +} 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/docs/developer-guide/Annotation-Component-Binding.asciidoc b/docs/developer-guide/Annotation-Component-Binding.asciidoc new file mode 100644 index 0000000000..553258d059 --- /dev/null +++ b/docs/developer-guide/Annotation-Component-Binding.asciidoc @@ -0,0 +1,136 @@ +== 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 `Binder` per `@Bindable` class at build time, so the wiring happens +through direct symbol references -- no reflection, no listener bookkeeping +in application code. + +It is 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) + public Property user = + new Property<>("user"); + + @Bind(name = "passwordField", attr = BindAttr.TEXT) + public String password; + + // One-way: the model decides the style, the user never edits it. + @Bind(name = "banner", attr = BindAttr.UIID, twoWay = false) + public String bannerStyle; + + @Bind(name = "rememberMe", attr = BindAttr.SELECTED) + public boolean remember; +} +---- + +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 and call b.refresh() to update the form. +model.user.set("alice"); +b.refresh(); + +// Before submitting: +b.commit(); // pull non-two-way fields back into the model + +// 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. +| `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 so the + form can be garbage-collected without keeping the + model alive. +|=== + +=== POJOs versus Property objects + +A `String`, `int`, or `boolean` field is read and written through direct +field access. 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 private field (use a `Property` field if you + want a real accessor + change notification). +* `@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 +`com.codename1.binding.generated.XxxBinder` per `@Bindable` class and a +single `BindersIndex` whose no-arg constructor registers them. The iOS +and Android per-build stubs call `new BindersIndex()` before +`Display.init`, so the registry is ready by the time the application's +first form opens. + +Generated binders never call `Class.forName`; the lookup of the target +component walks `Container#getComponentAt` directly. The binding +survives ParparVM / R8 obfuscation because the call sites and the +generated class are renamed together. 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..8c11bc9a2b --- /dev/null +++ b/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc @@ -0,0 +1,178 @@ +== 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 tightly-typed `Mapper` for +every `@Mapped` class, and ships it inside the same `target/classes` tree +as the rest of the application. 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 does not 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) +do not 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 `XxxMapper` class is generated under + `com.codename1.mapping.generated` for every `@Mapped` type, plus a + single `MappersIndex` whose no-arg constructor registers them all + with `Mappers#register`. +. The iOS and Android per-build stubs call `new MappersIndex()` before + `Display.init`, so the registry is populated by the time application + code runs. + +At runtime there is no `Class.forName`, no reflection, and no service +loader: every read and write in a generated mapper is a direct symbol +reference. ParparVM and R8 rename the call sites and the generated +class together, so the binding survives obfuscation in shipped builds. + +=== Custom mappers + +Sometimes a class lives in a third-party JAR the build cannot annotate. +Hand-write a `Mapper` and register it during `Display#init`: + +[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..c891aec5cc --- /dev/null +++ b/docs/developer-guide/Annotation-SQLite-ORM.asciidoc @@ -0,0 +1,152 @@ +== 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 `XxxDao` per entity, and hands them 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, deliberately simplified alternative to +the existing imperative `SQLMap`. It does not 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 auto-increment `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 facade over `Database`. The underlying +connection is reachable through `em.database()` for raw SQL when the +dao surface is not 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`, ...) are not 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 is not 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 +`com.codename1.orm.generated.XxxDao` per `@Entity` and a single +`DaosIndex` whose no-arg constructor registers them with +`EntityManager#registerDao`. The iOS and Android per-build stubs call +`new DaosIndex()` before `Display.init`, so `em.dao(User.class)` works +the first time it is called. + +There is no `Class.forName`, no service loader, and no field reflection +at runtime: every parameter bind, every column read, every constructor +call is a direct symbol reference that the iOS `Class.forName` ban and +the ParparVM rename pass leave intact. 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/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..1acc1cf87d 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,22 @@ 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 in one go. Each call is a direct symbol reference so + /// ParparVM iOS / R8 Android rename the call site and the generated + /// class together; projects without the corresponding annotations get + /// an empty fragment because cn1-core ships a no-op stub for each index + /// class that is shadowed by the real one at build time. The constructors + /// register the per-class mapper / binder / dao with their respective + /// runtime registries. + protected static String annotationFrameworksInstallSource(File sourceZip, String indent) { + StringBuilder sb = new StringBuilder(); + sb.append(indent).append("new com.codename1.mapping.generated.MappersIndex();\n"); + sb.append(indent).append("new com.codename1.binding.generated.BindersIndex();\n"); + sb.append(indent).append("new com.codename1.orm.generated.DaosIndex();\n"); + return sb.toString(); + } } 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..15a8c0cfc6 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BindingAnnotationProcessor.java @@ -0,0 +1,453 @@ +/* + * 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 `@Bindable` processor. Generates one `XxxBinder` Java class per +/// `@Bindable` type plus a `BindersIndex` that lazily registers them all with +/// `com.codename1.binding.Binders` on first use. +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 GENERATED_PACKAGE = "com.codename1.binding.generated"; + + 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.simpleName = simpleName(cls.getBinaryName()); + bc.binderSimpleName = bc.simpleName + "Binder"; + bc.binderBinaryName = GENERATED_PACKAGE + "." + bc.binderSimpleName; + + for (FieldInfo f : cls.getFields()) { + if (f.isStatic()) continue; + AnnotationValues bind = f.getAnnotation(BIND_DESC); + if (bind == null) continue; + if (!f.isPublic()) { + ctx.error(cls, "@Bind on " + bc.binaryName + "." + f.getName() + + " requires a public field"); + continue; + } + String compName = bind.getString("name"); + if (compName == null || compName.length() == 0) { + ctx.error(cls, "@Bind on " + bc.binaryName + "." + f.getName() + + " requires name() to identify the target component"); + continue; + } + BoundField bf = new BoundField(); + bf.fieldName = f.getName(); + bf.componentName = compName; + bf.attr = readAttr(bind); + bf.twoWay = bind.getBoolOrDefault("twoWay", true); + bf.kind = PropertyTypeKind.of(f); + if (bf.kind.kind == PropertyTypeKind.Kind.UNSUPPORTED) { + ctx.error(cls, "@Bind field " + bc.binaryName + "." + f.getName() + + " has an unsupported type (descriptor " + f.getDescriptor() + ")"); + continue; + } + bc.fields.add(bf); + } + if (bc.fields.isEmpty()) { + // Accepted with no fields is a no-op binder; still generate so the + // user gets a registration hit even if they remove every @Bind. + } + accepted.put(bc.binaryName, bc); + } + + private static BindAttrName readAttr(AnnotationValues bind) { + Object v = bind.get("attr"); + // ASM gives enums back as `String[] { internalDescriptor, valueName }`. + if (v instanceof String[]) { + String name = ((String[]) v)[1]; + for (BindAttrName candidate : BindAttrName.values()) { + if (candidate.name().equals(name)) return candidate; + } + } + return BindAttrName.TEXT; + } + + @Override + public void finish(ProcessorContext ctx) throws ProcessingException { + if (ctx.hasErrors()) return; + if (accepted.isEmpty()) return; + + Map sources = new LinkedHashMap(); + for (BindableClass bc : accepted.values()) { + sources.put(bc.binderBinaryName, generateBinderSource(bc)); + } + sources.put(GENERATED_PACKAGE + ".BindersIndex", generateIndexSource(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 binder sources: " + + ioe.getMessage(), ioe); + } + ctx.getLog().info("cn1: generated " + accepted.size() + + " @Bindable binder(s) under " + GENERATED_PACKAGE); + } + + // --------------------------------------------------------------- + // Source generation + // --------------------------------------------------------------- + + private static String generateBinderSource(BindableClass bc) { + StringBuilder sb = new StringBuilder(2048); + sb.append("package ").append(GENERATED_PACKAGE).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 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 every component up front so refresh() / commit() / disconnect() + // never re-walk the container. + 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"); + } + + // Initial push from model -> components. + sb.append(" final Runnable _refresh = new Runnable() { public void run() {\n"); + for (int i = 0; i < bc.fields.size(); i++) { + emitRefreshOne(sb, bc.fields.get(i), i); + } + sb.append(" }};\n"); + sb.append(" _refresh.run();\n"); + + // Two-way listeners. + sb.append(" final Runnable _commit = new Runnable() { public void run() {\n"); + for (int i = 0; i < bc.fields.size(); i++) { + BoundField f = bc.fields.get(i); + if (!f.twoWay) continue; + emitCommitOne(sb, f, i); + } + sb.append(" }};\n"); + + for (int i = 0; i < bc.fields.size(); i++) { + BoundField f = bc.fields.get(i); + if (!f.twoWay) continue; + emitListenerInstall(sb, f, i); + } + + sb.append(" return new com.codename1.binding.Binding() {\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(" for (com.codename1.ui.events.ActionListener _d : _disposers) _d.actionPerformed(null);\n"); + sb.append(" _disposers.clear();\n"); + sb.append(" }\n"); + sb.append(" };\n"); + sb.append(" }\n\n"); + + // Recursive component-by-name lookup. cn1 doesn't ship one as a + // public Container API, so we inline it -- keeps each binder + // self-contained with no shared runtime dependency. + 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 generateIndexSource(Iterable classes) { + StringBuilder sb = new StringBuilder(1024); + sb.append("package ").append(GENERATED_PACKAGE).append(";\n\n"); + sb.append("// Auto-generated by cn1:process-annotations. Do not edit.\n"); + sb.append("public final class BindersIndex {\n"); + sb.append(" public BindersIndex() {\n"); + for (BindableClass bc : classes) { + sb.append(" com.codename1.binding.Binders.register(new ").append(bc.binderSimpleName).append("());\n"); + } + sb.append(" }\n"); + sb.append("}\n"); + return sb.toString(); + } + + // --------------------------------------------------------------- + // Per-attribute code generation + // --------------------------------------------------------------- + + private static void emitRefreshOne(StringBuilder sb, BoundField f, int i) { + sb.append(" if (_c").append(i).append(" != null) {\n"); + String modelExpr = readModelExpr(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(booleanModelExpr(f, modelExpr)).append(");\n"); + break; + case VISIBLE: + sb.append(" _c").append(i).append(".setVisible(").append(booleanModelExpr(f, modelExpr)).append(");\n"); + break; + case ENABLED: + sb.append(" _c").append(i).append(".setEnabled(").append(booleanModelExpr(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(booleanModelExpr(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(booleanModelExpr(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"); + emitWriteModelFromString(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"); + emitWriteModelFromBoolean(sb, f, "_v"); + break; + default: + break; + } + sb.append(" }\n"); + } + + private static void emitListenerInstall(StringBuilder sb, BoundField f, int i) { + // Action listener captures the model field by reference; we listen on + // both TextArea (DataChanged via DataChangedListener -> ActionListener + // bridge isn't free, so we use addDataChangedListener) and CheckBox / + // RadioButton. + 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(" String _v = _ta.getText();\n"); + emitWriteModelFromString(sb, f, "_v"); + 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(" 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"); + emitWriteModelFromBoolean(sb, f, "_v"); + 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 readModelExpr(BoundField f) { + if (f.kind.kind == PropertyTypeKind.Kind.PROPERTY) { + return "model." + f.fieldName + ".get()"; + } + return "model." + f.fieldName; + } + + private static String booleanModelExpr(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 emitWriteModelFromString(StringBuilder sb, BoundField f, String src) { + if (f.kind.kind == PropertyTypeKind.Kind.PROPERTY) { + String elem = f.kind.elementBinaryName; + if ("java.lang.String".equals(elem)) { + sb.append(" model.").append(f.fieldName).append(".set(").append(src).append(");\n"); + } else if ("java.lang.Integer".equals(elem)) { + sb.append(" try { model.").append(f.fieldName).append(".set(Integer.valueOf(").append(src).append(")); } catch (NumberFormatException _nfe) {}\n"); + } else if ("java.lang.Long".equals(elem)) { + sb.append(" try { model.").append(f.fieldName).append(".set(Long.valueOf(").append(src).append(")); } catch (NumberFormatException _nfe) {}\n"); + } else if ("java.lang.Double".equals(elem)) { + sb.append(" try { model.").append(f.fieldName).append(".set(Double.valueOf(").append(src).append(")); } catch (NumberFormatException _nfe) {}\n"); + } else { + sb.append(" model.").append(f.fieldName).append(".set((").append(elem).append(") ").append(src).append(");\n"); + } + } else if (f.kind.kind == PropertyTypeKind.Kind.STRING) { + sb.append(" model.").append(f.fieldName).append(" = ").append(src).append(";\n"); + } else if (f.kind.kind == PropertyTypeKind.Kind.INT) { + sb.append(" try { model.").append(f.fieldName).append(" = Integer.parseInt(").append(src).append("); } catch (NumberFormatException _nfe) {}\n"); + } else if (f.kind.kind == PropertyTypeKind.Kind.LONG) { + sb.append(" try { model.").append(f.fieldName).append(" = Long.parseLong(").append(src).append("); } catch (NumberFormatException _nfe) {}\n"); + } else if (f.kind.kind == PropertyTypeKind.Kind.DOUBLE) { + sb.append(" try { model.").append(f.fieldName).append(" = Double.parseDouble(").append(src).append("); } catch (NumberFormatException _nfe) {}\n"); + } else if (f.kind.kind == PropertyTypeKind.Kind.FLOAT) { + sb.append(" try { model.").append(f.fieldName).append(" = Float.parseFloat(").append(src).append("); } catch (NumberFormatException _nfe) {}\n"); + } + } + + private static void emitWriteModelFromBoolean(StringBuilder sb, BoundField f, String src) { + if (f.kind.kind == PropertyTypeKind.Kind.PROPERTY + && "java.lang.Boolean".equals(f.kind.elementBinaryName)) { + sb.append(" model.").append(f.fieldName).append(".set(Boolean.valueOf(").append(src).append("));\n"); + } else if (f.kind.kind == PropertyTypeKind.Kind.BOOLEAN) { + 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 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 + // --------------------------------------------------------------- + + /// Mirror of `com.codename1.binding.BindAttr` -- can't reference the enum + /// directly here because the plugin module doesn't depend on cn1-core. + enum BindAttrName { TEXT, UIID, HIDDEN, VISIBLE, ENABLED, SELECTED, ICON_NAME, NAME } + + static final class BindableClass { + String binaryName; + 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; + } +} 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..d6a4624d63 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/MappingAnnotationProcessor.java @@ -0,0 +1,783 @@ +/* + * 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 (no abstract / interface targets, a +/// usable public no-arg constructor, supported field types), then generates: +/// +/// 1. One `XxxMapper` Java class per `@Mapped` type, in package +/// `com.codename1.mapping.generated`, implementing +/// `com.codename1.mapping.Mapper`. +/// 2. A single `MappersIndex` class in the same package whose no-arg +/// constructor calls `Mappers.register(new XxxMapper())` for every +/// discovered type. `Mappers#ensureIndexLoaded` lazy-loads it on the first +/// `Mappers.toJson` / `Mappers.fromJson` / `Mappers.toXml` / `Mappers.fromXml` +/// call. +/// +/// Everything goes through `JavaSourceCompiler` (the same JSR-199 wrapper the +/// router processor uses) so the emitted classes survive ParparVM rename and +/// R8 obfuscation in shipped builds -- the generated source references +/// application classes by direct symbol, not by `Class.forName`. +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 GENERATED_PACKAGE = "com.codename1.mapping.generated"; + + 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.mapperSimpleName = mc.simpleName + "Mapper"; + mc.mapperBinaryName = GENERATED_PACKAGE + "." + 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(GENERATED_PACKAGE + ".MappersIndex", generateIndexSource(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) under " + GENERATED_PACKAGE); + } + + // --------------------------------------------------------------- + // Source generation + // --------------------------------------------------------------- + + private static String generateMapperSource(MappedClass mc) { + StringBuilder sb = new StringBuilder(2048); + sb.append("package ").append(GENERATED_PACKAGE).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"); + + // 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 generateIndexSource(Iterable classes) { + StringBuilder sb = new StringBuilder(1024); + sb.append("package ").append(GENERATED_PACKAGE).append(";\n\n"); + sb.append("// Auto-generated by cn1:process-annotations. Do not edit.\n"); + sb.append("public final class MappersIndex {\n"); + sb.append(" public MappersIndex() {\n"); + for (MappedClass mc : classes) { + sb.append(" com.codename1.mapping.Mappers.register(new ").append(mc.mapperSimpleName).append("());\n"); + } + sb.append(" }\n"); + sb.append("}\n"); + return sb.toString(); + } + + // --------------------------------------------------------------- + // 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 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..5fe717d0f3 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java @@ -0,0 +1,637 @@ +/* + * 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 GENERATED_PACKAGE = "com.codename1.orm.generated"; + + 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.daoSimpleName = ec.simpleName + "Dao"; + ec.daoBinaryName = GENERATED_PACKAGE + "." + 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(GENERATED_PACKAGE + ".DaosIndex", generateIndexSource(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) under " + GENERATED_PACKAGE); + } + + // --------------------------------------------------------------- + // Source generation + // --------------------------------------------------------------- + + private static String generateDaoSource(EntityClass ec) { + StringBuilder sb = new StringBuilder(4096); + sb.append("package ").append(GENERATED_PACKAGE).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"); + + 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 generateIndexSource(Iterable classes) { + StringBuilder sb = new StringBuilder(1024); + sb.append("package ").append(GENERATED_PACKAGE).append(";\n\n"); + sb.append("// Auto-generated by cn1:process-annotations. Do not edit.\n"); + sb.append("public final class DaosIndex {\n"); + sb.append(" public DaosIndex() {\n"); + for (EntityClass ec : classes) { + sb.append(" com.codename1.orm.EntityManager.registerDao(new ").append(ec.daoSimpleName).append("());\n"); + } + sb.append(" }\n"); + sb.append("}\n"); + return sb.toString(); + } + + 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: + 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 BYTE_ARRAY: + sb.append(inst).append('.').append(f.fieldName); + 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 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..41e8ebea18 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BindingAnnotationProcessorTest.java @@ -0,0 +1,154 @@ +/* + * 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/codename1/binding/generated/LoginModelBinder.class"); + assertTrue("generated binder file should exist: " + binderFile, binderFile.exists()); + File indexFile = new File(classes, "com/codename1/binding/generated/BindersIndex.class"); + assertTrue("BindersIndex should exist", indexFile.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")); + } + + @Test + public void rejectsBindOnPrivateField() throws Exception { + File classes = tmp.newFolder("classes"); + JavaSourceCompiler.compile( + JavaSourceCompiler.singleSource("com.example.Bad", + "package com.example;\n" + + "import com.codename1.annotations.*;\n" + + "import com.codename1.binding.BindAttr;\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 @Bind 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); + 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); + 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..e879f5a647 --- /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/codename1/mapping/generated/UserMapper.class").exists()); + assertTrue(new File(classes, "com/codename1/mapping/generated/MappersIndex.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.codename1.mapping.generated.UserMapper"); + 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.codename1.mapping.generated.ItemMapper"); + 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..37cb1d93e3 --- /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/codename1/orm/generated/UserDao.class"); + assertTrue("generated dao file should exist: " + daoFile, daoFile.exists()); + File indexFile = new File(classes, "com/codename1/orm/generated/DaosIndex.class"); + assertTrue("DaosIndex should exist", indexFile.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(); + } +} From 579f471e0e6ebb3ec351de8206fe2f2cb15205d3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 27 May 2026 06:20:04 +0300 Subject: [PATCH 02/12] Fix CI: cn1 Character.forDigit gap + Vale/LanguageTool style nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build-test (8/17/21) failed because cn1's stripped-down java.lang.Character does not declare Character.forDigit -- the cn1 core compiles at source 1.5 / target 1.5 against the cn1 JavaAPI, not the full JDK runtime. Inline a "0123456789abcdef" lookup in Mappers#writeJsonString so the JSON string encoder stays portable to every cn1 target. build (developer-guide quality gate) failed on 14 Vale Microsoft contraction / adverb / hyphen findings and 3 LanguageTool spelling matches in the three new annotation chapters. Rewrite the prose to use contractions ("doesn't" / "isn't" / "aren't" / "can't" / "don't" / "it's"), drop the "tightly" and "deliberately" adverbs, switch "auto-increment" to "autoincrement", use "façade" with the diacritic, and add `[Dd]ao` / `[Dd]aos` to the LanguageTool accept-list so the data-access-object identifier doesn't trip the en_US dictionary. vale Annotation-JSON-XML-Mapping.asciidoc Annotation-Component-Binding.asciidoc Annotation-SQLite-ORM.asciidoc -> 0 errors, 0 warnings, 0 suggestions asciidoctor --safe-mode=safe -> clean on all three pages mvn -pl codenameone-maven-plugin test -> 93/93 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/com/codename1/mapping/Mappers.java | 9 +++++++-- .../Annotation-Component-Binding.asciidoc | 2 +- .../Annotation-JSON-XML-Mapping.asciidoc | 14 +++++++------- .../Annotation-SQLite-ORM.asciidoc | 18 +++++++++--------- docs/developer-guide/languagetool-accept.txt | 7 +++++++ 5 files changed, 31 insertions(+), 19 deletions(-) diff --git a/CodenameOne/src/com/codename1/mapping/Mappers.java b/CodenameOne/src/com/codename1/mapping/Mappers.java index 2cb9e84c67..fe5e583305 100644 --- a/CodenameOne/src/com/codename1/mapping/Mappers.java +++ b/CodenameOne/src/com/codename1/mapping/Mappers.java @@ -262,9 +262,14 @@ private static void writeJsonString(StringBuilder sb, String s) { case '\t': sb.append("\\t"); break; default: if (c < 0x20) { + // cn1's java.lang.Character is a stripped-down subset + // and does not include Character.forDigit. Inline the + // hex-digit lookup so this class stays portable to + // every cn1 target (ParparVM iOS, Android, JavaSE, + // CLDC11, ...). sb.append("\\u00"); - sb.append(Character.forDigit((c >> 4) & 0xF, 16)); - sb.append(Character.forDigit(c & 0xF, 16)); + sb.append("0123456789abcdef".charAt((c >> 4) & 0xF)); + sb.append("0123456789abcdef".charAt(c & 0xF)); } else { sb.append(c); } diff --git a/docs/developer-guide/Annotation-Component-Binding.asciidoc b/docs/developer-guide/Annotation-Component-Binding.asciidoc index 553258d059..e5db993089 100644 --- a/docs/developer-guide/Annotation-Component-Binding.asciidoc +++ b/docs/developer-guide/Annotation-Component-Binding.asciidoc @@ -7,7 +7,7 @@ a `Binder` per `@Bindable` class at build time, so the wiring happens through direct symbol references -- no reflection, no listener bookkeeping in application code. -It is a thin alternative to the imperative `UiBinding` API +It's a thin alternative to the imperative `UiBinding` API (`com.codename1.properties.UiBinding`). Both can be used together. === Annotate the model diff --git a/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc b/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc index 8c11bc9a2b..3b107fc340 100644 --- a/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc +++ b/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc @@ -4,13 +4,13 @@ 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 tightly-typed `Mapper` for -every `@Mapped` class, and ships it inside the same `target/classes` tree -as the rest of the application. The runtime entry point is two methods -on `com.codename1.mapping.Mappers`. +compiled bytecode at build time, generates a typed `Mapper` for every +`@Mapped` class, and ships it inside the same `target/classes` tree as +the rest of the application. 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 does not replace them. +`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. @@ -123,7 +123,7 @@ 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) -do not need to be fully buffered first. +don't need to be fully buffered first. === How the plumbing works @@ -149,7 +149,7 @@ class together, so the binding survives obfuscation in shipped builds. === Custom mappers -Sometimes a class lives in a third-party JAR the build cannot annotate. +Sometimes a class lives in a third-party JAR the build can't annotate. Hand-write a `Mapper` and register it during `Display#init`: [source,java] diff --git a/docs/developer-guide/Annotation-SQLite-ORM.asciidoc b/docs/developer-guide/Annotation-SQLite-ORM.asciidoc index c891aec5cc..2d37b1d78f 100644 --- a/docs/developer-guide/Annotation-SQLite-ORM.asciidoc +++ b/docs/developer-guide/Annotation-SQLite-ORM.asciidoc @@ -9,9 +9,9 @@ generates one `XxxDao` per entity, and hands them to prepared statements through `com.codename1.db.Database`, the same surface every cn1 SQLite port exposes. -The framework is a JPA-inspired, deliberately simplified alternative to -the existing imperative `SQLMap`. It does not replace `SQLMap`; both can -be used side by side. +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 @@ -93,15 +93,15 @@ users.delete(u); em.close(); ---- <1> Idempotent -- `CREATE TABLE IF NOT EXISTS`. -<2> The auto-increment `id` is filled in via +<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 facade over `Database`. The underlying +`EntityManager` is a thin façade over `Database`. The underlying connection is reachable through `em.database()` for raw SQL when the -dao surface is not enough. Transactions: +dao surface isn't enough. Transactions: [source,java] ---- @@ -118,7 +118,7 @@ try { === Relationships -Relationship annotations (`@OneToMany`, `@ManyToOne`, ...) are not yet +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 @@ -131,7 +131,7 @@ 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 is not supported (relationships, unsupported +* A field's static type isn't supported (relationships, unsupported reference types). Errors are accumulated so the first build run reports every offending @@ -144,7 +144,7 @@ entity at once. `DaosIndex` whose no-arg constructor registers them with `EntityManager#registerDao`. The iOS and Android per-build stubs call `new DaosIndex()` before `Display.init`, so `em.dao(User.class)` works -the first time it is called. +the first time it's called. There is no `Class.forName`, no service loader, and no field reflection at runtime: every parameter bind, every column read, every constructor 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 From e02287948653ba431ae986acd271d1b401f2b0d1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 27 May 2026 06:33:20 +0300 Subject: [PATCH 03/12] Fix CI: SpotBugs forbidden violations on build-test (JDK 8) Five SpotBugs findings out of the static-analysis gate on the JDK-8 Ant build: - 3x RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT on the `new com.codename1.{mapping,binding,orm}.generated.XxxIndex();` calls in Mappers#ensureIndexLoaded / Binders#ensureIndexLoaded / EntityManager#ensureIndexLoaded. The cn1-core stubs that ship in the framework have empty no-op constructors so SpotBugs can't see that the shadowing class generated by the annotation processor registers every mapper / binder / dao. Assign the result to a private static `indexInstance` Object so the instantiation has a visible side effect (which it does in practice -- the constructor registers everything before returning). - UCF_USELESS_CONTROL_FLOW on the empty `if (bc.fields.isEmpty()) { /* comment */ }` block in BindingAnnotationProcessor#processClass. Drop the branch and lift the comment up next to `accepted.put`. - DB_DUPLICATE_SWITCH_CLAUSES on OrmAnnotationProcessor#emitFieldRead where the STRING and BYTE_ARRAY cases shared a body (`sb.append(inst).append('.').append(f.fieldName)`). Merge them into a single multi-label case with a comment that explains why both feed the same Database#execute parameter bind path. mvn -pl codenameone-maven-plugin test -> 93/93 still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- CodenameOne/src/com/codename1/binding/Binders.java | 8 +++++++- CodenameOne/src/com/codename1/mapping/Mappers.java | 8 +++++++- CodenameOne/src/com/codename1/orm/EntityManager.java | 8 +++++++- .../maven/processors/BindingAnnotationProcessor.java | 7 +++---- .../maven/processors/OrmAnnotationProcessor.java | 12 +++++++----- 5 files changed, 31 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/binding/Binders.java b/CodenameOne/src/com/codename1/binding/Binders.java index 1118bf145e..e9c3c865ad 100644 --- a/CodenameOne/src/com/codename1/binding/Binders.java +++ b/CodenameOne/src/com/codename1/binding/Binders.java @@ -46,6 +46,12 @@ public final class Binders { private static final Map, Binder> BY_TYPE = new HashMap, Binder>(); private static boolean indexLoaded = false; + /// Pin the build-time-generated index after instantiation. Without the + /// assignment SpotBugs flags the `new BindersIndex()` as a no-op -- the + /// cn1-core stub has an empty constructor, so it can't see that the + /// shadowing class generated by the annotation processor registers + /// every binder. + private static Object indexInstance; private Binders() { } @@ -91,7 +97,7 @@ private static synchronized void ensureIndexLoaded() { if (indexLoaded) return; indexLoaded = true; try { - new com.codename1.binding.generated.BindersIndex(); + indexInstance = new com.codename1.binding.generated.BindersIndex(); } catch (NoClassDefFoundError ignore) { // No @Bindable types in this project. } catch (RuntimeException ignore) { diff --git a/CodenameOne/src/com/codename1/mapping/Mappers.java b/CodenameOne/src/com/codename1/mapping/Mappers.java index fe5e583305..59b47dafa2 100644 --- a/CodenameOne/src/com/codename1/mapping/Mappers.java +++ b/CodenameOne/src/com/codename1/mapping/Mappers.java @@ -58,6 +58,12 @@ public final class Mappers { private static final Map, Mapper> BY_TYPE = new HashMap, Mapper>(); private static boolean indexLoaded = false; + /// Pin the build-time-generated index after instantiation. Without the + /// assignment SpotBugs flags the `new MappersIndex()` as a no-op + /// (`RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT`) -- the cn1-core stub has + /// an empty constructor, so it can't see that the shadowing class + /// generated by the annotation processor registers every mapper. + private static Object indexInstance; private Mappers() { } @@ -187,7 +193,7 @@ private static synchronized void ensureIndexLoaded() { if (indexLoaded) return; indexLoaded = true; try { - new com.codename1.mapping.generated.MappersIndex(); + indexInstance = new com.codename1.mapping.generated.MappersIndex(); } catch (NoClassDefFoundError ignore) { // No @Mapped types in this project -- ignore. } catch (RuntimeException ignore) { diff --git a/CodenameOne/src/com/codename1/orm/EntityManager.java b/CodenameOne/src/com/codename1/orm/EntityManager.java index 2b1190ad07..e7f772f498 100644 --- a/CodenameOne/src/com/codename1/orm/EntityManager.java +++ b/CodenameOne/src/com/codename1/orm/EntityManager.java @@ -50,6 +50,12 @@ public final class EntityManager { private static final Map, Dao> BY_TYPE = new HashMap, Dao>(); private static boolean indexLoaded = false; + /// Pin the build-time-generated index after instantiation. Without the + /// assignment SpotBugs flags the `new DaosIndex()` as a no-op -- the + /// cn1-core stub has an empty constructor, so it can't see that the + /// shadowing class generated by the annotation processor registers + /// every dao. + private static Object indexInstance; private final Database db; private boolean closed; @@ -140,7 +146,7 @@ private static synchronized void ensureIndexLoaded() { if (indexLoaded) return; indexLoaded = true; try { - new com.codename1.orm.generated.DaosIndex(); + indexInstance = new com.codename1.orm.generated.DaosIndex(); } catch (NoClassDefFoundError ignore) { // No @Entity types in this project. } catch (RuntimeException ignore) { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BindingAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BindingAnnotationProcessor.java index 15a8c0cfc6..35a87ef4a3 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BindingAnnotationProcessor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BindingAnnotationProcessor.java @@ -114,10 +114,9 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces } bc.fields.add(bf); } - if (bc.fields.isEmpty()) { - // Accepted with no fields is a no-op binder; still generate so the - // user gets a registration hit even if they remove every @Bind. - } + // An @Bindable class with no @Bind fields is still accepted -- the + // generated binder is a no-op, but it stays in the index so the user + // gets a registration hit even if they remove every @Bind later. accepted.put(bc.binaryName, bc); } 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 index 5fe717d0f3..f438d05488 100644 --- 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 @@ -402,8 +402,13 @@ private static String defaultSqlTypeForBinary(String binary) { private static void emitFieldRead(StringBuilder sb, PersistedField f, String inst) { switch (f.kind.kind) { - case STRING: - sb.append(inst).append('.').append(f.fieldName); return; + 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; @@ -420,9 +425,6 @@ private static void emitFieldRead(StringBuilder sb, PersistedField f, String ins sb.append(inst).append('.').append(f.fieldName).append(" == null ? null : Long.valueOf(") .append(inst).append('.').append(f.fieldName).append(".getTime())"); return; - case BYTE_ARRAY: - sb.append(inst).append('.').append(f.fieldName); - return; case PROPERTY: emitPropertyRead(sb, f, inst); return; From bccfd634a31b6060d3f0060d6a385c6cce4621a1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 27 May 2026 06:46:14 +0300 Subject: [PATCH 04/12] Fix CI: use indexInstance as the load sentinel to satisfy SpotBugs The previous fix introduced `private static Object indexInstance` to silence SpotBugs' RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT finding on `new XxxIndex();`, but the field was only ever written -- SpotBugs flipped over to URF_UNREAD_FIELD on the next build-test (8) run. Read and write the field instead. Drop the redundant `indexLoaded` boolean and use `indexInstance != null` as the load gate. On the no- @Mapped / @Bindable / @Entity fallback path we pin Boolean.FALSE as the sentinel so we don't retry the NoClassDefFoundError lookup on every Mappers.get / Binders.get / EntityManager.dao call. The field is `volatile` so the initial-load happy path is lock-free on every subsequent call (the synchronized method only re-locks when the value is still null, which is the rare first-call case). mvn -pl codenameone-maven-plugin test -> 93/93 still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/com/codename1/binding/Binders.java | 27 ++++++++++--------- .../src/com/codename1/mapping/Mappers.java | 26 +++++++++--------- .../src/com/codename1/orm/EntityManager.java | 27 ++++++++++--------- 3 files changed, 42 insertions(+), 38 deletions(-) diff --git a/CodenameOne/src/com/codename1/binding/Binders.java b/CodenameOne/src/com/codename1/binding/Binders.java index e9c3c865ad..a916fb60e6 100644 --- a/CodenameOne/src/com/codename1/binding/Binders.java +++ b/CodenameOne/src/com/codename1/binding/Binders.java @@ -45,13 +45,13 @@ public final class Binders { private static final Map, Binder> BY_TYPE = new HashMap, Binder>(); - private static boolean indexLoaded = false; - /// Pin the build-time-generated index after instantiation. Without the - /// assignment SpotBugs flags the `new BindersIndex()` as a no-op -- the - /// cn1-core stub has an empty constructor, so it can't see that the - /// shadowing class generated by the annotation processor registers - /// every binder. - private static Object indexInstance; + /// Doubles as the "index loaded" sentinel and as the place we pin the + /// instantiated `BindersIndex` against SpotBugs' no-side-effect check: + /// the cn1-core stub has an empty constructor, so without an + /// assignment SpotBugs flags `new BindersIndex()` as a no-op. Reading + /// the field as the gate keeps SpotBugs from flipping that to + /// `URF_UNREAD_FIELD` instead. + private static volatile Object indexInstance; private Binders() { } @@ -94,14 +94,15 @@ public static Binding bind(T model, Container container) { } private static synchronized void ensureIndexLoaded() { - if (indexLoaded) return; - indexLoaded = true; + if (indexInstance != null) return; try { indexInstance = new com.codename1.binding.generated.BindersIndex(); - } catch (NoClassDefFoundError ignore) { - // No @Bindable types in this project. - } catch (RuntimeException ignore) { - // Index already loaded. + } catch (NoClassDefFoundError e) { + // No @Bindable types in this project. Pin a sentinel so we + // don't retry the lookup on every Binders.get call. + indexInstance = Boolean.FALSE; + } catch (RuntimeException e) { + indexInstance = Boolean.FALSE; } } } diff --git a/CodenameOne/src/com/codename1/mapping/Mappers.java b/CodenameOne/src/com/codename1/mapping/Mappers.java index 59b47dafa2..52543e6864 100644 --- a/CodenameOne/src/com/codename1/mapping/Mappers.java +++ b/CodenameOne/src/com/codename1/mapping/Mappers.java @@ -57,13 +57,13 @@ public final class Mappers { private static final Map, Mapper> BY_TYPE = new HashMap, Mapper>(); - private static boolean indexLoaded = false; - /// Pin the build-time-generated index after instantiation. Without the - /// assignment SpotBugs flags the `new MappersIndex()` as a no-op - /// (`RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT`) -- the cn1-core stub has - /// an empty constructor, so it can't see that the shadowing class - /// generated by the annotation processor registers every mapper. - private static Object indexInstance; + /// Doubles as the "index loaded" sentinel and as the place we pin the + /// instantiated `MappersIndex` against SpotBugs' no-side-effect check: + /// the cn1-core stub has an empty constructor, so without an + /// assignment SpotBugs flags `new MappersIndex()` as a no-op + /// (`RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT`). Reading the field as the + /// gate keeps SpotBugs from flipping that to `URF_UNREAD_FIELD` instead. + private static volatile Object indexInstance; private Mappers() { } @@ -190,14 +190,16 @@ public static T fromXml(Reader xml, Class type) { /// the annotation Mojo on their build path still compile and run -- the /// class is generated only when the project actually has @Mapped types. private static synchronized void ensureIndexLoaded() { - if (indexLoaded) return; - indexLoaded = true; + if (indexInstance != null) return; try { indexInstance = new com.codename1.mapping.generated.MappersIndex(); - } catch (NoClassDefFoundError ignore) { - // No @Mapped types in this project -- ignore. - } catch (RuntimeException ignore) { + } catch (NoClassDefFoundError e) { + // No @Mapped types in this project. Pin a sentinel so we don't + // retry the lookup on every Mappers.get call. + indexInstance = Boolean.FALSE; + } catch (RuntimeException e) { // Index already loaded by another path. + indexInstance = Boolean.FALSE; } } diff --git a/CodenameOne/src/com/codename1/orm/EntityManager.java b/CodenameOne/src/com/codename1/orm/EntityManager.java index e7f772f498..0cf09195b5 100644 --- a/CodenameOne/src/com/codename1/orm/EntityManager.java +++ b/CodenameOne/src/com/codename1/orm/EntityManager.java @@ -49,13 +49,13 @@ public final class EntityManager { private static final Map, Dao> BY_TYPE = new HashMap, Dao>(); - private static boolean indexLoaded = false; - /// Pin the build-time-generated index after instantiation. Without the - /// assignment SpotBugs flags the `new DaosIndex()` as a no-op -- the - /// cn1-core stub has an empty constructor, so it can't see that the - /// shadowing class generated by the annotation processor registers - /// every dao. - private static Object indexInstance; + /// Doubles as the "index loaded" sentinel and as the place we pin the + /// instantiated `DaosIndex` against SpotBugs' no-side-effect check: + /// the cn1-core stub has an empty constructor, so without an + /// assignment SpotBugs flags `new DaosIndex()` as a no-op. Reading the + /// field as the gate keeps SpotBugs from flipping that to + /// `URF_UNREAD_FIELD` instead. + private static volatile Object indexInstance; private final Database db; private boolean closed; @@ -143,14 +143,15 @@ public void close() throws IOException { } private static synchronized void ensureIndexLoaded() { - if (indexLoaded) return; - indexLoaded = true; + if (indexInstance != null) return; try { indexInstance = new com.codename1.orm.generated.DaosIndex(); - } catch (NoClassDefFoundError ignore) { - // No @Entity types in this project. - } catch (RuntimeException ignore) { - // Already loaded. + } catch (NoClassDefFoundError e) { + // No @Entity types in this project. Pin a sentinel so we don't + // retry on every dao() call. + indexInstance = Boolean.FALSE; + } catch (RuntimeException e) { + indexInstance = Boolean.FALSE; } } } From 5ae3b162410c486a05007003683ea48fc7bd22fb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 27 May 2026 07:05:54 +0300 Subject: [PATCH 05/12] Fix CI: 15 PMD violations on build-test (JDK 8) After fixing SpotBugs URF_UNREAD_FIELD the build-test (8) gate hit the PMD forbidden-rules list: - AvoidUsingVolatile (3): the `private static volatile Object indexInstance` field used to plug SpotBugs' no-side-effect check. Replace with the initialization-on-demand holder idiom -- a nested `IndexHolder` class whose static initializer instantiates the build-time-generated `MappersIndex` / `BindersIndex` / `DaosIndex`. The JVM defers class init until the first field reference and runs it exactly once under the class-init monitor, so we get a race-free lazy singleton without `volatile`. - ControlStatementBraces (11): every `if (cond) return X;` one-liner in Mappers.java and EntityManager.java now uses the full three-line brace form, per the CN1 PMD style gate (which interacts with Checkstyle's LeftCurly = eol option to require the explicit form). - UnnecessaryConstructor (3): drop the explicit no-op `public XxxIndex() { }` constructors from the three cn1-core stub Index classes. The compiler-generated default public no-arg constructor takes their place; the build-time-shadowing class declares its own constructor body that does the actual `register(...)` work. Also clean up the public surface: add `bootstrap()` static methods on `Mappers`, `Binders`, and `EntityManager` so the per-build application stub can call `Mappers.bootstrap()` etc. instead of forcing the holder load through `new XxxIndex();`. Update `Executor#annotationFrameworksInstallSource` to emit the new form. mvn -pl codenameone-maven-plugin test -> 93/93 still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/com/codename1/binding/Binders.java | 51 ++++++--- .../binding/generated/BindersIndex.java | 9 +- .../src/com/codename1/mapping/Mappers.java | 107 ++++++++++++------ .../mapping/generated/MappersIndex.java | 11 +- .../src/com/codename1/orm/EntityManager.java | 55 ++++++--- .../codename1/orm/generated/DaosIndex.java | 9 +- .../java/com/codename1/builders/Executor.java | 25 ++-- 7 files changed, 179 insertions(+), 88 deletions(-) diff --git a/CodenameOne/src/com/codename1/binding/Binders.java b/CodenameOne/src/com/codename1/binding/Binders.java index a916fb60e6..05eadd44eb 100644 --- a/CodenameOne/src/com/codename1/binding/Binders.java +++ b/CodenameOne/src/com/codename1/binding/Binders.java @@ -45,13 +45,6 @@ public final class Binders { private static final Map, Binder> BY_TYPE = new HashMap, Binder>(); - /// Doubles as the "index loaded" sentinel and as the place we pin the - /// instantiated `BindersIndex` against SpotBugs' no-side-effect check: - /// the cn1-core stub has an empty constructor, so without an - /// assignment SpotBugs flags `new BindersIndex()` as a no-op. Reading - /// the field as the gate keeps SpotBugs from flipping that to - /// `URF_UNREAD_FIELD` instead. - private static volatile Object indexInstance; private Binders() { } @@ -93,16 +86,40 @@ public static Binding bind(T model, Container container) { return binder.bind(model, container); } - private static synchronized void ensureIndexLoaded() { - if (indexInstance != null) return; - try { - indexInstance = new com.codename1.binding.generated.BindersIndex(); - } catch (NoClassDefFoundError e) { - // No @Bindable types in this project. Pin a sentinel so we - // don't retry the lookup on every Binders.get call. - indexInstance = Boolean.FALSE; - } catch (RuntimeException e) { - indexInstance = Boolean.FALSE; + /// Forces the lazy `IndexHolder` class to initialize, registering every + /// generated binder. Called from the per-build application stub before + /// `Display.init`. + public static void bootstrap() { + IndexHolder.touch(); + } + + private static void ensureIndexLoaded() { + IndexHolder.touch(); + } + + /// Initialization-on-demand holder. Class init runs exactly once, + /// race-free, without `volatile`. Direct symbol reference to the + /// generated index so ParparVM / R8 rewrite the call site and the + /// generated class together. + private static final class IndexHolder { + static final Object INDEX; + static { + Object resolved; + try { + resolved = new com.codename1.binding.generated.BindersIndex(); + } catch (NoClassDefFoundError missing) { + resolved = Boolean.FALSE; + } catch (RuntimeException failed) { + resolved = Boolean.FALSE; + } + INDEX = resolved; + } + + static void touch() { + if (INDEX == null) { + throw new IllegalStateException( + "BindersIndex failed to initialize"); + } } } } diff --git a/CodenameOne/src/com/codename1/binding/generated/BindersIndex.java b/CodenameOne/src/com/codename1/binding/generated/BindersIndex.java index 6231ecaa2f..a7c7e220de 100644 --- a/CodenameOne/src/com/codename1/binding/generated/BindersIndex.java +++ b/CodenameOne/src/com/codename1/binding/generated/BindersIndex.java @@ -25,8 +25,11 @@ /// Compile-time stub for the @Bindable annotation processor output. Projects /// that ship one or more `@Bindable` classes get a real `BindersIndex` /// generated under `target/classes`, shadowing this stub at runtime. +/// +/// The compiler-generated default no-arg public constructor is intentionally +/// inherited (no explicit constructor here) so the cn1 PMD gate's +/// `UnnecessaryConstructor` rule stays satisfied. The build-time-shadowing +/// `BindersIndex` written by `cn1:process-annotations` declares its own +/// constructor body that does the `Binders.register(...)` work. public class BindersIndex { - public BindersIndex() { - // No-op. Real implementation generated by cn1:process-annotations. - } } diff --git a/CodenameOne/src/com/codename1/mapping/Mappers.java b/CodenameOne/src/com/codename1/mapping/Mappers.java index 52543e6864..35669678cd 100644 --- a/CodenameOne/src/com/codename1/mapping/Mappers.java +++ b/CodenameOne/src/com/codename1/mapping/Mappers.java @@ -57,13 +57,6 @@ public final class Mappers { private static final Map, Mapper> BY_TYPE = new HashMap, Mapper>(); - /// Doubles as the "index loaded" sentinel and as the place we pin the - /// instantiated `MappersIndex` against SpotBugs' no-side-effect check: - /// the cn1-core stub has an empty constructor, so without an - /// assignment SpotBugs flags `new MappersIndex()` as a no-op - /// (`RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT`). Reading the field as the - /// gate keeps SpotBugs from flipping that to `URF_UNREAD_FIELD` instead. - private static volatile Object indexInstance; private Mappers() { } @@ -94,7 +87,9 @@ public static Mapper get(Class type) { /// missing `@Mapped` annotation or a build that ran without the /// process-annotations Mojo. public static String toJson(Object instance) { - if (instance == null) return "null"; + if (instance == null) { + return "null"; + } @SuppressWarnings("unchecked") Mapper m = (Mapper) get(instance.getClass()); if (m == null) { @@ -109,7 +104,9 @@ public static String toJson(Object instance) { /// 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; + if (json == null) { + return null; + } Mapper m = get(type); if (m == null) { throw missing(type); @@ -126,7 +123,9 @@ public static T fromJson(String json, Class type) { /// 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; + if (json == null) { + return null; + } Mapper m = get(type); if (m == null) { throw missing(type); @@ -142,7 +141,9 @@ public static T fromJson(Reader json, Class type) { /// Serializes `instance` to XML. public static String toXml(Object instance) { - if (instance == null) return ""; + if (instance == null) { + return ""; + } @SuppressWarnings("unchecked") Mapper m = (Mapper) get(instance.getClass()); if (m == null) { @@ -156,7 +157,9 @@ public static String toXml(Object instance) { /// 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; + if (xml == null) { + return null; + } Mapper m = get(type); if (m == null) { throw missing(type); @@ -168,7 +171,9 @@ public static T fromXml(String xml, Class type) { /// Parses XML read from a `Reader` without fully buffering it first. public static T fromXml(Reader xml, Class type) { - if (xml == null) return null; + if (xml == null) { + return null; + } Mapper m = get(type); if (m == null) { throw missing(type); @@ -182,24 +187,56 @@ public static T fromXml(Reader xml, Class type) { // Index bootstrap // --------------------------------------------------------------- - /// Lazily instantiates the generated `MappersIndex` class once. We use a - /// direct symbol reference (compiled in only when the build emits the - /// class) rather than `Class.forName`, so ParparVM / R8 rename the call - /// site and the generated class together and the binding survives in - /// shipped builds. The reference is guarded by `try` so projects without - /// the annotation Mojo on their build path still compile and run -- the - /// class is generated only when the project actually has @Mapped types. - private static synchronized void ensureIndexLoaded() { - if (indexInstance != null) return; - try { - indexInstance = new com.codename1.mapping.generated.MappersIndex(); - } catch (NoClassDefFoundError e) { - // No @Mapped types in this project. Pin a sentinel so we don't - // retry the lookup on every Mappers.get call. - indexInstance = Boolean.FALSE; - } catch (RuntimeException e) { - // Index already loaded by another path. - indexInstance = Boolean.FALSE; + /// Forces the lazy `IndexHolder` class to initialize. Its static + /// initializer constructs the build-time-generated `MappersIndex`, + /// whose constructor registers every generated mapper with this + /// registry. Calling `bootstrap()` is harmless after the first call; + /// the JVM guarantees the holder's class init runs exactly once. + /// + /// The iOS / Android per-build application stub invokes this from the + /// `annotationFrameworksInstallSource` fragment before `Display.init`. + public static void bootstrap() { + IndexHolder.touch(); + } + + private static void ensureIndexLoaded() { + IndexHolder.touch(); + } + + /// Initialization-on-demand holder. The JVM defers class init until + /// the first field reference, then runs it exactly once under the + /// class-init monitor -- a race-free lazy singleton without + /// `volatile`. Direct symbol reference (no `Class.forName`) so + /// ParparVM / R8 rewrite the call site and the generated class + /// together; the binding survives obfuscation in shipped builds. + private static final class IndexHolder { + static final Object INDEX; + static { + Object resolved; + try { + resolved = new com.codename1.mapping.generated.MappersIndex(); + } catch (NoClassDefFoundError missing) { + // No @Mapped types in this project -- nothing to register. + resolved = Boolean.FALSE; + } catch (RuntimeException failed) { + // The generated index hit a registration failure. Pin a + // sentinel so we don't retry on every call; the missing + // mapper surfaces from `Mappers.get` as the regular + // "no mapper registered" error. + resolved = Boolean.FALSE; + } + INDEX = resolved; + } + + static void touch() { + // Read INDEX so SpotBugs sees the field as used. Defensive + // null check documents the contract; createIndex pins a + // non-null sentinel on every fallback, so the throw is + // unreachable in practice. + if (INDEX == null) { + throw new IllegalStateException( + "MappersIndex failed to initialize"); + } } } @@ -228,7 +265,9 @@ static void writeJson(StringBuilder sb, Object value) { boolean first = true; Map map = (Map) value; for (Map.Entry e : map.entrySet()) { - if (!first) sb.append(','); + if (!first) { + sb.append(','); + } first = false; writeJsonString(sb, String.valueOf(e.getKey())); sb.append(':'); @@ -241,7 +280,9 @@ static void writeJson(StringBuilder sb, Object value) { sb.append('['); boolean first = true; for (Object item : (java.util.Collection) value) { - if (!first) sb.append(','); + if (!first) { + sb.append(','); + } first = false; writeJson(sb, item); } diff --git a/CodenameOne/src/com/codename1/mapping/generated/MappersIndex.java b/CodenameOne/src/com/codename1/mapping/generated/MappersIndex.java index 3a192e748b..0d07f66724 100644 --- a/CodenameOne/src/com/codename1/mapping/generated/MappersIndex.java +++ b/CodenameOne/src/com/codename1/mapping/generated/MappersIndex.java @@ -29,10 +29,13 @@ /// `com.codename1.mapping.Mappers` registry stays empty. /// /// Application code never references this class directly -- the lazy -/// instantiation happens inside `Mappers#ensureIndexLoaded`, by direct symbol +/// instantiation happens inside `Mappers#bootstrap`, by direct symbol /// reference, so the iOS `Class.forName` ban does not apply. +/// +/// The compiler-generated default no-arg public constructor is intentionally +/// inherited (no explicit constructor here) so the cn1 PMD gate's +/// `UnnecessaryConstructor` rule stays satisfied. The build-time-shadowing +/// `MappersIndex` written by `cn1:process-annotations` declares its own +/// constructor body that does the `Mappers.register(...)` work. public class MappersIndex { - public MappersIndex() { - // No-op. Real implementation generated by cn1:process-annotations. - } } diff --git a/CodenameOne/src/com/codename1/orm/EntityManager.java b/CodenameOne/src/com/codename1/orm/EntityManager.java index 0cf09195b5..a4ba5991ef 100644 --- a/CodenameOne/src/com/codename1/orm/EntityManager.java +++ b/CodenameOne/src/com/codename1/orm/EntityManager.java @@ -49,13 +49,6 @@ public final class EntityManager { private static final Map, Dao> BY_TYPE = new HashMap, Dao>(); - /// Doubles as the "index loaded" sentinel and as the place we pin the - /// instantiated `DaosIndex` against SpotBugs' no-side-effect check: - /// the cn1-core stub has an empty constructor, so without an - /// assignment SpotBugs flags `new DaosIndex()` as a no-op. Reading the - /// field as the gate keeps SpotBugs from flipping that to - /// `URF_UNREAD_FIELD` instead. - private static volatile Object indexInstance; private final Database db; private boolean closed; @@ -137,21 +130,47 @@ public void rollbackTransaction() throws IOException { /// Closes the underlying database. Idempotent. public void close() throws IOException { - if (closed) return; + if (closed) { + return; + } closed = true; db.close(); } - private static synchronized void ensureIndexLoaded() { - if (indexInstance != null) return; - try { - indexInstance = new com.codename1.orm.generated.DaosIndex(); - } catch (NoClassDefFoundError e) { - // No @Entity types in this project. Pin a sentinel so we don't - // retry on every dao() call. - indexInstance = Boolean.FALSE; - } catch (RuntimeException e) { - indexInstance = Boolean.FALSE; + /// Forces the lazy `IndexHolder` class to initialize, registering every + /// generated dao. Called from the per-build application stub before + /// `Display.init`. + public static void bootstrap() { + IndexHolder.touch(); + } + + private static void ensureIndexLoaded() { + IndexHolder.touch(); + } + + /// Initialization-on-demand holder. Class init runs exactly once, + /// race-free, without `volatile`. Direct symbol reference to the + /// generated index so ParparVM / R8 rewrite the call site and the + /// generated class together. + private static final class IndexHolder { + static final Object INDEX; + static { + Object resolved; + try { + resolved = new com.codename1.orm.generated.DaosIndex(); + } catch (NoClassDefFoundError missing) { + resolved = Boolean.FALSE; + } catch (RuntimeException failed) { + resolved = Boolean.FALSE; + } + INDEX = resolved; + } + + static void touch() { + if (INDEX == null) { + throw new IllegalStateException( + "DaosIndex failed to initialize"); + } } } } diff --git a/CodenameOne/src/com/codename1/orm/generated/DaosIndex.java b/CodenameOne/src/com/codename1/orm/generated/DaosIndex.java index 55620cf035..8fec32b43b 100644 --- a/CodenameOne/src/com/codename1/orm/generated/DaosIndex.java +++ b/CodenameOne/src/com/codename1/orm/generated/DaosIndex.java @@ -25,8 +25,11 @@ /// Compile-time stub for the @Entity annotation processor output. Projects /// that ship one or more `@Entity` classes get a real `DaosIndex` generated /// under `target/classes`, shadowing this stub at runtime. +/// +/// The compiler-generated default no-arg public constructor is intentionally +/// inherited (no explicit constructor here) so the cn1 PMD gate's +/// `UnnecessaryConstructor` rule stays satisfied. The build-time-shadowing +/// `DaosIndex` written by `cn1:process-annotations` declares its own +/// constructor body that does the `EntityManager.registerDao(...)` work. public class DaosIndex { - public DaosIndex() { - // No-op. Real implementation generated by cn1:process-annotations. - } } 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 1acc1cf87d..ea3b1f8686 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 @@ -2019,18 +2019,23 @@ protected static String routeDispatcherInstallSource(File sourceZip, String inde /// 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 in one go. Each call is a direct symbol reference so - /// ParparVM iOS / R8 Android rename the call site and the generated - /// class together; projects without the corresponding annotations get - /// an empty fragment because cn1-core ships a no-op stub for each index - /// class that is shadowed by the real one at build time. The constructors - /// register the per-class mapper / binder / dao with their respective - /// runtime registries. + /// dao index in one go. Each call is a direct symbol reference (no + /// `Class.forName`) so ParparVM iOS / R8 Android rename the call site + /// and the generated class together; projects without the corresponding + /// annotations resolve against the no-op stub cn1-core ships, which is + /// shadowed by the build-time-generated class when present. + /// + /// Each `bootstrap()` triggers an initialization-on-demand holder whose + /// static initializer instantiates the generated `XxxIndex`; that + /// constructor calls `Mappers.register` / `Binders.register` / + /// `EntityManager.registerDao` for every entry, so the registries are + /// populated by the time application code reaches `Mappers.toJson`, + /// `Binders.bind`, or `EntityManager.dao`. protected static String annotationFrameworksInstallSource(File sourceZip, String indent) { StringBuilder sb = new StringBuilder(); - sb.append(indent).append("new com.codename1.mapping.generated.MappersIndex();\n"); - sb.append(indent).append("new com.codename1.binding.generated.BindersIndex();\n"); - sb.append(indent).append("new com.codename1.orm.generated.DaosIndex();\n"); + sb.append(indent).append("com.codename1.mapping.Mappers.bootstrap();\n"); + sb.append(indent).append("com.codename1.binding.Binders.bootstrap();\n"); + sb.append(indent).append("com.codename1.orm.EntityManager.bootstrap();\n"); return sb.toString(); } } From 23f9e453e9b893066a17ad1c3a2684a2dadff2e6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 27 May 2026 07:18:03 +0300 Subject: [PATCH 06/12] Fix CI: PMD UseUtilityClass on the nested IndexHolder classes PMD's UseUtilityClass rule flagged the private `IndexHolder` nested class inside each runtime registry: it has only a static field and static methods, so PMD wants an explicit private constructor (or abstract). Add one to each Holder. The body is intentionally empty (plus a comment) -- the class is never instantiated; its sole job is to expose `static final INDEX` and `static touch()` for lazy class init. mvn -pl codenameone-maven-plugin test -> 93/93 still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- CodenameOne/src/com/codename1/binding/Binders.java | 5 +++++ CodenameOne/src/com/codename1/mapping/Mappers.java | 5 +++++ CodenameOne/src/com/codename1/orm/EntityManager.java | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/CodenameOne/src/com/codename1/binding/Binders.java b/CodenameOne/src/com/codename1/binding/Binders.java index 05eadd44eb..cffa72a48b 100644 --- a/CodenameOne/src/com/codename1/binding/Binders.java +++ b/CodenameOne/src/com/codename1/binding/Binders.java @@ -115,6 +115,11 @@ private static final class IndexHolder { INDEX = resolved; } + private IndexHolder() { + // Utility-style holder: only the static initializer + touch + // matter. PMD `UseUtilityClass` requires this private ctor. + } + static void touch() { if (INDEX == null) { throw new IllegalStateException( diff --git a/CodenameOne/src/com/codename1/mapping/Mappers.java b/CodenameOne/src/com/codename1/mapping/Mappers.java index 35669678cd..643baec902 100644 --- a/CodenameOne/src/com/codename1/mapping/Mappers.java +++ b/CodenameOne/src/com/codename1/mapping/Mappers.java @@ -228,6 +228,11 @@ private static final class IndexHolder { INDEX = resolved; } + private IndexHolder() { + // Utility-style holder: only the static initializer + touch + // matter. PMD `UseUtilityClass` requires this private ctor. + } + static void touch() { // Read INDEX so SpotBugs sees the field as used. Defensive // null check documents the contract; createIndex pins a diff --git a/CodenameOne/src/com/codename1/orm/EntityManager.java b/CodenameOne/src/com/codename1/orm/EntityManager.java index a4ba5991ef..1eee3f3181 100644 --- a/CodenameOne/src/com/codename1/orm/EntityManager.java +++ b/CodenameOne/src/com/codename1/orm/EntityManager.java @@ -166,6 +166,11 @@ private static final class IndexHolder { INDEX = resolved; } + private IndexHolder() { + // Utility-style holder: only the static initializer + touch + // matter. PMD `UseUtilityClass` requires this private ctor. + } + static void touch() { if (INDEX == null) { throw new IllegalStateException( From 1e1c658f88bf95bdf7e0e04756333f33ebf6ec5d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 27 May 2026 07:33:27 +0300 Subject: [PATCH 07/12] Fix CI: PMD UnnecessaryConstructor wants a non-empty body PMD's `UseUtilityClass` rule fires when a class has only static members and no constructor; it asks for `private` constructor or abstract. Adding the private constructor (previous commit) then made `UnnecessaryConstructor` fire because the body was empty (only a comment). Reconcile the two rules with the canonical "never instantiate" pattern: `throw new AssertionError(...)` inside the private constructor. The throw documents the contract and defends against reflection-based instantiation; PMD's `UnnecessaryConstructor` is satisfied because the body is now non-empty. mvn -pl codenameone-maven-plugin test -> 93/93 still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- CodenameOne/src/com/codename1/binding/Binders.java | 4 +++- CodenameOne/src/com/codename1/mapping/Mappers.java | 5 ++++- CodenameOne/src/com/codename1/orm/EntityManager.java | 4 +++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/binding/Binders.java b/CodenameOne/src/com/codename1/binding/Binders.java index cffa72a48b..5ef520c01b 100644 --- a/CodenameOne/src/com/codename1/binding/Binders.java +++ b/CodenameOne/src/com/codename1/binding/Binders.java @@ -117,7 +117,9 @@ private static final class IndexHolder { private IndexHolder() { // Utility-style holder: only the static initializer + touch - // matter. PMD `UseUtilityClass` requires this private ctor. + // matter. PMD `UseUtilityClass` requires this private ctor; + // `UnnecessaryConstructor` then requires a non-empty body. + throw new AssertionError("IndexHolder is not instantiable"); } static void touch() { diff --git a/CodenameOne/src/com/codename1/mapping/Mappers.java b/CodenameOne/src/com/codename1/mapping/Mappers.java index 643baec902..8af7e62072 100644 --- a/CodenameOne/src/com/codename1/mapping/Mappers.java +++ b/CodenameOne/src/com/codename1/mapping/Mappers.java @@ -230,7 +230,10 @@ private static final class IndexHolder { private IndexHolder() { // Utility-style holder: only the static initializer + touch - // matter. PMD `UseUtilityClass` requires this private ctor. + // matter. PMD `UseUtilityClass` requires this private ctor; + // `UnnecessaryConstructor` then requires a non-empty body, + // so document the contract by refusing instantiation. + throw new AssertionError("IndexHolder is not instantiable"); } static void touch() { diff --git a/CodenameOne/src/com/codename1/orm/EntityManager.java b/CodenameOne/src/com/codename1/orm/EntityManager.java index 1eee3f3181..9e837c15d3 100644 --- a/CodenameOne/src/com/codename1/orm/EntityManager.java +++ b/CodenameOne/src/com/codename1/orm/EntityManager.java @@ -168,7 +168,9 @@ private static final class IndexHolder { private IndexHolder() { // Utility-style holder: only the static initializer + touch - // matter. PMD `UseUtilityClass` requires this private ctor. + // matter. PMD `UseUtilityClass` requires this private ctor; + // `UnnecessaryConstructor` then requires a non-empty body. + throw new AssertionError("IndexHolder is not instantiable"); } static void touch() { From e1e6dbef93dbae6cab228f5ae3990f745205bb09 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 27 May 2026 13:30:57 +0300 Subject: [PATCH 08/12] Address review feedback: drop stubs, JavaBeans accessors, setter instrumentation, loop guard Significant architecture revision based on PR review: 1. No more cn1-core stubs. The three com.codename1.{mapping,binding,orm} .generated.XxxIndex stubs are gone -- they pretended to be shadowed by target/classes, but cn1 has no shadowing mechanism. The generated bootstrap class now lives at cn1app.{Mapper,Binder,Dao}Bootstrap and is generated only when the project actually uses each annotation; cn1-core never references it directly. 2. Bootstrap is purely external. cn1-core's Mappers / Binders / EntityManager are plain registries (no IndexHolder, no ensureIndexLoaded, no `new XxxIndex()` ref). iOS and Android stubs get the install line spliced in by the build server only when the bootstrap class is in the project zip (per-feature probe). JavaSEPort.postInit Class.forName-loads each bootstrap (the legitimate classloading path -- JavaSE runs unobfuscated, mirrors the @Route pattern). 3. String-keyed registries. Map performs badly across platforms; switch every registry to Map keyed on getClass().getName(). Obfuscation renames the registration and lookup sites together within a single execution, and the keys are never persisted. 4. Per-class generated artifacts live alongside the source. The processor now emits com.example.UserCn1Mapper next to com.example.User (and ItemCn1Binder, OrderCn1Dao, ...). Each has a public static `register()` hook that the bootstrap class invokes. 5. @Bind gains getter() / setter() members; the processor resolves accessors in priority order: explicit override, JavaBeans get/is/set, direct public-field access. Private fields with JavaBeans accessors work without making them public. 6. Setter instrumentation. For every two-way @Bind field whose write path is a method (explicit or detected), the processor reads the source class's .class file with ASM and inserts `ALOAD 0; INVOKESTATIC Binders.notifyChanged(Object)V` before every XRETURN. Mutating the model through the setter from anywhere triggers the binding fan-out automatically. 7. Two-way loop guard. Binders gains a thread-local update-depth counter (enterUpdate / exitUpdate / isInUpdate); every framework-initiated mutation runs inside an update region. The instrumented setter's notifyChanged is a no-op while the depth is positive, and component listeners short-circuit, so the model->component->model cycle terminates. New NotifiableBinding interface lets notifyChanged dispatch refreshes to the bindings that observe a given model. Documented limitation: a setter that synchronously mutates a *second* bound field won't propagate the second change to its component until something exits the region; the user must call binding.refresh() explicitly. Explained in Annotation-Component-Binding.asciidoc#annotation-binding-loop. JavaSE port: postInit now loads cn1app.MapperBootstrap / BinderBootstrap / DaoBootstrap via Class.forName, mirroring the existing Routes block. Build server (Executor.java in both this repo and BuildDaemon): the per-feature `projectHasBootstrap` probe gates each install line so projects that use only @Mapped (no @Bindable, no @Entity) get just the mapper bootstrap and nothing else. Docs: all three pages rewritten to describe the new layout, the JavaSE Class.forName / build-server probe split, the JavaBeans accessor resolution order, the ASM setter instrumentation, and the two-way loop-guard contract + cross-field-update limitation. Tests: 84 -> 95 plugin tests (two new accessor-detection cases on private-field binders + an ASM-level assertion that setUser carries the injected `INVOKESTATIC Binders.notifyChanged`). The mapping and ORM tests assert the new layout (com.example.UserCn1Mapper + cn1app.MapperBootstrap vs the old .generated paths). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/com/codename1/annotations/Bind.java | 54 +- .../src/com/codename1/binding/Binders.java | 247 +++++-- .../NotifiableBinding.java} | 38 +- .../binding/generated/BindersIndex.java | 35 - .../src/com/codename1/mapping/Mappers.java | 152 ++--- .../src/com/codename1/orm/EntityManager.java | 115 ++-- .../codename1/orm/generated/DaosIndex.java | 35 - .../com/codename1/impl/javase/JavaSEPort.java | 18 + .../Annotation-Component-Binding.asciidoc | 161 ++++- .../Annotation-JSON-XML-Mapping.asciidoc | 51 +- .../Annotation-SQLite-ORM.asciidoc | 42 +- .../java/com/codename1/builders/Executor.java | 52 +- .../BindingAnnotationProcessor.java | 604 ++++++++++++++---- .../MappingAnnotationProcessor.java | 84 ++- .../processors/OrmAnnotationProcessor.java | 52 +- .../BindingAnnotationProcessorTest.java | 85 ++- .../MappingAnnotationProcessorTest.java | 8 +- .../OrmAnnotationProcessorTest.java | 6 +- 18 files changed, 1268 insertions(+), 571 deletions(-) rename CodenameOne/src/com/codename1/{mapping/generated/MappersIndex.java => binding/NotifiableBinding.java} (52%) delete mode 100644 CodenameOne/src/com/codename1/binding/generated/BindersIndex.java delete mode 100644 CodenameOne/src/com/codename1/orm/generated/DaosIndex.java diff --git a/CodenameOne/src/com/codename1/annotations/Bind.java b/CodenameOne/src/com/codename1/annotations/Bind.java index 7c5d8858cb..8c2a31f913 100644 --- a/CodenameOne/src/com/codename1/annotations/Bind.java +++ b/CodenameOne/src/com/codename1/annotations/Bind.java @@ -31,13 +31,37 @@ /// 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. +/// `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. +/// 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 { @@ -47,8 +71,20 @@ /// 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. + /// 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/binding/Binders.java b/CodenameOne/src/com/codename1/binding/Binders.java index 5ef520c01b..4ad3642a00 100644 --- a/CodenameOne/src/com/codename1/binding/Binders.java +++ b/CodenameOne/src/com/codename1/binding/Binders.java @@ -24,51 +24,106 @@ 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 are picked up by the Maven plugin's annotation -/// processor at build time. The generated `Binder` is wired into this -/// registry through a generated `com.codename1.binding.generated.BindersIndex` -/// whose no-arg constructor fires the first time the registry is touched. -/// (Projects with no `@Bindable` classes fall through to an empty no-op -/// stub shipped with cn1-core, so the lookup degrades cleanly.) +/// `@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 ``: /// -/// ```java -/// Form f = (Form) Resources.getGlobalResources().getForm("LoginForm"); -/// LoginModel model = new LoginModel(); -/// Binding b = Binders.bind(model, f); -/// // user types -- model is updated; mutate the model and call b.refresh(). -/// ``` +/// - **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, Binder> BY_TYPE = new HashMap, Binder>(); + 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 Binders() { } + private static final ThreadLocal IN_UPDATE = new ThreadLocal(); - /// Installs a binder for `binder.type()`. Generated binders call this - /// from the `BindersIndex` static initializer; hand-written binders for - /// classes outside the build's annotation scan call it explicitly. + 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_TYPE.put(binder.type(), binder); + BY_NAME.put(binder.type().getName(), binder); } - /// Looks up the binder for `type` or null when no binder is registered. + /// Looks up the binder for `type` (by `type.getName()`) or null when + /// none is registered. @SuppressWarnings("unchecked") public static Binder get(Class type) { - ensureIndexLoaded(); - return (Binder) BY_TYPE.get(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()`. + /// 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) { @@ -77,56 +132,128 @@ public static Binding bind(T model, Container container) { if (container == null) { throw new IllegalArgumentException("container is null"); } - Binder binder = (Binder) get((Class) model.getClass()); + 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."); + + 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); } - /// Forces the lazy `IndexHolder` class to initialize, registering every - /// generated binder. Called from the per-build application stub before - /// `Display.init`. - public static void bootstrap() { - IndexHolder.touch(); + // --------------------------------------------------------------- + // 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 (int i = 0; i < snapshot.length; i++) { + NotifiableBinding b = snapshot[i]; + if (b.matches(model)) { + enterUpdate(); + try { + b.refresh(); + } finally { + exitUpdate(); + } + } + } } - private static void ensureIndexLoaded() { - IndexHolder.touch(); + /// 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); + } } - /// Initialization-on-demand holder. Class init runs exactly once, - /// race-free, without `volatile`. Direct symbol reference to the - /// generated index so ParparVM / R8 rewrite the call site and the - /// generated class together. - private static final class IndexHolder { - static final Object INDEX; - static { - Object resolved; - try { - resolved = new com.codename1.binding.generated.BindersIndex(); - } catch (NoClassDefFoundError missing) { - resolved = Boolean.FALSE; - } catch (RuntimeException failed) { - resolved = Boolean.FALSE; + /// 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) { + it.remove(); + break; + } + } + if (list.isEmpty()) { + LIVE_BINDINGS.remove(binding.modelTypeName()); } - INDEX = resolved; } + } - private IndexHolder() { - // Utility-style holder: only the static initializer + touch - // matter. PMD `UseUtilityClass` requires this private ctor; - // `UnnecessaryConstructor` then requires a non-empty body. - throw new AssertionError("IndexHolder is not instantiable"); + /// 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]++; + } - static void touch() { - if (INDEX == null) { - throw new IllegalStateException( - "BindersIndex failed to initialize"); - } + /// 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/mapping/generated/MappersIndex.java b/CodenameOne/src/com/codename1/binding/NotifiableBinding.java similarity index 52% rename from CodenameOne/src/com/codename1/mapping/generated/MappersIndex.java rename to CodenameOne/src/com/codename1/binding/NotifiableBinding.java index 0d07f66724..62a9964dba 100644 --- a/CodenameOne/src/com/codename1/mapping/generated/MappersIndex.java +++ b/CodenameOne/src/com/codename1/binding/NotifiableBinding.java @@ -20,22 +20,26 @@ * Please contact Codename One through http://www.codenameone.com/ if you * need additional information or have any questions. */ -package com.codename1.mapping.generated; +package com.codename1.binding; -/// Compile-time stub for the @Mapped annotation processor output. Projects -/// that ship one or more `@Mapped` classes get a real `MappersIndex` -/// generated under `target/classes`, shadowing this stub at runtime; projects -/// with no `@Mapped` classes fall through to this no-op and the -/// `com.codename1.mapping.Mappers` registry stays empty. -/// -/// Application code never references this class directly -- the lazy -/// instantiation happens inside `Mappers#bootstrap`, by direct symbol -/// reference, so the iOS `Class.forName` ban does not apply. -/// -/// The compiler-generated default no-arg public constructor is intentionally -/// inherited (no explicit constructor here) so the cn1 PMD gate's -/// `UnnecessaryConstructor` rule stays satisfied. The build-time-shadowing -/// `MappersIndex` written by `cn1:process-annotations` declares its own -/// constructor body that does the `Mappers.register(...)` work. -public class MappersIndex { +/// 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/generated/BindersIndex.java b/CodenameOne/src/com/codename1/binding/generated/BindersIndex.java deleted file mode 100644 index a7c7e220de..0000000000 --- a/CodenameOne/src/com/codename1/binding/generated/BindersIndex.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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.generated; - -/// Compile-time stub for the @Bindable annotation processor output. Projects -/// that ship one or more `@Bindable` classes get a real `BindersIndex` -/// generated under `target/classes`, shadowing this stub at runtime. -/// -/// The compiler-generated default no-arg public constructor is intentionally -/// inherited (no explicit constructor here) so the cn1 PMD gate's -/// `UnnecessaryConstructor` rule stays satisfied. The build-time-shadowing -/// `BindersIndex` written by `cn1:process-annotations` declares its own -/// constructor body that does the `Binders.register(...)` work. -public class BindersIndex { -} diff --git a/CodenameOne/src/com/codename1/mapping/Mappers.java b/CodenameOne/src/com/codename1/mapping/Mappers.java index 8af7e62072..10a69cc876 100644 --- a/CodenameOne/src/com/codename1/mapping/Mappers.java +++ b/CodenameOne/src/com/codename1/mapping/Mappers.java @@ -35,12 +35,25 @@ /// Public entry point for the build-time JSON / XML mapping framework. /// -/// `@Mapped` classes are picked up by the Maven plugin's annotation processor -/// at build time. A generated `Mapper` is wired into this registry through a -/// generated `com.codename1.mapping.generated.MappersIndex` whose static -/// initializer fires the first time the registry is touched. +/// `@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 +/// ``: /// -/// Typical use: +/// - **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); @@ -50,48 +63,49 @@ /// User u = Mappers.fromXml(xml, User.class); /// ``` /// -/// `register(...)` is public so application code can install a hand-written -/// mapper for a class the build-time processor cannot see (e.g. classes that -/// live in a dependency JAR). Generated mappers register themselves through -/// the same call. +/// 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, Mapper> BY_TYPE = new HashMap, Mapper>(); + private static final Map> BY_NAME = new HashMap>(); - private Mappers() { } + private Mappers() { + } - /// Installs a mapper for `mapper.type()`. Subsequent calls with the same - /// type replace the previously registered mapper. Thread-safe relative to - /// `get` (the registry is a plain HashMap, written from app init and read - /// at steady state; concurrent registration during steady-state lookups - /// is not supported and not needed in practice). + /// 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_TYPE.put(mapper.type(), mapper); + BY_NAME.put(mapper.type().getName(), mapper); } - /// Looks up the mapper for `type`, returning `null` when no mapper is - /// registered. The first call lazily triggers the generated - /// `MappersIndex` static initializer when present, so application code - /// does not need to wire it up explicitly. + /// Looks up the mapper for `type` (by `type.getName()`) or null when + /// none is registered. @SuppressWarnings("unchecked") public static Mapper get(Class type) { - ensureIndexLoaded(); - return (Mapper) BY_TYPE.get(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 + /// 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) get(instance.getClass()); + Mapper m = (Mapper) BY_NAME.get(instance.getClass().getName()); if (m == null) { throw missing(instance.getClass()); } @@ -101,8 +115,8 @@ public static String toJson(Object instance) { return sb.toString(); } - /// Inverse of `#toJson`. Parses the JSON text and hands the resulting Map - /// to the registered mapper. + /// 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; @@ -145,7 +159,7 @@ public static String toXml(Object instance) { return ""; } @SuppressWarnings("unchecked") - Mapper m = (Mapper) get(instance.getClass()); + Mapper m = (Mapper) BY_NAME.get(instance.getClass().getName()); if (m == null) { throw missing(instance.getClass()); } @@ -183,85 +197,16 @@ public static T fromXml(Reader xml, Class type) { return m.readXml(root); } - // --------------------------------------------------------------- - // Index bootstrap - // --------------------------------------------------------------- - - /// Forces the lazy `IndexHolder` class to initialize. Its static - /// initializer constructs the build-time-generated `MappersIndex`, - /// whose constructor registers every generated mapper with this - /// registry. Calling `bootstrap()` is harmless after the first call; - /// the JVM guarantees the holder's class init runs exactly once. - /// - /// The iOS / Android per-build application stub invokes this from the - /// `annotationFrameworksInstallSource` fragment before `Display.init`. - public static void bootstrap() { - IndexHolder.touch(); - } - - private static void ensureIndexLoaded() { - IndexHolder.touch(); - } - - /// Initialization-on-demand holder. The JVM defers class init until - /// the first field reference, then runs it exactly once under the - /// class-init monitor -- a race-free lazy singleton without - /// `volatile`. Direct symbol reference (no `Class.forName`) so - /// ParparVM / R8 rewrite the call site and the generated class - /// together; the binding survives obfuscation in shipped builds. - private static final class IndexHolder { - static final Object INDEX; - static { - Object resolved; - try { - resolved = new com.codename1.mapping.generated.MappersIndex(); - } catch (NoClassDefFoundError missing) { - // No @Mapped types in this project -- nothing to register. - resolved = Boolean.FALSE; - } catch (RuntimeException failed) { - // The generated index hit a registration failure. Pin a - // sentinel so we don't retry on every call; the missing - // mapper surfaces from `Mappers.get` as the regular - // "no mapper registered" error. - resolved = Boolean.FALSE; - } - INDEX = resolved; - } - - private IndexHolder() { - // Utility-style holder: only the static initializer + touch - // matter. PMD `UseUtilityClass` requires this private ctor; - // `UnnecessaryConstructor` then requires a non-empty body, - // so document the contract by refusing instantiation. - throw new AssertionError("IndexHolder is not instantiable"); - } - - static void touch() { - // Read INDEX so SpotBugs sees the field as used. Defensive - // null check documents the contract; createIndex pins a - // non-null sentinel on every fallback, so the throw is - // unreachable in practice. - if (INDEX == null) { - throw new IllegalStateException( - "MappersIndex failed to initialize"); - } - } - } - 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."); + + "Mojo ran during build, then re-run -- the generated MapperBootstrap " + + "populates this registry at startup."); } // --------------------------------------------------------------- // Tiny JSON writer // --------------------------------------------------------------- - // - // Hand-rolled rather than reusing PropertyIndex / Result so this class - // works on plain POJO maps without any cn1.properties dependency. We only - // emit the subset that JSONParser can read back: strings, numbers, - // booleans, null, nested maps, lists. static void writeJson(StringBuilder sb, Object value) { if (value == null) { @@ -319,11 +264,6 @@ private static void writeJsonString(StringBuilder sb, String s) { case '\t': sb.append("\\t"); break; default: if (c < 0x20) { - // cn1's java.lang.Character is a stripped-down subset - // and does not include Character.forDigit. Inline the - // hex-digit lookup so this class stays portable to - // every cn1 target (ParparVM iOS, Android, JavaSE, - // CLDC11, ...). sb.append("\\u00"); sb.append("0123456789abcdef".charAt((c >> 4) & 0xF)); sb.append("0123456789abcdef".charAt(c & 0xF)); diff --git a/CodenameOne/src/com/codename1/orm/EntityManager.java b/CodenameOne/src/com/codename1/orm/EntityManager.java index 9e837c15d3..38f29e29cf 100644 --- a/CodenameOne/src/com/codename1/orm/EntityManager.java +++ b/CodenameOne/src/com/codename1/orm/EntityManager.java @@ -31,12 +31,18 @@ /// Public entry point for the build-time SQLite ORM. /// -/// `@Entity` classes are picked up by the Maven plugin's annotation processor -/// at build time. The generated `Dao` is wired into an internal registry via -/// `com.codename1.orm.generated.DaosIndex` whose static initializer fires the -/// first time `EntityManager.open` is called. +/// `@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 ``: /// -/// Typical use: +/// - **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"); @@ -46,9 +52,13 @@ /// 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, Dao> BY_TYPE = new HashMap, Dao>(); + private static final Map> BY_NAME = new HashMap>(); private final Database db; private boolean closed; @@ -58,8 +68,8 @@ private EntityManager(Database db) { } /// Opens (or creates) the SQLite file `databaseName` via - /// `Display.openOrCreate`. Throws `IOException` when the platform refuses - /// to provide a SQLite database. + /// `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) { @@ -69,9 +79,9 @@ public static EntityManager open(String databaseName) throws IOException { return open(db); } - /// Wraps an existing `Database` (e.g. one already opened with a custom - /// path) in an `EntityManager`. The caller retains ownership; `close()` - /// will still close the database. + /// 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"); @@ -79,56 +89,59 @@ public static EntityManager open(Database db) { return new EntityManager(db); } - /// Registers a hand-written dao. The build-time-generated `DaosIndex` - /// uses the same call; explicit registration is only needed for entity - /// classes that live in a dependency JAR the annotation Mojo cannot scan. + /// 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_TYPE.put(dao.type(), dao); + 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. + /// 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) { - ensureIndexLoaded(); - Dao d = (Dao) BY_TYPE.get(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."); + + "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 is - /// not enough. + /// The underlying `Database`. Use it for raw SQL when the dao surface + /// isn't enough. public Database database() { return db; } - /// Begin a transaction on the underlying database. Equivalent to - /// `database().beginTransaction()`. + /// Begin a transaction. Equivalent to `database().beginTransaction()`. public void beginTransaction() throws IOException { db.beginTransaction(); } - /// Commit a transaction on the underlying database. + /// Commit the current transaction. public void commitTransaction() throws IOException { db.commitTransaction(); } - /// Roll back a transaction on the underlying database. + /// Roll back the current transaction. public void rollbackTransaction() throws IOException { db.rollbackTransaction(); } - /// Closes the underlying database. Idempotent. + /// Close the underlying database. Idempotent. public void close() throws IOException { if (closed) { return; @@ -136,48 +149,4 @@ public void close() throws IOException { closed = true; db.close(); } - - /// Forces the lazy `IndexHolder` class to initialize, registering every - /// generated dao. Called from the per-build application stub before - /// `Display.init`. - public static void bootstrap() { - IndexHolder.touch(); - } - - private static void ensureIndexLoaded() { - IndexHolder.touch(); - } - - /// Initialization-on-demand holder. Class init runs exactly once, - /// race-free, without `volatile`. Direct symbol reference to the - /// generated index so ParparVM / R8 rewrite the call site and the - /// generated class together. - private static final class IndexHolder { - static final Object INDEX; - static { - Object resolved; - try { - resolved = new com.codename1.orm.generated.DaosIndex(); - } catch (NoClassDefFoundError missing) { - resolved = Boolean.FALSE; - } catch (RuntimeException failed) { - resolved = Boolean.FALSE; - } - INDEX = resolved; - } - - private IndexHolder() { - // Utility-style holder: only the static initializer + touch - // matter. PMD `UseUtilityClass` requires this private ctor; - // `UnnecessaryConstructor` then requires a non-empty body. - throw new AssertionError("IndexHolder is not instantiable"); - } - - static void touch() { - if (INDEX == null) { - throw new IllegalStateException( - "DaosIndex failed to initialize"); - } - } - } } diff --git a/CodenameOne/src/com/codename1/orm/generated/DaosIndex.java b/CodenameOne/src/com/codename1/orm/generated/DaosIndex.java deleted file mode 100644 index 8fec32b43b..0000000000 --- a/CodenameOne/src/com/codename1/orm/generated/DaosIndex.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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.generated; - -/// Compile-time stub for the @Entity annotation processor output. Projects -/// that ship one or more `@Entity` classes get a real `DaosIndex` generated -/// under `target/classes`, shadowing this stub at runtime. -/// -/// The compiler-generated default no-arg public constructor is intentionally -/// inherited (no explicit constructor here) so the cn1 PMD gate's -/// `UnnecessaryConstructor` rule stays satisfied. The build-time-shadowing -/// `DaosIndex` written by `cn1:process-annotations` declares its own -/// constructor body that does the `EntityManager.registerDao(...)` work. -public class DaosIndex { -} 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 index e5db993089..5c7c3d4235 100644 --- a/docs/developer-guide/Annotation-Component-Binding.asciidoc +++ b/docs/developer-guide/Annotation-Component-Binding.asciidoc @@ -3,9 +3,9 @@ [[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 `Binder` per `@Bindable` class at build time, so the wiring happens -through direct symbol references -- no reflection, no listener bookkeeping -in application code. +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. @@ -24,20 +24,33 @@ import com.codename1.properties.Property; public class LoginModel { @Bind(name = "userField", attr = BindAttr.TEXT) - public Property user = - new Property<>("user"); + private String user; + public String getUser() { return user; } + public void setUser(String u) { this.user = u; } // <1> - @Bind(name = "passwordField", attr = BindAttr.TEXT) - public String password; + @Bind(name = "rememberMe", attr = BindAttr.SELECTED) + public boolean remember; // <2> - // One-way: the model decides the style, the user never edits it. @Bind(name = "banner", attr = BindAttr.UIID, twoWay = false) public String bannerStyle; - @Bind(name = "rememberMe", attr = BindAttr.SELECTED) - public boolean remember; + @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. @@ -74,12 +87,12 @@ 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 and call b.refresh() to update the form. -model.user.set("alice"); -b.refresh(); +// Mutate the model through the setter and the bound component refreshes +// automatically: +model.setUser("alice"); -// Before submitting: -b.commit(); // pull non-two-way fields back into the model +// Or pull pending edits into the model before submit: +b.commit(); // On form dispose: b.disconnect(); // remove every installed listener @@ -92,28 +105,99 @@ b.disconnect(); // remove every installed listener | Method | Purpose | `refresh()` | Push the current model values into every bound component. Use after mutating the model outside the - form. + 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 so the - form can be garbage-collected without keeping the - model alive. +| `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 +} +---- + +at 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 direct -field access. A `Property` field is read through `get()` and -written through `set()` -- so existing `PropertyChangeListener` +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 private field (use a `Property` field if you - want a real accessor + change notification). +* `@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, ...). @@ -124,13 +208,22 @@ field at once. === How the plumbing works `cn1:process-annotations` writes one -`com.codename1.binding.generated.XxxBinder` per `@Bindable` class and a -single `BindersIndex` whose no-arg constructor registers them. The iOS -and Android per-build stubs call `new BindersIndex()` before -`Display.init`, so the registry is ready by the time the application's -first form opens. - -Generated binders never call `Class.forName`; the lookup of the target -component walks `Container#getComponentAt` directly. The binding -survives ParparVM / R8 obfuscation because the call sites and the -generated class are renamed together. +`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`. Direct symbol references survive ParparVM + rename and R8 obfuscation. +* On **JavaSE** `JavaSEPort#postInit` loads the bootstrap via + `Class.forName("cn1app.BinderBootstrap")`. + +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 index 3b107fc340..3ff25233ce 100644 --- a/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc +++ b/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc @@ -4,10 +4,9 @@ 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` for every -`@Mapped` class, and ships it inside the same `target/classes` tree as -the rest of the application. The runtime entry point is two methods on -`com.codename1.mapping.Mappers`. +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. @@ -134,23 +133,41 @@ At build time: . 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 `XxxMapper` class is generated under - `com.codename1.mapping.generated` for every `@Mapped` type, plus a - single `MappersIndex` whose no-arg constructor registers them all - with `Mappers#register`. -. The iOS and Android per-build stubs call `new MappersIndex()` before - `Display.init`, so the registry is populated by the time application - code runs. - -At runtime there is no `Class.forName`, no reflection, and no service -loader: every read and write in a generated mapper is a direct symbol -reference. ParparVM and R8 rename the call sites and the generated -class together, so the binding survives obfuscation in shipped builds. +. 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`. Direct symbol references survive the + ParparVM rename and the R8 obfuscation pass: the call site and the + generated class are renamed together. +* On the **JavaSE simulator and desktop run**, `JavaSEPort#postInit` + loads the bootstrap via `Class.forName("cn1app.MapperBootstrap")`. + Classloading 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 during `Display#init`: +Hand-write a `Mapper` and register it at startup: [source,java] ---- diff --git a/docs/developer-guide/Annotation-SQLite-ORM.asciidoc b/docs/developer-guide/Annotation-SQLite-ORM.asciidoc index 2d37b1d78f..e11e22bd61 100644 --- a/docs/developer-guide/Annotation-SQLite-ORM.asciidoc +++ b/docs/developer-guide/Annotation-SQLite-ORM.asciidoc @@ -4,10 +4,11 @@ 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 `XxxDao` per entity, and hands them 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. +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 @@ -140,13 +141,26 @@ entity at once. === How the plumbing works `cn1:process-annotations` writes one -`com.codename1.orm.generated.XxxDao` per `@Entity` and a single -`DaosIndex` whose no-arg constructor registers them with -`EntityManager#registerDao`. The iOS and Android per-build stubs call -`new DaosIndex()` before `Display.init`, so `em.dao(User.class)` works -the first time it's called. - -There is no `Class.forName`, no service loader, and no field reflection -at runtime: every parameter bind, every column read, every constructor -call is a direct symbol reference that the iOS `Class.forName` ban and -the ParparVM rename pass leave intact. +`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`. Direct symbol references survive ParparVM + rename and R8 obfuscation: the call site and the generated dao are + renamed together. +* On the **JavaSE simulator and desktop run** `JavaSEPort#postInit` + loads the bootstrap via `Class.forName("cn1app.DaoBootstrap")`. + Classloading 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/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 ea3b1f8686..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 @@ -2019,23 +2019,47 @@ protected static String routeDispatcherInstallSource(File sourceZip, String inde /// 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 in one go. Each call is a direct symbol reference (no - /// `Class.forName`) so ParparVM iOS / R8 Android rename the call site - /// and the generated class together; projects without the corresponding - /// annotations resolve against the no-op stub cn1-core ships, which is - /// shadowed by the build-time-generated class when present. + /// dao index -- but only when the project actually uses each feature. /// - /// Each `bootstrap()` triggers an initialization-on-demand holder whose - /// static initializer instantiates the generated `XxxIndex`; that - /// constructor calls `Mappers.register` / `Binders.register` / - /// `EntityManager.registerDao` for every entry, so the registries are - /// populated by the time application code reaches `Mappers.toJson`, - /// `Binders.bind`, or `EntityManager.dao`. + /// 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(); - sb.append(indent).append("com.codename1.mapping.Mappers.bootstrap();\n"); - sb.append(indent).append("com.codename1.binding.Binders.bootstrap();\n"); - sb.append(indent).append("com.codename1.orm.EntityManager.bootstrap();\n"); + 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/maven/processors/BindingAnnotationProcessor.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BindingAnnotationProcessor.java index 35a87ef4a3..68bea3c264 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BindingAnnotationProcessor.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/BindingAnnotationProcessor.java @@ -31,7 +31,15 @@ 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; @@ -41,15 +49,62 @@ import java.util.Set; import java.util.TreeMap; -/// Build-time `@Bindable` processor. Generates one `XxxBinder` Java class per -/// `@Bindable` type plus a `BindersIndex` that lazily registers them all with -/// `com.codename1.binding.Binders` on first use. +/// 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 GENERATED_PACKAGE = "com.codename1.binding.generated"; + 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 { @@ -72,8 +127,12 @@ public void start(ProcessorContext ctx) throws ProcessingException { @Override public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws ProcessingException { - if (cls.isSynthetic()) return; - if (cls.getClassAnnotation(BINDABLE_DESC) == null) return; + 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"); @@ -82,76 +141,299 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces BindableClass bc = new BindableClass(); bc.binaryName = cls.getBinaryName(); + bc.internalName = cls.getInternalName(); + bc.classFile = cls.getClassFile(); bc.simpleName = simpleName(cls.getBinaryName()); - bc.binderSimpleName = bc.simpleName + "Binder"; - bc.binderBinaryName = GENERATED_PACKAGE + "." + bc.binderSimpleName; + 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; - if (!f.isPublic()) { - ctx.error(cls, "@Bind on " + bc.binaryName + "." + f.getName() - + " requires a public field"); + if (f.isStatic()) { continue; } - String compName = bind.getString("name"); - if (compName == null || compName.length() == 0) { - ctx.error(cls, "@Bind on " + bc.binaryName + "." + f.getName() - + " requires name() to identify the target component"); + AnnotationValues bind = f.getAnnotation(BIND_DESC); + if (bind == null) { continue; } BoundField bf = new BoundField(); bf.fieldName = f.getName(); - bf.componentName = compName; + 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); } - // An @Bindable class with no @Bind fields is still accepted -- the - // generated binder is a no-op, but it stays in the index so the user - // gets a registration hit even if they remove every @Bind later. + // @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"); - // ASM gives enums back as `String[] { internalDescriptor, valueName }`. if (v instanceof String[]) { String name = ((String[]) v)[1]; for (BindAttrName candidate : BindAttrName.values()) { - if (candidate.name().equals(name)) return candidate; + 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; + 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(GENERATED_PACKAGE + ".BindersIndex", generateIndexSource(accepted.values())); + sources.put(BOOTSTRAP_BINARY, generateBootstrapSource(accepted.values())); try { - java.util.List cp = new java.util.ArrayList(); + 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) under " + GENERATED_PACKAGE); + + " @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()); } // --------------------------------------------------------------- @@ -159,13 +441,22 @@ public void finish(ProcessorContext ctx) throws ProcessingException { // --------------------------------------------------------------- private static String generateBinderSource(BindableClass bc) { - StringBuilder sb = new StringBuilder(2048); - sb.append("package ").append(GENERATED_PACKAGE).append(";\n\n"); + 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"); @@ -174,50 +465,67 @@ private static String generateBinderSource(BindableClass bc) { .append(" model, final com.codename1.ui.Container container) {\n"); sb.append(" final java.util.ArrayList _disposers = new java.util.ArrayList();\n"); - // Resolve every component up front so refresh() / commit() / disconnect() - // never re-walk the container. + // 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"); } - // Initial push from model -> components. + // 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"); - // Two-way listeners. + // 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) continue; + 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) continue; + if (!f.twoWay || !f.attr.isTwoWayCapable()) { + continue; + } emitListenerInstall(sb, f, i); } - sb.append(" return new com.codename1.binding.Binding() {\n"); + // 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 component-by-name lookup. cn1 doesn't ship one as a - // public Container API, so we inline it -- keeps each binder - // self-contained with no shared runtime dependency. + // 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"); @@ -236,14 +544,21 @@ private static String generateBinderSource(BindableClass bc) { return sb.toString(); } - private static String generateIndexSource(Iterable classes) { + private static String generateBootstrapSource(Iterable classes) { StringBuilder sb = new StringBuilder(1024); - sb.append("package ").append(GENERATED_PACKAGE).append(";\n\n"); + sb.append("package ").append(BOOTSTRAP_PACKAGE).append(";\n\n"); sb.append("// Auto-generated by cn1:process-annotations. Do not edit.\n"); - sb.append("public final class BindersIndex {\n"); - sb.append(" public BindersIndex() {\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(" com.codename1.binding.Binders.register(new ").append(bc.binderSimpleName).append("());\n"); + sb.append(" ").append(bc.binderBinaryName).append(".register();\n"); } sb.append(" }\n"); sb.append("}\n"); @@ -254,9 +569,27 @@ private static String generateIndexSource(Iterable classes) { // 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 = readModelExpr(f); + String modelExpr = readExpr(f); switch (f.attr) { case TEXT: sb.append(" String _v = ").append(modelExpr).append(" == null ? \"\" : String.valueOf(").append(modelExpr).append(");\n"); @@ -269,17 +602,17 @@ private static void emitRefreshOne(StringBuilder sb, BoundField f, int i) { sb.append(" _c").append(i).append(".setUIID(_v);\n"); break; case HIDDEN: - sb.append(" _c").append(i).append(".setHidden(").append(booleanModelExpr(f, modelExpr)).append(");\n"); + sb.append(" _c").append(i).append(".setHidden(").append(boolExpr(f, modelExpr)).append(");\n"); break; case VISIBLE: - sb.append(" _c").append(i).append(".setVisible(").append(booleanModelExpr(f, modelExpr)).append(");\n"); + sb.append(" _c").append(i).append(".setVisible(").append(boolExpr(f, modelExpr)).append(");\n"); break; case ENABLED: - sb.append(" _c").append(i).append(".setEnabled(").append(booleanModelExpr(f, modelExpr)).append(");\n"); + 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(booleanModelExpr(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(booleanModelExpr(f, modelExpr)).append(");\n"); + 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"); @@ -296,40 +629,40 @@ private static void emitRefreshOne(StringBuilder sb, BoundField f, int i) { } private static void emitCommitOne(StringBuilder sb, BoundField f, int i) { - sb.append(" if (_c").append(i).append(" != null) {\n"); + 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"); - emitWriteModelFromString(sb, f, "_v"); - sb.append(" }\n"); + 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"); - emitWriteModelFromBoolean(sb, f, "_v"); + 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"); + sb.append(" }\n"); } private static void emitListenerInstall(StringBuilder sb, BoundField f, int i) { - // Action listener captures the model field by reference; we listen on - // both TextArea (DataChanged via DataChangedListener -> ActionListener - // bridge isn't free, so we use addDataChangedListener) and CheckBox / - // RadioButton. 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(" String _v = _ta.getText();\n"); - emitWriteModelFromString(sb, f, "_v"); + 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"); @@ -342,10 +675,14 @@ private static void emitListenerInstall(StringBuilder sb, BoundField f, int i) { 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(" 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"); - emitWriteModelFromBoolean(sb, f, "_v"); + 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"); @@ -356,14 +693,7 @@ private static void emitListenerInstall(StringBuilder sb, BoundField f, int i) { } } - private static String readModelExpr(BoundField f) { - if (f.kind.kind == PropertyTypeKind.Kind.PROPERTY) { - return "model." + f.fieldName + ".get()"; - } - return "model." + f.fieldName; - } - - private static String booleanModelExpr(BoundField f, String modelExpr) { + 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 + ")"; @@ -374,39 +704,72 @@ private static String booleanModelExpr(BoundField f, String modelExpr) { return modelExpr + " != null && Boolean.parseBoolean(String.valueOf(" + modelExpr + "))"; } - private static void emitWriteModelFromString(StringBuilder sb, BoundField f, String src) { + 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(" model.").append(f.fieldName).append(".set(").append(src).append(");\n"); + sb.append(" ").append(propRead).append(".set(").append(src).append(");\n"); } else if ("java.lang.Integer".equals(elem)) { - sb.append(" try { model.").append(f.fieldName).append(".set(Integer.valueOf(").append(src).append(")); } catch (NumberFormatException _nfe) {}\n"); + 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 { model.").append(f.fieldName).append(".set(Long.valueOf(").append(src).append(")); } catch (NumberFormatException _nfe) {}\n"); + 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 { model.").append(f.fieldName).append(".set(Double.valueOf(").append(src).append(")); } catch (NumberFormatException _nfe) {}\n"); + sb.append(" try { ").append(propRead).append(".set(Double.valueOf(").append(src).append(")); } catch (NumberFormatException _nfe) {}\n"); } else { - sb.append(" model.").append(f.fieldName).append(".set((").append(elem).append(") ").append(src).append(");\n"); + sb.append(" ").append(propRead).append(".set((").append(elem).append(") ").append(src).append(");\n"); } - } else if (f.kind.kind == PropertyTypeKind.Kind.STRING) { - sb.append(" model.").append(f.fieldName).append(" = ").append(src).append(";\n"); - } else if (f.kind.kind == PropertyTypeKind.Kind.INT) { - sb.append(" try { model.").append(f.fieldName).append(" = Integer.parseInt(").append(src).append("); } catch (NumberFormatException _nfe) {}\n"); - } else if (f.kind.kind == PropertyTypeKind.Kind.LONG) { - sb.append(" try { model.").append(f.fieldName).append(" = Long.parseLong(").append(src).append("); } catch (NumberFormatException _nfe) {}\n"); - } else if (f.kind.kind == PropertyTypeKind.Kind.DOUBLE) { - sb.append(" try { model.").append(f.fieldName).append(" = Double.parseDouble(").append(src).append("); } catch (NumberFormatException _nfe) {}\n"); - } else if (f.kind.kind == PropertyTypeKind.Kind.FLOAT) { - sb.append(" try { model.").append(f.fieldName).append(" = Float.parseFloat(").append(src).append("); } catch (NumberFormatException _nfe) {}\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 emitWriteModelFromBoolean(StringBuilder sb, BoundField f, String src) { + private static void emitWriteFromBoolean(StringBuilder sb, BoundField f, String src) { if (f.kind.kind == PropertyTypeKind.Kind.PROPERTY && "java.lang.Boolean".equals(f.kind.elementBinaryName)) { - sb.append(" model.").append(f.fieldName).append(".set(Boolean.valueOf(").append(src).append("));\n"); - } else if (f.kind.kind == PropertyTypeKind.Kind.BOOLEAN) { - sb.append(" model.").append(f.fieldName).append(" = ").append(src).append(";\n"); + 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"); } } @@ -415,12 +778,21 @@ private static String simpleName(String binary) { 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 ""; + 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('\\'); + if (c == '"' || c == '\\') { + b.append('\\'); + } b.append(c); } return b.toString(); @@ -430,12 +802,26 @@ private static String escape(String s) { // Accumulator types // --------------------------------------------------------------- - /// Mirror of `com.codename1.binding.BindAttr` -- can't reference the enum - /// directly here because the plugin module doesn't depend on cn1-core. - enum BindAttrName { TEXT, UIID, HIDDEN, VISIBLE, ENABLED, SELECTED, ICON_NAME, NAME } + 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; @@ -448,5 +834,9 @@ static final class BoundField { 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 index d6a4624d63..3e056c4afa 100644 --- 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 @@ -42,22 +42,23 @@ import java.util.TreeMap; /// Build-time `@Mapped` processor. Scans the project's compiled classes for -/// `@Mapped` types, validates each one (no abstract / interface targets, a -/// usable public no-arg constructor, supported field types), then generates: +/// `@Mapped` types, validates each one (concrete class, public no-arg +/// constructor, supported field types), then generates: /// -/// 1. One `XxxMapper` Java class per `@Mapped` type, in package -/// `com.codename1.mapping.generated`, implementing -/// `com.codename1.mapping.Mapper`. -/// 2. A single `MappersIndex` class in the same package whose no-arg -/// constructor calls `Mappers.register(new XxxMapper())` for every -/// discovered type. `Mappers#ensureIndexLoaded` lazy-loads it on the first -/// `Mappers.toJson` / `Mappers.fromJson` / `Mappers.toXml` / `Mappers.fromXml` -/// call. -/// -/// Everything goes through `JavaSourceCompiler` (the same JSR-199 wrapper the -/// router processor uses) so the emitted classes survive ParparVM rename and -/// R8 obfuscation in shipped builds -- the generated source references -/// application classes by direct symbol, not by `Class.forName`. +/// 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;"; @@ -68,7 +69,9 @@ public final class MappingAnnotationProcessor extends AbstractAnnotationProcesso 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 GENERATED_PACKAGE = "com.codename1.mapping.generated"; + 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 { @@ -109,8 +112,11 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces MappedClass mc = new MappedClass(); mc.binaryName = cls.getBinaryName(); mc.simpleName = simpleName(cls.getBinaryName()); - mc.mapperSimpleName = mc.simpleName + "Mapper"; - mc.mapperBinaryName = GENERATED_PACKAGE + "." + mc.mapperSimpleName; + 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) { @@ -183,7 +189,7 @@ public void finish(ProcessorContext ctx) throws ProcessingException { for (MappedClass mc : accepted.values()) { sources.put(mc.mapperBinaryName, generateMapperSource(mc)); } - sources.put(GENERATED_PACKAGE + ".MappersIndex", generateIndexSource(accepted.values())); + sources.put(BOOTSTRAP_BINARY, generateBootstrapSource(accepted.values())); try { // The output directory holds the application's @Mapped types -- @@ -197,7 +203,7 @@ public void finish(ProcessorContext ctx) throws ProcessingException { + ioe.getMessage(), ioe); } ctx.getLog().info("cn1: generated " + accepted.size() - + " @Mapped mapper(s) under " + GENERATED_PACKAGE); + + " @Mapped mapper(s) + " + BOOTSTRAP_BINARY); } // --------------------------------------------------------------- @@ -206,12 +212,25 @@ public void finish(ProcessorContext ctx) throws ProcessingException { private static String generateMapperSource(MappedClass mc) { StringBuilder sb = new StringBuilder(2048); - sb.append("package ").append(GENERATED_PACKAGE).append(";\n\n"); + 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"); @@ -284,20 +303,32 @@ private static String generateMapperSource(MappedClass mc) { return sb.toString(); } - private static String generateIndexSource(Iterable classes) { + private static String generateBootstrapSource(Iterable classes) { StringBuilder sb = new StringBuilder(1024); - sb.append("package ").append(GENERATED_PACKAGE).append(";\n\n"); + sb.append("package ").append(BOOTSTRAP_PACKAGE).append(";\n\n"); sb.append("// Auto-generated by cn1:process-annotations. Do not edit.\n"); - sb.append("public final class MappersIndex {\n"); - sb.append(" public MappersIndex() {\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(" com.codename1.mapping.Mappers.register(new ").append(mc.mapperSimpleName).append("());\n"); + 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 // --------------------------------------------------------------- @@ -764,6 +795,7 @@ private static String escape(String s) { static final class MappedClass { String binaryName; + String packageName; String simpleName; String mapperBinaryName; String mapperSimpleName; 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 index f438d05488..eccc9bd592 100644 --- 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 @@ -54,7 +54,9 @@ public final class OrmAnnotationProcessor extends AbstractAnnotationProcessor { public static final String COLUMN_DESC = "Lcom/codename1/annotations/Column;"; public static final String DB_TRANSIENT_DESC = "Lcom/codename1/annotations/DbTransient;"; - static final String GENERATED_PACKAGE = "com.codename1.orm.generated"; + 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 { @@ -94,8 +96,11 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces EntityClass ec = new EntityClass(); ec.binaryName = cls.getBinaryName(); ec.simpleName = simpleName(cls.getBinaryName()); - ec.daoSimpleName = ec.simpleName + "Dao"; - ec.daoBinaryName = GENERATED_PACKAGE + "." + ec.daoSimpleName; + 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; @@ -164,7 +169,7 @@ public void finish(ProcessorContext ctx) throws ProcessingException { for (EntityClass ec : accepted.values()) { sources.put(ec.daoBinaryName, generateDaoSource(ec)); } - sources.put(GENERATED_PACKAGE + ".DaosIndex", generateIndexSource(accepted.values())); + sources.put(BOOTSTRAP_BINARY, generateBootstrapSource(accepted.values())); try { java.util.List cp = new java.util.ArrayList(); cp.add(ctx.getOutputClassDir()); @@ -174,7 +179,7 @@ public void finish(ProcessorContext ctx) throws ProcessingException { + ioe.getMessage(), ioe); } ctx.getLog().info("cn1: generated " + accepted.size() - + " @Entity dao(s) under " + GENERATED_PACKAGE); + + " @Entity dao(s) + " + BOOTSTRAP_BINARY); } // --------------------------------------------------------------- @@ -183,12 +188,24 @@ public void finish(ProcessorContext ctx) throws ProcessingException { private static String generateDaoSource(EntityClass ec) { StringBuilder sb = new StringBuilder(4096); - sb.append("package ").append(GENERATED_PACKAGE).append(";\n\n"); + 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"); @@ -336,20 +353,32 @@ private static String generateDaoSource(EntityClass ec) { return sb.toString(); } - private static String generateIndexSource(Iterable classes) { + private static String generateBootstrapSource(Iterable classes) { StringBuilder sb = new StringBuilder(1024); - sb.append("package ").append(GENERATED_PACKAGE).append(";\n\n"); + sb.append("package ").append(BOOTSTRAP_PACKAGE).append(";\n\n"); sb.append("// Auto-generated by cn1:process-annotations. Do not edit.\n"); - sb.append("public final class DaosIndex {\n"); - sb.append(" public DaosIndex() {\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(" com.codename1.orm.EntityManager.registerDao(new ").append(ec.daoSimpleName).append("());\n"); + 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++) { @@ -619,6 +648,7 @@ private static String escape(String s) { static final class EntityClass { String binaryName; + String packageName; String simpleName; String daoBinaryName; String daoSimpleName; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BindingAnnotationProcessorTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BindingAnnotationProcessorTest.java index 41e8ebea18..0d331a7970 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BindingAnnotationProcessorTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/maven/processors/BindingAnnotationProcessorTest.java @@ -54,33 +54,95 @@ public void generatesBinderWithExpectedShape() throws Exception { + "}\n"); runProcessorOrFail(classes); - File binderFile = new File(classes, "com/codename1/binding/generated/LoginModelBinder.class"); + File binderFile = new File(classes, "com/example/LoginModelCn1Binder.class"); assertTrue("generated binder file should exist: " + binderFile, binderFile.exists()); - File indexFile = new File(classes, "com/codename1/binding/generated/BindersIndex.class"); - assertTrue("BindersIndex should exist", indexFile.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 rejectsBindOnPrivateField() throws Exception { + 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" - + "import com.codename1.binding.BindAttr;\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 @Bind field", ctx.hasErrors()); + 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]); } // --------------------------------------------------------------- @@ -115,6 +177,17 @@ private ProcessorContext runProcessor(File classesDir) throws Exception { 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; } 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 index e879f5a647..e9b85b8c59 100644 --- 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 @@ -54,15 +54,15 @@ public void pojoRoundTripsThroughGeneratedMapper() throws Exception { + "}\n"); runProcessorOrFail(classes); - assertTrue(new File(classes, "com/codename1/mapping/generated/UserMapper.class").exists()); - assertTrue(new File(classes, "com/codename1/mapping/generated/MappersIndex.class").exists()); + 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.codename1.mapping.generated.UserMapper"); + Class mapperCls = cl.loadClass("com.example.UserCn1Mapper"); Object mapper = mapperCls.newInstance(); Object user = userCls.newInstance(); @@ -109,7 +109,7 @@ public void propertyFieldRoundTripsThroughJsonAndXml() throws Exception { try (URLClassLoader cl = childLoader(classes)) { Class itemCls = cl.loadClass("com.example.Item"); - Class mapperCls = cl.loadClass("com.codename1.mapping.generated.ItemMapper"); + Class mapperCls = cl.loadClass("com.example.ItemCn1Mapper"); Object mapper = mapperCls.newInstance(); // Create item and populate via the generated mapper's fromMap. 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 index 37cb1d93e3..03982d3592 100644 --- 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 @@ -52,10 +52,10 @@ public void generatesDaoWithExpectedShape() throws Exception { + "}\n"); runProcessorOrFail(classes); - File daoFile = new File(classes, "com/codename1/orm/generated/UserDao.class"); + File daoFile = new File(classes, "com/example/UserCn1Dao.class"); assertTrue("generated dao file should exist: " + daoFile, daoFile.exists()); - File indexFile = new File(classes, "com/codename1/orm/generated/DaosIndex.class"); - assertTrue("DaosIndex should exist", indexFile.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", From 13f68c0801da667a1fde8c264e35fac31163a2b0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 27 May 2026 13:40:39 +0300 Subject: [PATCH 09/12] Fix CI: LanguageTool grammar/spelling nits in the three annotation chapters Six LanguageTool matches across the new docs: - 3x ATD_VERBS_TO_COLLOCATION on "Direct symbol references survive ParparVM rename..." -- LT reads "references" as a verb and expects a preposition. Rephrase as "ParparVM rename and R8 obfuscation rewrite the call site and the generated class together, so the direct symbol reference stays valid after the pass." - 2x MORFOLOGIK_RULE_EN_US on "Classloading" -- not in LT's en_US dictionary. Hyphenate as "Class-loading" (also more correct). - 1x UPPERCASE_SENTENCE_START on a paragraph that begins with the lowercase phrase "at every `return` point..." after a code listing. Rephrase to start the sentence with "The injection lands before every `return` point...". vale Annotation-*.asciidoc -> 0 errors, 0 warnings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Annotation-Component-Binding.asciidoc | 17 ++++++++++------- .../Annotation-JSON-XML-Mapping.asciidoc | 8 ++++---- .../Annotation-SQLite-ORM.asciidoc | 9 +++++---- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/docs/developer-guide/Annotation-Component-Binding.asciidoc b/docs/developer-guide/Annotation-Component-Binding.asciidoc index 5c7c3d4235..5c3e7c0786 100644 --- a/docs/developer-guide/Annotation-Component-Binding.asciidoc +++ b/docs/developer-guide/Annotation-Component-Binding.asciidoc @@ -164,10 +164,11 @@ public void setName(String name) { } ---- -at 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. +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 @@ -216,10 +217,12 @@ 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`. Direct symbol references survive ParparVM - rename and R8 obfuscation. + 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")`. + `Class.forName("cn1app.BinderBootstrap")` -- the unobfuscated + classloading path. Projects with no `@Bindable` classes produce no bootstrap; the build server probe falls through and the registry stays empty. diff --git a/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc b/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc index 3ff25233ce..0d12c3da48 100644 --- a/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc +++ b/docs/developer-guide/Annotation-JSON-XML-Mapping.asciidoc @@ -146,12 +146,12 @@ 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`. Direct symbol references survive the - ParparVM rename and the R8 obfuscation pass: the call site and the - generated class are renamed together. + 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")`. - Classloading is the legitimate path here -- JavaSE runs unobfuscated + 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 diff --git a/docs/developer-guide/Annotation-SQLite-ORM.asciidoc b/docs/developer-guide/Annotation-SQLite-ORM.asciidoc index e11e22bd61..d1e48ca154 100644 --- a/docs/developer-guide/Annotation-SQLite-ORM.asciidoc +++ b/docs/developer-guide/Annotation-SQLite-ORM.asciidoc @@ -150,12 +150,13 @@ 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`. Direct symbol references survive ParparVM - rename and R8 obfuscation: the call site and the generated dao are - renamed together. + 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")`. - Classloading is the legitimate path here -- JavaSE runs unobfuscated. + 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. From 7995289131564595bfc7ccea7b89fd8d93e1470a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 27 May 2026 14:03:39 +0300 Subject: [PATCH 10/12] Fix CI: bump SpotBugs maxHeap so the plugin module's analysis doesn't OOM The archetype-smoke job runs `mvn install -Pall` over the whole tree, which invokes SpotBugs against codenameone-maven-plugin. After the BindingAnnotationProcessor gained the ASM bytecode-instrumentation pass, the plugin module crossed a size threshold and the FindOpenStream dataflow detector exhausted the default 512MB heap on CI ("GC overhead limit exceeded"). Set 1536 on the SpotBugs plugin configuration so the forked JVM has room to finish. The effort/threshold settings stay at Max/Low to preserve coverage. Co-Authored-By: Claude Opus 4.7 (1M context) --- maven/codenameone-maven-plugin/pom.xml | 6 ++++++ 1 file changed, 6 insertions(+) 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 From 2f827d0d87c1ede3f1688ba360ffde07ac352df4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 27 May 2026 14:37:35 +0300 Subject: [PATCH 11/12] Fix CI: missed lowercase "classloading" in the binding chapter Last LT pass fixed two "Classloading" (capital C) occurrences in the mapping and ORM chapters but missed the lowercase "classloading path" prepositional phrase in the binding chapter. Hyphenate as "class-loading" to match the dictionary form already used elsewhere. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/developer-guide/Annotation-Component-Binding.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developer-guide/Annotation-Component-Binding.asciidoc b/docs/developer-guide/Annotation-Component-Binding.asciidoc index 5c3e7c0786..eb7d0e481f 100644 --- a/docs/developer-guide/Annotation-Component-Binding.asciidoc +++ b/docs/developer-guide/Annotation-Component-Binding.asciidoc @@ -222,7 +222,7 @@ every accepted `@Bindable` class. At app start: the binding still resolves after the pass. * On **JavaSE** `JavaSEPort#postInit` loads the bootstrap via `Class.forName("cn1app.BinderBootstrap")` -- the unobfuscated - classloading path. + class-loading path. Projects with no `@Bindable` classes produce no bootstrap; the build server probe falls through and the registry stays empty. From 89c1c0d2c0d5a2158e2181a9aebbff0afa5f0b1e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 27 May 2026 15:04:44 +0300 Subject: [PATCH 12/12] Fix CI: two PMD violations in Binders introduced by the live-bindings registry - ForLoopCanBeForeach at Binders.java:174 -- the snapshot iteration in notifyChanged was index-based; switch to foreach so PMD's ForLoopCanBeForeach (a forbidden rule) stops firing. - CompareObjectsWithEquals at Binders.java:217 -- unregisterBinding removes the exact binding instance from the live list, so the identity comparison is intentional. Add the NOPMD suppression comment cn1-core already uses elsewhere (see MapComponent / CodenameOneImplementation) so the build gate stops failing on the deliberate `==` check. mvn -pl codenameone-maven-plugin test -> 95/95 still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- CodenameOne/src/com/codename1/binding/Binders.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/binding/Binders.java b/CodenameOne/src/com/codename1/binding/Binders.java index 4ad3642a00..45be4d3778 100644 --- a/CodenameOne/src/com/codename1/binding/Binders.java +++ b/CodenameOne/src/com/codename1/binding/Binders.java @@ -171,8 +171,7 @@ public static void notifyChanged(Object model) { synchronized (LIVE_BINDINGS) { snapshot = bindings.toArray(new NotifiableBinding[0]); } - for (int i = 0; i < snapshot.length; i++) { - NotifiableBinding b = snapshot[i]; + for (NotifiableBinding b : snapshot) { if (b.matches(model)) { enterUpdate(); try { @@ -214,7 +213,7 @@ public static void unregisterBinding(NotifiableBinding binding) { } for (Iterator it = list.iterator(); it.hasNext(); ) { NotifiableBinding b = it.next(); - if (b == binding) { + if (b == binding) { //NOPMD CompareObjectsWithEquals -- identity dedup it.remove(); break; }