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
3 changes: 3 additions & 0 deletions packages/ack_generator/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
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.
* Diagnose value-model helper collisions, class/member name conflicts, and
duplicate schema-first keys before emission. Resolve same-build models
declared with annotated getters.

* 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 @@ -157,6 +157,7 @@ abstract base class ModelGraphBuilderBase {
Element? schemableDeclaration(Element element) {
final declaration = switch (element) {
GetterElement(isOriginVariable: true) => element.variable.baseElement,
GetterElement() => element.baseElement,
TopLevelVariableElement() => element.baseElement,
_ => null,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase {
path: declaration.id.declarationName,
);
}
_validateDelegatedHelperNames();
_validateGeneratedNames();
return graph;
}

Expand Down Expand Up @@ -367,6 +367,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase {
context: declaration.element,
);
final fields = <AckFieldNode>[];
final declaredKeys = <String>{};
for (final entry in literal.elements) {
if (entry is! MapLiteralEntry || entry.key is! SimpleStringLiteral) {
throw InvalidGenerationSource(
Expand All @@ -375,6 +376,12 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase {
);
}
final jsonKey = (entry.key as SimpleStringLiteral).value;
if (!declaredKeys.add(jsonKey)) {
throw InvalidGenerationSource(
'$path.$jsonKey is a duplicate object field. Declare each key once.',
element: declaration.element,
);
}
final problem = ackSchemaFirstFieldNameProblem(
jsonKey,
capturesExtras: additionalProperties,
Expand Down Expand Up @@ -1198,27 +1205,45 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase {
}
}

void _validateDelegatedHelperNames() {
void _validateGeneratedNames() {
final localNames = localDeclarationNames();
for (final node in graph.nodes) {
final declaration = _declarationsById[node.id];
if (declaration == null) continue;
switch (node) {
case AckUnionModelNode():
case AckValueModelNode():
continue;
case AckObjectModelNode():
validateCopyWithHelperNames(
className: node.className,
needsCopyWithSentinel: node.fields.any(

void checkMember(String name) {
if (name != node.className) return;
throw InvalidGenerationSource(
'${node.id.declarationName}.$name conflicts with the generated '
'class name "${node.className}".',
element: declaration.element,
);
}

if (node is AckUnionModelNode) {
checkMember(node.discriminatorKey);
continue;
}
if (node is AckObjectModelNode) {
for (final field in node.fields) {
checkMember(field.dartName);
}
if (node.discriminatorKey case final discriminator?) {
checkMember(discriminator);
}
}
validateCopyWithHelperNames(
className: node.className,
needsCopyWithSentinel:
node is AckObjectModelNode &&
node.fields.any(
(field) =>
field.jsonKey != node.discriminatorKey &&
(field.nullable || !field.isRequired),
),
element: declaration.element,
localNames: localNames,
);
}
element: declaration.element,
localNames: localNames,
);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import 'package:ack_generator/src/builder.dart';
import 'package:build/build.dart';
import 'package:build_test/build_test.dart';
import 'package:test/test.dart';

void main() {
test(
'class-first fields resolve same-build getter-generated models',
() async {
final readerWriter = TestReaderWriter(rootPackage: 'test_pkg');
await readerWriter.testing.loadIsolateSources();
await testBuilder(
ackModelBuilder(BuilderOptions.empty),
{
'test_pkg|lib/models.dart': r'''
import 'package:ack/ack.dart';
part 'models.g.dart';

@Schemable()
StringSchema get labelSchema => Ack.string();

@Schemable()
final class Holder with _$HolderAck {
const Holder({required this.label, required this.history});
final Label label;
final List<Label> history;
}
''',
},
generateFor: const {'test_pkg|lib/models.dart'},
readerWriter: readerWriter,
outputs: {
'test_pkg|lib/models.g.dart': decodedMatches(
allOf([
contains('final class Label'),
contains(r"Label.$ack.fromRuntime(value['label'] as String)"),
contains(r'Label.$ack.fromRuntime(item as String)'),
]),
),
},
);
},
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,8 @@ final scalarSchema = Ack.integer();
@Schemable()
final stringsSchema = Ack.list(Ack.string());
@Schemable()
StringSchema get getterLabelSchema => Ack.string();
@Schemable()
final encodedSchema = Ack.string().codec<Token>(
decode: Token.decode,
encode: Token.encode,
Expand All @@ -305,6 +307,7 @@ final class ValueHolder with _$ValueHolderAck {
required this.scalar,
required this.strings,
required this.encoded,
required this.getterLabel,
required this.history,
required this.unique,
this.patch = const JsonMaybe.absent(),
Expand All @@ -315,6 +318,7 @@ final class ValueHolder with _$ValueHolderAck {
final values.Scalar scalar;
final values.Strings strings;
final values.Encoded encoded;
final values.GetterLabel getterLabel;
final List<values.Scalar> history;
final Set<values.Scalar> unique;
final JsonMaybe<values.Encoded> patch;
Expand Down Expand Up @@ -367,6 +371,7 @@ void main() {
'scalar': 7,
'strings': ['one', 'two'],
'encoded': 'token',
'getterLabel': 'from-getter',
'history': [1, 2],
'unique': [3, 4],
'sets': {'present': [1, 2], 'none': null},
Expand All @@ -379,6 +384,7 @@ void main() {
expect(holder.scalar.value, 7);
expect(holder.strings.value, ['one', 'two']);
expect(holder.encoded.value.value, 'token');
expect(holder.getterLabel.value, 'from-getter');
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());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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> _expectFailure(String source, List<String> messages) async {
final readerWriter = TestReaderWriter(rootPackage: 'test_pkg');
await readerWriter.testing.loadIsolateSources();
final errors = <String>[];
await testBuilder(
ackModelBuilder(BuilderOptions.empty),
{'test_pkg|lib/models.dart': source},
generateFor: const {'test_pkg|lib/models.dart'},
readerWriter: readerWriter,
outputs: const {},
onLog: (log) {
if (log.level.name == 'SEVERE') errors.add(log.message);
},
);
for (final message in messages) {
expect(errors, contains(contains(message)));
}
}

const _head = '''
import 'package:ack/ack.dart';
part 'models.g.dart';
''';

void main() {
for (final name in [r'$TitleCopyWith', r'_$TitleCopyWith']) {
test('rejects value-model helper collision $name', () async {
await _expectFailure(
'''
$_head
@Schemable()
final titleSchema = Ack.string();
final class $name {}
''',
['Generated helper "$name"', 'conflicts'],
);
});
}

for (final model in ['User', 'Renamed']) {
test('rejects field matching generated class $model', () async {
await _expectFailure(
'''
$_head
@Schemable(name: '$model')
final userSchema = Ack.object({'$model': Ack.string()});
''',
['userSchema.$model', 'class name'],
);
});
}

test('checks inherited fields against an alias model name', () async {
await _expectFailure(
'''
$_head
@Schemable()
final originalSchema = Ack.object({'Alias': Ack.string()});
@Schemable()
final aliasSchema = originalSchema;
''',
['aliasSchema.Alias', 'class name'],
);
});

for (final discriminator in ['Pet', 'Cat']) {
test('rejects discriminator matching class $discriminator', () async {
await _expectFailure(
'''
$_head
@Schemable()
final catSchema = Ack.object({});
@Schemable()
final petSchema = Ack.discriminated(
discriminatorKey: '$discriminator', schemas: {'cat': catSchema},
);
''',
[discriminator, 'class name'],
);
});
}

test(
'rejects duplicate fields before generating duplicate members',
() async {
await _expectFailure(
'''
$_head
@Schemable()
final userSchema = Ack.object({'name': Ack.string(), 'name': Ack.integer()});
''',
['userSchema.name', 'duplicate'],
);
},
);
}
Loading