From c4ac0d846d90816ed455616ccb4dbe600e1ba9b6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:14:03 +0300 Subject: [PATCH 01/40] Add managed ORM sessions and relationship mappings across runtimes --- .../codename1/annotations/CascadeType.java | 26 + .../src/com/codename1/annotations/Column.java | 3 + .../com/codename1/annotations/Convert.java | 32 + .../annotations/DiscriminatorValue.java | 28 + .../annotations/ElementCollection.java | 28 + .../com/codename1/annotations/Embeddable.java | 28 + .../com/codename1/annotations/Embedded.java | 28 + .../com/codename1/annotations/EmbeddedId.java | 28 + .../src/com/codename1/annotations/Entity.java | 1 + .../com/codename1/annotations/FetchType.java | 26 + .../codename1/annotations/GeneratedValue.java | 31 + .../codename1/annotations/GenerationType.java | 25 + .../src/com/codename1/annotations/Index.java | 32 + .../codename1/annotations/Inheritance.java | 28 + .../com/codename1/annotations/JoinColumn.java | 29 + .../com/codename1/annotations/JoinTable.java | 33 + .../com/codename1/annotations/ManyToMany.java | 33 + .../com/codename1/annotations/ManyToOne.java | 33 + .../src/com/codename1/annotations/MapKey.java | 28 + .../codename1/annotations/MapKeyColumn.java | 28 + .../annotations/MappedSuperclass.java | 28 + .../com/codename1/annotations/OneToMany.java | 34 + .../com/codename1/annotations/OneToOne.java | 35 + .../com/codename1/annotations/OrderBy.java | 28 + .../codename1/annotations/OrderColumn.java | 28 + .../com/codename1/annotations/PostLoad.java | 28 + .../codename1/annotations/PostPersist.java | 28 + .../com/codename1/annotations/PostRemove.java | 28 + .../com/codename1/annotations/PostUpdate.java | 28 + .../com/codename1/annotations/PrePersist.java | 28 + .../com/codename1/annotations/PreRemove.java | 28 + .../com/codename1/annotations/PreUpdate.java | 28 + .../com/codename1/annotations/Version.java | 32 + .../src/com/codename1/orm/DaoFactory.java | 28 + .../src/com/codename1/orm/EntityManager.java | 33 +- .../com/codename1/orm/SessionSqlAccess.java | 141 +++ .../com/codename1/orm/session/Attribute.java | 41 + .../orm/session/AttributeConverter.java | 29 + .../com/codename1/orm/session/Elements.java | 49 + .../codename1/orm/session/EntityModel.java | 109 ++ .../codename1/orm/session/EntityState.java | 60 + .../com/codename1/orm/session/Identifier.java | 44 + .../src/com/codename1/orm/session/Index.java | 32 + .../com/codename1/orm/session/JpqlQuery.java | 395 ++++++ .../session/LazyInitializationException.java | 28 + .../com/codename1/orm/session/LockMode.java | 25 + .../codename1/orm/session/ManagedEntity.java | 29 + .../src/com/codename1/orm/session/Models.java | 77 ++ .../orm/session/OptimisticLockException.java | 28 + .../orm/session/PersistenceException.java | 29 + .../src/com/codename1/orm/session/Query.java | 175 +++ .../codename1/orm/session/Relationship.java | 57 + .../com/codename1/orm/session/Session.java | 1092 +++++++++++++++++ .../com/codename1/orm/session/SqlAccess.java | 49 + .../src/com/codename1/orm/session/Values.java | 562 +++++++++ .../Annotation-SQLite-ORM.asciidoc | 221 +++- .../orm/ManagedSessionDatabaseTest.java | 88 ++ .../backend/orm/ManagedSessionTest.java | 232 ++++ .../codename1/maven/BackendPackageMojo.java | 1 + .../maven/ProcessAnnotationsMojo.java | 7 + .../processors/OrmAnnotationProcessor.java | 703 ++++++++++- .../maven/processors/OrmEnhancer.java | 212 ++++ .../maven/processors/PropertyTypeKind.java | 10 +- .../OrmAnnotationProcessorTest.java | 260 +++- maven/core-unittests/spotbugs-exclude.xml | 6 + .../com/codename1/orm/EntityManagerTest.java | 14 + .../com/codename1/orm/ManagedSessionTest.java | 220 ++++ .../codename1/annotations/CascadeType.java | 26 + .../src/com/codename1/annotations/Column.java | 1 + .../com/codename1/annotations/Convert.java | 32 + .../annotations/DiscriminatorValue.java | 28 + .../annotations/ElementCollection.java | 28 + .../com/codename1/annotations/Embeddable.java | 28 + .../com/codename1/annotations/Embedded.java | 28 + .../com/codename1/annotations/EmbeddedId.java | 28 + .../src/com/codename1/annotations/Entity.java | 1 + .../com/codename1/annotations/FetchType.java | 26 + .../codename1/annotations/GeneratedValue.java | 31 + .../codename1/annotations/GenerationType.java | 25 + .../src/com/codename1/annotations/Index.java | 32 + .../codename1/annotations/Inheritance.java | 28 + .../com/codename1/annotations/JoinColumn.java | 29 + .../com/codename1/annotations/JoinTable.java | 33 + .../com/codename1/annotations/ManyToMany.java | 33 + .../com/codename1/annotations/ManyToOne.java | 33 + .../src/com/codename1/annotations/MapKey.java | 28 + .../codename1/annotations/MapKeyColumn.java | 28 + .../annotations/MappedSuperclass.java | 28 + .../com/codename1/annotations/OneToMany.java | 34 + .../com/codename1/annotations/OneToOne.java | 35 + .../com/codename1/annotations/OrderBy.java | 28 + .../codename1/annotations/OrderColumn.java | 28 + .../com/codename1/annotations/PostLoad.java | 28 + .../codename1/annotations/PostPersist.java | 28 + .../com/codename1/annotations/PostRemove.java | 28 + .../com/codename1/annotations/PostUpdate.java | 28 + .../com/codename1/annotations/PrePersist.java | 28 + .../com/codename1/annotations/PreRemove.java | 28 + .../com/codename1/annotations/PreUpdate.java | 28 + .../com/codename1/annotations/Version.java | 32 + .../src/com/codename1/backend/Database.java | 50 +- .../codename1/backend/orm/EntityManager.java | 28 +- .../backend/orm/SessionSqlAccess.java | 149 +++ .../com/codename1/orm/session/Attribute.java | 41 + .../orm/session/AttributeConverter.java | 29 + .../com/codename1/orm/session/Elements.java | 49 + .../codename1/orm/session/EntityModel.java | 109 ++ .../codename1/orm/session/EntityState.java | 60 + .../com/codename1/orm/session/Identifier.java | 44 + .../src/com/codename1/orm/session/Index.java | 32 + .../com/codename1/orm/session/JpqlQuery.java | 395 ++++++ .../session/LazyInitializationException.java | 28 + .../com/codename1/orm/session/LockMode.java | 25 + .../codename1/orm/session/ManagedEntity.java | 29 + .../src/com/codename1/orm/session/Models.java | 77 ++ .../orm/session/OptimisticLockException.java | 28 + .../orm/session/PersistenceException.java | 29 + .../src/com/codename1/orm/session/Query.java | 175 +++ .../codename1/orm/session/Relationship.java | 57 + .../com/codename1/orm/session/Session.java | 1092 +++++++++++++++++ .../com/codename1/orm/session/SqlAccess.java | 49 + .../src/com/codename1/orm/session/Values.java | 562 +++++++++ 122 files changed, 9979 insertions(+), 60 deletions(-) create mode 100644 CodenameOne/src/com/codename1/annotations/CascadeType.java create mode 100644 CodenameOne/src/com/codename1/annotations/Convert.java create mode 100644 CodenameOne/src/com/codename1/annotations/DiscriminatorValue.java create mode 100644 CodenameOne/src/com/codename1/annotations/ElementCollection.java create mode 100644 CodenameOne/src/com/codename1/annotations/Embeddable.java create mode 100644 CodenameOne/src/com/codename1/annotations/Embedded.java create mode 100644 CodenameOne/src/com/codename1/annotations/EmbeddedId.java create mode 100644 CodenameOne/src/com/codename1/annotations/FetchType.java create mode 100644 CodenameOne/src/com/codename1/annotations/GeneratedValue.java create mode 100644 CodenameOne/src/com/codename1/annotations/GenerationType.java create mode 100644 CodenameOne/src/com/codename1/annotations/Index.java create mode 100644 CodenameOne/src/com/codename1/annotations/Inheritance.java create mode 100644 CodenameOne/src/com/codename1/annotations/JoinColumn.java create mode 100644 CodenameOne/src/com/codename1/annotations/JoinTable.java create mode 100644 CodenameOne/src/com/codename1/annotations/ManyToMany.java create mode 100644 CodenameOne/src/com/codename1/annotations/ManyToOne.java create mode 100644 CodenameOne/src/com/codename1/annotations/MapKey.java create mode 100644 CodenameOne/src/com/codename1/annotations/MapKeyColumn.java create mode 100644 CodenameOne/src/com/codename1/annotations/MappedSuperclass.java create mode 100644 CodenameOne/src/com/codename1/annotations/OneToMany.java create mode 100644 CodenameOne/src/com/codename1/annotations/OneToOne.java create mode 100644 CodenameOne/src/com/codename1/annotations/OrderBy.java create mode 100644 CodenameOne/src/com/codename1/annotations/OrderColumn.java create mode 100644 CodenameOne/src/com/codename1/annotations/PostLoad.java create mode 100644 CodenameOne/src/com/codename1/annotations/PostPersist.java create mode 100644 CodenameOne/src/com/codename1/annotations/PostRemove.java create mode 100644 CodenameOne/src/com/codename1/annotations/PostUpdate.java create mode 100644 CodenameOne/src/com/codename1/annotations/PrePersist.java create mode 100644 CodenameOne/src/com/codename1/annotations/PreRemove.java create mode 100644 CodenameOne/src/com/codename1/annotations/PreUpdate.java create mode 100644 CodenameOne/src/com/codename1/annotations/Version.java create mode 100644 CodenameOne/src/com/codename1/orm/DaoFactory.java create mode 100644 CodenameOne/src/com/codename1/orm/SessionSqlAccess.java create mode 100644 CodenameOne/src/com/codename1/orm/session/Attribute.java create mode 100644 CodenameOne/src/com/codename1/orm/session/AttributeConverter.java create mode 100644 CodenameOne/src/com/codename1/orm/session/Elements.java create mode 100644 CodenameOne/src/com/codename1/orm/session/EntityModel.java create mode 100644 CodenameOne/src/com/codename1/orm/session/EntityState.java create mode 100644 CodenameOne/src/com/codename1/orm/session/Identifier.java create mode 100644 CodenameOne/src/com/codename1/orm/session/Index.java create mode 100644 CodenameOne/src/com/codename1/orm/session/JpqlQuery.java create mode 100644 CodenameOne/src/com/codename1/orm/session/LazyInitializationException.java create mode 100644 CodenameOne/src/com/codename1/orm/session/LockMode.java create mode 100644 CodenameOne/src/com/codename1/orm/session/ManagedEntity.java create mode 100644 CodenameOne/src/com/codename1/orm/session/Models.java create mode 100644 CodenameOne/src/com/codename1/orm/session/OptimisticLockException.java create mode 100644 CodenameOne/src/com/codename1/orm/session/PersistenceException.java create mode 100644 CodenameOne/src/com/codename1/orm/session/Query.java create mode 100644 CodenameOne/src/com/codename1/orm/session/Relationship.java create mode 100644 CodenameOne/src/com/codename1/orm/session/Session.java create mode 100644 CodenameOne/src/com/codename1/orm/session/SqlAccess.java create mode 100644 CodenameOne/src/com/codename1/orm/session/Values.java create mode 100644 maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionDatabaseTest.java create mode 100644 maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionTest.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/OrmEnhancer.java create mode 100644 maven/javase/src/test/java/com/codename1/orm/ManagedSessionTest.java create mode 100644 vm/backend/src/com/codename1/annotations/CascadeType.java create mode 100644 vm/backend/src/com/codename1/annotations/Convert.java create mode 100644 vm/backend/src/com/codename1/annotations/DiscriminatorValue.java create mode 100644 vm/backend/src/com/codename1/annotations/ElementCollection.java create mode 100644 vm/backend/src/com/codename1/annotations/Embeddable.java create mode 100644 vm/backend/src/com/codename1/annotations/Embedded.java create mode 100644 vm/backend/src/com/codename1/annotations/EmbeddedId.java create mode 100644 vm/backend/src/com/codename1/annotations/FetchType.java create mode 100644 vm/backend/src/com/codename1/annotations/GeneratedValue.java create mode 100644 vm/backend/src/com/codename1/annotations/GenerationType.java create mode 100644 vm/backend/src/com/codename1/annotations/Index.java create mode 100644 vm/backend/src/com/codename1/annotations/Inheritance.java create mode 100644 vm/backend/src/com/codename1/annotations/JoinColumn.java create mode 100644 vm/backend/src/com/codename1/annotations/JoinTable.java create mode 100644 vm/backend/src/com/codename1/annotations/ManyToMany.java create mode 100644 vm/backend/src/com/codename1/annotations/ManyToOne.java create mode 100644 vm/backend/src/com/codename1/annotations/MapKey.java create mode 100644 vm/backend/src/com/codename1/annotations/MapKeyColumn.java create mode 100644 vm/backend/src/com/codename1/annotations/MappedSuperclass.java create mode 100644 vm/backend/src/com/codename1/annotations/OneToMany.java create mode 100644 vm/backend/src/com/codename1/annotations/OneToOne.java create mode 100644 vm/backend/src/com/codename1/annotations/OrderBy.java create mode 100644 vm/backend/src/com/codename1/annotations/OrderColumn.java create mode 100644 vm/backend/src/com/codename1/annotations/PostLoad.java create mode 100644 vm/backend/src/com/codename1/annotations/PostPersist.java create mode 100644 vm/backend/src/com/codename1/annotations/PostRemove.java create mode 100644 vm/backend/src/com/codename1/annotations/PostUpdate.java create mode 100644 vm/backend/src/com/codename1/annotations/PrePersist.java create mode 100644 vm/backend/src/com/codename1/annotations/PreRemove.java create mode 100644 vm/backend/src/com/codename1/annotations/PreUpdate.java create mode 100644 vm/backend/src/com/codename1/annotations/Version.java create mode 100644 vm/backend/src/com/codename1/backend/orm/SessionSqlAccess.java create mode 100644 vm/backend/src/com/codename1/orm/session/Attribute.java create mode 100644 vm/backend/src/com/codename1/orm/session/AttributeConverter.java create mode 100644 vm/backend/src/com/codename1/orm/session/Elements.java create mode 100644 vm/backend/src/com/codename1/orm/session/EntityModel.java create mode 100644 vm/backend/src/com/codename1/orm/session/EntityState.java create mode 100644 vm/backend/src/com/codename1/orm/session/Identifier.java create mode 100644 vm/backend/src/com/codename1/orm/session/Index.java create mode 100644 vm/backend/src/com/codename1/orm/session/JpqlQuery.java create mode 100644 vm/backend/src/com/codename1/orm/session/LazyInitializationException.java create mode 100644 vm/backend/src/com/codename1/orm/session/LockMode.java create mode 100644 vm/backend/src/com/codename1/orm/session/ManagedEntity.java create mode 100644 vm/backend/src/com/codename1/orm/session/Models.java create mode 100644 vm/backend/src/com/codename1/orm/session/OptimisticLockException.java create mode 100644 vm/backend/src/com/codename1/orm/session/PersistenceException.java create mode 100644 vm/backend/src/com/codename1/orm/session/Query.java create mode 100644 vm/backend/src/com/codename1/orm/session/Relationship.java create mode 100644 vm/backend/src/com/codename1/orm/session/Session.java create mode 100644 vm/backend/src/com/codename1/orm/session/SqlAccess.java create mode 100644 vm/backend/src/com/codename1/orm/session/Values.java diff --git a/CodenameOne/src/com/codename1/annotations/CascadeType.java b/CodenameOne/src/com/codename1/annotations/CascadeType.java new file mode 100644 index 00000000000..32b06a6d1b4 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/CascadeType.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. + */ +package com.codename1.annotations; + +/** Lifecycle operations propagated to an association. */ +public enum CascadeType { ALL, PERSIST, MERGE, REMOVE, REFRESH, DETACH } diff --git a/CodenameOne/src/com/codename1/annotations/Column.java b/CodenameOne/src/com/codename1/annotations/Column.java index 7680f1d33cc..7ba023bea4a 100644 --- a/CodenameOne/src/com/codename1/annotations/Column.java +++ b/CodenameOne/src/com/codename1/annotations/Column.java @@ -41,6 +41,9 @@ /// When false the column gets a `NOT NULL` constraint at table-create time. boolean nullable() default true; + /// Creates a unique index when the managed schema is created. + boolean unique() default false; + /// 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. diff --git a/CodenameOne/src/com/codename1/annotations/Convert.java b/CodenameOne/src/com/codename1/annotations/Convert.java new file mode 100644 index 00000000000..872128dd653 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Convert.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.annotations; +import java.lang.annotation.*; +import com.codename1.orm.session.AttributeConverter; +/** A public no-arg converter for a basic field; converters also receive null values. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface Convert { + Class converter(); + Class storageType() default String.class; +} diff --git a/CodenameOne/src/com/codename1/annotations/DiscriminatorValue.java b/CodenameOne/src/com/codename1/annotations/DiscriminatorValue.java new file mode 100644 index 00000000000..bbc42b9fbca --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/DiscriminatorValue.java @@ -0,0 +1,28 @@ +/* + * 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.*; +/** The persistent discriminator for a concrete class in an entity hierarchy. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface DiscriminatorValue { String value(); } diff --git a/CodenameOne/src/com/codename1/annotations/ElementCollection.java b/CodenameOne/src/com/codename1/annotations/ElementCollection.java new file mode 100644 index 00000000000..0ca47274aa9 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/ElementCollection.java @@ -0,0 +1,28 @@ +/* + * 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.*; +/** Mapping for an owned collection of scalar values. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface ElementCollection { FetchType fetch() default FetchType.LAZY; String column() default "element_value"; } diff --git a/CodenameOne/src/com/codename1/annotations/Embeddable.java b/CodenameOne/src/com/codename1/annotations/Embeddable.java new file mode 100644 index 00000000000..d127e0ffd5c --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Embeddable.java @@ -0,0 +1,28 @@ +/* + * 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.*; +/** A reusable value object whose scalar fields are stored in its owner table. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface Embeddable {} diff --git a/CodenameOne/src/com/codename1/annotations/Embedded.java b/CodenameOne/src/com/codename1/annotations/Embedded.java new file mode 100644 index 00000000000..8ff3dd3a88b --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Embedded.java @@ -0,0 +1,28 @@ +/* + * 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.*; +/** Flattens an embeddable value into the owner table using a field-name prefix. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface Embedded {} diff --git a/CodenameOne/src/com/codename1/annotations/EmbeddedId.java b/CodenameOne/src/com/codename1/annotations/EmbeddedId.java new file mode 100644 index 00000000000..dbff1328064 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/EmbeddedId.java @@ -0,0 +1,28 @@ +/* + * 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.*; +/** Flattens an embeddable identifier into the owner table using a field-name prefix. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface EmbeddedId {} diff --git a/CodenameOne/src/com/codename1/annotations/Entity.java b/CodenameOne/src/com/codename1/annotations/Entity.java index 27bb000ad46..35761591ec6 100644 --- a/CodenameOne/src/com/codename1/annotations/Entity.java +++ b/CodenameOne/src/com/codename1/annotations/Entity.java @@ -57,4 +57,5 @@ public @interface Entity { /// SQL table name. Defaults to the simple class name when blank. String table() default ""; + Index[] indexes() default {}; } diff --git a/CodenameOne/src/com/codename1/annotations/FetchType.java b/CodenameOne/src/com/codename1/annotations/FetchType.java new file mode 100644 index 00000000000..d5209b273f8 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/FetchType.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. + */ +package com.codename1.annotations; + +/** Association loading policy. */ +public enum FetchType { EAGER, LAZY } diff --git a/CodenameOne/src/com/codename1/annotations/GeneratedValue.java b/CodenameOne/src/com/codename1/annotations/GeneratedValue.java new file mode 100644 index 00000000000..d99d450a997 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/GeneratedValue.java @@ -0,0 +1,31 @@ +/* + * 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.*; +/** Overrides the legacy Id.autoIncrement strategy for this identifier. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface GeneratedValue { + GenerationType strategy() default GenerationType.IDENTITY; + String generator() default ""; +} diff --git a/CodenameOne/src/com/codename1/annotations/GenerationType.java b/CodenameOne/src/com/codename1/annotations/GenerationType.java new file mode 100644 index 00000000000..1b44fd8b443 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/GenerationType.java @@ -0,0 +1,25 @@ +/* + * 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; +/** Portable identifier generation strategies. SEQUENCE uses a table when native sequences are unavailable. */ +public enum GenerationType { IDENTITY, UUID, SEQUENCE, TABLE } diff --git a/CodenameOne/src/com/codename1/annotations/Index.java b/CodenameOne/src/com/codename1/annotations/Index.java new file mode 100644 index 00000000000..e5983b67e56 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Index.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.annotations; +import java.lang.annotation.*; +/** Database index over mapped field names, declared on an entity. */ +@Retention(RetentionPolicy.CLASS) +@Target({}) +public @interface Index { + String name(); + String[] fields(); + boolean unique() default false; +} diff --git a/CodenameOne/src/com/codename1/annotations/Inheritance.java b/CodenameOne/src/com/codename1/annotations/Inheritance.java new file mode 100644 index 00000000000..d9a12109d83 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Inheritance.java @@ -0,0 +1,28 @@ +/* + * 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.*; +/** Stores an entity hierarchy in one table with an immutable discriminator. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface Inheritance { String discriminatorColumn() default "entity_type"; } diff --git a/CodenameOne/src/com/codename1/annotations/JoinColumn.java b/CodenameOne/src/com/codename1/annotations/JoinColumn.java new file mode 100644 index 00000000000..1d2b190729a --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/JoinColumn.java @@ -0,0 +1,29 @@ +/* + * 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.*; +/** Foreign key column on the owning table. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface JoinColumn { String name() default ""; boolean nullable() default true; } diff --git a/CodenameOne/src/com/codename1/annotations/JoinTable.java b/CodenameOne/src/com/codename1/annotations/JoinTable.java new file mode 100644 index 00000000000..83dac69ddc9 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/JoinTable.java @@ -0,0 +1,33 @@ +/* + * 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.*; +/** Join table and key column names for an owning to-many association. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface JoinTable { + String name(); + String joinColumn() default "owner_id"; + String inverseJoinColumn() default "target_id"; +} diff --git a/CodenameOne/src/com/codename1/annotations/ManyToMany.java b/CodenameOne/src/com/codename1/annotations/ManyToMany.java new file mode 100644 index 00000000000..36db0edecb0 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/ManyToMany.java @@ -0,0 +1,33 @@ +/* + * 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.*; +/** Declares a persistent association, resolved at build time. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface ManyToMany { + FetchType fetch() default FetchType.LAZY; + CascadeType[] cascade() default {}; + String mappedBy() default ""; +} diff --git a/CodenameOne/src/com/codename1/annotations/ManyToOne.java b/CodenameOne/src/com/codename1/annotations/ManyToOne.java new file mode 100644 index 00000000000..489ccebbc7e --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/ManyToOne.java @@ -0,0 +1,33 @@ +/* + * 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.*; +/** Declares a persistent association, resolved at build time. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface ManyToOne { + FetchType fetch() default FetchType.EAGER; + CascadeType[] cascade() default {}; + boolean optional() default true; +} diff --git a/CodenameOne/src/com/codename1/annotations/MapKey.java b/CodenameOne/src/com/codename1/annotations/MapKey.java new file mode 100644 index 00000000000..ef3468ce71e --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/MapKey.java @@ -0,0 +1,28 @@ +/* + * 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.*; +/** Collection mapping metadata interpreted at build time. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface MapKey { String name(); } diff --git a/CodenameOne/src/com/codename1/annotations/MapKeyColumn.java b/CodenameOne/src/com/codename1/annotations/MapKeyColumn.java new file mode 100644 index 00000000000..2873c7b6c04 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/MapKeyColumn.java @@ -0,0 +1,28 @@ +/* + * 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.*; +/** Mapping for an owned collection of scalar values. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface MapKeyColumn { String name() default "map_key"; } diff --git a/CodenameOne/src/com/codename1/annotations/MappedSuperclass.java b/CodenameOne/src/com/codename1/annotations/MappedSuperclass.java new file mode 100644 index 00000000000..5472c6f316d --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/MappedSuperclass.java @@ -0,0 +1,28 @@ +/* + * 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.*; +/** Contributes inherited scalar and relationship mappings to concrete entities. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface MappedSuperclass {} diff --git a/CodenameOne/src/com/codename1/annotations/OneToMany.java b/CodenameOne/src/com/codename1/annotations/OneToMany.java new file mode 100644 index 00000000000..f3ad29b784f --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/OneToMany.java @@ -0,0 +1,34 @@ +/* + * 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.*; +/** Declares a persistent association, resolved at build time. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface OneToMany { + FetchType fetch() default FetchType.LAZY; + CascadeType[] cascade() default {}; + String mappedBy() default ""; + boolean orphanRemoval() default false; +} diff --git a/CodenameOne/src/com/codename1/annotations/OneToOne.java b/CodenameOne/src/com/codename1/annotations/OneToOne.java new file mode 100644 index 00000000000..97c23c09c66 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/OneToOne.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.*; +/** Declares a persistent association, resolved at build time. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface OneToOne { + FetchType fetch() default FetchType.EAGER; + CascadeType[] cascade() default {}; + String mappedBy() default ""; + boolean orphanRemoval() default false; + boolean optional() default true; +} diff --git a/CodenameOne/src/com/codename1/annotations/OrderBy.java b/CodenameOne/src/com/codename1/annotations/OrderBy.java new file mode 100644 index 00000000000..dfc5ded8d28 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/OrderBy.java @@ -0,0 +1,28 @@ +/* + * 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.*; +/** Collection mapping metadata interpreted at build time. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface OrderBy { String value(); } diff --git a/CodenameOne/src/com/codename1/annotations/OrderColumn.java b/CodenameOne/src/com/codename1/annotations/OrderColumn.java new file mode 100644 index 00000000000..e45cf274ffa --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/OrderColumn.java @@ -0,0 +1,28 @@ +/* + * 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.*; +/** Collection mapping metadata interpreted at build time. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface OrderColumn { String name() default "list_position"; } diff --git a/CodenameOne/src/com/codename1/annotations/PostLoad.java b/CodenameOne/src/com/codename1/annotations/PostLoad.java new file mode 100644 index 00000000000..30a2d894a16 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/PostLoad.java @@ -0,0 +1,28 @@ +/* + * 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.*; +/** Marks a public no-argument entity lifecycle callback. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface PostLoad {} diff --git a/CodenameOne/src/com/codename1/annotations/PostPersist.java b/CodenameOne/src/com/codename1/annotations/PostPersist.java new file mode 100644 index 00000000000..164e4233538 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/PostPersist.java @@ -0,0 +1,28 @@ +/* + * 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.*; +/** Marks a public no-argument entity lifecycle callback. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface PostPersist {} diff --git a/CodenameOne/src/com/codename1/annotations/PostRemove.java b/CodenameOne/src/com/codename1/annotations/PostRemove.java new file mode 100644 index 00000000000..2d78ec97837 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/PostRemove.java @@ -0,0 +1,28 @@ +/* + * 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.*; +/** Marks a public no-argument entity lifecycle callback. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface PostRemove {} diff --git a/CodenameOne/src/com/codename1/annotations/PostUpdate.java b/CodenameOne/src/com/codename1/annotations/PostUpdate.java new file mode 100644 index 00000000000..e66119d3070 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/PostUpdate.java @@ -0,0 +1,28 @@ +/* + * 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.*; +/** Marks a public no-argument entity lifecycle callback. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface PostUpdate {} diff --git a/CodenameOne/src/com/codename1/annotations/PrePersist.java b/CodenameOne/src/com/codename1/annotations/PrePersist.java new file mode 100644 index 00000000000..105696e1295 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/PrePersist.java @@ -0,0 +1,28 @@ +/* + * 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.*; +/** Marks a public no-argument entity lifecycle callback. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface PrePersist {} diff --git a/CodenameOne/src/com/codename1/annotations/PreRemove.java b/CodenameOne/src/com/codename1/annotations/PreRemove.java new file mode 100644 index 00000000000..671d921e5f9 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/PreRemove.java @@ -0,0 +1,28 @@ +/* + * 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.*; +/** Marks a public no-argument entity lifecycle callback. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface PreRemove {} diff --git a/CodenameOne/src/com/codename1/annotations/PreUpdate.java b/CodenameOne/src/com/codename1/annotations/PreUpdate.java new file mode 100644 index 00000000000..0ebb00dffd4 --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/PreUpdate.java @@ -0,0 +1,28 @@ +/* + * 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.*; +/** Marks a public no-argument entity lifecycle callback. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface PreUpdate {} diff --git a/CodenameOne/src/com/codename1/annotations/Version.java b/CodenameOne/src/com/codename1/annotations/Version.java new file mode 100644 index 00000000000..b09c7f6789d --- /dev/null +++ b/CodenameOne/src/com/codename1/annotations/Version.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.annotations; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** An int or long counter maintained by a managed session for optimistic locking. */ +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.FIELD) +public @interface Version {} diff --git a/CodenameOne/src/com/codename1/orm/DaoFactory.java b/CodenameOne/src/com/codename1/orm/DaoFactory.java new file mode 100644 index 00000000000..bc105015ddf --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/DaoFactory.java @@ -0,0 +1,28 @@ +/* + * 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; + +/** Creates a database-independent DAO instance for one entity manager. */ +public interface DaoFactory { + Dao create(); +} diff --git a/CodenameOne/src/com/codename1/orm/EntityManager.java b/CodenameOne/src/com/codename1/orm/EntityManager.java index 38f29e29cf8..26258fc5e81 100644 --- a/CodenameOne/src/com/codename1/orm/EntityManager.java +++ b/CodenameOne/src/com/codename1/orm/EntityManager.java @@ -60,6 +60,8 @@ public final class EntityManager { private static final Map> BY_NAME = new HashMap>(); + private static final Map> FACTORIES = new HashMap>(); + private final Map> daos = new HashMap>(); private final Database db; private boolean closed; @@ -97,7 +99,19 @@ public static void registerDao(Dao dao) { if (dao == null) { throw new IllegalArgumentException("dao is null"); } - BY_NAME.put(dao.type().getName(), dao); + synchronized (BY_NAME) { + FACTORIES.remove(dao.type().getName()); + BY_NAME.put(dao.type().getName(), dao); + } + } + + /// Registers a factory for generated DAOs. Each manager gets its own instance. + public static void registerDaoFactory(Class type, DaoFactory factory) { + if (type == null || factory == null) throw new IllegalArgumentException("type/factory is null"); + synchronized (BY_NAME) { + BY_NAME.remove(type.getName()); + FACTORIES.put(type.getName(), factory); + } } /// Returns the dao for `entityClass`, freshly attached to this @@ -109,7 +123,15 @@ public Dao dao(Class entityClass) { if (entityClass == null) { throw new IllegalArgumentException("entityClass is null"); } - Dao d = (Dao) BY_NAME.get(entityClass.getName()); + if (com.codename1.orm.session.Models.requiresSession(entityClass)) + throw new IllegalStateException("Relationships and @Version require openSession() managed persistence"); + if (closed) throw new IllegalStateException("Entity manager is closed"); + Dao d = (Dao) daos.get(entityClass.getName()); + if (d != null) return d; + synchronized (BY_NAME) { + DaoFactory factory = (DaoFactory) FACTORIES.get(entityClass.getName()); + d = factory == null ? (Dao) BY_NAME.get(entityClass.getName()) : factory.create(); + } if (d == null) { throw new IllegalStateException("No dao registered for " + entityClass.getName() + ". Add @Entity and ensure the " @@ -117,9 +139,16 @@ public Dao dao(Class entityClass) { + "the generated DaoBootstrap populates this registry at startup."); } d.attach(db); + daos.put(entityClass.getName(), d); return d; } + /// Opens an independent managed persistence context over this manager's database. + public com.codename1.orm.session.Session openSession() { + if (closed) throw new IllegalStateException("Entity manager is closed"); + return new com.codename1.orm.session.Session(new SessionSqlAccess(db)); + } + /// The underlying `Database`. Use it for raw SQL when the dao surface /// isn't enough. public Database database() { diff --git a/CodenameOne/src/com/codename1/orm/SessionSqlAccess.java b/CodenameOne/src/com/codename1/orm/SessionSqlAccess.java new file mode 100644 index 00000000000..05e93545e7d --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/SessionSqlAccess.java @@ -0,0 +1,141 @@ +/* + * 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.Cursor; +import com.codename1.db.Database; +import com.codename1.db.Row; +import com.codename1.orm.session.Attribute; +import com.codename1.orm.session.SqlAccess; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** SQLite adapter used by managed sessions; the entity manager owns the database. */ +final class SessionSqlAccess implements SqlAccess { + private final Database db; + private boolean ownsTransaction; + private void checkOwner() throws IOException { + if(db.isInTransaction() && !ownsTransaction) throw new IOException("Database is in another transaction"); + if(!db.isInTransaction()) db.execute("PRAGMA foreign_keys = ON"); + } + SessionSqlAccess(Database db) { this.db=db; } + public String dialect() { return "sqlite"; } + public List describe(String table) throws IOException { + List raw=query("PRAGMA table_info("+quote(table)+")",new Object[0],new int[]{Attribute.INTEGER,Attribute.TEXT,Attribute.TEXT,Attribute.INTEGER,Attribute.TEXT,Attribute.INTEGER}); + List result=new ArrayList(); + for(Object[] row:raw) result.add(new Object[]{row[1],row[2],row[3],row[5]}); + return result; + } + public String quote(String name) { return "\""+name.replace("\"","\"\"")+"\""; } + public String columnType(int kind) { + switch(kind) { case Attribute.TEXT:return "TEXT"; case Attribute.REAL:return "REAL"; case Attribute.BLOB:return "BLOB"; default:return "INTEGER"; } + } + public String generatedKeyColumn(int kind,String name) { return "INTEGER PRIMARY KEY AUTOINCREMENT"; } + public String assignedKeyColumn(int kind) { return columnType(kind)+" PRIMARY KEY NOT NULL"; } + public String insertDefaults(String table) { return "INSERT INTO "+table+" DEFAULT VALUES"; } + public String lockClause(com.codename1.orm.session.LockMode mode) { + if(mode==com.codename1.orm.session.LockMode.NONE) return ""; + throw new UnsupportedOperationException("SQLite does not support pessimistic row locks"); + } + public String limit(int limit,int offset) { return limit<0 && offset==0?"":" LIMIT "+limit+" OFFSET "+offset; } + public List query(String sql,Object[] args,int[] kinds) throws IOException { + synchronized(db) { + checkOwner(); + Cursor cursor=db.executeQuery(sql,args); + try { + List result=new ArrayList(); + while(cursor.next()) { + Row row=cursor.getRow(); Object[] values=new Object[kinds.length]; + for(int i=0;i { + S toDatabase(T value); + T fromDatabase(S value); +} diff --git a/CodenameOne/src/com/codename1/orm/session/Elements.java b/CodenameOne/src/com/codename1/orm/session/Elements.java new file mode 100644 index 00000000000..9d864d7d5e9 --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/session/Elements.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.orm.session; +import java.io.IOException; +import java.util.Date; + +final class Elements { + private Elements() {} + static int kind(Class type) { + if(type==String.class) return Attribute.TEXT; + if(type==Double.class || type==Float.class) return Attribute.REAL; + return Attribute.BIGINT; + } + static Object read(Class type,Object value) { + try { + if(type==String.class) return Values.asString(value); + if(type==Integer.class) return Values.asIntObject(value); + if(type==Long.class) return Values.asLongObject(value); + if(type==Short.class) return Values.asShortObject(value); + if(type==Byte.class) return Values.asByteObject(value); + if(type==Double.class) return Values.asDoubleObject(value); + if(type==Float.class) return Values.asFloatObject(value); + if(type==Boolean.class) return Values.asBooleanObject(value); + if(type==Character.class) return Values.asCodeUnitObject(value); + if(type==Date.class) return Values.asDate(value); + throw new PersistenceException("Unsupported element type: "+type.getName()); + } catch(IOException error) { throw new PersistenceException(error.getMessage(),error); } + } +} diff --git a/CodenameOne/src/com/codename1/orm/session/EntityModel.java b/CodenameOne/src/com/codename1/orm/session/EntityModel.java new file mode 100644 index 00000000000..a065687dc13 --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/session/EntityModel.java @@ -0,0 +1,109 @@ +/* + * 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.session; + +/** Generated, reflection-free access to persistent entity values. */ +public abstract class EntityModel { + public abstract Class type(); + public abstract String table(); + public Class hierarchyRoot() { return type(); } + public int discriminatorIndex() { return -1; } + public String discriminatorValue() { return ""; } + public String[] discriminatorValues() { return new String[0]; } + public boolean hasRelationship(T entity,int index) { return true; } + public abstract Attribute[] attributes(); + public abstract T create(); + public abstract Object get(T entity, int index); + public Object domainValue(T entity,int index) { return get(entity,index); } + public abstract void set(T entity, int index, Object value); + public boolean requiresSession() { return relationships().length>0 || versionIndex()>=0 || generation()!=0 || idIndexes().length>1 || indexes().length>0 || discriminatorIndex()>=0; } + /** 0: assigned/identity, 1: UUID, 2: native sequence with table fallback, 3: table. */ + public Index[] indexes() { return new Index[0]; } + public int generation() { return 0; } + public String generator() { return table()+"_id"; } + public Object parameter(int index,Object value) { return Values.storage(value); } + public void read(T entity,Object[] values) { for(int i=0;i=relationships().length) throw new IllegalArgumentException("Unknown relationship"); + return null; + } + public void relation(T entity,int index,Object value) { throw new IllegalArgumentException("Unknown relationship"); } + public final int relationIndex(String field) { + Relationship[] all=relationships(); + for(int i=0;i=0) throw new PersistenceException("Multiple version fields: " + type().getName()); + found=i; + } + return found; + } +} diff --git a/CodenameOne/src/com/codename1/orm/session/EntityState.java b/CodenameOne/src/com/codename1/orm/session/EntityState.java new file mode 100644 index 00000000000..d03b2b511ff --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/session/EntityState.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.orm.session; + +/** Per-instance lazy state inserted into an entity by the enhancer. */ +public final class EntityState { + Session session; + final Object entity; + final boolean[] loaded,fetching; + final Object[] keys; + boolean attached=true; + EntityState(Session session,Object entity,int relationships) { + this.session=session;this.entity=entity;loaded=new boolean[relationships];fetching=new boolean[relationships];keys=new Object[relationships]; + } + public static void beforeRead(ManagedEntity entity,int index) { + EntityState state=entity.__cn1OrmState(); + if(state!=null) state.read(index); + } + public static void beforeSerialization(ManagedEntity entity,int index) { + EntityState state=entity.__cn1OrmState(); + if(state!=null && !state.loaded[index]) + throw new LazyInitializationException("Initialize the relationship before serialization"); + } + public static void beforeWrite(ManagedEntity entity,int index) { + EntityState state=entity.__cn1OrmState(); + if(state!=null) { + if(state.attached && !state.loaded[index]) state.session.beforeAssignment(entity,index); + state.assigned(index); + } + } + public void read(int index) { + if(loaded[index]) return; + if(!attached) throw new LazyInitializationException("Entity is detached"); + session.initialize(entity,index); + } + public void assigned(int index) { loaded[index]=true; } + /** Returns the stored key without triggering an association query. */ + public Object key(int index) { return keys[index]; } + public boolean isLoaded(int index) { return loaded[index]; } +} diff --git a/CodenameOne/src/com/codename1/orm/session/Identifier.java b/CodenameOne/src/com/codename1/orm/session/Identifier.java new file mode 100644 index 00000000000..ab78978a8db --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/session/Identifier.java @@ -0,0 +1,44 @@ +/* + * 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.session; + +import java.util.Arrays; + +/** Immutable composite identity, in the model's declared identifier-field order. */ +public final class Identifier { + private final Object[] values; + private Identifier(Object[] values) { + this.values=values.clone(); + for(int i=0;i { + private final Session session; + private final Class resultType; + private final Plan plan; + private final Map parameters=new LinkedHashMap(); + private int limit=-1,offset; + JpqlQuery(Session session,String statement,Class resultType) { + if(statement==null || resultType==null) throw new IllegalArgumentException("statement/resultType is null"); + this.session=session;this.resultType=resultType; + Parser parser=new Parser(session,lex(statement),new ArrayList(),new LinkedHashMap()); + plan=parser.parse();parser.end(); + } + public JpqlQuery setParameter(String name,Object value) { + boolean found=false; + for(Object binding:plan.bindings) if(binding instanceof Parameter && ((Parameter)binding).name.equals(name)) found=true; + if(!found) throw new IllegalArgumentException("Unknown named parameter: "+name); + parameters.put(name,value);return this; + } + public JpqlQuery limit(int value) { if(value<0) throw new IllegalArgumentException("Negative limit");limit=value;return this; } + public JpqlQuery offset(int value) { if(value<0) throw new IllegalArgumentException("Negative offset");offset=value;return this; } + public List list() { return list(limit); } + public T first() { List values=list(limit==0?0:1);return values.isEmpty()?null:values.get(0); } + private List list(int max) { + if(plan.mutation) throw new IllegalStateException("Use executeUpdate for bulk mutations"); + Bound bound=arguments();session.autoFlush(); + List result=new ArrayList();if(max==0) return result; + List rows=session.read(bound.sql+session.limit(max,offset),bound.args,plan.kinds); + if(plan.entity!=null) { + List entities=session.hydrateAll(plan.entity,rows,plan.fetches); + for(Object entity:entities) result.add(checked(entity)); + } else for(Object[] row:rows) result.add(checked(row.length==1?row[0]:row)); + return result; + } + private T checked(Object value) { + if(value!=null && resultType!=Object.class && !resultType.isInstance(value)) + throw new PersistenceException("Query result is "+value.getClass().getName()+", expected "+resultType.getName()); + return (T)value; + } + /** Bulk DML bypasses cascades/callbacks and clears the context after execution. */ + public int executeUpdate() { + if(!plan.mutation) throw new IllegalStateException("Not a bulk mutation"); + if(limit>=0 || offset>0) throw new IllegalArgumentException("Bulk mutation pagination is not supported"); + Bound bound=arguments();session.requireTransaction();session.flush(); + int changed=session.write(bound.sql,bound.args);session.clear();return changed; + } + private static final class Bound { + final String sql;final Object[] args; + Bound(String sql,Object[] args) { this.sql=sql;this.args=args; } + } + private Bound arguments() { + List values=new ArrayList();String statement=plan.sql; + for(Object binding:plan.bindings) { + if(binding instanceof Parameter) { + Parameter parameter=(Parameter)binding; + if(!parameters.containsKey(parameter.name)) throw new IllegalArgumentException("Unbound named parameter: "+parameter.name); + Object value=parameters.get(parameter.name); + if(parameter.marker!=null) { + Iterable collection; + if(value instanceof Object[]) collection=java.util.Arrays.asList((Object[])value); + else if(value instanceof Iterable) collection=(Iterable)value; + else throw new IllegalArgumentException("IN parameter requires an iterable or Object[]: "+parameter.name); + int count=0; + for(Object item:collection) { values.add(parameter.convert(item));count++; } + statement=statement.replace(parameter.marker,count==0?"SELECT NULL WHERE 1=0":Session.placeholders(count)); + } else { + if(value instanceof Iterable || value instanceof Object[]) throw new IllegalArgumentException("Collection parameter requires IN: "+parameter.name); + values.add(parameter.convert(value)); + } + } else values.add(Values.storage(binding)); + } + return new Bound(statement,values.toArray()); + } + private static final class Parameter { + final String name;String marker;Query query;String field; + Parameter(String name) { this.name=name; } + Object convert(Object value) { return query==null?Values.storage(value):query.parameter(field,value); } + } + private static final class Alias { + final Query query;final String path; + Alias(Query query,String path) { this.query=query;this.path=path; } + String field(String field) { return path.length()==0?field:path+"."+field; } + } + private static final class Expr { + final String sql;final int kind;final EntityModel entity;Query query;String field;Parameter parameter; + Expr(String sql,int kind) { this(sql,kind,null); } + Expr(String sql,int kind,EntityModel entity) { this.sql=sql;this.kind=kind;this.entity=entity; } + } + private static final class Plan { + String sql;int[] kinds=new int[0];EntityModel entity;boolean mutation; + final List bindings;final List fetches=new ArrayList(); + Plan(List bindings) { this.bindings=bindings; } + } + private static final class Parser { + final Session session;final List tokens;final List bindings; + final Map aliases;int position;Query root;String rootName; + Parser(Session session,List tokens,List bindings,Map outer) { + this.session=session;this.tokens=tokens;this.bindings=bindings;this.aliases=new LinkedHashMap(outer); + } + Plan parse() { + Plan plan=new Plan(bindings); + if(take("UPDATE")) { + plan.mutation=true;root();expect("SET"); + StringBuilder assignments=new StringBuilder(); + do { + if(assignments.length()>0) assignments.append(", "); + String path=path();String field=local(path); + if(field.indexOf('.')>=0) throw error("Bulk assignment must target a root field"); + Attribute attribute=root.model.attributes()[root.model.index(field)]; + if(attribute.id || root.model.discriminatorIndex()==root.model.index(field)) throw error("Bulk updates cannot change entity identifiers"); + expect("=");assignments.append(session.q(attribute.column)).append(" = ").append(expression().sql); + } while(take(",")); + String where=take("WHERE")?" WHERE "+expression().sql:""; + String filter=session.discriminatorCondition(root.model,root.rootAlias); + if(filter.length()>0) where=where.length()==0?" WHERE "+filter:where+" AND "+filter; + plan.sql="UPDATE "+session.q(root.model.table())+" AS "+root.rootAlias+" SET "+assignments+where; + refuseImplicitBulkJoins();return plan; + } + if(take("DELETE")) { + plan.mutation=true;expect("FROM");root(); + String where=take("WHERE")?" WHERE "+expression().sql:""; + String filter=session.discriminatorCondition(root.model,root.rootAlias); + if(filter.length()>0) where=where.length()==0?" WHERE "+filter:where+" AND "+filter; + plan.sql="DELETE FROM "+session.q(root.model.table())+" AS "+root.rootAlias+where; + refuseImplicitBulkJoins();return plan; + } + int start=position,from=-1,depth=0; + for(int i=position;i=0) throw error("Nested fetch paths are not supported"); + root.model.relationIndex(joinPath);plan.fetches.add(joinPath); + } else if(left) root.leftJoin(joinPath);else root.join(joinPath); + take("AS"); + if(identifier(peek()) && !clause(peek())) aliases.put(next(),new Alias(root,joinPath)); + } + int afterFrom=position; + position=start;boolean distinct=false; + List selections=new ArrayList(); + if(take("SELECT")) { + distinct=take("DISTINCT"); + do { selections.add(expression()); } while(take(",")); + if(position!=from) throw error("Unexpected SELECT expression"); + } else if(position==from) selections.add(new Expr(root.rootColumns(),Attribute.BIGINT,root.model)); + else throw error("Expected SELECT or FROM"); + position=afterFrom; + String where=take("WHERE")?" WHERE "+expression().sql:""; + String group="",having="",order=""; + if(take("GROUP")) { expect("BY");group=" GROUP BY "+expressions(); } + if(take("HAVING")) having=" HAVING "+expression().sql; + if(take("ORDER")) { + expect("BY");StringBuilder out=new StringBuilder(); + do { + if(out.length()>0) out.append(", ");out.append(expression().sql); + if(take("DESC")) out.append(" DESC");else { take("ASC");out.append(" ASC"); } + } while(take(","));order=" ORDER BY "+out; + } + StringBuilder projection=new StringBuilder();List kinds=new ArrayList(); + for(Expr expr:selections) { + if(projection.length()>0) projection.append(", ");projection.append(expr.sql); + if(expr.entity!=null) { + if(selections.size()!=1) throw error("Entity/scalar mixed projections are not supported"); + plan.entity=expr.entity;for(Attribute a:expr.entity.attributes()) kinds.add(Integer.valueOf(a.kind)); + } else { projection.append(" AS cn1_scalar_").append(kinds.size());kinds.add(Integer.valueOf(expr.kind)); } + } + if(!plan.fetches.isEmpty() && plan.entity!=root.model) throw error("Fetch joins require the root entity projection"); + plan.kinds=new int[kinds.size()];for(int i=0;i=0) throw error("Use a subquery for relationship predicates in bulk mutations"); + } + private void root() { + String entity=path();EntityModel model=session.model(entity);root=session.query(model.type()); + take("AS");rootName=identifier(peek()) && !clause(peek())?next():entity.substring(entity.lastIndexOf('.')+1); + aliases.put(rootName,new Alias(root,"")); + } + private String expressions() { + StringBuilder out=new StringBuilder(); + do { if(out.length()>0) out.append(", ");out.append(expression().sql); } while(take(",")); + return out.toString(); + } + private Expr expression() { return or(); } + private Expr or() { + Expr left=and();while(take("OR")) left=binary(left,"OR",and(),Attribute.BOOLEAN);return left; + } + private Expr and() { + Expr left=compare();while(take("AND")) left=binary(left,"AND",compare(),Attribute.BOOLEAN);return left; + } + private Expr compare() { + if(take("NOT")) return new Expr("NOT ("+compare().sql+")",Attribute.BOOLEAN); + if(take("EXISTS")) { expect("(");String sql=subquery();expect(")");return new Expr("EXISTS ("+sql+")",Attribute.BOOLEAN); } + Expr left=add(); + if(take("IS")) { boolean not=take("NOT");expect("NULL");return new Expr(left.sql+(not?" IS NOT NULL":" IS NULL"),Attribute.BOOLEAN); } + boolean not=take("NOT"); + if(take("IN")) { + boolean parens=take("(");String values; + if(peek()!=null && peek().startsWith(":") && (!parens || position+1".equals(op) || "!=".equals(op) || ">".equals(op) || "<".equals(op) || ">=".equals(op) || "<=".equals(op)) { + next();Expr right=add();bindType(left,right);bindType(right,left);return binary(left,op,right,Attribute.BOOLEAN); + } + return left; + } + private Expr add() { + Expr left=multiply();while(peek("+") || peek("-")) { String op=next();Expr right=multiply();left=binary(left,op,right,numeric(left,right)); }return left; + } + private Expr multiply() { + Expr left=primary();while(peek("*") || peek("/") || peek("%")) { String op=next();Expr right=primary();left=binary(left,op,right,numeric(left,right)); }return left; + } + private Expr primary() { + if(take("-")) { Expr value=primary();return new Expr("-"+value.sql,value.kind); } + if(take("+")) return primary(); + if(take("(")) { + if(peek("SELECT") || peek("FROM")) { String sql=subquery();expect(")");return new Expr("("+sql+")",Attribute.BIGINT); } + Expr value=expression();expect(")");return new Expr("("+value.sql+")",value.kind,value.entity); + } + String token=next(); + if(token.startsWith(":")) { Parameter parameter=new Parameter(token.substring(1));bindings.add(parameter);Expr result=new Expr("?",Attribute.TEXT);result.parameter=parameter;return result; } + if(token.startsWith("'")) { bindings.add(token.substring(1,token.length()-1).replace("''","'"));return new Expr("?",Attribute.TEXT); } + if("NULL".equalsIgnoreCase(token)) return new Expr("NULL",Attribute.TEXT); + if("TRUE".equalsIgnoreCase(token) || "FALSE".equalsIgnoreCase(token)) return new Expr("TRUE".equalsIgnoreCase(token)?"1":"0",Attribute.BOOLEAN); + if(Character.isDigit(token.charAt(0))) { + Object value=token.indexOf('.')>=0 || token.indexOf('e')>=0 || token.indexOf('E')>=0?(Object)Double.valueOf(token):Long.valueOf(Long.parseLong(token)); + bindings.add(value);return new Expr("?",value instanceof Double?Attribute.REAL:Attribute.BIGINT); + } + if(!identifier(token)) throw error("Expected expression"); + if(take("(")) { + String function=upper(token); + if(!(" COUNT SUM AVG MIN MAX LOWER UPPER LENGTH ABS COALESCE NULLIF TRIM ".contains(" "+function+" "))) throw error("Unsupported function: "+token); + boolean distinct=take("DISTINCT");List args=new ArrayList(); + if(take("*")) args.add(new Expr("*",Attribute.BIGINT)); + else do { args.add(expression()); } while(take(",")); + expect(")");StringBuilder sql=new StringBuilder(function).append('(').append(distinct?"DISTINCT ":""); + for(Expr arg:args) { + if(sql.charAt(sql.length()-1)!='(' && !(distinct && sql.toString().endsWith("DISTINCT "))) sql.append(", "); + if(arg.entity!=null) { + if(!"COUNT".equals(function)) throw error("Entity argument requires COUNT"); + if(distinct && arg.entity.idIndexes().length>1) throw error("Use the query builder count() for distinct composite identities"); + sql.append(root.rootAlias).append('.').append(session.q(arg.entity.attributes()[arg.entity.idIndex()].column)); + } else sql.append(arg.sql); + } + int kind="COUNT".equals(function) || "LENGTH".equals(function)?Attribute.BIGINT:"AVG".equals(function)?Attribute.REAL:args.get(0).kind; + return new Expr(sql.append(')').toString(),kind); + } + StringBuilder path=new StringBuilder(token);while(take(".")) path.append('.').append(word()); + String full=path.toString();int dot=full.indexOf('.'); + Alias alias=aliases.get(dot<0?full:full.substring(0,dot)); + if(alias==null) throw error("Unknown alias: "+full); + if(dot<0) { + if(alias.path.length()>0) throw error("Select a root entity or scalar fields"); + return new Expr(alias.query.rootColumns(),Attribute.BIGINT,alias.query.model); + } + String field=alias.field(full.substring(dot+1));Expr result=new Expr(alias.query.column(field),alias.query.kind(field));result.query=alias.query;result.field=field;return result; + } + private void bindType(Expr attribute,Expr value) { + if(attribute.query!=null && value.parameter!=null) { value.parameter.query=attribute.query;value.parameter.field=attribute.field; } + } + private String subquery() { + int start=position,depth=0; + while(position(tokens.subList(start,position)),bindings,aliases); + Plan plan=nested.parse();nested.end(); + if(plan.mutation || plan.entity!=null || plan.kinds.length!=1) throw error("Subquery requires a single scalar projection"); + return plan.sql; + } + private String local(String path) { + int dot=path.indexOf('.');Alias alias=aliases.get(dot<0?path:path.substring(0,dot)); + if(alias==null || alias.query!=root || dot<0) throw error("Expected a root relationship or attribute path"); + return alias.field(path.substring(dot+1)); + } + private String path() { StringBuilder value=new StringBuilder(word());while(take(".")) value.append('.').append(word());return value.toString(); } + private String word() { String word=next();if(!identifier(word)) throw error("Expected identifier");return word; } + private Expr binary(Expr left,String op,Expr right,int kind) { if(left.entity!=null || right.entity!=null) throw error("Compare entity identifiers explicitly");return new Expr("("+left.sql+" "+op+" "+right.sql+")",kind); } + private int numeric(Expr a,Expr b) { return a.kind==Attribute.REAL || b.kind==Attribute.REAL?Attribute.REAL:Attribute.BIGINT; } + private boolean peek(String value) { return value.equalsIgnoreCase(peek()); } + private String peek() { return position=tokens.size()) throw error("Unexpected end of query");return tokens.get(position++); } + private boolean take(String value) { if(peek(value)) { position++;return true; }return false; } + private void expect(String value) { if(!take(value)) throw error("Expected "+value); } + void end() { if(position!=tokens.size()) throw error("Unsupported syntax: "+peek()); } + private IllegalArgumentException error(String message) { return new IllegalArgumentException(message+" at token "+position); } + } + private static String upper(String token) { + StringBuilder result=new StringBuilder(); + for(int i=0;i='a' && c<='z'?(char)(c-32):c); + } + return result.toString(); + } + private static boolean clause(String token) { + return "WHERE".equalsIgnoreCase(token) || "JOIN".equalsIgnoreCase(token) || "LEFT".equalsIgnoreCase(token) || "INNER".equalsIgnoreCase(token) + || "GROUP".equalsIgnoreCase(token) || "HAVING".equalsIgnoreCase(token) || "ORDER".equalsIgnoreCase(token) || "SET".equalsIgnoreCase(token); + } + private static boolean identifier(String token) { + if(token.length()==0 || !Character.isJavaIdentifierStart(token.charAt(0))) return false; + for(int i=1;i lex(String text) { + List tokens=new ArrayList(); + for(int i=0;i=text.length() || !Character.isJavaIdentifierStart(text.charAt(i))) throw new IllegalArgumentException("Invalid named parameter"); + while(i!".indexOf(c)>=0) { + if(i')) i++; + } else if("(),.+-*/%".indexOf(c)<0) throw new IllegalArgumentException("Unsupported query character: "+c); + tokens.add(text.substring(start,i)); + } + return tokens; + } +} diff --git a/CodenameOne/src/com/codename1/orm/session/LazyInitializationException.java b/CodenameOne/src/com/codename1/orm/session/LazyInitializationException.java new file mode 100644 index 00000000000..28236ce6fad --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/session/LazyInitializationException.java @@ -0,0 +1,28 @@ +/* + * 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.session; + +/** An unloaded association was accessed after its session was detached or closed. */ +public final class LazyInitializationException extends PersistenceException { + public LazyInitializationException(String message) { super(message); } +} diff --git a/CodenameOne/src/com/codename1/orm/session/LockMode.java b/CodenameOne/src/com/codename1/orm/session/LockMode.java new file mode 100644 index 00000000000..3b7893f4087 --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/session/LockMode.java @@ -0,0 +1,25 @@ +/* + * 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.session; +/** Explicit row-lock modes. SQLite does not support these row locks. */ +public enum LockMode { NONE, PESSIMISTIC_READ, PESSIMISTIC_WRITE } diff --git a/CodenameOne/src/com/codename1/orm/session/ManagedEntity.java b/CodenameOne/src/com/codename1/orm/session/ManagedEntity.java new file mode 100644 index 00000000000..f51300fc8e6 --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/session/ManagedEntity.java @@ -0,0 +1,29 @@ +/* + * 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.session; + +/** Implemented by build-time enhancement, never by application models. */ +public interface ManagedEntity { + EntityState __cn1OrmState(); + void __cn1OrmState(EntityState state); +} diff --git a/CodenameOne/src/com/codename1/orm/session/Models.java b/CodenameOne/src/com/codename1/orm/session/Models.java new file mode 100644 index 00000000000..c0268428b0a --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/session/Models.java @@ -0,0 +1,77 @@ +/* + * 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.session; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Startup registry populated by generated bootstraps. */ +public final class Models { + private static final Map> REGISTRY=new LinkedHashMap>(); + private Models() {} + public static synchronized void register(EntityModel model) { + if(model==null) throw new IllegalArgumentException("model is null"); + REGISTRY.put(model.type().getName(),model); + } + /** Reads a relationship key without initializing it. Used by generated mappings. */ + public static Object foreignKey(Object owner,int index,Object target) { + if(owner instanceof ManagedEntity) { + EntityState state=((ManagedEntity)owner).__cn1OrmState(); + if(state!=null && !state.isLoaded(index)) return state.key(index); + } + if(target==null) return null; + EntityModel model; + EntityState state=owner instanceof ManagedEntity?((ManagedEntity)owner).__cn1OrmState():null; + if(state!=null && state.session!=null) model=state.session.model(target.getClass()); + else synchronized(Models.class) { model=REGISTRY.get(target.getClass().getName()); } + if(model==null) throw new PersistenceException("No model for relationship target"); + return model.identifier(target); + } + /** Component of a mapped foreign key, preserving unloaded association state. */ + public static Object foreignKey(Object owner,int index,Object target,int component) { + Object key=foreignKey(owner,index,target); + if(key==null) return null; + return key instanceof Identifier?((Identifier)key).values()[component]:key; + } + /** Builds a map using a target entity's unconverted basic attribute. */ + public static Map mapBy(java.util.Collection values,Class type,String field) { + if(values==null) return null; + EntityModel model;synchronized(Models.class) { model=REGISTRY.get(type.getName()); } + if(model==null) throw new PersistenceException("No model for map values"); + int index=model.index(field);Map result=new LinkedHashMap(); + for(Object value:values) { + Object key=model.domainValue(value,index); + if(key==null || result.containsKey(key)) throw new PersistenceException("Null or duplicate map key: "+field); + result.put(key,value); + } + return result; + } + /** New managed mappings must not silently fall through legacy scalar CRUD. */ + public static synchronized boolean requiresSession(Class type) { + EntityModel model=REGISTRY.get(type.getName()); + return model!=null && model.requiresSession(); + } + public static synchronized Map> snapshot() { + return new LinkedHashMap>(REGISTRY); + } +} diff --git a/CodenameOne/src/com/codename1/orm/session/OptimisticLockException.java b/CodenameOne/src/com/codename1/orm/session/OptimisticLockException.java new file mode 100644 index 00000000000..3f89295c7d8 --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/session/OptimisticLockException.java @@ -0,0 +1,28 @@ +/* + * 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.session; + +/** The row was changed or removed since it was loaded. */ +public final class OptimisticLockException extends PersistenceException { + public OptimisticLockException(String message) { super(message); } +} diff --git a/CodenameOne/src/com/codename1/orm/session/PersistenceException.java b/CodenameOne/src/com/codename1/orm/session/PersistenceException.java new file mode 100644 index 00000000000..e634e7f4529 --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/session/PersistenceException.java @@ -0,0 +1,29 @@ +/* + * 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.session; + +/** An ORM failure, including an underlying database failure during lazy access. */ +public class PersistenceException extends RuntimeException { + public PersistenceException(String message) { super(message); } + public PersistenceException(String message, Throwable cause) { super(message, cause); } +} diff --git a/CodenameOne/src/com/codename1/orm/session/Query.java b/CodenameOne/src/com/codename1/orm/session/Query.java new file mode 100644 index 00000000000..4b58e7681fe --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/session/Query.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. + * 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.session; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.LinkedHashMap; + +/** Parameterized query using Java attribute names rather than SQL identifiers. */ +public final class Query { + final Session session; + final EntityModel model; + final String rootAlias; + private final List params=new ArrayList(); + private final StringBuilder predicates=new StringBuilder(),order=new StringBuilder(); + private int limit=-1,offset; + private final Map joins=new LinkedHashMap(); + private boolean pluralJoin,relationOrdering; + private static final class Join { + final EntityModel model;final String alias,sql; + Join(EntityModel model,String alias,String sql) { this.model=model;this.alias=alias;this.sql=sql; } + } + /** Adds an inner join on a mapped relationship path. */ + public Query join(String path) { ensureJoin(path,false);return this; } + /** Adds a left join on a mapped relationship path before using it in a predicate. */ + public Query leftJoin(String path) { ensureJoin(path,true);return this; } + private final List fetches=new ArrayList(); + /** Overrides mapping laziness for the named direct relationship. */ + public Query fetch(String field) { model.relationIndex(field);fetches.add(field);return this; } + Query(Session session,EntityModel model) { this.session=session; this.model=model;this.rootAlias=session.nextAlias(); } + /** Tests membership in an owned scalar collection without loading it. */ + public Query containsElement(String field,Object value) { + Relationship relation=model.relationships()[model.relationIndex(field)]; + if(!relation.element) throw new IllegalArgumentException("Not an element collection: "+field); + String alias=session.nextAlias();conjunction(); + predicates.append("EXISTS (SELECT 1 FROM ").append(session.q(relation.joinTable)).append(' ').append(alias).append(" WHERE ") + .append(session.joinEquality(session.joinColumns(relation.joinColumn,model),alias,session.keyColumns(model),rootAlias)) + .append(" AND ").append(alias).append('.').append(session.q(relation.inverseJoinColumn)).append(value==null?" IS NULL)":" = ?)"); + if(value!=null) { if(!relation.target.isInstance(value)) throw new IllegalArgumentException("Wrong element type");params.add(Values.storage(value)); } + return this; + } + public Query eq(String field,Object value) { return compare(field,value==null?"IS NULL":"=",value); } + public Query ne(String field,Object value) { return compare(field,value==null?"IS NOT NULL":"<>",value); } + public Query gt(String field,Object value) { return compare(field,">",value); } + public Query ge(String field,Object value) { return compare(field,">=",value); } + public Query lt(String field,Object value) { return compare(field,"<",value); } + public Query le(String field,Object value) { return compare(field,"<=",value); } + public Query like(String field,String pattern) { return compare(field,"LIKE",pattern); } + public Query isNull(String field) { return compare(field,"IS NULL",null); } + public Query isNotNull(String field) { return compare(field,"IS NOT NULL",null); } + public Query in(String field,Object... values) { + String name=column(field); conjunction(); + if(values.length==0) { predicates.append("1 = 0"); return this; } + predicates.append(name).append(" IN ("); + for(int i=0;i0) predicates.append(", "); predicates.append('?'); params.add(parameter(field,values[i])); } + predicates.append(')'); return this; + } + public Query orderBy(String field,boolean ascending) { + String name=column(field); + if(field.indexOf('.')>=0) relationOrdering=true; + if(order.length()>0) order.append(", "); + order.append(name).append(ascending?" ASC":" DESC"); return this; + } + public Query limit(int value) { if(value<0) throw new IllegalArgumentException("Negative limit"); limit=value; return this; } + public Query offset(int value) { if(value<0) throw new IllegalArgumentException("Negative offset"); offset=value; return this; } + public List list() { return list(limit); } + public T first() { List rows=list(limit==0?0:1); return rows.isEmpty()?null:rows.get(0); } + private List list(int max) { + session.autoFlush(); List result=new ArrayList(); + if(max==0) return result; + if(pluralJoin && relationOrdering) throw new IllegalArgumentException("Collection joins require ordering by a root field"); + StringBuilder selection=new StringBuilder("SELECT ").append(pluralJoin?"DISTINCT ":""); + Attribute[] attributes=model.attributes(); + for(int i=0;i0) selection.append(", ");selection.append(rootAlias).append('.').append(session.q(attributes[i].column)); + } + String sql=selection+from()+where()+(order.length()==0?"":" ORDER BY "+order)+session.limit(max,offset); + return session.hydrateAll(model,session.read(sql,params.toArray(),session.kinds(model)),fetches); + } + /** Counts matching roots, ignoring pagination and ordering. */ + public long count() { + session.autoFlush(); + String statement="SELECT COUNT(*)"+from()+where(); + if(pluralJoin) { + StringBuilder keys=new StringBuilder(); + for(String column:session.keyColumns(model)) { if(keys.length()>0) keys.append(", ");keys.append(rootAlias).append('.').append(session.q(column)); } + statement="SELECT COUNT(*) FROM (SELECT DISTINCT "+keys+from()+where()+") cn1_count"; + } + List rows=session.read(statement,params.toArray(),new int[]{Attribute.BIGINT}); + return ((Number)rows.get(0)[0]).longValue(); + } + private Query compare(String field,String op,Object value) { + String name=column(field); boolean unary=op.startsWith("IS "); + if(!unary && value==null) throw new IllegalArgumentException("Null comparison requires isNull/isNotNull"); + conjunction(); predicates.append(name).append(' ').append(op); + if(!unary) { predicates.append(" ?"); params.add(parameter(field,value)); } + return this; + } + Object parameter(String field,Object value) { + for(Attribute attribute:model.attributes()) if(attribute.field.equals(field)) return model.parameter(model.index(field),value); + int dot=field.lastIndexOf('.');EntityModel target=dot<0?model:ensureJoin(field.substring(0,dot),false).model; + return target.parameter(target.index(dot<0?field:field.substring(dot+1)),value); + } + String column(String field) { + for(Attribute attribute:model.attributes()) if(attribute.field.equals(field)) return rootAlias+"."+session.q(attribute.column); + int dot=field.lastIndexOf('.'); + if(dot<0) return rootAlias+"."+session.q(model.attributes()[model.index(field)].column); + Join join=ensureJoin(field.substring(0,dot),false); + return join.alias+"."+session.q(join.model.attributes()[join.model.index(field.substring(dot+1))].column); + } + private Join ensureJoin(String path,boolean left) { + Join existing=joins.get(path);if(existing!=null) return existing; + int dot=path.lastIndexOf('.'); + Join parent=dot<0?new Join(model,rootAlias,""):ensureJoin(path.substring(0,dot),left); + String field=dot<0?path:path.substring(dot+1); + Relationship relation=parent.model.relationships()[parent.model.relationIndex(field)]; + if(relation.element) throw new IllegalArgumentException("Use containsElement() for scalar collections"); + EntityModel target=session.model(relation.target);String alias=rootAlias+"_j"+(joins.size()+1); + String prefix=left?" LEFT JOIN ":" INNER JOIN "; + String join; + if(relation.column>=0) join=prefix+session.tableSource(target)+" "+alias+" ON "+session.joinEquality(session.keyColumns(target),alias,session.foreignColumns(parent.model,relation),parent.alias); + else { + pluralJoin|=relation.many; + Relationship owning=relation;boolean inverse=relation.mappedBy.length()>0; + if(inverse) owning=target.relationships()[target.relationIndex(relation.mappedBy)]; + if(owning.column>=0) join=prefix+session.tableSource(target)+" "+alias+" ON "+session.joinEquality(session.foreignColumns(target,owning),alias,session.keyColumns(parent.model),parent.alias); + else { + String link=alias+"_link"; + String ownerColumn=inverse?owning.inverseJoinColumn:owning.joinColumn; + String targetColumn=inverse?owning.joinColumn:owning.inverseJoinColumn; + join=prefix+session.q(owning.joinTable)+" "+link+" ON "+session.joinEquality(session.joinColumns(ownerColumn,parent.model),link,session.keyColumns(parent.model),parent.alias) + +prefix+session.tableSource(target)+" "+alias+" ON "+session.joinEquality(session.keyColumns(target),alias,session.joinColumns(targetColumn,target),link); + } + } + Join created=new Join(target,alias,join);joins.put(path,created);return created; + } + String from() { + StringBuilder result=new StringBuilder(" FROM ").append(session.tableSource(model)).append(' ').append(rootAlias); + for(Join join:joins.values()) result.append(join.sql);return result.toString(); + } + int kind(String field) { + for(Attribute attribute:model.attributes()) if(attribute.field.equals(field)) return attribute.kind; + int dot=field.lastIndexOf('.'); + EntityModel target=dot<0?model:ensureJoin(field.substring(0,dot),false).model; + return target.attributes()[target.index(dot<0?field:field.substring(dot+1))].kind; + } + String rootColumns() { + StringBuilder result=new StringBuilder(); + for(Attribute a:model.attributes()) { if(result.length()>0) result.append(", ");result.append(rootAlias).append('.').append(session.q(a.column)); } + return result.toString(); + } + private void conjunction() { if(predicates.length()>0) predicates.append(" AND "); } + private String where() { return predicates.length()==0?"":" WHERE "+predicates; } +} diff --git a/CodenameOne/src/com/codename1/orm/session/Relationship.java b/CodenameOne/src/com/codename1/orm/session/Relationship.java new file mode 100644 index 00000000000..187235d5433 --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/session/Relationship.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.orm.session; + +/** Immutable generated relationship mapping. */ +public final class Relationship { + public final String field, mappedBy, joinTable, joinColumn, inverseJoinColumn,mapKey,orderColumn,orderBy; + public final Class target; + public final boolean many, lazy, orphanRemoval,unique,element; + public final int column, cascade; + public static final int PERSIST=1, MERGE=2, REMOVE=4, REFRESH=8, DETACH=16; + public Relationship(String field,Class target,boolean many,boolean lazy,int column, + String mappedBy,String joinTable,String joinColumn,String inverseJoinColumn, + int cascade,boolean orphanRemoval) { + this(field,target,many,lazy,column,mappedBy,joinTable,joinColumn,inverseJoinColumn,cascade,orphanRemoval,false); + } + public Relationship(String field,Class target,boolean many,boolean lazy,int column, + String mappedBy,String joinTable,String joinColumn,String inverseJoinColumn, + int cascade,boolean orphanRemoval,boolean unique) { + this(field,target,many,lazy,column,mappedBy,joinTable,joinColumn,inverseJoinColumn,cascade,orphanRemoval,unique,"","",""); + } + public Relationship(String field,Class target,boolean many,boolean lazy,int column, + String mappedBy,String joinTable,String joinColumn,String inverseJoinColumn, + int cascade,boolean orphanRemoval,boolean unique,String mapKey,String orderColumn,String orderBy) { + this(field,target,many,lazy,column,mappedBy,joinTable,joinColumn,inverseJoinColumn,cascade,orphanRemoval,unique,mapKey,orderColumn,orderBy,false); + } + public Relationship(String field,Class target,boolean many,boolean lazy,int column, + String mappedBy,String joinTable,String joinColumn,String inverseJoinColumn, + int cascade,boolean orphanRemoval,boolean unique,String mapKey,String orderColumn,String orderBy,boolean element) { + this.element=element; + this.mapKey=mapKey;this.orderColumn=orderColumn;this.orderBy=orderBy; + this.unique=unique; + this.field=field;this.target=target;this.many=many;this.lazy=lazy;this.column=column; + this.mappedBy=mappedBy;this.joinTable=joinTable;this.joinColumn=joinColumn; + this.inverseJoinColumn=inverseJoinColumn;this.cascade=cascade;this.orphanRemoval=orphanRemoval; + } +} diff --git a/CodenameOne/src/com/codename1/orm/session/Session.java b/CodenameOne/src/com/codename1/orm/session/Session.java new file mode 100644 index 00000000000..1c27569e302 --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/session/Session.java @@ -0,0 +1,1092 @@ +/* + * 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.session; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A bounded, non-thread-safe persistence context. Writes require an explicit + * transaction; closing never commits. Instances returned by find are unique by + * entity type and identifier until clear, detach, rollback, or close. + */ +public final class Session { + private final SqlAccess sql; + private final Map> models; + private final Map identities=new LinkedHashMap(); + private final IdentityHashMap entries=new IdentityHashMap(); + private int loading,deferredFetch,aliasSequence; + String nextAlias() { return "q"+(aliasSequence++); } + EntityModel model(String name) { + EntityModel result=null; + for(EntityModel candidate:models.values()) { + String binary=candidate.type().getName(); + if(binary.equals(name) || binary.substring(Math.max(binary.lastIndexOf('.'),binary.lastIndexOf('$'))+1).equals(name)) { + if(result!=null) throw new IllegalArgumentException("Ambiguous entity name: "+name); + result=candidate; + } + } + if(result==null) throw new IllegalArgumentException("Unknown entity: "+name); + return result; + } + public JpqlQuery createQuery(String statement,Class resultType) { + check();return new JpqlQuery(this,statement,resultType); + } + public JpqlQuery createQuery(String statement) { return createQuery(statement,Object.class); } + + private boolean closed, transaction, rollbackOnly, flushing; + + public Session(SqlAccess sql) { this(sql,Models.snapshot()); } + public Session(SqlAccess sql, Map> models) { + if(sql==null || models==null) throw new IllegalArgumentException("sql/models is null"); + this.sql=sql; + this.models=new LinkedHashMap>(models); + } + public void beginTransaction() { + check(); + if(transaction) throw new PersistenceException("Transaction already active"); + try { sql.begin(); transaction=true; rollbackOnly=false; } + catch(IOException e) { throw failure(e); } + } + public void commitTransaction() { + requireTransaction(); + if(rollbackOnly) throw new PersistenceException("Transaction requires rollback"); + try { flush(); sql.commit(); transaction=false; } + catch(IOException e) { rollbackOnly=true; throw failure(e); } + catch(RuntimeException e) { rollbackOnly=true; throw e; } + } + public void rollbackTransaction() { + check(); + if(!transaction) throw new PersistenceException("No active transaction"); + try { sql.rollback(); transaction=false; rollbackOnly=false; } + catch(IOException e) { rollbackOnly=true; throw failure(e); } + finally { clear(); } + } + public boolean isTransactionActive() { return transaction; } + public boolean isRollbackOnly() { return rollbackOnly; } + public boolean contains(Object entity) { check(); Entry entry=entries.get(entity);return entry!=null && !entry.removed; } + public void detach(Object entity) { + check();detach(entity,new IdentityHashMap()); + } + private void detach(Object entity,IdentityHashMap visited) { + if(visited.put(entity,Boolean.TRUE)!=null) return; + Entry entry=entries.get(entity); + if(entry==null) return; + EntityState state=state(entity); + Relationship[] relations=entry.model.relationships(); + for(int i=0;i T find(Class type,Object id) { + check(); EntityModel model=model(type); + if(id==null) throw new IllegalArgumentException("id is null"); + Object[] keyValues=model.keyValues(id); + Key key=new Key(model.hierarchyRoot().getName(),keyValues.length==1?keyValues[0]:Identifier.of(keyValues)); + Object cached=identities.get(key); + if(cached!=null) { checkManagedIdentity(entries.get(cached));return entries.get(cached).removed || !type.isInstance(cached)?null:(T)cached; } + autoFlush(); + List rows=read(select(model)+" WHERE "+keyCondition(model,null), + keyValues,kinds(model)); + return rows.isEmpty()?null:hydrate(model,rows.get(0)); + } + /** Reads or locks a row inside this session's transaction. */ + public T find(Class type,Object id,LockMode mode) { + if(mode==null) throw new IllegalArgumentException("lock mode is null"); + if(mode==LockMode.NONE) return find(type,id); + requireTransaction();String clause=sql.lockClause(mode);autoFlush(); + EntityModel model=model(type); + Object[] keyValues=model.keyValues(id); + List rows=read(select(model)+" WHERE "+keyCondition(model,null)+clause,keyValues,kinds(model)); + if(rows.isEmpty()) return null; + T cached=(T)identities.get(new Key(model.hierarchyRoot().getName(),keyValues.length==1?keyValues[0]:Identifier.of(keyValues)));int version=model.versionIndex(); + if(cached!=null && version>=0 && !same(model.get(cached,version),rows.get(0)[version])) { + rollbackOnly=true;throw new OptimisticLockException("Stale entity while acquiring lock: "+model.table()); + } + return hydrate(model,rows.get(0)); + } + public void lock(Object entity,LockMode mode) { + if(!contains(entity)) throw new PersistenceException("lock requires a managed entity"); + if(mode!=LockMode.NONE) { requireTransaction();autoFlush(); } + checkManagedIdentity(entries.get(entity));EntityModel model=model(entity.getClass()); + if(find(model.type(),model.identifier(entity),mode)==null) { + rollbackOnly=true;throw new OptimisticLockException("Row no longer exists"); + } + } + public void persist(T entity) { + try { persistInternal(entity); } + catch(RuntimeException error) { if(transaction) rollbackOnly=true;throw error; } + } + private void persistInternal(T entity) { + requireTransaction(); + if(entity==null) throw new IllegalArgumentException("entity is null"); + if(entries.containsKey(entity)) { entries.get(entity).removed=false;return; } + EntityState previous=state(entity); + if(previous!=null && previous.attached && previous.session!=this) throw new PersistenceException("Entity already belongs to another session"); + EntityModel model=model((Class)entity.getClass()); + int id=model.idIndex(); + Object value=model.identifier(entity); + if(model.generation()!=0) { + if(value!=null && (!(value instanceof Number) || ((Number)value).longValue()!=0)) + throw new PersistenceException("persist requires a new entity; use merge for detached entities"); + try { model.set(entity,id,sql.nextIdentifier(model.generation(),model.generator(),model.attributes()[id].kind)); } + catch(IOException error) { rollbackOnly=true;throw failure(error); } + value=model.identifier(entity); + } + if(model.attributes()[id].generated && value!=null && (!(value instanceof Number) || ((Number)value).longValue()!=0)) + throw new PersistenceException("persist requires a new entity; use merge for detached entities"); + if(!model.attributes()[id].generated) model.keyValues(value); + Entry e=new Entry(model,entity,null); e.fresh=true;e.initialId=value instanceof byte[]?((byte[])value).clone():value; + if(!model.attributes()[id].generated) claim(model,entity); + entries.put(entity,e); attachState(e,true,null); cascadePersist(e); + } + public T merge(T entity) { + try { return merge(entity,new IdentityHashMap()); } + catch(RuntimeException error) { if(transaction) rollbackOnly=true;throw error; } + } + private T merge(T entity,IdentityHashMap merging) { + requireTransaction(); + if(merging.containsKey(entity)) return (T)merging.get(entity); + if(entity==null) throw new IllegalArgumentException("entity is null"); + if(entries.containsKey(entity)) return entity; + EntityModel model=model((Class)entity.getClass()); + Object id=model.identifier(entity); + boolean generated=model.attributes()[model.idIndex()].generated || model.generation()!=0; + T managed=id==null || (generated && id instanceof Number && ((Number)id).longValue()==0) + ?null:find(model.type(),id); + if(managed==null) { + if(generated && id!=null && (!(id instanceof Number) || ((Number)id).longValue()!=0)) + throw new OptimisticLockException("Detached row no longer exists: "+model.table()); + managed=model.create(); + copy(model,entity,managed); persist(managed); + } else { + int version=model.versionIndex(); + if(version>=0 && !same(model.get(entity,version),model.get(managed,version))) + throw new OptimisticLockException("Stale entity: "+model.table()); + copy(model,entity,managed); + } + merging.put(entity,managed); + Relationship[] relations=model.relationships();EntityState sourceState=state(entity); + for(int i=0;i0) mapping.put(row.key,element);else copy.add(element); } + initialize(managed,i);model.relation(managed,i,relation.mapKey.length()>0?mapping:copy);continue; + } + if(relation.many && value!=null) { + List children=new ArrayList(); + for(Object child:relatedValues(value)) children.add(mergeTarget(relation,child,merging)); + value=children; + } else if(value!=null) value=mergeTarget(relation,value,merging); + initialize(managed,i); + model.relation(managed,i,value);EntityState targetState=state(managed); + if(targetState!=null) targetState.loaded[i]=true; + } + return managed; + } + private Object mergeTarget(Relationship relation,Object child,IdentityHashMap merging) { + if((relation.cascade & Relationship.MERGE)!=0) return merge(child,merging); + EntityModel target=model(relation.target);Object id=target.identifier(child); + Object managed=id==null?null:find(relation.target,id); + if(managed==null) throw new PersistenceException("Transient association without cascade MERGE: "+relation.field); + return managed; + } + public void remove(Object entity) { + try { removeInternal(entity); } + catch(RuntimeException error) { if(transaction) rollbackOnly=true;throw error; } + } + private void removeInternal(Object entity) { + requireTransaction(); Entry entry=entries.get(entity); + if(entry==null) throw new PersistenceException("remove requires a managed entity"); + if(entry.removed) return; + checkManagedIdentity(entry);entry.removed=true; + Relationship[] relations=entry.model.relationships(); + for(int i=0;i()); } + catch(PersistenceException error) { if(transaction) rollbackOnly=true;clear();throw error; } + } + private void refresh(Object entity,IdentityHashMap visited) { + if(visited.put(entity,Boolean.TRUE)!=null) return; + Entry entry=entries.get(entity); + if(entry==null || entry.fresh || entry.removed) throw new PersistenceException("refresh requires a persisted managed entity"); + EntityModel model=entry.model;checkManagedIdentity(entry); + List rows=read(select(model)+" WHERE "+keyCondition(model,null), + model.keyValues(model.identifier(entity)),kinds(model)); + if(rows.isEmpty()) { + if(transaction) rollbackOnly=true; + throw new OptimisticLockException("Row no longer exists: "+model.table()); + } + EntityState previous=state(entity); + boolean[] previouslyLoaded=previous==null?new boolean[0]:previous.loaded.clone(); + attachState(entry,false,rows.get(0)); + assign(model,entity,rows.get(0));entry.snapshot=snapshot(model,entity); + Relationship[] relations=model.relationships(); + loading++; + try { + for(int i=0;i(entries.values())) if(!entry.removed) cascadePersist(entry); + } while(previous!=entries.size()); + List pending=new ArrayList(entries.values()); + for(Entry e:pending) if(e.fresh) insert(e); + for(Entry e:pending) if(!e.removed) update(e); + for(Entry e:pending) if(!e.removed) syncCollections(e); + prepareDeletes(); + for(Entry e:new ArrayList(entries.values())) if(e.removed && entries.containsKey(e.entity)) delete(e); + } catch(RuntimeException e) { rollbackOnly=true; throw e; } + finally { flushing=false; } + } + /** Increments in SQL and refreshes an already managed instance. */ + public boolean increment(Class type,Object id,String field,long amount) { + requireTransaction(); flush(); EntityModel model=model(type); + int index=model.index(field),version=model.versionIndex(); Attribute a=model.attributes()[index]; + if(a.id || a.version || (a.kind!=Attribute.INTEGER && a.kind!=Attribute.BIGINT)) + throw new IllegalArgumentException("Counter must be a non-key integral field: "+field); + String statement="UPDATE "+q(model.table())+" SET "+q(a.column)+" = "+q(a.column)+" + ?"; + if(version>=0) { String v=q(model.attributes()[version].column); statement+=", "+v+" = "+v+" + 1"; } + long min=a.kind==Attribute.INTEGER?Integer.MIN_VALUE:Long.MIN_VALUE; + long max=a.kind==Attribute.INTEGER?Integer.MAX_VALUE:Long.MAX_VALUE; + long lower=amount<0?min-amount:min,upper=amount>0?max-amount:max; + if(lower>upper) return false; + statement+=" WHERE "+keyCondition(model,null)+" AND "+q(a.column)+" >= ? AND "+q(a.column)+" <= ?"; + if(version>=0) statement+=" AND "+q(model.attributes()[version].column)+" < "+(model.attributes()[version].kind==Attribute.INTEGER?Integer.MAX_VALUE:Long.MAX_VALUE); + List arguments=new ArrayList();arguments.add(Long.valueOf(amount)); + Object[] keyValues=model.keyValues(id);arguments.addAll(Arrays.asList(keyValues)); + arguments.add(Long.valueOf(lower));arguments.add(Long.valueOf(upper)); + int changed=write(statement,arguments.toArray()); + Object managed=identities.get(new Key(model.hierarchyRoot().getName(),keyValues.length==1?keyValues[0]:Identifier.of(keyValues))); + if(changed>0 && managed!=null) refresh(managed); + return changed>0; + } + public Query query(Class type) { check(); return new Query(this,model(type)); } + /** Creates missing tables and their constraints. Existing schemas require migrations. */ + public void createTables() { + check(); + if(transaction) throw new PersistenceException("Create schemas outside application transactions"); + List created=new ArrayList(); + List foreignKeys=new ArrayList(); + for(EntityModel model:models.values()) { + if(model.generation()!=0) { + try { sql.prepareGenerator(model.generation(),model.generator()); } + catch(IOException error) { throw failure(error); } + } + if(!describe(model.table()).isEmpty()) continue; + StringBuilder statement=new StringBuilder("CREATE TABLE ").append(q(model.table())).append(" ("); + Attribute[] attrs=model.attributes(); + for(int i=0;i0) statement.append(", "); + Attribute a=attrs[i];statement.append(q(a.column)).append(' '); + if(a.generated) statement.append(sql.generatedKeyColumn(a.kind,q(a.column))); + else statement.append(columnType(model,i)).append(a.nullable && !a.id?"":" NOT NULL"); + } + if(!attrs[model.idIndex()].generated) statement.append(", PRIMARY KEY (").append(quoted(keyColumns(model))).append(')'); + List keys=new ArrayList(); + for(Relationship relation:model.relationships()) if(relation.column>=0) + keys.add(new ForeignKey(model.table(),foreignColumns(model,relation),model(relation.target).table(),keyColumns(model(relation.target)))); + if("sqlite".equals(sql.dialect())) for(ForeignKey key:keys) statement.append(", ").append(foreignDeclaration(key)); + else foreignKeys.addAll(keys); + write(statement.append(')').toString(),new Object[0]);created.add(model.table()); + } + for(EntityModel model:models.values()) for(Relationship relation:model.relationships()) { + if(!relation.many || relation.mappedBy.length()>0 || !describe(relation.joinTable).isEmpty()) continue; + if(relation.element) { createElementTable(model,relation,foreignKeys);created.add(relation.joinTable);continue; } + EntityModel target=model(relation.target); + String[] owner=joinColumns(relation.joinColumn,model),child=joinColumns(relation.inverseJoinColumn,target); + StringBuilder definition=new StringBuilder("CREATE TABLE ").append(q(relation.joinTable)).append(" ("); + appendJoinDefinition(definition,owner,model);definition.append(", ");appendJoinDefinition(definition,child,target); + if(relation.orderColumn.length()>0) definition.append(", ").append(q(relation.orderColumn)).append(" INTEGER NOT NULL"); + definition.append(", PRIMARY KEY (").append(quoted(owner)).append(", ").append(relation.orderColumn.length()==0?quoted(child):q(relation.orderColumn)).append(')'); + ForeignKey ownerKey=new ForeignKey(relation.joinTable,owner,model.table(),keyColumns(model)); + ForeignKey childKey=new ForeignKey(relation.joinTable,child,target.table(),keyColumns(target)); + if("sqlite".equals(sql.dialect())) definition.append(", ").append(foreignDeclaration(ownerKey)).append(", ").append(foreignDeclaration(childKey)); + else { foreignKeys.add(ownerKey);foreignKeys.add(childKey); } + write(definition.append(')').toString(),new Object[0]);created.add(relation.joinTable); + createIndex(relation.joinTable,"",relation.unique,child); + } + for(ForeignKey key:foreignKeys) write("ALTER TABLE "+q(key.table)+" ADD "+foreignDeclaration(key),new Object[0]); + List indexed=new ArrayList(); + for(EntityModel model:models.values()) if(created.contains(model.table()) && !indexed.contains(model.table())) { + indexed.add(model.table()); + for(Relationship relation:model.relationships()) if(relation.column>=0) + createIndex(model.table(),"",relation.unique,foreignColumns(model,relation)); + for(Index index:model.indexes()) { + String[] fields=index.fields(),columns=new String[fields.length]; + if(fields.length==0) throw new PersistenceException("An index needs at least one field"); + for(int i=0;i describe(String table) { + try { return sql.describe(table); } catch(IOException error) { throw failure(error); } + } + private String columnType(EntityModel model,int index) { + Attribute attr=model.attributes()[index]; + if(attr.declaredType!=null) return attr.declaredType; + boolean key=attr.id; + for(Index definition:model.indexes()) for(String field:definition.fields()) if(field.equals(attr.field)) key=true; + for(Relationship relation:model.relationships()) if(relation.column>=0 && index>=relation.column && index errors=new ArrayList(); + for(EntityModel model:models.values()) { + List columns=describe(model.table()); + if(columns.isEmpty()) { errors.add("Missing table "+model.table());continue; } + Attribute[] attrs=model.attributes(); + for(int i=0;i columns=describe(relation.joinTable); + if(columns.isEmpty()) { errors.add("Missing collection table "+relation.joinTable);continue; } + List required=new ArrayList(Arrays.asList(joinColumns(relation.joinColumn,owner))); + if(relation.element) { + required.add(relation.inverseJoinColumn);required.add(relation.orderColumn); + if(relation.mapKey.length()>0) required.add(relation.mapKey); + } else { + required.addAll(Arrays.asList(joinColumns(relation.inverseJoinColumn,model(relation.target)))); + if(relation.orderColumn.length()>0) required.add(relation.orderColumn); + } + for(String name:required) { + boolean present=false;for(Object[] column:columns) if(name.equalsIgnoreCase((String)column[0])) present=true; + if(!present) errors.add("Missing collection column "+relation.joinTable+"."+name); + } + } + if(!errors.isEmpty()) throw new PersistenceException("Schema validation failed: "+errors); + } + private static String[] split(String text,char separator) { + List parts=new ArrayList();int start=0; + for(int i=0;i<=text.length();i++) { + if(i==text.length() || (separator==' '?Character.isWhitespace(text.charAt(i)):text.charAt(i)==separator)) { + String value=text.substring(start,i).trim();if(value.length()>0) parts.add(value);start=i+1; + } + } + return parts.toArray(new String[parts.size()]); + } + private static int typeFamily(String type) { + StringBuilder lower=new StringBuilder();for(int i=0;i='A' && ch<='Z'?(char)(ch+32):ch); }String name=lower.toString(); + if(name.indexOf("int")>=0 || name.indexOf("serial")>=0 || name.indexOf("bool")>=0) return 1; + if(name.indexOf("char")>=0 || name.indexOf("text")>=0 || name.indexOf("clob")>=0) return 2; + if(name.indexOf("real")>=0 || name.indexOf("double")>=0 || name.indexOf("float")>=0 || name.indexOf("decimal")>=0 || name.indexOf("numeric")>=0) return 3; + if(name.indexOf("blob")>=0 || name.indexOf("binary")>=0 || name.indexOf("bytea")>=0) return 4; + if(name.indexOf("date")>=0 || name.indexOf("time")>=0) return 5; + return 0; + } + /** Counts a relationship without materializing its entities. */ + public long count(Object entity,String field) { + check(); EntityModel owner=model(entity.getClass());Relationship relation=owner.relationships()[owner.relationIndex(field)]; + Object id=owner.identifier(entity); + autoFlush(); + if(relation.element) return countJoin(relation.joinTable,joinColumns(relation.joinColumn,owner),owner.keyValues(id)); + EntityModel target=model(relation.target); + if(relation.column>=0) return owner.get(entity,relation.column)==null?0:1; + if(relation.mappedBy.length()>0) { + Relationship inverse=target.relationships()[target.relationIndex(relation.mappedBy)]; + if(inverse.column>=0) return inverseQuery(target,inverse,owner,id).count(); + return countJoin(inverse.joinTable,joinColumns(inverse.inverseJoinColumn,owner),owner.keyValues(id)); + } + return countJoin(relation.joinTable,joinColumns(relation.joinColumn,owner),owner.keyValues(id)); + } + private long countJoin(String table,String[] columns,Object[] key) { + return ((Number)read("SELECT COUNT(*) FROM "+q(table)+" WHERE "+matches(columns,null),key,new int[]{Attribute.BIGINT}).get(0)[0]).longValue(); + } + private Query inverseQuery(EntityModel target,Relationship inverse,EntityModel owner,Object id) { + Query query=query(target.type());Object[] values=owner.keyValues(id); + for(int i=0;i List hydrateAll(EntityModel model,List rows,List fetches) { + List result=new ArrayList(); + deferredFetch++; + try { for(Object[] row:rows) result.add(hydrate(model,row)); } + finally { deferredFetch--; } + fetchAll(model,result,fetches); + return result; + } + private void fetchAll(EntityModel owner,List roots,List fetches) { + Relationship[] relations=owner.relationships(); + loading++; + try { + for(int i=0;i keys=new ArrayList();List unloaded=new ArrayList(); + for(T entity:roots) { + EntityState state=state(entity); + if(state==null || state.loaded[i] || state.fetching[i]) continue; + unloaded.add(entity); + Object key=relation.column>=0?state.keys[i]:owner.identifier(entity); + if(key!=null && !keys.contains(key)) keys.add(key); + } + if(unloaded.isEmpty()) continue; + if(relation.element) { for(T entity:unloaded) initialize(entity,i);continue; } + EntityModel target=model(relation.target); + for(T entity:unloaded) state(entity).fetching[i]=true; + try { + if(owner.idIndexes().length>1 || target.idIndexes().length>1 || relation.orderBy.length()>0 || relation.mapKey.length()>0) { + for(T entity:unloaded) initialize(entity,i); + } else if(relation.column>=0) { + if(!keys.isEmpty()) readBatches(relation.target,target.attributes()[target.idIndex()].field,keys); + for(T entity:unloaded) initialize(entity,i); + } else if(relation.mappedBy.length()>0) { + Relationship inverse=target.relationships()[target.relationIndex(relation.mappedBy)]; + if(inverse.column<0) { for(T entity:unloaded) initialize(entity,i);continue; } + List children=readBatches(relation.target,target.attributes()[inverse.column].field,keys); + for(T entity:unloaded) { + List matches=new ArrayList();Object id=owner.identifier(entity); + for(Object child:children) if(same(target.get(child,inverse.column),id)) matches.add(child); + if(!relation.many && matches.size()>1) throw new PersistenceException("One-to-one relationship returned multiple rows"); + Object value=relation.many?matches:matches.isEmpty()?null:matches.get(0); + owner.relation(entity,i,value);state(entity).loaded[i]=true; + Entry entry=entries.get(entity);if(entry!=null && relation.many) entry.collections[i]=relationKeys(relation,value); + } + } else for(T entity:unloaded) initialize(entity,i); + } finally { for(T entity:unloaded) state(entity).fetching[i]=false; } + } + } finally { loading--; } + } + + private List readBatches(Class type,String field,List keys) { + List result=new ArrayList(); + for(int start=0;start=0) { + value=state.keys[index]==null?null:find(relation.target,state.keys[index]); + if(value==null && state.keys[index]!=null) throw new PersistenceException("Missing relationship target: "+model.type().getName()+"."+relation.field); + } + else { + List found; + if(relation.mappedBy.length()>0) { + Relationship inverse=target.relationships()[target.relationIndex(relation.mappedBy)]; + if(inverse.column>=0) { + Query query=inverseQuery(target,inverse,model,model.identifier(entity)); + for(String clause:split(relation.orderBy,',')) if(clause.trim().length()>0) { + String[] parts=split(clause.trim(),' ');query.orderBy(parts[0],parts.length==1 || !"DESC".equalsIgnoreCase(parts[1])); + } + found=query.list(); + } else { + found=readJoin(model,target,inverse.joinTable,inverse.inverseJoinColumn,inverse.joinColumn,model.identifier(entity),relation); + } + } else found=readJoin(model,target,relation.joinTable,relation.joinColumn,relation.inverseJoinColumn,model.identifier(entity),relation); + if(relation.many) value=found; + else { + if(found.size()>1) throw new PersistenceException("One-to-one relationship returned multiple rows: "+relation.field); + value=found.isEmpty()?null:found.get(0); + } + } + model.relation(entity,index,value); state.loaded[index]=true; + Entry entry=entries.get(entity); + if(entry!=null) entry.collections[index]=relationshipKeys(relation,value); + } finally { loading--; } + } + private List readJoin(EntityModel owner,EntityModel target,String table,String ownerColumn,String targetColumn,Object id,Relationship relation) { + if(table.length()==0) throw new PersistenceException("Missing join table"); + String[] targetColumns=joinColumns(targetColumn,target),ownerColumns=joinColumns(ownerColumn,owner); + StringBuilder statement=new StringBuilder("SELECT "); + for(Attribute attribute:target.attributes()) { + if(statement.length()>7) statement.append(", ");statement.append("t.").append(q(attribute.column)); + } + statement.append(" FROM ").append(tableSource(target)).append(" t INNER JOIN ").append(q(table)).append(" l ON "); + statement.append(joinEquality(keyColumns(target),"t",targetColumns,"l")); + statement.append(" WHERE ").append(matches(ownerColumns,"l")); + if(relation.orderColumn.length()>0) statement.append(" ORDER BY l.").append(q(relation.orderColumn)); + else if(relation.orderBy.length()>0) { + statement.append(" ORDER BY ");boolean first=true; + for(String clause:split(relation.orderBy,',')) { + String[] parts=split(clause.trim(),' ');if(!first) statement.append(',');first=false; + statement.append("t.").append(q(target.attributes()[target.index(parts[0])].column)).append(parts.length>1 && "DESC".equalsIgnoreCase(parts[1])?" DESC":" ASC"); + } + } + return hydrateAll(target,read(statement.toString(),owner.keyValues(id),kinds(target)),new ArrayList()); + } + private EntityState state(Object entity) { return entity instanceof ManagedEntity?((ManagedEntity)entity).__cn1OrmState():null; } + private void attachState(Entry entry,boolean loaded,Object[] values) { + Relationship[] relations=entry.model.relationships(); + entry.collections=new List[relations.length]; + if(relations.length==0) return; + if(!(entry.entity instanceof ManagedEntity)) throw new PersistenceException("Entity has not been enhanced: "+entry.model.type().getName()); + EntityState state=new EntityState(this,entry.entity,relations.length); + ((ManagedEntity)entry.entity).__cn1OrmState(state); + for(int i=0;i=0) { + EntityModel target=model(relations[i].target);int count=target.idIndexes().length; + Object[] key=new Object[count];boolean any=false; + for(int part=0;part=0) { + entry.collections[i]=new ArrayList(); + if(state.keys[i]!=null) entry.collections[i].add(state.keys[i]); + } + } + } + private void cascadePersist(Entry entry) { + Relationship[] relations=entry.model.relationships(); + for(int i=0;i0) for(Object item:((Map)collection).entrySet()) { + Map.Entry entry=(Map.Entry)item; + if(!(entry.getKey() instanceof String)) throw new PersistenceException("Element maps require non-null String keys"); + checkElement(relation,entry.getValue());result.add(new ElementRow(entry.getKey(),entry.getValue())); + } else for(Object value:relatedValues(collection)) { checkElement(relation,value);result.add(new ElementRow(null,value)); } + return result; + } + private void checkElement(Relationship relation,Object value) { + if(value!=null && !relation.target.isInstance(value)) throw new PersistenceException("Wrong element type: "+relation.field); + } + private void createElementTable(EntityModel owner,Relationship relation,List foreignKeys) { + String[] columns=joinColumns(relation.joinColumn,owner); + StringBuilder definition=new StringBuilder("CREATE TABLE ").append(q(relation.joinTable)).append(" (");appendJoinDefinition(definition,columns,owner); + definition.append(", ").append(q(relation.orderColumn)).append(" INTEGER NOT NULL, ").append(q(relation.inverseJoinColumn)).append(' ').append(sql.columnType(Elements.kind(relation.target))); + if(relation.mapKey.length()>0) { + String type=sql.assignedKeyColumn(Attribute.TEXT).replace(" PRIMARY KEY","").replace(" NOT NULL",""); + definition.append(", ").append(q(relation.mapKey)).append(' ').append(type).append(" NOT NULL"); + } + definition.append(", PRIMARY KEY (").append(quoted(columns)).append(", ").append(q(relation.mapKey.length()>0?relation.mapKey:relation.orderColumn)).append(')'); + ForeignKey key=new ForeignKey(relation.joinTable,columns,owner.table(),keyColumns(owner)); + if("sqlite".equals(sql.dialect())) definition.append(", ").append(foreignDeclaration(key));else foreignKeys.add(key); + write(definition.append(')').toString(),new Object[0]); + } + private Object loadElements(EntityModel owner,Object entity,Relationship relation) { + boolean map=relation.mapKey.length()>0; + String selection=q(relation.inverseJoinColumn)+(map?", "+q(relation.mapKey):""); + int[] kinds=map?new int[]{Elements.kind(relation.target),Attribute.TEXT}:new int[]{Elements.kind(relation.target)}; + List rows=read("SELECT "+selection+" FROM "+q(relation.joinTable)+" WHERE "+matches(joinColumns(relation.joinColumn,owner),null)+" ORDER BY "+q(relation.orderColumn),owner.keyValues(owner.identifier(entity)),kinds); + List values=new ArrayList();Map mapping=new LinkedHashMap(); + for(Object[] row:rows) { + Object value=Elements.read(relation.target,row[0]); + if(map) mapping.put(row[1],value);else values.add(value); + } + return map?mapping:values; + } + private void syncElements(Entry entry,int index,Relationship relation,Object collection) { + List rows=elementRows(relation,collection);if(rows.equals(entry.collections[index])) return; + String[] ownerColumns=joinColumns(relation.joinColumn,entry.model);Object[] owner=entry.model.keyValues(entry.model.identifier(entry.entity)); + write("DELETE FROM "+q(relation.joinTable)+" WHERE "+matches(ownerColumns,null),owner); + for(int i=0;i0; + Object[] values=map?new Object[]{Integer.valueOf(i),row.value,row.key}:new Object[]{Integer.valueOf(i),row.value}; + Object[] args=concat(owner,values); + write("INSERT INTO "+q(relation.joinTable)+" ("+quoted(ownerColumns)+", "+q(relation.orderColumn)+", "+q(relation.inverseJoinColumn)+(map?", "+q(relation.mapKey):"")+") VALUES ("+placeholders(args.length)+")",args); + } + entry.collections[index]=rows; + } + + private static Iterable relatedValues(Object value) { return value instanceof Map?((Map)value).values():(Iterable)value; } + private List relationshipKeys(Relationship relation,Object value) { + if(relation.many) return relationKeys(relation,value); + List result=new ArrayList(); + if(value!=null) { EntityModel target=model(relation.target);result.add(target.identifier(value)); } + return result; + } + private List relationKeys(Relationship relation,Object value) { + if(relation.element) return elementRows(relation,value); + List result=new ArrayList(); + if(value!=null) { + EntityModel target=model(relation.target); + if(value instanceof Map) for(Object item:((Map)value).entrySet()) { + Map.Entry mapping=(Map.Entry)item; + if(mapping.getKey()==null || mapping.getValue()==null || !mapping.getKey().equals(target.domainValue(mapping.getValue(),target.index(relation.mapKey)))) + throw new PersistenceException("Map key does not match its entity attribute: "+relation.field); + } + for(Object child:relatedValues(value)) { + if(child==null) throw new PersistenceException("Null relationship element: "+relation.field); + Object id=target.identifier(child); + if(id==null || (target.attributes()[target.idIndex()].generated && id instanceof Number && ((Number)id).longValue()==0)) + throw new PersistenceException("Transient association without cascade PERSIST: "+relation.field); + result.add(id); + } + } + return result; + } + private void syncCollections(Entry entry) { + Relationship[] relations=entry.model.relationships(); EntityState state=state(entry.entity); + for(int i=0;i0) { + if(!keys.equals(old)) { + write("DELETE FROM "+q(relation.joinTable)+" WHERE "+matches(ownerColumns,null),ownerKey); + for(int position=0;position EntityModel model(Class type) { + EntityModel model=(EntityModel)models.get(type.getName()); + if(model==null) throw new PersistenceException("No generated model registered for "+type.getName()); + return model; + } + void check() { if(closed) throw new PersistenceException("Session is closed"); } + void requireTransaction() { check(); if(!transaction) throw new PersistenceException("An active transaction is required"); } + void autoFlush() { check(); if(transaction && !flushing && loading==0) flush(); } + String q(String value) { return sql.quote(value); } + String limit(int count,int offset) { return sql.limit(count,offset); } + String discriminatorCondition(EntityModel model,String alias) { + if(model.discriminatorIndex()<0) return ""; + String[] values=model.discriminatorValues();if(values.length==0) return "1=0"; + StringBuilder condition=new StringBuilder(alias==null?"":alias+".").append(q(model.attributes()[model.discriminatorIndex()].column)).append(" IN ("); + for(int i=0;i0) condition.append(',');condition.append("'").append(values[i].replace("'","''")).append("'"); + } + return condition.append(')').toString(); + } + String tableSource(EntityModel model) { + String filter=discriminatorCondition(model,null); + return filter.length()==0?q(model.table()):"(SELECT * FROM "+q(model.table())+" WHERE "+filter+")"; + } + String select(EntityModel model) { + StringBuilder out=new StringBuilder("SELECT "); Attribute[] attrs=model.attributes(); + for(int i=0;i0) out.append(", "); out.append(q(attrs[i].column)); } + return out.append(" FROM ").append(tableSource(model)).append(" cn1_entity").toString(); + } + int[] kinds(EntityModel model) { + Attribute[] attrs=model.attributes(); int[] kinds=new int[attrs.length]; + for(int i=0;i read(String statement,Object[] params,int[] kinds) { + check(); try { return sql.query(statement,params,kinds); } + catch(IOException e) { if(transaction) rollbackOnly=true; throw failure(e); } + } + int write(String statement,Object[] params) { + check(); try { return sql.execute(statement,params); } + catch(IOException e) { if(transaction) rollbackOnly=true; throw failure(e); } + } + T hydrate(EntityModel model,Object[] values) { + try { return hydrateInternal(model,values); } + catch(RuntimeException error) { if(transaction) rollbackOnly=true;clear();throw error; } + } + private T hydrateInternal(EntityModel model,Object[] values) { + if(model.discriminatorIndex()>=0) { + Object tag=values[model.discriminatorIndex()];EntityModel selected=null; + for(EntityModel candidate:models.values()) if(candidate.hierarchyRoot()==model.hierarchyRoot() && candidate.discriminatorValue().equals(tag)) { selected=candidate;break; } + if(selected==null || !model.type().isAssignableFrom(selected.type())) throw new PersistenceException("Unknown or incompatible discriminator: "+tag); + model=selected; + } + Key key=new Key(model.hierarchyRoot().getName(),model.identifierFromRow(values)); + T entity=(T)identities.get(key); + if(entity!=null) { checkManagedIdentity(entries.get(entity));return entity; } + entity=model.create(); + Entry entry=new Entry(model,entity,null); attachState(entry,false,values); + assign(model,entity,values); claim(model,entity); entries.put(entity,entry); + entry.snapshot=snapshot(model,entity); + Relationship[] relations=model.relationships(); + loading++; + try { if(deferredFetch==0) for(int i=0;i args=new ArrayList(); + int version=model.versionIndex(); if(version>=0) model.set(entry.entity,version,Long.valueOf(0)); + for(int i=0;i0) { cols.append(", "); marks.append(", "); } + cols.append(q(attrs[i].column)); marks.append('?'); + Object bound=model.get(entry.entity,i); + for(int ri=0;ri=0 && i>=model.relationships()[ri].column && i=0 && !same(now[version],entry.snapshot[version])) throw new PersistenceException("Version is managed by the ORM"); + boolean dirty=false; + for(int i=0;i0)?!current.equals(previous):current.size()!=previous.size() || !current.containsAll(previous))) dirty=true; + } + if(!dirty) return; + model.lifecycle(entry.entity,2); now=snapshot(model,entry.entity); + if(!same(model.identifierFromRow(now),model.identifierFromRow(entry.snapshot)) || version>=0 && !same(now[version],entry.snapshot[version])) + throw new PersistenceException("Lifecycle callback changed an identifier or version"); + List args=new ArrayList(); StringBuilder set=new StringBuilder(); + for(int i=0;i0) set.append(", "); + set.append(q(attrs[i].column)).append(" = ?"); args.add(now[i]); + } + if(args.isEmpty() && version<0) return; + Long next=null; + if(version>=0) { + long previous=((Number)entry.snapshot[version]).longValue(); + if(previous==(attrs[version].kind==Attribute.INTEGER?Integer.MAX_VALUE:Long.MAX_VALUE)) throw new PersistenceException("Version overflow"); + next=Long.valueOf(previous+1); + if(set.length()>0) set.append(", "); + set.append(q(attrs[version].column)).append(" = ?"); args.add(next); + } + String where=condition(entry,args); + int changed=write("UPDATE "+q(model.table())+" SET "+set+" WHERE "+where,args.toArray()); + if(changed!=1) throw new OptimisticLockException("Stale or missing row: "+model.table()); + if(version>=0) model.set(entry.entity,version,next); + entry.snapshot=snapshot(model,entry.entity); + model.lifecycle(entry.entity,3); + } + private void prepareDeletes() { + for(Entry entry:new ArrayList(entries.values())) if(entry.removed) { + checkManagedIdentity(entry); + StringBuilder changes=new StringBuilder(); + for(Relationship relation:entry.model.relationships()) if(relation.column>=0 && entry.model.attributes()[relation.column].nullable) { + for(String column:foreignColumns(entry.model,relation)) { + if(changes.length()>0) changes.append(", ");changes.append(q(column)).append(" = NULL"); + } + } + if(changes.length()>0) { + List args=new ArrayList();String where=condition(entry,args); + if(write("UPDATE "+q(entry.model.table())+" SET "+changes+" WHERE "+where,args.toArray())!=1) + throw new OptimisticLockException("Stale row during deletion: "+entry.model.table()); + } + } + } + private void delete(Entry entry) { + if(!entries.containsKey(entry.entity)) return; + if(entry.deleting) throw new PersistenceException("Cyclic non-nullable delete dependencies"); + entry.deleting=true; + for(Entry dependent:new ArrayList(entries.values())) if(dependent!=entry && dependent.removed) { + for(Relationship relation:dependent.model.relationships()) { + if(relation.column<0 || dependent.model.attributes()[relation.column].nullable || !relation.target.isInstance(entry.entity)) continue; + Object[] fk=new Object[entry.model.idIndexes().length];for(int i=0;i args=new ArrayList(); String where=condition(entry,args); + int changed=write("DELETE FROM "+q(entry.model.table())+" WHERE "+where,args.toArray()); + if(changed!=1) throw new OptimisticLockException("Stale or missing row: "+entry.model.table()); + detachOne(entry.entity);entry.model.lifecycle(entry.entity,5); + } + private void checkManagedIdentity(Entry entry) { + if(entry.snapshot==null) return; + if(!same(entry.model.identifierFromRow(entry.snapshot),entry.model.identifier(entry.entity))) throw new PersistenceException("Managed primary key cannot change"); + int version=entry.model.versionIndex(); + if(version>=0 && !same(entry.snapshot[version],entry.model.get(entry.entity,version))) throw new PersistenceException("Version is managed by the ORM"); + } + private String condition(Entry entry,List args) { + EntityModel model=entry.model; int version=model.versionIndex(); + for(int index:model.idIndexes()) args.add(entry.snapshot[index]); + String where=keyCondition(model,null); + if(version>=0) { where+=" AND "+q(model.attributes()[version].column)+" = ?"; args.add(entry.snapshot[version]); } + return where; + } + static Object[] concat(Object[] left,Object[] right) { + Object[] out=new Object[left.length+right.length];System.arraycopy(left,0,out,0,left.length);System.arraycopy(right,0,out,left.length,right.length);return out; + } + static String placeholders(int count) { + StringBuilder result=new StringBuilder();for(int i=0;i0) result.append(", ");result.append('?'); }return result.toString(); + } + String[] keyColumns(EntityModel model) { + int[] keys=model.idIndexes();String[] columns=new String[keys.length]; + for(int i=0;i0) result.append(" AND ");if(alias!=null) result.append(alias).append('.'); + result.append(q(column)).append(" = ?"); + } + return result.toString(); + } + String quoted(String[] columns) { + StringBuilder result=new StringBuilder();for(String column:columns) { if(result.length()>0) result.append(", ");result.append(q(column)); }return result.toString(); + } + String joinEquality(String[] left,String leftAlias,String[] right,String rightAlias) { + if(left.length!=right.length) throw new PersistenceException("Relationship key widths differ"); + StringBuilder result=new StringBuilder(); + for(int i=0;i0) result.append(" AND "); + result.append(leftAlias).append('.').append(q(left[i])).append(" = ").append(rightAlias).append('.').append(q(right[i])); + } + return result.toString(); + } + private void appendJoinDefinition(StringBuilder statement,String[] names,EntityModel model) { + int[] ids=model.idIndexes(); + for(int i=0;i0) statement.append(", "); + Attribute attr=model.attributes()[ids[i]]; + String type=attr.declaredType; + if(type==null) { + String keyType=sql.assignedKeyColumn(attr.kind); + int end=keyType.indexOf(" PRIMARY KEY");type=end<0?sql.columnType(attr.kind):keyType.substring(0,end).replace(" NOT NULL",""); + } + statement.append(q(names[i])).append(' ').append(type).append(" NOT NULL"); + } + } + String keyCondition(EntityModel model,String alias) { + StringBuilder result=new StringBuilder(); + for(int id:model.idIndexes()) { + if(result.length()>0) result.append(" AND "); + if(alias!=null) result.append(alias).append('.'); + result.append(q(model.attributes()[id].column)).append(" = ?"); + } + return result.toString(); + } + private void claim(EntityModel model,Object entity) { + Key key=key(model,entity); Object existing=identities.get(key); + if(existing!=null && existing!=entity) throw new PersistenceException("Two instances have the same identity: "+model.table()); + identities.put(key,entity); + } + private static Key key(EntityModel model,Object entity) { return new Key(model.hierarchyRoot().getName(),model.identifier(entity)); } + private static void assign(EntityModel model,Object entity,Object[] values) { model.read(entity,values); } + private static void copy(EntityModel model,Object from,Object to) { assign(model,to,snapshot(model,from)); } + private static Object[] snapshot(EntityModel model,Object entity) { + Object[] result=new Object[model.attributes().length]; + for(int i=0;i describe(String table) throws IOException; + String quote(String name); + String columnType(int kind); + String generatedKeyColumn(int kind, String quotedName); + String assignedKeyColumn(int kind); + String insertDefaults(String quotedTable); + String limit(int limit, int offset); + String lockClause(LockMode mode); + List query(String sql, Object[] params, int[] kinds) throws IOException; + int execute(String sql, Object[] params) throws IOException; + long insert(String sql, Object[] params, String keyColumn) throws IOException; + void prepareGenerator(int strategy,String name) throws IOException; + Object nextIdentifier(int strategy,String name,int kind) throws IOException; + void begin() throws IOException; + void commit() throws IOException; + void rollback() throws IOException; + void close() throws IOException; +} diff --git a/CodenameOne/src/com/codename1/orm/session/Values.java b/CodenameOne/src/com/codename1/orm/session/Values.java new file mode 100644 index 00000000000..796b5111d0d --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/session/Values.java @@ -0,0 +1,562 @@ +/* + * Copyright (c) 2012, 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.session; + +import java.io.IOException; +import java.util.Date; + +/** + * Turns what an engine sent into what a field holds. + * + *

{@link com.codename1.backend.Database} promises that rows come back as Long, + * Double, String, byte[] or null whichever engine answered, and it keeps that + * promise -- but "a Long" is not the same claim as "the type this field is". The + * same column is a Long from SQLite and a String from PostgreSQL when it is + * declared NUMERIC, because arbitrary precision does not fit in a double and the + * exact text is the only lossless answer; MySQL does the same for DECIMAL. A + * column somebody else's migration declared BOOLEAN comes back as a Long here and + * could be "t" from a text-format driver elsewhere. + * + *

So the conversions are written once, tolerantly, in one place instead of + * being generated into every entity -- and none of them is a cast whose failure + * is caught. ParparVM's CHECKCAST is unchecked, so a cast that fails hands the + * wrong object to the next instruction rather than throwing something a handler + * could see. + */ +public final class Values { + /** Normalizes mutable and boxed values before binding or taking a snapshot. */ + public static Object storage(Object value) { + if(value instanceof java.util.Date) return Long.valueOf(((java.util.Date)value).getTime()); + if(value instanceof Boolean) return Long.valueOf(((Boolean)value).booleanValue()?1:0); + if(value instanceof Character) return Long.valueOf(((Character)value).charValue()); + if(value instanceof Byte || value instanceof Short || value instanceof Integer) return Long.valueOf(((Number)value).longValue()); + if(value instanceof Float) return Double.valueOf(((Number)value).doubleValue()); + if(value instanceof Enum) return ((Enum)value).name(); + return value; + } + + private Values() { + } + + /** The value as text, or null. */ + public static String asString(Object value) { + if(value == null) { + return null; + } + if(value instanceof String) { + return (String)value; + } + if(value instanceof byte[]) { + // A BLOB column read into a String field. Decoded as UTF-8 rather + // than through String.valueOf, which would answer "[B@1f3a". + try { + byte[] bytes = (byte[])value; + return new String(bytes, 0, bytes.length, "UTF-8"); + } catch (IOException err) { + return null; + } + } + return String.valueOf(value); + } + + /** The value as a 64-bit integer, or {@code fallback} when it is null. */ + public static long asLong(Object value, long fallback) throws IOException { + Long out = asLongObject(value); + return out == null ? fallback : out.longValue(); + } + + /** The value as a 64-bit integer, or null. */ + public static Long asLongObject(Object value) throws IOException { + if(value == null) { + return null; + } + if(value instanceof Double || value instanceof Float) { + // SQLITE HAS NO COLUMN TYPES, only affinities: an INTEGER column + // holds 12.5 if something put one there -- a migration, raw SQL, + // another client -- and the driver hands it back as a Double. + // longValue() then truncated it into the entity, while the exact-text + // branch below refuses the identical "12.5". The same value cannot be + // corrupted one way and refused the other because of how the engine + // chose to encode it. + double exact = ((Number)value).doubleValue(); + if(exact != Math.floor(exact) || Double.isInfinite(exact) || Double.isNaN(exact)) { + throw new IOException("A column holding " + describe(value) + + " has a fractional part and the field it is read into is an " + + "integer; the value would have to be rounded to fit"); + } + // 9.223372036854776E18 IS 2^63, and Long.MAX_VALUE is 2^63 - 1, + // which no double can represent. So the boundary value compares + // equal to the literal rather than greater, and the cast below then + // clamps it to Long.MAX_VALUE: a different number, silently. The + // upper bound is exclusive for that reason; the lower one is not, + // because -2^63 IS Long.MIN_VALUE exactly. + if(exact < -9.223372036854776E18 || exact >= 9.223372036854776E18) { + throw new IOException("A column holding " + describe(value) + + " is outside the range of the integer field it is read into"); + } + return Long.valueOf((long)exact); + } + if(value instanceof Number) { + return Long.valueOf(((Number)value).longValue()); + } + if(value instanceof Boolean) { + return Long.valueOf(((Boolean)value).booleanValue() ? 1L : 0L); + } + if(value instanceof String) { + String text = ((String)value).trim(); + // The spellings a server sends for a boolean when it does not send a + // number: PostgreSQL's text format is t and f. + if("t".equals(text) || "true".equalsIgnoreCase(text)) { + return Long.valueOf(1L); + } + if("f".equals(text) || "false".equalsIgnoreCase(text)) { + return Long.valueOf(0L); + } + try { + return Long.valueOf(Long.parseLong(text)); + } catch (NumberFormatException notAnInteger) { + // A NUMERIC or DECIMAL column arrives as EXACT TEXT -- that is + // the whole reason those two engines send it as text rather than + // as a number -- so "12.00" is an ordinary integer that + // Long.parseLong refuses for its fraction alone. + // + // Parsed exactly rather than through Double.parseDouble, which + // was the previous fallback and threw away the precision the + // engine took care to preserve: 9007199254740993.00 fits in a + // long and came back as ...992, and anything past the double + // range clamped to Long.MAX_VALUE without a word. + return Long.valueOf(integralText(text, value)); + } + } + throw notANumber(value, "an integer"); + } + + /** The value as a 32-bit integer, or {@code fallback} when it is null. */ + public static int asInt(Object value, int fallback) throws IOException { + Long out = asLongObject(value); + return out == null ? fallback : (int)narrowed(out.longValue(), value, + -2147483648L, 2147483647L, "int"); + } + + /** The value as a boxed 32-bit integer, or null. */ + public static Integer asIntObject(Object value) throws IOException { + Long out = asLongObject(value); + return out == null ? null : Integer.valueOf((int)narrowed(out.longValue(), value, + -2147483648L, 2147483647L, "int")); + } + + /** The value as a 16-bit integer, or {@code fallback} when it is null. */ + public static short asShort(Object value, short fallback) throws IOException { + Long out = asLongObject(value); + return out == null ? fallback : (short)narrowed(out.longValue(), value, + -32768L, 32767L, "short"); + } + + /** The value as a boxed 16-bit integer, or null. */ + public static Short asShortObject(Object value) throws IOException { + Long out = asLongObject(value); + return out == null ? null : Short.valueOf((short)narrowed(out.longValue(), value, + -32768L, 32767L, "short")); + } + + /** The value as a byte, or {@code fallback} when it is null. */ + public static byte asByte(Object value, byte fallback) throws IOException { + Long out = asLongObject(value); + return out == null ? fallback : (byte)narrowed(out.longValue(), value, + -128L, 127L, "byte"); + } + + /** The value as a boxed byte, or null. */ + public static Byte asByteObject(Object value) throws IOException { + Long out = asLongObject(value); + return out == null ? null : Byte.valueOf((byte)narrowed(out.longValue(), value, + -128L, 127L, "byte")); + } + + /** + * {@code number} when the field it is going into can hold it, or a refusal. + * + *

A cast would WRAP: a column holding 2147483648 read into an int field + * became -2147483648, which is a different row's key, a different count, a + * different answer -- and nothing said so. The columns these fields map to + * are ordinary integer columns on every engine, and SQLite's are not even + * range-checked, so a value too large for the field is something a migration + * or another client can put there any day. + */ + private static long narrowed(long number, Object value, long min, long max, String field) + throws IOException { + if(number < min || number > max) { + throw new IOException("A column holding " + describe(value) + + " is outside the range of the " + field + " field it is read into"); + } + return number; + } + + /** The value as a double, or {@code fallback} when it is null. */ + public static double asDouble(Object value, double fallback) throws IOException { + Double out = asDoubleObject(value); + return out == null ? fallback : out.doubleValue(); + } + + /** The value as a boxed double, or null. */ + public static Double asDoubleObject(Object value) throws IOException { + if(value == null) { + return null; + } + if(value instanceof Number) { + return Double.valueOf(((Number)value).doubleValue()); + } + if(value instanceof String) { + String text = ((String)value).trim(); + double parsed; + try { + parsed = Double.parseDouble(text); + } catch (NumberFormatException err) { + throw notANumber(value, "a number"); + } + // A NUMERIC column comes back as exact text, and parseDouble answers + // INFINITY for a finite number too large to hold -- 1e999 -- and ZERO + // for one too small. Either way the entity would carry a value the + // row does not, which is the same silent substitution the integral + // conversions refuse. An Infinity or NaN the database really stores + // is spelled as such, and passes. + if(Double.isInfinite(parsed) && !spellsNonFinite(text)) { + throw new IOException("A column holding " + describe(value) + + " is too large for the floating point field it is read into"); + } + if(parsed == 0.0 && hasNonZeroDigit(text)) { + throw new IOException("A column holding " + describe(value) + + " is too small for the floating point field it is read into, " + + "which would read it as zero"); + } + return Double.valueOf(parsed); + } + throw notANumber(value, "a number"); + } + + /** The value as a float, or {@code fallback} when it is null. */ + public static float asFloat(Object value, float fallback) throws IOException { + Double out = asDoubleObject(value); + return out == null ? fallback : narrowedToFloat(out.doubleValue(), value); + } + + /** The value as a boxed float, or null. */ + public static Float asFloatObject(Object value) throws IOException { + Double out = asDoubleObject(value); + return out == null ? null : Float.valueOf(narrowedToFloat(out.doubleValue(), value)); + } + + /** + * {@code number} as a float, when a float can hold it. + * + *

A float field's column is DOUBLE on MySQL and DOUBLE PRECISION on + * PostgreSQL, both of which hold numbers a float cannot: 1e100 narrowed to + * INFINITY, so the entity held something the row does not and writing it + * back would store that or fail. The integral converters were given this + * check a round ago and this one was missed. + * + *

A value that is ALREADY infinite or NaN passes through: PostgreSQL can + * store either, and reproducing what the row holds is right. What is refused + * is a finite number becoming an infinite one. + */ + private static float narrowedToFloat(double number, Object value) throws IOException { + if(!Double.isNaN(number) && !Double.isInfinite(number) + && (number > 3.4028234663852886E38 || number < -3.4028234663852886E38)) { + throw new IOException("A column holding " + describe(value) + + " is outside the range of the float field it is read into"); + } + float narrowed = (float)number; + if(narrowed == 0.0f && number != 0.0) { + // THE OTHER BOUNDARY. A float's smallest subnormal is about 1.4e-45, + // so a stored 1e-100 narrows to zero -- a nonzero row read back as + // no value at all, which is the same silent substitution the + // overflow above refuses and the one asDoubleObject refuses for + // text. Losing PRECISION is inherent to a float and is not this; + // losing the value is. Note -0.0 compares equal to 0.0, so a stored + // negative zero passes rather than being reported as underflow. + throw new IOException("A column holding " + describe(value) + + " is too small for the float field it is read into, " + + "which would read it as zero"); + } + return narrowed; + } + + /** The value as a flag: anything non-zero, or the text of one, is true. */ + public static boolean asBoolean(Object value, boolean fallback) throws IOException { + Boolean out = asBooleanObject(value); + return out == null ? fallback : out.booleanValue(); + } + + /** The value as a boxed flag, or null. */ + public static Boolean asBooleanObject(Object value) throws IOException { + if(value == null) { + return null; + } + if(value instanceof Boolean) { + return (Boolean)value; + } + Long number = asLongObject(value); + return number == null ? null : Boolean.valueOf(number.longValue() != 0); + } + + /** + * The first character of the value as a boxed Character, or null. + * + *

The nullable twin of {@link #asChar}, and text-only for the same + * reason: the generated access for a Character field used asString and kept + * the first character of whatever a blob decoded to, while the primitive + * char field beside it refused the same row. + */ + public static Character asCharObject(Object value) throws IOException { + if(value == null) { + return null; + } + if(!(value instanceof String)) { + throw new IOException("A column holding " + describe(value) + + " cannot be read as a character; the field expects text"); + } + String text = (String)value; + return text.length() == 0 ? null : Character.valueOf(text.charAt(0)); + } + + /** + * The first character of the value, or {@code fallback} when it is null or + * empty. + * + *

TEXT ONLY. An entity's char field is stored in a text column, so a + * number or a blob arriving here means the table and the entity disagree -- + * and taking the first character of however that value happens to print is + * not a reading of it: a byte[] gave whatever its first byte decoded to, a + * Long gave the first DIGIT, and NaN gave 'N'. Every other converter in this + * class refuses an encoding it cannot mean; this one used to accept them all. + */ + public static char asChar(Object value, char fallback) throws IOException { + if(value == null) { + return fallback; + } + if(!(value instanceof String)) { + throw new IOException("A column holding " + describe(value) + + " cannot be read as a character; the field expects text"); + } + String text = (String)value; + return text.length() == 0 ? fallback : text.charAt(0); + } + + /** + * {@code value}, or a refusal when the column holds SQL NULL. + * + *

What the generated access wraps a PRIMITIVE field's read in. A Java + * primitive has no null, so the conversions below take a fallback and answer + * it -- which for a column that really is null means the entity carries 0, + * false or '\0' where the row carries nothing at all, told apart by nobody. + * The tables this ORM creates declare a primitive field's column NOT NULL so + * the case cannot arise in them; this is for the ones it did not create, and + * a migration or another client is exactly where a null turns up. + * + *

Boxed fields do not come through here: they can hold the null, and + * that is the reason to declare one. + */ + public static Object required(Object value, String field) throws IOException { + if(value == null) { + throw new IOException("The column for " + field + " holds SQL NULL and the " + + "field is a primitive, which cannot hold one. Declare the field as " + + "its boxed type to read a nullable column."); + } + return value; + } + + /** + * The value as a UTF-16 code unit, or {@code fallback} when it is null. + * + *

This is how an entity's char field is stored: the NUMBER of the code + * unit, in an integer column, on every engine. Text would be the obvious + * choice and it is the wrong one, because a char is not a string. The + * default value of an unset char field is {@code '\0'}, and PostgreSQL + * refuses a NUL inside a text value outright ("invalid byte sequence for + * encoding UTF8: 0x00"), so an entity with an untouched char field could not + * be inserted at all. An unpaired surrogate -- also a perfectly legal char -- + * has no UTF-8 encoding either. As a number both are ordinary values, and + * they compare exactly rather than through whatever collation the column + * happens to carry, which on MySQL is case insensitive by default. + * + *

Text is still accepted, for a column somebody mapped onto an existing + * CHAR(1): a number and a string cannot be confused for one another, so + * there is no ambiguity in taking both. + */ + public static char asCodeUnit(Object value, char fallback) throws IOException { + Character out = asCodeUnitObject(value); + return out == null ? fallback : out.charValue(); + } + + /** + * The nullable twin of {@link #asCodeUnit}. + */ + public static Character asCodeUnitObject(Object value) throws IOException { + if(value == null) { + return null; + } + if(value instanceof String) { + String text = (String)value; + return text.length() == 0 ? null : Character.valueOf(text.charAt(0)); + } + if(!(value instanceof Number)) { + throw new IOException("A column holding " + describe(value) + + " cannot be read as a character"); + } + Long number = asLongObject(value); + if(number == null) { + return null; + } + long unit = number.longValue(); + if(unit < 0 || unit > 65535) { + // NARROWING would answer a different character rather than say so, + // which is what every other integral conversion here refuses to do. + throw new IOException("A column holding " + describe(value) + + " is outside the range of a character"); + } + return Character.valueOf((char)unit); + } + + /** + * The value as a moment in time, or null. + * + *

An entity stores a Date as epoch MILLISECONDS in an integer column, on + * every engine, which is what makes it the same value everywhere: a native + * timestamp comes back as text whose format follows the server's DateStyle + * and session time zone. A number is therefore read as milliseconds; text + * that is a number is read the same way, and anything else is refused rather + * than guessed at. + */ + public static Date asDate(Object value) throws IOException { + if(value instanceof Boolean) { + // A flag is not a moment. asLongObject reads one as 0 or 1, which + // would make "true" the first millisecond of 1970 rather than an + // error, and every other converter refuses the encodings it cannot + // mean. + throw new IOException("A column holding " + describe(value) + + " cannot be read as a date"); + } + Long millis = asLongObject(value); + return millis == null ? null : new Date(millis.longValue()); + } + + /** The value as bytes, or null. Text is encoded as UTF-8. */ + public static byte[] asBytes(Object value) throws IOException { + if(value == null) { + return null; + } + if(value instanceof byte[]) { + return (byte[])value; + } + if(value instanceof String) { + return ((String)value).getBytes("UTF-8"); + } + throw new IOException("A column holding " + describe(value) + + " cannot be read as bytes"); + } + + /** + * {@code text} as a long, where it is an integer written with a fraction of + * zeros -- "12", "12.", "12.000". + * + *

A fraction that is NOT zero is refused rather than truncated. The field + * is an integer and the column is not, which is a disagreement between the + * entity and the table; rounding it silently is how the wrong number ends up + * stored back. Out of range is refused for the same reason: a clamp to + * Long.MAX_VALUE is a value nobody wrote. + */ + private static long integralText(String text, Object value) throws IOException { + int dot = text.indexOf('.'); + if(dot < 0) { + throw notANumber(value, "an integer"); + } + for(int iter = dot + 1 ; iter < text.length() ; iter++) { + if(text.charAt(iter) != '0') { + throw new IOException("A column holding " + describe(value) + + " has a fractional part and the field it is read into is an " + + "integer; the value would have to be rounded to fit"); + } + } + String whole = text.substring(0, dot); + if(whole.length() == 0 || "+".equals(whole) || "-".equals(whole)) { + whole = whole + "0"; + } + try { + return Long.parseLong(whole); + } catch (NumberFormatException err) { + throw new IOException("A column holding " + describe(value) + + " is outside the range of the integer field it is read into"); + } + } + + /** Whether the text itself says infinity or NaN, rather than overflowing to one. */ + private static boolean spellsNonFinite(String text) { + for(int iter = 0 ; iter < text.length() ; iter++) { + char c = text.charAt(iter); + if(c == 'i' || c == 'I' || c == 'n' || c == 'N') { + return true; + } + } + return false; + } + + /** + * Whether the text carries a non-zero digit before its exponent, which is + * what separates a real number that UNDERFLOWED from an honest zero. + */ + private static boolean hasNonZeroDigit(String text) { + for(int iter = 0 ; iter < text.length() ; iter++) { + char c = text.charAt(iter); + if(c == 'e' || c == 'E') { + return false; + } + if(c >= '1' && c <= '9') { + return true; + } + } + return false; + } + + private static IOException notANumber(Object value, String wanted) { + return new IOException("A column holding " + describe(value) + + " cannot be read as " + wanted); + } + + /** What a value is, for a message, without pasting a password into a log. */ + private static String describe(Object value) { + if(value == null) { + return "null"; + } + if(value instanceof String) { + String text = (String)value; + // Truncated: the value may be a row somebody stored, and an error + // message is a log line. + return "the text '" + (text.length() > 32 ? text.substring(0, 32) + "..." : text) + + "'"; + } + if(value instanceof byte[]) { + return ((byte[])value).length + " bytes"; + } + return "a " + value.getClass().getName(); + } +} diff --git a/docs/developer-guide/Annotation-SQLite-ORM.asciidoc b/docs/developer-guide/Annotation-SQLite-ORM.asciidoc index 0433931a9d9..3ba0f17d149 100644 --- a/docs/developer-guide/Annotation-SQLite-ORM.asciidoc +++ b/docs/developer-guide/Annotation-SQLite-ORM.asciidoc @@ -30,7 +30,8 @@ include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/g [cols="1,3", options="header"] |=== | Annotation | Purpose -| `@Id` | Marks the primary-key field. Exactly one per entity. +| `@Id` | Marks a primary-key field. The legacy DAO uses one key; + managed sessions also support composite assigned keys. `autoIncrement=true` (the default) emits `INTEGER PRIMARY KEY AUTOINCREMENT` and back-fills the field after `insert`. @@ -56,23 +57,219 @@ dao surface isn't enough. Transactions: include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnnotationSqliteOrmJava002Snippet.java[tag=annotation-sqlite-orm-java-002,indent=0] ---- -=== Relationships +=== Managed sessions and relationships -Relationship annotations (`@OneToMany`, `@ManyToOne`, ...) aren't yet -supported. An entity field that references another entity or a -`java.util.List` of entities fails the build with a clear error -- -mark such fields `@DbTransient` and persist the foreign key (id) as a -separate scalar column for now. +The managed ORM layer uses the same entity annotations on the client and the +backend. Open a `com.codename1.orm.session.Session` with +`EntityManager.openSession()`. Keep one session per unit of work. A session is +not thread-safe; a backend pooled entity manager can still be shared between +requests, with a separate session for each request. + +The existing scalar `Dao` API keeps its immediate-write behavior. Entities +using relationships, `@Version`, composite IDs, generated strategies, +inheritance, converters, indexes, or embedded values use sessions; asking for a +legacy DAO for these mappings throws an error rather than silently dropping +managed behavior. + +A managed session maintains one instance per entity identity, detects changes +at flush time, and flushes before commit and before queries in a transaction. +`persist`, `merge`, and `remove` require an active transaction. `merge` returns +the managed copy; it does not attach the supplied detached instance. + +[source,java] +---- +Session session = em.openSession(); +try { + session.beginTransaction(); + Customer customer = session.find(Customer.class, customerId); + customer.name = "Updated name"; + session.commitTransaction(); +} finally { + session.close(); +} +---- + +Closing a session rolls back any active transaction. It never commits. A +rollback clears the persistence context. An optimistic-lock or database failure +requires rollback before the session can continue writing. Closing the session +does not close its entity manager's database or pool. + +==== Association mappings + +Use `@ManyToOne`, `@OneToOne`, `@OneToMany`, and `@ManyToMany` on public entity +fields. To-many fields use `List`, `Set`, or `Map`. `@JoinColumn` names an owning +foreign-key column. `mappedBy` identifies the owning field on the other entity. +An owning to-many relationship uses a join table; `@JoinTable` customizes its +name and key columns. + +To-one relationships default to `FetchType.EAGER`; to-many relationships default +to `FetchType.LAZY`. Override these defaults with the annotation's `fetch` +member. `CascadeType` selects the persistence operations to propagate. +`orphanRemoval=true` removes children dropped from an initialized relationship. +Application code maintains both sides of bidirectional object relationships. + +The build enhances entity classes and field-access callers so a normal public +field read can initialize a lazy relationship. No runtime proxy generator or +reflection is needed. `session.isLoaded(entity, "relationship")` inspects load +state without fetching; `session.initialize(entity, "relationship")` fetches +explicitly. An unloaded relationship cannot be read after detaching its entity +or closing its session: this throws `LazyInitializationException`. Generated +`@Mapped` serializers reject unloaded associations rather than fetching them +during serialization; initialize the relationships needed for the output first. + +Fetching is synchronous. On the client, perform database work off the EDT and +initialize the data needed by the UI before closing the session. Already-loaded +data remains readable after the session closes. + +==== Queries and counters + +The session query builder uses Java field paths, including relationship paths: + +[source,java] +---- +List orders = session.query(Order.class) + .eq("customer.name", "Alice") + .orderBy("id", true) + .limit(20) + .fetch("customer") + .list(); +---- + +`fetch` overrides laziness for a direct relationship. To-one and inverse +foreign-key collection fetches with single-column keys are batched. Ordered, +map, composite-key, and join-table collections currently use individual loaders. Root pagination happens before +loading fetched collections. `query.count()` counts matching entities without +loading them and ignores pagination. `session.count(entity, "relationship")` +counts a relationship without materializing it. + +`createQuery` accepts an explicit JPQL subset: entity or scalar selects, +relationship joins, named parameters, comparisons and boolean predicates, +aggregates, grouping/having, ordering, scalar predicate subqueries, and bulk +update/delete. String and numeric literals and named values become bound SQL +parameters. `IN :values` and `IN (:values)` accept an `Iterable` or object array, +including an empty collection. Unsupported syntax is rejected. Scalar projections return the +stored scalar representation; multiple scalar projections return `Object[]`. + +[source,java] +---- +List orders = session.createQuery( + "select o from Order o where o.customer.name = :name order by o.id", + Order.class).setParameter("name", "Alice").list(); +---- + +Bulk mutations use `executeUpdate()` inside a transaction. They flush pending +managed changes, bypass per-entity callbacks and cascades, and clear the context +so later reads cannot reuse stale entities. + +An `int` or `long` field annotated `@Version` is maintained by the session. +Updates and deletes compare its previous value and throw +`OptimisticLockException` when another writer changed or deleted the row. +`session.increment(Entity.class, id, "counter", delta)` performs database +arithmetic, advances the version when present, and refreshes an already-managed +instance. It returns false for a missing row or an increment outside the +counter/version range. It requires a transaction. + +==== Values and callbacks + +`@MappedSuperclass` contributes public inherited fields to a concrete entity. +`@Embedded` flattens a public `@Embeddable` value into columns prefixed with the +embedding field name. An all-null embedded value reads as null. Embedded fields +can be used in query paths. Enums are stored by name. + +Public no-argument void methods can receive `@PrePersist`, `@PostPersist`, +`@PreUpdate`, `@PostUpdate`, `@PreRemove`, `@PostRemove`, and `@PostLoad` callbacks. + +Inherited callbacks run before subclass callbacks; overriding a callback method +replaces the inherited declaration. Client scalar `Property` fields participate +in dirty checking and counters. Backend entities use plain fields because the +backend runtime does not contain the client properties package. + +`@Convert(converter=CodeConverter.class, storageType=String.class)` maps a +custom basic value to a SQL scalar. The converter implements +`com.codename1.orm.session.AttributeConverter` and has a public +no-argument constructor. It receives null values too. Query-builder predicates +and JPQL comparison parameters apply the field's converter. Identifiers and +versions cannot use converters. + +==== Identity and inheritance + +Keep `@Id` for identity-column generation. `@GeneratedValue` selects `UUID` +(for String IDs), `SEQUENCE`, or `TABLE` (for int/long IDs). PostgreSQL uses +native sequences; other engines use a transactional generator table. UUIDs are +version 4. Identity IDs become available on flush; the other strategies allocate +on persist. Production migrations must provision the selected generators. + +For composite IDs, use several `@Id(autoIncrement=false)` fields or one +`@EmbeddedId` whose type is `@Embeddable`. Lookup accepts +`Identifier.of(component1, component2)` in declared-field order; an embedded-ID +mapping also accepts its key object. Relationships and join tables include all +key components. Generated composite IDs are rejected. + +`@Inheritance` on an entity root enables single-table inheritance, including +abstract roots and polymorphic queries. `@DiscriminatorValue("customer")` +assigns a stable discriminator to a concrete subtype. A base-class lookup and a +subclass lookup return the same managed instance for the same row. Use explicit +discriminator values when class names may change. + +==== Lists, maps, and owned values + +`@OrderColumn` on an owning List stores its positions in the join table, so +reordering survives reload. Many-to-many lists may contain repeated links. +`@OrderBy("name ASC, id DESC")` instead orders an entity collection by target +fields at load time. An inverse `mappedBy` collection can use `@OrderBy`. + +For an entity map, `@MapKey(name="code")` derives keys from a basic field on the +target entity. Keys must be non-null and unique in that collection. When changing +the target key field, update the Java map keys as well. + +`@ElementCollection` stores owned scalar values in a separate table. It supports +List, Set, and Map with String keys. Values may be String, boxed primitive types, +or `java.util.Date`. Lists preserve order and duplicate values. Map columns can +be named with `@MapKeyColumn`; `@JoinTable` names the collection table. +`query.containsElement("tags", "important")` tests membership without loading +the collection. Changing a mutable Date value is detected during flush. + +==== Schema and locking + +Run `session.createTables()` outside application transactions for development +schemas. It creates missing entity/collection tables, primary and foreign keys, +one-to-one uniqueness, and relationship indexes. `@Column(unique=true)` and +`@Entity(indexes=@Index(name="by_name", fields={"name"}))` declare additional +indexes. MySQL indexed text uses a bounded key type; specify `@Column(type=...)` +when the schema needs a particular length. + +`session.validateSchema()` checks mapped entity columns, storage-type families, +nullability, primary keys, and required collection columns. It does not migrate +schemas or validate every database-specific constraint. Existing tables are +left to explicit migrations. In particular, a legacy client DAO's text char +columns need migration to the managed layer's numeric UTF-16 storage when moving +to a managed schema. + +Backend sessions support `find(type, id, LockMode.PESSIMISTIC_READ)` and +`PESSIMISTIC_WRITE`, or `lock(entity, mode)`, inside a transaction. PostgreSQL and +MySQL/MariaDB use their row-lock clauses. SQLite rejects these modes explicitly; +it cannot provide row-level pessimistic locks. + +==== Supported boundary + +This API provides managed persistence through CN1 annotations; it is not a JPA +provider or a Hibernate binary replacement. Entity access uses public fields. +Getter/setter access mappings, property-backed associations, joined/table-per-class +inheritance, embeddable element collections, and independently stored entity-map +keys are not supported. Nested fetch graphs and mixed entity/scalar JPQL +projections are also outside the current query subset. Use the builder's +`count()` for distinct composite identities. Native device execution and each +backend database version still require application integration testing. === Validation The annotation processor fails the build when: -* `@Entity` lands on an abstract class or interface. -* The class has no public no-arg constructor. -* No field carries `@Id`, or more than one does. -* A field's static type isn't supported (relationships, unsupported - reference types). +* `@Entity` lands on an interface, or an abstract entity has no `@Inheritance` root. +* A concrete entity has no public no-arg constructor. +* No identifier is mapped, or a composite identifier requests generated values. +* Persistent column/table/index names conflict, or `mappedBy` does not resolve. +* A field's static type or relationship mapping is unsupported. Errors are accumulated so the first build run reports every offending entity at once. diff --git a/maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionDatabaseTest.java b/maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionDatabaseTest.java new file mode 100644 index 00000000000..9798fb194c6 --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionDatabaseTest.java @@ -0,0 +1,88 @@ +/* + * 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.backend.orm; + +import com.codename1.backend.Database; +import com.codename1.orm.session.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Assumptions; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Opt-in wire-protocol tests; creates and removes only a uniquely named test schema. */ +class ManagedSessionDatabaseTest { + @Test void realDatabaseSupportsSequencesTransactionsCountersAndRowLocks() throws Exception { + String url=System.getenv("CN1_ORM_TEST_DATABASE_URL"); + Assumptions.assumeTrue(url!=null && url.length()>0,"Set CN1_ORM_TEST_DATABASE_URL to an isolated PostgreSQL/MySQL/MariaDB database"); + Database db=Database.open(url); + boolean mysql="mysql".equals(db.dialect().getName()); + String schema="cn1_orm_test_"+Long.toHexString(System.nanoTime()); + String quoted=db.dialect().quote(schema); + String selectSchema=(mysql?"USE ":"SET search_path TO ")+quoted; + db.execute((mysql?"CREATE DATABASE ":"CREATE SCHEMA ")+quoted,new Object[0]); + db.execute(selectSchema,new Object[0]); + Models.register(new ManagedSessionTest.Model() { + public int generation() { return 2; } + public String generator() { return "record_sequence"; } + public Attribute[] attributes() { + Attribute[] attrs=super.attributes().clone(); + attrs[0]=new Attribute("id","id",Attribute.BIGINT,true,false,false,false);return attrs; + } + }); + EntityManager manager=EntityManager.open(db);Session session=manager.openSession(); + ExecutorService workers=Executors.newFixedThreadPool(2); + try { + session.createTables();session.validateSchema();session.beginTransaction(); + ManagedSessionTest.Record entity=new ManagedSessionTest.Record();entity.name="record";entity.bytes=new byte[]{1,2};session.persist(entity); + assertTrue(entity.id>0);session.commitTransaction();session.clear(); + assertArrayEquals(new byte[]{1,2},session.find(ManagedSessionTest.Record.class,entity.id).bytes); + session.beginTransaction();session.lock(session.find(ManagedSessionTest.Record.class,entity.id),LockMode.PESSIMISTIC_WRITE); + CountDownLatch attempting=new CountDownLatch(1); + Future waiting=workers.submit(()-> { + Database other=Database.open(url);other.execute(selectSchema,new Object[0]);EntityManager em=EntityManager.open(other);Session s=em.openSession(); + try { + s.beginTransaction();attempting.countDown(); + ManagedSessionTest.Record locked=s.find(ManagedSessionTest.Record.class,entity.id,LockMode.PESSIMISTIC_WRITE); + locked.counter++;s.commitTransaction();return locked.counter; + } finally { s.close();em.close(); } + }); + assertTrue(attempting.await(5,TimeUnit.SECONDS)); + assertThrows(TimeoutException.class,()->waiting.get(100,TimeUnit.MILLISECONDS)); + session.commitTransaction();assertEquals(1L,waiting.get(5,TimeUnit.SECONDS).longValue()); + Callable increment=()-> { + Database other=Database.open(url);other.execute(selectSchema,new Object[0]);EntityManager em=EntityManager.open(other);Session s=em.openSession(); + try { for(int i=0;i<5;i++) { s.beginTransaction();assertTrue(s.increment(ManagedSessionTest.Record.class,entity.id,"counter",1));s.commitTransaction(); }return null; } + finally { s.close();em.close(); } + }; + Future first=workers.submit(increment),second=workers.submit(increment);first.get(10,TimeUnit.SECONDS);second.get(10,TimeUnit.SECONDS); + session.clear();assertEquals(11,session.find(ManagedSessionTest.Record.class,entity.id).counter); + assertEquals(11,session.find(ManagedSessionTest.Record.class,entity.id).version); + session.beginTransaction();assertEquals(1,session.createQuery("update Record r set r.name=:name where r.id=:id").setParameter("name","updated").setParameter("id",entity.id).executeUpdate());session.commitTransaction(); + assertEquals("updated",session.find(ManagedSessionTest.Record.class,entity.id).name); + } finally { + workers.shutdownNow();workers.awaitTermination(5,TimeUnit.SECONDS); + try { session.close();db.execute((mysql?"DROP DATABASE ":"DROP SCHEMA ")+quoted+(mysql?"":" CASCADE"),new Object[0]); } + finally { manager.close(); } + } + } +} diff --git a/maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionTest.java b/maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionTest.java new file mode 100644 index 00000000000..40e3233982f --- /dev/null +++ b/maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionTest.java @@ -0,0 +1,232 @@ +/* + * 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.backend.orm; + +import com.codename1.backend.Database; +import com.codename1.orm.session.Attribute; +import com.codename1.orm.session.EntityModel; +import com.codename1.orm.session.Models; +import com.codename1.orm.session.OptimisticLockException; +import com.codename1.orm.session.PersistenceException; +import com.codename1.orm.session.Session; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class ManagedSessionTest { + static class Record { + long id,version,counter; + String name; + byte[] bytes; + } + static class Model extends EntityModel { + private final Attribute[] attrs={ + new Attribute("id","id",Attribute.BIGINT,true,true,false,false), + new Attribute("version","version",Attribute.BIGINT,false,false,false,true), + new Attribute("counter","counter",Attribute.BIGINT,false,false,false,false), + new Attribute("name","name",Attribute.TEXT,false,false,true,false), + new Attribute("bytes","bytes",Attribute.BLOB,false,false,true,false) + }; + public Class type() { return Record.class; } + public String table() { return "managed_record"; } + public Attribute[] attributes() { return attrs; } + public Record create() { return new Record(); } + public Object get(Record r,int i) { + switch(i) { case 0:return r.id; case 1:return r.version;case 2:return r.counter;case 3:return r.name;default:return r.bytes; } + } + public void set(Record r,int i,Object value) { + switch(i) { + case 0:r.id=((Number)value).longValue();break; + case 1:r.version=((Number)value).longValue();break; + case 2:r.counter=((Number)value).longValue();break; + case 3:r.name=(String)value;break; + case 4:r.bytes=(byte[])value;break; + } + } + } + private EntityManager manager() throws Exception { + Models.register(new Model()); + return EntityManager.open(Database.open(":memory:")); + } + private Record seed(Session session) { + session.createTables();session.validateSchema(); session.beginTransaction(); + Record r=new Record();r.name="first";r.bytes=new byte[]{1};session.persist(r);session.commitTransaction();return r; + } + @Test void schemaCreationIncludesLegacyDefinitionsAlongsideManagedEntities() throws Exception { + EntityManager.register(new TestEntities.NoteDefinition()); + EntityManager em=manager(); + try { + em.createTables(); + TestEntities.Note note=new TestEntities.Note();note.title="legacy"; + em.dao(TestEntities.Note.class).insert(note); + assertEquals("legacy",em.dao(TestEntities.Note.class).findById(note.id).title); + Session s=em.openSession();s.validateSchema();s.beginTransaction(); + Record record=new Record();s.persist(record);s.commitTransaction();assertTrue(record.id>0);s.close(); + } finally { em.close(); } + } + + @Test void schemaValidationRejectsIncompatibleColumnsAndSqliteRowLocks() throws Exception { + EntityManager em=manager(); + try { + Session s=em.openSession();Record r=seed(s);s.validateSchema(); + s.beginTransaction(); + assertThrows(UnsupportedOperationException.class,()->s.lock(r,com.codename1.orm.session.LockMode.PESSIMISTIC_WRITE)); + s.rollbackTransaction();s.close(); + Models.register(new Model() { public String table() { return "missing_record_table"; } }); + Session missing=em.openSession();assertThrows(PersistenceException.class,missing::validateSchema);missing.close(); + } finally { em.close(); } + } + + @Test void jpqlQueriesBindValuesProjectGroupAndExecuteBulkDml() throws Exception { + EntityManager em=manager(); + try { + Session s=em.openSession();Record r=seed(s); + assertSame(r,s.createQuery("select r from Record r where r.name = :name and (r.counter = 0 or r.counter > 3)",Record.class).setParameter("name","first").first()); + assertEquals(Long.valueOf(1),s.createQuery("select count(r) from Record r",Long.class).first()); + com.codename1.orm.session.JpqlQuery selection=s.createQuery("select r from Record r where r.name in :names and r.counter < 1e2",Record.class); + assertSame(r,selection.setParameter("names",java.util.Arrays.asList("first","second")).first()); + assertNull(selection.setParameter("names",java.util.Collections.emptyList()).first()); + assertSame(r,selection.setParameter("names",new String[]{"first"}).first()); + assertNull(s.createQuery("select r from Record r where r.name = :name",Record.class).setParameter("name","' OR 1=1 --").first()); + Object[] projection=s.createQuery("select r.name, sum(r.counter) from Record r group by r.name having count(r) > 0 order by r.name",Object[].class).first(); + assertEquals("first",projection[0]);assertEquals(0,((Number)projection[1]).longValue()); + assertArrayEquals(new Object[]{"first","first"},s.createQuery("select r.name,r.name from Record r",Object[].class).first()); + assertEquals(Long.valueOf(1),s.createQuery("select count(r) from Record r where exists (select x.id from Record x where x.id = r.id)",Long.class).first()); + assertThrows(IllegalArgumentException.class,()->s.createQuery("select r from Record r where r.id = :id",Record.class).list()); + assertThrows(IllegalArgumentException.class,()->s.createQuery("select r from Record r; delete from Record",Record.class)); + assertThrows(IllegalArgumentException.class,()->s.createQuery("select unsupported(r.name) from Record r",String.class)); + s.beginTransaction();r.name="flushed"; + assertEquals(1,s.createQuery("update Record r set r.counter = r.counter + :delta where r.name = :name").setParameter("delta",4L).setParameter("name","flushed").executeUpdate()); + assertFalse(s.contains(r));s.commitTransaction();assertEquals(4,s.find(Record.class,r.id).counter); + s.beginTransaction();assertEquals(1,s.createQuery("delete from Record r where r.counter between 3 and 5").executeUpdate());s.commitTransaction(); + assertEquals(0,s.query(Record.class).count());s.close(); + } finally { em.close(); } + } + + @Test void counterOverflowAndCrossSessionTransactionReadsAreRejected() throws Exception { + EntityManager em=manager(); + try { + Session s=em.openSession(),other=em.openSession();Record r=seed(s); + s.beginTransaction();r.counter=Long.MAX_VALUE;s.commitTransaction(); + s.beginTransaction();assertFalse(s.increment(Record.class,r.id,"counter",1)); + assertEquals(Long.MAX_VALUE,r.counter); + assertThrows(PersistenceException.class,()->other.query(Record.class).count()); + s.rollbackTransaction();s.close();other.close(); + } finally { em.close(); } + } + + @Test void portableTableAndUuidIdentifiersAreGenerated() throws Exception { + EntityManager em=manager(); + try { + Models.register(new Model() { + public int generation() { return 3; } + public String generator() { return "managed_record_ids"; } + public Attribute[] attributes() { + Attribute[] attrs=super.attributes().clone(); + attrs[0]=new Attribute("id","id",Attribute.BIGINT,true,false,false,false);return attrs; + } + }); + Session s=em.openSession();s.createTables();s.beginTransaction();Record a=new Record(),b=new Record();s.persist(a);s.persist(b); + assertEquals(1,a.id);assertEquals(2,b.id);s.commitTransaction();s.close(); + Models.register(new EntityModel() { + public Class type() { return Token.class; } + public String table() { return "managed_token"; } + public Attribute[] attributes() { return new Attribute[]{new Attribute("id","id",Attribute.TEXT,true,false,false,false)}; } + public Token create() { return new Token(); } + public Object get(Token token,int index) { return token.id; } + public void set(Token token,int index,Object value) { token.id=(String)value; } + public int generation() { return 1; } + }); + s=em.openSession();s.createTables();s.beginTransaction();Token one=new Token(),two=new Token();s.persist(one);s.persist(two);s.commitTransaction(); + assertNotEquals(one.id,two.id);assertTrue(one.id.matches("[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"));s.close(); + } finally { em.close(); } + } + static class Token { String id; } + + @Test void identityDirtyCheckingMutableValuesAndVersion() throws Exception { + EntityManager em=manager(); + try { + Session s=em.openSession();Record r=seed(s); + assertSame(r,s.find(Record.class,r.id)); + s.beginTransaction();r.name="changed";r.bytes[0]=2;s.commitTransaction();assertEquals(1,r.version); + s.clear();Record read=s.find(Record.class,r.id); + assertEquals("changed",read.name);assertArrayEquals(new byte[]{2},read.bytes); + assertSame(read,s.query(Record.class).eq("name","changed").first());s.close(); + } finally { em.close(); } + } + @Test void staleUpdateAndDeleteFailWithoutLosingCommittedChanges() throws Exception { + EntityManager em=manager(); + try { + Session a=em.openSession(),b=em.openSession();Record current=seed(a),stale=b.find(Record.class,current.id); + a.beginTransaction();current.name="winner";a.commitTransaction(); + b.beginTransaction();stale.name="loser"; + assertThrows(OptimisticLockException.class,b::commitTransaction);assertTrue(b.isRollbackOnly());b.rollbackTransaction(); + assertFalse(b.contains(stale));assertEquals("winner",b.find(Record.class,current.id).name); + b.beginTransaction();b.remove(b.find(Record.class,current.id));b.commitTransaction(); + a.beginTransaction();a.remove(current);assertThrows(OptimisticLockException.class,a::commitTransaction);a.rollbackTransaction(); + a.close();b.close(); + } finally { em.close(); } + } + @Test void rollbackCloseAndCounterHaveDefinedState() throws Exception { + EntityManager em=manager(); + try { + Session s=em.openSession();Record r=seed(s); + assertThrows(PersistenceException.class,()->s.persist(new Record())); + s.beginTransaction();assertTrue(s.increment(Record.class,r.id,"counter",3));assertEquals(3,r.counter);assertEquals(1,r.version);s.commitTransaction(); + s.beginTransaction();r.name="rolled back";s.flush();s.close(); + Session fresh=em.openSession();assertEquals("first",fresh.find(Record.class,r.id).name); + assertEquals(3,fresh.find(Record.class,r.id).counter); + assertThrows(PersistenceException.class,()->s.find(Record.class,r.id));fresh.close(); + } finally { em.close(); } + } + @Test void mergeReturnsManagedCopyAndDetectsStaleVersion() throws Exception { + EntityManager em=manager(); + try { + Session s=em.openSession();Record r=seed(s);s.detach(r);r.name="merged"; + s.beginTransaction();Record merged=s.merge(r);assertNotSame(r,merged);assertSame(merged,s.find(Record.class,r.id));s.commitTransaction(); + s.beginTransaction();assertThrows(OptimisticLockException.class,()->s.merge(r));s.rollbackTransaction();s.close(); + } finally { em.close(); } + } + @Test void identifierMutationCannotRedirectRefreshOrDeletion() throws Exception { + EntityManager em=manager(); + try { + Session s=em.openSession();Record a=seed(s);long aId=a.id; + s.beginTransaction();Record b=new Record();b.name="second";s.persist(b);s.commitTransaction();long bId=b.id; + a.id=bId;s.detach(a);assertNotSame(a,s.find(Record.class,aId));assertSame(b,s.find(Record.class,bId)); + s.beginTransaction();s.remove(b);b.id=aId;assertThrows(PersistenceException.class,s::commitTransaction);s.rollbackTransaction(); + assertEquals(2,s.query(Record.class).count());assertEquals("second",s.find(Record.class,bId).name); + Record changed=s.find(Record.class,aId);changed.id=bId;assertThrows(PersistenceException.class,()->s.refresh(changed)); + assertEquals("first",s.find(Record.class,aId).name);s.close(); + } finally { em.close(); } + } + + @Test void countsIgnorePaginationAndLimitsDoNotLeak() throws Exception { + EntityManager em=manager(); + try { + Session s=em.openSession();seed(s); + assertEquals(1,s.query(Record.class).limit(0).count());assertTrue(s.query(Record.class).limit(0).list().isEmpty()); + assertEquals(0,s.query(Record.class).in("id").count()); + assertThrows(IllegalArgumentException.class,()->s.query(Record.class).eq("id) OR 1=1 --",1)); + assertThrows(IllegalArgumentException.class,()->s.query(Record.class).limit(-1));s.close(); + } finally { em.close(); } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java index 7f6c74f457d..0e2a5f60f18 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/BackendPackageMojo.java @@ -298,6 +298,7 @@ private void generateControllers(File classes, File work) throws MojoExecutionEx } } processor.finish(ctx); + entities.enhance(ctx); } catch (ProcessingException err) { throw new MojoExecutionException("Could not process @RestController: " + err.getMessage(), err); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProcessAnnotationsMojo.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProcessAnnotationsMojo.java index 86db4675317..3a9c5be3977 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProcessAnnotationsMojo.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/ProcessAnnotationsMojo.java @@ -209,6 +209,13 @@ index, getLog(), getCN1ProjectDir(), rawProjectSettings(), mainClassBinaryName() } } + for (AnnotationProcessor processor : processors) { + if (processor instanceof com.codename1.maven.processors.OrmAnnotationProcessor) { + try { ((com.codename1.maven.processors.OrmAnnotationProcessor) processor).enhance(ctx); } + catch (ProcessingException error) { throw new MojoFailureException(error.getMessage(),error); } + } + } + if (!emitted.isEmpty()) { getLog().info("cn1: emitted " + emitted.size() + " generated class(es) under " + outputDirectory); 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 694c18d91a8..db2d52a46a9 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 @@ -115,6 +115,13 @@ public final class OrmAnnotationProcessor extends AbstractAnnotationProcessor { /// concludes "not a backend module" for the one build that most certainly /// is one. private Boolean forcedFlavour; + private Set dependencyOverlays = Collections.emptySet(); + + /** Runs after every processor has written its classes, including generated callers. */ + public void enhance(ProcessorContext ctx) throws ProcessingException { + try { OrmEnhancer.enhance(accepted,ctx); } + catch(IOException error) { throw new ProcessingException("ORM enhancement failed: "+error.getMessage(),error); } + } @Override public Set getAnnotationDescriptors() { @@ -124,6 +131,8 @@ public Set getAnnotationDescriptors() { @Override public void start(ProcessorContext ctx) throws ProcessingException { accepted.clear(); + try { dependencyOverlays=OrmEnhancer.prepare(ctx); } + catch(IOException error) { throw new ProcessingException("Could not refresh enhanced dependencies",error); } if (forcedFlavour != null) { backend = forcedFlavour.booleanValue(); return; @@ -264,6 +273,7 @@ public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws Proces private void processClass(AnnotatedClass cls, ProcessorContext ctx, boolean fromThisModule) throws ProcessingException { if (cls.isSynthetic()) return; + if (fromThisModule && dependencyOverlays.contains(cls.getBinaryName())) return; AnnotationValues entityAnn = cls.getClassAnnotation(ENTITY_DESC); if (entityAnn == null) return; // A DELETED ENTITY LEAVES ITS CLASS FILE BEHIND. Maven does not clean @@ -287,7 +297,7 @@ private void processClass(AnnotatedClass cls, ProcessorContext ctx, boolean from cls, ctx.getCompileSourceRoots(), ctx.getSourceEncoding())) { return; } - if (cls.isAbstract() || cls.isInterface()) { + if (cls.isInterface()) { ctx.error(cls, "@Entity requires a concrete class; " + cls.getBinaryName() + " is abstract or an interface"); return; @@ -321,20 +331,22 @@ private void processClass(AnnotatedClass cls, ProcessorContext ctx, boolean from + "its own."); return; } - if (!hasPublicNoArgConstructor(cls)) { + if (!cls.isAbstract() && !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.binaryName = cls.getBinaryName();ec.abstractClass=cls.isAbstract();ec.parent=cls.getSuperInternalName(); ec.simpleName = simpleName(cls.getBinaryName()); ec.packageName = packageOf(cls.getBinaryName()); ec.daoSimpleName = ec.simpleName + (backend ? BACKEND_DAO_SUFFIX : "Cn1Dao"); ec.daoBinaryName = (ec.packageName.length() == 0) ? ec.daoSimpleName : ec.packageName + "." + ec.daoSimpleName; + Object indexes=entityAnn.get("indexes"); + if(indexes instanceof List) for(Object index:(List)indexes) ec.indexes.add((AnnotationValues)index); String table = entityAnn.getString("table"); ec.tableName = (table == null || table.length() == 0) ? ec.simpleName : table; if (backend && tooLongForAnEngine(ec.tableName)) { @@ -346,14 +358,53 @@ private void processClass(AnnotatedClass cls, ProcessorContext ctx, boolean from return; } - for (FieldInfo f : cls.getFields()) { + String[] events={"PrePersist","PostPersist","PreUpdate","PostUpdate","PreRemove","PostRemove","PostLoad"}; + for (MethodInfo method : persistentMethods(cls,ctx)) for (int event=0;event())) { + FieldInfo f=fieldPath.field; 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 + if (!f.isPublic()) { + for(String annotation:f.getAnnotations().keySet()) { + if(annotation.equals(ID_DESC) || annotation.equals(COLUMN_DESC) || annotation.equals("Lcom/codename1/annotations/Version;") + || annotation.equals("Lcom/codename1/annotations/OneToOne;") || annotation.equals("Lcom/codename1/annotations/OneToMany;") + || annotation.equals("Lcom/codename1/annotations/ManyToOne;") || annotation.equals("Lcom/codename1/annotations/ManyToMany;") + || annotation.matches("Lcom/codename1/annotations/(ElementCollection|Convert|GeneratedValue|JoinColumn|JoinTable|MapKey|MapKeyColumn|OrderBy|OrderColumn);")) + ctx.error(cls,"Persistent annotated fields currently require public access: "+f.getName()); + } + continue; + } + RelationField relationship = relation(f, ec, ctx); + if (relationship != null) { + if(fieldPath.prefix.length()>0) { ctx.error(cls,"Relationships inside embeddables are not supported: "+fieldPath.path);continue; } + relationship.declaringType=fieldPath.declaringType;relationship.index=ec.relations.size(); ec.relations.add(relationship); continue; } PersistedField pf = new PersistedField(); - pf.fieldName = f.getName(); + pf.fieldName = fieldPath.path;pf.declaringType=fieldPath.declaringType; + pf.embeddedParent = fieldPath.prefix; + pf.version = f.getAnnotation("Lcom/codename1/annotations/Version;") != null; pf.kind = PropertyTypeKind.of(f); + if(f.isFinal() && pf.kind.kind!=PropertyTypeKind.Kind.PROPERTY) ctx.error(cls,"Persistent fields must be writable: "+pf.fieldName); + AnnotationValues conversion=f.getAnnotation("Lcom/codename1/annotations/Convert;"); + if(conversion!=null) { + Object converter=conversion.get("converter"),storage=conversion.get("storageType"); + if(!(converter instanceof org.objectweb.asm.Type)) { ctx.error(cls,"Missing converter for "+pf.fieldName);continue; } + pf.converter=((org.objectweb.asm.Type)converter).getClassName();pf.domainType=org.objectweb.asm.Type.getType(f.getDescriptor()).getClassName(); + pf.kind=PropertyTypeKind.scalar(storage instanceof org.objectweb.asm.Type?((org.objectweb.asm.Type)storage).getClassName():"java.lang.String"); + AnnotatedClass converterClass=findType(pf.converter,ctx); + if(converterClass==null || !hasPublicNoArgConstructor(converterClass)) ctx.error(cls,"Converter requires a public no-arg constructor: "+pf.converter); + if(f.getAnnotation(ID_DESC)!=null || pf.version) ctx.error(cls,"Identifier and version fields cannot declare converters"); + } + AnnotatedClass enumClass = pf.kind.kind == PropertyTypeKind.Kind.REFERENCE ? findType(pf.kind.binaryName,ctx) : null; + if (pf.kind.kind == PropertyTypeKind.Kind.REFERENCE && enumClass != null && enumClass.isEnum()) + pf.kind = PropertyTypeKind.enumType(pf.kind.binaryName); if (pf.kind.kind == PropertyTypeKind.Kind.REFERENCE || pf.kind.kind == PropertyTypeKind.Kind.LIST || pf.kind.kind == PropertyTypeKind.Kind.LIST_PROPERTY) { @@ -378,6 +429,7 @@ private void processClass(AnnotatedClass cls, ProcessorContext ctx, boolean from continue; } AnnotationValues col = f.getAnnotation(COLUMN_DESC); + pf.unique=col!=null && col.getBoolOrDefault("unique",false); String colName = null; String colType = null; boolean nullable = true; @@ -386,7 +438,7 @@ private void processClass(AnnotatedClass cls, ProcessorContext ctx, boolean from colType = col.getString("type"); nullable = col.getBoolOrDefault("nullable", true); } - pf.columnName = (colName == null || colName.length() == 0) ? pf.fieldName : colName; + pf.columnName = (colName == null || colName.length() == 0) ? pf.fieldName.replace('.', '_') : fieldPath.prefix.replace('.', '_')+colName; pf.sqlType = (colType == null || colType.length() == 0) ? defaultSqlType(pf.kind) : colType; // The EXPLICIT type, separately: the client flavour needs a type for // every column and defaults it to SQLite's, while the backend one @@ -398,6 +450,16 @@ private void processClass(AnnotatedClass cls, ProcessorContext ctx, boolean from pf.dialectKind = dialectKind(pf.kind); pf.boxed = isBoxed(pf.kind); + if (pf.version) { + if (pf.kind.kind != PropertyTypeKind.Kind.INT && pf.kind.kind != PropertyTypeKind.Kind.LONG) { + ctx.error(cls, "@Version requires an int or long field: " + pf.fieldName); + } + if (f.getAnnotation(ID_DESC) != null) ctx.error(cls, "@Version cannot also be @Id: " + pf.fieldName); + for (PersistedField previous : ec.fields) { + if (previous.version) ctx.error(cls, "An entity can have only one @Version field"); + } + pf.nullable = false; + } if (backend && isJavaPrimitive(pf)) { // A PRIMITIVE CANNOT HOLD NULL, so the column it is stored in // must not allow one: a nullable column read into an int gave @@ -458,10 +520,23 @@ private void processClass(AnnotatedClass cls, ProcessorContext ctx, boolean from continue; } + if(fieldPath.prefix.length()>0 && (col==null || col.getBoolOrDefault("nullable",true))) pf.nullable=true; AnnotationValues idAnn = f.getAnnotation(ID_DESC); - if (idAnn != null) { + if (idAnn != null || (ec.embeddedId!=null && pf.fieldName.startsWith(ec.embeddedId+"."))) { pf.isId = true; - pf.autoIncrement = idAnn.getBoolOrDefault("autoIncrement", true); + pf.autoIncrement = idAnn!=null && idAnn.getBoolOrDefault("autoIncrement", true); + AnnotationValues generator=f.getAnnotation("Lcom/codename1/annotations/GeneratedValue;"); + if(generator!=null) { + Object strategy=generator.get("strategy"); + String name=strategy instanceof String[]?((String[])strategy)[1]:"IDENTITY"; + ec.generation="UUID".equals(name)?1:"SEQUENCE".equals(name)?2:"TABLE".equals(name)?3:0; + ec.generator=generator.getStringOrDefault("generator",""); + if(ec.generator.length()==0) ec.generator=ec.tableName+"_"+pf.columnName; + pf.autoIncrement=ec.generation==0; + if(ec.generation==1 && pf.kind.kind!=PropertyTypeKind.Kind.STRING) ctx.error(cls,"UUID generation requires a String identifier"); + if(ec.generation>1 && pf.kind.kind!=PropertyTypeKind.Kind.INT && pf.kind.kind!=PropertyTypeKind.Kind.LONG) + ctx.error(cls,"Sequence/table generation requires an int or long identifier"); + } if (backend && pf.autoIncrement && !isGeneratableKey(pf.kind)) { // A DATABASE COUNTS. Every other type fails somewhere the // build cannot see: a String key is refused by SQLite, whose @@ -508,20 +583,18 @@ private void processClass(AnnotatedClass cls, ProcessorContext ctx, boolean from + " is a byte[], which MySQL cannot make a primary key without " + "a prefix length. Use a String or an integer key."); } - if (ec.idField != null) { - ctx.error(cls, "@Entity " + ec.binaryName - + " has more than one @Id field"); - continue; - } - ec.idField = pf; + if(ec.idField==null) ec.idField=pf; + ec.idFields.add(pf);pf.nullable=false; } ec.fields.add(pf); } if (ec.idField == null) { - ctx.error(cls, "@Entity " + ec.binaryName + " requires exactly one @Id field"); + ctx.error(cls, "@Entity " + ec.binaryName + " requires at least one @Id field"); return; } + if(ec.idFields.size()>1) for(PersistedField field:ec.idFields) + if(field.autoIncrement || ec.generation!=0) ctx.error(cls,"Composite identifiers must be assigned; use @Id(autoIncrement=false)"); // TWO FIELDS, ONE COLUMN. Every statement then names the column twice: // the CREATE TABLE is refused for a duplicate column, and against a // schema somebody else created -- where the ORM creates nothing -- a read @@ -550,7 +623,7 @@ private void processClass(AnnotatedClass cls, ProcessorContext ctx, boolean from @Override public void finish(ProcessorContext ctx) throws ProcessingException { - if (backend) { + { // BEFORE the emptiness check, because the entities need not be in // this module at all. An entity is the one class both halves of an // application own, so the natural place for it is a module the app @@ -558,6 +631,47 @@ public void finish(ProcessorContext ctx) throws ProcessingException { // compiled classes hold none of them. scanClasspathEntities(ctx); } + resolveRelations(ctx); + resolveHierarchies(ctx); + for(EntityClass entity:accepted.values()) { + Set fields=new LinkedHashSet(),columns=new LinkedHashSet(),indexNames=new LinkedHashSet(); + for(PersistedField field:entity.fields) { + fields.add(field.fieldName); + if(!columns.add(field.columnName.toLowerCase(java.util.Locale.ROOT))) ctx.error("Duplicate column on "+entity.binaryName+": "+field.columnName); + } + for(AnnotationValues index:entity.indexes) { + String name=index.getStringOrDefault("name",""); + if(name.length()==0 || tooLongForAnEngine(name) || !indexNames.add(name)) ctx.error("Invalid or duplicate index name: "+name); + Object indexed=index.get("fields"); + if(!(indexed instanceof List) || ((List)indexed).isEmpty()) ctx.error("Index needs mapped fields: "+name); + else for(Object field:(List)indexed) if(!fields.contains(field)) ctx.error("Unknown index field: "+field); + } + } + Map schemaTables=new LinkedHashMap(),schemaIndexes=new LinkedHashMap(); + boolean tableGenerators=false; + for(EntityClass entity:accepted.values()) { + String owner=entity.hierarchyRoot==null?entity.binaryName:entity.hierarchyRoot; + claimSchemaName(schemaTables,entity.tableName,owner,ctx); + tableGenerators|=entity.generation>1; + for(PersistedField field:entity.fields) if(backend && tooLongForAnEngine(field.columnName)) ctx.error("Column name exceeds the portable limit: "+field.columnName); + for(AnnotationValues index:entity.indexes) claimSchemaName(schemaIndexes,index.getStringOrDefault("name",""),owner,ctx); + } + if(tableGenerators && schemaTables.containsKey("cn1_orm_sequences")) ctx.error("cn1_orm_sequences is reserved for identifier generation"); + for(EntityClass entity:accepted.values()) for(RelationField relation:entity.relations) if(relation.many && relation.mappedBy.length()==0) { + String owner=(entity.hierarchyRoot==null?entity.binaryName:entity.hierarchyRoot)+"."+relation.field; + claimSchemaName(schemaTables,relation.table,owner,ctx); + Set columns=new LinkedHashSet(); + List physical=new ArrayList(); + for(PersistedField key:entity.idFields) physical.add(relation.ownerColumn+(entity.idFields.size()==1?"":"_"+key.columnName)); + if(relation.element) { + physical.add(relation.targetColumn);physical.add(relation.orderColumn);if(relation.mapKey.length()>0) physical.add(relation.mapKey); + } else { + EntityClass target=accepted.get(relation.target); + if(target!=null) for(PersistedField key:target.idFields) physical.add(relation.targetColumn+(target.idFields.size()==1?"":"_"+key.columnName)); + if(relation.orderColumn.length()>0) physical.add(relation.orderColumn); + } + for(String column:physical) if(column.length()==0 || !columns.add(column.toLowerCase(java.util.Locale.ROOT)) || backend && tooLongForAnEngine(column)) ctx.error("Invalid or duplicate collection column: "+owner+"."+column); + } if (ctx.hasErrors()) return; if (accepted.isEmpty()) { removeAStaleBootstrap(ctx); @@ -571,7 +685,8 @@ public void finish(ProcessorContext ctx) throws ProcessingException { continue; } sources.put(ec.daoBinaryName, - backend ? generateBackendDaoSource(ec) : generateDaoSource(ec)); + ec.hierarchyRoot!=null?generateHierarchyRegistration(ec,backend):backend ? generateBackendDaoSource(ec) : generateDaoSource(ec)); + sources.put(ec.binaryName + (backend ? "Cn1BackendModel" : "Cn1Model"), generateModelSource(ec, backend)); } if (ctx.hasErrors()) return; // THE BOOTSTRAP IS NOT CHECKED, and the difference from the daos above @@ -601,6 +716,7 @@ public void finish(ProcessorContext ctx) throws ProcessingException { cp.add(new java.io.File(element)); } JavaSourceCompiler.compile(sources, ctx.getOutputClassDir(), cp); + OrmEnhancer.enhance(accepted, ctx); } catch (IOException ioe) { throw new ProcessingException("Could not compile generated dao sources: " + ioe.getMessage(), ioe); @@ -614,6 +730,471 @@ public void finish(ProcessorContext ctx) throws ProcessingException { // Source generation // --------------------------------------------------------------- + private AnnotatedClass findType(String binary,ProcessorContext ctx) { + String internal=binary.replace('.','/');AnnotatedClass found=ctx.lookup(internal); + if(found!=null && !dependencyOverlays.contains(binary)) return found; + for(String path:ctx.getCompileClasspath()) { + File file=new File(path); + try { + if(file.isDirectory()) { + File type=new File(file,internal+".class"); + if(type.isFile()) { + InputStream in=new java.io.FileInputStream(type); + try { return ClassScanner.readClass(in,type); } finally { in.close(); } + } + } else if(file.isFile()) { + ZipFile zip=new ZipFile(file); + try { ZipEntry entry=zip.getEntry(internal+".class");if(entry!=null) return readEntry(zip,entry,file,ctx); } + finally { zip.close(); } + } + } catch(IOException error) { ctx.error("Cannot inspect "+binary+": "+error.getMessage()); } + catch(ProcessingException error) { ctx.error("Cannot inspect "+binary+": "+error.getMessage()); } + } + return null; + } + + private void claimSchemaName(Map names,String name,String owner,ProcessorContext ctx) { + if(name.length()==0 || backend && tooLongForAnEngine(name)) { ctx.error("Invalid schema name: "+name);return; } + String previous=names.put(name.toLowerCase(java.util.Locale.ROOT),owner); + if(previous!=null && !previous.equals(owner)) ctx.error("Schema name '"+name+"' is shared by "+previous+" and "+owner); + } + + private List persistentMethods(AnnotatedClass cls,ProcessorContext ctx) { + List result=new ArrayList(); + String parent=cls.getSuperInternalName(); + if(parent!=null && !"java/lang/Object".equals(parent)) { + AnnotatedClass base=findType(parent.replace('/','.'),ctx); + if(base!=null && (base.getClassAnnotation("Lcom/codename1/annotations/MappedSuperclass;")!=null || base.getClassAnnotation(ENTITY_DESC)!=null)) + result.addAll(persistentMethods(base,ctx)); + } + Set events=new LinkedHashSet(); + for(MethodInfo method:cls.getMethods()) { + for(int i=result.size()-1;i>=0;i--) if(result.get(i).getName().equals(method.getName()) && result.get(i).getDescriptor().equals(method.getDescriptor())) result.remove(i); + for(String annotation:method.getAnnotations().keySet()) { + if(annotation.matches("Lcom/codename1/annotations/(PrePersist|PostPersist|PreUpdate|PostUpdate|PreRemove|PostRemove|PostLoad);")) + if(!events.add(annotation)) ctx.error(cls,"Duplicate lifecycle callback: "+annotation); + } + result.add(method); + } + return result; + } + + private List persistentFields(AnnotatedClass cls,EntityClass entity,ProcessorContext ctx, + String prefix,Set visiting) { + List result=new ArrayList(); + if(!visiting.add(cls.getBinaryName())) { ctx.error("Recursive embedded value: "+cls.getBinaryName());return result; } + String parent=cls.getSuperInternalName(); + if(parent!=null && !"java/lang/Object".equals(parent)) { + AnnotatedClass base=findType(parent.replace('/','.'),ctx); + if(base!=null && (base.getClassAnnotation("Lcom/codename1/annotations/MappedSuperclass;")!=null || base.getClassAnnotation(ENTITY_DESC)!=null)) + result.addAll(persistentFields(base,entity,ctx,prefix,visiting)); + } + for(FieldInfo field:cls.getFields()) { + if(field.isStatic() || field.getAnnotation(DB_TRANSIENT_DESC)!=null) continue; + String path=prefix+field.getName(); + if(field.getAnnotation("Lcom/codename1/annotations/Embedded;")!=null || field.getAnnotation("Lcom/codename1/annotations/EmbeddedId;")!=null) { + if(field.getAnnotation("Lcom/codename1/annotations/EmbeddedId;")!=null) { + if(entity.embeddedId!=null) ctx.error("Multiple embedded identifiers"); + entity.embeddedId=path; + } + if(!field.isPublic() || field.isFinal()) { ctx.error("Embedded field must be public and writable: "+path);continue; } + String type=org.objectweb.asm.Type.getType(field.getDescriptor()).getClassName(); + AnnotatedClass embedded=findType(type,ctx); + if(embedded==null || embedded.getClassAnnotation("Lcom/codename1/annotations/Embeddable;")==null + || !hasPublicNoArgConstructor(embedded)) { ctx.error("Embedded value requires @Embeddable and a public no-arg constructor: "+path);continue; } + entity.embedded.put(path,type); + for(FieldPath nested:persistentFields(embedded,entity,ctx,path+".",visiting)) result.add(new FieldPath(nested.field,nested.path,nested.prefix,cls.getBinaryName())); + } else result.add(new FieldPath(field,path,prefix,cls.getBinaryName())); + } + visiting.remove(cls.getBinaryName());return result; + } + + private RelationField relation(FieldInfo field, EntityClass owner, ProcessorContext ctx) { + String prefix="Lcom/codename1/annotations/"; + AnnotationValues annotation=null; String kind=null; + for(String candidate:new String[]{"ManyToOne","OneToOne","OneToMany","ManyToMany","ElementCollection"}) { + AnnotationValues value=field.getAnnotation(prefix+candidate+";"); + if(value!=null) { + if(annotation!=null) ctx.error("Multiple relationship annotations on "+owner.binaryName+"."+field.getName()); + annotation=value;kind=candidate; + } + } + if(annotation==null) return null; + if(field.isFinal()) ctx.error("Relationship fields must be writable: "+owner.binaryName+"."+field.getName()); + RelationField relation=new RelationField(); relation.field=field.getName();relation.descriptor=field.getDescriptor(); + relation.element="ElementCollection".equals(kind);relation.many=relation.element || kind.endsWith("ToMany");relation.unique="OneToOne".equals(kind) || "OneToMany".equals(kind);relation.kind=kind; + relation.mappedBy=annotation.getStringOrDefault("mappedBy",""); + AnnotationValues mapKey=field.getAnnotation(prefix+"MapKey;"),orderColumn=field.getAnnotation(prefix+"OrderColumn;"),orderBy=field.getAnnotation(prefix+"OrderBy;"); + relation.mapKey=mapKey==null?"":mapKey.getStringOrDefault("name",""); + relation.orderColumn=orderColumn==null?"":orderColumn.getStringOrDefault("name","list_position"); + if(orderColumn!=null && relation.orderColumn.length()==0) relation.orderColumn="list_position"; + relation.orderBy=orderBy==null?"":orderBy.getStringOrDefault("value",""); + boolean map="Ljava/util/Map;".equals(field.getDescriptor()); + if(relation.element) { + AnnotationValues keyColumn=field.getAnnotation(prefix+"MapKeyColumn;"); + if(map) { relation.mapKey=keyColumn==null?"map_key":keyColumn.getStringOrDefault("name","map_key");if(relation.mapKey.length()==0) relation.mapKey="map_key"; } + if(relation.orderColumn.length()==0) relation.orderColumn="element_position"; + if(relation.orderBy.length()>0 || mapKey!=null) ctx.error("Scalar elements use OrderColumn/MapKeyColumn: "+relation.field); + } + if(map && relation.mapKey.length()==0) ctx.error("Map relationships require @MapKey(name=...): "+relation.field); + if(!map && mapKey!=null) ctx.error("MapKey requires a Map field: "+relation.field); + if(orderColumn!=null && (!"Ljava/util/List;".equals(field.getDescriptor()) || relation.mappedBy.length()>0)) ctx.error("OrderColumn requires an owning List with a join table: "+relation.field); + if(orderColumn!=null && orderBy!=null) ctx.error("Choose OrderBy or OrderColumn: "+relation.field); + if(!relation.many && (mapKey!=null || orderColumn!=null || orderBy!=null)) ctx.error("Collection metadata requires a to-many relationship"); + relation.orphan=annotation.getBoolOrDefault("orphanRemoval",false); + if(("ManyToMany".equals(kind) || "ManyToOne".equals(kind)) && relation.orphan) + ctx.error("orphanRemoval requires OneToOne or OneToMany: "+owner.binaryName+"."+relation.field); + if("ManyToOne".equals(kind) && relation.mappedBy.length()>0) ctx.error("ManyToOne must own its join column"); + relation.lazy=relation.many; + Object fetch=annotation.get("fetch");if(fetch instanceof String[]) relation.lazy="LAZY".equals(((String[])fetch)[1]); + Object cascades=annotation.get("cascade"); + if(cascades instanceof List) for(Object cascade:(List)cascades) { + String op=((String[])cascade)[1]; + if("ALL".equals(op)) relation.cascade=31; + else if("PERSIST".equals(op)) relation.cascade|=1; + else if("MERGE".equals(op)) relation.cascade|=2; + else if("REMOVE".equals(op)) relation.cascade|=4; + else if("REFRESH".equals(op)) relation.cascade|=8; + else if("DETACH".equals(op)) relation.cascade|=16; + } + relation.target=org.objectweb.asm.Type.getType(field.getDescriptor()).getClassName(); + if(relation.many) { + String signature=field.getSignature(); + int start=signature==null?-1:signature.indexOf("0 && (join!=null || table!=null)) ctx.error("Inverse associations must not declare join columns or tables: "+relation.field); + if(relation.many && join!=null) ctx.error("Use mappedBy or JoinTable for a to-many association: "+relation.field); + if(!relation.many && table!=null) ctx.error("To-one associations use JoinColumn: "+relation.field); + if(relation.element) { + relation.targetColumn=annotation.getStringOrDefault("column","element_value"); + if(map && (field.getSignature()==null || !field.getSignature().contains("0) { + boolean found=false;for(PersistedField field:target.fields) if(field.fieldName.equals(relation.mapKey) && field.relation==null) found=true; + if(!found) ctx.error("MapKey must name a target basic field: "+relation.mapKey); + } + if(relation.orderBy.length()>0) for(String clause:relation.orderBy.split(",")) { + String[] parts=clause.trim().split("\\s+");boolean found=false; + for(PersistedField field:target.fields) if(field.fieldName.equals(parts[0])) found=true; + if(!found || parts.length>2 || parts.length==2 && !"ASC".equalsIgnoreCase(parts[1]) && !"DESC".equalsIgnoreCase(parts[1])) ctx.error("Invalid OrderBy: "+clause); + } + if(relation.mappedBy.length()>0) { + RelationField inverse=null; + for(RelationField candidate:target.relations) if(candidate.field.equals(relation.mappedBy)) inverse=candidate; + if(inverse==null || accepted.get(inverse.target)==null || !descends(owner,accepted.get(inverse.target)) || inverse.mappedBy.length()>0 + || !("OneToMany".equals(relation.kind)?"ManyToOne":relation.kind).equals(inverse.kind)) + ctx.error("Invalid mappedBy for "+owner.binaryName+"."+relation.field); + } else if(!relation.many) { + relation.column=owner.fields.size(); + for(int part=0;part family=new ArrayList();family.add(root); + for(EntityClass child:accepted.values()) if(child!=root && descends(child,root)) family.add(child); + Map fields=new LinkedHashMap();Map relations=new LinkedHashMap(); + Set values=new LinkedHashSet();List inheritedIndexes=new ArrayList(); + for(EntityClass member:family) { + inheritedIndexes.addAll(member.indexes);member.hierarchyRoot=root.binaryName;member.tableName=root.tableName; + AnnotationValues tag=findType(member.binaryName,ctx).getClassAnnotation("Lcom/codename1/annotations/DiscriminatorValue;"); + member.discriminatorValue=tag==null?member.simpleName:tag.getStringOrDefault("value",member.simpleName); + if(!member.discriminatorValue.matches("[A-Za-z0-9_$]{1,63}") || !values.add(member.discriminatorValue)) ctx.error("Invalid or duplicate discriminator: "+member.discriminatorValue); + for(PersistedField field:member.fields) { + PersistedField prior=fields.get(field.fieldName); + if(prior==null) { + if(member!=root && !field.isId && !field.version) field.nullable=true; + fields.put(field.fieldName,field); + } else if(!prior.declaringType.equals(field.declaringType)) ctx.error("Hidden inherited persistent field: "+member.binaryName+"."+field.fieldName); + } + for(RelationField relation:member.relations) if(!relations.containsKey(relation.field)) relations.put(relation.field,relation); + } + PersistedField tag=new PersistedField();tag.fieldName="__cn1_discriminator";tag.columnName=discriminator; + tag.kind=PropertyTypeKind.scalar("java.lang.String");tag.dialectKind=KIND_TEXT;tag.sqlType="TEXT";tag.discriminator=true; + fields.put(tag.fieldName,tag); + List all=new ArrayList(fields.values()); + int index=0;for(RelationField relation:relations.values()) { + relation.index=index++;relation.column=-1; + for(int i=0;i=0) { + condition.append("e.").append(path.substring(0,dot)).append(" == null || "); + dot=path.indexOf('.',dot+1); + } + return condition.append("e.").append(path).append(" == null").toString(); + } + + private static String generateModelSource(EntityClass ec, boolean backend) { + String pkg = "com.codename1.orm.session."; + String simple = ec.simpleName + (backend ? "Cn1BackendModel" : "Cn1Model"); + StringBuilder sb = new StringBuilder(); + if (!ec.packageName.isEmpty()) sb.append("package ").append(ec.packageName).append(";\n"); + sb.append("public final class ").append(simple).append(" extends ").append(pkg) + .append("EntityModel<").append(ec.binaryName).append("> {\n") + .append(" private static final ").append(pkg).append("Attribute[] ATTRS = {\n"); + for (int i = 0; i < ec.fields.size(); i++) { + PersistedField f = ec.fields.get(i); + int kind = f.dialectKind; + if (f.kind.kind == PropertyTypeKind.Kind.PROPERTY) { + String type=f.kind.elementBinaryName; + kind="java.lang.String".equals(type)?KIND_TEXT: + "java.lang.Double".equals(type) || "java.lang.Float".equals(type)?KIND_REAL: + "java.lang.Integer".equals(type) || "java.lang.Short".equals(type) || "java.lang.Byte".equals(type) || "java.lang.Character".equals(type)?KIND_INTEGER:KIND_BIGINT; + } + sb.append(" new ").append(pkg).append("Attribute(\"").append(escape(f.fieldName)) + .append("\", \"").append(escape(f.columnName)).append("\", ").append(kind) + .append(", ").append(f.isId).append(", ").append(f.isId && f.autoIncrement) + .append(", ").append(f.nullable).append(", ").append(f.version).append(", ") + .append(f.explicitSqlType==null?"null":"\""+escape(f.explicitSqlType)+"\"").append(")") + .append(i + 1 < ec.fields.size() ? ",\n" : "\n"); + } + sb.append(" };\n public Class<").append(ec.binaryName).append("> type() { return ") + .append(ec.binaryName).append(".class; }\n") + .append(" public String table() { return \"").append(escape(ec.tableName)).append("\"; }\n") + .append(" public ").append(pkg).append("Attribute[] attributes() { return ATTRS.clone(); }\n") + .append(" public ").append(ec.binaryName).append(" create() { ").append(ec.abstractClass?"throw new IllegalStateException(\"Abstract entity\");":"return new "+ec.binaryName+"();").append(" }\n") + .append(" public Object get(").append(ec.binaryName).append(" e, int index) {\n switch(index) {\n"); + for (int i = 0; i < ec.fields.size(); i++) { + PersistedField f = ec.fields.get(i); + int accessStart=sb.length(); + if(f.discriminator) { sb.append("case ").append(i).append(": return \"").append(escape(ec.discriminatorValue)).append("\";\n");continue; } + sb.append(" case ").append(i).append(": return "); + if(f.embeddedParent!=null && f.embeddedParent.length()>0) { + String parent=f.embeddedParent.substring(0,f.embeddedParent.length()-1); + sb.append(embeddedNullCondition(parent)).append(" ? null : "); + } + if (f.kind.kind == PropertyTypeKind.Kind.PROPERTY) { + sb.append(pkg).append("Values.storage(e.").append(f.fieldName).append(".get())"); + } + else emitBackendRead(sb, f); + sb.append(";\n"); + if(ec.hierarchyRoot!=null) { + String access=sb.substring(accessStart).replaceAll("(? embedded:ec.embedded.entrySet()) { + String path=embedded.getKey();int lastDot=path.lastIndexOf('.'); + if(lastDot>=0) sb.append("if(!(").append(embeddedNullCondition(path.substring(0,lastDot))).append(")) {\n"); + sb.append("e.").append(path).append(" = (");boolean first=true; + for(int i=0;i=0) sb.append("}\n"); + } + for(int i=0;i0 && !ec.embedded.containsKey(field.embeddedParent.substring(0,field.embeddedParent.length()-1))) continue; + if(field.embeddedParent!=null && field.embeddedParent.length()>0) + sb.append("if(!(").append(embeddedNullCondition(field.embeddedParent.substring(0,field.embeddedParent.length()-1))).append(")) "); + sb.append("set(e,").append(i).append(",values[").append(i).append("]);\n"); + } + sb.append("}\n"); + } + sb.append("public ").append(pkg).append("Index[] indexes() { return new ").append(pkg).append("Index[]{"); + for(AnnotationValues index:ec.indexes) { + sb.append("new ").append(pkg).append("Index(\"").append(escape(index.getStringOrDefault("name",""))).append("\", ") + .append(index.getBoolOrDefault("unique",false)); + Object fields=index.get("fields");if(fields instanceof List) for(Object field:(List)fields) sb.append(",\"").append(escape((String)field)).append("\""); + sb.append("),"); + } + for(PersistedField field:ec.fields) if(field.unique) + sb.append("new ").append(pkg).append("Index(\"\",true,\"").append(escape(field.fieldName)).append("\"),"); + sb.append("};}\n"); + boolean converters=false;for(PersistedField field:ec.fields) if(field.converter!=null) converters=true; + if(converters) { + if(ec.embedded.isEmpty()) sb.append("public boolean requiresSession() { return true; }\n"); + sb.append("public Object parameter(int index,Object value) { switch(index) {\n"); + for(int i=0;i0) sb.append(',');sb.append("value.").append(ec.idFields.get(i).fieldName.substring(ec.embeddedId.length()+1)); + } + sb.append("}); } return super.keyValues(key); }\n"); + } + if(ec.generation!=0) sb.append("public int generation() { return ").append(ec.generation) + .append("; }\npublic String generator() { return ").append('"').append(escape(ec.generator)).append("\"; }\n"); + if(ec.hierarchyRoot!=null) { + sb.append("public Class hierarchyRoot() { return ").append(ec.hierarchyRoot).append(".class; }\n") + .append("public int discriminatorIndex() { return ").append(ec.fields.size()-1).append("; }\n") + .append("public String discriminatorValue() { return \"").append(escape(ec.discriminatorValue)).append("\"; }\n") + .append("public String[] discriminatorValues() { return new String[]{"); + for(String value:ec.discriminators) sb.append('"').append(escape(value)).append("\","); + sb.append("}; }\npublic boolean hasRelationship(").append(ec.binaryName).append(" e,int index) { switch(index) {"); + for(RelationField relation:ec.relations) sb.append("case ").append(relation.index).append(": return (Object)e instanceof ").append(relation.declaringType).append(';'); + sb.append("default:return false;} }\n"); + } + sb.append(" public void lifecycle(").append(ec.binaryName).append(" e,int event) { switch(event) {\n"); + for (int event=0;event 0) { @@ -628,7 +1209,12 @@ private static String generateDaoSource(EntityClass ec) { // 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(" com.codename1.orm.session.Models.register(new ").append(ec.simpleName) + .append(ec.daoSimpleName.endsWith(BACKEND_DAO_SUFFIX) ? "Cn1BackendModel" : "Cn1Model").append("());\n"); + sb.append(" com.codename1.orm.EntityManager.registerDaoFactory(").append(ec.binaryName) + .append(".class, new com.codename1.orm.DaoFactory<").append(ec.binaryName).append(">() {\n") + .append(" public com.codename1.orm.Dao<").append(ec.binaryName).append("> create() { return new ") + .append(ec.daoSimpleName).append("(); }\n });\n"); sb.append(" }\n\n"); sb.append(" public ").append(ec.daoSimpleName).append("() {\n"); @@ -1167,6 +1753,8 @@ private static String generateBackendDaoSource(EntityClass ec) { // The hook the generated bootstrap calls. Same name and same shape as // the client flavour's, so one bootstrap source serves both. sb.append(" public static void register() {\n"); + sb.append(" com.codename1.orm.session.Models.register(new ").append(ec.simpleName) + .append(ec.daoSimpleName.endsWith(BACKEND_DAO_SUFFIX) ? "Cn1BackendModel" : "Cn1Model").append("());\n"); sb.append(" ").append(ORM).append("EntityManager.register(new ") .append(ec.daoSimpleName).append("());\n"); sb.append(" }\n\n"); @@ -1222,8 +1810,19 @@ private static String generateBackendDaoSource(EntityClass ec) { /// A field as a bound parameter: Long, Double, String, byte[] or null, /// which is the set `Database` binds and returns on every engine. private static void emitBackendRead(StringBuilder sb, PersistedField f) { - String field = "e." + f.fieldName; + String field = "e." + (f.relation==null?f.fieldName:f.relation.field); + if (f.relation != null) { + sb.append("com.codename1.orm.session.Models.foreignKey(e, ").append(f.relation.index) + .append(", ").append(field).append(", ").append(f.relationPart).append(")"); + return; + } + if(f.converter!=null) { + sb.append("com.codename1.orm.session.Values.storage(new ").append(f.converter).append("().toDatabase(").append(field).append("))");return; + } switch (f.kind.kind) { + case ENUM: + sb.append(field).append(" == null ? null : ").append(field).append(".name()"); + return; case STRING: case BYTE_ARRAY: sb.append(field); @@ -1365,9 +1964,17 @@ private static String required(PersistedField f) { /// `Values`: the same column is a Long from one engine and exact text from /// another, and neither is the field's type. private static void emitBackendWrite(StringBuilder sb, PersistedField f) { - String field = "e." + f.fieldName; + if(f.converter!=null) { + String conversion=storageConversion(f.kind.binaryName,"value"); + sb.append("e.").append(f.fieldName).append(" = new ").append(f.converter).append("().fromDatabase(").append(conversion).append(");");return; + } + String field = "e." + (f.relation==null?f.fieldName:f.relation.field); String values = ORM + "Values."; + if (f.relation != null) return; switch (f.kind.kind) { + case ENUM: + sb.append(field).append(" = value == null ? null : ").append(f.kind.binaryName).append(".valueOf(").append(values).append("asString(value));"); + return; case STRING: sb.append(field).append(" = ").append(values).append("asString(value);"); return; @@ -1517,7 +2124,19 @@ private static String defaultSqlTypeForBinary(String binary) { } private static void emitFieldRead(StringBuilder sb, PersistedField f, String inst) { + if(f.converter!=null) { + sb.append("com.codename1.orm.session.Values.storage(new ").append(f.converter).append("().toDatabase(").append(inst).append('.').append(f.fieldName).append("))");return; + } + if (f.relation != null) { + sb.append("com.codename1.orm.session.Models.foreignKey(").append(inst).append(", ") + .append(f.relation.index).append(", ").append(inst).append('.').append(f.relation.field).append(", ").append(f.relationPart).append(")"); + return; + } switch (f.kind.kind) { + case ENUM: + sb.append(inst).append('.').append(f.fieldName).append(" == null ? null : ") + .append(inst).append('.').append(f.fieldName).append(".name()"); + return; case STRING: case BYTE_ARRAY: // Strings go through Database#execute(String, Object...) as // String params; byte[] is passed through unchanged for the @@ -1574,7 +2193,17 @@ private static void emitPropertyRead(StringBuilder sb, PersistedField f, String private static void emitFieldWrite(StringBuilder sb, PersistedField f, String inst, String row, String idx) { + if(f.converter!=null) { + String read=row+(f.kind.kind==PropertyTypeKind.Kind.BYTE_ARRAY?".getBlob(":".getString(")+idx+")"; + sb.append(inst).append('.').append(f.fieldName).append(" = new ").append(f.converter).append("().fromDatabase(").append(storageConversion(f.kind.binaryName,read)).append(");\n");return; + } + if (f.relation != null) return; switch (f.kind.kind) { + case ENUM: + sb.append(" String _enum = ").append(row).append(".getString(").append(idx).append(");\n") + .append(" ").append(inst).append('.').append(f.fieldName).append(" = _enum == null ? null : ") + .append(f.kind.binaryName).append(".valueOf(_enum);\n"); + return; case STRING: sb.append(" ").append(inst).append('.').append(f.fieldName) .append(" = ").append(row).append(".getString(").append(idx).append(");\n"); @@ -1638,6 +2267,8 @@ private static void emitPropertyWrite(StringBuilder sb, PersistedField f, String 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.Character".equals(elem)) { + sb.append(inst).append('.').append(f.fieldName).append(".set(com.codename1.orm.session.Values.asCodeUnitObject(").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"); @@ -1733,24 +2364,54 @@ private static String escape(String s) { // Accumulator types // --------------------------------------------------------------- + static final class FieldPath { + final FieldInfo field;final String path,prefix,declaringType; + FieldPath(FieldInfo field,String path,String prefix,String declaringType) { this.field=field;this.path=path;this.prefix=prefix;this.declaringType=declaringType; } + } + + static final class RelationField { + String field, descriptor, target, mappedBy, columnName, table, ownerColumn, targetColumn,kind,declaringType; + String mapKey="",orderColumn="",orderBy=""; + boolean many,lazy,orphan,nullable,unique,element; + int cascade,index,column=-1; + } + static final class EntityClass { String binaryName; + String parent,hierarchyRoot,discriminatorValue; + boolean abstractClass; + final List discriminators=new ArrayList(); String packageName; String simpleName; String daoBinaryName; String daoSimpleName; String tableName; + int generation; + String generator; PersistedField idField; + String embeddedId; + final List idFields=new ArrayList(); + final Map embedded=new LinkedHashMap(); + final String[] callbacks=new String[7]; + final List indexes=new ArrayList(); + final List relations = new ArrayList(); final List fields = new ArrayList(); } static final class PersistedField { String fieldName; + String embeddedParent; + String converter,domainType,declaringType; + boolean discriminator; String columnName; String sqlType; boolean nullable; boolean isId; boolean autoIncrement; + boolean version; + boolean unique; + RelationField relation; + int relationPart; PropertyTypeKind kind; /// @Column(type) as the developer wrote it, or null when absent. String explicitSqlType; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/OrmEnhancer.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/OrmEnhancer.java new file mode 100644 index 00000000000..13ae96bd8ab --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/maven/processors/OrmEnhancer.java @@ -0,0 +1,212 @@ +/* + * 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.ProcessorContext; +import org.objectweb.asm.*; +import java.io.*; +import java.nio.file.Files; +import java.util.*; +import java.util.zip.*; + +/** Enhances entity state and field access without reflection or runtime proxies. */ +final class OrmEnhancer { + private static final String MANAGED="com/codename1/orm/session/ManagedEntity"; + private static final String STATE="com/codename1/orm/session/EntityState"; + private static final String STATE_DESC="L"+STATE+";"; + private static final String MANIFEST="META-INF/cn1/orm-enhanced-dependencies.list"; + private OrmEnhancer() {} + static Set prepare(ProcessorContext ctx) throws IOException { + File manifest=new File(ctx.getOutputClassDir(),MANIFEST); + Set removed=new HashSet(); + if(!manifest.isFile()) return removed; + for(Map.Entry entry:readManifest(manifest).entrySet()) { + String name=entry.getKey();File output=new File(ctx.getOutputClassDir(),name); + // A compiler may have replaced an old dependency overlay with an app class. + // Only remove the exact bytes this enhancer previously copied. + if(output.isFile() && digest(Files.readAllBytes(output.toPath())).equals(entry.getValue())) { + Files.delete(output.toPath()); + removed.add(name.substring(0,name.length()-6).replace('/','.')); + } + } + Files.delete(manifest.toPath());return removed; + } + static void enhance(Map entities,ProcessorContext ctx) throws IOException { + final Map owners=new HashMap(); + for(OrmAnnotationProcessor.EntityClass entity:entities.values()) if(!entity.relations.isEmpty()) owners.put(entity.binaryName.replace('.','/'),entity); + // Accessors declared by mapped superclasses must use the same state as their entities. + for(OrmAnnotationProcessor.EntityClass entity:entities.values()) for(OrmAnnotationProcessor.RelationField relation:entity.relations) { + String declaring=relation.declaringType.replace('.','/'); + if(entities.containsKey(relation.declaringType)) continue; + OrmAnnotationProcessor.EntityClass owner=owners.get(declaring); + if(owner==null) { owner=new OrmAnnotationProcessor.EntityClass();owner.binaryName=relation.declaringType;owners.put(declaring,owner); } + boolean found=false; + for(OrmAnnotationProcessor.RelationField existing:owner.relations) if(existing.field.equals(relation.field)) { + if(existing.index!=relation.index) throw new IOException("Inherited association has incompatible state layouts: "+relation.declaringType+"."+relation.field); + found=true; + } + if(!found) owner.relations.add(relation); + } + if(owners.isEmpty()) return; + Map classes=new LinkedHashMap(); + collect(ctx.getOutputClassDir(),ctx.getOutputClassDir(),classes); + Set own=new HashSet(classes.keySet()),copied=new TreeSet(); + File manifest=new File(ctx.getOutputClassDir(),MANIFEST); + if(manifest.isFile()) copied.addAll(readManifest(manifest).keySet()); + own.removeAll(copied); + for(String path:ctx.getCompileClasspath()) { + File file=new File(path); + if(file.isDirectory()) collect(file,file,classes); + else if(file.isFile() && file.getName().endsWith(".jar")) { + ZipFile zip=new ZipFile(file); + try { + Enumeration entries=zip.entries(); + while(entries.hasMoreElements()) { + ZipEntry entry=entries.nextElement();String name=entry.getName(); + if(!candidate(name) || classes.containsKey(name)) continue; + InputStream in=zip.getInputStream(entry); + try { classes.put(name,read(in)); } finally { in.close(); } + } + } finally { zip.close(); } + } + } + for(Map.Entry entry:classes.entrySet()) { + String actual=new ClassReader(entry.getValue()).getClassName()+".class"; + if(!actual.equals(entry.getKey()) || actual.contains("..") || actual.startsWith("/")) throw new IOException("Invalid class entry: "+entry.getKey()); + byte[] result=transform(entry.getValue(),owners); + if(result!=null) { + File output=new File(ctx.getOutputClassDir(),entry.getKey()); + Files.createDirectories(output.toPath().getParent());Files.write(output.toPath(),result); + if(!own.contains(entry.getKey())) copied.add(entry.getKey()); + } + } + Files.createDirectories(manifest.toPath().getParent()); + List records=new ArrayList(); + for(String name:copied) records.add(name+"\t"+digest(Files.readAllBytes(new File(ctx.getOutputClassDir(),name).toPath()))); + Files.write(manifest.toPath(),records,java.nio.charset.StandardCharsets.UTF_8); + } + private static Map readManifest(File manifest) throws IOException { + Map entries=new LinkedHashMap(); + for(String line:Files.readAllLines(manifest.toPath(),java.nio.charset.StandardCharsets.UTF_8)) { + int separator=line.indexOf('\t'); + if(separator<0) throw new IOException("Invalid ORM enhancement manifest"); + String name=line.substring(0,separator),hash=line.substring(separator+1); + if(!candidate(name) || name.contains("..") || name.startsWith("/") || name.indexOf('\\')>=0 || !hash.matches("[0-9a-f]{64}")) throw new IOException("Invalid ORM enhancement manifest"); + entries.put(name,hash); + } + return entries; + } + private static String digest(byte[] bytes) throws IOException { + try { + byte[] hash=java.security.MessageDigest.getInstance("SHA-256").digest(bytes); + StringBuilder value=new StringBuilder(); + for(byte b:hash) { value.append(Character.forDigit((b>>>4)&15,16));value.append(Character.forDigit(b&15,16)); } + return value.toString(); + } catch(java.security.NoSuchAlgorithmException error) { throw new IOException("SHA-256 is unavailable",error); } + } + private static boolean candidate(String path) { + return path.endsWith(".class") && !path.startsWith("java/") && !path.startsWith("javax/") + && !path.startsWith("META-INF/"); + } + private static void collect(File root,File directory,Map classes) throws IOException { + File[] files=directory.listFiles();if(files==null) return; + for(File file:files) { + if(file.isDirectory()) collect(root,file,classes); + else { + String name=root.toPath().relativize(file.toPath()).toString().replace(File.separatorChar,'/'); + if(candidate(name) && !classes.containsKey(name)) classes.put(name,Files.readAllBytes(file.toPath())); + } + } + } + private static byte[] read(InputStream in) throws IOException { + ByteArrayOutputStream out=new ByteArrayOutputStream();byte[] buffer=new byte[8192];int count; + while((count=in.read(buffer))!=-1) out.write(buffer,0,count);return out.toByteArray(); + } + static byte[] transform(byte[] original,final Map owners) { + ClassReader reader=new ClassReader(original); + final String className=reader.getClassName(); + for(String owner:owners.keySet()) { + if(className.equals(owner+"Cn1Model") || className.equals(owner+"Cn1BackendModel") + || className.equals(owner+"Cn1Dao") || className.equals(owner+"Cn1BackendDao")) return null; + } + final boolean serializer=className.endsWith("Cn1Mapper"); + final OrmAnnotationProcessor.EntityClass entity=owners.get(className); + final boolean[] changed={false},already={false}; + final ClassWriter writer=new ClassWriter(reader,ClassWriter.COMPUTE_MAXS); + reader.accept(new ClassVisitor(Opcodes.ASM9,writer) { + @Override public void visit(int version,int access,String name,String signature,String superclass,String[] interfaces) { + List list=new ArrayList(Arrays.asList(interfaces));already[0]=list.contains(MANAGED); + if(entity!=null && !already[0]) { list.add(MANAGED);changed[0]=true; } + super.visit(version,access,name,signature,superclass,list.toArray(new String[0])); + } + @Override public MethodVisitor visitMethod(int access,String name,String descriptor,String signature,String[] exceptions) { + if(entity!=null && (name.startsWith("__cn1OrmRead_") || name.startsWith("__cn1OrmWrite_") || name.startsWith("__cn1OrmSerialize_"))) { changed[0]=true;return null; } + MethodVisitor method=super.visitMethod(access,name,descriptor,signature,exceptions); + if(name.startsWith("__cn1Orm")) return method; + return new MethodVisitor(Opcodes.ASM9,method) { + @Override public void visitFieldInsn(int opcode,String owner,String field,String desc) { + OrmAnnotationProcessor.EntityClass target=owners.get(owner); + if(target!=null && (opcode==Opcodes.GETFIELD || opcode==Opcodes.PUTFIELD)) { + for(OrmAnnotationProcessor.RelationField relation:target.relations) if(relation.field.equals(field)) { + String sig=opcode==Opcodes.GETFIELD?"(L"+owner+";)"+desc:"(L"+owner+";"+desc+")V"; + super.visitMethodInsn(Opcodes.INVOKESTATIC,owner,"__cn1Orm"+(opcode==Opcodes.GETFIELD?(serializer?"Serialize_":"Read_"):"Write_")+field,sig,false); + changed[0]=true;return; + } + } + super.visitFieldInsn(opcode,owner,field,desc); + } + }; + } + @Override public void visitEnd() { + if(entity!=null && !already[0]) { + writer.visitField(Opcodes.ACC_PRIVATE|Opcodes.ACC_TRANSIENT,"__cn1OrmState",STATE_DESC,null,null).visitEnd(); + MethodVisitor get=writer.visitMethod(Opcodes.ACC_PUBLIC,"__cn1OrmState","()"+STATE_DESC,null,null); + get.visitCode();get.visitVarInsn(Opcodes.ALOAD,0);get.visitFieldInsn(Opcodes.GETFIELD,className,"__cn1OrmState",STATE_DESC);get.visitInsn(Opcodes.ARETURN);get.visitMaxs(0,0);get.visitEnd(); + MethodVisitor set=writer.visitMethod(Opcodes.ACC_PUBLIC,"__cn1OrmState","("+STATE_DESC+")V",null,null); + set.visitCode();set.visitVarInsn(Opcodes.ALOAD,0);set.visitVarInsn(Opcodes.ALOAD,1);set.visitFieldInsn(Opcodes.PUTFIELD,className,"__cn1OrmState",STATE_DESC);set.visitInsn(Opcodes.RETURN);set.visitMaxs(0,0);set.visitEnd(); + } + if(entity!=null) { + changed[0]=true; + for(OrmAnnotationProcessor.RelationField relation:entity.relations) { + bridge(writer,className,relation,0);bridge(writer,className,relation,1);bridge(writer,className,relation,2); + } + } + super.visitEnd(); + } + },0); + return changed[0]?writer.toByteArray():null; + } + private static void bridge(ClassWriter writer,String owner,OrmAnnotationProcessor.RelationField relation,int operation) { + boolean write=operation==1; + String desc=relation.descriptor; + String signature=write?"(L"+owner+";"+desc+")V":"(L"+owner+";)"+desc; + MethodVisitor method=writer.visitMethod(Opcodes.ACC_PUBLIC|Opcodes.ACC_STATIC|Opcodes.ACC_SYNTHETIC, + "__cn1Orm"+(write?"Write_":operation==2?"Serialize_":"Read_")+relation.field,signature,null,null); + method.visitCode();method.visitVarInsn(Opcodes.ALOAD,0);method.visitLdcInsn(relation.index); + method.visitMethodInsn(Opcodes.INVOKESTATIC,STATE,write?"beforeWrite":operation==2?"beforeSerialization":"beforeRead","(L"+MANAGED+";I)V",false); + method.visitVarInsn(Opcodes.ALOAD,0); + if(write) method.visitVarInsn(Opcodes.ALOAD,1); + method.visitFieldInsn(write?Opcodes.PUTFIELD:Opcodes.GETFIELD,owner,relation.field,desc); + method.visitInsn(write?Opcodes.RETURN:Opcodes.ARETURN);method.visitMaxs(0,0);method.visitEnd(); + } +} 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 index 0701bbeea14..c72aabf0eac 100644 --- 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 @@ -71,6 +71,12 @@ public static PropertyTypeKind enumType(String binaryName) { return new PropertyTypeKind(Kind.ENUM, binaryName, null); } + public static PropertyTypeKind scalar(String binary) { + String[] types={"java.lang.String","java.lang.Integer","java.lang.Long","java.lang.Short","java.lang.Byte","java.lang.Character","java.lang.Double","java.lang.Float","java.lang.Boolean","java.util.Date","byte[]"}; + Kind[] kinds={Kind.STRING,Kind.INT,Kind.LONG,Kind.SHORT,Kind.BYTE,Kind.CHAR,Kind.DOUBLE,Kind.FLOAT,Kind.BOOLEAN,Kind.DATE,Kind.BYTE_ARRAY}; + for(int i=0;i sources=new java.util.LinkedHashMap(); + sources.put("overlay.Parent","package overlay; import com.codename1.annotations.*; @Entity public class Parent { @Id public long id; }"); + sources.put("overlay.Child","package overlay; import com.codename1.annotations.*; @Entity public class Child { @Id public long id; @ManyToOne(fetch=FetchType.LAZY) public Parent parent; }"); + sources.put("overlay.Reader","package overlay; public class Reader { public Parent read(Child child) { return child.parent; } }"); + JavaSourceCompiler.compile(sources,dependency,Arrays.asList(testClassesDir())); + List classpath=new ArrayList(backendClasspath());classpath.add(dependency.getAbsolutePath()); + ProcessorContext first=runProcessor(classes,classpath);assertFalse(first.getErrors().toString(),first.hasErrors()); + File copied=new File(classes,"overlay/Reader.class");assertTrue(copied.isFile()); + byte[] originalDependency=Files.readAllBytes(new File(dependency,"overlay/Reader.class").toPath()); + assertFalse(Arrays.equals(originalDependency,Files.readAllBytes(copied.toPath()))); + compileInto(classes,"overlay.Reader","package overlay; public class Reader { public String applicationCode() { return \"preserved\"; } }"); + byte[] applicationClass=Files.readAllBytes(copied.toPath()); + ProcessorContext second=runProcessor(classes,classpath);assertFalse(second.getErrors().toString(),second.hasErrors()); + org.junit.Assert.assertArrayEquals(applicationClass,Files.readAllBytes(copied.toPath())); + org.junit.Assert.assertArrayEquals(originalDependency,Files.readAllBytes(new File(dependency,"overlay/Reader.class").toPath())); + assertTrue(new File(classes,"overlay/Child.class").isFile()); + } + + @Test + public void clientPropertyScalarsAndCharactersUseManagedStorageConversions() throws Exception { + File classes=tmp.newFolder("property-scalars"); + Map sources=new java.util.LinkedHashMap(); + sources.put("propertymodel.Bean","package propertymodel; import com.codename1.annotations.*; import com.codename1.properties.*; @Entity(table=\"property_beans\") public class Bean implements PropertyBusinessObject { @Id public long id; @Version public long version; public char symbol; public final IntProperty counter=new IntProperty(\"counter\",0); public final CharProperty letter=new CharProperty(\"letter\",'Q'); private final PropertyIndex index=new PropertyIndex(this,\"Bean\",counter,letter); public PropertyIndex getPropertyIndex() { return index; } }"); + JavaSourceCompiler.compile(sources,classes,Arrays.asList(testClassesDir())); + ProcessorContext ctx=runProcessor(classes);assertFalse(ctx.getErrors().toString(),ctx.hasErrors()); + java.net.URLClassLoader loader=new java.net.URLClassLoader(new URL[]{classes.toURI().toURL()},getClass().getClassLoader());loader.loadClass("cn1app.DaoBootstrap").newInstance();Class type=loader.loadClass("propertymodel.Bean"); + Class.forName("org.sqlite.JDBC"); + com.codename1.orm.EntityManager em=com.codename1.orm.EntityManager.open(new com.codename1.impl.javase.SEDatabase(java.sql.DriverManager.getConnection("jdbc:sqlite::memory:"))); + com.codename1.orm.session.Session s=em.openSession(); + try { + s.createTables();s.beginTransaction();Object bean=type.newInstance();s.persist(bean);s.commitTransaction();Object id=type.getField("id").get(bean);s.clear(); + Object loaded=s.query(type).eq("letter",Character.valueOf('Q')).eq("symbol",Character.valueOf((char)0)).first();org.junit.Assert.assertNotNull(loaded); + s.beginTransaction();assertTrue(s.increment(type,id,"counter",3));s.commitTransaction(); + org.junit.Assert.assertEquals(Integer.valueOf(3),((com.codename1.properties.Property)type.getField("counter").get(loaded)).get()); + s.beginTransaction();((com.codename1.properties.Property)type.getField("letter").get(loaded)).set(Character.valueOf('Z'));s.commitTransaction();s.clear(); + org.junit.Assert.assertEquals(Character.valueOf('Z'),((com.codename1.properties.Property)type.getField("letter").get(s.find(type,id))).get()); + } finally { s.close();em.close();loader.close(); } + } + + @Test + public void scalarElementCollectionsSupportLazyReadsDirtyCheckingAndMembership() throws Exception { + File classes=tmp.newFolder("elements");Map sources=new java.util.LinkedHashMap(); + sources.put("elements.Profile","package elements; import com.codename1.annotations.*; @Entity(table=\"element_profiles\") public class Profile { @Id public long id; @Version public long version; @ElementCollection public java.util.List tags=new java.util.ArrayList(); @ElementCollection public java.util.Set flags=new java.util.LinkedHashSet(); @ElementCollection public java.util.Map dates=new java.util.LinkedHashMap(); }"); + sources.put("elements.Reader","package elements; public class Reader { public static java.util.List tags(Profile p) { return p.tags; } public static java.util.Set flags(Profile p) { return p.flags; } public static java.util.Map dates(Profile p) { return p.dates; } }"); + JavaSourceCompiler.compile(sources,classes,Arrays.asList(testClassesDir())); + ProcessorContext client=runProcessor(classes);assertFalse(client.getErrors().toString(),client.hasErrors()); + ProcessorContext ctx=runProcessor(classes,backendClasspath());assertFalse(ctx.getErrors().toString(),ctx.hasErrors()); + java.net.URLClassLoader loader=new java.net.URLClassLoader(new URL[]{classes.toURI().toURL()},getClass().getClassLoader());loader.loadClass("cn1app.BackendDaoBootstrap").newInstance();Class type=loader.loadClass("elements.Profile"),reader=loader.loadClass("elements.Reader"); + com.codename1.backend.orm.EntityManager em=com.codename1.backend.orm.EntityManager.open(com.codename1.backend.Database.open(":memory:"));com.codename1.orm.session.Session s=em.openSession(); + try { + s.createTables();s.beginTransaction();Object profile=type.newInstance();((List)type.getField("tags").get(profile)).addAll(Arrays.asList("a","b","a",null));((Set)type.getField("flags").get(profile)).add(7);((Map)type.getField("dates").get(profile)).put("start",new java.util.Date(1000));s.persist(profile);s.commitTransaction();Object id=type.getField("id").get(profile);s.clear(); + profile=s.find(type,id);assertFalse(s.isLoaded(profile,"tags"));org.junit.Assert.assertEquals(4,s.count(profile,"tags"));assertFalse(s.isLoaded(profile,"tags")); + org.junit.Assert.assertSame(profile,s.query(type).containsElement("tags","a").first());org.junit.Assert.assertEquals(1,s.query(type).containsElement("tags",null).count()); + org.junit.Assert.assertEquals(Arrays.asList("a","b","a",null),reader.getMethod("tags",type).invoke(null,profile));org.junit.Assert.assertEquals(java.util.Collections.singleton(7),reader.getMethod("flags",type).invoke(null,profile)); + Map dates=(Map)reader.getMethod("dates",type).invoke(null,profile);s.beginTransaction();((java.util.Date)dates.get("start")).setTime(2000);((List)reader.getMethod("tags",type).invoke(null,profile)).remove(0);s.commitTransaction();s.clear(); + profile=s.find(type,id);org.junit.Assert.assertEquals(Arrays.asList("b","a",null),reader.getMethod("tags",type).invoke(null,profile));org.junit.Assert.assertEquals(2000,((java.util.Date)((Map)reader.getMethod("dates",type).invoke(null,profile)).get("start")).getTime()); + s.detach(profile);((List)reader.getMethod("tags",type).invoke(null,profile)).add("merged");s.beginTransaction();profile=s.merge(profile);s.commitTransaction();org.junit.Assert.assertEquals(4,s.count(profile,"tags")); + s.beginTransaction();s.remove(profile);s.commitTransaction();org.junit.Assert.assertEquals(0,s.query(type).count()); + } finally { s.close();em.close();loader.close(); } + } + + @Test + public void orderedListsAndEntityMapsRoundTripWithoutLosingDuplicateLinks() throws Exception { + File classes=tmp.newFolder("collections");Map sources=new java.util.LinkedHashMap(); + sources.put("collections.Thing","package collections; import com.codename1.annotations.*; @Entity(table=\"collection_things\") public class Thing { @Id public long id; public String label; }"); + sources.put("collections.Box","package collections; import com.codename1.annotations.*; @Entity(table=\"collection_boxes\") public class Box { @Id public long id; @Version public long version; @ManyToMany(cascade=CascadeType.PERSIST) @OrderColumn public java.util.List sequence=new java.util.ArrayList(); @OneToMany(cascade=CascadeType.PERSIST) @MapKey(name=\"label\") public java.util.Map named=new java.util.LinkedHashMap(); }"); + sources.put("collections.Reader","package collections; public class Reader { public static java.util.List sequence(Box b) { return b.sequence; } public static java.util.Map named(Box b) { return b.named; } }"); + JavaSourceCompiler.compile(sources,classes,Arrays.asList(testClassesDir())); + ProcessorContext client=runProcessor(classes);assertFalse(client.getErrors().toString(),client.hasErrors()); + ProcessorContext ctx=runProcessor(classes,backendClasspath());assertFalse(ctx.getErrors().toString(),ctx.hasErrors()); + java.net.URLClassLoader loader=new java.net.URLClassLoader(new URL[]{classes.toURI().toURL()},getClass().getClassLoader());loader.loadClass("cn1app.BackendDaoBootstrap").newInstance(); + Class boxType=loader.loadClass("collections.Box"),thingType=loader.loadClass("collections.Thing"),reader=loader.loadClass("collections.Reader"); + com.codename1.backend.orm.EntityManager em=com.codename1.backend.orm.EntityManager.open(com.codename1.backend.Database.open(":memory:"));com.codename1.orm.session.Session s=em.openSession(); + try { + s.createTables();s.beginTransaction();Object box=boxType.newInstance(),a=thingType.newInstance(),b=thingType.newInstance();thingType.getField("label").set(a,"a");thingType.getField("label").set(b,"b"); + List sequence=(List)boxType.getField("sequence").get(box);sequence.add(a);sequence.add(b);sequence.add(a);Map named=(Map)boxType.getField("named").get(box);named.put("a",a);named.put("b",b); + s.persist(box);s.commitTransaction();Object id=boxType.getField("id").get(box);s.clear();box=s.find(boxType,id); + assertFalse(s.isLoaded(box,"sequence"));sequence=(List)reader.getMethod("sequence",boxType).invoke(null,box);org.junit.Assert.assertSame(sequence.get(0),sequence.get(2)); + named=(Map)reader.getMethod("named",boxType).invoke(null,box);org.junit.Assert.assertSame(sequence.get(0),named.get("a"));org.junit.Assert.assertSame(sequence.get(1),named.get("b")); + s.beginTransaction();Object first=sequence.remove(0);sequence.add(first);s.commitTransaction();s.clear();box=s.find(boxType,id); + sequence=(List)reader.getMethod("sequence",boxType).invoke(null,box);org.junit.Assert.assertEquals("b",thingType.getField("label").get(sequence.get(0)));org.junit.Assert.assertSame(sequence.get(1),sequence.get(2)); + org.junit.Assert.assertEquals(3,s.count(box,"sequence"));s.beginTransaction();s.remove(box);s.commitTransaction();org.junit.Assert.assertEquals(2,s.query(thingType).count()); + } finally { s.close();em.close();loader.close(); } + } + + @Test + public void polymorphicSingleTableInheritancePreservesIdentityAndSubtypeQueries() throws Exception { + File classes=tmp.newFolder("inheritance");Map sources=new java.util.LinkedHashMap(); + sources.put("hierarchy.Animal","package hierarchy; import com.codename1.annotations.*; @Entity(table=\"hierarchy_animals\") @Inheritance public abstract class Animal { @Id public long id; @Version public long version; public String name; @DbTransient public int callbacks; @PrePersist public void baseCallback() { callbacks++; } }"); + sources.put("hierarchy.Cat","package hierarchy; import com.codename1.annotations.*; @Entity @DiscriminatorValue(\"cat\") public class Cat extends Animal { public int lives; @PrePersist public void catCallback() { callbacks+=10; } }"); + sources.put("hierarchy.Dog","package hierarchy; import com.codename1.annotations.*; @Entity @DiscriminatorValue(\"dog\") public class Dog extends Animal { public String breed; @ManyToOne(fetch=FetchType.LAZY,cascade=CascadeType.PERSIST) public Keeper keeper; }"); + sources.put("hierarchy.Keeper","package hierarchy; import com.codename1.annotations.*; @Entity(table=\"hierarchy_keepers\") public class Keeper { @Id public long id; public String name; }"); + sources.put("hierarchy.Shelter","package hierarchy; import com.codename1.annotations.*; @Entity(table=\"hierarchy_shelters\") public class Shelter { @Id public long id; @OneToMany(cascade=CascadeType.ALL,orphanRemoval=true) public java.util.List animals=new java.util.ArrayList(); }"); + sources.put("hierarchy.Reader","package hierarchy; public class Reader { public static Keeper keeper(Dog d) { return d.keeper; } public static java.util.List animals(Shelter s) { return s.animals; } }"); + JavaSourceCompiler.compile(sources,classes,Arrays.asList(testClassesDir())); + ProcessorContext client=runProcessor(classes);assertFalse(client.getErrors().toString(),client.hasErrors()); + ProcessorContext ctx=runProcessor(classes,backendClasspath());assertFalse(ctx.getErrors().toString(),ctx.hasErrors()); + java.net.URLClassLoader loader=new java.net.URLClassLoader(new URL[]{classes.toURI().toURL()},getClass().getClassLoader()); + loader.loadClass("cn1app.BackendDaoBootstrap").newInstance();Class base=loader.loadClass("hierarchy.Animal"),catType=loader.loadClass("hierarchy.Cat"),dogType=loader.loadClass("hierarchy.Dog"),keeperType=loader.loadClass("hierarchy.Keeper"),shelterType=loader.loadClass("hierarchy.Shelter"),reader=loader.loadClass("hierarchy.Reader"); + com.codename1.backend.orm.EntityManager em=com.codename1.backend.orm.EntityManager.open(com.codename1.backend.Database.open(":memory:")); + com.codename1.orm.session.Session s=em.openSession(); + try { + em.createTables();s.validateSchema();s.beginTransaction(); + Object cat=catType.newInstance(),dog=dogType.newInstance(),keeper=keeperType.newInstance(),shelter=shelterType.newInstance(); + base.getField("name").set(cat,"Milo");catType.getField("lives").setInt(cat,9);base.getField("name").set(dog,"Fido");dogType.getField("breed").set(dog,"terrier");dogType.getField("keeper").set(dog,keeper); + List animals=(List)shelterType.getField("animals").get(shelter);animals.add(cat);animals.add(dog);s.persist(shelter);s.commitTransaction(); + org.junit.Assert.assertEquals(11,base.getField("callbacks").getInt(cat)); + Object catId=base.getField("id").get(cat),dogId=base.getField("id").get(dog),shelterId=shelterType.getField("id").get(shelter);s.clear(); + Object foundCat=s.find(base,catId);org.junit.Assert.assertEquals(catType,foundCat.getClass());org.junit.Assert.assertSame(foundCat,s.find(catType,catId)); + org.junit.Assert.assertNull(s.find(dogType,catId));org.junit.Assert.assertEquals(2,s.query(base).count());org.junit.Assert.assertEquals(1,s.query(catType).count()); + Object foundDog=s.find(base,dogId);assertFalse(s.isLoaded(foundDog,"keeper"));org.junit.Assert.assertNotNull(reader.getMethod("keeper",dogType).invoke(null,foundDog)); + List loaded=(List)reader.getMethod("animals",shelterType).invoke(null,s.find(shelterType,shelterId));org.junit.Assert.assertEquals(2,loaded.size()); + s.beginTransaction();catType.getField("lives").setInt(foundCat,8);s.commitTransaction();s.clear();org.junit.Assert.assertEquals(8,catType.getField("lives").getInt(s.find(base,catId))); + s.beginTransaction();org.junit.Assert.assertEquals(1,s.createQuery("update Cat c set c.name = :name").setParameter("name","Kitty").executeUpdate());s.commitTransaction(); + org.junit.Assert.assertEquals("Fido",base.getField("name").get(s.find(base,dogId))); + s.beginTransaction();s.remove(s.find(shelterType,shelterId));s.commitTransaction();org.junit.Assert.assertEquals(0,s.query(base).count()); + } finally { s.close();em.close();loader.close(); } + } + + @Test + public void convertersAndUniqueIndexesPreserveDomainValues() throws Exception { + File classes=tmp.newFolder("converted");Map sources=new java.util.LinkedHashMap(); + sources.put("converted.Code","package converted; public class Code { public String value; public Code(String value) { this.value=value; } }"); + sources.put("converted.CodeConverter","package converted; public class CodeConverter implements com.codename1.orm.session.AttributeConverter { public String toDatabase(Code c) { return c==null?null:c.value; } public Code fromDatabase(String value) { return value==null?null:new Code(value); } }"); + sources.put("converted.Entry","package converted; import com.codename1.annotations.*; @Entity(indexes=@Index(name=\"converted_code\",fields=\"code\",unique=true)) public class Entry { @Id public long id; @Convert(converter=CodeConverter.class) public Code code; }"); + JavaSourceCompiler.compile(sources,classes,Arrays.asList(testClassesDir())); + ProcessorContext client=runProcessor(classes);assertFalse(client.getErrors().toString(),client.hasErrors()); + ProcessorContext ctx=runProcessor(classes,backendClasspath());assertFalse(ctx.getErrors().toString(),ctx.hasErrors()); + java.net.URLClassLoader loader=new java.net.URLClassLoader(new URL[]{classes.toURI().toURL()},getClass().getClassLoader()); + loader.loadClass("cn1app.BackendDaoBootstrap").newInstance();Class type=loader.loadClass("converted.Entry"),codeType=loader.loadClass("converted.Code"); + com.codename1.backend.orm.EntityManager em=com.codename1.backend.orm.EntityManager.open(com.codename1.backend.Database.open(":memory:")); + com.codename1.orm.session.Session s=em.openSession(); + try { + s.createTables();s.validateSchema();s.beginTransaction();Object entity=type.newInstance(),code=codeType.getConstructor(String.class).newInstance("ABC");type.getField("code").set(entity,code);s.persist(entity);s.commitTransaction(); + Object id=type.getField("id").get(entity);s.clear();Object loaded=s.query(type).eq("code",code).first(); + org.junit.Assert.assertEquals("ABC",codeType.getField("value").get(type.getField("code").get(loaded))); + s.beginTransaction();codeType.getField("value").set(type.getField("code").get(loaded),"DEF");s.commitTransaction();s.clear(); + org.junit.Assert.assertEquals("DEF",codeType.getField("value").get(type.getField("code").get(s.find(type,id)))); + s.beginTransaction();Object duplicate=type.newInstance();type.getField("code").set(duplicate,codeType.getConstructor(String.class).newInstance("DEF"));s.persist(duplicate); + try { s.commitTransaction();fail("Unique index must reject duplicate domain values"); } catch(com.codename1.orm.session.PersistenceException expected) { s.rollbackTransaction(); } + } finally { s.close();em.close();loader.close(); } + } + + @Test + public void compositeIdentifiersRoundTripThroughRelationsAndJoinTables() throws Exception { + File classes=tmp.newFolder("composite"); + Map sources=new java.util.LinkedHashMap(); + sources.put("composite.Key","package composite; import com.codename1.annotations.*; @Embeddable public class Key { public String tenant; public long number; }"); + sources.put("composite.Account","package composite; import com.codename1.annotations.*; @Entity public class Account { @EmbeddedId public Key key; @Version public long version; public long counter; @OneToMany(mappedBy=\"account\",cascade=CascadeType.ALL,orphanRemoval=true) public java.util.List items=new java.util.ArrayList(); @ManyToMany(cascade=CascadeType.PERSIST) public java.util.Set