WIP on PostgreSQL (#37)

This commit is contained in:
Daniel J. Summers 2022-08-21 23:51:29 -04:00
parent 7d2a2a50eb
commit 55a835f9b3
4 changed files with 177 additions and 66 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
.ionide .ionide
.fake .fake
.idea
src/**/bin src/**/bin
src/**/obj src/**/obj
src/**/appsettings.*.json src/**/appsettings.*.json

View File

@ -7,7 +7,11 @@ 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 = CitizenId of Guid type CitizenId =
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>]

View File

@ -1,6 +1,7 @@
/// 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
@ -87,12 +88,20 @@ module Table =
/// 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; Listing; Profile; Success ]
open NodaTime
open Npgsql
open Npgsql.FSharp
open RethinkDb.Driver.FSharp.Functions open RethinkDb.Driver.FSharp.Functions
open RethinkDb.Driver.Net open RethinkDb.Driver.Net
@ -122,7 +131,6 @@ module Startup =
open Microsoft.Extensions.Configuration open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Logging open Microsoft.Extensions.Logging
open NodaTime
open NodaTime.Serialization.JsonNet open NodaTime.Serialization.JsonNet
open RethinkDb.Driver.FSharp open RethinkDb.Driver.FSharp
@ -137,42 +145,93 @@ module Startup =
log.LogInformation $"Connecting to rethinkdb://{config.Hostname}:{config.Port}/{config.Database}" log.LogInformation $"Connecting to rethinkdb://{config.Hostname}:{config.Port}/{config.Database}"
config.CreateConnection () config.CreateConnection ()
/// Ensure the data, tables, and indexes that are required exist /// Ensure the tables and indexes that are required exist
let establishEnvironment (cfg : IConfigurationSection) (log : ILogger) conn = task { let establishEnvironment (log : ILogger) conn = task {
// Ensure the database exists
match cfg["database"] |> Option.ofObj with let! tables =
| Some database -> Sql.existingConnection conn
let! dbs = dbList () |> result<string list> conn |> Sql.query "SELECT tablename FROM pg_tables WHERE schemaname = 'jjj'"
match dbs |> List.contains database with |> Sql.executeAsync (fun row -> row.string "tablename")
| true -> () let needsTable table = not (List.contains table tables)
| false ->
log.LogInformation $"Creating database {database}..." let sql = seq {
do! dbCreate database |> write conn if needsTable "continent" then
() "CREATE TABLE jjj.continent (
| None -> () id UUID NOT NULL PRIMARY KEY,
// Ensure the tables exist name TEXT NOT NULL)"
let! tables = tableListFromDefault () |> result<string list> conn if needsTable "citizen" then
for table in Table.all () do "CREATE TABLE jjj.citizen (
if not (List.contains table tables) then id UUID NOT NULL PRIMARY KEY,
log.LogInformation $"Creating {table} table..." display_name TEXT,
do! tableCreateInDefault table |> write conn profile_urls TEXT[] NOT NULL DEFAULT '{}',
// Ensure the indexes exist joined_on TIMESTAMPTZ NOT NULL,
let ensureIndexes table indexes = task { last_seen_on TIMESTAMPTZ NOT NULL,
let! tblIndexes = fromTable table |> indexList |> result<string list> conn is_legacy BOOLEAN NOT NULL)"
for index in indexes do if needsTable "profile" then
if not (List.contains index tblIndexes) then "CREATE TABLE jjj.profile (
log.LogInformation $"Creating \"{index}\" index on {table}" citizen_id UUID NOT NULL PRIMARY KEY,
do! fromTable table |> indexCreate index |> write conn is_seeking BOOLEAN NOT NULL,
is_public_searchable BOOLEAN NOT NULL,
is_public_linkable BOOLEAN NOT NULL,
continent_id UUID NOT NULL,
region TEXT NOT NULL,
is_available_remote BOOLEAN NOT NULL,
is_available_full_time BOOLEAN NOT NULL,
biography TEXT NOT NULL,
last_updated_on TIMESTAMPTZ NOT NULL,
experience TEXT,
FOREIGN KEY fk_profile_citizen (citizen_id) REFERENCES jjj.citizen (id) ON DELETE CASCADE,
FOREIGN KEY fk_profile_continent (continent_id) REFERENCES jjj.continent (id))"
"CREATE INDEX idx_profile_citizen ON jjj.profile (citizen_id)"
"CREATE INDEX idx_profile_continent ON jjj.profile (continent_id)"
"CREATE TABLE jjj.profile_skill (
id UUID NOT NULL PRIMARY KEY,
citizen_id UUID NOT NULL,
description TEXT NOT NULL,
notes TEXT,
FOREIGN KEY fk_profile_skill_profile (citizen_id) REFERENCES jjj.profile (citizen_id)
ON DELETE CASCADE)"
"CREATE INDEX idx_profile_skill_profile ON jjj.profile_skill (citizen_id)"
if needsTable "listing" then
"CREATE TABLE jjj.listing (
id UUID NOT NULL PRIMARY KEY,
citizen_id UUID NOT NULL,
created_on TIMESTAMPTZ NOT NULL,
title TEXT NOT NULL,
continent_id UUID NOT NULL,
region TEXT NOT NULL,
is_remote BOOLEAN NOT NULL,
is_expired BOOLEAN NOT NULL,
updated_on TIMESTAMPTZ NOT NULL,
listing_text TEXT NOT NULL,
needed_by DATE,
was_filled_here BOOLEAN,
FOREIGN KEY fk_listing_citizen (citizen_id) REFERENCES jjj.citizen (id) ON DELETE CASCADE,
FOREIGN KEY fk_listing_continent (continent_id) REFERENCES jjj.continent (id))"
"CREATE INDEX idx_listing_citizen ON jjj.listing (citizen_id)"
"CREATE INDEX idx_listing_continent ON jjj.listing (continent_id)"
if needsTable "success" then
"CREATE TABLE jjj.success (
id UUID NOT NULL PRIMARY KEY,
citizen_id UUID NOT NULL,
recorded_on TIMESTAMPTZ NOT NULL,
was_from_here BOOLEAN NOT NULL,
source TEXT NOT NULL,
story TEXT,
FOREIGN KEY fk_success_citizen (citizen_id) REFERENCES jjj.citizen (id) ON DELETE CASCADE)"
"CREATE INDEX idx_success_citizen ON jjj.success (citizen_id)"
} }
do! ensureIndexes Table.Listing [ "citizenId"; "continentId"; "isExpired" ] if not (Seq.isEmpty sql) then
do! ensureIndexes Table.Profile [ "continentId" ] let! _ =
do! ensureIndexes Table.Success [ "citizenId" ] Sql.existingConnection conn
// The instance/user is a compound index |> Sql.executeTransactionAsync
let! userIdx = fromTable Table.Citizen |> indexList |> result<string list> conn (sql
if not (List.contains "instanceUser" userIdx) then |> Seq.map (fun it ->
do! fromTable Table.Citizen let parts = it.Split ' '
|> indexCreateFunc "instanceUser" (fun row -> [| row.G "instance"; row.G "mastodonUser" |]) log.LogInformation $"Creating {parts[2]} {parts[1].ToLowerInvariant ()}..."
|> write conn it, [ [] ])
|> List.ofSeq)
()
} }
@ -196,22 +255,72 @@ 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")
/// Map data results to domain types
module Map =
/// Extract a count from a row
let toCount (row : RowReader) =
row.int64 "the_count"
/// Create a profile from a data row
let toProfile (row : RowReader) : Profile =
{ id = CitizenId (row.uuid "citizen_id")
seekingEmployment = row.bool "is_seeking"
isPublic = row.bool "is_public_searchable"
continentId = ContinentId (row.uuid "continent_id")
region = row.string "region"
remoteWork = row.bool "is_available_remote"
fullTime = row.bool "is_available_full_time"
biography = Text (row.string "biography")
lastUpdatedOn = row.fieldValue<Instant> "last_updated_on"
experience = row.stringOrNone "experience" |> Option.map Text
skills = []
}
/// Create a skill from a data row
let toSkill (row : RowReader) : Skill =
{ id = SkillId (row.uuid "id")
description = row.string "description"
notes = row.stringOrNone "notes"
}
/// Profile data access functions /// Profile data access functions
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Profile = module Profile =
/// Count the current profiles /// Count the current profiles
let count conn = let count conn =
fromTable Table.Profile Sql.existingConnection conn
|> count |> Sql.query
|> result<int64> conn "SELECT COUNT(p.citizen_id)
FROM jjj.profile p
INNER JOIN jjj.citizen c ON c.id = p.citizen_id
WHERE c.is_legacy = FALSE"
|> Sql.executeRowAsync Map.toCount
/// Find a profile by citizen ID /// Find a profile by citizen ID
let findById (citizenId : CitizenId) conn = let findById (citizenId : CitizenId) conn = backgroundTask {
fromTable Table.Profile let! tryProfile =
|> get citizenId Sql.existingConnection conn
|> resultOption<Profile> conn |> Sql.query
"SELECT *
FROM jjj.profile p
INNER JOIN jjj.citizen ON c.id = p.citizen_id
WHERE p.citizen_id = @id
AND c.is_legacy = FALSE"
|> Sql.parameters [ "@id", Sql.uuid citizenId.Value ]
|> Sql.executeAsync Map.toProfile
match List.tryHead tryProfile with
| Some profile ->
let! skills =
Sql.existingConnection conn
|> Sql.query "SELECT * FROM jjj.profile_skill WHERE citizen_id = @id"
|> Sql.parameters [ "@id", Sql.uuid citizenId.Value ]
|> Sql.executeAsync Map.toSkill
return Some { profile with skills = skills }
| None -> return None
}
/// Insert or update a profile /// Insert or update a profile
let save (profile : Profile) conn = let save (profile : Profile) conn =
fromTable Table.Profile fromTable Table.Profile
@ -220,11 +329,14 @@ module Profile =
|> write conn |> write conn
/// Delete a citizen's profile /// Delete a citizen's profile
let delete (citizenId : CitizenId) conn = let delete (citizenId : CitizenId) conn = backgroundTask {
fromTable Table.Profile let! _ =
|> get citizenId Sql.existingConnection conn
|> delete |> Sql.query "DELETE FROM jjj.profile WHERE citizen_id = @id"
|> write conn |> Sql.parameters [ "@id", Sql.uuid citizenId.Value ]
|> Sql.executeNonQueryAsync
()
}
/// Search profiles (logged-on users) /// Search profiles (logged-on users)
let search (search : ProfileSearch) conn = let search (search : ProfileSearch) conn =
@ -321,20 +433,13 @@ module Citizen =
|> write conn |> write conn
/// Delete a citizen /// Delete a citizen
let delete citizenId conn = task { let delete (citizenId : CitizenId) conn = backgroundTask {
do! Profile.delete citizenId conn let! _ =
do! fromTable Table.Success Sql.existingConnection conn
|> getAllWithIndex [ citizenId ] "citizenId" |> Sql.query "DELETE FROM citizen WHERE id = @id"
|> delete |> Sql.parameters [ "@id", Sql.uuid citizenId.Value ]
|> write conn |> Sql.executeNonQueryAsync
do! fromTable Table.Listing ()
|> getAllWithIndex [ citizenId ] "citizenId"
|> delete
|> write conn
do! fromTable Table.Citizen
|> get citizenId
|> delete
|> write conn
} }
/// Update a citizen's real name /// Update a citizen's real name
@ -365,8 +470,6 @@ module Continent =
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Listing = module Listing =
open NodaTime
/// Convert a joined query to the form needed for ListingForView deserialization /// Convert a joined query to the form needed for ListingForView deserialization
let private toListingForView (it : ReqlExpr) : obj = {| listing = it.G "left"; continent = it.G "right" |} let private toListingForView (it : ReqlExpr) : obj = {| listing = it.G "left"; continent = it.G "right" |}

View File

@ -27,6 +27,9 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.6" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.6" />
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" /> <PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" /> <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
<PackageReference Include="Npgsql" Version="6.0.6" />
<PackageReference Include="Npgsql.FSharp" Version="5.3.0" />
<PackageReference Include="Npgsql.NodaTime" Version="6.0.6" />
<PackageReference Include="RethinkDb.Driver" Version="2.3.150" /> <PackageReference Include="RethinkDb.Driver" Version="2.3.150" />
<PackageReference Include="RethinkDb.Driver.FSharp" Version="0.9.0-beta-05" /> <PackageReference Include="RethinkDb.Driver.FSharp" Version="0.9.0-beta-05" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.21.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.21.0" />