WIP on PostgreSQL (#37)

This commit is contained in:
Daniel J. Summers 2022-08-22 22:50:50 -04:00
parent 55a835f9b3
commit 1d928b631b
3 changed files with 311 additions and 113 deletions

View File

@ -27,6 +27,9 @@ module CitizenId =
/// Parse a string into a citizen ID /// Parse a string into a citizen ID
let ofString = fromShortGuid >> CitizenId let ofString = fromShortGuid >> CitizenId
/// Get the GUID value of a citizen ID
let value = function CitizenId guid -> guid
/// Support functions for citizens /// Support functions for citizens
module Citizen = module Citizen =
@ -50,6 +53,9 @@ module ContinentId =
/// Parse a string into a continent ID /// Parse a string into a continent ID
let ofString = fromShortGuid >> ContinentId let ofString = fromShortGuid >> ContinentId
/// Get the GUID value of a continent ID
let value = function ContinentId guid -> guid
/// Support functions for listing IDs /// Support functions for listing IDs
module ListingId = module ListingId =
@ -63,6 +69,9 @@ module ListingId =
/// Parse a string into a listing ID /// Parse a string into a listing ID
let ofString = fromShortGuid >> ListingId let ofString = fromShortGuid >> ListingId
/// Get the GUID value of a listing ID
let value = function ListingId guid -> guid
/// Support functions for Markdown strings /// Support functions for Markdown strings
module MarkdownString = module MarkdownString =
@ -85,6 +94,7 @@ module Profile =
{ id = CitizenId Guid.Empty { id = CitizenId Guid.Empty
seekingEmployment = false seekingEmployment = false
isPublic = false isPublic = false
isPublicLinkable = false
continentId = ContinentId Guid.Empty continentId = ContinentId Guid.Empty
region = "" region = ""
remoteWork = false remoteWork = false
@ -108,6 +118,9 @@ module SkillId =
/// Parse a string into a skill ID /// Parse a string into a skill ID
let ofString = fromShortGuid >> SkillId let ofString = fromShortGuid >> SkillId
/// Get the GUID value of a skill ID
let value = function SkillId guid -> guid
/// Support functions for success report IDs /// Support functions for success report IDs
module SuccessId = module SuccessId =
@ -120,3 +133,6 @@ module SuccessId =
/// Parse a string into a success report ID /// Parse a string into a success report ID
let ofString = fromShortGuid >> SuccessId let ofString = fromShortGuid >> SuccessId
/// Get the GUID value of a success ID
let value = function SuccessId guid -> guid

View File

@ -7,11 +7,7 @@ open System
// fsharplint:disable FieldNames // fsharplint:disable FieldNames
/// The ID of a user (a citizen of Gitmo Nation) /// The ID of a user (a citizen of Gitmo Nation)
type CitizenId = type CitizenId = CitizenId of Guid
CitizenId of Guid
with
/// The GUID value of this citizen ID
member this.Value = this |> function CitizenId guid -> guid
/// A user of Jobs, Jobs, Jobs /// A user of Jobs, Jobs, Jobs
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
@ -132,6 +128,9 @@ type Profile =
/// Whether this citizen allows their profile to be a part of the publicly-viewable, anonymous data /// Whether this citizen allows their profile to be a part of the publicly-viewable, anonymous data
isPublic : bool isPublic : bool
/// Whether this citizen allows their profile to be viewed via a public link
isPublicLinkable : bool
/// The ID of the continent on which the citizen resides /// The ID of the continent on which the citizen resides
continentId : ContinentId continentId : ContinentId

View File

@ -1,7 +1,6 @@
/// Data access functions for Jobs, Jobs, Jobs /// Data access functions for Jobs, Jobs, Jobs
module JobsJobsJobs.Api.Data module JobsJobsJobs.Api.Data
open CommonExtensionsAndTypesForNpgsqlFSharp
open JobsJobsJobs.Domain.Types open JobsJobsJobs.Domain.Types
/// JSON converters used with RethinkDB persistence /// JSON converters used with RethinkDB persistence
@ -82,20 +81,14 @@ module Table =
/// The continent table /// The continent table
let Continent = "continent" let Continent = "continent"
/// The job listing table
let Listing = "listing"
/// The citizen employment profile table /// The citizen employment profile table
let Profile = "profile" let Profile = "profile"
/// The profile / skill cross-reference
let ProfileSkill = "profile_skill"
/// The success story table /// The success story table
let Success = "success" let Success = "success"
/// All tables /// All tables
let all () = [ Citizen; Continent; Listing; Profile; Success ] let all () = [ Citizen; Continent; Profile; Success ]
open NodaTime open NodaTime
open Npgsql open Npgsql
@ -175,7 +168,7 @@ module Startup =
is_public_linkable BOOLEAN NOT NULL, is_public_linkable BOOLEAN NOT NULL,
continent_id UUID NOT NULL, continent_id UUID NOT NULL,
region TEXT NOT NULL, region TEXT NOT NULL,
is_available_remote BOOLEAN NOT NULL, is_available_remotely BOOLEAN NOT NULL,
is_available_full_time BOOLEAN NOT NULL, is_available_full_time BOOLEAN NOT NULL,
biography TEXT NOT NULL, biography TEXT NOT NULL,
last_updated_on TIMESTAMPTZ NOT NULL, last_updated_on TIMESTAMPTZ NOT NULL,
@ -255,23 +248,82 @@ let private deriveDisplayName (it : ReqlExpr) =
it.G("displayName").Default_("").Ne "", it.G "displayName", it.G("displayName").Default_("").Ne "", it.G "displayName",
it.G "mastodonUser") it.G "mastodonUser")
/// Custom SQL parameter functions
module Sql =
/// Create a citizen ID parameter
let citizenId = CitizenId.value >> Sql.uuid
/// Create a continent ID parameter
let continentId = ContinentId.value >> Sql.uuid
/// Create a listing ID parameter
let listingId = ListingId.value >> Sql.uuid
/// Create a Markdown string parameter
let markdown = MarkdownString.toString >> Sql.string
/// Create a parameter for the given value
let param<'T> name (value : 'T) =
name, Sql.parameter (NpgsqlParameter (name, value))
/// Create a parameter for a possibly-missing value
let paramOrNone<'T> name (value : 'T option) =
name, Sql.parameter (NpgsqlParameter (name, if Option.isSome value then box value else System.DBNull.Value))
/// Create a skill ID parameter
let skillId = SkillId.value >> Sql.uuid
/// Create a success ID parameter
let successId = SuccessId.value >> Sql.uuid
/// Map data results to domain types /// Map data results to domain types
module Map = module Map =
/// Create a continent from a data row
let toContinent (row : RowReader) : Continent =
{ id = (row.uuid >> ContinentId) "continent_id"
name = row.string "continent_name"
}
/// Extract a count from a row /// Extract a count from a row
let toCount (row : RowReader) = let toCount (row : RowReader) =
row.int64 "the_count" row.int64 "the_count"
/// Create a job listing from a data row
let toListing (row : RowReader) : Listing =
{ id = (row.uuid >> ListingId) "id"
citizenId = (row.uuid >> CitizenId) "citizen_id"
createdOn = row.fieldValue<Instant> "created_on"
title = row.string "title"
continentId = (row.uuid >> ContinentId) "continent_id"
region = row.string "region"
remoteWork = row.bool "is_remote"
isExpired = row.bool "is_expired"
updatedOn = row.fieldValue<Instant> "updated_on"
text = (row.string >> Text) "listing_text"
neededBy = row.fieldValueOrNone<LocalDate> "needed_by"
wasFilledHere = row.boolOrNone "was_filled_here"
}
/// Create a job listing for viewing from a data row
let toListingForView (row : RowReader) : ListingForView =
{ listing = toListing row
continent = toContinent row
}
/// Create a profile from a data row /// Create a profile from a data row
let toProfile (row : RowReader) : Profile = let toProfile (row : RowReader) : Profile =
{ id = CitizenId (row.uuid "citizen_id") { id = (row.uuid >> CitizenId) "citizen_id"
seekingEmployment = row.bool "is_seeking" seekingEmployment = row.bool "is_seeking"
isPublic = row.bool "is_public_searchable" isPublic = row.bool "is_public_searchable"
continentId = ContinentId (row.uuid "continent_id") isPublicLinkable = row.bool "is_public_linkable"
continentId = (row.uuid >> ContinentId) "continent_id"
region = row.string "region" region = row.string "region"
remoteWork = row.bool "is_available_remote" remoteWork = row.bool "is_available_remotely"
fullTime = row.bool "is_available_full_time" fullTime = row.bool "is_available_full_time"
biography = Text (row.string "biography") biography = (row.string >> Text) "biography"
lastUpdatedOn = row.fieldValue<Instant> "last_updated_on" lastUpdatedOn = row.fieldValue<Instant> "last_updated_on"
experience = row.stringOrNone "experience" |> Option.map Text experience = row.stringOrNone "experience" |> Option.map Text
skills = [] skills = []
@ -279,11 +331,21 @@ module Map =
/// Create a skill from a data row /// Create a skill from a data row
let toSkill (row : RowReader) : Skill = let toSkill (row : RowReader) : Skill =
{ id = SkillId (row.uuid "id") { id = (row.uuid >> SkillId) "id"
description = row.string "description" description = row.string "description"
notes = row.stringOrNone "notes" notes = row.stringOrNone "notes"
} }
/// Create a success story from a data row
let toSuccess (row : RowReader) : Success =
{ id = (row.uuid >> SuccessId) "id"
citizenId = (row.uuid >> CitizenId) "citizen_id"
recordedOn = row.fieldValue<Instant> "recorded_on"
fromHere = row.bool "was_from_here"
source = row.string "source"
story = row.stringOrNone "story" |> Option.map Text
}
/// Profile data access functions /// Profile data access functions
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
@ -300,7 +362,7 @@ module Profile =
|> Sql.executeRowAsync Map.toCount |> Sql.executeRowAsync Map.toCount
/// Find a profile by citizen ID /// Find a profile by citizen ID
let findById (citizenId : CitizenId) conn = backgroundTask { let findById citizenId conn = backgroundTask {
let! tryProfile = let! tryProfile =
Sql.existingConnection conn Sql.existingConnection conn
|> Sql.query |> Sql.query
@ -309,31 +371,82 @@ module Profile =
INNER JOIN jjj.citizen ON c.id = p.citizen_id INNER JOIN jjj.citizen ON c.id = p.citizen_id
WHERE p.citizen_id = @id WHERE p.citizen_id = @id
AND c.is_legacy = FALSE" AND c.is_legacy = FALSE"
|> Sql.parameters [ "@id", Sql.uuid citizenId.Value ] |> Sql.parameters [ "@id", Sql.citizenId citizenId ]
|> Sql.executeAsync Map.toProfile |> Sql.executeAsync Map.toProfile
match List.tryHead tryProfile with match List.tryHead tryProfile with
| Some profile -> | Some profile ->
let! skills = let! skills =
Sql.existingConnection conn Sql.existingConnection conn
|> Sql.query "SELECT * FROM jjj.profile_skill WHERE citizen_id = @id" |> Sql.query "SELECT * FROM jjj.profile_skill WHERE citizen_id = @id"
|> Sql.parameters [ "@id", Sql.uuid citizenId.Value ] |> Sql.parameters [ "@id", Sql.citizenId citizenId ]
|> Sql.executeAsync Map.toSkill |> Sql.executeAsync Map.toSkill
return Some { profile with skills = skills } return Some { profile with skills = skills }
| None -> return None | None -> return None
} }
/// Insert or update a profile /// Insert or update a profile
let save (profile : Profile) conn = let save (profile : Profile) conn = backgroundTask {
fromTable Table.Profile let! _ =
|> get profile.id Sql.existingConnection conn
|> replace profile |> Sql.executeTransactionAsync [
|> write conn "INSERT INTO jjj.profile (
citizen_id, is_seeking, is_public_searchable, is_public_linkable, continent_id, region,
is_available_remotely, is_available_full_time, biography, last_updated_on, experience
) VALUES (
@citizenId, @isSeeking, @isPublicSearchable, @isPublicLinkable, @continentId, @region,
@isAvailableRemotely, @isAvailableFullTime, @biography, @lastUpdatedOn, @experience
) ON CONFLICT (citizen_id) DO UPDATE
SET is_seeking = EXCLUDED.is_seeking,
is_public_searchable = EXCLUDED.is_public_searchable,
is_public_linkable = EXCLUDED.is_public_linkable,
continent_id = EXCLUDED.continent_id,
region = EXCLUDED.region,
is_available_remotely = EXCLUDED.is_available_remotely,
is_available_full_time = EXCLUDED.is_available_full_time,
biography = EXCLUDED.biography,
last_updated_on = EXCLUDED.last_updated_on,
experience = EXCLUDED.experience",
[ [ "@citizenId", Sql.citizenId profile.id
"@isSeeking", Sql.bool profile.seekingEmployment
"@isPublicSearchable", Sql.bool profile.isPublic
"@isPublicLinkable", Sql.bool profile.isPublicLinkable
"@continentId", Sql.continentId profile.continentId
"@region", Sql.string profile.region
"@isAvailableRemotely", Sql.bool profile.remoteWork
"@isAvailableFullTime", Sql.bool profile.fullTime
"@biography", Sql.markdown profile.biography
"@lastUpdatedOn" |>Sql.param<| profile.lastUpdatedOn
"@experience", Sql.stringOrNone (Option.map MarkdownString.toString profile.experience)
] ]
"INSERT INTO jjj.profile (
id, citizen_id, description, notes
) VALUES (
@id, @citizenId, @description, @notes
) ON CONFLICT (id) DO UPDATE
SET description = EXCLUDED.description,
notes = EXCLUDED.notes",
profile.skills
|> List.map (fun skill -> [
"@id", Sql.skillId skill.id
"@citizenId", Sql.citizenId profile.id
"@description", Sql.string skill.description
"@notes" , Sql.stringOrNone skill.notes
])
$"""DELETE FROM jjj.profile
WHERE id NOT IN ({profile.skills |> List.mapi (fun idx _ -> $"@id{idx}") |> String.concat ", "})""",
[ profile.skills |> List.mapi (fun idx skill -> $"@id{idx}", Sql.skillId skill.id) ]
]
()
}
/// Delete a citizen's profile /// Delete a citizen's profile
let delete (citizenId : CitizenId) conn = backgroundTask { let delete citizenId conn = backgroundTask {
let! _ = let! _ =
Sql.existingConnection conn Sql.existingConnection conn
|> Sql.query "DELETE FROM jjj.profile WHERE citizen_id = @id" |> Sql.query "DELETE FROM jjj.profile WHERE citizen_id = @id"
|> Sql.parameters [ "@id", Sql.uuid citizenId.Value ] |> Sql.parameters [ "@id", Sql.citizenId citizenId ]
|> Sql.executeNonQueryAsync |> Sql.executeNonQueryAsync
() ()
} }
@ -433,11 +546,11 @@ module Citizen =
|> write conn |> write conn
/// Delete a citizen /// Delete a citizen
let delete (citizenId : CitizenId) conn = backgroundTask { let delete citizenId conn = backgroundTask {
let! _ = let! _ =
Sql.existingConnection conn Sql.existingConnection conn
|> Sql.query "DELETE FROM citizen WHERE id = @id" |> Sql.query "DELETE FROM citizen WHERE id = @id"
|> Sql.parameters [ "@id", Sql.uuid citizenId.Value ] |> Sql.parameters [ "@id", Sql.citizenId citizenId ]
|> Sql.executeNonQueryAsync |> Sql.executeNonQueryAsync
() ()
} }
@ -456,89 +569,138 @@ module Continent =
/// Get all continents /// Get all continents
let all conn = let all conn =
fromTable Table.Continent Sql.existingConnection conn
|> result<Continent list> conn |> Sql.query "SELECT id AS continent_id, name AS continent_name FROM jjj.continent"
|> Sql.executeAsync Map.toContinent
/// Get a continent by its ID /// Get a continent by its ID
let findById (contId : ContinentId) conn = let findById contId conn = backgroundTask {
fromTable Table.Continent let! continent =
|> get contId Sql.existingConnection conn
|> resultOption<Continent> conn |> Sql.query "SELECT id AS continent_id, name AS continent_name FROM jjj.continent WHERE id = @id"
|> Sql.parameters [ "@id", Sql.continentId contId ]
|> Sql.executeAsync Map.toContinent
return List.tryHead continent
}
/// Job listing data access functions /// Job listing data access functions
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Listing = module Listing =
/// Convert a joined query to the form needed for ListingForView deserialization /// The SQL to select the listing and continent fields
let private toListingForView (it : ReqlExpr) : obj = {| listing = it.G "left"; continent = it.G "right" |} let private forViewSql =
"SELECT l.*, c.name AS continent_name
FROM jjj.listing l
INNER JOIN jjj.continent c ON c.id = l.continent_id"
/// Find all job listings posted by the given citizen /// Find all job listings posted by the given citizen
let findByCitizen (citizenId : CitizenId) conn = let findByCitizen citizenId conn =
fromTable Table.Listing Sql.existingConnection conn
|> getAllWithIndex [ citizenId ] (nameof citizenId) |> Sql.query $"{forViewSql} WHERE l.citizen_id = @citizenId"
|> eqJoin "continentId" (fromTable Table.Continent) |> Sql.parameters [ "@citizenId", Sql.citizenId citizenId ]
|> mapFunc toListingForView |> Sql.executeAsync Map.toListingForView
|> result<ListingForView list> conn
/// Find a listing by its ID /// Find a listing by its ID
let findById (listingId : ListingId) conn = let findById listingId conn = backgroundTask {
fromTable Table.Listing
|> get listingId
|> resultOption<Listing> conn
/// Find a listing by its ID for viewing (includes continent information)
let findByIdForView (listingId : ListingId) conn = task {
let! listing = let! listing =
fromTable Table.Listing Sql.existingConnection conn
|> filter {| id = listingId |} |> Sql.query "SELECT * FROM jjj.listing WHERE id = @id"
|> eqJoin "continentId" (fromTable Table.Continent) |> Sql.parameters [ "@id", Sql.listingId listingId ]
|> mapFunc toListingForView |> Sql.executeAsync Map.toListing
|> result<ListingForView list> conn
return List.tryHead listing return List.tryHead listing
} }
/// Add a listing /// Find a listing by its ID for viewing (includes continent information)
let add (listing : Listing) conn = let findByIdForView (listingId : ListingId) conn = backgroundTask {
fromTable Table.Listing let! listing =
|> insert listing Sql.existingConnection conn
|> write conn |> Sql.query $"{forViewSql} WHERE l.id = @id"
|> Sql.parameters [ "@id", Sql.listingId listingId ]
|> Sql.executeAsync Map.toListingForView
return List.tryHead listing
}
/// Update a listing /// Add or update a listing
let update (listing : Listing) conn = let save (listing : Listing) conn = backgroundTask {
fromTable Table.Listing let! _ =
|> get listing.id Sql.existingConnection conn
|> replace listing |> Sql.query
|> write conn "INSERT INTO jjj.listing (
id, citizen_id, created_on, title, continent_id, region, is_remote, is_expired, updated_on,
listing_text, needed_by, was_filled_here
) VALUES (
@id, @citizenId, @createdOn, @title, @continentId, @region, @isRemote, @isExpired, @updatedOn,
@text, @neededBy, @wasFilledHere
) ON CONFLICT (id) DO UPDATE
SET title = EXCLUDED.title,
continent_id = EXCLUDED.continent_id,
region = EXCLUDED.region,
is_remote = EXCLUDED.is_remote,
is_expired = EXCLUDED.is_expired,
updated_on = EXCLUDED.updated_on,
listing_text = EXCLUDED.listing_text,
needed_by = EXCLUDED.needed_by,
was_filled_here = EXCLUDED.was_filled_here"
|> Sql.parameters
[ "@id", Sql.listingId listing.id
"@citizenId", Sql.citizenId listing.citizenId
"@createdOn" |>Sql.param<| listing.createdOn
"@title", Sql.string listing.title
"@continentId", Sql.continentId listing.continentId
"@region", Sql.string listing.region
"@isRemote", Sql.bool listing.remoteWork
"@isExpired", Sql.bool listing.isExpired
"@updatedOn" |>Sql.param<| listing.updatedOn
"@text", Sql.markdown listing.text
"@neededBy" |>Sql.paramOrNone<| listing.neededBy
"@wasFilledHere", Sql.boolOrNone listing.wasFilledHere
]
|> Sql.executeNonQueryAsync
()
}
/// Expire a listing /// Expire a listing
let expire (listingId : ListingId) (fromHere : bool) (now : Instant) conn = let expire listingId fromHere (now : Instant) conn = backgroundTask {
(fromTable Table.Listing let! _ =
|> get listingId) Sql.existingConnection conn
.Update {| isExpired = true; wasFilledHere = fromHere; updatedOn = now |} |> Sql.query
|> write conn "UPDATE jjj.listing
SET is_expired = TRUE,
was_filled_here = @wasFilledHere,
updated_on = @updatedOn
WHERE id = @id"
|> Sql.parameters
[ "@wasFilledHere", Sql.bool fromHere
"@updatedOn" |>Sql.param<| now
"@id", Sql.listingId listingId
]
|> Sql.executeNonQueryAsync
()
}
/// Search job listings /// Search job listings
let search (search : ListingSearch) conn = let search (search : ListingSearch) conn =
fromTable Table.Listing let filters = seq {
|> getAllWithIndex [ false ] "isExpired" match search.continentId with
|> applyFilters | Some contId ->
[ match search.continentId with "l.continent = @continentId", [ "@continentId", Sql.continentId (ContinentId.ofString contId) ]
| Some contId -> yield filter {| continentId = ContinentId.ofString contId |}
| None -> () | None -> ()
match search.region with match search.region with
| Some rgn -> yield filterFunc (fun it -> it.G(nameof search.region).Match (regexContains rgn)) | Some region -> "l.region ILIKE '%@region%'", [ "@region", Sql.string region ]
| None -> () | None -> ()
match search.remoteWork with if search.remoteWork <> "" then
| "" -> () "l.is_remote = @isRemote", ["@isRemote", Sql.bool (search.remoteWork = "yes") ]
| _ -> yield filter {| remoteWork = search.remoteWork = "yes" |}
match search.text with match search.text with
| Some text -> yield filterFunc (fun it -> it.G(nameof search.text).Match (regexContains text)) | Some text -> "l.listing_text ILIKE '%@text%'", [ "@text", Sql.string text ]
| None -> () | None -> ()
] }
|> eqJoin "continentId" (fromTable Table.Continent) let filterSql = filters |> Seq.map fst |> String.concat " AND "
|> mapFunc toListingForView Sql.existingConnection conn
|> result<ListingForView list> conn |> Sql.query $"{forViewSql} WHERE l.is_expired = FALSE{filterSql}"
|> Sql.parameters (filters |> Seq.collect snd |> List.ofSeq)
|> Sql.executeAsync Map.toListingForView
/// Success story data access functions /// Success story data access functions
@ -546,17 +708,38 @@ module Listing =
module Success = module Success =
/// Find a success report by its ID /// Find a success report by its ID
let findById (successId : SuccessId) conn = let findById successId conn = backgroundTask {
fromTable Table.Success let! success =
|> get successId Sql.existingConnection conn
|> resultOption conn |> Sql.query "SELECT * FROM jjj.success WHERE id = @id"
|> Sql.parameters [ "@id", Sql.successId successId ]
|> Sql.executeAsync Map.toSuccess
return List.tryHead success
}
/// Insert or update a success story /// Insert or update a success story
let save (success : Success) conn = let save (success : Success) conn = backgroundTask {
fromTable Table.Success let! _ =
|> get success.id Sql.existingConnection conn
|> replace success |> Sql.query
|> write conn "INSERT INTO jjj.success (
id, citizen_id, recorded_on, was_from_here, source, story
) VALUES (
@id, @citizenId, @recordedOn, @wasFromHere, @source, @story
) ON CONFLICT (id) DO UPDATE
SET was_from_here = EXCLUDED.was_from_here,
story = EXCLUDED.story"
|> Sql.parameters
[ "@id", Sql.successId success.id
"@citizenId", Sql.citizenId success.citizenId
"@recordedOn" |>Sql.param<| success.recordedOn
"@wasFromHere", Sql.bool success.fromHere
"@source", Sql.string success.source
"@story", Sql.stringOrNone (Option.map MarkdownString.toString success.story)
]
|> Sql.executeNonQueryAsync
()
}
// Retrieve all success stories // Retrieve all success stories
let all conn = let all conn =