diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..92a79d7
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/.gitignore b/.gitignore
index 170e429..1f86ad5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -261,7 +261,8 @@ src/MyWebLog/wwwroot/img/daniel-j-summers
src/MyWebLog/wwwroot/img/bit-badger
.ionide
+.vscode
src/MyWebLog/appsettings.Production.json
# SQLite database files
-src/MyWebLog/*.db*
+src/MyWebLog/data/*.db*
diff --git a/build.fs b/build.fs
index e841ab6..9e17e7f 100644
--- a/build.fs
+++ b/build.fs
@@ -33,7 +33,7 @@ let zipTheme (name : string) (_ : TargetParameter) =
|> Zip.zipSpec $"{releasePath}/{name}-theme.zip"
/// Frameworks supported by this build
-let frameworks = [ "net6.0"; "net7.0" ]
+let frameworks = [ "net6.0"; "net7.0"; "net8.0" ]
/// Publish the project for the given runtime ID
let publishFor rid (_ : TargetParameter) =
diff --git a/build.fsproj b/build.fsproj
index 449dd30..5821d28 100644
--- a/build.fsproj
+++ b/build.fsproj
@@ -2,7 +2,7 @@
Exe
- net7.0
+ net8.0
@@ -10,11 +10,11 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/src/.dockerignore b/src/.dockerignore
new file mode 100644
index 0000000..147d38c
--- /dev/null
+++ b/src/.dockerignore
@@ -0,0 +1,4 @@
+**/bin
+**/obj
+**/*.db
+**/appsettings.*.json
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 5529e72..548e37b 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -1,9 +1,9 @@
- net6.0;net7.0
+ net6.0;net7.0;net8.0
embedded
- 2.0.0.0
- 2.0.0.0
- 2.0.0
+ 2.1.0.0
+ 2.1.0.0
+ 2.1.0
diff --git a/src/Dockerfile b/src/Dockerfile
new file mode 100644
index 0000000..89387d5
--- /dev/null
+++ b/src/Dockerfile
@@ -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" ]
diff --git a/src/MyWebLog.Data/Converters.fs b/src/MyWebLog.Data/Converters.fs
index 52a132c..ad14d54 100644
--- a/src/MyWebLog.Data/Converters.fs
+++ b/src/MyWebLog.Data/Converters.fs
@@ -9,116 +9,123 @@ module Json =
open Newtonsoft.Json
- type CategoryIdConverter () =
- inherit JsonConverter ()
- override _.WriteJson (writer : JsonWriter, value : CategoryId, _ : JsonSerializer) =
- writer.WriteValue (CategoryId.toString value)
- override _.ReadJson (reader : JsonReader, _ : Type, _ : CategoryId, _ : bool, _ : JsonSerializer) =
+ type CategoryIdConverter() =
+ inherit JsonConverter()
+ 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 ()
- override _.WriteJson (writer : JsonWriter, value : CommentId, _ : JsonSerializer) =
- writer.WriteValue (CommentId.toString value)
- override _.ReadJson (reader : JsonReader, _ : Type, _ : CommentId, _ : bool, _ : JsonSerializer) =
+ type CommentIdConverter() =
+ inherit JsonConverter()
+ 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 ()
- override _.WriteJson (writer : JsonWriter, value : CustomFeedId, _ : JsonSerializer) =
- writer.WriteValue (CustomFeedId.toString value)
- override _.ReadJson (reader : JsonReader, _ : Type, _ : CustomFeedId, _ : bool, _ : JsonSerializer) =
+ type CommentStatusConverter() =
+ inherit JsonConverter()
+ 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()
+ 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 ()
- 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()
+ 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 ()
- 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()
+ 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 ()
- 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()
+ 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 ()
- override _.WriteJson (writer : JsonWriter, value : Permalink, _ : JsonSerializer) =
- writer.WriteValue (Permalink.toString value)
- override _.ReadJson (reader : JsonReader, _ : Type, _ : Permalink, _ : bool, _ : JsonSerializer) =
+ type PermalinkConverter() =
+ inherit JsonConverter()
+ 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 ()
- override _.WriteJson (writer : JsonWriter, value : PageId, _ : JsonSerializer) =
- writer.WriteValue (PageId.toString value)
- override _.ReadJson (reader : JsonReader, _ : Type, _ : PageId, _ : bool, _ : JsonSerializer) =
+ type PageIdConverter() =
+ inherit JsonConverter()
+ override _.WriteJson(writer: JsonWriter, value: PageId, _: JsonSerializer) =
+ writer.WriteValue(string value)
+ override _.ReadJson(reader: JsonReader, _: Type, _: PageId, _: bool, _: JsonSerializer) =
(string >> PageId) reader.Value
- type PodcastMediumConverter () =
- inherit JsonConverter ()
- override _.WriteJson (writer : JsonWriter, value : PodcastMedium, _ : JsonSerializer) =
- writer.WriteValue (PodcastMedium.toString value)
- override _.ReadJson (reader : JsonReader, _ : Type, _ : PodcastMedium, _ : bool, _ : JsonSerializer) =
- (string >> PodcastMedium.parse) reader.Value
+ type PodcastMediumConverter() =
+ inherit JsonConverter()
+ 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 ()
- override _.WriteJson (writer : JsonWriter, value : PostId, _ : JsonSerializer) =
- writer.WriteValue (PostId.toString value)
- override _.ReadJson (reader : JsonReader, _ : Type, _ : PostId, _ : bool, _ : JsonSerializer) =
+ type PostIdConverter() =
+ inherit JsonConverter()
+ 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 ()
- override _.WriteJson (writer : JsonWriter, value : TagMapId, _ : JsonSerializer) =
- writer.WriteValue (TagMapId.toString value)
- override _.ReadJson (reader : JsonReader, _ : Type, _ : TagMapId, _ : bool, _ : JsonSerializer) =
+ type TagMapIdConverter() =
+ inherit JsonConverter()
+ 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 ()
- 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()
+ 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 ()
- override _.WriteJson (writer : JsonWriter, value : ThemeId, _ : JsonSerializer) =
- writer.WriteValue (ThemeId.toString value)
- override _.ReadJson (reader : JsonReader, _ : Type, _ : ThemeId, _ : bool, _ : JsonSerializer) =
+ type ThemeIdConverter() =
+ inherit JsonConverter()
+ override _.WriteJson(writer: JsonWriter, value: ThemeId, _: JsonSerializer) =
+ writer.WriteValue(string value)
+ override _.ReadJson(reader: JsonReader, _: Type, _: ThemeId, _: bool, _: JsonSerializer) =
(string >> ThemeId) reader.Value
- type UploadIdConverter () =
- inherit JsonConverter ()
- override _.WriteJson (writer : JsonWriter, value : UploadId, _ : JsonSerializer) =
- writer.WriteValue (UploadId.toString value)
- override _.ReadJson (reader : JsonReader, _ : Type, _ : UploadId, _ : bool, _ : JsonSerializer) =
+ type UploadIdConverter() =
+ inherit JsonConverter()
+ 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 ()
- override _.WriteJson (writer : JsonWriter, value : WebLogId, _ : JsonSerializer) =
- writer.WriteValue (WebLogId.toString value)
- override _.ReadJson (reader : JsonReader, _ : Type, _ : WebLogId, _ : bool, _ : JsonSerializer) =
+ type WebLogIdConverter() =
+ inherit JsonConverter()
+ 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 ()
- 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
@@ -128,27 +135,28 @@ module Json =
/// Configure a serializer to use these converters
let configure (ser : JsonSerializer) =
// Our converters
- [ CategoryIdConverter () :> JsonConverter
- CommentIdConverter ()
- CustomFeedIdConverter ()
- CustomFeedSourceConverter ()
- ExplicitRatingConverter ()
- MarkupTextConverter ()
- PermalinkConverter ()
- PageIdConverter ()
- PodcastMediumConverter ()
- PostIdConverter ()
- TagMapIdConverter ()
- ThemeAssetIdConverter ()
- ThemeIdConverter ()
- UploadIdConverter ()
- WebLogIdConverter ()
- WebLogUserIdConverter ()
- ] |> List.iter ser.Converters.Add
+ [ 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.Converters.Add(CompactUnionJsonConverter())
ser.NullValueHandling <- NullValueHandling.Ignore
ser.MissingMemberHandling <- MissingMemberHandling.Ignore
ser
diff --git a/src/MyWebLog.Data/Interfaces.fs b/src/MyWebLog.Data/Interfaces.fs
index f064cc4..6fd9207 100644
--- a/src/MyWebLog.Data/Interfaces.fs
+++ b/src/MyWebLog.Data/Interfaces.fs
@@ -7,6 +7,7 @@ open Newtonsoft.Json
open NodaTime
/// The result of a category deletion attempt
+[]
type CategoryDeleteResult =
/// The category was deleted successfully
| CategoryDeleted
@@ -32,7 +33,7 @@ type ICategoryData =
abstract member Delete : CategoryId -> WebLogId -> Task
/// Find all categories for a web log, sorted alphabetically and grouped by hierarchy
- abstract member FindAllForView : WebLogId -> Task
+ abstract member FindAllForView : WebLogId -> Task
/// Find a category by its ID
abstract member FindById : CategoryId -> WebLogId -> Task
@@ -53,7 +54,7 @@ type IPageData =
/// Add a page
abstract member Add : Page -> Task
- /// Get all pages for the web log (excluding meta items, text, revisions, and prior permalinks)
+ /// Get all pages for the web log (excluding text, metadata, revisions, and prior permalinks)
abstract member All : WebLogId -> Task
/// Count all pages for the given web log
@@ -84,7 +85,7 @@ type IPageData =
abstract member FindListed : WebLogId -> Task
/// Find a page of pages (displayed in admin section) (excluding meta items, revisions and prior permalinks)
- abstract member FindPageOfPages : WebLogId -> pageNbr : int -> Task
+ abstract member FindPageOfPages : WebLogId -> pageNbr: int -> Task
/// Restore pages from a backup
abstract member Restore : Page list -> Task
@@ -125,20 +126,20 @@ type IPostData =
/// 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
+ WebLogId -> CategoryId list -> pageNbr: int -> postsPerPage: int -> Task
- /// Find posts to be displayed on an admin page (excluding revisions and prior permalinks)
- abstract member FindPageOfPosts : WebLogId -> pageNbr : int -> postsPerPage : int -> Task
+ /// Find posts to be displayed on an admin page (excluding text, revisions, and prior permalinks)
+ abstract member FindPageOfPosts : WebLogId -> pageNbr: int -> postsPerPage: int -> Task
/// Find posts to be displayed on a page (excluding revisions and prior permalinks)
- abstract member FindPageOfPublishedPosts : WebLogId -> pageNbr : int -> postsPerPage : int -> Task
+ abstract member FindPageOfPublishedPosts : WebLogId -> pageNbr: int -> postsPerPage: int -> Task
/// 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
+ WebLogId -> tag : string -> pageNbr: int -> postsPerPage: int -> Task
/// Find the next older and newer post for the given published date/time (excluding revisions and prior permalinks)
- abstract member FindSurroundingPosts : WebLogId -> publishedOn : Instant -> Task
+ abstract member FindSurroundingPosts : WebLogId -> publishedOn: Instant -> Task
/// Restore posts from a backup
abstract member Restore : Post list -> Task
@@ -259,6 +260,9 @@ type IWebLogData =
/// Find a web log by its ID
abstract member FindById : WebLogId -> Task
+ /// Update redirect rules for a web log
+ abstract member UpdateRedirectRules : WebLog -> Task
+
/// Update RSS options for a web log
abstract member UpdateRssOptions : WebLog -> Task
diff --git a/src/MyWebLog.Data/MyWebLog.Data.fsproj b/src/MyWebLog.Data/MyWebLog.Data.fsproj
index 1f1cf76..a3d9a77 100644
--- a/src/MyWebLog.Data/MyWebLog.Data.fsproj
+++ b/src/MyWebLog.Data/MyWebLog.Data.fsproj
@@ -5,24 +5,25 @@
-
-
-
-
+
+
+
+
+
-
-
-
+
+
+
-
+
-
+
@@ -42,7 +43,13 @@
-
+
+
+
+
+
+ <_Parameter1>MyWebLog.Tests
+
diff --git a/src/MyWebLog.Data/Postgres/PostgresCache.fs b/src/MyWebLog.Data/Postgres/PostgresCache.fs
index a7b0280..2a8b9c8 100644
--- a/src/MyWebLog.Data/Postgres/PostgresCache.fs
+++ b/src/MyWebLog.Data/Postgres/PostgresCache.fs
@@ -2,38 +2,37 @@ namespace MyWebLog.Data.Postgres
open System.Threading
open System.Threading.Tasks
-open BitBadger.Npgsql.FSharp.Documents
+open BitBadger.Documents.Postgres
open Microsoft.Extensions.Caching.Distributed
open NodaTime
-open Npgsql.FSharp
/// Helper types and functions for the cache
[]
module private Helpers =
/// The cache entry
- type Entry =
- { /// The ID of the cache entry
- Id : string
-
- /// The value to be cached
- Payload : byte[]
-
- /// 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
- }
+ 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)
+ let sync<'T> (it: Task<'T>) = it |> (Async.AwaitTask >> Async.RunSynchronously)
/// Get the current instant
- let getNow () = SystemClock.Instance.GetCurrentInstant ()
+ let getNow () = SystemClock.Instance.GetCurrentInstant()
/// Create a parameter for the expire-at time
let expireParam =
@@ -49,9 +48,11 @@ type DistributedCache () =
task {
let! exists =
Custom.scalar
- $"SELECT EXISTS
- (SELECT 1 FROM pg_tables WHERE schemaname = 'public' AND tablename = 'session')
- AS {existsName}" [] Map.toExists
+ "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 (
@@ -69,13 +70,15 @@ type DistributedCache () =
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 "expire_at"
- SlidingExpiration = row.fieldValueOrNone "sliding_expiration"
- AbsoluteExpiration = row.fieldValueOrNone "absolute_expiration" })
+ Custom.single
+ "SELECT * FROM session WHERE id = @id"
+ [ idParam ]
+ (fun row ->
+ { Id = row.string "id"
+ Payload = row.bytea "payload"
+ ExpireAt = row.fieldValue "expire_at"
+ SlidingExpiration = row.fieldValueOrNone "sliding_expiration"
+ AbsoluteExpiration = row.fieldValueOrNone "absolute_expiration" })
match tryEntry with
| Some entry ->
let now = getNow ()
@@ -88,8 +91,9 @@ type DistributedCache () =
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 ]
+ 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
@@ -101,17 +105,17 @@ type DistributedCache () =
/// Purge expired entries every 30 minutes
let purge () = backgroundTask {
let now = getNow ()
- if lastPurge.Plus (Duration.FromMinutes 30L) < now then
+ 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 =
- Delete.byId "session" key
+ Custom.nonQuery "DELETE FROM session WHERE id = @id" [ "@id", Sql.string key ]
/// Save an entry
- let saveEntry (opts : DistributedCacheEntryOptions) key payload =
+ let saveEntry (opts: DistributedCacheEntryOptions) key payload =
let now = getNow ()
let expireAt, slideExp, absExp =
if opts.SlidingExpiration.HasValue then
@@ -121,7 +125,7 @@ type DistributedCache () =
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)
+ let exp = now.Plus(Duration.FromTimeSpan opts.AbsoluteExpirationRelativeToNow.Value)
exp, None, Some exp
else
// Default to 1 hour sliding expiration
@@ -146,7 +150,7 @@ type DistributedCache () =
// ~~~ IMPLEMENTATION FUNCTIONS ~~~
/// Retrieve the data for a cache entry
- let get key (_ : CancellationToken) = backgroundTask {
+ let get key (_: CancellationToken) = backgroundTask {
match! getEntry key with
| Some entry ->
do! purge ()
@@ -155,29 +159,29 @@ type DistributedCache () =
}
/// Refresh an entry
- let refresh key (cancelToken : CancellationToken) = backgroundTask {
+ let refresh key (cancelToken: CancellationToken) = backgroundTask {
let! _ = get key cancelToken
()
}
/// Remove an entry
- let remove key (_ : CancellationToken) = backgroundTask {
+ let remove key (_: CancellationToken) = backgroundTask {
do! removeEntry key
do! purge ()
}
/// Set an entry
- let set key value options (_ : CancellationToken) = backgroundTask {
+ 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 _.GetAsync(key, token) = get key token
member _.Refresh key = refresh key CancellationToken.None |> sync
- member _.RefreshAsync (key, token) = refresh key token
+ 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
+ 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
diff --git a/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs b/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs
index 244faed..6909684 100644
--- a/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs
+++ b/src/MyWebLog.Data/Postgres/PostgresCategoryData.fs
@@ -1,13 +1,14 @@
namespace MyWebLog.Data.Postgres
-open BitBadger.Npgsql.FSharp.Documents
+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) =
+type PostgresCategoryData(log: ILogger) =
/// Count all categories for the given web log
let countAll webLogId =
@@ -17,14 +18,20 @@ type PostgresCategoryData (log : ILogger) =
/// Count all top-level categories for the given web log
let countTopLevel webLogId =
log.LogTrace "Category.countTopLevel"
- Count.byContains Table.Category {| webLogDoc webLogId with ParentId = None |}
+ 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
+ Custom.list
+ $"{selectWithCriteria Table.Category} ORDER BY LOWER(data ->> '{nameof Category.Empty.Name}')"
+ [ webLogContains webLogId ]
+ fromData
let ordered = Utils.orderByHierarchy cats None None []
let counts =
ordered
@@ -33,20 +40,18 @@ type PostgresCategoryData (log : ILogger) =
let catIdSql, catIdParams =
ordered
|> Seq.filter (fun cat -> cat.ParentNames |> Array.contains it.Name)
- |> Seq.map (fun cat -> cat.Id)
+ |> Seq.map _.Id
|> Seq.append (Seq.singleton it.Id)
|> List.ofSeq
- |> arrayContains (nameof Post.empty.CategoryIds) id
+ |> arrayContains (nameof Post.Empty.CategoryIds) id
let postCount =
Custom.scalar
- $"""SELECT COUNT(DISTINCT id) AS {countName}
+ $"""SELECT COUNT(DISTINCT data ->> '{nameof Post.Empty.Id}') AS it
FROM {Table.Post}
WHERE {Query.whereDataContains "@criteria"}
AND {catIdSql}"""
- [ "@criteria",
- Query.jsonbDocParam {| webLogDoc webLogId with Status = PostStatus.toString Published |}
- catIdParams
- ] Map.toCount
+ [ jsonParam "@criteria" {| webLogDoc webLogId with Status = Published |}; catIdParams ]
+ toCount
|> Async.AwaitTask
|> Async.RunSynchronously
it.Id, postCount)
@@ -58,71 +63,72 @@ type PostgresCategoryData (log : ILogger) =
PostCount = counts
|> List.tryFind (fun c -> fst c = cat.Id)
|> Option.map snd
- |> Option.defaultValue 0
- })
+ |> 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 Table.Category catId CategoryId.toString webLogId
+ Document.findByIdAndWebLog Table.Category catId webLogId
/// Find all categories for the given web log
let findByWebLog webLogId =
log.LogTrace "Category.findByWebLog"
Document.findByWebLog Table.Category webLogId
- /// Create parameters for a category insert / update
- let catParameters (cat : Category) =
- Query.docParameters (CategoryId.toString cat.Id) cat
-
/// 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 Table.Category {| ParentId = CategoryId.toString catId |}
+ let! children = Find.byContains 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 [
- Query.Update.partialById Table.Category,
- children |> List.map (fun child -> [
- "@id", Sql.string (CategoryId.toString child.Id)
- "@data", Query.jsonbDocParam {| ParentId = cat.ParentId |}
- ])
- ]
+ |> 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"
- [ "@id", Query.jsonbDocParam [| CategoryId.toString catId |] ] fromData
+ Custom.list
+ $"SELECT data FROM {Table.Post} WHERE data -> '{nameof Post.Empty.CategoryIds}' @> @id"
+ [ jsonParam "@id" [| string catId |] ]
+ fromData
if not (List.isEmpty posts) then
let! _ =
Configuration.dataSource ()
|> Sql.fromDataSource
- |> Sql.executeTransactionAsync [
- Query.Update.partialById Table.Post,
- posts |> List.map (fun post -> [
- "@id", Sql.string (PostId.toString post.Id)
- "@data", Query.jsonbDocParam
- {| CategoryIds = post.CategoryIds |> List.filter (fun cat -> cat <> catId) |}
- ])
- ]
+ |> 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 (CategoryId.toString catId)
+ do! Delete.byId Table.Category catId
return if hasChildren then ReassignedChildCategories else CategoryDeleted
| None -> return CategoryNotFound
}
/// Save a category
- let save (cat : Category) = backgroundTask {
+ let save (cat: Category) = backgroundTask {
log.LogTrace "Category.save"
- do! save Table.Category (CategoryId.toString cat.Id) cat
+ do! save Table.Category cat
}
/// Restore categories from a backup
@@ -132,7 +138,7 @@ type PostgresCategoryData (log : ILogger) =
Configuration.dataSource ()
|> Sql.fromDataSource
|> Sql.executeTransactionAsync [
- Query.insert Table.Category, cats |> List.map catParameters
+ Query.insert Table.Category, cats |> List.map (fun c -> [ jsonParam "@data" c ])
]
()
}
diff --git a/src/MyWebLog.Data/Postgres/PostgresHelpers.fs b/src/MyWebLog.Data/Postgres/PostgresHelpers.fs
index 765e669..b9e8bff 100644
--- a/src/MyWebLog.Data/Postgres/PostgresHelpers.fs
+++ b/src/MyWebLog.Data/Postgres/PostgresHelpers.fs
@@ -61,7 +61,8 @@ module Table =
open System
open System.Threading.Tasks
-open BitBadger.Npgsql.FSharp.Documents
+open BitBadger.Documents
+open BitBadger.Documents.Postgres
open MyWebLog
open MyWebLog.Data
open NodaTime
@@ -69,29 +70,23 @@ open Npgsql
open Npgsql.FSharp
/// Create a SQL parameter for the web log ID
-let webLogIdParam webLogId =
- "@webLogId", Sql.string (WebLogId.toString webLogId)
+let webLogIdParam (webLogId: WebLogId) =
+ "@webLogId", Sql.string (string webLogId)
/// Create an anonymous record with the given web log ID
-let webLogDoc (webLogId : WebLogId) =
+let webLogDoc (webLogId: WebLogId) =
{| WebLogId = webLogId |}
/// Create a parameter for a web log document-contains query
let webLogContains webLogId =
- "@criteria", Query.jsonbDocParam (webLogDoc webLogId)
-
-/// The name of the field to select to be able to use Map.toCount
-let countName = "the_count"
-
-/// The name of the field to select to be able to use Map.toExists
-let existsName = "does_exist"
+ 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 (valueFunc: 'T -> string) (items : 'T list) =
+let inClause<'T> colNameAndPrefix paramName (items: 'T list) =
if List.isEmpty items then "", []
else
let mutable idx = 0
@@ -99,114 +94,109 @@ let inClause<'T> colNameAndPrefix paramName (valueFunc: 'T -> string) (items : '
|> List.skip 1
|> List.fold (fun (itemS, itemP) it ->
idx <- idx + 1
- $"{itemS}, @%s{paramName}{idx}", ($"@%s{paramName}{idx}", Sql.string (valueFunc it)) :: itemP)
+ $"{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 (valueFunc 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) =
+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 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))
+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)
+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 =
- /// Get a count from a row
- let toCount (row : RowReader) =
- row.int countName
-
- /// Get a true/false value as to whether an item exists
- let toExists (row : RowReader) =
- row.bool existsName
-
/// Create a permalink from the current row
- let toPermalink (row : RowReader) =
+ let toPermalink (row: RowReader) =
Permalink (row.string "permalink")
/// Create a revision from the current row
- let toRevision (row : RowReader) : Revision =
- { AsOf = row.fieldValue "as_of"
- Text = row.string "revision_text" |> MarkupText.parse
- }
+ let toRevision (row: RowReader) : Revision =
+ { AsOf = row.fieldValue "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 "updated_on"
- Data = if includeData then row.bytea "data" else [||]
- }
+ let toThemeAsset includeData (row: RowReader) : ThemeAsset =
+ { Id = ThemeAssetId (ThemeId (row.string "theme_id"), row.string "path")
+ UpdatedOn = row.fieldValue "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 "updated_on"
- Data = if includeData then row.bytea "data" else [||]
- }
+ 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 "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) (keyFunc : 'TKey -> string) webLogId =
+ let existsByWebLog<'TKey> table (key: 'TKey) webLogId =
Custom.scalar
- $""" SELECT EXISTS (
- SELECT 1 FROM %s{table} WHERE id = @id AND {Query.whereDataContains "@criteria"}
- ) AS {existsName}"""
- [ "@id", Sql.string (keyFunc key); webLogContains webLogId ] Map.toExists
+ $"""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) (keyFunc : 'TKey -> string) webLogId =
- Custom.single $"""{Query.selectFromTable table} WHERE id = @id AND {Query.whereDataContains "@criteria"}"""
- [ "@id", Sql.string (keyFunc key); webLogContains webLogId ] fromData<'TDoc>
+ 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 a document by its ID for the given web log
+ /// 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) (keyFunc : 'TKey -> string) =
- Custom.list $"SELECT as_of, revision_text FROM %s{revTable} WHERE %s{entityTable}_id = @id ORDER BY as_of DESC"
- [ "@id", Sql.string (keyFunc key) ] Map.toRevision
+ 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 =
+ let findByWebLog<'TKey> revTable entityTable (keyFunc: string -> 'TKey) webLogId =
Custom.list
$"""SELECT pr.*
FROM %s{revTable} pr
- INNER JOIN %s{entityTable} p ON p.id = pr.{entityTable}_id
+ 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)
+ [ webLogContains webLogId ]
+ (fun row -> keyFunc (row.string $"{entityTable}_id"), Map.toRevision row)
/// Parameters for a revision INSERT statement
- let revParams<'TKey> (key : 'TKey) (keyFunc : 'TKey -> string) rev = [
+ let revParams<'TKey> (key: 'TKey) rev = [
typedParam "asOf" rev.AsOf
- "@id", Sql.string (keyFunc key)
- "@text", Sql.string (MarkupText.toString rev.Text)
+ "@id", Sql.string (string key)
+ "@text", Sql.string (string rev.Text)
]
/// The SQL statement to insert a revision
@@ -214,23 +204,20 @@ module Revisions =
$"INSERT INTO %s{table} VALUES (@id, @asOf, @text)"
/// Update a page's revisions
- let update<'TKey> revTable entityTable (key : 'TKey) (keyFunc : 'TKey -> string) oldRevs newRevs = backgroundTask {
+ 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
+ |> 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 (keyFunc key)
- typedParam "asOf" it.AsOf
- ])
- if not (List.isEmpty toAdd) then
- insertSql revTable, toAdd |> List.map (revParams key keyFunc)
- ]
+ |> 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) ]
()
}
-
diff --git a/src/MyWebLog.Data/Postgres/PostgresPageData.fs b/src/MyWebLog.Data/Postgres/PostgresPageData.fs
index faa4c79..3c6e023 100644
--- a/src/MyWebLog.Data/Postgres/PostgresPageData.fs
+++ b/src/MyWebLog.Data/Postgres/PostgresPageData.fs
@@ -1,44 +1,55 @@
namespace MyWebLog.Data.Postgres
-open BitBadger.Npgsql.FSharp.Documents
+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) =
+/// PostgreSQL myWebLog page data implementation
+type PostgresPageData(log: ILogger) =
// SUPPORT FUNCTIONS
/// Append revisions to a page
- let appendPageRevisions (page : Page) = backgroundTask {
+ let appendPageRevisions (page: Page) = backgroundTask {
log.LogTrace "Page.appendPageRevisions"
- let! revisions = Revisions.findByEntityId Table.PageRevision Table.Page page.Id PageId.toString
+ 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) =
+ let pageWithoutText (row: RowReader) =
{ fromData row with Text = "" }
/// Update a page's revisions
- let updatePageRevisions pageId oldRevs newRevs =
+ let updatePageRevisions (pageId: PageId) oldRevs newRevs =
log.LogTrace "Page.updatePageRevisions"
- Revisions.update Table.PageRevision Table.Page pageId PageId.toString oldRevs newRevs
+ Revisions.update Table.PageRevision Table.Page pageId oldRevs newRevs
/// Does the given page exist?
- let pageExists pageId webLogId =
+ let pageExists (pageId: PageId) webLogId =
log.LogTrace "Page.pageExists"
- Document.existsByWebLog Table.Page pageId PageId.toString webLogId
+ Document.existsByWebLog Table.Page pageId webLogId
// IMPLEMENTATION FUNCTIONS
- /// Get all pages for a web log (without text or revisions)
+ /// 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 ] fromData
+ Custom.list
+ $"{selectWithCriteria Table.Page} ORDER BY LOWER(data ->> '{nameof Page.Empty.Title}')"
+ [ webLogContains webLogId ]
+ (fun row -> { fromData row with Text = ""; Metadata = []; PriorPermalinks = [] })
/// Count all pages for the given web log
let countAll webLogId =
@@ -50,50 +61,61 @@ type PostgresPageData (log : ILogger) =
log.LogTrace "Page.countListed"
Count.byContains Table.Page {| webLogDoc webLogId with IsInPageList = true |}
- /// Find a page by its ID (without revisions)
- let findById pageId webLogId =
+ /// Find a page by its ID (without revisions or prior permalinks)
+ let findById pageId webLogId = backgroundTask {
log.LogTrace "Page.findById"
- Document.findByIdAndWebLog Table.Page pageId PageId.toString webLogId
+ match! Document.findByIdAndWebLog 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! findById pageId webLogId with
+ match! Document.findByIdAndWebLog 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! Delete.byId Table.Page (PageId.toString pageId)
+ 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 webLogId =
+ let findByPermalink (permalink: Permalink) webLogId = backgroundTask {
log.LogTrace "Page.findByPermalink"
- Find.byContains Table.Page {| webLogDoc webLogId with Permalink = Permalink.toString permalink |}
- |> tryHead
+ let! page =
+ Find.byContains 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 webLogId = backgroundTask {
+ 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) Permalink.toString permalinks
+ let linkSql, linkParam = arrayContains (nameof Page.Empty.PriorPermalinks) string permalinks
return!
Custom.single
- $"""SELECT data ->> '{nameof Page.empty.Permalink}' AS permalink
+ $"""SELECT data ->> '{nameof Page.Empty.Permalink}' AS permalink
FROM page
WHERE {Query.whereDataContains "@criteria"}
- AND {linkSql}""" [ webLogContains webLogId; linkParam ] Map.toPermalink
+ AND {linkSql}"""
+ [ webLogContains webLogId; linkParam ]
+ Map.toPermalink
}
/// Get all complete pages for the given web log
@@ -110,58 +132,59 @@ type PostgresPageData (log : ILogger) =
/// 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}')"
- [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with IsInPageList = true |} ]
- pageWithoutText
+ 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}')
+ ORDER BY LOWER(data->>'{nameof Page.Empty.Title}')
LIMIT @pageSize OFFSET @toSkip"
[ webLogContains webLogId; "@pageSize", Sql.int 26; "@toSkip", Sql.int ((pageNbr - 1) * 25) ]
- fromData
+ (fun row -> { fromData row with Metadata = []; PriorPermalinks = [] })
/// Restore pages from a backup
- let restore (pages : Page list) = backgroundTask {
+ 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 -> Query.docParameters (PageId.toString page.Id) { page with Revisions = [] })
- Revisions.insertSql Table.PageRevision,
- revisions |> List.map (fun (pageId, rev) -> Revisions.revParams pageId PageId.toString rev)
- ]
+ |> 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) ]
()
}
- /// Save a page
- let save (page : Page) = backgroundTask {
- log.LogTrace "Page.save"
- let! oldPage = findFullById page.Id page.WebLogId
- do! save Table.Page (PageId.toString page.Id) { page with Revisions = [] }
- do! updatePageRevisions page.Id (match oldPage with Some p -> p.Revisions | None -> []) page.Revisions
+ /// 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 = backgroundTask {
+ let updatePriorPermalinks pageId webLogId (permalinks: Permalink list) = backgroundTask {
log.LogTrace "Page.updatePriorPermalinks"
match! pageExists pageId webLogId with
| true ->
- do! Update.partialById Table.Page (PageId.toString pageId) {| PriorPermalinks = permalinks |}
+ do! Patch.byId Table.Page pageId {| PriorPermalinks = permalinks |}
return true
| false -> return false
}
interface IPageData with
- member _.Add page = save page
+ member _.Add page = add page
member _.All webLogId = all webLogId
member _.CountAll webLogId = countAll webLogId
member _.CountListed webLogId = countListed webLogId
@@ -174,5 +197,5 @@ type PostgresPageData (log : ILogger) =
member _.FindListed webLogId = findListed webLogId
member _.FindPageOfPages webLogId pageNbr = findPageOfPages webLogId pageNbr
member _.Restore pages = restore pages
- member _.Update page = save page
+ member _.Update page = update page
member _.UpdatePriorPermalinks pageId webLogId permalinks = updatePriorPermalinks pageId webLogId permalinks
diff --git a/src/MyWebLog.Data/Postgres/PostgresPostData.fs b/src/MyWebLog.Data/Postgres/PostgresPostData.fs
index d3791de..7724ca0 100644
--- a/src/MyWebLog.Data/Postgres/PostgresPostData.fs
+++ b/src/MyWebLog.Data/Postgres/PostgresPostData.fs
@@ -1,62 +1,77 @@
namespace MyWebLog.Data.Postgres
-open BitBadger.Npgsql.FSharp.Documents
+open BitBadger.Documents
+open BitBadger.Documents.Postgres
open Microsoft.Extensions.Logging
open MyWebLog
open MyWebLog.Data
-open NodaTime.Text
+open NodaTime
open Npgsql.FSharp
-/// PostgreSQL myWebLog post data implementation
-type PostgresPostData (log : ILogger) =
+/// PostgreSQL myWebLog post data implementation
+type PostgresPostData(log: ILogger) =
// SUPPORT FUNCTIONS
/// Append revisions to a post
- let appendPostRevisions (post : Post) = backgroundTask {
+ let appendPostRevisions (post: Post) = backgroundTask {
log.LogTrace "Post.appendPostRevisions"
- let! revisions = Revisions.findByEntityId Table.PostRevision Table.Post post.Id PostId.toString
+ 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 row with PriorPermalinks = [] }
+
/// Return a post with no revisions, prior permalinks, or text
let postWithoutText row =
- { fromData row with Text = "" }
+ { postWithoutLinks row with Text = "" }
/// Update a post's revisions
- let updatePostRevisions postId oldRevs newRevs =
+ let updatePostRevisions (postId: PostId) oldRevs newRevs =
log.LogTrace "Post.updatePostRevisions"
- Revisions.update Table.PostRevision Table.Post postId PostId.toString oldRevs newRevs
+ Revisions.update Table.PostRevision Table.Post postId oldRevs newRevs
/// Does the given post exist?
- let postExists postId webLogId =
+ let postExists (postId: PostId) webLogId =
log.LogTrace "Post.postExists"
- Document.existsByWebLog Table.Post postId PostId.toString webLogId
+ 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 webLogId =
+ let countByStatus (status: PostStatus) webLogId =
log.LogTrace "Post.countByStatus"
- Count.byContains Table.Post {| webLogDoc webLogId with Status = PostStatus.toString status |}
+ 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 =
+ let findById postId webLogId = backgroundTask {
log.LogTrace "Post.findById"
- Document.findByIdAndWebLog Table.Post postId PostId.toString webLogId
+ match! Document.findByIdAndWebLog 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 and prior permalinks)
- let findByPermalink permalink webLogId =
+ /// 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)
- [ "@criteria",
- Query.jsonbDocParam {| webLogDoc webLogId with Permalink = Permalink.toString permalink |}
- ] fromData
+ 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! findById postId webLogId with
+ match! Document.findByIdAndWebLog Table.Post postId webLogId with
| Some post ->
let! withRevisions = appendPostRevisions post
return Some withRevisions
@@ -68,28 +83,29 @@ type PostgresPostData (log : ILogger) =
log.LogTrace "Post.delete"
match! postExists postId webLogId with
| true ->
- let theId = PostId.toString postId
do! Custom.nonQuery
- $"""DELETE FROM {Table.PostComment} WHERE {Query.whereDataContains "@criteria"};
- DELETE FROM {Table.Post} WHERE id = @id"""
- [ "@id", Sql.string theId; "@criteria", Query.jsonbDocParam {| PostId = theId |} ]
+ $"""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 webLogId = backgroundTask {
+ 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) Permalink.toString permalinks
+ let linkSql, linkParam = arrayContains (nameof Post.Empty.PriorPermalinks) string permalinks
return!
Custom.single
- $"""SELECT data ->> '{nameof Post.empty.Permalink}' AS permalink
+ $"""SELECT data ->> '{nameof Post.Empty.Permalink}' AS permalink
FROM {Table.Post}
WHERE {Query.whereDataContains "@criteria"}
- AND {linkSql}""" [ webLogContains webLogId; linkParam ] Map.toPermalink
+ AND {linkSql}"""
+ [ webLogContains webLogId; linkParam ]
+ Map.toPermalink
}
/// Get all complete posts for the given web log
@@ -104,79 +120,73 @@ type PostgresPostData (log : ILogger) =
}
/// Get a page of categorized posts for the given web log (excludes revisions)
- let findPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage =
+ let findPageOfCategorizedPosts webLogId (categoryIds: CategoryId list) pageNbr postsPerPage =
log.LogTrace "Post.findPageOfCategorizedPosts"
- let catSql, catParam = arrayContains (nameof Post.empty.CategoryIds) CategoryId.toString categoryIds
+ 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
+ ORDER BY data ->> '{nameof Post.Empty.PublishedOn}' DESC
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
- [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = PostStatus.toString Published |}
- catParam
- ] fromData
+ [ 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}'
+ 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
+ [ 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
+ ORDER BY data ->> '{nameof Post.Empty.PublishedOn}' DESC
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
- [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = PostStatus.toString Published |} ]
- fromData
+ [ 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 =
+ 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
+ AND data['{nameof Post.Empty.Tags}'] @> @tag
+ ORDER BY data ->> '{nameof Post.Empty.PublishedOn}' DESC
LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
- [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = PostStatus.toString Published |}
- "@tag", Query.jsonbDocParam [| tag |]
- ] fromData
+ [ 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 = backgroundTask {
+ let findSurroundingPosts webLogId (publishedOn: Instant) = backgroundTask {
log.LogTrace "Post.findSurroundingPosts"
- let queryParams () = [
- "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Status = PostStatus.toString Published |}
- "@publishedOn", Sql.string ((InstantPattern.General.Format publishedOn).Substring (0, 19))
- ]
- let pubField = nameof Post.empty.PublishedOn
- let! older =
- Custom.list
- $"{selectWithCriteria Table.Post}
- AND SUBSTR(data ->> '{pubField}', 1, 19) < @publishedOn
- ORDER BY data ->> '{pubField}' DESC
- LIMIT 1" (queryParams ()) fromData
- let! newer =
- Custom.list
- $"{selectWithCriteria Table.Post}
- AND SUBSTR(data ->> '{pubField}', 1, 19) > @publishedOn
- ORDER BY data ->> '{pubField}'
- LIMIT 1" (queryParams ()) fromData
+ 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
}
- /// Save a post
- let save (post : Post) = backgroundTask {
+ /// Update a post
+ let update (post : Post) = backgroundTask {
log.LogTrace "Post.save"
- let! oldPost = findFullById post.Id post.WebLogId
- do! save Table.Post (PostId.toString post.Id) { post with Revisions = [] }
- do! updatePostRevisions post.Id (match oldPost with Some p -> p.Revisions | None -> []) post.Revisions
+ 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
@@ -186,28 +196,26 @@ type PostgresPostData (log : ILogger) =
let! _ =
Configuration.dataSource ()
|> Sql.fromDataSource
- |> Sql.executeTransactionAsync [
- Query.insert Table.Post,
- posts
- |> List.map (fun post -> Query.docParameters (PostId.toString post.Id) { post with Revisions = [] })
- Revisions.insertSql Table.PostRevision,
- revisions |> List.map (fun (postId, rev) -> Revisions.revParams postId PostId.toString rev)
- ]
+ |> 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 = backgroundTask {
+ let updatePriorPermalinks postId webLogId (permalinks: Permalink list) = backgroundTask {
log.LogTrace "Post.updatePriorPermalinks"
match! postExists postId webLogId with
| true ->
- do! Update.partialById Table.Post (PostId.toString postId) {| PriorPermalinks = permalinks |}
+ do! Patch.byId Table.Post postId {| PriorPermalinks = permalinks |}
return true
| false -> return false
}
interface IPostData with
- member _.Add post = save post
+ 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
@@ -224,5 +232,5 @@ type PostgresPostData (log : ILogger) =
findPageOfTaggedPosts webLogId tag pageNbr postsPerPage
member _.FindSurroundingPosts webLogId publishedOn = findSurroundingPosts webLogId publishedOn
member _.Restore posts = restore posts
- member _.Update post = save post
+ member _.Update post = update post
member _.UpdatePriorPermalinks postId webLogId permalinks = updatePriorPermalinks postId webLogId permalinks
diff --git a/src/MyWebLog.Data/Postgres/PostgresTagMapData.fs b/src/MyWebLog.Data/Postgres/PostgresTagMapData.fs
index 6c0aa52..2369a6f 100644
--- a/src/MyWebLog.Data/Postgres/PostgresTagMapData.fs
+++ b/src/MyWebLog.Data/Postgres/PostgresTagMapData.fs
@@ -1,62 +1,65 @@
namespace MyWebLog.Data.Postgres
-open BitBadger.Npgsql.FSharp.Documents
+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) =
+/// 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 Table.TagMap tagMapId TagMapId.toString webLogId
+ Document.findByIdAndWebLog Table.TagMap tagMapId webLogId
/// Delete a tag mapping for the given web log
- let delete tagMapId webLogId = backgroundTask {
+ let delete (tagMapId: TagMapId) webLogId = backgroundTask {
log.LogTrace "TagMap.delete"
- let! exists = Document.existsByWebLog Table.TagMap tagMapId TagMapId.toString webLogId
+ let! exists = Document.existsByWebLog Table.TagMap tagMapId webLogId
if exists then
- do! Delete.byId Table.TagMap (TagMapId.toString tagMapId)
+ 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 =
+ let findByUrlValue (urlValue: string) webLogId =
log.LogTrace "TagMap.findByUrlValue"
- Custom.single (selectWithCriteria Table.TagMap)
- [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with UrlValue = urlValue |} ]
- fromData
+ Find.firstByContains 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
+ Custom.list
+ $"{selectWithCriteria Table.TagMap} ORDER BY data ->> 'tag'"
+ [ webLogContains webLogId ]
+ fromData
/// 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
+ let tagSql, tagParam = arrayContains (nameof TagMap.Empty.Tag) id tags
+ Custom.list
+ $"{selectWithCriteria Table.TagMap} AND {tagSql}"
+ [ webLogContains webLogId; tagParam ]
+ fromData
/// Save a tag mapping
- let save (tagMap : TagMap) =
- save Table.TagMap (TagMapId.toString tagMap.Id) tagMap
+ 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 restore (tagMaps: TagMap list) = backgroundTask {
let! _ =
Configuration.dataSource ()
|> Sql.fromDataSource
- |> Sql.executeTransactionAsync [
- Query.insert Table.TagMap,
- tagMaps |> List.map (fun tagMap -> Query.docParameters (TagMapId.toString tagMap.Id) tagMap)
- ]
+ |> Sql.executeTransactionAsync
+ [ Query.insert Table.TagMap,
+ tagMaps |> List.map (fun tagMap -> [ jsonParam "@data" tagMap ]) ]
()
}
diff --git a/src/MyWebLog.Data/Postgres/PostgresThemeData.fs b/src/MyWebLog.Data/Postgres/PostgresThemeData.fs
index 00af329..1f4d089 100644
--- a/src/MyWebLog.Data/Postgres/PostgresThemeData.fs
+++ b/src/MyWebLog.Data/Postgres/PostgresThemeData.fs
@@ -1,13 +1,13 @@
namespace MyWebLog.Data.Postgres
-open BitBadger.Npgsql.FSharp.Documents
+open BitBadger.Documents
+open BitBadger.Documents.Postgres
open Microsoft.Extensions.Logging
open MyWebLog
open MyWebLog.Data
-open Npgsql.FSharp
-/// PostreSQL myWebLog theme data implementation
-type PostgresThemeData (log : ILogger) =
+/// PostreSQL myWebLog theme data implementation
+type PostgresThemeData(log: ILogger) =
/// Clear out the template text from a theme
let withoutTemplateText row =
@@ -17,40 +17,48 @@ type PostgresThemeData (log : ILogger) =
/// Retrieve all themes (except 'admin'; excludes template text)
let all () =
log.LogTrace "Theme.all"
- Custom.list $"{Query.selectFromTable Table.Theme} WHERE id <> 'admin' ORDER BY id" [] withoutTemplateText
+ 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 =
+ let exists (themeId: ThemeId) =
log.LogTrace "Theme.exists"
- Exists.byId Table.Theme (ThemeId.toString themeId)
+ Exists.byId Table.Theme themeId
/// Find a theme by its ID
- let findById themeId =
+ let findById (themeId: ThemeId) =
log.LogTrace "Theme.findById"
- Find.byId Table.Theme (ThemeId.toString themeId)
+ Find.byId Table.Theme themeId
/// Find a theme by its ID (excludes the text of templates)
- let findByIdWithoutText themeId =
+ let findByIdWithoutText (themeId: ThemeId) =
log.LogTrace "Theme.findByIdWithoutText"
- Custom.single (Query.Find.byId Table.Theme) [ "@id", Sql.string (ThemeId.toString themeId) ] withoutTemplateText
+ 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! Delete.byId Table.Theme (ThemeId.toString themeId)
+ 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) =
+ let save (theme: Theme) =
log.LogTrace "Theme.save"
- save Table.Theme (ThemeId.toString theme.Id) theme
+ save Table.Theme theme
interface IThemeData with
- member _.All () = all ()
+ member _.All() = all ()
member _.Delete themeId = delete themeId
member _.Exists themeId = exists themeId
member _.FindById themeId = findById themeId
@@ -58,8 +66,8 @@ type PostgresThemeData (log : ILogger) =
member _.Save theme = save theme
-/// PostreSQL myWebLog theme data implementation
-type PostgresThemeAssetData (log : ILogger) =
+/// PostreSQL myWebLog theme data implementation
+type PostgresThemeAssetData(log: ILogger) =
/// Get all theme assets (excludes data)
let all () =
@@ -67,32 +75,34 @@ type PostgresThemeAssetData (log : ILogger) =
Custom.list $"SELECT theme_id, path, updated_on FROM {Table.ThemeAsset}" [] (Map.toThemeAsset false)
/// Delete all assets for the given theme
- let deleteByTheme themeId =
+ let deleteByTheme (themeId: ThemeId) =
log.LogTrace "ThemeAsset.deleteByTheme"
- Custom.nonQuery $"DELETE FROM {Table.ThemeAsset} WHERE theme_id = @themeId"
- [ "@themeId", Sql.string (ThemeId.toString themeId) ]
+ 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 = @themeId AND path = @path"
- [ "@themeId", Sql.string themeId; "@path", Sql.string path ] (Map.toThemeAsset true)
+ 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 =
+ let findByTheme (themeId: ThemeId) =
log.LogTrace "ThemeAsset.findByTheme"
- Custom.list $"SELECT theme_id, path, updated_on FROM {Table.ThemeAsset} WHERE theme_id = @themeId"
- [ "@themeId", Sql.string (ThemeId.toString themeId) ] (Map.toThemeAsset false)
+ 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 =
+ let findByThemeWithData (themeId: ThemeId) =
log.LogTrace "ThemeAsset.findByThemeWithData"
- Custom.list $"SELECT * FROM {Table.ThemeAsset} WHERE theme_id = @themeId"
- [ "@themeId", Sql.string (ThemeId.toString themeId) ] (Map.toThemeAsset true)
+ Custom.list $"SELECT * FROM {Table.ThemeAsset} WHERE theme_id = @id" [ idParam themeId ] (Map.toThemeAsset true)
/// Save a theme asset
- let save (asset : ThemeAsset) =
+ let save (asset: ThemeAsset) =
log.LogTrace "ThemeAsset.save"
let (ThemeAssetId (ThemeId themeId, path)) = asset.Id
Custom.nonQuery
@@ -103,13 +113,13 @@ type PostgresThemeAssetData (log : ILogger) =
) 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 ]
+ [ "@themeId", Sql.string themeId
+ "@path", Sql.string path
+ "@data", Sql.bytea asset.Data
+ typedParam "updatedOn" asset.UpdatedOn ]
interface IThemeAssetData with
- member _.All () = all ()
+ member _.All() = all ()
member _.DeleteByTheme themeId = deleteByTheme themeId
member _.FindById assetId = findById assetId
member _.FindByTheme themeId = findByTheme themeId
diff --git a/src/MyWebLog.Data/Postgres/PostgresUploadData.fs b/src/MyWebLog.Data/Postgres/PostgresUploadData.fs
index 97e36eb..1ceda0f 100644
--- a/src/MyWebLog.Data/Postgres/PostgresUploadData.fs
+++ b/src/MyWebLog.Data/Postgres/PostgresUploadData.fs
@@ -1,13 +1,13 @@
namespace MyWebLog.Data.Postgres
-open BitBadger.Npgsql.FSharp.Documents
+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) =
+/// PostgreSQL myWebLog uploaded file data implementation
+type PostgresUploadData(log: ILogger) =
/// The INSERT statement for an uploaded file
let upInsert = $"
@@ -18,13 +18,12 @@ type PostgresUploadData (log : ILogger) =
)"
/// Parameters for adding an uploaded file
- let upParams (upload : Upload) = [
- webLogIdParam upload.WebLogId
- typedParam "updatedOn" upload.UpdatedOn
- "@id", Sql.string (UploadId.toString upload.Id)
- "@path", Sql.string (Permalink.toString upload.Path)
- "@data", Sql.bytea upload.Data
- ]
+ 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 =
@@ -34,33 +33,41 @@ type PostgresUploadData (log : ILogger) =
/// Delete an uploaded file by its ID
let delete uploadId webLogId = backgroundTask {
log.LogTrace "Upload.delete"
- let idParam = [ "@id", Sql.string (UploadId.toString uploadId) ]
+ 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")
+ 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 (Query.Delete.byId Table.Upload) idParam
+ do! Custom.nonQuery $"DELETE FROM {Table.Upload} WHERE id = @id" idParam
return Ok path.Value
- else return Error $"""Upload ID {UploadId.toString uploadId} not found"""
+ 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)
+ 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)
+ 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)
+ 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 {
diff --git a/src/MyWebLog.Data/Postgres/PostgresWebLogData.fs b/src/MyWebLog.Data/Postgres/PostgresWebLogData.fs
index 713005b..5ec39d4 100644
--- a/src/MyWebLog.Data/Postgres/PostgresWebLogData.fs
+++ b/src/MyWebLog.Data/Postgres/PostgresWebLogData.fs
@@ -1,17 +1,18 @@
namespace MyWebLog.Data.Postgres
-open BitBadger.Npgsql.FSharp.Documents
+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) =
+/// PostgreSQL myWebLog web log data implementation
+type PostgresWebLogData(log: ILogger) =
/// Add a web log
- let add (webLog : WebLog) =
+ let add (webLog: WebLog) =
log.LogTrace "WebLog.add"
- insert Table.WebLog (WebLogId.toString webLog.Id) webLog
+ insert Table.WebLog webLog
/// Retrieve all web logs
let all () =
@@ -23,46 +24,60 @@ type PostgresWebLogData (log : ILogger) =
log.LogTrace "WebLog.delete"
Custom.nonQuery
$"""DELETE FROM {Table.PostComment}
- WHERE data ->> '{nameof Comment.empty.PostId}' IN
- (SELECT id FROM {Table.Post} WHERE {Query.whereDataContains "@criteria"});
+ 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 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) =
+ let findByHost (url: string) =
log.LogTrace "WebLog.findByHost"
- Custom.single (selectWithCriteria Table.WebLog) [ "@criteria", Query.jsonbDocParam {| UrlBase = url |} ]
- fromData
+ Find.firstByContains Table.WebLog {| UrlBase = url |}
/// Find a web log by its ID
- let findById webLogId =
+ let findById (webLogId: WebLogId) =
log.LogTrace "WebLog.findById"
- Find.byId Table.WebLog (WebLogId.toString webLogId)
+ Find.byId Table.WebLog webLogId
- /// Update settings for a web log
- let updateSettings (webLog : WebLog) =
- log.LogTrace "WebLog.updateSettings"
- Update.full Table.WebLog (WebLogId.toString webLog.Id) webLog
-
- /// Update RSS options for a web log
- let updateRssOptions (webLog : WebLog) = backgroundTask {
- log.LogTrace "WebLog.updateRssOptions"
+ /// Update redirect rules for a web log
+ let updateRedirectRules (webLog: WebLog) = backgroundTask {
+ log.LogTrace "WebLog.updateRedirectRules"
match! findById webLog.Id with
- | Some _ -> do! Update.partialById Table.WebLog (WebLogId.toString webLog.Id) {| Rss = webLog.Rss |}
+ | 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 _.All() = all ()
member _.Delete webLogId = delete webLogId
member _.FindByHost url = findByHost url
member _.FindById webLogId = findById webLogId
- member _.UpdateSettings webLog = updateSettings webLog
+ member _.UpdateRedirectRules webLog = updateRedirectRules webLog
member _.UpdateRssOptions webLog = updateRssOptions webLog
+ member _.UpdateSettings webLog = updateSettings webLog
diff --git a/src/MyWebLog.Data/Postgres/PostgresWebLogUserData.fs b/src/MyWebLog.Data/Postgres/PostgresWebLogUserData.fs
index 80eeee3..8ccf5be 100644
--- a/src/MyWebLog.Data/Postgres/PostgresWebLogUserData.fs
+++ b/src/MyWebLog.Data/Postgres/PostgresWebLogUserData.fs
@@ -1,18 +1,24 @@
namespace MyWebLog.Data.Postgres
-open BitBadger.Npgsql.FSharp.Documents
+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) =
+/// 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 Table.WebLogUser userId WebLogUserId.toString webLogId
+ Document.findByIdAndWebLog Table.WebLogUser userId webLogId
/// Delete a user if they have no posts or pages
let delete userId webLogId = backgroundTask {
@@ -22,73 +28,70 @@ type PostgresWebLogUserData (log : ILogger) =
let criteria = Query.whereDataContains "@criteria"
let! isAuthor =
Custom.scalar
- $" SELECT ( EXISTS (SELECT 1 FROM {Table.Page} WHERE {criteria}
+ $" SELECT ( EXISTS (SELECT 1 FROM {Table.Page} WHERE {criteria})
OR EXISTS (SELECT 1 FROM {Table.Post} WHERE {criteria})
- ) AS {existsName}"
- [ "@criteria", Query.jsonbDocParam {| AuthorId = userId |} ] Map.toExists
+ ) 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 (WebLogUserId.toString userId)
+ 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 =
+ let findByEmail (email: string) webLogId =
log.LogTrace "WebLogUser.findByEmail"
- Custom.single (selectWithCriteria Table.WebLogUser)
- [ "@criteria", Query.jsonbDocParam {| webLogDoc webLogId with Email = email |} ]
- fromData
+ Find.firstByContains 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
+ $"{selectWithCriteria Table.WebLogUser} ORDER BY LOWER(data ->> '{nameof WebLogUser.Empty.PreferredName}')"
+ [ webLogContains webLogId ]
+ fromData
/// Find the names of users by their IDs for the given web log
- let findNames webLogId userIds = backgroundTask {
+ let findNames webLogId (userIds: WebLogUserId list) = backgroundTask {
log.LogTrace "WebLogUser.findNames"
- let idSql, idParams = inClause "AND id" "id" WebLogUserId.toString userIds
+ let idSql, idParams = inClause $"AND data ->> '{nameof WebLogUser.Empty.Id}'" "id" userIds
let! users =
- Custom.list $"{selectWithCriteria Table.WebLogUser} {idSql}" (webLogContains webLogId :: idParams)
- fromData
- return
- users
- |> List.map (fun u -> { Name = WebLogUserId.toString u.Id; Value = WebLogUser.displayName u })
+ Custom.list
+ $"{selectWithCriteria Table.WebLogUser} {idSql}"
+ (webLogContains webLogId :: idParams)
+ fromData
+ return users |> List.map (fun u -> { Name = string u.Id; Value = u.DisplayName })
}
/// Restore users from a backup
- let restore (users : WebLogUser list) = backgroundTask {
+ 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 -> Query.docParameters (WebLogUserId.toString user.Id) user)
- ]
+ |> 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 webLogId = backgroundTask {
+ let setLastSeen (userId: WebLogUserId) webLogId = backgroundTask {
log.LogTrace "WebLogUser.setLastSeen"
- match! Document.existsByWebLog Table.WebLogUser userId WebLogUserId.toString webLogId with
- | true ->
- do! Update.partialById Table.WebLogUser (WebLogUserId.toString userId) {| LastSeenOn = Some (Noda.now ()) |}
+ match! Document.existsByWebLog Table.WebLogUser userId webLogId with
+ | true -> do! Patch.byId Table.WebLogUser userId {| LastSeenOn = Some (Noda.now ()) |}
| false -> ()
}
- /// Save a user
- let save (user : WebLogUser) =
- log.LogTrace "WebLogUser.save"
- save Table.WebLogUser (WebLogUserId.toString user.Id) user
+ /// Update a user
+ let update (user: WebLogUser) =
+ log.LogTrace "WebLogUser.update"
+ Update.byId Table.WebLogUser user.Id user
interface IWebLogUserData with
- member _.Add user = save user
+ 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
@@ -96,5 +99,4 @@ type PostgresWebLogUserData (log : ILogger) =
member _.FindNames webLogId userIds = findNames webLogId userIds
member _.Restore users = restore users
member _.SetLastSeen userId webLogId = setLastSeen userId webLogId
- member _.Update user = save user
-
+ member _.Update user = update user
diff --git a/src/MyWebLog.Data/PostgresData.fs b/src/MyWebLog.Data/PostgresData.fs
index 0650379..43d9bef 100644
--- a/src/MyWebLog.Data/PostgresData.fs
+++ b/src/MyWebLog.Data/PostgresData.fs
@@ -1,43 +1,34 @@
namespace MyWebLog.Data
-open BitBadger.Npgsql.Documents
-open BitBadger.Npgsql.FSharp.Documents
+open BitBadger.Documents
+open BitBadger.Documents.Postgres
open Microsoft.Extensions.Logging
open MyWebLog
open MyWebLog.Data.Postgres
open Newtonsoft.Json
-open Npgsql
open Npgsql.FSharp
/// Data implementation for PostgreSQL
-type PostgresData (source : NpgsqlDataSource, log : ILogger, ser : JsonSerializer) =
+type PostgresData(log: ILogger, ser: JsonSerializer) =
/// Create any needed tables
let ensureTables () = backgroundTask {
// Set up the PostgreSQL document store
- Configuration.useDataSource source
- Configuration.useSerializer
- { new IDocumentSerializer with
- member _.Serialize<'T> (it : 'T) : string = Utils.serialize ser it
- member _.Deserialize<'T> (it : string) : 'T = Utils.deserialize ser it
- }
+ Configuration.useSerializer (Utils.createDocumentSerializer ser)
let! tables =
- Sql.fromDataSource source
- |> Sql.query "SELECT tablename FROM pg_tables WHERE schemaname = 'public'"
- |> Sql.executeAsync (fun row -> row.string "tablename")
+ Custom.list
+ "SELECT tablename FROM pg_tables WHERE schemaname = 'public'" [] (fun row -> row.string "tablename")
let needsTable table = not (List.contains table tables)
- // Create a document table
- let mutable isNew = false
let sql = seq {
// Theme tables
if needsTable Table.Theme then
- isNew <- true
- Definition.createTable Table.Theme
+ Query.Definition.ensureTable Table.Theme
+ Query.Definition.ensureKey Table.Theme
if needsTable Table.ThemeAsset then
$"CREATE TABLE {Table.ThemeAsset} (
- theme_id TEXT NOT NULL REFERENCES {Table.Theme} (id) ON DELETE CASCADE,
+ theme_id TEXT NOT NULL,
path TEXT NOT NULL,
updated_on TIMESTAMPTZ NOT NULL,
data BYTEA NOT NULL,
@@ -45,84 +36,90 @@ type PostgresData (source : NpgsqlDataSource, log : ILogger, ser :
// Web log table
if needsTable Table.WebLog then
- Definition.createTable Table.WebLog
- Definition.createIndex Table.WebLog Optimized
+ Query.Definition.ensureTable Table.WebLog
+ Query.Definition.ensureKey Table.WebLog
+ Query.Definition.ensureDocumentIndex Table.WebLog Optimized
// Category table
if needsTable Table.Category then
- Definition.createTable Table.Category
- Definition.createIndex Table.Category Optimized
+ 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
- Definition.createTable Table.WebLogUser
- Definition.createIndex Table.WebLogUser Optimized
+ Query.Definition.ensureTable Table.WebLogUser
+ Query.Definition.ensureKey Table.WebLogUser
+ Query.Definition.ensureDocumentIndex Table.WebLogUser Optimized
// Page tables
if needsTable Table.Page then
- Definition.createTable Table.Page
- $"CREATE INDEX page_web_log_idx ON {Table.Page} ((data ->> '{nameof Page.empty.WebLogId}'))"
- $"CREATE INDEX page_author_idx ON {Table.Page} ((data ->> '{nameof Page.empty.AuthorId}'))"
- $"CREATE INDEX page_permalink_idx ON {Table.Page}
- ((data ->> '{nameof Page.empty.WebLogId}'), (data ->> '{nameof Page.empty.Permalink}'))"
+ 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 REFERENCES {Table.Page} (id) ON DELETE CASCADE,
+ 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
- Definition.createTable Table.Post
- $"CREATE INDEX post_web_log_idx ON {Table.Post} ((data ->> '{nameof Post.empty.WebLogId}'))"
- $"CREATE INDEX post_author_idx ON {Table.Post} ((data ->> '{nameof Post.empty.AuthorId}'))"
- $"CREATE INDEX post_status_idx ON {Table.Post}
- ((data ->> '{nameof Post.empty.WebLogId}'), (data ->> '{nameof Post.empty.Status}'),
- (data ->> '{nameof Post.empty.UpdatedOn}'))"
- $"CREATE INDEX post_permalink_idx ON {Table.Post}
- ((data ->> '{nameof Post.empty.WebLogId}'), (data ->> '{nameof Post.empty.Permalink}'))"
- $"CREATE INDEX post_category_idx ON {Table.Post} USING GIN ((data['{nameof Post.empty.CategoryIds}']))"
- $"CREATE INDEX post_tag_idx ON {Table.Post} USING GIN ((data['{nameof Post.empty.Tags}']))"
+ 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 REFERENCES {Table.Post} (id) ON DELETE CASCADE,
+ 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
- Definition.createTable Table.PostComment
- $"CREATE INDEX post_comment_post_idx ON {Table.PostComment}
- ((data ->> '{nameof Comment.empty.PostId}'))"
+ 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
- Definition.createTable Table.TagMap
- Definition.createIndex Table.TagMap Optimized
+ 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 REFERENCES {Table.WebLog} (id),
+ web_log_id TEXT NOT NULL,
path TEXT NOT NULL,
updated_on TIMESTAMPTZ NOT NULL,
data BYTEA NOT NULL)"
- $"CREATE INDEX upload_web_log_idx ON {Table.Upload} (web_log_id)"
- $"CREATE INDEX upload_path_idx ON {Table.Upload} (web_log_id, path)"
+ $"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.currentDbVersion}')"
+ $"INSERT INTO {Table.DbVersion} VALUES ('{Utils.Migration.currentDbVersion}')"
}
- Sql.fromDataSource source
+ 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
+ if parts[1].ToLowerInvariant() = "table" then
log.LogInformation $"Creating {parts[2]} table..."
s, [ [] ])
|> List.ofSeq)
@@ -137,40 +134,72 @@ type PostgresData (source : NpgsqlDataSource, log : ILogger, ser :
/// Migrate from v2-rc2 to v2 (manual migration required)
let migrateV2Rc2ToV2 () = backgroundTask {
- Utils.logMigrationStep log "v2-rc2 to v2" "Requires user action"
-
let! webLogs =
- Configuration.dataSource ()
- |> Sql.fromDataSource
- |> Sql.query $"SELECT url_base, slug FROM {Table.WebLog}"
- |> Sql.executeAsync (fun row -> row.string "url_base", row.string "slug")
+ 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
+ let migrateV2ToV2point1 () = backgroundTask {
+ let migration = "v2 to v2.1"
+ Utils.Migration.logStep log migration "Adding empty redirect rule set to all weblogs"
+ do! Custom.nonQuery $"""UPDATE {Table.WebLog} SET data = data + '{{ "RedirectRules": [] }}'::json""" []
+
+ let tables =
+ [ Table.Category; Table.Page; Table.Post; Table.PostComment; Table.TagMap; Table.Theme; Table.WebLog
+ Table.WebLogUser ]
- [ "** MANUAL DATABASE UPGRADE REQUIRED **"; ""
- "The data structure for PostgreSQL changed significantly between v2-rc2 and v2."
- "To migrate your data:"
- " - Use a v2-rc2 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) -> sprintf "./myWebLog backup %s v2-rc2.%s.json" url slug)
- ]
- |> String.concat "\n"
- |> log.LogWarning
+ Utils.Migration.logStep log migration "Adding unique indexes on ID fields"
+ do! Custom.nonQuery (tables |> List.map Query.Definition.ensureKey |> String.concat "; ") []
- log.LogCritical "myWebLog will now exit"
- exit 1
+ 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 "Adjusting indexes"
+ let toDrop = [ "page_web_log_idx"; "post_web_log_idx" ]
+ do! Custom.nonQuery (toDrop |> List.map (sprintf "DROP INDEX %s") |> String.concat "; ") []
+
+ let toRename =
+ [ "idx_category", "idx_category_document"
+ "idx_tag_map", "idx_tag_map_document"
+ "idx_web_log", "idx_web_log_document"
+ "idx_web_log_user", "idx_web_log_user_document"
+ "page_author_idx", "idx_page_author"
+ "page_permalink_idx", "idx_page_permalink"
+ "post_author_idx", "idx_post_author"
+ "post_status_idx", "idx_post_status"
+ "post_permalink_idx", "idx_post_permalink"
+ "post_category_idx", "idx_post_category"
+ "post_tag_idx", "idx_post_tag"
+ "post_comment_post_idx", "idx_post_comment_post"
+ "upload_web_log_idx", "idx_upload_web_log"
+ "upload_path_idx", "idx_upload_path" ]
+ do! Custom.nonQuery
+ (toRename
+ |> List.map (fun (oldName, newName) -> $"ALTER INDEX {oldName} RENAME TO {newName}")
+ |> String.concat "; ")
+ []
+
+ Utils.Migration.logStep log migration "Setting database to version 2.1"
+ do! setDbVersion "v2.1"
}
/// Do required data migration between versions
let migrate version = backgroundTask {
- match version with
- | Some "v2" -> ()
- | Some "v2-rc2" -> do! migrateV2Rc2ToV2 ()
- // Future versions will be inserted here
- | Some _
- | None ->
- log.LogWarning $"Unknown database version; assuming {Utils.currentDbVersion}"
- do! setDbVersion Utils.currentDbVersion
+ let mutable v = defaultArg version ""
+
+ if v = "v2-rc2" then
+ do! migrateV2Rc2ToV2 ()
+ v <- "v2"
+
+ if v = "v2" then
+ do! migrateV2ToV2point1 ()
+ v <- "v2.1"
+
+ if v <> Utils.Migration.currentDbVersion then
+ log.LogWarning $"Unknown database version; assuming {Utils.Migration.currentDbVersion}"
+ do! setDbVersion Utils.Migration.currentDbVersion
}
interface IData with
@@ -192,8 +221,5 @@ type PostgresData (source : NpgsqlDataSource, log : ILogger, ser :
do! ensureTables ()
let! version = Custom.single "SELECT id FROM db_version" [] (fun row -> row.string "id")
- match version with
- | Some v when v = Utils.currentDbVersion -> ()
- | Some _
- | None -> do! migrate version
+ do! migrate version
}
diff --git a/src/MyWebLog.Data/RethinkDbData.fs b/src/MyWebLog.Data/RethinkDbData.fs
index 92ace6e..1c035e3 100644
--- a/src/MyWebLog.Data/RethinkDbData.fs
+++ b/src/MyWebLog.Data/RethinkDbData.fs
@@ -69,20 +69,20 @@ module private RethinkHelpers =
let r = RethinkDB.R
/// Verify that the web log ID matches before returning an item
- let verifyWebLog<'T> webLogId (prop : 'T -> WebLogId) (f : Net.IConnection -> Task<'T option>) =
+ let verifyWebLog<'T> webLogId (prop: 'T -> WebLogId) (f: Net.IConnection -> Task<'T option>) =
fun conn -> backgroundTask {
match! f conn with Some it when (prop it) = webLogId -> return Some it | _ -> return None
}
/// Get the first item from a list, or None if the list is empty
- let tryFirst<'T> (f : Net.IConnection -> Task<'T list>) =
+ let tryFirst<'T> (f: Net.IConnection -> Task<'T list>) =
fun conn -> backgroundTask {
let! results = f conn
return results |> List.tryHead
}
/// Cast a strongly-typed list to an object list
- let objList<'T> (objects : 'T list) = objects |> List.map (fun it -> it :> obj)
+ let objList<'T> (objects: 'T list) = objects |> List.map (fun it -> it :> obj)
open System
@@ -92,16 +92,16 @@ open RethinkDb.Driver.FSharp
open RethinkHelpers
/// RethinkDB implementation of data functions for myWebLog
-type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger) =
+type RethinkDbData(conn: Net.IConnection, config: DataConfig, log: ILogger) =
/// Match theme asset IDs by their prefix (the theme ID)
let matchAssetByThemeId themeId =
- let keyPrefix = $"^{ThemeId.toString themeId}/"
- fun (row : Ast.ReqlExpr) -> row[nameof ThemeAsset.empty.Id].Match keyPrefix :> obj
+ let keyPrefix = $"^{themeId}/"
+ fun (row: Ast.ReqlExpr) -> row[nameof ThemeAsset.Empty.Id].Match keyPrefix :> obj
/// Function to exclude template text from themes
- let withoutTemplateText (row : Ast.ReqlExpr) : obj =
- {| Templates = row[nameof Theme.empty.Templates].Without [| nameof ThemeTemplate.empty.Text |] |}
+ let withoutTemplateText (row: Ast.ReqlExpr) : obj =
+ {| Templates = row[nameof Theme.Empty.Templates].Merge(r.HashMap(nameof ThemeTemplate.Empty.Text, "")) |}
/// Ensure field indexes exist, as well as special indexes for selected tables
let ensureIndexes table fields = backgroundTask {
@@ -112,27 +112,27 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger List.contains table then
- let permalinkIdx = nameof Page.empty.Permalink
+ let permalinkIdx = nameof Page.Empty.Permalink
if not (indexes |> List.contains permalinkIdx) then
log.LogInformation $"Creating index {table}.{permalinkIdx}..."
do! rethink {
withTable table
indexCreate permalinkIdx
- (fun row -> r.Array (row[nameof Page.empty.WebLogId], row[permalinkIdx].Downcase ()) :> obj)
+ (fun row -> r.Array(row[nameof Page.Empty.WebLogId], row[permalinkIdx].Downcase()) :> obj)
write; withRetryOnce; ignoreResult conn
}
// Prior permalinks are searched when a post or page permalink do not match the current URL
- let priorIdx = nameof Post.empty.PriorPermalinks
+ let priorIdx = nameof Post.Empty.PriorPermalinks
if not (indexes |> List.contains priorIdx) then
log.LogInformation $"Creating index {table}.{priorIdx}..."
do! rethink {
withTable table
- indexCreate priorIdx (fun row -> row[priorIdx].Downcase () :> obj) [ Multi ]
+ indexCreate priorIdx [ Multi ]
write; withRetryOnce; ignoreResult conn
}
// Post needs indexes by category and tag (used for counting and retrieving posts)
if Table.Post = table then
- for idx in [ nameof Post.empty.CategoryIds; nameof Post.empty.Tags ] do
+ for idx in [ nameof Post.Empty.CategoryIds; nameof Post.Empty.Tags ] do
if not (List.contains idx indexes) then
log.LogInformation $"Creating index {table}.{idx}..."
do! rethink {
@@ -147,7 +147,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger
- [| row[nameof TagMap.empty.WebLogId]; row[nameof TagMap.empty.Tag] |] :> obj)
+ [| row[nameof TagMap.Empty.WebLogId]; row[nameof TagMap.Empty.Tag] |] :> obj)
write; withRetryOnce; ignoreResult conn
}
if not (indexes |> List.contains Index.WebLogAndUrl) then
@@ -155,7 +155,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger
- [| row[nameof TagMap.empty.WebLogId]; row[nameof TagMap.empty.UrlValue] |] :> obj)
+ [| row[nameof TagMap.Empty.WebLogId]; row[nameof TagMap.Empty.UrlValue] |] :> obj)
write; withRetryOnce; ignoreResult conn
}
// Uploaded files need an index by web log ID and path, as that is how they are retrieved
@@ -165,7 +165,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger
- [| row[nameof Upload.empty.WebLogId]; row[nameof Upload.empty.Path] |] :> obj)
+ [| row[nameof Upload.Empty.WebLogId]; row[nameof Upload.Empty.Path] |] :> obj)
write; withRetryOnce; ignoreResult conn
}
// Users log on with e-mail
@@ -175,14 +175,18 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger
- [| row[nameof WebLogUser.empty.WebLogId]; row[nameof WebLogUser.empty.Email] |] :> obj)
+ [| row[nameof WebLogUser.Empty.WebLogId]; row[nameof WebLogUser.Empty.Email] |] :> obj)
write; withRetryOnce; ignoreResult conn
}
+ do! rethink { withTable table; indexWait; result; withRetryDefault; ignoreResult conn }
}
/// The batch size for restoration methods
let restoreBatchSize = 100
+ /// A value to use when files need to be retrieved without their data
+ let emptyFile = r.Binary(Array.Empty())
+
/// Delete assets for the given theme ID
let deleteAssetsByTheme themeId = rethink {
withTable Table.ThemeAsset
@@ -192,7 +196,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger obj ]
+ write; withRetryOnce; ignoreResult conn
+ }
+
+ Utils.Migration.logStep log "v2 to v2.1" "Setting database version to v2.1"
+ do! setDbVersion "v2.1"
+ }
/// Migrate data between versions
let migrate version = backgroundTask {
- match version with
- | Some v when v = "v2" -> ()
- | Some v when v = "v2-rc2" -> do! migrateV2Rc2ToV2 ()
- | Some v when v = "v2-rc1" -> do! migrateV2Rc1ToV2Rc2 ()
- | Some _
- | None ->
- log.LogWarning $"Unknown database version; assuming {Utils.currentDbVersion}"
- do! setDbVersion Utils.currentDbVersion
+ let mutable v = defaultArg version ""
+
+ if v = "v2-rc1" then
+ do! migrateV2Rc1ToV2Rc2 ()
+ v <- "v2-rc2"
+
+ if v = "v2-rc2" then
+ do! migrateV2Rc2ToV2 ()
+ v <- "v2"
+
+ if v = "v2" then
+ do! migrateV2ToV2point1 ()
+ v <- "v2.1"
+
+ if v <> Utils.Migration.currentDbVersion then
+ log.LogWarning $"Unknown database version; assuming {Utils.Migration.currentDbVersion}"
+ do! setDbVersion Utils.Migration.currentDbVersion
}
/// The connection for this instance
@@ -249,15 +275,15 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.Category
- getAll [ webLogId ] (nameof Category.empty.WebLogId)
+ getAll [ webLogId ] (nameof Category.Empty.WebLogId)
count
result; withRetryDefault conn
}
member _.CountTopLevel webLogId = rethink {
withTable Table.Category
- getAll [ webLogId ] (nameof Category.empty.WebLogId)
- filter (nameof Category.empty.ParentId) None
+ getAll [ webLogId ] (nameof Category.Empty.WebLogId)
+ filter (nameof Category.Empty.ParentId) None (Default FilterDefaultHandling.Return)
count
result; withRetryDefault conn
}
@@ -265,8 +291,8 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.Category
- getAll [ webLogId ] (nameof Category.empty.WebLogId)
- orderByFunc (fun it -> it[nameof Category.empty.Name].Downcase () :> obj)
+ getAll [ webLogId ] (nameof Category.Empty.WebLogId)
+ orderByFunc (fun it -> it[nameof Category.Empty.Name].Downcase() :> obj)
result; withRetryDefault conn
}
let ordered = Utils.orderByHierarchy cats None None []
@@ -282,8 +308,8 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger List.ofSeq
let! count = rethink {
withTable Table.Post
- getAll catIds (nameof Post.empty.CategoryIds)
- filter (nameof Post.empty.Status) Published
+ getAll catIds (nameof Post.Empty.CategoryIds)
+ filter (nameof Post.Empty.Status) Published
distinct
count
result; withRetryDefault conn
@@ -298,8 +324,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger Array.tryFind (fun c -> fst c = cat.Id)
|> Option.map snd
- |> Option.defaultValue 0
- })
+ |> Option.defaultValue 0 })
|> Array.ofSeq
}
@@ -309,11 +334,11 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger verifyWebLog webLogId (fun c -> c.WebLogId) <| conn
+ |> verifyWebLog webLogId _.WebLogId <| conn
member _.FindByWebLog webLogId = rethink {
withTable Table.Category
- getAll [ webLogId ] (nameof Category.empty.WebLogId)
+ getAll [ webLogId ] (nameof Category.Empty.WebLogId)
result; withRetryDefault conn
}
@@ -323,24 +348,26 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.Category
- filter (nameof Category.empty.ParentId) catId
+ filter (nameof Category.Empty.ParentId) catId
count
result; withRetryDefault conn
}
if children > 0 then
do! rethink {
withTable Table.Category
- filter (nameof Category.empty.ParentId) catId
- update [ nameof Category.empty.ParentId, cat.ParentId :> obj ]
+ filter (nameof Category.Empty.ParentId) catId
+ update [ nameof Category.Empty.ParentId, cat.ParentId :> obj ]
write; withRetryDefault; ignoreResult conn
}
// Delete the category off all posts where it is assigned
do! rethink {
withTable Table.Post
- getAll [ webLogId ] (nameof Post.empty.WebLogId)
- filter (fun row -> row[nameof Post.empty.CategoryIds].Contains catId :> obj)
+ getAll [ webLogId ] (nameof Post.Empty.WebLogId)
+ filter (fun row -> row[nameof Post.Empty.CategoryIds].Contains catId :> obj)
update (fun row ->
- {| CategoryIds = r.Array(row[nameof Post.empty.CategoryIds]).Remove catId |} :> obj)
+ {| CategoryIds =
+ row[nameof Post.Empty.CategoryIds].CoerceTo("array")
+ .SetDifference(r.Array(catId)) |} :> obj)
write; withRetryDefault; ignoreResult conn
}
// Delete the category itself
@@ -386,26 +413,26 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.Page
- getAll [ webLogId ] (nameof Page.empty.WebLogId)
- without [ nameof Page.empty.Text
- nameof Page.empty.Metadata
- nameof Page.empty.Revisions
- nameof Page.empty.PriorPermalinks ]
- orderByFunc (fun row -> row[nameof Page.empty.Title].Downcase () :> obj)
+ getAll [ webLogId ] (nameof Page.Empty.WebLogId)
+ merge (r.HashMap(nameof Page.Empty.Text, "")
+ .With(nameof Page.Empty.Metadata, [||])
+ .With(nameof Page.Empty.Revisions, [||])
+ .With(nameof Page.Empty.PriorPermalinks, [||]))
+ orderByFunc (fun row -> row[nameof Page.Empty.Title].Downcase() :> obj)
result; withRetryDefault conn
}
member _.CountAll webLogId = rethink {
withTable Table.Page
- getAll [ webLogId ] (nameof Page.empty.WebLogId)
+ getAll [ webLogId ] (nameof Page.Empty.WebLogId)
count
result; withRetryDefault conn
}
member _.CountListed webLogId = rethink {
withTable Table.Page
- getAll [ webLogId ] (nameof Page.empty.WebLogId)
- filter (nameof Page.empty.IsInPageList) true
+ getAll [ webLogId ] (nameof Page.Empty.WebLogId)
+ filter (nameof Page.Empty.IsInPageList) true
count
result; withRetryDefault conn
}
@@ -414,7 +441,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.Page
getAll [ pageId ]
- filter (fun row -> row[nameof Page.empty.WebLogId].Eq webLogId :> obj)
+ filter (fun row -> row[nameof Page.Empty.WebLogId].Eq webLogId :> obj)
delete
write; withRetryDefault conn
}
@@ -422,19 +449,22 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
+ rethink {
withTable Table.Page
- get pageId
- without [ nameof Page.empty.PriorPermalinks; nameof Page.empty.Revisions ]
- resultOption; withRetryOptionDefault
+ getAll [ pageId ]
+ filter (nameof Page.Empty.WebLogId) webLogId
+ merge (r.HashMap(nameof Page.Empty.PriorPermalinks, [||])
+ .With(nameof Page.Empty.Revisions, [||]))
+ result; withRetryDefault
}
- |> verifyWebLog webLogId (fun it -> it.WebLogId) <| conn
+ |> tryFirst <| conn
member _.FindByPermalink permalink webLogId =
rethink {
withTable Table.Page
- getAll [ [| webLogId :> obj; permalink |] ] (nameof Page.empty.Permalink)
- without [ nameof Page.empty.PriorPermalinks; nameof Page.empty.Revisions ]
+ getAll [ [| webLogId :> obj; permalink |] ] (nameof Page.Empty.Permalink)
+ merge (r.HashMap(nameof Page.Empty.PriorPermalinks, [||])
+ .With(nameof Page.Empty.Revisions, [||]))
limit 1
result; withRetryDefault
}
@@ -444,14 +474,14 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.Page
- getAll (objList permalinks) (nameof Page.empty.PriorPermalinks)
- filter (nameof Page.empty.WebLogId) webLogId
- without [ nameof Page.empty.Revisions; nameof Page.empty.Text ]
+ getAll (objList permalinks) (nameof Page.Empty.PriorPermalinks)
+ filter (nameof Page.Empty.WebLogId) webLogId
+ without [ nameof Page.Empty.Revisions; nameof Page.Empty.Text ]
limit 1
result; withRetryDefault
}
|> tryFirst) conn
- return result |> Option.map (fun pg -> pg.Permalink)
+ return result |> Option.map _.Permalink
}
member _.FindFullById pageId webLogId =
@@ -460,30 +490,32 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger verifyWebLog webLogId (fun it -> it.WebLogId) <| conn
+ |> verifyWebLog webLogId _.WebLogId <| conn
member _.FindFullByWebLog webLogId = rethink {
withTable Table.Page
- getAll [ webLogId ] (nameof Page.empty.WebLogId)
+ getAll [ webLogId ] (nameof Page.Empty.WebLogId)
resultCursor; withRetryCursorDefault; toList conn
}
member _.FindListed webLogId = rethink {
withTable Table.Page
- getAll [ webLogId ] (nameof Page.empty.WebLogId)
- filter [ nameof Page.empty.IsInPageList, true :> obj ]
- without [ nameof Page.empty.Text; nameof Page.empty.PriorPermalinks; nameof Page.empty.Revisions ]
- orderBy (nameof Page.empty.Title)
+ getAll [ webLogId ] (nameof Page.Empty.WebLogId)
+ filter [ nameof Page.Empty.IsInPageList, true :> obj ]
+ merge (r.HashMap(nameof Page.Empty.Text, "")
+ .With(nameof Page.Empty.PriorPermalinks, [||])
+ .With(nameof Page.Empty.Revisions, [||]))
+ orderBy (nameof Page.Empty.Title)
result; withRetryDefault conn
}
member _.FindPageOfPages webLogId pageNbr = rethink {
withTable Table.Page
- getAll [ webLogId ] (nameof Page.empty.WebLogId)
- without [ nameof Page.empty.Metadata
- nameof Page.empty.PriorPermalinks
- nameof Page.empty.Revisions ]
- orderByFunc (fun row -> row[nameof Page.empty.Title].Downcase ())
+ getAll [ webLogId ] (nameof Page.Empty.WebLogId)
+ merge (r.HashMap(nameof Page.Empty.Metadata, [||])
+ .With(nameof Page.Empty.PriorPermalinks, [||])
+ .With(nameof Page.Empty.Revisions, [||]))
+ orderByFunc (fun row -> row[nameof Page.Empty.Title].Downcase())
skip ((pageNbr - 1) * 25)
limit 25
result; withRetryDefault conn
@@ -511,7 +543,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger obj ]
+ update [ nameof Page.Empty.PriorPermalinks, permalinks :> obj ]
write; withRetryDefault; ignoreResult conn
}
return true
@@ -540,8 +572,8 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.Post
- getAll [ webLogId ] (nameof Post.empty.WebLogId)
- filter (nameof Post.empty.Status) status
+ getAll [ webLogId ] (nameof Post.Empty.WebLogId)
+ filter (nameof Post.Empty.Status) status
count
result; withRetryDefault conn
}
@@ -550,7 +582,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.Post
getAll [ postId ]
- filter (fun row -> row[nameof Post.empty.WebLogId].Eq webLogId :> obj)
+ filter (fun row -> row[nameof Post.Empty.WebLogId].Eq webLogId :> obj)
delete
write; withRetryDefault conn
}
@@ -558,19 +590,22 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
+ rethink {
withTable Table.Post
- get postId
- without [ nameof Post.empty.PriorPermalinks; nameof Post.empty.Revisions ]
- resultOption; withRetryOptionDefault
+ getAll [ postId ]
+ filter (nameof Post.Empty.WebLogId) webLogId
+ merge (r.HashMap(nameof Post.Empty.PriorPermalinks, [||])
+ .With(nameof Post.Empty.Revisions, [||]))
+ result; withRetryDefault
}
- |> verifyWebLog webLogId (fun p -> p.WebLogId) <| conn
+ |> tryFirst <| conn
member _.FindByPermalink permalink webLogId =
rethink {
withTable Table.Post
- getAll [ [| webLogId :> obj; permalink |] ] (nameof Post.empty.Permalink)
- without [ nameof Post.empty.PriorPermalinks; nameof Post.empty.Revisions ]
+ getAll [ [| webLogId :> obj; permalink |] ] (nameof Post.Empty.Permalink)
+ merge (r.HashMap(nameof Post.Empty.PriorPermalinks, [||])
+ .With(nameof Post.Empty.Revisions, [||]))
limit 1
result; withRetryDefault
}
@@ -582,36 +617,37 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger verifyWebLog webLogId (fun p -> p.WebLogId) <| conn
+ |> verifyWebLog webLogId _.WebLogId <| conn
member _.FindCurrentPermalink permalinks webLogId = backgroundTask {
let! result =
(rethink {
withTable Table.Post
- getAll (objList permalinks) (nameof Post.empty.PriorPermalinks)
- filter (nameof Post.empty.WebLogId) webLogId
- without [ nameof Post.empty.Revisions; nameof Post.empty.Text ]
+ getAll (objList permalinks) (nameof Post.Empty.PriorPermalinks)
+ filter (nameof Post.Empty.WebLogId) webLogId
+ without [ nameof Post.Empty.Revisions; nameof Post.Empty.Text ]
limit 1
result; withRetryDefault
}
|> tryFirst) conn
- return result |> Option.map (fun post -> post.Permalink)
+ return result |> Option.map _.Permalink
}
member _.FindFullByWebLog webLogId = rethink {
withTable Table.Post
- getAll [ webLogId ] (nameof Post.empty.WebLogId)
+ getAll [ webLogId ] (nameof Post.Empty.WebLogId)
resultCursor; withRetryCursorDefault; toList conn
}
member _.FindPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage = rethink {
withTable Table.Post
- getAll (objList categoryIds) (nameof Post.empty.CategoryIds)
- filter [ nameof Post.empty.WebLogId, webLogId :> obj
- nameof Post.empty.Status, Published ]
- without [ nameof Post.empty.PriorPermalinks; nameof Post.empty.Revisions ]
+ getAll (objList categoryIds) (nameof Post.Empty.CategoryIds)
+ filter [ nameof Post.Empty.WebLogId, webLogId :> obj
+ nameof Post.Empty.Status, Published ]
+ merge (r.HashMap(nameof Post.Empty.PriorPermalinks, [||])
+ .With(nameof Post.Empty.Revisions, [||]))
distinct
- orderByDescending (nameof Post.empty.PublishedOn)
+ orderByDescending (nameof Post.Empty.PublishedOn)
skip ((pageNbr - 1) * postsPerPage)
limit (postsPerPage + 1)
result; withRetryDefault conn
@@ -619,10 +655,12 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.Post
- getAll [ webLogId ] (nameof Post.empty.WebLogId)
- without [ nameof Post.empty.PriorPermalinks; nameof Post.empty.Revisions ]
+ getAll [ webLogId ] (nameof Post.Empty.WebLogId)
+ merge (r.HashMap(nameof Post.Empty.Text, "")
+ .With(nameof Post.Empty.PriorPermalinks, [||])
+ .With(nameof Post.Empty.Revisions, [||]))
orderByFuncDescending (fun row ->
- row[nameof Post.empty.PublishedOn].Default_ (nameof Post.empty.UpdatedOn) :> obj)
+ row[nameof Post.Empty.PublishedOn].Default_(nameof Post.Empty.UpdatedOn) :> obj)
skip ((pageNbr - 1) * postsPerPage)
limit (postsPerPage + 1)
result; withRetryDefault conn
@@ -630,10 +668,11 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.Post
- getAll [ webLogId ] (nameof Post.empty.WebLogId)
- filter (nameof Post.empty.Status) Published
- without [ nameof Post.empty.PriorPermalinks; nameof Post.empty.Revisions ]
- orderByDescending (nameof Post.empty.PublishedOn)
+ getAll [ webLogId ] (nameof Post.Empty.WebLogId)
+ filter (nameof Post.Empty.Status) Published
+ merge (r.HashMap(nameof Post.Empty.PriorPermalinks, [||])
+ .With(nameof Post.Empty.Revisions, [||]))
+ orderByDescending (nameof Post.Empty.PublishedOn)
skip ((pageNbr - 1) * postsPerPage)
limit (postsPerPage + 1)
result; withRetryDefault conn
@@ -641,11 +680,12 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.Post
- getAll [ tag ] (nameof Post.empty.Tags)
- filter [ nameof Post.empty.WebLogId, webLogId :> obj
- nameof Post.empty.Status, Published ]
- without [ nameof Post.empty.PriorPermalinks; nameof Post.empty.Revisions ]
- orderByDescending (nameof Post.empty.PublishedOn)
+ getAll [ tag ] (nameof Post.Empty.Tags)
+ filter [ nameof Post.Empty.WebLogId, webLogId :> obj
+ nameof Post.Empty.Status, Published ]
+ merge (r.HashMap(nameof Post.Empty.PriorPermalinks, [||])
+ .With(nameof Post.Empty.Revisions, [||]))
+ orderByDescending (nameof Post.Empty.PublishedOn)
skip ((pageNbr - 1) * postsPerPage)
limit (postsPerPage + 1)
result; withRetryDefault conn
@@ -655,10 +695,11 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.Post
- getAll [ webLogId ] (nameof Post.empty.WebLogId)
- filter (fun row -> row[nameof Post.empty.PublishedOn].Lt publishedOn :> obj)
- without [ nameof Post.empty.PriorPermalinks; nameof Post.empty.Revisions ]
- orderByDescending (nameof Post.empty.PublishedOn)
+ getAll [ webLogId ] (nameof Post.Empty.WebLogId)
+ filter (fun row -> row[nameof Post.Empty.PublishedOn].Lt publishedOn :> obj)
+ merge (r.HashMap(nameof Post.Empty.PriorPermalinks, [||])
+ .With(nameof Post.Empty.Revisions, [||]))
+ orderByDescending (nameof Post.Empty.PublishedOn)
limit 1
result; withRetryDefault
}
@@ -666,10 +707,11 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.Post
- getAll [ webLogId ] (nameof Post.empty.WebLogId)
- filter (fun row -> row[nameof Post.empty.PublishedOn].Gt publishedOn :> obj)
- without [ nameof Post.empty.PriorPermalinks; nameof Post.empty.Revisions ]
- orderBy (nameof Post.empty.PublishedOn)
+ getAll [ webLogId ] (nameof Post.Empty.WebLogId)
+ filter (fun row -> row[nameof Post.Empty.PublishedOn].Gt publishedOn :> obj)
+ merge (r.HashMap(nameof Post.Empty.PriorPermalinks, [||])
+ .With(nameof Post.Empty.Revisions, [||]))
+ orderBy (nameof Post.Empty.PublishedOn)
limit 1
result; withRetryDefault
}
@@ -686,27 +728,25 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger
+ do! rethink {
+ withTable Table.Post
+ get post.Id
+ replace post
+ write; withRetryDefault; ignoreResult conn
+ }
+ | None -> ()
}
- member _.UpdatePriorPermalinks postId webLogId permalinks = backgroundTask {
- match! (
- rethink {
- withTable Table.Post
- get postId
- without [ nameof Post.empty.Revisions; nameof Post.empty.PriorPermalinks ]
- resultOption; withRetryOptionDefault
- }
- |> verifyWebLog webLogId (fun p -> p.WebLogId)) conn with
+ member this.UpdatePriorPermalinks postId webLogId permalinks = backgroundTask {
+ match! this.FindById postId webLogId with
| Some _ ->
do! rethink {
withTable Table.Post
get postId
- update [ nameof Post.empty.PriorPermalinks, permalinks :> obj ]
+ update [ nameof Post.Empty.PriorPermalinks, permalinks :> obj ]
write; withRetryDefault; ignoreResult conn
}
return true
@@ -721,7 +761,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.TagMap
getAll [ tagMapId ]
- filter (fun row -> row[nameof TagMap.empty.WebLogId].Eq webLogId :> obj)
+ filter (fun row -> row[nameof TagMap.Empty.WebLogId].Eq webLogId :> obj)
delete
write; withRetryDefault conn
}
@@ -734,7 +774,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger verifyWebLog webLogId (fun tm -> tm.WebLogId) <| conn
+ |> verifyWebLog webLogId _.WebLogId <| conn
member _.FindByUrlValue urlValue webLogId =
rethink {
@@ -747,9 +787,9 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.TagMap
- between [| webLogId :> obj; r.Minval () |] [| webLogId :> obj; r.Maxval () |]
+ between [| webLogId :> obj; r.Minval() |] [| webLogId :> obj; r.Maxval() |]
[ Index Index.WebLogAndTag ]
- orderBy (nameof TagMap.empty.Tag)
+ orderBy (nameof TagMap.Empty.Tag)
result; withRetryDefault conn
}
@@ -781,16 +821,16 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.Theme
- filter (fun row -> row[nameof Theme.empty.Id].Ne "admin" :> obj)
+ filter (fun row -> row[nameof Theme.Empty.Id].Ne "admin" :> obj)
merge withoutTemplateText
- orderBy (nameof Theme.empty.Id)
+ orderBy (nameof Theme.Empty.Id)
result; withRetryDefault conn
}
member _.Exists themeId = backgroundTask {
let! count = rethink {
withTable Table.Theme
- filter (nameof Theme.empty.Id) themeId
+ filter (nameof Theme.Empty.Id) themeId
count
result; withRetryDefault conn
}
@@ -803,12 +843,14 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
- withTable Table.Theme
- get themeId
- merge withoutTemplateText
- resultOption; withRetryOptionDefault conn
- }
+ member _.FindByIdWithoutText themeId =
+ rethink {
+ withTable Table.Theme
+ getAll [ themeId ]
+ merge withoutTemplateText
+ result; withRetryDefault
+ }
+ |> tryFirst <| conn
member this.Delete themeId = backgroundTask {
match! this.FindByIdWithoutText themeId with
@@ -837,7 +879,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.ThemeAsset
- without [ nameof ThemeAsset.empty.Data ]
+ merge (r.HashMap(nameof ThemeAsset.Empty.Data, emptyFile))
result; withRetryDefault conn
}
@@ -852,7 +894,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.ThemeAsset
filter (matchAssetByThemeId themeId)
- without [ nameof ThemeAsset.empty.Data ]
+ merge (r.HashMap(nameof ThemeAsset.Empty.Data, emptyFile))
result; withRetryDefault conn
}
@@ -886,7 +928,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger verifyWebLog webLogId (fun u -> u.WebLogId) <| conn
+ |> verifyWebLog webLogId _.WebLogId <| conn
match upload with
| Some up ->
do! rethink {
@@ -895,8 +937,8 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger return Result.Error $"Upload ID {UploadId.toString uploadId} not found"
+ return Ok (string up.Path)
+ | None -> return Result.Error $"Upload ID {uploadId} not found"
}
member _.FindByPath path webLogId =
@@ -909,15 +951,15 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.Upload
- between [| webLogId :> obj; r.Minval () |] [| webLogId :> obj; r.Maxval () |]
+ between [| webLogId :> obj; r.Minval() |] [| webLogId :> obj; r.Maxval() |]
[ Index Index.WebLogAndPath ]
- without [ nameof Upload.empty.Data ]
+ merge (r.HashMap(nameof Upload.Empty.Data, emptyFile))
resultCursor; withRetryCursorDefault; toList conn
}
member _.FindByWebLogWithData webLogId = rethink {
withTable Table.Upload
- between [| webLogId :> obj; r.Minval () |] [| webLogId :> obj; r.Maxval () |]
+ between [| webLogId :> obj; r.Minval() |] [| webLogId :> obj; r.Maxval() |]
[ Index Index.WebLogAndPath ]
resultCursor; withRetryCursorDefault; toList conn
}
@@ -926,7 +968,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger List.chunkBySize 5 do
do! rethink {
- withTable Table.TagMap
+ withTable Table.Upload
insert batch
write; withRetryOnce; ignoreResult conn
}
@@ -949,24 +991,24 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
+ let! thePostIds = rethink<{| Id: string |} list> {
withTable Table.Post
- getAll [ webLogId ] (nameof Post.empty.WebLogId)
- pluck [ nameof Post.empty.Id ]
+ getAll [ webLogId ] (nameof Post.Empty.WebLogId)
+ pluck [ nameof Post.Empty.Id ]
result; withRetryOnce conn
}
if not (List.isEmpty thePostIds) then
let postIds = thePostIds |> List.map (fun it -> it.Id :> obj)
do! rethink {
withTable Table.Comment
- getAll postIds (nameof Comment.empty.PostId)
+ getAll postIds (nameof Comment.Empty.PostId)
delete
write; withRetryOnce; ignoreResult conn
}
// Tag mappings do not have a straightforward webLogId index
do! rethink {
withTable Table.TagMap
- between [| webLogId :> obj; r.Minval () |] [| webLogId :> obj; r.Maxval () |]
+ between [| webLogId :> obj; r.Minval() |] [| webLogId :> obj; r.Maxval() |]
[ Index Index.WebLogAndTag ]
delete
write; withRetryOnce; ignoreResult conn
@@ -974,7 +1016,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger obj; r.Minval () |] [| webLogId :> obj; r.Maxval () |]
+ between [| webLogId :> obj; r.Minval() |] [| webLogId :> obj; r.Maxval() |]
[ Index Index.WebLogAndPath ]
delete
write; withRetryOnce; ignoreResult conn
@@ -982,7 +1024,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.WebLog
- getAll [ url ] (nameof WebLog.empty.UrlBase)
+ getAll [ url ] (nameof WebLog.Empty.UrlBase)
limit 1
result; withRetryDefault
}
@@ -1009,10 +1051,17 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger obj ]
+ write; withRetryDefault; ignoreResult conn
+ }
+
member _.UpdateRssOptions webLog = rethink {
withTable Table.WebLog
get webLog.Id
- update [ nameof WebLog.empty.Rss, webLog.Rss :> obj ]
+ update [ nameof WebLog.Empty.Rss, webLog.Rss :> obj ]
write; withRetryDefault; ignoreResult conn
}
@@ -1049,22 +1098,22 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger verifyWebLog webLogId (fun u -> u.WebLogId) <| conn
+ |> verifyWebLog webLogId _.WebLogId <| conn
member this.Delete userId webLogId = backgroundTask {
match! this.FindById userId webLogId with
| Some _ ->
let! pageCount = rethink {
withTable Table.Page
- getAll [ webLogId ] (nameof Page.empty.WebLogId)
- filter (nameof Page.empty.AuthorId) userId
+ getAll [ webLogId ] (nameof Page.Empty.WebLogId)
+ filter (nameof Page.Empty.AuthorId) userId
count
result; withRetryDefault conn
}
let! postCount = rethink {
withTable Table.Post
- getAll [ webLogId ] (nameof Post.empty.WebLogId)
- filter (nameof Post.empty.AuthorId) userId
+ getAll [ webLogId ] (nameof Post.Empty.WebLogId)
+ filter (nameof Post.Empty.AuthorId) userId
count
result; withRetryDefault conn
}
@@ -1092,8 +1141,8 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.WebLogUser
- getAll [ webLogId ] (nameof WebLogUser.empty.WebLogId)
- orderByFunc (fun row -> row[nameof WebLogUser.empty.PreferredName].Downcase ())
+ getAll [ webLogId ] (nameof WebLogUser.Empty.WebLogId)
+ orderByFunc (fun row -> row[nameof WebLogUser.Empty.PreferredName].Downcase())
result; withRetryDefault conn
}
@@ -1101,12 +1150,10 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
withTable Table.WebLogUser
getAll (objList userIds)
- filter (nameof WebLogUser.empty.WebLogId) webLogId
+ filter (nameof WebLogUser.Empty.WebLogId) webLogId
result; withRetryDefault conn
}
- return
- users
- |> List.map (fun u -> { Name = WebLogUserId.toString u.Id; Value = WebLogUser.displayName u })
+ return users |> List.map (fun u -> { Name = string u.Id; Value = u.DisplayName })
}
member _.Restore users = backgroundTask {
@@ -1124,7 +1171,7 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger obj ]
+ update [ nameof WebLogUser.Empty.LastSeenOn, Noda.now () :> obj ]
write; withRetryOnce; ignoreResult conn
}
| None -> ()
@@ -1169,21 +1216,19 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger {
+ let! version = rethink<{| Id: string |} list> {
withTable Table.DbVersion
limit 1
result; withRetryOnce conn
}
- match List.tryHead version with
- | Some v when v.Id = "v2-rc2" -> ()
- | it -> do! migrate (it |> Option.map (fun x -> x.Id))
+ do! migrate (List.tryHead version |> Option.map _.Id)
}
diff --git a/src/MyWebLog.Data/SQLite/Helpers.fs b/src/MyWebLog.Data/SQLite/Helpers.fs
deleted file mode 100644
index 150085f..0000000
--- a/src/MyWebLog.Data/SQLite/Helpers.fs
+++ /dev/null
@@ -1,314 +0,0 @@
-/// Helper functions for the SQLite data implementation
-[]
-module MyWebLog.Data.SQLite.Helpers
-
-open System
-open Microsoft.Data.Sqlite
-open MyWebLog
-open MyWebLog.Data
-open NodaTime.Text
-
-/// Run a command that returns a count
-let count (cmd : SqliteCommand) = backgroundTask {
- let! it = cmd.ExecuteScalarAsync ()
- return int (it :?> int64)
-}
-
-/// Create a list of items from the given data reader
-let toList<'T> (it : SqliteDataReader -> 'T) (rdr : SqliteDataReader) =
- seq { while rdr.Read () do it rdr }
- |> List.ofSeq
-
-/// Verify that the web log ID matches before returning an item
-let verifyWebLog<'T> webLogId (prop : 'T -> WebLogId) (it : SqliteDataReader -> 'T) (rdr : SqliteDataReader) =
- if rdr.Read () then
- let item = it rdr
- if prop item = webLogId then Some item else None
- else None
-
-/// 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 a Duration
-let durationParam =
- DurationPattern.Roundtrip.Format
-
-/// Create a value for an Instant
-let instantParam =
- InstantPattern.General.Format
-
-/// Create an optional value for a Duration
-let maybeDuration =
- Option.map durationParam >> maybe
-
-/// Create an optional value for an Instant
-let maybeInstant =
- Option.map instantParam >> maybe
-
-/// 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 a Duration from the given value
- let parseDuration value =
- match DurationPattern.Roundtrip.Parse value with
- | it when it.Success -> it.Value
- | it -> raise it.Exception
-
- /// Get a Duration value from a data reader
- let getDuration col rdr =
- getString col rdr |> parseDuration
-
- /// 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 Duration value from a data reader
- let tryDuration col rdr =
- tryString col rdr |> Option.map parseDuration
-
- /// Get a possibly null Instant value from a data reader
- let tryInstant col rdr =
- tryString col rdr |> Option.map parseInstant
-
- /// 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)
-
- /// Map an id field to a category ID
- let toCategoryId rdr = getString "id" rdr |> CategoryId
-
- /// Create a category from the current row in the given data reader
- let toCategory rdr : Category =
- { Id = toCategoryId rdr
- WebLogId = getString "web_log_id" rdr |> WebLogId
- Name = getString "name" rdr
- Slug = getString "slug" rdr
- Description = tryString "description" rdr
- ParentId = tryString "parent_id" rdr |> Option.map CategoryId
- }
-
- /// Create a custom feed from the current row in the given data reader
- let toCustomFeed ser rdr : CustomFeed =
- { Id = getString "id" rdr |> CustomFeedId
- Source = getString "source" rdr |> CustomFeedSource.parse
- Path = getString "path" rdr |> Permalink
- Podcast = tryString "podcast" rdr |> Option.map (Utils.deserialize ser)
- }
-
- /// Create a permalink from the current row in the given data reader
- let toPermalink rdr = getString "permalink" rdr |> Permalink
-
- /// Create a page from the current row in the given data reader
- let toPage ser rdr : Page =
- { Page.empty with
- Id = getString "id" rdr |> PageId
- WebLogId = getString "web_log_id" rdr |> WebLogId
- AuthorId = getString "author_id" rdr |> WebLogUserId
- Title = getString "title" rdr
- Permalink = toPermalink rdr
- PublishedOn = getInstant "published_on" rdr
- UpdatedOn = getInstant "updated_on" rdr
- IsInPageList = getBoolean "is_in_page_list" rdr
- Template = tryString "template" rdr
- Text = getString "page_text" rdr
- Metadata = tryString "meta_items" rdr
- |> Option.map (Utils.deserialize ser)
- |> Option.defaultValue []
- }
-
- /// Create a post from the current row in the given data reader
- let toPost ser rdr : Post =
- { Post.empty with
- Id = getString "id" rdr |> PostId
- WebLogId = getString "web_log_id" rdr |> WebLogId
- AuthorId = getString "author_id" rdr |> WebLogUserId
- Status = getString "status" rdr |> PostStatus.parse
- Title = getString "title" rdr
- Permalink = toPermalink rdr
- PublishedOn = tryInstant "published_on" rdr
- UpdatedOn = getInstant "updated_on" rdr
- Template = tryString "template" rdr
- Text = getString "post_text" rdr
- Episode = tryString "episode" rdr |> Option.map (Utils.deserialize ser)
- Metadata = tryString "meta_items" rdr
- |> Option.map (Utils.deserialize ser)
- |> Option.defaultValue []
- }
-
- /// 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 tag mapping from the current row in the given data reader
- let toTagMap rdr : TagMap =
- { Id = getString "id" rdr |> TagMapId
- WebLogId = getString "web_log_id" rdr |> WebLogId
- Tag = getString "tag" rdr
- UrlValue = getString "url_value" rdr
- }
-
- /// Create a theme from the current row in the given data reader (excludes templates)
- let toTheme rdr : Theme =
- { Theme.empty with
- Id = getString "id" rdr |> ThemeId
- Name = getString "name" rdr
- Version = getString "version" rdr
- }
-
- /// 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 a theme template from the current row in the given data reader
- let toThemeTemplate includeText rdr : ThemeTemplate =
- { Name = getString "name" rdr
- Text = if includeText then getString "template" rdr else ""
- }
-
- /// 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 web log from the current row in the given data reader
- let toWebLog rdr : WebLog =
- { Id = getString "id" rdr |> WebLogId
- Name = getString "name" rdr
- Slug = getString "slug" rdr
- Subtitle = tryString "subtitle" rdr
- DefaultPage = getString "default_page" rdr
- PostsPerPage = getInt "posts_per_page" rdr
- ThemeId = getString "theme_id" rdr |> ThemeId
- UrlBase = getString "url_base" rdr
- TimeZone = getString "time_zone" rdr
- AutoHtmx = getBoolean "auto_htmx" rdr
- Uploads = getString "uploads" rdr |> UploadDestination.parse
- Rss = {
- IsFeedEnabled = getBoolean "is_feed_enabled" rdr
- FeedName = getString "feed_name" rdr
- ItemsInFeed = tryInt "items_in_feed" rdr
- IsCategoryEnabled = getBoolean "is_category_enabled" rdr
- IsTagEnabled = getBoolean "is_tag_enabled" rdr
- Copyright = tryString "copyright" rdr
- CustomFeeds = []
- }
- }
-
- /// Create a web log user from the current row in the given data reader
- let toWebLogUser rdr : WebLogUser =
- { Id = getString "id" rdr |> WebLogUserId
- WebLogId = getString "web_log_id" rdr |> WebLogId
- Email = getString "email" rdr
- FirstName = getString "first_name" rdr
- LastName = getString "last_name" rdr
- PreferredName = getString "preferred_name" rdr
- PasswordHash = getString "password_hash" rdr
- Url = tryString "url" rdr
- AccessLevel = getString "access_level" rdr |> AccessLevel.parse
- CreatedOn = getInstant "created_on" rdr
- LastSeenOn = tryInstant "last_seen_on" rdr
- }
-
-/// Add a web log ID parameter
-let addWebLogId (cmd : SqliteCommand) webLogId =
- cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString webLogId) |> ignore
diff --git a/src/MyWebLog.Data/SQLite/SQLiteCategoryData.fs b/src/MyWebLog.Data/SQLite/SQLiteCategoryData.fs
index 75728b8..4cf6104 100644
--- a/src/MyWebLog.Data/SQLite/SQLiteCategoryData.fs
+++ b/src/MyWebLog.Data/SQLite/SQLiteCategoryData.fs
@@ -1,69 +1,43 @@
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) =
+/// SQLite myWebLog category data implementation
+type SQLiteCategoryData(conn: SqliteConnection, ser: JsonSerializer, log: ILogger) =
- /// Add parameters for category INSERT or UPDATE statements
- let addCategoryParameters (cmd : SqliteCommand) (cat : Category) =
- [ cmd.Parameters.AddWithValue ("@id", CategoryId.toString cat.Id)
- cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString cat.WebLogId)
- cmd.Parameters.AddWithValue ("@name", cat.Name)
- cmd.Parameters.AddWithValue ("@slug", cat.Slug)
- cmd.Parameters.AddWithValue ("@description", maybe cat.Description)
- cmd.Parameters.AddWithValue ("@parentId", maybe (cat.ParentId |> Option.map CategoryId.toString))
- ] |> ignore
-
- /// Add a category
- let add cat = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <-
- "INSERT INTO category (
- id, web_log_id, name, slug, description, parent_id
- ) VALUES (
- @id, @webLogId, @name, @slug, @description, @parentId
- )"
- addCategoryParameters cmd cat
- let! _ = cmd.ExecuteNonQueryAsync ()
- ()
- }
+ /// The name of the parent ID field
+ let parentIdField = nameof Category.Empty.ParentId
/// Count all categories for the given web log
- let countAll webLogId = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT COUNT(id) FROM category WHERE web_log_id = @webLogId"
- addWebLogId cmd webLogId
- return! count cmd
- }
+ 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 = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <-
- "SELECT COUNT(id) FROM category WHERE web_log_id = @webLogId AND parent_id IS NULL"
- addWebLogId cmd webLogId
- return! count cmd
- }
+ 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 Table.Category webLogId conn
/// Retrieve all categories for the given web log in a DotLiquid-friendly format
let findAllForView webLogId = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT * FROM category WHERE web_log_id = @webLogId"
- addWebLogId cmd webLogId
- use! rdr = cmd.ExecuteReaderAsync ()
- let cats =
- seq {
- while rdr.Read () do
- Map.toCategory rdr
- }
- |> Seq.sortBy (fun cat -> cat.Name.ToLowerInvariant ())
- |> List.ofSeq
- do! rdr.CloseAsync ()
- let ordered = Utils.orderByHierarchy cats None None []
+ 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 {
@@ -71,104 +45,80 @@ type SQLiteCategoryData (conn : SqliteConnection) =
let catSql, catParams =
ordered
|> Seq.filter (fun cat -> cat.ParentNames |> Array.contains it.Name)
- |> Seq.map (fun cat -> cat.Id)
+ |> Seq.map _.Id
|> Seq.append (Seq.singleton it.Id)
|> List.ofSeq
- |> inClause "AND pc.category_id" "catId" id
- cmd.Parameters.Clear ()
- addWebLogId cmd webLogId
- cmd.Parameters.AddRange catParams
- cmd.CommandText <- $"
- SELECT COUNT(DISTINCT p.id)
- FROM post p
- INNER JOIN post_category pc ON pc.post_id = p.id
- WHERE p.web_log_id = @webLogId
- AND p.status = 'Published'
- {catSql}"
- let! postCount = count cmd
- return it.Id, postCount
- })
+ |> 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 = counts
- |> Array.tryFind (fun c -> fst c = cat.Id)
- |> Option.map snd
- |> Option.defaultValue 0
+ 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 = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT * FROM category WHERE id = @id"
- cmd.Parameters.AddWithValue ("@id", CategoryId.toString catId) |> ignore
- use! rdr = cmd.ExecuteReaderAsync ()
- return Helpers.verifyWebLog webLogId (fun c -> c.WebLogId) Map.toCategory rdr
- }
- /// Find all categories for the given web log
- let findByWebLog webLogId = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT * FROM category WHERE web_log_id = @webLogId"
- cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString webLogId) |> ignore
- use! rdr = cmd.ExecuteReaderAsync ()
- return toList Map.toCategory rdr
- }
+ /// Find a category by its ID for the given web log
+ let findById catId webLogId =
+ log.LogTrace "Category.findById"
+ Document.findByIdAndWebLog Table.Category catId webLogId conn
/// Delete a category
let delete catId webLogId = backgroundTask {
+ log.LogTrace "Category.delete"
match! findById catId webLogId with
| Some cat ->
- use cmd = conn.CreateCommand ()
// Reassign any children to the category's parent category
- cmd.CommandText <- "SELECT COUNT(id) FROM category WHERE parent_id = @parentId"
- cmd.Parameters.AddWithValue ("@parentId", CategoryId.toString catId) |> ignore
- let! children = count cmd
- if children > 0 then
- cmd.CommandText <- "UPDATE category SET parent_id = @newParentId WHERE parent_id = @parentId"
- cmd.Parameters.AddWithValue ("@newParentId", maybe (cat.ParentId |> Option.map CategoryId.toString))
- |> ignore
- do! write cmd
+ 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
- cmd.CommandText <-
- "DELETE FROM post_category
- WHERE category_id = @id
- AND post_id IN (SELECT id FROM post WHERE web_log_id = @webLogId);
- DELETE FROM category WHERE id = @id"
- cmd.Parameters.Clear ()
- let _ = cmd.Parameters.AddWithValue ("@id", CategoryId.toString catId)
- addWebLogId cmd webLogId
- do! write cmd
- return if children = 0 then CategoryDeleted else ReassignedChildCategories
+ 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 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 Table.Category cat
+
/// Restore categories from a backup
let restore cats = backgroundTask {
- for cat in cats do
- do! add cat
- }
-
- /// Update a category
- let update cat = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <-
- "UPDATE category
- SET name = @name,
- slug = @slug,
- description = @description,
- parent_id = @parentId
- WHERE id = @id
- AND web_log_id = @webLogId"
- addCategoryParameters cmd cat
- do! write cmd
+ log.LogTrace "Category.restore"
+ for cat in cats do do! save cat
}
interface ICategoryData with
- member _.Add cat = add cat
+ member _.Add cat = save cat
member _.CountAll webLogId = countAll webLogId
member _.CountTopLevel webLogId = countTopLevel webLogId
member _.FindAllForView webLogId = findAllForView webLogId
@@ -176,4 +126,4 @@ type SQLiteCategoryData (conn : SqliteConnection) =
member _.FindByWebLog webLogId = findByWebLog webLogId
member _.Delete catId webLogId = delete catId webLogId
member _.Restore cats = restore cats
- member _.Update cat = update cat
+ member _.Update cat = save cat
diff --git a/src/MyWebLog.Data/SQLite/SQLiteHelpers.fs b/src/MyWebLog.Data/SQLite/SQLiteHelpers.fs
new file mode 100644
index 0000000..bfb8952
--- /dev/null
+++ b/src/MyWebLog.Data/SQLite/SQLiteHelpers.fs
@@ -0,0 +1,307 @@
+/// Helper functions for the SQLite data implementation
+[]
+module MyWebLog.Data.SQLite.SQLiteHelpers
+
+/// The table names used in the SQLite implementation
+[]
+module Table =
+
+ /// Categories
+ []
+ let Category = "category"
+
+ /// Database Version
+ []
+ let DbVersion = "db_version"
+
+ /// Pages
+ []
+ let Page = "page"
+
+ /// Page Revisions
+ []
+ let PageRevision = "page_revision"
+
+ /// Posts
+ []
+ let Post = "post"
+
+ /// Post Comments
+ []
+ let PostComment = "post_comment"
+
+ /// Post Revisions
+ []
+ let PostRevision = "post_revision"
+
+ /// Tag/URL Mappings
+ []
+ let TagMap = "tag_map"
+
+ /// Themes
+ []
+ let Theme = "theme"
+
+ /// Theme Assets
+ []
+ let ThemeAsset = "theme_asset"
+
+ /// Uploads
+ []
+ let Upload = "upload"
+
+ /// Web Logs
+ []
+ let WebLog = "web_log"
+
+ /// Users
+ []
+ 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
+ }
diff --git a/src/MyWebLog.Data/SQLite/SQLitePageData.fs b/src/MyWebLog.Data/SQLite/SQLitePageData.fs
index 5562bcc..17af376 100644
--- a/src/MyWebLog.Data/SQLite/SQLitePageData.fs
+++ b/src/MyWebLog.Data/SQLite/SQLitePageData.fs
@@ -1,300 +1,173 @@
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 page data implementation
-type SQLitePageData (conn : SqliteConnection, ser : JsonSerializer) =
+/// 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
- /// Add parameters for page INSERT or UPDATE statements
- let addPageParameters (cmd : SqliteCommand) (page : Page) =
- [ cmd.Parameters.AddWithValue ("@id", PageId.toString page.Id)
- cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString page.WebLogId)
- cmd.Parameters.AddWithValue ("@authorId", WebLogUserId.toString page.AuthorId)
- cmd.Parameters.AddWithValue ("@title", page.Title)
- cmd.Parameters.AddWithValue ("@permalink", Permalink.toString page.Permalink)
- cmd.Parameters.AddWithValue ("@publishedOn", instantParam page.PublishedOn)
- cmd.Parameters.AddWithValue ("@updatedOn", instantParam page.UpdatedOn)
- cmd.Parameters.AddWithValue ("@isInPageList", page.IsInPageList)
- cmd.Parameters.AddWithValue ("@template", maybe page.Template)
- cmd.Parameters.AddWithValue ("@text", page.Text)
- cmd.Parameters.AddWithValue ("@metaItems", maybe (if List.isEmpty page.Metadata then None
- else Some (Utils.serialize ser page.Metadata)))
- ] |> ignore
-
- /// Append revisions and permalinks to a page
- let appendPageRevisionsAndPermalinks (page : Page) = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.Parameters.AddWithValue ("@pageId", PageId.toString page.Id) |> ignore
-
- cmd.CommandText <- "SELECT permalink FROM page_permalink WHERE page_id = @pageId"
- use! rdr = cmd.ExecuteReaderAsync ()
- let page = { page with PriorPermalinks = toList Map.toPermalink rdr }
- do! rdr.CloseAsync ()
-
- cmd.CommandText <- "SELECT as_of, revision_text FROM page_revision WHERE page_id = @pageId ORDER BY as_of DESC"
- use! rdr = cmd.ExecuteReaderAsync ()
- return { page with Revisions = toList Map.toRevision rdr }
+ /// 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 }
}
- /// Shorthand for mapping a data reader to a page
- let toPage =
- Map.toPage ser
-
- /// Return a page with no text (or prior permalinks or revisions)
- let pageWithoutText rdr =
- { toPage rdr with Text = "" }
-
- /// Update a page's prior permalinks
- let updatePagePermalinks pageId oldLinks newLinks = backgroundTask {
- let toDelete, toAdd = Utils.diffPermalinks oldLinks newLinks
- if List.isEmpty toDelete && List.isEmpty toAdd then
- return ()
- else
- use cmd = conn.CreateCommand ()
- [ cmd.Parameters.AddWithValue ("@pageId", PageId.toString pageId)
- cmd.Parameters.Add ("@link", SqliteType.Text)
- ] |> ignore
- let runCmd link = backgroundTask {
- cmd.Parameters["@link"].Value <- Permalink.toString link
- do! write cmd
- }
- cmd.CommandText <- "DELETE FROM page_permalink WHERE page_id = @pageId AND permalink = @link"
- toDelete
- |> List.map runCmd
- |> Task.WhenAll
- |> ignore
- cmd.CommandText <- "INSERT INTO page_permalink VALUES (@pageId, @link)"
- toAdd
- |> List.map runCmd
- |> Task.WhenAll
- |> ignore
- }
+ /// Create a page with no prior permalinks
+ let pageWithoutLinks rdr =
+ { fromData rdr with PriorPermalinks = [] }
/// Update a page's revisions
- let updatePageRevisions pageId oldRevs newRevs = backgroundTask {
- let toDelete, toAdd = Utils.diffRevisions oldRevs newRevs
- if List.isEmpty toDelete && List.isEmpty toAdd then
- return ()
- else
- use cmd = conn.CreateCommand ()
- let runCmd withText rev = backgroundTask {
- cmd.Parameters.Clear ()
- [ cmd.Parameters.AddWithValue ("@pageId", PageId.toString pageId)
- cmd.Parameters.AddWithValue ("@asOf", instantParam rev.AsOf)
- ] |> ignore
- if withText then cmd.Parameters.AddWithValue ("@text", MarkupText.toString rev.Text) |> ignore
- do! write cmd
- }
- cmd.CommandText <- "DELETE FROM page_revision WHERE page_id = @pageId AND as_of = @asOf"
- toDelete
- |> List.map (runCmd false)
- |> Task.WhenAll
- |> ignore
- cmd.CommandText <- "INSERT INTO page_revision VALUES (@pageId, @asOf, @text)"
- toAdd
- |> List.map (runCmd true)
- |> Task.WhenAll
- |> ignore
- }
+ 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 = backgroundTask {
- use cmd = conn.CreateCommand ()
- // The page itself
- cmd.CommandText <-
- "INSERT INTO page (
- id, web_log_id, author_id, title, permalink, published_on, updated_on, is_in_page_list, template,
- page_text, meta_items
- ) VALUES (
- @id, @webLogId, @authorId, @title, @permalink, @publishedOn, @updatedOn, @isInPageList, @template,
- @text, @metaItems
- )"
- addPageParameters cmd page
- do! write cmd
- do! updatePagePermalinks page.Id [] page.PriorPermalinks
- do! updatePageRevisions page.Id [] page.Revisions
+ 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, revisions, prior permalinks, or metadata)
- let all webLogId = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT * FROM page WHERE web_log_id = @webLogId ORDER BY LOWER(title)"
- addWebLogId cmd webLogId
- use! rdr = cmd.ExecuteReaderAsync ()
- return toList pageWithoutText rdr
- }
+ /// 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 rdr with Text = ""; Metadata = []; PriorPermalinks = [] })
/// Count all pages for the given web log
- let countAll webLogId = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT COUNT(id) FROM page WHERE web_log_id = @webLogId"
- addWebLogId cmd webLogId
- return! count cmd
- }
+ 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 = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <-
- "SELECT COUNT(id)
- FROM page
- WHERE web_log_id = @webLogId
- AND is_in_page_list = @isInPageList"
- addWebLogId cmd webLogId
- cmd.Parameters.AddWithValue ("@isInPageList", true) |> ignore
- return! count cmd
- }
+ 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 {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT * FROM page WHERE id = @id"
- cmd.Parameters.AddWithValue ("@id", PageId.toString pageId) |> ignore
- use! rdr = cmd.ExecuteReaderAsync ()
- return Helpers.verifyWebLog webLogId (fun it -> it.WebLogId) (Map.toPage ser) rdr
+ log.LogTrace "Page.findById"
+ match! Document.findByIdAndWebLog 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 {
- match! findById pageId webLogId with
+ log.LogTrace "Page.findFullById"
+ match! Document.findByIdAndWebLog Table.Page pageId webLogId conn with
| Some page ->
- let! page = appendPageRevisionsAndPermalinks 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 _ ->
- use cmd = conn.CreateCommand ()
- cmd.Parameters.AddWithValue ("@id", PageId.toString pageId) |> ignore
- cmd.CommandText <-
- "DELETE FROM page_revision WHERE page_id = @id;
- DELETE FROM page_permalink WHERE page_id = @id;
- DELETE FROM page WHERE id = @id"
- do! write cmd
+ 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 webLogId = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT * FROM page WHERE web_log_id = @webLogId AND permalink = @link"
- addWebLogId cmd webLogId
- cmd.Parameters.AddWithValue ("@link", Permalink.toString permalink) |> ignore
- use! rdr = cmd.ExecuteReaderAsync ()
- return if rdr.Read () then Some (toPage rdr) else None
- }
+ 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 webLogId = backgroundTask {
- use cmd = conn.CreateCommand ()
- let linkSql, linkParams = inClause "AND pp.permalink" "link" Permalink.toString permalinks
- cmd.CommandText <- $"
- SELECT p.permalink
- FROM page p
- INNER JOIN page_permalink pp ON pp.page_id = p.id
- WHERE p.web_log_id = @webLogId
- {linkSql}"
- addWebLogId cmd webLogId
- cmd.Parameters.AddRange linkParams
- use! rdr = cmd.ExecuteReaderAsync ()
- return if rdr.Read () then Some (Map.toPermalink rdr) else None
- }
+ 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 {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT * FROM page WHERE web_log_id = @webLogId"
- addWebLogId cmd webLogId
- use! rdr = cmd.ExecuteReaderAsync ()
- let! pages =
- toList toPage rdr
- |> List.map (fun page -> backgroundTask { return! appendPageRevisionsAndPermalinks page })
- |> Task.WhenAll
- return List.ofArray pages
+ log.LogTrace "Page.findFullByWebLog"
+ let! pages = Document.findByWebLog 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, prior permalinks, or text)
- let findListed webLogId = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <-
- "SELECT *
- FROM page
- WHERE web_log_id = @webLogId
- AND is_in_page_list = @isInPageList
- ORDER BY LOWER(title)"
- addWebLogId cmd webLogId
- cmd.Parameters.AddWithValue ("@isInPageList", true) |> ignore
- use! rdr = cmd.ExecuteReaderAsync ()
- return toList pageWithoutText rdr
- }
+ /// 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 rdr with Text = "" })
- /// Get a page of pages for the given web log (without revisions, prior permalinks, or metadata)
- let findPageOfPages webLogId pageNbr = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <-
- "SELECT *
- FROM page
- WHERE web_log_id = @webLogId
- ORDER BY LOWER(title)
- LIMIT @pageSize OFFSET @toSkip"
- addWebLogId cmd webLogId
- [ cmd.Parameters.AddWithValue ("@pageSize", 26)
- cmd.Parameters.AddWithValue ("@toSkip", (pageNbr - 1) * 25)
- ] |> ignore
- use! rdr = cmd.ExecuteReaderAsync ()
- return toList toPage rdr
+ /// 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 {
- for page in pages do
- do! add page
- }
-
- /// Update a page
- let update (page : Page) = backgroundTask {
- match! findFullById page.Id page.WebLogId with
- | Some oldPage ->
- use cmd = conn.CreateCommand ()
- cmd.CommandText <-
- "UPDATE page
- SET author_id = @authorId,
- title = @title,
- permalink = @permalink,
- published_on = @publishedOn,
- updated_on = @updatedOn,
- is_in_page_list = @isInPageList,
- template = @template,
- page_text = @text,
- meta_items = @metaItems
- WHERE id = @id
- AND web_log_id = @webLogId"
- addPageParameters cmd page
- do! write cmd
- do! updatePagePermalinks page.Id oldPage.PriorPermalinks page.PriorPermalinks
- do! updatePageRevisions page.Id oldPage.Revisions page.Revisions
- return ()
- | None -> return ()
+ log.LogTrace "Page.restore"
+ for page in pages do do! add page
}
/// Update a page's prior permalinks
- let updatePriorPermalinks pageId webLogId permalinks = backgroundTask {
- match! findFullById pageId webLogId with
- | Some page ->
- do! updatePagePermalinks pageId page.PriorPermalinks 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
+ | None -> return false
}
interface IPageData with
diff --git a/src/MyWebLog.Data/SQLite/SQLitePostData.fs b/src/MyWebLog.Data/SQLite/SQLitePostData.fs
index 257bdf7..cc2063e 100644
--- a/src/MyWebLog.Data/SQLite/SQLitePostData.fs
+++ b/src/MyWebLog.Data/SQLite/SQLitePostData.fs
@@ -1,467 +1,215 @@
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
open NodaTime
-/// SQLite myWebLog post data implementation
-type SQLitePostData (conn : SqliteConnection, ser : JsonSerializer) =
-
+/// 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
- /// Add parameters for post INSERT or UPDATE statements
- let addPostParameters (cmd : SqliteCommand) (post : Post) =
- [ cmd.Parameters.AddWithValue ("@id", PostId.toString post.Id)
- cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString post.WebLogId)
- cmd.Parameters.AddWithValue ("@authorId", WebLogUserId.toString post.AuthorId)
- cmd.Parameters.AddWithValue ("@status", PostStatus.toString post.Status)
- cmd.Parameters.AddWithValue ("@title", post.Title)
- cmd.Parameters.AddWithValue ("@permalink", Permalink.toString post.Permalink)
- cmd.Parameters.AddWithValue ("@publishedOn", maybeInstant post.PublishedOn)
- cmd.Parameters.AddWithValue ("@updatedOn", instantParam post.UpdatedOn)
- cmd.Parameters.AddWithValue ("@template", maybe post.Template)
- cmd.Parameters.AddWithValue ("@text", post.Text)
- cmd.Parameters.AddWithValue ("@episode", maybe (if Option.isSome post.Episode then
- Some (Utils.serialize ser post.Episode)
- else None))
- cmd.Parameters.AddWithValue ("@metaItems", maybe (if List.isEmpty post.Metadata then None
- else Some (Utils.serialize ser post.Metadata)))
- ] |> ignore
-
- /// Append category IDs and tags to a post
- let appendPostCategoryAndTag (post : Post) = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.Parameters.AddWithValue ("@id", PostId.toString post.Id) |> ignore
-
- cmd.CommandText <- "SELECT category_id AS id FROM post_category WHERE post_id = @id"
- use! rdr = cmd.ExecuteReaderAsync ()
- let post = { post with CategoryIds = toList Map.toCategoryId rdr }
- do! rdr.CloseAsync ()
-
- cmd.CommandText <- "SELECT tag FROM post_tag WHERE post_id = @id"
- use! rdr = cmd.ExecuteReaderAsync ()
- return { post with Tags = toList (Map.getString "tag") rdr }
+ /// 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 }
}
- /// Append revisions and permalinks to a post
- let appendPostRevisionsAndPermalinks (post : Post) = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.Parameters.AddWithValue ("@postId", PostId.toString post.Id) |> ignore
-
- cmd.CommandText <- "SELECT permalink FROM post_permalink WHERE post_id = @postId"
- use! rdr = cmd.ExecuteReaderAsync ()
- let post = { post with PriorPermalinks = toList Map.toPermalink rdr }
- do! rdr.CloseAsync ()
-
- cmd.CommandText <- "SELECT as_of, revision_text FROM post_revision WHERE post_id = @postId ORDER BY as_of DESC"
- use! rdr = cmd.ExecuteReaderAsync ()
- return { post with Revisions = toList Map.toRevision rdr }
- }
+ /// The SELECT statement to retrieve posts with a web log ID parameter
+ let postByWebLog = Document.Query.selectByWebLog Table.Post
- /// The SELECT statement for a post that will include episode data, if it exists
- let selectPost = "SELECT p.* FROM post p"
-
- /// Shorthand for mapping a data reader to a post
- let toPost =
- Map.toPost ser
-
- /// Find just-the-post by its ID for the given web log (excludes category, tag, meta, revisions, and permalinks)
- let findPostById postId webLogId = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- $"{selectPost} WHERE p.id = @id"
- cmd.Parameters.AddWithValue ("@id", PostId.toString postId) |> ignore
- use! rdr = cmd.ExecuteReaderAsync ()
- return Helpers.verifyWebLog webLogId (fun p -> p.WebLogId) toPost rdr
- }
+ /// Return a post with no revisions or prior permalinks
+ let postWithoutLinks rdr =
+ { fromData rdr with PriorPermalinks = [] }
/// Return a post with no revisions, prior permalinks, or text
let postWithoutText rdr =
- { toPost rdr with Text = "" }
+ { postWithoutLinks rdr with Text = "" }
- /// Update a post's assigned categories
- let updatePostCategories postId oldCats newCats = backgroundTask {
- let toDelete, toAdd = Utils.diffLists oldCats newCats CategoryId.toString
- if List.isEmpty toDelete && List.isEmpty toAdd then
- return ()
- else
- use cmd = conn.CreateCommand ()
- [ cmd.Parameters.AddWithValue ("@postId", PostId.toString postId)
- cmd.Parameters.Add ("@categoryId", SqliteType.Text)
- ] |> ignore
- let runCmd catId = backgroundTask {
- cmd.Parameters["@categoryId"].Value <- CategoryId.toString catId
- do! write cmd
- }
- cmd.CommandText <- "DELETE FROM post_category WHERE post_id = @postId AND category_id = @categoryId"
- toDelete
- |> List.map runCmd
- |> Task.WhenAll
- |> ignore
- cmd.CommandText <- "INSERT INTO post_category VALUES (@postId, @categoryId)"
- toAdd
- |> List.map runCmd
- |> Task.WhenAll
- |> ignore
- }
-
- /// Update a post's assigned categories
- let updatePostTags postId (oldTags : string list) newTags = backgroundTask {
- let toDelete, toAdd = Utils.diffLists oldTags newTags id
- if List.isEmpty toDelete && List.isEmpty toAdd then
- return ()
- else
- use cmd = conn.CreateCommand ()
- [ cmd.Parameters.AddWithValue ("@postId", PostId.toString postId)
- cmd.Parameters.Add ("@tag", SqliteType.Text)
- ] |> ignore
- let runCmd (tag : string) = backgroundTask {
- cmd.Parameters["@tag"].Value <- tag
- do! write cmd
- }
- cmd.CommandText <- "DELETE FROM post_tag WHERE post_id = @postId AND tag = @tag"
- toDelete
- |> List.map runCmd
- |> Task.WhenAll
- |> ignore
- cmd.CommandText <- "INSERT INTO post_tag VALUES (@postId, @tag)"
- toAdd
- |> List.map runCmd
- |> Task.WhenAll
- |> ignore
- }
-
- /// Update a post's prior permalinks
- let updatePostPermalinks postId oldLinks newLinks = backgroundTask {
- let toDelete, toAdd = Utils.diffPermalinks oldLinks newLinks
- if List.isEmpty toDelete && List.isEmpty toAdd then
- return ()
- else
- use cmd = conn.CreateCommand ()
- [ cmd.Parameters.AddWithValue ("@postId", PostId.toString postId)
- cmd.Parameters.Add ("@link", SqliteType.Text)
- ] |> ignore
- let runCmd link = backgroundTask {
- cmd.Parameters["@link"].Value <- Permalink.toString link
- do! write cmd
- }
- cmd.CommandText <- "DELETE FROM post_permalink WHERE post_id = @postId AND permalink = @link"
- toDelete
- |> List.map runCmd
- |> Task.WhenAll
- |> ignore
- cmd.CommandText <- "INSERT INTO post_permalink VALUES (@postId, @link)"
- toAdd
- |> List.map runCmd
- |> Task.WhenAll
- |> ignore
- }
+ /// 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 oldRevs newRevs = backgroundTask {
- let toDelete, toAdd = Utils.diffRevisions oldRevs newRevs
- if List.isEmpty toDelete && List.isEmpty toAdd then
- return ()
- else
- use cmd = conn.CreateCommand ()
- let runCmd withText rev = backgroundTask {
- cmd.Parameters.Clear ()
- [ cmd.Parameters.AddWithValue ("@postId", PostId.toString postId)
- cmd.Parameters.AddWithValue ("@asOf", instantParam rev.AsOf)
- ] |> ignore
- if withText then cmd.Parameters.AddWithValue ("@text", MarkupText.toString rev.Text) |> ignore
- do! write cmd
- }
- cmd.CommandText <- "DELETE FROM post_revision WHERE post_id = @postId AND as_of = @asOf"
- toDelete
- |> List.map (runCmd false)
- |> Task.WhenAll
- |> ignore
- cmd.CommandText <- "INSERT INTO post_revision VALUES (@postId, @asOf, @text)"
- toAdd
- |> List.map (runCmd true)
- |> Task.WhenAll
- |> ignore
- }
+ 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 = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <-
- "INSERT INTO post (
- id, web_log_id, author_id, status, title, permalink, published_on, updated_on, template, post_text,
- episode, meta_items
- ) VALUES (
- @id, @webLogId, @authorId, @status, @title, @permalink, @publishedOn, @updatedOn, @template, @text,
- @episode, @metaItems
- )"
- addPostParameters cmd post
- do! write cmd
- do! updatePostCategories post.Id [] post.CategoryIds
- do! updatePostTags post.Id [] post.Tags
- do! updatePostPermalinks post.Id [] post.PriorPermalinks
- do! updatePostRevisions post.Id [] post.Revisions
+ 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 webLogId = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT COUNT(id) FROM post WHERE web_log_id = @webLogId AND status = @status"
- addWebLogId cmd webLogId
- cmd.Parameters.AddWithValue ("@status", PostStatus.toString status) |> ignore
- return! count cmd
- }
+ 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 and prior permalinks
+ /// Find a post by its ID for the given web log (excluding revisions)
let findById postId webLogId = backgroundTask {
- match! findPostById postId webLogId with
- | Some post ->
- let! post = appendPostCategoryAndTag post
- return Some post
+ log.LogTrace "Post.findById"
+ match! Document.findByIdAndWebLog 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 and prior permalinks)
- let findByPermalink permalink webLogId = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- $"{selectPost} WHERE p.web_log_id = @webLogId AND p.permalink = @link"
- addWebLogId cmd webLogId
- cmd.Parameters.AddWithValue ("@link", Permalink.toString permalink) |> ignore
- use! rdr = cmd.ExecuteReaderAsync ()
- if rdr.Read () then
- let! post = appendPostCategoryAndTag (toPost rdr)
- return Some post
- else
- 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 {
- match! findById postId webLogId with
+ log.LogTrace "Post.findFullById"
+ match! Document.findByIdAndWebLog Table.Post postId webLogId conn with
| Some post ->
- let! post = appendPostRevisionsAndPermalinks 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 {
- match! findFullById postId webLogId with
+ log.LogTrace "Post.delete"
+ match! findById postId webLogId with
| Some _ ->
- use cmd = conn.CreateCommand ()
- cmd.Parameters.AddWithValue ("@id", PostId.toString postId) |> ignore
- cmd.CommandText <-
- "DELETE FROM post_revision WHERE post_id = @id;
- DELETE FROM post_permalink WHERE post_id = @id;
- DELETE FROM post_tag WHERE post_id = @id;
- DELETE FROM post_category WHERE post_id = @id;
- DELETE FROM post_comment WHERE post_id = @id;
- DELETE FROM post WHERE id = @id"
- do! write cmd
+ 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 webLogId = backgroundTask {
- use cmd = conn.CreateCommand ()
- let linkSql, linkParams = inClause "AND pp.permalink" "link" Permalink.toString permalinks
- cmd.CommandText <- $"
- SELECT p.permalink
- FROM post p
- INNER JOIN post_permalink pp ON pp.post_id = p.id
- WHERE p.web_log_id = @webLogId
- {linkSql}"
- addWebLogId cmd webLogId
- cmd.Parameters.AddRange linkParams
- use! rdr = cmd.ExecuteReaderAsync ()
- return if rdr.Read () then Some (Map.toPermalink rdr) else None
- }
+ 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 {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- $"{selectPost} WHERE p.web_log_id = @webLogId"
- addWebLogId cmd webLogId
- use! rdr = cmd.ExecuteReaderAsync ()
- let! posts =
- toList toPost rdr
- |> List.map (fun post -> backgroundTask {
- let! post = appendPostCategoryAndTag post
- return! appendPostRevisionsAndPermalinks post
- })
- |> Task.WhenAll
- return List.ofArray posts
+ log.LogTrace "Post.findFullByWebLog"
+ let! posts = Document.findByWebLog 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 and prior permalinks)
- let findPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage = backgroundTask {
- use cmd = conn.CreateCommand ()
- let catSql, catParams = inClause "AND pc.category_id" "catId" CategoryId.toString categoryIds
- cmd.CommandText <- $"
- {selectPost}
- INNER JOIN post_category pc ON pc.post_id = p.id
- WHERE p.web_log_id = @webLogId
- AND p.status = @status
- {catSql}
- ORDER BY published_on DESC
- LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
- addWebLogId cmd webLogId
- cmd.Parameters.AddWithValue ("@status", PostStatus.toString Published) |> ignore
- cmd.Parameters.AddRange catParams
- use! rdr = cmd.ExecuteReaderAsync ()
- let! posts =
- toList toPost rdr
- |> List.map (fun post -> backgroundTask { return! appendPostCategoryAndTag post })
- |> Task.WhenAll
- return List.ofArray posts
- }
+ /// 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, revisions, and prior permalinks)
- let findPageOfPosts webLogId pageNbr postsPerPage = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- $"
- {selectPost}
- WHERE p.web_log_id = @webLogId
- ORDER BY p.published_on DESC NULLS FIRST, p.updated_on
- LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
- addWebLogId cmd webLogId
- use! rdr = cmd.ExecuteReaderAsync ()
- let! posts =
- toList postWithoutText rdr
- |> List.map (fun post -> backgroundTask { return! appendPostCategoryAndTag post })
- |> Task.WhenAll
- return List.ofArray posts
- }
+ /// 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 and prior permalinks)
- let findPageOfPublishedPosts webLogId pageNbr postsPerPage = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- $"
- {selectPost}
- WHERE p.web_log_id = @webLogId
- AND p.status = @status
- ORDER BY p.published_on DESC
- LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
- addWebLogId cmd webLogId
- cmd.Parameters.AddWithValue ("@status", PostStatus.toString Published) |> ignore
- use! rdr = cmd.ExecuteReaderAsync ()
- let! posts =
- toList toPost rdr
- |> List.map (fun post -> backgroundTask { return! appendPostCategoryAndTag post })
- |> Task.WhenAll
- return List.ofArray posts
- }
+ /// 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 and prior permalinks)
- let findPageOfTaggedPosts webLogId (tag : string) pageNbr postsPerPage = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- $"
- {selectPost}
- INNER JOIN post_tag pt ON pt.post_id = p.id
- WHERE p.web_log_id = @webLogId
- AND p.status = @status
- AND pt.tag = @tag
- ORDER BY p.published_on DESC
- LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}"
- addWebLogId cmd webLogId
- [ cmd.Parameters.AddWithValue ("@status", PostStatus.toString Published)
- cmd.Parameters.AddWithValue ("@tag", tag)
- ] |> ignore
- use! rdr = cmd.ExecuteReaderAsync ()
- let! posts =
- toList toPost rdr
- |> List.map (fun post -> backgroundTask { return! appendPostCategoryAndTag post })
- |> Task.WhenAll
- return List.ofArray posts
- }
+ /// 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 {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- $"
- {selectPost}
- WHERE p.web_log_id = @webLogId
- AND p.status = @status
- AND p.published_on < @publishedOn
- ORDER BY p.published_on DESC
- LIMIT 1"
- addWebLogId cmd webLogId
- [ cmd.Parameters.AddWithValue ("@status", PostStatus.toString Published)
- cmd.Parameters.AddWithValue ("@publishedOn", instantParam publishedOn)
- ] |> ignore
- use! rdr = cmd.ExecuteReaderAsync ()
- let! older = backgroundTask {
- if rdr.Read () then
- let! post = appendPostCategoryAndTag (postWithoutText rdr)
- return Some post
- else
- return None
- }
- do! rdr.CloseAsync ()
- cmd.CommandText <- $"
- {selectPost}
- WHERE p.web_log_id = @webLogId
- AND p.status = @status
- AND p.published_on > @publishedOn
- ORDER BY p.published_on
- LIMIT 1"
- use! rdr = cmd.ExecuteReaderAsync ()
- let! newer = backgroundTask {
- if rdr.Read () then
- let! post = appendPostCategoryAndTag (postWithoutText rdr)
- return Some post
- else
- return None
- }
+ 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 {
- for post in posts do
- do! add post
- }
-
- /// Update a post
- let update (post : Post) = backgroundTask {
- match! findFullById post.Id post.WebLogId with
- | Some oldPost ->
- use cmd = conn.CreateCommand ()
- cmd.CommandText <-
- "UPDATE post
- SET author_id = @authorId,
- status = @status,
- title = @title,
- permalink = @permalink,
- published_on = @publishedOn,
- updated_on = @updatedOn,
- template = @template,
- post_text = @text,
- episode = @episode,
- meta_items = @metaItems
- WHERE id = @id
- AND web_log_id = @webLogId"
- addPostParameters cmd post
- do! write cmd
- do! updatePostCategories post.Id oldPost.CategoryIds post.CategoryIds
- do! updatePostTags post.Id oldPost.Tags post.Tags
- do! updatePostPermalinks post.Id oldPost.PriorPermalinks post.PriorPermalinks
- do! updatePostRevisions post.Id oldPost.Revisions post.Revisions
- | None -> return ()
+ log.LogTrace "Post.restore"
+ for post in posts do do! add post
}
/// Update prior permalinks for a post
- let updatePriorPermalinks postId webLogId permalinks = backgroundTask {
- match! findFullById postId webLogId with
- | Some post ->
- do! updatePostPermalinks postId post.PriorPermalinks permalinks
+ 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
+ | None -> return false
}
interface IPostData with
diff --git a/src/MyWebLog.Data/SQLite/SQLiteTagMapData.fs b/src/MyWebLog.Data/SQLite/SQLiteTagMapData.fs
index 00de07b..f71c61e 100644
--- a/src/MyWebLog.Data/SQLite/SQLiteTagMapData.fs
+++ b/src/MyWebLog.Data/SQLite/SQLiteTagMapData.fs
@@ -1,97 +1,62 @@
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) =
+/// 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 = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT * FROM tag_map WHERE id = @id"
- cmd.Parameters.AddWithValue ("@id", TagMapId.toString tagMapId) |> ignore
- use! rdr = cmd.ExecuteReaderAsync ()
- return Helpers.verifyWebLog webLogId (fun tm -> tm.WebLogId) Map.toTagMap rdr
- }
+ let findById tagMapId webLogId =
+ log.LogTrace "TagMap.findById"
+ Document.findByIdAndWebLog 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 _ ->
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "DELETE FROM tag_map WHERE id = @id"
- cmd.Parameters.AddWithValue ("@id", TagMapId.toString tagMapId) |> ignore
- do! write cmd
+ 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 = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT * FROM tag_map WHERE web_log_id = @webLogId AND url_value = @urlValue"
- addWebLogId cmd webLogId
- cmd.Parameters.AddWithValue ("@urlValue", urlValue) |> ignore
- use! rdr = cmd.ExecuteReaderAsync ()
- return if rdr.Read () then Some (Map.toTagMap rdr) else None
- }
+ 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
/// Get all tag mappings for the given web log
- let findByWebLog webLogId = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT * FROM tag_map WHERE web_log_id = @webLogId ORDER BY tag"
- addWebLogId cmd webLogId
- use! rdr = cmd.ExecuteReaderAsync ()
- return toList Map.toTagMap rdr
- }
+ let findByWebLog webLogId =
+ log.LogTrace "TagMap.findByWebLog"
+ Document.findByWebLog Table.TagMap webLogId conn
/// Find any tag mappings in a list of tags for the given web log
- let findMappingForTags (tags : string list) webLogId = backgroundTask {
- use cmd = conn.CreateCommand ()
- let mapSql, mapParams = inClause "AND tag" "tag" id tags
- cmd.CommandText <- $"
- SELECT *
- FROM tag_map
- WHERE web_log_id = @webLogId
- {mapSql}"
- addWebLogId cmd webLogId
- cmd.Parameters.AddRange mapParams
- use! rdr = cmd.ExecuteReaderAsync ()
- return toList Map.toTagMap rdr
- }
+ 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
/// Save a tag mapping
- let save (tagMap : TagMap) = backgroundTask {
- use cmd = conn.CreateCommand ()
- match! findById tagMap.Id tagMap.WebLogId with
- | Some _ ->
- cmd.CommandText <-
- "UPDATE tag_map
- SET tag = @tag,
- url_value = @urlValue
- WHERE id = @id
- AND web_log_id = @webLogId"
- | None ->
- cmd.CommandText <-
- "INSERT INTO tag_map (
- id, web_log_id, tag, url_value
- ) VALUES (
- @id, @webLogId, @tag, @urlValue
- )"
- addWebLogId cmd tagMap.WebLogId
- [ cmd.Parameters.AddWithValue ("@id", TagMapId.toString tagMap.Id)
- cmd.Parameters.AddWithValue ("@tag", tagMap.Tag)
- cmd.Parameters.AddWithValue ("@urlValue", tagMap.UrlValue)
- ] |> ignore
- do! write cmd
- }
+ let save (tagMap: TagMap) =
+ log.LogTrace "TagMap.save"
+ conn.save Table.TagMap tagMap
/// Restore tag mappings from a backup
let restore tagMaps = backgroundTask {
- for tagMap in tagMaps do
- do! save tagMap
+ log.LogTrace "TagMap.restore"
+ for tagMap in tagMaps do do! save tagMap
}
interface ITagMapData with
diff --git a/src/MyWebLog.Data/SQLite/SQLiteThemeData.fs b/src/MyWebLog.Data/SQLite/SQLiteThemeData.fs
index dd3d81b..7ff48f3 100644
--- a/src/MyWebLog.Data/SQLite/SQLiteThemeData.fs
+++ b/src/MyWebLog.Data/SQLite/SQLiteThemeData.fs
@@ -1,141 +1,69 @@
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 theme data implementation
-type SQLiteThemeData (conn : SqliteConnection) =
+/// 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 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 () = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT * FROM theme WHERE id <> 'admin' ORDER BY id"
- use! rdr = cmd.ExecuteReaderAsync ()
- let themes = toList Map.toTheme rdr
- do! rdr.CloseAsync ()
- cmd.CommandText <- "SELECT name, theme_id FROM theme_template WHERE theme_id <> 'admin' ORDER BY name"
- use! rdr = cmd.ExecuteReaderAsync ()
- let templates =
- seq { while rdr.Read () do ThemeId (Map.getString "theme_id" rdr), Map.toThemeTemplate false rdr }
- |> List.ofSeq
- return
- themes
- |> List.map (fun t ->
- { t with Templates = templates |> List.filter (fun (themeId, _) -> themeId = t.Id) |> List.map snd })
- }
+ 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 = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT COUNT(id) FROM theme WHERE id = @id"
- cmd.Parameters.AddWithValue ("@id", ThemeId.toString themeId) |> ignore
- let! count = count cmd
- return count > 0
- }
+ let exists (themeId: ThemeId) =
+ log.LogTrace "Theme.exists"
+ conn.existsById Table.Theme themeId
/// Find a theme by its ID
- let findById themeId = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT * FROM theme WHERE id = @id"
- cmd.Parameters.AddWithValue ("@id", ThemeId.toString themeId) |> ignore
- use! rdr = cmd.ExecuteReaderAsync ()
- if rdr.Read () then
- let theme = Map.toTheme rdr
- let templateCmd = conn.CreateCommand ()
- templateCmd.CommandText <- "SELECT * FROM theme_template WHERE theme_id = @id"
- templateCmd.Parameters.Add cmd.Parameters["@id"] |> ignore
- use! templateRdr = templateCmd.ExecuteReaderAsync ()
- return Some { theme with Templates = toList (Map.toThemeTemplate true) templateRdr }
- else
- return None
- }
+ let findById themeId =
+ log.LogTrace "Theme.findById"
+ conn.findById Table.Theme themeId
/// Find a theme by its ID (excludes the text of templates)
- let findByIdWithoutText themeId = backgroundTask {
- match! findById themeId with
- | Some theme ->
- return Some {
- theme with Templates = theme.Templates |> List.map (fun t -> { t with Text = "" })
- }
- | None -> return None
- }
+ 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 _ ->
- use cmd = conn.CreateCommand ()
- cmd.CommandText <-
- "DELETE FROM theme_asset WHERE theme_id = @id;
- DELETE FROM theme_template WHERE theme_id = @id;
- DELETE FROM theme WHERE id = @id"
- cmd.Parameters.AddWithValue ("@id", ThemeId.toString themeId) |> ignore
- do! write cmd
+ 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) = backgroundTask {
- use cmd = conn.CreateCommand ()
- let! oldTheme = findById theme.Id
- cmd.CommandText <-
- match oldTheme with
- | Some _ -> "UPDATE theme SET name = @name, version = @version WHERE id = @id"
- | None -> "INSERT INTO theme VALUES (@id, @name, @version)"
- [ cmd.Parameters.AddWithValue ("@id", ThemeId.toString theme.Id)
- cmd.Parameters.AddWithValue ("@name", theme.Name)
- cmd.Parameters.AddWithValue ("@version", theme.Version)
- ] |> ignore
- do! write cmd
-
- let toDelete, toAdd =
- Utils.diffLists (oldTheme |> Option.map (fun t -> t.Templates) |> Option.defaultValue [])
- theme.Templates (fun t -> t.Name)
- let toUpdate =
- theme.Templates
- |> List.filter (fun t ->
- not (toDelete |> List.exists (fun d -> d.Name = t.Name))
- && not (toAdd |> List.exists (fun a -> a.Name = t.Name)))
- cmd.CommandText <-
- "UPDATE theme_template SET template = @template WHERE theme_id = @themeId AND name = @name"
- cmd.Parameters.Clear ()
- [ cmd.Parameters.AddWithValue ("@themeId", ThemeId.toString theme.Id)
- cmd.Parameters.Add ("@name", SqliteType.Text)
- cmd.Parameters.Add ("@template", SqliteType.Text)
- ] |> ignore
- toUpdate
- |> List.map (fun template -> backgroundTask {
- cmd.Parameters["@name" ].Value <- template.Name
- cmd.Parameters["@template"].Value <- template.Text
- do! write cmd
- })
- |> Task.WhenAll
- |> ignore
- cmd.CommandText <- "INSERT INTO theme_template VALUES (@themeId, @name, @template)"
- toAdd
- |> List.map (fun template -> backgroundTask {
- cmd.Parameters["@name" ].Value <- template.Name
- cmd.Parameters["@template"].Value <- template.Text
- do! write cmd
- })
- |> Task.WhenAll
- |> ignore
- cmd.CommandText <- "DELETE FROM theme_template WHERE theme_id = @themeId AND name = @name"
- cmd.Parameters.Remove cmd.Parameters["@template"]
- toDelete
- |> List.map (fun template -> backgroundTask {
- cmd.Parameters["@name"].Value <- template.Name
- do! write cmd
- })
- |> Task.WhenAll
- |> ignore
- }
+ let save (theme: Theme) =
+ log.LogTrace "Theme.save"
+ conn.save Table.Theme theme
interface IThemeData with
- member _.All () = all ()
+ member _.All() = all ()
member _.Delete themeId = delete themeId
member _.Exists themeId = exists themeId
member _.FindById themeId = findById themeId
@@ -145,97 +73,75 @@ type SQLiteThemeData (conn : SqliteConnection) =
open System.IO
-/// SQLite myWebLog theme data implementation
-type SQLiteThemeAssetData (conn : SqliteConnection) =
+/// 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 () = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT theme_id, path, updated_on FROM theme_asset"
- use! rdr = cmd.ExecuteReaderAsync ()
- return toList (Map.toThemeAsset false) rdr
- }
+ 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 = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "DELETE FROM theme_asset WHERE theme_id = @themeId"
- cmd.Parameters.AddWithValue ("@themeId", ThemeId.toString themeId) |> ignore
- do! write cmd
- }
+ 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 = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT *, ROWID FROM theme_asset WHERE theme_id = @themeId AND path = @path"
- let (ThemeAssetId (ThemeId themeId, path)) = assetId
- [ cmd.Parameters.AddWithValue ("@themeId", themeId)
- cmd.Parameters.AddWithValue ("@path", path)
- ] |> ignore
- use! rdr = cmd.ExecuteReaderAsync ()
- return if rdr.Read () then Some (Map.toThemeAsset true rdr) else None
- }
+ 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 = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT theme_id, path, updated_on FROM theme_asset WHERE theme_id = @themeId"
- cmd.Parameters.AddWithValue ("@themeId", ThemeId.toString themeId) |> ignore
- use! rdr = cmd.ExecuteReaderAsync ()
- return toList (Map.toThemeAsset false) rdr
- }
+ 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 = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT *, ROWID FROM theme_asset WHERE theme_id = @themeId"
- cmd.Parameters.AddWithValue ("@themeId", ThemeId.toString themeId) |> ignore
- use! rdr = cmd.ExecuteReaderAsync ()
- return toList (Map.toThemeAsset true) rdr
- }
+ 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 {
- use sideCmd = conn.CreateCommand ()
- sideCmd.CommandText <-
- "SELECT COUNT(path) FROM theme_asset WHERE theme_id = @themeId AND path = @path"
- let (ThemeAssetId (ThemeId themeId, path)) = asset.Id
- [ sideCmd.Parameters.AddWithValue ("@themeId", themeId)
- sideCmd.Parameters.AddWithValue ("@path", path)
- ] |> ignore
- let! exists = count sideCmd
-
- use cmd = conn.CreateCommand ()
- cmd.CommandText <-
- if exists = 1 then
- "UPDATE theme_asset
- SET updated_on = @updatedOn,
- data = ZEROBLOB(@dataLength)
- WHERE theme_id = @themeId
- AND path = @path"
- else
- "INSERT INTO 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 (
- @themeId, @path, @updatedOn, ZEROBLOB(@dataLength)
- )"
- [ cmd.Parameters.AddWithValue ("@themeId", themeId)
- cmd.Parameters.AddWithValue ("@path", path)
- cmd.Parameters.AddWithValue ("@updatedOn", instantParam asset.UpdatedOn)
- cmd.Parameters.AddWithValue ("@dataLength", asset.Data.Length)
- ] |> ignore
- do! write cmd
+ ) 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) ]
- sideCmd.CommandText <- "SELECT ROWID FROM theme_asset WHERE theme_id = @themeId AND path = @path"
- let! rowId = sideCmd.ExecuteScalarAsync ()
-
- use dataStream = new MemoryStream (asset.Data)
- use blobStream = new SqliteBlob (conn, "theme_asset", "data", rowId :?> int64)
+ 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 _.All() = all ()
member _.DeleteByTheme themeId = deleteByTheme themeId
member _.FindById assetId = findById assetId
member _.FindByTheme themeId = findByTheme themeId
diff --git a/src/MyWebLog.Data/SQLite/SQLiteUploadData.fs b/src/MyWebLog.Data/SQLite/SQLiteUploadData.fs
index 886e113..fbc2ce9 100644
--- a/src/MyWebLog.Data/SQLite/SQLiteUploadData.fs
+++ b/src/MyWebLog.Data/SQLite/SQLiteUploadData.fs
@@ -1,93 +1,78 @@
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) =
+/// SQLite myWebLog web log data implementation
+type SQLiteUploadData(conn: SqliteConnection, log: ILogger) =
- /// Add parameters for uploaded file INSERT and UPDATE statements
- let addUploadParameters (cmd : SqliteCommand) (upload : Upload) =
- [ cmd.Parameters.AddWithValue ("@id", UploadId.toString upload.Id)
- cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString upload.WebLogId)
- cmd.Parameters.AddWithValue ("@path", Permalink.toString upload.Path)
- cmd.Parameters.AddWithValue ("@updatedOn", instantParam upload.UpdatedOn)
- cmd.Parameters.AddWithValue ("@dataLength", upload.Data.Length)
- ] |> ignore
-
/// Save an uploaded file
- let add upload = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <-
- "INSERT INTO upload (
- id, web_log_id, path, updated_on, data
- ) VALUES (
- @id, @webLogId, @path, @updatedOn, ZEROBLOB(@dataLength)
- )"
- addUploadParameters cmd upload
- do! write cmd
-
- cmd.CommandText <- "SELECT ROWID FROM upload WHERE id = @id"
- let! rowId = cmd.ExecuteScalarAsync ()
-
- use dataStream = new MemoryStream (upload.Data)
- use blobStream = new SqliteBlob (conn, "upload", "data", rowId :?> int64)
+ 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 webLogId = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <-
- "SELECT id, web_log_id, path, updated_on
- FROM upload
- WHERE id = @id
- AND web_log_id = @webLogId"
- addWebLogId cmd webLogId
- cmd.Parameters.AddWithValue ("@id", UploadId.toString uploadId) |> ignore
- let! rdr = cmd.ExecuteReaderAsync ()
- if (rdr.Read ()) then
- let upload = Map.toUpload false rdr
- do! rdr.CloseAsync ()
- cmd.CommandText <- "DELETE FROM upload WHERE id = @id AND web_log_id = @webLogId"
- do! write cmd
- return Ok (Permalink.toString upload.Path)
- else
- return Error $"""Upload ID {cmd.Parameters["@id"]} not found"""
+ 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 = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT *, ROWID FROM upload WHERE web_log_id = @webLogId AND path = @path"
- addWebLogId cmd webLogId
- cmd.Parameters.AddWithValue ("@path", path) |> ignore
- let! rdr = cmd.ExecuteReaderAsync ()
- return if rdr.Read () then Some (Map.toUpload true rdr) else None
- }
+ 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 = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT id, web_log_id, path, updated_on FROM upload WHERE web_log_id = @webLogId"
- addWebLogId cmd webLogId
- let! rdr = cmd.ExecuteReaderAsync ()
- return toList (Map.toUpload false) rdr
- }
+ 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 = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT *, ROWID FROM upload WHERE web_log_id = @webLogId"
- addWebLogId cmd webLogId
- let! rdr = cmd.ExecuteReaderAsync ()
- return toList (Map.toUpload true) rdr
- }
+ 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
}
diff --git a/src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs b/src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs
index aa34719..4c5a797 100644
--- a/src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs
+++ b/src/MyWebLog.Data/SQLite/SQLiteWebLogData.fs
@@ -1,251 +1,67 @@
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
-// The web log podcast insert loop is not statically compilable; this is OK
-#nowarn "3511"
-
-/// SQLite myWebLog web log data implementation
-type SQLiteWebLogData (conn : SqliteConnection, ser : JsonSerializer) =
-
- // SUPPORT FUNCTIONS
-
- /// Add parameters for web log INSERT or web log/RSS options UPDATE statements
- let addWebLogRssParameters (cmd : SqliteCommand) (webLog : WebLog) =
- [ cmd.Parameters.AddWithValue ("@isFeedEnabled", webLog.Rss.IsFeedEnabled)
- cmd.Parameters.AddWithValue ("@feedName", webLog.Rss.FeedName)
- cmd.Parameters.AddWithValue ("@itemsInFeed", maybe webLog.Rss.ItemsInFeed)
- cmd.Parameters.AddWithValue ("@isCategoryEnabled", webLog.Rss.IsCategoryEnabled)
- cmd.Parameters.AddWithValue ("@isTagEnabled", webLog.Rss.IsTagEnabled)
- cmd.Parameters.AddWithValue ("@copyright", maybe webLog.Rss.Copyright)
- ] |> ignore
-
- /// Add parameters for web log INSERT or UPDATE statements
- let addWebLogParameters (cmd : SqliteCommand) (webLog : WebLog) =
- [ cmd.Parameters.AddWithValue ("@id", WebLogId.toString webLog.Id)
- cmd.Parameters.AddWithValue ("@name", webLog.Name)
- cmd.Parameters.AddWithValue ("@slug", webLog.Slug)
- cmd.Parameters.AddWithValue ("@subtitle", maybe webLog.Subtitle)
- cmd.Parameters.AddWithValue ("@defaultPage", webLog.DefaultPage)
- cmd.Parameters.AddWithValue ("@postsPerPage", webLog.PostsPerPage)
- cmd.Parameters.AddWithValue ("@themeId", ThemeId.toString webLog.ThemeId)
- cmd.Parameters.AddWithValue ("@urlBase", webLog.UrlBase)
- cmd.Parameters.AddWithValue ("@timeZone", webLog.TimeZone)
- cmd.Parameters.AddWithValue ("@autoHtmx", webLog.AutoHtmx)
- cmd.Parameters.AddWithValue ("@uploads", UploadDestination.toString webLog.Uploads)
- ] |> ignore
- addWebLogRssParameters cmd webLog
-
- /// Add parameters for custom feed INSERT or UPDATE statements
- let addCustomFeedParameters (cmd : SqliteCommand) webLogId (feed : CustomFeed) =
- [ cmd.Parameters.AddWithValue ("@id", CustomFeedId.toString feed.Id)
- cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString webLogId)
- cmd.Parameters.AddWithValue ("@source", CustomFeedSource.toString feed.Source)
- cmd.Parameters.AddWithValue ("@path", Permalink.toString feed.Path)
- cmd.Parameters.AddWithValue ("@podcast", maybe (if Option.isSome feed.Podcast then
- Some (Utils.serialize ser feed.Podcast)
- else None))
- ] |> ignore
-
- /// Shorthand to map a data reader to a custom feed
- let toCustomFeed =
- Map.toCustomFeed ser
-
- /// Get the current custom feeds for a web log
- let getCustomFeeds (webLog : WebLog) = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT * FROM web_log_feed WHERE web_log_id = @webLogId"
- addWebLogId cmd webLog.Id
- use! rdr = cmd.ExecuteReaderAsync ()
- return toList toCustomFeed rdr
- }
-
- /// Append custom feeds to a web log
- let appendCustomFeeds (webLog : WebLog) = backgroundTask {
- let! feeds = getCustomFeeds webLog
- return { webLog with Rss = { webLog.Rss with CustomFeeds = feeds } }
- }
-
- /// Update the custom feeds for a web log
- let updateCustomFeeds (webLog : WebLog) = backgroundTask {
- let! feeds = getCustomFeeds webLog
- let toDelete, toAdd = Utils.diffLists feeds webLog.Rss.CustomFeeds (fun it -> $"{CustomFeedId.toString it.Id}")
- let toId (feed : CustomFeed) = feed.Id
- let toUpdate =
- webLog.Rss.CustomFeeds
- |> List.filter (fun f ->
- not (toDelete |> List.map toId |> List.append (toAdd |> List.map toId) |> List.contains f.Id))
- use cmd = conn.CreateCommand ()
- cmd.Parameters.Add ("@id", SqliteType.Text) |> ignore
- toDelete
- |> List.map (fun it -> backgroundTask {
- cmd.CommandText <- "DELETE FROM web_log_feed WHERE id = @id"
- cmd.Parameters["@id"].Value <- CustomFeedId.toString it.Id
- do! write cmd
- })
- |> Task.WhenAll
- |> ignore
- cmd.Parameters.Clear ()
- toAdd
- |> List.map (fun it -> backgroundTask {
- cmd.CommandText <-
- "INSERT INTO web_log_feed (
- id, web_log_id, source, path, podcast
- ) VALUES (
- @id, @webLogId, @source, @path, @podcast
- )"
- cmd.Parameters.Clear ()
- addCustomFeedParameters cmd webLog.Id it
- do! write cmd
- })
- |> Task.WhenAll
- |> ignore
- toUpdate
- |> List.map (fun it -> backgroundTask {
- cmd.CommandText <-
- "UPDATE web_log_feed
- SET source = @source,
- path = @path,
- podcast = @podcast
- WHERE id = @id
- AND web_log_id = @webLogId"
- cmd.Parameters.Clear ()
- addCustomFeedParameters cmd webLog.Id it
- do! write cmd
- })
- |> Task.WhenAll
- |> ignore
- }
-
- // IMPLEMENTATION FUNCTIONS
+/// SQLite myWebLog web log data implementation
+type SQLiteWebLogData(conn: SqliteConnection, log: ILogger) =
/// Add a web log
- let add webLog = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <-
- "INSERT INTO web_log (
- id, name, slug, subtitle, default_page, posts_per_page, theme_id, url_base, time_zone, auto_htmx,
- uploads, is_feed_enabled, feed_name, items_in_feed, is_category_enabled, is_tag_enabled, copyright
- ) VALUES (
- @id, @name, @slug, @subtitle, @defaultPage, @postsPerPage, @themeId, @urlBase, @timeZone, @autoHtmx,
- @uploads, @isFeedEnabled, @feedName, @itemsInFeed, @isCategoryEnabled, @isTagEnabled, @copyright
- )"
- addWebLogParameters cmd webLog
- do! write cmd
- do! updateCustomFeeds webLog
- }
+ let add webLog =
+ log.LogTrace "WebLog.add"
+ conn.insert Table.WebLog webLog
/// Retrieve all web logs
- let all () = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT * FROM web_log"
- use! rdr = cmd.ExecuteReaderAsync ()
- let! webLogs =
- toList Map.toWebLog rdr
- |> List.map (fun webLog -> backgroundTask { return! appendCustomFeeds webLog })
- |> Task.WhenAll
- return List.ofArray webLogs
- }
+ let all () =
+ log.LogTrace "WebLog.all"
+ conn.findAll Table.WebLog
/// Delete a web log by its ID
- let delete webLogId = backgroundTask {
- use cmd = conn.CreateCommand ()
- addWebLogId cmd webLogId
- let subQuery table = $"(SELECT id FROM {table} WHERE web_log_id = @webLogId)"
- let postSubQuery = subQuery "post"
- let pageSubQuery = subQuery "page"
- cmd.CommandText <- $"
- DELETE FROM post_comment WHERE post_id IN {postSubQuery};
- DELETE FROM post_revision WHERE post_id IN {postSubQuery};
- DELETE FROM post_permalink WHERE post_id IN {postSubQuery};
- DELETE FROM post_tag WHERE post_id IN {postSubQuery};
- DELETE FROM post_category WHERE post_id IN {postSubQuery};
- DELETE FROM post WHERE web_log_id = @webLogId;
- DELETE FROM page_revision WHERE page_id IN {pageSubQuery};
- DELETE FROM page_permalink WHERE page_id IN {pageSubQuery};
- DELETE FROM page WHERE web_log_id = @webLogId;
- DELETE FROM category WHERE web_log_id = @webLogId;
- DELETE FROM tag_map WHERE web_log_id = @webLogId;
- DELETE FROM upload WHERE web_log_id = @webLogId;
- DELETE FROM web_log_user WHERE web_log_id = @webLogId;
- DELETE FROM web_log_feed WHERE web_log_id = @webLogId;
- DELETE FROM web_log WHERE id = @webLogId"
- do! write cmd
- }
+ 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) = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT * FROM web_log WHERE url_base = @urlBase"
- cmd.Parameters.AddWithValue ("@urlBase", url) |> ignore
- use! rdr = cmd.ExecuteReaderAsync ()
- if rdr.Read () then
- let! webLog = appendCustomFeeds (Map.toWebLog rdr)
- return Some webLog
- else
- return None
- }
+ let findByHost (url: string) =
+ log.LogTrace "WebLog.findByHost"
+ conn.findFirstByField Table.WebLog (Field.EQ (nameof WebLog.Empty.UrlBase) url)
/// Find a web log by its ID
- let findById webLogId = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT * FROM web_log WHERE id = @webLogId"
- addWebLogId cmd webLogId
- use! rdr = cmd.ExecuteReaderAsync ()
- if rdr.Read () then
- let! webLog = appendCustomFeeds (Map.toWebLog rdr)
- return Some webLog
- else
- return None
- }
+ let findById webLogId =
+ log.LogTrace "WebLog.findById"
+ conn.findById 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 = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <-
- "UPDATE web_log
- SET name = @name,
- slug = @slug,
- subtitle = @subtitle,
- default_page = @defaultPage,
- posts_per_page = @postsPerPage,
- theme_id = @themeId,
- url_base = @urlBase,
- time_zone = @timeZone,
- auto_htmx = @autoHtmx,
- uploads = @uploads,
- is_feed_enabled = @isFeedEnabled,
- feed_name = @feedName,
- items_in_feed = @itemsInFeed,
- is_category_enabled = @isCategoryEnabled,
- is_tag_enabled = @isTagEnabled,
- copyright = @copyright
- WHERE id = @id"
- addWebLogParameters cmd webLog
- do! write cmd
- }
-
- /// Update RSS options for a web log
- let updateRssOptions webLog = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <-
- "UPDATE web_log
- SET is_feed_enabled = @isFeedEnabled,
- feed_name = @feedName,
- items_in_feed = @itemsInFeed,
- is_category_enabled = @isCategoryEnabled,
- is_tag_enabled = @isTagEnabled,
- copyright = @copyright
- WHERE id = @id"
- addWebLogRssParameters cmd webLog
- cmd.Parameters.AddWithValue ("@id", WebLogId.toString webLog.Id) |> ignore
- do! write cmd
- do! updateCustomFeeds webLog
- }
+ let updateSettings (webLog: WebLog) =
+ log.LogTrace "WebLog.updateSettings"
+ conn.updateById Table.WebLog webLog.Id webLog
interface IWebLogData with
member _.Add webLog = add webLog
@@ -253,5 +69,6 @@ type SQLiteWebLogData (conn : SqliteConnection, ser : JsonSerializer) =
member _.Delete webLogId = delete webLogId
member _.FindByHost url = findByHost url
member _.FindById webLogId = findById webLogId
- member _.UpdateSettings webLog = updateSettings webLog
+ member _.UpdateRedirectRules webLog = updateRedirectRules webLog
member _.UpdateRssOptions webLog = updateRssOptions webLog
+ member _.UpdateSettings webLog = updateSettings webLog
diff --git a/src/MyWebLog.Data/SQLite/SQLiteWebLogUserData.fs b/src/MyWebLog.Data/SQLite/SQLiteWebLogUserData.fs
index 8eb8cd9..35a9cd5 100644
--- a/src/MyWebLog.Data/SQLite/SQLiteWebLogUserData.fs
+++ b/src/MyWebLog.Data/SQLite/SQLiteWebLogUserData.fs
@@ -1,147 +1,86 @@
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) =
-
- // SUPPORT FUNCTIONS
-
- /// Add parameters for web log user INSERT or UPDATE statements
- let addWebLogUserParameters (cmd : SqliteCommand) (user : WebLogUser) =
- [ cmd.Parameters.AddWithValue ("@id", WebLogUserId.toString user.Id)
- cmd.Parameters.AddWithValue ("@webLogId", WebLogId.toString user.WebLogId)
- cmd.Parameters.AddWithValue ("@email", user.Email)
- cmd.Parameters.AddWithValue ("@firstName", user.FirstName)
- cmd.Parameters.AddWithValue ("@lastName", user.LastName)
- cmd.Parameters.AddWithValue ("@preferredName", user.PreferredName)
- cmd.Parameters.AddWithValue ("@passwordHash", user.PasswordHash)
- cmd.Parameters.AddWithValue ("@url", maybe user.Url)
- cmd.Parameters.AddWithValue ("@accessLevel", AccessLevel.toString user.AccessLevel)
- cmd.Parameters.AddWithValue ("@createdOn", instantParam user.CreatedOn)
- cmd.Parameters.AddWithValue ("@lastSeenOn", maybeInstant user.LastSeenOn)
- ] |> ignore
-
- // IMPLEMENTATION FUNCTIONS
+/// SQLite myWebLog user data implementation
+type SQLiteWebLogUserData(conn: SqliteConnection, log: ILogger) =
/// Add a user
- let add user = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <-
- "INSERT INTO web_log_user (
- id, web_log_id, email, first_name, last_name, preferred_name, password_hash, url, access_level,
- created_on, last_seen_on
- ) VALUES (
- @id, @webLogId, @email, @firstName, @lastName, @preferredName, @passwordHash, @url, @accessLevel,
- @createdOn, @lastSeenOn
- )"
- addWebLogUserParameters cmd user
- do! write cmd
- }
+ let add user =
+ log.LogTrace "WebLogUser.add"
+ conn.insert Table.WebLogUser user
/// Find a user by their ID for the given web log
- let findById userId webLogId = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT * FROM web_log_user WHERE id = @id"
- cmd.Parameters.AddWithValue ("@id", WebLogUserId.toString userId) |> ignore
- use! rdr = cmd.ExecuteReaderAsync ()
- return Helpers.verifyWebLog webLogId (fun u -> u.WebLogId) Map.toWebLogUser rdr
- }
+ let findById userId webLogId =
+ log.LogTrace "WebLogUser.findById"
+ Document.findByIdAndWebLog 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 _ ->
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT COUNT(id) FROM page WHERE author_id = @userId"
- cmd.Parameters.AddWithValue ("@userId", WebLogUserId.toString userId) |> ignore
- let! pageCount = count cmd
- cmd.CommandText <- "SELECT COUNT(id) FROM post WHERE author_id = @userId"
- let! postCount = count cmd
+ 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
- cmd.CommandText <- "DELETE FROM web_log_user WHERE id = @userId"
- let! _ = cmd.ExecuteNonQueryAsync ()
+ 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 = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT * FROM web_log_user WHERE web_log_id = @webLogId AND email = @email"
- addWebLogId cmd webLogId
- cmd.Parameters.AddWithValue ("@email", email) |> ignore
- use! rdr = cmd.ExecuteReaderAsync ()
- return if rdr.Read () then Some (Map.toWebLogUser rdr) else None
- }
+ 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
/// Get all users for the given web log
let findByWebLog webLogId = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- "SELECT * FROM web_log_user WHERE web_log_id = @webLogId ORDER BY LOWER(preferred_name)"
- addWebLogId cmd webLogId
- use! rdr = cmd.ExecuteReaderAsync ()
- return toList Map.toWebLogUser rdr
+ log.LogTrace "WebLogUser.findByWebLog"
+ let! users = Document.findByWebLog 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 = backgroundTask {
- use cmd = conn.CreateCommand ()
- let nameSql, nameParams = inClause "AND id" "id" WebLogUserId.toString userIds
- cmd.CommandText <- $"SELECT * FROM web_log_user WHERE web_log_id = @webLogId {nameSql}"
- addWebLogId cmd webLogId
- cmd.Parameters.AddRange nameParams
- use! rdr = cmd.ExecuteReaderAsync ()
- return
- toList Map.toWebLogUser rdr
- |> List.map (fun u -> { Name = WebLogUserId.toString u.Id; Value = WebLogUser.displayName u })
- }
+ 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 rdr
+ { Name = string user.Id; Value = user.DisplayName })
/// Restore users from a backup
let restore users = backgroundTask {
- for user in users do
- do! add user
+ 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 {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <-
- "UPDATE web_log_user
- SET last_seen_on = @lastSeenOn
- WHERE id = @id
- AND web_log_id = @webLogId"
- addWebLogId cmd webLogId
- [ cmd.Parameters.AddWithValue ("@id", WebLogUserId.toString userId)
- cmd.Parameters.AddWithValue ("@lastSeenOn", instantParam (Noda.now ()))
- ] |> ignore
- let! _ = cmd.ExecuteNonQueryAsync ()
- ()
+ 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 = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <-
- "UPDATE web_log_user
- SET email = @email,
- first_name = @firstName,
- last_name = @lastName,
- preferred_name = @preferredName,
- password_hash = @passwordHash,
- url = @url,
- access_level = @accessLevel,
- created_on = @createdOn,
- last_seen_on = @lastSeenOn
- WHERE id = @id
- AND web_log_id = @webLogId"
- addWebLogUserParameters cmd user
- do! write cmd
- }
+ let update (user: WebLogUser) =
+ log.LogTrace "WebLogUser.update"
+ conn.updateById Table.WebLogUser user.Id user
interface IWebLogUserData with
member _.Add user = add user
diff --git a/src/MyWebLog.Data/SQLiteData.fs b/src/MyWebLog.Data/SQLiteData.fs
index 873945c..424f282 100644
--- a/src/MyWebLog.Data/SQLiteData.fs
+++ b/src/MyWebLog.Data/SQLiteData.fs
@@ -1,5 +1,9 @@
namespace MyWebLog.Data
+open System
+open System.Threading.Tasks
+open BitBadger.Documents
+open BitBadger.Documents.Sqlite
open Microsoft.Data.Sqlite
open Microsoft.Extensions.Logging
open MyWebLog
@@ -7,231 +11,122 @@ open MyWebLog.Data.SQLite
open Newtonsoft.Json
open NodaTime
-/// SQLite myWebLog data implementation
-type SQLiteData (conn : SqliteConnection, log : ILogger, ser : JsonSerializer) =
+/// SQLite myWebLog data implementation
+type SQLiteData(conn: SqliteConnection, log: ILogger, ser: JsonSerializer) =
+ /// Create tables (and their associated indexes) if they do not exist
let ensureTables () = backgroundTask {
-
- use cmd = conn.CreateCommand ()
- let! tables = backgroundTask {
- cmd.CommandText <- "SELECT name FROM sqlite_master WHERE type = 'table'"
- let! rdr = cmd.ExecuteReaderAsync ()
- let mutable tableList = []
- while rdr.Read() do
- tableList <- Map.getString "name" rdr :: tableList
- do! rdr.CloseAsync ()
- return tableList
- }
+ Configuration.useSerializer (Utils.createDocumentSerializer ser)
+
+ let! tables = conn.customList "SELECT name FROM sqlite_master WHERE type = 'table'" [] _.GetString(0)
+
let needsTable table =
not (List.contains table tables)
- seq {
- // Theme tables
- if needsTable "theme" then
- "CREATE TABLE theme (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- version TEXT NOT NULL)"
- if needsTable "theme_template" then
- "CREATE TABLE theme_template (
- theme_id TEXT NOT NULL REFERENCES theme (id),
- name TEXT NOT NULL,
- template TEXT NOT NULL,
- PRIMARY KEY (theme_id, name))"
- if needsTable "theme_asset" then
- "CREATE TABLE theme_asset (
- theme_id TEXT NOT NULL REFERENCES theme (id),
- path TEXT NOT NULL,
- updated_on TEXT NOT NULL,
- data BLOB NOT NULL,
- PRIMARY KEY (theme_id, path))"
-
- // Web log tables
- if needsTable "web_log" then
- "CREATE TABLE web_log (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- slug TEXT NOT NULL,
- subtitle TEXT,
- default_page TEXT NOT NULL,
- posts_per_page INTEGER NOT NULL,
- theme_id TEXT NOT NULL REFERENCES theme (id),
- url_base TEXT NOT NULL,
- time_zone TEXT NOT NULL,
- auto_htmx INTEGER NOT NULL DEFAULT 0,
- uploads TEXT NOT NULL,
- is_feed_enabled INTEGER NOT NULL DEFAULT 0,
- feed_name TEXT NOT NULL,
- items_in_feed INTEGER,
- is_category_enabled INTEGER NOT NULL DEFAULT 0,
- is_tag_enabled INTEGER NOT NULL DEFAULT 0,
- copyright TEXT);
- CREATE INDEX web_log_theme_idx ON web_log (theme_id)"
- if needsTable "web_log_feed" then
- "CREATE TABLE web_log_feed (
- id TEXT PRIMARY KEY,
- web_log_id TEXT NOT NULL REFERENCES web_log (id),
- source TEXT NOT NULL,
- path TEXT NOT NULL,
- podcast TEXT);
- CREATE INDEX web_log_feed_web_log_idx ON web_log_feed (web_log_id)"
-
- // Category table
- if needsTable "category" then
- "CREATE TABLE category (
- id TEXT PRIMARY KEY,
- web_log_id TEXT NOT NULL REFERENCES web_log (id),
- name TEXT NOT NULL,
- slug TEXT NOT NULL,
- description TEXT,
- parent_id TEXT);
- CREATE INDEX category_web_log_idx ON category (web_log_id)"
-
- // Web log user table
- if needsTable "web_log_user" then
- "CREATE TABLE web_log_user (
- id TEXT PRIMARY KEY,
- web_log_id TEXT NOT NULL REFERENCES web_log (id),
- email TEXT NOT NULL,
- first_name TEXT NOT NULL,
- last_name TEXT NOT NULL,
- preferred_name TEXT NOT NULL,
- password_hash TEXT NOT NULL,
- url TEXT,
- access_level TEXT NOT NULL,
- created_on TEXT NOT NULL,
- last_seen_on TEXT);
- CREATE INDEX web_log_user_web_log_idx ON web_log_user (web_log_id);
- CREATE INDEX web_log_user_email_idx ON web_log_user (web_log_id, email)"
-
- // Page tables
- if needsTable "page" then
- "CREATE TABLE page (
- id TEXT PRIMARY KEY,
- web_log_id TEXT NOT NULL REFERENCES web_log (id),
- author_id TEXT NOT NULL REFERENCES web_log_user (id),
- title TEXT NOT NULL,
- permalink TEXT NOT NULL,
- published_on TEXT NOT NULL,
- updated_on TEXT NOT NULL,
- is_in_page_list INTEGER NOT NULL DEFAULT 0,
- template TEXT,
- page_text TEXT NOT NULL,
- meta_items TEXT);
- CREATE INDEX page_web_log_idx ON page (web_log_id);
- CREATE INDEX page_author_idx ON page (author_id);
- CREATE INDEX page_permalink_idx ON page (web_log_id, permalink)"
- if needsTable "page_permalink" then
- "CREATE TABLE page_permalink (
- page_id TEXT NOT NULL REFERENCES page (id),
- permalink TEXT NOT NULL,
- PRIMARY KEY (page_id, permalink))"
- if needsTable "page_revision" then
- "CREATE TABLE page_revision (
- page_id TEXT NOT NULL REFERENCES page (id),
- as_of TEXT NOT NULL,
- revision_text TEXT NOT NULL,
- PRIMARY KEY (page_id, as_of))"
-
- // Post tables
- if needsTable "post" then
- "CREATE TABLE post (
- id TEXT PRIMARY KEY,
- web_log_id TEXT NOT NULL REFERENCES web_log (id),
- author_id TEXT NOT NULL REFERENCES web_log_user (id),
- status TEXT NOT NULL,
- title TEXT NOT NULL,
- permalink TEXT NOT NULL,
- published_on TEXT,
- updated_on TEXT NOT NULL,
- template TEXT,
- post_text TEXT NOT NULL,
- meta_items TEXT,
- episode TEXT);
- CREATE INDEX post_web_log_idx ON post (web_log_id);
- CREATE INDEX post_author_idx ON post (author_id);
- CREATE INDEX post_status_idx ON post (web_log_id, status, updated_on);
- CREATE INDEX post_permalink_idx ON post (web_log_id, permalink)"
- if needsTable "post_category" then
- "CREATE TABLE post_category (
- post_id TEXT NOT NULL REFERENCES post (id),
- category_id TEXT NOT NULL REFERENCES category (id),
- PRIMARY KEY (post_id, category_id));
- CREATE INDEX post_category_category_idx ON post_category (category_id)"
- if needsTable "post_tag" then
- "CREATE TABLE post_tag (
- post_id TEXT NOT NULL REFERENCES post (id),
- tag TEXT NOT NULL,
- PRIMARY KEY (post_id, tag))"
- if needsTable "post_permalink" then
- "CREATE TABLE post_permalink (
- post_id TEXT NOT NULL REFERENCES post (id),
- permalink TEXT NOT NULL,
- PRIMARY KEY (post_id, permalink))"
- if needsTable "post_revision" then
- "CREATE TABLE post_revision (
- post_id TEXT NOT NULL REFERENCES post (id),
- as_of TEXT NOT NULL,
- revision_text TEXT NOT NULL,
- PRIMARY KEY (post_id, as_of))"
- if needsTable "post_comment" then
- "CREATE TABLE post_comment (
- id TEXT PRIMARY KEY,
- post_id TEXT NOT NULL REFERENCES post(id),
- in_reply_to_id TEXT,
- name TEXT NOT NULL,
- email TEXT NOT NULL,
- url TEXT,
- status TEXT NOT NULL,
- posted_on TEXT NOT NULL,
- comment_text TEXT NOT NULL);
- CREATE INDEX post_comment_post_idx ON post_comment (post_id)"
-
- // Tag map table
- if needsTable "tag_map" then
- "CREATE TABLE tag_map (
- id TEXT PRIMARY KEY,
- web_log_id TEXT NOT NULL REFERENCES web_log (id),
- tag TEXT NOT NULL,
- url_value TEXT NOT NULL);
- CREATE INDEX tag_map_web_log_idx ON tag_map (web_log_id)"
-
- // Uploaded file table
- if needsTable "upload" then
- "CREATE TABLE upload (
- id TEXT PRIMARY KEY,
- web_log_id TEXT NOT NULL REFERENCES web_log (id),
- path TEXT NOT NULL,
- updated_on TEXT NOT NULL,
- data BLOB NOT NULL);
- CREATE INDEX upload_web_log_idx ON upload (web_log_id);
- CREATE INDEX upload_path_idx ON upload (web_log_id, path)"
-
- // Database version table
- if needsTable "db_version" then
- "CREATE TABLE db_version (id TEXT PRIMARY KEY);
- INSERT INTO db_version VALUES ('v2-rc1')"
- }
- |> Seq.map (fun sql ->
- log.LogInformation $"Creating {(sql.Split ' ')[2]} table..."
- cmd.CommandText <- sql
- write cmd |> Async.AwaitTask |> Async.RunSynchronously)
- |> List.ofSeq
- |> ignore
+
+ let jsonTable table =
+ $"{Query.Definition.ensureTable table}; {Query.Definition.ensureKey table}"
+
+ let tasks =
+ seq {
+ // Theme tables
+ if needsTable Table.Theme then jsonTable Table.Theme
+ if needsTable Table.ThemeAsset then
+ $"CREATE TABLE {Table.ThemeAsset} (
+ theme_id TEXT NOT NULL,
+ path TEXT NOT NULL,
+ updated_on TEXT NOT NULL,
+ data BLOB NOT NULL,
+ PRIMARY KEY (theme_id, path))"
+
+ // Web log table
+ if needsTable Table.WebLog then jsonTable Table.WebLog
+
+ // Category table
+ if needsTable Table.Category then
+ $"""{jsonTable Table.Category};
+ {Query.Definition.ensureIndexOn Table.Category "web_log" [ nameof Category.Empty.WebLogId ]}"""
+
+ // Web log user table
+ if needsTable Table.WebLogUser then
+ $"""{jsonTable Table.WebLogUser};
+ {Query.Definition.ensureIndexOn
+ Table.WebLogUser
+ "email"
+ [ nameof WebLogUser.Empty.WebLogId; nameof WebLogUser.Empty.Email ]}"""
+
+ // Page tables
+ if needsTable Table.Page then
+ $"""{jsonTable 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 TEXT NOT NULL,
+ revision_text TEXT NOT NULL,
+ PRIMARY KEY (page_id, as_of))"
+
+ // Post tables
+ if needsTable Table.Post then
+ $"""{jsonTable 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 ]}"""
+ // TODO: index categories by post?
+ if needsTable Table.PostRevision then
+ $"CREATE TABLE {Table.PostRevision} (
+ post_id TEXT NOT NULL,
+ as_of TEXT NOT NULL,
+ revision_text TEXT NOT NULL,
+ PRIMARY KEY (post_id, as_of))"
+ if needsTable Table.PostComment then
+ $"""{jsonTable Table.PostComment};
+ {Query.Definition.ensureIndexOn Table.PostComment "post" [ nameof Comment.Empty.PostId ]}"""
+
+ // Tag map table
+ if needsTable Table.TagMap then
+ $"""{jsonTable Table.TagMap};
+ {Query.Definition.ensureIndexOn
+ Table.TagMap "url" [ nameof TagMap.Empty.WebLogId; nameof TagMap.Empty.UrlValue ]}"""
+
+ // Uploaded file table
+ if needsTable Table.Upload then
+ $"CREATE TABLE {Table.Upload} (
+ id TEXT PRIMARY KEY,
+ web_log_id TEXT NOT NULL,
+ path TEXT NOT NULL,
+ updated_on TEXT NOT NULL,
+ data BLOB NOT NULL);
+ CREATE INDEX idx_{Table.Upload}_path ON {Table.Upload} (web_log_id, path)"
+
+ // Database version table
+ if needsTable Table.DbVersion then
+ $"CREATE TABLE {Table.DbVersion} (id TEXT PRIMARY KEY);
+ INSERT INTO {Table.DbVersion} VALUES ('{Utils.Migration.currentDbVersion}')"
+ }
+ |> Seq.map (fun sql ->
+ log.LogInformation $"""Creating {(sql.Replace("IF NOT EXISTS ", "").Split ' ')[2]} table..."""
+ conn.customNonQuery sql [])
+
+ let! _ = Task.WhenAll tasks
+ ()
}
/// Set the database version to the specified version
- let setDbVersion version = backgroundTask {
- use cmd = conn.CreateCommand ()
- cmd.CommandText <- $"DELETE FROM db_version; INSERT INTO db_version VALUES ('%s{version}')"
- do! write cmd
- }
-
+ let setDbVersion version =
+ conn.customNonQuery $"DELETE FROM {Table.DbVersion}; INSERT INTO {Table.DbVersion} VALUES ('%s{version}')" []
+
/// Implement the changes between v2-rc1 and v2-rc2
let migrateV2Rc1ToV2Rc2 () = backgroundTask {
- let logStep = Utils.logMigrationStep log "v2-rc1 to v2-rc2"
+ let logStep = Utils.Migration.logStep log "v2-rc1 to v2-rc2"
// Move meta items, podcast settings, and episode details to JSON-encoded text fields
- use cmd = conn.CreateCommand ()
+ use cmd = conn.CreateCommand()
logStep "Adding new columns"
cmd.CommandText <-
"ALTER TABLE web_log_feed ADD COLUMN podcast TEXT;
@@ -242,10 +137,10 @@ type SQLiteData (conn : SqliteConnection, log : ILogger