Compare commits
55 Commits
v2.0-beta0
...
main
Author | SHA1 | Date | |
---|---|---|---|
f4be57b665 | |||
7f94e0beef | |||
f59566a3d3 | |||
f2f766fc05 | |||
75c4d4f991 | |||
b50d0d9884 | |||
7ae15b9e93 | |||
823286255b | |||
f1a7e55f3e | |||
7b325dc19e | |||
5f3daa1de9 | |||
1ec664ad24 | |||
33698bd182 | |||
6b49793fbb | |||
a8386d6c97 | |||
b1ca48c2c5 | |||
3189681021 | |||
ff9c08842b | |||
e103738d39 | |||
d854178255 | |||
0a32181e65 | |||
81fe03b8f3 | |||
4514c4864d | |||
99ccdebcc7 | |||
59f385122b | |||
41ae1d8dad | |||
1e987fdf72 | |||
7eaad4a076 | |||
5fb3a73dcf | |||
e0a03bfca9 | |||
d30312c23f | |||
eae1509d81 | |||
425223a3a8 | |||
07aff16c3a | |||
d290e6e8a6 | |||
039d09aed5 | |||
d667d09372 | |||
2906c20efa | |||
355ade8c87 | |||
1d096d696b | |||
ce3816a8ae | |||
879710a0a3 | |||
c957279162 | |||
9307ace24a | |||
feada6f11f | |||
0567dff54a | |||
c29bbc04ac | |||
46bd785a1f | |||
3203f1b2ee | |||
7203fa5a38 | |||
16603bbcaf | |||
80c65bcad6 | |||
9fbb1bb14d | |||
707b67c630 | |||
dfb0ff3b9c |
@ -1,12 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"fake-cli": {
|
||||
"version": "5.22.0",
|
||||
"commands": [
|
||||
"fake"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
99
.github/workflows/ci.yml
vendored
Normal file
99
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,99 @@
|
||||
name: Continuous Integration
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
env:
|
||||
MWL_TEST_RETHINK_URI: rethinkdb://localhost/mwl_test
|
||||
jobs:
|
||||
build_and_test:
|
||||
name: Build / Test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
dotnet-version:
|
||||
- "6.0"
|
||||
- "7.0"
|
||||
- "8.0"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
rethink:
|
||||
image: rethinkdb:latest
|
||||
ports:
|
||||
- 28015:28015
|
||||
|
||||
steps:
|
||||
- name: Check Out Code
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup .NET Core SDK
|
||||
uses: actions/setup-dotnet@v4.0.0
|
||||
with:
|
||||
dotnet-version: 8.x
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore src/MyWebLog.sln
|
||||
- name: Build (${{ matrix.dotnet-version }})
|
||||
run: dotnet build src/MyWebLog.sln -f net${{ matrix.dotnet-version }}
|
||||
- name: Test (${{ matrix.dotnet-version }})
|
||||
run: cd src/MyWebLog.Tests; dotnet run -f net${{ matrix.dotnet-version }}
|
||||
|
||||
publish:
|
||||
name: Publish Packages
|
||||
runs-on: ubuntu-latest
|
||||
needs: build_and_test
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
ver:
|
||||
- "net6.0"
|
||||
- "net7.0"
|
||||
- "net8.0"
|
||||
os:
|
||||
- "linux-x64"
|
||||
- "win-x64"
|
||||
include:
|
||||
- os: "linux-x64"
|
||||
bz2: true
|
||||
- os: "win-x64"
|
||||
zip: true
|
||||
steps:
|
||||
- name: Check Out Code
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup .NET Core SDK
|
||||
uses: actions/setup-dotnet@v4.0.0
|
||||
with:
|
||||
dotnet-version: 8.x
|
||||
- name: Publish (Release)
|
||||
run: dotnet publish -c Release -f ${{ matrix.ver }} -r ${{ matrix.os }} src/MyWebLog/MyWebLog.fsproj
|
||||
- name: Zip Admin Theme
|
||||
run: cd src/admin-theme; zip -r ../MyWebLog/bin/Release/${{ matrix.ver }}/${{ matrix.os }}/publish/admin-theme.zip *; cd ../..
|
||||
- name: Zip Default Theme
|
||||
run: cd src/default-theme; zip -r ../MyWebLog/bin/Release/${{ matrix.ver }}/${{ matrix.os }}/publish/default-theme.zip *; cd ../..
|
||||
- if: ${{ matrix.bz2 }}
|
||||
name: Create .tar.bz2 Archive
|
||||
run: tar cfj myWebLog-${{ matrix.ver }}-${{ matrix.os }}.tar.bz2 -C src/MyWebLog/bin/Release/${{ matrix.ver }}/${{ matrix.os }}/publish .
|
||||
- if: ${{ matrix.zip }}
|
||||
name: Create .zip Archive
|
||||
run: cd src/MyWebLog/bin/Release/${{ matrix.ver }}/${{ matrix.os }}/publish; zip -r myWebLog-${{ matrix.ver }}-${{ matrix.os }}.zip *; cp myWeb*.zip ../../../../../../..; cd ../../../../../../..
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: package-${{ matrix.ver }}-${{ matrix.os }}
|
||||
path: |
|
||||
*x64.zip
|
||||
*.bz2
|
7
.gitignore
vendored
7
.gitignore
vendored
@ -260,4 +260,9 @@ paket-files/
|
||||
src/MyWebLog/wwwroot/img/daniel-j-summers
|
||||
src/MyWebLog/wwwroot/img/bit-badger
|
||||
|
||||
.ionide
|
||||
.ionide
|
||||
.vscode
|
||||
src/MyWebLog/appsettings.Production.json
|
||||
|
||||
# SQLite database files
|
||||
src/MyWebLog/data/*.db*
|
||||
|
166
build.fs
Normal file
166
build.fs
Normal file
@ -0,0 +1,166 @@
|
||||
open System.IO
|
||||
open Fake.Core
|
||||
open Fake.DotNet
|
||||
open Fake.IO
|
||||
open Fake.IO.Globbing.Operators
|
||||
|
||||
let execContext = Context.FakeExecutionContext.Create false "build.fsx" []
|
||||
Context.setExecutionContext (Context.RuntimeContext.Fake execContext)
|
||||
|
||||
/// The output directory for release ZIPs
|
||||
let releasePath = "releases"
|
||||
|
||||
/// The path to the main project
|
||||
let projectPath = "src/MyWebLog"
|
||||
|
||||
/// The path and name of the main project
|
||||
let projName = $"{projectPath}/MyWebLog.fsproj"
|
||||
|
||||
/// The version being packaged (extracted from appsettings.json)
|
||||
let version =
|
||||
let settings = File.ReadAllText $"{projectPath}/appsettings.json"
|
||||
let generator = settings.Substring (settings.IndexOf "\"Generator\":")
|
||||
let appVersion = generator.Replace("\"Generator\": \"", "")
|
||||
let appVersion = appVersion.Substring (0, appVersion.IndexOf "\"")
|
||||
appVersion.Split ' ' |> Array.last
|
||||
|
||||
/// Zip a theme distributed with myWebLog
|
||||
let zipTheme (name : string) (_ : TargetParameter) =
|
||||
let path = $"src/{name}-theme"
|
||||
!! $"{path}/**/*"
|
||||
|> Zip.filesAsSpecs path
|
||||
|> Seq.filter (fun (_, name) -> not (name.EndsWith ".zip"))
|
||||
|> Zip.zipSpec $"{releasePath}/{name}-theme.zip"
|
||||
|
||||
/// Frameworks supported by this build
|
||||
let frameworks = [ "net6.0"; "net8.0" ]
|
||||
|
||||
/// Publish the project for the given runtime ID
|
||||
let publishFor rid (_ : TargetParameter) =
|
||||
frameworks
|
||||
|> List.iter (fun fwk ->
|
||||
DotNet.publish
|
||||
(fun opts ->
|
||||
{ opts with Runtime = Some rid; SelfContained = Some false; NoLogo = true; Framework = Some fwk })
|
||||
projName)
|
||||
|
||||
/// Package published output for the given runtime ID
|
||||
let packageFor rid (_ : TargetParameter) =
|
||||
frameworks
|
||||
|> List.iter (fun fwk ->
|
||||
let path = $"{projectPath}/bin/Release/{fwk}/%s{rid}/publish"
|
||||
let prodSettings = $"{path}/appsettings.Production.json"
|
||||
if File.exists prodSettings then File.delete prodSettings
|
||||
[ !! $"{path}/**/*"
|
||||
|> Zip.filesAsSpecs path
|
||||
|> Seq.map (fun (orig, dest) ->
|
||||
orig, if dest.StartsWith "MyWebLog" then dest.Replace ("MyWebLog", "myWebLog") else dest)
|
||||
Seq.singleton ($"{releasePath}/admin-theme.zip", "admin-theme.zip")
|
||||
Seq.singleton ($"{releasePath}/default-theme.zip", "default-theme.zip")
|
||||
]
|
||||
|> Seq.concat
|
||||
|> Zip.zipSpec $"{releasePath}/myWebLog-{version}.{fwk}.{rid}.zip")
|
||||
|
||||
|
||||
Target.create "Clean" (fun _ ->
|
||||
!! "src/**/bin"
|
||||
++ "src/**/obj"
|
||||
|> Shell.cleanDirs
|
||||
Shell.cleanDir releasePath
|
||||
)
|
||||
|
||||
Target.create "Build" (fun _ ->
|
||||
DotNet.build (fun opts -> { opts with NoLogo = true }) projName
|
||||
)
|
||||
|
||||
Target.create "ZipAdminTheme" (zipTheme "admin")
|
||||
Target.create "ZipDefaultTheme" (zipTheme "default")
|
||||
|
||||
Target.create "PublishWindows" (publishFor "win-x64")
|
||||
Target.create "PackageWindows" (packageFor "win-x64")
|
||||
|
||||
Target.create "PublishLinux" (publishFor "linux-x64")
|
||||
Target.create "PackageLinux" (packageFor "linux-x64")
|
||||
|
||||
Target.create "RepackageLinux" (fun _ ->
|
||||
let workDir = $"{releasePath}/linux"
|
||||
frameworks
|
||||
|> List.iter (fun fwk ->
|
||||
let zipArchive = $"{releasePath}/myWebLog-{version}.{fwk}.linux-x64.zip"
|
||||
let sh command args =
|
||||
CreateProcess.fromRawCommand command args
|
||||
|> CreateProcess.redirectOutput
|
||||
|> Proc.run
|
||||
|> ignore
|
||||
Shell.mkdir workDir
|
||||
Zip.unzip workDir zipArchive
|
||||
Shell.cd workDir
|
||||
sh "chmod" [ "+x"; "./myWebLog" ]
|
||||
sh "tar" [ "cfj"; $"../myWebLog-{version}.{fwk}.linux-x64.tar.bz2"; "." ]
|
||||
Shell.cd "../.."
|
||||
Shell.rm zipArchive)
|
||||
Shell.rm_rf workDir
|
||||
)
|
||||
|
||||
Target.create "All" ignore
|
||||
|
||||
Target.create "RemoveThemeArchives" (fun _ ->
|
||||
Shell.rm $"{releasePath}/admin-theme.zip"
|
||||
Shell.rm $"{releasePath}/default-theme.zip"
|
||||
)
|
||||
|
||||
Target.create "CI" ignore
|
||||
|
||||
open Fake.Core.TargetOperators
|
||||
|
||||
let dependencies = [
|
||||
"Clean"
|
||||
==> "All"
|
||||
|
||||
"Clean"
|
||||
?=> "Build"
|
||||
==> "All"
|
||||
|
||||
"Clean"
|
||||
?=> "ZipDefaultTheme"
|
||||
==> "All"
|
||||
|
||||
"Clean"
|
||||
?=> "ZipAdminTheme"
|
||||
==> "All"
|
||||
|
||||
"Build"
|
||||
==> "PublishWindows"
|
||||
==> "All"
|
||||
|
||||
"Build"
|
||||
==> "PublishLinux"
|
||||
==> "All"
|
||||
|
||||
"PublishWindows"
|
||||
==> "PackageWindows"
|
||||
==> "All"
|
||||
|
||||
"PublishLinux"
|
||||
==> "PackageLinux"
|
||||
==> "All"
|
||||
|
||||
"PackageLinux"
|
||||
==> "RepackageLinux"
|
||||
==> "All"
|
||||
|
||||
"All"
|
||||
==> "RemoveThemeArchives"
|
||||
==> "CI"
|
||||
]
|
||||
|
||||
[<EntryPoint>]
|
||||
let main args =
|
||||
try
|
||||
match args with
|
||||
| [| target |] -> Target.runOrDefault target
|
||||
| _ -> Target.runOrDefault "All"
|
||||
0
|
||||
with e ->
|
||||
printfn "%A" e
|
||||
1
|
20
build.fsproj
Normal file
20
build.fsproj
Normal file
@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="build.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Fake.Core.Target" Version="6.0.0" />
|
||||
<PackageReference Include="Fake.DotNet.Cli" Version="6.0.0" />
|
||||
<PackageReference Include="Fake.IO.FileSystem" Version="6.0.0" />
|
||||
<PackageReference Include="Fake.IO.Zip" Version="6.0.0" />
|
||||
<PackageReference Include="MSBuild.StructuredLogger" Version="2.2.206" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
143
build.fsx
143
build.fsx
@ -1,143 +0,0 @@
|
||||
#r "paket:
|
||||
nuget Fake.DotNet.Cli
|
||||
nuget Fake.IO.FileSystem
|
||||
nuget Fake.IO.Zip
|
||||
nuget Fake.Core.Target //"
|
||||
#load ".fake/build.fsx/intellisense.fsx"
|
||||
open System.IO
|
||||
open Fake.Core
|
||||
open Fake.DotNet
|
||||
open Fake.IO
|
||||
open Fake.IO.Globbing.Operators
|
||||
open Fake.Core.TargetOperators
|
||||
|
||||
Target.initEnvironment ()
|
||||
|
||||
/// The output directory for release ZIPs
|
||||
let releasePath = "releases"
|
||||
|
||||
/// The path to the main project
|
||||
let projectPath = "src/MyWebLog"
|
||||
|
||||
/// The path and name of the main project
|
||||
let projName = $"{projectPath}/MyWebLog.fsproj"
|
||||
|
||||
/// The version being packaged (extracted from appsettings.json)
|
||||
let version =
|
||||
let settings = File.ReadAllText $"{projectPath}/appsettings.json"
|
||||
let generator = settings.Substring (settings.IndexOf "\"Generator\":")
|
||||
let appVersion = generator.Replace("\"Generator\": \"", "")
|
||||
let appVersion = appVersion.Substring (0, appVersion.IndexOf "\"")
|
||||
appVersion.Split ' ' |> Array.last
|
||||
|
||||
/// Zip a theme distributed with myWebLog
|
||||
let zipTheme (name : string) (_ : TargetParameter) =
|
||||
let path = $"src/{name}-theme"
|
||||
Trace.log $"Path = {path}"
|
||||
!! $"{path}/**/*"
|
||||
|> Zip.filesAsSpecs path //$"src/{name}-theme"
|
||||
|> Seq.filter (fun (_, name) -> not (name.EndsWith ".zip"))
|
||||
|> Zip.zipSpec $"{releasePath}/{name}.zip"
|
||||
|
||||
/// Publish the project for the given runtime ID
|
||||
let publishFor rid (_ : TargetParameter) =
|
||||
DotNet.publish (fun opts -> { opts with Runtime = Some rid; SelfContained = Some false; NoLogo = true }) projName
|
||||
|
||||
/// Package published output for the given runtime ID
|
||||
let packageFor (rid : string) (_ : TargetParameter) =
|
||||
let path = $"{projectPath}/bin/Release/net6.0/{rid}/publish"
|
||||
[ !! $"{path}/**/*"
|
||||
|> Zip.filesAsSpecs path
|
||||
|> Zip.moveToFolder "app"
|
||||
Seq.singleton ($"{releasePath}/admin.zip", "admin.zip")
|
||||
Seq.singleton ($"{releasePath}/default.zip", "default.zip")
|
||||
]
|
||||
|> Seq.concat
|
||||
|> Zip.zipSpec $"{releasePath}/myWebLog-{version}.{rid}.zip"
|
||||
|
||||
|
||||
Target.create "Clean" (fun _ ->
|
||||
!! "src/**/bin"
|
||||
++ "src/**/obj"
|
||||
|> Shell.cleanDirs
|
||||
Shell.cleanDir releasePath
|
||||
)
|
||||
|
||||
Target.create "Build" (fun _ ->
|
||||
DotNet.build (fun opts -> { opts with NoLogo = true }) projName
|
||||
)
|
||||
|
||||
Target.create "ZipAdminTheme" (zipTheme "admin")
|
||||
Target.create "ZipDefaultTheme" (zipTheme "default")
|
||||
|
||||
Target.create "PublishWindows" (publishFor "win-x64")
|
||||
Target.create "PackageWindows" (packageFor "win-x64")
|
||||
|
||||
Target.create "PublishLinux" (publishFor "linux-x64")
|
||||
Target.create "PackageLinux" (packageFor "linux-x64")
|
||||
|
||||
Target.create "RepackageLinux" (fun _ ->
|
||||
let workDir = $"{releasePath}/linux"
|
||||
let zipArchive = $"{releasePath}/myWebLog-{version}.linux-x64.zip"
|
||||
Shell.mkdir workDir
|
||||
Zip.unzip workDir zipArchive
|
||||
Shell.cd workDir
|
||||
[ "cfj"; $"../myWebLog-{version}.linux-x64.tar.bz2"; "." ]
|
||||
|> CreateProcess.fromRawCommand "tar"
|
||||
|> CreateProcess.redirectOutput
|
||||
|> Proc.run
|
||||
|> ignore
|
||||
Shell.cd "../.."
|
||||
Shell.rm zipArchive
|
||||
Shell.rm_rf workDir
|
||||
)
|
||||
|
||||
Target.create "All" ignore
|
||||
|
||||
Target.create "RemoveThemeArchives" (fun _ ->
|
||||
Shell.rm $"{releasePath}/admin.zip"
|
||||
Shell.rm $"{releasePath}/default.zip"
|
||||
)
|
||||
|
||||
Target.create "CI" ignore
|
||||
|
||||
"Clean"
|
||||
==> "All"
|
||||
|
||||
"Clean"
|
||||
?=> "Build"
|
||||
==> "All"
|
||||
|
||||
"Clean"
|
||||
?=> "ZipDefaultTheme"
|
||||
==> "All"
|
||||
|
||||
"Clean"
|
||||
?=> "ZipAdminTheme"
|
||||
==> "All"
|
||||
|
||||
"Build"
|
||||
==> "PublishWindows"
|
||||
==> "All"
|
||||
|
||||
"Build"
|
||||
==> "PublishLinux"
|
||||
==> "All"
|
||||
|
||||
"PublishWindows"
|
||||
==> "PackageWindows"
|
||||
==> "All"
|
||||
|
||||
"PublishLinux"
|
||||
==> "PackageLinux"
|
||||
==> "All"
|
||||
|
||||
"PackageLinux"
|
||||
==> "RepackageLinux"
|
||||
==> "All"
|
||||
|
||||
"All"
|
||||
==> "RemoveThemeArchives"
|
||||
==> "CI"
|
||||
|
||||
Target.runOrDefault "All"
|
227
build.fsx.lock
227
build.fsx.lock
@ -1,227 +0,0 @@
|
||||
STORAGE: NONE
|
||||
RESTRICTION: || (== net6.0) (== netstandard2.0)
|
||||
NUGET
|
||||
remote: https://api.nuget.org/v3/index.json
|
||||
BlackFox.VsWhere (1.1)
|
||||
FSharp.Core (>= 4.2.3)
|
||||
Microsoft.Win32.Registry (>= 4.7)
|
||||
Fake.Core.CommandLineParsing (5.22)
|
||||
FParsec (>= 1.1.1)
|
||||
FSharp.Core (>= 6.0)
|
||||
Fake.Core.Context (5.22)
|
||||
FSharp.Core (>= 6.0)
|
||||
Fake.Core.Environment (5.22)
|
||||
FSharp.Core (>= 6.0)
|
||||
Fake.Core.FakeVar (5.22)
|
||||
Fake.Core.Context (>= 5.22)
|
||||
FSharp.Core (>= 6.0)
|
||||
Fake.Core.Process (5.22)
|
||||
Fake.Core.Environment (>= 5.22)
|
||||
Fake.Core.FakeVar (>= 5.22)
|
||||
Fake.Core.String (>= 5.22)
|
||||
Fake.Core.Trace (>= 5.22)
|
||||
Fake.IO.FileSystem (>= 5.22)
|
||||
FSharp.Core (>= 6.0)
|
||||
System.Collections.Immutable (>= 5.0)
|
||||
Fake.Core.SemVer (5.22)
|
||||
FSharp.Core (>= 6.0)
|
||||
Fake.Core.String (5.22)
|
||||
FSharp.Core (>= 6.0)
|
||||
Fake.Core.Target (5.22)
|
||||
Fake.Core.CommandLineParsing (>= 5.22)
|
||||
Fake.Core.Context (>= 5.22)
|
||||
Fake.Core.Environment (>= 5.22)
|
||||
Fake.Core.FakeVar (>= 5.22)
|
||||
Fake.Core.Process (>= 5.22)
|
||||
Fake.Core.String (>= 5.22)
|
||||
Fake.Core.Trace (>= 5.22)
|
||||
FSharp.Control.Reactive (>= 5.0.2)
|
||||
FSharp.Core (>= 6.0)
|
||||
Fake.Core.Tasks (5.22)
|
||||
Fake.Core.Trace (>= 5.22)
|
||||
FSharp.Core (>= 6.0)
|
||||
Fake.Core.Trace (5.22)
|
||||
Fake.Core.Environment (>= 5.22)
|
||||
Fake.Core.FakeVar (>= 5.22)
|
||||
FSharp.Core (>= 6.0)
|
||||
Fake.Core.Xml (5.22)
|
||||
Fake.Core.String (>= 5.22)
|
||||
FSharp.Core (>= 6.0)
|
||||
Fake.DotNet.Cli (5.22)
|
||||
Fake.Core.Environment (>= 5.22)
|
||||
Fake.Core.Process (>= 5.22)
|
||||
Fake.Core.String (>= 5.22)
|
||||
Fake.Core.Trace (>= 5.22)
|
||||
Fake.DotNet.MSBuild (>= 5.22)
|
||||
Fake.DotNet.NuGet (>= 5.22)
|
||||
Fake.IO.FileSystem (>= 5.22)
|
||||
FSharp.Core (>= 6.0)
|
||||
Mono.Posix.NETStandard (>= 1.0)
|
||||
Newtonsoft.Json (>= 13.0.1)
|
||||
Fake.DotNet.MSBuild (5.22)
|
||||
BlackFox.VsWhere (>= 1.1)
|
||||
Fake.Core.Environment (>= 5.22)
|
||||
Fake.Core.Process (>= 5.22)
|
||||
Fake.Core.String (>= 5.22)
|
||||
Fake.Core.Trace (>= 5.22)
|
||||
Fake.IO.FileSystem (>= 5.22)
|
||||
FSharp.Core (>= 6.0)
|
||||
MSBuild.StructuredLogger (>= 2.1.545)
|
||||
Fake.DotNet.NuGet (5.22)
|
||||
Fake.Core.Environment (>= 5.22)
|
||||
Fake.Core.Process (>= 5.22)
|
||||
Fake.Core.SemVer (>= 5.22)
|
||||
Fake.Core.String (>= 5.22)
|
||||
Fake.Core.Tasks (>= 5.22)
|
||||
Fake.Core.Trace (>= 5.22)
|
||||
Fake.Core.Xml (>= 5.22)
|
||||
Fake.IO.FileSystem (>= 5.22)
|
||||
Fake.Net.Http (>= 5.22)
|
||||
FSharp.Core (>= 6.0)
|
||||
Newtonsoft.Json (>= 13.0.1)
|
||||
NuGet.Protocol (>= 5.11)
|
||||
Fake.IO.FileSystem (5.22)
|
||||
Fake.Core.String (>= 5.22)
|
||||
FSharp.Core (>= 6.0)
|
||||
Fake.IO.Zip (5.22)
|
||||
Fake.Core.String (>= 5.22)
|
||||
Fake.IO.FileSystem (>= 5.22)
|
||||
FSharp.Core (>= 6.0)
|
||||
Fake.Net.Http (5.22)
|
||||
Fake.Core.Trace (>= 5.22)
|
||||
FSharp.Core (>= 6.0)
|
||||
FParsec (1.1.1)
|
||||
FSharp.Core (>= 4.3.4)
|
||||
FSharp.Control.Reactive (5.0.5)
|
||||
FSharp.Core (>= 4.7.2)
|
||||
System.Reactive (>= 5.0 < 6.0)
|
||||
FSharp.Core (6.0.5)
|
||||
Microsoft.Build (17.2)
|
||||
Microsoft.Build.Framework (>= 17.2) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
|
||||
Microsoft.NET.StringTools (>= 1.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
|
||||
Microsoft.Win32.Registry (>= 4.3) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net6.0))
|
||||
System.Collections.Immutable (>= 5.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
|
||||
System.Configuration.ConfigurationManager (>= 4.7) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
|
||||
System.Reflection.Metadata (>= 1.6) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net6.0))
|
||||
System.Security.Principal.Windows (>= 4.7) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net6.0))
|
||||
System.Text.Encoding.CodePages (>= 4.0.1) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net6.0))
|
||||
System.Text.Json (>= 6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
|
||||
System.Threading.Tasks.Dataflow (>= 6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
|
||||
Microsoft.Build.Framework (17.2)
|
||||
Microsoft.Win32.Registry (>= 4.3)
|
||||
System.Security.Permissions (>= 4.7)
|
||||
Microsoft.Build.Tasks.Core (17.2)
|
||||
Microsoft.Build.Framework (>= 17.2)
|
||||
Microsoft.Build.Utilities.Core (>= 17.2)
|
||||
Microsoft.NET.StringTools (>= 1.0)
|
||||
Microsoft.Win32.Registry (>= 4.3)
|
||||
System.CodeDom (>= 4.4)
|
||||
System.Collections.Immutable (>= 5.0)
|
||||
System.Reflection.Metadata (>= 1.6)
|
||||
System.Resources.Extensions (>= 4.6)
|
||||
System.Security.Cryptography.Pkcs (>= 4.7)
|
||||
System.Security.Cryptography.Xml (>= 4.7)
|
||||
System.Security.Permissions (>= 4.7)
|
||||
System.Threading.Tasks.Dataflow (>= 6.0)
|
||||
Microsoft.Build.Utilities.Core (17.2)
|
||||
Microsoft.Build.Framework (>= 17.2)
|
||||
Microsoft.NET.StringTools (>= 1.0)
|
||||
Microsoft.Win32.Registry (>= 4.3)
|
||||
System.Collections.Immutable (>= 5.0)
|
||||
System.Configuration.ConfigurationManager (>= 4.7)
|
||||
System.Security.Permissions (>= 4.7) - restriction: == netstandard2.0
|
||||
System.Text.Encoding.CodePages (>= 4.0.1) - restriction: == netstandard2.0
|
||||
Microsoft.NET.StringTools (1.0)
|
||||
System.Memory (>= 4.5.4)
|
||||
System.Runtime.CompilerServices.Unsafe (>= 5.0)
|
||||
Microsoft.NETCore.Platforms (6.0.4) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (&& (== net6.0) (< netstandard1.2)) (&& (== net6.0) (< netstandard1.3)) (&& (== net6.0) (< netstandard1.5)) (== netstandard2.0)
|
||||
Microsoft.NETCore.Targets (5.0) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (&& (== net6.0) (< netstandard1.2)) (&& (== net6.0) (< netstandard1.3)) (&& (== net6.0) (< netstandard1.5)) (== netstandard2.0)
|
||||
Microsoft.Win32.Registry (5.0)
|
||||
System.Buffers (>= 4.5.1) - restriction: || (&& (== net6.0) (>= monoandroid) (< netstandard1.3)) (&& (== net6.0) (>= monotouch)) (&& (== net6.0) (< netcoreapp2.0)) (&& (== net6.0) (>= xamarinios)) (&& (== net6.0) (>= xamarinmac)) (&& (== net6.0) (>= xamarintvos)) (&& (== net6.0) (>= xamarinwatchos)) (== netstandard2.0)
|
||||
System.Memory (>= 4.5.4) - restriction: || (&& (== net6.0) (< netcoreapp2.0)) (&& (== net6.0) (< netcoreapp2.1)) (&& (== net6.0) (>= uap10.1)) (== netstandard2.0)
|
||||
System.Security.AccessControl (>= 5.0)
|
||||
System.Security.Principal.Windows (>= 5.0)
|
||||
Microsoft.Win32.SystemEvents (6.0.1) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1))
|
||||
Mono.Posix.NETStandard (1.0)
|
||||
MSBuild.StructuredLogger (2.1.669)
|
||||
Microsoft.Build (>= 16.10)
|
||||
Microsoft.Build.Framework (>= 16.10)
|
||||
Microsoft.Build.Tasks.Core (>= 16.10)
|
||||
Microsoft.Build.Utilities.Core (>= 16.10)
|
||||
Newtonsoft.Json (13.0.1)
|
||||
NuGet.Common (6.2.1)
|
||||
NuGet.Frameworks (>= 6.2.1)
|
||||
NuGet.Configuration (6.2.1)
|
||||
NuGet.Common (>= 6.2.1)
|
||||
System.Security.Cryptography.ProtectedData (>= 4.4)
|
||||
NuGet.Frameworks (6.2.1)
|
||||
NuGet.Packaging (6.2.1)
|
||||
Newtonsoft.Json (>= 13.0.1)
|
||||
NuGet.Configuration (>= 6.2.1)
|
||||
NuGet.Versioning (>= 6.2.1)
|
||||
System.Security.Cryptography.Cng (>= 5.0)
|
||||
System.Security.Cryptography.Pkcs (>= 5.0)
|
||||
NuGet.Protocol (6.2.1)
|
||||
NuGet.Packaging (>= 6.2.1)
|
||||
NuGet.Versioning (6.2.1)
|
||||
System.Buffers (4.5.1) - restriction: || (&& (== net6.0) (>= monoandroid) (< netstandard1.3)) (&& (== net6.0) (>= monotouch)) (&& (== net6.0) (< netcoreapp2.0)) (&& (== net6.0) (>= xamarinios)) (&& (== net6.0) (>= xamarinmac)) (&& (== net6.0) (>= xamarintvos)) (&& (== net6.0) (>= xamarinwatchos)) (== netstandard2.0)
|
||||
System.CodeDom (6.0)
|
||||
System.Collections.Immutable (6.0)
|
||||
System.Memory (>= 4.5.4) - restriction: || (&& (== net6.0) (>= net461)) (== netstandard2.0)
|
||||
System.Runtime.CompilerServices.Unsafe (>= 6.0)
|
||||
System.Configuration.ConfigurationManager (6.0)
|
||||
System.Security.Cryptography.ProtectedData (>= 6.0)
|
||||
System.Security.Permissions (>= 6.0)
|
||||
System.Drawing.Common (6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1))
|
||||
Microsoft.Win32.SystemEvents (>= 6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1))
|
||||
System.Formats.Asn1 (6.0)
|
||||
System.Buffers (>= 4.5.1) - restriction: || (&& (== net6.0) (>= net461)) (== netstandard2.0)
|
||||
System.Memory (>= 4.5.4) - restriction: || (&& (== net6.0) (>= net461)) (== netstandard2.0)
|
||||
System.Memory (4.5.5)
|
||||
System.Buffers (>= 4.5.1) - restriction: || (&& (== net6.0) (>= monotouch)) (&& (== net6.0) (>= net461)) (&& (== net6.0) (< netcoreapp2.0)) (&& (== net6.0) (< netstandard1.1)) (&& (== net6.0) (< netstandard2.0)) (&& (== net6.0) (>= xamarinios)) (&& (== net6.0) (>= xamarinmac)) (&& (== net6.0) (>= xamarintvos)) (&& (== net6.0) (>= xamarinwatchos)) (== netstandard2.0)
|
||||
System.Numerics.Vectors (>= 4.4) - restriction: || (&& (== net6.0) (< netcoreapp2.0)) (== netstandard2.0)
|
||||
System.Runtime.CompilerServices.Unsafe (>= 4.5.3) - restriction: || (&& (== net6.0) (>= monotouch)) (&& (== net6.0) (>= net461)) (&& (== net6.0) (< netcoreapp2.0)) (&& (== net6.0) (< netcoreapp2.1)) (&& (== net6.0) (< netstandard1.1)) (&& (== net6.0) (< netstandard2.0)) (&& (== net6.0) (>= uap10.1)) (&& (== net6.0) (>= xamarinios)) (&& (== net6.0) (>= xamarinmac)) (&& (== net6.0) (>= xamarintvos)) (&& (== net6.0) (>= xamarinwatchos)) (== netstandard2.0)
|
||||
System.Numerics.Vectors (4.5) - restriction: || (&& (== net6.0) (>= net461)) (== netstandard2.0)
|
||||
System.Reactive (5.0)
|
||||
System.Runtime.InteropServices.WindowsRuntime (>= 4.3) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (== netstandard2.0)
|
||||
System.Threading.Tasks.Extensions (>= 4.5.4) - restriction: || (&& (== net6.0) (>= net472)) (&& (== net6.0) (< netcoreapp3.1)) (&& (== net6.0) (>= uap10.1)) (== netstandard2.0)
|
||||
System.Reflection.Metadata (6.0.1)
|
||||
System.Collections.Immutable (>= 6.0)
|
||||
System.Resources.Extensions (6.0)
|
||||
System.Memory (>= 4.5.4) - restriction: || (&& (== net6.0) (>= net461)) (== netstandard2.0)
|
||||
System.Runtime (4.3.1) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (== netstandard2.0)
|
||||
Microsoft.NETCore.Platforms (>= 1.1.1)
|
||||
Microsoft.NETCore.Targets (>= 1.1.3)
|
||||
System.Runtime.CompilerServices.Unsafe (6.0)
|
||||
System.Runtime.InteropServices.WindowsRuntime (4.3) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (== netstandard2.0)
|
||||
System.Runtime (>= 4.3)
|
||||
System.Security.AccessControl (6.0)
|
||||
System.Security.Principal.Windows (>= 5.0) - restriction: || (&& (== net6.0) (>= net461)) (== netstandard2.0)
|
||||
System.Security.Cryptography.Cng (5.0)
|
||||
System.Formats.Asn1 (>= 5.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.0))
|
||||
System.Security.Cryptography.Pkcs (6.0.1)
|
||||
System.Buffers (>= 4.5.1) - restriction: || (&& (== net6.0) (< netstandard2.1)) (== netstandard2.0)
|
||||
System.Formats.Asn1 (>= 6.0)
|
||||
System.Memory (>= 4.5.4) - restriction: || (&& (== net6.0) (< netstandard2.1)) (== netstandard2.0)
|
||||
System.Security.Cryptography.Cng (>= 5.0) - restriction: || (&& (== net6.0) (< netcoreapp3.1)) (&& (== net6.0) (< netstandard2.1)) (== netstandard2.0)
|
||||
System.Security.Cryptography.ProtectedData (6.0)
|
||||
System.Security.Cryptography.Xml (6.0)
|
||||
System.Memory (>= 4.5.4) - restriction: == netstandard2.0
|
||||
System.Security.AccessControl (>= 6.0)
|
||||
System.Security.Cryptography.Pkcs (>= 6.0)
|
||||
System.Security.Permissions (6.0)
|
||||
System.Security.AccessControl (>= 6.0)
|
||||
System.Windows.Extensions (>= 6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1))
|
||||
System.Security.Principal.Windows (5.0)
|
||||
System.Text.Encoding.CodePages (6.0)
|
||||
System.Runtime.CompilerServices.Unsafe (>= 6.0)
|
||||
System.Text.Encodings.Web (6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
|
||||
System.Runtime.CompilerServices.Unsafe (>= 6.0)
|
||||
System.Text.Json (6.0.5) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= net472)) (&& (== netstandard2.0) (>= net6.0))
|
||||
System.Runtime.CompilerServices.Unsafe (>= 6.0)
|
||||
System.Text.Encodings.Web (>= 6.0)
|
||||
System.Threading.Tasks.Dataflow (6.0)
|
||||
System.Threading.Tasks.Extensions (4.5.4) - restriction: || (&& (== net6.0) (>= net472)) (&& (== net6.0) (< netcoreapp3.1)) (&& (== net6.0) (>= uap10.1)) (== netstandard2.0)
|
||||
System.Runtime.CompilerServices.Unsafe (>= 4.5.3) - restriction: || (&& (== net6.0) (>= net461)) (&& (== net6.0) (< netcoreapp2.1)) (&& (== net6.0) (< netstandard1.0)) (&& (== net6.0) (< netstandard2.0)) (&& (== net6.0) (>= wp8)) (== netstandard2.0)
|
||||
System.Windows.Extensions (6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1))
|
||||
System.Drawing.Common (>= 6.0) - restriction: || (== net6.0) (&& (== netstandard2.0) (>= netcoreapp3.1))
|
7
fake.sh
7
fake.sh
@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -eu
|
||||
set -o pipefail
|
||||
|
||||
dotnet tool restore
|
||||
dotnet fake "$@"
|
4
src/.dockerignore
Normal file
4
src/.dockerignore
Normal file
@ -0,0 +1,4 @@
|
||||
**/bin
|
||||
**/obj
|
||||
**/*.db
|
||||
**/appsettings.*.json
|
9
src/Directory.Build.props
Normal file
9
src/Directory.Build.props
Normal file
@ -0,0 +1,9 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net6.0;net8.0</TargetFrameworks>
|
||||
<DebugType>embedded</DebugType>
|
||||
<AssemblyVersion>2.2.0.0</AssemblyVersion>
|
||||
<FileVersion>2.2.0.0</FileVersion>
|
||||
<Version>2.2.0</Version>
|
||||
</PropertyGroup>
|
||||
</Project>
|
33
src/Dockerfile
Normal file
33
src/Dockerfile
Normal file
@ -0,0 +1,33 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
|
||||
WORKDIR /mwl
|
||||
COPY ./MyWebLog.sln ./
|
||||
COPY ./Directory.Build.props ./
|
||||
COPY ./MyWebLog/MyWebLog.fsproj ./MyWebLog/
|
||||
COPY ./MyWebLog.Data/MyWebLog.Data.fsproj ./MyWebLog.Data/
|
||||
COPY ./MyWebLog.Domain/MyWebLog.Domain.fsproj ./MyWebLog.Domain/
|
||||
COPY ./MyWebLog.Tests/MyWebLog.Tests.fsproj ./MyWebLog.Tests/
|
||||
RUN dotnet restore
|
||||
|
||||
COPY . ./
|
||||
WORKDIR /mwl/MyWebLog
|
||||
RUN dotnet publish -f net8.0 -c Release -r linux-x64 --no-self-contained -p:PublishSingleFile=false
|
||||
|
||||
FROM alpine AS theme
|
||||
RUN apk add --no-cache zip
|
||||
WORKDIR /themes/default-theme
|
||||
COPY ./default-theme ./
|
||||
RUN zip -r ../default-theme.zip ./*
|
||||
WORKDIR /themes/admin-theme
|
||||
COPY ./admin-theme ./
|
||||
RUN zip -r ../admin-theme.zip ./*
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine as final
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache icu-libs
|
||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
COPY --from=build /mwl/MyWebLog/bin/Release/net8.0/linux-x64/publish/ ./
|
||||
COPY --from=theme /themes/*.zip /app/
|
||||
RUN mkdir themes
|
||||
|
||||
EXPOSE 80
|
||||
CMD [ "dotnet", "/app/MyWebLog.dll" ]
|
@ -9,124 +9,179 @@ module Json =
|
||||
|
||||
open Newtonsoft.Json
|
||||
|
||||
type CategoryIdConverter () =
|
||||
inherit JsonConverter<CategoryId> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : CategoryId, _ : JsonSerializer) =
|
||||
writer.WriteValue (CategoryId.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : CategoryId, _ : bool, _ : JsonSerializer) =
|
||||
type CategoryIdConverter() =
|
||||
inherit JsonConverter<CategoryId>()
|
||||
override _.WriteJson(writer: JsonWriter, value: CategoryId, _: JsonSerializer) =
|
||||
writer.WriteValue(string value)
|
||||
override _.ReadJson(reader: JsonReader, _: Type, _: CategoryId, _: bool, _: JsonSerializer) =
|
||||
(string >> CategoryId) reader.Value
|
||||
|
||||
type CommentIdConverter () =
|
||||
inherit JsonConverter<CommentId> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : CommentId, _ : JsonSerializer) =
|
||||
writer.WriteValue (CommentId.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : CommentId, _ : bool, _ : JsonSerializer) =
|
||||
type CommentIdConverter() =
|
||||
inherit JsonConverter<CommentId>()
|
||||
override _.WriteJson(writer: JsonWriter, value: CommentId, _: JsonSerializer) =
|
||||
writer.WriteValue(string value)
|
||||
override _.ReadJson(reader: JsonReader, _: Type, _: CommentId, _: bool, _: JsonSerializer) =
|
||||
(string >> CommentId) reader.Value
|
||||
|
||||
type CustomFeedIdConverter () =
|
||||
inherit JsonConverter<CustomFeedId> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : CustomFeedId, _ : JsonSerializer) =
|
||||
writer.WriteValue (CustomFeedId.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : CustomFeedId, _ : bool, _ : JsonSerializer) =
|
||||
type CommentStatusConverter() =
|
||||
inherit JsonConverter<CommentStatus>()
|
||||
override _.WriteJson(writer: JsonWriter, value: CommentStatus, _: JsonSerializer) =
|
||||
writer.WriteValue(string value)
|
||||
override _.ReadJson(reader: JsonReader, _: Type, _: CommentStatus, _: bool, _: JsonSerializer) =
|
||||
(string >> CommentStatus.Parse) reader.Value
|
||||
|
||||
type CustomFeedIdConverter() =
|
||||
inherit JsonConverter<CustomFeedId>()
|
||||
override _.WriteJson(writer: JsonWriter, value: CustomFeedId, _: JsonSerializer) =
|
||||
writer.WriteValue(string value)
|
||||
override _.ReadJson(reader: JsonReader, _: Type, _: CustomFeedId, _: bool, _: JsonSerializer) =
|
||||
(string >> CustomFeedId) reader.Value
|
||||
|
||||
type CustomFeedSourceConverter () =
|
||||
inherit JsonConverter<CustomFeedSource> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : CustomFeedSource, _ : JsonSerializer) =
|
||||
writer.WriteValue (CustomFeedSource.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : CustomFeedSource, _ : bool, _ : JsonSerializer) =
|
||||
(string >> CustomFeedSource.parse) reader.Value
|
||||
type CustomFeedSourceConverter() =
|
||||
inherit JsonConverter<CustomFeedSource>()
|
||||
override _.WriteJson(writer: JsonWriter, value: CustomFeedSource, _: JsonSerializer) =
|
||||
writer.WriteValue(string value)
|
||||
override _.ReadJson(reader: JsonReader, _: Type, _: CustomFeedSource, _: bool, _: JsonSerializer) =
|
||||
(string >> CustomFeedSource.Parse) reader.Value
|
||||
|
||||
type ExplicitRatingConverter () =
|
||||
inherit JsonConverter<ExplicitRating> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : ExplicitRating, _ : JsonSerializer) =
|
||||
writer.WriteValue (ExplicitRating.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : ExplicitRating, _ : bool, _ : JsonSerializer) =
|
||||
(string >> ExplicitRating.parse) reader.Value
|
||||
type ExplicitRatingConverter() =
|
||||
inherit JsonConverter<ExplicitRating>()
|
||||
override _.WriteJson(writer: JsonWriter, value: ExplicitRating, _: JsonSerializer) =
|
||||
writer.WriteValue(string value)
|
||||
override _.ReadJson(reader: JsonReader, _: Type, _: ExplicitRating, _: bool, _: JsonSerializer) =
|
||||
(string >> ExplicitRating.Parse) reader.Value
|
||||
|
||||
type MarkupTextConverter () =
|
||||
inherit JsonConverter<MarkupText> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : MarkupText, _ : JsonSerializer) =
|
||||
writer.WriteValue (MarkupText.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : MarkupText, _ : bool, _ : JsonSerializer) =
|
||||
(string >> MarkupText.parse) reader.Value
|
||||
type MarkupTextConverter() =
|
||||
inherit JsonConverter<MarkupText>()
|
||||
override _.WriteJson(writer: JsonWriter, value: MarkupText, _: JsonSerializer) =
|
||||
writer.WriteValue(string value)
|
||||
override _.ReadJson(reader: JsonReader, _: Type, _: MarkupText, _: bool, _: JsonSerializer) =
|
||||
(string >> MarkupText.Parse) reader.Value
|
||||
|
||||
type PermalinkConverter () =
|
||||
inherit JsonConverter<Permalink> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : Permalink, _ : JsonSerializer) =
|
||||
writer.WriteValue (Permalink.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : Permalink, _ : bool, _ : JsonSerializer) =
|
||||
type PermalinkConverter() =
|
||||
inherit JsonConverter<Permalink>()
|
||||
override _.WriteJson(writer: JsonWriter, value: Permalink, _: JsonSerializer) =
|
||||
writer.WriteValue(string value)
|
||||
override _.ReadJson(reader: JsonReader, _: Type, _: Permalink, _: bool, _: JsonSerializer) =
|
||||
(string >> Permalink) reader.Value
|
||||
|
||||
type PageIdConverter () =
|
||||
inherit JsonConverter<PageId> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : PageId, _ : JsonSerializer) =
|
||||
writer.WriteValue (PageId.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : PageId, _ : bool, _ : JsonSerializer) =
|
||||
type PageIdConverter() =
|
||||
inherit JsonConverter<PageId>()
|
||||
override _.WriteJson(writer: JsonWriter, value: PageId, _: JsonSerializer) =
|
||||
writer.WriteValue(string value)
|
||||
override _.ReadJson(reader: JsonReader, _: Type, _: PageId, _: bool, _: JsonSerializer) =
|
||||
(string >> PageId) reader.Value
|
||||
|
||||
type PostIdConverter () =
|
||||
inherit JsonConverter<PostId> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : PostId, _ : JsonSerializer) =
|
||||
writer.WriteValue (PostId.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : PostId, _ : bool, _ : JsonSerializer) =
|
||||
type PodcastMediumConverter() =
|
||||
inherit JsonConverter<PodcastMedium>()
|
||||
override _.WriteJson(writer: JsonWriter, value: PodcastMedium, _: JsonSerializer) =
|
||||
writer.WriteValue(string value)
|
||||
override _.ReadJson(reader: JsonReader, _: Type, _: PodcastMedium, _: bool, _: JsonSerializer) =
|
||||
(string >> PodcastMedium.Parse) reader.Value
|
||||
|
||||
type PostIdConverter() =
|
||||
inherit JsonConverter<PostId>()
|
||||
override _.WriteJson(writer: JsonWriter, value: PostId, _: JsonSerializer) =
|
||||
writer.WriteValue(string value)
|
||||
override _.ReadJson(reader: JsonReader, _: Type, _: PostId, _: bool, _: JsonSerializer) =
|
||||
(string >> PostId) reader.Value
|
||||
|
||||
type TagMapIdConverter () =
|
||||
inherit JsonConverter<TagMapId> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : TagMapId, _ : JsonSerializer) =
|
||||
writer.WriteValue (TagMapId.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : TagMapId, _ : bool, _ : JsonSerializer) =
|
||||
type TagMapIdConverter() =
|
||||
inherit JsonConverter<TagMapId>()
|
||||
override _.WriteJson(writer: JsonWriter, value: TagMapId, _: JsonSerializer) =
|
||||
writer.WriteValue(string value)
|
||||
override _.ReadJson(reader: JsonReader, _: Type, _: TagMapId, _: bool, _: JsonSerializer) =
|
||||
(string >> TagMapId) reader.Value
|
||||
|
||||
type ThemeAssetIdConverter () =
|
||||
inherit JsonConverter<ThemeAssetId> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : ThemeAssetId, _ : JsonSerializer) =
|
||||
writer.WriteValue (ThemeAssetId.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : ThemeAssetId, _ : bool, _ : JsonSerializer) =
|
||||
(string >> ThemeAssetId.ofString) reader.Value
|
||||
type ThemeAssetIdConverter() =
|
||||
inherit JsonConverter<ThemeAssetId>()
|
||||
override _.WriteJson(writer: JsonWriter, value: ThemeAssetId, _: JsonSerializer) =
|
||||
writer.WriteValue(string value)
|
||||
override _.ReadJson(reader: JsonReader, _: Type, _: ThemeAssetId, _: bool, _: JsonSerializer) =
|
||||
(string >> ThemeAssetId.Parse) reader.Value
|
||||
|
||||
type ThemeIdConverter () =
|
||||
inherit JsonConverter<ThemeId> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : ThemeId, _ : JsonSerializer) =
|
||||
writer.WriteValue (ThemeId.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : ThemeId, _ : bool, _ : JsonSerializer) =
|
||||
type ThemeIdConverter() =
|
||||
inherit JsonConverter<ThemeId>()
|
||||
override _.WriteJson(writer: JsonWriter, value: ThemeId, _: JsonSerializer) =
|
||||
writer.WriteValue(string value)
|
||||
override _.ReadJson(reader: JsonReader, _: Type, _: ThemeId, _: bool, _: JsonSerializer) =
|
||||
(string >> ThemeId) reader.Value
|
||||
|
||||
type WebLogIdConverter () =
|
||||
inherit JsonConverter<WebLogId> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : WebLogId, _ : JsonSerializer) =
|
||||
writer.WriteValue (WebLogId.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : WebLogId, _ : bool, _ : JsonSerializer) =
|
||||
|
||||
type UploadIdConverter() =
|
||||
inherit JsonConverter<UploadId>()
|
||||
override _.WriteJson(writer: JsonWriter, value: UploadId, _: JsonSerializer) =
|
||||
writer.WriteValue(string value)
|
||||
override _.ReadJson(reader: JsonReader, _: Type, _: UploadId, _: bool, _: JsonSerializer) =
|
||||
(string >> UploadId) reader.Value
|
||||
|
||||
type WebLogIdConverter() =
|
||||
inherit JsonConverter<WebLogId>()
|
||||
override _.WriteJson(writer: JsonWriter, value: WebLogId, _: JsonSerializer) =
|
||||
writer.WriteValue(string value)
|
||||
override _.ReadJson(reader: JsonReader, _: Type, _: WebLogId, _: bool, _: JsonSerializer) =
|
||||
(string >> WebLogId) reader.Value
|
||||
|
||||
type WebLogUserIdConverter () =
|
||||
type WebLogUserIdConverter() =
|
||||
inherit JsonConverter<WebLogUserId> ()
|
||||
override _.WriteJson (writer : JsonWriter, value : WebLogUserId, _ : JsonSerializer) =
|
||||
writer.WriteValue (WebLogUserId.toString value)
|
||||
override _.ReadJson (reader : JsonReader, _ : Type, _ : WebLogUserId, _ : bool, _ : JsonSerializer) =
|
||||
override _.WriteJson(writer: JsonWriter, value: WebLogUserId, _: JsonSerializer) =
|
||||
writer.WriteValue(string value)
|
||||
override _.ReadJson(reader: JsonReader, _: Type, _: WebLogUserId, _: bool, _: JsonSerializer) =
|
||||
(string >> WebLogUserId) reader.Value
|
||||
|
||||
open Microsoft.FSharpLu.Json
|
||||
|
||||
/// All converters to use for data conversion
|
||||
let all () : JsonConverter seq =
|
||||
seq {
|
||||
// Our converters
|
||||
CategoryIdConverter ()
|
||||
CommentIdConverter ()
|
||||
CustomFeedIdConverter ()
|
||||
CustomFeedSourceConverter ()
|
||||
ExplicitRatingConverter ()
|
||||
MarkupTextConverter ()
|
||||
PermalinkConverter ()
|
||||
PageIdConverter ()
|
||||
PostIdConverter ()
|
||||
TagMapIdConverter ()
|
||||
ThemeAssetIdConverter ()
|
||||
ThemeIdConverter ()
|
||||
WebLogIdConverter ()
|
||||
WebLogUserIdConverter ()
|
||||
// Handles DUs with no associated data, as well as option fields
|
||||
CompactUnionJsonConverter ()
|
||||
}
|
||||
open NodaTime
|
||||
open NodaTime.Serialization.JsonNet
|
||||
|
||||
/// Configure a serializer to use these converters
|
||||
let configure (ser : JsonSerializer) =
|
||||
// Our converters
|
||||
[ CategoryIdConverter() :> JsonConverter
|
||||
CommentIdConverter()
|
||||
CommentStatusConverter()
|
||||
CustomFeedIdConverter()
|
||||
CustomFeedSourceConverter()
|
||||
ExplicitRatingConverter()
|
||||
MarkupTextConverter()
|
||||
PermalinkConverter()
|
||||
PageIdConverter()
|
||||
PodcastMediumConverter()
|
||||
PostIdConverter()
|
||||
TagMapIdConverter()
|
||||
ThemeAssetIdConverter()
|
||||
ThemeIdConverter()
|
||||
UploadIdConverter()
|
||||
WebLogIdConverter()
|
||||
WebLogUserIdConverter() ]
|
||||
|> List.iter ser.Converters.Add
|
||||
// NodaTime
|
||||
let _ = ser.ConfigureForNodaTime DateTimeZoneProviders.Tzdb
|
||||
// Handles DUs with no associated data, as well as option fields
|
||||
ser.Converters.Add(CompactUnionJsonConverter())
|
||||
ser.NullValueHandling <- NullValueHandling.Ignore
|
||||
ser.MissingMemberHandling <- MissingMemberHandling.Ignore
|
||||
ser
|
||||
|
||||
/// Serializer settings extracted from a JsonSerializer (a property sure would be nice...)
|
||||
let mutable private serializerSettings : JsonSerializerSettings option = None
|
||||
|
||||
/// Extract settings from the serializer to be used in JsonConvert calls
|
||||
let settings (ser : JsonSerializer) =
|
||||
if Option.isNone serializerSettings then
|
||||
serializerSettings <- JsonSerializerSettings (
|
||||
ConstructorHandling = ser.ConstructorHandling,
|
||||
ContractResolver = ser.ContractResolver,
|
||||
Converters = ser.Converters,
|
||||
DefaultValueHandling = ser.DefaultValueHandling,
|
||||
DateFormatHandling = ser.DateFormatHandling,
|
||||
DateParseHandling = ser.DateParseHandling,
|
||||
MetadataPropertyHandling = ser.MetadataPropertyHandling,
|
||||
MissingMemberHandling = ser.MissingMemberHandling,
|
||||
NullValueHandling = ser.NullValueHandling,
|
||||
ObjectCreationHandling = ser.ObjectCreationHandling,
|
||||
ReferenceLoopHandling = ser.ReferenceLoopHandling,
|
||||
SerializationBinder = ser.SerializationBinder,
|
||||
TraceWriter = ser.TraceWriter,
|
||||
TypeNameAssemblyFormatHandling = ser.TypeNameAssemblyFormatHandling,
|
||||
TypeNameHandling = ser.TypeNameHandling)
|
||||
|> Some
|
||||
serializerSettings.Value
|
||||
|
@ -1,252 +1,304 @@
|
||||
namespace MyWebLog.Data
|
||||
|
||||
open System
|
||||
open System.Threading.Tasks
|
||||
open MyWebLog
|
||||
open MyWebLog.ViewModels
|
||||
open Newtonsoft.Json
|
||||
open NodaTime
|
||||
|
||||
/// The result of a category deletion attempt
|
||||
[<Struct>]
|
||||
type CategoryDeleteResult =
|
||||
/// The category was deleted successfully
|
||||
| CategoryDeleted
|
||||
/// The category was deleted successfully, and its children were reassigned to its parent
|
||||
| ReassignedChildCategories
|
||||
/// The category was not found, so no effort was made to delete it
|
||||
| CategoryNotFound
|
||||
|
||||
|
||||
/// Data functions to support manipulating categories
|
||||
type ICategoryData =
|
||||
|
||||
/// Add a category
|
||||
abstract member add : Category -> Task<unit>
|
||||
abstract member Add : Category -> Task<unit>
|
||||
|
||||
/// Count all categories for the given web log
|
||||
abstract member countAll : WebLogId -> Task<int>
|
||||
abstract member CountAll : WebLogId -> Task<int>
|
||||
|
||||
/// Count all top-level categories for the given web log
|
||||
abstract member countTopLevel : WebLogId -> Task<int>
|
||||
abstract member CountTopLevel : WebLogId -> Task<int>
|
||||
|
||||
/// Delete a category (also removes it from posts)
|
||||
abstract member delete : CategoryId -> WebLogId -> Task<bool>
|
||||
abstract member Delete : CategoryId -> WebLogId -> Task<CategoryDeleteResult>
|
||||
|
||||
/// Find all categories for a web log, sorted alphabetically and grouped by hierarchy
|
||||
abstract member findAllForView : WebLogId -> Task<DisplayCategory[]>
|
||||
abstract member FindAllForView : WebLogId -> Task<DisplayCategory array>
|
||||
|
||||
/// Find a category by its ID
|
||||
abstract member findById : CategoryId -> WebLogId -> Task<Category option>
|
||||
abstract member FindById : CategoryId -> WebLogId -> Task<Category option>
|
||||
|
||||
/// Find all categories for the given web log
|
||||
abstract member findByWebLog : WebLogId -> Task<Category list>
|
||||
abstract member FindByWebLog : WebLogId -> Task<Category list>
|
||||
|
||||
/// Restore categories from a backup
|
||||
abstract member restore : Category list -> Task<unit>
|
||||
abstract member Restore : Category list -> Task<unit>
|
||||
|
||||
/// Update a category (slug, name, description, and parent ID)
|
||||
abstract member update : Category -> Task<unit>
|
||||
abstract member Update : Category -> Task<unit>
|
||||
|
||||
|
||||
/// Data functions to support manipulating pages
|
||||
type IPageData =
|
||||
|
||||
/// Add a page
|
||||
abstract member add : Page -> Task<unit>
|
||||
abstract member Add : Page -> Task<unit>
|
||||
|
||||
/// Get all pages for the web log (excluding meta items, text, revisions, and prior permalinks)
|
||||
abstract member all : WebLogId -> Task<Page list>
|
||||
/// Get all pages for the web log (excluding text, metadata, revisions, and prior permalinks)
|
||||
abstract member All : WebLogId -> Task<Page list>
|
||||
|
||||
/// Count all pages for the given web log
|
||||
abstract member countAll : WebLogId -> Task<int>
|
||||
abstract member CountAll : WebLogId -> Task<int>
|
||||
|
||||
/// Count pages marked as "show in page list" for the given web log
|
||||
abstract member countListed : WebLogId -> Task<int>
|
||||
abstract member CountListed : WebLogId -> Task<int>
|
||||
|
||||
/// Delete a page
|
||||
abstract member delete : PageId -> WebLogId -> Task<bool>
|
||||
abstract member Delete : PageId -> WebLogId -> Task<bool>
|
||||
|
||||
/// Find a page by its ID (excluding revisions and prior permalinks)
|
||||
abstract member findById : PageId -> WebLogId -> Task<Page option>
|
||||
abstract member FindById : PageId -> WebLogId -> Task<Page option>
|
||||
|
||||
/// Find a page by its permalink (excluding revisions and prior permalinks)
|
||||
abstract member findByPermalink : Permalink -> WebLogId -> Task<Page option>
|
||||
abstract member FindByPermalink : Permalink -> WebLogId -> Task<Page option>
|
||||
|
||||
/// Find the current permalink for a page from a list of prior permalinks
|
||||
abstract member findCurrentPermalink : Permalink list -> WebLogId -> Task<Permalink option>
|
||||
abstract member FindCurrentPermalink : Permalink list -> WebLogId -> Task<Permalink option>
|
||||
|
||||
/// Find a page by its ID (including revisions and prior permalinks)
|
||||
abstract member findFullById : PageId -> WebLogId -> Task<Page option>
|
||||
abstract member FindFullById : PageId -> WebLogId -> Task<Page option>
|
||||
|
||||
/// Find all pages for the given web log (including revisions and prior permalinks)
|
||||
abstract member findFullByWebLog : WebLogId -> Task<Page list>
|
||||
abstract member FindFullByWebLog : WebLogId -> Task<Page list>
|
||||
|
||||
/// Find pages marked as "show in page list" for the given web log (excluding text, revisions, and prior permalinks)
|
||||
abstract member findListed : WebLogId -> Task<Page list>
|
||||
abstract member FindListed : WebLogId -> Task<Page list>
|
||||
|
||||
/// Find a page of pages (displayed in admin section) (excluding meta items, revisions and prior permalinks)
|
||||
abstract member findPageOfPages : WebLogId -> pageNbr : int -> Task<Page list>
|
||||
abstract member FindPageOfPages : WebLogId -> pageNbr: int -> Task<Page list>
|
||||
|
||||
/// Restore pages from a backup
|
||||
abstract member restore : Page list -> Task<unit>
|
||||
abstract member Restore : Page list -> Task<unit>
|
||||
|
||||
/// Update a page
|
||||
abstract member update : Page -> Task<unit>
|
||||
abstract member Update : Page -> Task<unit>
|
||||
|
||||
/// Update the prior permalinks for the given page
|
||||
abstract member updatePriorPermalinks : PageId -> WebLogId -> Permalink list -> Task<bool>
|
||||
abstract member UpdatePriorPermalinks : PageId -> WebLogId -> Permalink list -> Task<bool>
|
||||
|
||||
|
||||
/// Data functions to support manipulating posts
|
||||
type IPostData =
|
||||
|
||||
/// Add a post
|
||||
abstract member add : Post -> Task<unit>
|
||||
abstract member Add : Post -> Task<unit>
|
||||
|
||||
/// Count posts by their status
|
||||
abstract member countByStatus : PostStatus -> WebLogId -> Task<int>
|
||||
abstract member CountByStatus : PostStatus -> WebLogId -> Task<int>
|
||||
|
||||
/// Delete a post
|
||||
abstract member delete : PostId -> WebLogId -> Task<bool>
|
||||
abstract member Delete : PostId -> WebLogId -> Task<bool>
|
||||
|
||||
/// Find a post by its ID (excluding revisions and prior permalinks)
|
||||
abstract member FindById : PostId -> WebLogId -> Task<Post option>
|
||||
|
||||
/// Find a post by its permalink (excluding revisions and prior permalinks)
|
||||
abstract member findByPermalink : Permalink -> WebLogId -> Task<Post option>
|
||||
abstract member FindByPermalink : Permalink -> WebLogId -> Task<Post option>
|
||||
|
||||
/// Find the current permalink for a post from a list of prior permalinks
|
||||
abstract member findCurrentPermalink : Permalink list -> WebLogId -> Task<Permalink option>
|
||||
abstract member FindCurrentPermalink : Permalink list -> WebLogId -> Task<Permalink option>
|
||||
|
||||
/// Find a post by its ID (including revisions and prior permalinks)
|
||||
abstract member findFullById : PostId -> WebLogId -> Task<Post option>
|
||||
abstract member FindFullById : PostId -> WebLogId -> Task<Post option>
|
||||
|
||||
/// Find all posts for the given web log (including revisions and prior permalinks)
|
||||
abstract member findFullByWebLog : WebLogId -> Task<Post list>
|
||||
abstract member FindFullByWebLog : WebLogId -> Task<Post list>
|
||||
|
||||
/// Find posts to be displayed on a category list page (excluding revisions and prior permalinks)
|
||||
abstract member findPageOfCategorizedPosts :
|
||||
WebLogId -> CategoryId list -> pageNbr : int -> postsPerPage : int -> Task<Post list>
|
||||
abstract member FindPageOfCategorizedPosts :
|
||||
WebLogId -> CategoryId list -> pageNbr: int -> postsPerPage: int -> Task<Post list>
|
||||
|
||||
/// Find posts to be displayed on an admin page (excluding revisions and prior permalinks)
|
||||
abstract member findPageOfPosts : WebLogId -> pageNbr : int -> postsPerPage : int -> Task<Post list>
|
||||
/// Find posts to be displayed on an admin page (excluding text, revisions, and prior permalinks)
|
||||
abstract member FindPageOfPosts : WebLogId -> pageNbr: int -> postsPerPage: int -> Task<Post list>
|
||||
|
||||
/// Find posts to be displayed on a page (excluding revisions and prior permalinks)
|
||||
abstract member findPageOfPublishedPosts : WebLogId -> pageNbr : int -> postsPerPage : int -> Task<Post list>
|
||||
abstract member FindPageOfPublishedPosts : WebLogId -> pageNbr: int -> postsPerPage: int -> Task<Post list>
|
||||
|
||||
/// Find posts to be displayed on a tag list page (excluding revisions and prior permalinks)
|
||||
abstract member findPageOfTaggedPosts :
|
||||
WebLogId -> tag : string -> pageNbr : int -> postsPerPage : int -> Task<Post list>
|
||||
abstract member FindPageOfTaggedPosts :
|
||||
WebLogId -> tag : string -> pageNbr: int -> postsPerPage: int -> Task<Post list>
|
||||
|
||||
/// Find the next older and newer post for the given published date/time (excluding revisions and prior permalinks)
|
||||
abstract member findSurroundingPosts : WebLogId -> publishedOn : DateTime -> Task<Post option * Post option>
|
||||
abstract member FindSurroundingPosts : WebLogId -> publishedOn: Instant -> Task<Post option * Post option>
|
||||
|
||||
/// Restore posts from a backup
|
||||
abstract member restore : Post list -> Task<unit>
|
||||
abstract member Restore : Post list -> Task<unit>
|
||||
|
||||
/// Update a post
|
||||
abstract member update : Post -> Task<unit>
|
||||
abstract member Update : Post -> Task<unit>
|
||||
|
||||
/// Update the prior permalinks for a post
|
||||
abstract member updatePriorPermalinks : PostId -> WebLogId -> Permalink list -> Task<bool>
|
||||
abstract member UpdatePriorPermalinks : PostId -> WebLogId -> Permalink list -> Task<bool>
|
||||
|
||||
|
||||
/// Functions to manipulate tag mappings
|
||||
type ITagMapData =
|
||||
|
||||
/// Delete a tag mapping
|
||||
abstract member delete : TagMapId -> WebLogId -> Task<bool>
|
||||
abstract member Delete : TagMapId -> WebLogId -> Task<bool>
|
||||
|
||||
/// Find a tag mapping by its ID
|
||||
abstract member findById : TagMapId -> WebLogId -> Task<TagMap option>
|
||||
abstract member FindById : TagMapId -> WebLogId -> Task<TagMap option>
|
||||
|
||||
/// Find a tag mapping by its URL value
|
||||
abstract member findByUrlValue : string -> WebLogId -> Task<TagMap option>
|
||||
abstract member FindByUrlValue : string -> WebLogId -> Task<TagMap option>
|
||||
|
||||
/// Retrieve all tag mappings for the given web log
|
||||
abstract member findByWebLog : WebLogId -> Task<TagMap list>
|
||||
abstract member FindByWebLog : WebLogId -> Task<TagMap list>
|
||||
|
||||
/// Find tag mappings for the given tags
|
||||
abstract member findMappingForTags : tags : string list -> WebLogId -> Task<TagMap list>
|
||||
abstract member FindMappingForTags : tags : string list -> WebLogId -> Task<TagMap list>
|
||||
|
||||
/// Restore tag mappings from a backup
|
||||
abstract member restore : TagMap list -> Task<unit>
|
||||
abstract member Restore : TagMap list -> Task<unit>
|
||||
|
||||
/// Save a tag mapping (insert or update)
|
||||
abstract member save : TagMap -> Task<unit>
|
||||
abstract member Save : TagMap -> Task<unit>
|
||||
|
||||
|
||||
/// Functions to manipulate themes
|
||||
type IThemeData =
|
||||
|
||||
/// Retrieve all themes (except "admin")
|
||||
abstract member all : unit -> Task<Theme list>
|
||||
/// Retrieve all themes (except "admin") (excluding the text of templates)
|
||||
abstract member All : unit -> Task<Theme list>
|
||||
|
||||
/// Delete a theme
|
||||
abstract member Delete : ThemeId -> Task<bool>
|
||||
|
||||
/// Determine if a theme exists
|
||||
abstract member Exists : ThemeId -> Task<bool>
|
||||
|
||||
/// Find a theme by its ID
|
||||
abstract member findById : ThemeId -> Task<Theme option>
|
||||
abstract member FindById : ThemeId -> Task<Theme option>
|
||||
|
||||
/// Find a theme by its ID (excluding the text of its templates)
|
||||
abstract member findByIdWithoutText : ThemeId -> Task<Theme option>
|
||||
abstract member FindByIdWithoutText : ThemeId -> Task<Theme option>
|
||||
|
||||
/// Save a theme (insert or update)
|
||||
abstract member save : Theme -> Task<unit>
|
||||
abstract member Save : Theme -> Task<unit>
|
||||
|
||||
|
||||
/// Functions to manipulate theme assets
|
||||
type IThemeAssetData =
|
||||
|
||||
/// Retrieve all theme assets (excluding data)
|
||||
abstract member all : unit -> Task<ThemeAsset list>
|
||||
abstract member All : unit -> Task<ThemeAsset list>
|
||||
|
||||
/// Delete all theme assets for the given theme
|
||||
abstract member deleteByTheme : ThemeId -> Task<unit>
|
||||
abstract member DeleteByTheme : ThemeId -> Task<unit>
|
||||
|
||||
/// Find a theme asset by its ID
|
||||
abstract member findById : ThemeAssetId -> Task<ThemeAsset option>
|
||||
abstract member FindById : ThemeAssetId -> Task<ThemeAsset option>
|
||||
|
||||
/// Find all assets for the given theme (excludes data)
|
||||
abstract member findByTheme : ThemeId -> Task<ThemeAsset list>
|
||||
abstract member FindByTheme : ThemeId -> Task<ThemeAsset list>
|
||||
|
||||
/// Find all assets for the given theme (includes data)
|
||||
abstract member findByThemeWithData : ThemeId -> Task<ThemeAsset list>
|
||||
abstract member FindByThemeWithData : ThemeId -> Task<ThemeAsset list>
|
||||
|
||||
/// Save a theme asset (insert or update)
|
||||
abstract member save : ThemeAsset -> Task<unit>
|
||||
abstract member Save : ThemeAsset -> Task<unit>
|
||||
|
||||
|
||||
/// Functions to manipulate uploaded files
|
||||
type IUploadData =
|
||||
|
||||
/// Add an uploaded file
|
||||
abstract member Add : Upload -> Task<unit>
|
||||
|
||||
/// Delete an uploaded file
|
||||
abstract member Delete : UploadId -> WebLogId -> Task<Result<string, string>>
|
||||
|
||||
/// Find an uploaded file by its path for the given web log
|
||||
abstract member FindByPath : string -> WebLogId -> Task<Upload option>
|
||||
|
||||
/// Find all uploaded files for a web log (excludes data)
|
||||
abstract member FindByWebLog : WebLogId -> Task<Upload list>
|
||||
|
||||
/// Find all uploaded files for a web log
|
||||
abstract member FindByWebLogWithData : WebLogId -> Task<Upload list>
|
||||
|
||||
/// Restore uploaded files from a backup
|
||||
abstract member Restore : Upload list -> Task<unit>
|
||||
|
||||
|
||||
/// Functions to manipulate web logs
|
||||
type IWebLogData =
|
||||
|
||||
/// Add a web log
|
||||
abstract member add : WebLog -> Task<unit>
|
||||
abstract member Add : WebLog -> Task<unit>
|
||||
|
||||
/// Retrieve all web logs
|
||||
abstract member all : unit -> Task<WebLog list>
|
||||
abstract member All : unit -> Task<WebLog list>
|
||||
|
||||
/// Delete a web log, including categories, tag mappings, posts/comments, and pages
|
||||
abstract member delete : WebLogId -> Task<unit>
|
||||
abstract member Delete : WebLogId -> Task<unit>
|
||||
|
||||
/// Find a web log by its host (URL base)
|
||||
abstract member findByHost : string -> Task<WebLog option>
|
||||
abstract member FindByHost : string -> Task<WebLog option>
|
||||
|
||||
/// Find a web log by its ID
|
||||
abstract member findById : WebLogId -> Task<WebLog option>
|
||||
abstract member FindById : WebLogId -> Task<WebLog option>
|
||||
|
||||
/// Update redirect rules for a web log
|
||||
abstract member UpdateRedirectRules : WebLog -> Task<unit>
|
||||
|
||||
/// Update RSS options for a web log
|
||||
abstract member updateRssOptions : WebLog -> Task<unit>
|
||||
abstract member UpdateRssOptions : WebLog -> Task<unit>
|
||||
|
||||
/// Update web log settings (from the settings page)
|
||||
abstract member updateSettings : WebLog -> Task<unit>
|
||||
abstract member UpdateSettings : WebLog -> Task<unit>
|
||||
|
||||
|
||||
/// Functions to manipulate web log users
|
||||
type IWebLogUserData =
|
||||
|
||||
/// Add a web log user
|
||||
abstract member add : WebLogUser -> Task<unit>
|
||||
abstract member Add : WebLogUser -> Task<unit>
|
||||
|
||||
/// Delete a web log user
|
||||
abstract member Delete : WebLogUserId -> WebLogId -> Task<Result<bool, string>>
|
||||
|
||||
/// Find a web log user by their e-mail address
|
||||
abstract member findByEmail : email : string -> WebLogId -> Task<WebLogUser option>
|
||||
abstract member FindByEmail : email : string -> WebLogId -> Task<WebLogUser option>
|
||||
|
||||
/// Find a web log user by their ID
|
||||
abstract member findById : WebLogUserId -> WebLogId -> Task<WebLogUser option>
|
||||
abstract member FindById : WebLogUserId -> WebLogId -> Task<WebLogUser option>
|
||||
|
||||
/// Find all web log users for the given web log
|
||||
abstract member findByWebLog : WebLogId -> Task<WebLogUser list>
|
||||
abstract member FindByWebLog : WebLogId -> Task<WebLogUser list>
|
||||
|
||||
/// Get a user ID -> name dictionary for the given user IDs
|
||||
abstract member findNames : WebLogId -> WebLogUserId list -> Task<MetaItem list>
|
||||
abstract member FindNames : WebLogId -> WebLogUserId list -> Task<MetaItem list>
|
||||
|
||||
/// Restore users from a backup
|
||||
abstract member restore : WebLogUser list -> Task<unit>
|
||||
abstract member Restore : WebLogUser list -> Task<unit>
|
||||
|
||||
/// Set a user's last seen date/time to now
|
||||
abstract member SetLastSeen : WebLogUserId -> WebLogId -> Task<unit>
|
||||
|
||||
/// Update a web log user
|
||||
abstract member update : WebLogUser -> Task<unit>
|
||||
abstract member Update : WebLogUser -> Task<unit>
|
||||
|
||||
|
||||
/// Data interface required for a myWebLog data implementation
|
||||
@ -270,12 +322,18 @@ type IData =
|
||||
/// Theme asset data functions
|
||||
abstract member ThemeAsset : IThemeAssetData
|
||||
|
||||
/// Uploaded file functions
|
||||
abstract member Upload : IUploadData
|
||||
|
||||
/// Web log data functions
|
||||
abstract member WebLog : IWebLogData
|
||||
|
||||
/// Web log user data functions
|
||||
abstract member WebLogUser : IWebLogUserData
|
||||
|
||||
/// A JSON serializer for use in persistence
|
||||
abstract member Serializer : JsonSerializer
|
||||
|
||||
/// Do any required start up data checks
|
||||
abstract member startUp : unit -> Task<unit>
|
||||
abstract member StartUp : unit -> Task<unit>
|
||||
|
@ -1,31 +1,55 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MyWebLog.Domain\MyWebLog.Domain.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="6.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="BitBadger.Documents.Postgres" Version="3.1.0" />
|
||||
<PackageReference Include="BitBadger.Documents.Sqlite" Version="3.1.0" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.FSharpLu.Json" Version="0.11.7" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.1.0" />
|
||||
<PackageReference Include="Npgsql.NodaTime" Version="8.0.3" />
|
||||
<PackageReference Include="RethinkDb.Driver" Version="2.3.150" />
|
||||
<PackageReference Include="RethinkDb.Driver.FSharp" Version="0.9.0-beta-05" />
|
||||
<PackageReference Update="FSharp.Core" Version="6.0.5" />
|
||||
<PackageReference Include="RethinkDb.Driver.FSharp" Version="0.9.0-beta-07" />
|
||||
<PackageReference Update="FSharp.Core" Version="8.0.300" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Converters.fs" />
|
||||
<Compile Include="Interfaces.fs" />
|
||||
<Compile Include="Utils.fs" />
|
||||
<Compile Include="RethinkDbData.fs" />
|
||||
<Compile Include="SQLite\SQLiteHelpers.fs" />
|
||||
<Compile Include="SQLite\SQLiteCategoryData.fs" />
|
||||
<Compile Include="SQLite\SQLitePageData.fs" />
|
||||
<Compile Include="SQLite\SQLitePostData.fs" />
|
||||
<Compile Include="SQLite\SQLiteTagMapData.fs" />
|
||||
<Compile Include="SQLite\SQLiteThemeData.fs" />
|
||||
<Compile Include="SQLite\SQLiteUploadData.fs" />
|
||||
<Compile Include="SQLite\SQLiteWebLogData.fs" />
|
||||
<Compile Include="SQLite\SQLiteWebLogUserData.fs" />
|
||||
<Compile Include="SQLiteData.fs" />
|
||||
<Compile Include="Postgres\PostgresHelpers.fs" />
|
||||
<Compile Include="Postgres\PostgresCache.fs" />
|
||||
<Compile Include="Postgres\PostgresCategoryData.fs" />
|
||||
<Compile Include="Postgres\PostgresPageData.fs" />
|
||||
<Compile Include="Postgres\PostgresPostData.fs" />
|
||||
<Compile Include="Postgres\PostgresTagMapData.fs" />
|
||||
<Compile Include="Postgres\PostgresThemeData.fs" />
|
||||
<Compile Include="Postgres\PostgresUploadData.fs" />
|
||||
<Compile Include="Postgres\PostgresWebLogData.fs" />
|
||||
<Compile Include="Postgres\PostgresWebLogUserData.fs" />
|
||||
<Compile Include="PostgresData.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||
<_Parameter1>MyWebLog.Tests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
187
src/MyWebLog.Data/Postgres/PostgresCache.fs
Normal file
187
src/MyWebLog.Data/Postgres/PostgresCache.fs
Normal file
@ -0,0 +1,187 @@
|
||||
namespace MyWebLog.Data.Postgres
|
||||
|
||||
open System.Threading
|
||||
open System.Threading.Tasks
|
||||
open BitBadger.Documents.Postgres
|
||||
open Microsoft.Extensions.Caching.Distributed
|
||||
open NodaTime
|
||||
|
||||
/// Helper types and functions for the cache
|
||||
[<AutoOpen>]
|
||||
module private Helpers =
|
||||
|
||||
/// The cache entry
|
||||
type Entry = {
|
||||
/// The ID of the cache entry
|
||||
Id: string
|
||||
|
||||
/// The value to be cached
|
||||
Payload: byte array
|
||||
|
||||
/// When this entry will expire
|
||||
ExpireAt: Instant
|
||||
|
||||
/// The duration by which the expiration should be pushed out when being refreshed
|
||||
SlidingExpiration: Duration option
|
||||
|
||||
/// The must-expire-by date/time for the cache entry
|
||||
AbsoluteExpiration: Instant option
|
||||
}
|
||||
|
||||
/// Run a task synchronously
|
||||
let sync<'T> (it: Task<'T>) = it |> (Async.AwaitTask >> Async.RunSynchronously)
|
||||
|
||||
/// Get the current instant
|
||||
let getNow () = SystemClock.Instance.GetCurrentInstant()
|
||||
|
||||
/// Create a parameter for the expire-at time
|
||||
let expireParam =
|
||||
typedParam "expireAt"
|
||||
|
||||
|
||||
/// A distributed cache implementation in PostgreSQL used to handle sessions for myWebLog
|
||||
type DistributedCache () =
|
||||
|
||||
// ~~~ INITIALIZATION ~~~
|
||||
|
||||
do
|
||||
task {
|
||||
let! exists =
|
||||
Custom.scalar
|
||||
"SELECT EXISTS
|
||||
(SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'session')
|
||||
AS it"
|
||||
[]
|
||||
toExists
|
||||
if not exists then
|
||||
do! Custom.nonQuery
|
||||
"CREATE TABLE session (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
payload BYTEA NOT NULL,
|
||||
expire_at TIMESTAMPTZ NOT NULL,
|
||||
sliding_expiration INTERVAL,
|
||||
absolute_expiration TIMESTAMPTZ);
|
||||
CREATE INDEX idx_session_expiration ON session (expire_at)" []
|
||||
} |> sync
|
||||
|
||||
// ~~~ SUPPORT FUNCTIONS ~~~
|
||||
|
||||
/// Get an entry, updating it for sliding expiration
|
||||
let getEntry key = backgroundTask {
|
||||
let idParam = "@id", Sql.string key
|
||||
let! tryEntry =
|
||||
Custom.single
|
||||
"SELECT * FROM session WHERE id = @id"
|
||||
[ idParam ]
|
||||
(fun row ->
|
||||
{ Id = row.string "id"
|
||||
Payload = row.bytea "payload"
|
||||
ExpireAt = row.fieldValue<Instant> "expire_at"
|
||||
SlidingExpiration = row.fieldValueOrNone<Duration> "sliding_expiration"
|
||||
AbsoluteExpiration = row.fieldValueOrNone<Instant> "absolute_expiration" })
|
||||
match tryEntry with
|
||||
| Some entry ->
|
||||
let now = getNow ()
|
||||
let slideExp = defaultArg entry.SlidingExpiration Duration.MinValue
|
||||
let absExp = defaultArg entry.AbsoluteExpiration Instant.MinValue
|
||||
let needsRefresh, item =
|
||||
if entry.ExpireAt = absExp then false, entry
|
||||
elif slideExp = Duration.MinValue && absExp = Instant.MinValue then false, entry
|
||||
elif absExp > Instant.MinValue && entry.ExpireAt.Plus slideExp > absExp then
|
||||
true, { entry with ExpireAt = absExp }
|
||||
else true, { entry with ExpireAt = now.Plus slideExp }
|
||||
if needsRefresh then
|
||||
do! Custom.nonQuery
|
||||
"UPDATE session SET expire_at = @expireAt WHERE id = @id"
|
||||
[ expireParam item.ExpireAt; idParam ]
|
||||
()
|
||||
return if item.ExpireAt > now then Some entry else None
|
||||
| None -> return None
|
||||
}
|
||||
|
||||
/// The last time expired entries were purged (runs every 30 minutes)
|
||||
let mutable lastPurge = Instant.MinValue
|
||||
|
||||
/// Purge expired entries every 30 minutes
|
||||
let purge () = backgroundTask {
|
||||
let now = getNow ()
|
||||
if lastPurge.Plus(Duration.FromMinutes 30L) < now then
|
||||
do! Custom.nonQuery "DELETE FROM session WHERE expire_at < @expireAt" [ expireParam now ]
|
||||
lastPurge <- now
|
||||
}
|
||||
|
||||
/// Remove a cache entry
|
||||
let removeEntry key =
|
||||
Custom.nonQuery "DELETE FROM session WHERE id = @id" [ "@id", Sql.string key ]
|
||||
|
||||
/// Save an entry
|
||||
let saveEntry (opts: DistributedCacheEntryOptions) key payload =
|
||||
let now = getNow ()
|
||||
let expireAt, slideExp, absExp =
|
||||
if opts.SlidingExpiration.HasValue then
|
||||
let slide = Duration.FromTimeSpan opts.SlidingExpiration.Value
|
||||
now.Plus slide, Some slide, None
|
||||
elif opts.AbsoluteExpiration.HasValue then
|
||||
let exp = Instant.FromDateTimeOffset opts.AbsoluteExpiration.Value
|
||||
exp, None, Some exp
|
||||
elif opts.AbsoluteExpirationRelativeToNow.HasValue then
|
||||
let exp = now.Plus(Duration.FromTimeSpan opts.AbsoluteExpirationRelativeToNow.Value)
|
||||
exp, None, Some exp
|
||||
else
|
||||
// Default to 1 hour sliding expiration
|
||||
let slide = Duration.FromHours 1
|
||||
now.Plus slide, Some slide, None
|
||||
Custom.nonQuery
|
||||
"INSERT INTO session (
|
||||
id, payload, expire_at, sliding_expiration, absolute_expiration
|
||||
) VALUES (
|
||||
@id, @payload, @expireAt, @slideExp, @absExp
|
||||
) ON CONFLICT (id) DO UPDATE
|
||||
SET payload = EXCLUDED.payload,
|
||||
expire_at = EXCLUDED.expire_at,
|
||||
sliding_expiration = EXCLUDED.sliding_expiration,
|
||||
absolute_expiration = EXCLUDED.absolute_expiration"
|
||||
[ "@id", Sql.string key
|
||||
"@payload", Sql.bytea payload
|
||||
expireParam expireAt
|
||||
optParam "slideExp" slideExp
|
||||
optParam "absExp" absExp ]
|
||||
|
||||
// ~~~ IMPLEMENTATION FUNCTIONS ~~~
|
||||
|
||||
/// Retrieve the data for a cache entry
|
||||
let get key (_: CancellationToken) = backgroundTask {
|
||||
match! getEntry key with
|
||||
| Some entry ->
|
||||
do! purge ()
|
||||
return entry.Payload
|
||||
| None -> return null
|
||||
}
|
||||
|
||||
/// Refresh an entry
|
||||
let refresh key (cancelToken: CancellationToken) = backgroundTask {
|
||||
let! _ = get key cancelToken
|
||||
()
|
||||
}
|
||||
|
||||
/// Remove an entry
|
||||
let remove key (_: CancellationToken) = backgroundTask {
|
||||
do! removeEntry key
|
||||
do! purge ()
|
||||
}
|
||||
|
||||
/// Set an entry
|
||||
let set key value options (_: CancellationToken) = backgroundTask {
|
||||
do! saveEntry options key value
|
||||
do! purge ()
|
||||
}
|
||||
|
||||
interface IDistributedCache with
|
||||
member _.Get key = get key CancellationToken.None |> sync
|
||||
member _.GetAsync(key, token) = get key token
|
||||
member _.Refresh key = refresh key CancellationToken.None |> sync
|
||||
member _.RefreshAsync(key, token) = refresh key token
|
||||
member _.Remove key = remove key CancellationToken.None |> sync
|
||||
member _.RemoveAsync(key, token) = remove key token
|
||||
member _.Set(key, value, options) = set key value options CancellationToken.None |> sync
|
||||
member _.SetAsync(key, value, options, token) = set key value options token
|
155
src/MyWebLog.Data/Postgres/PostgresCategoryData.fs
Normal file
155
src/MyWebLog.Data/Postgres/PostgresCategoryData.fs
Normal file
@ -0,0 +1,155 @@
|
||||
namespace MyWebLog.Data.Postgres
|
||||
|
||||
open BitBadger.Documents
|
||||
open BitBadger.Documents.Postgres
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
open Npgsql.FSharp
|
||||
|
||||
/// PostgreSQL myWebLog category data implementation
|
||||
type PostgresCategoryData(log: ILogger) =
|
||||
|
||||
/// Count all categories for the given web log
|
||||
let countAll webLogId =
|
||||
log.LogTrace "Category.countAll"
|
||||
Count.byContains Table.Category (webLogDoc webLogId)
|
||||
|
||||
/// Count all top-level categories for the given web log
|
||||
let countTopLevel webLogId =
|
||||
log.LogTrace "Category.countTopLevel"
|
||||
Custom.scalar
|
||||
$"""{Query.Count.byContains Table.Category}
|
||||
AND {Query.whereByField (Field.NEX (nameof Category.Empty.ParentId)) ""}"""
|
||||
[ webLogContains webLogId ]
|
||||
toCount
|
||||
|
||||
/// Retrieve all categories for the given web log in a DotLiquid-friendly format
|
||||
let findAllForView webLogId = backgroundTask {
|
||||
log.LogTrace "Category.findAllForView"
|
||||
let! cats =
|
||||
Custom.list
|
||||
$"{selectWithCriteria Table.Category} ORDER BY LOWER(data ->> '{nameof Category.Empty.Name}')"
|
||||
[ webLogContains webLogId ]
|
||||
fromData<Category>
|
||||
let ordered = Utils.orderByHierarchy cats None None []
|
||||
let counts =
|
||||
ordered
|
||||
|> Seq.map (fun it ->
|
||||
// Parent category post counts include posts in subcategories
|
||||
let catIdSql, catIdParams =
|
||||
ordered
|
||||
|> Seq.filter (fun cat -> cat.ParentNames |> Array.contains it.Name)
|
||||
|> Seq.map _.Id
|
||||
|> Seq.append (Seq.singleton it.Id)
|
||||
|> List.ofSeq
|
||||
|> arrayContains (nameof Post.Empty.CategoryIds) id
|
||||
let postCount =
|
||||
Custom.scalar
|
||||
$"""SELECT COUNT(DISTINCT data ->> '{nameof Post.Empty.Id}') AS it
|
||||
FROM {Table.Post}
|
||||
WHERE {Query.whereDataContains "@criteria"}
|
||||
AND {catIdSql}"""
|
||||
[ jsonParam "@criteria" {| webLogDoc webLogId with Status = Published |}; catIdParams ]
|
||||
toCount
|
||||
|> Async.AwaitTask
|
||||
|> Async.RunSynchronously
|
||||
it.Id, postCount)
|
||||
|> List.ofSeq
|
||||
return
|
||||
ordered
|
||||
|> Seq.map (fun cat ->
|
||||
{ cat with
|
||||
PostCount = counts
|
||||
|> List.tryFind (fun c -> fst c = cat.Id)
|
||||
|> Option.map snd
|
||||
|> Option.defaultValue 0 })
|
||||
|> Array.ofSeq
|
||||
}
|
||||
/// Find a category by its ID for the given web log
|
||||
let findById catId webLogId =
|
||||
log.LogTrace "Category.findById"
|
||||
Document.findByIdAndWebLog<CategoryId, Category> Table.Category catId webLogId
|
||||
|
||||
/// Find all categories for the given web log
|
||||
let findByWebLog webLogId =
|
||||
log.LogTrace "Category.findByWebLog"
|
||||
Document.findByWebLog<Category> Table.Category webLogId
|
||||
|
||||
/// Delete a category
|
||||
let delete catId webLogId = backgroundTask {
|
||||
log.LogTrace "Category.delete"
|
||||
match! findById catId webLogId with
|
||||
| Some cat ->
|
||||
// Reassign any children to the category's parent category
|
||||
let! children = Find.byContains<Category> Table.Category {| ParentId = catId |}
|
||||
let hasChildren = not (List.isEmpty children)
|
||||
if hasChildren then
|
||||
let childQuery, childParams =
|
||||
if cat.ParentId.IsSome then
|
||||
Query.Patch.byId Table.Category,
|
||||
children
|
||||
|> List.map (fun child -> [ idParam child.Id; jsonParam "@data" {| ParentId = cat.ParentId |} ])
|
||||
else
|
||||
Query.RemoveFields.byId Table.Category,
|
||||
children
|
||||
|> List.map (fun child ->
|
||||
[ idParam child.Id; fieldNameParam [ nameof Category.Empty.ParentId ] ])
|
||||
let! _ =
|
||||
Configuration.dataSource ()
|
||||
|> Sql.fromDataSource
|
||||
|> Sql.executeTransactionAsync [ childQuery, childParams ]
|
||||
()
|
||||
// Delete the category off all posts where it is assigned
|
||||
let! posts =
|
||||
Custom.list
|
||||
$"SELECT data FROM {Table.Post} WHERE data -> '{nameof Post.Empty.CategoryIds}' @> @id"
|
||||
[ jsonParam "@id" [| string catId |] ]
|
||||
fromData<Post>
|
||||
if not (List.isEmpty posts) then
|
||||
let! _ =
|
||||
Configuration.dataSource ()
|
||||
|> Sql.fromDataSource
|
||||
|> Sql.executeTransactionAsync
|
||||
[ Query.Patch.byId Table.Post,
|
||||
posts
|
||||
|> List.map (fun post ->
|
||||
[ idParam post.Id
|
||||
jsonParam
|
||||
"@data"
|
||||
{| CategoryIds = post.CategoryIds |> List.filter (fun cat -> cat <> catId) |} ]) ]
|
||||
()
|
||||
// Delete the category itself
|
||||
do! Delete.byId Table.Category catId
|
||||
return if hasChildren then ReassignedChildCategories else CategoryDeleted
|
||||
| None -> return CategoryNotFound
|
||||
}
|
||||
|
||||
/// Save a category
|
||||
let save (cat: Category) = backgroundTask {
|
||||
log.LogTrace "Category.save"
|
||||
do! save Table.Category cat
|
||||
}
|
||||
|
||||
/// Restore categories from a backup
|
||||
let restore cats = backgroundTask {
|
||||
log.LogTrace "Category.restore"
|
||||
let! _ =
|
||||
Configuration.dataSource ()
|
||||
|> Sql.fromDataSource
|
||||
|> Sql.executeTransactionAsync [
|
||||
Query.insert Table.Category, cats |> List.map (fun c -> [ jsonParam "@data" c ])
|
||||
]
|
||||
()
|
||||
}
|
||||
|
||||
interface ICategoryData with
|
||||
member _.Add cat = save cat
|
||||
member _.CountAll webLogId = countAll webLogId
|
||||
member _.CountTopLevel webLogId = countTopLevel webLogId
|
||||
member _.FindAllForView webLogId = findAllForView webLogId
|
||||
member _.FindById catId webLogId = findById catId webLogId
|
||||
member _.FindByWebLog webLogId = findByWebLog webLogId
|
||||
member _.Delete catId webLogId = delete catId webLogId
|
||||
member _.Restore cats = restore cats
|
||||
member _.Update cat = save cat
|
223
src/MyWebLog.Data/Postgres/PostgresHelpers.fs
Normal file
223
src/MyWebLog.Data/Postgres/PostgresHelpers.fs
Normal file
@ -0,0 +1,223 @@
|
||||
/// Helper functions for the PostgreSQL data implementation
|
||||
[<AutoOpen>]
|
||||
module MyWebLog.Data.Postgres.PostgresHelpers
|
||||
|
||||
/// The table names used in the PostgreSQL implementation
|
||||
[<RequireQualifiedAccess>]
|
||||
module Table =
|
||||
|
||||
/// Categories
|
||||
[<Literal>]
|
||||
let Category = "category"
|
||||
|
||||
/// Database Version
|
||||
[<Literal>]
|
||||
let DbVersion = "db_version"
|
||||
|
||||
/// Pages
|
||||
[<Literal>]
|
||||
let Page = "page"
|
||||
|
||||
/// Page Revisions
|
||||
[<Literal>]
|
||||
let PageRevision = "page_revision"
|
||||
|
||||
/// Posts
|
||||
[<Literal>]
|
||||
let Post = "post"
|
||||
|
||||
/// Post Comments
|
||||
[<Literal>]
|
||||
let PostComment = "post_comment"
|
||||
|
||||
/// Post Revisions
|
||||
[<Literal>]
|
||||
let PostRevision = "post_revision"
|
||||
|
||||
/// Tag/URL Mappings
|
||||
[<Literal>]
|
||||
let TagMap = "tag_map"
|
||||
|
||||
/// Themes
|
||||
[<Literal>]
|
||||
let Theme = "theme"
|
||||
|
||||
/// Theme Assets
|
||||
[<Literal>]
|
||||
let ThemeAsset = "theme_asset"
|
||||
|
||||
/// Uploads
|
||||
[<Literal>]
|
||||
let Upload = "upload"
|
||||
|
||||
/// Web Logs
|
||||
[<Literal>]
|
||||
let WebLog = "web_log"
|
||||
|
||||
/// Users
|
||||
[<Literal>]
|
||||
let WebLogUser = "web_log_user"
|
||||
|
||||
|
||||
open System
|
||||
open System.Threading.Tasks
|
||||
open BitBadger.Documents
|
||||
open BitBadger.Documents.Postgres
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
open NodaTime
|
||||
open Npgsql
|
||||
open Npgsql.FSharp
|
||||
|
||||
/// Create a SQL parameter for the web log ID
|
||||
let webLogIdParam (webLogId: WebLogId) =
|
||||
"@webLogId", Sql.string (string webLogId)
|
||||
|
||||
/// Create an anonymous record with the given web log ID
|
||||
let webLogDoc (webLogId: WebLogId) =
|
||||
{| WebLogId = webLogId |}
|
||||
|
||||
/// Create a parameter for a web log document-contains query
|
||||
let webLogContains webLogId =
|
||||
jsonParam "@criteria" (webLogDoc webLogId)
|
||||
|
||||
/// A SQL string to select data from a table with the given JSON document contains criteria
|
||||
let selectWithCriteria tableName =
|
||||
$"""{Query.selectFromTable tableName} WHERE {Query.whereDataContains "@criteria"}"""
|
||||
|
||||
/// Create the SQL and parameters for an IN clause
|
||||
let inClause<'T> colNameAndPrefix paramName (items: 'T list) =
|
||||
if List.isEmpty items then "", []
|
||||
else
|
||||
let mutable idx = 0
|
||||
items
|
||||
|> List.skip 1
|
||||
|> List.fold (fun (itemS, itemP) it ->
|
||||
idx <- idx + 1
|
||||
$"{itemS}, @%s{paramName}{idx}", ($"@%s{paramName}{idx}", Sql.string (string it)) :: itemP)
|
||||
(Seq.ofList items
|
||||
|> Seq.map (fun it ->
|
||||
$"%s{colNameAndPrefix} IN (@%s{paramName}0", [ $"@%s{paramName}0", Sql.string (string it) ])
|
||||
|> Seq.head)
|
||||
|> function sql, ps -> $"{sql})", ps
|
||||
|
||||
/// Create the SQL and parameters for match-any array query
|
||||
let arrayContains<'T> name (valueFunc: 'T -> string) (items: 'T list) =
|
||||
$"data['{name}'] ?| @{name}Values",
|
||||
($"@{name}Values", Sql.stringArray (items |> List.map valueFunc |> Array.ofList))
|
||||
|
||||
/// Get the first result of the given query
|
||||
let tryHead<'T> (query: Task<'T list>) = backgroundTask {
|
||||
let! results = query
|
||||
return List.tryHead results
|
||||
}
|
||||
|
||||
/// Create a parameter for a non-standard type
|
||||
let typedParam<'T> name (it: 'T) =
|
||||
$"@%s{name}", Sql.parameter (NpgsqlParameter($"@{name}", it))
|
||||
|
||||
/// Create a parameter for a possibly-missing non-standard type
|
||||
let optParam<'T> name (it: 'T option) =
|
||||
let p = NpgsqlParameter($"@%s{name}", if Option.isSome it then box it.Value else DBNull.Value)
|
||||
p.ParameterName, Sql.parameter p
|
||||
|
||||
/// Mapping functions for SQL queries
|
||||
module Map =
|
||||
|
||||
/// Create a permalink from the current row
|
||||
let toPermalink (row: RowReader) =
|
||||
Permalink (row.string "permalink")
|
||||
|
||||
/// Create a revision from the current row
|
||||
let toRevision (row: RowReader) : Revision =
|
||||
{ AsOf = row.fieldValue<Instant> "as_of"
|
||||
Text = row.string "revision_text" |> MarkupText.Parse }
|
||||
|
||||
/// Create a theme asset from the current row
|
||||
let toThemeAsset includeData (row: RowReader) : ThemeAsset =
|
||||
{ Id = ThemeAssetId (ThemeId (row.string "theme_id"), row.string "path")
|
||||
UpdatedOn = row.fieldValue<Instant> "updated_on"
|
||||
Data = if includeData then row.bytea "data" else [||] }
|
||||
|
||||
/// Create an uploaded file from the current row
|
||||
let toUpload includeData (row: RowReader) : Upload =
|
||||
{ Id = row.string "id" |> UploadId
|
||||
WebLogId = row.string "web_log_id" |> WebLogId
|
||||
Path = row.string "path" |> Permalink
|
||||
UpdatedOn = row.fieldValue<Instant> "updated_on"
|
||||
Data = if includeData then row.bytea "data" else [||] }
|
||||
|
||||
/// Document manipulation functions
|
||||
module Document =
|
||||
|
||||
/// Determine whether a document exists with the given key for the given web log
|
||||
let existsByWebLog<'TKey> table (key: 'TKey) webLogId =
|
||||
Custom.scalar
|
||||
$"""SELECT EXISTS (
|
||||
SELECT 1 FROM %s{table} WHERE {Query.whereById "@id"} AND {Query.whereDataContains "@criteria"}
|
||||
) AS it"""
|
||||
[ "@id", Sql.string (string key); webLogContains webLogId ]
|
||||
toExists
|
||||
|
||||
/// Find a document by its ID for the given web log
|
||||
let findByIdAndWebLog<'TKey, 'TDoc> table (key: 'TKey) webLogId =
|
||||
Custom.single
|
||||
$"""{Query.selectFromTable table} WHERE {Query.whereById "@id"} AND {Query.whereDataContains "@criteria"}"""
|
||||
[ "@id", Sql.string (string key); webLogContains webLogId ]
|
||||
fromData<'TDoc>
|
||||
|
||||
/// Find documents for the given web log
|
||||
let findByWebLog<'TDoc> table webLogId : Task<'TDoc list> =
|
||||
Find.byContains table (webLogDoc webLogId)
|
||||
|
||||
|
||||
/// Functions to support revisions
|
||||
module Revisions =
|
||||
|
||||
/// Find all revisions for the given entity
|
||||
let findByEntityId<'TKey> revTable entityTable (key: 'TKey) =
|
||||
Custom.list
|
||||
$"SELECT as_of, revision_text FROM %s{revTable} WHERE %s{entityTable}_id = @id ORDER BY as_of DESC"
|
||||
[ "@id", Sql.string (string key) ]
|
||||
Map.toRevision
|
||||
|
||||
/// Find all revisions for all posts for the given web log
|
||||
let findByWebLog<'TKey> revTable entityTable (keyFunc: string -> 'TKey) webLogId =
|
||||
Custom.list
|
||||
$"""SELECT pr.*
|
||||
FROM %s{revTable} pr
|
||||
INNER JOIN %s{entityTable} p ON p.data ->> '{nameof Post.Empty.Id}' = pr.{entityTable}_id
|
||||
WHERE p.{Query.whereDataContains "@criteria"}
|
||||
ORDER BY as_of DESC"""
|
||||
[ webLogContains webLogId ]
|
||||
(fun row -> keyFunc (row.string $"{entityTable}_id"), Map.toRevision row)
|
||||
|
||||
/// Parameters for a revision INSERT statement
|
||||
let revParams<'TKey> (key: 'TKey) rev = [
|
||||
typedParam "asOf" rev.AsOf
|
||||
"@id", Sql.string (string key)
|
||||
"@text", Sql.string (string rev.Text)
|
||||
]
|
||||
|
||||
/// The SQL statement to insert a revision
|
||||
let insertSql table =
|
||||
$"INSERT INTO %s{table} VALUES (@id, @asOf, @text)"
|
||||
|
||||
/// Update a page's revisions
|
||||
let update<'TKey> revTable entityTable (key: 'TKey) oldRevs newRevs = backgroundTask {
|
||||
let toDelete, toAdd = Utils.diffRevisions oldRevs newRevs
|
||||
if not (List.isEmpty toDelete) || not (List.isEmpty toAdd) then
|
||||
let! _ =
|
||||
Configuration.dataSource ()
|
||||
|> Sql.fromDataSource
|
||||
|> Sql.executeTransactionAsync
|
||||
[ if not (List.isEmpty toDelete) then
|
||||
$"DELETE FROM %s{revTable} WHERE %s{entityTable}_id = @id AND as_of = @asOf",
|
||||
toDelete
|
||||
|> List.map (fun it ->
|
||||
[ "@id", Sql.string (string key)
|
||||
typedParam "asOf" it.AsOf ])
|
||||
if not (List.isEmpty toAdd) then
|
||||
insertSql revTable, toAdd |> List.map (revParams key) ]
|
||||
()
|
||||
}
|
201
src/MyWebLog.Data/Postgres/PostgresPageData.fs
Normal file
201
src/MyWebLog.Data/Postgres/PostgresPageData.fs
Normal file
@ -0,0 +1,201 @@
|
||||
namespace MyWebLog.Data.Postgres
|
||||
|
||||
open BitBadger.Documents
|
||||
open BitBadger.Documents.Postgres
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
open Npgsql.FSharp
|
||||
|
||||
/// PostgreSQL myWebLog page data implementation
|
||||
type PostgresPageData(log: ILogger) =
|
||||
|
||||
// SUPPORT FUNCTIONS
|
||||
|
||||
/// Append revisions to a page
|
||||
let appendPageRevisions (page: Page) = backgroundTask {
|
||||
log.LogTrace "Page.appendPageRevisions"
|
||||
let! revisions = Revisions.findByEntityId Table.PageRevision Table.Page page.Id
|
||||
return { page with Revisions = revisions }
|
||||
}
|
||||
|
||||
/// Return a page with no text or revisions
|
||||
let pageWithoutText (row: RowReader) =
|
||||
{ fromData<Page> row with Text = "" }
|
||||
|
||||
/// Update a page's revisions
|
||||
let updatePageRevisions (pageId: PageId) oldRevs newRevs =
|
||||
log.LogTrace "Page.updatePageRevisions"
|
||||
Revisions.update Table.PageRevision Table.Page pageId oldRevs newRevs
|
||||
|
||||
/// Does the given page exist?
|
||||
let pageExists (pageId: PageId) webLogId =
|
||||
log.LogTrace "Page.pageExists"
|
||||
Document.existsByWebLog Table.Page pageId webLogId
|
||||
|
||||
// IMPLEMENTATION FUNCTIONS
|
||||
|
||||
/// Add a page
|
||||
let add (page: Page) = backgroundTask {
|
||||
log.LogTrace "Page.add"
|
||||
do! insert Table.Page { page with Revisions = [] }
|
||||
do! updatePageRevisions page.Id [] page.Revisions
|
||||
()
|
||||
}
|
||||
|
||||
/// Get all pages for a web log (without text, metadata, revisions, or prior permalinks)
|
||||
let all webLogId =
|
||||
log.LogTrace "Page.all"
|
||||
Custom.list
|
||||
$"{selectWithCriteria Table.Page} ORDER BY LOWER(data ->> '{nameof Page.Empty.Title}')"
|
||||
[ webLogContains webLogId ]
|
||||
(fun row -> { fromData<Page> row with Text = ""; Metadata = []; PriorPermalinks = [] })
|
||||
|
||||
/// Count all pages for the given web log
|
||||
let countAll webLogId =
|
||||
log.LogTrace "Page.countAll"
|
||||
Count.byContains Table.Page (webLogDoc webLogId)
|
||||
|
||||
/// Count all pages shown in the page list for the given web log
|
||||
let countListed webLogId =
|
||||
log.LogTrace "Page.countListed"
|
||||
Count.byContains Table.Page {| webLogDoc webLogId with IsInPageList = true |}
|
||||
|
||||
/// Find a page by its ID (without revisions or prior permalinks)
|
||||
let findById pageId webLogId = backgroundTask {
|
||||
log.LogTrace "Page.findById"
|
||||
match! Document.findByIdAndWebLog<PageId, Page> Table.Page pageId webLogId with
|
||||
| Some page -> return Some { page with PriorPermalinks = [] }
|
||||
| None -> return None
|
||||
}
|
||||
|
||||
/// Find a complete page by its ID
|
||||
let findFullById pageId webLogId = backgroundTask {
|
||||
log.LogTrace "Page.findFullById"
|
||||
match! Document.findByIdAndWebLog<PageId, Page> Table.Page pageId webLogId with
|
||||
| Some page ->
|
||||
let! withMore = appendPageRevisions page
|
||||
return Some withMore
|
||||
| None -> return None
|
||||
}
|
||||
|
||||
// TODO: need to handle when the page being deleted is the home page
|
||||
/// Delete a page by its ID
|
||||
let delete pageId webLogId = backgroundTask {
|
||||
log.LogTrace "Page.delete"
|
||||
match! pageExists pageId webLogId with
|
||||
| true ->
|
||||
do! Custom.nonQuery
|
||||
$"""DELETE FROM {Table.PageRevision} WHERE page_id = @id;
|
||||
DELETE FROM {Table.Page} WHERE {Query.whereById "@id"}"""
|
||||
[ idParam pageId ]
|
||||
return true
|
||||
| false -> return false
|
||||
}
|
||||
|
||||
/// Find a page by its permalink for the given web log
|
||||
let findByPermalink (permalink: Permalink) webLogId = backgroundTask {
|
||||
log.LogTrace "Page.findByPermalink"
|
||||
let! page =
|
||||
Find.byContains<Page> Table.Page {| webLogDoc webLogId with Permalink = permalink |}
|
||||
|> tryHead
|
||||
return page |> Option.map (fun pg -> { pg with PriorPermalinks = [] })
|
||||
}
|
||||
|
||||
/// Find the current permalink within a set of potential prior permalinks for the given web log
|
||||
let findCurrentPermalink (permalinks: Permalink list) webLogId = backgroundTask {
|
||||
log.LogTrace "Page.findCurrentPermalink"
|
||||
if List.isEmpty permalinks then return None
|
||||
else
|
||||
let linkSql, linkParam = arrayContains (nameof Page.Empty.PriorPermalinks) string permalinks
|
||||
return!
|
||||
Custom.single
|
||||
$"""SELECT data ->> '{nameof Page.Empty.Permalink}' AS permalink
|
||||
FROM page
|
||||
WHERE {Query.whereDataContains "@criteria"}
|
||||
AND {linkSql}"""
|
||||
[ webLogContains webLogId; linkParam ]
|
||||
Map.toPermalink
|
||||
}
|
||||
|
||||
/// Get all complete pages for the given web log
|
||||
let findFullByWebLog webLogId = backgroundTask {
|
||||
log.LogTrace "Page.findFullByWebLog"
|
||||
let! pages = Document.findByWebLog<Page> Table.Page webLogId
|
||||
let! revisions = Revisions.findByWebLog Table.PageRevision Table.Page PageId webLogId
|
||||
return
|
||||
pages
|
||||
|> List.map (fun it ->
|
||||
{ it with Revisions = revisions |> List.filter (fun r -> fst r = it.Id) |> List.map snd })
|
||||
}
|
||||
|
||||
/// Get all listed pages for the given web log (without revisions or text)
|
||||
let findListed webLogId =
|
||||
log.LogTrace "Page.findListed"
|
||||
Custom.list
|
||||
$"{selectWithCriteria Table.Page} ORDER BY LOWER(data ->> '{nameof Page.Empty.Title}')"
|
||||
[ jsonParam "@criteria" {| webLogDoc webLogId with IsInPageList = true |} ]
|
||||
pageWithoutText
|
||||
|
||||
/// Get a page of pages for the given web log (without revisions)
|
||||
let findPageOfPages webLogId pageNbr =
|
||||
log.LogTrace "Page.findPageOfPages"
|
||||
Custom.list
|
||||
$"{selectWithCriteria Table.Page}
|
||||
ORDER BY LOWER(data->>'{nameof Page.Empty.Title}')
|
||||
LIMIT @pageSize OFFSET @toSkip"
|
||||
[ webLogContains webLogId; "@pageSize", Sql.int 26; "@toSkip", Sql.int ((pageNbr - 1) * 25) ]
|
||||
(fun row -> { fromData<Page> row with Metadata = []; PriorPermalinks = [] })
|
||||
|
||||
/// Restore pages from a backup
|
||||
let restore (pages: Page list) = backgroundTask {
|
||||
log.LogTrace "Page.restore"
|
||||
let revisions = pages |> List.collect (fun p -> p.Revisions |> List.map (fun r -> p.Id, r))
|
||||
let! _ =
|
||||
Configuration.dataSource ()
|
||||
|> Sql.fromDataSource
|
||||
|> Sql.executeTransactionAsync
|
||||
[ Query.insert Table.Page,
|
||||
pages |> List.map (fun page -> [ jsonParam "@data" { page with Revisions = [] } ])
|
||||
Revisions.insertSql Table.PageRevision,
|
||||
revisions |> List.map (fun (pageId, rev) -> Revisions.revParams pageId rev) ]
|
||||
()
|
||||
}
|
||||
|
||||
/// Update a page
|
||||
let update (page: Page) = backgroundTask {
|
||||
log.LogTrace "Page.update"
|
||||
match! findFullById page.Id page.WebLogId with
|
||||
| Some oldPage ->
|
||||
do! Update.byId Table.Page page.Id { page with Revisions = [] }
|
||||
do! updatePageRevisions page.Id oldPage.Revisions page.Revisions
|
||||
| None -> ()
|
||||
()
|
||||
}
|
||||
|
||||
/// Update a page's prior permalinks
|
||||
let updatePriorPermalinks pageId webLogId (permalinks: Permalink list) = backgroundTask {
|
||||
log.LogTrace "Page.updatePriorPermalinks"
|
||||
match! pageExists pageId webLogId with
|
||||
| true ->
|
||||
do! Patch.byId Table.Page pageId {| PriorPermalinks = permalinks |}
|
||||
return true
|
||||
| false -> return false
|
||||
}
|
||||
|
||||
interface IPageData with
|
||||
member _.Add page = add page
|
||||
member _.All webLogId = all webLogId
|
||||
member _.CountAll webLogId = countAll webLogId
|
||||
member _.CountListed webLogId = countListed webLogId
|
||||
member _.Delete pageId webLogId = delete pageId webLogId
|
||||
member _.FindById pageId webLogId = findById pageId webLogId
|
||||
member _.FindByPermalink permalink webLogId = findByPermalink permalink webLogId
|
||||
member _.FindCurrentPermalink permalinks webLogId = findCurrentPermalink permalinks webLogId
|
||||
member _.FindFullById pageId webLogId = findFullById pageId webLogId
|
||||
member _.FindFullByWebLog webLogId = findFullByWebLog webLogId
|
||||
member _.FindListed webLogId = findListed webLogId
|
||||
member _.FindPageOfPages webLogId pageNbr = findPageOfPages webLogId pageNbr
|
||||
member _.Restore pages = restore pages
|
||||
member _.Update page = update page
|
||||
member _.UpdatePriorPermalinks pageId webLogId permalinks = updatePriorPermalinks pageId webLogId permalinks
|
236
src/MyWebLog.Data/Postgres/PostgresPostData.fs
Normal file
236
src/MyWebLog.Data/Postgres/PostgresPostData.fs
Normal file
@ -0,0 +1,236 @@
|
||||
namespace MyWebLog.Data.Postgres
|
||||
|
||||
open BitBadger.Documents
|
||||
open BitBadger.Documents.Postgres
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
open NodaTime
|
||||
open Npgsql.FSharp
|
||||
|
||||
/// PostgreSQL myWebLog post data implementation
|
||||
type PostgresPostData(log: ILogger) =
|
||||
|
||||
// SUPPORT FUNCTIONS
|
||||
|
||||
/// Append revisions to a post
|
||||
let appendPostRevisions (post: Post) = backgroundTask {
|
||||
log.LogTrace "Post.appendPostRevisions"
|
||||
let! revisions = Revisions.findByEntityId Table.PostRevision Table.Post post.Id
|
||||
return { post with Revisions = revisions }
|
||||
}
|
||||
|
||||
/// Return a post with no revisions or prior permalinks
|
||||
let postWithoutLinks row =
|
||||
{ fromData<Post> row with PriorPermalinks = [] }
|
||||
|
||||
/// Return a post with no revisions, prior permalinks, or text
|
||||
let postWithoutText row =
|
||||
{ postWithoutLinks row with Text = "" }
|
||||
|
||||
/// Update a post's revisions
|
||||
let updatePostRevisions (postId: PostId) oldRevs newRevs =
|
||||
log.LogTrace "Post.updatePostRevisions"
|
||||
Revisions.update Table.PostRevision Table.Post postId oldRevs newRevs
|
||||
|
||||
/// Does the given post exist?
|
||||
let postExists (postId: PostId) webLogId =
|
||||
log.LogTrace "Post.postExists"
|
||||
Document.existsByWebLog Table.Post postId webLogId
|
||||
|
||||
// IMPLEMENTATION FUNCTIONS
|
||||
|
||||
/// Add a post
|
||||
let add (post : Post) = backgroundTask {
|
||||
log.LogTrace "Post.add"
|
||||
do! insert Table.Post { post with Revisions = [] }
|
||||
do! updatePostRevisions post.Id [] post.Revisions
|
||||
}
|
||||
|
||||
/// Count posts in a status for the given web log
|
||||
let countByStatus (status: PostStatus) webLogId =
|
||||
log.LogTrace "Post.countByStatus"
|
||||
Count.byContains Table.Post {| webLogDoc webLogId with Status = status |}
|
||||
|
||||
/// Find a post by its ID for the given web log (excluding revisions)
|
||||
let findById postId webLogId = backgroundTask {
|
||||
log.LogTrace "Post.findById"
|
||||
match! Document.findByIdAndWebLog<PostId, Post> Table.Post postId webLogId with
|
||||
| Some post -> return Some { post with PriorPermalinks = [] }
|
||||
| None -> return None
|
||||
}
|
||||
|
||||
/// Find a post by its permalink for the given web log (excluding revisions)
|
||||
let findByPermalink (permalink: Permalink) webLogId =
|
||||
log.LogTrace "Post.findByPermalink"
|
||||
Custom.single
|
||||
(selectWithCriteria Table.Post)
|
||||
[ jsonParam "@criteria" {| webLogDoc webLogId with Permalink = permalink |} ]
|
||||
postWithoutLinks
|
||||
|
||||
/// Find a complete post by its ID for the given web log
|
||||
let findFullById postId webLogId = backgroundTask {
|
||||
log.LogTrace "Post.findFullById"
|
||||
match! Document.findByIdAndWebLog<PostId, Post> Table.Post postId webLogId with
|
||||
| Some post ->
|
||||
let! withRevisions = appendPostRevisions post
|
||||
return Some withRevisions
|
||||
| None -> return None
|
||||
}
|
||||
|
||||
/// Delete a post by its ID for the given web log
|
||||
let delete postId webLogId = backgroundTask {
|
||||
log.LogTrace "Post.delete"
|
||||
match! postExists postId webLogId with
|
||||
| true ->
|
||||
do! Custom.nonQuery
|
||||
$"""DELETE FROM {Table.PostComment} WHERE {Query.whereDataContains "@criteria"};
|
||||
DELETE FROM {Table.PostRevision} WHERE post_id = @id;
|
||||
DELETE FROM {Table.Post} WHERE {Query.whereById "@id"}"""
|
||||
[ idParam postId; jsonParam "@criteria" {| PostId = postId |} ]
|
||||
return true
|
||||
| false -> return false
|
||||
}
|
||||
|
||||
/// Find the current permalink from a list of potential prior permalinks for the given web log
|
||||
let findCurrentPermalink (permalinks: Permalink list) webLogId = backgroundTask {
|
||||
log.LogTrace "Post.findCurrentPermalink"
|
||||
if List.isEmpty permalinks then return None
|
||||
else
|
||||
let linkSql, linkParam = arrayContains (nameof Post.Empty.PriorPermalinks) string permalinks
|
||||
return!
|
||||
Custom.single
|
||||
$"""SELECT data ->> '{nameof Post.Empty.Permalink}' AS permalink
|
||||
FROM {Table.Post}
|
||||
WHERE {Query.whereDataContains "@criteria"}
|
||||
AND {linkSql}"""
|
||||
[ webLogContains webLogId; linkParam ]
|
||||
Map.toPermalink
|
||||
}
|
||||
|
||||
/// Get all complete posts for the given web log
|
||||
let findFullByWebLog webLogId = backgroundTask {
|
||||
log.LogTrace "Post.findFullByWebLog"
|
||||
let! posts = Document.findByWebLog<Post> Table.Post webLogId
|
||||
let! revisions = Revisions.findByWebLog Table.PostRevision Table.Post PostId webLogId
|
||||
return
|
||||
posts
|
||||
|> List.map (fun it ->
|
||||
{ it with Revisions = revisions |> List.filter (fun r -> fst r = it.Id) |> List.map snd })
|
||||
}
|
||||
|
||||
/// Get a page of categorized posts for the given web log (excludes revisions)
|
||||
let findPageOfCategorizedPosts webLogId (categoryIds: CategoryId list) pageNbr postsPerPage =
|
||||
log.LogTrace "Post.findPageOfCategorizedPosts"
|
||||
let catSql, catParam = arrayContains (nameof Post.Empty.CategoryIds) string categoryIds
|
||||
Custom.list
|
||||
$"{selectWithCriteria Table.Post}
|
||||
AND {catSql}
|
||||
ORDER BY data ->> '{nameof Post.Empty.PublishedOn}' DESC
|
||||
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
|
||||
[ jsonParam "@criteria" {| webLogDoc webLogId with Status = Published |}; catParam ]
|
||||
postWithoutLinks
|
||||
|
||||
/// Get a page of posts for the given web log (excludes text and revisions)
|
||||
let findPageOfPosts webLogId pageNbr postsPerPage =
|
||||
log.LogTrace "Post.findPageOfPosts"
|
||||
Custom.list
|
||||
$"{selectWithCriteria Table.Post}
|
||||
ORDER BY data ->> '{nameof Post.Empty.PublishedOn}' DESC NULLS FIRST,
|
||||
data ->> '{nameof Post.Empty.UpdatedOn}'
|
||||
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
|
||||
[ webLogContains webLogId ]
|
||||
postWithoutText
|
||||
|
||||
/// Get a page of published posts for the given web log (excludes revisions)
|
||||
let findPageOfPublishedPosts webLogId pageNbr postsPerPage =
|
||||
log.LogTrace "Post.findPageOfPublishedPosts"
|
||||
Custom.list
|
||||
$"{selectWithCriteria Table.Post}
|
||||
ORDER BY data ->> '{nameof Post.Empty.PublishedOn}' DESC
|
||||
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
|
||||
[ jsonParam "@criteria" {| webLogDoc webLogId with Status = Published |} ]
|
||||
postWithoutLinks
|
||||
|
||||
/// Get a page of tagged posts for the given web log (excludes revisions and prior permalinks)
|
||||
let findPageOfTaggedPosts webLogId (tag: string) pageNbr postsPerPage =
|
||||
log.LogTrace "Post.findPageOfTaggedPosts"
|
||||
Custom.list
|
||||
$"{selectWithCriteria Table.Post}
|
||||
AND data['{nameof Post.Empty.Tags}'] @> @tag
|
||||
ORDER BY data ->> '{nameof Post.Empty.PublishedOn}' DESC
|
||||
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
|
||||
[ jsonParam "@criteria" {| webLogDoc webLogId with Status = Published |}; jsonParam "@tag" [| tag |] ]
|
||||
postWithoutLinks
|
||||
|
||||
/// Find the next newest and oldest post from a publish date for the given web log
|
||||
let findSurroundingPosts webLogId (publishedOn: Instant) = backgroundTask {
|
||||
log.LogTrace "Post.findSurroundingPosts"
|
||||
let queryParams () =
|
||||
[ jsonParam "@criteria" {| webLogDoc webLogId with Status = Published |}
|
||||
"@publishedOn", Sql.timestamptz (publishedOn.ToDateTimeOffset()) ]
|
||||
let query op direction =
|
||||
$"{selectWithCriteria Table.Post}
|
||||
AND (data ->> '{nameof Post.Empty.PublishedOn}')::timestamp with time zone %s{op} @publishedOn
|
||||
ORDER BY data ->> '{nameof Post.Empty.PublishedOn}' %s{direction}
|
||||
LIMIT 1"
|
||||
let! older = Custom.list (query "<" "DESC") (queryParams ()) postWithoutLinks
|
||||
let! newer = Custom.list (query ">" "") (queryParams ()) postWithoutLinks
|
||||
return List.tryHead older, List.tryHead newer
|
||||
}
|
||||
|
||||
/// Update a post
|
||||
let update (post : Post) = backgroundTask {
|
||||
log.LogTrace "Post.save"
|
||||
match! findFullById post.Id post.WebLogId with
|
||||
| Some oldPost ->
|
||||
do! Update.byId Table.Post post.Id { post with Revisions = [] }
|
||||
do! updatePostRevisions post.Id oldPost.Revisions post.Revisions
|
||||
| None -> ()
|
||||
}
|
||||
|
||||
/// Restore posts from a backup
|
||||
let restore posts = backgroundTask {
|
||||
log.LogTrace "Post.restore"
|
||||
let revisions = posts |> List.collect (fun p -> p.Revisions |> List.map (fun r -> p.Id, r))
|
||||
let! _ =
|
||||
Configuration.dataSource ()
|
||||
|> Sql.fromDataSource
|
||||
|> Sql.executeTransactionAsync
|
||||
[ Query.insert Table.Post,
|
||||
posts |> List.map (fun post -> [ jsonParam "@data" { post with Revisions = [] } ])
|
||||
Revisions.insertSql Table.PostRevision,
|
||||
revisions |> List.map (fun (postId, rev) -> Revisions.revParams postId rev) ]
|
||||
()
|
||||
}
|
||||
|
||||
/// Update prior permalinks for a post
|
||||
let updatePriorPermalinks postId webLogId (permalinks: Permalink list) = backgroundTask {
|
||||
log.LogTrace "Post.updatePriorPermalinks"
|
||||
match! postExists postId webLogId with
|
||||
| true ->
|
||||
do! Patch.byId Table.Post postId {| PriorPermalinks = permalinks |}
|
||||
return true
|
||||
| false -> return false
|
||||
}
|
||||
|
||||
interface IPostData with
|
||||
member _.Add post = add post
|
||||
member _.CountByStatus status webLogId = countByStatus status webLogId
|
||||
member _.Delete postId webLogId = delete postId webLogId
|
||||
member _.FindById postId webLogId = findById postId webLogId
|
||||
member _.FindByPermalink permalink webLogId = findByPermalink permalink webLogId
|
||||
member _.FindCurrentPermalink permalinks webLogId = findCurrentPermalink permalinks webLogId
|
||||
member _.FindFullById postId webLogId = findFullById postId webLogId
|
||||
member _.FindFullByWebLog webLogId = findFullByWebLog webLogId
|
||||
member _.FindPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage =
|
||||
findPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage
|
||||
member _.FindPageOfPosts webLogId pageNbr postsPerPage = findPageOfPosts webLogId pageNbr postsPerPage
|
||||
member _.FindPageOfPublishedPosts webLogId pageNbr postsPerPage =
|
||||
findPageOfPublishedPosts webLogId pageNbr postsPerPage
|
||||
member _.FindPageOfTaggedPosts webLogId tag pageNbr postsPerPage =
|
||||
findPageOfTaggedPosts webLogId tag pageNbr postsPerPage
|
||||
member _.FindSurroundingPosts webLogId publishedOn = findSurroundingPosts webLogId publishedOn
|
||||
member _.Restore posts = restore posts
|
||||
member _.Update post = update post
|
||||
member _.UpdatePriorPermalinks postId webLogId permalinks = updatePriorPermalinks postId webLogId permalinks
|
73
src/MyWebLog.Data/Postgres/PostgresTagMapData.fs
Normal file
73
src/MyWebLog.Data/Postgres/PostgresTagMapData.fs
Normal file
@ -0,0 +1,73 @@
|
||||
namespace MyWebLog.Data.Postgres
|
||||
|
||||
open BitBadger.Documents
|
||||
open BitBadger.Documents.Postgres
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
open Npgsql.FSharp
|
||||
|
||||
/// PostgreSQL myWebLog tag mapping data implementation
|
||||
type PostgresTagMapData(log: ILogger) =
|
||||
|
||||
/// Find a tag mapping by its ID for the given web log
|
||||
let findById tagMapId webLogId =
|
||||
log.LogTrace "TagMap.findById"
|
||||
Document.findByIdAndWebLog<TagMapId, TagMap> Table.TagMap tagMapId webLogId
|
||||
|
||||
/// Delete a tag mapping for the given web log
|
||||
let delete (tagMapId: TagMapId) webLogId = backgroundTask {
|
||||
log.LogTrace "TagMap.delete"
|
||||
let! exists = Document.existsByWebLog Table.TagMap tagMapId webLogId
|
||||
if exists then
|
||||
do! Delete.byId Table.TagMap tagMapId
|
||||
return true
|
||||
else return false
|
||||
}
|
||||
|
||||
/// Find a tag mapping by its URL value for the given web log
|
||||
let findByUrlValue (urlValue: string) webLogId =
|
||||
log.LogTrace "TagMap.findByUrlValue"
|
||||
Find.firstByContains<TagMap> Table.TagMap {| webLogDoc webLogId with UrlValue = urlValue |}
|
||||
|
||||
/// Get all tag mappings for the given web log
|
||||
let findByWebLog webLogId =
|
||||
log.LogTrace "TagMap.findByWebLog"
|
||||
Custom.list
|
||||
$"{selectWithCriteria Table.TagMap} ORDER BY data ->> 'tag'"
|
||||
[ webLogContains webLogId ]
|
||||
fromData<TagMap>
|
||||
|
||||
/// Find any tag mappings in a list of tags for the given web log
|
||||
let findMappingForTags tags webLogId =
|
||||
log.LogTrace "TagMap.findMappingForTags"
|
||||
let tagSql, tagParam = arrayContains (nameof TagMap.Empty.Tag) id tags
|
||||
Custom.list
|
||||
$"{selectWithCriteria Table.TagMap} AND {tagSql}"
|
||||
[ webLogContains webLogId; tagParam ]
|
||||
fromData<TagMap>
|
||||
|
||||
/// Save a tag mapping
|
||||
let save (tagMap: TagMap) =
|
||||
log.LogTrace "TagMap.save"
|
||||
save Table.TagMap tagMap
|
||||
|
||||
/// Restore tag mappings from a backup
|
||||
let restore (tagMaps: TagMap list) = backgroundTask {
|
||||
let! _ =
|
||||
Configuration.dataSource ()
|
||||
|> Sql.fromDataSource
|
||||
|> Sql.executeTransactionAsync
|
||||
[ Query.insert Table.TagMap,
|
||||
tagMaps |> List.map (fun tagMap -> [ jsonParam "@data" tagMap ]) ]
|
||||
()
|
||||
}
|
||||
|
||||
interface ITagMapData with
|
||||
member _.Delete tagMapId webLogId = delete tagMapId webLogId
|
||||
member _.FindById tagMapId webLogId = findById tagMapId webLogId
|
||||
member _.FindByUrlValue urlValue webLogId = findByUrlValue urlValue webLogId
|
||||
member _.FindByWebLog webLogId = findByWebLog webLogId
|
||||
member _.FindMappingForTags tags webLogId = findMappingForTags tags webLogId
|
||||
member _.Save tagMap = save tagMap
|
||||
member _.Restore tagMaps = restore tagMaps
|
127
src/MyWebLog.Data/Postgres/PostgresThemeData.fs
Normal file
127
src/MyWebLog.Data/Postgres/PostgresThemeData.fs
Normal file
@ -0,0 +1,127 @@
|
||||
namespace MyWebLog.Data.Postgres
|
||||
|
||||
open BitBadger.Documents
|
||||
open BitBadger.Documents.Postgres
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
|
||||
/// PostreSQL myWebLog theme data implementation
|
||||
type PostgresThemeData(log: ILogger) =
|
||||
|
||||
/// Clear out the template text from a theme
|
||||
let withoutTemplateText row =
|
||||
let theme = fromData<Theme> row
|
||||
{ theme with Templates = theme.Templates |> List.map (fun template -> { template with Text = "" }) }
|
||||
|
||||
/// Retrieve all themes (except 'admin'; excludes template text)
|
||||
let all () =
|
||||
log.LogTrace "Theme.all"
|
||||
Custom.list
|
||||
$"{Query.selectFromTable Table.Theme}
|
||||
WHERE data ->> '{nameof Theme.Empty.Id}' <> 'admin'
|
||||
ORDER BY data ->> '{nameof Theme.Empty.Id}'"
|
||||
[]
|
||||
withoutTemplateText
|
||||
|
||||
/// Does a given theme exist?
|
||||
let exists (themeId: ThemeId) =
|
||||
log.LogTrace "Theme.exists"
|
||||
Exists.byId Table.Theme themeId
|
||||
|
||||
/// Find a theme by its ID
|
||||
let findById (themeId: ThemeId) =
|
||||
log.LogTrace "Theme.findById"
|
||||
Find.byId<ThemeId, Theme> Table.Theme themeId
|
||||
|
||||
/// Find a theme by its ID (excludes the text of templates)
|
||||
let findByIdWithoutText (themeId: ThemeId) =
|
||||
log.LogTrace "Theme.findByIdWithoutText"
|
||||
Custom.single (Query.Find.byId Table.Theme) [ idParam themeId ] withoutTemplateText
|
||||
|
||||
/// Delete a theme by its ID
|
||||
let delete themeId = backgroundTask {
|
||||
log.LogTrace "Theme.delete"
|
||||
match! exists themeId with
|
||||
| true ->
|
||||
do! Custom.nonQuery
|
||||
$"""DELETE FROM {Table.ThemeAsset} WHERE theme_id = @id;
|
||||
DELETE FROM {Table.Theme} WHERE {Query.whereById "@id"}"""
|
||||
[ idParam themeId ]
|
||||
return true
|
||||
| false -> return false
|
||||
}
|
||||
|
||||
/// Save a theme
|
||||
let save (theme: Theme) =
|
||||
log.LogTrace "Theme.save"
|
||||
save Table.Theme theme
|
||||
|
||||
interface IThemeData with
|
||||
member _.All() = all ()
|
||||
member _.Delete themeId = delete themeId
|
||||
member _.Exists themeId = exists themeId
|
||||
member _.FindById themeId = findById themeId
|
||||
member _.FindByIdWithoutText themeId = findByIdWithoutText themeId
|
||||
member _.Save theme = save theme
|
||||
|
||||
|
||||
/// PostreSQL myWebLog theme data implementation
|
||||
type PostgresThemeAssetData(log: ILogger) =
|
||||
|
||||
/// Get all theme assets (excludes data)
|
||||
let all () =
|
||||
log.LogTrace "ThemeAsset.all"
|
||||
Custom.list $"SELECT theme_id, path, updated_on FROM {Table.ThemeAsset}" [] (Map.toThemeAsset false)
|
||||
|
||||
/// Delete all assets for the given theme
|
||||
let deleteByTheme (themeId: ThemeId) =
|
||||
log.LogTrace "ThemeAsset.deleteByTheme"
|
||||
Custom.nonQuery $"DELETE FROM {Table.ThemeAsset} WHERE theme_id = @id" [ idParam themeId ]
|
||||
|
||||
/// Find a theme asset by its ID
|
||||
let findById assetId =
|
||||
log.LogTrace "ThemeAsset.findById"
|
||||
let (ThemeAssetId (ThemeId themeId, path)) = assetId
|
||||
Custom.single
|
||||
$"SELECT * FROM {Table.ThemeAsset} WHERE theme_id = @id AND path = @path"
|
||||
[ idParam themeId; "@path", Sql.string path ]
|
||||
(Map.toThemeAsset true)
|
||||
|
||||
/// Get theme assets for the given theme (excludes data)
|
||||
let findByTheme (themeId: ThemeId) =
|
||||
log.LogTrace "ThemeAsset.findByTheme"
|
||||
Custom.list
|
||||
$"SELECT theme_id, path, updated_on FROM {Table.ThemeAsset} WHERE theme_id = @id"
|
||||
[ idParam themeId ]
|
||||
(Map.toThemeAsset false)
|
||||
|
||||
/// Get theme assets for the given theme
|
||||
let findByThemeWithData (themeId: ThemeId) =
|
||||
log.LogTrace "ThemeAsset.findByThemeWithData"
|
||||
Custom.list $"SELECT * FROM {Table.ThemeAsset} WHERE theme_id = @id" [ idParam themeId ] (Map.toThemeAsset true)
|
||||
|
||||
/// Save a theme asset
|
||||
let save (asset: ThemeAsset) =
|
||||
log.LogTrace "ThemeAsset.save"
|
||||
let (ThemeAssetId (ThemeId themeId, path)) = asset.Id
|
||||
Custom.nonQuery
|
||||
$"INSERT INTO {Table.ThemeAsset} (
|
||||
theme_id, path, updated_on, data
|
||||
) VALUES (
|
||||
@themeId, @path, @updatedOn, @data
|
||||
) ON CONFLICT (theme_id, path) DO UPDATE
|
||||
SET updated_on = EXCLUDED.updated_on,
|
||||
data = EXCLUDED.data"
|
||||
[ "@themeId", Sql.string themeId
|
||||
"@path", Sql.string path
|
||||
"@data", Sql.bytea asset.Data
|
||||
typedParam "updatedOn" asset.UpdatedOn ]
|
||||
|
||||
interface IThemeAssetData with
|
||||
member _.All() = all ()
|
||||
member _.DeleteByTheme themeId = deleteByTheme themeId
|
||||
member _.FindById assetId = findById assetId
|
||||
member _.FindByTheme themeId = findByTheme themeId
|
||||
member _.FindByThemeWithData themeId = findByThemeWithData themeId
|
||||
member _.Save asset = save asset
|
90
src/MyWebLog.Data/Postgres/PostgresUploadData.fs
Normal file
90
src/MyWebLog.Data/Postgres/PostgresUploadData.fs
Normal file
@ -0,0 +1,90 @@
|
||||
namespace MyWebLog.Data.Postgres
|
||||
|
||||
open BitBadger.Documents.Postgres
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
open Npgsql.FSharp
|
||||
|
||||
/// PostgreSQL myWebLog uploaded file data implementation
|
||||
type PostgresUploadData(log: ILogger) =
|
||||
|
||||
/// The INSERT statement for an uploaded file
|
||||
let upInsert = $"
|
||||
INSERT INTO {Table.Upload} (
|
||||
id, web_log_id, path, updated_on, data
|
||||
) VALUES (
|
||||
@id, @webLogId, @path, @updatedOn, @data
|
||||
)"
|
||||
|
||||
/// Parameters for adding an uploaded file
|
||||
let upParams (upload: Upload) =
|
||||
[ webLogIdParam upload.WebLogId
|
||||
typedParam "updatedOn" upload.UpdatedOn
|
||||
idParam upload.Id
|
||||
"@path", Sql.string (string upload.Path)
|
||||
"@data", Sql.bytea upload.Data ]
|
||||
|
||||
/// Save an uploaded file
|
||||
let add upload =
|
||||
log.LogTrace "Upload.add"
|
||||
Custom.nonQuery upInsert (upParams upload)
|
||||
|
||||
/// Delete an uploaded file by its ID
|
||||
let delete uploadId webLogId = backgroundTask {
|
||||
log.LogTrace "Upload.delete"
|
||||
let idParam = [ idParam uploadId ]
|
||||
let! path =
|
||||
Custom.single
|
||||
$"SELECT path FROM {Table.Upload} WHERE id = @id AND web_log_id = @webLogId"
|
||||
(webLogIdParam webLogId :: idParam)
|
||||
(fun row -> row.string "path")
|
||||
if Option.isSome path then
|
||||
do! Custom.nonQuery $"DELETE FROM {Table.Upload} WHERE id = @id" idParam
|
||||
return Ok path.Value
|
||||
else return Error $"Upload ID {uploadId} not found"
|
||||
}
|
||||
|
||||
/// Find an uploaded file by its path for the given web log
|
||||
let findByPath path webLogId =
|
||||
log.LogTrace "Upload.findByPath"
|
||||
Custom.single
|
||||
$"SELECT * FROM {Table.Upload} WHERE web_log_id = @webLogId AND path = @path"
|
||||
[ webLogIdParam webLogId; "@path", Sql.string path ]
|
||||
(Map.toUpload true)
|
||||
|
||||
/// Find all uploaded files for the given web log (excludes data)
|
||||
let findByWebLog webLogId =
|
||||
log.LogTrace "Upload.findByWebLog"
|
||||
Custom.list
|
||||
$"SELECT id, web_log_id, path, updated_on FROM {Table.Upload} WHERE web_log_id = @webLogId"
|
||||
[ webLogIdParam webLogId ]
|
||||
(Map.toUpload false)
|
||||
|
||||
/// Find all uploaded files for the given web log
|
||||
let findByWebLogWithData webLogId =
|
||||
log.LogTrace "Upload.findByWebLogWithData"
|
||||
Custom.list
|
||||
$"SELECT * FROM {Table.Upload} WHERE web_log_id = @webLogId"
|
||||
[ webLogIdParam webLogId ]
|
||||
(Map.toUpload true)
|
||||
|
||||
/// Restore uploads from a backup
|
||||
let restore uploads = backgroundTask {
|
||||
log.LogTrace "Upload.restore"
|
||||
for batch in uploads |> List.chunkBySize 5 do
|
||||
let! _ =
|
||||
Configuration.dataSource ()
|
||||
|> Sql.fromDataSource
|
||||
|> Sql.executeTransactionAsync [ upInsert, batch |> List.map upParams ]
|
||||
()
|
||||
}
|
||||
|
||||
interface IUploadData with
|
||||
member _.Add upload = add upload
|
||||
member _.Delete uploadId webLogId = delete uploadId webLogId
|
||||
member _.FindByPath path webLogId = findByPath path webLogId
|
||||
member _.FindByWebLog webLogId = findByWebLog webLogId
|
||||
member _.FindByWebLogWithData webLogId = findByWebLogWithData webLogId
|
||||
member _.Restore uploads = restore uploads
|
||||
|
83
src/MyWebLog.Data/Postgres/PostgresWebLogData.fs
Normal file
83
src/MyWebLog.Data/Postgres/PostgresWebLogData.fs
Normal file
@ -0,0 +1,83 @@
|
||||
namespace MyWebLog.Data.Postgres
|
||||
|
||||
open BitBadger.Documents
|
||||
open BitBadger.Documents.Postgres
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
|
||||
/// PostgreSQL myWebLog web log data implementation
|
||||
type PostgresWebLogData(log: ILogger) =
|
||||
|
||||
/// Add a web log
|
||||
let add (webLog: WebLog) =
|
||||
log.LogTrace "WebLog.add"
|
||||
insert Table.WebLog webLog
|
||||
|
||||
/// Retrieve all web logs
|
||||
let all () =
|
||||
log.LogTrace "WebLog.all"
|
||||
Find.all<WebLog> Table.WebLog
|
||||
|
||||
/// Delete a web log by its ID
|
||||
let delete webLogId =
|
||||
log.LogTrace "WebLog.delete"
|
||||
Custom.nonQuery
|
||||
$"""DELETE FROM {Table.PostComment}
|
||||
WHERE data ->> '{nameof Comment.Empty.PostId}'
|
||||
IN (SELECT data ->> '{nameof Post.Empty.Id}'
|
||||
FROM {Table.Post}
|
||||
WHERE {Query.whereDataContains "@criteria"});
|
||||
DELETE FROM {Table.PostRevision}
|
||||
WHERE post_id IN (SELECT data ->> 'Id' FROM {Table.Post} WHERE {Query.whereDataContains "@criteria"});
|
||||
DELETE FROM {Table.PageRevision}
|
||||
WHERE page_id IN (SELECT data ->> 'Id' FROM {Table.Page} WHERE {Query.whereDataContains "@criteria"});
|
||||
{Query.Delete.byContains Table.Post};
|
||||
{Query.Delete.byContains Table.Page};
|
||||
{Query.Delete.byContains Table.Category};
|
||||
{Query.Delete.byContains Table.TagMap};
|
||||
{Query.Delete.byContains Table.WebLogUser};
|
||||
DELETE FROM {Table.Upload} WHERE web_log_id = @webLogId;
|
||||
DELETE FROM {Table.WebLog} WHERE {Query.whereById "@webLogId"}"""
|
||||
[ webLogIdParam webLogId; webLogContains webLogId ]
|
||||
|
||||
/// Find a web log by its host (URL base)
|
||||
let findByHost (url: string) =
|
||||
log.LogTrace "WebLog.findByHost"
|
||||
Find.firstByContains<WebLog> Table.WebLog {| UrlBase = url |}
|
||||
|
||||
/// Find a web log by its ID
|
||||
let findById (webLogId: WebLogId) =
|
||||
log.LogTrace "WebLog.findById"
|
||||
Find.byId<WebLogId, WebLog> Table.WebLog webLogId
|
||||
|
||||
/// Update redirect rules for a web log
|
||||
let updateRedirectRules (webLog: WebLog) = backgroundTask {
|
||||
log.LogTrace "WebLog.updateRedirectRules"
|
||||
match! findById webLog.Id with
|
||||
| Some _ -> do! Patch.byId Table.WebLog webLog.Id {| RedirectRules = webLog.RedirectRules |}
|
||||
| None -> ()
|
||||
}
|
||||
|
||||
/// Update RSS options for a web log
|
||||
let updateRssOptions (webLog: WebLog) = backgroundTask {
|
||||
log.LogTrace "WebLog.updateRssOptions"
|
||||
match! findById webLog.Id with
|
||||
| Some _ -> do! Patch.byId Table.WebLog webLog.Id {| Rss = webLog.Rss |}
|
||||
| None -> ()
|
||||
}
|
||||
|
||||
/// Update settings for a web log
|
||||
let updateSettings (webLog: WebLog) =
|
||||
log.LogTrace "WebLog.updateSettings"
|
||||
Update.byId Table.WebLog webLog.Id webLog
|
||||
|
||||
interface IWebLogData with
|
||||
member _.Add webLog = add webLog
|
||||
member _.All() = all ()
|
||||
member _.Delete webLogId = delete webLogId
|
||||
member _.FindByHost url = findByHost url
|
||||
member _.FindById webLogId = findById webLogId
|
||||
member _.UpdateRedirectRules webLog = updateRedirectRules webLog
|
||||
member _.UpdateRssOptions webLog = updateRssOptions webLog
|
||||
member _.UpdateSettings webLog = updateSettings webLog
|
102
src/MyWebLog.Data/Postgres/PostgresWebLogUserData.fs
Normal file
102
src/MyWebLog.Data/Postgres/PostgresWebLogUserData.fs
Normal file
@ -0,0 +1,102 @@
|
||||
namespace MyWebLog.Data.Postgres
|
||||
|
||||
open BitBadger.Documents
|
||||
open BitBadger.Documents.Postgres
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
open Npgsql.FSharp
|
||||
|
||||
/// PostgreSQL myWebLog user data implementation
|
||||
type PostgresWebLogUserData(log: ILogger) =
|
||||
|
||||
/// Add a user
|
||||
let add (user: WebLogUser) =
|
||||
log.LogTrace "WebLogUser.add"
|
||||
insert Table.WebLogUser user
|
||||
|
||||
/// Find a user by their ID for the given web log
|
||||
let findById userId webLogId =
|
||||
log.LogTrace "WebLogUser.findById"
|
||||
Document.findByIdAndWebLog<WebLogUserId, WebLogUser> Table.WebLogUser userId webLogId
|
||||
|
||||
/// Delete a user if they have no posts or pages
|
||||
let delete userId webLogId = backgroundTask {
|
||||
log.LogTrace "WebLogUser.delete"
|
||||
match! findById userId webLogId with
|
||||
| Some _ ->
|
||||
let criteria = Query.whereDataContains "@criteria"
|
||||
let! isAuthor =
|
||||
Custom.scalar
|
||||
$" SELECT ( EXISTS (SELECT 1 FROM {Table.Page} WHERE {criteria})
|
||||
OR EXISTS (SELECT 1 FROM {Table.Post} WHERE {criteria})
|
||||
) AS it"
|
||||
[ jsonParam "@criteria" {| AuthorId = userId |} ]
|
||||
toExists
|
||||
if isAuthor then
|
||||
return Error "User has pages or posts; cannot delete"
|
||||
else
|
||||
do! Delete.byId Table.WebLogUser userId
|
||||
return Ok true
|
||||
| None -> return Error "User does not exist"
|
||||
}
|
||||
|
||||
/// Find a user by their e-mail address for the given web log
|
||||
let findByEmail (email: string) webLogId =
|
||||
log.LogTrace "WebLogUser.findByEmail"
|
||||
Find.firstByContains<WebLogUser> Table.WebLogUser {| webLogDoc webLogId with Email = email |}
|
||||
|
||||
/// Get all users for the given web log
|
||||
let findByWebLog webLogId =
|
||||
log.LogTrace "WebLogUser.findByWebLog"
|
||||
Custom.list
|
||||
$"{selectWithCriteria Table.WebLogUser} ORDER BY LOWER(data ->> '{nameof WebLogUser.Empty.PreferredName}')"
|
||||
[ webLogContains webLogId ]
|
||||
fromData<WebLogUser>
|
||||
|
||||
/// Find the names of users by their IDs for the given web log
|
||||
let findNames webLogId (userIds: WebLogUserId list) = backgroundTask {
|
||||
log.LogTrace "WebLogUser.findNames"
|
||||
let idSql, idParams = inClause $"AND data ->> '{nameof WebLogUser.Empty.Id}'" "id" userIds
|
||||
let! users =
|
||||
Custom.list
|
||||
$"{selectWithCriteria Table.WebLogUser} {idSql}"
|
||||
(webLogContains webLogId :: idParams)
|
||||
fromData<WebLogUser>
|
||||
return users |> List.map (fun u -> { Name = string u.Id; Value = u.DisplayName })
|
||||
}
|
||||
|
||||
/// Restore users from a backup
|
||||
let restore (users: WebLogUser list) = backgroundTask {
|
||||
log.LogTrace "WebLogUser.restore"
|
||||
let! _ =
|
||||
Configuration.dataSource ()
|
||||
|> Sql.fromDataSource
|
||||
|> Sql.executeTransactionAsync
|
||||
[ Query.insert Table.WebLogUser, users |> List.map (fun user -> [ jsonParam "@data" user ]) ]
|
||||
()
|
||||
}
|
||||
|
||||
/// Set a user's last seen date/time to now
|
||||
let setLastSeen (userId: WebLogUserId) webLogId = backgroundTask {
|
||||
log.LogTrace "WebLogUser.setLastSeen"
|
||||
match! Document.existsByWebLog Table.WebLogUser userId webLogId with
|
||||
| true -> do! Patch.byId Table.WebLogUser userId {| LastSeenOn = Some (Noda.now ()) |}
|
||||
| false -> ()
|
||||
}
|
||||
|
||||
/// Update a user
|
||||
let update (user: WebLogUser) =
|
||||
log.LogTrace "WebLogUser.update"
|
||||
Update.byId Table.WebLogUser user.Id user
|
||||
|
||||
interface IWebLogUserData with
|
||||
member _.Add user = add user
|
||||
member _.Delete userId webLogId = delete userId webLogId
|
||||
member _.FindByEmail email webLogId = findByEmail email webLogId
|
||||
member _.FindById userId webLogId = findById userId webLogId
|
||||
member _.FindByWebLog webLogId = findByWebLog webLogId
|
||||
member _.FindNames webLogId userIds = findNames webLogId userIds
|
||||
member _.Restore users = restore users
|
||||
member _.SetLastSeen userId webLogId = setLastSeen userId webLogId
|
||||
member _.Update user = update user
|
269
src/MyWebLog.Data/PostgresData.fs
Normal file
269
src/MyWebLog.Data/PostgresData.fs
Normal file
@ -0,0 +1,269 @@
|
||||
namespace MyWebLog.Data
|
||||
|
||||
open BitBadger.Documents
|
||||
open BitBadger.Documents.Postgres
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog
|
||||
open MyWebLog.Data.Postgres
|
||||
open Newtonsoft.Json
|
||||
open Npgsql.FSharp
|
||||
|
||||
/// Data implementation for PostgreSQL
|
||||
type PostgresData(log: ILogger<PostgresData>, ser: JsonSerializer) =
|
||||
|
||||
/// Create any needed tables
|
||||
let ensureTables () = backgroundTask {
|
||||
// Set up the PostgreSQL document store
|
||||
Configuration.useSerializer (Utils.createDocumentSerializer ser)
|
||||
|
||||
let! tables =
|
||||
Custom.list
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname = 'public'" [] (fun row -> row.string "tablename")
|
||||
let needsTable table = not (List.contains table tables)
|
||||
|
||||
let sql = seq {
|
||||
// Theme tables
|
||||
if needsTable Table.Theme then
|
||||
Query.Definition.ensureTable Table.Theme
|
||||
Query.Definition.ensureKey Table.Theme
|
||||
if needsTable Table.ThemeAsset then
|
||||
$"CREATE TABLE {Table.ThemeAsset} (
|
||||
theme_id TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
updated_on TIMESTAMPTZ NOT NULL,
|
||||
data BYTEA NOT NULL,
|
||||
PRIMARY KEY (theme_id, path))"
|
||||
|
||||
// Web log table
|
||||
if needsTable Table.WebLog then
|
||||
Query.Definition.ensureTable Table.WebLog
|
||||
Query.Definition.ensureKey Table.WebLog
|
||||
Query.Definition.ensureDocumentIndex Table.WebLog Optimized
|
||||
|
||||
// Category table
|
||||
if needsTable Table.Category then
|
||||
Query.Definition.ensureTable Table.Category
|
||||
Query.Definition.ensureKey Table.Category
|
||||
Query.Definition.ensureDocumentIndex Table.Category Optimized
|
||||
|
||||
// Web log user table
|
||||
if needsTable Table.WebLogUser then
|
||||
Query.Definition.ensureTable Table.WebLogUser
|
||||
Query.Definition.ensureKey Table.WebLogUser
|
||||
Query.Definition.ensureDocumentIndex Table.WebLogUser Optimized
|
||||
|
||||
// Page tables
|
||||
if needsTable Table.Page then
|
||||
Query.Definition.ensureTable Table.Page
|
||||
Query.Definition.ensureKey Table.Page
|
||||
Query.Definition.ensureIndexOn Table.Page "author" [ nameof Page.Empty.AuthorId ]
|
||||
Query.Definition.ensureIndexOn
|
||||
Table.Page "permalink" [ nameof Page.Empty.WebLogId; nameof Page.Empty.Permalink ]
|
||||
if needsTable Table.PageRevision then
|
||||
$"CREATE TABLE {Table.PageRevision} (
|
||||
page_id TEXT NOT NULL,
|
||||
as_of TIMESTAMPTZ NOT NULL,
|
||||
revision_text TEXT NOT NULL,
|
||||
PRIMARY KEY (page_id, as_of))"
|
||||
|
||||
// Post tables
|
||||
if needsTable Table.Post then
|
||||
Query.Definition.ensureTable Table.Post
|
||||
Query.Definition.ensureKey Table.Post
|
||||
Query.Definition.ensureIndexOn Table.Post "author" [ nameof Post.Empty.AuthorId ]
|
||||
Query.Definition.ensureIndexOn
|
||||
Table.Post "permalink" [ nameof Post.Empty.WebLogId; nameof Post.Empty.Permalink ]
|
||||
Query.Definition.ensureIndexOn
|
||||
Table.Post
|
||||
"status"
|
||||
[ nameof Post.Empty.WebLogId; nameof Post.Empty.Status; nameof Post.Empty.UpdatedOn ]
|
||||
$"CREATE INDEX idx_post_category ON {Table.Post} USING GIN ((data['{nameof Post.Empty.CategoryIds}']))"
|
||||
$"CREATE INDEX idx_post_tag ON {Table.Post} USING GIN ((data['{nameof Post.Empty.Tags}']))"
|
||||
if needsTable Table.PostRevision then
|
||||
$"CREATE TABLE {Table.PostRevision} (
|
||||
post_id TEXT NOT NULL,
|
||||
as_of TIMESTAMPTZ NOT NULL,
|
||||
revision_text TEXT NOT NULL,
|
||||
PRIMARY KEY (post_id, as_of))"
|
||||
if needsTable Table.PostComment then
|
||||
Query.Definition.ensureTable Table.PostComment
|
||||
Query.Definition.ensureKey Table.PostComment
|
||||
Query.Definition.ensureIndexOn Table.PostComment "post" [ nameof Comment.Empty.PostId ]
|
||||
|
||||
// Tag map table
|
||||
if needsTable Table.TagMap then
|
||||
Query.Definition.ensureTable Table.TagMap
|
||||
Query.Definition.ensureKey Table.TagMap
|
||||
Query.Definition.ensureDocumentIndex Table.TagMap Optimized
|
||||
|
||||
// Uploaded file table
|
||||
if needsTable Table.Upload then
|
||||
$"CREATE TABLE {Table.Upload} (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
web_log_id TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
updated_on TIMESTAMPTZ NOT NULL,
|
||||
data BYTEA NOT NULL)"
|
||||
$"CREATE INDEX idx_upload_web_log ON {Table.Upload} (web_log_id)"
|
||||
$"CREATE INDEX idx_upload_path ON {Table.Upload} (web_log_id, path)"
|
||||
|
||||
// Database version table
|
||||
if needsTable Table.DbVersion then
|
||||
$"CREATE TABLE {Table.DbVersion} (id TEXT NOT NULL PRIMARY KEY)"
|
||||
$"INSERT INTO {Table.DbVersion} VALUES ('{Utils.Migration.currentDbVersion}')"
|
||||
}
|
||||
|
||||
Configuration.dataSource ()
|
||||
|> Sql.fromDataSource
|
||||
|> Sql.executeTransactionAsync
|
||||
(sql
|
||||
|> Seq.map (fun s ->
|
||||
let parts = s.Replace(" IF NOT EXISTS", "", System.StringComparison.OrdinalIgnoreCase).Split ' '
|
||||
if parts[1].ToLowerInvariant() = "table" then
|
||||
log.LogInformation $"Creating {parts[2]} table..."
|
||||
s, [ [] ])
|
||||
|> List.ofSeq)
|
||||
|> Async.AwaitTask
|
||||
|> Async.RunSynchronously
|
||||
|> ignore
|
||||
}
|
||||
|
||||
/// Set a specific database version
|
||||
let setDbVersion version = backgroundTask {
|
||||
do! Custom.nonQuery $"DELETE FROM db_version; INSERT INTO db_version VALUES ('%s{version}')" []
|
||||
return version
|
||||
}
|
||||
|
||||
/// Migrate from v2-rc2 to v2 (manual migration required)
|
||||
let migrateV2Rc2ToV2 () = backgroundTask {
|
||||
let! webLogs =
|
||||
Custom.list
|
||||
$"SELECT url_base, slug FROM {Table.WebLog}" [] (fun row -> row.string "url_base", row.string "slug")
|
||||
Utils.Migration.backupAndRestoreRequired log "v2-rc2" "v2" webLogs
|
||||
}
|
||||
|
||||
/// Migrate from v2 to v2.1.1
|
||||
let migrateV2ToV2point1point1 () = backgroundTask {
|
||||
let migration = "v2 to v2.1.1"
|
||||
Utils.Migration.logStep log migration "Adding empty redirect rule set to all weblogs"
|
||||
do! Custom.nonQuery $"""UPDATE {Table.WebLog} SET data = data || '{{ "RedirectRules": [] }}'::jsonb""" []
|
||||
|
||||
let tables =
|
||||
[ Table.Category; Table.Page; Table.Post; Table.PostComment; Table.TagMap; Table.Theme; Table.WebLog
|
||||
Table.WebLogUser ]
|
||||
|
||||
Utils.Migration.logStep log migration "Adding unique indexes on ID fields"
|
||||
do! Custom.nonQuery (tables |> List.map Query.Definition.ensureKey |> String.concat "; ") []
|
||||
|
||||
Utils.Migration.logStep log migration "Removing constraints"
|
||||
let fkToDrop =
|
||||
[ "page_revision", "page_revision_page_id_fkey"
|
||||
"post_revision", "post_revision_post_id_fkey"
|
||||
"theme_asset", "theme_asset_theme_id_fkey"
|
||||
"upload", "upload_web_log_id_fkey"
|
||||
"category", "category_pkey"
|
||||
"page", "page_pkey"
|
||||
"post", "post_pkey"
|
||||
"post_comment", "post_comment_pkey"
|
||||
"tag_map", "tag_map_pkey"
|
||||
"theme", "theme_pkey"
|
||||
"web_log", "web_log_pkey"
|
||||
"web_log_user", "web_log_user_pkey" ]
|
||||
do! Custom.nonQuery
|
||||
(fkToDrop
|
||||
|> List.map (fun (tbl, fk) -> $"ALTER TABLE {tbl} DROP CONSTRAINT {fk}")
|
||||
|> String.concat "; ")
|
||||
[]
|
||||
|
||||
Utils.Migration.logStep log migration "Dropping old indexes"
|
||||
let toDrop =
|
||||
[ "idx_category"; "page_author_idx"; "page_permalink_idx"; "page_web_log_idx"; "post_author_idx"
|
||||
"post_category_idx"; "post_permalink_idx"; "post_status_idx"; "post_tag_idx"; "post_web_log_idx"
|
||||
"post_comment_post_idx"; "idx_tag_map"; "idx_web_log"; "idx_web_log_user" ]
|
||||
do! Custom.nonQuery (toDrop |> List.map (sprintf "DROP INDEX %s") |> String.concat "; ") []
|
||||
|
||||
Utils.Migration.logStep log migration "Dropping old ID columns"
|
||||
do! Custom.nonQuery (tables |> List.map (sprintf "ALTER TABLE %s DROP COLUMN id") |> String.concat "; ") []
|
||||
|
||||
Utils.Migration.logStep log migration "Adding new indexes"
|
||||
let newIdx =
|
||||
[ yield! tables |> List.map Query.Definition.ensureKey
|
||||
Query.Definition.ensureDocumentIndex Table.Category Optimized
|
||||
Query.Definition.ensureDocumentIndex Table.TagMap Optimized
|
||||
Query.Definition.ensureDocumentIndex Table.WebLog Optimized
|
||||
Query.Definition.ensureDocumentIndex Table.WebLogUser Optimized
|
||||
Query.Definition.ensureIndexOn Table.Page "author" [ nameof Page.Empty.AuthorId ]
|
||||
Query.Definition.ensureIndexOn
|
||||
Table.Page "permalink" [ nameof Page.Empty.WebLogId; nameof Page.Empty.Permalink ]
|
||||
Query.Definition.ensureIndexOn Table.Post "author" [ nameof Post.Empty.AuthorId ]
|
||||
Query.Definition.ensureIndexOn
|
||||
Table.Post "permalink" [ nameof Post.Empty.WebLogId; nameof Post.Empty.Permalink ]
|
||||
Query.Definition.ensureIndexOn
|
||||
Table.Post
|
||||
"status"
|
||||
[ nameof Post.Empty.WebLogId; nameof Post.Empty.Status; nameof Post.Empty.UpdatedOn ]
|
||||
$"CREATE INDEX idx_post_category ON {Table.Post} USING GIN ((data['{nameof Post.Empty.CategoryIds}']))"
|
||||
$"CREATE INDEX idx_post_tag ON {Table.Post} USING GIN ((data['{nameof Post.Empty.Tags}']))"
|
||||
Query.Definition.ensureIndexOn Table.PostComment "post" [ nameof Comment.Empty.PostId ] ]
|
||||
do! Custom.nonQuery (newIdx |> String.concat "; ") []
|
||||
|
||||
Utils.Migration.logStep log migration "Setting database to version 2.1.1"
|
||||
return! setDbVersion "v2.1.1"
|
||||
}
|
||||
|
||||
/// Migrate from v2.1.1 to v2.2
|
||||
let migrateV2point1point1ToV2point2 () = backgroundTask {
|
||||
Utils.Migration.logStep log "v2.1.1 to v2.2" "Setting e-mail to lowercase"
|
||||
do! Custom.nonQuery
|
||||
$"""UPDATE {Table.WebLogUser} SET data = data || ('{{"Email":"' || lower(data->>'Email') || '"}}')::jsonb"""
|
||||
[]
|
||||
Utils.Migration.logStep log "v2.1.1 to v2.2" "Setting database version to v2.2"
|
||||
return! setDbVersion "v2.2"
|
||||
}
|
||||
|
||||
/// Do required data migration between versions
|
||||
let migrate version = backgroundTask {
|
||||
let mutable v = defaultArg version ""
|
||||
|
||||
if v = "v2-rc2" then
|
||||
let! webLogs =
|
||||
Custom.list
|
||||
$"SELECT url_base, slug FROM {Table.WebLog}" []
|
||||
(fun row -> row.string "url_base", row.string "slug")
|
||||
Utils.Migration.backupAndRestoreRequired log "v2-rc2" "v2" webLogs
|
||||
|
||||
if v = "v2" then
|
||||
let! ver = migrateV2ToV2point1point1 ()
|
||||
v <- ver
|
||||
|
||||
if v = "v2.1.1" then
|
||||
let! ver = migrateV2point1point1ToV2point2 ()
|
||||
v <- ver
|
||||
|
||||
if v <> Utils.Migration.currentDbVersion then
|
||||
log.LogWarning $"Unknown database version; assuming {Utils.Migration.currentDbVersion}"
|
||||
let! _ = setDbVersion Utils.Migration.currentDbVersion
|
||||
()
|
||||
}
|
||||
|
||||
interface IData with
|
||||
|
||||
member _.Category = PostgresCategoryData log
|
||||
member _.Page = PostgresPageData log
|
||||
member _.Post = PostgresPostData log
|
||||
member _.TagMap = PostgresTagMapData log
|
||||
member _.Theme = PostgresThemeData log
|
||||
member _.ThemeAsset = PostgresThemeAssetData log
|
||||
member _.Upload = PostgresUploadData log
|
||||
member _.WebLog = PostgresWebLogData log
|
||||
member _.WebLogUser = PostgresWebLogUserData log
|
||||
|
||||
member _.Serializer = ser
|
||||
|
||||
member _.StartUp () = backgroundTask {
|
||||
log.LogTrace "PostgresData.StartUp"
|
||||
do! ensureTables ()
|
||||
|
||||
let! version = Custom.single "SELECT id FROM db_version" [] (fun row -> row.string "id")
|
||||
do! migrate version
|
||||
}
|
File diff suppressed because it is too large
Load Diff
129
src/MyWebLog.Data/SQLite/SQLiteCategoryData.fs
Normal file
129
src/MyWebLog.Data/SQLite/SQLiteCategoryData.fs
Normal file
@ -0,0 +1,129 @@
|
||||
namespace MyWebLog.Data.SQLite
|
||||
|
||||
open System.Threading.Tasks
|
||||
open BitBadger.Documents
|
||||
open BitBadger.Documents.Sqlite
|
||||
open Microsoft.Data.Sqlite
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
open Newtonsoft.Json
|
||||
|
||||
/// SQLite myWebLog category data implementation
|
||||
type SQLiteCategoryData(conn: SqliteConnection, ser: JsonSerializer, log: ILogger) =
|
||||
|
||||
/// The name of the parent ID field
|
||||
let parentIdField = nameof Category.Empty.ParentId
|
||||
|
||||
/// Count all categories for the given web log
|
||||
let countAll webLogId =
|
||||
log.LogTrace "Category.countAll"
|
||||
Document.countByWebLog Table.Category webLogId conn
|
||||
|
||||
/// Count all top-level categories for the given web log
|
||||
let countTopLevel webLogId =
|
||||
log.LogTrace "Category.countTopLevel"
|
||||
conn.customScalar
|
||||
$"{Document.Query.countByWebLog Table.Category} AND data ->> '{parentIdField}' IS NULL"
|
||||
[ webLogParam webLogId ]
|
||||
(toCount >> int)
|
||||
|
||||
/// Find all categories for the given web log
|
||||
let findByWebLog webLogId =
|
||||
log.LogTrace "Category.findByWebLog"
|
||||
Document.findByWebLog<Category> Table.Category webLogId conn
|
||||
|
||||
/// Retrieve all categories for the given web log in a DotLiquid-friendly format
|
||||
let findAllForView webLogId = backgroundTask {
|
||||
log.LogTrace "Category.findAllForView"
|
||||
let! cats = findByWebLog webLogId
|
||||
let ordered = Utils.orderByHierarchy (cats |> List.sortBy _.Name.ToLowerInvariant()) None None []
|
||||
let! counts =
|
||||
ordered
|
||||
|> Seq.map (fun it -> backgroundTask {
|
||||
// Parent category post counts include posts in subcategories
|
||||
let catSql, catParams =
|
||||
ordered
|
||||
|> Seq.filter (fun cat -> cat.ParentNames |> Array.contains it.Name)
|
||||
|> Seq.map _.Id
|
||||
|> Seq.append (Seq.singleton it.Id)
|
||||
|> List.ofSeq
|
||||
|> inJsonArray Table.Post (nameof Post.Empty.CategoryIds) "catId"
|
||||
let query = $"""
|
||||
SELECT COUNT(DISTINCT data ->> '{nameof Post.Empty.Id}')
|
||||
FROM {Table.Post}
|
||||
WHERE {Document.Query.whereByWebLog}
|
||||
AND {Query.whereByField (Field.EQ (nameof Post.Empty.Status) "") $"'{string Published}'"}
|
||||
AND {catSql}"""
|
||||
let! postCount = conn.customScalar query (webLogParam webLogId :: catParams) toCount
|
||||
return it.Id, int postCount
|
||||
})
|
||||
|> Task.WhenAll
|
||||
return
|
||||
ordered
|
||||
|> Seq.map (fun cat ->
|
||||
{ cat with
|
||||
PostCount = defaultArg (counts |> Array.tryFind (fun c -> fst c = cat.Id) |> Option.map snd) 0
|
||||
})
|
||||
|> Array.ofSeq
|
||||
}
|
||||
|
||||
/// Find a category by its ID for the given web log
|
||||
let findById catId webLogId =
|
||||
log.LogTrace "Category.findById"
|
||||
Document.findByIdAndWebLog<CategoryId, Category> Table.Category catId webLogId conn
|
||||
|
||||
/// Delete a category
|
||||
let delete catId webLogId = backgroundTask {
|
||||
log.LogTrace "Category.delete"
|
||||
match! findById catId webLogId with
|
||||
| Some cat ->
|
||||
// Reassign any children to the category's parent category
|
||||
let! children = conn.countByField Table.Category (Field.EQ parentIdField (string catId))
|
||||
if children > 0L then
|
||||
let parent = Field.EQ parentIdField (string catId)
|
||||
match cat.ParentId with
|
||||
| Some _ -> do! conn.patchByField Table.Category parent {| ParentId = cat.ParentId |}
|
||||
| None -> do! conn.removeFieldsByField Table.Category parent [ parentIdField ]
|
||||
// Delete the category off all posts where it is assigned, and the category itself
|
||||
let catIdField = nameof Post.Empty.CategoryIds
|
||||
let! posts =
|
||||
conn.customList
|
||||
$"SELECT data ->> '{nameof Post.Empty.Id}', data -> '{catIdField}'
|
||||
FROM {Table.Post}
|
||||
WHERE {Document.Query.whereByWebLog}
|
||||
AND EXISTS
|
||||
(SELECT 1
|
||||
FROM json_each({Table.Post}.data -> '{catIdField}')
|
||||
WHERE json_each.value = @id)"
|
||||
[ idParam catId; webLogParam webLogId ]
|
||||
(fun rdr -> rdr.GetString 0, Utils.deserialize<string list> ser (rdr.GetString 1))
|
||||
for postId, cats in posts do
|
||||
do! conn.patchById
|
||||
Table.Post postId {| CategoryIds = cats |> List.filter (fun it -> it <> string catId) |}
|
||||
do! conn.deleteById Table.Category catId
|
||||
return if children = 0L then CategoryDeleted else ReassignedChildCategories
|
||||
| None -> return CategoryNotFound
|
||||
}
|
||||
|
||||
/// Save a category
|
||||
let save cat =
|
||||
log.LogTrace "Category.save"
|
||||
conn.save<Category> Table.Category cat
|
||||
|
||||
/// Restore categories from a backup
|
||||
let restore cats = backgroundTask {
|
||||
log.LogTrace "Category.restore"
|
||||
for cat in cats do do! save cat
|
||||
}
|
||||
|
||||
interface ICategoryData with
|
||||
member _.Add cat = save cat
|
||||
member _.CountAll webLogId = countAll webLogId
|
||||
member _.CountTopLevel webLogId = countTopLevel webLogId
|
||||
member _.FindAllForView webLogId = findAllForView webLogId
|
||||
member _.FindById catId webLogId = findById catId webLogId
|
||||
member _.FindByWebLog webLogId = findByWebLog webLogId
|
||||
member _.Delete catId webLogId = delete catId webLogId
|
||||
member _.Restore cats = restore cats
|
||||
member _.Update cat = save cat
|
307
src/MyWebLog.Data/SQLite/SQLiteHelpers.fs
Normal file
307
src/MyWebLog.Data/SQLite/SQLiteHelpers.fs
Normal file
@ -0,0 +1,307 @@
|
||||
/// Helper functions for the SQLite data implementation
|
||||
[<AutoOpen>]
|
||||
module MyWebLog.Data.SQLite.SQLiteHelpers
|
||||
|
||||
/// The table names used in the SQLite implementation
|
||||
[<RequireQualifiedAccess>]
|
||||
module Table =
|
||||
|
||||
/// Categories
|
||||
[<Literal>]
|
||||
let Category = "category"
|
||||
|
||||
/// Database Version
|
||||
[<Literal>]
|
||||
let DbVersion = "db_version"
|
||||
|
||||
/// Pages
|
||||
[<Literal>]
|
||||
let Page = "page"
|
||||
|
||||
/// Page Revisions
|
||||
[<Literal>]
|
||||
let PageRevision = "page_revision"
|
||||
|
||||
/// Posts
|
||||
[<Literal>]
|
||||
let Post = "post"
|
||||
|
||||
/// Post Comments
|
||||
[<Literal>]
|
||||
let PostComment = "post_comment"
|
||||
|
||||
/// Post Revisions
|
||||
[<Literal>]
|
||||
let PostRevision = "post_revision"
|
||||
|
||||
/// Tag/URL Mappings
|
||||
[<Literal>]
|
||||
let TagMap = "tag_map"
|
||||
|
||||
/// Themes
|
||||
[<Literal>]
|
||||
let Theme = "theme"
|
||||
|
||||
/// Theme Assets
|
||||
[<Literal>]
|
||||
let ThemeAsset = "theme_asset"
|
||||
|
||||
/// Uploads
|
||||
[<Literal>]
|
||||
let Upload = "upload"
|
||||
|
||||
/// Web Logs
|
||||
[<Literal>]
|
||||
let WebLog = "web_log"
|
||||
|
||||
/// Users
|
||||
[<Literal>]
|
||||
let WebLogUser = "web_log_user"
|
||||
|
||||
|
||||
open System
|
||||
open Microsoft.Data.Sqlite
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
open NodaTime.Text
|
||||
|
||||
/// Execute a command that returns no data
|
||||
let write (cmd: SqliteCommand) = backgroundTask {
|
||||
let! _ = cmd.ExecuteNonQueryAsync()
|
||||
()
|
||||
}
|
||||
|
||||
/// Add a possibly-missing parameter, substituting null for None
|
||||
let maybe<'T> (it: 'T option) : obj = match it with Some x -> x :> obj | None -> DBNull.Value
|
||||
|
||||
/// Create a value for an Instant
|
||||
let instantParam =
|
||||
InstantPattern.General.Format
|
||||
|
||||
/// Create an optional value for an Instant
|
||||
let maybeInstant =
|
||||
Option.map instantParam >> maybe
|
||||
|
||||
/// Create the SQL and parameters for an EXISTS applied to a JSON array
|
||||
let inJsonArray<'T> table jsonField paramName (items: 'T list) =
|
||||
if List.isEmpty items then "", []
|
||||
else
|
||||
let mutable idx = 0
|
||||
items
|
||||
|> List.skip 1
|
||||
|> List.fold (fun (itemS, itemP) it ->
|
||||
idx <- idx + 1
|
||||
$"{itemS}, @%s{paramName}{idx}", (SqliteParameter($"@%s{paramName}{idx}", string it) :: itemP))
|
||||
(Seq.ofList items
|
||||
|> Seq.map (fun it -> $"(@%s{paramName}0", [ SqliteParameter($"@%s{paramName}0", string it) ])
|
||||
|> Seq.head)
|
||||
|> function
|
||||
sql, ps ->
|
||||
$"EXISTS (SELECT 1 FROM json_each(%s{table}.data, '$.%s{jsonField}') WHERE value IN {sql}))", ps
|
||||
|
||||
/// Create the SQL and parameters for an IN clause
|
||||
let inClause<'T> colNameAndPrefix paramName (valueFunc: 'T -> string) (items: 'T list) =
|
||||
if List.isEmpty items then "", []
|
||||
else
|
||||
let mutable idx = 0
|
||||
items
|
||||
|> List.skip 1
|
||||
|> List.fold (fun (itemS, itemP) it ->
|
||||
idx <- idx + 1
|
||||
$"{itemS}, @%s{paramName}{idx}", (SqliteParameter ($"@%s{paramName}{idx}", valueFunc it) :: itemP))
|
||||
(Seq.ofList items
|
||||
|> Seq.map (fun it ->
|
||||
$"%s{colNameAndPrefix} IN (@%s{paramName}0", [ SqliteParameter ($"@%s{paramName}0", valueFunc it) ])
|
||||
|> Seq.head)
|
||||
|> function sql, ps -> $"{sql})", ps
|
||||
|
||||
|
||||
/// Functions to map domain items from a data reader
|
||||
module Map =
|
||||
|
||||
open System.IO
|
||||
|
||||
/// Get a boolean value from a data reader
|
||||
let getBoolean col (rdr: SqliteDataReader) = rdr.GetBoolean(rdr.GetOrdinal col)
|
||||
|
||||
/// Get a date/time value from a data reader
|
||||
let getDateTime col (rdr: SqliteDataReader) = rdr.GetDateTime(rdr.GetOrdinal col)
|
||||
|
||||
/// Get a Guid value from a data reader
|
||||
let getGuid col (rdr: SqliteDataReader) = rdr.GetGuid(rdr.GetOrdinal col)
|
||||
|
||||
/// Get an int value from a data reader
|
||||
let getInt col (rdr: SqliteDataReader) = rdr.GetInt32(rdr.GetOrdinal col)
|
||||
|
||||
/// Get a long (64-bit int) value from a data reader
|
||||
let getLong col (rdr: SqliteDataReader) = rdr.GetInt64(rdr.GetOrdinal col)
|
||||
|
||||
/// Get a BLOB stream value from a data reader
|
||||
let getStream col (rdr: SqliteDataReader) = rdr.GetStream(rdr.GetOrdinal col)
|
||||
|
||||
/// Get a string value from a data reader
|
||||
let getString col (rdr: SqliteDataReader) = rdr.GetString(rdr.GetOrdinal col)
|
||||
|
||||
/// Parse an Instant from the given value
|
||||
let parseInstant value =
|
||||
match InstantPattern.General.Parse value with
|
||||
| it when it.Success -> it.Value
|
||||
| it -> raise it.Exception
|
||||
|
||||
/// Get an Instant value from a data reader
|
||||
let getInstant col rdr =
|
||||
getString col rdr |> parseInstant
|
||||
|
||||
/// Get a timespan value from a data reader
|
||||
let getTimeSpan col (rdr: SqliteDataReader) = rdr.GetTimeSpan(rdr.GetOrdinal col)
|
||||
|
||||
/// Get a possibly null boolean value from a data reader
|
||||
let tryBoolean col (rdr: SqliteDataReader) =
|
||||
if rdr.IsDBNull(rdr.GetOrdinal col) then None else Some (getBoolean col rdr)
|
||||
|
||||
/// Get a possibly null date/time value from a data reader
|
||||
let tryDateTime col (rdr: SqliteDataReader) =
|
||||
if rdr.IsDBNull(rdr.GetOrdinal col) then None else Some (getDateTime col rdr)
|
||||
|
||||
/// Get a possibly null Guid value from a data reader
|
||||
let tryGuid col (rdr: SqliteDataReader) =
|
||||
if rdr.IsDBNull(rdr.GetOrdinal col) then None else Some (getGuid col rdr)
|
||||
|
||||
/// Get a possibly null int value from a data reader
|
||||
let tryInt col (rdr: SqliteDataReader) =
|
||||
if rdr.IsDBNull(rdr.GetOrdinal col) then None else Some (getInt col rdr)
|
||||
|
||||
/// Get a possibly null string value from a data reader
|
||||
let tryString col (rdr: SqliteDataReader) =
|
||||
if rdr.IsDBNull(rdr.GetOrdinal col) then None else Some (getString col rdr)
|
||||
|
||||
/// Get a possibly null timespan value from a data reader
|
||||
let tryTimeSpan col (rdr: SqliteDataReader) =
|
||||
if rdr.IsDBNull(rdr.GetOrdinal col) then None else Some (getTimeSpan col rdr)
|
||||
|
||||
/// Create a permalink from the current row in the given data reader
|
||||
let toPermalink rdr = getString "permalink" rdr |> Permalink
|
||||
|
||||
/// Create a revision from the current row in the given data reader
|
||||
let toRevision rdr : Revision =
|
||||
{ AsOf = getInstant "as_of" rdr
|
||||
Text = getString "revision_text" rdr |> MarkupText.Parse }
|
||||
|
||||
/// Create a theme asset from the current row in the given data reader
|
||||
let toThemeAsset includeData rdr : ThemeAsset =
|
||||
let assetData =
|
||||
if includeData then
|
||||
use dataStream = new MemoryStream()
|
||||
use blobStream = getStream "data" rdr
|
||||
blobStream.CopyTo dataStream
|
||||
dataStream.ToArray()
|
||||
else
|
||||
[||]
|
||||
{ Id = ThemeAssetId (ThemeId (getString "theme_id" rdr), getString "path" rdr)
|
||||
UpdatedOn = getInstant "updated_on" rdr
|
||||
Data = assetData }
|
||||
|
||||
/// Create an uploaded file from the current row in the given data reader
|
||||
let toUpload includeData rdr : Upload =
|
||||
let data =
|
||||
if includeData then
|
||||
use dataStream = new MemoryStream()
|
||||
use blobStream = getStream "data" rdr
|
||||
blobStream.CopyTo dataStream
|
||||
dataStream.ToArray ()
|
||||
else
|
||||
[||]
|
||||
{ Id = getString "id" rdr |> UploadId
|
||||
WebLogId = getString "web_log_id" rdr |> WebLogId
|
||||
Path = getString "path" rdr |> Permalink
|
||||
UpdatedOn = getInstant "updated_on" rdr
|
||||
Data = data }
|
||||
|
||||
|
||||
/// Create a named parameter
|
||||
let sqlParam name (value: obj) =
|
||||
SqliteParameter(name, value)
|
||||
|
||||
/// Create a web log ID parameter
|
||||
let webLogParam (webLogId: WebLogId) =
|
||||
sqlParam "@webLogId" (string webLogId)
|
||||
|
||||
|
||||
open BitBadger.Documents
|
||||
open BitBadger.Documents.Sqlite
|
||||
open BitBadger.Documents.Sqlite.WithConn
|
||||
|
||||
/// Functions for manipulating documents
|
||||
module Document =
|
||||
|
||||
/// Queries to assist with document manipulation
|
||||
module Query =
|
||||
|
||||
/// Fragment to add a web log ID condition to a WHERE clause (parameter @webLogId)
|
||||
let whereByWebLog =
|
||||
Query.whereByField (Field.EQ "WebLogId" "") "@webLogId"
|
||||
|
||||
/// A SELECT query to count documents for a given web log ID
|
||||
let countByWebLog table =
|
||||
$"{Query.Count.all table} WHERE {whereByWebLog}"
|
||||
|
||||
/// A query to select from a table by the document's ID and its web log ID
|
||||
let selectByIdAndWebLog table =
|
||||
$"{Query.Find.byId table} AND {whereByWebLog}"
|
||||
|
||||
/// A query to select from a table by its web log ID
|
||||
let selectByWebLog table =
|
||||
$"{Query.selectFromTable table} WHERE {whereByWebLog}"
|
||||
|
||||
/// Count documents for the given web log ID
|
||||
let countByWebLog table (webLogId: WebLogId) conn = backgroundTask {
|
||||
let! count = Count.byField table (Field.EQ "WebLogId" (string webLogId)) conn
|
||||
return int count
|
||||
}
|
||||
|
||||
/// Find a document by its ID and web log ID
|
||||
let findByIdAndWebLog<'TKey, 'TDoc> table (key: 'TKey) webLogId conn =
|
||||
Custom.single (Query.selectByIdAndWebLog table) [ idParam key; webLogParam webLogId ] fromData<'TDoc> conn
|
||||
|
||||
/// Find documents for the given web log
|
||||
let findByWebLog<'TDoc> table (webLogId: WebLogId) conn =
|
||||
Find.byField<'TDoc> table (Field.EQ "WebLogId" (string webLogId)) conn
|
||||
|
||||
|
||||
/// Functions to support revisions
|
||||
module Revisions =
|
||||
|
||||
/// Find all revisions for the given entity
|
||||
let findByEntityId<'TKey> revTable entityTable (key: 'TKey) conn =
|
||||
Custom.list
|
||||
$"SELECT as_of, revision_text FROM %s{revTable} WHERE %s{entityTable}_id = @id ORDER BY as_of DESC"
|
||||
[ idParam key ]
|
||||
Map.toRevision
|
||||
conn
|
||||
|
||||
/// Find all revisions for all posts for the given web log
|
||||
let findByWebLog<'TKey> revTable entityTable (keyFunc: string -> 'TKey) webLogId conn =
|
||||
Custom.list
|
||||
$"SELECT pr.*
|
||||
FROM %s{revTable} pr
|
||||
INNER JOIN %s{entityTable} p ON p.data ->> 'Id' = pr.{entityTable}_id
|
||||
WHERE p.{Document.Query.whereByWebLog}
|
||||
ORDER BY as_of DESC"
|
||||
[ webLogParam webLogId ]
|
||||
(fun rdr -> keyFunc (Map.getString $"{entityTable}_id" rdr), Map.toRevision rdr)
|
||||
conn
|
||||
|
||||
/// Update a page or post's revisions
|
||||
let update<'TKey> revTable entityTable (key: 'TKey) oldRevs newRevs conn = backgroundTask {
|
||||
let toDelete, toAdd = Utils.diffRevisions oldRevs newRevs
|
||||
for delRev in toDelete do
|
||||
do! Custom.nonQuery
|
||||
$"DELETE FROM %s{revTable} WHERE %s{entityTable}_id = @id AND as_of = @asOf"
|
||||
[ idParam key; sqlParam "@asOf" (instantParam delRev.AsOf) ]
|
||||
conn
|
||||
for addRev in toAdd do
|
||||
do! Custom.nonQuery
|
||||
$"INSERT INTO {revTable} VALUES (@id, @asOf, @text)"
|
||||
[ idParam key; sqlParam "asOf" (instantParam addRev.AsOf); sqlParam "@text" (string addRev.Text) ]
|
||||
conn
|
||||
}
|
188
src/MyWebLog.Data/SQLite/SQLitePageData.fs
Normal file
188
src/MyWebLog.Data/SQLite/SQLitePageData.fs
Normal file
@ -0,0 +1,188 @@
|
||||
namespace MyWebLog.Data.SQLite
|
||||
|
||||
open System.Threading.Tasks
|
||||
open BitBadger.Documents
|
||||
open BitBadger.Documents.Sqlite
|
||||
open Microsoft.Data.Sqlite
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
|
||||
/// SQLite myWebLog page data implementation
|
||||
type SQLitePageData(conn: SqliteConnection, log: ILogger) =
|
||||
|
||||
/// The JSON field name for the permalink
|
||||
let linkName = nameof Page.Empty.Permalink
|
||||
|
||||
/// The JSON field name for the "is in page list" flag
|
||||
let pgListName = nameof Page.Empty.IsInPageList
|
||||
|
||||
/// The JSON field for the title of the page
|
||||
let titleField = $"data ->> '{nameof Page.Empty.Title}'"
|
||||
|
||||
// SUPPORT FUNCTIONS
|
||||
|
||||
/// Append revisions to a page
|
||||
let appendPageRevisions (page : Page) = backgroundTask {
|
||||
log.LogTrace "Page.appendPageRevisions"
|
||||
let! revisions = Revisions.findByEntityId Table.PageRevision Table.Page page.Id conn
|
||||
return { page with Revisions = revisions }
|
||||
}
|
||||
|
||||
/// Create a page with no prior permalinks
|
||||
let pageWithoutLinks rdr =
|
||||
{ fromData<Page> rdr with PriorPermalinks = [] }
|
||||
|
||||
/// Update a page's revisions
|
||||
let updatePageRevisions (pageId: PageId) oldRevs newRevs =
|
||||
log.LogTrace "Page.updatePageRevisions"
|
||||
Revisions.update Table.PageRevision Table.Page pageId oldRevs newRevs conn
|
||||
|
||||
// IMPLEMENTATION FUNCTIONS
|
||||
|
||||
/// Add a page
|
||||
let add (page: Page) = backgroundTask {
|
||||
log.LogTrace "Page.add"
|
||||
do! conn.insert Table.Page { page with Revisions = [] }
|
||||
do! updatePageRevisions page.Id [] page.Revisions
|
||||
}
|
||||
|
||||
/// Get all pages for a web log (without text, metadata, revisions, or prior permalinks)
|
||||
let all webLogId =
|
||||
log.LogTrace "Page.all"
|
||||
conn.customList
|
||||
$"{Query.selectFromTable Table.Page} WHERE {Document.Query.whereByWebLog} ORDER BY LOWER({titleField})"
|
||||
[ webLogParam webLogId ]
|
||||
(fun rdr -> { fromData<Page> rdr with Text = ""; Metadata = []; PriorPermalinks = [] })
|
||||
|
||||
/// Count all pages for the given web log
|
||||
let countAll webLogId =
|
||||
log.LogTrace "Page.countAll"
|
||||
Document.countByWebLog Table.Page webLogId conn
|
||||
|
||||
/// Count all pages shown in the page list for the given web log
|
||||
let countListed webLogId =
|
||||
log.LogTrace "Page.countListed"
|
||||
conn.customScalar
|
||||
$"""{Document.Query.countByWebLog Table.Page} AND {Query.whereByField (Field.EQ pgListName "") "true"}"""
|
||||
[ webLogParam webLogId ]
|
||||
(toCount >> int)
|
||||
|
||||
/// Find a page by its ID (without revisions and prior permalinks)
|
||||
let findById pageId webLogId = backgroundTask {
|
||||
log.LogTrace "Page.findById"
|
||||
match! Document.findByIdAndWebLog<PageId, Page> Table.Page pageId webLogId conn with
|
||||
| Some page -> return Some { page with PriorPermalinks = [] }
|
||||
| None -> return None
|
||||
}
|
||||
|
||||
/// Find a complete page by its ID
|
||||
let findFullById pageId webLogId = backgroundTask {
|
||||
log.LogTrace "Page.findFullById"
|
||||
match! Document.findByIdAndWebLog<PageId, Page> Table.Page pageId webLogId conn with
|
||||
| Some page ->
|
||||
let! page = appendPageRevisions page
|
||||
return Some page
|
||||
| None -> return None
|
||||
}
|
||||
|
||||
// TODO: need to handle when the page being deleted is the home page
|
||||
/// Delete a page by its ID
|
||||
let delete pageId webLogId = backgroundTask {
|
||||
log.LogTrace "Page.delete"
|
||||
match! findById pageId webLogId with
|
||||
| Some _ ->
|
||||
do! conn.customNonQuery
|
||||
$"DELETE FROM {Table.PageRevision} WHERE page_id = @id; {Query.Delete.byId Table.Page}"
|
||||
[ idParam pageId ]
|
||||
return true
|
||||
| None -> return false
|
||||
}
|
||||
|
||||
/// Find a page by its permalink for the given web log
|
||||
let findByPermalink (permalink: Permalink) webLogId =
|
||||
log.LogTrace "Page.findByPermalink"
|
||||
let linkParam = Field.EQ linkName (string permalink)
|
||||
conn.customSingle
|
||||
$"""{Document.Query.selectByWebLog Table.Page} AND {Query.whereByField linkParam "@link"}"""
|
||||
(addFieldParam "@link" linkParam [ webLogParam webLogId ])
|
||||
pageWithoutLinks
|
||||
|
||||
/// Find the current permalink within a set of potential prior permalinks for the given web log
|
||||
let findCurrentPermalink (permalinks: Permalink list) webLogId =
|
||||
log.LogTrace "Page.findCurrentPermalink"
|
||||
let linkSql, linkParams = inJsonArray Table.Page (nameof Page.Empty.PriorPermalinks) "link" permalinks
|
||||
conn.customSingle
|
||||
$"SELECT data ->> '{linkName}' AS permalink
|
||||
FROM {Table.Page}
|
||||
WHERE {Document.Query.whereByWebLog} AND {linkSql}"
|
||||
(webLogParam webLogId :: linkParams)
|
||||
Map.toPermalink
|
||||
|
||||
/// Get all complete pages for the given web log
|
||||
let findFullByWebLog webLogId = backgroundTask {
|
||||
log.LogTrace "Page.findFullByWebLog"
|
||||
let! pages = Document.findByWebLog<Page> Table.Page webLogId conn
|
||||
let! withRevs = pages |> List.map appendPageRevisions |> Task.WhenAll
|
||||
return List.ofArray withRevs
|
||||
}
|
||||
|
||||
/// Get all listed pages for the given web log (without revisions or text)
|
||||
let findListed webLogId =
|
||||
log.LogTrace "Page.findListed"
|
||||
conn.customList
|
||||
$"""{Document.Query.selectByWebLog Table.Page} AND {Query.whereByField (Field.EQ pgListName "") "true"}
|
||||
ORDER BY LOWER({titleField})"""
|
||||
[ webLogParam webLogId ]
|
||||
(fun rdr -> { fromData<Page> rdr with Text = "" })
|
||||
|
||||
/// Get a page of pages for the given web log (without revisions)
|
||||
let findPageOfPages webLogId pageNbr =
|
||||
log.LogTrace "Page.findPageOfPages"
|
||||
conn.customList
|
||||
$"{Document.Query.selectByWebLog Table.Page} ORDER BY LOWER({titleField}) LIMIT @pageSize OFFSET @toSkip"
|
||||
[ webLogParam webLogId; SqliteParameter("@pageSize", 26); SqliteParameter("@toSkip", (pageNbr - 1) * 25) ]
|
||||
(fun rdr -> { pageWithoutLinks rdr with Metadata = [] })
|
||||
|
||||
/// Update a page
|
||||
let update (page: Page) = backgroundTask {
|
||||
log.LogTrace "Page.update"
|
||||
match! findFullById page.Id page.WebLogId with
|
||||
| Some oldPage ->
|
||||
do! conn.updateById Table.Page page.Id { page with Revisions = [] }
|
||||
do! updatePageRevisions page.Id oldPage.Revisions page.Revisions
|
||||
| None -> ()
|
||||
}
|
||||
|
||||
/// Restore pages from a backup
|
||||
let restore pages = backgroundTask {
|
||||
log.LogTrace "Page.restore"
|
||||
for page in pages do do! add page
|
||||
}
|
||||
|
||||
/// Update a page's prior permalinks
|
||||
let updatePriorPermalinks pageId webLogId (permalinks: Permalink list) = backgroundTask {
|
||||
log.LogTrace "Page.updatePriorPermalinks"
|
||||
match! findById pageId webLogId with
|
||||
| Some _ ->
|
||||
do! conn.patchById Table.Page pageId {| PriorPermalinks = permalinks |}
|
||||
return true
|
||||
| None -> return false
|
||||
}
|
||||
|
||||
interface IPageData with
|
||||
member _.Add page = add page
|
||||
member _.All webLogId = all webLogId
|
||||
member _.CountAll webLogId = countAll webLogId
|
||||
member _.CountListed webLogId = countListed webLogId
|
||||
member _.Delete pageId webLogId = delete pageId webLogId
|
||||
member _.FindById pageId webLogId = findById pageId webLogId
|
||||
member _.FindByPermalink permalink webLogId = findByPermalink permalink webLogId
|
||||
member _.FindCurrentPermalink permalinks webLogId = findCurrentPermalink permalinks webLogId
|
||||
member _.FindFullById pageId webLogId = findFullById pageId webLogId
|
||||
member _.FindFullByWebLog webLogId = findFullByWebLog webLogId
|
||||
member _.FindListed webLogId = findListed webLogId
|
||||
member _.FindPageOfPages webLogId pageNbr = findPageOfPages webLogId pageNbr
|
||||
member _.Restore pages = restore pages
|
||||
member _.Update page = update page
|
||||
member _.UpdatePriorPermalinks pageId webLogId permalinks = updatePriorPermalinks pageId webLogId permalinks
|
234
src/MyWebLog.Data/SQLite/SQLitePostData.fs
Normal file
234
src/MyWebLog.Data/SQLite/SQLitePostData.fs
Normal file
@ -0,0 +1,234 @@
|
||||
namespace MyWebLog.Data.SQLite
|
||||
|
||||
open System.Threading.Tasks
|
||||
open BitBadger.Documents
|
||||
open BitBadger.Documents.Sqlite
|
||||
open Microsoft.Data.Sqlite
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
open NodaTime
|
||||
|
||||
/// SQLite myWebLog post data implementation
|
||||
type SQLitePostData(conn: SqliteConnection, log: ILogger) =
|
||||
|
||||
/// The name of the JSON field for the post's permalink
|
||||
let linkName = nameof Post.Empty.Permalink
|
||||
|
||||
/// The JSON field for when the post was published
|
||||
let publishField = $"data ->> '{nameof Post.Empty.PublishedOn}'"
|
||||
|
||||
/// The name of the JSON field for the post's status
|
||||
let statName = nameof Post.Empty.Status
|
||||
|
||||
// SUPPORT FUNCTIONS
|
||||
|
||||
/// Append revisions to a post
|
||||
let appendPostRevisions (post: Post) = backgroundTask {
|
||||
log.LogTrace "Post.appendPostRevisions"
|
||||
let! revisions = Revisions.findByEntityId Table.PostRevision Table.Post post.Id conn
|
||||
return { post with Revisions = revisions }
|
||||
}
|
||||
|
||||
/// The SELECT statement to retrieve posts with a web log ID parameter
|
||||
let postByWebLog = Document.Query.selectByWebLog Table.Post
|
||||
|
||||
/// Return a post with no revisions or prior permalinks
|
||||
let postWithoutLinks rdr =
|
||||
{ fromData<Post> rdr with PriorPermalinks = [] }
|
||||
|
||||
/// Return a post with no revisions, prior permalinks, or text
|
||||
let postWithoutText rdr =
|
||||
{ postWithoutLinks rdr with Text = "" }
|
||||
|
||||
/// The SELECT statement to retrieve published posts with a web log ID parameter
|
||||
let publishedPostByWebLog =
|
||||
$"""{postByWebLog} AND {Query.whereByField (Field.EQ statName "") $"'{string Published}'"}"""
|
||||
|
||||
/// Update a post's revisions
|
||||
let updatePostRevisions (postId: PostId) oldRevs newRevs =
|
||||
log.LogTrace "Post.updatePostRevisions"
|
||||
Revisions.update Table.PostRevision Table.Post postId oldRevs newRevs conn
|
||||
|
||||
// IMPLEMENTATION FUNCTIONS
|
||||
|
||||
/// Add a post
|
||||
let add (post: Post) = backgroundTask {
|
||||
log.LogTrace "Post.add"
|
||||
do! conn.insert Table.Post { post with Revisions = [] }
|
||||
do! updatePostRevisions post.Id [] post.Revisions
|
||||
}
|
||||
|
||||
/// Count posts in a status for the given web log
|
||||
let countByStatus (status: PostStatus) webLogId =
|
||||
log.LogTrace "Post.countByStatus"
|
||||
let statParam = Field.EQ statName (string status)
|
||||
conn.customScalar
|
||||
$"""{Document.Query.countByWebLog Table.Post} AND {Query.whereByField statParam "@status"}"""
|
||||
(addFieldParam "@status" statParam [ webLogParam webLogId ])
|
||||
(toCount >> int)
|
||||
|
||||
/// Find a post by its ID for the given web log (excluding revisions)
|
||||
let findById postId webLogId = backgroundTask {
|
||||
log.LogTrace "Post.findById"
|
||||
match! Document.findByIdAndWebLog<PostId, Post> Table.Post postId webLogId conn with
|
||||
| Some post -> return Some { post with PriorPermalinks = [] }
|
||||
| None -> return None
|
||||
}
|
||||
|
||||
/// Find a post by its permalink for the given web log (excluding revisions)
|
||||
let findByPermalink (permalink: Permalink) webLogId =
|
||||
log.LogTrace "Post.findByPermalink"
|
||||
let linkParam = Field.EQ linkName (string permalink)
|
||||
conn.customSingle
|
||||
$"""{Document.Query.selectByWebLog Table.Post} AND {Query.whereByField linkParam "@link"}"""
|
||||
(addFieldParam "@link" linkParam [ webLogParam webLogId ])
|
||||
postWithoutLinks
|
||||
|
||||
/// Find a complete post by its ID for the given web log
|
||||
let findFullById postId webLogId = backgroundTask {
|
||||
log.LogTrace "Post.findFullById"
|
||||
match! Document.findByIdAndWebLog<PostId, Post> Table.Post postId webLogId conn with
|
||||
| Some post ->
|
||||
let! post = appendPostRevisions post
|
||||
return Some post
|
||||
| None -> return None
|
||||
}
|
||||
|
||||
/// Delete a post by its ID for the given web log
|
||||
let delete postId webLogId = backgroundTask {
|
||||
log.LogTrace "Post.delete"
|
||||
match! findById postId webLogId with
|
||||
| Some _ ->
|
||||
do! conn.customNonQuery
|
||||
$"""DELETE FROM {Table.PostRevision} WHERE post_id = @id;
|
||||
DELETE FROM {Table.PostComment}
|
||||
WHERE {Query.whereByField (Field.EQ (nameof Comment.Empty.PostId) "") "@id"};
|
||||
{Query.Delete.byId Table.Post}"""
|
||||
[ idParam postId ]
|
||||
return true
|
||||
| None -> return false
|
||||
}
|
||||
|
||||
/// Find the current permalink from a list of potential prior permalinks for the given web log
|
||||
let findCurrentPermalink (permalinks: Permalink list) webLogId =
|
||||
log.LogTrace "Post.findCurrentPermalink"
|
||||
let linkSql, linkParams = inJsonArray Table.Post (nameof Post.Empty.PriorPermalinks) "link" permalinks
|
||||
conn.customSingle
|
||||
$"SELECT data ->> '{linkName}' AS permalink
|
||||
FROM {Table.Post}
|
||||
WHERE {Document.Query.whereByWebLog} AND {linkSql}"
|
||||
(webLogParam webLogId :: linkParams)
|
||||
Map.toPermalink
|
||||
|
||||
/// Get all complete posts for the given web log
|
||||
let findFullByWebLog webLogId = backgroundTask {
|
||||
log.LogTrace "Post.findFullByWebLog"
|
||||
let! posts = Document.findByWebLog<Post> Table.Post webLogId conn
|
||||
let! withRevs = posts |> List.map appendPostRevisions |> Task.WhenAll
|
||||
return List.ofArray withRevs
|
||||
}
|
||||
|
||||
/// Get a page of categorized posts for the given web log (excludes revisions)
|
||||
let findPageOfCategorizedPosts webLogId (categoryIds: CategoryId list) pageNbr postsPerPage =
|
||||
log.LogTrace "Post.findPageOfCategorizedPosts"
|
||||
let catSql, catParams = inJsonArray Table.Post (nameof Post.Empty.CategoryIds) "catId" categoryIds
|
||||
conn.customList
|
||||
$"{publishedPostByWebLog} AND {catSql}
|
||||
ORDER BY {publishField} DESC
|
||||
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
|
||||
(webLogParam webLogId :: catParams)
|
||||
postWithoutLinks
|
||||
|
||||
/// Get a page of posts for the given web log (excludes text and revisions)
|
||||
let findPageOfPosts webLogId pageNbr postsPerPage =
|
||||
log.LogTrace "Post.findPageOfPosts"
|
||||
conn.customList
|
||||
$"{postByWebLog}
|
||||
ORDER BY {publishField} DESC NULLS FIRST, data ->> '{nameof Post.Empty.UpdatedOn}'
|
||||
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
|
||||
[ webLogParam webLogId ]
|
||||
postWithoutText
|
||||
|
||||
/// Get a page of published posts for the given web log (excludes revisions)
|
||||
let findPageOfPublishedPosts webLogId pageNbr postsPerPage =
|
||||
log.LogTrace "Post.findPageOfPublishedPosts"
|
||||
conn.customList
|
||||
$"{publishedPostByWebLog}
|
||||
ORDER BY {publishField} DESC
|
||||
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
|
||||
[ webLogParam webLogId ]
|
||||
postWithoutLinks
|
||||
|
||||
/// Get a page of tagged posts for the given web log (excludes revisions)
|
||||
let findPageOfTaggedPosts webLogId (tag : string) pageNbr postsPerPage =
|
||||
log.LogTrace "Post.findPageOfTaggedPosts"
|
||||
let tagSql, tagParams = inJsonArray Table.Post (nameof Post.Empty.Tags) "tag" [ tag ]
|
||||
conn.customList
|
||||
$"{publishedPostByWebLog} AND {tagSql}
|
||||
ORDER BY {publishField} DESC
|
||||
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
|
||||
(webLogParam webLogId :: tagParams)
|
||||
postWithoutLinks
|
||||
|
||||
/// Find the next newest and oldest post from a publish date for the given web log
|
||||
let findSurroundingPosts webLogId (publishedOn : Instant) = backgroundTask {
|
||||
log.LogTrace "Post.findSurroundingPosts"
|
||||
let! older =
|
||||
conn.customSingle
|
||||
$"{publishedPostByWebLog} AND {publishField} < @publishedOn ORDER BY {publishField} DESC LIMIT 1"
|
||||
[ webLogParam webLogId; SqliteParameter("@publishedOn", instantParam publishedOn) ]
|
||||
postWithoutLinks
|
||||
let! newer =
|
||||
conn.customSingle
|
||||
$"{publishedPostByWebLog} AND {publishField} > @publishedOn ORDER BY {publishField} LIMIT 1"
|
||||
[ webLogParam webLogId; SqliteParameter("@publishedOn", instantParam publishedOn) ]
|
||||
postWithoutLinks
|
||||
return older, newer
|
||||
}
|
||||
|
||||
/// Update a post
|
||||
let update (post: Post) = backgroundTask {
|
||||
log.LogTrace "Post.update"
|
||||
match! findFullById post.Id post.WebLogId with
|
||||
| Some oldPost ->
|
||||
do! conn.updateById Table.Post post.Id { post with Revisions = [] }
|
||||
do! updatePostRevisions post.Id oldPost.Revisions post.Revisions
|
||||
| None -> ()
|
||||
}
|
||||
|
||||
/// Restore posts from a backup
|
||||
let restore posts = backgroundTask {
|
||||
log.LogTrace "Post.restore"
|
||||
for post in posts do do! add post
|
||||
}
|
||||
|
||||
/// Update prior permalinks for a post
|
||||
let updatePriorPermalinks postId webLogId (permalinks: Permalink list) = backgroundTask {
|
||||
match! findById postId webLogId with
|
||||
| Some _ ->
|
||||
do! conn.patchById Table.Post postId {| PriorPermalinks = permalinks |}
|
||||
return true
|
||||
| None -> return false
|
||||
}
|
||||
|
||||
interface IPostData with
|
||||
member _.Add post = add post
|
||||
member _.CountByStatus status webLogId = countByStatus status webLogId
|
||||
member _.Delete postId webLogId = delete postId webLogId
|
||||
member _.FindById postId webLogId = findById postId webLogId
|
||||
member _.FindByPermalink permalink webLogId = findByPermalink permalink webLogId
|
||||
member _.FindCurrentPermalink permalinks webLogId = findCurrentPermalink permalinks webLogId
|
||||
member _.FindFullById postId webLogId = findFullById postId webLogId
|
||||
member _.FindFullByWebLog webLogId = findFullByWebLog webLogId
|
||||
member _.FindPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage =
|
||||
findPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage
|
||||
member _.FindPageOfPosts webLogId pageNbr postsPerPage = findPageOfPosts webLogId pageNbr postsPerPage
|
||||
member _.FindPageOfPublishedPosts webLogId pageNbr postsPerPage =
|
||||
findPageOfPublishedPosts webLogId pageNbr postsPerPage
|
||||
member _.FindPageOfTaggedPosts webLogId tag pageNbr postsPerPage =
|
||||
findPageOfTaggedPosts webLogId tag pageNbr postsPerPage
|
||||
member _.FindSurroundingPosts webLogId publishedOn = findSurroundingPosts webLogId publishedOn
|
||||
member _.Restore posts = restore posts
|
||||
member _.Update post = update post
|
||||
member _.UpdatePriorPermalinks postId webLogId permalinks = updatePriorPermalinks postId webLogId permalinks
|
69
src/MyWebLog.Data/SQLite/SQLiteTagMapData.fs
Normal file
69
src/MyWebLog.Data/SQLite/SQLiteTagMapData.fs
Normal file
@ -0,0 +1,69 @@
|
||||
namespace MyWebLog.Data.SQLite
|
||||
|
||||
open BitBadger.Documents
|
||||
open BitBadger.Documents.Sqlite
|
||||
open Microsoft.Data.Sqlite
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
|
||||
/// SQLite myWebLog tag mapping data implementation
|
||||
type SQLiteTagMapData(conn: SqliteConnection, log: ILogger) =
|
||||
|
||||
/// Find a tag mapping by its ID for the given web log
|
||||
let findById tagMapId webLogId =
|
||||
log.LogTrace "TagMap.findById"
|
||||
Document.findByIdAndWebLog<TagMapId, TagMap> Table.TagMap tagMapId webLogId conn
|
||||
|
||||
/// Delete a tag mapping for the given web log
|
||||
let delete tagMapId webLogId = backgroundTask {
|
||||
log.LogTrace "TagMap.delete"
|
||||
match! findById tagMapId webLogId with
|
||||
| Some _ ->
|
||||
do! conn.deleteById Table.TagMap tagMapId
|
||||
return true
|
||||
| None -> return false
|
||||
}
|
||||
|
||||
/// Find a tag mapping by its URL value for the given web log
|
||||
let findByUrlValue (urlValue: string) webLogId =
|
||||
log.LogTrace "TagMap.findByUrlValue"
|
||||
let urlParam = Field.EQ (nameof TagMap.Empty.UrlValue) urlValue
|
||||
conn.customSingle
|
||||
$"""{Document.Query.selectByWebLog Table.TagMap} AND {Query.whereByField urlParam "@urlValue"}"""
|
||||
(addFieldParam "@urlValue" urlParam [ webLogParam webLogId ])
|
||||
fromData<TagMap>
|
||||
|
||||
/// Get all tag mappings for the given web log
|
||||
let findByWebLog webLogId =
|
||||
log.LogTrace "TagMap.findByWebLog"
|
||||
Document.findByWebLog<TagMap> Table.TagMap webLogId conn
|
||||
|
||||
/// Find any tag mappings in a list of tags for the given web log
|
||||
let findMappingForTags (tags: string list) webLogId =
|
||||
log.LogTrace "TagMap.findMappingForTags"
|
||||
let mapSql, mapParams = inClause $"AND data ->> '{nameof TagMap.Empty.Tag}'" "tag" id tags
|
||||
conn.customList
|
||||
$"{Document.Query.selectByWebLog Table.TagMap} {mapSql}"
|
||||
(webLogParam webLogId :: mapParams)
|
||||
fromData<TagMap>
|
||||
|
||||
/// Save a tag mapping
|
||||
let save (tagMap: TagMap) =
|
||||
log.LogTrace "TagMap.save"
|
||||
conn.save Table.TagMap tagMap
|
||||
|
||||
/// Restore tag mappings from a backup
|
||||
let restore tagMaps = backgroundTask {
|
||||
log.LogTrace "TagMap.restore"
|
||||
for tagMap in tagMaps do do! save tagMap
|
||||
}
|
||||
|
||||
interface ITagMapData with
|
||||
member _.Delete tagMapId webLogId = delete tagMapId webLogId
|
||||
member _.FindById tagMapId webLogId = findById tagMapId webLogId
|
||||
member _.FindByUrlValue urlValue webLogId = findByUrlValue urlValue webLogId
|
||||
member _.FindByWebLog webLogId = findByWebLog webLogId
|
||||
member _.FindMappingForTags tags webLogId = findMappingForTags tags webLogId
|
||||
member _.Save tagMap = save tagMap
|
||||
member _.Restore tagMaps = restore tagMaps
|
149
src/MyWebLog.Data/SQLite/SQLiteThemeData.fs
Normal file
149
src/MyWebLog.Data/SQLite/SQLiteThemeData.fs
Normal file
@ -0,0 +1,149 @@
|
||||
namespace MyWebLog.Data.SQLite
|
||||
|
||||
open BitBadger.Documents
|
||||
open BitBadger.Documents.Sqlite
|
||||
open Microsoft.Data.Sqlite
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
|
||||
/// SQLite myWebLog theme data implementation
|
||||
type SQLiteThemeData(conn : SqliteConnection, log: ILogger) =
|
||||
|
||||
/// The JSON field for the theme ID
|
||||
let idField = $"data ->> '{nameof Theme.Empty.Id}'"
|
||||
|
||||
/// Convert a document to a theme with no template text
|
||||
let withoutTemplateText (rdr: SqliteDataReader) =
|
||||
let theme = fromData<Theme> rdr
|
||||
{ theme with Templates = theme.Templates |> List.map (fun t -> { t with Text = "" })}
|
||||
|
||||
/// Remove the template text from a theme
|
||||
let withoutTemplateText' (it: Theme) =
|
||||
{ it with Templates = it.Templates |> List.map (fun t -> { t with Text = "" }) }
|
||||
|
||||
/// Retrieve all themes (except 'admin'; excludes template text)
|
||||
let all () =
|
||||
log.LogTrace "Theme.all"
|
||||
conn.customList
|
||||
$"{Query.selectFromTable Table.Theme} WHERE {idField} <> 'admin' ORDER BY {idField}"
|
||||
[]
|
||||
withoutTemplateText
|
||||
|
||||
/// Does a given theme exist?
|
||||
let exists (themeId: ThemeId) =
|
||||
log.LogTrace "Theme.exists"
|
||||
conn.existsById Table.Theme themeId
|
||||
|
||||
/// Find a theme by its ID
|
||||
let findById themeId =
|
||||
log.LogTrace "Theme.findById"
|
||||
conn.findById<ThemeId, Theme> Table.Theme themeId
|
||||
|
||||
/// Find a theme by its ID (excludes the text of templates)
|
||||
let findByIdWithoutText (themeId: ThemeId) =
|
||||
log.LogTrace "Theme.findByIdWithoutText"
|
||||
conn.customSingle (Query.Find.byId Table.Theme) [ idParam themeId ] withoutTemplateText
|
||||
|
||||
/// Delete a theme by its ID
|
||||
let delete themeId = backgroundTask {
|
||||
log.LogTrace "Theme.delete"
|
||||
match! findByIdWithoutText themeId with
|
||||
| Some _ ->
|
||||
do! conn.customNonQuery
|
||||
$"DELETE FROM {Table.ThemeAsset} WHERE theme_id = @id; {Query.Delete.byId Table.Theme}"
|
||||
[ idParam themeId ]
|
||||
return true
|
||||
| None -> return false
|
||||
}
|
||||
|
||||
/// Save a theme
|
||||
let save (theme: Theme) =
|
||||
log.LogTrace "Theme.save"
|
||||
conn.save Table.Theme theme
|
||||
|
||||
interface IThemeData with
|
||||
member _.All() = all ()
|
||||
member _.Delete themeId = delete themeId
|
||||
member _.Exists themeId = exists themeId
|
||||
member _.FindById themeId = findById themeId
|
||||
member _.FindByIdWithoutText themeId = findByIdWithoutText themeId
|
||||
member _.Save theme = save theme
|
||||
|
||||
|
||||
open System.IO
|
||||
|
||||
/// SQLite myWebLog theme data implementation
|
||||
type SQLiteThemeAssetData(conn : SqliteConnection, log: ILogger) =
|
||||
|
||||
/// Create parameters for a theme asset ID
|
||||
let assetIdParams assetId =
|
||||
let (ThemeAssetId (ThemeId themeId, path)) = assetId
|
||||
[ idParam themeId; sqlParam "@path" path ]
|
||||
|
||||
/// Get all theme assets (excludes data)
|
||||
let all () =
|
||||
log.LogTrace "ThemeAsset.all"
|
||||
conn.customList $"SELECT theme_id, path, updated_on FROM {Table.ThemeAsset}" [] (Map.toThemeAsset false)
|
||||
|
||||
/// Delete all assets for the given theme
|
||||
let deleteByTheme (themeId: ThemeId) =
|
||||
log.LogTrace "ThemeAsset.deleteByTheme"
|
||||
conn.customNonQuery $"DELETE FROM {Table.ThemeAsset} WHERE theme_id = @id" [ idParam themeId ]
|
||||
|
||||
/// Find a theme asset by its ID
|
||||
let findById assetId =
|
||||
log.LogTrace "ThemeAsset.findById"
|
||||
conn.customSingle
|
||||
$"SELECT *, ROWID FROM {Table.ThemeAsset} WHERE theme_id = @id AND path = @path"
|
||||
(assetIdParams assetId)
|
||||
(Map.toThemeAsset true)
|
||||
|
||||
/// Get theme assets for the given theme (excludes data)
|
||||
let findByTheme (themeId: ThemeId) =
|
||||
log.LogTrace "ThemeAsset.findByTheme"
|
||||
conn.customList
|
||||
$"SELECT theme_id, path, updated_on FROM {Table.ThemeAsset} WHERE theme_id = @id"
|
||||
[ idParam themeId ]
|
||||
(Map.toThemeAsset false)
|
||||
|
||||
/// Get theme assets for the given theme
|
||||
let findByThemeWithData (themeId: ThemeId) =
|
||||
log.LogTrace "ThemeAsset.findByThemeWithData"
|
||||
conn.customList
|
||||
$"SELECT *, ROWID FROM {Table.ThemeAsset} WHERE theme_id = @id"
|
||||
[ idParam themeId ]
|
||||
(Map.toThemeAsset true)
|
||||
|
||||
/// Save a theme asset
|
||||
let save (asset: ThemeAsset) = backgroundTask {
|
||||
log.LogTrace "ThemeAsset.save"
|
||||
do! conn.customNonQuery
|
||||
$"INSERT INTO {Table.ThemeAsset} (
|
||||
theme_id, path, updated_on, data
|
||||
) VALUES (
|
||||
@id, @path, @updatedOn, ZEROBLOB(@dataLength)
|
||||
) ON CONFLICT (theme_id, path) DO UPDATE
|
||||
SET updated_on = @updatedOn,
|
||||
data = ZEROBLOB(@dataLength)"
|
||||
[ sqlParam "@updatedOn" (instantParam asset.UpdatedOn)
|
||||
sqlParam "@dataLength" asset.Data.Length
|
||||
yield! (assetIdParams asset.Id) ]
|
||||
|
||||
let! rowId =
|
||||
conn.customScalar
|
||||
$"SELECT ROWID FROM {Table.ThemeAsset} WHERE theme_id = @id AND path = @path"
|
||||
(assetIdParams asset.Id)
|
||||
_.GetInt64(0)
|
||||
use dataStream = new MemoryStream(asset.Data)
|
||||
use blobStream = new SqliteBlob(conn, Table.ThemeAsset, "data", rowId)
|
||||
do! dataStream.CopyToAsync blobStream
|
||||
}
|
||||
|
||||
interface IThemeAssetData with
|
||||
member _.All() = all ()
|
||||
member _.DeleteByTheme themeId = deleteByTheme themeId
|
||||
member _.FindById assetId = findById assetId
|
||||
member _.FindByTheme themeId = findByTheme themeId
|
||||
member _.FindByThemeWithData themeId = findByThemeWithData themeId
|
||||
member _.Save asset = save asset
|
86
src/MyWebLog.Data/SQLite/SQLiteUploadData.fs
Normal file
86
src/MyWebLog.Data/SQLite/SQLiteUploadData.fs
Normal file
@ -0,0 +1,86 @@
|
||||
namespace MyWebLog.Data.SQLite
|
||||
|
||||
open System.IO
|
||||
open BitBadger.Documents.Sqlite
|
||||
open Microsoft.Data.Sqlite
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
|
||||
/// SQLite myWebLog web log data implementation
|
||||
type SQLiteUploadData(conn: SqliteConnection, log: ILogger) =
|
||||
|
||||
/// Save an uploaded file
|
||||
let add (upload: Upload) = backgroundTask {
|
||||
log.LogTrace "Upload.add"
|
||||
do! conn.customNonQuery
|
||||
$"INSERT INTO {Table.Upload} (
|
||||
id, web_log_id, path, updated_on, data
|
||||
) VALUES (
|
||||
@id, @webLogId, @path, @updatedOn, ZEROBLOB(@dataLength)
|
||||
)"
|
||||
[ idParam upload.Id
|
||||
webLogParam upload.WebLogId
|
||||
sqlParam "@path" (string upload.Path)
|
||||
sqlParam "@updatedOn" (instantParam upload.UpdatedOn)
|
||||
sqlParam "@dataLength" upload.Data.Length ]
|
||||
let! rowId =
|
||||
conn.customScalar $"SELECT ROWID FROM {Table.Upload} WHERE id = @id" [ idParam upload.Id ] _.GetInt64(0)
|
||||
use dataStream = new MemoryStream(upload.Data)
|
||||
use blobStream = new SqliteBlob(conn, Table.Upload, "data", rowId)
|
||||
do! dataStream.CopyToAsync blobStream
|
||||
}
|
||||
|
||||
/// Delete an uploaded file by its ID
|
||||
let delete (uploadId: UploadId) webLogId = backgroundTask {
|
||||
log.LogTrace "Upload.delete"
|
||||
let! upload =
|
||||
conn.customSingle
|
||||
$"SELECT id, web_log_id, path, updated_on FROM {Table.Upload} WHERE id = @id AND web_log_id = @webLogId"
|
||||
[ idParam uploadId; webLogParam webLogId ]
|
||||
(Map.toUpload false)
|
||||
match upload with
|
||||
| Some up ->
|
||||
do! conn.customNonQuery $"DELETE FROM {Table.Upload} WHERE id = @id" [ idParam up.Id ]
|
||||
return Ok (string up.Path)
|
||||
| None -> return Error $"Upload ID {string uploadId} not found"
|
||||
}
|
||||
|
||||
/// Find an uploaded file by its path for the given web log
|
||||
let findByPath (path: string) webLogId =
|
||||
log.LogTrace "Upload.findByPath"
|
||||
conn.customSingle
|
||||
$"SELECT *, ROWID FROM {Table.Upload} WHERE web_log_id = @webLogId AND path = @path"
|
||||
[ webLogParam webLogId; sqlParam "@path" path ]
|
||||
(Map.toUpload true)
|
||||
|
||||
/// Find all uploaded files for the given web log (excludes data)
|
||||
let findByWebLog webLogId =
|
||||
log.LogTrace "Upload.findByWebLog"
|
||||
conn.customList
|
||||
$"SELECT id, web_log_id, path, updated_on FROM {Table.Upload} WHERE web_log_id = @webLogId"
|
||||
[ webLogParam webLogId ]
|
||||
(Map.toUpload false)
|
||||
|
||||
/// Find all uploaded files for the given web log
|
||||
let findByWebLogWithData webLogId =
|
||||
log.LogTrace "Upload.findByWebLogWithData"
|
||||
conn.customList
|
||||
$"SELECT *, ROWID FROM {Table.Upload} WHERE web_log_id = @webLogId"
|
||||
[ webLogParam webLogId ]
|
||||
(Map.toUpload true)
|
||||
|
||||
/// Restore uploads from a backup
|
||||
let restore uploads = backgroundTask {
|
||||
log.LogTrace "Upload.restore"
|
||||
for upload in uploads do do! add upload
|
||||
}
|
||||
|
||||
interface IUploadData with
|
||||
member _.Add upload = add upload
|
||||
member _.Delete uploadId webLogId = delete uploadId webLogId
|
||||
member _.FindByPath path webLogId = findByPath path webLogId
|
||||
member _.FindByWebLog webLogId = findByWebLog webLogId
|
||||
member _.FindByWebLogWithData webLogId = findByWebLogWithData webLogId
|
||||
member _.Restore uploads = restore uploads
|
||||
|
74
src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs
Normal file
74
src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs
Normal file
@ -0,0 +1,74 @@
|
||||
namespace MyWebLog.Data.SQLite
|
||||
|
||||
open BitBadger.Documents
|
||||
open BitBadger.Documents.Sqlite
|
||||
open Microsoft.Data.Sqlite
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
|
||||
/// SQLite myWebLog web log data implementation
|
||||
type SQLiteWebLogData(conn: SqliteConnection, log: ILogger) =
|
||||
|
||||
/// Add a web log
|
||||
let add webLog =
|
||||
log.LogTrace "WebLog.add"
|
||||
conn.insert<WebLog> Table.WebLog webLog
|
||||
|
||||
/// Retrieve all web logs
|
||||
let all () =
|
||||
log.LogTrace "WebLog.all"
|
||||
conn.findAll<WebLog> Table.WebLog
|
||||
|
||||
/// Delete a web log by its ID
|
||||
let delete webLogId =
|
||||
log.LogTrace "WebLog.delete"
|
||||
let webLogMatches = Query.whereByField (Field.EQ "WebLogId" "") "@webLogId"
|
||||
let subQuery table = $"(SELECT data ->> 'Id' FROM {table} WHERE {webLogMatches})"
|
||||
Custom.nonQuery
|
||||
$"""DELETE FROM {Table.PostComment} WHERE data ->> 'PostId' IN {subQuery Table.Post};
|
||||
DELETE FROM {Table.PostRevision} WHERE post_id IN {subQuery Table.Post};
|
||||
DELETE FROM {Table.PageRevision} WHERE page_id IN {subQuery Table.Page};
|
||||
DELETE FROM {Table.Post} WHERE {webLogMatches};
|
||||
DELETE FROM {Table.Page} WHERE {webLogMatches};
|
||||
DELETE FROM {Table.Category} WHERE {webLogMatches};
|
||||
DELETE FROM {Table.TagMap} WHERE {webLogMatches};
|
||||
DELETE FROM {Table.Upload} WHERE web_log_id = @webLogId;
|
||||
DELETE FROM {Table.WebLogUser} WHERE {webLogMatches};
|
||||
DELETE FROM {Table.WebLog} WHERE {Query.whereById "@webLogId"}"""
|
||||
[ webLogParam webLogId ]
|
||||
|
||||
/// Find a web log by its host (URL base)
|
||||
let findByHost (url: string) =
|
||||
log.LogTrace "WebLog.findByHost"
|
||||
conn.findFirstByField<WebLog> Table.WebLog (Field.EQ (nameof WebLog.Empty.UrlBase) url)
|
||||
|
||||
/// Find a web log by its ID
|
||||
let findById webLogId =
|
||||
log.LogTrace "WebLog.findById"
|
||||
conn.findById<WebLogId, WebLog> Table.WebLog webLogId
|
||||
|
||||
/// Update redirect rules for a web log
|
||||
let updateRedirectRules (webLog: WebLog) =
|
||||
log.LogTrace "WebLog.updateRedirectRules"
|
||||
conn.patchById Table.WebLog webLog.Id {| RedirectRules = webLog.RedirectRules |}
|
||||
|
||||
/// Update RSS options for a web log
|
||||
let updateRssOptions (webLog: WebLog) =
|
||||
log.LogTrace "WebLog.updateRssOptions"
|
||||
conn.patchById Table.WebLog webLog.Id {| Rss = webLog.Rss |}
|
||||
|
||||
/// Update settings for a web log
|
||||
let updateSettings (webLog: WebLog) =
|
||||
log.LogTrace "WebLog.updateSettings"
|
||||
conn.updateById Table.WebLog webLog.Id webLog
|
||||
|
||||
interface IWebLogData with
|
||||
member _.Add webLog = add webLog
|
||||
member _.All () = all ()
|
||||
member _.Delete webLogId = delete webLogId
|
||||
member _.FindByHost url = findByHost url
|
||||
member _.FindById webLogId = findById webLogId
|
||||
member _.UpdateRedirectRules webLog = updateRedirectRules webLog
|
||||
member _.UpdateRssOptions webLog = updateRssOptions webLog
|
||||
member _.UpdateSettings webLog = updateSettings webLog
|
94
src/MyWebLog.Data/SQLite/SQLiteWebLogUserData.fs
Normal file
94
src/MyWebLog.Data/SQLite/SQLiteWebLogUserData.fs
Normal file
@ -0,0 +1,94 @@
|
||||
namespace MyWebLog.Data.SQLite
|
||||
|
||||
open BitBadger.Documents
|
||||
open BitBadger.Documents.Sqlite
|
||||
open Microsoft.Data.Sqlite
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
|
||||
/// SQLite myWebLog user data implementation
|
||||
type SQLiteWebLogUserData(conn: SqliteConnection, log: ILogger) =
|
||||
|
||||
/// Add a user
|
||||
let add user =
|
||||
log.LogTrace "WebLogUser.add"
|
||||
conn.insert<WebLogUser> Table.WebLogUser user
|
||||
|
||||
/// Find a user by their ID for the given web log
|
||||
let findById userId webLogId =
|
||||
log.LogTrace "WebLogUser.findById"
|
||||
Document.findByIdAndWebLog<WebLogUserId, WebLogUser> Table.WebLogUser userId webLogId conn
|
||||
|
||||
/// Delete a user if they have no posts or pages
|
||||
let delete userId webLogId = backgroundTask {
|
||||
log.LogTrace "WebLogUser.delete"
|
||||
match! findById userId webLogId with
|
||||
| Some _ ->
|
||||
let! pageCount = conn.countByField Table.Page (Field.EQ (nameof Page.Empty.AuthorId) (string userId))
|
||||
let! postCount = conn.countByField Table.Post (Field.EQ (nameof Post.Empty.AuthorId) (string userId))
|
||||
if pageCount + postCount > 0 then
|
||||
return Error "User has pages or posts; cannot delete"
|
||||
else
|
||||
do! conn.deleteById Table.WebLogUser userId
|
||||
return Ok true
|
||||
| None -> return Error "User does not exist"
|
||||
}
|
||||
|
||||
/// Find a user by their e-mail address for the given web log
|
||||
let findByEmail (email: string) webLogId =
|
||||
log.LogTrace "WebLogUser.findByEmail"
|
||||
let emailParam = Field.EQ (nameof WebLogUser.Empty.Email) email
|
||||
conn.customSingle
|
||||
$"""{Document.Query.selectByWebLog Table.WebLogUser}
|
||||
AND {Query.whereByField emailParam "@email"}"""
|
||||
(addFieldParam "@email" emailParam [ webLogParam webLogId ])
|
||||
fromData<WebLogUser>
|
||||
|
||||
/// Get all users for the given web log
|
||||
let findByWebLog webLogId = backgroundTask {
|
||||
log.LogTrace "WebLogUser.findByWebLog"
|
||||
let! users = Document.findByWebLog<WebLogUser> Table.WebLogUser webLogId conn
|
||||
return users |> List.sortBy _.PreferredName.ToLowerInvariant()
|
||||
}
|
||||
|
||||
/// Find the names of users by their IDs for the given web log
|
||||
let findNames webLogId (userIds: WebLogUserId list) =
|
||||
log.LogTrace "WebLogUser.findNames"
|
||||
let nameSql, nameParams = inClause $"AND data ->> '{nameof WebLogUser.Empty.Id}'" "id" string userIds
|
||||
conn.customList
|
||||
$"{Document.Query.selectByWebLog Table.WebLogUser} {nameSql}"
|
||||
(webLogParam webLogId :: nameParams)
|
||||
(fun rdr ->
|
||||
let user = fromData<WebLogUser> rdr
|
||||
{ Name = string user.Id; Value = user.DisplayName })
|
||||
|
||||
/// Restore users from a backup
|
||||
let restore users = backgroundTask {
|
||||
log.LogTrace "WebLogUser.restore"
|
||||
for user in users do do! add user
|
||||
}
|
||||
|
||||
/// Set a user's last seen date/time to now
|
||||
let setLastSeen userId webLogId = backgroundTask {
|
||||
log.LogTrace "WebLogUser.setLastSeen"
|
||||
match! findById userId webLogId with
|
||||
| Some _ -> do! conn.patchById Table.WebLogUser userId {| LastSeenOn = Noda.now () |}
|
||||
| None -> ()
|
||||
}
|
||||
|
||||
/// Update a user
|
||||
let update (user: WebLogUser) =
|
||||
log.LogTrace "WebLogUser.update"
|
||||
conn.updateById Table.WebLogUser user.Id user
|
||||
|
||||
interface IWebLogUserData with
|
||||
member _.Add user = add user
|
||||
member _.Delete userId webLogId = delete userId webLogId
|
||||
member _.FindByEmail email webLogId = findByEmail email webLogId
|
||||
member _.FindById userId webLogId = findById userId webLogId
|
||||
member _.FindByWebLog webLogId = findByWebLog webLogId
|
||||
member _.FindNames webLogId userIds = findNames webLogId userIds
|
||||
member _.Restore users = restore users
|
||||
member _.SetLastSeen userId webLogId = setLastSeen userId webLogId
|
||||
member _.Update user = update user
|
File diff suppressed because it is too large
Load Diff
@ -6,17 +6,75 @@ open MyWebLog
|
||||
open MyWebLog.ViewModels
|
||||
|
||||
/// Create a category hierarchy from the given list of categories
|
||||
let rec 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
|
||||
let rec 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 = string 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)
|
||||
PostCount = 0 }
|
||||
yield! orderByHierarchy cats (Some cat.Id) (Some fullSlug) ([ cat.Name ] |> List.append parentNames)
|
||||
}
|
||||
|
||||
/// Get lists of items removed from and added to the given lists
|
||||
let diffLists<'T, 'U when 'U: equality> oldItems newItems (f: 'T -> 'U) =
|
||||
let diff compList = fun item -> not (compList |> List.exists (fun other -> f item = f other))
|
||||
List.filter (diff newItems) oldItems, List.filter (diff oldItems) newItems
|
||||
|
||||
/// Find the revisions added and removed
|
||||
let diffRevisions (oldRevs: Revision list) newRevs =
|
||||
diffLists oldRevs newRevs (fun rev -> $"{rev.AsOf.ToUnixTimeTicks()}|{rev.Text}")
|
||||
|
||||
open MyWebLog.Converters
|
||||
open Newtonsoft.Json
|
||||
|
||||
/// Serialize an object to JSON
|
||||
let serialize<'T> ser (item: 'T) =
|
||||
JsonConvert.SerializeObject(item, Json.settings ser)
|
||||
|
||||
/// Deserialize a JSON string
|
||||
let deserialize<'T> (ser: JsonSerializer) value =
|
||||
JsonConvert.DeserializeObject<'T>(value, Json.settings ser)
|
||||
|
||||
open BitBadger.Documents
|
||||
|
||||
/// Create a document serializer using the given JsonSerializer
|
||||
let createDocumentSerializer ser =
|
||||
{ new IDocumentSerializer with
|
||||
member _.Serialize<'T>(it: 'T) : string = serialize ser it
|
||||
member _.Deserialize<'T>(it: string) : 'T = deserialize ser it
|
||||
}
|
||||
|
||||
/// Data migration utilities
|
||||
module Migration =
|
||||
|
||||
open Microsoft.Extensions.Logging
|
||||
|
||||
/// The current database version
|
||||
let currentDbVersion = "v2.2"
|
||||
|
||||
/// Log a migration step
|
||||
let logStep<'T> (log: ILogger<'T>) migration message =
|
||||
log.LogInformation $"Migrating %s{migration}: %s{message}"
|
||||
|
||||
/// Notify the user that a backup/restore
|
||||
let backupAndRestoreRequired log oldVersion newVersion webLogs =
|
||||
logStep log $"%s{oldVersion} to %s{newVersion}" "Requires Using Action"
|
||||
|
||||
[ "** MANUAL DATABASE UPGRADE REQUIRED **"; ""
|
||||
$"The data structure changed between {oldVersion} and {newVersion}."
|
||||
"To migrate your data:"
|
||||
$" - Use a {oldVersion} executable to back up each web log"
|
||||
" - Drop all tables from the database"
|
||||
" - Use this executable to restore each backup"; ""
|
||||
"Commands to back up all web logs:"
|
||||
yield! webLogs |> List.map (fun (url, slug) -> $"./myWebLog backup %s{url} {oldVersion}.%s{slug}.json") ]
|
||||
|> String.concat "\n"
|
||||
|> log.LogWarning
|
||||
|
||||
log.LogCritical "myWebLog will now exit"
|
||||
exit 1 |> ignore
|
||||
|
@ -1,427 +1,440 @@
|
||||
namespace MyWebLog
|
||||
|
||||
open System
|
||||
open MyWebLog
|
||||
open NodaTime
|
||||
|
||||
/// A category under which a post may be identified
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Category =
|
||||
{ /// The ID of the category
|
||||
id : CategoryId
|
||||
type Category = {
|
||||
/// The ID of the category
|
||||
Id: CategoryId
|
||||
|
||||
/// The ID of the web log to which the category belongs
|
||||
webLogId : WebLogId
|
||||
/// The ID of the web log to which the category belongs
|
||||
WebLogId: WebLogId
|
||||
|
||||
/// The displayed name
|
||||
name : string
|
||||
/// The displayed name
|
||||
Name: string
|
||||
|
||||
/// The slug (used in category URLs)
|
||||
slug : string
|
||||
/// The slug (used in category URLs)
|
||||
Slug: string
|
||||
|
||||
/// A longer description of the category
|
||||
description : string option
|
||||
/// A longer description of the category
|
||||
Description: string option
|
||||
|
||||
/// The parent ID of this category (if a subcategory)
|
||||
parentId : CategoryId option
|
||||
}
|
||||
|
||||
/// Functions to support categories
|
||||
module Category =
|
||||
/// The parent ID of this category (if a subcategory)
|
||||
ParentId: CategoryId option
|
||||
} with
|
||||
|
||||
/// An empty category
|
||||
let empty =
|
||||
{ id = CategoryId.empty
|
||||
webLogId = WebLogId.empty
|
||||
name = ""
|
||||
slug = ""
|
||||
description = None
|
||||
parentId = None
|
||||
}
|
||||
static member Empty =
|
||||
{ Id = CategoryId.Empty
|
||||
WebLogId = WebLogId.Empty
|
||||
Name = ""
|
||||
Slug = ""
|
||||
Description = None
|
||||
ParentId = None }
|
||||
|
||||
|
||||
/// A comment on a post
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Comment =
|
||||
{ /// The ID of the comment
|
||||
id : CommentId
|
||||
type Comment = {
|
||||
/// The ID of the comment
|
||||
Id: CommentId
|
||||
|
||||
/// The ID of the post to which this comment applies
|
||||
postId : PostId
|
||||
/// The ID of the post to which this comment applies
|
||||
PostId: PostId
|
||||
|
||||
/// The ID of the comment to which this comment is a reply
|
||||
inReplyToId : CommentId option
|
||||
/// The ID of the comment to which this comment is a reply
|
||||
InReplyToId: CommentId option
|
||||
|
||||
/// The name of the commentor
|
||||
name : string
|
||||
/// The name of the commentor
|
||||
Name: string
|
||||
|
||||
/// The e-mail address of the commentor
|
||||
email : string
|
||||
/// The e-mail address of the commentor
|
||||
Email: string
|
||||
|
||||
/// The URL of the commentor's personal website
|
||||
url : string option
|
||||
/// The URL of the commentor's personal website
|
||||
Url: string option
|
||||
|
||||
/// The status of the comment
|
||||
status : CommentStatus
|
||||
/// The status of the comment
|
||||
Status: CommentStatus
|
||||
|
||||
/// When the comment was posted
|
||||
postedOn : DateTime
|
||||
/// When the comment was posted
|
||||
PostedOn: Instant
|
||||
|
||||
/// The text of the comment
|
||||
text : string
|
||||
}
|
||||
|
||||
/// Functions to support comments
|
||||
module Comment =
|
||||
/// The text of the comment
|
||||
Text: string
|
||||
} with
|
||||
|
||||
/// An empty comment
|
||||
let empty =
|
||||
{ id = CommentId.empty
|
||||
postId = PostId.empty
|
||||
inReplyToId = None
|
||||
name = ""
|
||||
email = ""
|
||||
url = None
|
||||
status = Pending
|
||||
postedOn = DateTime.UtcNow
|
||||
text = ""
|
||||
}
|
||||
static member Empty =
|
||||
{ Id = CommentId.Empty
|
||||
PostId = PostId.Empty
|
||||
InReplyToId = None
|
||||
Name = ""
|
||||
Email = ""
|
||||
Url = None
|
||||
Status = Pending
|
||||
PostedOn = Noda.epoch
|
||||
Text = "" }
|
||||
|
||||
|
||||
/// A page (text not associated with a date/time)
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Page =
|
||||
{ /// The ID of this page
|
||||
id : PageId
|
||||
type Page = {
|
||||
/// The ID of this page
|
||||
Id: PageId
|
||||
|
||||
/// The ID of the web log to which this page belongs
|
||||
webLogId : WebLogId
|
||||
/// The ID of the web log to which this page belongs
|
||||
WebLogId: WebLogId
|
||||
|
||||
/// The ID of the author of this page
|
||||
authorId : WebLogUserId
|
||||
/// The ID of the author of this page
|
||||
AuthorId: WebLogUserId
|
||||
|
||||
/// The title of the page
|
||||
title : string
|
||||
/// The title of the page
|
||||
Title: string
|
||||
|
||||
/// The link at which this page is displayed
|
||||
permalink : Permalink
|
||||
/// The link at which this page is displayed
|
||||
Permalink: Permalink
|
||||
|
||||
/// When this page was published
|
||||
publishedOn : DateTime
|
||||
/// When this page was published
|
||||
PublishedOn: Instant
|
||||
|
||||
/// When this page was last updated
|
||||
updatedOn : DateTime
|
||||
/// When this page was last updated
|
||||
UpdatedOn: Instant
|
||||
|
||||
/// Whether this page shows as part of the web log's navigation
|
||||
showInPageList : bool
|
||||
/// Whether this page shows as part of the web log's navigation
|
||||
IsInPageList: bool
|
||||
|
||||
/// The template to use when rendering this page
|
||||
template : string option
|
||||
/// The template to use when rendering this page
|
||||
Template: string option
|
||||
|
||||
/// The current text of the page
|
||||
text : string
|
||||
/// The current text of the page
|
||||
Text: string
|
||||
|
||||
/// Metadata for this page
|
||||
metadata : MetaItem list
|
||||
|
||||
/// Permalinks at which this page may have been previously served (useful for migrated content)
|
||||
priorPermalinks : Permalink list
|
||||
/// Metadata for this page
|
||||
Metadata: MetaItem list
|
||||
|
||||
/// Permalinks at which this page may have been previously served (useful for migrated content)
|
||||
PriorPermalinks: Permalink list
|
||||
|
||||
/// Revisions of this page
|
||||
revisions : Revision list
|
||||
}
|
||||
|
||||
/// Functions to support pages
|
||||
module Page =
|
||||
/// Revisions of this page
|
||||
Revisions: Revision list
|
||||
} with
|
||||
|
||||
/// An empty page
|
||||
let empty =
|
||||
{ id = PageId.empty
|
||||
webLogId = WebLogId.empty
|
||||
authorId = WebLogUserId.empty
|
||||
title = ""
|
||||
permalink = Permalink.empty
|
||||
publishedOn = DateTime.MinValue
|
||||
updatedOn = DateTime.MinValue
|
||||
showInPageList = false
|
||||
template = None
|
||||
text = ""
|
||||
metadata = []
|
||||
priorPermalinks = []
|
||||
revisions = []
|
||||
}
|
||||
static member Empty =
|
||||
{ Id = PageId.Empty
|
||||
WebLogId = WebLogId.Empty
|
||||
AuthorId = WebLogUserId.Empty
|
||||
Title = ""
|
||||
Permalink = Permalink.Empty
|
||||
PublishedOn = Noda.epoch
|
||||
UpdatedOn = Noda.epoch
|
||||
IsInPageList = false
|
||||
Template = None
|
||||
Text = ""
|
||||
Metadata = []
|
||||
PriorPermalinks = []
|
||||
Revisions = [] }
|
||||
|
||||
|
||||
/// A web log post
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Post =
|
||||
{ /// The ID of this post
|
||||
id : PostId
|
||||
type Post = {
|
||||
/// The ID of this post
|
||||
Id: PostId
|
||||
|
||||
/// The ID of the web log to which this post belongs
|
||||
webLogId : WebLogId
|
||||
/// The ID of the web log to which this post belongs
|
||||
WebLogId: WebLogId
|
||||
|
||||
/// The ID of the author of this post
|
||||
authorId : WebLogUserId
|
||||
/// The ID of the author of this post
|
||||
AuthorId: WebLogUserId
|
||||
|
||||
/// The status
|
||||
status : PostStatus
|
||||
/// The status
|
||||
Status: PostStatus
|
||||
|
||||
/// The title
|
||||
title : string
|
||||
/// The title
|
||||
Title: string
|
||||
|
||||
/// The link at which the post resides
|
||||
permalink : Permalink
|
||||
/// The link at which the post resides
|
||||
Permalink: Permalink
|
||||
|
||||
/// The instant on which the post was originally published
|
||||
publishedOn : DateTime option
|
||||
/// The instant on which the post was originally published
|
||||
PublishedOn: Instant option
|
||||
|
||||
/// The instant on which the post was last updated
|
||||
updatedOn : DateTime
|
||||
/// The instant on which the post was last updated
|
||||
UpdatedOn: Instant
|
||||
|
||||
/// The template to use in displaying the post
|
||||
template : string option
|
||||
|
||||
/// The text of the post in HTML (ready to display) format
|
||||
text : string
|
||||
/// The template to use in displaying the post
|
||||
Template: string option
|
||||
|
||||
/// The text of the post in HTML (ready to display) format
|
||||
Text: string
|
||||
|
||||
/// The Ids of the categories to which this is assigned
|
||||
categoryIds : CategoryId list
|
||||
/// The Ids of the categories to which this is assigned
|
||||
CategoryIds: CategoryId list
|
||||
|
||||
/// The tags for the post
|
||||
tags : string list
|
||||
/// The tags for the post
|
||||
Tags: string list
|
||||
|
||||
/// Metadata for the post
|
||||
metadata : MetaItem list
|
||||
|
||||
/// Permalinks at which this post may have been previously served (useful for migrated content)
|
||||
priorPermalinks : Permalink list
|
||||
/// Podcast episode information for this post
|
||||
Episode: Episode option
|
||||
|
||||
/// Metadata for the post
|
||||
Metadata: MetaItem list
|
||||
|
||||
/// Permalinks at which this post may have been previously served (useful for migrated content)
|
||||
PriorPermalinks: Permalink list
|
||||
|
||||
/// The revisions for this post
|
||||
revisions : Revision list
|
||||
}
|
||||
|
||||
/// Functions to support posts
|
||||
module Post =
|
||||
/// The revisions for this post
|
||||
Revisions: Revision list
|
||||
} with
|
||||
|
||||
/// An empty post
|
||||
let empty =
|
||||
{ id = PostId.empty
|
||||
webLogId = WebLogId.empty
|
||||
authorId = WebLogUserId.empty
|
||||
status = Draft
|
||||
title = ""
|
||||
permalink = Permalink.empty
|
||||
publishedOn = None
|
||||
updatedOn = DateTime.MinValue
|
||||
text = ""
|
||||
template = None
|
||||
categoryIds = []
|
||||
tags = []
|
||||
metadata = []
|
||||
priorPermalinks = []
|
||||
revisions = []
|
||||
}
|
||||
static member Empty =
|
||||
{ Id = PostId.Empty
|
||||
WebLogId = WebLogId.Empty
|
||||
AuthorId = WebLogUserId.Empty
|
||||
Status = Draft
|
||||
Title = ""
|
||||
Permalink = Permalink.Empty
|
||||
PublishedOn = None
|
||||
UpdatedOn = Noda.epoch
|
||||
Text = ""
|
||||
Template = None
|
||||
CategoryIds = []
|
||||
Tags = []
|
||||
Episode = None
|
||||
Metadata = []
|
||||
PriorPermalinks = []
|
||||
Revisions = [] }
|
||||
|
||||
|
||||
/// A mapping between a tag and its URL value, used to translate restricted characters (ex. "#1" -> "number-1")
|
||||
type TagMap =
|
||||
{ /// The ID of this tag mapping
|
||||
id : TagMapId
|
||||
|
||||
/// The ID of the web log to which this tag mapping belongs
|
||||
webLogId : WebLogId
|
||||
|
||||
/// The tag which should be mapped to a different value in links
|
||||
tag : string
|
||||
|
||||
/// The value by which the tag should be linked
|
||||
urlValue : string
|
||||
}
|
||||
|
||||
/// Functions to support tag mappings
|
||||
module TagMap =
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type TagMap = {
|
||||
/// The ID of this tag mapping
|
||||
Id: TagMapId
|
||||
|
||||
/// The ID of the web log to which this tag mapping belongs
|
||||
WebLogId: WebLogId
|
||||
|
||||
/// The tag which should be mapped to a different value in links
|
||||
Tag: string
|
||||
|
||||
/// The value by which the tag should be linked
|
||||
UrlValue: string
|
||||
} with
|
||||
|
||||
/// An empty tag mapping
|
||||
let empty =
|
||||
{ id = TagMapId.empty
|
||||
webLogId = WebLogId.empty
|
||||
tag = ""
|
||||
urlValue = ""
|
||||
}
|
||||
static member Empty =
|
||||
{ Id = TagMapId.Empty; WebLogId = WebLogId.Empty; Tag = ""; UrlValue = "" }
|
||||
|
||||
|
||||
/// A theme
|
||||
type Theme =
|
||||
{ /// The ID / path of the theme
|
||||
id : ThemeId
|
||||
|
||||
/// A long name of the theme
|
||||
name : string
|
||||
|
||||
/// The version of the theme
|
||||
version : string
|
||||
|
||||
/// The templates for this theme
|
||||
templates: ThemeTemplate list
|
||||
}
|
||||
|
||||
/// Functions to support themes
|
||||
module Theme =
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Theme = {
|
||||
/// The ID / path of the theme
|
||||
Id: ThemeId
|
||||
|
||||
/// A long name of the theme
|
||||
Name: string
|
||||
|
||||
/// The version of the theme
|
||||
Version: string
|
||||
|
||||
/// The templates for this theme
|
||||
Templates: ThemeTemplate list
|
||||
} with
|
||||
|
||||
/// An empty theme
|
||||
let empty =
|
||||
{ id = ThemeId ""
|
||||
name = ""
|
||||
version = ""
|
||||
templates = []
|
||||
}
|
||||
static member Empty =
|
||||
{ Id = ThemeId.Empty; Name = ""; Version = ""; Templates = [] }
|
||||
|
||||
|
||||
/// A theme asset (a file served as part of a theme, at /themes/[theme]/[asset-path])
|
||||
type ThemeAsset =
|
||||
{
|
||||
/// The ID of the asset (consists of theme and path)
|
||||
id : ThemeAssetId
|
||||
|
||||
/// The updated date (set from the file date from the ZIP archive)
|
||||
updatedOn : DateTime
|
||||
|
||||
/// The data for the asset
|
||||
data : byte[]
|
||||
}
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type ThemeAsset = {
|
||||
/// The ID of the asset (consists of theme and path)
|
||||
Id: ThemeAssetId
|
||||
|
||||
/// The updated date (set from the file date from the ZIP archive)
|
||||
UpdatedOn: Instant
|
||||
|
||||
/// The data for the asset
|
||||
Data: byte array
|
||||
} with
|
||||
|
||||
/// An empty theme asset
|
||||
static member Empty =
|
||||
{ Id = ThemeAssetId.Empty; UpdatedOn = Noda.epoch; Data = [||] }
|
||||
|
||||
|
||||
/// An uploaded file
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Upload = {
|
||||
/// The ID of the upload
|
||||
Id: UploadId
|
||||
|
||||
/// The ID of the web log to which this upload belongs
|
||||
WebLogId: WebLogId
|
||||
|
||||
/// The link at which this upload is served
|
||||
Path: Permalink
|
||||
|
||||
/// The updated date/time for this upload
|
||||
UpdatedOn: Instant
|
||||
|
||||
/// The data for the upload
|
||||
Data: byte array
|
||||
} with
|
||||
|
||||
/// An empty upload
|
||||
static member Empty =
|
||||
{ Id = UploadId.Empty; WebLogId = WebLogId.Empty; Path = Permalink.Empty; UpdatedOn = Noda.epoch; Data = [||] }
|
||||
|
||||
|
||||
open Newtonsoft.Json
|
||||
|
||||
/// A web log
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type WebLog =
|
||||
{ /// The ID of the web log
|
||||
id : WebLogId
|
||||
type WebLog = {
|
||||
/// The ID of the web log
|
||||
Id: WebLogId
|
||||
|
||||
/// The name of the web log
|
||||
name : string
|
||||
/// The name of the web log
|
||||
Name: string
|
||||
|
||||
/// A subtitle for the web log
|
||||
subtitle : string option
|
||||
/// The slug of the web log
|
||||
Slug: string
|
||||
|
||||
/// A subtitle for the web log
|
||||
Subtitle: string option
|
||||
|
||||
/// The default page ("posts" or a page Id)
|
||||
defaultPage : string
|
||||
/// The default page ("posts" or a page Id)
|
||||
DefaultPage: string
|
||||
|
||||
/// The number of posts to display on pages of posts
|
||||
postsPerPage : int
|
||||
/// The number of posts to display on pages of posts
|
||||
PostsPerPage: int
|
||||
|
||||
/// The path of the theme (within /themes)
|
||||
themePath : string
|
||||
/// The ID of the theme (also the path within /themes)
|
||||
ThemeId: ThemeId
|
||||
|
||||
/// The URL base
|
||||
urlBase : string
|
||||
/// The URL base
|
||||
UrlBase: string
|
||||
|
||||
/// The time zone in which dates/times should be displayed
|
||||
timeZone : string
|
||||
|
||||
/// The RSS options for this web log
|
||||
rss : RssOptions
|
||||
|
||||
/// Whether to automatically load htmx
|
||||
autoHtmx : bool
|
||||
}
|
||||
/// The time zone in which dates/times should be displayed
|
||||
TimeZone: string
|
||||
|
||||
/// The RSS options for this web log
|
||||
Rss: RssOptions
|
||||
|
||||
/// Whether to automatically load htmx
|
||||
AutoHtmx: bool
|
||||
|
||||
/// Where uploads are placed
|
||||
Uploads: UploadDestination
|
||||
|
||||
/// Functions to support web logs
|
||||
module WebLog =
|
||||
/// Redirect rules for this weblog
|
||||
RedirectRules: RedirectRule list
|
||||
} with
|
||||
|
||||
/// An empty web log
|
||||
let empty =
|
||||
{ id = WebLogId.empty
|
||||
name = ""
|
||||
subtitle = None
|
||||
defaultPage = ""
|
||||
postsPerPage = 10
|
||||
themePath = "default"
|
||||
urlBase = ""
|
||||
timeZone = ""
|
||||
rss = RssOptions.empty
|
||||
autoHtmx = false
|
||||
}
|
||||
static member Empty =
|
||||
{ Id = WebLogId.Empty
|
||||
Name = ""
|
||||
Slug = ""
|
||||
Subtitle = None
|
||||
DefaultPage = ""
|
||||
PostsPerPage = 10
|
||||
ThemeId = ThemeId "default"
|
||||
UrlBase = ""
|
||||
TimeZone = ""
|
||||
Rss = RssOptions.Empty
|
||||
AutoHtmx = false
|
||||
Uploads = Database
|
||||
RedirectRules = [] }
|
||||
|
||||
/// Get the host (including scheme) and extra path from the URL base
|
||||
let hostAndPath webLog =
|
||||
let scheme = webLog.urlBase.Split "://"
|
||||
let host = scheme[1].Split "/"
|
||||
$"{scheme[0]}://{host[0]}", if host.Length > 1 then $"""/{String.Join ("/", host |> Array.skip 1)}""" else ""
|
||||
/// Any extra path where this web log is hosted (blank if web log is hosted at the root of the domain)
|
||||
[<JsonIgnore>]
|
||||
member this.ExtraPath =
|
||||
let pathParts = this.UrlBase.Split "://"
|
||||
if pathParts.Length < 2 then
|
||||
""
|
||||
else
|
||||
let path = pathParts[1].Split "/"
|
||||
if path.Length > 1 then $"""/{path |> Array.skip 1 |> String.concat "/"}""" else ""
|
||||
|
||||
/// Generate an absolute URL for the given link
|
||||
let absoluteUrl webLog permalink =
|
||||
$"{webLog.urlBase}/{Permalink.toString permalink}"
|
||||
|
||||
/// Generate a relative URL for the given link
|
||||
let relativeUrl webLog permalink =
|
||||
let _, leadPath = hostAndPath webLog
|
||||
$"{leadPath}/{Permalink.toString permalink}"
|
||||
member this.AbsoluteUrl(permalink: Permalink) =
|
||||
$"{this.UrlBase}/{permalink}"
|
||||
|
||||
/// Convert a UTC date/time to the web log's local date/time
|
||||
let localTime webLog (date : DateTime) =
|
||||
TimeZoneInfo.ConvertTimeFromUtc
|
||||
(DateTime (date.Ticks, DateTimeKind.Utc), TimeZoneInfo.FindSystemTimeZoneById webLog.timeZone)
|
||||
|
||||
/// Convert a date/time in the web log's local date/time to UTC
|
||||
let utcTime webLog (date : DateTime) =
|
||||
TimeZoneInfo.ConvertTimeToUtc
|
||||
(DateTime (date.Ticks, DateTimeKind.Unspecified), TimeZoneInfo.FindSystemTimeZoneById webLog.timeZone)
|
||||
/// Generate a relative URL for the given link
|
||||
member this.RelativeUrl(permalink: Permalink) =
|
||||
$"{this.ExtraPath}/{permalink}"
|
||||
|
||||
/// Convert an Instant (UTC reference) to the web log's local date/time
|
||||
member this.LocalTime(date: Instant) =
|
||||
DateTimeZoneProviders.Tzdb.GetZoneOrNull this.TimeZone
|
||||
|> Option.ofObj
|
||||
|> Option.map (fun tz -> date.InZone(tz).ToDateTimeUnspecified())
|
||||
|> Option.defaultValue (date.ToDateTimeUtc())
|
||||
|
||||
|
||||
/// A user of the web log
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type WebLogUser =
|
||||
{ /// The ID of the user
|
||||
id : WebLogUserId
|
||||
type WebLogUser = {
|
||||
/// The ID of the user
|
||||
Id: WebLogUserId
|
||||
|
||||
/// The ID of the web log to which this user belongs
|
||||
webLogId : WebLogId
|
||||
/// The ID of the web log to which this user belongs
|
||||
WebLogId: WebLogId
|
||||
|
||||
/// The user name (e-mail address)
|
||||
userName : string
|
||||
/// The user name (e-mail address)
|
||||
Email: string
|
||||
|
||||
/// The user's first name
|
||||
firstName : string
|
||||
/// The user's first name
|
||||
FirstName: string
|
||||
|
||||
/// The user's last name
|
||||
lastName : string
|
||||
/// The user's last name
|
||||
LastName: string
|
||||
|
||||
/// The user's preferred name
|
||||
preferredName : string
|
||||
/// The user's preferred name
|
||||
PreferredName: string
|
||||
|
||||
/// The hash of the user's password
|
||||
passwordHash : string
|
||||
/// The hash of the user's password
|
||||
PasswordHash: string
|
||||
|
||||
/// Salt used to calculate the user's password hash
|
||||
salt : Guid
|
||||
/// The URL of the user's personal site
|
||||
Url: string option
|
||||
|
||||
/// The URL of the user's personal site
|
||||
url : string option
|
||||
|
||||
/// The user's authorization level
|
||||
authorizationLevel : AuthorizationLevel
|
||||
}
|
||||
|
||||
/// Functions to support web log users
|
||||
module WebLogUser =
|
||||
/// The user's access level
|
||||
AccessLevel: AccessLevel
|
||||
|
||||
/// When the user was created
|
||||
CreatedOn: Instant
|
||||
|
||||
/// When the user last logged on
|
||||
LastSeenOn: Instant option
|
||||
} with
|
||||
|
||||
/// An empty web log user
|
||||
let empty =
|
||||
{ id = WebLogUserId.empty
|
||||
webLogId = WebLogId.empty
|
||||
userName = ""
|
||||
firstName = ""
|
||||
lastName = ""
|
||||
preferredName = ""
|
||||
passwordHash = ""
|
||||
salt = Guid.Empty
|
||||
url = None
|
||||
authorizationLevel = User
|
||||
}
|
||||
static member Empty =
|
||||
{ Id = WebLogUserId.Empty
|
||||
WebLogId = WebLogId.Empty
|
||||
Email = ""
|
||||
FirstName = ""
|
||||
LastName = ""
|
||||
PreferredName = ""
|
||||
PasswordHash = ""
|
||||
Url = None
|
||||
AccessLevel = Author
|
||||
CreatedOn = Noda.epoch
|
||||
LastSeenOn = None }
|
||||
|
||||
/// Get the user's displayed name
|
||||
let displayName user =
|
||||
let name =
|
||||
seq { match user.preferredName with "" -> user.firstName | n -> n; " "; user.lastName }
|
||||
|> Seq.reduce (+)
|
||||
name.Trim ()
|
||||
[<JsonIgnore>]
|
||||
member this.DisplayName =
|
||||
(seq { (match this.PreferredName with "" -> this.FirstName | n -> n); " "; this.LastName }
|
||||
|> Seq.reduce (+)).Trim()
|
||||
|
@ -1,11 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="SupportTypes.fs" />
|
||||
<Compile Include="DataTypes.fs" />
|
||||
@ -13,9 +7,11 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Markdig" Version="0.30.2" />
|
||||
<PackageReference Update="FSharp.Core" Version="6.0.5" />
|
||||
<PackageReference Include="Markdown.ColorCode" Version="1.0.1" />
|
||||
<PackageReference Include="Markdig" Version="0.37.0" />
|
||||
<PackageReference Include="Markdown.ColorCode" Version="2.2.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="NodaTime" Version="3.1.11" />
|
||||
<PackageReference Update="FSharp.Core" Version="8.0.300" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
150
src/MyWebLog.Tests/Data/CategoryDataTests.fs
Normal file
150
src/MyWebLog.Tests/Data/CategoryDataTests.fs
Normal file
@ -0,0 +1,150 @@
|
||||
/// <summary>
|
||||
/// Integration tests for <see cref="ICategoryData" /> implementations
|
||||
/// </summary>
|
||||
module CategoryDataTests
|
||||
|
||||
open Expecto
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
|
||||
/// The ID of the root web log
|
||||
let rootId = WebLogId "uSitJEuD3UyzWC9jgOHc8g"
|
||||
|
||||
/// The ID of the Favorites category
|
||||
let private favoritesId = CategoryId "S5JflPsJ9EG7gA2LD4m92A"
|
||||
|
||||
let ``Add succeeds`` (data: IData) = task {
|
||||
let category =
|
||||
{ Category.Empty with Id = CategoryId "added-cat"; WebLogId = WebLogId "test"; Name = "Added"; Slug = "added" }
|
||||
do! data.Category.Add category
|
||||
let! stored = data.Category.FindById (CategoryId "added-cat") (WebLogId "test")
|
||||
Expect.isSome stored "The category should have been added"
|
||||
}
|
||||
|
||||
let ``CountAll succeeds when categories exist`` (data: IData) = task {
|
||||
let! count = data.Category.CountAll rootId
|
||||
Expect.equal count 3 "There should have been 3 categories"
|
||||
}
|
||||
|
||||
let ``CountAll succeeds when categories do not exist`` (data: IData) = task {
|
||||
let! count = data.Category.CountAll WebLogId.Empty
|
||||
Expect.equal count 0 "There should have been no categories"
|
||||
}
|
||||
|
||||
let ``CountTopLevel succeeds when top-level categories exist`` (data: IData) = task {
|
||||
let! count = data.Category.CountTopLevel rootId
|
||||
Expect.equal count 2 "There should have been 2 top-level categories"
|
||||
}
|
||||
|
||||
let ``CountTopLevel succeeds when no top-level categories exist`` (data: IData) = task {
|
||||
let! count = data.Category.CountTopLevel WebLogId.Empty
|
||||
Expect.equal count 0 "There should have been no top-level categories"
|
||||
}
|
||||
|
||||
let ``FindAllForView succeeds`` (data: IData) = task {
|
||||
let! all = data.Category.FindAllForView rootId
|
||||
Expect.equal all.Length 3 "There should have been 3 categories returned"
|
||||
Expect.equal all[0].Name "Favorites" "The first category is incorrect"
|
||||
Expect.equal all[0].PostCount 1 "There should be one post in this category"
|
||||
Expect.equal all[1].Name "Spitball" "The second category is incorrect"
|
||||
Expect.equal all[1].PostCount 2 "There should be two posts in this category"
|
||||
Expect.equal all[2].Name "Moonshot" "The third category is incorrect"
|
||||
Expect.equal all[2].PostCount 1 "There should be one post in this category"
|
||||
}
|
||||
|
||||
let ``FindById succeeds when a category is found`` (data: IData) = task {
|
||||
let! cat = data.Category.FindById favoritesId rootId
|
||||
Expect.isSome cat "There should have been a category returned"
|
||||
Expect.equal cat.Value.Name "Favorites" "The category retrieved is incorrect"
|
||||
Expect.equal cat.Value.Slug "favorites" "The slug is incorrect"
|
||||
Expect.equal cat.Value.Description (Some "Favorite posts") "The description is incorrect"
|
||||
Expect.isNone cat.Value.ParentId "There should have been no parent ID"
|
||||
}
|
||||
|
||||
let ``FindById succeeds when a category is not found`` (data: IData) = task {
|
||||
let! cat = data.Category.FindById CategoryId.Empty rootId
|
||||
Expect.isNone cat "There should not have been a category returned"
|
||||
}
|
||||
|
||||
let ``FindByWebLog succeeds when categories exist`` (data: IData) = task {
|
||||
let! cats = data.Category.FindByWebLog rootId
|
||||
Expect.equal cats.Length 3 "There should be 3 categories"
|
||||
Expect.exists cats (fun it -> it.Name = "Favorites") "Favorites category not found"
|
||||
Expect.exists cats (fun it -> it.Name = "Spitball") "Spitball category not found"
|
||||
Expect.exists cats (fun it -> it.Name = "Moonshot") "Moonshot category not found"
|
||||
}
|
||||
|
||||
let ``FindByWebLog succeeds when no categories exist`` (data: IData) = task {
|
||||
let! cats = data.Category.FindByWebLog WebLogId.Empty
|
||||
Expect.isEmpty cats "There should have been no categories returned"
|
||||
}
|
||||
|
||||
let ``Update succeeds`` (data: IData) = task {
|
||||
match! data.Category.FindById favoritesId rootId with
|
||||
| Some cat ->
|
||||
do! data.Category.Update { cat with Name = "My Favorites"; Slug = "my-favorites"; Description = None }
|
||||
match! data.Category.FindById favoritesId rootId with
|
||||
| Some updated ->
|
||||
Expect.equal updated.Name "My Favorites" "Name not updated properly"
|
||||
Expect.equal updated.Slug "my-favorites" "Slug not updated properly"
|
||||
Expect.isNone updated.Description "Description should have been removed"
|
||||
| None -> Expect.isTrue false "The updated favorites category could not be retrieved"
|
||||
| None -> Expect.isTrue false "The favorites category could not be retrieved"
|
||||
}
|
||||
|
||||
let ``Delete succeeds when the category is deleted (no posts)`` (data: IData) = task {
|
||||
let! result = data.Category.Delete (CategoryId "added-cat") (WebLogId "test")
|
||||
Expect.equal result CategoryDeleted "The category should have been deleted"
|
||||
let! cat = data.Category.FindById (CategoryId "added-cat") (WebLogId "test")
|
||||
Expect.isNone cat "The deleted category should not still exist"
|
||||
}
|
||||
|
||||
let ``Delete succeeds when the category does not exist`` (data: IData) = task {
|
||||
let! result = data.Category.Delete CategoryId.Empty (WebLogId "none")
|
||||
Expect.equal result CategoryNotFound "The category should not have been found"
|
||||
}
|
||||
|
||||
let ``Delete succeeds when reassigning parent category to None`` (data: IData) = task {
|
||||
let moonshotId = CategoryId "ScVpyu1e7UiP7bDdge3ZEw"
|
||||
let spitballId = CategoryId "jw6N69YtTEWVHAO33jHU-w"
|
||||
let! result = data.Category.Delete spitballId rootId
|
||||
Expect.equal result ReassignedChildCategories "Child categories should have been reassigned"
|
||||
match! data.Category.FindById moonshotId rootId with
|
||||
| Some cat -> Expect.isNone cat.ParentId "Parent ID should have been cleared"
|
||||
| None -> Expect.isTrue false "Unable to find former child category"
|
||||
}
|
||||
|
||||
let ``Delete succeeds when reassigning parent category to Some`` (data: IData) = task {
|
||||
do! data.Category.Add { Category.Empty with Id = CategoryId "a"; WebLogId = WebLogId "test"; Name = "A" }
|
||||
do! data.Category.Add
|
||||
{ Category.Empty with
|
||||
Id = CategoryId "b"
|
||||
WebLogId = WebLogId "test"
|
||||
Name = "B"
|
||||
ParentId = Some (CategoryId "a") }
|
||||
do! data.Category.Add
|
||||
{ Category.Empty with
|
||||
Id = CategoryId "c"
|
||||
WebLogId = WebLogId "test"
|
||||
Name = "C"
|
||||
ParentId = Some (CategoryId "b") }
|
||||
let! result = data.Category.Delete (CategoryId "b") (WebLogId "test")
|
||||
Expect.equal result ReassignedChildCategories "Child categories should have been reassigned"
|
||||
match! data.Category.FindById (CategoryId "c") (WebLogId "test") with
|
||||
| Some cat -> Expect.equal cat.ParentId (Some (CategoryId "a")) "Parent category ID not reassigned properly"
|
||||
| None -> Expect.isTrue false "Expected former child category not found"
|
||||
}
|
||||
|
||||
let ``Delete succeeds and removes category from posts`` (data: IData) = task {
|
||||
let moonshotId = CategoryId "ScVpyu1e7UiP7bDdge3ZEw"
|
||||
let postId = PostId "RCsCU2puYEmkpzotoi8p4g"
|
||||
match! data.Post.FindById postId rootId with
|
||||
| Some post ->
|
||||
Expect.equal post.CategoryIds [ moonshotId ] "Post category IDs are not as expected"
|
||||
let! result = data.Category.Delete moonshotId rootId
|
||||
Expect.equal result CategoryDeleted "The category should have been deleted (no children)"
|
||||
match! data.Post.FindById postId rootId with
|
||||
| Some p -> Expect.isEmpty p.CategoryIds "Category ID was not removed"
|
||||
| None -> Expect.isTrue false "The expected updated post was not found"
|
||||
| None -> Expect.isTrue false "The expected test post was not found"
|
||||
}
|
296
src/MyWebLog.Tests/Data/ConvertersTests.fs
Normal file
296
src/MyWebLog.Tests/Data/ConvertersTests.fs
Normal file
@ -0,0 +1,296 @@
|
||||
module ConvertersTests
|
||||
|
||||
open Expecto
|
||||
open Microsoft.FSharpLu.Json
|
||||
open MyWebLog
|
||||
open MyWebLog.Converters.Json
|
||||
open Newtonsoft.Json
|
||||
|
||||
/// Unit tests for the CategoryIdConverter type
|
||||
let categoryIdConverterTests = testList "CategoryIdConverter" [
|
||||
let opts = JsonSerializerSettings()
|
||||
opts.Converters.Add(CategoryIdConverter())
|
||||
test "succeeds when serializing" {
|
||||
let after = JsonConvert.SerializeObject(CategoryId "test-cat-id", opts)
|
||||
Expect.equal after "\"test-cat-id\"" "Category ID serialized incorrectly"
|
||||
}
|
||||
test "succeeds when deserializing" {
|
||||
let after = JsonConvert.DeserializeObject<CategoryId>("\"test-cat-id\"", opts)
|
||||
Expect.equal after (CategoryId "test-cat-id") "Category ID not serialized incorrectly"
|
||||
}
|
||||
]
|
||||
|
||||
/// Unit tests for the CommentIdConverter type
|
||||
let commentIdConverterTests = testList "CommentIdConverter" [
|
||||
let opts = JsonSerializerSettings()
|
||||
opts.Converters.Add(CommentIdConverter())
|
||||
test "succeeds when serializing" {
|
||||
let after = JsonConvert.SerializeObject(CommentId "test-id", opts)
|
||||
Expect.equal after "\"test-id\"" "Comment ID serialized incorrectly"
|
||||
}
|
||||
test "succeeds when deserializing" {
|
||||
let after = JsonConvert.DeserializeObject<CommentId>("\"my-test\"", opts)
|
||||
Expect.equal after (CommentId "my-test") "Comment ID deserialized incorrectly"
|
||||
}
|
||||
]
|
||||
|
||||
/// Unit tests for the CommentStatusConverter type
|
||||
let commentStatusConverterTests = testList "CommentStatusConverter" [
|
||||
let opts = JsonSerializerSettings()
|
||||
opts.Converters.Add(CommentStatusConverter())
|
||||
test "succeeds when serializing" {
|
||||
let after = JsonConvert.SerializeObject(Approved, opts)
|
||||
Expect.equal after "\"Approved\"" "Comment status serialized incorrectly"
|
||||
}
|
||||
test "succeeds when deserializing" {
|
||||
let after = JsonConvert.DeserializeObject<CommentStatus>("\"Spam\"", opts)
|
||||
Expect.equal after Spam "Comment status deserialized incorrectly"
|
||||
}
|
||||
]
|
||||
|
||||
/// Unit tests for the CustomFeedIdConverter type
|
||||
let customFeedIdConverterTests = testList "CustomFeedIdConverter" [
|
||||
let opts = JsonSerializerSettings()
|
||||
opts.Converters.Add(CustomFeedIdConverter())
|
||||
test "succeeds when serializing" {
|
||||
let after = JsonConvert.SerializeObject(CustomFeedId "my-feed", opts)
|
||||
Expect.equal after "\"my-feed\"" "Custom feed ID serialized incorrectly"
|
||||
}
|
||||
test "succeeds when deserializing" {
|
||||
let after = JsonConvert.DeserializeObject<CustomFeedId>("\"feed-me\"", opts)
|
||||
Expect.equal after (CustomFeedId "feed-me") "Custom feed ID deserialized incorrectly"
|
||||
}
|
||||
]
|
||||
|
||||
/// Unit tests for the CustomFeedSourceConverter type
|
||||
let customFeedSourceConverterTests = testList "CustomFeedSourceConverter" [
|
||||
let opts = JsonSerializerSettings()
|
||||
opts.Converters.Add(CustomFeedSourceConverter())
|
||||
test "succeeds when serializing" {
|
||||
let after = JsonConvert.SerializeObject(Category (CategoryId "abc-123"), opts)
|
||||
Expect.equal after "\"category:abc-123\"" "Custom feed source serialized incorrectly"
|
||||
}
|
||||
test "succeeds when deserializing" {
|
||||
let after = JsonConvert.DeserializeObject<CustomFeedSource>("\"tag:testing\"", opts)
|
||||
Expect.equal after (Tag "testing") "Custom feed source deserialized incorrectly"
|
||||
}
|
||||
]
|
||||
|
||||
/// Unit tests for the ExplicitRating type
|
||||
let explicitRatingConverterTests = testList "ExplicitRatingConverter" [
|
||||
let opts = JsonSerializerSettings()
|
||||
opts.Converters.Add(ExplicitRatingConverter())
|
||||
test "succeeds when serializing" {
|
||||
let after = JsonConvert.SerializeObject(Yes, opts)
|
||||
Expect.equal after "\"yes\"" "Explicit rating serialized incorrectly"
|
||||
}
|
||||
test "succeeds when deserializing" {
|
||||
let after = JsonConvert.DeserializeObject<ExplicitRating>("\"clean\"", opts)
|
||||
Expect.equal after Clean "Explicit rating deserialized incorrectly"
|
||||
}
|
||||
]
|
||||
|
||||
/// Unit tests for the MarkupText type
|
||||
let markupTextConverterTests = testList "MarkupTextConverter" [
|
||||
let opts = JsonSerializerSettings()
|
||||
opts.Converters.Add(MarkupTextConverter())
|
||||
test "succeeds when serializing" {
|
||||
let after = JsonConvert.SerializeObject(Html "<h4>test</h4>", opts)
|
||||
Expect.equal after "\"HTML: <h4>test</h4>\"" "Markup text serialized incorrectly"
|
||||
}
|
||||
test "succeeds when deserializing" {
|
||||
let after = JsonConvert.DeserializeObject<MarkupText>("\"Markdown: #### test\"", opts)
|
||||
Expect.equal after (Markdown "#### test") "Markup text deserialized incorrectly"
|
||||
}
|
||||
]
|
||||
|
||||
/// Unit tests for the PermalinkConverter type
|
||||
let permalinkConverterTests = testList "PermalinkConverter" [
|
||||
let opts = JsonSerializerSettings()
|
||||
opts.Converters.Add(PermalinkConverter())
|
||||
test "succeeds when serializing" {
|
||||
let after = JsonConvert.SerializeObject(Permalink "2022/test", opts)
|
||||
Expect.equal after "\"2022/test\"" "Permalink serialized incorrectly"
|
||||
}
|
||||
test "succeeds when deserializing" {
|
||||
let after = JsonConvert.DeserializeObject<Permalink>("\"2023/unit.html\"", opts)
|
||||
Expect.equal after (Permalink "2023/unit.html") "Permalink deserialized incorrectly"
|
||||
}
|
||||
]
|
||||
|
||||
/// Unit tests for the PageIdConverter type
|
||||
let pageIdConverterTests = testList "PageIdConverter" [
|
||||
let opts = JsonSerializerSettings()
|
||||
opts.Converters.Add(PageIdConverter())
|
||||
test "succeeds when serializing" {
|
||||
let after = JsonConvert.SerializeObject(PageId "test-page", opts)
|
||||
Expect.equal after "\"test-page\"" "Page ID serialized incorrectly"
|
||||
}
|
||||
test "succeeds when deserializing" {
|
||||
let after = JsonConvert.DeserializeObject<PageId>("\"page-test\"", opts)
|
||||
Expect.equal after (PageId "page-test") "Page ID deserialized incorrectly"
|
||||
}
|
||||
]
|
||||
|
||||
/// Unit tests for the PodcastMedium type
|
||||
let podcastMediumConverterTests = testList "PodcastMediumConverter" [
|
||||
let opts = JsonSerializerSettings()
|
||||
opts.Converters.Add(PodcastMediumConverter())
|
||||
test "succeeds when serializing" {
|
||||
let after = JsonConvert.SerializeObject(Audiobook, opts)
|
||||
Expect.equal after "\"audiobook\"" "Podcast medium serialized incorrectly"
|
||||
}
|
||||
test "succeeds when deserializing" {
|
||||
let after = JsonConvert.DeserializeObject<PodcastMedium>("\"newsletter\"", opts)
|
||||
Expect.equal after Newsletter "Podcast medium deserialized incorrectly"
|
||||
}
|
||||
]
|
||||
|
||||
/// Unit tests for the PostIdConverter type
|
||||
let postIdConverterTests = testList "PostIdConverter" [
|
||||
let opts = JsonSerializerSettings()
|
||||
opts.Converters.Add(PostIdConverter())
|
||||
test "succeeds when serializing" {
|
||||
let after = JsonConvert.SerializeObject(PostId "test-post", opts)
|
||||
Expect.equal after "\"test-post\"" "Post ID serialized incorrectly"
|
||||
}
|
||||
test "succeeds when deserializing" {
|
||||
let after = JsonConvert.DeserializeObject<PostId>("\"post-test\"", opts)
|
||||
Expect.equal after (PostId "post-test") "Post ID deserialized incorrectly"
|
||||
}
|
||||
]
|
||||
|
||||
/// Unit tests for the TagMapIdConverter type
|
||||
let tagMapIdConverterTests = testList "TagMapIdConverter" [
|
||||
let opts = JsonSerializerSettings()
|
||||
opts.Converters.Add(TagMapIdConverter())
|
||||
test "succeeds when serializing" {
|
||||
let after = JsonConvert.SerializeObject(TagMapId "test-map", opts)
|
||||
Expect.equal after "\"test-map\"" "Tag map ID serialized incorrectly"
|
||||
}
|
||||
test "succeeds when deserializing" {
|
||||
let after = JsonConvert.DeserializeObject<TagMapId>("\"map-test\"", opts)
|
||||
Expect.equal after (TagMapId "map-test") "Tag map ID deserialized incorrectly"
|
||||
}
|
||||
]
|
||||
|
||||
/// Unit tests for the ThemeAssetIdConverter type
|
||||
let themeAssetIdConverterTests = testList "ThemeAssetIdConverter" [
|
||||
let opts = JsonSerializerSettings()
|
||||
opts.Converters.Add(ThemeAssetIdConverter())
|
||||
test "succeeds when serializing" {
|
||||
let after = JsonConvert.SerializeObject(ThemeAssetId (ThemeId "test", "unit.jpg"), opts)
|
||||
Expect.equal after "\"test/unit.jpg\"" "Theme asset ID serialized incorrectly"
|
||||
}
|
||||
test "succeeds when deserializing" {
|
||||
let after = JsonConvert.DeserializeObject<ThemeAssetId>("\"theme/test.png\"", opts)
|
||||
Expect.equal after (ThemeAssetId (ThemeId "theme", "test.png")) "Theme asset ID deserialized incorrectly"
|
||||
}
|
||||
]
|
||||
|
||||
/// Unit tests for the ThemeIdConverter type
|
||||
let themeIdConverterTests = testList "ThemeIdConverter" [
|
||||
let opts = JsonSerializerSettings()
|
||||
opts.Converters.Add(ThemeIdConverter())
|
||||
test "succeeds when serializing" {
|
||||
let after = JsonConvert.SerializeObject(ThemeId "test-theme", opts)
|
||||
Expect.equal after "\"test-theme\"" "Theme ID serialized incorrectly"
|
||||
}
|
||||
test "succeeds when deserializing" {
|
||||
let after = JsonConvert.DeserializeObject<ThemeId>("\"theme-test\"", opts)
|
||||
Expect.equal after (ThemeId "theme-test") "Theme ID deserialized incorrectly"
|
||||
}
|
||||
]
|
||||
|
||||
/// Unit tests for the UploadIdConverter type
|
||||
let uploadIdConverterTests = testList "UploadIdConverter" [
|
||||
let opts = JsonSerializerSettings()
|
||||
opts.Converters.Add(UploadIdConverter())
|
||||
test "succeeds when serializing" {
|
||||
let after = JsonConvert.SerializeObject(UploadId "test-up", opts)
|
||||
Expect.equal after "\"test-up\"" "Upload ID serialized incorrectly"
|
||||
}
|
||||
test "succeeds when deserializing" {
|
||||
let after = JsonConvert.DeserializeObject<UploadId>("\"up-test\"", opts)
|
||||
Expect.equal after (UploadId "up-test") "Upload ID deserialized incorrectly"
|
||||
}
|
||||
]
|
||||
|
||||
/// Unit tests for the WebLogIdConverter type
|
||||
let webLogIdConverterTests = testList "WebLogIdConverter" [
|
||||
let opts = JsonSerializerSettings()
|
||||
opts.Converters.Add(WebLogIdConverter())
|
||||
test "succeeds when serializing" {
|
||||
let after = JsonConvert.SerializeObject(WebLogId "test-web", opts)
|
||||
Expect.equal after "\"test-web\"" "Web log ID serialized incorrectly"
|
||||
}
|
||||
test "succeeds when deserializing" {
|
||||
let after = JsonConvert.DeserializeObject<WebLogId>("\"web-test\"", opts)
|
||||
Expect.equal after (WebLogId "web-test") "Web log ID deserialized incorrectly"
|
||||
}
|
||||
]
|
||||
|
||||
/// Unit tests for the WebLogUserIdConverter type
|
||||
let webLogUserIdConverterTests = testList "WebLogUserIdConverter" [
|
||||
let opts = JsonSerializerSettings()
|
||||
opts.Converters.Add(WebLogUserIdConverter())
|
||||
test "succeeds when serializing" {
|
||||
let after = JsonConvert.SerializeObject(WebLogUserId "test-user", opts)
|
||||
Expect.equal after "\"test-user\"" "Web log user ID serialized incorrectly"
|
||||
}
|
||||
test "succeeds when deserializing" {
|
||||
let after = JsonConvert.DeserializeObject<WebLogUserId>("\"user-test\"", opts)
|
||||
Expect.equal after (WebLogUserId "user-test") "Web log user ID deserialized incorrectly"
|
||||
}
|
||||
]
|
||||
|
||||
open NodaTime.Serialization.JsonNet
|
||||
|
||||
/// Unit tests for the Json.configure function
|
||||
let configureTests = test "Json.configure succeeds" {
|
||||
let has typ (converter: JsonConverter) = converter.GetType() = typ
|
||||
let ser = configure (JsonSerializer.Create())
|
||||
Expect.hasCountOf ser.Converters 1u (has typeof<CategoryIdConverter>) "Category ID converter not found"
|
||||
Expect.hasCountOf ser.Converters 1u (has typeof<CommentIdConverter>) "Comment ID converter not found"
|
||||
Expect.hasCountOf ser.Converters 1u (has typeof<CommentStatusConverter>) "Comment status converter not found"
|
||||
Expect.hasCountOf ser.Converters 1u (has typeof<CustomFeedIdConverter>) "Custom feed ID converter not found"
|
||||
Expect.hasCountOf ser.Converters 1u (has typeof<CustomFeedSourceConverter>) "Custom feed source converter not found"
|
||||
Expect.hasCountOf ser.Converters 1u (has typeof<ExplicitRatingConverter>) "Explicit rating converter not found"
|
||||
Expect.hasCountOf ser.Converters 1u (has typeof<MarkupTextConverter>) "Markup text converter not found"
|
||||
Expect.hasCountOf ser.Converters 1u (has typeof<PermalinkConverter>) "Permalink converter not found"
|
||||
Expect.hasCountOf ser.Converters 1u (has typeof<PageIdConverter>) "Page ID converter not found"
|
||||
Expect.hasCountOf ser.Converters 1u (has typeof<PodcastMediumConverter>) "Podcast medium converter not found"
|
||||
Expect.hasCountOf ser.Converters 1u (has typeof<PostIdConverter>) "Post ID converter not found"
|
||||
Expect.hasCountOf ser.Converters 1u (has typeof<TagMapIdConverter>) "Tag map ID converter not found"
|
||||
Expect.hasCountOf ser.Converters 1u (has typeof<ThemeAssetIdConverter>) "Theme asset ID converter not found"
|
||||
Expect.hasCountOf ser.Converters 1u (has typeof<ThemeIdConverter>) "Theme ID converter not found"
|
||||
Expect.hasCountOf ser.Converters 1u (has typeof<UploadIdConverter>) "Upload ID converter not found"
|
||||
Expect.hasCountOf ser.Converters 1u (has typeof<WebLogIdConverter>) "Web log ID converter not found"
|
||||
Expect.hasCountOf ser.Converters 1u (has typeof<WebLogUserIdConverter>) "Web log user ID converter not found"
|
||||
Expect.hasCountOf ser.Converters 1u (has typeof<CompactUnionJsonConverter>) "F# type converter not found"
|
||||
Expect.hasCountOf ser.Converters 1u (has (NodaConverters.InstantConverter.GetType())) "NodaTime converter not found"
|
||||
Expect.equal ser.NullValueHandling NullValueHandling.Ignore "Null handling set incorrectly"
|
||||
Expect.equal ser.MissingMemberHandling MissingMemberHandling.Ignore "Missing member handling set incorrectly"
|
||||
}
|
||||
|
||||
/// All tests for the Data.Converters file
|
||||
let all = testList "Converters" [
|
||||
categoryIdConverterTests
|
||||
commentIdConverterTests
|
||||
commentStatusConverterTests
|
||||
customFeedIdConverterTests
|
||||
customFeedSourceConverterTests
|
||||
explicitRatingConverterTests
|
||||
markupTextConverterTests
|
||||
permalinkConverterTests
|
||||
pageIdConverterTests
|
||||
podcastMediumConverterTests
|
||||
postIdConverterTests
|
||||
tagMapIdConverterTests
|
||||
themeAssetIdConverterTests
|
||||
themeIdConverterTests
|
||||
uploadIdConverterTests
|
||||
webLogIdConverterTests
|
||||
webLogUserIdConverterTests
|
||||
configureTests
|
||||
]
|
267
src/MyWebLog.Tests/Data/PageDataTests.fs
Normal file
267
src/MyWebLog.Tests/Data/PageDataTests.fs
Normal file
@ -0,0 +1,267 @@
|
||||
/// <summary>
|
||||
/// Integration tests for <see cref="IPageData" /> implementations
|
||||
/// </summary>
|
||||
module PageDataTests
|
||||
|
||||
open System
|
||||
open Expecto
|
||||
open MyWebLog
|
||||
open MyWebLog.Data
|
||||
open NodaTime
|
||||
|
||||
/// The ID of the root web log
|
||||
let private rootId = CategoryDataTests.rootId
|
||||
|
||||
/// The ID of the "A cool page" page
|
||||
let coolPageId = PageId "hgc_BLEZ50SoAWLuPNISvA"
|
||||
|
||||
/// The published and updated time of the "A cool page" page
|
||||
let private coolPagePublished = Instant.FromDateTimeOffset(DateTimeOffset.Parse "2024-01-20T22:14:28Z")
|
||||
|
||||
/// The ID of the "Yet Another Page" page
|
||||
let private otherPageId = PageId "KouRjvSmm0Wz6TMD8xf67A"
|
||||
|
||||
let ``Add succeeds`` (data: IData) = task {
|
||||
let page =
|
||||
{ Id = PageId "added-page"
|
||||
WebLogId = WebLogId "test"
|
||||
AuthorId = WebLogUserId "the-author"
|
||||
Title = "A New Page"
|
||||
Permalink = Permalink "2024/the-page.htm"
|
||||
PublishedOn = Noda.epoch + Duration.FromDays 3
|
||||
UpdatedOn = Noda.epoch + Duration.FromDays 3 + Duration.FromMinutes 2L
|
||||
IsInPageList = true
|
||||
Template = Some "new-page-template"
|
||||
Text = "<h1>A new page</h1>"
|
||||
Metadata = [ { Name = "Meta Item"; Value = "Meta Value" } ]
|
||||
PriorPermalinks = [ Permalink "2024/the-new-page.htm" ]
|
||||
Revisions = [ { AsOf = Noda.epoch + Duration.FromDays 3; Text = Html "<h1>A new page</h1>" } ] }
|
||||
do! data.Page.Add page
|
||||
let! stored = data.Page.FindFullById (PageId "added-page") (WebLogId "test")
|
||||
Expect.isSome stored "The page should have been added"
|
||||
let pg = stored.Value
|
||||
Expect.equal pg.Id page.Id "ID not saved properly"
|
||||
Expect.equal pg.WebLogId page.WebLogId "Web log ID not saved properly"
|
||||
Expect.equal pg.AuthorId page.AuthorId "Author ID not saved properly"
|
||||
Expect.equal pg.Title page.Title "Title not saved properly"
|
||||
Expect.equal pg.Permalink page.Permalink "Permalink not saved properly"
|
||||
Expect.equal pg.PublishedOn page.PublishedOn "Published On not saved properly"
|
||||
Expect.equal pg.UpdatedOn page.UpdatedOn "Updated On not saved properly"
|
||||
Expect.equal pg.IsInPageList page.IsInPageList "Is in page list flag not saved properly"
|
||||
Expect.equal pg.Template page.Template "Template not saved properly"
|
||||
Expect.equal pg.Text page.Text "Text not saved properly"
|
||||
Expect.equal pg.Metadata page.Metadata "Metadata not saved properly"
|
||||
Expect.equal pg.PriorPermalinks page.PriorPermalinks "Prior permalinks not saved properly"
|
||||
Expect.equal pg.Revisions page.Revisions "Revisions not saved properly"
|
||||
}
|
||||
|
||||
let ``All succeeds`` (data: IData) = task {
|
||||
let! pages = data.Page.All rootId
|
||||
Expect.hasLength pages 2 "There should have been 2 pages retrieved"
|
||||
pages |> List.iteri (fun idx pg ->
|
||||
Expect.equal pg.Text "" $"Page {idx} should have had no text"
|
||||
Expect.isEmpty pg.Metadata $"Page {idx} should have had no metadata"
|
||||
Expect.isEmpty pg.Revisions $"Page {idx} should have had no revisions"
|
||||
Expect.isEmpty pg.PriorPermalinks $"Page {idx} should have had no prior permalinks")
|
||||
let! others = data.Page.All (WebLogId "not-there")
|
||||
Expect.isEmpty others "There should not be pages retrieved"
|
||||
}
|
||||
|
||||
let ``CountAll succeeds`` (data: IData) = task {
|
||||
let! pages = data.Page.CountAll rootId
|
||||
Expect.equal pages 2 "There should have been 2 pages counted"
|
||||
}
|
||||
|
||||
let ``CountListed succeeds`` (data: IData) = task {
|
||||
let! pages = data.Page.CountListed rootId
|
||||
Expect.equal pages 1 "There should have been 1 page in the page list"
|
||||
}
|
||||
|
||||
let ``FindById succeeds when a page is found`` (data: IData) = task {
|
||||
let! page = data.Page.FindById coolPageId rootId
|
||||
Expect.isSome page "A page should have been returned"
|
||||
let pg = page.Value
|
||||
Expect.equal pg.Id coolPageId "The wrong page was retrieved"
|
||||
Expect.equal pg.WebLogId rootId "The page's web log did not match the called parameter"
|
||||
Expect.equal pg.AuthorId (WebLogUserId "5EM2rimH9kONpmd2zQkiVA") "Author ID is incorrect"
|
||||
Expect.equal pg.Title "Page Title" "Title is incorrect"
|
||||
Expect.equal pg.Permalink (Permalink "a-cool-page.html") "Permalink is incorrect"
|
||||
Expect.equal pg.PublishedOn coolPagePublished "Published On is incorrect"
|
||||
Expect.equal pg.UpdatedOn coolPagePublished "Updated On is incorrect"
|
||||
Expect.isFalse pg.IsInPageList "Is in page list flag should not have been set"
|
||||
Expect.equal pg.Text "<h1 id=\"a-cool-page\">A Cool Page</h1>\n<p>It really is cool!</p>\n" "Text is incorrect"
|
||||
Expect.equal
|
||||
pg.Metadata [ { Name = "Cool"; Value = "true" }; { Name = "Warm"; Value = "false" } ] "Metadata is incorrect"
|
||||
Expect.isEmpty pg.Revisions "Revisions should not have been retrieved"
|
||||
Expect.isEmpty pg.PriorPermalinks "Prior permalinks should not have been retrieved"
|
||||
}
|
||||
|
||||
let ``FindById succeeds when a page is not found (incorrect weblog)`` (data: IData) = task {
|
||||
let! page = data.Page.FindById coolPageId (WebLogId "wrong")
|
||||
Expect.isNone page "The page should not have been retrieved"
|
||||
}
|
||||
|
||||
let ``FindById succeeds when a page is not found (bad page ID)`` (data: IData) = task {
|
||||
let! page = data.Page.FindById (PageId "missing") rootId
|
||||
Expect.isNone page "The page should not have been retrieved"
|
||||
}
|
||||
|
||||
let ``FindByPermalink succeeds when a page is found`` (data: IData) = task {
|
||||
let! page = data.Page.FindByPermalink (Permalink "a-cool-page.html") rootId
|
||||
Expect.isSome page "A page should have been returned"
|
||||
let pg = page.Value
|
||||
Expect.equal pg.Id coolPageId "The wrong page was retrieved"
|
||||
Expect.isEmpty pg.Revisions "Revisions should not have been retrieved"
|
||||
Expect.isEmpty pg.PriorPermalinks "Prior permalinks should not have been retrieved"
|
||||
}
|
||||
|
||||
let ``FindByPermalink succeeds when a page is not found (incorrect weblog)`` (data: IData) = task {
|
||||
let! page = data.Page.FindByPermalink (Permalink "a-cool-page.html") (WebLogId "wrong")
|
||||
Expect.isNone page "The page should not have been retrieved"
|
||||
}
|
||||
|
||||
let ``FindByPermalink succeeds when a page is not found (no such permalink)`` (data: IData) = task {
|
||||
let! page = data.Page.FindByPermalink (Permalink "1970/no-www-then.html") rootId
|
||||
Expect.isNone page "The page should not have been retrieved"
|
||||
}
|
||||
|
||||
let ``FindCurrentPermalink succeeds when a page is found`` (data: IData) = task {
|
||||
let! link = data.Page.FindCurrentPermalink [ Permalink "a-cool-pg.html"; Permalink "a-cool-pg.html/" ] rootId
|
||||
Expect.isSome link "A permalink should have been returned"
|
||||
Expect.equal link (Some (Permalink "a-cool-page.html")) "The wrong permalink was retrieved"
|
||||
}
|
||||
|
||||
let ``FindCurrentPermalink succeeds when a page is not found`` (data: IData) = task {
|
||||
let! link = data.Page.FindCurrentPermalink [ Permalink "blah/"; Permalink "blah" ] rootId
|
||||
Expect.isNone link "A permalink should not have been returned"
|
||||
}
|
||||
|
||||
let ``FindFullById succeeds when a page is found`` (data: IData) = task {
|
||||
let! page = data.Page.FindFullById coolPageId rootId
|
||||
Expect.isSome page "A page should have been returned"
|
||||
let pg = page.Value
|
||||
Expect.equal pg.Id coolPageId "The wrong page was retrieved"
|
||||
Expect.equal pg.WebLogId rootId "The page's web log did not match the called parameter"
|
||||
Expect.equal
|
||||
pg.Revisions
|
||||
[ { AsOf = coolPagePublished; Text = Markdown "# A Cool Page\n\nIt really is cool!" } ]
|
||||
"Revisions are incorrect"
|
||||
Expect.equal pg.PriorPermalinks [ Permalink "a-cool-pg.html" ] "Prior permalinks are incorrect"
|
||||
}
|
||||
|
||||
let ``FindFullById succeeds when a page is not found`` (data: IData) = task {
|
||||
let! page = data.Page.FindFullById (PageId "not-there") rootId
|
||||
Expect.isNone page "A page should not have been retrieved"
|
||||
}
|
||||
|
||||
let ``FindFullByWebLog succeeds when pages are found`` (data: IData) = task {
|
||||
let! pages = data.Page.FindFullByWebLog rootId
|
||||
Expect.hasLength pages 2 "There should have been 2 pages returned"
|
||||
pages |> List.iter (fun pg ->
|
||||
Expect.contains [ coolPageId; otherPageId ] pg.Id $"Page ID {pg.Id} unexpected"
|
||||
if pg.Id = coolPageId then
|
||||
Expect.isNonEmpty pg.Metadata "Metadata should have been retrieved"
|
||||
Expect.isNonEmpty pg.PriorPermalinks "Prior permalinks should have been retrieved"
|
||||
Expect.isNonEmpty pg.Revisions "Revisions should have been retrieved")
|
||||
}
|
||||
|
||||
let ``FindFullByWebLog succeeds when pages are not found`` (data: IData) = task {
|
||||
let! pages = data.Page.FindFullByWebLog (WebLogId "does-not-exist")
|
||||
Expect.isEmpty pages "No pages should have been retrieved"
|
||||
}
|
||||
|
||||
let ``FindListed succeeds when pages are found`` (data: IData) = task {
|
||||
let! pages = data.Page.FindListed rootId
|
||||
Expect.hasLength pages 1 "There should have been 1 page returned"
|
||||
Expect.equal pages[0].Id otherPageId "An unexpected page was returned"
|
||||
Expect.equal pages[0].Text "" "Text should not have been returned"
|
||||
Expect.isEmpty pages[0].PriorPermalinks "Prior permalinks should not have been retrieved"
|
||||
Expect.isEmpty pages[0].Revisions "Revisions should not have been retrieved"
|
||||
}
|
||||
|
||||
let ``FindListed succeeds when pages are not found`` (data: IData) = task {
|
||||
let! pages = data.Page.FindListed (WebLogId "none")
|
||||
Expect.isEmpty pages "No pages should have been retrieved"
|
||||
}
|
||||
|
||||
let ``FindPageOfPages succeeds when pages are found`` (data: IData) = task {
|
||||
let! pages = data.Page.FindPageOfPages rootId 1
|
||||
Expect.hasLength pages 2 "There should have been 2 page returned"
|
||||
Expect.equal pages[0].Id coolPageId "Pages not sorted correctly"
|
||||
pages |> List.iteri (fun idx pg ->
|
||||
Expect.notEqual pg.Text "" $"Text for page {idx} should have been retrieved"
|
||||
Expect.isEmpty pg.Metadata $"Metadata for page {idx} should not have been retrieved"
|
||||
Expect.isEmpty pg.PriorPermalinks $"Prior permalinks for page {idx} should not have been retrieved"
|
||||
Expect.isEmpty pg.Revisions $"Revisions for page {idx} should not have been retrieved")
|
||||
}
|
||||
|
||||
let ``FindPageOfPages succeeds when pages are not found`` (data: IData) = task {
|
||||
let! pages = data.Page.FindPageOfPages rootId 2
|
||||
Expect.isEmpty pages "No pages should have been retrieved"
|
||||
}
|
||||
|
||||
let ``Update succeeds when the page exists`` (data: IData) = task {
|
||||
let! page = data.Page.FindFullById coolPageId rootId
|
||||
Expect.isSome page "A page should have been returned"
|
||||
do! data.Page.Update
|
||||
{ page.Value with
|
||||
Title = "This Is Neat"
|
||||
Permalink = Permalink "neat-page.html"
|
||||
UpdatedOn = page.Value.PublishedOn + Duration.FromHours 5
|
||||
IsInPageList = true
|
||||
Text = "<p>I have been updated"
|
||||
Metadata = [ List.head page.Value.Metadata ]
|
||||
PriorPermalinks = [ Permalink "a-cool-page.html" ]
|
||||
Revisions =
|
||||
{ AsOf = page.Value.PublishedOn + Duration.FromHours 5; Text = Html "<p>I have been updated" }
|
||||
:: page.Value.Revisions }
|
||||
let! updated = data.Page.FindFullById coolPageId rootId
|
||||
Expect.isSome updated "The updated page should have been returned"
|
||||
let pg = updated.Value
|
||||
Expect.equal pg.Title "This Is Neat" "Title is incorrect"
|
||||
Expect.equal pg.Permalink (Permalink "neat-page.html") "Permalink is incorrect"
|
||||
Expect.equal pg.PublishedOn coolPagePublished "Published On is incorrect"
|
||||
Expect.equal pg.UpdatedOn (coolPagePublished + Duration.FromHours 5) "Updated On is incorrect"
|
||||
Expect.isTrue pg.IsInPageList "Is in page list flag should have been set"
|
||||
Expect.equal pg.Text "<p>I have been updated" "Text is incorrect"
|
||||
Expect.equal pg.Metadata [ { Name = "Cool"; Value = "true" } ] "Metadata is incorrect"
|
||||
Expect.equal pg.PriorPermalinks [ Permalink "a-cool-page.html" ] "Prior permalinks are incorrect"
|
||||
Expect.equal
|
||||
pg.Revisions
|
||||
[ { AsOf = coolPagePublished + Duration.FromHours 5; Text = Html "<p>I have been updated" }
|
||||
{ AsOf = coolPagePublished; Text = Markdown "# A Cool Page\n\nIt really is cool!" } ]
|
||||
"Revisions are incorrect"
|
||||
}
|
||||
|
||||
let ``Update succeeds when the page does not exist`` (data: IData) = task {
|
||||
let pageId = PageId "missing-page"
|
||||
do! data.Page.Update { Page.Empty with Id = pageId; WebLogId = rootId }
|
||||
let! page = data.Page.FindById pageId rootId
|
||||
Expect.isNone page "A page should not have been retrieved"
|
||||
}
|
||||
|
||||
let ``UpdatePriorPermalinks succeeds when the page exists`` (data: IData) = task {
|
||||
let links = [ Permalink "link-1.html"; Permalink "link-1.aspx"; Permalink "link-3.php" ]
|
||||
let! found = data.Page.UpdatePriorPermalinks otherPageId rootId links
|
||||
Expect.isTrue found "The permalinks should have been updated"
|
||||
let! page = data.Page.FindFullById otherPageId rootId
|
||||
Expect.isSome page "The page should have been found"
|
||||
Expect.equal page.Value.PriorPermalinks links "The prior permalinks were not correct"
|
||||
}
|
||||
|
||||
let ``UpdatePriorPermalinks succeeds when the page does not exist`` (data: IData) = task {
|
||||
let! found =
|
||||
data.Page.UpdatePriorPermalinks (PageId "no-page") WebLogId.Empty
|
||||
[ Permalink "link-1.html"; Permalink "link-1.aspx"; Permalink "link-3.php" ]
|
||||
Expect.isFalse found "The permalinks should not have been updated"
|
||||
}
|
||||
|
||||
let ``Delete succeeds when a page is deleted`` (data: IData) = task {
|
||||
let! deleted = data.Page.Delete coolPageId rootId
|
||||
Expect.isTrue deleted "The page should have been deleted"
|
||||
}
|
||||
|
||||
let ``Delete succeeds when a page is not deleted`` (data: IData) = task {
|
||||
let! deleted = data.Page.Delete coolPageId rootId // this was deleted abov |