#lang racket/base ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; PostgreSQL-backed concept-map storage. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (require db json racket/string "database.rkt" "storage.rkt") (provide list-concept-maps list-concept-usage list-recent-concept-maps search-concept-maps read-concept-map concept-map-history read-concept-map-version delete-concept-map-version! create-concept-map! rename-concept-map! update-concept-map! archive-concept-map!) (define maximum-document-size (* 10 1024 1024)) (define concept-map-columns "slug, title, document::text, current_version, created_at, updated_at, created_by, updated_by") (define (document->text document) (unless (jsexpr? document) (raise-argument-error 'document->text "jsexpr?" document)) (define text (jsexpr->string document)) (when (> (bytes-length (string->bytes/utf-8 text)) maximum-document-size) (error 'document->text "concept map document exceeds 10 MiB")) text) (define (text->document text) (with-handlers ((exn:fail? (λ (e) (error 'text->document "invalid stored concept map document: ~a" (exn-message e))))) (define parsed (string->jsexpr text)) (define document (if (string? parsed) (string->jsexpr parsed) parsed)) (unless (hash? document) (error 'text->document "stored concept map document is not a JSON object")) document)) (define (row->concept-map row [include-document? #t]) (define result (hash 'slug (vector-ref row 0) 'title (vector-ref row 1) 'currentVersion (vector-ref row 3) 'createdAt (vector-ref row 4) 'updatedAt (vector-ref row 5) 'createdBy (vector-ref row 6) 'updatedBy (vector-ref row 7))) (if include-document? (hash-set result 'document (text->document (vector-ref row 2))) result)) (define (validate-concept-map-input who slug title document) (unless (valid-slug? slug) (error who "invalid concept map address: ~a" slug)) (when (string=? (string-trim title) "") (error who "title is required")) (unless (hash? document) (raise-argument-error who "hash?" document)) (document->text document) (void)) (define (insert-concept-map-version! db map-id version title document-text author action summary now) (query-exec db #<concept-map row #f))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Count current concept placements across every active CMap. ; pre : Concept-map documents use an items array when placements exist. ; post : No database state is changed. ; result : Rows containing global concept identity, linked page, concept/map ; labels, CMap slug and placement count. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (list-concept-usage config) (call-with-wiki-database config (λ (db) (for/list ((row (in-list (query-rows db #<> 'conceptId' ~ '^concept-[0-9]+$' THEN concat('legacy:', cm.slug, ':', item ->> 'conceptId') ELSE item ->> 'conceptId' END AS concept_id, coalesce(nullif(repository.document ->> 'label', ''), nullif(item ->> 'label', ''), 'Concept') AS concept_label, coalesce(nullif(repository.document ->> 'pageSlug', ''), nullif(item ->> 'pageSlug', '')) AS page_slug FROM concept_maps cm CROSS JOIN LATERAL jsonb_array_elements( CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array' THEN cm.document -> 'items' ELSE '[]'::jsonb END ) AS item LEFT JOIN LATERAL ( SELECT concept_value AS document FROM jsonb_array_elements( CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array' THEN cm.document -> 'concepts' ELSE '[]'::jsonb END ) AS concept_entry(concept_value) WHERE concept_value ->> 'id' = item ->> 'conceptId' LIMIT 1 ) repository ON TRUE WHERE cm.archived = FALSE AND nullif(item ->> 'conceptId', '') IS NOT NULL AND coalesce(item ->> 'kind', 'concept') NOT IN ('phrase', 'submap') ) SELECT concept_id, slug, cmap_title, concept_label, page_slug, count(*) FROM placements GROUP BY concept_id, slug, cmap_title, concept_label, page_slug ORDER BY concept_id, lower(cmap_title), cmap_title SQL )))) (hash 'conceptId (vector-ref row 0) 'cmapSlug (vector-ref row 1) 'cmapTitle (vector-ref row 2) 'label (vector-ref row 3) 'pageSlug (if (sql-null? (vector-ref row 4)) 'null (vector-ref row 4)) 'count (vector-ref row 5)))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : List recently edited current concept maps. ; pre : Database schema migration 9 has been installed. ; post : Concept-map rows have only been read. ; result : At most limit metadata hashes, newest first. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (list-recent-concept-maps config [limit 50]) (call-with-wiki-database config (λ (db) (for/list ((row (in-list (query-rows db (string-append "SELECT " concept-map-columns " FROM concept_maps WHERE archived = FALSE" " ORDER BY updated_at DESC, lower(title), title" " LIMIT $1") limit)))) (row->concept-map row #f))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Search current concept maps by map title, address and concept text. ; pre : query-text is a string and database schema 9 is installed. ; post : Concept-map documents have only been read. ; result : Up to 50 relevance-sorted search result hashes without documents. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (search-concept-maps config query-text) (if (string=? (string-trim query-text) "") '() (call-with-wiki-database config (λ (db) (for/list ((row (in-list (query-rows db #<> 'label', item ->> 'synopsis'), ' ') FROM jsonb_array_elements( CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array' THEN cm.document -> 'items' ELSE '[]'::jsonb END) AS item ), '') AS concept_text FROM concept_maps cm WHERE cm.archived = FALSE ), ranked AS ( SELECT slug, title, concept_text, setweight(to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(slug, '')), 'A') || setweight(to_tsvector('simple', concept_text), 'B') AS search_document FROM map_documents ) SELECT ranked.slug, ranked.title, ts_rank(ranked.search_document, q.query) AS rank, ts_headline( 'simple', concat_ws(' ', ranked.title, ranked.concept_text), q.query, 'StartSel=[[[, StopSel=]]], MaxWords=28, MinWords=8, ShortWord=2') AS snippet FROM ranked, q WHERE ranked.search_document @@ q.query ORDER BY rank DESC, lower(ranked.title), ranked.title LIMIT 50 SQL query-text)))) (hash 'slug (vector-ref row 0) 'title (vector-ref row 1) 'rank (vector-ref row 2) 'snippet (vector-ref row 3) 'type "cmap")))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Read one current concept map including its editor document. ; pre : slug is a string. ; post : No database state is changed. ; result : Concept-map hash, or #f when no current map has that slug. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (read-concept-map config slug) (if (not (valid-slug? slug)) #f (call-with-wiki-database config (λ (db) (define row (query-maybe-row db (string-append "SELECT " concept-map-columns " FROM concept_maps WHERE slug = $1 AND archived = FALSE") slug)) (if row (row->concept-map row) #f))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Create a persistent concept map. ; pre : slug is unused, title is non-empty and document is a JSON object. ; post : One concept_maps row exists at version 1; history starts only when ; the user explicitly saves or creates a snapshot. ; result : The newly stored concept-map hash. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (create-concept-map! config slug title document author) (define clean-slug (string-trim slug)) (define clean-title (string-trim title)) (validate-concept-map-input 'create-concept-map! clean-slug clean-title document) (define document-text (document->text document)) (define now (current-seconds)) (call-with-wiki-database config (λ (db) (call-with-transaction db (λ () (query-value db #<number (format "~a" base-version)))) (unless (and supplied-version (= supplied-version current-version)) (error 'rename-concept-map! "version-conflict")) (define next-version (+ current-version 1)) (define now (current-seconds)) (query-exec db #<text document)) (call-with-wiki-database config (λ (db) (call-with-transaction db (λ () (define row (query-maybe-row db "SELECT id, current_version FROM concept_maps WHERE slug = $1 AND archived = FALSE FOR UPDATE" slug)) (unless row (error 'update-concept-map! "unknown concept map: ~a" slug)) (define current-version (vector-ref row 1)) (define supplied-version (if (number? base-version) base-version (string->number (format "~a" base-version)))) (unless (and supplied-version (= supplied-version current-version)) (error 'update-concept-map! "version-conflict")) (define next-version (+ current-version 1)) (define now (current-seconds)) (query-exec db #<number version))) (and version-number (call-with-wiki-database config (λ (db) (define row (query-maybe-row db #<document (vector-ref row 2)) 'author (vector-ref row 3) 'action (vector-ref row 4) 'summary (vector-ref row 5) 'createdAt (vector-ref row 6))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Delete one user-facing concept-map history item. ; pre : slug identifies a current map and version is numeric. ; post : Only the selected snapshot or manual-save row is removed; the ; current concept_maps document and version are unchanged. ; result : #t when an item was deleted, #f when it did not exist. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (delete-concept-map-version! config slug version) (define version-number (if (number? version) version (string->number version))) (and version-number (call-with-wiki-database config (lambda (db) (and (query-maybe-value db #<