Generating code with Orsak.Myriad
Each side effect in Orsak comes with the same boilerplate: a provider interface exposing the effect, and a module with a
function per member, wrapping it with Effect.Create. Environments that implement several providers are boilerplate
too. Orsak.Myriad is a Myriad plugin that generates both at build time,
from attributes on your interfaces.
The generated code shown on this page is from the showcase sample, where it can be seen in full.
Setting up
Reference Orsak.Myriad and Myriad.Sdk, and add a file for the generated code after the file with your interfaces,
naming the latter as its MyriadFile:
|
The code is generated on each build. The generated file can be committed, so it can be read and reviewed like any other code.
The PackageDownload is needed because Myriad.Sdk runs the Myriad tool from the NuGet cache, and the download it
declares itself is in a props file, which NuGet doesn't import during restore. Without it, the build fails with
MSB3954: Failed to compute hash for file '...\myriad\1.1.0\tools\net8.0\any\Myriad.dll'. Its version must match
the version of Myriad.Sdk exactly. It can also go in a Directory.Build.props. Installing Myriad as a global tool
doesn't help, as global tools aren't installed into the NuGet cache.
Myriad 1.x requires FSharp.Core 9 or later in any project that loads Myriad.Core directly.
In a project with several TargetFrameworks, each target framework runs Myriad on the same files, in parallel. When
the generated code changes, they replace the same files at once, and the build can fail with Myriad exiting with
code 1, or with an error that a file is in use or doesn't exist. Generating once, for the first target framework,
before the target frameworks are built, leaves the files up to date for the others, which then don't write them:
|
BuildOnlySettings makes the references be resolved as they are for a build, as the build of that target framework
reuses them. Without it, the xml docs and pdbs of the references aren't copied to the output.
Effects
[<GenEffects>] on an interface describing a side effect generates its provider interface, and a module named after
the interface without its leading I, with a function for each member. Given
[<GenEffects>]
type IFace =
abstract CountBeans: int * string -> Result<int, string>
abstract PushButton: unit -> Task<unit>
the generated code is
type IFaceProvider =
abstract Effect: IFace
module Face =
let countBeans a b =
Effect.Create(fun (er: #IFaceProvider) -> er.Effect.CountBeans(a, b))
let pushButton () =
Effect.Create(fun (er: #IFaceProvider) -> er.Effect.PushButton())
Tupled parameters become curried ones, and the functions are named in camel case. As with hand-written effects, a
member returning a Result gives an effect failing with its error, and a member returning a Task, ValueTask or
Async gives an effect awaiting it. The file must open Orsak, as some of the Effect.Create overloads come from
it.
Environments
An environment implements the provider interfaces of all the effects a program uses. Rather than writing a type for
it, [<GenEnvironment>] on an interface that inherits the providers generates a module with a create function,
taking an anonymous record with an effect for each provider. Given
type IBeanCounter =
abstract Count: unit -> int
type IBeanCounterProvider =
abstract Effect: IBeanCounter
[<GenEnvironment>]
type IAppEnvironment =
inherit IBeanCounterProvider
inherit IGuidGenProvider
inherit IRandomProvider
the generated code is
module AppEnvironment =
let create
(effects:
{|
BeanCounter: IBeanCounter
GuidGenerator: Orsak.IGuidGenerator
RandomGenerator: Orsak.IRandomGenerator
|})
: IAppEnvironment =
{ new IAppEnvironment
interface IBeanCounterProvider with
member _.Effect = effects.BeanCounter
interface IGuidGenProvider with
member _.GuidGenerator = effects.GuidGenerator
interface IRandomProvider with
member _.Effect = effects.RandomGenerator
}
let run effects = Effect.run (create effects)
let runOrFail effects = Effect.runOrFail (create effects)
run and runOrFail create the environment and run an effect in it, and like the functions generated by
[<GenEffects>], they need the file to open Orsak:
let environment =
AppEnvironment.create {|
BeanCounter = { new IBeanCounter with member _.Count() = 3 }
GuidGenerator = GuidGenerator.defaultGen ()
RandomGenerator = DefaultRandom(System.Random 42)
|}
let! result = workflow () |> Effect.run environment
// or, in one go
let! result =
workflow ()
|> AppEnvironment.run {| BeanCounter = counter; GuidGenerator = guids; RandomGenerator = random |}
This suits tests well: each test builds the environment it needs in place, with the fakes it needs.
The generator only sees the syntax of your code, not its types, so it works by convention: inheriting IFooProvider
is taken to mean a property Effect: IFoo. Orsak's own providers (IGuidGenProvider, ITimeProvider,
ICacheProvider, ICancellationProvider and IRandomProvider) are known. Other providers are described in
myriad.toml, in a section named after the provider:
|
The generator fails the build with a message when the interface declares members of its own, inherits a generic
interface other than those of scopes, inherits a provider whose effect it can't work out, or inherits two
providers whose effects have the same name. A provider generated by [<GenEffects>] must be generated from an earlier
file than the environment inheriting it, unless both are in a file using inline generation.
Extending an environment
An environment can inherit another [<GenEnvironment>] interface of the same file, and add providers of its own,
such as the environment of a part of a program that needs more effects than the rest:
[<GenEnvironment>]
type IInlineEnvironment =
inherit IClockProvider
inherit IGreeterProvider
[<GenEnvironment>]
type ILocalizedEnvironment =
inherit IInlineEnvironment
inherit ILocaleProvider
Its create takes every effect, those it inherits included. As it inherits exactly one other environment, its module
also gets an extend function, which creates it from an instance of the environment it inherits, taking only the
effects it adds, and an Extend member on the inherited environment, calling extend:
module LocalizedEnvironment =
let extend (outer: IInlineEnvironment) (effect: ILocale) : ILocalizedEnvironment =
{ new ILocalizedEnvironment
interface IClockProvider with
member _.Effect = (outer :> IClockProvider).Effect
interface IGreeterProvider with
member _.Effect = (outer :> IGreeterProvider).Effect
interface ILocaleProvider with
member _.Effect = effect
}
let create (effects: {| Clock: IClock; Greeter: IGreeter; Locale: ILocale |}) : ILocalizedEnvironment = ...
[<AutoOpen>]
module InlineEnvironmentExtensions =
type IInlineEnvironment with
member this.Extend(effect: ILocale) = LocalizedEnvironment.extend this effect
The new environment forwards the inherited providers to the instance it extends, so they are shared with it:
let localized = environment.Extend { new ILocale with member _.Culture = "sv-SE" }
// or
let localized = LocalizedEnvironment.extend environment locale
An environment adding a single provider takes its effect, as above. One adding several takes an anonymous record of
them, as create does, such as {| Locale = locale; Clock = clock |}. When several environments extend the same
one, Extend is overloaded on what they take. Environments taking the same effects get no Extend member, as F#
couldn't tell them apart, but still get extend. An environment inheriting more than one other environment, adding no
providers, or inheriting the interfaces of scopes itself, only gets create.
Scopes
Environments can also implement the interfaces of scopes, such as a unit of work or a transaction, which effects run
in, from Orsak.Scoped. Inheriting ScopeProvider<'Scope> or CompletableScopeProvider<'Scope> adds a
field to the record taken by create, named after the scope type without its leading I and followed by Scope,
whose BeginScope the environment calls.
Inheriting IGrowsInto<'Scope, 'Inner> lets an effect run in a scope with Scope<'Scope>.Within or
CompletingScope<'Scope>.Within, in the environment 'Inner, grown from the environment the scoped effect runs in.
'Inner must be a [<GenEnvironment>] interface of the same file that extends the one growing into it, adding a
single provider whose effect is 'Scope. The generator implements Grow with the extend function of 'Inner, so
the environment in the scope is the outer one and the scope:
type IUnitOfWork =
inherit CompletableScope
abstract Record: string -> unit
type IUnitOfWorkProvider =
abstract Effect: IUnitOfWork
[<GenEnvironment>]
type IAppEnvironmentWithWork =
inherit IGreeterProvider
inherit CompletableScopeProvider<IUnitOfWork>
inherit IGrowsInto<IUnitOfWork, IWorkEnvironment>
and [<GenEnvironment>] IWorkEnvironment =
inherit IAppEnvironmentWithWork
inherit IUnitOfWorkProvider
As the two interfaces refer to each other, they are declared with and, or in a rec module or namespace. The
environment is created with a provider of the scope, and an effect is run in a scope from it:
let environment =
AppEnvironmentWithWork.create {|
Greeter = greeter
UnitOfWorkScope =
{ new CompletableScopeProvider<IUnitOfWork> with
member _.BeginScope() = ValueTask<_>(beginUnitOfWork ()) }
|}
let inUnitOfWork () =
eff {
let! greeting = greet "unit of work"
do! Effect.Create(fun (provider: #IUnitOfWorkProvider) -> provider.Effect.Record greeting)
return greeting
}
|> CompletingScope<IUnitOfWork>.Within
CompletingScope<IUnitOfWork>.Within begins a unit of work, runs the effect in an IWorkEnvironment grown from the
outer environment, and completes the unit of work if the effect succeeds. The scoped API is experimental, so using it
gives warning FS0057, which can be turned off with #nowarn "57".
Inline generation
With <MyriadInlineGeneration>true</MyriadInlineGeneration> on a file, the generated code is appended to the end of
the file instead of going in a file of its own. That keeps an environment next to the code using it, such as a test
environment in a test file:
module rec MyTests
open Orsak
open Orsak.Myriad
[<GenEnvironment>]
type ITestEnvironment =
inherit IButtonPusherProvider
inherit IGuidGenProvider
let ``pushes the button`` () = task {
let env = TestEnvironment.create {| ButtonPusher = fakePusher; GuidGenerator = GuidGenerator.defaultGen () |}
let! result = pushButton () |> Effect.run env
...
}
// TestEnvironment is appended here
-
The generated code comes last, so the module or namespace must be
recto use it above. Generated types, and the fully annotatedcreate, can then be used anywhere in the file. The other generated functions, includingrunand those generated by[<GenEffects>], have inferred types, which F# can't use above their declaration, so use them from later files. - The file must
open Orsakitself, as arecmodule doesn't allowopenafter other declarations. - The attributed interfaces must be in the last namespace or module of the file, where the code is appended.
-
Myriad 1.1 and later tell generators whether a file is inline. With Myriad 1.0, set
Inline = trueon the attributes instead, as in[<GenEnvironment(Inline = true)>].
Runners
[<GenRunner>] is an alternative to [<GenEnvironment>] for effects given as IProvide<'t>. On an interface
inheriting IProvide<'t> for each effect, it generates a record type implementing their providers, and a mkRunner
computation expression that assembles one from the effects:
[<GenRunner(Name = "BasicRunner")>]
type ITest =
inherit IProvide<IFace>
inherit IProvide<IRandomGenerator>
// using the generated code
let runner =
Runner.mkRunner {
fromEffect random
fromEffect face
}
Face.countBeans 1 "" |> Effect.run runner
The effects can be given in any order.
val int: value: 'T -> int (requires member op_Explicit)
--------------------
type int = int32
--------------------
type int<'Measure> = int
val string: value: 'T -> string
--------------------
type string = System.String
module Result from Microsoft.FSharp.Core
--------------------
type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
type Random = new: unit -> unit + 1 overload member GetHexString: stringLength: int * ?lowercase: bool -> string + 1 overload member GetItems<'T> : choices: ReadOnlySpan<'T> * length: int -> 'T array + 2 overloads member GetString: choices: ReadOnlySpan<char> * length: int -> string member Next: unit -> int + 2 overloads member NextBytes: buffer: byte array -> unit + 1 overload member NextDouble: unit -> float member NextInt64: unit -> int64 + 2 overloads member NextSingle: unit -> float32 member Shuffle<'T> : values: Span<'T> -> unit + 1 overload ...
<summary>Represents a pseudo-random number generator, which is an algorithm that produces a sequence of numbers that meet certain statistical requirements for randomness.</summary>
--------------------
System.Random() : System.Random
System.Random(Seed: int) : System.Random
type AutoOpenAttribute = inherit Attribute new: unit -> AutoOpenAttribute + 1 overload member Path: string
--------------------
new: unit -> AutoOpenAttribute
new: path: string -> AutoOpenAttribute
Orsak