Giter VIP home page Giter VIP logo

modddels's Introduction

Build pub package

banner

Introducing MODDDELS: A powerful code generator that allows you to create self-validated models with compile-safe states, seamless failure handling, and easy unit-testing.

Motivation

Let's say you want to model a Student object. The Student has a name, an age, and an email. You may create your class/data-class this way :

class Student {
  Student({
    required this.name,
    required this.age,
    required this.email,
  });

  final String name;
  final int age;
  final String email;
}

You will then need to validate the name, age and email in various parts of your app. The Student model will be used in different places : for example, a widget that displays a student profile, or a function addStudent(Student newStudent), etc...

There are several problems with this approach :

  • Where should you validate the name, age and email ?
  • After validation is done, how can you distinguish between a valid Student instance and an invalid one ?
  • How can you ensure that valid Student instances are passed to functions or widgets that require them, and likewise, invalid instances are passed to those that specifically handle them?
  • How to handle an invalid Student differently based on what field is invalid and why it's invalid, all this in various parts of the app ?
  • If you have, for example, a widget that displays the email of a Student. How can you prevent any random string from being passed to the widget ?

All these problems (and more) can be resolved by using this package.

meme

The Modddels package offers a way to validate your models and deal with its different states (valid, invalid...) in a type-safe and compile-safe way.

  • 🔎 Self-Validation : Your models are validated upon creation. This way, you'll never deal with non-validated models.
  • 🧊 Sealed class : Your model is a sealed class (compatible with previous versions of Dart) which has union-cases for the different states it can be in (valid, invalid...). For example, Student would be a sealed class with union-cases ValidStudent and InvalidStudent.
  • 🚨 Failures handling : Your model, when invalid, holds the responsible failure(s), which you can access anytime anywhere.
  • 🔒 Value Equality and Immutability : All models are immutable and override operator == and hashCode for data equality.
  • 🧪 Unit-testing : Easily test your models and the validation logic.

NB : This package is NOT a data-class generator. It is meant to create models that are at the core of your app (If you use DDD or Clean architecture, those would be in the "domain" layer). Therefore, you are meant to create separate classes for things like json serialization, either manually or with tools like freezed and json_serializable (These classes are usually called "DataTransferObjects", "DTOs", or simply "models").

Documentation

Check out modddels.dev for comprehensive documentation, examples, VS code snippets and more.

Example

Here is a sneak peek at what you can do with modddels. You can find the full example in the example folder.

// In this example, [Username], [Age] and [User] are all "modddels".
void main() {
  final username =
      Username('dash_the_bird', availabilityService: MyAvailabilityService());

  final age = Age(20);

  final user = User.appUser(username: username, age: age);

  // Map over the different validation states of the user.
  user.map(
    valid: (valid) => greetUser(valid),
    invalidMid: (invalidMid) => redirectToProfileInfoScreen(invalidMid),
  );
}

// This method can only accept a [ValidUser], i.e a valid instance of [User].
void greetUser(ValidUser user) {
  final username = user.username.value;

  // Map over the different types of users ([AppUser] or [Moderator])
  final greeting = user.mapUser(
      appUser: (validAppUser) => 'Hey $username ! Enjoy our app.',
      moderator: (validModerator) =>
          'Hello $username ! Thanks for being a great moderator.');

  print(greeting);
}

// This method can only accept an [InvalidUserMid], i.e an invalid instance of
// [User] specifically because of a failure in the validationStep named "mid".
void redirectToProfileInfoScreen(InvalidUserMid user) {
  print('Redirecting to profile ...');

  // Checking if the `age` is invalid, and handling the only possible failure.
  user.age.mapOrNull(
    invalidValue: (invalidAgeValue) => invalidAgeValue.legalFailure.map(
      minor: (_) => print('You should be 18 to use our app.'),
    ),
  );

  // Checking if the `username` is invalid, and handling its possible failures.
  user.username.map(
    valid: (validUsername) {},
    invalidValue1: (invalidUsernameValue1) {
      // Handling failures of the "length" validation.
      if (invalidUsernameValue1.hasLengthFailure) {
        final errorMessage = invalidUsernameValue1.lengthFailure!.map(
          empty: (value) => 'Username can\'t be empty.',
          tooShort: (value) =>
              'Username must be at least ${value.minLength} characters long.',
          tooLong: (value) =>
              'Username must be less than ${value.maxLength} characters long.',
        );
        print(errorMessage);
      }

      // Handling failures of the "characters" validation.
      if (invalidUsernameValue1.hasCharactersFailure) {
        final errorMessage = invalidUsernameValue1.charactersFailure!.map(
          hasWhiteSpace: (hasWhiteSpace) =>
              'Username can\'t contain any whitespace character.',
          hasSpecialCharacters: (hasSpecialCharacters) =>
              'Username can only contain letters, numbers and dashes.',
        );
        print(errorMessage);
      }
    },
    invalidValue2: (invalidUsernameValue2) {
      // Handling failures of the "availability" validation. This validation is
      // part of a separate validationStep than the two previous ones for
      // optimization purposes.
      final errorMessage = invalidUsernameValue2.availabilityFailure.map(
        unavailable: (unavailable) => 'Username is already taken.',
      );

      print(errorMessage);
    },
  );
}

Contributing

If you're interested in contributing, feel free to submit pull requests, report bugs, or suggest new features by creating an issue on the GitHub repository.

For those who want to dive deeper into the source code, you can refer to the internal.md and architecture.md files to better understand the inner workings of the package.

Sponsoring

Your support matters ! Help me continue working on my projects by buying me a coffee. Thank you for your contribution !

Buy Me A Coffee

sponsors

modddels's People

Contributors

codingsoot avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

modddels's Issues

Upgrade `fpdart` to `0.6.0`

There is currently a dependency conflict with modddels_annotation_fpdart and latest fpdart: ^0.6.0 because it uses 0.5.0.

Default non-constant value for factory parameter

Hi (again) 👋

I have this ValueObject implementing a [0.0; 1.0] double inclusive range:

import "package:freezed_annotation/freezed_annotation.dart";
import "package:modddels_annotation_fpdart/modddels_annotation_fpdart.dart";

part 'percentage.modddel.dart';
part 'percentage.freezed.dart';

@Modddel(
  validationSteps: [
    ValidationStep([Validation("range", FailureType<RangeFailure>())]),
  ],
)
class Percentage extends SingleValueObject<InvalidPercentage, ValidPercentage>
    with _$Percentage {
  Percentage._();

  factory Percentage(double value) {
    return _$Percentage._create(
      value: value,
    );
  }

  @override
  Option<RangeFailure> validateRange(percentage) {
    if (percentage.value < 0.0) {
      return some(const RangeFailure.min());
    }
    if (percentage.value > 1.0) {
      return some(const RangeFailure.max());
    }
    return none();
  }
}

@freezed
class RangeFailure extends ValueFailure with _$RangeFailure {
  const factory RangeFailure.min() = _Min;
  const factory RangeFailure.max() = _Max;
}

Which is leveraged in this kind of modddel factories:

class MapLayer extends SimpleEntity<InvalidMapLayer, ValidMapLayer>
    with _$MapLayer {
  MapLayer._();

  factory MapLayer.raster(
      {required MapLayerPosition position,
      required URI uri,
      @validParam bool loaded = false,
      @validParam bool enabled = false,
      Percentage opacity = 1.0}) {
    return _$MapLayer._createRaster(
      position: position,
      uri: uri,
      loaded: loaded,
      enabled: enabled,
      opacity: opacity,
    );
  }

  // etc.
}

The opacity attribute has an error: A value of type 'double' can’t be assigned to a variable of type 'Percentage'.

Should I somehow use Percentage(1.0) instead of the 1.0 literal? In which case I get the (expected) following error: The default value of an optional parameter must be constant.

MultiEntity?

Hi! Thank you for creating this great library.

I wonder whether it’s possible, or could be made possible, creating a union of Entity modddels.

Currently the documentation only covers creating a union of ValueObject modddels:

class Weather extends MultiValueObject<InvalidWeather, ValidWeather> with _$Weather {

with the variants factories (Weather.sunny, Weather.rainy…) expected raw types (int, double, String, etc.) for the attributes.

I’d like to have the factories support Entity and/or ValueObject types, and the generated modddel behave as an Entity itself.

Rational

My use-case is as follow:

  • I’m modelling data layers for a map
    • there are multiple, specific layer types: raster, vector, geojson…
      • each with different attributes and behaviors
      • but with some shared attributes as well
  • "leaf" attributes are implemented as value objects: URI, VectorTilesStyle, GeoJSON, Percentage… each with its specific validation logic
  • for there are multiple layer types, and each type supports only so much of the possible "leaf" attributes, there’s need to be layer types variants: MapLayer ought to be the union of MapLayer.raster, MapLayer.vector, MapLayer.geojson… with some shared attributes (int position in the layer selector, URI uri to fetch the layer tiles from a remote source…), and some specific attributes depending on the variant (e.g., MapLayer.geojson requires GeoJSON geojsonText, whereas other variants don’t)

It could look like this (not using shared props, but could dry things up nicely):

// import "package:freezed_annotation/freezed_annotation.dart";
import "package:modddels_annotation_fpdart/modddels_annotation_fpdart.dart";

import "../../value_objects/geojson/geojson.dart";
import "../../value_objects/map_layer_position/map_layer_position.dart";
import "../../value_objects/vector_tiles_style/vector_tiles_style.dart";
import "../../value_objects/map_layer_type/map_layer_type.dart";
import "../../value_objects/percentage/percentage.dart";
import "../../value_objects/uri/uri.dart";

part "map_layer.modddel.dart";
// part "map_layer.freezed.dart";

@Modddel(
  validationSteps: [
    ValidationStep([
      contentValidation,
    ]),
    // [… some more validations …]
  ],
)
class MapLayer extends MultiEntity<InvalidMapLayer, ValidMapLayer>
    with _$MapLayer {
  MapLayer._();

  factory MapLayer.raster(
      {required MapLayerPosition position,
      required URI uri,
      @validParam bool loaded = false,
      @validParam bool enabled = false,
      Percentage opacity = 1}) {
    return _$MapLayer._createRaster(
      position: position,
      uri: uri,
    );
  }

  factory MapLayer.vector(
      {required MapLayerPosition position,
      required URI uri,
      @validParam bool loaded = false,
      @validParam bool enabled = false,
      Percentage opacity = 1,
      // MapLayel.vector’s specific attributes
      required VectorTilesStyle style,
      // [ … more specific attributes …]
    }) {
    return _$MapLayer._createVector(
      position: position,
      uri: uri,
      loaded: loaded,
      enabled: enabled,
      opacity: opacity,
      style: style,
    );
  }

 factory MapLayer.geojson(
      {required MapLayerPosition position,
      required URI uri,
      @validParam bool loaded = false,
      @validParam bool enabled = false,
      Percentage opacity = 1,
      // MapLayel.geojson’s specific attributes
      required GeoJSON geojsonText,
      // [ … more specific attributes …]
    }) {
    return _$MapLayer._createGeojson(
      position: position,
      uri: uri,
      loaded: loaded,
      enabled: enabled,
      opacity: opacity,
      geojsonText: geojsonText
    );
  }

  // [… validation methods …]
}

// [… ValueFailure implementation …]

Add support for fpdart 1.1.0

modddels current version 0.1.5 is not compatible with fpdart 1.0.0 . Please upgrade modddels to support fpdart 1.0.0

Directly create a case-modddel with preserved type

Freezed supports directly creating a union-case and preserving its type, for example :

@freezed
class MapLayer with _$MapLayer {
  const factory MapLayer.raster(int someParam) = Raster;

  const factory MapLayer.vector(int someParam) = Vector;

  const factory MapLayer.geojson(int someParam) = Geojson;
}

// then, instead of calling `MapLayer.raster(1)` :
final Raster raster = Raster(1);

In modddels, this is not supported (for now). If needed, you can cast the case-modddel directly after creating it, like this :

final raster = MapLayer.raster(
  //...
) as Raster;

Although it's pretty safe, it's not very clean so I'll look into implementing a better alternative.

Originally posted by @CodingSoot in #8 (comment)

Add support for async validations

Right now, all validation methods are synchronous, and there is no way to make them async.

@Modddel(
  // ...
)
class Age extends SingleValueObject<InvalidAge, ValidAge> with _$Age {
  Age._();

  factory Age(int value) {
    return _$Age._create(
      value: value,
    );
  }

  // ⚠️ This can't be async
  @override
  Option<AgeLegalFailure> validateLegal(age) {
    if (age.value < 18) {
      return some(const AgeLegalFailure.minor());
    }
    return none();
  }
}

Given that factory constructors can't be async, I only see three options :

Option 1 : Use the builder pattern

We generate a "Builder" that you should instantiate and then call an async method (for example runValidations) to get your modddel instance.

final builder = AgeBuilder(20);

final age = await builder.runValidations();
  • Advantages :
    • Ability to lazily validate a modddel (Although this can be easily accomplished by the user using a package like lazy_evaluation or any other way).
  • Disadvantages :
    • Boilerplate code +++
    • Different syntax for instantiating a modddel with sync validations versus async validations

Option 2 : Initialize the modddel manually

We add an init method to the modddel that the user should call, and its returned value is the initialized modddel.

final age = await Age(20).init();
  • Advantages :
    • Less boilerplate code
  • Disadvantages :
    • ⚠️ RUNTIME ERRORS : If the user forgets to call init or accidentally uses the instance created with the factory constructor (Age(20)) , there will be runtime errors that may be hard to debug
    • Again : different syntax for instantiating a sync modddel versus async

Option 3 : Ditch the factory constructors for async methods

We replace the factory constructors with static methods :

  // Instead of a factory constructor : 
  factory Age(int value) {
    return _$Age._create(
      value: value,
    );
  }

  // We use a static method
  static Age create(int value) {
     // To accompany this change, this static method is no longer private
    // (it never needed to be private since the mixin `_$Age` is private)
    return _$Age.create(
      value: value,
    );
  }

The name of the method should be create for solo modddels, and create{UnionCaseName} for unions (ex for a Weather union : createSunny, createRainy...).

Then, these static methods can easily be made async :

  // Change the return type to a future, and optionally add the async keyword if needed 
  static Future<Age> create(int value) {
    return _$Age.create(
      value: value,
    );
  }

And then for making an instance of the modddel :

// If sync : 
final age = Age.create(20);

// If async :
final age = await Age.create(20);
  • Advantages :
    • Same syntax for instantiating sync and async modddels (you only add await)
    • Solves dart-lang/sdk#9 , because static methods have a return type
    • Devs can freely use factory constructors for other purposes
    • The forwarding method (_$Age.create) has the same name as the static method
  • Disadvantages :
    • BREAKING CHANGE
    • Static methods can't be unnamed like factory constructors (Age.create(20) vs Age(20)).
    • Static methods don't preserve generic type information (so if you have generics, you need to forward them in a verbose way)
    • Usually, you create an instance with a constructor, and not a static method, so this might be a little less elegant

SSealed ModddelParams doesn't support default values of factory parameters

If you have a union of modddels where a factory parameter has a default value, for example :

@Modddel(
  // ...
  generateTestClasses: true,
)
class MyClass extends SingleValueObject<InvalidMyClass, ValidMyClass>
    with _$MyClass {
  MyClass._();

  factory MyClass.first({int value = 0}) {
    return _$MyClass._createFirst(value: value);
  }

 // ...
}

The generated ModddelParams class looks like this :

abstract class MyClassParams extends ModddelParams<MyClass> {
  // ...
  
  // Syntax error
  factory MyClassParams.first({int value}) {
    return _FirstParams(value: value);
  }
}

The default value should be added : int value = 0.

Add support for generics

Background

When it comes to implementing generics, we should consider two main issues : param transformations and type constraints.

In general, a "param transformation" is a change in the type of a parameter. There are three kinds of param transformations :

  • Valid param transformations : Happens on the member parameters of an Entity, after the contentValidation step. Example : Age age becomes ValidAge age.
  • Non-null param transformations : Happens on a nullable member parameter of a ValueObject or Entity, when it's annotated with @NullFailure. Example : Age? age becomes Age age.
  • Null param transformations : Happens on a nullable member parameter (which should be an InvalidModddel) of a SimpleEntity, when it's annotated with @invalidParam, after the contentValidation step. Example : InvalidAge? age becomes Null age.

Additionally, we should keep in mind that :

  • Member parameters of entities must be modddels
  • For Iterable/Iterable2 entities, the member parameter must match the TypeTemplate

Solution

With all this in mind, let's see how we can implement generics :

  • ✅ For dependency parameters, there's no problem
  • ✅ For member parameters that don't have any param transformation, there's no problem. These would be :
    • The member parameters of ValueObjects that are not annotated with @NullFailure
    • The member parameters of Entities that are annotated with @validParam and aren't annotated with @NullFailure
  • 🔴 For member parameters that have a valid param transformation : The type parameter must extend BaseModddel. However, there's no way to convert the type parameter to its valid version. We can either disable the valid param transformation for type parameters, or disallow type parameters in this case.
  • ✅ For member parameters that have a non-null param transformation : The type parameter must be non-nullable (ex : T extends Object), and the type of the member parameter must be nullable (T? param).
  • ✅ For member parameters that have a null param transformation : The type parameter must extend InvalidModddel, and there's no restriction on whether its nullable or not as long as the resulting type of the member parameter is nullable.

Another thing to consider is how these rules apply to the types of shared properties, knowing that a SharedProp has options to disable certain param transformations.

Provide `node` in `InvalidGenerationSourceError` when possible

In the current implementation, when raising an InvalidGenerationSourceError due to an annotation error, we are passing the annotated element to the InvalidGenerationSourceError.element field to show where the issue is, which isn't very precise.

The latest source_gen version added a node argument to InvalidGenerationSourceError to allow finding the source location from an AstNode over an Element.

As a result, we should be using the node argument whenever it would improve error reporting, and such AST node is available.

Union of Modddels: how to properly handle narrowed types?

Sorry for the continuous streak of questions, I got hyped!

Using my repo from #6 as context, I now face a rather annoying issue regarding (I guess) ahead of compilation vs runtime types, when a modddel is exposed through a Riverpod provider cased Modddels in a union (in my case, the union modddels is exposed through a Riverpod AsyncNotifier, which wraps an AsyncValue<MapLayer> with MapLayer being the union of modddels: MapLayer.raster, MapLayer.vector and MapLayer.geojson).

My guess is that I’m not providing Riverpod with enough insight, but I did not manage to discover how. edit: wrong guessing 🙈


Here it is, explained by comments:

// [… imports … ]

class Map extends ConsumerWidget {
  const Map({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final logger = ref.read(loggerProvider);

    final provider = mapLayerControllerProvider(layerName: "some_raster_layer");

    final rasterLayer = ref.watch(provider);

    return rasterLayer.when(data: (layer) {
      logger.d(layer);
      // ==> layer exposed as a ValidRaster, which is expected, for it was created
      // with MapLayer.raster(…) with valid attributes values;
      // but this fails because Dart thinks it’s a MapLayer, which has no position attribute:
      // logger.i(layer.position.value);
      // ==> Hence doing a cumbersome type casting:
      final l = layer as ValidRaster;
      logger.i(l.position.value); // works fine now!
      return Text("OK");
    }, error: (e, _) {
      logger.d(e);
      return Text("KO");
    }, loading: () {
      logger.d("Loading…");
      return Text("Loading…");
    });
  }
}

Of course, the problem stays the same if I, say, mapValidity on layer within the widget (rather than within the controller/provider):

    return rasterLayer.when(data: (layer) {
      return layer.mapValidity(valid: (layer) {
        logger.d(layer.runtimeType); // ValidRaster
        // logger.i(layer.position.value); // Error: The getter 'position' isn’t defined for the type 'ValidMapLayer'.
        logger.i((layer as ValidRaster).position.value); // works fine, for ValidRaster extends MapLayer
                                                         // with Raster, which has the attributes
        return Text("Valid layer $layer");
      }, invalid: (layer) {
        logger.d(layer.runtimeType); // InvalidRaster
        logger.d(layer.failures); // works fine; layer is seen as an InvalidMapLayer, though
        return Text("Invalid layer $layer");
      });
    }, error: (e, _) {
      logger.d(e);
      return Text("KO");
    }, loading: () {
      logger.d("Loading…");
      return Text("Loading…");
    });

Disallow adding non-factory constructors

Hi,

I have some code available on GitHub showcasing a weird issue.

Context: refactoring a working app to leverage DDD, with proper SoC between:

  • features (what the end-user does)
    • which leverage widgets (what the end-user sees and can interact with)
      • which leverage widgets controllers (indirection sublayer in the Presentation layer)
        • which (in this POC) are Riverpod async providers for modddels (in the Domain layer), with eventually remote data being fetched through a DataMapper layer

I got a union of modddels (source) named MapLayer, with three unioned "variants": MapLayer.raster, MapLayer.vector and MapLayer.geojson. I applied your insights from #4 and got it working… until I try to get a modddel instance.

On this specific call I get the following exception: UnsupportedError (Unsupported operation: It seems like you constructed your class using MyClass._(), or you tried to access an instance member from within the annotated class.)

My understanding is that it would only ever be raised only if trying to call MapLayer._() or an instance method such as map on a raw MapLayer instance, but it’s not what I’m doing here: I’m instantiating through the factory MapLayer.raster.

What am I missing?

Thank you!

ValueObject for enums: how to properly validate?

Hi 👋,

Continuing on implementing my map project, let’s say I have this enum representing a map layer’s "type"/kind: enum Type { raster, vector, geojson }.

If I wish to have some entity’s attribute be of that kind, as far as I understand (and based on trial/error), it cannot be simply typed as a Type but rather has to be wrapped in a value object — such as this one, although it seems "useless" (see inlined comments as to why):

import "package:freezed_annotation/freezed_annotation.dart";

part 'map_layer_type.modddel.dart';
part 'map_layer_type.freezed.dart';

enum Type { raster, vector, geojson }

@Modddel(
  validationSteps: [
    // It seems like at least one validation step MUST be implemented.
    ValidationStep([Validation("allowed", FailureType<EnumFailure>())]),
  ],
)
class MapLayerType
    extends SingleValueObject<InvalidMapLayerType, ValidMapLayerType>
    with _$MapLayerType {
  MapLayerType._();

  // For value is typed as Type, the "allowed" validation is useless.
  // Code will *not* compile if trying to instanciate a ValueObject with anything
  // but a (valid by design) Type enum value.
  factory MapLayerType(Type value) {
    return _$MapLayerType._create(
      value: value,
    );
  }

  @override
  Option<EnumFailure> validateAllowed(mapLayerType) {
    if (mapLayerType.value is! Type) { // Warning: "Unecessary type check; the result is always 'false'. Try correcting the type check, or removing the type check.
      return some(const EnumFailure.allowed());
    }
    // I guess I could get rid of the type checking then, and simply return none(), period.
    return none();
  }
}

@freezed
class EnumFailure extends ValueFailure with _$EnumFailure {
  const factory EnumFailure.allowed() = _Allowed;
}

Is there a better way?

I feel like enums being sealed by design, having to come up with a dedicated value object is useless boilerplate, but maybe I’m missing something?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.