Skip to content
Open
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline
unrelated annotation data untouched.
* Preserve schema-first alias metadata and strict/passthrough precedence;
diagnose unsupported shape transformations instead of emitting wrong fields.
* Accept parenthesized no-op `null` overrides in generated schema metadata.
* Resolve same-build value-model and getter references, nullable nested Sets,
generated-name collisions, and application-generic nullability correctly.
* Substitute nested and recursive type bounds when resolving type-owned schemas.
Expand Down
2 changes: 1 addition & 1 deletion docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@
],
"variables": {
"versions": {
"default": "2.0.0-alpha.1",
"default": "2.0.0-alpha.2",
"isPrerelease": true
}
},
Expand Down
4 changes: 2 additions & 2 deletions docs/guides/ecosystem-integrations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,10 @@ Future<WeatherReport> askGeminiWeather(
);

final response = await model.generateContent([Content.text(prompt)]);
final jsonMap = jsonDecode(response.text ?? 'null');
final decoded = jsonDecode(response.text ?? 'null');

// Validate and decode Gemini's response into WeatherReport
return WeatherReportSchema.fromJson(jsonMap);
return WeatherReportSchema.parse(decoded);
}
```

Expand Down
1 change: 0 additions & 1 deletion packages/ack/lib/src/json_schema/json_schema_compiler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,6 @@ final class _JsonSchemaCompiler {

bool _discoverBundledDialect(_ImportedNode node) {
final wanted = node.dialectUri;
if (resources.containsKey(wanted)) return true;

bool visit(_ImportedNode parent, List<(_ImportedNode, String)> path) {
final source = parent.source;
Expand Down
26 changes: 0 additions & 26 deletions packages/ack/lib/src/json_schema/json_schema_utils.dart

This file was deleted.

68 changes: 31 additions & 37 deletions packages/ack/lib/src/schema_model/ack_schema_model_builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import 'package:collection/collection.dart';
import '../constraints/constraint.dart';
import '../constraints/datetime_constraint.dart';
import '../context.dart';
import '../json_schema/json_schema_utils.dart';
import '../schemas/schema.dart';
import '../utils/json_utils.dart';
import 'ack_schema_model.dart';
Expand Down Expand Up @@ -153,17 +152,22 @@ final class _SchemaModelBuilder {
}

final model = switch (schema) {
StringSchema() => _string(schema),
IntegerSchema() => _integer(schema),
DoubleSchema() => _number(
StringSchema() => AckStringSchemaModel(
description: schema.description,
nullable: schema.isNullable,
),
NumberSchema() => _number(
IntegerSchema() => AckIntegerSchemaModel(
description: schema.description,
nullable: schema.isNullable,
),
DoubleSchema() || NumberSchema() => AckNumberSchemaModel(
description: schema.description,
nullable: schema.isNullable,
),
BooleanSchema() => AckBooleanSchemaModel(
description: schema.description,
nullable: schema.isNullable,
),
BooleanSchema() => _boolean(schema),
EnumSchema() => _enum(schema),
ListSchema() => _array(schema),
MapSchema() => _map(schema),
Expand Down Expand Up @@ -393,31 +397,6 @@ final class _SchemaModelBuilder {
return share(normalize(document, base))! as Map<String, Object?>;
}

AckSchemaModel _string(StringSchema schema) {
return AckStringSchemaModel(
description: schema.description,
nullable: schema.isNullable,
);
}

AckSchemaModel _integer(IntegerSchema schema) {
return AckIntegerSchemaModel(
description: schema.description,
nullable: schema.isNullable,
);
}

AckSchemaModel _number({String? description, required bool nullable}) {
return AckNumberSchemaModel(description: description, nullable: nullable);
}

AckSchemaModel _boolean(BooleanSchema schema) {
return AckBooleanSchemaModel(
description: schema.description,
nullable: schema.isNullable,
);
}

AckSchemaModel _enum(EnumSchema schema) {
return AckStringSchemaModel(
description: schema.description,
Expand Down Expand Up @@ -451,7 +430,7 @@ final class _SchemaModelBuilder {

for (final entry in schema.properties.entries) {
ordering.add(entry.key);
properties[entry.key] = wrapPropertyConversion(
properties[entry.key] = _wrapPropertyConversion(
entry.key,
() => _build(entry.value),
);
Expand Down Expand Up @@ -599,9 +578,6 @@ final class _SchemaModelBuilder {
if (schema.excludesTargetNull) model = _excludeNull(model);
final constraintCount = schema.runtimeConstraintCount;
final refinementCount = schema.runtimeRefinementCount;
if (constraintCount == 0 && refinementCount == 0) {
return model;
}

return model.withWarnings([
...model.warnings,
Expand Down Expand Up @@ -810,8 +786,26 @@ Object? _jsonRoundTripOrNull(Object? value) {
}
}

// Only schema-bearing keywords contain resource identities. Values of const,
// default, examples, and unknown annotations remain ordinary instance data.
/// Adds the property name to supported conversion errors without losing stacks.
T _wrapPropertyConversion<T>(String key, T Function() fn) {
try {
return fn();
} catch (e, st) {
final msg =
'Error converting property "$key": ${e is Error ? e.toString() : e}';
if (e is UnsupportedError) {
Error.throwWithStackTrace(UnsupportedError(msg), st);
} else if (e is ArgumentError) {
Error.throwWithStackTrace(ArgumentError(msg), st);
} else if (e is StateError) {
Error.throwWithStackTrace(StateError(msg), st);
}
rethrow;
}
}

// Rewrite identities only at compiler-indexed schema locations. Annotation
// payloads stay ordinary data unless explicitly referenced as schemas.
Map<String, Object?> _mapImportedChildren(
Map<String, Object?> schema,
Object? Function(Object?, List<String>) visit,
Expand Down
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 @@

### Fixed

* Preserve schema flags and object policies when `copyWith` no-op `null`
arguments are parenthesized.
* Preserve schema-first presence, nullability, and default-wrapper behavior
through aliases and explicit constant flag overrides. Apply the outermost
strict/passthrough policy to object models and aliases.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/dart/element/nullability_suffix.dart';
import 'package:analyzer/dart/element/type.dart';
// Analyzer 13 has no public API for substituting supplied method arguments
// into nested or F-bounded types. Keep its complete substituter for these bounds.
// ignore: implementation_imports
import 'package:analyzer/src/dart/element/type_algebra.dart' show Substitution;
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 @@ -594,14 +597,15 @@ final class CustomSchemaResolver {
element: field,
);
}
final bounds = instantiateTypeParameterBounds(
final substitution = Substitution.fromPairs2(
method.typeParameters,
type.typeArguments,
);
for (var i = 0; i < method.typeParameters.length; i++) {
final typeParam = method.typeParameters[i];
final bound = bounds[i];
if (bound == null) continue;
final rawBound = typeParam.bound;
if (rawBound == null) continue;
final bound = substitution.substituteType(rawBound);
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 @@ -155,14 +155,11 @@ abstract base class ModelGraphBuilderBase {

/// Returns the `@Schemable` top-level declaration behind [element], if any.
Element? schemableDeclaration(Element element) {
final declaration = switch (element) {
GetterElement(isOriginVariable: true) => element.variable.baseElement,
GetterElement() => element.baseElement,
TopLevelVariableElement() => element.baseElement,
_ => null,
};
return declaration != null &&
schemableChecker.hasAnnotationOfExact(declaration)
if (element is! GetterElement && element is! TopLevelVariableElement) {
return null;
}
final declaration = propertyDeclaration(element);
return schemableChecker.hasAnnotationOfExact(declaration)
? declaration
: null;
}
Expand Down Expand Up @@ -847,9 +844,7 @@ abstract base class ModelGraphBuilderBase {
: null;

bool? constantBool(Expression expression) {
while (expression is ParenthesizedExpression) {
expression = expression.expression;
}
expression = expression.unParenthesized;
if (expression is BooleanLiteral) return expression.value;

final element = switch (expression) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ final class _SchemaMetadata {
final bool innerOptional;
final bool innerNullable;

AckSchemaFieldPresence get presence {
if (defaulted) return AckSchemaFieldPresence.defaulted;
if (optional) return AckSchemaFieldPresence.optional;
return AckSchemaFieldPresence.required;
}

_SchemaMetadata copyWith({bool? optional, bool? nullable}) => _SchemaMetadata(
optional: (optional ?? this.optional) || innerOptional,
nullable: (nullable ?? this.nullable) || innerNullable,
Expand Down Expand Up @@ -417,7 +423,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase {
AckFieldNode(
dartName: jsonKey,
jsonKey: jsonKey,
presence: _fieldPresence(fieldMetadata),
presence: fieldMetadata.presence,
nullable: fieldMetadata.nullable,
runtimeRef: runtimeRef,
description: descriptionFromExpression(entry.value),
Expand Down Expand Up @@ -546,7 +552,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase {
required String path,
required Element context,
required bool throughLazy,
Set<Element>? visited,
Set<Element> visited = const {},
int depth = 0,
String? followedName,
bool collectionElement = false,
Expand Down Expand Up @@ -708,7 +714,7 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase {
path: path,
context: context,
throughLazy: throughLazy,
visited: visited ?? {},
visited: visited,
depth: depth,
);
if (followed != null) return followed;
Expand Down Expand Up @@ -1142,12 +1148,6 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase {
];
}

AckSchemaFieldPresence _fieldPresence(_SchemaMetadata metadata) {
if (metadata.defaulted) return AckSchemaFieldPresence.defaulted;
if (metadata.optional) return AckSchemaFieldPresence.optional;
return AckSchemaFieldPresence.required;
}

String _className(
String declarationName,
String? customName,
Expand Down Expand Up @@ -1447,7 +1447,10 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase {
for (final argument in current.argumentList.arguments) {
final entry = namedArgument(argument);
if (entry?.name != 'additionalProperties') continue;
if (entry!.expression is NullLiteral && name == 'copyWith') continue;
if (entry!.expression.unParenthesized is NullLiteral &&
name == 'copyWith') {
continue;
}
final value = constantBool(entry.expression);
if (value != null) return value;
throw InvalidGenerationSource(
Expand Down Expand Up @@ -1568,10 +1571,12 @@ final class SchemaModelGraphBuilder extends ModelGraphBuilderBase {
final nullable = named('isNullable');
final optional = named('isOptional');
return inner.copyWith(
nullable: nullable == null || nullable is NullLiteral
nullable:
nullable == null || nullable.unParenthesized is NullLiteral
? null
: flag(nullable, '$name isNullable'),
optional: optional == null || optional is NullLiteral
optional:
optional == null || optional.unParenthesized is NullLiteral
? null
: flag(optional, '$name isOptional'),
);
Expand Down
22 changes: 0 additions & 22 deletions packages/ack_generator/lib/src/utils/type_parameter_bounds.dart

This file was deleted.

24 changes: 24 additions & 0 deletions packages/ack_generator/test/integration/ack_infer_graph_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,30 @@ final titleSchema = Ack.string().nullable().nullable(value: false);
);
});

for (final omitted in ['null', '(null)', '((null))']) {
test('treats copyWith null arguments as no-ops: $omitted', () async {
await _expectOutput(
'''
$_head
final name = Ack.string().nullable().optional().copyWith(
isNullable: $omitted, isOptional: $omitted,
);
@Schemable()
final flagsSchema = Ack.object({'name': name});
@Schemable()
final policySchema = Ack.object({}, additionalProperties: true).copyWith(
additionalProperties: $omitted,
);
''',
allOf([
contains('final String? name;'),
isNot(contains('required this.name')),
contains('Policy({Map<String, Object?> additionalProperties'),
]),
);
});
}

test('preserves wrapper flag and outer default semantics', () async {
await _expectOutput(
'''
Expand Down
Loading