1   /*
2    * SPDX-FileCopyrightText: none
3    * SPDX-License-Identifier: CC0-1.0
4    */
5   
6   package dev.metaschema.databind.model.impl;
7   
8   import java.lang.reflect.Field;
9   import java.util.ArrayList;
10  import java.util.Arrays;
11  import java.util.LinkedHashMap;
12  import java.util.List;
13  import java.util.Map;
14  import java.util.Objects;
15  import java.util.stream.Collectors;
16  import java.util.stream.Stream;
17  
18  import dev.metaschema.core.model.DefaultAssemblyModelBuilder;
19  import dev.metaschema.core.model.IChoiceInstance;
20  import dev.metaschema.core.model.IContainerModelAssemblySupport;
21  import dev.metaschema.core.util.CollectionUtil;
22  import dev.metaschema.core.util.ObjectUtils;
23  import dev.metaschema.databind.model.IBoundDefinitionModelAssembly;
24  import dev.metaschema.databind.model.IBoundInstanceModel;
25  import dev.metaschema.databind.model.IBoundInstanceModelAny;
26  import dev.metaschema.databind.model.IBoundInstanceModelAssembly;
27  import dev.metaschema.databind.model.IBoundInstanceModelChoiceGroup;
28  import dev.metaschema.databind.model.IBoundInstanceModelField;
29  import dev.metaschema.databind.model.IBoundInstanceModelNamed;
30  import dev.metaschema.databind.model.annotations.BoundAny;
31  import dev.metaschema.databind.model.annotations.BoundAssembly;
32  import dev.metaschema.databind.model.annotations.BoundChoice;
33  import dev.metaschema.databind.model.annotations.BoundChoiceGroup;
34  import dev.metaschema.databind.model.annotations.BoundField;
35  import dev.metaschema.databind.model.annotations.Ignore;
36  import edu.umd.cs.findbugs.annotations.NonNull;
37  import edu.umd.cs.findbugs.annotations.Nullable;
38  
39  /**
40   * Generates assembly model containers for annotation-based bindings.
41   */
42  final class AssemblyModelGenerator {
43  
44    /**
45     * A custom builder that allows adding choice instances without adding them to
46     * the model instances list.
47     * <p>
48     * For annotation-based bindings, the choice alternatives (fields/assemblies)
49     * are already in the model instances list with their {@code @BoundChoice}
50     * annotations. The {@link BoundInstanceModelChoice} wrapper is metadata that
51     * groups them, but should not be iterated during reading.
52     *
53     * @param <MI>
54     *          the model instance type
55     * @param <NMI>
56     *          the named model instance type
57     * @param <FI>
58     *          the field instance type
59     * @param <AI>
60     *          the assembly instance type
61     * @param <CI>
62     *          the choice instance type
63     * @param <CGI>
64     *          the choice group instance type
65     */
66    private static final class BoundAssemblyModelBuilder<
67        MI extends IBoundInstanceModel<?>,
68        NMI extends IBoundInstanceModelNamed<?>,
69        FI extends IBoundInstanceModelField<?>,
70        AI extends IBoundInstanceModelAssembly,
71        CI extends IChoiceInstance,
72        CGI extends IBoundInstanceModelChoiceGroup>
73        extends DefaultAssemblyModelBuilder<MI, NMI, FI, AI, CI, CGI> {
74  
75      /**
76       * Append a choice instance to the choice instances list only, without adding it
77       * to the model instances list.
78       * <p>
79       * This is used for annotation-based bindings where choice alternatives are
80       * already in the model instances list as regular field/assembly instances.
81       *
82       * @param instance
83       *          the choice instance to append
84       */
85      void appendChoiceOnly(@NonNull CI instance) {
86        getChoiceInstances().add(instance);
87      }
88    }
89  
90    @NonNull
91    public static IContainerModelAssemblySupport<
92        IBoundInstanceModel<?>,
93        IBoundInstanceModelNamed<?>,
94        IBoundInstanceModelField<?>,
95        IBoundInstanceModelAssembly,
96        IChoiceInstance,
97        IBoundInstanceModelChoiceGroup> of(@NonNull DefinitionAssembly containingDefinition) {
98      BoundAssemblyModelBuilder<IBoundInstanceModel<?>,
99          IBoundInstanceModelNamed<?>,
100         IBoundInstanceModelField<?>,
101         IBoundInstanceModelAssembly,
102         IChoiceInstance,
103         IBoundInstanceModelChoiceGroup> builder = new BoundAssemblyModelBuilder<>();
104 
105     List<IBoundInstanceModel<?>> modelInstances = CollectionUtil.unmodifiableList(ObjectUtils.notNull(
106         getModelInstanceStream(containingDefinition, containingDefinition.getBoundClass())
107             .collect(Collectors.toUnmodifiableList())));
108 
109     // Group named instances by @BoundChoice annotation
110     Map<String, List<ChoiceInstanceEntry>> choiceGroups = groupByChoiceId(modelInstances);
111 
112     // Validate that choice instances are adjacent
113     validateChoiceAdjacency(choiceGroups, containingDefinition.getBoundClass());
114 
115     // Create choice instances
116     Map<String, BoundInstanceModelChoice> choiceInstances = new LinkedHashMap<>();
117     for (Map.Entry<String, List<ChoiceInstanceEntry>> entry : choiceGroups.entrySet()) {
118       String choiceId = entry.getKey();
119       List<IBoundInstanceModelNamed<?>> instances = ObjectUtils.notNull(entry.getValue().stream()
120           .map(ChoiceInstanceEntry::getInstance)
121           .collect(Collectors.toList()));
122       choiceInstances.put(choiceId, new BoundInstanceModelChoice(
123           ObjectUtils.notNull(choiceId), containingDefinition, instances));
124     }
125 
126     for (IBoundInstanceModel<?> instance : modelInstances) {
127       if (instance instanceof IBoundInstanceModelNamed) {
128         IBoundInstanceModelNamed<?> named = (IBoundInstanceModelNamed<?>) instance;
129         if (instance instanceof IBoundInstanceModelField) {
130           builder.append((IBoundInstanceModelField<?>) named);
131         } else if (instance instanceof IBoundInstanceModelAssembly) {
132           builder.append((IBoundInstanceModelAssembly) named);
133         }
134       } else if (instance instanceof IBoundInstanceModelChoiceGroup) {
135         IBoundInstanceModelChoiceGroup choiceGroup = (IBoundInstanceModelChoiceGroup) instance;
136         builder.append(choiceGroup);
137       }
138     }
139 
140     // Append choice instances to the builder (only to choice list, not model
141     // instances)
142     for (BoundInstanceModelChoice choice : choiceInstances.values()) {
143       assert choice != null;
144       builder.appendChoiceOnly(choice);
145     }
146 
147     // Scan for @BoundAny field (handled separately from model instances)
148     IBoundInstanceModelAny anyInstance
149         = findBoundAnyInstance(containingDefinition, containingDefinition.getBoundClass());
150     if (anyInstance != null) {
151       builder.setAnyInstance(anyInstance);
152     }
153 
154     return builder.buildAssembly();
155   }
156 
157   /**
158    * Groups named model instances by their {@code @BoundChoice} choiceId.
159    *
160    * @param modelInstances
161    *          the list of model instances
162    * @return a map of choiceId to list of instances with their positions
163    */
164   @NonNull
165   private static Map<String, List<ChoiceInstanceEntry>> groupByChoiceId(
166       @NonNull List<IBoundInstanceModel<?>> modelInstances) {
167     Map<String, List<ChoiceInstanceEntry>> choiceGroups = new LinkedHashMap<>();
168 
169     int index = 0;
170     for (IBoundInstanceModel<?> instance : modelInstances) {
171       if (instance instanceof IBoundInstanceModelNamed) {
172         IBoundInstanceModelNamed<?> named = (IBoundInstanceModelNamed<?>) instance;
173         Field field = named.getField();
174         BoundChoice annotation = field.getAnnotation(BoundChoice.class);
175         if (annotation != null) {
176           choiceGroups
177               .computeIfAbsent(annotation.choiceId(), k -> new ArrayList<>())
178               .add(new ChoiceInstanceEntry(index, named));
179         }
180       }
181       index++;
182     }
183 
184     return choiceGroups;
185   }
186 
187   /**
188    * Validates that all fields with the same choiceId are adjacent (consecutive)
189    * in the class declaration.
190    *
191    * @param choiceGroups
192    *          the grouped choice instances
193    * @param boundClass
194    *          the class being validated (for error messages)
195    * @throws IllegalStateException
196    *           if choice fields are not adjacent
197    */
198   private static void validateChoiceAdjacency(
199       @NonNull Map<String, List<ChoiceInstanceEntry>> choiceGroups,
200       @NonNull Class<?> boundClass) {
201     for (Map.Entry<String, List<ChoiceInstanceEntry>> entry : choiceGroups.entrySet()) {
202       String choiceId = entry.getKey();
203       List<ChoiceInstanceEntry> instances = entry.getValue();
204 
205       if (instances.size() > 1) {
206         // Check that indices are consecutive
207         for (int i = 1; i < instances.size(); i++) {
208           int prevIndex = instances.get(i - 1).getIndex();
209           int currIndex = instances.get(i).getIndex();
210           if (currIndex != prevIndex + 1) {
211             throw new IllegalStateException(String.format(
212                 "Choice fields with choiceId '%s' are not adjacent in class '%s'. "
213                     + "All fields in a choice must be declared consecutively.",
214                 choiceId,
215                 boundClass.getName()));
216           }
217         }
218       }
219     }
220   }
221 
222   /**
223    * An entry representing a named model instance with its position in the model.
224    */
225   private static final class ChoiceInstanceEntry {
226     private final int index;
227     @NonNull
228     private final IBoundInstanceModelNamed<?> instance;
229 
230     ChoiceInstanceEntry(int index, @NonNull IBoundInstanceModelNamed<?> instance) {
231       this.index = index;
232       this.instance = instance;
233     }
234 
235     int getIndex() {
236       return index;
237     }
238 
239     @NonNull
240     IBoundInstanceModelNamed<?> getInstance() {
241       return instance;
242     }
243   }
244 
245   private static IBoundInstanceModel<?> newBoundModelInstance(
246       @NonNull Field field,
247       @NonNull IBoundDefinitionModelAssembly definition) {
248     IBoundInstanceModel<?> retval = null;
249     if (field.isAnnotationPresent(BoundAssembly.class)) {
250       retval = IBoundInstanceModelAssembly.newInstance(field, definition);
251     } else if (field.isAnnotationPresent(BoundField.class)) {
252       retval = IBoundInstanceModelField.newInstance(field, definition);
253     } else if (field.isAnnotationPresent(BoundChoiceGroup.class)) {
254       retval = IBoundInstanceModelChoiceGroup.newInstance(field, definition);
255     }
256     return retval;
257   }
258 
259   @NonNull
260   private static Stream<IBoundInstanceModel<?>> getModelInstanceStream(
261       @NonNull IBoundDefinitionModelAssembly definition,
262       @NonNull Class<?> clazz) {
263 
264     Stream<IBoundInstanceModel<?>> superInstances;
265     Class<?> superClass = clazz.getSuperclass();
266     if (superClass == null) {
267       superInstances = Stream.empty();
268     } else {
269       // get instances from superclass
270       superInstances = getModelInstanceStream(definition, superClass);
271     }
272 
273     return ObjectUtils.notNull(Stream.concat(superInstances, Arrays.stream(clazz.getDeclaredFields())
274         // skip this field, since it is ignored
275         .filter(field -> !field.isAnnotationPresent(Ignore.class))
276         // skip fields that aren't a Module field or assembly instance
277         .filter(field -> field.isAnnotationPresent(BoundField.class)
278             || field.isAnnotationPresent(BoundAssembly.class)
279             || field.isAnnotationPresent(BoundChoiceGroup.class))
280         .map(field -> {
281           assert field != null;
282 
283           IBoundInstanceModel<?> retval = newBoundModelInstance(field, definition);
284           if (retval == null) {
285             throw new IllegalStateException(
286                 String.format("The field '%s' on class '%s' is not bound", field.getName(), clazz.getName()));
287           }
288           return retval;
289         })
290         .filter(Objects::nonNull)));
291   }
292 
293   /**
294    * Scans the bound class hierarchy for a field annotated with
295    * {@link BoundAny @BoundAny}.
296    *
297    * @param containingDefinition
298    *          the assembly definition containing the bound class
299    * @param clazz
300    *          the class to scan (including its superclass hierarchy)
301    * @return the bound {@code any} instance, or {@code null} if no
302    *         {@code @BoundAny} field is found
303    * @throws IllegalStateException
304    *           if more than one {@code @BoundAny} field is found in the class
305    *           hierarchy
306    */
307   @Nullable
308   private static IBoundInstanceModelAny findBoundAnyInstance(
309       @NonNull IBoundDefinitionModelAssembly containingDefinition,
310       @NonNull Class<?> clazz) {
311     IBoundInstanceModelAny result = null;
312 
313     // Walk the class hierarchy (superclass first)
314     Class<?> superClass = clazz.getSuperclass();
315     if (superClass != null) {
316       result = findBoundAnyInstance(containingDefinition, superClass);
317     }
318 
319     // Scan declared fields for @BoundAny
320     for (Field field : clazz.getDeclaredFields()) {
321       if (field.isAnnotationPresent(BoundAny.class)) {
322         if (result != null) {
323           throw new IllegalStateException(String.format(
324               "Multiple @BoundAny fields found in class hierarchy of '%s'."
325                   + " Only one @BoundAny field is allowed per assembly.",
326               containingDefinition.getBoundClass().getName()));
327         }
328         result = IBoundInstanceModelAny.newInstance(field, containingDefinition);
329       }
330     }
331 
332     return result;
333   }
334 
335   private AssemblyModelGenerator() {
336     // disable construction
337   }
338 }