From d761abc87bd5336768b5ac94bac32ecbc79c58c2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:59:52 +0300 Subject: [PATCH 1/8] Add generated ORM repositories and CASE query expressions --- .github/workflows/parparvm-tests.yml | 2 + .../com/codename1/impl/orm/JpqlQueryImpl.java | 71 +++- .../codename1/impl/orm/NativeQueryImpl.java | 155 +++++++++ .../com/codename1/impl/orm/SessionImpl.java | 6 + .../orm/repository/CrudRepository.java | 36 +++ .../com/codename1/orm/repository/Lock.java | 36 +++ .../codename1/orm/repository/Modifying.java | 36 +++ .../codename1/orm/repository/PageRequest.java | 38 +++ .../com/codename1/orm/repository/Param.java | 36 +++ .../com/codename1/orm/repository/Query.java | 37 +++ .../codename1/orm/repository/Repository.java | 37 +++ .../orm/repository/package-info.java | 26 ++ .../com/codename1/orm/session/JpqlQuery.java | 5 +- .../com/codename1/orm/session/Session.java | 12 + .../Annotation-SQLite-ORM.asciidoc | 119 +++++++ docs/developer-guide/Backend-Data.asciidoc | 4 + .../orm/ManagedSessionDatabaseTest.java | 1 + .../backend/orm/ManagedSessionTest.java | 39 +++ .../processors/OrmAnnotationProcessor.java | 18 ++ .../maven/processors/RepositoryGenerator.java | 306 ++++++++++++++++++ .../OrmAnnotationProcessorTest.java | 61 ++++ vm/backend/contract/com/demo/Records.java | 47 +++ .../contract/com/demo/RepositoryRecord.java | 34 ++ .../demo/ormcheck/com/demo/OrmCheck.java | 34 ++ vm/backend/generate-contract.sh | 3 +- .../impl/backend/TransactionSession.java | 5 + 26 files changed, 1198 insertions(+), 6 deletions(-) create mode 100644 CodenameOne/src/com/codename1/impl/orm/NativeQueryImpl.java create mode 100644 CodenameOne/src/com/codename1/orm/repository/CrudRepository.java create mode 100644 CodenameOne/src/com/codename1/orm/repository/Lock.java create mode 100644 CodenameOne/src/com/codename1/orm/repository/Modifying.java create mode 100644 CodenameOne/src/com/codename1/orm/repository/PageRequest.java create mode 100644 CodenameOne/src/com/codename1/orm/repository/Param.java create mode 100644 CodenameOne/src/com/codename1/orm/repository/Query.java create mode 100644 CodenameOne/src/com/codename1/orm/repository/Repository.java create mode 100644 CodenameOne/src/com/codename1/orm/repository/package-info.java create mode 100644 maven/build-engine/src/main/java/com/codename1/maven/processors/RepositoryGenerator.java create mode 100644 vm/backend/contract/com/demo/Records.java create mode 100644 vm/backend/contract/com/demo/RepositoryRecord.java diff --git a/.github/workflows/parparvm-tests.yml b/.github/workflows/parparvm-tests.yml index 060b8adbd0d..58e65b9a609 100644 --- a/.github/workflows/parparvm-tests.yml +++ b/.github/workflows/parparvm-tests.yml @@ -33,6 +33,7 @@ on: - 'CodenameOne/src/com/codename1/impl/SharedWithBackend.java' - 'CodenameOne/src/com/codename1/impl/orm/**' - 'CodenameOne/src/com/codename1/orm/session/**' + - 'CodenameOne/src/com/codename1/orm/repository/**' - 'CodenameOne/src/com/codename1/annotations/db/**' - 'CodenameOne/src/com/codename1/annotations/Entity.java' - 'CodenameOne/src/com/codename1/annotations/Id.java' @@ -85,6 +86,7 @@ on: - 'CodenameOne/src/com/codename1/impl/SharedWithBackend.java' - 'CodenameOne/src/com/codename1/impl/orm/**' - 'CodenameOne/src/com/codename1/orm/session/**' + - 'CodenameOne/src/com/codename1/orm/repository/**' - 'CodenameOne/src/com/codename1/annotations/db/**' - 'CodenameOne/src/com/codename1/annotations/Entity.java' - 'CodenameOne/src/com/codename1/annotations/Id.java' diff --git a/CodenameOne/src/com/codename1/impl/orm/JpqlQueryImpl.java b/CodenameOne/src/com/codename1/impl/orm/JpqlQueryImpl.java index 43be24d5057..7daec1e66c5 100644 --- a/CodenameOne/src/com/codename1/impl/orm/JpqlQueryImpl.java +++ b/CodenameOne/src/com/codename1/impl/orm/JpqlQueryImpl.java @@ -331,6 +331,7 @@ private static final class Expr { String field; Parameter parameter; final List children = new ArrayList(); + List resultBranches; boolean literal; boolean aggregate; boolean numericOperands; @@ -845,7 +846,62 @@ private Expr multiply() { } return left; } + private Expr caseExpression() { + Expr operand = peek("WHEN") ? null : expression(); + List conditions = new ArrayList(); + List branches = new ArrayList(); + List predicates = new ArrayList(); + do { + expect("WHEN"); + Expr test = expression(); + if (operand != null) { + compatible(operand, test); + bindType(operand, test); + bindType(test, operand); + String left = operand.sql, right = test.sql; + if (operand.kind == Attribute.BOOLEAN || test.kind == Attribute.BOOLEAN) { + left = condition(operand); right = condition(test); + } else if (numericKind(operand.kind) && numericKind(test.kind) + && (operand.kind == Attribute.REAL || test.kind == Attribute.REAL)) { + left = session.numericOperand(left, Attribute.REAL); + right = session.numericOperand(right, Attribute.REAL); + } + test = node("(" + left + " = " + right + ")", Attribute.BOOLEAN, operand, test); + } + predicates.add(condition(test)); + conditions.add(test); + expect("THEN"); + branches.add(expression()); + } while (peek("WHEN")); + branches.add(take("ELSE") ? expression() : literal(-1, null)); + expect("END"); + int kind = commonKind(branches); + if (kind < 0) throw error("CASE needs at least one typed result branch"); + Expr mapping = coalesceMapping(branches); + StringBuilder sql = new StringBuilder("CASE"); + for (int i = 0; i < branches.size(); i++) { + Expr branch = branches.get(i); + requireKind(branch, kind); + if (branch.entity != null || branch.association || branch.predicate) { + throw error("CASE result branches must be scalar values, not entities or predicates"); + } + if (mapping != null && mapping.projectionQuery != null) bindType(mapping, branch); + sql.append(i < predicates.size() ? " WHEN " + predicates.get(i) + " THEN " : " ELSE "); + // Explicit numeric casts give PostgreSQL the same branch promotion as SQLite. + sql.append(numericKind(kind) ? session.numericOperand(branch.sql, kind) : branch.sql); + } + Expr result = new Expr(sql.append(" END").toString(), kind); + result.resultBranches = branches; + result.children.addAll(conditions); + result.children.addAll(branches); + if (mapping != null && mapping.projectionQuery != null && kind == mapping.kind) { + result.query = result.projectionQuery = mapping.projectionQuery; + result.field = result.projectionField = mapping.projectionField; + } + return result; + } private Expr primary() { + if (take("CASE")) return caseExpression(); if (take("-")) { String magnitude = peek(); if (magnitude.length() > 0 && Character.isDigit(magnitude.charAt(0)) @@ -1137,6 +1193,7 @@ private Expr wrap(String sql, Expr value) { result.nullPreserving = true; result.numericOperands = true; result.predicate = value.predicate; + result.resultBranches = value.resultBranches; result.association = value.association; result.projectionQuery = value.projectionQuery; result.projectionField = value.projectionField; @@ -1183,6 +1240,12 @@ private boolean nonNullExpression(Expr value, boolean allowParameters, boolean e if (value.nonNull) { return true; } + if (value.resultBranches != null) { + for (Expr branch : value.resultBranches) { + if (!nonNullExpression(branch, allowParameters, enforce)) return false; + } + return true; + } if (value.coalesce) { // Prefer an unconditional fallback so earlier nullable parameters // remain usable when a later operand already guarantees a value. @@ -1255,8 +1318,8 @@ private void requireIntegralAssignment(Expr value) { if (value.parameter != null) { value.parameter.integralAssignment = true; } - if (value.kind < 0 || value.numericOperands) { - for (Expr child : value.children) { + if (value.kind < 0 || value.numericOperands || value.resultBranches != null) { + for (Expr child : value.resultBranches == null ? value.children : value.resultBranches) { requireIntegralAssignment(child); } } @@ -1269,7 +1332,7 @@ private void validateAssignment(String mapping, Expr value) { && !mapping.equals(value.projectionQuery.mapping(value.projectionField))) { throw error("Bulk assignment requires matching domain and converter mappings"); } - for (Expr child : value.children) { + for (Expr child : value.resultBranches == null ? value.children : value.resultBranches) { validateAssignment(mapping, child); } } @@ -1285,7 +1348,7 @@ private void bindType(Expr attribute, Expr value) { value.parameter.query = attribute.query; value.parameter.field = attribute.field; } - for (Expr child : value.children) { + for (Expr child : value.resultBranches == null ? value.children : value.resultBranches) { bindType(attribute, child); } } diff --git a/CodenameOne/src/com/codename1/impl/orm/NativeQueryImpl.java b/CodenameOne/src/com/codename1/impl/orm/NativeQueryImpl.java new file mode 100644 index 00000000000..c9ab0a27669 --- /dev/null +++ b/CodenameOne/src/com/codename1/impl/orm/NativeQueryImpl.java @@ -0,0 +1,155 @@ +/* + * 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.impl.orm; + +import com.codename1.orm.session.JpqlQuery; +import com.codename1.orm.session.PersistenceException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Scalar native SQL using the session's connection and failure handling. +/// @hidden +@com.codename1.impl.SharedWithBackend +public final class NativeQueryImpl implements JpqlQuery { + private final SessionImpl session; + private final String sql; + private final boolean mutation; + private final int kind; + private final Class type; + private final List names = new ArrayList(); + private final Map parameters = new LinkedHashMap(); + private int maximum = -1; + private int skip; + + NativeQueryImpl(SessionImpl session, String statement, Class type) { + if (statement == null || type == null) throw new IllegalArgumentException("statement/resultType is null"); + this.session = session; + this.type = type; + String start = statement.trim(); + mutation = keyword(start, "UPDATE") || keyword(start, "DELETE"); + if (!mutation && !keyword(start, "SELECT")) { + throw new IllegalArgumentException("Native query must start with SELECT, UPDATE or DELETE followed by whitespace"); + } + kind = type == String.class ? Attribute.TEXT : type == Long.class || type == Object.class && mutation ? Attribute.BIGINT + : type == Integer.class ? Attribute.INTEGER : type == Double.class ? Attribute.REAL + : type == Boolean.class ? Attribute.BOOLEAN : type == byte[].class ? Attribute.BLOB : -1; + if (kind < 0) throw new IllegalArgumentException("Native selects support String, Long, Integer, Double, Boolean or byte[] scalars"); + StringBuilder output = new StringBuilder(); + char quote = 0; + for (int i = 0; i < statement.length(); i++) { + char c = statement.charAt(i); + if (c == '\\') throw new IllegalArgumentException("Bind backslashes as parameter values instead of SQL literals"); + if (quote != 0) { + output.append(c); + if (c == quote) { + if (i + 1 < statement.length() && statement.charAt(i + 1) == quote) output.append(statement.charAt(++i)); + else quote = 0; + } + } else if (c == '\'' || c == '"') { + quote = c; output.append(c); + } else if (c == ':' && i + 1 < statement.length() && statement.charAt(i + 1) == ':') { + output.append("::"); i++; + } else if (c == ':') { + int begin = ++i; + if (i >= statement.length() || !Character.isJavaIdentifierStart(statement.charAt(i))) { + throw new IllegalArgumentException("Expected native parameter name after colon"); + } + while (i < statement.length() && Character.isJavaIdentifierPart(statement.charAt(i))) i++; + names.add(statement.substring(begin, i--)); output.append('?'); + } else { + if (c == ';' || c == '?' || c == '$' || c == '`' + || c == '-' && i + 1 < statement.length() && statement.charAt(i + 1) == '-' + || c == '/' && i + 1 < statement.length() && statement.charAt(i + 1) == '*') { + throw new IllegalArgumentException("Native queries require one statement, named parameters, standard quotes and no comments"); + } + output.append(c); + } + } + if (quote != 0) throw new IllegalArgumentException("Unterminated native SQL quote"); + sql = output.toString(); + } + private static boolean keyword(String sql, String word) { + return sql.length() > word.length() && sql.substring(0, word.length()).equalsIgnoreCase(word) + && Character.isWhitespace(sql.charAt(word.length())); + } + public JpqlQuery setParameter(String name, Object value) { + if (!names.contains(name)) throw new IllegalArgumentException("Unknown named parameter: " + name); + if (value instanceof Iterable || value instanceof Object[]) throw new IllegalArgumentException("Native parameters must be scalars"); + parameters.put(name, value); return this; + } + public JpqlQuery limit(int value) { + if (value < 0) throw new IllegalArgumentException("Negative limit"); + maximum = value; return this; + } + public JpqlQuery offset(int value) { + if (value < 0) throw new IllegalArgumentException("Negative offset"); + skip = value; return this; + } + private Object[] arguments() { + SessionImpl.checkParameterCount(names.size()); + Object[] result = new Object[names.size()]; + for (int i = 0; i < result.length; i++) { + String name = names.get(i); + if (!parameters.containsKey(name)) throw new IllegalArgumentException("Unbound named parameter: " + name); + result[i] = parameters.get(name); + } + return result; + } + public List list() { return list(maximum); } + public T first() { + List rows = list(maximum == 0 ? 0 : 1); + return rows.isEmpty() ? null : rows.get(0); + } + private List list(int max) { + if (mutation) throw new IllegalStateException("Use executeUpdate for bulk mutations"); + Object[] args = arguments(); + session.autoFlush(); + List result = new ArrayList(); + if (max == 0) return result; + for (Object[] row : session.read(sql + session.limit(max, skip), args, new int[]{kind})) { + Object value = row[0]; + try { + if (kind == Attribute.BOOLEAN) value = Values.asBooleanObject(value); + else if (kind == Attribute.INTEGER) value = Values.asIntObject(value); + else if (kind == Attribute.BIGINT) value = Values.asLongObject(value); + } catch (java.io.IOException error) { + throw new PersistenceException("Invalid native scalar projection", error); + } + if (value != null && !type.isInstance(value)) throw new PersistenceException("Native scalar does not match " + type.getName()); + result.add((T) value); + } + return result; + } + public int executeUpdate() { + if (!mutation) throw new IllegalStateException("Not a bulk mutation"); + if (maximum >= 0 || skip > 0) throw new IllegalArgumentException("Bulk mutation pagination is not supported"); + Object[] args = arguments(); + session.requireTransaction(); + session.flush(); + int changed = session.write(sql, args); + session.clear(); + return changed; + } +} diff --git a/CodenameOne/src/com/codename1/impl/orm/SessionImpl.java b/CodenameOne/src/com/codename1/impl/orm/SessionImpl.java index bdb135a42a0..93951e15bc7 100644 --- a/CodenameOne/src/com/codename1/impl/orm/SessionImpl.java +++ b/CodenameOne/src/com/codename1/impl/orm/SessionImpl.java @@ -182,6 +182,12 @@ EntityModel model(String name) { } return result; } + @Override + public com.codename1.orm.session.JpqlQuery createNativeQuery(String statement, Class resultType) { + check(); + return new NativeQueryImpl(this, statement, resultType); + } + @Override public JpqlQueryImpl createQuery(String statement, Class resultType) { check(); diff --git a/CodenameOne/src/com/codename1/orm/repository/CrudRepository.java b/CodenameOne/src/com/codename1/orm/repository/CrudRepository.java new file mode 100644 index 00000000000..6a1f97d43c4 --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/repository/CrudRepository.java @@ -0,0 +1,36 @@ +/* + * 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.repository; + +/// Base contract for generated repositories. The caller owns the Session and transaction. +/// save returns the managed merge result; insert schedules a new instance for persistence. +@com.codename1.impl.SharedWithBackend +public interface CrudRepository { + E findById(K id); + E save(E entity); + void insert(E entity); + void deleteById(K id); + java.util.List findAll(); + long count(); + boolean existsById(K id); +} diff --git a/CodenameOne/src/com/codename1/orm/repository/Lock.java b/CodenameOne/src/com/codename1/orm/repository/Lock.java new file mode 100644 index 00000000000..76b17ed1667 --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/repository/Lock.java @@ -0,0 +1,36 @@ +/* + * 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.repository; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Applies a lock to a findById lookup in the caller-owned transaction. +@com.codename1.impl.SharedWithBackend +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface Lock { + com.codename1.orm.session.LockMode value(); +} diff --git a/CodenameOne/src/com/codename1/orm/repository/Modifying.java b/CodenameOne/src/com/codename1/orm/repository/Modifying.java new file mode 100644 index 00000000000..24aa9efa4aa --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/repository/Modifying.java @@ -0,0 +1,36 @@ +/* + * 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.repository; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Marks a bulk update or delete. Always flushes before and clears after execution. +@com.codename1.impl.SharedWithBackend +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface Modifying { + +} diff --git a/CodenameOne/src/com/codename1/orm/repository/PageRequest.java b/CodenameOne/src/com/codename1/orm/repository/PageRequest.java new file mode 100644 index 00000000000..f9a1f469924 --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/repository/PageRequest.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.orm.repository; + +/// An immutable offset and positive maximum row count. Supply a deterministic OrderBy +/// in the repository method name or declared query when paging. +@com.codename1.impl.SharedWithBackend +public final class PageRequest { + private final int offset; + private final int size; + public PageRequest(int offset, int size) { + if (offset < 0 || size <= 0) throw new IllegalArgumentException("Page offset must be nonnegative and size positive"); + this.offset = offset; + this.size = size; + } + public int getOffset() { return offset; } + public int getSize() { return size; } +} diff --git a/CodenameOne/src/com/codename1/orm/repository/Param.java b/CodenameOne/src/com/codename1/orm/repository/Param.java new file mode 100644 index 00000000000..794bb9c9608 --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/repository/Param.java @@ -0,0 +1,36 @@ +/* + * 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.repository; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Names a declared query parameter without its colon. +@com.codename1.impl.SharedWithBackend +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.PARAMETER) +public @interface Param { + String value(); +} diff --git a/CodenameOne/src/com/codename1/orm/repository/Query.java b/CodenameOne/src/com/codename1/orm/repository/Query.java new file mode 100644 index 00000000000..b74ed32c933 --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/repository/Query.java @@ -0,0 +1,37 @@ +/* + * 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.repository; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Declares JPQL, or scalar native SQL when nativeSql is true. +@com.codename1.impl.SharedWithBackend +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.METHOD) +public @interface Query { + String value(); + boolean nativeSql() default false; +} diff --git a/CodenameOne/src/com/codename1/orm/repository/Repository.java b/CodenameOne/src/com/codename1/orm/repository/Repository.java new file mode 100644 index 00000000000..be5fd23906b --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/repository/Repository.java @@ -0,0 +1,37 @@ +/* + * 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.repository; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Generates an implementation named InterfaceNameCn1Repository at build time. +@com.codename1.impl.SharedWithBackend +@Retention(RetentionPolicy.CLASS) +@Target(ElementType.TYPE) +public @interface Repository { + Class entity(); + Class key(); +} diff --git a/CodenameOne/src/com/codename1/orm/repository/package-info.java b/CodenameOne/src/com/codename1/orm/repository/package-info.java new file mode 100644 index 00000000000..f01c634d35c --- /dev/null +++ b/CodenameOne/src/com/codename1/orm/repository/package-info.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +/// Build-time repository interfaces over managed ORM sessions. +/// Implementations are generated by the ORM processor. Applications supply the +/// Session and own its transaction boundaries; bulk methods flush and clear it. +package com.codename1.orm.repository; diff --git a/CodenameOne/src/com/codename1/orm/session/JpqlQuery.java b/CodenameOne/src/com/codename1/orm/session/JpqlQuery.java index b73b3f545ad..b19003d8ec3 100644 --- a/CodenameOne/src/com/codename1/orm/session/JpqlQuery.java +++ b/CodenameOne/src/com/codename1/orm/session/JpqlQuery.java @@ -24,7 +24,10 @@ import java.util.List; -/// A mutable query obtained from {@link Session#createQuery(String, Class)}. +/// A mutable query obtained from {@link Session#createQuery(String, Class)} or +/// {@link Session#createNativeQuery(String, Class)}. Native queries use the same +/// binding, pagination and bulk-execution contract but accept native SQL instead +/// of the JPQL features below. /// Supports entity and scalar selections, relationship joins, aggregates, grouping, /// ordering, predicate subqueries, and transactional bulk update/delete statements. /// This is a JPQL subset, not a complete JPA implementation; unsupported syntax is diff --git a/CodenameOne/src/com/codename1/orm/session/Session.java b/CodenameOne/src/com/codename1/orm/session/Session.java index 6c7fbbaf803..8466048686f 100644 --- a/CodenameOne/src/com/codename1/orm/session/Session.java +++ b/CodenameOne/src/com/codename1/orm/session/Session.java @@ -53,6 +53,18 @@ public interface Session { /// @throws IllegalArgumentException if syntax or a mapped name is unsupported JpqlQuery createQuery(String statement); + /// Creates a native SQL query with named parameters. Selects return a single + /// scalar column of String, Long, Integer, Double, Boolean, or byte[]. Entity + /// hydration and tuples are not supported. SQL identifiers and functions are + /// database-specific. Bulk UPDATE/DELETE requires a transaction, flushes first, + /// and clears the persistence context after success, just like JPQL. + /// @param scalar result type + /// @param statement one SELECT, UPDATE or DELETE statement + /// @param resultType scalar class, or Object.class for mutations + /// @return a query belonging to this session + /// @throws IllegalArgumentException for unsupported query syntax or result types + JpqlQuery createNativeQuery(String statement, Class resultType); + /// Starts a transaction for this session. /// @throws PersistenceException if the session is closed, a transaction is already /// active, or the database cannot begin the transaction diff --git a/docs/developer-guide/Annotation-SQLite-ORM.asciidoc b/docs/developer-guide/Annotation-SQLite-ORM.asciidoc index d1204dbad0a..8f62c0846e4 100644 --- a/docs/developer-guide/Annotation-SQLite-ORM.asciidoc +++ b/docs/developer-guide/Annotation-SQLite-ORM.asciidoc @@ -157,6 +157,22 @@ parameters can't be compared directly. `IS NULL` and `IS NOT NULL` also require known operand type; a standalone untyped parameter is rejected. A scalar nested query requires an aggregate without `GROUP BY`, so it returns at most one row. `IN` and `EXISTS` may use queries that return multiple rows. +Searched `CASE WHEN predicate THEN value ... ELSE value END` and simple +`CASE expression WHEN value THEN value ... ELSE value END` work in scalar +expressions, including `SUM(CASE WHEN ... THEN ... ELSE ... END)`. The first +matching branch wins. An omitted `ELSE` yields SQL NULL; a NULL condition is +not true, and simple CASE uses SQL equality (NULL doesn't match NULL). Aggregates +ignore NULL inputs; SUM over no non-null inputs yields NULL, while COUNT yields +zero. Use a boxed scalar return type when NULL is possible. + +Result branches must have compatible scalar storage types. Integral branches +promote to Long when combined with Long literals; any real branch promotes the +result to Double. At least one branch must have a known type; CASE with only +untyped parameters or NULL branches is rejected. Entity and predicate result +branches are unsupported; Boolean values and literals are supported. Converter +mappings must agree across mapped result branches. Parameters in WHEN conditions +and result branches remain bound parameters. + A `DISTINCT` query can order only by selected expressions. Text range comparisons and `LIKE` patterns are case-sensitive across supported databases. An explicit `LIKE ... ESCAPE` uses a literal or named parameter for both the pattern and the @@ -409,3 +425,106 @@ The runtime registry is keyed on `Class#getName()`; obfuscation renames the call sites and the registered keys together within a single execution. The keys are never persisted across builds, so the renaming has no observable effect on behavior. + + +[[generated-orm-repositories]] +=== Generated repositories + +A public top-level interface annotated with +`com.codename1.orm.repository.Repository` and extending +`CrudRepository` directly gets an `InterfaceNameCn1Repository` +implementation during `cn1:process-annotations`. Both class literals are explicit; +the key must match the mapped identifier's boxed type (the embedded key class or +`Identifier` for composite keys). Instantiate the generated class with a `Session`. +The implementation makes direct session calls. There is no runtime classpath scan, +proxy generation, or Hibernate dependency. + +[source,java] +---- +include::../../vm/backend/contract/com/demo/Records.java[tag=generated-repository] +---- + +The example's `RepositoryRecord` entity has `@Id public long id`, +`public String name`, and `public long score`. After the build generates the class: + +[source,java] +---- +Session session = entityManager.openSession(); +Records records = new RecordsCn1Repository(session); +session.beginTransaction(); +try { + RepositoryRecord record = new RepositoryRecord(); + record.name = "open"; + records.insert(record); + session.commitTransaction(); +} catch (RuntimeException error) { + session.rollbackTransaction(); + throw error; +} finally { + session.close(); +} +---- + +The inherited CRUD methods are `insert`, `save`, `findById`, `findAll`, `count`, +`existsById`, and `deleteById`. `insert` calls `persist`; `save` calls `merge` and +returns the managed instance, which may differ from the argument. `findById` +returns null for a missing row. `deleteById` removes a managed entity and observes +its cascades and callbacks; deleting a missing key is a no-op in an active +transaction. + +Derived methods support the following bounded grammar: + +* `findBy`, `findFirstBy`, `findTopBy`, `findFirstNBy`, and `findTopNBy` return the + entity or `List`. A single result means the first row, not a uniqueness + assertion. +* `countBy` returns `long`; `existsBy` returns `boolean`; `deleteBy` returns `int` + (affected rows) or `void`. +* Direct scalar property names combine with `And` and `Or`; AND binds more tightly. + Comparison suffixes are `Not`, `GreaterThan`, `GreaterThanEqual`, `LessThan`, + `LessThanEqual`, `Like`, and `In`. No suffix means equality. `IsNull`, + `IsNotNull`, `True`, and `False` consume no parameter. `In` takes an Iterable or + object array. Use `IsNull` to match NULL; equality retains SQL NULL semantics. +* Find methods can end with `OrderByScoreDescIdAsc`; every field needs an explicit + `Asc` or `Desc`. Relationship paths and ambiguous property names containing + grammar keywords should use `@Query` instead. +* A list select can take one final `PageRequest(offset, size)`. Offset must be + nonnegative and size positive. It returns the requested list without a separate + total-count query. Use explicit ordering with a unique tie-breaker for stable + pages. Top/First and PageRequest aren't combined. + +`@Query` declares the existing JPQL subset. Every value argument requires a unique +`@Param("name")` matching the query exactly. SQL values are bound, never +interpolated. `@Modifying` is required for declared UPDATE/DELETE and forbidden +for SELECT. Such methods return `int` or `void`. Native SQL uses +`@Query(value="...", nativeSql=true)` with the same named parameters, including +repeated occurrences. Native repository selects return one scalar column of +String, Long, Integer, Double, or Boolean, or a List of that type. They don't hydrate +entities. Native SQL is database-specific: use portable SQL or validate each +engine. Only SELECT, UPDATE, and DELETE are accepted; comments, multiple +statements, positional parameters, dollar quoting, and backtick quoting are +unsupported. Use standard quotes and bind values containing backslashes. + +Unsupported signatures, unmapped derived fields, and missing or surplus declared +parameter names produce build errors naming the repository and method. JPQL syntax +and mapped names are validated when the method creates its query, before SQL +execution. Native SQL syntax beyond the supported envelope is checked by the +database. Generic methods, additional superinterfaces, default methods, dynamic +sort arguments, and arbitrary DTO projections aren't supported. + +Repositories never open, commit, roll back, or close transactions or sessions. +The caller owns those boundaries, including when the supplied Session is injected +into a backend `@Transactional` service. Writes require an active usable +transaction. Reads follow Session's auto-flush rules. Redeclare only +`findById(Key)` with `@Lock` to request a lock; pessimistic locks need an active +transaction and an engine supporting row locks. SQLite rejects them explicitly. + +Derived deletes and declared JPQL/native modifications flush pending changes, +execute bulk SQL, then clear the entire persistence context. They bypass entity +callbacks, cascades, and automatic optimistic-version updates. Previously held +objects are detached and retain their old Java values; reload them before further +work. There is no option to disable flush or clear. Validation failures remain +`IllegalArgumentException`; database failures remain `PersistenceException`, and +optimistic conflicts remain `OptimisticLockException`. Repository code neither +swallows nor retries them. Roll back after a failed database write; Session marks +the transaction rollback-only. Rollback detaches entities without reverting Java +field values. diff --git a/docs/developer-guide/Backend-Data.asciidoc b/docs/developer-guide/Backend-Data.asciidoc index 8f894984c3e..5d056ac7097 100644 --- a/docs/developer-guide/Backend-Data.asciidoc +++ b/docs/developer-guide/Backend-Data.asciidoc @@ -8,6 +8,10 @@ make several statements one unit of work. The last of those gets the most room, because declarative transactions are where a Spring developer's intuitions are most likely to be almost right. +For generated CRUD interfaces, derived finders, paging, declared queries, and +conditional aggregates over managed sessions, see +<>. + === Talking to a database The server opens a connection pool from `cn1.datasource.url` -- a SQLite path or 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 index 9bfe99fbaac..f1f10264177 100644 --- a/maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionDatabaseTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionDatabaseTest.java @@ -80,6 +80,7 @@ public Attribute[] attributes() { ManagedSessionTest.assertDecimalIdentifiers(db); ManagedSessionTest.assertBulkTextKeyBounds(db); ManagedSessionTest.assertMinimumLongLiteral(session); + ManagedSessionTest.assertCaseAndNativeQueries(session); java.util.Map> realModels=new java.util.LinkedHashMap>();realModels.put(ManagedSessionTest.RealRecord.class.getName(),new ManagedSessionTest.RealModel()); Session realSession=new com.codename1.impl.orm.SessionImpl(new com.codename1.impl.orm.BackendSqlAccess(null,db,db.dialect()),realModels); try { ManagedSessionTest.assertRealAssignments(realSession); } finally { realSession.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 index d9c8d57cc89..81d73504d57 100644 --- a/maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionTest.java @@ -33,6 +33,45 @@ import static org.junit.jupiter.api.Assertions.*; class ManagedSessionTest { + @Test void caseAndNativeQueriesUseSessionSemantics() throws Exception { + EntityManager em=manager(); Session session=em.openSession(); + try { session.createTables(); assertCaseAndNativeQueries(session); } + finally { session.close(); em.close(); } + } + static void assertCaseAndNativeQueries(Session s) { + s.beginTransaction(); + Record one=new Record();one.name="case-first";one.counter=4;s.persist(one); + Record two=new Record();two.name="case-second";two.counter=7;s.persist(two); + Record three=new Record();three.counter=2;s.persist(three); + s.commitTransaction(); + assertEquals(Long.valueOf(11),s.createQuery("select sum(case when r.counter>=:floor then r.counter else 0 end) from "+Record.class.getName()+" r",Long.class).setParameter("floor",4L).first()); + assertEquals(Long.valueOf(2),s.createQuery("select sum(case r.name when :first then 1 when 'case-second' then 1 else 0 end) from "+Record.class.getName()+" r",Long.class).setParameter("first","case-first").first()); + assertEquals(Long.valueOf(2),s.createQuery("select sum(case (r.counter>=4) when true then 1 else 0 end) from "+Record.class.getName()+" r",Long.class).first()); + assertEquals(Long.valueOf(1),s.createQuery("select sum(case when r.name is null then 1 end) from "+Record.class.getName()+" r",Long.class).first()); + assertNull(s.createQuery("select sum(case when r.counter<0 then 1 end) from "+Record.class.getName()+" r",Long.class).first()); + assertEquals(Double.valueOf(1.5),s.createQuery("select case when r.counter=4 then 1.5 else 0 end from "+Record.class.getName()+" r where r.id=:id",Double.class).setParameter("id",one.id).first()); + assertEquals("yes",s.createQuery("select case when r.counter=4 then case when r.name=:name then 'yes' else 'no' end else 'other' end from "+Record.class.getName()+" r where r.id=:id",String.class).setParameter("name","case-first").setParameter("id",one.id).first()); + assertEquals(Long.valueOf(1),s.createQuery("select count(r) from "+Record.class.getName()+" r where r.name=case when true then :name else 'none' end",Long.class).setParameter("name","case-first").first()); + s.beginTransaction(); + assertEquals(3,s.createQuery("update "+Record.class.getName()+" r set r.name=case when true then :name else r.name end").setParameter("name","renamed").executeUpdate()); + s.rollbackTransaction();one=s.find(Record.class,one.id); + assertThrows(IllegalArgumentException.class,()->s.createQuery("select case when r.counter>0 then 1 else 'text' end from "+Record.class.getName()+" r")); + assertThrows(IllegalArgumentException.class,()->s.createQuery("select case when r.counter>0 then :a else :b end from "+Record.class.getName()+" r")); + assertThrows(IllegalArgumentException.class,()->s.createQuery("select case when r.counter>0 then 1 from "+Record.class.getName()+" r")); + assertThrows(IllegalArgumentException.class,()->s.createQuery("select sum(case when r.counter>:floor then 1 else 0 end) from "+Record.class.getName()+" r").first()); + assertEquals(Long.valueOf(2),s.createNativeQuery("SELECT COUNT(*) FROM managed_record WHERE counter>=:floor OR counter=:floor",Long.class).setParameter("floor",4L).first()); + assertEquals(Integer.valueOf(4),s.createNativeQuery("SELECT counter FROM managed_record WHERE id=:id",Integer.class).setParameter("id",one.id).first()); + assertEquals(":literal",s.createNativeQuery("SELECT ':literal' FROM managed_record WHERE id=:id",String.class).setParameter("id",one.id).first()); + assertThrows(IllegalArgumentException.class,()->s.createNativeQuery("SELECT counter FROM managed_record; DELETE FROM managed_record",Long.class)); + assertThrows(IllegalArgumentException.class,()->s.createNativeQuery("SELECT counter FROM managed_record WHERE id=:id",Long.class).first()); + assertThrows(PersistenceException.class,()->s.createNativeQuery("UPDATE managed_record SET counter=:n",Object.class).setParameter("n",9L).executeUpdate()); + s.beginTransaction();one.counter=8; + assertEquals(1,s.createNativeQuery("UPDATE managed_record SET counter=:n WHERE id=:id AND counter=:old",Object.class).setParameter("n",9L).setParameter("id",one.id).setParameter("old",8L).executeUpdate()); + assertFalse(s.contains(one));assertEquals(9,s.find(Record.class,one.id).counter); + s.rollbackTransaction();assertEquals(4,s.find(Record.class,one.id).counter); + s.beginTransaction();s.createQuery("delete from "+Record.class.getName()+"").executeUpdate();s.commitTransaction(); + } + static class Record { long id,version,counter; String name; diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java index 5abfeac05f1..b719160d758 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java @@ -101,6 +101,7 @@ public final class OrmAnnotationProcessor extends AbstractAnnotationProcessor { static { Set s = new LinkedHashSet(); s.add(ENTITY_DESC); + s.add(RepositoryGenerator.REPOSITORY); s.add(MIGRATION_DESC); DESCRIPTORS = Collections.unmodifiableSet(s); } @@ -114,6 +115,7 @@ public final class OrmAnnotationProcessor extends AbstractAnnotationProcessor { /// Whether this module's daos go over the server-side backend runtime /// rather than over the Codename One core. Settled once in [#start]. private boolean backend; + private final List repositories = new ArrayList(); /// Set to skip the detection in [#start]. The native packaging goal knows /// the answer and the classpath cannot tell it: `cn1:backend-package` @@ -138,6 +140,7 @@ public Set getAnnotationDescriptors() { @Override public void start(ProcessorContext ctx) throws ProcessingException { accepted.clear(); + repositories.clear(); javaMigrations.clear(); try { dependencyOverlays=OrmEnhancer.prepare(ctx); } catch(IOException error) { throw new ProcessingException("Could not refresh enhanced dependencies",error); } @@ -271,6 +274,10 @@ private static boolean onCompileClasspath(ProcessorContext ctx, String entryName @Override public void processClass(AnnotatedClass cls, ProcessorContext ctx) throws ProcessingException { + if (cls.getClassAnnotation(RepositoryGenerator.REPOSITORY) != null + && BuildHintAnnotationProcessor.hasBackingSource(cls, ctx.getCompileSourceRoots(), ctx.getSourceEncoding())) { + repositories.add(cls); + } processClass(cls, ctx, true); } @@ -784,6 +791,17 @@ public void finish(ProcessorContext ctx) throws ProcessingException { sources.put(MigrationGenerator.BACKEND_CLI_BINARY, MigrationGenerator.cliSource(librarySets, !migrations.isEmpty())); } + for (AnnotatedClass repository : repositories) { + try { + String name = repository.getBinaryName() + "Cn1Repository"; + if (!wouldReplaceAnExistingClass(name, "repository for " + repository.getBinaryName(), ctx)) { + sources.put(name, RepositoryGenerator.generate(repository, accepted)); + } + } catch (IllegalArgumentException error) { + ctx.error(repository.getBinaryName() + ": " + error.getMessage()); + } + } + if (ctx.hasErrors()) return; if (accepted.isEmpty() && migrations.isEmpty()) { removeAStaleBootstrap(ctx); if (!sources.isEmpty()) { diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/RepositoryGenerator.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/RepositoryGenerator.java new file mode 100644 index 00000000000..87817622d41 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/RepositoryGenerator.java @@ -0,0 +1,306 @@ +/* + * 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.AnnotatedClass; +import com.codename1.maven.annotations.AnnotationValues; +import com.codename1.maven.annotations.MethodInfo; +import org.objectweb.asm.Type; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Generates direct Session calls; no runtime proxies, reflection or scanning. */ +final class RepositoryGenerator { + static final String PREFIX = "Lcom/codename1/orm/repository/"; + static final String REPOSITORY = PREFIX + "Repository;"; + private static final String BASE = "com/codename1/orm/repository/CrudRepository"; + private static final String PAGE = "com.codename1.orm.repository.PageRequest"; + private final OrmAnnotationProcessor.EntityClass entity; + private final String entityType; + private final String keyType; + + private RepositoryGenerator(OrmAnnotationProcessor.EntityClass entity, String keyType) { + this.entity = entity; + this.entityType = entity.binaryName; + this.keyType = keyType; + } + static String generate(AnnotatedClass repository, Map entities) { + if (!repository.isInterface() || !repository.isPublic() || repository.getBinaryName().contains("$")) { + throw invalid("@Repository requires a public top-level interface"); + } + AnnotationValues ann = repository.getClassAnnotation(REPOSITORY); + Type entityType = (Type) ann.get("entity"), key = (Type) ann.get("key"); + if (entityType == null || key == null || key.getSort() != Type.OBJECT) { + throw invalid("@Repository needs explicit entity and boxed key class literals"); + } + OrmAnnotationProcessor.EntityClass entity = entities.get(entityType.getClassName()); + if (entity == null) throw invalid("Repository entity is not a generated @Entity: " + entityType.getClassName()); + String expectedKey = entity.embeddedId != null ? entity.embedded.get(entity.embeddedId) : entity.idFields.size() != 1 + ? "com.codename1.orm.session.Identifier" : domainType(entity.idFields.get(0)); + if (!key.getClassName().equals(expectedKey)) throw invalid("Repository key must be " + expectedKey); + String signature = "Ljava/lang/Object;L" + BASE + "<" + entityType.getDescriptor() + key.getDescriptor() + ">;"; + if (repository.getInterfaceInternalNames().size() != 1 + || !repository.getInterfaceInternalNames().get(0).equals(BASE) + || !signature.equals(repository.getSignature())) { + throw invalid("Extend CrudRepository<" + entityType.getClassName() + ", " + key.getClassName() + + "> directly; generic repositories and additional superinterfaces are unsupported"); + } + RepositoryGenerator generator = new RepositoryGenerator(entity, key.getClassName()); + StringBuilder methods = new StringBuilder(); + Set custom = new HashSet(); + for (MethodInfo method : repository.getMethods()) { + if (method.isStatic() || method.isSynthetic()) continue; + if (!method.isAbstract()) throw invalid(method.getName() + ": default methods are unsupported"); + try { methods.append(generator.method(method)); custom.add(method.getName()); } + catch (IllegalArgumentException error) { throw invalid(method.getName() + ": " + error.getMessage()); } + } + String e = generator.entityType, k = generator.keyType; + String[][] defaults = { + {"findById", "public " + e + " findById(" + k + " id) { return session.find(" + e + ".class,id); }"}, + {"save", "public " + e + " save(" + e + " entity) { return session.merge(entity); }"}, + {"insert", "public void insert(" + e + " entity) { session.persist(entity); }"}, + {"deleteById", "public void deleteById(" + k + " id) { requireTransaction(); " + e + " value=session.find(" + e + ".class,id); if(value!=null) session.remove(value); }"}, + {"findAll", "public java.util.List<" + e + "> findAll() { return session.query(" + e + ".class).list(); }"}, + {"count", "public long count() { return session.query(" + e + ".class).count(); }"}, + {"existsById", "public boolean existsById(" + k + " id) { return session.find(" + e + ".class,id)!=null; }"} + }; + for (String[] method : defaults) if (!custom.contains(method[0])) methods.append(method[1]).append('\n'); + int dot = repository.getBinaryName().lastIndexOf('.'); + String simple = repository.getBinaryName().substring(dot + 1) + "Cn1Repository"; + return (dot < 0 ? "" : "package " + repository.getBinaryName().substring(0, dot) + ";\n") + + "@com.codename1.backend.annotations.Generated public final class " + simple + " implements " + repository.getBinaryName() + " {\n" + + "private final com.codename1.orm.session.Session session;\n" + + "public " + simple + "(com.codename1.orm.session.Session session) { if(session==null) throw new IllegalArgumentException(\"session is null\"); this.session=session; " + + " }\n" + + "private void requireTransaction() { if(!session.isTransactionActive() || session.isRollbackOnly()) throw new com.codename1.orm.session.PersistenceException(\"Repository write requires an active usable transaction\"); }\n" + + methods + "}\n"; + } + private String method(MethodInfo method) { + Type[] args = Type.getArgumentTypes(method.getDescriptor()); + Type result = Type.getReturnType(method.getDescriptor()); + String name = method.getName(); + AnnotationValues query = method.getAnnotation(PREFIX + "Query;"), lock = method.getAnnotation(PREFIX + "Lock;"); + boolean modifying = method.getAnnotation(PREFIX + "Modifying;") != null; + if (method.getSignature() != null && method.getSignature().startsWith("<")) throw invalid("Generic methods are unsupported"); + if (lock != null) { + if (!name.equals("findById") || args.length != 1 || !args[0].getClassName().equals(keyType) + || !result.getClassName().equals(entityType) || query != null || modifying) { + throw invalid("@Lock is supported only on an entity-returning findById(key) override"); + } + String[] mode = (String[]) lock.get("value"); + return declaration(method, args, entityType) + "return session.find(" + entityType + ".class,p0,com.codename1.orm.session.LockMode." + mode[1] + ");}\n"; + } + if (name.equals("findById") || name.equals("save") || name.equals("insert") || name.equals("deleteById") + || name.equals("findAll") || name.equals("count") || name.equals("existsById")) { + throw invalid("Use the inherited CRUD method; only findById may be redeclared with @Lock"); + } + boolean list = result.getClassName().equals("java.util.List"); + String scalar = result.getClassName(); + if (list) { + String sig = method.getSignature(); + int pos = sig == null ? -1 : sig.indexOf(")Ljava/util/List<"); + if (pos < 0 || !sig.endsWith(">;")) throw invalid("Use List, not a raw List or wildcard"); + String item = sig.substring(pos + ")Ljava/util/List<".length(), sig.length() - 2); + if (!item.startsWith("L") || item.indexOf('<') >= 0 || !item.endsWith(";")) throw invalid("List needs a concrete element class"); + scalar = Type.getType(item).getClassName(); + } + int page = -1; + int values = args.length; + for (int i = 0; i < args.length; i++) if (args[i].getClassName().equals(PAGE)) { + if (page >= 0 || i != args.length - 1 || !list || modifying) throw invalid("PageRequest must be the last parameter of a list select"); + page = i; values--; + } + List names = new ArrayList(); + String statement; + boolean nativeSql = query != null && query.getBoolOrDefault("nativeSql", false); + boolean count = false, exists = false, delete = false; + int limit = -1; + if (query != null) { + statement = query.getString("value"); + if (statement == null || statement.trim().length() == 0) throw invalid("@Query must contain a statement"); + String upper = statement.trim().toUpperCase(java.util.Locale.ROOT); + boolean mutation = upper.matches("(?s)^(UPDATE|DELETE)\\s.*"); + if (mutation != modifying) throw invalid("UPDATE/DELETE requires @Modifying; SELECT must not use @Modifying"); + for (int i = 0; i < values; i++) { + AnnotationValues param = method.getParameterAnnotations().size() > i + ? method.getParameterAnnotations().get(i).get(PREFIX + "Param;") : null; + String binding = param == null ? null : param.getString("value"); + if (binding == null || !binding.matches("[A-Za-z_][A-Za-z0-9_]*") || names.contains(binding)) { + throw invalid("Every declared query argument needs a unique @Param(\"name\")"); + } + names.add(binding); + } + Set used = parameters(statement); + if (!used.equals(new HashSet(names))) throw invalid("@Param names must match query parameters exactly: " + used); + } else { + count = name.startsWith("countBy"); exists = name.startsWith("existsBy"); delete = name.startsWith("deleteBy"); + String tail; + if (count || exists || delete) tail = name.substring(name.indexOf("By") + 2); + else if (name.startsWith("findBy")) tail = name.substring(6); + else if (name.matches("find(First|Top)[0-9]*By.+")) { + int by = name.indexOf("By"); + String digits = name.substring(name.startsWith("findFirst") ? 9 : 7, by); + limit = digits.length() == 0 ? 1 : Integer.parseInt(digits); + if (limit <= 0) throw invalid("Top/First limit must be positive"); + tail = name.substring(by + 2); + } else throw invalid("Unsupported method name; use find/count/exists/deleteBy or @Query"); + if (modifying) throw invalid("Derived deletes do not need @Modifying"); + String ordering = ""; + int order = tail.indexOf("OrderBy"); + if (order >= 0) { + if (count || exists || delete) throw invalid("Only find methods support OrderBy"); + ordering = order(tail.substring(order + 7)); tail = tail.substring(0, order); + } + if (tail.length() == 0) throw invalid("Derived query needs a field predicate"); + StringBuilder where = new StringBuilder(); + for (String or : tail.split("Or(?=[A-Z])", -1)) { + if (where.length() > 0) where.append(" OR "); + where.append('('); + int begin = where.length(); + for (String term : or.split("And(?=[A-Z])", -1)) { + if (where.length() > begin) where.append(" AND "); + String suffix = "", operator = "="; + String[][] operations = {{"IsNotNull","IS NOT NULL"},{"IsNull","IS NULL"},{"GreaterThanEqual",">="},{"LessThanEqual","<="}, + {"GreaterThan",">"},{"LessThan","<"},{"Not","<>"},{"Like","LIKE"},{"In","IN"},{"True","= TRUE"},{"False","= FALSE"}}; + for (String[] operation : operations) if (term.endsWith(operation[0])) { suffix=operation[0];operator=operation[1];break; } + String field = field(term.substring(0, term.length() - suffix.length())); + where.append("e.").append(field).append(' ').append(operator); + if (!suffix.equals("IsNull") && !suffix.equals("IsNotNull") && !suffix.equals("True") && !suffix.equals("False")) { + if (names.size() >= values) throw invalid("Missing argument for " + field); + checkArgument(field, suffix, args[names.size()]); + String binding = "p" + names.size(); names.add(binding); + where.append(" :").append(binding); + } + } + where.append(')'); + } + statement = (delete ? "DELETE FROM " : count || exists ? "SELECT COUNT(e) FROM " : "SELECT e FROM ") + + entityType + " e WHERE " + where + ordering; + if (names.size() != values) throw invalid("Derived predicate expects " + names.size() + " value arguments, got " + values); + if (delete && (page >= 0 || !scalar.equals("int") && !scalar.equals("void"))) throw invalid("Derived delete returns int or void and cannot be paged"); + if (count && !scalar.equals("long") || exists && !scalar.equals("boolean")) throw invalid("countBy returns long; existsBy returns boolean"); + if (!delete && !count && !exists && !scalar.equals(entityType)) throw invalid("Derived find returns entity or List"); + } + boolean mutation = modifying || delete; + if (mutation && (!scalar.equals("int") && !scalar.equals("void") || list || page >= 0)) throw invalid("Modifying queries return int or void without pagination"); + if (!mutation && result.getSort() != Type.OBJECT && !count && !exists) throw invalid("Declared scalar selects must use boxed return types for SQL NULL"); + if (page >= 0 && limit > 0) throw invalid("Do not combine PageRequest with Top/First"); + String valueType = mutation ? "Object" : count || exists ? "Long" : scalar; + if (nativeSql && !mutation && !scalar.equals("java.lang.String") && !scalar.equals("java.lang.Long") + && !scalar.equals("java.lang.Integer") && !scalar.equals("java.lang.Double") && !scalar.equals("java.lang.Boolean")) { + throw invalid("Native repository selects return String, Long, Integer, Double or Boolean (or List thereof)"); + } + String create = "session." + (nativeSql ? "createNativeQuery" : "createQuery") + "(" + quote(statement) + "," + valueType + ".class)"; + StringBuilder body = new StringBuilder(declaration(method, args, list ? "java.util.List<" + scalar + ">" : scalar)); + body.append("com.codename1.orm.session.JpqlQuery<").append(valueType).append("> q=").append(create).append(';'); + for (int i = 0; i < names.size(); i++) body.append("q.setParameter(").append(quote(names.get(i))).append(",p").append(i).append(");"); + if (page >= 0) body.append("q.offset(p").append(page).append(".getOffset()).limit(p").append(page).append(".getSize());"); + if (limit > 0) body.append("q.limit(").append(limit).append(");"); + body.append(mutation ? scalar.equals("void") ? "q.executeUpdate();" : "return q.executeUpdate();" + : count ? "return q.first().longValue();" : exists ? "return q.first().longValue()!=0;" + : list ? "return q.list();" : "return q.first();"); + return body.append("}\n").toString(); + } + private void checkArgument(String field, String suffix, Type argument) { + if (suffix.equals("In")) { + String type = argument.getClassName(); + if (!(argument.getSort() == Type.ARRAY && argument.getElementType().getSort() == Type.OBJECT) + && !type.equals("java.lang.Iterable") && !type.equals("java.util.List") + && !type.equals("java.util.Collection") && !type.equals("java.util.Set")) { + throw invalid("In requires an Iterable, Collection, List, Set or object array argument"); + } + return; + } + for (OrmAnnotationProcessor.PersistedField property : entity.fields) if (property.fieldName.equals(field)) { + String expected = domainType(property); + if (!expected.equals(boxed(argument.getClassName()))) throw invalid("Argument for " + field + " must be " + expected); + if (suffix.equals("Like") && !expected.equals("java.lang.String")) throw invalid("Like requires a String property"); + return; + } + } + private String field(String token) { + if (token.length() == 0) throw invalid("Missing property name"); + String path = Character.toLowerCase(token.charAt(0)) + token.substring(1); + for (OrmAnnotationProcessor.PersistedField f : entity.fields) if (f.fieldName.equals(path) && f.relation == null) return path; + throw invalid("Unknown scalar property " + path + " on " + entityType + "; use @Query for relationship paths"); + } + private String order(String token) { + StringBuilder sql = new StringBuilder(" ORDER BY "); + java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("(.+?)(Asc|Desc)(?=[A-Z]|$)").matcher(token); + int end = 0; + while (matcher.find()) { + if (matcher.start() != end) throw invalid("Invalid OrderBy suffix"); + if (end > 0) sql.append(", "); + sql.append("e.").append(field(matcher.group(1))).append(matcher.group(2).equals("Asc") ? " ASC" : " DESC"); + end = matcher.end(); + } + if (end != token.length() || end == 0) throw invalid("OrderBy fields need explicit Asc or Desc"); + return sql.toString(); + } + private static Set parameters(String sql) { + Set names = new HashSet(); + char quote = 0; + for (int i = 0; i < sql.length(); i++) { + char c = sql.charAt(i); + if (quote != 0) { + if (c == quote) { + if (i + 1 < sql.length() && sql.charAt(i + 1) == quote) i++; + else quote = 0; + } + } else if (c == '\'' || c == '"') quote = c; + else if (c == ':' && i + 1 < sql.length() && sql.charAt(i + 1) == ':') i++; + else if (c == ':') { + int start = ++i; + while (i < sql.length() && Character.isJavaIdentifierPart(sql.charAt(i))) i++; + if (start == i) throw invalid("Expected parameter name after colon"); + names.add(sql.substring(start, i--)); + } + } + return names; + } + private static String declaration(MethodInfo method, Type[] args, String result) { + StringBuilder text = new StringBuilder("public ").append(result).append(' ').append(method.getName()).append('('); + for (int i = 0; i < args.length; i++) { + if (i > 0) text.append(','); + text.append(args[i].getClassName()).append(" p").append(i); + } + return text.append(") {").toString(); + } + private static String quote(String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r") + "\""; + } + private static String domainType(OrmAnnotationProcessor.PersistedField property) { + return boxed(property.domainType != null ? property.domainType + : property.kind.kind == PropertyTypeKind.Kind.PROPERTY ? property.kind.elementBinaryName : property.javaType); + } + private static String boxed(String type) { + String[] primitive={"long","int","short","byte","boolean","double","float","char"}; + String[] boxed={"Long","Integer","Short","Byte","Boolean","Double","Float","Character"}; + for(int i=0;i sources=new java.util.LinkedHashMap(); + sources.put("repositories.Note","package repositories; import com.codename1.annotations.*; @Entity(table=\"repository_note\") public class Note { @Id public long id; public String name; public long score; }"); + sources.put("repositories.Notes","package repositories; import com.codename1.orm.repository.*; import java.util.List; @Repository(entity=Note.class,key=Long.class) public interface Notes extends CrudRepository { " + + "List findByNameAndScoreGreaterThanOrderByScoreDescIdAsc(String name,long score,PageRequest page); " + + "List findTop1ByNameOrderByScoreDesc(String name); long countByName(String name); boolean existsByName(String name); int deleteByName(String name); " + + "@Lock(com.codename1.orm.session.LockMode.PESSIMISTIC_WRITE) Note findById(Long id); " + + "@Query(\"select sum(case when e.score>=:floor then 1 else 0 end) from repositories.Note e\") Long qualified(@Param(\"floor\") long floor); " + + "@Modifying @Query(\"update repositories.Note e set e.score=:score where e.name=:name\") int revise(@Param(\"name\") String name,@Param(\"score\") long score); " + + "@Query(value=\"SELECT COUNT(*) FROM repository_note WHERE name=:name\",nativeSql=true) Long nativeCount(@Param(\"name\") String name); }"); + JavaSourceCompiler.compile(sources,classes,Arrays.asList(testClassesDir())); + ProcessorContext ctx=runProcessor(classes,backend ? backendClasspath() : Collections.emptyList());assertFalse(ctx.getErrors().toString(),ctx.hasErrors()); + // Compile a real caller against the generated class, so generic signatures and all + // CRUD methods are checked by javac as well as exercised against a database. + sources.clear(); + sources.put("repositories.Check","package repositories; import com.codename1.orm.repository.*; import com.codename1.orm.session.*; public class Check { public static void run(Session s) { " + + "Notes repo=new NotesCn1Repository(s); s.createTables(); s.beginTransaction(); Note a=new Note(); a.name=\"same\";a.score=3;repo.insert(a);Note b=new Note();b.name=\"same\";b.score=9;repo.insert(b);s.commitTransaction(); " + + "if(repo.count()!=2 || !repo.existsByName(\"same\") || repo.countByName(\"same\")!=2 || repo.nativeCount(\"same\")!=2) throw new AssertionError(\"counts\"); " + + "if(repo.findByNameAndScoreGreaterThanOrderByScoreDescIdAsc(\"same\",0,new PageRequest(1,1)).get(0)!=a || repo.findTop1ByNameOrderByScoreDesc(\"same\").get(0)!=b || repo.qualified(5)!=1) throw new AssertionError(\"queries\"); " + + "s.beginTransaction(); a.score=4; if(repo.revise(\"same\",11)!=2 || s.contains(a)) throw new AssertionError(\"bulk\"); s.rollbackTransaction(); " + + "if(s.find(Note.class,a.id).score!=3) throw new AssertionError(\"rollback\"); " + + "s.beginTransaction(); try { repo.findById(a.id); throw new AssertionError(\"SQLite should refuse row lock\"); } catch(UnsupportedOperationException expected) {} s.rollbackTransaction(); " + + "s.beginTransaction(); if(repo.save(a)==a) throw new AssertionError(\"merge returns managed copy\"); repo.deleteById(b.id); s.commitTransaction(); " + + "s.beginTransaction(); if(repo.deleteByName(\"same\")!=1) throw new AssertionError(\"delete\");s.commitTransaction();if(repo.count()!=0) throw new AssertionError(); }}"); + JavaSourceCompiler.compile(sources,classes,Arrays.asList(classes,testClassesDir())); + java.net.URLClassLoader loader=new java.net.URLClassLoader(new URL[]{classes.toURI().toURL()},getClass().getClassLoader()); + loader.loadClass(backend ? "cn1app.BackendDaoBootstrap" : "cn1app.DaoBootstrap").newInstance(); + com.codename1.backend.orm.EntityManager server=backend ? com.codename1.backend.orm.EntityManager.open(com.codename1.backend.Database.open(":memory:")) : null; + com.codename1.orm.EntityManager client=backend ? null : com.codename1.orm.EntityManager.open(new com.codename1.impl.javase.SEDatabase(java.sql.DriverManager.getConnection("jdbc:sqlite::memory:"))); + com.codename1.orm.session.Session session=backend ? server.openSession() : client.openSession(); + try { loader.loadClass("repositories.Check").getMethod("run",com.codename1.orm.session.Session.class).invoke(null,session); } + finally { session.close();if(server!=null)server.close();if(client!=null)client.close();loader.close(); } + } + + @Test + public void generatedRepositoriesRejectUnsupportedSignatures() throws Exception { + for(String declaration:new String[]{ + "java.util.List findByMissing(String value);", + "long findByName(String value);", + "java.util.List findByName();", + "java.util.List findByName(long value);", + "@Query(\"select e from badrepo.Note e where e.name=:name\") Note lookup(String name);", + "@Query(\"update badrepo.Note e set e.name=:name\") int rename(@Param(\"name\") String name);", + "@Lock(com.codename1.orm.session.LockMode.PESSIMISTIC_WRITE) Note findByName(String name);" + }) { + File classes=tmp.newFolder();Map sources=new java.util.LinkedHashMap(); + sources.put("badrepo.Note","package badrepo; import com.codename1.annotations.*; @Entity public class Note { @Id public long id; public String name; }"); + sources.put("badrepo.Notes","package badrepo; import com.codename1.orm.repository.*; @Repository(entity=Note.class,key=Long.class) public interface Notes extends CrudRepository {"+declaration+"}"); + JavaSourceCompiler.compile(sources,classes,Arrays.asList(testClassesDir())); + ProcessorContext ctx=runProcessor(classes,backendClasspath()); + assertTrue(declaration+ctx.getErrors(),ctx.hasErrors()); + assertFalse(new File(classes,"badrepo/NotesCn1Repository.class").exists()); + } + } + @Test public void managedPublicContractsDoNotExposeRuntimePlumbing() { Class[] contracts = { diff --git a/vm/backend/contract/com/demo/Records.java b/vm/backend/contract/com/demo/Records.java new file mode 100644 index 00000000000..faa8294d599 --- /dev/null +++ b/vm/backend/contract/com/demo/Records.java @@ -0,0 +1,47 @@ +/* + * 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.demo; + +import com.codename1.orm.repository.*; +import com.codename1.orm.session.LockMode; +import java.util.List; + +/** Build-time repository example; no handwritten implementation. */ +// tag::generated-repository[] +@Repository(entity=RepositoryRecord.class, key=Long.class) +public interface Records extends CrudRepository { + List findByNameAndScoreGreaterThanOrderByScoreDescIdAsc(String name, long score, PageRequest page); + boolean existsByName(String name); + long countByName(String name); + int deleteByName(String name); + @Lock(LockMode.PESSIMISTIC_WRITE) + RepositoryRecord findById(Long id); + @Query("select sum(case when r.score>=:floor then 1 else 0 end) from com.demo.RepositoryRecord r") + Long qualified(@Param("floor") long floor); + @Query(value="SELECT COUNT(*) FROM repository_records WHERE name=:name OR name=:name", nativeSql=true) + Long nativeCount(@Param("name") String name); + @Modifying + @Query("update com.demo.RepositoryRecord r set r.score=:score where r.name=:name") + int revise(@Param("name") String name, @Param("score") long score); +} +// end::generated-repository[] diff --git a/vm/backend/contract/com/demo/RepositoryRecord.java b/vm/backend/contract/com/demo/RepositoryRecord.java new file mode 100644 index 00000000000..44f590b3da6 --- /dev/null +++ b/vm/backend/contract/com/demo/RepositoryRecord.java @@ -0,0 +1,34 @@ +/* + * 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.demo; + +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/** Fixture shared by generated repository checks on the JVM and ParparVM. */ +@Entity(table="repository_records") +public class RepositoryRecord { + @Id public long id; + public String name; + public long score; +} diff --git a/vm/backend/demo/ormcheck/com/demo/OrmCheck.java b/vm/backend/demo/ormcheck/com/demo/OrmCheck.java index ac887b89e5d..a3d6bc46e30 100644 --- a/vm/backend/demo/ormcheck/com/demo/OrmCheck.java +++ b/vm/backend/demo/ormcheck/com/demo/OrmCheck.java @@ -73,6 +73,7 @@ public static void main(String[] args) throws Exception { notes.dropTable(); notes.createTable(); try { + generatedRepositories(em, pool.dialect().getName()); roundTripsEveryColumn(notes); updatesAndDeletes(notes); queriesByFieldName(notes, pool.dialect().getName()); @@ -108,6 +109,39 @@ public static void main(String[] args) throws Exception { } } + private static void generatedRepositories(EntityManager em, String dialect) { + com.codename1.orm.session.Session s=em.openSession(); + try { + s.createTables(); + Records records=new RecordsCn1Repository(s); + s.beginTransaction(); + s.createQuery("delete from com.demo.RepositoryRecord").executeUpdate(); + RepositoryRecord first=new RepositoryRecord();first.name="repository";first.score=3;records.insert(first); + RepositoryRecord second=new RepositoryRecord();second.name="repository";second.score=9;records.insert(second); + s.commitTransaction(); + check("repository count", "2", ""+records.count()); + check("repository exists", "true", ""+records.existsByName("repository")); + check("repository native binding", "2", ""+records.nativeCount("repository")); + check("repository CASE aggregate", "1", ""+records.qualified(5)); + check("repository ordered page", ""+first.id, ""+records.findByNameAndScoreGreaterThanOrderByScoreDescIdAsc( + "repository",0,new com.codename1.orm.repository.PageRequest(1,1)).get(0).id); + s.beginTransaction();first.score=4; + check("repository bulk rows", "2", ""+records.revise("repository",11)); + check("repository bulk clears", "false", ""+s.contains(first)); + s.rollbackTransaction(); + check("repository rollback", "1", ""+records.qualified(5)); + s.beginTransaction(); + if (!"sqlite".equals(dialect)) { + check("repository locked lookup", "3", ""+records.findById(first.id).score); + } else { + try { records.findById(first.id); check("repository SQLite lock rejection","rejected","accepted"); } + catch(UnsupportedOperationException expected) { passed++; } + } + check("repository derived delete", "2", ""+records.deleteByName("repository")); + s.commitTransaction(); + } finally { s.close(); } + } + /** * Every column type, out and back. * diff --git a/vm/backend/generate-contract.sh b/vm/backend/generate-contract.sh index 6d5ea0df0e0..54b64791d5a 100755 --- a/vm/backend/generate-contract.sh +++ b/vm/backend/generate-contract.sh @@ -131,7 +131,8 @@ for f in $(find contract -name '*.java'); do dao="gen/${rel%.java}Cn1BackendDao.class" # An @Entity ships for the same reason a DTO does: the generated dao names # it, so dropping the class would leave the dao unlinkable. - if [ ! -f "$codec" ] && [ ! -f "$dao" ]; then + repository="gen/${rel%.java}Cn1Repository.class" + if [ ! -f "$codec" ] && [ ! -f "$dao" ] && [ ! -f "$repository" ]; then rm -f "$cls" "gen/${rel%.java}"'$'*.class 2>/dev/null || true fi done diff --git a/vm/backend/src/com/codename1/impl/backend/TransactionSession.java b/vm/backend/src/com/codename1/impl/backend/TransactionSession.java index 7231241dead..f0e8fdb5fc4 100644 --- a/vm/backend/src/com/codename1/impl/backend/TransactionSession.java +++ b/vm/backend/src/com/codename1/impl/backend/TransactionSession.java @@ -62,6 +62,11 @@ private static UnsupportedOperationException boundary(String what) { + "Transactions.setRollbackOnly()."); } + @Override + public JpqlQuery createNativeQuery(String statement, Class resultType) { + return current().createNativeQuery(statement, resultType); + } + @Override public JpqlQuery createQuery(String statement, Class resultType) { return current().createQuery(statement, resultType); From 8500deec29911f37e5227ef3e409c13d8ef3e836 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:17:07 +0300 Subject: [PATCH 2/8] Validate repository predicates and native SQL contracts --- .../codename1/impl/orm/NativeQueryImpl.java | 4 +- .../codename1/impl/orm/SessionSqlAccess.java | 3 + .../Annotation-SQLite-ORM.asciidoc | 4 +- .../backend/orm/ManagedSessionTest.java | 18 +++++ .../processors/OrmAnnotationProcessor.java | 18 +++++ .../maven/processors/RepositoryGenerator.java | 69 +++++++++++++++---- .../OrmAnnotationProcessorTest.java | 56 ++++++++++++++- .../com/codename1/orm/ManagedSessionTest.java | 18 +++++ 8 files changed, 173 insertions(+), 17 deletions(-) diff --git a/CodenameOne/src/com/codename1/impl/orm/NativeQueryImpl.java b/CodenameOne/src/com/codename1/impl/orm/NativeQueryImpl.java index c9ab0a27669..3f69006f554 100644 --- a/CodenameOne/src/com/codename1/impl/orm/NativeQueryImpl.java +++ b/CodenameOne/src/com/codename1/impl/orm/NativeQueryImpl.java @@ -96,7 +96,9 @@ private static boolean keyword(String sql, String word) { } public JpqlQuery setParameter(String name, Object value) { if (!names.contains(name)) throw new IllegalArgumentException("Unknown named parameter: " + name); - if (value instanceof Iterable || value instanceof Object[]) throw new IllegalArgumentException("Native parameters must be scalars"); + if (value instanceof Iterable || value != null && value.getClass().isArray() && !(value instanceof byte[])) { + throw new IllegalArgumentException("Native parameters must be scalars or byte[] BLOBs"); + } parameters.put(name, value); return this; } public JpqlQuery limit(int value) { diff --git a/CodenameOne/src/com/codename1/impl/orm/SessionSqlAccess.java b/CodenameOne/src/com/codename1/impl/orm/SessionSqlAccess.java index 0a4786f4c09..1370f0f65fb 100644 --- a/CodenameOne/src/com/codename1/impl/orm/SessionSqlAccess.java +++ b/CodenameOne/src/com/codename1/impl/orm/SessionSqlAccess.java @@ -143,6 +143,9 @@ public List query(String sql, Object[] args, int[] kinds) throws IOExc checkOwner(); Cursor cursor = db.executeQuery(sql, args); try { + if (cursor.getColumnCount() != kinds.length) { + throw new IOException("Unexpected SQL projection width"); + } List result = new ArrayList(); while (cursor.next()) { Row row = cursor.getRow(); diff --git a/docs/developer-guide/Annotation-SQLite-ORM.asciidoc b/docs/developer-guide/Annotation-SQLite-ORM.asciidoc index 8f62c0846e4..965880a45b4 100644 --- a/docs/developer-guide/Annotation-SQLite-ORM.asciidoc +++ b/docs/developer-guide/Annotation-SQLite-ORM.asciidoc @@ -483,7 +483,9 @@ Derived methods support the following bounded grammar: Comparison suffixes are `Not`, `GreaterThan`, `GreaterThanEqual`, `LessThan`, `LessThanEqual`, `Like`, and `In`. No suffix means equality. `IsNull`, `IsNotNull`, `True`, and `False` consume no parameter. `In` takes an Iterable or - object array. Use `IsNull` to match NULL; equality retains SQL NULL semantics. + object array whose concrete element type matches the property. Raw and wildcard + collections are rejected at build time. `True` and `False` require Boolean + properties with Boolean storage. Use `IsNull` to match NULL; equality retains SQL NULL semantics. * Find methods can end with `OrderByScoreDescIdAsc`; every field needs an explicit `Asc` or `Desc`. Relationship paths and ambiguous property names containing grammar keywords should use `@Query` instead. 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 index 81d73504d57..ab946940408 100644 --- a/maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionTest.java +++ b/maven/backend/src/test/java/com/codename1/backend/orm/ManagedSessionTest.java @@ -72,6 +72,24 @@ static void assertCaseAndNativeQueries(Session s) { s.beginTransaction();s.createQuery("delete from "+Record.class.getName()+"").executeUpdate();s.commitTransaction(); } + @Test void nativeScalarsRejectExtraColumnsAndNonBlobArrays() throws Exception { + EntityManager em=manager(); Session s=em.openSession(); + try { + Record row=seed(s); + PersistenceException width=assertThrows(PersistenceException.class,()-> + s.createNativeQuery("SELECT id,name FROM managed_record",Long.class).list()); + assertTrue(width.getCause().getMessage().contains("projection width")); + assertEquals(Long.valueOf(row.id),s.createNativeQuery("SELECT id FROM managed_record",Long.class).first()); + com.codename1.orm.session.JpqlQuery query=s.createNativeQuery("SELECT COUNT(*) FROM managed_record WHERE id=:id",Long.class); + for(Object value:new Object[]{new boolean[0],new char[0],new short[0],new int[0],new long[0],new float[0],new double[0],new String[0],new byte[0][],java.util.Collections.emptyList()}) { + assertThrows(IllegalArgumentException.class,()->query.setParameter("id",value)); + } + assertEquals(Long.valueOf(0),query.setParameter("id",null).first()); + assertEquals(Long.valueOf(1),query.setParameter("id",row.id).first()); + assertArrayEquals(row.bytes,s.createNativeQuery("SELECT bytes FROM managed_record WHERE bytes=:bytes",byte[].class).setParameter("bytes",row.bytes).first()); + } finally { s.close();em.close(); } + } + static class Record { long id,version,counter; String name; diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java index b719160d758..3ba51554625 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/OrmAnnotationProcessor.java @@ -691,6 +691,7 @@ private void processClass(AnnotatedClass cls, ProcessorContext ctx, boolean from @Override public void finish(ProcessorContext ctx) throws ProcessingException { + removeStaleRepositories(ctx); { // 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 @@ -2331,6 +2332,23 @@ private void removeStaleMigrations(ProcessorContext ctx, boolean noEntities, } } + private void removeStaleRepositories(ProcessorContext ctx) throws ProcessingException { + Set live = new LinkedHashSet(); + for (AnnotatedClass repository : repositories) live.add(repository.getBinaryName() + "Cn1Repository"); + for (AnnotatedClass cls : ctx.getClassIndex().values()) { + String name = cls.getBinaryName(); + if (!name.endsWith("Cn1Repository") || live.contains(name) + || cls.getClassAnnotation(GENERATED_DESC) == null) continue; + String owner = name.substring(0, name.length() - "Cn1Repository".length()).replace('.', '/'); + if (!cls.getInterfaceInternalNames().contains(owner)) continue; + File output = new File(ctx.getOutputClassDir(), cls.getInternalName() + ".class"); + if (output.isFile() && !output.delete()) { + throw new ProcessingException("Could not remove stale generated repository " + name + "; run a clean build"); + } + ctx.getLog().info("cn1: removed " + name + ", whose repository annotation or source was removed"); + } + } + /// Deletes a generated class and the classes nested in it from the output /// directory; whether the class itself was there and is gone. private static boolean deleteGenerated(ProcessorContext ctx, String binaryName) { diff --git a/maven/build-engine/src/main/java/com/codename1/maven/processors/RepositoryGenerator.java b/maven/build-engine/src/main/java/com/codename1/maven/processors/RepositoryGenerator.java index 87817622d41..42a462a7c45 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/processors/RepositoryGenerator.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/processors/RepositoryGenerator.java @@ -187,10 +187,17 @@ else if (name.matches("find(First|Top)[0-9]*By.+")) { {"GreaterThan",">"},{"LessThan","<"},{"Not","<>"},{"Like","LIKE"},{"In","IN"},{"True","= TRUE"},{"False","= FALSE"}}; for (String[] operation : operations) if (term.endsWith(operation[0])) { suffix=operation[0];operator=operation[1];break; } String field = field(term.substring(0, term.length() - suffix.length())); + if (suffix.equals("True") || suffix.equals("False")) { + OrmAnnotationProcessor.PersistedField property = property(field); + if (property.dialectKind != OrmAnnotationProcessor.KIND_BOOLEAN + || !domainType(property).equals("java.lang.Boolean")) { + throw invalid(suffix + " requires a Boolean property with Boolean storage: " + field); + } + } where.append("e.").append(field).append(' ').append(operator); if (!suffix.equals("IsNull") && !suffix.equals("IsNotNull") && !suffix.equals("True") && !suffix.equals("False")) { if (names.size() >= values) throw invalid("Missing argument for " + field); - checkArgument(field, suffix, args[names.size()]); + checkArgument(field, suffix, args[names.size()], method, names.size()); String binding = "p" + names.size(); names.add(binding); where.append(" :").append(binding); } @@ -224,22 +231,60 @@ else if (name.matches("find(First|Top)[0-9]*By.+")) { : list ? "return q.list();" : "return q.first();"); return body.append("}\n").toString(); } - private void checkArgument(String field, String suffix, Type argument) { + private void checkArgument(String field, String suffix, Type argument, MethodInfo method, int parameter) { + String expected = domainType(property(field)); if (suffix.equals("In")) { - String type = argument.getClassName(); - if (!(argument.getSort() == Type.ARRAY && argument.getElementType().getSort() == Type.OBJECT) - && !type.equals("java.lang.Iterable") && !type.equals("java.util.List") - && !type.equals("java.util.Collection") && !type.equals("java.util.Set")) { - throw invalid("In requires an Iterable, Collection, List, Set or object array argument"); + String element; + if (argument.getSort() == Type.ARRAY) { + Type component = Type.getType(argument.getDescriptor().substring(1)); + if (component.getSort() != Type.OBJECT && component.getSort() != Type.ARRAY) { + throw invalid("In requires an object array, not a primitive array: " + field); + } + element = component.getClassName(); + } else { + String type = argument.getClassName(); + if (!type.equals("java.lang.Iterable") && !type.equals("java.util.List") + && !type.equals("java.util.Collection") && !type.equals("java.util.Set")) { + throw invalid("In requires an Iterable, Collection, List, Set or object array argument"); + } + element = collectionElement(method, parameter, argument); } + if (!expected.equals(element)) throw invalid("In elements for " + field + " must be " + expected + ", got " + element); return; } - for (OrmAnnotationProcessor.PersistedField property : entity.fields) if (property.fieldName.equals(field)) { - String expected = domainType(property); - if (!expected.equals(boxed(argument.getClassName()))) throw invalid("Argument for " + field + " must be " + expected); - if (suffix.equals("Like") && !expected.equals("java.lang.String")) throw invalid("Like requires a String property"); - return; + if (!expected.equals(boxed(argument.getClassName()))) throw invalid("Argument for " + field + " must be " + expected); + if (suffix.equals("Like") && !expected.equals("java.lang.String")) throw invalid("Like requires a String property"); + } + private static String collectionElement(MethodInfo method, int parameter, Type argument) { + final List parameters = + new ArrayList(); + if (method.getSignature() != null) { + new org.objectweb.asm.signature.SignatureReader(method.getSignature()).accept( + new org.objectweb.asm.signature.SignatureVisitor(org.objectweb.asm.Opcodes.ASM9) { + @Override public org.objectweb.asm.signature.SignatureVisitor visitParameterType() { + org.objectweb.asm.signature.SignatureWriter writer = new org.objectweb.asm.signature.SignatureWriter(); + parameters.add(writer); + return writer; + } + }); + } + String signature = parameter < parameters.size() ? parameters.get(parameter).toString() : ""; + String prefix = "L" + argument.getInternalName() + "<"; + if (signature.startsWith(prefix) && signature.endsWith(">;")) { + String element = signature.substring(prefix.length(), signature.length() - 2); + // Accept only a concrete class or array; raw, wildcard, variable and + // nested generic element types cannot prove the mapped domain type. + if (element.matches("(?:L[^;<]+;|\\[+(?:L[^;<]+;|[ZBCSIJFD]))")) { + return Type.getType(element).getClassName(); + } + } + throw invalid("In collections require a concrete element type matching the property (no raw or wildcard collections)"); + } + private OrmAnnotationProcessor.PersistedField property(String name) { + for (OrmAnnotationProcessor.PersistedField property : entity.fields) { + if (property.fieldName.equals(name)) return property; } + throw invalid("Unknown scalar property " + name); } private String field(String token) { if (token.length() == 0) throw invalid("Missing property name"); diff --git a/maven/build-engine/src/test/java/com/codename1/maven/processors/OrmAnnotationProcessorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/processors/OrmAnnotationProcessorTest.java index afe77f9ebeb..63bcec94ee5 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/processors/OrmAnnotationProcessorTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/processors/OrmAnnotationProcessorTest.java @@ -67,9 +67,10 @@ public void generatedRepositoryExecutesCrudDerivedPagingLocksAndDeclaredQueries( private void generatedRepository(boolean backend) throws Exception { File classes=tmp.newFolder(); Map sources=new java.util.LinkedHashMap(); - sources.put("repositories.Note","package repositories; import com.codename1.annotations.*; @Entity(table=\"repository_note\") public class Note { @Id public long id; public String name; public long score; }"); + sources.put("repositories.Note","package repositories; import com.codename1.annotations.*; @Entity(table=\"repository_note\") public class Note { @Id public long id; public String name; public long score; public boolean active; public Boolean archived; }"); sources.put("repositories.Notes","package repositories; import com.codename1.orm.repository.*; import java.util.List; @Repository(entity=Note.class,key=Long.class) public interface Notes extends CrudRepository { " + "List findByNameAndScoreGreaterThanOrderByScoreDescIdAsc(String name,long score,PageRequest page); " + + "List findByActiveTrue(); List findByArchivedFalse(); List findByNameAndScoreIn(String name,java.util.List scores); List findByScoreIn(Long[] scores); List findByNameIn(java.util.Set names); " + "List findTop1ByNameOrderByScoreDesc(String name); long countByName(String name); boolean existsByName(String name); int deleteByName(String name); " + "@Lock(com.codename1.orm.session.LockMode.PESSIMISTIC_WRITE) Note findById(Long id); " + "@Query(\"select sum(case when e.score>=:floor then 1 else 0 end) from repositories.Note e\") Long qualified(@Param(\"floor\") long floor); " @@ -81,9 +82,10 @@ private void generatedRepository(boolean backend) throws Exception { // CRUD methods are checked by javac as well as exercised against a database. sources.clear(); sources.put("repositories.Check","package repositories; import com.codename1.orm.repository.*; import com.codename1.orm.session.*; public class Check { public static void run(Session s) { " - + "Notes repo=new NotesCn1Repository(s); s.createTables(); s.beginTransaction(); Note a=new Note(); a.name=\"same\";a.score=3;repo.insert(a);Note b=new Note();b.name=\"same\";b.score=9;repo.insert(b);s.commitTransaction(); " + + "Notes repo=new NotesCn1Repository(s); s.createTables(); s.beginTransaction(); Note a=new Note(); a.name=\"same\";a.score=3;a.active=true;a.archived=false;repo.insert(a);Note b=new Note();b.name=\"same\";b.score=9;repo.insert(b);s.commitTransaction(); " + "if(repo.count()!=2 || !repo.existsByName(\"same\") || repo.countByName(\"same\")!=2 || repo.nativeCount(\"same\")!=2) throw new AssertionError(\"counts\"); " + "if(repo.findByNameAndScoreGreaterThanOrderByScoreDescIdAsc(\"same\",0,new PageRequest(1,1)).get(0)!=a || repo.findTop1ByNameOrderByScoreDesc(\"same\").get(0)!=b || repo.qualified(5)!=1) throw new AssertionError(\"queries\"); " + + "if(repo.findByActiveTrue().get(0)!=a || repo.findByArchivedFalse().get(0)!=a || repo.findByNameAndScoreIn(\"same\",java.util.Arrays.asList(3L)).get(0)!=a || repo.findByScoreIn(new Long[]{9L}).get(0)!=b || repo.findByNameIn(java.util.Collections.singleton(\"same\")).size()!=2) throw new AssertionError(\"typed finders\"); " + "s.beginTransaction(); a.score=4; if(repo.revise(\"same\",11)!=2 || s.contains(a)) throw new AssertionError(\"bulk\"); s.rollbackTransaction(); " + "if(s.find(Note.class,a.id).score!=3) throw new AssertionError(\"rollback\"); " + "s.beginTransaction(); try { repo.findById(a.id); throw new AssertionError(\"SQLite should refuse row lock\"); } catch(UnsupportedOperationException expected) {} s.rollbackTransaction(); " @@ -106,12 +108,23 @@ public void generatedRepositoriesRejectUnsupportedSignatures() throws Exception "long findByName(String value);", "java.util.List findByName();", "java.util.List findByName(long value);", + "java.util.List findByNameTrue();", + "java.util.List findByScoreFalse();", + "java.util.List findByScoreIn(String[] values);", + "java.util.List findByScoreIn(Long[][] values);", + "java.util.List findByScoreIn(long[] values);", + "java.util.List findByScoreIn(java.util.List values);", + "java.util.List findByNameAndScoreIn(String name,java.util.Set values);", + "java.util.List findByScoreIn(java.util.List values);", + "java.util.List findByScoreIn(java.util.List values);", + "java.util.List findByScoreIn(java.util.List values);", + "java.util.List findByScoreIn(java.util.List> values);", "@Query(\"select e from badrepo.Note e where e.name=:name\") Note lookup(String name);", "@Query(\"update badrepo.Note e set e.name=:name\") int rename(@Param(\"name\") String name);", "@Lock(com.codename1.orm.session.LockMode.PESSIMISTIC_WRITE) Note findByName(String name);" }) { File classes=tmp.newFolder();Map sources=new java.util.LinkedHashMap(); - sources.put("badrepo.Note","package badrepo; import com.codename1.annotations.*; @Entity public class Note { @Id public long id; public String name; }"); + sources.put("badrepo.Note","package badrepo; import com.codename1.annotations.*; @Entity public class Note { @Id public long id; public String name; public long score; }"); sources.put("badrepo.Notes","package badrepo; import com.codename1.orm.repository.*; @Repository(entity=Note.class,key=Long.class) public interface Notes extends CrudRepository {"+declaration+"}"); JavaSourceCompiler.compile(sources,classes,Arrays.asList(testClassesDir())); ProcessorContext ctx=runProcessor(classes,backendClasspath()); @@ -120,6 +133,43 @@ public void generatedRepositoriesRejectUnsupportedSignatures() throws Exception } } + @Test + public void incrementalBuildRemovesOnlyStaleGeneratedRepositories() throws Exception { + for (boolean backend : new boolean[]{false,true}) { + for (boolean deleteSource : new boolean[]{false,true}) { + File classes=tmp.newFolder(), sourcesDir=tmp.newFolder(); + Map sources=new java.util.LinkedHashMap(); + sources.put("incrementalrepo.Note","package incrementalrepo; import com.codename1.annotations.*; @Entity public class Note { @Id public long id; }"); + String repository="package incrementalrepo; import com.codename1.orm.repository.*; @Repository(entity=Note.class,key=Long.class) public interface Notes extends CrudRepository {}"; + sources.put("incrementalrepo.Notes",repository); + sources.put("incrementalrepo.Live",repository.replace("interface Notes", "interface Live")); + sources.put("incrementalrepo.HandwrittenCn1Repository","package incrementalrepo; public class HandwrittenCn1Repository {}"); + File pkg=new File(sourcesDir,"incrementalrepo");assertTrue(pkg.mkdirs()); + for(Map.Entry source:sources.entrySet()) { + writeFile(new File(pkg,source.getKey().substring(source.getKey().lastIndexOf('.')+1)+".java"),source.getValue()); + } + JavaSourceCompiler.compile(sources,classes,Arrays.asList(testClassesDir())); + List cp=backend?backendClasspath():Collections.emptyList(); + ProcessorContext first=runProcessor(classes,cp,Arrays.asList(sourcesDir.getAbsolutePath())); + assertFalse(first.getErrors().toString(),first.hasErrors()); + File generated=new File(classes,"incrementalrepo/NotesCn1Repository.class");assertTrue(generated.isFile()); + File handwritten=new File(classes,"incrementalrepo/HandwrittenCn1Repository.class");byte[] original=Files.readAllBytes(handwritten.toPath()); + if(deleteSource) { + assertTrue(new File(pkg,"Notes.java").delete()); // Leave its stale .class, like Maven does. + } else { + sources.clear();sources.put("incrementalrepo.Notes",repository.replace("@Repository(entity=Note.class,key=Long.class) ","")); + writeFile(new File(pkg,"Notes.java"),sources.get("incrementalrepo.Notes")); + JavaSourceCompiler.compile(sources,classes,Arrays.asList(classes,testClassesDir())); + } + ProcessorContext second=runProcessor(classes,cp,Arrays.asList(sourcesDir.getAbsolutePath())); + assertFalse(second.getErrors().toString(),second.hasErrors()); + assertFalse("obsolete implementation survives incremental processing",generated.exists()); + assertTrue(new File(classes,"incrementalrepo/LiveCn1Repository.class").isFile()); + org.junit.Assert.assertArrayEquals(original,Files.readAllBytes(handwritten.toPath())); + } + } + } + @Test public void managedPublicContractsDoNotExposeRuntimePlumbing() { Class[] contracts = { diff --git a/maven/javase/src/test/java/com/codename1/orm/ManagedSessionTest.java b/maven/javase/src/test/java/com/codename1/orm/ManagedSessionTest.java index 6fb9d27358e..a49437955a8 100644 --- a/maven/javase/src/test/java/com/codename1/orm/ManagedSessionTest.java +++ b/maven/javase/src/test/java/com/codename1/orm/ManagedSessionTest.java @@ -33,6 +33,24 @@ import static org.junit.jupiter.api.Assertions.*; class ManagedSessionTest { + @Test void nativeScalarsRejectExtraColumnsAndNonBlobArrays() throws Exception { + EntityManager em=manager(); Session s=em.openSession(); + try { + Record row=seed(s); + PersistenceException width=assertThrows(PersistenceException.class,()-> + s.createNativeQuery("SELECT id,name FROM managed_record",Long.class).list()); + assertTrue(width.getCause().getMessage().contains("projection width")); + assertEquals(Long.valueOf(row.id),s.createNativeQuery("SELECT id FROM managed_record",Long.class).first()); + com.codename1.orm.session.JpqlQuery query=s.createNativeQuery("SELECT COUNT(*) FROM managed_record WHERE id=:id",Long.class); + for(Object value:new Object[]{new boolean[0],new char[0],new short[0],new int[0],new long[0],new float[0],new double[0],new String[0],new byte[0][],java.util.Collections.emptyList()}) { + assertThrows(IllegalArgumentException.class,()->query.setParameter("id",value)); + } + assertEquals(Long.valueOf(0),query.setParameter("id",null).first()); + assertEquals(Long.valueOf(1),query.setParameter("id",row.id).first()); + assertArrayEquals(row.bytes,s.createNativeQuery("SELECT bytes FROM managed_record WHERE bytes=:bytes",byte[].class).setParameter("bytes",row.bytes).first()); + } finally { s.close();em.close(); } + } + static class Record { long id,version,counter; String name; From 1c82e5ca28b6b5fe71a68747e13ff34ddcec8e47 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Mon, 12 Oct 2026 03:53:44 +0300 Subject: [PATCH 3/8] Fix ORM quality gates, guide snippets, and UI CI checks Clear all core PMD and Checkstyle findings and include compiled repository examples from docs/demos. Wait for the worker to finish opening a native string picker before asserting first-click success. Refresh the watch widget baseline from the CI artifact: only the left background gutter changes, with all widget pixels preserved. --- .../com/codename1/impl/orm/JpqlQueryImpl.java | 19 ++-- .../codename1/impl/orm/NativeQueryImpl.java | 83 +++++++++++++----- .../codename1/orm/repository/PageRequest.java | 12 ++- .../snippets/repository/Records.java | 47 ++++++++++ .../snippets/repository/RepositoryRecord.java | 34 +++++++ .../repository/RepositorySnippets.java | 44 ++++++++++ .../Annotation-SQLite-ORM.asciidoc | 26 ++---- .../AndroidCompatWidgets.png | Bin 23537 -> 23513 bytes .../test-javascript-composited-rendering.mjs | 8 ++ 9 files changed, 227 insertions(+), 46 deletions(-) create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/repository/Records.java create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/repository/RepositoryRecord.java create mode 100644 docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/repository/RepositorySnippets.java diff --git a/CodenameOne/src/com/codename1/impl/orm/JpqlQueryImpl.java b/CodenameOne/src/com/codename1/impl/orm/JpqlQueryImpl.java index 7daec1e66c5..ec58fe27d34 100644 --- a/CodenameOne/src/com/codename1/impl/orm/JpqlQueryImpl.java +++ b/CodenameOne/src/com/codename1/impl/orm/JpqlQueryImpl.java @@ -858,7 +858,8 @@ private Expr caseExpression() { compatible(operand, test); bindType(operand, test); bindType(test, operand); - String left = operand.sql, right = test.sql; + String left = operand.sql; + String right = test.sql; if (operand.kind == Attribute.BOOLEAN || test.kind == Attribute.BOOLEAN) { left = condition(operand); right = condition(test); } else if (numericKind(operand.kind) && numericKind(test.kind) @@ -876,7 +877,9 @@ private Expr caseExpression() { branches.add(take("ELSE") ? expression() : literal(-1, null)); expect("END"); int kind = commonKind(branches); - if (kind < 0) throw error("CASE needs at least one typed result branch"); + if (kind < 0) { + throw error("CASE needs at least one typed result branch"); + } Expr mapping = coalesceMapping(branches); StringBuilder sql = new StringBuilder("CASE"); for (int i = 0; i < branches.size(); i++) { @@ -885,7 +888,9 @@ private Expr caseExpression() { if (branch.entity != null || branch.association || branch.predicate) { throw error("CASE result branches must be scalar values, not entities or predicates"); } - if (mapping != null && mapping.projectionQuery != null) bindType(mapping, branch); + if (mapping != null && mapping.projectionQuery != null) { + bindType(mapping, branch); + } sql.append(i < predicates.size() ? " WHEN " + predicates.get(i) + " THEN " : " ELSE "); // Explicit numeric casts give PostgreSQL the same branch promotion as SQLite. sql.append(numericKind(kind) ? session.numericOperand(branch.sql, kind) : branch.sql); @@ -901,7 +906,9 @@ private Expr caseExpression() { return result; } private Expr primary() { - if (take("CASE")) return caseExpression(); + if (take("CASE")) { + return caseExpression(); + } if (take("-")) { String magnitude = peek(); if (magnitude.length() > 0 && Character.isDigit(magnitude.charAt(0)) @@ -1242,7 +1249,9 @@ private boolean nonNullExpression(Expr value, boolean allowParameters, boolean e } if (value.resultBranches != null) { for (Expr branch : value.resultBranches) { - if (!nonNullExpression(branch, allowParameters, enforce)) return false; + if (!nonNullExpression(branch, allowParameters, enforce)) { + return false; + } } return true; } diff --git a/CodenameOne/src/com/codename1/impl/orm/NativeQueryImpl.java b/CodenameOne/src/com/codename1/impl/orm/NativeQueryImpl.java index 3f69006f554..f3d1896f4c7 100644 --- a/CodenameOne/src/com/codename1/impl/orm/NativeQueryImpl.java +++ b/CodenameOne/src/com/codename1/impl/orm/NativeQueryImpl.java @@ -44,7 +44,9 @@ public final class NativeQueryImpl implements JpqlQuery { private int skip; NativeQueryImpl(SessionImpl session, String statement, Class type) { - if (statement == null || type == null) throw new IllegalArgumentException("statement/resultType is null"); + if (statement == null || type == null) { + throw new IllegalArgumentException("statement/resultType is null"); + } this.session = session; this.type = type; String start = statement.trim(); @@ -55,17 +57,24 @@ public final class NativeQueryImpl implements JpqlQuery { kind = type == String.class ? Attribute.TEXT : type == Long.class || type == Object.class && mutation ? Attribute.BIGINT : type == Integer.class ? Attribute.INTEGER : type == Double.class ? Attribute.REAL : type == Boolean.class ? Attribute.BOOLEAN : type == byte[].class ? Attribute.BLOB : -1; - if (kind < 0) throw new IllegalArgumentException("Native selects support String, Long, Integer, Double, Boolean or byte[] scalars"); + if (kind < 0) { + throw new IllegalArgumentException("Native selects support String, Long, Integer, Double, Boolean or byte[] scalars"); + } StringBuilder output = new StringBuilder(); char quote = 0; for (int i = 0; i < statement.length(); i++) { char c = statement.charAt(i); - if (c == '\\') throw new IllegalArgumentException("Bind backslashes as parameter values instead of SQL literals"); + if (c == '\\') { + throw new IllegalArgumentException("Bind backslashes as parameter values instead of SQL literals"); + } if (quote != 0) { output.append(c); if (c == quote) { - if (i + 1 < statement.length() && statement.charAt(i + 1) == quote) output.append(statement.charAt(++i)); - else quote = 0; + if (i + 1 < statement.length() && statement.charAt(i + 1) == quote) { + output.append(statement.charAt(++i)); + } else { + quote = 0; + } } } else if (c == '\'' || c == '"') { quote = c; output.append(c); @@ -76,7 +85,9 @@ public final class NativeQueryImpl implements JpqlQuery { if (i >= statement.length() || !Character.isJavaIdentifierStart(statement.charAt(i))) { throw new IllegalArgumentException("Expected native parameter name after colon"); } - while (i < statement.length() && Character.isJavaIdentifierPart(statement.charAt(i))) i++; + while (i < statement.length() && Character.isJavaIdentifierPart(statement.charAt(i))) { + i++; + } names.add(statement.substring(begin, i--)); output.append('?'); } else { if (c == ';' || c == '?' || c == '$' || c == '`' @@ -87,26 +98,37 @@ public final class NativeQueryImpl implements JpqlQuery { output.append(c); } } - if (quote != 0) throw new IllegalArgumentException("Unterminated native SQL quote"); + if (quote != 0) { + throw new IllegalArgumentException("Unterminated native SQL quote"); + } sql = output.toString(); } private static boolean keyword(String sql, String word) { return sql.length() > word.length() && sql.substring(0, word.length()).equalsIgnoreCase(word) && Character.isWhitespace(sql.charAt(word.length())); } + @Override public JpqlQuery setParameter(String name, Object value) { - if (!names.contains(name)) throw new IllegalArgumentException("Unknown named parameter: " + name); + if (!names.contains(name)) { + throw new IllegalArgumentException("Unknown named parameter: " + name); + } if (value instanceof Iterable || value != null && value.getClass().isArray() && !(value instanceof byte[])) { throw new IllegalArgumentException("Native parameters must be scalars or byte[] BLOBs"); } parameters.put(name, value); return this; } + @Override public JpqlQuery limit(int value) { - if (value < 0) throw new IllegalArgumentException("Negative limit"); + if (value < 0) { + throw new IllegalArgumentException("Negative limit"); + } maximum = value; return this; } + @Override public JpqlQuery offset(int value) { - if (value < 0) throw new IllegalArgumentException("Negative offset"); + if (value < 0) { + throw new IllegalArgumentException("Negative offset"); + } skip = value; return this; } private Object[] arguments() { @@ -114,39 +136,60 @@ private Object[] arguments() { Object[] result = new Object[names.size()]; for (int i = 0; i < result.length; i++) { String name = names.get(i); - if (!parameters.containsKey(name)) throw new IllegalArgumentException("Unbound named parameter: " + name); + if (!parameters.containsKey(name)) { + throw new IllegalArgumentException("Unbound named parameter: " + name); + } result[i] = parameters.get(name); } return result; } - public List list() { return list(maximum); } + @Override + public List list() { + return list(maximum); + } + @Override public T first() { List rows = list(maximum == 0 ? 0 : 1); return rows.isEmpty() ? null : rows.get(0); } private List list(int max) { - if (mutation) throw new IllegalStateException("Use executeUpdate for bulk mutations"); + if (mutation) { + throw new IllegalStateException("Use executeUpdate for bulk mutations"); + } Object[] args = arguments(); session.autoFlush(); List result = new ArrayList(); - if (max == 0) return result; + if (max == 0) { + return result; + } for (Object[] row : session.read(sql + session.limit(max, skip), args, new int[]{kind})) { Object value = row[0]; try { - if (kind == Attribute.BOOLEAN) value = Values.asBooleanObject(value); - else if (kind == Attribute.INTEGER) value = Values.asIntObject(value); - else if (kind == Attribute.BIGINT) value = Values.asLongObject(value); + if (kind == Attribute.BOOLEAN) { + value = Values.asBooleanObject(value); + } else if (kind == Attribute.INTEGER) { + value = Values.asIntObject(value); + } else if (kind == Attribute.BIGINT) { + value = Values.asLongObject(value); + } } catch (java.io.IOException error) { throw new PersistenceException("Invalid native scalar projection", error); } - if (value != null && !type.isInstance(value)) throw new PersistenceException("Native scalar does not match " + type.getName()); + if (value != null && !type.isInstance(value)) { + throw new PersistenceException("Native scalar does not match " + type.getName()); + } result.add((T) value); } return result; } + @Override public int executeUpdate() { - if (!mutation) throw new IllegalStateException("Not a bulk mutation"); - if (maximum >= 0 || skip > 0) throw new IllegalArgumentException("Bulk mutation pagination is not supported"); + if (!mutation) { + throw new IllegalStateException("Not a bulk mutation"); + } + if (maximum >= 0 || skip > 0) { + throw new IllegalArgumentException("Bulk mutation pagination is not supported"); + } Object[] args = arguments(); session.requireTransaction(); session.flush(); diff --git a/CodenameOne/src/com/codename1/orm/repository/PageRequest.java b/CodenameOne/src/com/codename1/orm/repository/PageRequest.java index f9a1f469924..8f44ebbe27f 100644 --- a/CodenameOne/src/com/codename1/orm/repository/PageRequest.java +++ b/CodenameOne/src/com/codename1/orm/repository/PageRequest.java @@ -29,10 +29,16 @@ public final class PageRequest { private final int offset; private final int size; public PageRequest(int offset, int size) { - if (offset < 0 || size <= 0) throw new IllegalArgumentException("Page offset must be nonnegative and size positive"); + if (offset < 0 || size <= 0) { + throw new IllegalArgumentException("Page offset must be nonnegative and size positive"); + } this.offset = offset; this.size = size; } - public int getOffset() { return offset; } - public int getSize() { return size; } + public int getOffset() { + return offset; + } + public int getSize() { + return size; + } } diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/repository/Records.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/repository/Records.java new file mode 100644 index 00000000000..6eb25578081 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/repository/Records.java @@ -0,0 +1,47 @@ +/* + * 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.codenameone.developerguide.snippets.repository; + +import com.codename1.orm.repository.*; +import com.codename1.orm.session.LockMode; +import java.util.List; + +/** Build-time repository example; no handwritten implementation. */ +// tag::generated-repository[] +@Repository(entity=RepositoryRecord.class, key=Long.class) +public interface Records extends CrudRepository { + List findByNameAndScoreGreaterThanOrderByScoreDescIdAsc(String name, long score, PageRequest page); + boolean existsByName(String name); + long countByName(String name); + int deleteByName(String name); + @Lock(LockMode.PESSIMISTIC_WRITE) + RepositoryRecord findById(Long id); + @Query("select sum(case when r.score>=:floor then 1 else 0 end) from com.codenameone.developerguide.snippets.repository.RepositoryRecord r") + Long qualified(@Param("floor") long floor); + @Query(value="SELECT COUNT(*) FROM repository_records WHERE name=:name OR name=:name", nativeSql=true) + Long nativeCount(@Param("name") String name); + @Modifying + @Query("update com.codenameone.developerguide.snippets.repository.RepositoryRecord r set r.score=:score where r.name=:name") + int revise(@Param("name") String name, @Param("score") long score); +} +// end::generated-repository[] diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/repository/RepositoryRecord.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/repository/RepositoryRecord.java new file mode 100644 index 00000000000..a02367c2b86 --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/repository/RepositoryRecord.java @@ -0,0 +1,34 @@ +/* + * 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.codenameone.developerguide.snippets.repository; + +import com.codename1.annotations.Entity; +import com.codename1.annotations.Id; + +/** Entity used by the generated repository guide example. */ +@Entity(table="repository_records") +public class RepositoryRecord { + @Id public long id; + public String name; + public long score; +} diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/repository/RepositorySnippets.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/repository/RepositorySnippets.java new file mode 100644 index 00000000000..89809ba2d7b --- /dev/null +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/repository/RepositorySnippets.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.codenameone.developerguide.snippets.repository; + +import com.codename1.orm.session.Session; + +public class RepositorySnippets { + // tag::repository-unit-of-work[] + public void insertRecord(Session session, Records records) { + session.beginTransaction(); + try { + RepositoryRecord record = new RepositoryRecord(); + record.name = "open"; + records.insert(record); + session.commitTransaction(); + } catch (RuntimeException error) { + session.rollbackTransaction(); + throw error; + } finally { + session.close(); + } + } + // end::repository-unit-of-work[] +} diff --git a/docs/developer-guide/Annotation-SQLite-ORM.asciidoc b/docs/developer-guide/Annotation-SQLite-ORM.asciidoc index 965880a45b4..00697dc84fa 100644 --- a/docs/developer-guide/Annotation-SQLite-ORM.asciidoc +++ b/docs/developer-guide/Annotation-SQLite-ORM.asciidoc @@ -441,28 +441,18 @@ proxy generation, or Hibernate dependency. [source,java] ---- -include::../../vm/backend/contract/com/demo/Records.java[tag=generated-repository] +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/repository/Records.java[tag=generated-repository] ---- The example's `RepositoryRecord` entity has `@Id public long id`, -`public String name`, and `public long score`. After the build generates the class: +`public String name`, and `public long score`. After the build generates the class, +open a session with `entityManager.openSession()` and construct +`new RecordsCn1Repository(session)`. Pass both objects to this operation, which +commits the insert and closes the session: [source,java] ---- -Session session = entityManager.openSession(); -Records records = new RecordsCn1Repository(session); -session.beginTransaction(); -try { - RepositoryRecord record = new RepositoryRecord(); - record.name = "open"; - records.insert(record); - session.commitTransaction(); -} catch (RuntimeException error) { - session.rollbackTransaction(); - throw error; -} finally { - session.close(); -} +include::../demos/common/src/main/java/com/codenameone/developerguide/snippets/repository/RepositorySnippets.java[tag=repository-unit-of-work] ---- The inherited CRUD methods are `insert`, `save`, `findById`, `findAll`, `count`, @@ -479,7 +469,7 @@ Derived methods support the following bounded grammar: assertion. * `countBy` returns `long`; `existsBy` returns `boolean`; `deleteBy` returns `int` (affected rows) or `void`. -* Direct scalar property names combine with `And` and `Or`; AND binds more tightly. +* Direct scalar property names combine with `And` and `Or`; AND has higher precedence than OR. Comparison suffixes are `Not`, `GreaterThan`, `GreaterThanEqual`, `LessThan`, `LessThanEqual`, `Like`, and `In`. No suffix means equality. `IsNull`, `IsNotNull`, `True`, and `False` consume no parameter. `In` takes an Iterable or @@ -516,7 +506,7 @@ sort arguments, and arbitrary DTO projections aren't supported. Repositories never open, commit, roll back, or close transactions or sessions. The caller owns those boundaries, including when the supplied Session is injected into a backend `@Transactional` service. Writes require an active usable -transaction. Reads follow Session's auto-flush rules. Redeclare only +transaction. Reads follow Session's automatic flush rules. Redeclare only `findById(Key)` with `@Lock` to request a lock; pessimistic locks need an active transaction and an engine supporting row locks. SQLite rejects them explicitly. diff --git a/scripts/ios/screenshots-watch/AndroidCompatWidgets.png b/scripts/ios/screenshots-watch/AndroidCompatWidgets.png index 14586d81cf8f06db9c41359c4cf93cc00489dec4..9120bdc04c807dd5c9b443388c2e50e46aa55c3a 100644 GIT binary patch literal 23513 zcmeFYWmFtpx2W5V6ChY{C%C&d5(vS9gy0&21rP4f1QH}cf?Go;KyY_xAQ0T$-Q8VJ z<=y-4bN2bZG47xH>y8Z+)vMO35Gt4eg#0%N@CyQygHZqZ3<4>EDgNuT z2AJhP(;$IBA=V(2|4gF~-2eV402lD~f8LSd;QyT?9O?f_4Ti&!|LgP7Kd<#J{4EXh zqk^6b2!uuW_X2}b(nx`al-4iRG=V!%;6FcCz<(Ct`g;d1W`@$R;d&5==}7VA3r$b( z9xNo0NNy(N{;d$Kf7xz9g5ud}&MuqfWH2TXvL!Ej2a#dl9$R7Y0X-X9pvw*4;C#NXgO-pe0(?fCK7iLg!T^j zgBOsAzzfv6uA!9wgr%`*r7J{w}ODIFz}F2kbzfTkV->A+!&s^ z&AQD%5O^F6YWY8lnBbn^{+}ZLr=$L7M*V-aF7}_n7bX$A9ZmP2DNdT@4jvzbie=eO z&3sfoszDmM`fojBZ+E8&#TeB5_wJ@lL1^_9$n(a~o3m7MorC-(cE16Ht47{W{|{Yv z;ZB?xSjGN1aUI-AHk*i}8IM(0302bd3Hz&=>0iG0zWTTK<*C2wgmVg=ad3P%l|5Fj z9jn*}CybGg^Rj?#ranB-fm=GqSYTLDUGkegvh{4k=w$r^Zl8OAgspGh&jX)RpE8hN zRU;i|n2+Dx6OYkGn%eZuCOxH9siU{vYOa?M0pZ9R$HgWM>R!pk4eI;LPSsALM{cV~ zZbGc8;5*+Ff2phBVTa`vPKWjU{Q;>A)o-9TWNYi8o~0TBX!VVtX=I7LI9|?|3Pn*t zYLqa&?F^4`afiY5jfr5kv(L)a<{dRI*UM{{6D-LFjSeBZUaGwZrB=p<1Ye_z84Kjr zHxtk24^|fk{RlD!A84gD+4TBKX@tTDq)ky2aO)2_5A_-;Lre;%jkR7IQ&$yo^ozgw znkV>K$YF%U>4xGkIXPLClg_QPifOU1mc;1NVfvg{3p)0s(Qj$*sTY&pVE8@NAIib+ zo221^GMLfSG5Yim?#_ojW(@hiFAwYwUKunz>}qR;XO!IZ*w0mtQn~t0N_%@}C_d~H zRbx5K^tm~CSg@3eI!+$-xNL~lgao!?4vh|Sa}~g8$m-$w@VZ6Y91Ron)zE>j=_MWI0>+k?atd;&$UM>J@Q+BY9gORo!Pj4f zbc)u{AQz^3*qwg>BmvkOs1i`-5A`ES{kylm(8e{C7xogOtdv2*k^SNAkWBV|1)C@Y&Td$)aJPsGlu za`f7P8=>uGD1R_M!xk1E+1=}K8~N+{6|N+2^NQP6XJ)wHRe|zqLVSnf+FGftj+t+9 zdxqckn*FBZxW06!yh}2ZiUE~W6q(CJL*mcfGSRoGHG9soA$|mlf2I@F+;7s{rp+&F zzAg?RRI8Y*2IiOCg#{8s9k)`}K+fw$hcyc9!_o5Zokt;s=M)|Zl7mqk0UPo#$$<#U z26}4l88V5WsLX`@3HyfCM;+hec!Q&E;u;2TCQY#_t!?%6xEx+}1@K6Fvy`o$o}Z2) zi!oILB+{0s@gZ+x`qvXJqmLKxJ9X_kt&;~iO+s3&TAIFDJm%qWbRn;c(%8`0Ww}qk?8j0w64?wIR6pl;#S&Uoq~TS{v_+O&wq55DEpM7Fj`NkP z7)2AT=yY&+*xXw;ihj*eH}aTyg(xeuYi=jI=+Wq9ds?z@!sSV9! zyq5sUjmWxSOe>ovs?alFz8dI7;wT}bih8^Xe_uy6dt#jSK?1nwX{u2#ccYWOjX(LH zG8LnQfZf=I^IukZ#37Vil`Ai2s;nU$D4Jfy#;Mf8C7`^9H?zN6Xnv)uO~QE@Q9_m2 zBIGZ|(MW)rfJIE5N7+!FREY1tkEfx$-L0^u&v&!9HhM{(sql2H;OBU)+ybM|-Ca3~ zg7TTJ$5h4el*S49{iER4p`$etLrdx1vznM7sD41T1&I9-DIu z1{MVtmU7CpN6ggLQ(v+&Bh8khbanVWI}DF|;rn=(V{}$+K@wt(T@$JEpyCYWBuFdmU-m2ytj9H2+v%Tsh7BM)`;+o=74SUdV&758RA)Zjtl z6sGhPExbkg2dcuV_{mwheeZ;k>Mgb;j)Uze6tN#$@u}H5tu2x@F1}1s!AY`Suc`j^QH+}tVA^Irptxjsd++*>0tNkb?)niiA?dDrs)lVsxr-N3+ z)m_{rpzaqfi)WOvkxEryb=~#_ z&?+YiM@X^E7tcqiE?A3k9nYMsI|w_r{3^flpIP&eDiHu{WZTT9cQjfcCHE;m-hP6+ zS|gb&qv$oAd{ORIzTFsb^pB8BdHY%4yxLk)LtE18a+8QQ>{k_H@9xR?d$R*vB37i-M(kdoZ^|m zK^hxioX9j9yV0b#`qU3i5TvZDlbs3`cK2Jat3~^7N=5Yole^$3VeYqQFDuWYGpack zPoG4eOiU$p%#H{Zx&H{OvadrMdDfuu^KK{S4k-VV+xpIiVl?^b@KlsBOODF?M@6_+ z_ifNsif5jP8B?*VO?Ksm;M6#ZLc05d_I}m87plTjl8aUeuUhLp7OxB&oSB66cSj|; z6#do0Oj!*f%%??X0k$X#*B?+r9ucJs4q-EHx-6Uc&9{{!UZUDk@x&HJ;uc1PNO8Vl zv-ZfAb=wt_oyhYkYsqMZhaH7a49x_|Np#7F{3x;t_-p&lbvLgeQ}z}L2bIP zDH+FTp+|_6b`@^U~tXfSsScc^(5PN+F~4+euPYAH-5Q;rOYLG?~QNV&LHK?>WZnW(o--A4G$B}!LQwI0(K`}kFh_l@!S8)L!e(zp_$&94IlDx_>3!dmv5#iYYV=i)dWv~^XW z0vo5$?zs>DD6z*VfAr-RH4q*gA)Js$l@pzG7oGeDvbN_J#hcQ$ zud)p+I$#PI^;ED8Ok{L4nAk;MquO-bdEb+-zJ9&#X!9c5C{~|#Mj&B)VIr`e$KC=- zyrv^)to3BDNbC6-ntqYa<=D|#;?4EfHk*A3sm76+9zp^*=I~Tp0OZ=Jz|3~ET&mmb z&SaK|aNU4r-GCp7;oV}!IHZm@3afKormjxQ=Vtu!_N3{NXe`Uq^e8}aQ zH_9U~4k=|^Ws`d{)2e`%+o%~b?`q`U?0&z2SS)|iYIiZEOIJU*9IRcyE*K&)26eI(4ekt|rtCA^)5 zF@4ct8SG|9TJ1Vt#hpIJAox`oz+T}YuMav5g)e*r&{KLxQ}dZM#h1l zsCq_%<4jX!-J67(iH=hep7T-LR@<2>U45+ceC&WCVJ}i99pXfgV&56Pn7z%lqUJhU zNwFr?B)L<)T5n>hJ3D<(wPbJgFBAp5Ua}uCo4N$9MgB!3#d)aFkO{rVg7~ZhA}6^i(apk%@!Yzb6$6Ms(XG*%SFMrJ4UobatwL=a zqsRH*T*B)+T_Xsr8V9=uV9c7rKhiiyn7Y^+4?};5>ZS-3@Y2K7=#!Y1yatrhZVJ0t zF5f5k^kb?^Ky%C38oyViD0`gfvB31nY)lo*V>+)htdNj-hT;LF@?CHt9vAU^;C!Z* z$f-tag2IR$CTlxt{nI8}gp!?_FpkVZ`z0D4{1aw0XA&E#Qt=54UAi%q<$56tvK=am ziVoy64CI4KNV_6k@-Fq=_?r6vvSAC=orRL3xkKdjUqSZ(K^Fo$`@FSxO(@Xt96+*f7`l331 z=Ur{vPVJDP-s?Lrk-M(?1Nn}M4ZT0oV#pZkEhq}Ky+JA^>jV5`S}yj*`-8jKxu6kt zP&(EiBLk2CjM|01@lBbj{nS!mQ~oqLo#t9^j?u&VthK&Si|p>qHwWnxm=vQOgrxZq z`~>ysX7&gW{=og0F14l6l0t#VRuB@3=ZoLACF?J}3mr*WB>x?HvOO-+>51mE`{&L7 z&ZUffIGIPx{QgKElSxDIXi+f!Lb6W)@d6v;9hDk?b!^o55@+i3Zz5-GMZZ(;QlNzo z{yT1_@4Fk%mxVps`>&Cc#=T)@x{EyOz5sng!2knof(Ft80|q5QffP=z7?i}(Sxg!K zYb4@AUp1@q=1brT|Gyz}jl_%I+1RXqF8Ka6vTD+qUSdKR`&kCS^}sUFzhICS1L&$H zR50#t)zt*Mg#uZ1;=e|gG$s&kmo=HfudpdgEqdJ(H%EttmadMl|K(h&a{bVLa!sG0-J=)=hVGAZ+s_&oaegeoWHEud@4MqrRM$)NP6%QS{xO?d zD89m`J|vePbdPvTT>C|3*6>dxtk~4UmGhzM7`6!pwn#HzLHY+4FRolXC11a|pxBr@ zMuv&sG!x^_`*Lh{^p-Eep(GtU`Z20(aKR+1o;Bpz^==&#vjGxIvKiy?g(+;xv9%6-( z`wCS-N*eQP+pG92NvZ2@wK+Kwqh~tl_c!M*^3-1Dh)40o>qwuaK=ipu6MoEU{q`#H zl#KJM)u;vvM?~3~AwK-Qru%qL!m5!;B!)T$c!#W?H$`Nz&VxkMWAx+Z4L0W`V)r+f zh*px+&2ZF8&q-FSDqF>vo88vXkL>WX+{{R43SkG1uYaQW?&a(3XRAkCWT7J&_R|I3EOhzts(mvX7?7`N;dfq%19^fgW&W{{k97pt+2axthD zulW$~!i>8k02bSV2eCaF3dSTR^w>GAT9n0Ye=5PPm=(?cz*zl(ty%_p-aaXN>^jToYj=tx}f zJyr2&K_zKh@I=yDRX*tvzuC$(Dlu-H`^w$4^=y#Vf4^#SZavZDVC+f5@)wFZ<_upA zF-s?oy{D7kpV8KtoA@5Lm$`JFPnsMOa}`3q>kjUh_j0*}Pqb$%I?dPKBK z*k+`?^gi2KUb`uJbZowN_n33)tb3FBsw&iRu&b53>5Bfs8?qKHJ2z8hyL!cz>M|h8 z+@$TL+*2{Q!v1)B*t~eYH_gwrnUmpP@7lL=Wx7y)GxAQBLDh`7D$yEuII8?3MnRD=2B&N#HctF!tVNyeu%e zXCvmu>@PNISgFj{#g)8Tipf=cz0se#>3=)B*s)p8%L;qz!U0l2JB!`PLZ1l4E z@m{3pWUAiH_UrC8*W@x*G)hbQkI!OU!k6Y24gUAu9tU-s^*}i@{TWh={mJ~DMsDL; zPB$43XE_5s@7lwO4qCCeT^AoNYLvZ{r5SYHBNrbZE;&zXPFWAK6ODJ));}CGzB*Zo z#i~Q;=qO`wIQf(&>F4eHy6~xJ2tAR--xYG&*-gynqpE(I@leZnTUVdAI zY!&;iK><3}DssD5vD%{;KNhPdnXKQ9L8=v(k0EHhhAiN+@%pmg(Vu@{H7_SUJj(BS zJ+Umi_yD5i+es7PYxq6?336W7AgA6E<^v54be{W-7;=K z=x3cz@IkXE3F5;^-YUG!0T>0=ENf00L58fT_C9lnEJ`DdHshW4{q;e7?LqEAYJ#|#kyV|~r#2^Fafadot(>S&OLNL}kpFIs` zxW1q}2fLkZZ|BJ_130Ft@PN2VNL6GxxHcw^k>jf_=d&PsPRM@c=v*K=wQhdxok%{9 zR^4`O^W)zA$}b0rD(5@1@|z+J-_;1=o=DboeRQ#6E=egPpxs8Xm_yH_AR6uwV!saL z8k!ppn;KkwXDY3a{i!Ywl@}SY6J8Z5i$4sAAI4qalJR9#E=KBLF@8%Z(HH5y+6fBsSx-MKKA@1(VmW71TqYPKC*^yO$0sLu>bCbBS12qRQ%-woA|YyhQV}Vu zI5pA4e_8?<&};{?h;`jbJ?+o$;)j+)Xs8Wt$}jE*O9Jk9Yh!LEYMQjP1J4iU#TQDr zYRwmHCknd@r=DFl-5z|6x^+pc?{%AMOaf(#(&fr!dTHe&1e?sPi|aO0JTvO&?yPbT znNXB6Z)*eya0tOwT=oGA>YPN%%{Jrt#M|~7_8NQf&HC>WChF0unu{#HU#5iV(5(Ee zI8#eHE1OG#sIW(!%c+h~oto`aHEm@D&Wy~TiM_Dn4{zrXW`EBo_ZZN1{Pj{xkTQ{S z3hJQ_DBn{PGf#`2y>>*NC?Q)*Cw3$obLf{!s?w&8ZP$oy?hB9XagopayIhl|lW!HF z&*Y0#=7aQ;6E@li)xKRSYO+blYaJ!LnosMI&D#}c9g!HY#J%Q7;O3K&D*!6I>l$%S z+%4VBW0D?(oW8!BYr1SajIZYkI89w_yJhpb(t9AjL5iacDHDm8F@b8A7#n$~Cv>2& z6rpB`FiFQUsjy|++v-h;V&c;P=aLp7ty{CAD}$ssJ-$Ao-a=X7DT*_L$OCvYJ-p58 zEbUjV&>(Z~Y(EM!nR_58@5ln@afPHgls!r6@p=#++dzrvo5_o=w<__`XtVeC<4qow zAUB|Tx5Z4-CzQ&PHB$H6YR65L4&uGSWbX1{8(Tew!-&USgHSxU4H5%7ih-E?%5!|B z>R<**=j@4k2X^5Dk_@3r-`An_3edCFOPv?d!utB3dCWS#)sARd>_j0mtV&+axmcJ< zQ*lbquZ!(96<`ki4n$tv{8g;1?6Q$;)p2`wpe}CtqkeiV`rV_)KZQ<7;7`;X>0UpV z29V#+Xje9c>R?r`Qbk{ZJWY0r%BuBIMI?qpfv}Jcn!=^~q55~vHm^4`9P#DnEp}e< z+4`Su^mDEh8q^HHsJt4luigKnl|F?`(i@sQ{&!a+E@e)163Qx|YQ&v-@#bnr1UuDk zB+}SK`O#tQVJP0;xu9RG#jG!*KkmIh;d~sXBhn?hVb*G9ie3Y?o1-hhQgM;%GgFq~ z2iB3sx>bCzWkKGFLMiyhA!QKF zZx5Qa-0PjD(;L2uU)dVYG@z`B2d}-Dpem8QG%JwqlJIN!gqrO$qVnIE~pwwENtjPxeM$AD1`ThS4f>^D1`$6eY43Ly>wkoFqX= z^i3V-YoNvfKy@*wqM2h>o)P7~7Pd2?EZ3LeoN%`_>+KvE2P0((|IEkgGwF~c3QO=F zPKy;N5vCBRc_N=szoMU<&o|DXE$;B6{>XiA>P>jgx|P87SS0&c8ZGB?CS)zaK32!= zHGQUbjA7Keb#|>kDyN!Ab3=ZEVTeAzK8cz#`nISoeU84L^PakJ%3+Md;E+t6!0a8X zkE*l!QLmgTz2EHxXI0WOwIZ&F@oR^f1S>vG_MMGXM=s3l5nC^qfkPdmaiZFL%N4Up zxR>e4!suM-tV2V8w`hCh{Z*$_WUu0-{br8=F>yv|6DqUbgO?jBn9oT)$qsWr(zt+D z*j|*j3!kp8IyFOR@bW1jtG)18d)$#s6>)lxn9AjJnErmf* z`L=MRF*im>2R89`Gz_YL+PR_$u$nb6T7QJ-Q_9y;hV9f};IUi3};hJ)8 zS1sPR+&=j-ZwS45Vets}6oOEYb^)Mbj2O&ESEge-7`kA0)9eBBaV>NS2qPw`TD8Z) zhij1FN^RUpS*8;W=6?Y6pM89I^^@0Ct^jYT%;^28#bU@!KSlmm{SNI(@1g!rhJf@8 z1u>zk*w&u^C2+FdN|o6dbq$?9JCmZ65kYKwEf*EP%fHJz-I5`h$2- zCcBTNVgTrd{~K-WzAgwyl+BpJNrBWrkN$VJsoeHbbynt{JpO+}n#4x)=^YE*Nm(8}gujUGq{Aj8 z@cqAY{`x`uMQdwhy~(m}?{Ya+)(lcNTLr zqzoPM>2&pE35-^MjnkOCB8@#IfZTHSp63^t4DUOh_Bd!BnvC!`Ly~lH@5{p}t7LZN z0?rxng89P`ziI_?Yb(iqpMhllOq2c;!CAo*a#GTd-g!^6z}C{_(P-fULl|$THgzHn zbX{fa6IrtV!0JRH+BX523V=F=u6&q@N< zTv#m9C0$AoC*c9d$%_y7hXD;V z{#Px){qZgO=zAlN8>TJK>&p8msk%TKf-YadUL0T1-u~+|3gOM5WQT*b?T2xa*qZen;*P@#KL?D$c6R73!Z=G0r|&aZ>sFz?sU+@D#Nc@ z{Qhk8{_7x#k?VJ#qZah}IM%n2U{~4mJh`x((+OsW0T;&xFOt_EPgR@7`)P!F`WMej z2KEI;ny(-MkHZcJZcz<&geK0&6$BVna22Z29P)x(Z4@{f`O`yGZZt=xGuL#l4KiWN zK2ne4c9I4{Rgo4dK-GJgm&|bPVLeDNaB#X@3~0UuRQ&rgPi0RPPqinb=O3&BFF%pQ zdh!QBV3^#s*PW3rr?^c$b^QZQ4w?M0+xt2!W8Vdv;8fuiV`0a=Rfhrb49=ra0>^G2 zAOp`gCt0W)Xg7uVr1VJY$Pt*nNAHg;k8JuzMgI!;ip($p49VXtCbSCIJsRA+JWu1sK3z@X)+lVABZQnC$A?zs2xJJgdB{~0A_6c2KS7wN66$X5bny*_l;E1POu^L54_4$wBsosuQ@M(g`Df`@BZ5 z1)@O@J_^NF51SMaK29g)K3RSF?Rod}m}TwrTc$dHFiR%>D76~Cf&!#83t7f4Ax<5I z3(gqD(nK^Nv^PSn_MEotKc9n&uN#b3Hejw_AwR$QRPPIECclAlfwF@;Yq3FebHv^d zVx>Q;*3T%tXm5K>9#&K#$1e}bA}B>ijJ($49d^d@9B$6G{p}Ql-^#(wr|tQz>gvpB zDE5w*+U;Ty1W`GrAIFf%%;Xf4CCxqNR2&Y1MtyY$d}LU0l%pta(we0 zZ|xH(QebrQ7n0U1?DT=7K+6dwCscogX3>?SmihrlNoHC25 zlYJ`MW{)`TaDcf@ILT8w1*22{M)kC_eI+7W#hrKPnPK4ASY=>W)=peE@N=K4alm{J zjT^T4&gTN(==7#&w}+t5bJU zwAyPHI`M>MM9)v;MUzg5Q*FUK7PdP-9n~K-^-yAwOL$ivzJ3)!UKM%- zCEU!a*wzN=F}qwXhurS=osNZ98;E8jLd>MXNLCs%80=HVc0_6X^{`_YBD@zEaXFwm z&Il7QU7-3hB*Xmu?wpfao!9e26P|@Q1}4|{93L9bCr;fO?@oFfG6EjF@aN++LXZM? z1c6f~P7?`gYPVUpnSScxowO7?zgbQDdl4|0DhF8}ZBnCx3F3U?*%YF)+UH*yc;*JqU#pB(Q z^xAmSCS8qG$xIFfYo5qU2CFkT$!x7!aRZApjk(QSi{_+P+%dM#Z~e(R2Y$79-xA|GC9(fHjHnmA&!PE2KGCeugqzWBS!N5<_R*hfGc z4Vs?Q-CeYx)2uy2{bhB#;Fl~cw%A=ewI3Sxb$Z0pyRJ6p5$rTZOJN*+-e&8_G)ld@ zx&5bQ=13X^$scXHu?kUj1_|{=m;{)?r0k4SD3jMdCfYQ4WBK^xH+<$oCh+? zQCYw&b)wMMB8TXXC?)2(#Q3N}o(51{4DfS!a0p74e&ZX~Kku)DXHI(PP!w}wYZ@v1 zxshHEUuZna@);xB*|1YFEMOQb9?<{vO2<9IG{Nzf`6jV!uQwyigQR3p^pg{WiJiT` z?S_p9X30xa1;dA5?JjC2EbbLJ-~}Y z#FwDRbHoOA%N4&C0ERx}1UdwW-x<#=0KevNgy+;}i4$-%nN`!ao=RT5s~OiexW*O+ zb(4E}{pS2z%cHw}e+P1qSGpQTh9%^OC0I8HxhwyxJ9y0Q_9sR8-cc=2{go=x&{Off zC2G&5z!&87N={>M_xf zT-sJgjA5B~vb-0u#Gc+9xqW!F>2n0i9B+b#Qf!O2W2sNXyo+(BJ_d1DjcHV^Lh*(7 zZ3>Duz!iOby;t_Gu9&%UCtU$eubIQ;r-=jhSdSedjISlf3~&%#I(Ed2lq z`gCS7@^6dW*>mQrzB}*mCqMTcp=CP>9x;0n@dZSI@^>!P23Qe&#!6q@*F?5nE+CO#}ngHPGp8KMo$6=sL5yK`t3K&GYXon;w z)A|;1E54ISlK`XHP27O1pyzq?cg-I>AMYfH!kTUXD#-aso2nEi0!zzI^4 zfTn_k#3czcowBYr=#HR{s(OVa!#whvSuGvX#lQTEQFK`?;N~qSBB4nYWFag}t?DiQ zL!{az2zltE_(Tzva>)DHq|rZzy4e>Lhgl7?4<(HkGjMyme_D5BxbMq!3{OCVx!(9P z)3r!-3+!#_y%T`#1H}$sRx-zB1%yl<$ObARwbENJ<=wZMkzda}h3Z?I#JnrqPfmil zMR+dk1=XZ2Gyw*M8+(sMze~9NpyX}gBh32gwi(Z_y|nGsk=q@g>!mWWeIL+YbY2w( zhiC_C+G&)R^%cf^*@2Di7KWAbTlTT6>qFVfF4hwairr702#bMPk-UI6$Wfm9Ogd|~ zMeIK4so00__G1}T6p`G=G|77kUyOCB%@nHxi1+pky-tBmGi1|dlmFDgKN%yX&^J2k zeZ|~>bW93Wg81Q?xWArQXZd_MG$AU(A(U1HFedvIT8nhB^2Jh`*=0)v3PE#S{)#aoI8w{X7aw1nAILlWVuOb zc#N1z6FN)Rv-0RyfpdKv0OqH|1viF-yR3b&q4>yu&l-@IT&zHZLEqHChUUUX` z?w$^{FclQvE+D_R*!hUWcKm*&PcK1N8#Upaf{q=5o$$zNE5jC4*2kUi5uy9JFyVJZ z4}JD>6AXm+wT|GG*?rydYUkvD{D>?ONZ_)=++xRku5+-(6q`klIAq;v#7vrluO~|* zVLGoUj;b|#i{G?iZ9A7kvZq`ESy4u|$Ua6j(QQ@tqEqT1wfrbfDJ6PF31i?OQG2Ea zAN0fG^n)kI6StPy=c6b?{iq~{N6PmMvr|u#rY?qLaXHJ2v=gx|$1g=_cYHv+1E$JH zc+V|;KcUL#vAE{GRLuQERNB4OQov%XNF76-r2OVg%I+?h(WkYnL=}YbdxSOGhT2$1 z3nXCGW8gp6$=wnZ62pPrgY@Go$LFA$R$my3Oks4tvv?2_YyvsjOA^!f4rSDQc?8V2 zSXlA8$8uf-!IAjwHm;FDtpfBu(BPX$aY_7zJ&JVPeK&K4=ku`LS>WLDV`5%jtlTs^ zuLM?@XVE?&ZZ(g=#+O??ek;m+qRPDdf%;|LEnuM974`KWZnUQv>= z6yUct}*{s>?dy|8>$cakvi6KLiK2J4Ts;B&9J(!Hft3M6Bvp&U?zA z<fdtua?em7%uZOr2_(OOG1es#txR(ZP1SO{PI*Du8M2xu zW@Jg3at3!dlg-8bw>|m%2RM+6(t88zMIJ0JWvjGQY~f;NL$Bw%DXojOB|3T*M2{vs zZ5Z;ie$t|Ihp)ZmY?qyG44)d3=XExrGHN5)?x%Vo%L+qO!u)-?^431y=y769t<-J6rT0u#h>Vr0L+(nj;4Uf zZ=Fv$8eOIvf9(fpoEcIhD~K>vdco{!1bAY)i(h-&--y24b{4y9B-~flC5}qo)Ng!z zxHX=XwjRk+l+4aJU%XJ>Kwcgg?F57h-Uv}T_cw`4LMyM(epA;6OU`UA-oA7F{@+3#+CSP*{|LRDEQ+AwK}La+)`hJngXkG;e$p zFS|Ey%7gk%6bTG9K|NFY^UKeL>V~x$lABv%pSV!q@DWVC?2O(&x@g8S6ScD9G99<3 z(=kbdbT*qeZhKI*SGsF;kkMGihf(X%zz*NWWlUZ*7R>B^Wv1kEyFuv~-DraxwBqE7 zxw*0^!3WBpXI|`ARD+)x$Xi?Fx{>B`E)+uM>I>gdot*^WLy=za5kEr;bp3!fq`1t% zulYF}ZHn~Pl!}e-u*VnAh#RZA;KAUg4x%W+>9X z=F@7oenlAtAJu938Yw>m{^#2oipP***4bSG=qCzVM-B`OFgAn%LHw@GQ%U5biP}5j z737a`Elcc2reWl07lF8Vt+(%e8p|s=4Z7a!FgkLCVrDt|Rxa^QdkPi4kM5f{J^uto zBul#_XOMik(lrYFRwGK&ceXGB$1KVUU{;f}@UbM5+sqwu*A^{(EwoTGZAl?dF4ZgY z9lHV$QEtA~7JgRpX>`bcctn9Oy%JD=XEE*KMT*r*xFT*Q^BTbDG+(h;1o@Jz-~ACo z=IbM7ev5sDO(Qw?N8U`z2qi(9(9Q8Et&Fy&-7B)we6^@l{~UqER4Y2zxUa)as(?tY zw{SlA{BE^ukz;)|adz5zja&KVnM6qL@NUVF|7+4s|7qXT{(Dcn0{7@IIH9kisEtU| z*$YpfjLUJ~lYm)dJqyJJZN^w|sa`tL@%2Vh@LPT}yT;#m8Li(3p(l6(!b!*HmQ3wL z9`aNtiN@?jvU|OOV)xs+Cyw=@)|fz7>Ml_fwjS>8rV2LnMmkU|iNo^FGE{)lJ-nz9`gwSQ12? zCs0dj%;`o-=>1i8)bQm2{|O2l8^n8r865sQkU2=cZN{n3f-WI+mEPY7DErL5PEW>Yu6#Ha!@ZjyW)kf`)y9_}D#@ON;BR55r8L?z>a)#+&iRVrWfEq?)|cB|y%bgN6a z$=XMOGm+0g=v(cB=P{a>`$<3c@jnR+o_x6iUKYU`7ED3FRe!F)g1ufU*4i5rxIe$! z&>2=mkmJ%ok-uRpbYFc}GgctL#poQDaqZ3|Ay+c~Ak#ad-Ci~R+@2F8I2^r!|NW!% z3K$bOl7i=t9QydnqbbACzdir1A3=ipJ4JC(oB7SLD*aKY0_68sI{Y8R&isR2GR|f+ zm#Yd3vzW2Z$W;~{{*NrHhNJtC=_TUg>=5)5$t&N{5LbOST3%74+UP?eRMIh@`k!Dt z{BRfM@+@Z?JU$*gQA{rvyR2sY3enRN+m?k@4l`@1a3iVH2)d8AQ7@oQ1%Q8~Xb=OA0`iFTv%e+=<7dm=N=1?J z`PCW*Zc501BSDJDfRzDH4hRI3$a%ubb2W<#-K^nap> zAPKcqC{?rHJ)Y1itB<^;}@j7Fh#ldMW@cOY+^Y_`^v8ZV^zEuGsBp+0@@ z=}06EIB68!0@l{KzGVJ+phX9gw|o4TK#Lmwx_tq5b)$bw7yy-ET|Wn0ujVBH75rda z$K?F+2Z<{SV6Z`o78n{O$B>{#z_caOUm@r(u1|Wo=K<8R0RUA=55Pv9h(tq+_74cS z1hku57d)ml-#Z$LKl$Wwe`_Z(f33OyW$ZlR&5RVfWM5%njQUSPa%QbDKjEfqf-lAQhZ^dFWZA#(Qy zRP~R4LU1oT_bNwKoA5(^g8$N4WJ-hEM!-a6vpX4fHJOZyl`4HHNHRLyyKKdL5mehbtg^YiUS{P%P5 z_rH64W;z_DF}Rr=Z)I^M)=IESzueO9Y3Ue^t0eLKDE)R+tLg`Q{mRF=QDx-_J3S_U zChxSsFpzx=^ai=Zherv}aChlG<6c5G|9XwhxeQ8db~>E=elu~nfzK<1^5IYY*2JI$ z7A>1EKs?T=gz1Wb@mjb)&3G)FWpRz!>-AJh#(8zo1JL&==j5Dp@BHRsbZSrdI$zJ7 zl%B=+&lP~7;x^1rZ}vQOiD!)b_bu<$(ZYwV!oqtI=2gBdl~~A7m=m1C6F=Mx@zwS8 zNAaoU;N&T2#&uN17Ej-?@%#yT3m7lxu3y`H;h`;X($J;98uU^jdY5gMzqesS+y4=m zz&j=Qp*7BkNPKwD1n3q1B~8f>%3EG&^5r=Z`9tHZKjx{kC^q3EQcef*Gg^dZ2D zQP|_8n|YG95};4FY1fdlL`+(aXe#%qmW&1}+z;(UI%gJ;<2FL0>AKZq{Ee&w6xu+B z!v*r4#cf}*5IV3aOQ%1!^o6PrrF!j24|@oTw2;Xb&<$JO+_~JKV8%+o+hUy7wTCrl z&3#P{goO0h0&)wOOb@GP9mM55_W<5(m<4I|17@7oewrsQu!}SWn8K#|6)XpU0bCba z^gVrHJs#S50XPf2=DX$WOX_9+GteS_`wcL2eMc!h?x1J@oSsLm*3I=4+6KrVjH{84 zr2x(rkHHA#4B3W_v0KD|UNjzy0p7EBg2v9>j8%_a*Ez3~S85-Cea3z0a9L1DzlIWd zEBEsLVs_9}?}Wn4+Hg>4)^7Ew)ZNEKbh{Bv#n;n-{BZx6DNzErl6nAs&l|=Dp0FE3 zoUSkI)UMtKQ7ufnu#Z!KzvgylpyIQxC#mt*6lO1i@49~PUX;O`KzLMWR6KMdyDXg% z6p7Q*YhVms8kas^$+Ey;Bs9{$drwM;EV@dF9gNTzEHm{XGvZG4a>2*(5w-{bDM2X_ zB`d_7bMJUDEA~CUFg0NE2q1$-NS`eGnSj2XwbUX5 zhho4fxLKP?{nvrEiJzO)Q;YNVTAJwy_eKOV7j{Tyl;jNZ*FKg?>3a@rj7G2NsRw9zawT=G6Ap)0Ph@Nz6pXxXD3=?54>_9y>vGTFZ_%hMZf$`{#f0iy2l?oB#4> z5FG&lPv6dq%szV|;$U8i2^C?#19s4xW7kqkx{|UE@`Wc)DS`)Bao~UbJ84cE7Vo(# z=*k2NkuA%9mm<7D@1@XK_NUIgiqgD6Y-~ZNs}^PNe#ToJrrMQJ zL37?0J6^I2Sl9)3UHtLsW2=GHGX}p(MyLGvo8C;VZFc&VWx5ga;YQK-8R~}tE0vfc zu8$-3*TBlD1tti$n6JzgSPm}T)%bd7E&hMCbKYM~Wm_D-kcdGD1`rHUI)Va_q3DWa6nLAsF=gM^|YphGA+RK-XyQWYto2M{Du1Y}^44gxA5io?60@4azN-XHMR z^4Gi8y=&j^IeV{jKIgm7u7#O6*feFHVqLq=Pke%JVa-2rsEnkWf9kUu{06s?>Z_&n zipPsc%_|Bks~O=Ox#Z?G5kOYgZQ!1I5&04{qXyk}Bh#ZLDL;RizZ+U~{NR9aK!Pl} zax}=jM|Pw$Fcr^cck&L<#&xRqveZK_)`p0IM&~JQpBGa3#ncQ>={wY=GK(NDb4BZ0 zvs;vodCkpd)7Up5qI*yJeKcZW$5Rl zaOQj$f-~{$nn4|yuP+JdaD~CkJ?duwdkgDbitnWkCfEFA=hgDK4TsJ$#o{)OdVsW5KF^Ji6T|D8W`ppF5VwgErybl;^I}o zdE6G;ZN7TQxW4cyKM)~c|2&|h_q2@(FAwGl=?JtVfMniQfc7107 zk=**vV&hI5Dq4&VW<5|wgzKfwg_M8 z34J?^PjOn7{|;k&z8rpPE1suS>WS%FatG&6~zAtalUPzyMwft@2 zY4_TesG3DCH{e$3WzB>|9e1Q#;^Tw%&^rqVpKR4&vK4xiI1826x8ol-5=f!o;Ge5= zlLtiRmp3N1rihq3H#OAI*JiCqfN9LH1boKL&{g?(^TJ$&g*ck+c9=48pUbUP%R*vKF}xz zH5cHBd)nRK9wn?)QEpOM07TF0YDLd0y|0?s+!7BRF0@?ggT$4;0auNS+pQPrUfpG3 zNP`t*^fD&dz2);>fI21xk-LUlM)GlaBv&-ZapwKz6|&?C+%^ zj;@OI{vDFBeQZRq&nKU$iVdY_jIqW9@~iS9K_=SdQ!}h!-9}bs@5b?~-h>1m8y(IW zzIgR*U%Fxkh%tF=xB(8ZgCg#?}*hjy2(H>o?8T$oO)OtX1`CSt0Xp?(=aHZEOOqMegUFaYClbYxMo95?bRTS9$bzgwT7>`22Vj>2`1~HO_wlnD}r2z zDv7GCD0>cs30z4cvrfB};4u4q8fY5XFCguF6XLIFD0D~!3~j!XInF1(8Y(YPfZ@q@OCkP~~J`QemhebRz=Sx_quJmS_L65=`g* zI^-bSEs!uLwb&(ZQrWq}dIA}CXuf)UwfF;sC9L6|U$0Yz0)K#h7OWL3vB_Z=W|_!@ z_)90Wkb`s_3)}shC9TQzeNrZKTxygbSDI7r3Hm8rb>tqvN5=QP*E*ppJ%|Id7|4}x z2*}NmwG2~YPAWU)=LLNoQWzN@?1#Bm-i;wz?0UB_)Vl=R58RL9_Uiu9Rbk>3lp-+P zKNBn!FzoVXq1D4*SwfjrS^Tng;Q1F{Tpouk4y(nB7#be1Rzk*+0u?a+U*49**oE&V zjSt>jet>(ySs=985-z+^WY&`f(q(-!m%|qy4(Xv>&OwmpW8$ok97}}5tv8ee`L2*g zbulvu>kgJzPV+>IUEW7&&3bO8va{DVfT-m#iEub(FbS((Ri4Uhi)`!=xkcT8Jx{$h zmEzTzESrt19bYNVRWMi_TS54Wik!Ii!7&|=jVKX>WC4#}QY2IaxvNGWd_)Bm)Gpru zn5CHG@1JdPqi9{JZ#7r1huBYsqhAQtYl%x{`v1i(AaME6B&wUT$3f%qiSMW&EUh1C zhXK((XIpSY1b<+PgJyzGHTz!^0kZEufewP;?x=kpQ&T`}plzpxm{0WXu96!8-ocHn-Hy8M#199^5W2t!?)2)d%4T?RD~wUj|59k zgmJbXTdYZj8U-F1gXV6^)TByYQ=R>a{jp)E$YUr4AZO42wux|BwcRBrmy{rNLcbSCUmC#6ElfDskKSz;x1`m5xy7a1ZYw}QgH+zOLnGD99!Dt(wN>TZL2 z0@_yp4zs{S-u6`5=0fQ8Ba~79ZP#p=2fS%bV3!b69wn?GR^s`D@rx(6Jw(8?blyF& zXmoN{CR0G!oJ)3C$aWxDqhO&DI8%W7Mrz!gc7W(MfWhu=%$RfRYuATt{P8mGJO)1gK+JN%0Fr!|5-HQ^qWAe8<*7sGP)IBIrx8^l zRu`9DkG`0P8h{in5B%o4urui~{kiyn-<8!}Glm$-IceqDVYWzFlhR@+=LI84Ept## zPlj^(7F0~;u9}`T_BH#O^Nj|o_D>5jU;l5kc%EZ$ zQ)@>#ym5A(J(_m;C@MeOr>xVAk>EV%^?;!?FN?5nWpBe>O} ze<%%FmvtfAvg_fENfwc-kI;@029Umf263fH54c$ux@nYO;$tMIRAbP2zEWy-n43F; zxQr6r%a2-=rw=iqlLZ z^Syr!^S|lxuVMZNGROVO=ARoCeq}RQJbylu`F|_gSY;yWDE4btU@oLDY^Pv0qdt=* zbE@u{>g0;KM%n38*|$@i_n{MhKCI&BwT{Xa?K3W7RJ)4?j;egq nxF*w!DPdH89skG6V_T}43Px5W&&S($UF)4Wce+sX@{NB1o3Y~f literal 23537 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 diff --git a/scripts/test-javascript-composited-rendering.mjs b/scripts/test-javascript-composited-rendering.mjs index 955e0b458c4..a2a53538e11 100644 --- a/scripts/test-javascript-composited-rendering.mjs +++ b/scripts/test-javascript-composited-rendering.mjs @@ -225,6 +225,14 @@ async function picker(name, page, press) { check(await select.inputValue() === '0', `${name}: the