Skip to content

Commit 6b50d17

Browse files
authored
[MODFQMMGR-1034] Handle entity type inheritance when migrating queries, custom entities (#1144)
1 parent 0eee31c commit 6b50d17

33 files changed

Lines changed: 1687 additions & 159 deletions

docs/Migration.md

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
# Query Migration
1+
# Migration
22

33
Entity types and their fields change over time, be it adding fields, moving them between entity types, or completely rethinking the way some fields are handled. As such, we have a robust migration system to ensure that consuming apps will not break, and their queries will continue to work despite any internal FQM changes.
44

5-
- [Query versions](#query-versions)
5+
- [Versions](#versions)
66
- [Updating a query](#updating-a-query)
7+
- [Updating an entity type](#updating-an-entity-type)
78
- [Writing migrations](#writing-migrations)
89
- [Changes](#changes)
910
- [Entity type changes](#entity-type-changes)
@@ -26,8 +27,10 @@ Entity types and their fields change over time, be it adding fields, moving them
2627
- [State](#state)
2728
- [Extra magic](#extra-magic)
2829
- [Advanced migration tips](#advanced-migration-tips)
30+
- [Custom entity types support](#custom-entity-types-support)
31+
- [Recovery](#recovery)
2932

30-
## Query versions
33+
## Versions
3134

3235
The version of a query is stored inside the FQL string:
3336

@@ -38,14 +41,28 @@ The version of a query is stored inside the FQL string:
3841
}
3942
```
4043

41-
These are arbitrary strings, and consuming applications should make no assumptions about them (they are currently integers, but may be changed in the future to commit hashes, module versions, or anything else).
44+
And versions of custom entity types are stored inside the entity definition:
45+
46+
```json
47+
{
48+
"id": "d41130e9-0302-5ef3-a6b2-70f6ae1678ce",
49+
"name": "my_custom_entity",
50+
"_version": "3"
51+
}
52+
```
53+
54+
These are arbitrary strings, and consuming applications should make no assumptions about them (they are currently semver-adjacent, but may be changed in the future to commit hashes, module versions, or anything else).
4255

4356
Queries from Quesnelia or earlier will have no version associated with them and will be considered version `"0"`.
4457

4558
## Updating a query
4659

4760
To update a query, send it, the entity type ID, and a list of fields (if desired) to `/fqm/migrate`. See our [API documentation](https://dev.folio.org/reference/api/#mod-fqm-manager) for more information about this endpoint. Our module will return the updated query, entity type ID, and list of fields, all of which should be saved. Additionally, the response may contain [warnings](#warnings), meaning that some parts of the query or field list was unable to be migrated.
4861

62+
## Updating an entity type
63+
64+
Custom entity types will be migrated when the module is installed. No additional action is required; for more information see [custom entity types support](#custom-entity-types-support).
65+
4966
## Writing migrations
5067

5168
Any change to an entity type that results in a field being removed or renamed should result in a migration script. The easiest way to do this is to do the following:
@@ -108,7 +125,20 @@ public Map<UUID, UUID> getEntityTypeChanges() {
108125

109126
### Defining source maps
110127

111-
<!-- TODO: describe this (in next PR) -->
128+
Source maps are used in migrations to define relations between composite and simple entity types. For example, if your migration alters `simple_instance_status`, it's necessary for the migration system to know that `composite_instances`'s `inst_stat` source points to `simple_instance_status`. To define these relationships, override `getEntityTypeSourceMaps` (note that the inner keys are the source aliases used by the composite):
129+
130+
```java
131+
public Map<UUID, Map<String, UUID>> getEntityTypeSourceMaps() {
132+
return Map.of(
133+
COMPOSITE_INSTANCES_ID, Map.of("inst_stat", SIMPLE_INSTANCE_STATUS_ID),
134+
COMPOSITE_ITEM_DETAILS_ID, Map.of("instance_status", SIMPLE_INSTANCE_STATUS_ID)
135+
);
136+
}
137+
```
138+
139+
> [!NOTE]
140+
>
141+
> Only references from all inheriting composites to the migrated entities need to be defined here — other sources used in parent entities do not need to be explicitly stated.
112142
113143
### Warnings
114144

@@ -346,3 +376,38 @@ public MigratableQueryInformation additionalChanges(Void v, MigratableQueryInfor
346376
- `{entityTypeId=composite-users-et, fieldPrefix=outer_entity., field=users.id}`
347377
- `{entityTypeId=simple-user-et, fieldPrefix=outer_entity.users., field=id}`
348378
- Iterations are done in this order (from the outermost entity to the simplest) and will stop either when a transformation **does** occur (field/condition changes, warning emitted, etc) or when there's no more levels to process.
379+
380+
## Custom entity types support
381+
382+
> “With great power comes great responsibility”
383+
>
384+
> _- Uncle Ben, Spider-Man comics_
385+
386+
Custom entity types are incredibly powerful, however, this very power limits the ability for the entities and their queries to be automatically migrated.
387+
388+
Currently, FQM will migrate custom entity types based on changes to FQM itself. **No migration is supported for changes made by users to custom entity types.** Here is what FQM will migrate on the entities:
389+
390+
- Source entity type ID changes,
391+
- Source/target join field changes,
392+
- Default sort order, and
393+
- Group by definitions.
394+
395+
Queries will be migrated just like any other, with the exception of:
396+
397+
- If a source's entity type ID changes, queries may not have migrations applicable to that source performed.
398+
399+
> [!WARNING]
400+
>
401+
> Custom entity migration is done on a “best effort” basis and may not cover all edge cases, nor will it necessarily guarantee a working entity type or query after migration. In the event that something could not be automatically handled (for example, a source's `targetField` is no longer available), a warning will be emitted in the custom entity's `description`. Be sure to check these descriptions and the migration warnings after performing a migration to ensure everything is still as expected.
402+
>
403+
> For additional validation, or if you experience issues, follow the [recovery](#recovery) steps below.
404+
405+
### Recovery
406+
407+
In the event that migration results in a ”broken” entity type (for example, a source no longer exists), it can be easily repaired. To do so, follow these steps:
408+
409+
1. `GET` the migrated entity type via `/entity-types/custom/{id}`,
410+
2. Fix any noticed issues,
411+
3. `PUT` it back to `/entity-types/custom/{id}`,
412+
4. If validation fails, go back to step 2.
413+
5. Success! 🎉

src/main/java/org/folio/fqm/migration/MigratableQueryInformation.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@ public record MigratableQueryInformation(
1616
@CheckForNull String fqlQuery,
1717
List<String> fields,
1818
@Singular List<Warning> warnings,
19-
String version,
2019
boolean hadBreakingChanges
2120
)
2221
implements Serializable {
2322
public MigratableQueryInformation(UUID entityTypeId, String fqlQuery, List<String> fields) {
24-
this(entityTypeId, fqlQuery, fields, List.of(), null, false);
23+
this(entityTypeId, fqlQuery, fields, List.of(), false);
2524
}
2625
}

src/main/java/org/folio/fqm/migration/MigrationUtils.java

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,21 @@ public class MigrationUtils {
4141
* Helper function to transform an FQL query where each field gets turned into a new quantity of fields.
4242
* This runs a given function on each field's condition in the query, potentially adding or removing $and as needed.
4343
*
44+
* Note that, for nested fields, the handler function may be called multiple times for the same field,
45+
* once for each level of nesting. If the handler modifies the field/condition, no further unwrapping will be attempted.
46+
*
4447
* @param entityTypeId The entity type ID of the query being migrated
4548
* @param fqlQuery The root query to migrate
4649
* @param handler something that takes an {@link MigratableFqlFieldAndCondition} and returns a list of
4750
* {@link SingleFieldMigrationResult} indicating the new field(s), warnings, and whether
4851
* a breaking change occurred
52+
* @param sourceMappings A map of entity type IDs to their sources (alias -> source target ID), used for unwrapping nested fields
4953
*/
5054
public static MigrationResult<String> migrateFql(
5155
UUID entityTypeId,
5256
String fqlQuery,
53-
Function<MigratableFqlFieldAndCondition, SingleFieldMigrationResult<MigratableFqlFieldAndCondition>> handler
57+
Function<MigratableFqlFieldAndCondition, SingleFieldMigrationResult<MigratableFqlFieldAndCondition>> handler,
58+
Map<UUID, Map<String, UUID>> sourceMappings
5459
) {
5560
try {
5661
ObjectNode fql = (ObjectNode) objectMapper.readTree(fqlQuery);
@@ -68,7 +73,9 @@ public static MigrationResult<String> migrateFql(
6873

6974
List<SingleFieldMigrationResult<MigratableFqlFieldAndCondition>> transformed = startingFields
7075
.stream()
71-
.map(f -> handleSingleFieldWithNesting(f, handler, MigrationUtils::didMigrationModifyFieldAndCondition))
76+
.map(f ->
77+
handleSingleFieldWithNesting(f, handler, MigrationUtils::didMigrationModifyFieldAndCondition, sourceMappings)
78+
)
7279
.toList();
7380
List<MigratableFqlFieldAndCondition> resultingFields = transformed
7481
.stream()
@@ -105,19 +112,22 @@ public static MigrationResult<String> migrateFql(
105112
* @param handler something that takes an {@link MigratableFqlFieldOnly} and returns a list of
106113
* {@link SingleFieldMigrationResult} indicating the new field(s), warnings, and
107114
* whether a breaking change occurred
115+
* @param sourceMappings A map of entity type IDs to their sources (alias -> source target ID), used for unwrapping nested fields
108116
*/
109117
public static MigrationResult<List<String>> migrateFieldNames(
110118
UUID entityTypeId,
111119
List<String> fields,
112-
Function<MigratableFqlFieldOnly, SingleFieldMigrationResult<MigratableFqlFieldOnly>> handler
120+
Function<MigratableFqlFieldOnly, SingleFieldMigrationResult<MigratableFqlFieldOnly>> handler,
121+
Map<UUID, Map<String, UUID>> sourceMappings
113122
) {
114123
List<SingleFieldMigrationResult<MigratableFqlFieldOnly>> transformed = fields
115124
.stream()
116125
.map(f ->
117126
handleSingleFieldWithNesting(
118127
new MigratableFqlFieldOnly(entityTypeId, "", f),
119128
handler,
120-
MigrationUtils::didMigrationModifyFieldOnly
129+
MigrationUtils::didMigrationModifyFieldOnly,
130+
sourceMappings
121131
)
122132
)
123133
.toList();
@@ -135,17 +145,37 @@ public static MigrationResult<List<String>> migrateFieldNames(
135145
);
136146
}
137147

148+
/**
149+
* Iteratively calls `handler` on each level of nesting for the given field, until either:
150+
* - the handler modifies the field (as determined by `didModify`), or
151+
* - there is no further nesting to unwrap
152+
*/
138153
private static <F extends MigratableFqlField<F>> SingleFieldMigrationResult<F> handleSingleFieldWithNesting(
139154
F original,
140155
Function<F, SingleFieldMigrationResult<F>> handler,
141-
BiPredicate<F, SingleFieldMigrationResult<F>> didModify
156+
BiPredicate<F, SingleFieldMigrationResult<F>> didModify,
157+
Map<UUID, Map<String, UUID>> sourceMappings
142158
) {
143159
SingleFieldMigrationResult<F> transformed = handler.apply(original);
160+
int fieldDelimiterIndex = original.field().indexOf('.');
144161

145-
// stub for follow-up ticket
146-
didModify.test(original, transformed);
162+
if (didModify.test(original, transformed) || fieldDelimiterIndex == -1) {
163+
return transformed;
164+
}
165+
166+
Map<String, UUID> sourceMap = sourceMappings.get(original.entityTypeId());
167+
String source = original.field().substring(0, fieldDelimiterIndex);
168+
String remainder = original.field().substring(fieldDelimiterIndex + 1);
169+
if (sourceMap == null || sourceMap.get(source) == null) {
170+
return transformed;
171+
}
147172

148-
return transformed;
173+
return handleSingleFieldWithNesting(
174+
original.dereferenced(sourceMap.get(source), source, remainder),
175+
handler,
176+
didModify,
177+
sourceMappings
178+
);
149179
}
150180

151181
public static boolean didMigrationModifyFieldAndCondition(

src/main/java/org/folio/fqm/migration/strategies/AbstractRegularMigrationStrategy.java

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package org.folio.fqm.migration.strategies;
22

33
import java.util.Collection;
4+
import java.util.HashMap;
45
import java.util.List;
6+
import java.util.Map;
57
import java.util.Objects;
8+
import java.util.UUID;
69
import java.util.stream.Stream;
710
import org.folio.fqm.migration.MigratableQueryInformation;
811
import org.folio.fqm.migration.MigrationUtils;
@@ -23,6 +26,27 @@ public S getStartingState() {
2326
return null;
2427
}
2528

29+
/**
30+
* Defines the relationship of composite entities to the entity being migrated here. For example,
31+
* a migration which alters `simple_instance_status` would need to define that
32+
* `composite_instances` and `composite_item_details` inherited it. This must be provided in the
33+
* migration as, without this, we cannot guarantee that a future `composite_instances` will refer
34+
* to `simple_instance_status` in the same way.
35+
*
36+
* Note that this map needs to only contain relevant sources; it is not necessary to define every
37+
* other source/composite.
38+
*
39+
* To define these relationships, return a map like:
40+
* @example
41+
* Map.of(
42+
* COMPOSITE_INSTANCES_ID, Map.of("inst_stat", SIMPLE_INSTANCE_STATUS_ID),
43+
* COMPOSITE_ITEM_DETAILS_ID, Map.of("instance_status", SIMPLE_INSTANCE_STATUS_ID)
44+
* )
45+
*/
46+
public Map<UUID, Map<String, UUID>> getEntityTypeSourceMaps() {
47+
return Map.of();
48+
}
49+
2650
/**
2751
* Perform changes to fields and conditions within the FQL. This enables settings values for
2852
* the field's name, operator, and value together. Note that renaming fields must be done both
@@ -77,18 +101,26 @@ public MigratableQueryInformation additionalChanges(S state, MigratableQueryInfo
77101
}
78102

79103
@Override
80-
public final MigratableQueryInformation apply(MigratableQueryInformation query) {
104+
public final MigratableQueryInformation apply(
105+
MigratableQueryInformation query,
106+
Map<UUID, Map<String, UUID>> customEntityTypeMappings
107+
) {
81108
S state = getStartingState();
82109

110+
Map<UUID, Map<String, UUID>> sourceMappings = new HashMap<>(getEntityTypeSourceMaps());
111+
sourceMappings.putAll(customEntityTypeMappings);
112+
83113
MigrationResult<String> fqlMigration = MigrationUtils.migrateFql(
84114
query.entityTypeId(),
85115
query.fqlQuery(),
86-
f -> this.migrateFql(state, f)
116+
f -> this.migrateFql(state, f),
117+
sourceMappings
87118
);
88119
MigrationResult<List<String>> fieldsMigration = MigrationUtils.migrateFieldNames(
89120
query.entityTypeId(),
90121
query.fields(),
91-
f -> this.migrateFieldName(state, f)
122+
f -> this.migrateFieldName(state, f),
123+
sourceMappings
92124
);
93125

94126
return additionalChanges(

src/main/java/org/folio/fqm/migration/strategies/MigrationStrategy.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package org.folio.fqm.migration.strategies;
22

3+
import java.util.Map;
4+
import java.util.UUID;
35
import org.folio.fqm.migration.MigratableQueryInformation;
46

57
/**
@@ -21,5 +23,8 @@ public interface MigrationStrategy {
2123
/**
2224
* Migrate a query.
2325
*/
24-
MigratableQueryInformation apply(MigratableQueryInformation migratableQueryInformation);
26+
MigratableQueryInformation apply(
27+
MigratableQueryInformation migratableQueryInformation,
28+
Map<UUID, Map<String, UUID>> customEntityTypeMappings
29+
);
2530
}

src/main/java/org/folio/fqm/migration/strategies/impl/V23UserCreatedUpdatedDateFieldDeprecation.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ public String getLabel() {
3131
return "V23 -> V24 Legacy user created/updated dates deprecation (part of MODFQMMGR-1006)";
3232
}
3333

34+
@Override
35+
public Map<UUID, Map<String, UUID>> getEntityTypeSourceMaps() {
36+
return Map.ofEntries(
37+
Map.entry(COMPOSITE_USERS_ID, Map.of("users", SIMPLE_USERS_ID)),
38+
Map.entry(COMPOSITE_LOAN_DETAILS_ID, Map.of("users", SIMPLE_USERS_ID))
39+
);
40+
}
41+
3442
@Override
3543
public Map<UUID, Map<String, String>> getFieldChanges() {
3644
return Map.ofEntries(

src/main/java/org/folio/fqm/migration/warnings/RemovedFieldWarning.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import lombok.EqualsAndHashCode;
77
import lombok.RequiredArgsConstructor;
88
import lombok.ToString;
9+
import org.folio.fqm.service.LocalizationService;
910
import org.folio.spring.i18n.service.TranslationService;
1011

1112
@Builder
@@ -31,7 +32,20 @@ public WarningType getType() {
3132

3233
@Override
3334
public String getDescription(TranslationService translationService) {
34-
return Warning.getDescriptionByAlternativeAndFql(translationService, this.getType(), field, fql, alternative);
35+
String translationKey = LocalizationService.MIGRATION_WARNING_TRANSLATION_TEMPLATE.formatted(TYPE.toString());
36+
37+
if (fql == null) {
38+
translationKey += ".field";
39+
} else {
40+
translationKey += ".query";
41+
}
42+
if (alternative == null) {
43+
translationKey += ".withoutAlternative";
44+
} else {
45+
translationKey += ".withAlternative";
46+
}
47+
48+
return translationService.format(translationKey, "name", field, "alternative", alternative, "fql", fql);
3549
}
3650

3751
public static FieldWarningFactory withoutAlternative() {

0 commit comments

Comments
 (0)