dart_mappable

repository·main·Indexed 42 days ago

https://github.com/schultek/dart_mappable

An advanced JSON serialization and data class library for Dart. It provides support for generics, inheritance, polymorphism, and multi-inheritance, featuring flexible customization hooks and configuration. Key capabilities include deep copy with dot-syntax, null-aware copyWith, and a MapperContainer system for managing serialization logic. It offers built-in case style transformations and can be configured at the class, library, or global level via build.yaml.

Tokens
22.9K
Snippets
82
Records
99
Agent score
53%

What's inside dart_mappable

  1. Compare dart_mappable with other serialization packages

    main

    The examples/package_comparison directory provides a side-by-side comparison of dart_mappable against other popular Dart serialization libraries to help you decide which tool fits your needs.

    Key comparisons include:

    • JSON Serialization: Compared against json_serializable and dart_json_mapper.
    • Data Classes: Compared against freezed.

    The comparisons cover several scenarios:

    • Basic: Setup and serialization of a model with a single primitive property.
    • Nested: Models containing nested properties of other models.
    • Generic: Models utilizing generic type parameters.
    • Renaming: Handling case-style changes or renaming specific fields.
  2. Serialize record fields in classes

    main

    By default, dart_mappable supports Dart record types. Any field in a @MappableClass that uses a record type will be automatically included in serialization.

    • Positional records are serialized using keys $1, $2, etc.
    • Named records are serialized using their field names as keys.
    @MappableClass()
    class Location with LocationMappable {
      
      const Location({
        required this.name,
        required this.coordinates, 
      });
    
      final (String, String) name;
      final ({double lat, double lng}) coordinates;
    }

    Resulting JSON structure:

    {
      "name": {
        "$1": "John",
        "$2": "Doe"
      },
      "coordinates": {
        "lat": 123,
        "lng": 456
      }
    }
  3. Perform typeless serialization and deserialization

    main

    You can serialize and deserialize objects without knowing their static types by using a special __type key in the JSON data. This is known as typeless serialization.

    Deserialization

    If the JSON contains a "__type": "ClassName" key, MapperContainer.globals.fromJson(json) will return the correct instance type even if the static type is dynamic.

    Serialization

    To include the __type indicator in your output, use MapperContainer.globals.toValue<T>(T value), toMap, or toJson. The __type property is added when the static type T differs from the runtime type of the value being encoded (e.g., when encoding a specific instance as dynamic).

    // Typeless Deserialization
    void main() {
      initializeMappers(); // Generated via @MappableLib
      
      var json = '{"__type": "Person", "name": "Bob"}';
      dynamic object = MapperContainer.globals.fromJson(json);
      
      assert(object.runtimeType == Person);
    }
    
    // Typeless Serialization (adding __type)
    void main() {
      initializeMappers();
      var myClassInstance = Person(name: 'Bob');
    
      // Adding '__type' by using dynamic as the static type
      var json = MapperContainer.globals.toJson<dynamic>(myClassInstance);
      
      // Resulting JSON will include: {"__type": "Person", ...}
    }
  4. Configure enum encoding modes with ValuesMode

    main

    You can control how enum values are encoded by setting the mode property in the @MappableEnum() annotation using ValuesMode:

    • ValuesMode.named (default): Encodes values to their names as Strings. This respects the caseStyle configuration if provided.
    • ValuesMode.indexed: Encodes values to their respective integer index as ints.
  5. Handle complex mapping logic via constructors

    main

    Instead of complex configuration, dart_mappable uses constructor arguments to handle advanced use cases. The library analyzes constructor arguments rather than class fields. This allows you to:

    • Provide default values: Use standard Dart default parameters.
    • Rename fields: Map a constructor argument to a different field name.
    • Ignore fields: Simply omit a field from a specific constructor.
    • Compute values: Assign values to fields based on constructor arguments.

    Warning for Computed Values: If you use a constructor to compute a field (e.g., combining firstName and lastName into name), you must provide matching getters for the original arguments (e.g., get firstName => ...) to ensure successful deserialization back to the original state.

    @MappableClass()
    class Person with PersonMappable {
      String name;
      int age;
        
      Person.base(this.name, this.age);
      
      Person.opt(this.name, [this.age = 18]);
    
      Person.renamed(this.name, int years) : age = years;
      
      Person.ignored(this.name);
     
      Person.computed(String firstName, String lastName, this.age) 
          : name = '$firstName $lastName';
    
      // Required for correct deserialization
      String get firstName => name.split(' ')[0];
      String get lastName => name.split(' ')[1];
    }
  6. Understand generated Mapper and Mappable objects

    main

    For every class annotated with @MappableClass(), two objects are generated:

    1. <ClassName>Mapper: Contains the actual implementation logic for serialization and deserialization. While modular, you rarely need to interact with this directly.
    2. <ClassName>Mappable: A mixin applied to your class that provides high-level utility methods:
      • toMap() and toJson(): For serialization.
      • copyWith(): To create modified copies of the model.
      • toString(): Overridden to print all model properties.
      • operator == and hashCode: Overridden for value-based equality.
  7. Configure dart_mappable at different levels

    main

    Configuration for dart_mappable can be applied at three levels of granularity. The hierarchy follows an override pattern where more specific configurations take precedence over more general ones:

    1. Class Level: Using the @MappableClass() annotation on a specific class.
    2. Library Level: Using the @MappableLib() annotation on a library.
    3. Global Level: Defining options in the build.yaml file.

    Precedence Order: Class Level > Library Level > Global Level.

  8. How MapperContainers work

    main

    A MapperContainer is the core component of dart_mappable responsible for looking up the correct mapper for a type and executing its serialization or mapping methods.

    When searching for a mapper, a container follows this hierarchy:

    1. It checks its own internal set of mappers.
    2. If no match is found, it refers to its linked containers.

    While most users interact with containers implicitly through generated mixins (like toString, ==, or hashCode) or standard JSON methods, they can be managed manually for advanced use cases.

  9. Handle Unknown or Null Discriminators

    main

    When working with polymorphic types, you may encounter JSON where the discriminator is either null or contains an unrecognized value. dart_mappable provides two ways to handle these cases:

    1. Handling null values: To map a null discriminator to a specific subclass, set the discriminatorValue of that subclass to null in the @MappableClass annotation.
    2. Handling unknown values (Fallback): To provide a fallback class for any unrecognized discriminator value, set the discriminatorValue of the fallback class to MappableClass.useAsDefault.

    Example

    // This sub-class will be chosen when the discriminator is null.
    @MappableClass(discriminatorValue: null)
    class NullAnimal extends Animal with NullAnimalMappable {
      NullAnimal(String name) : super(name);
    }
    
    // This sub-class will be chosen on any unknown discriminator.
    @MappableClass(discriminatorValue: MappableClass.useAsDefault)
    class DefaultAnimal extends Animal with DefaultAnimalMappable {
      String type;
      DefaultAnimal(String name, this.type) : super(name);
    }
    
    void main() {
      // Decodes to NullAnimal
      Animal animal1 = AnimalMapper.fromJson('{"name": "Scar", "type": null}');
      
      // Decodes to DefaultAnimal because "Bear" is unknown
      Animal animal2 = AnimalMapper.fromJson('{"name": "Balu", "type": "Bear"}');
      print(animal2.type); // Bear
    }
    @MappableClass(discriminatorValue: null)
    class NullAnimal extends Animal with NullAnimalMappable { ... }
    
    @MappableClass(discriminatorValue: MappableClass.useAsDefault)
    class DefaultAnimal extends Animal with DefaultAnimalMappable { ... }
  10. Use MappingHooks to intercept encoding and decoding

    main

    Hooks allow you to inspect and modify values during the encoding or decoding process for an entire class or a specific field.

    To implement a hook, extend the MappingHook class and override one or more of the following methods:

    • Object? beforeDecode(Object? value): Modify the raw input before decoding begins.
    • Object? afterDecode(Object? value): Modify the object after decoding is complete.
    • Object? beforeEncode(Object? value): Modify the object before it is encoded.
    • Object? afterEncode(Object? value): Modify the encoded value after encoding is complete.

    Important Behavior:

    • If beforeDecode returns an instance of the target type, the standard decoding logic is skipped.
    • If beforeEncode returns a value, the standard encoding logic is skipped.
    • Inheritance: Class-hooks are inherited by subclasses. When both superclass and subclass define class-hooks, they are applied in this order: super.beforeDecode $\rightarrow$ sub.beforeDecode $\rightarrow$ decode $\rightarrow$ sub.afterDecode $\rightarrow$ super.afterDecode.
    • Field-hooks: For field-level hooks, only the subclass hook is applied; they are not chained like class-hooks.
    class GameHook extends MappingHook {
      const GameHook();
    
      @override
      Object? beforeDecode(Object? value) {
        // logic here
        return value;
      }
    }
    
    @MappableClass(hook: GameHook())
    class Game {
      @MappableField(hook: PlayerHook())
      Player player;
    
      Game(this.player);
    }
  11. Use IterableMapper and MapMapper for special collections

    main

    For specialized implementations of Iterable or Map (like HashSet or HashMap), use the built-in IterableMapper and MapMapper classes. These require two arguments:

    1. A factory function: Converts a generic Iterable/Map to your specific implementation.
    2. A type factory: A function used to construct the generic type instance.

    Example of registering a HashSet and HashMap globally:

    void main() {
      MapperContainer.globals.use(IterableMapper<HashSet>(
        <T>(Iterable<T> i) => HashSet.of(i),
        <T>(f) => f<HashSet<T>>(),
      ));
    
      MapperContainer.globals.use(MapMapper<HashMap>(
        <K, V>(Map<K, V> m) => HashMap.of(m),
        <K, V>(f) => f<HashMap<K, V>>(),
      ));
    
      HashSet<String> brands = MapperContainer.globals.fromJson('["Toyota", "Audi", "Audi"]');
      print(brands); // Output: {Toyota, Audi}
    }
  12. Include Subclasses in Polymorphic Mapping

    main

    For polymorphism to work, all possible subclasses must be known to the mapper at compile-time. dart_mappable provides three ways to ensure subclasses are included:

    1. Same Library: If the subclass is defined in the same library as the parent class, it is automatically included.
    2. Explicit Inclusion: If a subclass is defined in a different library, you must explicitly list it in the includeSubClasses property of the @MappableClass annotation on the base class.
    3. Manual Initialization: You can manually register a subclass by calling MySubClassMapper.ensureInitialized(). This registers the subclass with its superclass.

    Example: Explicit Inclusion

    // If Hamster is in a different library
    @MappableClass(discriminatorKey: 'type', includeSubClasses: [Hamster])
    abstract class Animal with AnimalMappable {
      // ...
    }
    @MappableClass(discriminatorKey: 'type', includeSubClasses: [Hamster])
    abstract class Animal with AnimalMappable {
      // ...
    }