Skip to content

Getting Started

AlexNav73 edited this page Jun 19, 2023 · 3 revisions

To get started, you need to add references to the libraries.

dotnet add package Navitski.Crystalized.Model

This is the main package with all core functionality.

To enable code generation, add another package:

dotnet add package Navitski.Crystalized.Model.Generators

After these two packages are installed, we can start writing a model description. Create a file called Model.model.json and add it to the project as an additional file like this:

<ItemGroup>
    <AdditionalFiles Include="Model.model.json" />
</ItemGroup>

Files with an extension *.model.json will be used to generate parts of the domain model called Shards. Think of them as sub-databases that can help to divide the domain model by functionality or provide a possibility to register new shards by plugins.

The content of the Model.model.json should include shards (sub-databases), entities with their properties, collections, and relations between entities. See the example below:

{
  "shards": [
    {
      "name": "MyApp",
      "entities": [
        {
          "name": "MyEntity",
          "properties": [
            {
              "name": "MyProperty",
              "type": "string",
              "defaultValue": "string.Empty"
            }
          ]
        }
      ],
      "collections": [
        {
          "name": "MyEntitiesCollection",
          "entityType": "MyEntity"
        }
      ],
      "relations": []
    }
  ]
}

For simplicity, we will use only one entity without any relations

Next, we need to create a domain model class (we could use one of the out-of-the-box implementations). This class must inherit from the DomainModel abstract class.

class MyModel : DomainModel
{
    public MyModel(IEnumerable<IModelShard> shards)
        : base(shards, new SyncScheduler())
    {
    }
}

The DomainModel base constructor takes a collection of shards (generated from Model.model.json) and an IScheduler implementation. For now, we will use SyncScheduler. It runs everything in the same thread, so it can be used for unit testing, for example.

Now, we can create our model:

var model = new MyModel(new[]
{
    // this model shard will be generated in the {my-project-namespace}.Model
    // and all entities will be generated in {my-project-namespace}.Model.Entities namespace
    new Model.MyAppModelShard()
});

When we create our first domain model, we can start implementing commands. Commands are used to group related modifications into a single change (CQRS pattern). All modifications must be made only inside a command.

class MyAddCommand : ICommand
{
    public void Execute(IModel model, CancellationToken token)
    {
        // for each shard two interfaces will be generated
        // IMyAppModelShard and IMutableMyAppModelShard
        // first one is a read-only interface and the second one
        // is for commands to allow modifications
        var shard = model.Shard<Model.IMutableMyAppModelShard>();
        shard.MyEntitiesCollection.Add(new() { MyProperty = "test" });
    }
}

This command will simply add a new entity to the collection with MyProperty set to "test".

The DomainModel base class contains overloads to run delegates as commands (see the Run method's overloads). So, you don't need to create a new class for each logic, but it is a good practice to create a new class if the command's logic is quite big.

Now, we need to execute the command, but before that, we need to subscribe to the domain model changes.

using (model.Subscribe(OnModelChanged))
{
    // command should be executed here ...
}

private static void OnModelChanged(Change<IModelChanges> change)
{
    // here we request changes related to the `MyApp` shard.
    // all this interfaces will be generated automatically for you
    if (change.Hunk.TryGetFrame<Model.IMyAppChangesFrame>(out var frame) && frame.HasChanges())
    {
        // IMyAppChangesFrame have the same structure as a IMyAppModelShard
        // but instead of ICollection type of MyEntitiesCollection it will
        // have ICollectionChangeSet which contains an action performed,
        // entity, old and new properties 
        foreach(var cc in frame.MyEntitiesCollection)
        {
            // here we just print what was changed in the domain model
            // if an entity hasn't changed - the MyEntitiesCollection will
            // not have a record for this entity
            Console.WriteLine($"Entity [{cc.Entity}] has been {cc.Action}ed.");
            Console.WriteLine($"   Old data: {cc.OldData}");
            Console.WriteLine($"   New data: {cc.NewData}");
        }
    }
}

It is also possible to subscribe to specific changes, such as model shard changes or collection/relation changes.

using (model.For<Model.IMyAppChangesFrame>().With(y => y.MyEntitiesCollection).Subscribe(OnModelChanged))
{
    // command should be executed here ...
}

private static void OnModelChanged(Change<ICollectionChangeSet<MyEntity, MyEntityProperties>> change)
{
    foreach(var c in change.Hunk)
    {
        // here we just print what was changed in the domain model
        // if an entity hasn't changed - the MyEntitiesCollection will
        // not have a record for this entity
        Console.WriteLine($"Entity [{c.Entity}] has been {c.Action}ed.");
        Console.WriteLine($"   Old data: {c.OldData}");
        Console.WriteLine($"   New data: {c.NewData}");
    }
}

Once we have everything set up, we can execute our command:

using (model.Subscribe(OnModelChanged))
{
    model.Run(new MyAddCommand());

    // Some overloads exists:
    // model.Run((IModel m) => { ... })
    // model.Run<Model.IMutableMyAppModelShard>((Model.IMutableMyAppModelShard shard, CancellationToken token) => { ... })
}

The result of the execution will be printed on the console:

Entity [MyEntity { Id = 262485ee-3426-4da6-96d5-f65976e7fe9e }] has been Added.
   Old data:
   New data: MyEntityProperties { MyProperty = test }

💾 Store data in the SQLite database

To store model data in the SQLite database, you can use the Navitski.Crystalized.Model.Storage.Sqlite package.

dotnet add package Navitski.Crystalized.Model.Storage.Sqlite

After adding this package, you can implement a save method for the model class:

// the "DomainModel" base class provides couple of methods to save and load data.
class MyModel : DomainModel
{
    // "IStorage" interface comes from "Navitski.Crystalized.Model" package
    // and everybody can implement it to store data in a suitable way.
    // Json and SQLite are supported out of the box.
    private readonly IStorage _storage;

    public MyModel(IEnumerable<IModelShard> shards)
        : base(shards, new SyncScheduler())
    {
        _storage = new SqliteStorage(
            // To create a SQLite storage, we need to provide a collection
            // of migrations (each database, store the latest version so it will
            // execute only those migrations, that are needed).
            Array.Empty<IMigration>(),
            // Also, we need to provide model shard storages which are generated
            // automatically for each shard. Storages know, how to store a specific
            // model shard.
            new[] { new Model.ExampleModelShardStorage() },
            // If you want to see, which SQL statements are executed, you can provide optional
            // logging method
            Console.WriteLine);
    }

    public void Save(string path)
    {
        // base Save method writes all the data to the file.
        // We don't want to corrupt the data so we delete the old
        // file before we save all the data again 
        if (File.Exists(path))
        {
            File.Delete(path);
        }

        // in this case, we call base Save method to save whole
        // data to the file. If we want to save only changes, then we need
        // to call another Save overload which accepts a collection of IModelChanges.
        Save(_storage, path);
    }
}

Now, you can save your model:

var model = new MyModel(new[] { /* ... */ });
using (model.Subscribe(OnModelChanged))
{
    // executing some commands ...
}
// save all data to the file
model.Save("test.db");

👷 Advanced

Usage of types not supported by the storage (e.g., SQLite)

SQLite doesn't have an enum type as a column type, so it can be stored as a string (TEXT) value. Therefore, generating an enum based on the *.model.json schema is not flexible because there could be different options for how the user would like to handle enums (stored as text or as an int). To give the user the possibility to have enum properties in their model, all generated property types are marked as partial. So, if an entity in *.model.json is described as:

{
    "name": "MyEntity",
    "properties": [
        {
            "name": "IntProperty",
            "type": "int"
        }
    ]
}

The model generator will generate the following code:

public sealed partial record MyEntityProperties : Properties
//            ^^^^^^^ - helps us to extend the properties type
{
    public int IntProperty { get; init; }

    public override MyEntityProperties ReadFrom(IPropertiesBag bag)
    {
        return new MyEntityProperties() { IntProperty = bag.Read<int>("IntProperty") };
    }

    public override void WriteTo(IPropertiesBag bag)
    {
        bag.Write("IntProperty", IntProperty);
    }
}

The generated record will have the partial modifier. In this case, you can add a new property with a custom data type that will map to the IntProperty and save it in the database as an int value. When it is loaded, you can read the enum value by reading the value from the IntProperty and casting it to the enum type. For example:

public enum MyEnum : int
{
    None = 0,
    First = 1,
    Second = 2,
}

partial record MyEntityProperties
{
    public MyEnum EnumProperty
    {
        get => (MyEnum)IntProperty;
        init => IntProperty = (int)value;
    }
}

This approach could be used to store arbitrary types by storing values as JSON in string (TEXT) columns, but it is preferable to describe all your data types as entities in *.model.json.