WIP on PostgreSQL (#37)
This commit is contained in:
parent
7d2a2a50eb
commit
55a835f9b3
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,5 +1,6 @@
|
||||||
.ionide
|
.ionide
|
||||||
.fake
|
.fake
|
||||||
|
.idea
|
||||||
src/**/bin
|
src/**/bin
|
||||||
src/**/obj
|
src/**/obj
|
||||||
src/**/appsettings.*.json
|
src/**/appsettings.*.json
|
||||||
|
|
|
@ -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>]
|
||||||
|
|
|
@ -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" |}
|
||||||
|
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
Loading…
Reference in New Issue
Block a user