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 @@ -12,6 +12,9 @@
* Diagnose value-model helper collisions, class/member name conflicts, and
duplicate schema-first keys before emission. Resolve same-build models
declared with annotated getters.
* Preserve application-generic argument nullability when selecting custom
schemas, and substitute nested generic bounds before validating type-owned
schema methods.

* 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 @@ -6,6 +6,7 @@ import 'package:source_gen/source_gen.dart';

import '../models/schema_model_graph.dart';
import '../utils/string_literal.dart';
import '../utils/type_parameter_bounds.dart';
import 'generated_companion_visibility.dart';
import 'model_graph_builder_base.dart';

Expand Down Expand Up @@ -593,17 +594,14 @@ final class CustomSchemaResolver {
element: field,
);
}
final bounds = instantiateTypeParameterBounds(
method.typeParameters,
type.typeArguments,
);
for (var i = 0; i < method.typeParameters.length; i++) {
final typeParam = method.typeParameters[i];
final rawBound = typeParam.bound;
if (rawBound == null) continue;
final bound =
rawBound is TypeParameterType &&
method.typeParameters.contains(rawBound.element)
? type.typeArguments[method.typeParameters.indexOf(
rawBound.element,
)]
: rawBound;
final bound = bounds[i];
if (bound == null) continue;
final argument = type.typeArguments[i];
if (!_library.element.typeSystem.isSubtypeOf(argument, bound)) {
throw InvalidGenerationSource(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,9 @@ abstract base class ModelGraphBuilderBase {

/// Whether a schema producing [left] can back a field of type [right].
///
/// The types must match apart from nullability: collection schemas such as
/// `Ack.map` type their values as nullable.
/// Outer nullability and converted core collection elements may differ:
/// `Ack.map` types its values as nullable. Application type arguments must
/// remain exact because generated code does not convert inside those values.
bool sameTypeIgnoringNullability(DartType left, DartType right) {
final typeSystem = library.element.typeSystem;
final a = typeSystem.promoteToNonNull(left);
Expand All @@ -252,6 +253,9 @@ abstract base class ModelGraphBuilderBase {
a.typeArguments.length != b.typeArguments.length) {
return false;
}
if (!a.isDartCoreList && !a.isDartCoreSet && !a.isDartCoreMap) {
return typeSystem.isSubtypeOf(a, b) && typeSystem.isSubtypeOf(b, a);
}
for (var i = 0; i < a.typeArguments.length; i++) {
if (!sameTypeIgnoringNullability(
a.typeArguments[i],
Expand Down
22 changes: 22 additions & 0 deletions packages/ack_generator/lib/src/utils/type_parameter_bounds.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/dart/element/type.dart';
// The public TypeSystem API cannot substitute nested or F-bounded types.
// Keep this analyzer-13-specific dependency here while the package is pinned
// to >=13.0.0 <14.0.0, rather than maintaining a partial type substituter.
// ignore: implementation_imports
import 'package:analyzer/src/dart/element/type_algebra.dart' show Substitution;

/// Substitutes all method type arguments into their parameter bounds.
List<DartType?> instantiateTypeParameterBounds(
List<TypeParameterElement> parameters,
List<DartType> arguments,
) {
final substitution = Substitution.fromPairs2(parameters, arguments);
return [
for (final parameter in parameters)
if (parameter.bound case final bound?)
substitution.substituteType(bound)
else
null,
];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import 'package:ack_generator/src/builder.dart';
import 'package:build/build.dart';
import 'package:build_test/build_test.dart';
import 'package:test/test.dart';

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

Future<void> _build(
String source, {
Matcher? output,
List<String> errors = const [],
}) async {
final readerWriter = TestReaderWriter(rootPackage: 'test_pkg');
await readerWriter.testing.loadIsolateSources();
final actualErrors = <String>[];
await testBuilder(
ackModelBuilder(BuilderOptions.empty),
{'test_pkg|lib/models.dart': '$_head\n$source'},
generateFor: const {'test_pkg|lib/models.dart'},
readerWriter: readerWriter,
outputs: output == null
? const {}
: {'test_pkg|lib/models.g.dart': decodedMatches(output)},
onLog: (log) {
if (log.level.name == 'SEVERE') actualErrors.add(log.message);
},
);
for (final error in errors) {
expect(actualErrors, contains(contains(error)));
}
}

const _box = r'''
final class Box<T> {
const Box(this.value);
final T value;
}
AckSchema<String, Box<String>> boxSchema() => Ack.string().codec<Box<String>>(
decode: Box<String>.new, encode: (box) => box.value,
);
AckSchema<String, Box<String?>> nullableBoxSchema() =>
Ack.string().codec<Box<String?>>(
decode: Box<String?>.new, encode: (box) => box.value!,
);
''';

const _bounded = r'''
final class Bounded<T> {
const Bounded(this.value);
final T value;
static AckSchema<String, Bounded<U>> schema<U extends Comparable<U>>() =>
Ack.string().codec<Bounded<U>>(
decode: (value) => Bounded<U>(value as U),
encode: (bounded) => bounded.value.toString(),
);
}
''';

void main() {
test(
'does not register nullable application arguments for nonnullable fields',
() async {
await _build(
'''
final class Box<T> {
const Box(this.value);
final T value;
}
AckSchema<String, Box<String?>> nullableBoxSchema() =>
Ack.string().codec<Box<String?>>(
decode: Box<String?>.new, encode: (box) => box.value!,
);
@Schemable(schemas: [nullableBoxSchema])
final class Holder with _\$HolderAck {
const Holder({required this.box});
final Box<String> box;
}
''',
errors: ['Holder.box', 'unsupported Box<String>'],
);
},
);

test(
'distinguishes registrations by application argument nullability',
() async {
await _build(
'''
$_box
@Schemable(schemas: [boxSchema, nullableBoxSchema])
final class Holder with _\$HolderAck {
const Holder({required this.box, required this.nullableBox});
final Box<String> box;
final Box<String?> nullableBox;
}
''',
output: allOf([
contains("'box': boxSchema()"),
contains("'nullableBox': nullableBoxSchema()"),
]),
);
},
);

test(
'rejects a type-owned schema with different argument nullability',
() async {
await _build(
r'''
final class Box<T> {
const Box(this.value);
final T value;
static AckSchema<String, Box<U?>> schema<U>() => Ack.string().codec<Box<U?>>(
decode: (value) => Box<U?>(value as U?),
encode: (box) => box.value.toString(),
);
}
@Schemable()
final class Holder with _$HolderAck {
const Holder({required this.box});
final Box<String> box;
}
''',
errors: [
'Holder.box',
'produces Box<String?>',
'field type is Box<String>',
],
);
},
);

test('substitutes nested F-bounds for valid type-owned schemas', () async {
await _build('''
$_bounded
@Schemable()
final class Holder with _\$HolderAck {
const Holder({required this.box});
final Bounded<String> box;
}
''', output: contains('Bounded.schema<String>()'));
});

test('reports the substituted bound for invalid type arguments', () async {
await _build(
'''
$_bounded
@Schemable()
final class Holder with _\$HolderAck {
const Holder({required this.box});
final Bounded<Object?> box;
}
''',
errors: ['Holder.box', 'Comparable<Object?>', 'received Object?'],
);
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ final class Box<A extends Object> {
) => value.codec<Box<A>>(decode: Box<A>.new, encode: (box) => box.value);
}

final class ComparableBox<T> {
const ComparableBox(this.value);
final T value;

static AckSchema<String, ComparableBox<U>> schema<U extends Comparable<U>>() =>
Ack.string().codec<ComparableBox<U>>(
decode: (value) => ComparableBox<U>(value as U),
encode: (box) => box.value.toString(),
);
}

final class NullableBox<T> {
const NullableBox(this.value);
final T value;
}

final class Trigger<A> {
const Trigger(this.name);

Expand Down Expand Up @@ -151,6 +167,23 @@ final colorSchema = Ack.string().codec<Color>(
encode: (color) => color.hex,
);

AckSchema<String, NullableBox<String>> stringBoxSchema() =>
Ack.string().codec<NullableBox<String>>(
decode: NullableBox<String>.new, encode: (box) => box.value,
);
AckSchema<String, NullableBox<String?>> nullableStringBoxSchema() =>
Ack.string().codec<NullableBox<String?>>(
decode: NullableBox<String?>.new, encode: (box) => box.value!,
);

@Schemable(schemas: [stringBoxSchema, nullableStringBoxSchema])
final class GenericFields with _$GenericFieldsAck {
const GenericFields({required this.bounded, required this.strict, required this.loose});
final ComparableBox<String> bounded;
final NullableBox<String> strict;
final NullableBox<String?> loose;
}

@Schemable()
final class Habit with _$HabitAck {
const Habit({required this.name});
Expand Down Expand Up @@ -240,6 +273,17 @@ import 'package:ack_type_owned/types.dart';
import 'package:test/test.dart';

void main() {
test('F-bounds and distinct generic nullability compile and round-trip', () {
final json = {'bounded': 'bound', 'strict': 'present', 'loose': 'nullable'};
final fields = GenericFieldsSchema.parse(json);
expect(fields.bounded.value, 'bound');
expect(fields.strict, isA<NullableBox<String>>());
expect(fields.loose, isA<NullableBox<String?>>());
expect(fields.strict.value, 'present');
expect(fields.loose.value, 'nullable');
expect(fields.toJson(), json);
});

test('class-first fields decode through their types', () {
final json = {
'header': 'top',
Expand Down
Loading