Skip to content

Commit 7326bb9

Browse files
feat(urn-validation): Add UrnValidation PDL annotation (#12572)
1 parent b202115 commit 7326bb9

File tree

32 files changed

+1164
-189
lines changed

32 files changed

+1164
-189
lines changed

docs/modeling/extending-the-metadata-model.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ The Aspect has four key components: its properties, the @Aspect annotation, the
207207
the case of DashboardInfo, the `charts` field is an Array of Urns. The @Relationship annotation cannot be applied
208208
directly to an array of Urns. That’s why you see the use of an Annotation override (`"/*":`) to apply the @Relationship
209209
annotation to the Urn directly. Read more about overrides in the annotation docs further down on this page.
210+
- **@UrnValidation**: This annotation can enforce constraints on Urn fields, including entity type restrictions and existence.
210211

211212
After you create your Aspect, you need to attach to all the entities that it applies to.
212213

@@ -496,6 +497,27 @@ This annotation says that when we ingest an Entity with an Ownership Aspect, Dat
496497
between that entity and the CorpUser or CorpGroup who owns it. This will be queryable using the Relationships resource
497498
in both the forward and inverse directions.
498499

500+
#### @UrnValidation
501+
502+
This annotation can be applied to Urn fields inside an aspect. The annotation can optionally perform one or more of the following:
503+
- Enforce that the URN exists
504+
- Enforce stricter URN validation
505+
- Restrict the URN to specific entity types
506+
507+
##### Example
508+
509+
Using this example from StructuredPropertyDefinition, we are enforcing that the valueType URN must exist,
510+
it must follow stricter Urn encoding logic, and it can only be of entity type `dataType`.
511+
512+
```
513+
@UrnValidation = {
514+
"exist": true,
515+
"strict": true,
516+
"entityTypes": [ "dataType" ],
517+
}
518+
valueType: Urn
519+
```
520+
499521
#### Annotating Collections & Annotation Overrides
500522

501523
You will not always be able to apply annotations to a primitive field directly. This may be because the field is wrapped

entity-registry/src/main/java/com/linkedin/metadata/models/AspectSpec.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public class AspectSpec {
2424
private final Map<String, TimeseriesFieldSpec> _timeseriesFieldSpecs;
2525
private final Map<String, TimeseriesFieldCollectionSpec> _timeseriesFieldCollectionSpecs;
2626
private final Map<String, SearchableRefFieldSpec> _searchableRefFieldSpecs;
27+
private final Map<String, UrnValidationFieldSpec> _urnValidationFieldSpecs;
2728

2829
// Classpath & Pegasus-specific: Temporary.
2930
private final RecordDataSchema _schema;
@@ -39,6 +40,7 @@ public AspectSpec(
3940
@Nonnull final List<TimeseriesFieldSpec> timeseriesFieldSpecs,
4041
@Nonnull final List<TimeseriesFieldCollectionSpec> timeseriesFieldCollectionSpecs,
4142
@Nonnull final List<SearchableRefFieldSpec> searchableRefFieldSpecs,
43+
@Nonnull final List<UrnValidationFieldSpec> urnValidationFieldSpecs,
4244
final RecordDataSchema schema,
4345
final Class<RecordTemplate> aspectClass) {
4446
_aspectAnnotation = aspectAnnotation;
@@ -76,6 +78,11 @@ public AspectSpec(
7678
spec -> spec.getTimeseriesFieldCollectionAnnotation().getCollectionName(),
7779
spec -> spec,
7880
(val1, val2) -> val1));
81+
_urnValidationFieldSpecs =
82+
urnValidationFieldSpecs.stream()
83+
.collect(
84+
Collectors.toMap(
85+
spec -> spec.getPath().toString(), spec -> spec, (val1, val2) -> val1));
7986
_schema = schema;
8087
_aspectClass = aspectClass;
8188
}
@@ -112,6 +119,10 @@ public Map<String, TimeseriesFieldSpec> getTimeseriesFieldSpecMap() {
112119
return _timeseriesFieldSpecs;
113120
}
114121

122+
public Map<String, UrnValidationFieldSpec> getUrnValidationFieldSpecMap() {
123+
return _urnValidationFieldSpecs;
124+
}
125+
115126
public Map<String, TimeseriesFieldCollectionSpec> getTimeseriesFieldCollectionSpecMap() {
116127
return _timeseriesFieldCollectionSpecs;
117128
}

entity-registry/src/main/java/com/linkedin/metadata/models/EntitySpecBuilder.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import com.linkedin.metadata.models.annotation.SearchableRefAnnotation;
2121
import com.linkedin.metadata.models.annotation.TimeseriesFieldAnnotation;
2222
import com.linkedin.metadata.models.annotation.TimeseriesFieldCollectionAnnotation;
23+
import com.linkedin.metadata.models.annotation.UrnValidationAnnotation;
2324
import java.util.ArrayList;
2425
import java.util.Collections;
2526
import java.util.HashSet;
@@ -48,6 +49,8 @@ public class EntitySpecBuilder {
4849
new PegasusSchemaAnnotationHandlerImpl(TimeseriesFieldAnnotation.ANNOTATION_NAME);
4950
public static SchemaAnnotationHandler _timeseriesFieldCollectionHandler =
5051
new PegasusSchemaAnnotationHandlerImpl(TimeseriesFieldCollectionAnnotation.ANNOTATION_NAME);
52+
public static SchemaAnnotationHandler _urnValidationAnnotationHandler =
53+
new PegasusSchemaAnnotationHandlerImpl(UrnValidationAnnotation.ANNOTATION_NAME);
5154

5255
private final AnnotationExtractionMode _extractionMode;
5356
private final Set<String> _entityNames = new HashSet<>();
@@ -226,6 +229,7 @@ public AspectSpec buildAspectSpec(
226229
Collections.emptyList(),
227230
Collections.emptyList(),
228231
Collections.emptyList(),
232+
Collections.emptyList(),
229233
aspectRecordSchema,
230234
aspectClass);
231235
}
@@ -299,6 +303,18 @@ public AspectSpec buildAspectSpec(
299303
new DataSchemaRichContextTraverser(timeseriesFieldSpecExtractor);
300304
timeseriesFieldSpecTraverser.traverse(processedTimeseriesFieldResult.getResultSchema());
301305

306+
// Extract UrnValidation aspects
307+
final SchemaAnnotationProcessor.SchemaAnnotationProcessResult processedTimestampResult =
308+
SchemaAnnotationProcessor.process(
309+
Collections.singletonList(_urnValidationAnnotationHandler),
310+
aspectRecordSchema,
311+
new SchemaAnnotationProcessor.AnnotationProcessOption());
312+
final UrnValidationFieldSpecExtractor urnValidationFieldSpecExtractor =
313+
new UrnValidationFieldSpecExtractor();
314+
final DataSchemaRichContextTraverser timestampFieldSpecTraverser =
315+
new DataSchemaRichContextTraverser(urnValidationFieldSpecExtractor);
316+
timestampFieldSpecTraverser.traverse(processedTimestampResult.getResultSchema());
317+
302318
return new AspectSpec(
303319
aspectAnnotation,
304320
searchableFieldSpecExtractor.getSpecs(),
@@ -307,6 +323,7 @@ public AspectSpec buildAspectSpec(
307323
timeseriesFieldSpecExtractor.getTimeseriesFieldSpecs(),
308324
timeseriesFieldSpecExtractor.getTimeseriesFieldCollectionSpecs(),
309325
searchableRefFieldSpecExtractor.getSpecs(),
326+
urnValidationFieldSpecExtractor.getUrnValidationFieldSpecs(),
310327
aspectRecordSchema,
311328
aspectClass);
312329
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.linkedin.metadata.models;
2+
3+
import com.linkedin.data.schema.DataSchema;
4+
import com.linkedin.data.schema.PathSpec;
5+
import com.linkedin.metadata.models.annotation.UrnValidationAnnotation;
6+
import javax.annotation.Nonnull;
7+
import lombok.Value;
8+
9+
@Value
10+
public class UrnValidationFieldSpec {
11+
@Nonnull PathSpec path;
12+
@Nonnull UrnValidationAnnotation urnValidationAnnotation;
13+
@Nonnull DataSchema pegasusSchema;
14+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.linkedin.metadata.models;
2+
3+
import com.linkedin.data.schema.DataSchema;
4+
import com.linkedin.data.schema.DataSchemaTraverse;
5+
import com.linkedin.data.schema.PathSpec;
6+
import com.linkedin.data.schema.annotation.SchemaVisitor;
7+
import com.linkedin.data.schema.annotation.SchemaVisitorTraversalResult;
8+
import com.linkedin.data.schema.annotation.TraverserContext;
9+
import com.linkedin.metadata.models.annotation.UrnValidationAnnotation;
10+
import java.util.ArrayList;
11+
import java.util.List;
12+
import lombok.Getter;
13+
14+
@Getter
15+
public class UrnValidationFieldSpecExtractor implements SchemaVisitor {
16+
private final List<UrnValidationFieldSpec> urnValidationFieldSpecs = new ArrayList<>();
17+
18+
@Override
19+
public void callbackOnContext(TraverserContext context, DataSchemaTraverse.Order order) {
20+
if (context.getEnclosingField() == null) {
21+
return;
22+
}
23+
24+
if (DataSchemaTraverse.Order.PRE_ORDER.equals(order)) {
25+
final DataSchema currentSchema = context.getCurrentSchema().getDereferencedDataSchema();
26+
final PathSpec path = new PathSpec(context.getSchemaPathSpec());
27+
28+
// Check for @UrnValidation annotation in primary properties
29+
final Object urnValidationAnnotationObj =
30+
context.getEnclosingField().getProperties().get(UrnValidationAnnotation.ANNOTATION_NAME);
31+
32+
// Check if it's either explicitly annotated with @UrnValidation
33+
if (urnValidationAnnotationObj != null) {
34+
addUrnValidationFieldSpec(currentSchema, path, urnValidationAnnotationObj);
35+
}
36+
}
37+
}
38+
39+
private void addUrnValidationFieldSpec(
40+
DataSchema currentSchema, PathSpec path, Object annotationObj) {
41+
UrnValidationAnnotation annotation =
42+
UrnValidationAnnotation.fromPegasusAnnotationObject(
43+
annotationObj, FieldSpecUtils.getSchemaFieldName(path), path.toString());
44+
45+
urnValidationFieldSpecs.add(new UrnValidationFieldSpec(path, annotation, currentSchema));
46+
}
47+
48+
@Override
49+
public VisitorContext getInitialVisitorContext() {
50+
return null;
51+
}
52+
53+
@Override
54+
public SchemaVisitorTraversalResult getSchemaVisitorTraversalResult() {
55+
return new SchemaVisitorTraversalResult();
56+
}
57+
}

entity-registry/src/main/java/com/linkedin/metadata/models/annotation/AnnotationUtils.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package com.linkedin.metadata.models.annotation;
22

3+
import java.util.ArrayList;
4+
import java.util.Collections;
5+
import java.util.List;
36
import java.util.Map;
47
import java.util.Optional;
58
import lombok.experimental.UtilityClass;
@@ -13,4 +16,23 @@ <T> Optional<T> getField(final Map fieldMap, final String fieldName, final Class
1316
}
1417
return Optional.empty();
1518
}
19+
20+
<T> List<T> getFieldList(
21+
final Map<String, ?> fieldMap, final String fieldName, final Class<T> itemType) {
22+
Object value = fieldMap.get(fieldName);
23+
if (!(value instanceof List<?>)) {
24+
return Collections.emptyList();
25+
}
26+
27+
List<?> list = (List<?>) value;
28+
List<T> result = new ArrayList<>();
29+
30+
for (Object item : list) {
31+
if (itemType.isInstance(item)) {
32+
result.add(itemType.cast(item));
33+
}
34+
}
35+
36+
return Collections.unmodifiableList(result);
37+
}
1638
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.linkedin.metadata.models.annotation;
2+
3+
import com.linkedin.metadata.models.ModelValidationException;
4+
import java.util.List;
5+
import java.util.Map;
6+
import java.util.Optional;
7+
import javax.annotation.Nonnull;
8+
import lombok.Value;
9+
10+
@Value
11+
public class UrnValidationAnnotation {
12+
public static final String ANNOTATION_NAME = "UrnValidation";
13+
boolean exist;
14+
boolean strict;
15+
List<String> entityTypes;
16+
17+
@Nonnull
18+
public static UrnValidationAnnotation fromPegasusAnnotationObject(
19+
@Nonnull final Object annotationObj,
20+
@Nonnull final String schemaFieldName,
21+
@Nonnull final String context) {
22+
if (!Map.class.isAssignableFrom(annotationObj.getClass())) {
23+
throw new ModelValidationException(
24+
String.format(
25+
"Failed to validate @%s annotation declared at %s: Invalid value type provided (Expected Map)",
26+
ANNOTATION_NAME, context));
27+
}
28+
29+
Map<String, ?> map = (Map<String, ?>) annotationObj;
30+
final Optional<Boolean> exist = AnnotationUtils.getField(map, "exist", Boolean.class);
31+
final Optional<Boolean> strict = AnnotationUtils.getField(map, "strict", Boolean.class);
32+
final List<String> entityTypes = AnnotationUtils.getFieldList(map, "entityTypes", String.class);
33+
34+
return new UrnValidationAnnotation(exist.orElse(true), strict.orElse(true), entityTypes);
35+
}
36+
}

entity-registry/src/test/java/com/linkedin/metadata/models/registry/PluginEntityRegistryLoaderTest.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ private EntityRegistry getBaseEntityRegistry() {
100100
Collections.emptyList(),
101101
Collections.emptyList(),
102102
Collections.emptyList(),
103+
Collections.emptyList(),
103104
(RecordDataSchema) DataSchemaFactory.getInstance().getAspectSchema("datasetKey").get(),
104105
DataSchemaFactory.getInstance().getAspectClass("datasetKey").get());
105106

metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/EntityAspect.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import com.linkedin.metadata.aspect.SystemAspect;
1010
import com.linkedin.metadata.models.AspectSpec;
1111
import com.linkedin.metadata.models.EntitySpec;
12+
import com.linkedin.metadata.utils.EntityApiUtils;
1213
import com.linkedin.mxe.GenericAspect;
1314
import com.linkedin.mxe.SystemMetadata;
1415
import java.sql.Timestamp;

metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/AspectsBatchImpl.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import com.linkedin.metadata.aspect.batch.MCPItem;
1414
import com.linkedin.metadata.aspect.plugins.hooks.MutationHook;
1515
import com.linkedin.metadata.aspect.plugins.validation.ValidationExceptionCollection;
16+
import com.linkedin.metadata.entity.validation.ValidationException;
1617
import com.linkedin.metadata.models.EntitySpec;
1718
import com.linkedin.mxe.MetadataChangeProposal;
1819
import com.linkedin.util.Pair;
@@ -243,7 +244,7 @@ public AspectsBatchImpl build() {
243244
ValidationExceptionCollection exceptions =
244245
AspectsBatch.validateProposed(this.nonRepeatedItems, this.retrieverContext);
245246
if (!exceptions.isEmpty()) {
246-
throw new IllegalArgumentException("Failed to validate MCP due to: " + exceptions);
247+
throw new ValidationException("Failed to validate MCP due to: " + exceptions);
247248
}
248249

249250
return new AspectsBatchImpl(this.items, this.nonRepeatedItems, this.retrieverContext);

metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/ChangeItemImpl.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@
1414
import com.linkedin.metadata.aspect.batch.MCPItem;
1515
import com.linkedin.metadata.aspect.patch.template.common.GenericPatchTemplate;
1616
import com.linkedin.metadata.entity.AspectUtils;
17-
import com.linkedin.metadata.entity.EntityApiUtils;
1817
import com.linkedin.metadata.entity.EntityAspect;
1918
import com.linkedin.metadata.entity.validation.ValidationApiUtils;
2019
import com.linkedin.metadata.models.AspectSpec;
2120
import com.linkedin.metadata.models.EntitySpec;
21+
import com.linkedin.metadata.utils.EntityApiUtils;
2222
import com.linkedin.metadata.utils.EntityKeyUtils;
2323
import com.linkedin.metadata.utils.GenericRecordUtils;
2424
import com.linkedin.metadata.utils.SystemMetadataUtils;

metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/DeleteItemImpl.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88
import com.linkedin.metadata.aspect.SystemAspect;
99
import com.linkedin.metadata.aspect.batch.BatchItem;
1010
import com.linkedin.metadata.aspect.batch.ChangeMCP;
11-
import com.linkedin.metadata.entity.EntityApiUtils;
1211
import com.linkedin.metadata.entity.EntityAspect;
1312
import com.linkedin.metadata.entity.validation.ValidationApiUtils;
1413
import com.linkedin.metadata.models.AspectSpec;
1514
import com.linkedin.metadata.models.EntitySpec;
15+
import com.linkedin.metadata.utils.EntityApiUtils;
1616
import com.linkedin.mxe.MetadataChangeProposal;
1717
import com.linkedin.mxe.SystemMetadata;
1818
import java.util.Objects;

0 commit comments

Comments
 (0)