diff --git a/native/codegen-fixtures/24-nullable-discriminated-union/dart/client.dart b/native/codegen-fixtures/24-nullable-discriminated-union/dart/client.dart index 2c6b9803..f49d646d 100644 --- a/native/codegen-fixtures/24-nullable-discriminated-union/dart/client.dart +++ b/native/codegen-fixtures/24-nullable-discriminated-union/dart/client.dart @@ -8,26 +8,226 @@ export 'package:blocks_runtime/blocks_runtime.dart' show BlocksClient, BlocksRpc // --- Models --- +class IsUpdatedFalseNextStep { + final String name; + final String destination; + + const IsUpdatedFalseNextStep({ + required this.name, + required this.destination, + }); + + factory IsUpdatedFalseNextStep.fromJson(Map json) { + return IsUpdatedFalseNextStep( + name: json['name'] as String, + destination: json['destination'] as String, + ); + } + + Map toJson() { + return { + 'name': name, + 'destination': destination, + }; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is IsUpdatedFalseNextStep && + name == other.name && + destination == other.destination; + + @override + int get hashCode => Object.hash(name, destination); + + @override + String toString() => 'IsUpdatedFalseNextStep(name: $name, destination: $destination)'; +} + + // --- API Namespaces --- +sealed class UpdateAttributesResult { + const UpdateAttributesResult(); + Map toJson(); + static UpdateAttributesResult fromJson(Map json) { + switch (json['isUpdated'] as bool) { + case true: return IsUpdatedTrue.fromJson(json); + case false: return IsUpdatedFalse.fromJson(json); + } + } +} + +class IsUpdatedTrue extends UpdateAttributesResult { + + const IsUpdatedTrue(); + + factory IsUpdatedTrue.fromJson(Map json) { + return IsUpdatedTrue( + ); + } + + @override + Map toJson() { + return { + 'isUpdated': true, + }; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is IsUpdatedTrue; + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() => 'IsUpdatedTrue()'; +} + +class IsUpdatedFalse extends UpdateAttributesResult { + final IsUpdatedFalseNextStep nextStep; + + const IsUpdatedFalse({ + required this.nextStep, + }); + + factory IsUpdatedFalse.fromJson(Map json) { + return IsUpdatedFalse( + nextStep: IsUpdatedFalseNextStep.fromJson(json['nextStep'] as Map), + ); + } + + @override + Map toJson() { + return { + 'isUpdated': false, + 'nextStep': nextStep.toJson(), + }; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is IsUpdatedFalse && + nextStep == other.nextStep; + + @override + int get hashCode => nextStep.hashCode; + + @override + String toString() => 'IsUpdatedFalse(nextStep: $nextStep)'; +} + + + +sealed class GetNotificationResult { + const GetNotificationResult(); + Map toJson(); + static GetNotificationResult fromJson(Map json) { + switch (json['type'] as String) { + case 'email': return EmailGetNotificationResult.fromJson(json); + case 'sms': return SmsGetNotificationResult.fromJson(json); + default: throw ArgumentError('Unknown type: ${json['type']}'); + } + } +} + +class EmailGetNotificationResult extends GetNotificationResult { + final String subject; + final String body; + + const EmailGetNotificationResult({ + required this.subject, + required this.body, + }); + + factory EmailGetNotificationResult.fromJson(Map json) { + return EmailGetNotificationResult( + subject: json['subject'] as String, + body: json['body'] as String, + ); + } + + @override + Map toJson() { + return { + 'type': 'email', + 'subject': subject, + 'body': body, + }; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is EmailGetNotificationResult && + subject == other.subject && + body == other.body; + + @override + int get hashCode => Object.hash(subject, body); + + @override + String toString() => 'EmailGetNotificationResult(subject: $subject, body: $body)'; +} + +class SmsGetNotificationResult extends GetNotificationResult { + final String message; + + const SmsGetNotificationResult({ + required this.message, + }); + + factory SmsGetNotificationResult.fromJson(Map json) { + return SmsGetNotificationResult( + message: json['message'] as String, + ); + } + + @override + Map toJson() { + return { + 'type': 'sms', + 'message': message, + }; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SmsGetNotificationResult && + message == other.message; + + @override + int get hashCode => message.hashCode; + + @override + String toString() => 'SmsGetNotificationResult(message: $message)'; +} + + + class ApiApi { final BlocksClient _client; ApiApi(this._client); - Future> updateAttributes({required Map attributes}) async { + Future> updateAttributes({required Map attributes}) async { final params = { 'attributes': attributes, }; final result = await _client.call('api.updateAttributes', params); - return (result as Map).map((k, v) => MapEntry(k, v as dynamic)); + return (result as Map).map((k, v) => MapEntry(k, v == null ? null : UpdateAttributesResult.fromJson(v as Map))); } - Future getNotification({required String id}) async { + Future getNotification({required String id}) async { final params = { 'id': id, }; final result = await _client.call('api.getNotification', params); - return result as dynamic; + return result == null ? null : GetNotificationResult.fromJson(result as Map); } } diff --git a/native/dart/packages/blocks_codegen/lib/src/builder.dart b/native/dart/packages/blocks_codegen/lib/src/builder.dart index f168f7c4..ca8a58ae 100644 --- a/native/dart/packages/blocks_codegen/lib/src/builder.dart +++ b/native/dart/packages/blocks_codegen/lib/src/builder.dart @@ -47,10 +47,15 @@ class SealedClassType extends ResolvedType { String name; final String discriminant; final List variants; + + /// True when the discriminant is a boolean enum (true/false) rather than a + /// string enum. Drives bool (vs String) emission in the generator. + final bool discriminantIsBoolean; SealedClassType({ required this.name, required this.discriminant, required this.variants, + this.discriminantIsBoolean = false, }); } @@ -330,7 +335,7 @@ class CodegenModelBuilder { final fk = fieldKeys.map((f) => '${f.name}:${_typeKey(f.type)}${f.isRequired ? '!' : ''}').join(','); return '${v.discriminantValue}{$fk}'; }); - return 'sealed[${sealed.discriminant}]{${parts.join('|')}}'; + return 'sealed[${sealed.discriminant}${sealed.discriminantIsBoolean ? ':bool' : ''}]{${parts.join('|')}}'; } ResolvedType _resolveEnum(List values, String? hint, [String? path]) { @@ -405,7 +410,13 @@ class CodegenModelBuilder { final variants = groups.entries.map((entry) { final discValue = entry.key; final group = entry.value; - final className = '${_capitalize(discValue)}${_inferSuffix(name)}'; + // String discriminants keep the established `` scheme + // (e.g. `EmailGetNotificationResult`). A boolean discriminant has no + // descriptive value, so name the arm after the field + value + // (e.g. `IsUpdatedTrue`/`IsUpdatedFalse`), mirroring the Kotlin output. + final className = ref.discriminantIsBoolean + ? '${_capitalize(ref.discriminant)}${_capitalize(discValue)}' + : '${_capitalize(discValue)}${_inferSuffix(name)}'; final variantPath = path == null ? null : '$path>$discValue'; final List fields; @@ -454,7 +465,12 @@ class CodegenModelBuilder { ); }).toList(); - final sealed = SealedClassType(name: name, discriminant: ref.discriminant, variants: variants); + final sealed = SealedClassType( + name: name, + discriminant: ref.discriminant, + variants: variants, + discriminantIsBoolean: ref.discriminantIsBoolean, + ); // Structural dedup: reuse an existing sealed class with the same shape // (ported from #682 — recursive structural + sealed-class dedup). diff --git a/native/dart/packages/blocks_codegen/lib/src/generator.dart b/native/dart/packages/blocks_codegen/lib/src/generator.dart index 472734f8..fa14db4f 100644 --- a/native/dart/packages/blocks_codegen/lib/src/generator.dart +++ b/native/dart/packages/blocks_codegen/lib/src/generator.dart @@ -265,12 +265,23 @@ class DartCodeGenerator { buf.writeln(' const ${sealed.name}();'); buf.writeln(' Map toJson();'); buf.writeln(' static ${sealed.name} fromJson(Map json) {'); - buf.writeln(" switch (json['${sealed.discriminant}'] as String) {"); - for (final v in sealed.variants) { - buf.writeln(" case '${v.discriminantValue}': return ${v.className}.fromJson(json);"); + if (sealed.discriminantIsBoolean) { + // Boolean discriminant: switch on the real bool; case labels and the + // serialized value are unquoted true/false literals. true/false are + // exhaustive for a bool, so no (unreachable) default clause is emitted. + buf.writeln(" switch (json['${sealed.discriminant}'] as bool) {"); + for (final v in sealed.variants) { + buf.writeln(" case ${v.discriminantValue}: return ${v.className}.fromJson(json);"); + } + buf.writeln(' }'); + } else { + buf.writeln(" switch (json['${sealed.discriminant}'] as String) {"); + for (final v in sealed.variants) { + buf.writeln(" case '${v.discriminantValue}': return ${v.className}.fromJson(json);"); + } + buf.writeln(" default: throw ArgumentError('Unknown ${sealed.discriminant}: \${json['${sealed.discriminant}']}');"); + buf.writeln(' }'); } - buf.writeln(" default: throw ArgumentError('Unknown ${sealed.discriminant}: \${json['${sealed.discriminant}']}');"); - buf.writeln(' }'); buf.writeln(' }'); buf.writeln('}'); buf.writeln(); @@ -328,7 +339,11 @@ class DartCodeGenerator { buf.writeln(' @override'); buf.writeln(' Map toJson() {'); buf.writeln(' return {'); - buf.writeln(" '${sealed.discriminant}': '${v.discriminantValue}',"); + if (sealed.discriminantIsBoolean) { + buf.writeln(" '${sealed.discriminant}': ${v.discriminantValue},"); + } else { + buf.writeln(" '${sealed.discriminant}': '${v.discriminantValue}',"); + } for (final f in v.fields) { final ident = _escapeIdentifier(f.name); final expr = _toJsonExpr(ident, f.type, allTypes, !f.isRequired); @@ -712,7 +727,7 @@ class DartCodeGenerator { PrimitiveType(dartType: final dt) => dt == 'int' ? '($accessor as num).toInt()' : '$accessor as $dt', NullableType(inner: final inner) => _deserializeNullable(accessor, inner, allTypes), ListType(items: final items) => _deserializeList(accessor, items, allTypes), - MapType(valueType: final vt) => '($accessor as Map).map((k, v) => MapEntry(k, v as ${_dartTypeStr(vt, allTypes)}))', + MapType(valueType: final vt) => '($accessor as Map).map((k, v) => MapEntry(k, ${_mapValueFromJson('v', vt, allTypes)}))', RecordType(name: final name) => '$name.fromJson($accessor as Map)', SchemaReference(name: final name) => _deserializeSchema(accessor, name, allTypes), SealedClassType(name: final name) => '$name.fromJson($accessor as Map)', @@ -729,10 +744,49 @@ class DartCodeGenerator { '$accessor == null ? null : $name.fromJson($accessor as Map)', SchemaReference(name: final name) => '$accessor == null ? null : $name.fromJson($accessor as Map)', + SealedClassType(name: final name) => + '$accessor == null ? null : $name.fromJson($accessor as Map)', _ => '$accessor', }; } + /// Per-value deserialization for a `Map` entry value `v` (dynamic). + /// Primitive value types keep the plain `v as T` cast (byte-identical to the + /// prior output); object-like value types (records, sealed classes) — and the + /// nullable variants thereof — are decoded via their generated `fromJson`. + String _mapValueFromJson(String v, ResolvedType valueType, Map allTypes) { + switch (valueType) { + case SealedClassType(name: final n): + return '$n.fromJson($v as Map)'; + case RecordType(name: final n): + return '$n.fromJson($v as Map)'; + case SchemaReference(name: final n): + final resolved = allTypes[n]; + if (resolved is RecordType || resolved is SealedClassType) { + return '$n.fromJson($v as Map)'; + } + if (resolved is EnumType) { + return '$n.fromJson($v as String)'; + } + return '$v as $n'; + case NullableType(inner: final inner): + final innerName = switch (inner) { + SealedClassType(name: final n) => n, + RecordType(name: final n) => n, + SchemaReference(name: final n) + when allTypes[n] is RecordType || allTypes[n] is SealedClassType => + n, + _ => null, + }; + if (innerName != null) { + return '$v == null ? null : $innerName.fromJson($v as Map)'; + } + return '$v as ${_dartTypeStr(valueType, allTypes)}'; + default: + return '$v as ${_dartTypeStr(valueType, allTypes)}'; + } + } + String _deserializeList(String accessor, ResolvedType items, Map allTypes) { final itemExpr = switch (items) { RecordType(name: final name) => '(e) => $name.fromJson(e as Map)', diff --git a/native/dart/packages/blocks_codegen/lib/src/model.dart b/native/dart/packages/blocks_codegen/lib/src/model.dart index 13077fb1..802cc9b2 100644 --- a/native/dart/packages/blocks_codegen/lib/src/model.dart +++ b/native/dart/packages/blocks_codegen/lib/src/model.dart @@ -68,7 +68,16 @@ class UnionLiteralRef extends TypeRef { class DiscriminatedUnionRef extends TypeRef { final String discriminant; final List variants; - const DiscriminatedUnionRef({required this.discriminant, required this.variants}); + + /// True when the discriminant is a boolean-enum (`{"type":"boolean", + /// "enum":[true|false]}`) rather than the usual string enum. Drives bool + /// (vs String) discriminant handling in the generator. + final bool discriminantIsBoolean; + const DiscriminatedUnionRef({ + required this.discriminant, + required this.variants, + this.discriminantIsBoolean = false, + }); } class UnionVariant { diff --git a/native/dart/packages/blocks_codegen/lib/src/parser.dart b/native/dart/packages/blocks_codegen/lib/src/parser.dart index 1c74a19a..adb57cc8 100644 --- a/native/dart/packages/blocks_codegen/lib/src/parser.dart +++ b/native/dart/packages/blocks_codegen/lib/src/parser.dart @@ -194,29 +194,46 @@ class OpenRpcParser { } TypeRef _parseOneOf(List> oneOf) { - // Check nullable pattern: [T, {type: "null"}] - if (oneOf.length == 2) { - final nullIdx = oneOf.indexWhere((s) => s['type'] == 'null'); - if (nullIdx != -1) { - final inner = oneOf[nullIdx == 0 ? 1 : 0]; - return NullableRef(_parseTypeRef(inner)); - } + // A `{"type":"null"}` member (e.g. from TS `Partial>`) does + // not participate in discrimination and must not become a sealed-class + // variant. Strip such members out, and if any were present mark the + // resolved union nullable. The remaining (non-null) arms drive the + // nullable/discriminated-union detection below. + final hasNullMember = oneOf.any((s) => s['type'] == 'null'); + final nonNull = oneOf.where((s) => s['type'] != 'null').toList(); + + // Nullable of a single member: [T, {type: "null"}] → T? + if (hasNullMember && nonNull.length == 1) { + return NullableRef(_parseTypeRef(nonNull.first)); } // Check discriminated union: all objects with a shared single-value enum field - if (oneOf.every((v) => v['type'] == 'object' && v.containsKey('properties'))) { - final discriminant = _findDiscriminant(oneOf); + if (nonNull.isNotEmpty && + nonNull.every((v) => v['type'] == 'object' && v.containsKey('properties'))) { + final discriminant = _findDiscriminant(nonNull); if (discriminant != null) { - final variants = oneOf.map((v) { + // A boolean-enum discriminant (`{"type":"boolean","enum":[true|false]}`) + // is handled distinctly from the usual string enum: its JSON value is a + // real bool, not a quoted string. + final discriminantIsBoolean = nonNull.every((v) { + final p = (v['properties'] as Map)[discriminant] + as Map; + return p['type'] == 'boolean'; + }); + final variants = nonNull.map((v) { final props = (v['properties'] as Map) .map((k, val) => MapEntry(k, _parseTypeRef(val as Map))); final required = (v['required'] as List?) ?.cast() .toSet() ?? {}; + // `.toString()` yields the literal value for both string ("create") + // and boolean (true/false) discriminants; `as String` would throw on + // a bool. final discValue = ((v['properties'] as Map)[discriminant] - as Map)['enum'][0] as String; + as Map)['enum'][0] + .toString(); // Remove discriminant from properties final filteredProps = Map.from(props)..remove(discriminant); final filteredRequired = Set.from(required)..remove(discriminant); @@ -238,12 +255,16 @@ class OpenRpcParser { embeddedUnion: embeddedUnion, ); }).toList(); - return DiscriminatedUnionRef(discriminant: discriminant, variants: variants); + final union = DiscriminatedUnionRef( + discriminant: discriminant, + variants: variants, + discriminantIsBoolean: discriminantIsBoolean, + ); + return hasNullMember ? NullableRef(union) : union; } } // Fallback: treat as nullable of first non-null - final nonNull = oneOf.where((s) => s['type'] != 'null').toList(); if (nonNull.length == 1) { return NullableRef(_parseTypeRef(nonNull.first)); } @@ -253,16 +274,27 @@ class OpenRpcParser { String? _findDiscriminant(List> variants) { final firstProps = (variants.first['properties'] as Map).keys.toList(); - for (final field in firstProps) { - final allMatch = variants.every((v) { + // A discriminant is a required-shaped single-value enum field shared by + // every arm. String discriminants are the classic case; a boolean-enum + // field (`{"type":"boolean","enum":[true|false]}`) also discriminates. + bool isDiscriminantField(String field, String type) { + return variants.every((v) { final props = v['properties'] as Map; if (!props.containsKey(field)) return false; final p = props[field] as Map; - return p['type'] == 'string' && + return p['type'] == type && p.containsKey('enum') && (p['enum'] as List).length == 1; }); - if (allMatch) return field; + } + + // Prefer a string discriminant (preserves existing selection), then fall + // back to a boolean one. + for (final field in firstProps) { + if (isDiscriminantField(field, 'string')) return field; + } + for (final field in firstProps) { + if (isDiscriminantField(field, 'boolean')) return field; } return null; }