bjopg/tests/pg.bjo
Linus Björnstam b7e9c8076f PostgreSQL for Bjolang: bjosql over Npgsql, for bjoroutines
Every operation suspends its fiber while it waits for the server, and
the ambient cancellation token cancels the query. A Db is a pool; a
transaction holds one connection and hands its function a Db bound to
it, and a nested one is a savepoint. Each SqlValue is a typed parameter,
and a value is read back by the PostgreSQL type of its column. The
server's errors are DbError with its SQLSTATE, and a lost connection is
DbError 08000.

🤖 Generated with [ECA](https://eca.dev)

Co-Authored-By: eca-agent <git@eca.dev>
2026-10-12 19:20:19 +02:00

292 lines
13 KiB
Text

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;; bjopg/tests/pg.bjo — against a real PostgreSQL.
;;
;; BJOPG_TEST="Host=localhost;Database=bjosql_test" bjo run tests/pg.bjo
;;
;; The tables it makes are dropped first and last. Without BJOPG_TEST it says
;; so and runs nothing. Every other line should start with "ok:".
(import (std prelude))
(import (std simpletest))
(import (std datetime))
(import (std uuid))
(import (bjosql core))
(import (bjopg core))
(import/class
(Exception (: System.Exception))
(InvalidOperation (: System.InvalidOperationException (-> string InvalidOperation))))
(def/table events Event #:sql-name "bjopg_events"
(key Uuid #:primary-key)
(day Date)
(at Time)
(local DateTime)
(happened Instant)
(price decimal)
(doc JsonDoc)
(count int)
(note (Option Instant))
(tag (Option Uuid)))
(def/table people Person #:sql-name "bjopg_people"
(id int #:primary-key #:auto)
(name string)
(email (Option string))
(score double)
(admin bool)
(avatar (Option (Array byte))))
;; A type of one's own, which PostgreSQL has and bjosql does not.
(type (: Inet (Struct (: text string))))
(impl (ToSql Inet) (defun (to-sql a) (SqlOther "inet" (struct-ref a text))))
(impl (FromSql Inet)
(defun (from-sql v)
(match v
((SqlOther "inet" s) (Ok (Inet (text s))))
(_ (Err (str "expected an inet, got " (describe-sql-value v)))))))
(def/table hosts Host #:sql-name "bjopg_hosts"
(name string #:primary-key)
(addr Inet #:sql-type "INET"))
(: some! (-> (Option %a) %a))
(defun (some! o)
(match o
((Some x) x)
(None (panic! "expected a value"))))
(: instant (-> string Instant))
(defun (instant s) (some! (iso8601->instant s)))
(: ok! (-> (Result SqlError %a) %a))
(defun (ok! r)
(match r
((Ok x) x)
((Err e) (panic! (sql-error-message e)))))
(: code-of (-> (Result SqlError %a) string))
(defun (code-of r)
(match r
((Ok _) "ok")
((Err (DbError code _)) code)
((Err e) (sql-error-message e))))
(: event (-> Uuid string string Event))
(defun (event key when price)
(Event (key key)
(day (some! (date 2026 10 12)))
(at (some! (time 14 5 #:seconds 9 #:nanoseconds 250000000)))
(local (some! (datetime 2026 10 12 #:hours 14 #:minutes 5 #:seconds 9 #:nanoseconds 123456000)))
(happened (instant when))
(price (string->decimal price))
(doc (json-doc "{\"b\": 1, \"a\": [1,2]}"))
(count 3)
(note None)
(tag None)))
(: person (-> string Person))
(defun (person name)
(Person (id 0) (name name) (email None) (score 1.0) (admin #f) (avatar None)))
;; A column read as a long.
(: long-field (-> Row string (Result SqlError long)))
(defun (long-field row name) (row-field row name))
;; One column of every row, as text, in the order the server gave them.
(: column (-bjo-> Db string (List string)))
(defbjo (column db query)
(list-map (fun (row) (match (row-value row "c")
((Some (SqlText s)) s)
((Some v) (describe-sql-value v))
(None "none")))
(ok! (query-sql db (sql query Nil)))))
(: names (-bjo-> Db (List string)))
(defbjo (names db)
(list-map (fun (p) (record-ref p name)) (ok! (fetch db people '(select (order-by name))))))
(: drop-all (-bjo-> Db void))
(defbjo (drop-all db)
(ignore (ok! (execute-sql db (sql "DROP TABLE IF EXISTS bjopg_events, bjopg_people, bjopg_hosts" Nil)))))
(: run (-bjo-> Db void))
(defbjo (run db)
(drop-all db)
(ignore (ok! (create-table db events)))
(ignore (ok! (create-table db people)))
(ignore (ok! (create-table db hosts)))
(expect "the columns have PostgreSQL's own types"
(column db "SELECT data_type AS c FROM information_schema.columns WHERE table_name = 'bjopg_events' ORDER BY ordinal_position")
(list "uuid" "date" "time without time zone" "timestamp without time zone" "timestamp with time zone"
"numeric" "jsonb" "integer" "timestamp with time zone" "uuid"))
;; ---- written and read back ---------------------------------------------
(def first (event (uuid-v7) "2026-10-12T09:00:00.5Z" "12.50"))
(def stored (ok! (insert db events first)))
(expect "a row of every new type comes back equal"
(list (= (record-ref stored key) (record-ref first key))
(= (compare (record-ref stored day) (record-ref first day)) 0)
(= (compare (record-ref stored at) (record-ref first at)) 0)
(= (compare (record-ref stored local) (record-ref first local)) 0)
(= (compare (record-ref stored happened) (record-ref first happened)) 0)
(= (decimal->string (record-ref stored price)) "12.50")
(= (record-ref stored count) 3)
(none? (record-ref stored note))
(none? (record-ref stored tag)))
(list #t #t #t #t #t #t #t #t #t))
(expect "jsonb keeps the value, in its own form"
(json-doc-text (record-ref stored doc))
"{\"a\": [1, 2], \"b\": 1}")
(expect "the instant is stored as a point in time"
(column db "SELECT to_char(happened AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.US') AS c FROM bjopg_events")
(list "2026-10-12 09:00:00.500000"))
;; ---- queries ------------------------------------------------------------
(ignore (ok! (insert db events (event (uuid-v7) "2026-10-12T09:00:00.25Z" "7.00"))))
(ignore (ok! (insert db events (event (uuid-v7) "2026-10-12T09:00:00Z" "100.00"))))
(ignore (ok! (insert db events (event (uuid-v7) "2026-10-12T10:00:00+02:00" "-3.00"))))
(expect "ORDER BY an instant"
(list-map (fun (e) (->str (record-ref e happened))) (ok! (fetch db events '(select (order-by happened)))))
(list "2026-10-12T08:00:00Z" "2026-10-12T09:00:00Z" "2026-10-12T09:00:00.25Z" "2026-10-12T09:00:00.5Z"))
(expect "ORDER BY a version 7 uuid sorts by the time it was made"
(list-map (fun (e) (decimal->string (record-ref e price))) (ok! (fetch db events '(select (order-by key)))))
(list "12.50" "7.00" "100.00" "-3.00"))
(expect "ORDER BY a decimal is numeric, as SQLite's text is not"
(list-map (fun (e) (decimal->string (record-ref e price))) (ok! (fetch db events '(select (order-by price)))))
(list "-3.00" "7.00" "12.50" "100.00"))
(expect "a test on a decimal is numeric"
(list-length (ok! (fetch db events '(select (where (price (> 10.0M)))))))
2)
(expect "a test on an instant"
(list-length (ok! (fetch db events '(select (where (happened (>= ,(instant "2026-10-12T09:00:00.25Z"))))))))
2)
(expect "a test on a uuid"
(list-length (ok! (fetch db events '(select (where (key (= ,(record-ref first key))))))))
1)
(def label (uuid-v4))
(expect "a nullable uuid and instant set from values"
(ok! (execute db events '(update (set (tag ,(Some label)) (note ,(Some (instant "2026-01-01T00:00:00Z"))))
(where (key (= ,(record-ref first key)))))))
1)
(expect "and read back"
(match (ok! (fetch-one db events '(select (where (tag not-null)))))
((Some e) (list (if (= (some! (record-ref e tag)) label) "the label" "another uuid")
(->str (record-ref e note))))
(None (list "none")))
(list "the label" "(Some 2026-01-01T00:00:00Z)"))
(expect "set back to NULL"
(ok! (execute db events '(update (set (tag null) (note null)) (where (tag not-null)))))
1)
;; ---- the six that were there --------------------------------------------
(def ada (ok! (insert db people (Person (id 0) (name "Ada") (email (Some "ada@example.org")) (score 9.5)
(admin #t) (avatar (Some #[1uy 2uy 3uy]))))))
(expect "an #:auto key comes back" (record-ref ada id) 1)
(expect "the rest of the row"
(str (record-ref ada name) " " (->str (record-ref ada email)) " " (double->string (record-ref ada score))
" " (->str (record-ref ada admin)) " "
(match (record-ref ada avatar) ((Some b) (int->string (array-length b))) (None "none")))
"Ada (Some ada@example.org) 9.5 True 3")
(expect "insert-all"
(list-map (fun (p) (record-ref p id)) (ok! (insert-all db people (list (person "Grace") (person "Linus")))))
(list 2 3))
(expect "SQL by hand, with ? for a value"
(match (ok! (query-sql db (sql "SELECT count(*) AS n FROM bjopg_people WHERE score > ?" (list (to-sql 5.0)))))
((Cons row _) (match (long-field row "n") ((Ok n) n) ((Err _) -1L)))
(Nil -1L))
1L)
(expect "an update counts its rows" (ok! (execute db people '(update (set (score 2.0)) (where (admin (= #f)))))) 2)
(expect "a delete counts its rows" (ok! (execute db people '(delete (where (name (= "Linus")))))) 1)
;; ---- errors ---------------------------------------------------------------
(ignore (ok! (execute-sql db (sql "CREATE UNIQUE INDEX bjopg_people_name ON bjopg_people (name)" Nil))))
(expect "a unique violation is SQLSTATE 23505" (code-of (insert db people (person "Ada"))) "23505")
(expect "SQL the server cannot parse is 42601" (code-of (execute-sql db (sql "SELEKT 1" Nil))) "42601")
;; ---- transactions -----------------------------------------------------------
(expect "Ok commits"
(code-of (with-transaction db (bjoroutine (tx) (insert tx people (person "Barbara")))))
"ok")
(expect "Err rolls back"
(code-of (with-transaction db
(bjoroutine (tx)
(ignore (ok! (insert tx people (person "Edsger"))))
(Err (BadQuery "changed my mind")))))
"changed my mind")
(expect "a throw rolls back, and is thrown on"
(match (try (with-transaction db
(bjoroutine (tx)
(ignore (ok! (insert tx people (person "Niklaus"))))
(raise (cast Exception (InvalidOperation. "boom")))))
#:catch (System.Exception))
((Ok _) "returned")
((Err _) "thrown"))
"thrown")
(expect "after a failed savepoint, the outer transaction goes on"
(code-of (with-transaction db
(bjoroutine (tx)
(ignore (ok! (insert tx people (person "Alan"))))
(expect "the inner one fails on the unique name"
(code-of (with-transaction tx (bjoroutine (inner) (insert inner people (person "Ada")))))
"23505")
(insert tx people (person "Tony")))))
"ok")
(expect "who is there"
(names db)
(list "Ada" "Alan" "Barbara" "Grace" "Tony"))
;; ---- a type of one's own ------------------------------------------------------
(ignore (ok! (insert db hosts (Host (name "router") (addr (Inet (text "10.0.0.1")))))))
(expect "SqlOther is sent with a cast to its type"
(list-map (fun (h) (struct-ref (record-ref h addr) text))
(ok! (fetch db hosts '(select (where (addr (= ,(Inet (text "10.0.0.1")))))))))
(list "10.0.0.1"))
(expect "and the server checks it"
(code-of (insert db hosts (Host (name "bad") (addr (Inet (text "not an address"))))))
"22P02")
;; ---- many fibers, one pool ------------------------------------------------------
;; Each query waits a little on the server, so the 20 overlap: they are only
;; done in a fraction of 20 times the wait if the fibers suspend while they
;; wait, rather than holding a thread each.
(def started (monotonic-ms))
(def queries
(loop (:for i (range 0 20))
(:acc out (listing (bjo (query-sql db (sql "SELECT count(*) AS n, pg_sleep(0.2) FROM bjopg_people" Nil)))))
=> out))
(def answers
(loop (:for p queries)
(:acc out (listing (match (sync (promise-join p))
((Ok (Ok (Cons row _))) (match (long-field row "n") ((Ok n) n) ((Err e) -1L)))
((Ok (Err e)) -1L)
((Ok (Ok Nil)) -2L)
((Err e) -3L))))
=> out))
(expect "20 queries at once through the pool all answer" answers (list-map (fun (_) 5L) (range-list 0 20)))
(expect-true "and they overlap" (< (- (monotonic-ms) started) 2000L))
(drop-all db))
(: range-list (-> int int (List int)))
(defun (range-list a b) (loop (:for i (range a b)) (:acc out (listing i)) => out))
(defbjo (main args)
(match (get-environment-variable "BJOPG_TEST")
(None (println "skipped: set BJOPG_TEST to a connection string, such as Host=localhost;Database=bjosql_test"))
((Some connection-string)
(expect "a server that is not there is DbError 08000"
(code-of (open-db "Host=localhost;Port=1;Database=nothing;Timeout=2"))
"08000")
(expect "a database that is not there is the server's 3D000"
(code-of (open-db (str connection-string ";Database=bjopg_no_such_database")))
"3D000")
(match (open-db connection-string)
((Err e) (println (str "FAILURE: open: " (sql-error-message e))))
((Ok db) (try (run db) #:finally (close-db db))))))
0)