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