799 lines
28 KiB
Forth

[<RequireQualifiedAccess>]
module MyWebLog.Data
/// Table names
[<RequireQualifiedAccess>]
module Table =
/// The category table
let Category = "Category"
/// The comment table
let Comment = "Comment"
/// The page table
let Page = "Page"
/// The post table
let Post = "Post"
/// The tag map table
let TagMap = "TagMap"
/// The web log table
let WebLog = "WebLog"
/// The web log user table
let WebLogUser = "WebLogUser"
/// A list of all tables
let all = [ Category; Comment; Page; Post; TagMap; WebLog; WebLogUser ]
/// Functions to assist with retrieving data
[<AutoOpen>]
module Helpers =
open RethinkDb.Driver
open RethinkDb.Driver.Net
open System.Threading.Tasks
/// Shorthand for the ReQL starting point
let r = RethinkDB.R
/// Verify that the web log ID matches before returning an item
let verifyWebLog<'T> webLogId (prop : 'T -> WebLogId) (f : IConnection -> Task<'T option>) =
fun conn -> backgroundTask {
match! f conn with Some it when (prop it) = webLogId -> return Some it | _ -> return None
}
/// Get the first item from a list, or None if the list is empty
let tryFirst<'T> (f : IConnection -> Task<'T list>) =
fun conn -> backgroundTask {
let! results = f conn
return results |> List.tryHead
}
/// Cast a strongly-typed list to an object list
let objList<'T> (objects : 'T list) = objects |> List.map (fun it -> it :> obj)
open RethinkDb.Driver.FSharp
open Microsoft.Extensions.Logging
/// Start up checks to ensure the database, tables, and indexes exist
module Startup =
/// Ensure field indexes exist, as well as special indexes for selected tables
let private ensureIndexes (log : ILogger) conn table fields = backgroundTask {
let! indexes = rethink<string list> { withTable table; indexList; result; withRetryOnce conn }
for field in fields do
if not (indexes |> List.contains field) then
log.LogInformation $"Creating index {table}.{field}..."
do! rethink { withTable table; indexCreate field; write; withRetryOnce; ignoreResult conn }
// Post and page need index by web log ID and permalink
if [ Table.Page; Table.Post ] |> List.contains table then
if not (indexes |> List.contains "permalink") then
log.LogInformation $"Creating index {table}.permalink..."
do! rethink {
withTable table
indexCreate "permalink" (fun row -> r.Array (row["webLogId"], row["permalink"].Downcase ()) :> obj)
write; withRetryOnce; ignoreResult conn
}
// Prior permalinks are searched when a post or page permalink do not match the current URL
if not (indexes |> List.contains "priorPermalinks") then
log.LogInformation $"Creating index {table}.priorPermalinks..."
do! rethink {
withTable table
indexCreate "priorPermalinks" (fun row -> row["priorPermalinks"].Downcase () :> obj) [ Multi ]
write; withRetryOnce; ignoreResult conn
}
// Post needs indexes by category and tag (used for counting and retrieving posts)
if Table.Post = table then
for idx in [ "categoryIds"; "tags" ] do
if not (List.contains idx indexes) then
log.LogInformation $"Creating index {table}.{idx}..."
do! rethink {
withTable table
indexCreate idx [ Multi ]
write; withRetryOnce; ignoreResult conn
}
// Tag mapping needs an index by web log ID and both tag and URL values
if Table.TagMap = table then
if not (indexes |> List.contains "webLogAndTag") then
log.LogInformation $"Creating index {table}.webLogAndTag..."
do! rethink {
withTable table
indexCreate "webLogAndTag" (fun row -> r.Array (row["webLogId"], row["tag"]) :> obj)
write; withRetryOnce; ignoreResult conn
}
if not (indexes |> List.contains "webLogAndUrl") then
log.LogInformation $"Creating index {table}.webLogAndUrl..."
do! rethink {
withTable table
indexCreate "webLogAndUrl" (fun row -> r.Array (row["webLogId"], row["urlValue"]) :> obj)
write; withRetryOnce; ignoreResult conn
}
// Users log on with e-mail
if Table.WebLogUser = table && not (indexes |> List.contains "logOn") then
log.LogInformation $"Creating index {table}.logOn..."
do! rethink {
withTable table
indexCreate "logOn" (fun row -> r.Array (row["webLogId"], row["userName"]) :> obj)
write; withRetryOnce; ignoreResult conn
}
}
/// Ensure all necessary tables and indexes exist
let ensureDb (config : DataConfig) (log : ILogger) conn = backgroundTask {
let! dbs = rethink<string list> { dbList; result; withRetryOnce conn }
if not (dbs |> List.contains config.Database) then
log.LogInformation($"Creating database {config.Database}...")
do! rethink { dbCreate config.Database; write; withRetryOnce; ignoreResult conn }
let! tables = rethink<string list> { tableList; result; withRetryOnce conn }
for tbl in Table.all do
if not (tables |> List.contains tbl) then
log.LogInformation($"Creating table {tbl}...")
do! rethink { tableCreate tbl; write; withRetryOnce; ignoreResult conn }
let makeIdx = ensureIndexes log conn
do! makeIdx Table.Category [ "webLogId" ]
do! makeIdx Table.Comment [ "postId" ]
do! makeIdx Table.Page [ "webLogId"; "authorId" ]
do! makeIdx Table.Post [ "webLogId"; "authorId" ]
do! makeIdx Table.TagMap []
do! makeIdx Table.WebLog [ "urlBase" ]
do! makeIdx Table.WebLogUser [ "webLogId" ]
}
/// Functions to manipulate categories
module Category =
open System.Threading.Tasks
open MyWebLog.ViewModels
/// Add a category
let add (cat : Category) =
rethink {
withTable Table.Category
insert cat
write; withRetryDefault; ignoreResult
}
/// Count all categories for a web log
let countAll (webLogId : WebLogId) =
rethink<int> {
withTable Table.Category
getAll [ webLogId ] (nameof webLogId)
count
result; withRetryDefault
}
/// Count top-level categories for a web log
let countTopLevel (webLogId : WebLogId) =
rethink<int> {
withTable Table.Category
getAll [ webLogId ] (nameof webLogId)
filter "parentId" None
count
result; withRetryDefault
}
/// Create a category hierarchy from the given list of categories
let rec private orderByHierarchy (cats : Category list) parentId slugBase parentNames = seq {
for cat in cats |> List.filter (fun c -> c.parentId = parentId) do
let fullSlug = (match slugBase with Some it -> $"{it}/" | None -> "") + cat.slug
{ id = CategoryId.toString cat.id
slug = fullSlug
name = cat.name
description = cat.description
parentNames = Array.ofList parentNames
// Post counts are filled on a second pass
postCount = 0
}
yield! orderByHierarchy cats (Some cat.id) (Some fullSlug) ([ cat.name ] |> List.append parentNames)
}
/// Find all categories for a web log, sorted alphabetically, arranged in groups, in view model format
let findAllForView (webLogId : WebLogId) conn = backgroundTask {
let! cats = rethink<Category list> {
withTable Table.Category
getAll [ webLogId ] (nameof webLogId)
orderByFunc (fun it -> it["name"].Downcase () :> obj)
result; withRetryDefault conn
}
let ordered = orderByHierarchy cats None None []
let! counts =
ordered
|> Seq.map (fun it -> backgroundTask {
// Parent category post counts include posts in subcategories
let catIds =
ordered
|> Seq.filter (fun cat -> cat.parentNames |> Array.contains it.name)
|> Seq.map (fun cat -> cat.id :> obj)
|> Seq.append (Seq.singleton it.id)
|> List.ofSeq
let! count = rethink<int> {
withTable Table.Post
getAll catIds "categoryIds"
filter "status" Published
distinct
count
result; withRetryDefault conn
}
return it.id, count
})
|> Task.WhenAll
return
ordered
|> Seq.map (fun cat ->
{ cat with
postCount = counts
|> Array.tryFind (fun c -> fst c = cat.id)
|> Option.map snd
|> Option.defaultValue 0
})
|> Array.ofSeq
}
/// Find a category by its ID
let findById (catId : CategoryId) webLogId =
rethink<Category> {
withTable Table.Category
get catId
resultOption; withRetryOptionDefault
}
|> verifyWebLog webLogId (fun c -> c.webLogId)
/// Delete a category, also removing it from any posts to which it is assigned
let delete catId webLogId conn = backgroundTask {
match! findById catId webLogId conn with
| Some _ ->
// Delete the category off all posts where it is assigned
do! rethink {
withTable Table.Post
getAll [ webLogId ] (nameof webLogId)
filter (fun row -> row["categoryIds"].Contains catId :> obj)
update (fun row -> r.HashMap ("categoryIds", r.Array(row["categoryIds"]).Remove catId) :> obj)
write; withRetryDefault; ignoreResult conn
}
// Delete the category itself
do! rethink {
withTable Table.Category
get catId
delete
write; withRetryDefault; ignoreResult conn
}
return true
| None -> return false
}
/// Get a category ID -> name dictionary for the given category IDs
let findNames (webLogId : WebLogId) conn (catIds : CategoryId list) = backgroundTask {
let! cats = rethink<Category list> {
withTable Table.Category
getAll (objList catIds)
filter "webLogId" webLogId
result; withRetryDefault conn
}
return cats |> List.map (fun c -> { name = CategoryId.toString c.id; value = c.name})
}
/// Update a category
let update (cat : Category) =
rethink {
withTable Table.Category
get cat.id
update [ "name", cat.name :> obj
"slug", cat.slug
"description", cat.description
"parentId", cat.parentId
]
write; withRetryDefault; ignoreResult
}
/// Functions to manipulate pages
module Page =
open RethinkDb.Driver.Model
/// Add a new page
let add (page : Page) =
rethink {
withTable Table.Page
insert page
write; withRetryDefault; ignoreResult
}
/// Count all pages for a web log
let countAll (webLogId : WebLogId) =
rethink<int> {
withTable Table.Page
getAll [ webLogId ] (nameof webLogId)
count
result; withRetryDefault
}
/// Count listed pages for a web log
let countListed (webLogId : WebLogId) =
rethink<int> {
withTable Table.Page
getAll [ webLogId ] (nameof webLogId)
filter "showInPageList" true
count
result; withRetryDefault
}
/// Delete a page
let delete (pageId : PageId) (webLogId : WebLogId) conn = backgroundTask {
let! result =
rethink<Result> {
withTable Table.Page
getAll [ pageId ]
filter (fun row -> row["webLogId"].Eq webLogId :> obj)
delete
write; withRetryDefault conn
}
return result.Deleted > 0UL
}
/// Retrieve all pages for a web log (excludes text, prior permalinks, and revisions)
let findAll (webLogId : WebLogId) =
rethink<Page list> {
withTable Table.Page
getAll [ webLogId ] (nameof webLogId)
without [ "text"; "priorPermalinks"; "revisions" ]
result; withRetryDefault
}
/// Find a page by its ID (including prior permalinks and revisions)
let findByFullId (pageId : PageId) webLogId =
rethink<Page> {
withTable Table.Page
get pageId
resultOption; withRetryOptionDefault
}
|> verifyWebLog webLogId (fun it -> it.webLogId)
/// Find a page by its ID (excludes prior permalinks and revisions)
let findById (pageId : PageId) webLogId =
rethink<Page> {
withTable Table.Page
get pageId
without [ "priorPermalinks"; "revisions" ]
resultOption; withRetryOptionDefault
}
|> verifyWebLog webLogId (fun it -> it.webLogId)
/// Find a page by its permalink
let findByPermalink (permalink : Permalink) (webLogId : WebLogId) =
rethink<Page list> {
withTable Table.Page
getAll [ r.Array (webLogId, permalink) ] (nameof permalink)
without [ "priorPermalinks"; "revisions" ]
limit 1
result; withRetryDefault
}
|> tryFirst
/// Find the current permalink for a page by a prior permalink
let findCurrentPermalink (permalinks : Permalink list) (webLogId : WebLogId) conn = backgroundTask {
let! result =
(rethink<Page list> {
withTable Table.Page
getAll (objList permalinks) "priorPermalinks"
filter "webLogId" webLogId
without [ "revisions"; "text" ]
limit 1
result; withRetryDefault
}
|> tryFirst) conn
return result |> Option.map (fun pg -> pg.permalink)
}
/// Find all pages in the page list for the given web log
let findListed (webLogId : WebLogId) =
rethink<Page list> {
withTable Table.Page
getAll [ webLogId ] (nameof webLogId)
filter [ "showInPageList", true :> obj ]
without [ "text"; "priorPermalinks"; "revisions" ]
orderBy "title"
result; withRetryDefault
}
/// Find a list of pages (displayed in admin area)
let findPageOfPages (webLogId : WebLogId) pageNbr =
rethink<Page list> {
withTable Table.Page
getAll [ webLogId ] (nameof webLogId)
without [ "priorPermalinks"; "revisions" ]
orderByFunc (fun row -> row["title"].Downcase ())
skip ((pageNbr - 1) * 25)
limit 25
result; withRetryDefault
}
/// Update a page
let update (page : Page) =
rethink {
withTable Table.Page
get page.id
update [
"title", page.title :> obj
"permalink", page.permalink
"updatedOn", page.updatedOn
"showInPageList", page.showInPageList
"template", page.template
"text", page.text
"priorPermalinks", page.priorPermalinks
"metadata", page.metadata
"revisions", page.revisions
]
write; withRetryDefault; ignoreResult
}
/// Update prior permalinks for a page
let updatePriorPermalinks pageId webLogId (permalinks : Permalink list) conn = backgroundTask {
match! findById pageId webLogId conn with
| Some _ ->
do! rethink {
withTable Table.Page
get pageId
update [ "priorPermalinks", permalinks :> obj ]
write; withRetryDefault; ignoreResult conn
}
return true
| None -> return false
}
/// Functions to manipulate posts
module Post =
open System
open RethinkDb.Driver.Model
/// Add a post
let add (post : Post) =
rethink {
withTable Table.Post
insert post
write; withRetryDefault; ignoreResult
}
/// Count posts for a web log by their status
let countByStatus (status : PostStatus) (webLogId : WebLogId) =
rethink<int> {
withTable Table.Post
getAll [ webLogId ] (nameof webLogId)
filter "status" status
count
result; withRetryDefault
}
/// Delete a post
let delete (postId : PostId) (webLogId : WebLogId) conn = backgroundTask {
let! result =
rethink<Result> {
withTable Table.Post
getAll [ postId ]
filter (fun row -> row["webLogId"].Eq webLogId :> obj)
delete
write; withRetryDefault conn
}
return result.Deleted > 0UL
}
/// Find a post by its permalink
let findByPermalink (permalink : Permalink) (webLogId : WebLogId) =
rethink<Post list> {
withTable Table.Post
getAll [ r.Array (webLogId, permalink) ] (nameof permalink)
without [ "priorPermalinks"; "revisions" ]
limit 1
result; withRetryDefault
}
|> tryFirst
/// Find a post by its ID, including all revisions and prior permalinks
let findByFullId (postId : PostId) webLogId =
rethink<Post> {
withTable Table.Post
get postId
resultOption; withRetryOptionDefault
}
|> verifyWebLog webLogId (fun p -> p.webLogId)
/// Find the current permalink for a post by a prior permalink
let findCurrentPermalink (permalinks : Permalink list) (webLogId : WebLogId) conn = backgroundTask {
let! result =
(rethink<Post list> {
withTable Table.Post
getAll (objList permalinks) "priorPermalinks"
filter "webLogId" webLogId
without [ "revisions"; "text" ]
limit 1
result; withRetryDefault
}
|> tryFirst) conn
return result |> Option.map (fun post -> post.permalink)
}
/// Find posts to be displayed on a category list page
let findPageOfCategorizedPosts (webLogId : WebLogId) (catIds : CategoryId list) (pageNbr : int64) postsPerPage =
let pg = int pageNbr
rethink<Post list> {
withTable Table.Post
getAll (objList catIds) "categoryIds"
filter "webLogId" webLogId
filter "status" Published
without [ "priorPermalinks"; "revisions" ]
distinct
orderByDescending "publishedOn"
skip ((pg - 1) * postsPerPage)
limit (postsPerPage + 1)
result; withRetryDefault
}
/// Find posts to be displayed on an admin page
let findPageOfPosts (webLogId : WebLogId) (pageNbr : int) postsPerPage =
rethink<Post list> {
withTable Table.Post
getAll [ webLogId ] (nameof webLogId)
without [ "priorPermalinks"; "revisions" ]
orderByFuncDescending (fun row -> row["publishedOn"].Default_ "updatedOn" :> obj)
skip ((pageNbr - 1) * postsPerPage)
limit (postsPerPage + 1)
result; withRetryDefault
}
/// Find posts to be displayed on a page
let findPageOfPublishedPosts (webLogId : WebLogId) pageNbr postsPerPage =
rethink<Post list> {
withTable Table.Post
getAll [ webLogId ] (nameof webLogId)
filter "status" Published
without [ "priorPermalinks"; "revisions" ]
orderByDescending "publishedOn"
skip ((pageNbr - 1) * postsPerPage)
limit (postsPerPage + 1)
result; withRetryDefault
}
/// Find posts to be displayed on a tag list page
let findPageOfTaggedPosts (webLogId : WebLogId) (tag : string) pageNbr postsPerPage =
rethink<Post list> {
withTable Table.Post
getAll [ tag ] "tags"
filter "webLogId" webLogId
filter "status" Published
without [ "priorPermalinks"; "revisions" ]
orderByDescending "publishedOn"
skip ((pageNbr - 1) * postsPerPage)
limit (postsPerPage + 1)
result; withRetryDefault
}
/// Find the next older and newer post for the given post
let findSurroundingPosts (webLogId : WebLogId) (publishedOn : DateTime) conn = backgroundTask {
let! older =
rethink<Post list> {
withTable Table.Post
getAll [ webLogId ] (nameof webLogId)
filter (fun row -> row["publishedOn"].Lt publishedOn :> obj)
orderByDescending "publishedOn"
limit 1
result; withRetryDefault
}
|> tryFirst <| conn
let! newer =
rethink<Post list> {
withTable Table.Post
getAll [ webLogId ] (nameof webLogId)
filter (fun row -> row["publishedOn"].Gt publishedOn :> obj)
orderBy "publishedOn"
limit 1
result; withRetryDefault
}
|> tryFirst <| conn
return older, newer
}
/// Update a post (all fields are updated)
let update (post : Post) =
rethink {
withTable Table.Post
get post.id
replace post
write; withRetryDefault; ignoreResult
}
/// Update prior permalinks for a post
let updatePriorPermalinks (postId : PostId) webLogId (permalinks : Permalink list) conn = backgroundTask {
match! (
rethink<Post> {
withTable Table.Post
get postId
without [ "revisions"; "priorPermalinks" ]
resultOption; withRetryOptionDefault
}
|> verifyWebLog webLogId (fun p -> p.webLogId)) conn with
| Some _ ->
do! rethink {
withTable Table.Post
get postId
update [ "priorPermalinks", permalinks :> obj ]
write; withRetryDefault; ignoreResult conn
}
return true
| None -> return false
}
/// Functions to manipulate tag mappings
module TagMap =
open RethinkDb.Driver.Model
/// Delete a tag mapping
let delete (tagMapId : TagMapId) (webLogId : WebLogId) conn = backgroundTask {
let! result =
rethink<Result> {
withTable Table.TagMap
getAll [ tagMapId ]
filter (fun row -> row["webLogId"].Eq webLogId :> obj)
delete
write; withRetryDefault conn
}
return result.Deleted > 0UL
}
/// Find a tag map by its ID
let findById (tagMapId : TagMapId) webLogId =
rethink<TagMap> {
withTable Table.TagMap
get tagMapId
resultOption; withRetryOptionDefault
}
|> verifyWebLog webLogId (fun tm -> tm.webLogId)
/// Find a tag mapping via URL value for a given web log
let findByUrlValue (urlValue : string) (webLogId : WebLogId) =
rethink<TagMap list> {
withTable Table.TagMap
getAll [ r.Array (webLogId, urlValue) ] "webLogAndUrl"
limit 1
result; withRetryDefault
}
|> tryFirst
/// Find all tag mappings for a web log
let findByWebLogId (webLogId : WebLogId) =
rethink<TagMap list> {
withTable Table.TagMap
between (r.Array (webLogId, r.Minval ())) (r.Array (webLogId, r.Maxval ())) [ Index "webLogAndTag" ]
orderBy "tag"
result; withRetryDefault
}
/// Retrieve mappings for the specified tags
let findMappingForTags (tags : string list) (webLogId : WebLogId) =
rethink<TagMap list> {
withTable Table.TagMap
getAll (tags |> List.map (fun tag -> r.Array (webLogId, tag) :> obj)) "webLogAndTag"
result; withRetryDefault
}
/// Save a tag mapping
let save (tagMap : TagMap) =
rethink {
withTable Table.TagMap
get tagMap.id
replace tagMap
write; withRetryDefault; ignoreResult
}
/// Functions to manipulate web logs
module WebLog =
/// Add a web log
let add (webLog : WebLog) = rethink {
withTable Table.WebLog
insert webLog
write; withRetryOnce; ignoreResult
}
/// Get all web logs
let all = rethink<WebLog list> {
withTable Table.WebLog
result; withRetryDefault
}
/// Retrieve a web log by the URL base
let findByHost (url : string) =
rethink<WebLog list> {
withTable Table.WebLog
getAll [ url ] "urlBase"
limit 1
result; withRetryDefault
}
|> tryFirst
/// Retrieve a web log by its ID
let findById (webLogId : WebLogId) =
rethink<WebLog> {
withTable Table.WebLog
get webLogId
resultOption; withRetryOptionDefault
}
/// Update web log settings (updates all values)
let updateSettings (webLog : WebLog) =
rethink {
withTable Table.WebLog
get webLog.id
replace webLog
write; withRetryDefault; ignoreResult
}
/// Functions to manipulate web log users
module WebLogUser =
/// Add a web log user
let add (user : WebLogUser) =
rethink {
withTable Table.WebLogUser
insert user
write; withRetryDefault; ignoreResult
}
/// Find a user by their e-mail address
let findByEmail (email : string) (webLogId : WebLogId) =
rethink<WebLogUser list> {
withTable Table.WebLogUser
getAll [ r.Array (webLogId, email) ] "logOn"
limit 1
result; withRetryDefault
}
|> tryFirst
/// Find a user by their ID
let findById (userId : WebLogUserId) =
rethink<WebLogUser> {
withTable Table.WebLogUser
get userId
resultOption; withRetryOptionDefault
}
/// Get a user ID -> name dictionary for the given user IDs
let findNames (webLogId : WebLogId) conn (userIds : WebLogUserId list) = backgroundTask {
let! users = rethink<WebLogUser list> {
withTable Table.WebLogUser
getAll (objList userIds)
filter "webLogId" webLogId
result; withRetryDefault conn
}
return users |> List.map (fun u -> { name = WebLogUserId.toString u.id; value = WebLogUser.displayName u })
}
/// Update a user
let update (user : WebLogUser) =
rethink {
withTable Table.WebLogUser
get user.id
update [
"firstName", user.firstName :> obj
"lastName", user.lastName
"preferredName", user.preferredName
"passwordHash", user.passwordHash
"salt", user.salt
]
write; withRetryDefault; ignoreResult
}