From 2f40aab36542f931968bff06d9ba229fbdfd4d3d Mon Sep 17 00:00:00 2001 From: Jake Macdonald Date: Thu, 2 May 2024 17:20:29 +0000 Subject: [PATCH] Initial extraction of the json macro into a package, without configuration. Change-Id: I94067a57151066736e3c703ef85bfa90a4af1c70 Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/365081 Reviewed-by: Bob Nystrom Reviewed-by: Marya Belanger Reviewed-by: Morgan :) Commit-Queue: Jake Macdonald --- pkg/json/CHANGELOG.md | 3 + pkg/json/LICENSE | 27 ++ pkg/json/README.md | 93 ++++ pkg/json/analysis_options.yaml | 5 + pkg/json/lib/json.dart | 639 +++++++++++++++++++++++++++ pkg/json/pubspec.yaml | 13 + pkg/json/test/json_codable_test.dart | 57 +++ 7 files changed, 837 insertions(+) create mode 100644 pkg/json/CHANGELOG.md create mode 100644 pkg/json/LICENSE create mode 100644 pkg/json/README.md create mode 100644 pkg/json/analysis_options.yaml create mode 100644 pkg/json/lib/json.dart create mode 100644 pkg/json/pubspec.yaml create mode 100644 pkg/json/test/json_codable_test.dart diff --git a/pkg/json/CHANGELOG.md b/pkg/json/CHANGELOG.md new file mode 100644 index 00000000000..1715641b2b2 --- /dev/null +++ b/pkg/json/CHANGELOG.md @@ -0,0 +1,3 @@ +# 0.1.0-wip + +- Initial release, adds the JsonCodable macro. diff --git a/pkg/json/LICENSE b/pkg/json/LICENSE new file mode 100644 index 00000000000..b03a7886835 --- /dev/null +++ b/pkg/json/LICENSE @@ -0,0 +1,27 @@ +Copyright 2024, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkg/json/README.md b/pkg/json/README.md new file mode 100644 index 00000000000..8261d9041df --- /dev/null +++ b/pkg/json/README.md @@ -0,0 +1,93 @@ +This package provides an experimental macro that encodes and decodes +user-defined Dart classes to JSON maps (maps of type `Map`). + +This relies on the experimental macros language feature, and as such may be +unstable or have breaking changes if the feature changes. + +## Applying the JsonCodable macro + +To apply the JsonCodable macro to a class, add it as an annotation: + +```dart +import 'package:json/json.dart'; + +@JsonCodable() +class User { + final String name; + + final int? age; +} +``` + +The macro generates two members for the `User` class: a `fromJson` constructor +and a `toJson` method, with APIs like the following: + +```dart +class User { + User.fromJson(Map json); + + Map toJson(); +} +``` + +Each non-nullable field in the annotated class must have an entry with the same name in the `json` map, but +nullable fields are allowed to have no key at all. The `toJson` will omit null +fields entirely from the map, and will not contain explicit null entries. + +### Extending other classes + +You are allowed to extend classes other than `Object`, but they must have a +valid `toJson` method and `fromJson` constructor, so the following is allowed: + +```dart +@JsonCodable() +class Manager extends User { + final List reports; +} +``` + +## Supported field types + +All native JSON types are supported (`int`, `double`, `String`, `bool`, `Null`). + +The core collection types `List`, `Set`, and `Map` are also supported, if their +elements are supported types. For elements which require more than just a cast, +the type must be statically provided through a generic type argument on the +collection in order for the specialized code to be generated. + +Additionally, custom types with a `toJson` method and `fromJson` constructor are +allowed, including as generic type arguments to the collection types. + +## Generics + +Classes with generic type parameters are not supported, but may be in the +future. + +## Configuration + +Macro configuration is a feature we intend to add, but it is not available at this time. + +Because of this, field names must exactly match the keys in the maps, and +default values are not supported. + +## Enabling the macros experiment + +Most tools accept the `--enable-experiment=macros` option, and appending that +to your typical command line invocations should be all that is needed. For +example, you can launch your flutter project like: +`flutter run --enable-experiment=macros`. + +For the analyzer, you will want to add some configuration to an +`analysis_options.yaml` file at the root of your project: + +```yaml +analyzer: + enable-experiment: + - macros +``` + +Note that `dart run` is a little bit special, in that the option must come +_immediately_ following `dart` and before `run` - this is because it is an +option to the Dart VM, and not the Dart script itself. For example, +`dart --enable-experiment=macros run bin/my_script.dart`. This is also how the +`test` package expects to be invoked, so `dart --enable-experiment=macros test`. diff --git a/pkg/json/analysis_options.yaml b/pkg/json/analysis_options.yaml new file mode 100644 index 00000000000..f3edb69f429 --- /dev/null +++ b/pkg/json/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:lints/recommended.yaml + +analyzer: + enable-experiment: + - macros diff --git a/pkg/json/lib/json.dart b/pkg/json/lib/json.dart new file mode 100644 index 00000000000..87d788d37ed --- /dev/null +++ b/pkg/json/lib/json.dart @@ -0,0 +1,639 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// ignore_for_file: deprecated_member_use + +import 'dart:async'; + +import 'package:macros/macros.dart'; + +/// A macro which adds a `fromJson(Map json)` constructor and +/// a `Map toJson()` method to a class. +/// +/// To use this macro, annotate your class with `@JsonCodable()` and enable the +/// macros experiment (see README.md for full instructions). +/// +/// The implementations are derived from the fields defined directly on the +/// annotated class, and field names are expected to exactly match the keys of +/// the maps that they are being decoded from. +/// +/// If extending any class other than [Object], then the super class is expected +/// to also have a corresponding `toJson` method and `fromJson` constructor. +/// +/// Annotated classes are not allowed to have a manually defined `toJson` method +/// or `fromJson` constructor. +macro class JsonCodable + implements ClassDeclarationsMacro, ClassDefinitionMacro { + const JsonCodable(); + + /// Declares the `fromJson` constructor and `toJson` method, but does not + /// implement them. + @override + Future buildDeclarationsForClass( + ClassDeclaration clazz, MemberDeclarationBuilder builder) async { + if (clazz.typeParameters.isNotEmpty) { + throw DiagnosticException(Diagnostic(DiagnosticMessage( + // TODO: Target the actual type parameter, issue #55611 + 'Cannot be applied to classes with generic type parameters'), + Severity.error)); + } + + final (map, string, object, _, _) = await ( + builder.resolveIdentifier(_dartCore, 'Map'), + builder.resolveIdentifier(_dartCore, 'String'), + builder.resolveIdentifier(_dartCore, 'Object'), + // These are just for validation, and will throw if the check fails. + _checkNoFromJson(builder, clazz), + _checkNoToJson(builder, clazz), + ).wait; + final mapStringObject = NamedTypeAnnotationCode(name: map, typeArguments: [ + NamedTypeAnnotationCode(name: string), + NamedTypeAnnotationCode(name: object).asNullable + ]); + + builder.declareInType(DeclarationCode.fromParts([ + // TODO(language#3580): Remove/replace 'external'? + ' external ', + clazz.identifier.name, + '.fromJson(', + mapStringObject, + ' json);', + ])); + + builder.declareInType(DeclarationCode.fromParts([ + // TODO(language#3580): Remove/replace 'external'? + ' external ', + mapStringObject, + ' toJson();', + ])); + } + + /// Provides the actual definitions of the `fromJson` constructor and `toJson` + /// method, which were declared in the previous phase. + @override + Future buildDefinitionForClass( + ClassDeclaration clazz, TypeDefinitionBuilder builder) async { + final (introspectionData, constructors, methods) = await ( + _SharedIntrospectionData.build(builder, clazz), + builder.constructorsOf(clazz), + builder.methodsOf(clazz) + ).wait; + final fromJson = + constructors.firstWhereOrNull((c) => c.identifier.name == 'fromJson'); + final toJson = + methods.firstWhereOrNull((c) => c.identifier.name == 'toJson'); + + // An earlier step failed, just bail out without emitting additional + // diagnostics. + if (fromJson == null || toJson == null) return; + + final (fromJsonBuilder, toJsonBuilder) = await ( + builder.buildConstructor(fromJson.identifier), + builder.buildMethod(toJson.identifier), + ).wait; + await ( + _buildFromJson(fromJson, fromJsonBuilder, introspectionData), + _buildToJson(toJson, toJsonBuilder, introspectionData), + ).wait; + } + + /// Builds the actual `fromJson` constructor. + Future _buildFromJson( + ConstructorDeclaration constructor, + ConstructorDefinitionBuilder builder, + _SharedIntrospectionData introspectionData) async { + await _checkValidFromJson(constructor, introspectionData, builder); + + // If extending something other than `Object`, it must have a `fromJson` + // constructor. + var superclassHasFromJson = false; + final superclassDeclaration = introspectionData.superclass; + if (superclassDeclaration != null && + !superclassDeclaration.isExactly('Object', _dartCore)) { + final superclassConstructors = + await builder.constructorsOf(superclassDeclaration); + for (final superConstructor in superclassConstructors) { + if (superConstructor.identifier.name == 'fromJson') { + await _checkValidFromJson( + superConstructor, introspectionData, builder); + superclassHasFromJson = true; + break; + } + } + if (!superclassHasFromJson) { + throw DiagnosticException(Diagnostic( + DiagnosticMessage( + 'Serialization of classes that extend other classes is only ' + 'supported if those classes have a valid ' + '`fromJson(Map json)` constructor.', + target: introspectionData.clazz.superclass?.asDiagnosticTarget), + Severity.error)); + } + } + + final fields = introspectionData.fields; + final jsonParam = constructor.positionalParameters.single.identifier; + + Future initializerForField(FieldDeclaration field) async { + return RawCode.fromParts([ + field.identifier, + ' = ', + await _convertTypeFromJson( + field.type, + RawCode.fromParts([ + jsonParam, + "['", + field.identifier.name, + "']", + ]), + builder, + introspectionData), + ]); + } + + final initializers = await Future.wait(fields.map(initializerForField)); + + if (superclassHasFromJson) { + initializers.add(RawCode.fromParts([ + 'super.fromJson(', + jsonParam, + ')', + ])); + } + + builder.augment(initializers: initializers); + } + + /// Builds the actual `toJson` method. + Future _buildToJson( + MethodDeclaration method, + FunctionDefinitionBuilder builder, + _SharedIntrospectionData introspectionData) async { + if (!(await _checkValidToJson(method, introspectionData, builder))) return; + + // If extending something other than `Object`, it must have a `toJson` + // method. + var superclassHasToJson = false; + final superclassDeclaration = introspectionData.superclass; + if (superclassDeclaration != null && + !superclassDeclaration.isExactly('Object', _dartCore)) { + final superclassMethods = await builder.methodsOf(superclassDeclaration); + for (final superMethod in superclassMethods) { + if (superMethod.identifier.name == 'toJson') { + if (!(await _checkValidToJson( + superMethod, introspectionData, builder))) { + return; + } + superclassHasToJson = true; + break; + } + } + if (!superclassHasToJson) { + builder.report(Diagnostic( + DiagnosticMessage( + 'Serialization of classes that extend other classes is only ' + 'supported if those classes have a valid ' + '`Map toJson()` method.', + target: introspectionData.clazz.superclass?.asDiagnosticTarget), + Severity.error)); + return; + } + } + + final fields = introspectionData.fields; + final parts = [ + '{\n final json = ', + if (superclassHasToJson) + 'super.toJson()' + else ...[ + '<', + introspectionData.stringCode, + ', ', + introspectionData.objectCode.asNullable, + '>{}', + ], + ';\n ', + ]; + + Future addEntryForField(FieldDeclaration field) async { + final parts = []; + final doNullCheck = field.type.isNullable; + if (doNullCheck) { + parts.addAll([ + 'if (', + field.identifier, + // `null` is a reserved word, we can just use it. + ' != null) {\n ', + ]); + } + parts.addAll([ + "json['", + field.identifier.name, + "'] = ", + await _convertTypeToJson( + field.type, + RawCode.fromParts([ + field.identifier, + if (doNullCheck) '!', + ]), + builder, + introspectionData), + ';\n', + ]); + if (doNullCheck) { + parts.add(' }\n'); + } + return RawCode.fromParts(parts); + } + + parts.addAll(await Future.wait(fields.map(addEntryForField))); + + parts.add(' return json;\n }'); + + builder.augment(FunctionBodyCode.fromParts(parts)); + } + + /// Throws a [DiagnosticException] if there is an existing `fromJson` + /// constructor on [clazz]. + Future _checkNoFromJson( + DeclarationBuilder builder, ClassDeclaration clazz) async { + final constructors = await builder.constructorsOf(clazz); + final fromJson = + constructors.firstWhereOrNull((c) => c.identifier.name == 'fromJson'); + if (fromJson != null) { + throw DiagnosticException(Diagnostic( + DiagnosticMessage( + 'Cannot generate a fromJson constructor due to this existing ' + 'one.', + target: fromJson.asDiagnosticTarget), + Severity.error)); + } + } + + /// Throws a [DiagnosticException] if there is an existing `toJson` method on + /// [clazz]. + Future _checkNoToJson( + DeclarationBuilder builder, ClassDeclaration clazz) async { + final methods = await builder.methodsOf(clazz); + final toJson = + methods.firstWhereOrNull((m) => m.identifier.name == 'toJson'); + if (toJson != null) { + throw DiagnosticException(Diagnostic( + DiagnosticMessage( + 'Cannot generate a toJson method due to this existing one.', + target: toJson.asDiagnosticTarget), + Severity.error)); + } + } + + /// Checks that [constructor] is a valid `fromJson` constructor, and throws a + /// [DiagnosticException] if not. + Future _checkValidFromJson( + ConstructorDeclaration constructor, + _SharedIntrospectionData introspectionData, + DefinitionBuilder builder) async { + if (constructor.namedParameters.isNotEmpty || + constructor.positionalParameters.length != 1 || + !(await (await builder + .resolve(constructor.positionalParameters.single.type.code)) + .isExactly(introspectionData.jsonMapType))) { + throw DiagnosticException(Diagnostic( + DiagnosticMessage( + 'Expected exactly one parameter, with the type ' + 'Map', + target: constructor.asDiagnosticTarget), + Severity.error)); + } + } + + /// Returns a [Code] object which is an expression that converts a JSON map + /// (referenced by [jsonReference]) into an instance of type [type]. + Future _convertTypeFromJson( + TypeAnnotation type, + Code jsonReference, + DefinitionBuilder builder, + _SharedIntrospectionData introspectionData) async { + if (type is! NamedTypeAnnotation) { + builder.report(Diagnostic( + DiagnosticMessage( + 'Only fields with named types are allowed on serializable ' + 'classes', + target: type.asDiagnosticTarget), + Severity.error)); + return RawCode.fromString( + "throw 'Unable to deserialize type ${type.code.debugString}'"); + } + + // Follow type aliases until we reach an actual named type. + var classDecl = await _classDeclarationOf(builder, type); + if (classDecl == null) { + return RawCode.fromString( + "throw 'Unable to deserialize type ${type.code.debugString}'"); + } + + var nullCheck = type.isNullable + ? RawCode.fromParts([ + jsonReference, + // `null` is a reserved word, we can just use it. + ' == null ? null : ', + ]) + : null; + + // Check if `typeDecl` is one of the supported collection types. + if (classDecl.isExactly('List', _dartCore)) { + return RawCode.fromParts([ + if (nullCheck != null) nullCheck, + '[ for (final item in ', + jsonReference, + ' as ', + introspectionData.jsonListCode, + ') ', + await _convertTypeFromJson(type.typeArguments.single, + RawCode.fromString('item'), builder, introspectionData), + ']', + ]); + } else if (classDecl.isExactly('Set', _dartCore)) { + return RawCode.fromParts([ + if (nullCheck != null) nullCheck, + '{ for (final item in ', + jsonReference, + ' as ', + introspectionData.jsonListCode, + ')', + await _convertTypeFromJson(type.typeArguments.single, + RawCode.fromString('item'), builder, introspectionData), + '}', + ]); + } else if (classDecl.isExactly('Map', _dartCore)) { + return RawCode.fromParts([ + if (nullCheck != null) nullCheck, + '{ for (final entry in ', + jsonReference, + ' as ', + introspectionData.jsonMapCode, + '.entries) entry.key: ', + await _convertTypeFromJson(type.typeArguments.single, + RawCode.fromString('entry.value'), builder, introspectionData), + '}', + ]); + } + + // Otherwise, check if `classDecl` has a `fromJson` constructor. + final constructors = await builder.constructorsOf(classDecl); + final fromJson = constructors + .firstWhereOrNull((c) => c.identifier.name == 'fromJson') + ?.identifier; + if (fromJson != null) { + return RawCode.fromParts([ + if (nullCheck != null) nullCheck, + fromJson, + '(', + jsonReference, + ' as ', + introspectionData.jsonMapCode, + ')', + ]); + } + + // Finally, we just cast directly to the field type. + // TODO: Check that it is a valid type we can cast from JSON. + return RawCode.fromParts([ + jsonReference, + ' as ', + type.code, + ]); + } + + /// Checks that [method] is a valid `toJson` method, and throws a + /// [DiagnosticException] if not. + Future _checkValidToJson( + MethodDeclaration method, + _SharedIntrospectionData introspectionData, + DefinitionBuilder builder) async { + if (method.namedParameters.isNotEmpty || + method.positionalParameters.isNotEmpty || + !(await (await builder.resolve(method.returnType.code)) + .isExactly(introspectionData.jsonMapType))) { + builder.report(Diagnostic( + DiagnosticMessage( + 'Expected no parameters, and a return type of ' + 'Map', + target: method.asDiagnosticTarget), + Severity.error)); + return false; + } + return true; + } + + /// Returns a [Code] object which is an expression that converts an instance + /// of type [type] (referenced by [valueReference]) into a JSON map. + Future _convertTypeToJson( + TypeAnnotation type, + Code valueReference, + DefinitionBuilder builder, + _SharedIntrospectionData introspectionData) async { + if (type is! NamedTypeAnnotation) { + builder.report(Diagnostic( + DiagnosticMessage( + 'Only fields with named types are allowed on serializable ' + 'classes', + target: type.asDiagnosticTarget), + Severity.error)); + return RawCode.fromString( + "throw 'Unable to serialize type ${type.code.debugString}'"); + } + + // Follow type aliases until we reach an actual named type. + var classDecl = await _classDeclarationOf(builder, type); + if (classDecl == null) { + return RawCode.fromString( + "throw 'Unable to serialize type ${type.code.debugString}'"); + } + + // Check for the supported collection types, and serialize them accordingly. + if (classDecl.isExactly('List', _dartCore) || + classDecl.isExactly('Set', _dartCore)) { + return RawCode.fromParts([ + '[ for (final item in ', + valueReference, + ') ', + await _convertTypeToJson(type.typeArguments.single, + RawCode.fromString('item'), builder, introspectionData), + ']', + ]); + } else if (classDecl.isExactly('Map', _dartCore)) { + return RawCode.fromParts([ + '{ for (final entry in ', + valueReference, + '.entries) entry.key: ', + await _convertTypeToJson(type.typeArguments.single, + RawCode.fromString('entry.value'), builder, introspectionData), + '}', + ]); + } + + // Next, check if it has a `toJson()` method and call that. + final methods = await builder.methodsOf(classDecl); + final toJson = methods + .firstWhereOrNull((c) => c.identifier.name == 'toJson') + ?.identifier; + if (toJson != null) { + return RawCode.fromParts([ + valueReference, + '.toJson()', + ]); + } + + // Finally, we just return the value as is if we can't otherwise handle it. + // TODO: Check that it is a valid type we can serialize. + return valueReference; + } + + /// Follows [type] through any type aliases, until it reaches a + /// [ClassDeclaration], or returns null if it does not bottom out on a class. + Future _classDeclarationOf( + DefinitionBuilder builder, NamedTypeAnnotation type) async { + var typeDecl = await builder.typeDeclarationOf(type.identifier); + while (typeDecl is TypeAliasDeclaration) { + final aliasedType = typeDecl.aliasedType; + if (aliasedType is! NamedTypeAnnotation) { + builder.report(Diagnostic( + DiagnosticMessage( + 'Only fields with named types are allowed on serializable ' + 'classes', + target: type.asDiagnosticTarget), + Severity.error)); + return null; + } + typeDecl = await builder.typeDeclarationOf(aliasedType.identifier); + } + if (typeDecl is! ClassDeclaration) { + builder.report(Diagnostic( + DiagnosticMessage( + 'Only classes are supported as field types for serializable ' + 'classes', + target: type.asDiagnosticTarget), + Severity.error)); + return null; + } + return typeDecl; + } +} + +/// This data is collected asynchronously, so we only want to do it once and +/// share that work across multiple locations. +final class _SharedIntrospectionData { + /// The declaration of the class we are generating for. + final ClassDeclaration clazz; + + /// All the fields on the [clazz]. + final List fields; + + /// A [Code] representation of the type [List]. + final NamedTypeAnnotationCode jsonListCode; + + /// A [Code] representation of the type [Map]. + final NamedTypeAnnotationCode jsonMapCode; + + /// The resolved [StaticType] representing the [Map] type. + final StaticType jsonMapType; + + /// A [Code] representation of the type [Object]. + final NamedTypeAnnotationCode objectCode; + + /// A [Code] representation of the type [String]. + final NamedTypeAnnotationCode stringCode; + + /// The declaration of the superclass of [clazz], if it is not [Object]. + final ClassDeclaration? superclass; + + _SharedIntrospectionData({ + required this.clazz, + required this.fields, + required this.jsonListCode, + required this.jsonMapCode, + required this.jsonMapType, + required this.objectCode, + required this.stringCode, + required this.superclass, + }); + + static Future<_SharedIntrospectionData> build( + DeclarationPhaseIntrospector builder, ClassDeclaration clazz) async { + final (list, map, object, string) = await ( + builder.resolveIdentifier(_dartCore, 'List'), + builder.resolveIdentifier(_dartCore, 'Map'), + builder.resolveIdentifier(_dartCore, 'Object'), + builder.resolveIdentifier(_dartCore, 'String'), + ).wait; + final objectCode = NamedTypeAnnotationCode(name: object); + final nullableObjectCode = objectCode.asNullable; + final jsonListCode = NamedTypeAnnotationCode(name: list, typeArguments: [ + nullableObjectCode, + ]); + final jsonMapCode = NamedTypeAnnotationCode(name: map, typeArguments: [ + NamedTypeAnnotationCode(name: string), + nullableObjectCode, + ]); + final stringCode = NamedTypeAnnotationCode(name: string); + final superclass = clazz.superclass; + final (fields, jsonMapType, superclassDecl) = await ( + builder.fieldsOf(clazz), + builder.resolve(jsonMapCode), + superclass == null + ? Future.value(null) + : builder.typeDeclarationOf(superclass.identifier), + ).wait; + + return _SharedIntrospectionData( + clazz: clazz, + fields: fields, + jsonListCode: jsonListCode, + jsonMapCode: jsonMapCode, + jsonMapType: jsonMapType, + objectCode: objectCode, + stringCode: stringCode, + superclass: superclassDecl as ClassDeclaration?, + ); + } +} + +final _dartCore = Uri.parse('dart:core'); + +extension _FirstWhereOrNull on Iterable { + T? firstWhereOrNull(bool Function(T) compare) { + for (final item in this) { + if (compare(item)) return item; + } + return null; + } +} + +extension _IsExactly on TypeDeclaration { + /// Cheaper than checking types using a [StaticType]. + bool isExactly(String name, Uri library) => + identifier.name == name && this.library.uri == library; +} + +extension on Code { + /// Used for error messages. + String get debugString { + final buffer = StringBuffer(); + _writeDebugString(buffer); + return buffer.toString(); + } + + void _writeDebugString(StringBuffer buffer) { + for (final part in parts) { + switch (part) { + case Code(): + part._writeDebugString(buffer); + case Identifier(): + buffer.write(part.name); + default: + buffer.write(part); + } + } + } +} diff --git a/pkg/json/pubspec.yaml b/pkg/json/pubspec.yaml new file mode 100644 index 00000000000..07f7befb922 --- /dev/null +++ b/pkg/json/pubspec.yaml @@ -0,0 +1,13 @@ +name: json +description: > + A package which provides an experimental macro that encodes and decodes + user-defined Dart classes to JSON maps (maps of type `Map`). +repository: https://github.com/dart-lang/sdk/tree/main/pkg/json +version: 0.1.0-wip +environment: + sdk: ^3.4.0-0 +dependencies: + macros: ^0.1.0-0 +dev_dependencies: + lints: any + test: any diff --git a/pkg/json/test/json_codable_test.dart b/pkg/json/test/json_codable_test.dart new file mode 100644 index 00000000000..fef2d0709d1 --- /dev/null +++ b/pkg/json/test/json_codable_test.dart @@ -0,0 +1,57 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. +// +// SharedOptions=--enable-experiment=macros + +import 'package:json/json.dart'; +import 'package:test/test.dart'; + +void main() { + group('Can be encoded and decoded', () { + test('without nullable fields', () { + var userJson = { + 'name': 'John', + 'age': 35, + }; + + var user = User.fromJson(userJson); + expect(user.name, 'John'); + expect(user.age, 35); + expect(user.friends, null); + expect(userJson, equals(user.toJson())); + }); + + test('with nullable fields', () { + var userJson = { + 'name': 'John', + 'age': 35, + 'friends': [ + { + 'name': 'Jill', + 'age': 28, + }, + ], + }; + + var user = User.fromJson(userJson); + expect(user.name, 'John'); + expect(user.age, 35); + expect(user.friends?.length, 1); + + var friend = user.friends!.single; + expect(friend.name, 'Jill'); + expect(friend.age, 28); + expect(userJson, equals(user.toJson())); + }); + }); +} + +@JsonCodable() +class User { + String name; + + int age; + + List? friends; +}