WIP on PostgreSQL data impl
This commit is contained in:
		
							parent
							
								
									b3c008629a
								
							
						
					
					
						commit
						73c7a686a4
					
				| @ -39,6 +39,7 @@ | ||||
| 		<Compile Include="PostgreSql\PostgreSqlThemeData.fs" /> | ||||
| 		<Compile Include="PostgreSql\PostgreSqlUploadData.fs" /> | ||||
| 		<Compile Include="PostgreSql\PostgreSqlWebLogData.fs" /> | ||||
| 		<Compile Include="PostgreSql\PostgreSqlWebLogUserData.fs" /> | ||||
|         <Compile Include="PostgreSqlData.fs" /> | ||||
| 	</ItemGroup> | ||||
| 
 | ||||
|  | ||||
| @ -10,14 +10,14 @@ type PostgreSqlCategoryData (conn : NpgsqlConnection) = | ||||
|     /// Count all categories for the given web log | ||||
|     let countAll webLogId = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query "SELECT COUNT(id) AS the_count FROM category WHERE web_log_id = @webLogId" | ||||
|         |> Sql.query $"SELECT COUNT(id) AS {countName} FROM category WHERE web_log_id = @webLogId" | ||||
|         |> Sql.parameters [ webLogIdParam webLogId ] | ||||
|         |> Sql.executeRowAsync Map.toCount | ||||
|      | ||||
|     /// Count all top-level categories for the given web log | ||||
|     let countTopLevel webLogId = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query "SELECT COUNT(id) FROM category WHERE web_log_id = @webLogId AND parent_id IS NULL" | ||||
|         |> Sql.query $"SELECT COUNT(id) AS {countName} FROM category WHERE web_log_id = @webLogId AND parent_id IS NULL" | ||||
|         |> Sql.parameters [ webLogIdParam webLogId ] | ||||
|         |> Sql.executeRowAsync Map.toCount | ||||
|      | ||||
| @ -41,13 +41,13 @@ type PostgreSqlCategoryData (conn : NpgsqlConnection) = | ||||
|                     |> inClause "id" id | ||||
|                 let postCount = | ||||
|                     Sql.existingConnection conn | ||||
|                     |> Sql.query $""" | ||||
|                         SELECT COUNT(DISTINCT p.id) AS the_count | ||||
|                     |> Sql.query $" | ||||
|                         SELECT COUNT(DISTINCT p.id) AS {countName} | ||||
|                           FROM post p | ||||
|                                INNER JOIN post_category pc ON pc.post_id = p.id | ||||
|                          WHERE p.web_log_id = @webLogId | ||||
|                            AND p.status     = 'Published' | ||||
|                            AND pc.category_id IN ({catIdSql})""" | ||||
|                            AND pc.category_id IN ({catIdSql})" | ||||
|                     |> Sql.parameters (webLogIdParam webLogId :: catIdParams) | ||||
|                     |> Sql.executeRowAsync Map.toCount | ||||
|                     |> Async.AwaitTask | ||||
| @ -66,14 +66,12 @@ type PostgreSqlCategoryData (conn : NpgsqlConnection) = | ||||
|             |> Array.ofSeq | ||||
|     } | ||||
|     /// Find a category by its ID for the given web log | ||||
|     let findById catId webLogId = backgroundTask { | ||||
|         let! cat = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query "SELECT * FROM category WHERE id = @id AND web_log_id = @webLogId" | ||||
|             |> Sql.parameters [ "@id", Sql.string (CategoryId.toString catId); webLogIdParam webLogId ] | ||||
|             |> Sql.executeAsync Map.toCategory | ||||
|         return List.tryHead cat | ||||
|     } | ||||
|     let findById catId webLogId = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query "SELECT * FROM category WHERE id = @id AND web_log_id = @webLogId" | ||||
|         |> Sql.parameters [ "@id", Sql.string (CategoryId.toString catId); webLogIdParam webLogId ] | ||||
|         |> Sql.executeAsync Map.toCategory | ||||
|         |> tryHead | ||||
|      | ||||
|     /// Find all categories for the given web log | ||||
|     let findByWebLog webLogId = | ||||
| @ -88,13 +86,13 @@ type PostgreSqlCategoryData (conn : NpgsqlConnection) = | ||||
|         match! findById catId webLogId with | ||||
|         | Some cat -> | ||||
|             // Reassign any children to the category's parent category | ||||
|             let parentParam = "@parentId", Sql.string (CategoryId.toString catId) | ||||
|             let! children = | ||||
|             let  parentParam = "@parentId", Sql.string (CategoryId.toString catId) | ||||
|             let! hasChildren = | ||||
|                 Sql.existingConnection conn | ||||
|                 |> Sql.query "SELECT COUNT(id) AS the_count FROM category WHERE parent_id = @parentId" | ||||
|                 |> Sql.query $"SELECT EXISTS (SELECT 1 FROM category WHERE parent_id = @parentId) AS {existsName}" | ||||
|                 |> Sql.parameters [ parentParam ] | ||||
|                 |> Sql.executeRowAsync Map.toCount | ||||
|             if children > 0 then | ||||
|                 |> Sql.executeRowAsync Map.toExists | ||||
|             if hasChildren then | ||||
|                 let! _ = | ||||
|                     Sql.existingConnection conn | ||||
|                     |> Sql.query "UPDATE category SET parent_id = @newParentId WHERE parent_id = @parentId" | ||||
| @ -106,24 +104,24 @@ type PostgreSqlCategoryData (conn : NpgsqlConnection) = | ||||
|             // Delete the category off all posts where it is assigned, and the category itself | ||||
|             let! _ = | ||||
|                 Sql.existingConnection conn | ||||
|                 |> Sql.query """ | ||||
|                     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""" | ||||
|                 |> Sql.query | ||||
|                     "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" | ||||
|                 |> Sql.parameters [ "@id", Sql.string (CategoryId.toString catId); webLogIdParam webLogId ] | ||||
|                 |> Sql.executeNonQueryAsync | ||||
|             return if children = 0 then CategoryDeleted else ReassignedChildCategories | ||||
|             return if hasChildren then ReassignedChildCategories else CategoryDeleted | ||||
|         | None -> return CategoryNotFound | ||||
|     } | ||||
|      | ||||
|     /// The INSERT statement for a category | ||||
|     let catInsert = """ | ||||
|         INSERT INTO category ( | ||||
|     let catInsert = | ||||
|         "INSERT INTO category ( | ||||
|             id, web_log_id, name, slug, description, parent_id | ||||
|         ) VALUES ( | ||||
|             @id, @webLogId, @name, @slug, @description, @parentId | ||||
|         )""" | ||||
|         )" | ||||
|      | ||||
|     /// Create parameters for a category insert / update | ||||
|     let catParameters (cat : Category) = [ | ||||
| @ -139,12 +137,12 @@ type PostgreSqlCategoryData (conn : NpgsqlConnection) = | ||||
|     let save cat = backgroundTask { | ||||
|         let! _ = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query $""" | ||||
|             |> Sql.query $" | ||||
|                 {catInsert} ON CONFLICT (id) DO UPDATE | ||||
|                 SET name        = EXCLUDED.name, | ||||
|                     slug        = EXCLUDED.slug, | ||||
|                     description = EXCLUDED.description, | ||||
|                     parent_id   = EXCLUDED.parent_id""" | ||||
|                     parent_id   = EXCLUDED.parent_id" | ||||
|             |> Sql.parameters (catParameters cat) | ||||
|             |> Sql.executeNonQueryAsync | ||||
|         () | ||||
|  | ||||
| @ -2,6 +2,7 @@ | ||||
| [<AutoOpen>] | ||||
| module MyWebLog.Data.PostgreSql.PostgreSqlHelpers | ||||
| 
 | ||||
| open System.Threading.Tasks | ||||
| open MyWebLog | ||||
| open Newtonsoft.Json | ||||
| open Npgsql.FSharp | ||||
| @ -10,19 +11,11 @@ open Npgsql.FSharp | ||||
| let webLogIdParam webLogId = | ||||
|     "@webLogId", Sql.string (WebLogId.toString webLogId) | ||||
| 
 | ||||
| /// Create the SQL and parameters to find a page or post by one or more prior permalinks | ||||
| let priorPermalinkSql permalinks = | ||||
|     let mutable idx = 0 | ||||
|     permalinks | ||||
|     |> List.skip 1 | ||||
|     |> List.fold (fun (linkSql, linkParams) it -> | ||||
|         idx <- idx + 1 | ||||
|         $"{linkSql} OR prior_permalinks && ARRAY[@link{idx}]", | ||||
|         ($"@link{idx}", Sql.string (Permalink.toString it)) :: linkParams) | ||||
|         (Seq.ofList permalinks | ||||
|          |> Seq.map (fun it -> | ||||
|              "prior_permalinks && ARRAY[@link0]", [ "@link0", Sql.string (Permalink.toString it) ]) | ||||
|          |> Seq.head) | ||||
| /// 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" | ||||
| 
 | ||||
| /// Create the SQL and parameters for an IN clause | ||||
| let inClause<'T> name (valueFunc: 'T -> string) (items : 'T list) = | ||||
| @ -36,6 +29,26 @@ let inClause<'T> name (valueFunc: 'T -> string) (items : 'T list) = | ||||
|          |> Seq.map (fun it -> $"@%s{name}0", [ $"@%s{name}0", Sql.string (valueFunc it) ]) | ||||
|          |> Seq.head) | ||||
| 
 | ||||
| /// Create the SQL and parameters for the array equivalent of an IN clause | ||||
| let arrayInClause<'T> name (valueFunc : 'T -> string) (items : 'T list) = | ||||
|     let mutable idx = 0 | ||||
|     items | ||||
|     |> List.skip 1 | ||||
|     |> List.fold (fun (itemS, itemP) it -> | ||||
|         idx <- idx + 1 | ||||
|         $"{itemS} OR %s{name} && ARRAY[@{name}{idx}]", | ||||
|         ($"@{name}{idx}", Sql.string (valueFunc it)) :: itemP) | ||||
|         (Seq.ofList items | ||||
|          |> Seq.map (fun it -> | ||||
|              $"{name} && ARRAY[@{name}0]", [ $"@{name}0", Sql.string (valueFunc it) ]) | ||||
|          |> Seq.head) | ||||
|      | ||||
| /// Get the first result of the given query | ||||
| let tryHead<'T> (query : Task<'T list>) = backgroundTask { | ||||
|     let! results = query | ||||
|     return List.tryHead results | ||||
| } | ||||
| 
 | ||||
| /// Mapping functions for SQL queries | ||||
| module Map = | ||||
|      | ||||
| @ -55,7 +68,7 @@ module Map = | ||||
| 
 | ||||
|     /// Get a count from a row | ||||
|     let toCount (row : RowReader) = | ||||
|         row.int "the_count" | ||||
|         row.int countName | ||||
|      | ||||
|     /// Create a custom feed from the current row | ||||
|     let toCustomFeed (row : RowReader) : CustomFeed = | ||||
| @ -88,7 +101,7 @@ module Map = | ||||
|      | ||||
|     /// Get a true/false value as to whether an item exists | ||||
|     let toExists (row : RowReader) = | ||||
|         row.bool "does_exist" | ||||
|         row.bool existsName | ||||
|      | ||||
|     /// Create a meta item from the current row | ||||
|     let toMetaItem (row : RowReader) : MetaItem = | ||||
| @ -213,3 +226,18 @@ module Map = | ||||
|             } | ||||
|         } | ||||
|      | ||||
|     /// Create a web log user from the current row | ||||
|     let toWebLogUser (row : RowReader) : WebLogUser = | ||||
|         {   Id            = row.string         "id"             |> WebLogUserId | ||||
|             WebLogId      = row.string         "web_log_id"     |> WebLogId | ||||
|             Email         = row.string         "email" | ||||
|             FirstName     = row.string         "first_name" | ||||
|             LastName      = row.string         "last_name" | ||||
|             PreferredName = row.string         "preferred_name" | ||||
|             PasswordHash  = row.string         "password_hash" | ||||
|             Salt          = row.uuid           "salt" | ||||
|             Url           = row.stringOrNone   "url" | ||||
|             AccessLevel   = row.string         "access_level"   |> AccessLevel.parse | ||||
|             CreatedOn     = row.dateTime       "created_on" | ||||
|             LastSeenOn    = row.dateTimeOrNone "last_seen_on" | ||||
|         } | ||||
|  | ||||
| @ -55,6 +55,13 @@ type PostgreSqlPageData (conn : NpgsqlConnection) = | ||||
|             () | ||||
|     } | ||||
|      | ||||
|     /// Does the given page exist? | ||||
|     let pageExists pageId webLogId = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query $"SELECT EXISTS (SELECT 1 FROM page WHERE id = @id AND web_log_id = @webLogId) AS {existsName}" | ||||
|         |> Sql.parameters [ "@id", Sql.string (PageId.toString pageId); webLogIdParam webLogId ] | ||||
|         |> Sql.executeRowAsync Map.toExists | ||||
|      | ||||
|     // IMPLEMENTATION FUNCTIONS | ||||
|      | ||||
|     /// Get all pages for a web log (without text, revisions, prior permalinks, or metadata) | ||||
| @ -67,26 +74,28 @@ type PostgreSqlPageData (conn : NpgsqlConnection) = | ||||
|     /// Count all pages for the given web log | ||||
|     let countAll webLogId = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query "SELECT COUNT(id) AS the_count FROM page WHERE web_log_id = @webLogId" | ||||
|         |> Sql.query $"SELECT COUNT(id) AS {countName} FROM page WHERE web_log_id = @webLogId" | ||||
|         |> Sql.parameters [ webLogIdParam webLogId ] | ||||
|         |> Sql.executeRowAsync Map.toCount | ||||
|      | ||||
|     /// Count all pages shown in the page list for the given web log | ||||
|     let countListed webLogId = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query "SELECT COUNT(id) AS the_count FROM page WHERE web_log_id = @webLogId AND is_in_page_list = TRUE" | ||||
|         |> Sql.query $" | ||||
|             SELECT COUNT(id) AS {countName} | ||||
|               FROM page | ||||
|              WHERE web_log_id      = @webLogId | ||||
|                AND is_in_page_list = TRUE" | ||||
|         |> Sql.parameters [ webLogIdParam webLogId ] | ||||
|         |> Sql.executeRowAsync Map.toCount | ||||
|      | ||||
|     /// Find a page by its ID (without revisions) | ||||
|     let findById pageId webLogId = backgroundTask { | ||||
|         let! page = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query "SELECT * FROM page WHERE id = @id AND web_log_id = @webLogId" | ||||
|             |> Sql.parameters [ "@id", Sql.string (PageId.toString pageId); webLogIdParam webLogId ] | ||||
|             |> Sql.executeAsync Map.toPage | ||||
|         return List.tryHead page | ||||
|     } | ||||
|     let findById pageId webLogId = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query "SELECT * FROM page WHERE id = @id AND web_log_id = @webLogId" | ||||
|         |> Sql.parameters [ "@id", Sql.string (PageId.toString pageId); webLogIdParam webLogId ] | ||||
|         |> Sql.executeAsync Map.toPage | ||||
|         |> tryHead | ||||
|      | ||||
|     /// Find a complete page by its ID | ||||
|     let findFullById pageId webLogId = backgroundTask { | ||||
| @ -99,40 +108,38 @@ type PostgreSqlPageData (conn : NpgsqlConnection) = | ||||
|      | ||||
|     /// Delete a page by its ID | ||||
|     let delete pageId webLogId = backgroundTask { | ||||
|         match! findById pageId webLogId with | ||||
|         | Some _ -> | ||||
|         match! pageExists pageId webLogId with | ||||
|         | true -> | ||||
|             let! _ = | ||||
|                 Sql.existingConnection conn | ||||
|                 |> Sql.query """ | ||||
|                     DELETE FROM page_revision WHERE page_id = @id; | ||||
|                     DELETE FROM page          WHERE id      = @id""" | ||||
|                 |> Sql.query | ||||
|                     "DELETE FROM page_revision WHERE page_id = @id; | ||||
|                      DELETE FROM page          WHERE id      = @id" | ||||
|                 |> Sql.parameters [ "@id", Sql.string (PageId.toString pageId) ] | ||||
|                 |> Sql.executeNonQueryAsync | ||||
|             return true | ||||
|         | None -> return false | ||||
|         | false -> return false | ||||
|     } | ||||
|      | ||||
|     /// Find a page by its permalink for the given web log | ||||
|     let findByPermalink permalink webLogId = backgroundTask { | ||||
|         let! page = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query "SELECT * FROM page WHERE web_log_id = @webLogId AND permalink = @link" | ||||
|             |> Sql.parameters [ webLogIdParam webLogId; "@link", Sql.string (Permalink.toString permalink) ] | ||||
|             |> Sql.executeAsync Map.toPage | ||||
|         return List.tryHead page | ||||
|     } | ||||
|     let findByPermalink permalink webLogId = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query "SELECT * FROM page WHERE web_log_id = @webLogId AND permalink = @link" | ||||
|         |> Sql.parameters [ webLogIdParam webLogId; "@link", Sql.string (Permalink.toString permalink) ] | ||||
|         |> Sql.executeAsync Map.toPage | ||||
|         |> tryHead | ||||
|      | ||||
|     /// Find the current permalink within a set of potential prior permalinks for the given web log | ||||
|     let findCurrentPermalink permalinks webLogId = backgroundTask { | ||||
|         if List.isEmpty permalinks then return None | ||||
|         else | ||||
|             let linkSql, linkParams = priorPermalinkSql permalinks | ||||
|             let! links = | ||||
|             let linkSql, linkParams = arrayInClause "prior_permalinks" Permalink.toString permalinks | ||||
|             return! | ||||
|                 Sql.existingConnection conn | ||||
|                 |> Sql.query $"SELECT permalink FROM page WHERE web_log_id = @webLogId AND ({linkSql})" | ||||
|                 |> Sql.parameters (webLogIdParam webLogId :: linkParams) | ||||
|                 |> Sql.executeAsync Map.toPermalink | ||||
|             return List.tryHead links | ||||
|                 |> tryHead | ||||
|     } | ||||
|      | ||||
|     /// Get all complete pages for the given web log | ||||
| @ -144,12 +151,12 @@ type PostgreSqlPageData (conn : NpgsqlConnection) = | ||||
|             |> Sql.executeAsync Map.toPage | ||||
|         let! revisions = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query """ | ||||
|                 SELECT * | ||||
|                   FROM page_revision pr | ||||
|                        INNER JOIN page p ON p.id = pr.page_id | ||||
|                  WHERE p.web_log_id = @webLogId | ||||
|                  ORDER BY pr.as_of DESC""" | ||||
|             |> Sql.query | ||||
|                 "SELECT * | ||||
|                    FROM page_revision pr | ||||
|                         INNER JOIN page p ON p.id = pr.page_id | ||||
|                   WHERE p.web_log_id = @webLogId | ||||
|                   ORDER BY pr.as_of DESC" | ||||
|             |> Sql.parameters [ webLogIdParam webLogId ] | ||||
|             |> Sql.executeAsync (fun row -> PageId (row.string "page_id"), Map.toRevision row) | ||||
|         return | ||||
| @ -168,24 +175,24 @@ type PostgreSqlPageData (conn : NpgsqlConnection) = | ||||
|     /// Get a page of pages for the given web log (without revisions) | ||||
|     let findPageOfPages webLogId pageNbr = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query""" | ||||
|             SELECT * | ||||
|               FROM page | ||||
|              WHERE web_log_id = @webLogId | ||||
|              ORDER BY LOWER(title) | ||||
|              LIMIT @pageSize OFFSET @toSkip""" | ||||
|         |> Sql.query | ||||
|             "SELECT * | ||||
|                FROM page | ||||
|               WHERE web_log_id = @webLogId | ||||
|               ORDER BY LOWER(title) | ||||
|               LIMIT @pageSize OFFSET @toSkip" | ||||
|         |> Sql.parameters [ webLogIdParam webLogId; "@pageSize", Sql.int 26; "@toSkip", Sql.int ((pageNbr - 1) * 25) ] | ||||
|         |> Sql.executeAsync Map.toPage | ||||
|      | ||||
|     /// The INSERT statement for a page | ||||
|     let pageInsert = """ | ||||
|         INSERT INTO page ( | ||||
|     let pageInsert = | ||||
|         "INSERT INTO page ( | ||||
|             id, web_log_id, author_id, title, permalink, prior_permalinks, published_on, updated_on, is_in_page_list, | ||||
|             template, page_text, meta_items | ||||
|         ) VALUES ( | ||||
|             @id, @webLogId, @authorId, @title, @permalink, @priorPermalinks, @publishedOn, @updatedOn, @isInPageList, | ||||
|             @template, @text, @metaItems | ||||
|         )""" | ||||
|         )" | ||||
|      | ||||
|     /// The parameters for saving a page | ||||
|     let pageParams (page : Page) = [ | ||||
| @ -203,29 +210,6 @@ type PostgreSqlPageData (conn : NpgsqlConnection) = | ||||
|         "@priorPermalinks", Sql.stringArray  (page.PriorPermalinks |> List.map Permalink.toString |> Array.ofList) | ||||
|     ] | ||||
| 
 | ||||
|     /// Save a page | ||||
|     let save (page : Page) = backgroundTask { | ||||
|         let! oldPage = findFullById page.Id page.WebLogId | ||||
|         let! _ = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query $""" | ||||
|                 {pageInsert} ON CONFLICT (id) DO UPDATE | ||||
|                 SET author_id        = EXCLUDED.author_id, | ||||
|                     title            = EXCLUDED.title, | ||||
|                     permalink        = EXCLUDED.permalink, | ||||
|                     prior_permalinks = EXCLUDED.prior_permalinks, | ||||
|                     published_on     = EXCLUDED.published_on, | ||||
|                     updated_on       = EXCLUDED.updated_on, | ||||
|                     is_in_page_list  = EXCLUDED.is_in_page_list, | ||||
|                     template         = EXCLUDED.template, | ||||
|                     page_text        = EXCLUDED.text, | ||||
|                     meta_items       = EXCLUDED.meta_items""" | ||||
|             |> Sql.parameters (pageParams page) | ||||
|             |> Sql.executeNonQueryAsync | ||||
|         do! updatePageRevisions page.Id (match oldPage with Some p -> p.Revisions | None -> []) page.Revisions | ||||
|         () | ||||
|     } | ||||
|      | ||||
|     /// Restore pages from a backup | ||||
|     let restore (pages : Page list) = backgroundTask { | ||||
|         let revisions = pages |> List.collect (fun p -> p.Revisions |> List.map (fun r -> p.Id, r)) | ||||
| @ -238,10 +222,33 @@ type PostgreSqlPageData (conn : NpgsqlConnection) = | ||||
|         () | ||||
|     } | ||||
|      | ||||
|     /// Save a page | ||||
|     let save (page : Page) = backgroundTask { | ||||
|         let! oldPage = findFullById page.Id page.WebLogId | ||||
|         let! _ = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query $" | ||||
|                 {pageInsert} ON CONFLICT (id) DO UPDATE | ||||
|                 SET author_id        = EXCLUDED.author_id, | ||||
|                     title            = EXCLUDED.title, | ||||
|                     permalink        = EXCLUDED.permalink, | ||||
|                     prior_permalinks = EXCLUDED.prior_permalinks, | ||||
|                     published_on     = EXCLUDED.published_on, | ||||
|                     updated_on       = EXCLUDED.updated_on, | ||||
|                     is_in_page_list  = EXCLUDED.is_in_page_list, | ||||
|                     template         = EXCLUDED.template, | ||||
|                     page_text        = EXCLUDED.text, | ||||
|                     meta_items       = EXCLUDED.meta_items" | ||||
|             |> Sql.parameters (pageParams page) | ||||
|             |> Sql.executeNonQueryAsync | ||||
|         do! updatePageRevisions page.Id (match oldPage with Some p -> p.Revisions | None -> []) page.Revisions | ||||
|         () | ||||
|     } | ||||
|      | ||||
|     /// Update a page's prior permalinks | ||||
|     let updatePriorPermalinks pageId webLogId permalinks = backgroundTask { | ||||
|         match! findById pageId webLogId with | ||||
|         | Some _ -> | ||||
|         match! pageExists pageId webLogId with | ||||
|         | true -> | ||||
|             let! _ = | ||||
|                 Sql.existingConnection conn | ||||
|                 |> Sql.query "UPDATE page SET prior_permalinks = @prior WHERE id = @id" | ||||
| @ -250,7 +257,7 @@ type PostgreSqlPageData (conn : NpgsqlConnection) = | ||||
|                         "@prior", Sql.stringArray (permalinks |> List.map Permalink.toString |> Array.ofList) ] | ||||
|                 |> Sql.executeNonQueryAsync | ||||
|             return true | ||||
|         | None -> return false | ||||
|         | false -> return false | ||||
|     } | ||||
|      | ||||
|     interface IPageData with | ||||
|  | ||||
| @ -24,8 +24,8 @@ type PostgreSqlPostData (conn : NpgsqlConnection) = | ||||
|      | ||||
|     /// The SELECT statement for a post that will include category IDs | ||||
|     let selectPost = | ||||
|         """SELECT *, ARRAY(SELECT cat.category_id FROM post_category cat WHERE cat.post_id = p.id) AS category_ids | ||||
|              FROM post""" | ||||
|         "SELECT *, ARRAY(SELECT cat.category_id FROM post_category cat WHERE cat.post_id = p.id) AS category_ids | ||||
|            FROM post" | ||||
|      | ||||
|     /// Return a post with no revisions, prior permalinks, or text | ||||
|     let postWithoutText row = | ||||
| @ -86,34 +86,37 @@ type PostgreSqlPostData (conn : NpgsqlConnection) = | ||||
|             () | ||||
|     } | ||||
|      | ||||
|     /// Does the given post exist? | ||||
|     let postExists postId webLogId = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query $"SELECT EXISTS (SELECT 1 FROM post WHERE id = @id AND web_log_id = @webLogId) AS {existsName}" | ||||
|         |> Sql.parameters [ "@id", Sql.string (PostId.toString postId); webLogIdParam webLogId ] | ||||
|         |> Sql.executeRowAsync Map.toExists | ||||
|      | ||||
|     // IMPLEMENTATION FUNCTIONS | ||||
|      | ||||
|     /// Count posts in a status for the given web log | ||||
|     let countByStatus status webLogId = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query "SELECT COUNT(id) AS the_count FROM post WHERE web_log_id = @webLogId AND status = @status" | ||||
|         |> Sql.query $"SELECT COUNT(id) AS {countName} FROM post WHERE web_log_id = @webLogId AND status = @status" | ||||
|         |> Sql.parameters [ webLogIdParam webLogId; "@status", Sql.string (PostStatus.toString status) ] | ||||
|         |> Sql.executeRowAsync Map.toCount | ||||
|      | ||||
|     /// Find a post by its ID for the given web log (excluding revisions) | ||||
|     let findById postId webLogId = backgroundTask { | ||||
|         let! post = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query $"{selectPost} WHERE id = @id AND web_log_id = @webLogId" | ||||
|             |> Sql.parameters [ "@id", Sql.string (PostId.toString postId); webLogIdParam webLogId ] | ||||
|             |> Sql.executeAsync Map.toPost | ||||
|         return List.tryHead post | ||||
|     } | ||||
|     let findById postId webLogId =  | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query $"{selectPost} WHERE id = @id AND web_log_id = @webLogId" | ||||
|         |> Sql.parameters [ "@id", Sql.string (PostId.toString postId); webLogIdParam webLogId ] | ||||
|         |> Sql.executeAsync Map.toPost | ||||
|         |> tryHead | ||||
|      | ||||
|     /// Find a post by its permalink for the given web log (excluding revisions and prior permalinks) | ||||
|     let findByPermalink permalink webLogId = backgroundTask { | ||||
|         let! post = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query $"{selectPost} WHERE web_log_id = @webLogId AND permalink = @link" | ||||
|             |> Sql.parameters [ webLogIdParam webLogId; "@link", Sql.string (Permalink.toString permalink) ] | ||||
|             |> Sql.executeAsync Map.toPost | ||||
|         return List.tryHead post | ||||
|     } | ||||
|     let findByPermalink permalink webLogId = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query $"{selectPost} WHERE web_log_id = @webLogId AND permalink = @link" | ||||
|         |> Sql.parameters [ webLogIdParam webLogId; "@link", Sql.string (Permalink.toString permalink) ] | ||||
|         |> Sql.executeAsync Map.toPost | ||||
|         |> tryHead | ||||
|      | ||||
|     /// Find a complete post by its ID for the given web log | ||||
|     let findFullById postId webLogId = backgroundTask { | ||||
| @ -126,31 +129,31 @@ type PostgreSqlPostData (conn : NpgsqlConnection) = | ||||
|      | ||||
|     /// Delete a post by its ID for the given web log | ||||
|     let delete postId webLogId = backgroundTask { | ||||
|         match! findById postId webLogId with | ||||
|         | Some _ -> | ||||
|         match! postExists postId webLogId with | ||||
|         | true -> | ||||
|             let! _ = | ||||
|                 Sql.existingConnection conn | ||||
|                 |> Sql.query """ | ||||
|                     DELETE FROM post_revision WHERE post_id = @id; | ||||
|                     DELETE FROM post_category WHERE post_id = @id; | ||||
|                     DELETE FROM post          WHERE id      = @id""" | ||||
|                 |> Sql.query | ||||
|                     "DELETE FROM post_revision WHERE post_id = @id; | ||||
|                      DELETE FROM post_category WHERE post_id = @id; | ||||
|                      DELETE FROM post          WHERE id      = @id" | ||||
|                 |> Sql.parameters [ "@id", Sql.string (PostId.toString postId) ] | ||||
|                 |> Sql.executeNonQueryAsync | ||||
|             return true | ||||
|         | None -> return false | ||||
|         | false -> return false | ||||
|     } | ||||
|      | ||||
|     /// Find the current permalink from a list of potential prior permalinks for the given web log | ||||
|     let findCurrentPermalink permalinks webLogId = backgroundTask { | ||||
|         if List.isEmpty permalinks then return None | ||||
|         else | ||||
|             let linkSql, linkParams = priorPermalinkSql permalinks | ||||
|             let! links = | ||||
|             let linkSql, linkParams = arrayInClause "prior_permalinks" Permalink.toString permalinks | ||||
|             return! | ||||
|                 Sql.existingConnection conn | ||||
|                 |> Sql.query $"SELECT permalink FROM post WHERE web_log_id = @webLogId AND ({linkSql}" | ||||
|                 |> Sql.parameters (webLogIdParam webLogId :: linkParams) | ||||
|                 |> Sql.executeAsync Map.toPermalink | ||||
|             return List.tryHead links | ||||
|                 |> tryHead | ||||
|     } | ||||
|      | ||||
|     /// Get all complete posts for the given web log | ||||
| @ -162,12 +165,12 @@ type PostgreSqlPostData (conn : NpgsqlConnection) = | ||||
|             |> Sql.executeAsync Map.toPost | ||||
|         let! revisions = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query """ | ||||
|                 SELECT * | ||||
|                   FROM post_revision pr | ||||
|                        INNER JOIN post p ON p.id = pr.post_id | ||||
|                  WHERE p.web_log_id = @webLogId | ||||
|                  ORDER BY as_of DESC""" | ||||
|             |> Sql.query | ||||
|                 "SELECT * | ||||
|                    FROM post_revision pr | ||||
|                         INNER JOIN post p ON p.id = pr.post_id | ||||
|                   WHERE p.web_log_id = @webLogId | ||||
|                   ORDER BY as_of DESC" | ||||
|             |> Sql.parameters [ webLogIdParam webLogId ] | ||||
|             |> Sql.executeAsync (fun row -> PostId (row.string "post_id"), Map.toRevision row) | ||||
|         return | ||||
| @ -180,14 +183,14 @@ type PostgreSqlPostData (conn : NpgsqlConnection) = | ||||
|     let findPageOfCategorizedPosts webLogId categoryIds pageNbr postsPerPage = | ||||
|         let catSql, catParams = inClause "catId" CategoryId.toString categoryIds | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query $""" | ||||
|         |> Sql.query $" | ||||
|             {selectPost} p | ||||
|                    INNER JOIN post_category pc ON pc.post_id = p.id | ||||
|              WHERE p.web_log_id = @webLogId | ||||
|                AND p.status     = @status | ||||
|                AND pc.category_id IN ({catSql}) | ||||
|              ORDER BY published_on DESC | ||||
|              LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}""" | ||||
|              LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}" | ||||
|         |> Sql.parameters | ||||
|             [   webLogIdParam webLogId | ||||
|                 "@status", Sql.string (PostStatus.toString Published) | ||||
| @ -197,36 +200,36 @@ type PostgreSqlPostData (conn : NpgsqlConnection) = | ||||
|     /// Get a page of posts for the given web log (excludes text and revisions) | ||||
|     let findPageOfPosts webLogId pageNbr postsPerPage = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query $""" | ||||
|         |> Sql.query $" | ||||
|             {selectPost} | ||||
|              WHERE web_log_id = @webLogId | ||||
|              ORDER BY published_on DESC NULLS FIRST, updated_on | ||||
|              LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}""" | ||||
|              LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}" | ||||
|         |> Sql.parameters [ webLogIdParam webLogId ] | ||||
|         |> Sql.executeAsync postWithoutText | ||||
|      | ||||
|     /// Get a page of published posts for the given web log (excludes revisions) | ||||
|     let findPageOfPublishedPosts webLogId pageNbr postsPerPage = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query $""" | ||||
|         |> Sql.query $" | ||||
|             {selectPost} | ||||
|              WHERE web_log_id = @webLogId | ||||
|                AND status     = @status | ||||
|              ORDER BY published_on DESC | ||||
|              LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}""" | ||||
|              LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}" | ||||
|         |> Sql.parameters [ webLogIdParam webLogId; "@status", Sql.string (PostStatus.toString Published) ] | ||||
|         |> Sql.executeAsync Map.toPost | ||||
|      | ||||
|     /// Get a page of tagged posts for the given web log (excludes revisions and prior permalinks) | ||||
|     let findPageOfTaggedPosts webLogId (tag : string) pageNbr postsPerPage = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query $""" | ||||
|         |> Sql.query $" | ||||
|             {selectPost} | ||||
|              WHERE web_log_id =  @webLogId | ||||
|                AND status     =  @status | ||||
|                AND tag        && ARRAY[@tag] | ||||
|              ORDER BY published_on DESC | ||||
|              LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}""" | ||||
|              LIMIT {postsPerPage + 1} OFFSET {(pageNbr - 1) * postsPerPage}" | ||||
|         |> Sql.parameters | ||||
|             [   webLogIdParam webLogId | ||||
|                 "@status", Sql.string (PostStatus.toString Published) | ||||
| @ -238,43 +241,43 @@ type PostgreSqlPostData (conn : NpgsqlConnection) = | ||||
|     let findSurroundingPosts webLogId (publishedOn : DateTime) = backgroundTask { | ||||
|         let queryParams = Sql.parameters [ | ||||
|             webLogIdParam webLogId | ||||
|             "@status",      Sql.string (PostStatus.toString Published) | ||||
|             "@status",      Sql.string      (PostStatus.toString Published) | ||||
|             "@publishedOn", Sql.timestamptz publishedOn | ||||
|         ] | ||||
|         let! older = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query $""" | ||||
|             |> Sql.query $" | ||||
|                 {selectPost} | ||||
|                  WHERE web_log_id   = @webLogId | ||||
|                    AND status       = @status | ||||
|                    AND published_on < @publishedOn | ||||
|                  ORDER BY published_on DESC | ||||
|                  LIMIT 1""" | ||||
|                  LIMIT 1" | ||||
|             |> queryParams | ||||
|             |> Sql.executeAsync Map.toPost | ||||
|         let! newer = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query $""" | ||||
|             |> Sql.query $" | ||||
|                 {selectPost} | ||||
|                  WHERE web_log_id   = @webLogId | ||||
|                    AND status       = @status | ||||
|                    AND published_on > @publishedOn | ||||
|                  ORDER BY published_on | ||||
|                  LIMIT 1""" | ||||
|                  LIMIT 1" | ||||
|             |> queryParams | ||||
|             |> Sql.executeAsync Map.toPost | ||||
|         return List.tryHead older, List.tryHead newer | ||||
|     } | ||||
|      | ||||
|     /// The INSERT statement for a post | ||||
|     let postInsert = """ | ||||
|         INSERT INTO post ( | ||||
|     let postInsert = | ||||
|         "INSERT INTO post ( | ||||
|             id, web_log_id, author_id, status, title, permalink, prior_permalinks, published_on, updated_on, | ||||
|             template, post_text, tags, meta_items, episode | ||||
|         ) VALUES ( | ||||
|             @id, @webLogId, @authorId, @status, @title, @permalink, @priorPermalinks, @publishedOn, @updatedOn, | ||||
|             @template, @text, @tags, @metaItems, @episode | ||||
|         )""" | ||||
|         )" | ||||
|      | ||||
|     /// The parameters for saving a post | ||||
|     let postParams (post : Post) = [ | ||||
| @ -301,7 +304,7 @@ type PostgreSqlPostData (conn : NpgsqlConnection) = | ||||
|         let! oldPost = findFullById post.Id post.WebLogId | ||||
|         let! _ = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query $""" | ||||
|             |> Sql.query $" | ||||
|                 {postInsert} ON CONFLICT (id) DO UPDATE | ||||
|                 SET author_id        = EXCLUDED.author_id, | ||||
|                     status           = EXCLUDED.status, | ||||
| @ -314,7 +317,7 @@ type PostgreSqlPostData (conn : NpgsqlConnection) = | ||||
|                     post_text        = EXCLUDED.text, | ||||
|                     tags             = EXCLUDED.tags, | ||||
|                     meta_items       = EXCLUDED.meta_items, | ||||
|                     episode          = EXCLUDED.episode""" | ||||
|                     episode          = EXCLUDED.episode" | ||||
|             |> Sql.parameters (postParams post) | ||||
|             |> Sql.executeNonQueryAsync | ||||
|         do! updatePostCategories post.Id (match oldPost with Some p -> p.CategoryIds | None -> []) post.CategoryIds | ||||
| @ -337,8 +340,8 @@ type PostgreSqlPostData (conn : NpgsqlConnection) = | ||||
|      | ||||
|     /// Update prior permalinks for a post | ||||
|     let updatePriorPermalinks postId webLogId permalinks = backgroundTask { | ||||
|         match! findById postId webLogId with | ||||
|         | Some _ -> | ||||
|         match! postExists postId webLogId with | ||||
|         | true -> | ||||
|             let! _ = | ||||
|                 Sql.existingConnection conn | ||||
|                 |> Sql.query "UPDATE post SET prior_permalinks = @prior WHERE id = @id" | ||||
| @ -347,7 +350,7 @@ type PostgreSqlPostData (conn : NpgsqlConnection) = | ||||
|                         "@prior", Sql.stringArray (permalinks |> List.map Permalink.toString |> Array.ofList) ] | ||||
|                 |> Sql.executeNonQueryAsync | ||||
|             return true | ||||
|         | None -> return false | ||||
|         | false -> return false | ||||
|     } | ||||
|      | ||||
|     interface IPostData with | ||||
|  | ||||
| @ -9,37 +9,41 @@ open Npgsql.FSharp | ||||
| type PostgreSqlTagMapData (conn : NpgsqlConnection) = | ||||
| 
 | ||||
|     /// Find a tag mapping by its ID for the given web log | ||||
|     let findById tagMapId webLogId = backgroundTask { | ||||
|         let! tagMap = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query "SELECT * FROM tag_map WHERE id = @id AND web_log_id = @webLogId" | ||||
|             |> Sql.parameters [ "@id", Sql.string (TagMapId.toString tagMapId); webLogIdParam webLogId ] | ||||
|             |> Sql.executeAsync Map.toTagMap | ||||
|         return List.tryHead tagMap | ||||
|     } | ||||
|     let findById tagMapId webLogId = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query "SELECT * FROM tag_map WHERE id = @id AND web_log_id = @webLogId" | ||||
|         |> Sql.parameters [ "@id", Sql.string (TagMapId.toString tagMapId); webLogIdParam webLogId ] | ||||
|         |> Sql.executeAsync Map.toTagMap | ||||
|         |> tryHead | ||||
|      | ||||
|     /// Delete a tag mapping for the given web log | ||||
|     let delete tagMapId webLogId = backgroundTask { | ||||
|         match! findById tagMapId webLogId with | ||||
|         | Some _ -> | ||||
|         let idParams = [ "@id", Sql.string (TagMapId.toString tagMapId) ] | ||||
|         let! exists = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query $" | ||||
|                 SELECT EXISTS | ||||
|                     (SELECT 1 FROM tag_map WHERE id = @id AND web_log_id = @webLogId) | ||||
|                   AS {existsName}" | ||||
|             |> Sql.parameters (webLogIdParam webLogId :: idParams) | ||||
|             |> Sql.executeRowAsync Map.toExists | ||||
|         if exists then | ||||
|             let! _ = | ||||
|                 Sql.existingConnection conn | ||||
|                 |> Sql.query "DELETE FROM tag_map WHERE id = @id" | ||||
|                 |> Sql.parameters [ "@id", Sql.string (TagMapId.toString tagMapId) ] | ||||
|                 |> Sql.parameters idParams | ||||
|                 |> Sql.executeNonQueryAsync | ||||
|             return true | ||||
|         | None -> return false | ||||
|         else return false | ||||
|     } | ||||
|      | ||||
|     /// Find a tag mapping by its URL value for the given web log | ||||
|     let findByUrlValue urlValue webLogId = backgroundTask { | ||||
|         let! tagMap = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query "SELECT * FROM tag_map WHERE web_log_id = @webLogId AND url_value = @urlValue" | ||||
|             |> Sql.parameters [ webLogIdParam webLogId; "@urlValue", Sql.string urlValue ] | ||||
|             |> Sql.executeAsync Map.toTagMap | ||||
|         return List.tryHead tagMap | ||||
|     } | ||||
|     let findByUrlValue urlValue webLogId = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query "SELECT * FROM tag_map WHERE web_log_id = @webLogId AND url_value = @urlValue" | ||||
|         |> Sql.parameters [ webLogIdParam webLogId; "@urlValue", Sql.string urlValue ] | ||||
|         |> Sql.executeAsync Map.toTagMap | ||||
|         |> tryHead | ||||
|      | ||||
|     /// Get all tag mappings for the given web log | ||||
|     let findByWebLog webLogId = | ||||
| @ -57,12 +61,12 @@ type PostgreSqlTagMapData (conn : NpgsqlConnection) = | ||||
|         |> Sql.executeAsync Map.toTagMap | ||||
|      | ||||
|     /// The INSERT statement for a tag mapping | ||||
|     let tagMapInsert = """ | ||||
|         INSERT INTO tag_map ( | ||||
|     let tagMapInsert = | ||||
|         "INSERT INTO tag_map ( | ||||
|             id, web_log_id, tag, url_value | ||||
|         ) VALUES ( | ||||
|             @id, @webLogId, @tag, @urlValue | ||||
|         )""" | ||||
|         )" | ||||
|      | ||||
|     /// The parameters for saving a tag mapping | ||||
|     let tagMapParams (tagMap : TagMap) = [ | ||||
| @ -76,10 +80,10 @@ type PostgreSqlTagMapData (conn : NpgsqlConnection) = | ||||
|     let save tagMap = backgroundTask { | ||||
|         let! _ = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query $""" | ||||
|             |> Sql.query $" | ||||
|                 {tagMapInsert} ON CONFLICT (id) DO UPDATE | ||||
|                 SET tag       = EXCLUDED.tag, | ||||
|                     url_value = EXCLUDED.url_value""" | ||||
|                     url_value = EXCLUDED.url_value" | ||||
|             |> Sql.parameters (tagMapParams tagMap) | ||||
|             |> Sql.executeNonQueryAsync | ||||
|         () | ||||
|  | ||||
| @ -34,20 +34,20 @@ type PostgreSqlThemeData (conn : NpgsqlConnection) = | ||||
|     /// Find a theme by its ID | ||||
|     let findById themeId = backgroundTask { | ||||
|         let themeIdParam = [ "@id", Sql.string (ThemeId.toString themeId) ] | ||||
|         let! tryTheme = | ||||
|         let! theme = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query "SELECT * FROM theme WHERE id = @id" | ||||
|             |> Sql.parameters themeIdParam | ||||
|             |> Sql.executeAsync Map.toTheme | ||||
|         match List.tryHead tryTheme with | ||||
|         | Some theme -> | ||||
|             |> tryHead | ||||
|         if Option.isSome theme then | ||||
|             let! templates = | ||||
|                 Sql.existingConnection conn | ||||
|                 |> Sql.query "SELECT * FROM theme_template WHERE theme_id = @id" | ||||
|                 |> Sql.parameters themeIdParam | ||||
|                 |> Sql.executeAsync (Map.toThemeTemplate true) | ||||
|             return Some { theme with Templates = templates } | ||||
|         | None -> return None | ||||
|             return Some { theme.Value with Templates = templates } | ||||
|         else return None | ||||
|     } | ||||
|      | ||||
|     /// Find a theme by its ID (excludes the text of templates) | ||||
| @ -62,18 +62,23 @@ type PostgreSqlThemeData (conn : NpgsqlConnection) = | ||||
|      | ||||
|     /// Delete a theme by its ID | ||||
|     let delete themeId = backgroundTask { | ||||
|         match! findByIdWithoutText themeId with | ||||
|         | Some _ -> | ||||
|         let idParams = [ "@id", Sql.string (ThemeId.toString themeId) ] | ||||
|         let! exists = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query $"SELECT EXISTS (SELECT 1 FROM theme WHERE id = @id) AS {existsName}" | ||||
|             |> Sql.parameters idParams | ||||
|             |> Sql.executeRowAsync Map.toExists | ||||
|         if exists then | ||||
|             let! _ = | ||||
|                 Sql.existingConnection conn | ||||
|                 |> Sql.query """ | ||||
|                     DELETE FROM theme_asset    WHERE theme_id = @id; | ||||
|                     DELETE FROM theme_template WHERE theme_id = @id; | ||||
|                     DELETE FROM theme          WHERE id       = @id""" | ||||
|                 |> Sql.parameters [ "@id", Sql.string (ThemeId.toString themeId) ] | ||||
|                 |> Sql.query | ||||
|                     "DELETE FROM theme_asset    WHERE theme_id = @id; | ||||
|                      DELETE FROM theme_template WHERE theme_id = @id; | ||||
|                      DELETE FROM theme          WHERE id       = @id" | ||||
|                 |> Sql.parameters idParams | ||||
|                 |> Sql.executeNonQueryAsync | ||||
|             return true | ||||
|         | None -> return false | ||||
|         else return false | ||||
|     } | ||||
|      | ||||
|     /// Save a theme | ||||
| @ -82,11 +87,11 @@ type PostgreSqlThemeData (conn : NpgsqlConnection) = | ||||
|         let  themeIdParam = Sql.string (ThemeId.toString theme.Id) | ||||
|         let! _ = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query """ | ||||
|                 INSERT INTO theme VALUES (@id, @name, @version) | ||||
|                 ON CONFLICT (id) DO UPDATE | ||||
|                 SET name    = EXCLUDED.name, | ||||
|                     version = EXCLUDED.version""" | ||||
|             |> Sql.query | ||||
|                 "INSERT INTO theme VALUES (@id, @name, @version) | ||||
|                  ON CONFLICT (id) DO UPDATE | ||||
|                  SET name    = EXCLUDED.name, | ||||
|                      version = EXCLUDED.version" | ||||
|             |> Sql.parameters | ||||
|                 [   "@id",      themeIdParam | ||||
|                     "@name",    Sql.string theme.Name | ||||
| @ -108,9 +113,9 @@ type PostgreSqlThemeData (conn : NpgsqlConnection) = | ||||
|                         "DELETE FROM theme_template WHERE theme_id = @themeId AND name = @name", | ||||
|                         toDelete |> List.map (fun tmpl -> [ "@themeId", themeIdParam; "@name", Sql.string tmpl.Name ]) | ||||
|                     if not (List.isEmpty toAddOrUpdate) then | ||||
|                         """INSERT INTO theme_template VALUES (@themeId, @name, @template) | ||||
|                             ON CONFLICT (theme_id, name) DO UPDATE | ||||
|                             SET template = EXCLUDED.template""", | ||||
|                         "INSERT INTO theme_template VALUES (@themeId, @name, @template) | ||||
|                          ON CONFLICT (theme_id, name) DO UPDATE | ||||
|                          SET template = EXCLUDED.template", | ||||
|                         toAddOrUpdate |> List.map (fun tmpl -> [ | ||||
|                             "@themeId",  themeIdParam | ||||
|                             "@name",     Sql.string tmpl.Name | ||||
| @ -149,15 +154,13 @@ type PostgreSqlThemeAssetData (conn : NpgsqlConnection) = | ||||
|     } | ||||
|      | ||||
|     /// Find a theme asset by its ID | ||||
|     let findById assetId = backgroundTask { | ||||
|     let findById assetId = | ||||
|         let (ThemeAssetId (ThemeId themeId, path)) = assetId | ||||
|         let! asset = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query "SELECT * FROM theme_asset WHERE theme_id = @themeId AND path = @path" | ||||
|             |> Sql.parameters [ "@themeId", Sql.string themeId; "@path", Sql.string path ] | ||||
|             |> Sql.executeAsync (Map.toThemeAsset true) | ||||
|         return List.tryHead asset | ||||
|     } | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query "SELECT * FROM theme_asset WHERE theme_id = @themeId AND path = @path" | ||||
|         |> Sql.parameters [ "@themeId", Sql.string themeId; "@path", Sql.string path ] | ||||
|         |> Sql.executeAsync (Map.toThemeAsset true) | ||||
|         |> tryHead | ||||
|      | ||||
|     /// Get theme assets for the given theme (excludes data) | ||||
|     let findByTheme themeId = | ||||
| @ -178,14 +181,14 @@ type PostgreSqlThemeAssetData (conn : NpgsqlConnection) = | ||||
|         let (ThemeAssetId (ThemeId themeId, path)) = asset.Id | ||||
|         let! _ = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query """ | ||||
|                 INSERT INTO theme_asset ( | ||||
|             |> Sql.query | ||||
|                 "INSERT INTO theme_asset ( | ||||
|                     theme_id, path, updated_on, data | ||||
|                 ) VALUES ( | ||||
|                     @themeId, @path, @updatedOn, @data | ||||
|                 ) ON CONFLICT (theme_id, path) DO UPDATE | ||||
|                 SET updated_on = EXCLUDED.updated_on, | ||||
|                     data       = EXCLUDED.data""" | ||||
|                     data       = EXCLUDED.data" | ||||
|             |> Sql.parameters | ||||
|                 [   "@themeId",   Sql.string      themeId | ||||
|                     "@path",      Sql.string      path | ||||
|  | ||||
| @ -9,12 +9,12 @@ open Npgsql.FSharp | ||||
| type PostgreSqlUploadData (conn : NpgsqlConnection) = | ||||
| 
 | ||||
|     /// The INSERT statement for an uploaded file | ||||
|     let upInsert = """ | ||||
|         INSERT INTO upload ( | ||||
|     let upInsert = | ||||
|         "INSERT INTO upload ( | ||||
|             id, web_log_id, path, updated_on, data | ||||
|         ) VALUES ( | ||||
|             @id, @webLogId, @path, @updatedOn, @data | ||||
|         )""" | ||||
|         )" | ||||
|      | ||||
|     /// Parameters for adding an uploaded file | ||||
|     let upParams (upload : Upload) = [ | ||||
| @ -38,31 +38,29 @@ type PostgreSqlUploadData (conn : NpgsqlConnection) = | ||||
|     /// Delete an uploaded file by its ID | ||||
|     let delete uploadId webLogId = backgroundTask { | ||||
|         let theParams = [ "@id", Sql.string (UploadId.toString uploadId); webLogIdParam webLogId ] | ||||
|         let! tryPath = | ||||
|         let! path = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query "SELECT path FROM upload WHERE id = @id AND web_log_id = @webLogId" | ||||
|             |> Sql.parameters theParams | ||||
|             |> Sql.executeAsync (fun row -> row.string "path") | ||||
|         match List.tryHead tryPath with | ||||
|         | Some path -> | ||||
|             |> tryHead | ||||
|         if Option.isSome path then | ||||
|             let! _ = | ||||
|                 Sql.existingConnection conn | ||||
|                 |> Sql.query "DELETE FROM upload WHERE id = @id AND web_log_id = @webLogId" | ||||
|                 |> Sql.parameters theParams | ||||
|                 |> Sql.executeNonQueryAsync | ||||
|             return Ok path | ||||
|         | None -> return Error $"""Upload ID {UploadId.toString uploadId} not found""" | ||||
|             return Ok path.Value | ||||
|         else return Error $"""Upload ID {UploadId.toString uploadId} not found""" | ||||
|     } | ||||
|      | ||||
|     /// Find an uploaded file by its path for the given web log | ||||
|     let findByPath (path : string) webLogId = backgroundTask { | ||||
|         let! upload = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query "SELECT * FROM upload WHERE web_log_id = @webLogId AND path = @path" | ||||
|             |> Sql.parameters [ webLogIdParam webLogId; "@path", Sql.string path ] | ||||
|             |> Sql.executeAsync (Map.toUpload true) | ||||
|         return List.tryHead upload | ||||
|     } | ||||
|     let findByPath path webLogId = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query "SELECT * FROM upload WHERE web_log_id = @webLogId AND path = @path" | ||||
|         |> Sql.parameters [ webLogIdParam webLogId; "@path", Sql.string path ] | ||||
|         |> Sql.executeAsync (Map.toUpload true) | ||||
|         |> tryHead | ||||
|      | ||||
|     /// Find all uploaded files for the given web log (excludes data) | ||||
|     let findByWebLog webLogId = | ||||
|  | ||||
| @ -5,56 +5,44 @@ open MyWebLog.Data | ||||
| open Npgsql | ||||
| open Npgsql.FSharp | ||||
| 
 | ||||
| // The web log podcast insert loop is not statically compilable; this is OK | ||||
| //#nowarn "3511" | ||||
| 
 | ||||
| /// PostgreSQL myWebLog web log data implementation         | ||||
| type PostgreSqlWebLogData (conn : NpgsqlConnection) = | ||||
|      | ||||
|     // SUPPORT FUNCTIONS | ||||
|      | ||||
|     /// Add parameters for web log INSERT or web log/RSS options UPDATE statements | ||||
|     let addWebLogRssParameters (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 | ||||
|     /// The parameters for web log INSERT or web log/RSS options UPDATE statements | ||||
|     let rssParams (webLog : WebLog) = [ | ||||
|         "@isFeedEnabled",     Sql.bool         webLog.Rss.IsFeedEnabled | ||||
|         "@feedName",          Sql.string       webLog.Rss.FeedName | ||||
|         "@itemsInFeed",       Sql.intOrNone    webLog.Rss.ItemsInFeed | ||||
|         "@isCategoryEnabled", Sql.bool         webLog.Rss.IsCategoryEnabled | ||||
|         "@isTagEnabled",      Sql.bool         webLog.Rss.IsTagEnabled | ||||
|         "@copyright",         Sql.stringOrNone webLog.Rss.Copyright | ||||
|     ] | ||||
|      | ||||
|     /// Add parameters for web log INSERT or UPDATE statements | ||||
|     let addWebLogParameters (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 | ||||
|     /// The parameters for web log INSERT or UPDATE statements | ||||
|     let webLogParams (webLog : WebLog) = [ | ||||
|         "@id",           Sql.string       (WebLogId.toString webLog.Id) | ||||
|         "@name",         Sql.string       webLog.Name | ||||
|         "@slug",         Sql.string       webLog.Slug | ||||
|         "@subtitle",     Sql.stringOrNone webLog.Subtitle | ||||
|         "@defaultPage",  Sql.string       webLog.DefaultPage | ||||
|         "@postsPerPage", Sql.int          webLog.PostsPerPage | ||||
|         "@themeId",      Sql.string       (ThemeId.toString webLog.ThemeId) | ||||
|         "@urlBase",      Sql.string       webLog.UrlBase | ||||
|         "@timeZone",     Sql.string       webLog.TimeZone | ||||
|         "@autoHtmx",     Sql.bool         webLog.AutoHtmx | ||||
|         "@uploads",      Sql.string       (UploadDestination.toString webLog.Uploads) | ||||
|         yield! rssParams 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) | ||||
|         ] |> ignore | ||||
|     /// The SELECT statement for custom feeds, which includes podcast feed settings if present     | ||||
|     let feedSelect = "SELECT f.*, p.* FROM web_log_feed f LEFT JOIN web_log_feed_podcast p ON p.feed_id = f.id" | ||||
|      | ||||
|     /// Get the current custom feeds for a web log | ||||
|     let getCustomFeeds (webLog : WebLog) = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query """ | ||||
|             SELECT f.*, p.* | ||||
|               FROM web_log_feed f | ||||
|                    LEFT JOIN web_log_feed_podcast p ON p.feed_id = f.id | ||||
|              WHERE f.web_log_id = @webLogId""" | ||||
|         |> Sql.query $"{feedSelect} WHERE f.web_log_id = @webLogId" | ||||
|         |> Sql.parameters [ webLogIdParam webLog.Id ] | ||||
|         |> Sql.executeAsync Map.toCustomFeed | ||||
|      | ||||
| @ -64,20 +52,8 @@ type PostgreSqlWebLogData (conn : NpgsqlConnection) = | ||||
|         return { webLog with Rss = { webLog.Rss with CustomFeeds = feeds } } | ||||
|     } | ||||
|      | ||||
|     /// The INSERT statement for a podcast feed | ||||
|     let feedInsert = """ | ||||
|         INSERT INTO web_log_feed_podcast ( | ||||
|             feed_id, title, subtitle, items_in_feed, summary, displayed_author, email, image_url, apple_category, | ||||
|             apple_subcategory, explicit, default_media_type, media_base_url, podcast_guid, funding_url, funding_text, | ||||
|             medium | ||||
|         ) VALUES ( | ||||
|             @feedId, @title, @subtitle, @itemsInFeed, @summary, @displayedAuthor, @email, @imageUrl, @appleCategory, | ||||
|             @appleSubcategory, @explicit, @defaultMediaType, @mediaBaseUrl, @podcastGuid, @fundingUrl, @fundingText, | ||||
|             @medium | ||||
|         )""" | ||||
|      | ||||
|     /// The parameters to save a podcast feed | ||||
|     let feedParams feedId (podcast : PodcastOptions) = [ | ||||
|     let podcastParams feedId (podcast : PodcastOptions) = [ | ||||
|         "@feedId",           Sql.string       (CustomFeedId.toString feedId) | ||||
|         "@title",            Sql.string       podcast.Title | ||||
|         "@subtitle",         Sql.stringOrNone podcast.Subtitle | ||||
| @ -97,127 +73,115 @@ type PostgreSqlWebLogData (conn : NpgsqlConnection) = | ||||
|         "@medium",           Sql.stringOrNone (podcast.Medium |> Option.map PodcastMedium.toString) | ||||
|     ] | ||||
|      | ||||
|     /// Save a podcast for a custom feed | ||||
|     let savePodcast feedId (podcast : PodcastOptions) = backgroundTask { | ||||
|         let! _ = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query $""" | ||||
|                 {feedInsert} ON CONFLICT (feed_id) DO UPDATE | ||||
|                 SET title              = EXCLUDED.title, | ||||
|                     subtitle           = EXCLUDED.subtitle, | ||||
|                     items_in_feed      = EXCLUDED.items_in_feed, | ||||
|                     summary            = EXCLUDED.summary, | ||||
|                     displayed_author   = EXCLUDED.displayed_author, | ||||
|                     email              = EXCLUDED.email, | ||||
|                     image_url          = EXCLUDED.image_url, | ||||
|                     apple_category     = EXCLUDED.apple_category, | ||||
|                     apple_subcategory  = EXCLUDED.apple_subcategory, | ||||
|                     explicit           = EXCLUDED.explicit, | ||||
|                     default_media_type = EXCLUDED.default_media_type, | ||||
|                     media_base_url     = EXCLUDED.media_base_url, | ||||
|                     podcast_guid       = EXCLUDED.podcast_guid, | ||||
|                     funding_url        = EXCLUDED.funding_url, | ||||
|                     funding_text       = EXCLUDED.funding_text, | ||||
|                     medium             = EXCLUDED.medium""" | ||||
|             |> Sql.parameters (feedParams feedId podcast) | ||||
|             |> Sql.executeNonQueryAsync | ||||
|         () | ||||
|     } | ||||
|      | ||||
|     /// The parameters to save a custom feed | ||||
|     let feedParams webLogId (feed : CustomFeed) = [ | ||||
|         webLogIdParam webLogId | ||||
|         "@id",     Sql.string (CustomFeedId.toString feed.Id) | ||||
|         "@source", Sql.string (CustomFeedSource.toString feed.Source) | ||||
|         "@path",   Sql.string (Permalink.toString feed.Path) | ||||
|     ] | ||||
| 
 | ||||
|     /// 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 toDelete, _ = 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_podcast WHERE feed_id = @id; | ||||
|                 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 | ||||
|                 ) VALUES ( | ||||
|                     @id, @webLogId, @source, @path | ||||
|                 )""" | ||||
|             cmd.Parameters.Clear () | ||||
|             addCustomFeedParameters cmd webLog.Id it | ||||
|             do! write cmd | ||||
|             match it.Podcast with | ||||
|             | Some podcast -> do! addPodcast it.Id podcast | ||||
|             | None -> () | ||||
|         }) | ||||
|         |> Task.WhenAll | ||||
|         |> ignore | ||||
|         toUpdate | ||||
|         |> List.map (fun it -> backgroundTask { | ||||
|             cmd.CommandText <- """ | ||||
|                 UPDATE web_log_feed | ||||
|                    SET source = @source, | ||||
|                        path   = @path | ||||
|                  WHERE id         = @id | ||||
|                    AND web_log_id = @webLogId""" | ||||
|             cmd.Parameters.Clear () | ||||
|             addCustomFeedParameters cmd webLog.Id it | ||||
|             do! write cmd | ||||
|             let hadPodcast = Option.isSome (feeds |> List.find (fun f -> f.Id = it.Id)).Podcast | ||||
|             match it.Podcast with | ||||
|             | Some podcast -> do! savePodcast it.Id podcast | ||||
|             | None -> | ||||
|                 if hadPodcast then | ||||
|                     cmd.CommandText <- "DELETE FROM web_log_feed_podcast WHERE feed_id = @id" | ||||
|                     cmd.Parameters.Clear () | ||||
|                     cmd.Parameters.AddWithValue ("@id", CustomFeedId.toString it.Id) |> ignore | ||||
|                     do! write cmd | ||||
|                 else | ||||
|                     () | ||||
|         }) | ||||
|         |> Task.WhenAll | ||||
|         |> ignore | ||||
|         let toAddOrUpdate = | ||||
|             webLog.Rss.CustomFeeds |> List.filter (fun f -> not (toDelete |> List.map toId |> List.contains f.Id)) | ||||
|         if not (List.isEmpty toDelete) || not (List.isEmpty toAddOrUpdate) then | ||||
|             let! _ = | ||||
|                 Sql.existingConnection conn | ||||
|                 |> Sql.executeTransactionAsync [ | ||||
|                     if not (List.isEmpty toDelete) then | ||||
|                         "DELETE FROM web_log_feed_podcast WHERE feed_id = @id; | ||||
|                          DELETE FROM web_log_feed         WHERE id      = @id", | ||||
|                         toDelete |> List.map (fun it -> [ "@id", Sql.string (CustomFeedId.toString it.Id) ]) | ||||
|                     if not (List.isEmpty toAddOrUpdate) then | ||||
|                         "INSERT INTO web_log_feed ( | ||||
|                             id, web_log_id, source, path | ||||
|                         ) VALUES ( | ||||
|                             @id, @webLogId, @source, @path | ||||
|                         ) ON CONFLICT (id) DO UPDATE | ||||
|                         SET source = EXCLUDED.source, | ||||
|                             path   = EXCLUDED.path", | ||||
|                         toAddOrUpdate |> List.map (feedParams webLog.Id) | ||||
|                         let podcasts = toAddOrUpdate |> List.filter (fun it -> Option.isSome it.Podcast) | ||||
|                         if not (List.isEmpty podcasts) then | ||||
|                             "INSERT INTO web_log_feed_podcast ( | ||||
|                                 feed_id, title, subtitle, items_in_feed, summary, displayed_author, email, image_url, | ||||
|                                 apple_category, apple_subcategory, explicit, default_media_type, media_base_url, | ||||
|                                 podcast_guid, funding_url, funding_text, medium | ||||
|                             ) VALUES ( | ||||
|                                 @feedId, @title, @subtitle, @itemsInFeed, @summary, @displayedAuthor, @email, @imageUrl, | ||||
|                                 @appleCategory, @appleSubcategory, @explicit, @defaultMediaType, @mediaBaseUrl, | ||||
|                                 @podcastGuid, @fundingUrl, @fundingText, @medium | ||||
|                             ) ON CONFLICT (feed_id) DO UPDATE | ||||
|                             SET title              = EXCLUDED.title, | ||||
|                                 subtitle           = EXCLUDED.subtitle, | ||||
|                                 items_in_feed      = EXCLUDED.items_in_feed, | ||||
|                                 summary            = EXCLUDED.summary, | ||||
|                                 displayed_author   = EXCLUDED.displayed_author, | ||||
|                                 email              = EXCLUDED.email, | ||||
|                                 image_url          = EXCLUDED.image_url, | ||||
|                                 apple_category     = EXCLUDED.apple_category, | ||||
|                                 apple_subcategory  = EXCLUDED.apple_subcategory, | ||||
|                                 explicit           = EXCLUDED.explicit, | ||||
|                                 default_media_type = EXCLUDED.default_media_type, | ||||
|                                 media_base_url     = EXCLUDED.media_base_url, | ||||
|                                 podcast_guid       = EXCLUDED.podcast_guid, | ||||
|                                 funding_url        = EXCLUDED.funding_url, | ||||
|                                 funding_text       = EXCLUDED.funding_text, | ||||
|                                 medium             = EXCLUDED.medium", | ||||
|                             podcasts |> List.map (fun it -> podcastParams it.Id it.Podcast.Value) | ||||
|                         let hadPodcasts = | ||||
|                             toAddOrUpdate | ||||
|                             |> List.filter (fun it -> | ||||
|                                 match feeds |> List.tryFind (fun feed -> feed.Id = it.Id) with | ||||
|                                 | Some feed -> Option.isSome feed.Podcast && Option.isNone it.Podcast | ||||
|                                 | None -> false) | ||||
|                         if not (List.isEmpty hadPodcasts) then | ||||
|                             "DELETE FROM web_log_feed_podcast WHERE feed_id = @id", | ||||
|                             hadPodcasts |> List.map (fun it -> [ "@id", Sql.string (CustomFeedId.toString it.Id) ]) | ||||
|                 ] | ||||
|             () | ||||
|     } | ||||
|      | ||||
|     // IMPLEMENTATION FUNCTIONS | ||||
|      | ||||
|     /// 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 | ||||
|         let! _ = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query | ||||
|                 "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 | ||||
|                 )" | ||||
|             |> Sql.parameters (webLogParams webLog) | ||||
|             |> Sql.executeNonQueryAsync | ||||
|         do! updateCustomFeeds 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 | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query "SELECT * FROM web_log" | ||||
|             |> Sql.executeAsync Map.toWebLog | ||||
|         let! feeds = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query feedSelect | ||||
|             |> Sql.executeAsync (fun row -> WebLogId (row.string "web_log_id"), Map.toCustomFeed row) | ||||
|         return | ||||
|             webLogs | ||||
|             |> List.map (fun it -> | ||||
|                 { it with | ||||
|                     Rss = | ||||
|                         { it.Rss with | ||||
|                             CustomFeeds = feeds |> List.filter (fun (wlId, _) -> wlId = it.Id) |> List.map snd } }) | ||||
|     } | ||||
|      | ||||
|     /// Delete a web log by its ID | ||||
| @ -247,72 +211,76 @@ type PostgreSqlWebLogData (conn : NpgsqlConnection) = | ||||
|     } | ||||
|      | ||||
|     /// 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 = backgroundTask { | ||||
|         let! webLog = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query "SELECT * FROM web_log WHERE url_base = @urlBase" | ||||
|             |> Sql.parameters [ "@urlBase", Sql.string url ] | ||||
|             |> Sql.executeAsync Map.toWebLog | ||||
|             |> tryHead | ||||
|         if Option.isSome webLog then | ||||
|             let! withFeeds = appendCustomFeeds webLog.Value | ||||
|             return Some withFeeds | ||||
|         else return None | ||||
|     } | ||||
|      | ||||
|     /// 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! webLog = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query "SELECT * FROM web_log WHERE id = @webLogId" | ||||
|             |> Sql.parameters [ webLogIdParam webLogId ] | ||||
|             |> Sql.executeAsync Map.toWebLog | ||||
|             |> tryHead | ||||
|         if Option.isSome webLog then | ||||
|             let! withFeeds = appendCustomFeeds webLog.Value | ||||
|             return Some withFeeds | ||||
|         else return None | ||||
|     } | ||||
|      | ||||
|     /// 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 | ||||
|         let! _ = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query | ||||
|                 "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" | ||||
|             |> Sql.parameters (webLogParams webLog) | ||||
|             |> Sql.executeNonQueryAsync | ||||
|         () | ||||
|     } | ||||
|      | ||||
|     /// 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 | ||||
|     let updateRssOptions (webLog : WebLog) = backgroundTask { | ||||
|         let! _ = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query | ||||
|                 "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 = @webLogId" | ||||
|             |> Sql.parameters (webLogIdParam webLog.Id :: rssParams webLog) | ||||
|             |> Sql.executeNonQueryAsync | ||||
|         do! updateCustomFeeds webLog | ||||
|     } | ||||
|      | ||||
|  | ||||
							
								
								
									
										151
									
								
								src/MyWebLog.Data/PostgreSql/PostgreSqlWebLogUserData.fs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								src/MyWebLog.Data/PostgreSql/PostgreSqlWebLogUserData.fs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,151 @@ | ||||
| namespace MyWebLog.Data.PostgreSql | ||||
| 
 | ||||
| open MyWebLog | ||||
| open MyWebLog.Data | ||||
| open Npgsql | ||||
| open Npgsql.FSharp | ||||
| 
 | ||||
| /// PostgreSQL myWebLog user data implementation         | ||||
| type PostgreSqlWebLogUserData (conn : NpgsqlConnection) = | ||||
|      | ||||
|     /// The INSERT statement for a user | ||||
|     let userInsert = | ||||
|         "INSERT INTO web_log_user ( | ||||
|             id, web_log_id, email, first_name, last_name, preferred_name, password_hash, salt, url, access_level, | ||||
|             created_on, last_seen_on | ||||
|         ) VALUES ( | ||||
|             @id, @webLogId, @email, @firstName, @lastName, @preferredName, @passwordHash, @salt, @url, @accessLevel, | ||||
|             @createdOn, @lastSeenOn | ||||
|         )" | ||||
|      | ||||
|     /// Parameters for saving web log users | ||||
|     let userParams (user : WebLogUser) = [ | ||||
|         "@id",            Sql.string            (WebLogUserId.toString user.Id) | ||||
|         "@webLogId",      Sql.string            (WebLogId.toString user.WebLogId) | ||||
|         "@email",         Sql.string            user.Email | ||||
|         "@firstName",     Sql.string            user.FirstName | ||||
|         "@lastName",      Sql.string            user.LastName | ||||
|         "@preferredName", Sql.string            user.PreferredName | ||||
|         "@passwordHash",  Sql.string            user.PasswordHash | ||||
|         "@salt",          Sql.uuid              user.Salt | ||||
|         "@url",           Sql.stringOrNone      user.Url | ||||
|         "@accessLevel",   Sql.string            (AccessLevel.toString user.AccessLevel) | ||||
|         "@createdOn",     Sql.timestamptz       user.CreatedOn | ||||
|         "@lastSeenOn",    Sql.timestamptzOrNone user.LastSeenOn | ||||
|     ] | ||||
| 
 | ||||
|     /// Find a user by their ID for the given web log | ||||
|     let findById userId webLogId = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query "SELECT * FROM web_log_user WHERE id = @id AND web_log_id = @webLogId" | ||||
|         |> Sql.parameters [ "@id", Sql.string (WebLogUserId.toString userId); webLogIdParam webLogId ] | ||||
|         |> Sql.executeAsync Map.toWebLogUser | ||||
|         |> tryHead | ||||
|      | ||||
|     /// Delete a user if they have no posts or pages | ||||
|     let delete userId webLogId = backgroundTask { | ||||
|         match! findById userId webLogId with | ||||
|         | Some _ -> | ||||
|             let userParam = [ "@userId", Sql.string (WebLogUserId.toString userId) ] | ||||
|             let! isAuthor = | ||||
|                 Sql.existingConnection conn | ||||
|                 |> Sql.query | ||||
|                     "SELECT (   EXISTS (SELECT 1 FROM page WHERE author_id = @userId | ||||
|                              OR EXISTS (SELECT 1 FROM post WHERE author_id = @userId)) AS does_exist" | ||||
|                 |> Sql.parameters userParam | ||||
|                 |> Sql.executeRowAsync Map.toExists | ||||
|             if isAuthor then | ||||
|                 return Error "User has pages or posts; cannot delete" | ||||
|             else | ||||
|                 let! _ = | ||||
|                     Sql.existingConnection conn | ||||
|                     |> Sql.query "DELETE FROM web_log_user WHERE id = @userId" | ||||
|                     |> Sql.parameters userParam | ||||
|                     |> Sql.executeNonQueryAsync | ||||
|                 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 webLogId = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query "SELECT * FROM web_log_user WHERE web_log_id = @webLogId AND email = @email" | ||||
|         |> Sql.parameters [ webLogIdParam webLogId; "@email", Sql.string email ] | ||||
|         |> Sql.executeAsync Map.toWebLogUser | ||||
|         |> tryHead | ||||
|      | ||||
|     /// Get all users for the given web log | ||||
|     let findByWebLog webLogId = | ||||
|         Sql.existingConnection conn | ||||
|         |> Sql.query "SELECT * FROM web_log_user WHERE web_log_id = @webLogId ORDER BY LOWER(preferred_name)" | ||||
|         |> Sql.parameters [ webLogIdParam webLogId ] | ||||
|         |> Sql.executeAsync Map.toWebLogUser | ||||
|      | ||||
|     /// Find the names of users by their IDs for the given web log | ||||
|     let findNames webLogId userIds = backgroundTask { | ||||
|         let idSql, idParams = inClause "id" WebLogUserId.toString userIds | ||||
|         let! users = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query $"SELECT * FROM web_log_user WHERE web_log_id = @webLogId AND id IN ({idSql})" | ||||
|             |> Sql.parameters (webLogIdParam webLogId :: idParams) | ||||
|             |> Sql.executeAsync Map.toWebLogUser | ||||
|         return | ||||
|             users | ||||
|             |> List.map (fun u -> { Name = WebLogUserId.toString u.Id; Value = WebLogUser.displayName u }) | ||||
|     } | ||||
|      | ||||
|     /// Restore users from a backup | ||||
|     let restore users = backgroundTask { | ||||
|         let! _ = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.executeTransactionAsync [ | ||||
|                 userInsert, users |> List.map userParams | ||||
|             ] | ||||
|         () | ||||
|     } | ||||
|      | ||||
|     /// Set a user's last seen date/time to now | ||||
|     let setLastSeen userId webLogId = backgroundTask { | ||||
|         let! _ = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query "UPDATE web_log_user SET last_seen_on = @lastSeenOn WHERE id = @id AND web_log_id = @webLogId" | ||||
|             |> Sql.parameters | ||||
|                 [   webLogIdParam webLogId | ||||
|                     "@id",         Sql.string      (WebLogUserId.toString userId) | ||||
|                     "@lastSeenOn", Sql.timestamptz System.DateTime.UtcNow ] | ||||
|             |> Sql.executeNonQueryAsync | ||||
|         () | ||||
|     } | ||||
|      | ||||
|     /// Save a user | ||||
|     let save user = backgroundTask { | ||||
|         let! _ = | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.query $" | ||||
|                 {userInsert} ON CONFLICT (id) DO UPDATE | ||||
|                 SET email          = @email, | ||||
|                     first_name     = @firstName, | ||||
|                     last_name      = @lastName, | ||||
|                     preferred_name = @preferredName, | ||||
|                     password_hash  = @passwordHash, | ||||
|                     salt           = @salt, | ||||
|                     url            = @url, | ||||
|                     access_level   = @accessLevel, | ||||
|                     created_on     = @createdOn, | ||||
|                     last_seen_on   = @lastSeenOn" | ||||
|             |> Sql.parameters (userParams user) | ||||
|             |> Sql.executeNonQueryAsync | ||||
|         () | ||||
|     } | ||||
|      | ||||
|     interface IWebLogUserData with | ||||
|         member _.Add user = save user | ||||
|         member _.Delete userId webLogId = delete userId webLogId | ||||
|         member _.FindByEmail email webLogId = findByEmail email webLogId | ||||
|         member _.FindById userId webLogId = findById userId webLogId | ||||
|         member _.FindByWebLog webLogId = findByWebLog webLogId | ||||
|         member _.FindNames webLogId userIds = findNames webLogId userIds | ||||
|         member _.Restore users = restore users | ||||
|         member _.SetLastSeen userId webLogId = setLastSeen userId webLogId | ||||
|         member _.Update user = save user | ||||
| 
 | ||||
| @ -7,7 +7,6 @@ open Npgsql.FSharp | ||||
| 
 | ||||
| /// Data implementation for PostgreSQL | ||||
| type PostgreSqlData (conn : NpgsqlConnection, log : ILogger<PostgreSqlData>) = | ||||
|      | ||||
| 
 | ||||
|     interface IData with | ||||
|          | ||||
| @ -19,6 +18,7 @@ type PostgreSqlData (conn : NpgsqlConnection, log : ILogger<PostgreSqlData>) = | ||||
|         member _.ThemeAsset = PostgreSqlThemeAssetData conn | ||||
|         member _.Upload     = PostgreSqlUploadData     conn | ||||
|         member _.WebLog     = PostgreSqlWebLogData     conn | ||||
|         member _.WebLogUser = PostgreSqlWebLogUserData conn | ||||
|          | ||||
|         member _.StartUp () = backgroundTask { | ||||
| 
 | ||||
| @ -28,202 +28,204 @@ type PostgreSqlData (conn : NpgsqlConnection, log : ILogger<PostgreSqlData>) = | ||||
|                 |> Sql.executeAsync (fun row -> row.string "tablename") | ||||
|             let needsTable table = not (List.contains table tables) | ||||
|              | ||||
|             seq { | ||||
|             let sql = seq { | ||||
|                 // Theme tables | ||||
|                 if needsTable "theme" then | ||||
|                     """CREATE TABLE theme ( | ||||
|                             id       TEXT NOT NULL PRIMARY KEY, | ||||
|                             name     TEXT NOT NULL, | ||||
|                             version  TEXT NOT NULL)""" | ||||
|                     "CREATE TABLE theme ( | ||||
|                         id       TEXT NOT NULL 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))""" | ||||
|                     "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  TIMESTAMPTZ NOT NULL, | ||||
|                             data        BYTEA       NOT NULL, | ||||
|                             PRIMARY KEY (theme_id, path))""" | ||||
|                     "CREATE TABLE theme_asset ( | ||||
|                         theme_id    TEXT        NOT NULL REFERENCES theme (id), | ||||
|                         path        TEXT        NOT NULL, | ||||
|                         updated_on  TIMESTAMPTZ NOT NULL, | ||||
|                         data        BYTEA       NOT NULL, | ||||
|                         PRIMARY KEY (theme_id, path))" | ||||
|                  | ||||
|                 // Web log tables | ||||
|                 if needsTable "web_log" then | ||||
|                     """CREATE TABLE web_log ( | ||||
|                             id                   TEXT    NOT NULL 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            BOOLEAN NOT NULL DEFAULT FALSE, | ||||
|                             uploads              TEXT    NOT NULL, | ||||
|                             is_feed_enabled      BOOLEAN NOT NULL DEFAULT FALSE, | ||||
|                             feed_name            TEXT    NOT NULL, | ||||
|                             items_in_feed        INTEGER, | ||||
|                             is_category_enabled  BOOLEAN NOT NULL DEFAULT FALSE, | ||||
|                             is_tag_enabled       BOOLEAN NOT NULL DEFAULT FALSE, | ||||
|                             copyright            TEXT); | ||||
|                         CREATE INDEX web_log_theme_idx ON web_log (theme_id)""" | ||||
|                     "CREATE TABLE web_log ( | ||||
|                         id                   TEXT    NOT NULL 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            BOOLEAN NOT NULL DEFAULT FALSE, | ||||
|                         uploads              TEXT    NOT NULL, | ||||
|                         is_feed_enabled      BOOLEAN NOT NULL DEFAULT FALSE, | ||||
|                         feed_name            TEXT    NOT NULL, | ||||
|                         items_in_feed        INTEGER, | ||||
|                         is_category_enabled  BOOLEAN NOT NULL DEFAULT FALSE, | ||||
|                         is_tag_enabled       BOOLEAN NOT NULL DEFAULT FALSE, | ||||
|                         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 NOT NULL PRIMARY KEY, | ||||
|                             web_log_id  TEXT NOT NULL REFERENCES web_log (id), | ||||
|                             source      TEXT NOT NULL, | ||||
|                             path        TEXT NOT NULL); | ||||
|                         CREATE INDEX web_log_feed_web_log_idx ON web_log_feed (web_log_id)""" | ||||
|                     "CREATE TABLE web_log_feed ( | ||||
|                         id          TEXT NOT NULL PRIMARY KEY, | ||||
|                         web_log_id  TEXT NOT NULL REFERENCES web_log (id), | ||||
|                         source      TEXT NOT NULL, | ||||
|                         path        TEXT NOT NULL); | ||||
|                     CREATE INDEX web_log_feed_web_log_idx ON web_log_feed (web_log_id)" | ||||
|                 if needsTable "web_log_feed_podcast" then | ||||
|                     """CREATE TABLE web_log_feed_podcast ( | ||||
|                             feed_id             TEXT    NOT NULL PRIMARY KEY REFERENCES web_log_feed (id), | ||||
|                             title               TEXT    NOT NULL, | ||||
|                             subtitle            TEXT, | ||||
|                             items_in_feed       INTEGER NOT NULL, | ||||
|                             summary             TEXT    NOT NULL, | ||||
|                             displayed_author    TEXT    NOT NULL, | ||||
|                             email               TEXT    NOT NULL, | ||||
|                             image_url           TEXT    NOT NULL, | ||||
|                             apple_category      TEXT    NOT NULL, | ||||
|                             apple_subcategory   TEXT, | ||||
|                             explicit            TEXT    NOT NULL, | ||||
|                             default_media_type  TEXT, | ||||
|                             media_base_url      TEXT, | ||||
|                             podcast_guid        TEXT, | ||||
|                             funding_url         TEXT, | ||||
|                             funding_text        TEXT, | ||||
|                             medium              TEXT)""" | ||||
|                     "CREATE TABLE web_log_feed_podcast ( | ||||
|                         feed_id             TEXT    NOT NULL PRIMARY KEY REFERENCES web_log_feed (id), | ||||
|                         title               TEXT    NOT NULL, | ||||
|                         subtitle            TEXT, | ||||
|                         items_in_feed       INTEGER NOT NULL, | ||||
|                         summary             TEXT    NOT NULL, | ||||
|                         displayed_author    TEXT    NOT NULL, | ||||
|                         email               TEXT    NOT NULL, | ||||
|                         image_url           TEXT    NOT NULL, | ||||
|                         apple_category      TEXT    NOT NULL, | ||||
|                         apple_subcategory   TEXT, | ||||
|                         explicit            TEXT    NOT NULL, | ||||
|                         default_media_type  TEXT, | ||||
|                         media_base_url      TEXT, | ||||
|                         podcast_guid        TEXT, | ||||
|                         funding_url         TEXT, | ||||
|                         funding_text        TEXT, | ||||
|                         medium              TEXT)" | ||||
|                  | ||||
|                 // Category table | ||||
|                 if needsTable "category" then | ||||
|                     """CREATE TABLE category ( | ||||
|                             id           TEXT NOT NULL 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)""" | ||||
|                     "CREATE TABLE category ( | ||||
|                         id           TEXT NOT NULL 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        NOT NULL 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, | ||||
|                             salt            TEXT        NOT NULL, | ||||
|                             url             TEXT, | ||||
|                             access_level    TEXT        NOT NULL, | ||||
|                             created_on      TIMESTAMPTZ NOT NULL, | ||||
|                             last_seen_on    TIMESTAMPTZ); | ||||
|                         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)""" | ||||
|                     "CREATE TABLE web_log_user ( | ||||
|                         id              TEXT        NOT NULL 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, | ||||
|                         salt            TEXT        NOT NULL, | ||||
|                         url             TEXT, | ||||
|                         access_level    TEXT        NOT NULL, | ||||
|                         created_on      TIMESTAMPTZ NOT NULL, | ||||
|                         last_seen_on    TIMESTAMPTZ); | ||||
|                     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        NOT NULL 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, | ||||
|                             prior_permalinks TEXT[]      NOT NULL DEFAULT '{}', | ||||
|                             published_on     TIMESTAMPTZ NOT NULL, | ||||
|                             updated_on       TIMESTAMPTZ NOT NULL, | ||||
|                             is_in_page_list  BOOLEAN     NOT NULL DEFAULT FALSE, | ||||
|                             template         TEXT, | ||||
|                             page_text        TEXT        NOT NULL | ||||
|                             meta_items       JSONB); | ||||
|                         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)""" | ||||
|                     "CREATE TABLE page ( | ||||
|                         id               TEXT        NOT NULL 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, | ||||
|                         prior_permalinks TEXT[]      NOT NULL DEFAULT '{}', | ||||
|                         published_on     TIMESTAMPTZ NOT NULL, | ||||
|                         updated_on       TIMESTAMPTZ NOT NULL, | ||||
|                         is_in_page_list  BOOLEAN     NOT NULL DEFAULT FALSE, | ||||
|                         template         TEXT, | ||||
|                         page_text        TEXT        NOT NULL | ||||
|                         meta_items       JSONB); | ||||
|                     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_revision" then | ||||
|                     """CREATE TABLE page_revision ( | ||||
|                             page_id        TEXT        NOT NULL REFERENCES page (id), | ||||
|                             as_of          TIMESTAMPTZ NOT NULL, | ||||
|                             revision_text  TEXT        NOT NULL, | ||||
|                             PRIMARY KEY (page_id, as_of))""" | ||||
|                     "CREATE TABLE page_revision ( | ||||
|                         page_id        TEXT        NOT NULL REFERENCES page (id), | ||||
|                         as_of          TIMESTAMPTZ NOT NULL, | ||||
|                         revision_text  TEXT        NOT NULL, | ||||
|                         PRIMARY KEY (page_id, as_of))" | ||||
|                  | ||||
|                 // Post tables | ||||
|                 if needsTable "post" then | ||||
|                     """CREATE TABLE post ( | ||||
|                             id               TEXT        NOT NULL 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, | ||||
|                             prior_permalinks TEXT[]      NOT NULL DEFAULT '{}', | ||||
|                             published_on     TIMESTAMPTZ, | ||||
|                             updated_on       TIMESTAMPTZ NOT NULL, | ||||
|                             template         TEXT, | ||||
|                             post_text        TEXT        NOT NULL, | ||||
|                             tags             TEXT[], | ||||
|                             meta_items       JSONB, | ||||
|                             episode          JSONB); | ||||
|                         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)""" | ||||
|                     "CREATE TABLE post ( | ||||
|                         id               TEXT        NOT NULL 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, | ||||
|                         prior_permalinks TEXT[]      NOT NULL DEFAULT '{}', | ||||
|                         published_on     TIMESTAMPTZ, | ||||
|                         updated_on       TIMESTAMPTZ NOT NULL, | ||||
|                         template         TEXT, | ||||
|                         post_text        TEXT        NOT NULL, | ||||
|                         tags             TEXT[], | ||||
|                         meta_items       JSONB, | ||||
|                         episode          JSONB); | ||||
|                     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)""" | ||||
|                     "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_revision" then | ||||
|                     """CREATE TABLE post_revision ( | ||||
|                             post_id        TEXT        NOT NULL REFERENCES post (id), | ||||
|                             as_of          TIMESTAMPTZ NOT NULL, | ||||
|                             revision_text  TEXT        NOT NULL, | ||||
|                             PRIMARY KEY (post_id, as_of))""" | ||||
|                     "CREATE TABLE post_revision ( | ||||
|                         post_id        TEXT        NOT NULL REFERENCES post (id), | ||||
|                         as_of          TIMESTAMPTZ NOT NULL, | ||||
|                         revision_text  TEXT        NOT NULL, | ||||
|                         PRIMARY KEY (post_id, as_of))" | ||||
|                 if needsTable "post_comment" then | ||||
|                     """CREATE TABLE post_comment ( | ||||
|                             id              TEXT        NOT NULL 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       TIMESTAMPTZ NOT NULL, | ||||
|                             comment_text    TEXT        NOT NULL); | ||||
|                         CREATE INDEX post_comment_post_idx ON post_comment (post_id)""" | ||||
|                     "CREATE TABLE post_comment ( | ||||
|                         id              TEXT        NOT NULL 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       TIMESTAMPTZ 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 NOT NULL 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)""" | ||||
|                     "CREATE TABLE tag_map ( | ||||
|                         id          TEXT NOT NULL 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        NOT NULL PRIMARY KEY, | ||||
|                             web_log_id  TEXT        NOT NULL REFERENCES web_log (id), | ||||
|                             path        TEXT        NOT NULL, | ||||
|                             updated_on  TIMESTAMPTZ NOT NULL, | ||||
|                             data        BYTEA       NOT NULL); | ||||
|                         CREATE INDEX upload_web_log_idx ON upload (web_log_id); | ||||
|                         CREATE INDEX upload_path_idx    ON upload (web_log_id, path)""" | ||||
|                     "CREATE TABLE upload ( | ||||
|                         id          TEXT        NOT NULL PRIMARY KEY, | ||||
|                         web_log_id  TEXT        NOT NULL REFERENCES web_log (id), | ||||
|                         path        TEXT        NOT NULL, | ||||
|                         updated_on  TIMESTAMPTZ NOT NULL, | ||||
|                         data        BYTEA       NOT NULL); | ||||
|                     CREATE INDEX upload_web_log_idx ON upload (web_log_id); | ||||
|                     CREATE INDEX upload_path_idx    ON upload (web_log_id, path)" | ||||
|             } | ||||
|             |> Seq.iter (fun sql -> | ||||
|                 let table = (sql.Split ' ')[2] | ||||
|                 log.LogInformation $"Creating {(sql.Split ' ')[2]} table..." | ||||
|                 Sql.existingConnection conn | ||||
|                 |> Sql.query sql | ||||
|                 |> Sql.executeNonQueryAsync | ||||
|                 |> Async.AwaitTask | ||||
|                 |> Async.RunSynchronously | ||||
|                 |> ignore) | ||||
|              | ||||
|             Sql.existingConnection conn | ||||
|             |> Sql.executeTransactionAsync | ||||
|                 (sql | ||||
|                  |> Seq.map (fun s -> | ||||
|                     log.LogInformation $"Creating {(s.Split ' ')[2]} table..." | ||||
|                     s, [ [] ]) | ||||
|                  |> List.ofSeq) | ||||
|             |> Async.AwaitTask | ||||
|             |> Async.RunSynchronously | ||||
|             |> ignore | ||||
|         } | ||||
|  | ||||
| @ -3,6 +3,7 @@ open Microsoft.Data.Sqlite | ||||
| open Microsoft.Extensions.Configuration | ||||
| open Microsoft.Extensions.Logging | ||||
| open MyWebLog | ||||
| open Npgsql | ||||
| 
 | ||||
| /// Middleware to derive the current web log | ||||
| type WebLogMiddleware (next : RequestDelegate, log : ILogger<WebLogMiddleware>) = | ||||
| @ -58,6 +59,11 @@ module DataImplementation = | ||||
|             let rethinkCfg = DataConfig.FromUri (connStr "RethinkDB") | ||||
|             let conn       = await (rethinkCfg.CreateConnectionAsync log) | ||||
|             upcast RethinkDbData (conn, rethinkCfg, log) | ||||
|         elif hasConnStr "PostgreSQL" then | ||||
|             let log  = sp.GetRequiredService<ILogger<PostgreSqlData>> () | ||||
|             let conn = new NpgsqlConnection (connStr "PostgreSQL") | ||||
|             log.LogInformation $"Using PostgreSQL database {conn.Host}:{conn.Port}/{conn.Database}" | ||||
|             PostgreSqlData (conn, log) | ||||
|         else | ||||
|             upcast createSQLite "Data Source=./myweblog.db;Cache=Shared" | ||||
| 
 | ||||
| @ -138,6 +144,16 @@ let rec main args = | ||||
|         // Use SQLite for caching as well | ||||
|         let cachePath = defaultArg (Option.ofObj (cfg.GetConnectionString "SQLiteCachePath")) "./session.db" | ||||
|         builder.Services.AddSqliteCache (fun o -> o.CachePath <- cachePath) |> ignore | ||||
|     | :? PostgreSqlData -> | ||||
|         // ADO.NET connections are designed to work as per-request instantiation | ||||
|         let cfg  = sp.GetRequiredService<IConfiguration> () | ||||
|         builder.Services.AddScoped<NpgsqlConnection> (fun sp -> | ||||
|             new NpgsqlConnection (cfg.GetConnectionString "PostgreSQL")) | ||||
|         |> ignore | ||||
|         builder.Services.AddScoped<IData, PostgreSqlData> () |> ignore | ||||
|         // Use SQLite for caching (for now) | ||||
|         let cachePath = defaultArg (Option.ofObj (cfg.GetConnectionString "SQLiteCachePath")) "./session.db" | ||||
|         builder.Services.AddSqliteCache (fun o -> o.CachePath <- cachePath) |> ignore | ||||
|     | _ -> () | ||||
|      | ||||
|     let _ = builder.Services.AddSession(fun opts -> | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user