From cc5dd3bd7fa9c7041d59a2049ea20912b07de032 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sat, 13 Jul 2019 22:55:53 -0500 Subject: [PATCH] Began model migration to RavenDB format --- src/MyPrayerJournal.Api/Data.fs | 124 ++++++++++++++ src/MyPrayerJournal.Api/Domain.fs | 158 ++++++++++++++++++ src/MyPrayerJournal.Api/Handlers.fs | 86 +++++----- .../MyPrayerJournal.Api.fsproj | 6 +- src/MyPrayerJournal.Domain/Entities.fs | 114 ------------- .../MyPrayerJournal.Domain.fsproj | 11 -- 6 files changed, 330 insertions(+), 169 deletions(-) create mode 100644 src/MyPrayerJournal.Api/Domain.fs delete mode 100644 src/MyPrayerJournal.Domain/Entities.fs delete mode 100644 src/MyPrayerJournal.Domain/MyPrayerJournal.Domain.fsproj diff --git a/src/MyPrayerJournal.Api/Data.fs b/src/MyPrayerJournal.Api/Data.fs index 6da44b0..fd8a8a1 100644 --- a/src/MyPrayerJournal.Api/Data.fs +++ b/src/MyPrayerJournal.Api/Data.fs @@ -4,6 +4,130 @@ open FSharp.Control.Tasks.V2.ContextInsensitive open Microsoft.EntityFrameworkCore open Microsoft.FSharpLu +open Newtonsoft.Json +open Raven.Client.Documents.Indexes +open System +open System.Collections.Generic + +/// JSON converter for request IDs +type RequestIdJsonConverter() = + inherit JsonConverter() + override __.WriteJson(writer : JsonWriter, value : RequestId, _ : JsonSerializer) = + (string >> writer.WriteValue) value + override __.ReadJson(reader: JsonReader, _ : Type, _ : RequestId, _ : bool, _ : JsonSerializer) = + (string >> RequestId.fromIdString) reader.Value + + +/// JSON converter for user IDs +type UserIdJsonConverter() = + inherit JsonConverter() + override __.WriteJson(writer : JsonWriter, value : UserId, _ : JsonSerializer) = + (string >> writer.WriteValue) value + override __.ReadJson(reader: JsonReader, _ : Type, _ : UserId, _ : bool, _ : JsonSerializer) = + (string >> UserId) reader.Value + + +/// JSON converter for Ticks +type TicksJsonConverter() = + inherit JsonConverter() + override __.WriteJson(writer : JsonWriter, value : Ticks, _ : JsonSerializer) = + writer.WriteValue (value.toLong ()) + override __.ReadJson(reader: JsonReader, _ : Type, _ : Ticks, _ : bool, _ : JsonSerializer) = + (string >> int64 >> Ticks) reader.Value + +/// Index episodes by their series Id +type Requests_ByUserId () as this = + inherit AbstractJavaScriptIndexCreationTask () + do + this.Maps <- HashSet [ "map('Requests', function (req) { return { userId : req.userId } })" ] + + +/// Extensions on the IAsyncDocumentSession interface to support our data manipulation needs +[] +module Extensions = + + open Raven.Client.Documents.Commands.Batches + open Raven.Client.Documents.Operations + open Raven.Client.Documents.Session + open System + + /// Format an RQL query by a strongly-typed index + let fromIndex (typ : Type) = + typ.Name.Replace ("_", "/") |> sprintf "from index '%s'" + + /// Utility method to create patch requests + let createPatch<'T> collName itemId (item : 'T) = + let r = PatchRequest() + r.Script <- sprintf "this.%s.push(args.Item)" collName + r.Values.["Item"] <- item + PatchCommandData (itemId, null, r, null) + + // Extensions for the RavenDB session type + type IAsyncDocumentSession with + + /// Add a history entry + member this.AddHistory (reqId : RequestId) (hist : History) = + createPatch "history" (string reqId) hist + |> this.Advanced.Defer + + /// Add a request + member this.AddRequest req = + this.StoreAsync (req, req.Id) + + /// Retrieve all answered requests for the given user + // TODO: not right + member this.AnsweredRequests (userId : UserId) = + sprintf "%s where userId = '%s' and lastStatus = 'Answered' order by asOf as long desc" + (fromIndex typeof) (string userId) + |> this.Advanced.AsyncRawQuery + + /// Retrieve the user's current journal + // TODO: probably not right either + member this.JournalByUserId (userId : UserId) = + sprintf "%s where userId = '%s' and lastStatus <> 'Answered' order by showAfter as long" + (fromIndex typeof) (string userId) + |> this.Advanced.AsyncRawQuery + + /// Retrieve a request by its ID and user ID + member this.TryRequestById (reqId : RequestId) userId = + task { + let! req = this.LoadAsync (string reqId) + match Option.fromObject req with + | Some r when r.userId = userId -> return Some r + | _ -> return None + } + + /// Retrieve notes for a request by its ID and user ID + member this.NotesById reqId userId = + task { + match! this.TryRequestById reqId userId with + | Some _ -> return this.Notes.AsNoTracking().Where(fun n -> n.requestId = reqId) |> List.ofSeq + | None -> return [] + } + + /// Retrieve a journal request by its ID and user ID + member this.TryJournalById reqId userId = + task { + let! req = this.Journal.FirstOrDefaultAsync(fun r -> r.requestId = reqId && r.userId = userId) + return Option.fromObject req + } + + /// Retrieve a request, including its history and notes, by its ID and user ID + member this.TryFullRequestById requestId userId = + task { + match! this.TryJournalById requestId userId with + | Some req -> + let! fullReq = + this.Requests.AsNoTracking() + .Include(fun r -> r.history) + .Include(fun r -> r.notes) + .FirstOrDefaultAsync(fun r -> r.requestId = requestId && r.userId = userId) + match Option.fromObject fullReq with + | Some _ -> return Some { req with history = List.ofSeq fullReq.history; notes = List.ofSeq fullReq.notes } + | None -> return None + | None -> return None + } + /// Entity Framework configuration for myPrayerJournal module internal EFConfig = diff --git a/src/MyPrayerJournal.Api/Domain.fs b/src/MyPrayerJournal.Api/Domain.fs new file mode 100644 index 0000000..ebd5227 --- /dev/null +++ b/src/MyPrayerJournal.Api/Domain.fs @@ -0,0 +1,158 @@ +[] +/// The data model for myPrayerJournal +module MyPrayerJournal.Domain + +/// A Collision-resistant Unique IDentifier +type Cuid = + | Cuid of string +with + /// The string value of the CUID + override x.ToString () = match x with Cuid y -> y + + +/// Request ID is a CUID +type RequestId = + | RequestId of Cuid +with + /// The string representation of the request ID + override x.ToString () = match x with RequestId y -> (string >> sprintf "Requests/%s") y + /// Create a request ID from a string representation + static member fromIdString (y : string) = (Cuid >> RequestId) <| y.Replace("Requests/", "") + + +/// User ID is a string (the "sub" part of the JWT) +type UserId = + | UserId of string +with + /// The string representation of the user ID + override x.ToString () = match x with UserId y -> y + + +/// A long integer representing seconds since the epoch +type Ticks = + | Ticks of int64 +with + /// The int64 (long) representation of ticks + member x.toLong () = match x with Ticks y -> y + + +/// How frequently a request should reappear after it is marked "Prayed" +type Recurrence = + | Immediate + | Hours + | Days + | Weeks +with + /// The string reprsentation used in the database and the web app + override x.ToString () = + match x with + | Immediate -> "immediate" + | Hours -> "hours" + | Days -> "days" + | Weeks -> "weeks" + /// Create a recurrence value from a string + static member fromString x = + match x with + | "immediate" -> Immediate + | "hours" -> Hours + | "days" -> Days + | "weeks" -> Weeks + | _ -> invalidOp (sprintf "%s is not a valid recurrence" x) + + +/// History is a record of action taken on a prayer request, including updates to its text +[] +type History = + { /// The time when this history entry was made + asOf : Ticks + /// The status for this history entry + status : string + /// The text of the update, if applicable + text : string option + } +with + /// An empty history entry + static member empty = + { asOf = Ticks 0L + status = "" + text = None + } + +/// Note is a note regarding a prayer request that does not result in an update to its text +[] +type Note = + { /// The time when this note was made + asOf : Ticks + /// The text of the notes + notes : string + } +with + /// An empty note + static member empty = + { asOf = Ticks 0L + notes = "" + } + +/// Request is the identifying record for a prayer request +[] +type Request = + { /// The ID of the request + Id : string + /// The time this request was initially entered + enteredOn : Ticks + /// The ID of the user to whom this request belongs ("sub" from the JWT) + userId : UserId + /// The time at which this request should reappear in the user's journal by manual user choice + snoozedUntil : Ticks + /// The time at which this request should reappear in the user's journal by recurrence + showAfter : Ticks + /// The type of recurrence for this request + recurType : Recurrence + /// How many of the recurrence intervals should occur between appearances in the journal + recurCount : int16 + /// The history entries for this request + history : History list + /// The notes for this request + notes : Note list + } +with + /// An empty request + static member empty = + { Id = "" + enteredOn = Ticks 0L + userId = UserId "" + snoozedUntil = Ticks 0L + showAfter = Ticks 0L + recurType = Immediate + recurCount = 0s + history = [] + notes = [] + } + +/// JournalRequest is the form of a prayer request returned for the request journal display. It also contains +/// properties that may be filled for history and notes +[] +type JournalRequest = + { /// The ID of the request + requestId : RequestId + /// The ID of the user to whom the request belongs + userId : UserId + /// The current text of the request + text : string + /// The last time action was taken on the request + asOf : Ticks + /// The last status for the request + lastStatus : string + /// The time that this request should reappear in the user's journal + snoozedUntil : Ticks + /// The time after which this request should reappear in the user's journal by configured recurrence + showAfter : Ticks + /// The type of recurrence for this request + recurType : Recurrence + /// How many of the recurrence intervals should occur between appearances in the journal + recurCount : int16 + /// History entries for the request + history : History list + /// Note entries for the request + notes : Note list + } diff --git a/src/MyPrayerJournal.Api/Handlers.fs b/src/MyPrayerJournal.Api/Handlers.fs index 5f62b86..c6264f1 100644 --- a/src/MyPrayerJournal.Api/Handlers.fs +++ b/src/MyPrayerJournal.Api/Handlers.fs @@ -40,6 +40,7 @@ module Error = module private Helpers = open Microsoft.AspNetCore.Http + open Raven.Client.Documents open System.Threading.Tasks open System.Security.Claims @@ -47,6 +48,10 @@ module private Helpers = let db (ctx : HttpContext) = ctx.GetService () + /// Create a RavenDB session + let session (ctx : HttpContext) = + ctx.GetService().OpenAsyncSession () + /// Get the user's "sub" claim let user (ctx : HttpContext) = ctx.User.Claims |> Seq.tryFind (fun u -> u.Type = ClaimTypes.NameIdentifier) @@ -54,7 +59,7 @@ module private Helpers = /// Get the current user's ID // NOTE: this may raise if you don't run the request through the authorize handler first let userId ctx = - ((user >> Option.get) ctx).Value + ((user >> Option.get) ctx).Value |> UserId /// Return a 201 CREATED response let created next ctx = @@ -163,28 +168,28 @@ module Request = >=> fun next ctx -> task { let! r = ctx.BindJsonAsync () - let db = db ctx - let reqId = Cuid.Generate () + use sess = session ctx + let reqId = (Cuid.Generate >> Domain.Cuid >> RequestId) () let usrId = userId ctx - let now = jsNow () - { Request.empty with - requestId = reqId - userId = usrId - enteredOn = now - showAfter = now - recurType = r.recurType - recurCount = r.recurCount - } - |> db.AddEntry - { History.empty with - requestId = reqId - asOf = now - status = "Created" - text = Some r.requestText - } - |> db.AddEntry - let! _ = db.SaveChangesAsync () - match! db.TryJournalById reqId usrId with + let now = (jsNow >> Ticks) () + do! sess.AddRequest + { Request.empty with + Id = string reqId + userId = usrId + enteredOn = now + showAfter = now + recurType = Recurrence.fromString r.recurType + recurCount = r.recurCount + history = [ + { History.empty with + asOf = now + status = "Created" + text = Some r.requestText + } + ] + } + do! sess.SaveChangesAsync () + match! sess.TryJournalById reqId usrId with | Some req -> return! (setStatusCode 201 >=> json req) next ctx | None -> return! Error.notFound next ctx } @@ -194,23 +199,23 @@ module Request = authorize >=> fun next ctx -> task { - let db = db ctx - match! db.TryRequestById reqId (userId ctx) with + use sess = session ctx + let reqId = (Domain.Cuid >> RequestId) reqId + match! sess.TryRequestById reqId (userId ctx) with | Some req -> let! hist = ctx.BindJsonAsync () - let now = jsNow () + let now = (jsNow >> Ticks) () { History.empty with - requestId = reqId - asOf = now - status = hist.status - text = match hist.updateText with null | "" -> None | x -> Some x + asOf = now + status = hist.status + text = match hist.updateText with null | "" -> None | x -> Some x } - |> db.AddEntry + |> sess.AddHistory reqId match hist.status with | "Prayed" -> - db.UpdateEntry { req with showAfter = now + (recurrence.[req.recurType] * int64 req.recurCount) } + sess.UpdateEntry { req with showAfter = now + (recurrence.[req.recurType] * int64 req.recurCount) } | _ -> () - let! _ = db.SaveChangesAsync () + do! sess.SaveChangesAsync () return! created next ctx | None -> return! Error.notFound next ctx } @@ -225,9 +230,8 @@ module Request = | Some _ -> let! notes = ctx.BindJsonAsync () { Note.empty with - requestId = reqId - asOf = jsNow () - notes = notes.notes + asOf = (jsNow >> Ticks) () + notes = notes.notes } |> db.AddEntry let! _ = db.SaveChangesAsync () @@ -248,7 +252,8 @@ module Request = authorize >=> fun next ctx -> task { - match! (db ctx).TryJournalById reqId (userId ctx) with + use sess = session ctx + match! sess.TryJournalById reqId (userId ctx) with | Some req -> return! json req next ctx | None -> return! Error.notFound next ctx } @@ -258,7 +263,8 @@ module Request = authorize >=> fun next ctx -> task { - match! (db ctx).TryFullRequestById reqId (userId ctx) with + use sess = session ctx + match! sess.TryFullRequestById reqId (userId ctx) with | Some req -> return! json req next ctx | None -> return! Error.notFound next ctx } @@ -281,7 +287,7 @@ module Request = match! db.TryRequestById reqId (userId ctx) with | Some req -> let! show = ctx.BindJsonAsync () - { req with showAfter = show.showAfter } + { req with showAfter = Ticks show.showAfter } |> db.UpdateEntry let! _ = db.SaveChangesAsync () return! setStatusCode 204 next ctx @@ -297,7 +303,7 @@ module Request = match! db.TryRequestById reqId (userId ctx) with | Some req -> let! until = ctx.BindJsonAsync () - { req with snoozedUntil = until.until; showAfter = until.until } + { req with snoozedUntil = Ticks until.until; showAfter = Ticks until.until } |> db.UpdateEntry let! _ = db.SaveChangesAsync () return! setStatusCode 204 next ctx @@ -313,7 +319,7 @@ module Request = match! db.TryRequestById reqId (userId ctx) with | Some req -> let! recur = ctx.BindJsonAsync () - { req with recurType = recur.recurType; recurCount = recur.recurCount } + { req with recurType = Recurrence.fromString recur.recurType; recurCount = recur.recurCount } |> db.UpdateEntry let! _ = db.SaveChangesAsync () return! setStatusCode 204 next ctx diff --git a/src/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj b/src/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj index 632e08e..7c4a819 100644 --- a/src/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj +++ b/src/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj @@ -6,6 +6,7 @@ + @@ -20,6 +21,7 @@ + @@ -31,8 +33,4 @@ - - - - diff --git a/src/MyPrayerJournal.Domain/Entities.fs b/src/MyPrayerJournal.Domain/Entities.fs deleted file mode 100644 index 043aa0e..0000000 --- a/src/MyPrayerJournal.Domain/Entities.fs +++ /dev/null @@ -1,114 +0,0 @@ -[] -/// Entities for use in the data model for myPrayerJournal -module MyPrayerJournal.Entities - -open System.Collections.Generic - -/// Type alias for a Collision-resistant Unique IDentifier -type Cuid = string - -/// Request ID is a CUID -type RequestId = Cuid - -/// User ID is a string (the "sub" part of the JWT) -type UserId = string - -/// History is a record of action taken on a prayer request, including updates to its text -type [] History = - { /// The ID of the request to which this history entry applies - requestId : RequestId - /// The time when this history entry was made - asOf : int64 - /// The status for this history entry - status : string - /// The text of the update, if applicable - text : string option - } -with - /// An empty history entry - static member empty = - { requestId = "" - asOf = 0L - status = "" - text = None - } - -/// Note is a note regarding a prayer request that does not result in an update to its text -and [] Note = - { /// The ID of the request to which this note applies - requestId : RequestId - /// The time when this note was made - asOf : int64 - /// The text of the notes - notes : string - } -with - /// An empty note - static member empty = - { requestId = "" - asOf = 0L - notes = "" - } - -/// Request is the identifying record for a prayer request -and [] Request = - { /// The ID of the request - requestId : RequestId - /// The time this request was initially entered - enteredOn : int64 - /// The ID of the user to whom this request belongs ("sub" from the JWT) - userId : string - /// The time at which this request should reappear in the user's journal by manual user choice - snoozedUntil : int64 - /// The time at which this request should reappear in the user's journal by recurrence - showAfter : int64 - /// The type of recurrence for this request - recurType : string - /// How many of the recurrence intervals should occur between appearances in the journal - recurCount : int16 - /// The history entries for this request - history : ICollection - /// The notes for this request - notes : ICollection - } -with - /// An empty request - static member empty = - { requestId = "" - enteredOn = 0L - userId = "" - snoozedUntil = 0L - showAfter = 0L - recurType = "immediate" - recurCount = 0s - history = List () - notes = List () - } - -/// JournalRequest is the form of a prayer request returned for the request journal display. It also contains -/// properties that may be filled for history and notes -[] -type JournalRequest = - { /// The ID of the request - requestId : RequestId - /// The ID of the user to whom the request belongs - userId : string - /// The current text of the request - text : string - /// The last time action was taken on the request - asOf : int64 - /// The last status for the request - lastStatus : string - /// The time that this request should reappear in the user's journal - snoozedUntil : int64 - /// The time after which this request should reappear in the user's journal by configured recurrence - showAfter : int64 - /// The type of recurrence for this request - recurType : string - /// How many of the recurrence intervals should occur between appearances in the journal - recurCount : int16 - /// History entries for the request - history : History list - /// Note entries for the request - notes : Note list - } diff --git a/src/MyPrayerJournal.Domain/MyPrayerJournal.Domain.fsproj b/src/MyPrayerJournal.Domain/MyPrayerJournal.Domain.fsproj deleted file mode 100644 index 028d930..0000000 --- a/src/MyPrayerJournal.Domain/MyPrayerJournal.Domain.fsproj +++ /dev/null @@ -1,11 +0,0 @@ - - - - netstandard2.0 - - - - - - -