Skip to content

Commit

Permalink
Add LazyMergedMap for merging Model objects (#105)
Browse files Browse the repository at this point in the history
Close #102
  • Loading branch information
jakemac53 authored Oct 21, 2024
1 parent 44bc307 commit 47b2e10
Show file tree
Hide file tree
Showing 6 changed files with 232 additions and 21 deletions.
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];
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);
} 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

0 comments on commit 47b2e10

Please sign in to comment.