Thinktecture.Runtime.Extensions 8.0.2

dotnet add package Thinktecture.Runtime.Extensions --version 8.0.2                
NuGet\Install-Package Thinktecture.Runtime.Extensions -Version 8.0.2                
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Thinktecture.Runtime.Extensions" Version="8.0.2" />                
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add Thinktecture.Runtime.Extensions --version 8.0.2                
#r "nuget: Thinktecture.Runtime.Extensions, 8.0.2"                
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install Thinktecture.Runtime.Extensions as a Cake Addin
#addin nuget:?package=Thinktecture.Runtime.Extensions&version=8.0.2

// Install Thinktecture.Runtime.Extensions as a Cake Tool
#tool nuget:?package=Thinktecture.Runtime.Extensions&version=8.0.2                

Build TestResults NuGet Downloads

Thinktecture.Runtime.Extensions
Thinktecture.Runtime.Extensions.EntityFrameworkCore6
Thinktecture.Runtime.Extensions.EntityFrameworkCore7
Thinktecture.Runtime.Extensions.EntityFrameworkCore8
Thinktecture.Runtime.Extensions.EntityFrameworkCore9
Thinktecture.Runtime.Extensions.Json
Thinktecture.Runtime.Extensions.Newtonsoft.Json
Thinktecture.Runtime.Extensions.MessagePack
Thinktecture.Runtime.Extensions.AspNetCore

This library provides some interfaces, classes, Roslyn Source Generators, Roslyn Analyzers and Roslyn CodeFixes for implementation of Smart Enums, Value Objects and Discriminated Unions.

Documentation

See wiki for more documentation.

Ideas and real-world use cases

Smart Enums:

Value objects:

Requirements

  • Version 8:
    • C# 11 (or higher) for generated code
    • SDK 8.0.400 (or higher) for building projects
  • Version 7:
    • C# 11 (or higher) for generated code
    • SDK 7.0.401 (or higher) for building projects

Migrations

Smart Enums

Install: Install-Package Thinktecture.Runtime.Extensions

Documentation: Smart Enums

Features:

Definition of a 2 Smart Enums without any custom properties and methods. All other features mentioned above are generated by the Roslyn Source Generators in the background.

// Smart Enum with a string as the underlying type
[SmartEnum<string>]
public sealed partial class ProductType
{
   public static readonly ProductType Groceries = new("Groceries");
   public static readonly ProductType Housewares = new("Housewares");
}

// Smart Enum with an int as the underlying type
[SmartEnum<int>]
public sealed partial class ProductGroup
{
   public static readonly ProductGroup Apple = new(1);
   public static readonly ProductGroup Orange = new(2);
}

// Smart Enum without identifier (keyless)
[SmartEnum]
public sealed partial class SalesCsvImporterType
{
   public static readonly SalesCsvImporterType Daily = new(articleIdIndex: 0, volumeIndex: 2);
   public static readonly SalesCsvImporterType Monthly = new(articleIdIndex: 2, volumeIndex: 0);

   public int ArticleIdIndex { get; }
   public int VolumeIndex { get; }
}

Behind the scenes a Roslyn Source Generator generates additional code. Some of the features that are now available are ...

// A private constructor which takes the key "Groceries" and additional members (if we had any)
[SmartEnum<string>]
public sealed partial class ProductType
{
   public static readonly ProductType Groceries = new("Groceries");
   ...

------------

// A property for iteration over all items
IReadOnlyList<ProductType> allTypes = ProductType.Items;

------------

// Getting the item with specific name, i.e. its key.
// Throws UnknownEnumIdentifierException if the provided key doesn't belong to any item
ProductType productType = ProductType.Get("Groceries");

// Alternatively, using an explicit cast (behaves the same as "Get")
ProductType productType = (ProductType)"Groceries";

------------

// the same as above but returns a bool instead of throwing an exception (dictionary-style)
bool found = ProductType.TryGet("Groceries", out ProductType? productType);

------------

// similar to TryGet but accepts `IFormatProvider` and returns a ValidationError instead of a boolean.
ValidationError? validationError = ProductType.Validate("Groceries", null, out ProductType? productType);

if (validationError is null)
{
    logger.Information("Product type {Type} found with Validate", productType);
}
else
{
    logger.Warning("Failed to fetch the product type with Validate. Validation error: {validationError}", validationError.ToString());
}

------------

// implicit conversion to the type of the key
string key = ProductType.Groceries; // "Groceries"

------------

// Equality comparison
bool equal = ProductType.Groceries.Equals(ProductType.Groceries);

------------

// Equality comparison with '==' and '!='
bool equal = ProductType.Groceries == ProductType.Groceries;
bool notEqual = ProductType.Groceries != ProductType.Groceries;

------------

// Hash code
int hashCode = ProductType.Groceries.GetHashCode();

------------

// 'ToString' implementation
string key = ProductType.Groceries.ToString(); // "Groceries"

------------

ILogger logger = ...;

// Switch-case (with "Action")
productType.Switch(groceries: () => logger.Information("Switch with Action: Groceries"),
                   housewares: () => logger.Information("Switch with Action: Housewares"));
                   
// Switch-case with parameter (Action<TParam>) to prevent closures
productType.Switch(logger,
                   groceries: static l => l.Information("Switch with Action: Groceries"),
                   housewares: static l => l.Information("Switch with Action: Housewares"));

// Switch case returning a value (Func<TResult>)
var returnValue = productType.Switch(groceries: static () => "Switch with Func<T>: Groceries",
                                     housewares: static () => "Switch with Func<T>: Housewares");

// Switch case with parameter returning a value (Func<TParam, TResult>) to prevent closures
var returnValue = productType.Switch(logger,
                                     groceries: static l => "Switch with Func<T>: Groceries",
                                     housewares: static l => "Switch with Func<T>: Housewares");

// Map an item to another instance
returnValue = productType.Map(groceries: "Map: Groceries",
                              housewares: "Map: Housewares");
------------

// Implements IParsable<T> which is especially helpful with minimal apis.
bool parsed = ProductType.TryParse("Groceries", null, out ProductType? parsedProductType);

------------

// Implements IFormattable if the underlyng type (like int) is an IFormattable itself.
var formatted = ProductGroup.Fruits.ToString("000", CultureInfo.InvariantCulture); // 001

------------

// Implements IComparable and IComparable<T> if the key member type (like int) is an IComparable itself.
var comparison = ProductGroup.Fruits.CompareTo(ProductGroup.Vegetables); // -1

// Implements comparison operators (<,<=,>,>=) if the underlyng type (like int) has comparison operators itself.
var isBigger = ProductGroup.Fruits > ProductGroup.Vegetables;       

Definition of a new Smart Enum with 1 custom property RequiresFoodVendorLicense and 1 method Do with different behaviors for different enum items.

[SmartEnum<string>]
public partial class ProductType
{
   public static readonly ProductType Groceries = new("Groceries",  requiresFoodVendorLicense: true);
   public static readonly ProductType Housewares = new HousewaresProductType();

   public bool RequiresFoodVendorLicense { get; }

   public virtual void Do()
   {
      // do default stuff
   }

   private sealed class HousewaresProductType : ProductType
   {
      public HousewaresProductType()
         : base("Housewares", requiresFoodVendorLicense: false)
      {
      }

      public override void Do()
      {
         // do special stuff
      }
   }
}

Value Objects

Install: Install-Package Thinktecture.Runtime.Extensions

Documentation: Value Objects

Features:

Simple Value Object

A simple value object has 1 field/property only, i.e., it is kind of wrapper for another (primitive) type. The main use case is to prevent creation of values/instances which are considered invalid according to some rules. In DDD (domain-driven design), working with primitive types, like string, directly is called primitive obsession and should be avoided.

Most simple value objects with a key member of type string and another one (which is a struct) with an int.

[ValueObject<string>]
public sealed partial class ProductName
{
}

[ValueObject<int>]
public readonly partial struct Amount
{
}

After the implementation of a value object, a Roslyn source generator kicks in and implements the rest. Following API is available from now on.

// Factory method for creation of new instances.
// Throws ValidationException if the validation fails (if we had any)
ProductName apple = ProductName.Create("Apple");

// Alternatively, using an explicit cast, which behaves the same way as calling "ProductName.Create"
ProductName apple = (ProductName)"Apple";

-----------

// The same as above but returns a bool instead of throwing an exception (dictionary-style)
bool created = ProductName.TryCreate("Chocolate", out ProductName? chocolate);

-----------

// Similar to TryCreate but returns a ValidationError instead of a boolean.
ValidationError? validationError = ProductName.Validate("Chocolate", null, out var chocolate);

if (validationError is null)
{
    logger.Information("Product name {Name} created", chocolate);
}
else
{
    logger.Warning("Failed to create product name. Validation result: {validationError}", validationError.ToString());
}

-----------

// Implicit conversion to the type of the key member
string valueOfTheProductName = apple; // "Apple"

-----------

// Equality comparison compares the key member using default comparer by default.
// Key members of type `string` are compared with 'StringComparer.OrdinalIgnoreCase' by default.
bool equal = apple.Equals(apple);

-----------

// Equality comparison operators: '==' and '!='
bool equal = apple == apple;
bool notEqual = apple != apple;

-----------

// Hash code: combined hash code of type and key member. 
// Strings are using 'StringComparer.OrdinalIgnoreCase' by default.
int hashCode = apple.GetHashCode();

-----------

// 'ToString' implementation return the string representation of the key member
string value = apple.ToString(); // "Apple"

------------

// Implements IParsable<T> which is especially helpful with minimal apis.
bool success = ProductName.TryParse("New product name", null, out var productName);

ProductName productName = ProductName.Parse("New product name", null);

------------

// Implements "IFormattable" if the key member is an "IFormattable".
Amount amount = Amount.Create(42);
string formattedValue = amount.ToString("000", CultureInfo.InvariantCulture); // "042"

------------

// Implements "IComparable<ProductName>" if the key member is an "IComparable",
// or if custom comparer is provided.
Amount amount = Amount.Create(1);
Amount otherAmount = Amount.Create(2);

int comparison = amount.CompareTo(otherAmount); // -1

------------

// Implements comparison operators (<,<=,>,>=) if the key member has comparison operators itself.
bool isBigger = amount > otherAmount;

// Implements comparison operators to compare the value object with an instance of key-member-type,
// if "ComparisonOperators" is set "OperatorsGeneration.DefaultWithKeyTypeOverloads"
bool isBigger = amount > 2;

------------

// Implements addition / subtraction / multiplication / division if the key member supports corresponding operators
Amount sum = amount + otherAmount;

// Implements operators that accept an instance of key-member-type,
// if the "OperatorsGeneration" is set "DefaultWithKeyTypeOverloads"
Amount sum = amount + 2;

------------

// Provides a static default value "Empty" (similar to "Guid.Empty"), if the value object is a struct
Amount defaultValue = Amount.Empty; // same as "Amount defaultValue = default;"

Complex Value Objects

A complex value object is an immutable class or a readonly struct with a ComplexValueObjectAttribute. Complex value object usually has multiple readonly fields/properties.

A simple example would be a Boundary with 2 properties. One property is the lower boundary and the other is the upper boundary. Yet again, we skip the validation at the moment.

[ComplexValueObject]
public sealed partial class Boundary
{
   public decimal Lower { get; }
   public decimal Upper { get; }
}

The rest is implemented by a Roslyn source generator, providing the following API:

// Factory method for creation of new instances.
// Throws ValidationException if the validation fails (if we had any)
Boundary boundary = Boundary.Create(lower: 1, upper: 2);

-----------

// the same as above but returns a bool instead of throwing an exception (dictionary-style)
bool created = Boundary.TryCreate(lower: 1, upper: 2, out Boundary? boundary);

-----------

// similar to TryCreate but returns a ValidationError instead of a boolean.
ValidationError? validationError = Boundary.Validate(lower: 1, upper: 2, out Boundary? boundary);

if (validationError is null)
{
    logger.Information("Boundary {Boundary} created", boundary);
}
else
{
    logger.Warning("Failed to create boundary. Validation result: {validationError}", validationError.ToString());
}

-----------

// Equality comparison compares the members using default or custom comparers.
// Strings are compared with 'StringComparer.OrdinalIgnoreCase' by default.
bool equal = boundary.Equals(boundary);

-----------

// Equality comparison with '==' and '!='
bool equal = boundary == boundary;
bool notEqual = boundary != boundary;

-----------

// Hash code of the members according default or custom comparers
int hashCode = boundary.GetHashCode();

-----------

// 'ToString' implementation
string value = boundary.ToString(); // "{ Lower = 1, Upper = 2 }"

Discriminated Unions

Install: Install-Package Thinktecture.Runtime.Extensions (requires version 8.x.x)

Documentation: Discriminated Unions

There are 2 types of unions: ad hoc union and "regular" unions

Ad hoc unions

Features:

  • Roslyn Analyzers and CodeFixes help the developers to implement the unions correctly
  • Provides proper implementation of Equals, GetHashCode, ToString and equality comparison via == and !=
  • Switch-Case/Map
  • Renaming of properties
  • Definition of nullable reference types

Definition of a basic union with 2 types using a class, a struct or ref struct:

// class
[Union<string, int>]
public partial class TextOrNumber;

// struct
[Union<string, int>]
public partial struct TextOrNumber;

// ref struct
[Union<string, int>]
public ref partial struct TextOrNumber;

// Up to 5 types
[Union<string, int, bool, Guid, char>]
public partial class MyUnion;

Behind the scenes a Roslyn Source Generator generates additional code. Some of the features that are now available are ...

// Implicit conversion from one of the defined generics.
TextOrNumber textOrNumberFromString = "text";
TextOrNumber textOrNumberFromInt = 42;

// Check the type of the value.
// By default, the properties are named using the name of the type (`String`, `Int32`)
bool isText = textOrNumberFromString.IsString;
bool isNumber = textOrNumberFromString.IsInt32;

// Getting the typed value.
// Throws "InvalidOperationException" if the current value doesn't match the calling property.
// By default, the properties are named using the name of the type (`String`, `Int32`)
string text = textOrNumberFromString.AsString;
int number = textOrNumberFromInt.AsInt32;

// Alternative approach is to use explicit cast.
// Behavior is identical to methods "As..."
string text = (string)textOrNumberFromString;
int number = (int)textOrNumberFromInt;

// Getting the value as object, i.e. untyped.
object value = textOrNumberFromString.Value;

// Implementation of Equals, GetHashCode and ToString
// PLEASE NOTE: Strings are compared using "StringComparison.OrdinalIgnoreCase" by default! (configurable)
bool equals = textOrNumberFromInt.Equals(textOrNumberFromString);
int hashCode = textOrNumberFromInt.GetHashCode();
string toString = textOrNumberFromInt.ToString();

// Equality comparison operators
bool equal = textOrNumberFromInt == textOrNumberFromString;
bool notEqual = textOrNumberFromInt != textOrNumberFromString;

There are multiple overloads of switch-cases: with Action, Func<T> and concrete values. To prevent closures, you can pass a value to method Switch, which is going to be passed to provided callback (Action/Func<T>).

By default, the names of the method arguments are named after the type specified by UnionAttribute<T1, T2>. Reserved C# keywords (like string) must string with @ (like @string, @default, etc.).

// With "Action"
textOrNumberFromString.Switch(@string: s => logger.Information("[Switch] String Action: {Text}", s),
                              int32: i => logger.Information("[Switch] Int Action: {Number}", i));

// With "Action". Logger is passed as additional parameter to prevent closures.
textOrNumberFromString.Switch(logger,
                              @string: static (l, s) => l.Information("[Switch] String Action with logger: {Text}", s),
                              int32: static (l, i) => l.Information("[Switch] Int Action with logger: {Number}", i));

// With "Func<T>"
var switchResponse = textOrNumberFromInt.Switch(@string: static s => $"[Switch] String Func: {s}",
                                                int32: static i => $"[Switch] Int Func: {i}");

// With "Func<T>" and additional argument to prevent closures.
var switchResponseWithContext = textOrNumberFromInt.Switch(123.45,
                                                           @string: static (value, s) => $"[Switch] String Func with value: {ctx} | {s}",
                                                           int32: static (value, i) => $"[Switch] Int Func with value: {ctx} | {i}");

// Use `Map` instead of `Switch` to return concrete values directly.
var mapResponse = textOrNumberFromString.Map(@string: "[Map] Mapped string",
                                             int32: "[Map] Mapped int");

Use T1Name/T2Name of the UnionAttribute to get more meaningful names.

[Union<string, int>(T1Name = "Text",
                    T2Name = "Number")]
public partial class TextOrNumber;

The properties and method arguments are renamed accordingly:

bool isText = textOrNumberFromString.IsText;
bool isNumber = textOrNumberFromString.IsNumber;

string text = textOrNumberFromString.AsText;
int number = textOrNumberFromInt.AsNumber;

textOrNumberFromString.Switch(text: s => logger.Information("[Switch] String Action: {Text}", s),
                              number: i => logger.Information("[Switch] Int Action: {Number}", i));

Regular unions

Features:

  • Roslyn Analyzers and CodeFixes help the developers to implement the unions correctly
  • Can be a class or record
  • Switch-Case/Map
  • Supports generics
  • Derived types can be simple classes or something complex like a value object.

Simple union using a class and a value object:

[Union]
public partial class Animal
{
   [ValueObject<string>]
   public partial class Dog : Animal;

   public sealed class Cat : Animal;
}

Similar example as above but with records:

[Union]
public partial record AnimalRecord
{
   public sealed record Dog(string Name) : AnimalRecord;

   public sealed record Cat(string Name) : AnimalRecord;
}

A union type (i.e. the base class) with a property:

[Union]
public partial class Animal
{
   public string Name { get; }

   private Animal(string name)
   {
      Name = name;
   }

   public sealed class Dog(string Name) : Animal(Name);

   public sealed class Cat(string Name) : Animal(Name);
}

A record with a generic:

[Union]
public partial record Result<T>
{
   public record Success(T Value) : Result<T>;

   public record Failure(string Error) : Result<T>;

   public static implicit operator Result<T>(T value) => new Success(value);
   public static implicit operator Result<T>(string error) => new Failure(error);
}

One of the main purposes for a regular union is their exhaustiveness, i.e. all member types are accounted for in a switch/map:

Animal animal = new Animal.Dog("Milo");

animal.Switch(dog: d => logger.Information("Dog: {Dog}", d),  
              cat: c => logger.Information("Cat: {Cat}", c));

var result = animal.Map(dog: "Dog",
                        cat: "Cat");

Use flags SwitchMethods and MapMethods for generation of SwitchPartially/MapPartially:

[Union(SwitchMethods = SwitchMapMethodsGeneration.DefaultWithPartialOverloads,
       MapMethods = SwitchMapMethodsGeneration.DefaultWithPartialOverloads)]
public partial record AnimalRecord
{
   public sealed record Dog(string Name) : AnimalRecord;

   public sealed record Cat(string Name) : AnimalRecord;
}

---------------------------
    
Animal animal = new Animal.Dog("Milo");

animal.SwitchPartially(@default: a => logger.Information("Default: {Animal}", a),
                       cat: c => logger.Information("Cat: {Cat}", c.Name));
Product Compatible and additional computed target framework versions.
.NET net7.0 is compatible.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (10)

Showing the top 5 NuGet packages that depend on Thinktecture.Runtime.Extensions:

Package Downloads
Thinktecture.Runtime.Extensions.Json

Adds better JSON support to components from Thinktecture.Runtime.Extensions when using System.Text.Json.

Thinktecture.Runtime.Extensions.EntityFrameworkCore

Extends Entity Framework Core to support some components from Thinktecture.Runtime.Extensions.

Thinktecture.Runtime.Extensions.AspNetCore

Adds better validation support to components from Thinktecture.Runtime.Extensions when using them along with ASP.NET Core.

Thinktecture.Runtime.Extensions.Newtonsoft.Json

Adds better JSON support to components from Thinktecture.Runtime.Extensions when using Newtonsoft.Json.

Thinktecture.Runtime.Extensions.EntityFrameworkCore8

Extends Entity Framework Core to support some components from Thinktecture.Runtime.Extensions.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
8.0.2 125 12/18/2024
8.0.1 229 12/11/2024
8.0.0 195 12/9/2024
8.0.0-beta13 138 11/21/2024
8.0.0-beta12 131 11/19/2024
8.0.0-beta11 130 11/15/2024
8.0.0-beta10 164 10/25/2024
8.0.0-beta09 190 10/23/2024
8.0.0-beta08 130 10/23/2024
8.0.0-beta07 143 9/27/2024
8.0.0-beta06 212 9/19/2024
8.0.0-beta05 188 9/9/2024
8.0.0-beta04 172 9/9/2024
8.0.0-beta03 165 9/8/2024
8.0.0-beta02 159 9/3/2024
8.0.0-beta01 173 8/13/2024
7.6.1 232 11/21/2024
7.6.0 3,561 11/13/2024
7.5.3 741 10/23/2024
7.5.2 5,580 9/27/2024
7.5.1 466 9/5/2024
7.5.0 1,944 7/9/2024
7.4.0 817 6/13/2024
7.3.0 570 4/18/2024
7.2.1 222 4/14/2024
7.2.0 23,684 1/31/2024
7.1.0 3,953 12/11/2023
7.0.0 275 12/10/2023
7.0.0-beta10 218 11/30/2023
7.0.0-beta09 207 11/26/2023
7.0.0-beta08 224 11/19/2023
7.0.0-beta07 208 11/17/2023
7.0.0-beta06 204 11/14/2023
7.0.0-beta05 172 11/14/2023
7.0.0-beta04 164 11/7/2023
7.0.0-beta03 187 10/19/2023
7.0.0-beta02 182 10/10/2023
7.0.0-beta01 175 10/8/2023
6.6.0 256 11/30/2023
6.5.2 308 11/17/2023
6.5.1 224 11/11/2023
6.5.0 221 11/11/2023
6.5.0-beta03 178 11/6/2023
6.5.0-beta02 171 11/5/2023
6.5.0-beta01 169 11/5/2023
6.4.1 218 11/9/2023
6.4.0 2,156 9/3/2023
6.3.0 364 8/31/2023
6.2.0 2,554 4/2/2023
6.1.0 407 3/22/2023
6.1.0-beta02 207 3/19/2023
6.1.0-beta01 209 3/19/2023
6.0.1 392 3/12/2023
6.0.0 378 3/9/2023
6.0.0-beta03 204 3/6/2023
6.0.0-beta02 205 3/5/2023
6.0.0-beta01 218 3/2/2023
5.2.0 1,594 2/6/2023
5.1.0 33,103 11/25/2022
5.0.1 3,514 10/6/2022
5.0.0 1,593 9/28/2022
5.0.0-beta03 301 9/7/2022
5.0.0-beta02 249 9/4/2022
5.0.0-beta01 678 9/4/2022
4.4.0-beta02 604 8/31/2022
4.4.0-beta01 583 8/30/2022
4.3.3 35,852 8/17/2022
4.3.3-beta01 641 8/17/2022
4.3.2 5,477 6/15/2022
4.3.2-beta02 679 6/15/2022
4.3.2-beta01 653 5/18/2022
4.3.1 12,232 5/17/2022
4.3.0 2,012 5/16/2022
4.2.0 3,127 4/24/2022
4.2.0-beta01 655 4/23/2022
4.1.3 2,008 4/13/2022
4.1.2 3,893 2/10/2022
4.1.1 2,001 2/6/2022
4.1.0 2,093 2/5/2022
4.0.1 37,986 1/16/2022
4.0.0 1,932 1/14/2022
4.0.0-beta03 669 12/17/2021
4.0.0-beta02 669 12/14/2021
4.0.0-beta01 668 12/13/2021
3.1.0 3,761 9/25/2021
3.0.0 1,877 7/10/2021
3.0.0-beta10 942 7/10/2021
3.0.0-beta09 824 4/1/2021
3.0.0-beta08 959 3/15/2021
3.0.0-beta07 826 3/14/2021
3.0.0-beta06 882 3/11/2021
3.0.0-beta05 1,368 2/12/2021
3.0.0-beta04 813 2/7/2021
3.0.0-beta03 818 1/30/2021
3.0.0-beta02 926 1/11/2021
3.0.0-beta01 882 1/10/2021
2.1.0-beta02 1,014 11/6/2020
2.1.0-beta01 333 11/6/2020
2.0.1 51,698 10/8/2019
2.0.0 1,396 10/8/2019
2.0.0-beta02 898 10/8/2019
2.0.0-beta01 1,008 9/30/2019
1.2.0 3,601 9/29/2019
1.2.0-beta04 1,384 8/29/2019
1.2.0-beta03 1,078 8/26/2019
1.2.0-beta02 1,045 8/12/2019
1.2.0-beta01 991 8/4/2019
1.1.0 5,155 5/31/2018
1.0.0 1,121 2/26/2018
0.1.0-beta5 832 2/23/2018
0.1.0-beta4 807 2/22/2018
0.1.0-beta3 828 2/19/2018
0.1.0-beta2 847 2/16/2018
0.1.0-beta1 890 2/11/2018