Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: StreamId #100

Merged
merged 9 commits into from
Aug 8, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/FsCodec/FsCodec.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<ItemGroup>
<Compile Include="FsCodec.fs" />
<Compile Include="Codec.fs" />
<Compile Include="StreamId.fs" />
<Compile Include="StreamName.fs" />
</ItemGroup>

Expand Down
100 changes: 100 additions & 0 deletions src/FsCodec/StreamId.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Represents the second half of a canonical StreamName, i.e., the streamId in "{categoryName}-{streamId}"
// Low-level helpers for composing and rendering StreamId values; prefer the ones in the Equinox namespace
namespace FsCodec

open FSharp.UMX

/// Represents the second half of a canonical StreamName, i.e., the streamId in "{categoryName}-{streamId}"
type StreamId = string<streamId>
and [<Measure>] streamId

/// Helpers for composing and rendering StreamId values
module StreamId =

/// Any string can be a StreamId; parse/dec/Elements.split will judge whether it adheres to a valid form
let create: string -> StreamId = UMX.tag

/// Render as a string for external use
let toString: StreamId -> string = UMX.untag

module Element =

let [<Literal>] Separator = '_' // separates {subId1_subId2_..._subIdN}

/// Throws if a candidate id element includes a '_', is null, or is empty
let inline validate (raw: string) =
if raw |> System.String.IsNullOrEmpty then invalidArg "raw" "Element must not be null or empty"
if raw.IndexOf Separator <> -1 then invalidArg "raw" "Element may not contain embedded '_' symbols"

module Elements =

let [<Literal>] Separator = "_"

/// Create a StreamId, trusting the input to be well-formed (see the gen* functions for composing with validation)
let trust (raw: string): StreamId = UMX.tag raw

/// Creates from exactly one fragment. Throws if the fragment embeds a `_`, are `null`, or is empty
let parseExactlyOne (rawFragment: string): StreamId =
Element.validate rawFragment
trust rawFragment

/// Combines streamId fragments. Throws if any of the fragments embed a `_`, are `null`, or are empty
let compose (rawFragments: string[]): StreamId =
rawFragments |> Array.iter Element.validate
System.String.Join(Separator, rawFragments) |> trust

let private separator = [| Element.Separator |]
/// Splits a streamId into its constituent fragments
let split (x: StreamId): string[] =
(toString x).Split separator
/// Splits a streamId into its constituent fragments
let (|Split|): StreamId -> string[] = split

/// Validates and extracts the StreamId into a single fragment value
/// Throws if the item embeds a `_`, is `null`, or is empty
let parseExactlyOne (x: StreamId): string = toString x |> Elements.parseExactlyOne |> toString
/// Validates and extracts the StreamId into a single fragment value
/// Throws if the item embeds a `_`, is `null`, or is empty
let (|Parse1|) (x: StreamId): string = parseExactlyOne x

/// Splits a StreamId into the specified number of fragments.
/// Throws if the value does not adhere to the expected fragment count.
let parse count (x: StreamId): string[] =
let xs = Elements.split x
if xs.Length <> count then
invalidArg "x" (sprintf "StreamId '{%s}' must have {%d} elements, but had {%d}." (toString x) count xs.Length)
xs
/// Splits a StreamId into an expected number of fragments.
/// Throws if the value does not adhere to the expected fragment count.
let (|Parse|) count: StreamId -> string[] = parse count

/// Helpers to generate StreamIds given a number of individual id to string mapper functions
[<AbstractClass; Sealed>]
type Gen private () =

/// Generate a StreamId from a single application-level id, given a rendering function that maps to a non empty fragment without embedded `_` chars
static member Map(f: 'a -> string) = System.Func<'a, StreamId>(fun id -> f id |> Elements.parseExactlyOne)
/// Generate a StreamId from a tuple of application-level ids, given 2 rendering functions that map to a non empty fragment without embedded `_` chars
static member Map(f, f2) = System.Func<'a, 'b, StreamId>(fun id1 id2 -> Elements.compose [| f id1; f2 id2 |])
/// Generate a StreamId from a triple of application-level ids, given 3 rendering functions that map to a non empty fragment without embedded `_` chars
static member Map(f1, f2, f3) = System.Func<'a, 'b, 'c, StreamId>(fun id1 id2 id3 -> Elements.compose [| f1 id1; f2 id2; f3 id3 |])
/// Generate a StreamId from a 4-tuple of application-level ids, given 4 rendering functions that map to a non empty fragment without embedded `_` chars
static member Map(f1, f2, f3, f4) = System.Func<'a, 'b, 'c, 'd, StreamId>(fun id1 id2 id3 id4 -> Elements.compose [| f1 id1; f2 id2; f3 id3; f4 id4 |])

/// Generate a StreamId from a single application-level id, given a rendering function that maps to a non empty fragment without embedded `_` chars
let gen (f: 'a -> string): 'a -> StreamId = Gen.Map(f).Invoke
/// Generate a StreamId from a tuple of application-level ids, given two rendering functions that map to a non empty fragment without embedded `_` chars
let gen2 f1 f2: 'a * 'b -> StreamId = Gen.Map(f1, f2).Invoke
/// Generate a StreamId from a triple of application-level ids, given three rendering functions that map to a non empty fragment without embedded `_` chars
let gen3 f1 f2 f3: 'a * 'b * 'c -> StreamId = Gen.Map(f1, f2, f3).Invoke
/// Generate a StreamId from a 4-tuple of application-level ids, given four rendering functions that map to a non empty fragment without embedded `_` chars
let gen4 f1 f2 f3 f4: 'a * 'b * 'c * 'd -> StreamId = Gen.Map(f1, f2, f3, f4).Invoke

/// Extracts a single fragment from the StreamId. Throws if the value is composed of more than one item.
let dec f (x: StreamId) = parse 1 x |> Array.exactlyOne |> f
/// Extracts 2 fragments from the StreamId. Throws if the value does not adhere to that expected form.
let dec2 f1 f2 (x: StreamId) = let xs = parse 2 x in struct (f1 xs[0], f2 xs[1])
/// Extracts 3 fragments from the StreamId. Throws if the value does not adhere to that expected form.
let dec3 f1 f2 f3 (x: StreamId) = let xs = parse 3 x in struct (f1 xs[0], f2 xs[1], f3 xs[2])
/// Extracts 4 fragments from the StreamId. Throws if the value does not adhere to that expected form.
let dec4 f1 f2 f3 f4 (x: StreamId) = let xs = parse 4 x in struct (f1 xs[0], f2 xs[1], f3 xs[2], f4 xs[3])
144 changes: 58 additions & 86 deletions src/FsCodec/StreamName.fs
Original file line number Diff line number Diff line change
Expand Up @@ -15,116 +15,88 @@ and [<Measure>] streamName
/// 2. {category}-{id1}_{id2}_...{idN}
module StreamName =

/// Strip off the strong typing (It's recommended to pattern match as below in the general case)
let inline toString (x: StreamName) : string =
UMX.untag x

// Validation helpers, etc.
module Internal =
module Category =

/// Throws if a candidate category includes a '-', is null, or is empty
let inline validateCategory (rawCategory : string) =
if rawCategory |> System.String.IsNullOrEmpty then invalidArg "rawCategory" "may not be null or empty"
if rawCategory.IndexOf '-' <> -1 then invalidArg "rawCategory" "may not contain embedded '-' symbols"
let [<Literal>] Separator = '_' // separates {category}-{streamId}
let [<Literal>] SeparatorStr = "_"
let internal separator = [| Separator |]

/// Throws if a candidate id element includes a '_', is null, or is empty
let inline validateElement (rawElement : string) =
if rawElement |> System.String.IsNullOrEmpty then invalidArg "rawElement" "may not contain null or empty components"
if rawElement.IndexOf '_' <> -1 then invalidArg "rawElement" "may not contain embedded '_' symbols"
/// Throws if a candidate category includes a '-', is null, or is empty
let inline validate (raw: string) =
if raw |> System.String.IsNullOrEmpty then invalidArg "raw" "Category must not be null or empty"
if raw.IndexOf Separator <> -1 then invalidArg "raw" "Category must not contain embedded '-' symbols"

/// Low level helper used to gate ingestion from a canonical form, guarding against malformed streamNames
let inline ofCategoryAndStreamId struct (category : string, streamId : string) : string =
validateCategory category
System.String.Concat(category, "-", streamId)
/// Extracts the category portion of the StreamName
let ofStreamName (x: StreamName) =
let raw = toString x
raw.Substring(0, raw.IndexOf Separator)

/// Generates a StreamId from name elements; elements are separated from each other by '_'
let createStreamId (elements : string seq) : string =
for x in elements do validateElement x
System.String.Join("_", elements)
module Internal =

(* Creators: Building from constituent parts
Guards against malformed category, streamId and/or streamId elements with exceptions *)
/// Create a StreamName, trusting the input to be well-formed
let trust (raw: string): StreamName = UMX.tag raw

/// Recommended way to specify a stream identifier; a category identifier and an streamId representing the aggregate's identity
/// category is separated from id by `-`
let create (category : string) (streamId : string) : StreamName =
Internal.ofCategoryAndStreamId (category, streamId) |> UMX.tag
/// <summary>Attempts to split a Stream Name in the form <c>{category}-{streamId}</c> into its two elements.
/// The <c>{streamId}</c> segment is permitted to include embedded '-' (dash) characters
/// Returns <c>None</c> if it does not adhere to that form.</summary>
let tryParse (raw: string): struct (string * StreamId) voption =
match raw.Split(Category.separator, 2) with
| [| cat; id |] -> ValueSome struct (cat, StreamId.Elements.trust id)
| _ -> ValueNone

/// Composes a StreamName from a category and > 1 name elements.
/// category is separated from the streamId by '-'; elements are separated from each other by '_'
let compose (category : string) (streamIdElements : string seq) : StreamName =
create category (Internal.createStreamId streamIdElements)
/// <summary>Attempts to split a Stream Name in the form <c>{category}-{streamId}</c> into its two elements.
/// The <c>{streamId}</c> segment is permitted to include embedded '-' (dash) characters
/// Yields <c>NotCategorized</c> if it does not adhere to that form.</summary>
let (|Categorized|NotCategorized|) (raw: string): Choice<struct (string * StreamId), unit> =
match tryParse raw with
| ValueSome catAndId -> Categorized catAndId
| ValueNone -> NotCategorized

(* Parsing: Raw Stream name Validation functions/pattern that handle malformed cases without throwing *)
let private throwInvalid raw = invalidArg "raw" (sprintf "Stream Name '%s' must contain a '-' separator" raw)

/// <summary>Validates and maps a trusted Stream Name consisting of a Category and an Id separated by a '-' (dash).<br/>
/// <summary>Validates and maps a Stream Name consisting of a Category and an StreamId separated by a '-' (dash).<br/>
/// Throws <c>InvalidArgumentException</c> if it does not adhere to that form.</summary>
let parse (rawStreamName : string) : StreamName =
if rawStreamName.IndexOf '-' = -1 then
invalidArg "rawStreamName" (sprintf "Stream Name '%s' must contain a '-' separator" rawStreamName)
UMX.tag rawStreamName

let private dash = [|'-'|] // Separates {category}-{streamId}

/// <summary>Attempts to split a Stream Name in the form <c>{category}-{streamId}</c> into its two elements.
/// The <c>{streamId}</c> segment is permitted to include embedded '-' (dash) characters
/// Returns <c>None</c> if it does not adhere to that form.</summary>
let trySplitCategoryAndStreamId (rawStreamName : string) : struct (string * string) voption =
match rawStreamName.Split(dash, 2) with
| [| cat; id |] -> ValueSome struct (cat, id)
| _ -> ValueNone
let parse (raw: string): StreamName =
if raw.IndexOf Category.Separator = -1 then throwInvalid raw
raw |> Internal.trust

/// <summary>Attempts to split a Stream Name in the form <c>{category}-{streamId}</c> into its two elements.
/// The <c>{streamId}</c> segment is permitted to include embedded '-' (dash) characters
/// Yields <c>NotCategorized</c> if it does not adhere to that form.</summary>
let (|Categorized|NotCategorized|) (rawStreamName : string) : Choice<struct (string * string), unit> =
match trySplitCategoryAndStreamId rawStreamName with
| ValueSome catAndId -> Categorized catAndId
| ValueNone -> NotCategorized

(* Rendering *)

/// Strip off the strong typing (It's recommended to pattern match as below in the general case)
let inline toString (streamName : StreamName) : string =
UMX.untag streamName
/// Creates a StreamName in the canonical form; a category identifier and an streamId representing the aggregate's identity
/// category is separated from id by `-`
let create (category: string) (streamId: StreamId): StreamName =
Category.validate category
System.String.Concat(category, Category.SeparatorStr, StreamId.toString streamId) |> Internal.trust

(* Splitting: functions/Active patterns for (i.e. generated via `parse`, `create` or `compose`) well-formed Stream Names
Will throw if presented with malformed strings [generated via alternate means] *)
/// <summary>Composes a StreamName from a category and >= 0 name elements.
/// category is separated from the streamId by '-'; elements are separated from each other by '_'
/// Throws <c>InvalidArgumentException</c> if category embeds '-' symbols, or elements embed '_' symbols.</summary>
let compose (categoryName: string) (streamIdElements: string[]): StreamName =
create categoryName (StreamId.Elements.compose streamIdElements)

/// Extracts the category portion of the StreamName
let category (x : StreamName) =
let raw = toString x
raw.Substring(0, raw.IndexOf '-')
let category (x: StreamName) = Category.ofStreamName x
/// Extracts the category portion of a StreamName
let (|Category|) = category

/// <summary>Splits a well-formed Stream Name of the form <c>{category}-{streamId}</c> into its two elements.<br/>
/// Throws <c>InvalidArgumentException</c> if it does not adhere to the well known format (i.e. if it was not produced by `parse`).</summary>
/// <remarks>Inverse of <c>create</c></remarks>
let splitCategoryAndStreamId (streamName : StreamName) : struct (string * string) =
let split (streamName: StreamName): struct (string * StreamId) =
let rawName = toString streamName
match trySplitCategoryAndStreamId rawName with
match Internal.tryParse rawName with
| ValueSome catAndId -> catAndId
| ValueNone -> invalidArg "streamName" (sprintf "Stream Name '%s' must contain a '-' separator" rawName)

| ValueNone -> throwInvalid rawName // Yes, it _should_ never happen
/// <summary>Splits a well-formed Stream Name of the form <c>{category}-{streamId}</c> into its two elements.<br/>
/// Throws <c>InvalidArgumentException</c> if the stream name is not well-formed.</summary>
/// <remarks>Inverse of <c>create</c></remarks>
let (|CategoryAndId|) : StreamName -> struct (string * string) = splitCategoryAndStreamId
let (|Split|): StreamName -> struct (string * StreamId) = split

let private underscore = [|'_'|] // separates {category}-{subId1_subId2_..._subIdN}

/// <summary>Splits a `_`-separated set of id elements (as formed by `compose`) into its (one or more) constituent elements.</summary>
/// <remarks>Inverse of what <code>compose</code> does to the subElements</remarks>
let (|IdElements|) (streamId : string) : string[] =
streamId.Split underscore

/// <summary>Splits a well-formed Stream Name of the form {category}-{id1}_{id2}_{idN} into a pair of category and ids.<br/>
/// Throws <c>InvalidArgumentException</c> if it does not adhere to the well known format (i.e. if it was not produced by `parse`).</summary>
/// <remarks>Inverse of <c>create</c></remarks>
let splitCategoryAndIds (streamName : StreamName) : struct (string * string[]) =
let rawName = toString streamName
match trySplitCategoryAndStreamId rawName with
| ValueSome (cat, IdElements ids) -> (cat, ids)
| ValueNone -> invalidArg "streamName" (sprintf "Stream Name '%s' must contain a '-' separator" rawName)

/// <summary>Splits a well-formed Stream Name of the form <c>{category}-{streamId}</c> into the two elements.<br/>
/// Throws <c>InvalidArgumentException</c> if the stream name is not well-formed</summary>
/// <remarks>Inverse of <c>create</c></remarks>
let (|CategoryAndIds|) : StreamName -> struct (string * string[]) = splitCategoryAndIds
/// Yields the StreamId, if the Category matches the specified one
let tryFind categoryName (x: StreamName): StreamId voption =
match split x with
| cat, id when cat = categoryName -> id |> ValueSome
| _ -> ValueNone
31 changes: 18 additions & 13 deletions tests/FsCodec.Tests/StreamNameTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,36 @@ open Xunit
let [<Fact>] ``Can roundtrip composed multi-ids with embedded dashes`` () =
let cat, e1, e2 = "Cat", "a-b", "c-d"

let sn = StreamName.compose cat [e1;e2]
let sn = StreamName.compose cat [| e1; e2 |]

test <@ StreamName.parse "Cat-a-b_c-d" = sn @>

test <@ let (StreamName.CategoryAndIds (scat, elems)) = sn
scat = cat && [e1;e2] = List.ofArray elems @>
test <@ let (StreamName.Split (scat, StreamId.Parse 2 elems)) = sn
scat = cat && [| e1; e2 |] = elems @>

test <@ let (StreamName.CategoryAndId (scat, aggId)) = sn
scat = cat && aggId = "a-b_c-d" @>
test <@ let (StreamName.Split (scat, sid)) = sn
cat = scat
&& StreamId.create "a-b_c-d" = sid
&& (e1 + StreamId.Elements.Separator + e2) = StreamId.toString sid @>

let [<Fact>] ``Can roundtrip streamId with embedded dashes and underscores`` () =
let cat, aggId = "Cat", "a-b_c-d"
let cat, streamId = "Cat", "a-b_c-d"

let sn = StreamName.create cat aggId
let sn = StreamName.create cat (StreamId.create streamId)

test <@ StreamName.parse "Cat-a-b_c-d" = sn @>

test <@ let (StreamName.CategoryAndId (scat, aggId)) = sn
scat = cat && "a-b_c-d" = aggId @>
test <@ let (StreamName.Split (sCat, sid)) = sn
sCat = cat
&& streamId = StreamId.toString sid
&& [| "a-b"; "c-d" |] = StreamId.parse 2 sid @>

test <@ let (StreamName.CategoryAndIds (scat, aggIds)) = sn
scat = cat && ["a-b";"c-d"] = List.ofArray aggIds @>
test <@ let (StreamName.Split (sCat, StreamId.Parse 2 ids)) = sn
sCat = cat
&& [| "a-b"; "c-d" |] = ids @>

let [<Fact>] ``StreamName parse throws given 0 separators`` () =
raisesWith <@ StreamName.parse "Cat" @> <|
fun (e : System.ArgumentException) ->
<@ e.ParamName = "rawStreamName"
fun (e: System.ArgumentException) ->
<@ e.ParamName = "raw"
&& e.Message.StartsWith "Stream Name 'Cat' must contain a '-' separator" @>