Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add LazyMergedMap for merging Model objects #105

Merged
merged 8 commits into from
Oct 21, 2024
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
1 change: 1 addition & 0 deletions pkgs/dart_model/lib/dart_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
export 'src/dart_model.dart';
export 'src/json.dart';
export 'src/json_changes.dart';
export 'src/lazy_merged_map.dart' show MergeModels;
export 'src/scopes.dart';
export 'src/type.dart';
export 'src/type_system.dart';
40 changes: 31 additions & 9 deletions pkgs/dart_model/lib/src/dart_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import 'dart_model.g.dart';
import 'json_buffer/json_buffer_builder.dart';
import 'lazy_merged_map.dart';

export 'dart_model.g.dart';

Expand Down Expand Up @@ -32,20 +33,39 @@ extension ModelExtension on Model {

/// Returns the [QualifiedName] in the model to [node], or `null` if [node]
/// is not in this [Model].
QualifiedName? _qualifiedNameOf(Map<String, Object?> node) {
var parent = _getParent(node);
QualifiedName? _qualifiedNameOf(Map<String, Object?> model) {
var parent = _getParent(model);
if (parent == null) return null;
final path = <String>[];
path.add(_keyOf(node, parent));
path.add(_keyOf(model, parent));
var previousParent = parent;
while ((parent = _getParent(previousParent)) != this.node) {

// Checks if any merged map of `left` == any merged map of `right.
bool isEqualNested(Map<String, Object?> left, Map<String, Object?> right) {
if (left == right) return true;
return left.expand.any((l) => right.expand.contains(l));
}

while (true) {
parent = _getParent(previousParent);
if (parent == null) return null;

/// We reached this models node, stop searching higher.
if (isEqualNested(parent, node)) break;

path.insert(0, _keyOf(previousParent, parent));
previousParent = parent;
}

if (path case [final uri, 'scopes', final name]) {
return QualifiedName(uri: uri, name: name);
} else if (path
case [final uri, 'scopes', final scope, 'members', final name]) {
return QualifiedName(
uri: uri,
scope: scope,
name: name,
isStatic: Member.fromJson(model).properties.isStatic);
}
throw UnsupportedError(
'Unsupported node type for `qualifiedNameOf`, only top level members '
Expand All @@ -62,19 +82,21 @@ extension ModelExtension on Model {
throw ArgumentError('Value not in map: $value, $map');
}

/// Gets the `Map` that contains [node], or `null` if there isn't one.
Map<String, Object?>? _getParent(Map<String, Object?> node) {
/// Gets the `Map` that contains [child], or `null` if there isn't one.
Map<String, Object?>? _getParent(Map<String, Object?> child) {
// If both maps are in the same `JsonBufferBuilder` then the parent is
// immediately available.
if (this case MapInBuffer thisMapInBuffer) {
if (node case MapInBuffer thatMapInBuffer) {
final childMaps = child.expand;
final childBufferMaps = childMaps.whereType<MapInBuffer>();
for (final thisMapInBuffer in node.expand.whereType<MapInBuffer>()) {
for (final thatMapInBuffer in childBufferMaps) {
if (thisMapInBuffer.buffer == thatMapInBuffer.buffer) {
return thatMapInBuffer.parent;
}
}
}
// Otherwise, build a `Map` of references to parents and use that.
return _lazyParentsMap[node];
return _lazyParentsMap[child];
}

/// Gets a `Map` from values to parent `Map`s.
Expand Down
91 changes: 91 additions & 0 deletions pkgs/dart_model/lib/src/lazy_merged_map.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:collection';

import 'dart_model.dart';

/// An implementation of a lazy merged json [Map] view over two [Map]s.
///
/// The intended use case is for merging JSON payloads together into a single
/// payload, where their structure is the same.
///
/// If both maps have the same key present, the logic for the values of those
/// shared keys goes as follows:
///
/// - If both values are `Map<String, Object?>`, a nested [LazyMergedMapView]
/// is returned.
/// - Else if they are equal values, the value from [left] is returned.
/// - Else a [StateError] is thrown.
///
/// Nested [List]s are not specifically handled at this time and must be equal.
///
/// The [keys] getter will de-duplicate the keys.
class LazyMergedMapView extends MapBase<String, Object?> {
final Map<String, Object?> left;
final Map<String, Object?> right;

LazyMergedMapView(this.left, this.right);

@override
Object? operator [](Object? key) {
// TODO: Can we do better? These lookups can each be linear for buffer maps.
var leftValue = left[key];
jakemac53 marked this conversation as resolved.
Show resolved Hide resolved
var rightValue = right[key];
if (leftValue != null) {
if (rightValue != null) {
if (leftValue is Map<String, Object?> &&
rightValue is Map<String, Object?>) {
return LazyMergedMapView(leftValue, rightValue);
}
if (leftValue != rightValue) {
throw StateError('Cannot merge maps with different values, and '
'$leftValue != $rightValue');
}
return leftValue;
}
return leftValue;
} else if (rightValue != null) {
return rightValue;
}
return null;
}

@override
void operator []=(String key, Object? value) =>
throw UnsupportedError('Merged maps are read only');

@override
void clear() => throw UnsupportedError('Merged maps are read only');

@override
Iterable<String> get keys sync* {
var seen = <String>{};
for (var key in left.keys.followedBy(right.keys)) {
if (seen.add(key)) yield key;
}
}

@override
Object? remove(Object? key) =>
throw UnsupportedError('Merged maps are read only');
}

extension AllMaps on Map<String, Object?> {
/// All the maps merged into this map, recursively expanded.
Iterable<Map<String, Object?>> get expand sync* {
if (this case final LazyMergedMapView self) {
yield* self.left.expand;
yield* self.right.expand;
} else {
yield this;
}
}
}

extension MergeModels on Model {
/// Creates a lazy merged view of `this` with [other].
Model mergeWith(Model other) =>
Model.fromJson(LazyMergedMapView(node, other.node));
}
8 changes: 5 additions & 3 deletions pkgs/dart_model/lib/src/scopes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,12 @@ class MacroScope {

/// Merges a new partial [model] into the scope's accumulated model spanning
/// multiple queries.
// TODO: Actually accumulate instead of replacing the model
void addModel(Model model) {
_accumulatedModel = model;
_typeSystem = null;
if (_accumulatedModel case var accumulated?) {
accumulated.mergeWith(model);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that should be _accumulatedModel = ... :)

I guess we don't have any test coverage for this :) meaning we never use the type system after a second query.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hah!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

} else {
_accumulatedModel = model;
}
}

static MacroScope get current {
Expand Down
66 changes: 66 additions & 0 deletions pkgs/dart_model/test/lazy_merged_map_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'package:dart_model/dart_model.dart';
import 'package:dart_model/src/lazy_merged_map.dart';
import 'package:test/test.dart' hide test;
import 'package:test/test.dart' as t show test;

void main() {
test('Can merge models with different libraries', () async {
final libA = Library();
final libB = Library();
final a = Model()..uris['package:a/a.dart'] = libA;
final b = Model()..uris['package:b/b.dart'] = libB;
final c = a.mergeWith(b);
expect(c.uris['package:a/a.dart'], libA);
expect(c.uris['package:b/b.dart'], libB);
});

test('Can merge models with different scopes from the same library',
() async {
final interfaceA = Interface();
final interfaceB = Interface();
final a = Model()
..uris['package:a/a.dart'] = (Library()..scopes['A'] = interfaceA);
final b = Model()
..uris['package:a/a.dart'] = (Library()..scopes['B'] = interfaceB);
final c = a.mergeWith(b);
expect(c.uris['package:a/a.dart'], isA<LazyMergedMapView>());
expect(c.uris['package:a/a.dart']!.scopes['A'], interfaceA);
expect(c.uris['package:a/a.dart']!.scopes['B'], interfaceB);
});

test('Can merge models with the same interface but different properties',
() async {
final interfaceA1 = Interface(properties: Properties(isClass: true));
final interfaceA2 = Interface(properties: Properties(isAbstract: true));
final a = Model()
..uris['package:a/a.dart'] = (Library()..scopes['A'] = interfaceA1);
final b = Model()
..uris['package:a/a.dart'] = (Library()..scopes['A'] = interfaceA2);
final c = a.mergeWith(b);
final properties = c.uris['package:a/a.dart']!.scopes['A']!.properties;
expect(properties, isA<LazyMergedMapView>());
expect(properties.isClass, true);
expect(properties.isAbstract, true);
// Not set
expect(() => properties.isConstructor, throwsA(isA<TypeError>()));
});

test('Errors if maps have same the key with different values', () async {
final interfaceA1 = Interface(properties: Properties(isClass: true));
final interfaceA2 = Interface(properties: Properties(isClass: false));
final a = Model()
..uris['package:a/a.dart'] = (Library()..scopes['A'] = interfaceA1);
final b = Model()
..uris['package:a/a.dart'] = (Library()..scopes['A'] = interfaceA2);
final c = a.mergeWith(b);
expect(() => c.uris['package:a/a.dart']!.scopes['A']!.properties.isClass,
throwsA(isA<StateError>()));
});
}

void test(String description, void Function() body) =>
t.test(description, () => Scope.query.run(body));
47 changes: 38 additions & 9 deletions pkgs/dart_model/test/model_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import 'package:dart_model/dart_model.dart';
import 'package:test/test.dart';

void main() {
group(Model, () {
group('Model', () {
late Model model;

setUp(() {
Expand All @@ -24,7 +24,7 @@ void main() {
name: 'SomeAnnotation'))
])
..members['_root'] = Member(
properties: Properties(isField: true),
properties: Properties(isField: true, isStatic: false),
)));
});
});
Expand All @@ -44,7 +44,7 @@ void main() {
],
'members': {
'_root': {
'properties': {'isField': true}
'properties': {'isField': true, 'isStatic': false}
}
},
'properties': {'isClass': true}
Expand Down Expand Up @@ -118,9 +118,8 @@ void main() {
test('can give the path to Members in buffer backed maps', () {
final member = model.uris['package:dart_model/dart_model.dart']!
.scopes['JsonData']!.members['_root']!;
expect(() => model.qualifiedNameOf(member.node)!.asString,
throwsUnsupportedError,
reason: 'Requires https://github.com/dart-lang/macros/pull/101');
expect(model.qualifiedNameOf(member.node)!.asString,
'package:dart_model/dart_model.dart#JsonData._root');
});

test('can give the path to Interfaces in SDK maps', () {
Expand All @@ -135,9 +134,39 @@ void main() {
final copiedModel = Model.fromJson(_copyMap(model.node));
final member = copiedModel.uris['package:dart_model/dart_model.dart']!
.scopes['JsonData']!.members['_root']!;
expect(() => copiedModel.qualifiedNameOf(member.node)!.asString,
throwsUnsupportedError,
reason: 'Requires https://github.com/dart-lang/macros/pull/101');
expect(copiedModel.qualifiedNameOf(member.node)!.asString,
'package:dart_model/dart_model.dart#JsonData._root');
});

test('can give the path to Members in merged maps', () {
late final Member fooMember;
late final Model otherModel;

/// Create one model in a different scope so it gets a different buffer.
Scope.query.run(() {
otherModel = Model()
..uris['package:dart_model/dart_model.dart'] = (Library()
..scopes['JsonData'] = (Interface()
..members['foo'] =
Member(properties: Properties(isStatic: true))));
fooMember = otherModel.uris['package:dart_model/dart_model.dart']!
.scopes['JsonData']!.members['foo']!;
});

Scope.macro.run(() {
final rootMember = model.uris['package:dart_model/dart_model.dart']!
.scopes['JsonData']!.members['_root']!;

final mergedModel = model.mergeWith(otherModel);

expect(mergedModel.qualifiedNameOf(rootMember.node)!.asString,
'package:dart_model/dart_model.dart#JsonData._root');
expect(mergedModel.qualifiedNameOf(fooMember.node)!.asString,
'package:dart_model/dart_model.dart#JsonData::foo');

expect(model.qualifiedNameOf(fooMember.node), null);
expect(otherModel.qualifiedNameOf(rootMember.node), null);
});
});

test('path to Members throws on cycle', () {
Expand Down