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
2 changes: 2 additions & 0 deletions packages/ack_generator/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
strict/passthrough policy to object models and aliases.
* Reject object shape-changing methods that cannot be modeled statically,
instead of generating fields that disagree with the authored schema.
* Resolve same-build scalar, list, and codec model references using their
actual runtime types, and generate valid codecs for nullable nested Sets.

* Allow built-in and contextual Dart keywords such as `required` as generated
field, discriminator, and capture-member names without changing their JSON
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,22 @@ final class CustomSchemaResolver {
field: field,
);
if (target == null) return null;
final schemaType = switch (target) {
TopLevelVariableElement(:final type) => type,
GetterElement(:final returnType) => returnType,
_ => null,
};
final runtime = schemaType == null
? null
: _base.ackSchemaRuntimeType(schemaType);
if (runtime == null || _base.containsInvalidType(runtime)) {
throw InvalidGenerationSource(
'${_fieldPath(field)} resolves to generated model "$name", but '
'${target.name} has no resolvable AckSchema runtime type. Declare '
'the schema type explicitly.',
element: field,
);
}
final visibleName = qualifySymbol(prefix, name);
result = (
schemaExpression: '$visibleName.\$ack.schema',
Expand All @@ -835,7 +851,11 @@ final class CustomSchemaResolver {
declarationName: target.name!,
),
className: name,
runtimeRef: ModelGraphBuilderBase.jsonMapRef,
runtimeRef: _base.inferTypeRef(
runtime,
path: _fieldPath(field),
context: field,
),
importPrefix: prefix,
),
setListSchema: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,11 @@ final class AckSchemaInference {
rejectNullableCollectionElement: rejectNullableCollectionElement,
validateMapKey: validateMapKey,
);
return '${_ack('Ack')}.list($itemSchema).codec<${renderType(type)}>(decode: (list) => list.toSet(), encode: (set) => set.toList(growable: false),)';
final runtimeType = type.element.instantiate(
typeArguments: type.typeArguments,
nullabilitySuffix: NullabilitySuffix.none,
);
return '${_ack('Ack')}.list($itemSchema).codec<${renderType(runtimeType)}>(decode: (list) => list.toSet(), encode: (set) => set.toList(growable: false),)';
}
if (type.isDartCoreMap) {
validateMapKey(type);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,76 @@ final class Holder with _$HolderAck {
}
''',
);
File(
p.join(temporary.path, 'lib', 'value_models.dart'),
).writeAsStringSync(r'''
import 'package:ack/ack.dart';

part 'value_models.g.dart';

final class Token {
const Token(this.value);
final String value;
static int decodes = 0;
static int encodes = 0;

static Token decode(String value) {
decodes++;
return Token(value);
}

static String encode(Token token) {
encodes++;
return token.value;
}

@override
bool operator ==(Object other) => other is Token && other.value == value;
@override
int get hashCode => value.hashCode;
}

@Schemable()
final scalarSchema = Ack.integer();
@Schemable()
final stringsSchema = Ack.list(Ack.string());
@Schemable()
final encodedSchema = Ack.string().codec<Token>(
decode: Token.decode,
encode: Token.encode,
);
''');
File(
p.join(temporary.path, 'lib', 'value_holder.dart'),
).writeAsStringSync(r'''
import 'package:ack/ack.dart';
import 'value_models.dart' as values;

part 'value_holder.g.dart';

@Schemable()
final class ValueHolder with _$ValueHolderAck {
const ValueHolder({
required this.scalar,
required this.strings,
required this.encoded,
required this.history,
required this.unique,
this.patch = const JsonMaybe.absent(),
required this.sets,
required this.nestedSets,
});

final values.Scalar scalar;
final values.Strings strings;
final values.Encoded encoded;
final List<values.Scalar> history;
final Set<values.Scalar> unique;
final JsonMaybe<values.Encoded> patch;
final Map<String, Set<int>?> sets;
final Map<String, Map<String, Set<String>?>?> nestedSets;
}
''');
File(
p.join(temporary.path, 'lib', 'address_envelope.dart'),
).writeAsStringSync(r'''
Expand Down Expand Up @@ -284,9 +354,55 @@ import 'package:ack_class_first_nested/itinerary.dart';
import 'package:ack_class_first_nested/order.dart';
import 'package:ack_class_first_nested/parcel.dart';
import 'package:ack_class_first_nested/pet.dart';
import 'package:ack_class_first_nested/value_holder.dart';
import 'package:ack_class_first_nested/value_models.dart' as values;
import 'package:ack/ack.dart';
import 'package:test/test.dart';

void main() {
test('same-build value models and nullable nested Sets round-trip', () {
values.Token.decodes = 0;
values.Token.encodes = 0;
final json = <String, Object?>{
'scalar': 7,
'strings': ['one', 'two'],
'encoded': 'token',
'history': [1, 2],
'unique': [3, 4],
'sets': {'present': [1, 2], 'none': null},
'nestedSets': {
'present': {'items': ['a', 'b'], 'none': null},
'none': null,
},
};
final holder = ValueHolderSchema.parse(json);
expect(holder.scalar.value, 7);
expect(holder.strings.value, ['one', 'two']);
expect(holder.encoded.value.value, 'token');
expect(holder.history.map((model) => model.value), [1, 2]);
expect(holder.unique.map((model) => model.value), [3, 4]);
expect(holder.patch, const JsonMaybe<values.Encoded>.absent());
expect(holder.sets['present'], {1, 2});
expect(holder.sets['none'], isNull);
expect(holder.nestedSets['present']!['items'], {'a', 'b'});
expect(holder.nestedSets['present']!['none'], isNull);
expect(holder.nestedSets['none'], isNull);
expect(values.Token.decodes, 1);
expect(holder.toJson(), json);
expect(values.Token.encodes, 1);
expect(ValueHolderSchema.parse(holder.toJson()), holder);
expect(() => holder.strings.value.add('three'), throwsUnsupportedError);
expect(() => holder.sets['present']!.add(3), throwsUnsupportedError);
expect(
() => holder.nestedSets['present']!['items']!.add('c'),
throwsUnsupportedError,
);

final patched = ValueHolderSchema.parse({...json, 'patch': 'changed'});
expect(patched.patch.valueOrNull!.value.value, 'changed');
expect(patched.toJson()['patch'], 'changed');
});

test('nested models parse, encode, and export through facades', () {
final order = OrderSchema.parse({
'shipping': {'city': 'Amsterdam'},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import 'package:ack_generator/src/builder.dart';
import 'package:build/build.dart';
import 'package:build_test/build_test.dart';
import 'package:test/test.dart';

Future<void> _expectOutput(String source, Matcher matcher) async {
final readerWriter = TestReaderWriter(rootPackage: 'test_pkg');
await readerWriter.testing.loadIsolateSources();
await testBuilder(
ackModelBuilder(BuilderOptions.empty),
{'test_pkg|lib/models.dart': source},
generateFor: const {'test_pkg|lib/models.dart'},
readerWriter: readerWriter,
outputs: {'test_pkg|lib/models.g.dart': decodedMatches(matcher)},
);
}

void main() {
test(
'same-build value models use their actual schema runtime types',
() async {
await _expectOutput(
r'''
import 'package:ack/ack.dart';
part 'models.g.dart';

final class Token {
const Token(this.value);
final String value;
}

@Schemable()
final scalarSchema = Ack.integer();
@Schemable()
final stringsSchema = Ack.list(Ack.string());
@Schemable()
final encodedSchema = Ack.string().codec<Token>(
decode: Token.new,
encode: (token) => token.value,
);

@Schemable()
final class Holder with _$HolderAck {
const Holder({required this.scalar, required this.strings, required this.encoded});
final Scalar scalar;
final Strings strings;
final Encoded encoded;
}
''',
allOf([
contains(r"Scalar.$ack.fromRuntime(value['scalar'] as int)"),
contains(
r"Strings.$ack.fromRuntime(value['strings'] as List<String>)",
),
contains(r"Encoded.$ack.fromRuntime(value['encoded'] as Token)"),
]),
);
},
);

test('nullable nested Sets use non-nullable codec type arguments', () async {
await _expectOutput(
r'''
import 'package:ack/ack.dart';
part 'models.g.dart';

@Schemable()
final class Holder with _$HolderAck {
const Holder({required this.values, required this.nested});
final Map<String, Set<int>?> values;
final Map<String, Map<String, Set<String>?>?> nested;
}
''',
allOf([
contains('.codec<Set<int>>('),
contains('.codec<Set<String>>('),
isNot(contains('.codec<Set<int>?>(')),
isNot(contains('.codec<Set<String>?>(')),
]),
);
});
}
Loading