Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions dart/a2ui_core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
- Extracted `ComponentGraphBound` for incremental `SurfaceComponentsModel`
component graph checks and optimized its initial height traversal to linear
time.
- Under a validation config that defers the graph checks, `MessageProcessor`
checks the `allowedParents` and `allowedChildren` constraints of an
`updateComponents` against only the edges it changes, so a container whose
catalog declares `allowedChildren`, receiving one child per message, is no
longer checked against all of its children each time. The errors raised are
unchanged.
- Exported `checkPathsAndRecursion`, the check of data-model paths and
nesting depth that `MessageProcessor` runs on each message under a
validation config. Its new `grownFrom` argument takes an earlier form of
Expand Down
4 changes: 4 additions & 0 deletions dart/a2ui_core/lib/src/core/component_graph_bound.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ class ComponentGraphBound {
/// The reference fields each type's references were read through.
final Map<String, ComponentRefFields?> _fields = {};

/// The components that reference [id], held or not, in the graph this
/// bound describes.
Iterable<String> parentsOf(String id) => _parents[id] ?? const <String>{};

void _setChildren(
String id,
String type,
Expand Down
15 changes: 11 additions & 4 deletions dart/a2ui_core/lib/src/core/component_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -276,8 +276,9 @@ class SurfaceComponentsModel {
void validateComponentsUpdate(
List<Map<String, Object?>> components, [
ValidationConfig config = ValidationConfig.strict,
]) =>
checkComponentsUpdate(components, config, refFields);
]) {
checkComponentsUpdate(components, config, refFields);
}

/// [validateComponentsUpdate] with [refFields] in place of this model's,
/// for a batch that draws on catalogs the model has not seen, and
Expand All @@ -290,8 +291,13 @@ class SurfaceComponentsModel {
/// shows the graph passes. Otherwise, or when it cannot show that, the
/// whole graph is walked, so a batch the graph rejects fails as it would
/// have without the shortcut.
///
/// Returns what the check found about the graph with the batch applied
/// when it started from what the check before found, which means nothing
/// changed the model since the batch before was checked and applied, or
/// null when it walked the whole graph.
@internal
void checkComponentsUpdate(
ComponentGraphBound? checkComponentsUpdate(
List<Map<String, Object?>> components,
ValidationConfig config,
Map<String, ComponentRefFields> refFields, {
Expand Down Expand Up @@ -342,7 +348,7 @@ class SurfaceComponentsModel {
)) {
_bound = bound;
_boundBatch = components;
return;
return bound;
}

// Each component as its type and the map its references are read from.
Expand All @@ -360,6 +366,7 @@ class SurfaceComponentsModel {
_bound = ComponentGraphBound.of(candidate, refFields);
_boundBatch = components;
}
return null;
}

/// Reports that [components], the batch the last [checkComponentsUpdate]
Expand Down
42 changes: 37 additions & 5 deletions dart/a2ui_core/lib/src/processing/processor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import 'package:json_schema_builder/json_schema_builder.dart';

import '../core/catalog.dart';
import '../core/common.dart';
import '../core/component_graph_bound.dart';
import '../core/component_model.dart';
import '../core/messages.dart';
import '../core/renderer_capabilities.dart';
Expand Down Expand Up @@ -854,10 +855,11 @@ class MessageProcessor<T extends ComponentApi> {
catalogIds,
);
final ValidationConfig? config = validationConfig;
ComponentGraphBound? bound;
if (config == null) {
checkDuplicateComponentIds(resolved);
} else {
model.checkComponentsUpdate(
bound = model.checkComponentsUpdate(
resolved,
config,
refFields,
Expand All @@ -869,12 +871,42 @@ class MessageProcessor<T extends ComponentApi> {
// arrived yet is skipped, so this holds without a config too. None of the
// catalogs the surface draws on may declare any, which is the common case
// and leaves nothing to check.
if (!{
if (surface.defaultCatalog case final catalog?) catalog.id,
final Catalog<T, FunctionImplementation>? defaultCatalog =
surface.defaultCatalog;
final Set<String> ruled = {
if (defaultCatalog != null) defaultCatalog.id,
for (final String? id in catalogIds)
if (id != null) id,
}.any((String id) => _rulesOf(surface.resolveCatalog(id)).isNotEmpty)) {
return (resolved: resolved, refFields: refFields);
}
.where((String id) => _rulesOf(surface.resolveCatalog(id)).isNotEmpty)
.toSet();
if (ruled.isEmpty) return (resolved: resolved, refFields: refFields);
// When the graph check started from what the check before found, the
// surface passed the check of the batch before, so only the edges this
// batch changes are checked. That needs constraints the surface passed:
// while only the default catalog declares any, they are its own, and
// every check before took its constraints first. A batch that fails is
// checked again below, for the error the whole surface raises.
if (bound != null &&
defaultCatalog != null &&
ruled.length == 1 &&
ruled.single == defaultCatalog.id) {
final types = <String, String>{
for (final Map<String, Object?> c in resolved)
if ((c['id'], c['component'])
case (final String id, final String type))
id: type,
};
if (compositionHoldsAfter(
resolved,
refFields,
_rulesOf(defaultCatalog),
typeOf: (String id) => types[id] ?? model.get(id)?.type,
parentsOf: bound.parentsOf,
rootId: surface.rootId,
)) {
return (resolved: resolved, refFields: refFields);
}
}
final List<Map<String, Object?>> existing = [
for (final ComponentModel c in model.all)
Expand Down
206 changes: 159 additions & 47 deletions dart/a2ui_core/lib/src/validation/component_graph.dart
Original file line number Diff line number Diff line change
Expand Up @@ -109,59 +109,21 @@ void checkCompositionConstraints(
case (final String id, final String type))
id: type,
};
String list(List<String> names) =>
'[${names.map((name) => "'$name'").join(', ')}]';

// [pointer] locates [component] in the message, or is null when the
// component was already stored.
void check(Map<String, Object?> component, String? pointer) {
final Object? id = component['id'];
final Object? type = component['component'];
if (id is! String || type is! String) return;

final List<String>? rootParents = rules[type]?.allowedParents;
if (id == rootId &&
rootParents != null &&
!rootParents.contains(_surfaceParent)) {
throw A2uiValidationError(
"Component '$id' ($type) cannot be placed under parent "
"'$_surfaceParent' ($_surfaceParent). Allowed parents: "
'${list(rootParents)}.',
code: 'UNALLOWED_PARENT',
path: pointer,
surfaceId: surfaceId,
);
}

final List<String>? allowedChildren = rules[type]?.allowedChildren;
for (final ComponentReference reference in _referencesOf(
if (_componentViolation(
component,
refFields,
)) {
final String? childType = types[reference.id];
if (childType == null) continue;
final String? path = pointer == null
? null
: '$pointer${referencePointer(reference.field)}';
if (allowedChildren != null && !allowedChildren.contains(childType)) {
throw A2uiValidationError(
"Container '$id' ($type) cannot contain child '${reference.id}' "
'($childType). Allowed children: ${list(allowedChildren)}.',
code: 'UNALLOWED_CHILD',
path: path,
surfaceId: surfaceId,
);
}
final List<String>? allowedParents = rules[childType]?.allowedParents;
if (allowedParents != null && !allowedParents.contains(type)) {
throw A2uiValidationError(
"Component '${reference.id}' ($childType) cannot be placed under "
"parent '$id' ($type). Allowed parents: ${list(allowedParents)}.",
code: 'UNALLOWED_PARENT',
path: path,
surfaceId: surfaceId,
);
}
rules,
(String id) => types[id],
rootId: rootId,
pointer: pointer,
surfaceId: surfaceId,
)
case final A2uiValidationError error) {
throw error;
}
}

Expand All @@ -173,6 +135,156 @@ void checkCompositionConstraints(
}
}

/// Whether [checkCompositionConstraints] would pass on the surface
/// [components] leave behind, checking only the edges they change.
///
/// This holds only when [checkCompositionConstraints] passed on the surface
/// before [components], under the same [rules]. [typeOf] gives the type of
/// each component the surface holds with [components] applied, and
/// [parentsOf] the components that reference an id there.
///
/// A component of [components] is checked against the surface and all of
/// its children, as [checkCompositionConstraints] checks it. A component
/// outside [components] that references one of them, which holds it, keeps
/// its other children, so its edge to each component of [components] it
/// holds is checked and the rest are not. Every other edge is unchanged and
/// passed before. A container holding many children, receiving one per
/// message, is then checked against one child per message rather than all
/// of them.
bool compositionHoldsAfter(
List<Map<String, Object?>> components,
Map<String, ComponentRefFields> refFields,
Map<String, CompositionRule> rules, {
required String? Function(String id) typeOf,
required Iterable<String> Function(String id) parentsOf,
String rootId = _rootComponentId,
}) {
if (rules.isEmpty) return true;
final ids = <String>{
for (final component in components)
if (component['id'] case final String id) id,
};
for (final component in components) {
final A2uiValidationError? error = _componentViolation(
component,
refFields,
rules,
typeOf,
rootId: rootId,
);
if (error != null) return false;
}
for (final id in ids) {
final String? type = typeOf(id);
if (type == null) continue;
for (final String holder in parentsOf(id)) {
if (ids.contains(holder)) continue;
final String? holderType = typeOf(holder);
if (holderType == null) continue;
if (_edgeViolation(holder, holderType, id, type, rules) != null) {
return false;
}
}
}
return true;
}

String _typeList(List<String> names) =>
'[${names.map((name) => "'$name'").join(', ')}]';

/// The first composition error of [component]: the surface as the parent of
/// the root, then each of its references whose child [typeOf] knows, in
/// order. Null when there is none.
///
/// [pointer] locates [component] in the message, or is null when the
/// component was already stored.
A2uiValidationError? _componentViolation(
Map<String, Object?> component,
Map<String, ComponentRefFields> refFields,
Map<String, CompositionRule> rules,
String? Function(String id) typeOf, {
required String rootId,
String? pointer,
String? surfaceId,
}) {
final Object? id = component['id'];
final Object? type = component['component'];
if (id is! String || type is! String) return null;

final List<String>? rootParents = rules[type]?.allowedParents;
if (id == rootId &&
rootParents != null &&
!rootParents.contains(_surfaceParent)) {
return A2uiValidationError(
"Component '$id' ($type) cannot be placed under parent "
"'$_surfaceParent' ($_surfaceParent). Allowed parents: "
'${_typeList(rootParents)}.',
code: 'UNALLOWED_PARENT',
path: pointer,
surfaceId: surfaceId,
);
}

for (final ComponentReference reference in _referencesOf(
component,
refFields,
)) {
final String? childType = typeOf(reference.id);
if (childType == null) continue;
if (_edgeViolation(
id,
type,
reference.id,
childType,
rules,
path: pointer == null
? null
: '$pointer${referencePointer(reference.field)}',
surfaceId: surfaceId,
)
case final A2uiValidationError error) {
return error;
}
}
return null;
}

/// The composition error of the edge from [parent] to [child], checking the
/// children [parentType] allows and then the parents [childType] allows.
/// Null when there is none.
A2uiValidationError? _edgeViolation(
String parent,
String parentType,
String child,
String childType,
Map<String, CompositionRule> rules, {
String? path,
String? surfaceId,
}) {
final List<String>? allowedChildren = rules[parentType]?.allowedChildren;
if (allowedChildren != null && !allowedChildren.contains(childType)) {
return A2uiValidationError(
"Container '$parent' ($parentType) cannot contain child '$child' "
'($childType). Allowed children: ${_typeList(allowedChildren)}.',
code: 'UNALLOWED_CHILD',
path: path,
surfaceId: surfaceId,
);
}
final List<String>? allowedParents = rules[childType]?.allowedParents;
if (allowedParents != null && !allowedParents.contains(parentType)) {
return A2uiValidationError(
"Component '$child' ($childType) cannot be placed under "
"parent '$parent' ($parentType). Allowed parents: "
'${_typeList(allowedParents)}.',
code: 'UNALLOWED_PARENT',
path: path,
surfaceId: surfaceId,
);
}
return null;
}

/// Matches a JSON Pointer as A2UI writes data-model paths, allowing the
/// leading slash to be omitted.
final RegExp _pathPattern = RegExp(
Expand Down
Loading
Loading