From 87c1fb1bbd241859f955d84b2cd3f7d535d0c519 Mon Sep 17 00:00:00 2001 From: Brandon Lucas Date: Mon, 16 Feb 2026 04:30:44 -0500 Subject: [PATCH] feat: add PostgreSQL driver with Postgres effect Implements full PostgreSQL support through the Postgres effect: - connect(connStr): Connect to PostgreSQL database - close(conn): Close connection - execute(conn, sql): Execute INSERT/UPDATE/DELETE, return affected rows - query(conn, sql): Execute SELECT, return all rows as records - queryOne(conn, sql): Execute SELECT, return first row as Option - beginTx(conn): Start transaction - commit(conn): Commit transaction - rollback(conn): Rollback transaction Includes: - Connection tracking with connection IDs - Row mapping to Lux records with field access - Transaction support - Example: examples/postgres_demo.lux - Documentation in docs/guide/11-databases.md Co-Authored-By: Claude Opus 4.5 --- Cargo.lock | 501 ++++++++++++++++++++++++++++++++++++- Cargo.toml | 2 + docs/guide/11-databases.md | 450 +++++++++++++++++++++++++++++++++ examples/postgres_demo.lux | 185 ++++++++++++++ src/interpreter.rs | 315 +++++++++++++++++++++++ src/typechecker.rs | 4 +- 6 files changed, 1447 insertions(+), 10 deletions(-) create mode 100644 docs/guide/11-databases.md create mode 100644 examples/postgres_demo.lux diff --git a/Cargo.lock b/Cargo.lock index 5cf0cdd..5777c3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "anyhow" version = "1.0.101" @@ -14,12 +26,29 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "base64" version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -32,12 +61,27 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -107,6 +151,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -122,6 +175,27 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -170,6 +244,24 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -236,6 +328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -270,6 +363,7 @@ checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", "futures-io", + "futures-sink", "futures-task", "memchr", "pin-project-lite", @@ -277,6 +371,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -285,7 +389,19 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", ] [[package]] @@ -320,6 +436,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -335,12 +460,30 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.12" @@ -575,6 +718,27 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -587,6 +751,15 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -625,8 +798,10 @@ version = "0.1.0" dependencies = [ "lsp-server", "lsp-types", - "rand", + "postgres", + "rand 0.8.5", "reqwest", + "rusqlite", "rustyline", "serde", "serde_json", @@ -635,6 +810,16 @@ dependencies = [ "tiny_http", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.0" @@ -654,7 +839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -696,6 +881,24 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -746,12 +949,54 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -770,6 +1015,49 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "postgres" +version = "0.19.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c48ece1c6cda0db61b058c1721378da76855140e9214339fa1317decacb176" +dependencies = [ + "bytes", + "fallible-iterator 0.2.0", + "futures-util", + "log", + "tokio", + "tokio-postgres", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee9dd5fe15055d2b6806f4736aa0c9637217074e224bbec46d4041b91bb9491" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "hmac", + "md-5", + "memchr", + "rand 0.9.2", + "sha2", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b858f82211e84682fecd373f68e1ceae642d8d751a1ebd13f33de6257b3e20" +dependencies = [ + "bytes", + "fallible-iterator 0.2.0", + "postgres-protocol", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -839,8 +1127,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -850,7 +1148,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -862,13 +1170,31 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "reqwest" version = "0.11.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" dependencies = [ - "base64", + "base64 0.21.7", "bytes", "encoding_rs", "futures-core", @@ -902,6 +1228,20 @@ dependencies = [ "winreg", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.10.0", + "fallible-iterator 0.3.0", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustix" version = "1.1.3" @@ -921,7 +1261,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "base64", + "base64 0.21.7", ] [[package]] @@ -967,6 +1307,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "security-framework" version = "3.6.0" @@ -1062,12 +1408,29 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.12" @@ -1106,6 +1469,23 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.115" @@ -1210,6 +1590,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.49.0" @@ -1234,6 +1629,32 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-postgres" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcea47c8f71744367793f16c2db1f11cb859d28f436bdb4ca9193eb1f787ee42" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator 0.2.0", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.9.2", + "socket2 0.6.2", + "tokio", + "tokio-util", + "whoami", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -1278,12 +1699,39 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -1333,6 +1781,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "want" version = "0.3.1" @@ -1348,6 +1802,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -1366,6 +1829,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -1469,6 +1941,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "whoami" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6a5b12f9df4f978d2cfdb1bd3bac52433f44393342d7ee9c25f5a1c14c0f45d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index c6c2224..ee38a1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,8 @@ serde_json = "1" rand = "0.8" reqwest = { version = "0.11", features = ["blocking", "json"] } tiny_http = "0.12" +rusqlite = { version = "0.31", features = ["bundled"] } +postgres = "0.19" [dev-dependencies] diff --git a/docs/guide/11-databases.md b/docs/guide/11-databases.md new file mode 100644 index 0000000..bfb658c --- /dev/null +++ b/docs/guide/11-databases.md @@ -0,0 +1,450 @@ +# Working with Databases + +Lux includes built-in support for SQL databases through the `Sql` effect. This guide covers how to connect to databases, execute queries, handle transactions, and best practices for database operations. + +## Quick Start + +```lux +fn main(): Unit with {Console, Sql} = { + // Open an in-memory SQLite database + let db = Sql.openMemory() + + // Create a table + Sql.execute(db, "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)") + + // Insert data + Sql.execute(db, "INSERT INTO users (name) VALUES ('Alice')") + Sql.execute(db, "INSERT INTO users (name) VALUES ('Bob')") + + // Query data + let users = Sql.query(db, "SELECT * FROM users") + Console.print("Found users: " ++ toString(users)) + + // Clean up + Sql.close(db) +} + +run main() with {} +``` + +## Connecting to Databases + +### In-Memory Database + +For testing and temporary data: + +```lux +let db = Sql.openMemory() +// Database exists only in memory, lost when closed +``` + +### File-Based Database + +For persistent storage: + +```lux +let db = Sql.open("./data/app.db") +// Creates file if it doesn't exist +``` + +### Connection Lifecycle + +Always close connections when done: + +```lux +fn withDatabase(path: String, f: fn(SqlConn): T with Sql): T with Sql = { + let db = Sql.open(path) + let result = f(db) + Sql.close(db) + result +} +``` + +## Executing Queries + +### Non-Returning Queries (INSERT, UPDATE, DELETE, CREATE) + +Use `Sql.execute` for statements that don't return rows: + +```lux +// Create table +Sql.execute(db, "CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + content TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP +)") + +// Insert +Sql.execute(db, "INSERT INTO posts (title, content) VALUES ('Hello', 'World')") + +// Update +Sql.execute(db, "UPDATE posts SET title = 'Updated' WHERE id = 1") + +// Delete +Sql.execute(db, "DELETE FROM posts WHERE id = 1") +``` + +### Queries that Return Rows (SELECT) + +Use `Sql.query` to get all matching rows: + +```lux +// Returns List +let users = Sql.query(db, "SELECT * FROM users") + +// Each row is a record-like structure +for user in users { + Console.print("User: " ++ user.name) +} +``` + +Use `Sql.queryOne` to get a single row (or None): + +```lux +// Returns Option +let maybeUser = Sql.queryOne(db, "SELECT * FROM users WHERE id = 1") + +match maybeUser { + Some(user) => Console.print("Found: " ++ user.name), + None => Console.print("User not found") +} +``` + +## Transactions + +Transactions ensure multiple operations succeed or fail together. + +### Basic Transaction + +```lux +// Start transaction +Sql.beginTx(db) + +// Do operations +Sql.execute(db, "INSERT INTO accounts (name, balance) VALUES ('Alice', 1000)") +Sql.execute(db, "INSERT INTO accounts (name, balance) VALUES ('Bob', 1000)") + +// Commit changes +Sql.commit(db) +``` + +### Rollback on Error + +```lux +Sql.beginTx(db) + +let result = transferFunds(db, fromId, toId, amount) + +match result { + Ok(_) => Sql.commit(db), + Err(_) => Sql.rollback(db) // Undo all changes +} +``` + +### Transaction Helper + +Here's a pattern for safe transactions: + +```lux +fn transaction(db: SqlConn, f: fn(): T with Sql): Result with Sql = { + Sql.beginTx(db) + + // Execute the function + // In a real implementation, you'd catch errors here + let result = f() + + Sql.commit(db) + Ok(result) +} +``` + +## Working with Results + +Query results are returned as `List` where each row behaves like a record: + +```lux +let rows = Sql.query(db, "SELECT id, name, age FROM users") + +for row in rows { + // Access columns by name + let id = row.id // Int + let name = row.name // String + let age = row.age // Int or null + + Console.print("{name} (age {age})") +} +``` + +### Handling NULL values + +NULL values from the database are represented as options: + +```lux +let row = Sql.queryOne(db, "SELECT email FROM users WHERE id = 1") + +match row { + Some(r) => { + match r.email { + Some(email) => Console.print("Email: " ++ email), + None => Console.print("No email set") + } + }, + None => Console.print("User not found") +} +``` + +## SQL Injection Prevention + +**IMPORTANT**: The current API passes SQL strings directly. For production use, always: + +1. Validate and sanitize user input +2. Use parameterized queries (when available) +3. Never concatenate user input into SQL strings + +```lux +// DANGEROUS - Never do this! +let query = "SELECT * FROM users WHERE name = '" ++ userInput ++ "'" + +// SAFER - Validate input first +fn safeUserLookup(db: SqlConn, userId: Int): Option with Sql = { + // Integers are safe to interpolate + Sql.queryOne(db, "SELECT * FROM users WHERE id = " ++ toString(userId)) +} + +// For strings, escape quotes at minimum +fn escapeString(s: String): String = { + String.replace(s, "'", "''") +} +``` + +## Common Patterns + +### Repository Pattern + +Encapsulate database operations: + +```lux +type User = { id: Int, name: String, email: Option } + +fn createUser(db: SqlConn, name: String): Int with Sql = { + Sql.execute(db, "INSERT INTO users (name) VALUES ('" ++ escapeString(name) ++ "')") + // Get last inserted ID + let row = Sql.queryOne(db, "SELECT last_insert_rowid() as id") + match row { + Some(r) => r.id, + None => -1 + } +} + +fn findUserById(db: SqlConn, id: Int): Option with Sql = { + let row = Sql.queryOne(db, "SELECT * FROM users WHERE id = " ++ toString(id)) + match row { + Some(r) => Some({ id: r.id, name: r.name, email: r.email }), + None => None + } +} + +fn findAllUsers(db: SqlConn): List with Sql = { + let rows = Sql.query(db, "SELECT * FROM users ORDER BY name") + List.map(rows, fn(r) => { id: r.id, name: r.name, email: r.email }) +} +``` + +### Testing with In-Memory Database + +```lux +fn testUserRepository(): Unit with {Test, Sql} = { + // Each test gets a fresh database + let db = Sql.openMemory() + + // Set up schema + Sql.execute(db, "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)") + + // Test + let id = createUser(db, "Test User") + Test.assertTrue(id > 0, "Should return valid ID") + + let user = findUserById(db, id) + Test.assertTrue(Option.isSome(user), "Should find created user") + + Sql.close(db) +} +``` + +### Handler for Testing (Mock Database) + +The effect system lets you swap database implementations: + +```lux +// Production handler uses real SQLite +handler realDatabase(): Sql { + // Uses actual rusqlite implementation +} + +// Test handler uses in-memory storage +handler mockDatabase(): Sql { + let storage = ref [] + + fn execute(conn, sql) = { + // Parse and simulate SQL + } + + fn query(conn, sql) = { + // Return mock data + [] + } +} + +// Run with mock database in tests +run userService() with { + Sql -> mockDatabase() +} +``` + +## API Reference + +### Types + +```lux +type SqlConn // Opaque connection handle +type SqlRow // Row result with named column access +``` + +### Operations + +| Function | Type | Description | +|----------|------|-------------| +| `Sql.open(path)` | `String -> SqlConn` | Open database file | +| `Sql.openMemory()` | `() -> SqlConn` | Open in-memory database | +| `Sql.close(conn)` | `SqlConn -> Unit` | Close connection | +| `Sql.execute(conn, sql)` | `(SqlConn, String) -> Int` | Execute statement, return affected rows | +| `Sql.query(conn, sql)` | `(SqlConn, String) -> List` | Query, return all rows | +| `Sql.queryOne(conn, sql)` | `(SqlConn, String) -> Option` | Query, return first row | +| `Sql.beginTx(conn)` | `SqlConn -> Unit` | Begin transaction | +| `Sql.commit(conn)` | `SqlConn -> Unit` | Commit transaction | +| `Sql.rollback(conn)` | `SqlConn -> Unit` | Rollback transaction | + +## Error Handling + +Database operations can fail. In the current implementation, errors cause runtime failures. Future versions will support returning `Result` types: + +```lux +// Future API (not yet implemented) +fn safeQuery(db: SqlConn, sql: String): Result, SqlError> with Sql = { + Sql.tryQuery(db, sql) +} +``` + +## Performance Tips + +1. **Batch inserts in transactions** - Much faster than individual inserts: + ```lux + Sql.beginTx(db) + for item in items { + Sql.execute(db, "INSERT INTO data (value) VALUES (" ++ toString(item) ++ ")") + } + Sql.commit(db) + ``` + +2. **Use `queryOne` for single results** - More efficient than `query` when you only need one row + +3. **Create indexes** for frequently queried columns: + ```lux + Sql.execute(db, "CREATE INDEX idx_users_email ON users(email)") + ``` + +4. **Close connections** when done to free resources + +## PostgreSQL Support + +Lux also provides native PostgreSQL support through the `Postgres` effect. This is ideal for production applications requiring a full-featured relational database. + +### Connecting to PostgreSQL + +```lux +fn main(): Unit with {Console, Postgres} = { + // Connect using a connection string + let connStr = "host=localhost user=myuser password=mypass dbname=mydb" + let conn = Postgres.connect(connStr) + + Console.print("Connected to PostgreSQL!") + + // ... use the connection ... + + Postgres.close(conn) +} +``` + +### PostgreSQL Operations + +The PostgreSQL API mirrors the SQLite API: + +```lux +// Execute non-returning queries +Postgres.execute(conn, "CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT)") +Postgres.execute(conn, "INSERT INTO users (name) VALUES ('Alice')") + +// Query multiple rows +let users = Postgres.query(conn, "SELECT * FROM users") +for user in users { + Console.print("User: " ++ user.name) +} + +// Query single row +match Postgres.queryOne(conn, "SELECT * FROM users WHERE id = 1") { + Some(user) => Console.print("Found: " ++ user.name), + None => Console.print("Not found") +} +``` + +### PostgreSQL Transactions + +```lux +// Start transaction +Postgres.beginTx(conn) + +// Make changes +Postgres.execute(conn, "UPDATE accounts SET balance = balance - 100 WHERE id = 1") +Postgres.execute(conn, "UPDATE accounts SET balance = balance + 100 WHERE id = 2") + +// Commit or rollback +Postgres.commit(conn) +// Or: Postgres.rollback(conn) +``` + +### PostgreSQL API Reference + +| Function | Type | Description | +|----------|------|-------------| +| `Postgres.connect(connStr)` | `String -> Int` | Connect to PostgreSQL, returns connection ID | +| `Postgres.close(conn)` | `Int -> Unit` | Close connection | +| `Postgres.execute(conn, sql)` | `(Int, String) -> Int` | Execute statement, return affected rows | +| `Postgres.query(conn, sql)` | `(Int, String) -> List` | Query, return all rows | +| `Postgres.queryOne(conn, sql)` | `(Int, String) -> Option` | Query, return first row | +| `Postgres.beginTx(conn)` | `Int -> Unit` | Begin transaction | +| `Postgres.commit(conn)` | `Int -> Unit` | Commit transaction | +| `Postgres.rollback(conn)` | `Int -> Unit` | Rollback transaction | + +### When to Use SQLite vs PostgreSQL + +| Feature | SQLite | PostgreSQL | +|---------|--------|------------| +| Setup | No setup needed | Requires server | +| Deployment | Single file | Server process | +| Concurrency | Limited | Excellent | +| Scale | Small-medium | Large | +| Features | Basic | Full RDBMS | +| Use case | Embedded, testing | Production | + +## Limitations + +- No connection pooling yet +- No prepared statements / parameterized queries yet +- Limited type mapping (basic Int, String, Float) + +## See Also + +- [Effects Guide](./05-effects.md) - Understanding the effect system +- [Testing Guide](../testing.md) - Writing tests with mock handlers +- [Roadmap](../ROADMAP.md) - Planned database features diff --git a/examples/postgres_demo.lux b/examples/postgres_demo.lux new file mode 100644 index 0000000..b26131d --- /dev/null +++ b/examples/postgres_demo.lux @@ -0,0 +1,185 @@ +// PostgreSQL Database Example +// +// Demonstrates the Postgres effect for database operations. +// +// Prerequisites: +// - PostgreSQL server running locally +// - Database 'testdb' created +// - User 'testuser' with password 'testpass' +// +// To set up: +// createdb testdb +// psql testdb -c "CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT, email TEXT);" +// +// Run with: lux examples/postgres_demo.lux + +// ============================================================ +// Helper Functions +// ============================================================ + +fn jsonStr(key: String, value: String): String = + "\"" + key + "\":\"" + value + "\"" + +fn jsonNum(key: String, value: Int): String = + "\"" + key + "\":" + toString(value) + +fn jsonObj(content: String): String = + "{" + content + "}" + +// ============================================================ +// Database Operations +// ============================================================ + +// Insert a user +fn insertUser(connId: Int, name: String, email: String): Int with {Console, Postgres} = { + let sql = "INSERT INTO users (name, email) VALUES ('" + name + "', '" + email + "') RETURNING id" + Console.print("Inserting user: " + name) + match Postgres.queryOne(connId, sql) { + Some(row) => { + Console.print(" Inserted with ID: " + toString(row.id)) + row.id + }, + None => { + Console.print(" Insert failed") + -1 + } + } +} + +// Get all users +fn getUsers(connId: Int): Unit with {Console, Postgres} = { + Console.print("Fetching all users...") + let rows = Postgres.query(connId, "SELECT id, name, email FROM users ORDER BY id") + Console.print(" Found " + toString(List.length(rows)) + " users:") + List.forEach(rows, fn(row: { id: Int, name: String, email: String }): Unit with {Console} => { + Console.print(" - " + toString(row.id) + ": " + row.name + " <" + row.email + ">") + }) +} + +// Get user by ID +fn getUserById(connId: Int, id: Int): Unit with {Console, Postgres} = { + let sql = "SELECT id, name, email FROM users WHERE id = " + toString(id) + Console.print("Looking up user " + toString(id) + "...") + match Postgres.queryOne(connId, sql) { + Some(row) => Console.print(" Found: " + row.name + " <" + row.email + ">"), + None => Console.print(" User not found") + } +} + +// Update user email +fn updateUserEmail(connId: Int, id: Int, newEmail: String): Unit with {Console, Postgres} = { + let sql = "UPDATE users SET email = '" + newEmail + "' WHERE id = " + toString(id) + Console.print("Updating user " + toString(id) + " email to " + newEmail) + let affected = Postgres.execute(connId, sql) + Console.print(" Rows affected: " + toString(affected)) +} + +// Delete user +fn deleteUser(connId: Int, id: Int): Unit with {Console, Postgres} = { + let sql = "DELETE FROM users WHERE id = " + toString(id) + Console.print("Deleting user " + toString(id)) + let affected = Postgres.execute(connId, sql) + Console.print(" Rows affected: " + toString(affected)) +} + +// ============================================================ +// Transaction Example +// ============================================================ + +fn transactionDemo(connId: Int): Unit with {Console, Postgres} = { + Console.print("") + Console.print("=== Transaction Demo ===") + + // Start transaction + Console.print("Beginning transaction...") + Postgres.beginTx(connId) + + // Make some changes + insertUser(connId, "TxUser1", "tx1@example.com") + insertUser(connId, "TxUser2", "tx2@example.com") + + // Show users before commit + Console.print("Users before commit:") + getUsers(connId) + + // Commit the transaction + Console.print("Committing transaction...") + Postgres.commit(connId) + + Console.print("Transaction committed!") +} + +// ============================================================ +// Main +// ============================================================ + +fn main(): Unit with {Console, Postgres} = { + Console.print("========================================") + Console.print(" PostgreSQL Demo") + Console.print("========================================") + Console.print("") + + // Connect to database + Console.print("Connecting to PostgreSQL...") + let connStr = "host=localhost user=testuser password=testpass dbname=testdb" + let connId = Postgres.connect(connStr) + Console.print("Connected! Connection ID: " + toString(connId)) + Console.print("") + + // Create table if not exists + Console.print("Creating users table...") + Postgres.execute(connId, "CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL)") + Console.print("") + + // Clear table for demo + Console.print("Clearing existing data...") + Postgres.execute(connId, "DELETE FROM users") + Console.print("") + + // Insert some users + Console.print("=== Inserting Users ===") + let id1 = insertUser(connId, "Alice", "alice@example.com") + let id2 = insertUser(connId, "Bob", "bob@example.com") + let id3 = insertUser(connId, "Charlie", "charlie@example.com") + Console.print("") + + // Query all users + Console.print("=== All Users ===") + getUsers(connId) + Console.print("") + + // Query single user + Console.print("=== Single User Lookup ===") + getUserById(connId, id2) + Console.print("") + + // Update user + Console.print("=== Update User ===") + updateUserEmail(connId, id2, "bob.new@example.com") + getUserById(connId, id2) + Console.print("") + + // Delete user + Console.print("=== Delete User ===") + deleteUser(connId, id3) + getUsers(connId) + Console.print("") + + // Transaction demo + transactionDemo(connId) + Console.print("") + + // Final state + Console.print("=== Final State ===") + getUsers(connId) + Console.print("") + + // Close connection + Console.print("Closing connection...") + Postgres.close(connId) + Console.print("Done!") +} + +// Note: This will fail if PostgreSQL is not running +// To test the syntax only, you can comment out the last line +let output = run main() with {} diff --git a/src/interpreter.rs b/src/interpreter.rs index 1e71357..288c7cd 100644 --- a/src/interpreter.rs +++ b/src/interpreter.rs @@ -5,6 +5,7 @@ use crate::ast::*; use crate::diagnostics::{Diagnostic, ErrorCode, Severity}; use rand::Rng; +use postgres::{Client as PgClient, NoTls}; use rusqlite::Connection; use std::cell::RefCell; use std::collections::HashMap; @@ -615,6 +616,10 @@ pub struct Interpreter { sql_connections: RefCell>, /// Next SQL connection ID next_sql_conn_id: RefCell, + /// PostgreSQL database connections (connection ID -> Client) + pg_connections: RefCell>, + /// Next PostgreSQL connection ID + next_pg_conn_id: RefCell, } /// Results from running tests @@ -657,6 +662,8 @@ impl Interpreter { test_results: RefCell::new(TestResults::default()), sql_connections: RefCell::new(HashMap::new()), next_sql_conn_id: RefCell::new(1), + pg_connections: RefCell::new(HashMap::new()), + next_pg_conn_id: RefCell::new(1), } } @@ -4054,6 +4061,314 @@ impl Interpreter { } } + // ============================================================ + // PostgreSQL Effect + // ============================================================ + + ("Postgres", "connect") => { + let conn_str = match request.args.first() { + Some(Value::String(s)) => s.clone(), + _ => return Err(RuntimeError { + message: "Postgres.connect requires a connection string".to_string(), + span: None, + }), + }; + + match PgClient::connect(&conn_str, NoTls) { + Ok(client) => { + let id = *self.next_pg_conn_id.borrow(); + *self.next_pg_conn_id.borrow_mut() += 1; + self.pg_connections.borrow_mut().insert(id, client); + Ok(Value::Int(id)) + } + Err(e) => Err(RuntimeError { + message: format!("Postgres.connect failed: {}", e), + span: None, + }), + } + } + + ("Postgres", "close") => { + let conn_id = match request.args.first() { + Some(Value::Int(id)) => *id, + _ => return Err(RuntimeError { + message: "Postgres.close requires a connection ID".to_string(), + span: None, + }), + }; + + if self.pg_connections.borrow_mut().remove(&conn_id).is_some() { + Ok(Value::Unit) + } else { + Err(RuntimeError { + message: format!("Postgres.close: invalid connection ID {}", conn_id), + span: None, + }) + } + } + + ("Postgres", "execute") => { + let (conn_id, sql) = match (request.args.get(0), request.args.get(1)) { + (Some(Value::Int(id)), Some(Value::String(s))) => (*id, s.clone()), + _ => return Err(RuntimeError { + message: "Postgres.execute requires connection ID and SQL string".to_string(), + span: None, + }), + }; + + let mut conns = self.pg_connections.borrow_mut(); + let conn = match conns.get_mut(&conn_id) { + Some(c) => c, + None => return Err(RuntimeError { + message: format!("Postgres.execute: invalid connection ID {}", conn_id), + span: None, + }), + }; + + match conn.execute(&sql, &[]) { + Ok(rows) => Ok(Value::Int(rows as i64)), + Err(e) => Err(RuntimeError { + message: format!("Postgres.execute failed: {}", e), + span: None, + }), + } + } + + ("Postgres", "query") => { + let (conn_id, sql) = match (request.args.get(0), request.args.get(1)) { + (Some(Value::Int(id)), Some(Value::String(s))) => (*id, s.clone()), + _ => return Err(RuntimeError { + message: "Postgres.query requires connection ID and SQL string".to_string(), + span: None, + }), + }; + + let mut conns = self.pg_connections.borrow_mut(); + let conn = match conns.get_mut(&conn_id) { + Some(c) => c, + None => return Err(RuntimeError { + message: format!("Postgres.query: invalid connection ID {}", conn_id), + span: None, + }), + }; + + match conn.query(&sql, &[]) { + Ok(rows) => { + let mut results = Vec::new(); + for row in rows { + let mut record = HashMap::new(); + for (i, col) in row.columns().iter().enumerate() { + let col_name = col.name().to_string(); + let value: Value = match col.type_().name() { + "int4" | "int8" | "int2" => { + let v: Option = row.get(i); + match v { + Some(n) => Value::Int(n), + None => Value::Constructor { name: "None".to_string(), fields: vec![] }, + } + } + "float4" | "float8" => { + let v: Option = row.get(i); + match v { + Some(n) => Value::Float(n), + None => Value::Constructor { name: "None".to_string(), fields: vec![] }, + } + } + "bool" => { + let v: Option = row.get(i); + match v { + Some(b) => Value::Bool(b), + None => Value::Constructor { name: "None".to_string(), fields: vec![] }, + } + } + "text" | "varchar" | "char" | "bpchar" | "name" => { + let v: Option = row.get(i); + match v { + Some(s) => Value::String(s), + None => Value::Constructor { name: "None".to_string(), fields: vec![] }, + } + } + _ => { + // Try to get as string for other types + let v: Option = row.try_get(i).ok().flatten(); + match v { + Some(s) => Value::String(s), + None => Value::Constructor { name: "None".to_string(), fields: vec![] }, + } + } + }; + record.insert(col_name, value); + } + results.push(Value::Record(record)); + } + Ok(Value::List(results)) + } + Err(e) => Err(RuntimeError { + message: format!("Postgres.query failed: {}", e), + span: None, + }), + } + } + + ("Postgres", "queryOne") => { + let (conn_id, sql) = match (request.args.get(0), request.args.get(1)) { + (Some(Value::Int(id)), Some(Value::String(s))) => (*id, s.clone()), + _ => return Err(RuntimeError { + message: "Postgres.queryOne requires connection ID and SQL string".to_string(), + span: None, + }), + }; + + let mut conns = self.pg_connections.borrow_mut(); + let conn = match conns.get_mut(&conn_id) { + Some(c) => c, + None => return Err(RuntimeError { + message: format!("Postgres.queryOne: invalid connection ID {}", conn_id), + span: None, + }), + }; + + match conn.query_opt(&sql, &[]) { + Ok(Some(row)) => { + let mut record = HashMap::new(); + for (i, col) in row.columns().iter().enumerate() { + let col_name = col.name().to_string(); + let value: Value = match col.type_().name() { + "int4" | "int8" | "int2" => { + let v: Option = row.get(i); + match v { + Some(n) => Value::Int(n), + None => Value::Constructor { name: "None".to_string(), fields: vec![] }, + } + } + "float4" | "float8" => { + let v: Option = row.get(i); + match v { + Some(n) => Value::Float(n), + None => Value::Constructor { name: "None".to_string(), fields: vec![] }, + } + } + "bool" => { + let v: Option = row.get(i); + match v { + Some(b) => Value::Bool(b), + None => Value::Constructor { name: "None".to_string(), fields: vec![] }, + } + } + "text" | "varchar" | "char" | "bpchar" | "name" => { + let v: Option = row.get(i); + match v { + Some(s) => Value::String(s), + None => Value::Constructor { name: "None".to_string(), fields: vec![] }, + } + } + _ => { + let v: Option = row.try_get(i).ok().flatten(); + match v { + Some(s) => Value::String(s), + None => Value::Constructor { name: "None".to_string(), fields: vec![] }, + } + } + }; + record.insert(col_name, value); + } + Ok(Value::Constructor { + name: "Some".to_string(), + fields: vec![Value::Record(record)], + }) + } + Ok(None) => Ok(Value::Constructor { + name: "None".to_string(), + fields: vec![], + }), + Err(e) => Err(RuntimeError { + message: format!("Postgres.queryOne failed: {}", e), + span: None, + }), + } + } + + ("Postgres", "beginTx") => { + let conn_id = match request.args.first() { + Some(Value::Int(id)) => *id, + _ => return Err(RuntimeError { + message: "Postgres.beginTx requires a connection ID".to_string(), + span: None, + }), + }; + + let mut conns = self.pg_connections.borrow_mut(); + let conn = match conns.get_mut(&conn_id) { + Some(c) => c, + None => return Err(RuntimeError { + message: format!("Postgres.beginTx: invalid connection ID {}", conn_id), + span: None, + }), + }; + + match conn.execute("BEGIN", &[]) { + Ok(_) => Ok(Value::Unit), + Err(e) => Err(RuntimeError { + message: format!("Postgres.beginTx failed: {}", e), + span: None, + }), + } + } + + ("Postgres", "commit") => { + let conn_id = match request.args.first() { + Some(Value::Int(id)) => *id, + _ => return Err(RuntimeError { + message: "Postgres.commit requires a connection ID".to_string(), + span: None, + }), + }; + + let mut conns = self.pg_connections.borrow_mut(); + let conn = match conns.get_mut(&conn_id) { + Some(c) => c, + None => return Err(RuntimeError { + message: format!("Postgres.commit: invalid connection ID {}", conn_id), + span: None, + }), + }; + + match conn.execute("COMMIT", &[]) { + Ok(_) => Ok(Value::Unit), + Err(e) => Err(RuntimeError { + message: format!("Postgres.commit failed: {}", e), + span: None, + }), + } + } + + ("Postgres", "rollback") => { + let conn_id = match request.args.first() { + Some(Value::Int(id)) => *id, + _ => return Err(RuntimeError { + message: "Postgres.rollback requires a connection ID".to_string(), + span: None, + }), + }; + + let mut conns = self.pg_connections.borrow_mut(); + let conn = match conns.get_mut(&conn_id) { + Some(c) => c, + None => return Err(RuntimeError { + message: format!("Postgres.rollback: invalid connection ID {}", conn_id), + span: None, + }), + }; + + match conn.execute("ROLLBACK", &[]) { + Ok(_) => Ok(Value::Unit), + Err(e) => Err(RuntimeError { + message: format!("Postgres.rollback failed: {}", e), + span: None, + }), + } + } + _ => Err(RuntimeError { message: format!( "Unhandled effect operation: {}.{}", diff --git a/src/typechecker.rs b/src/typechecker.rs index 6587249..4c5fd90 100644 --- a/src/typechecker.rs +++ b/src/typechecker.rs @@ -1229,7 +1229,7 @@ impl TypeChecker { fn check_function(&mut self, func: &FunctionDecl) { // Validate that all declared effects exist - let builtin_effects = ["Console", "Fail", "State", "Reader", "Random", "Time", "File", "Process", "Http", "HttpServer", "Test", "Sql"]; + let builtin_effects = ["Console", "Fail", "State", "Reader", "Random", "Time", "File", "Process", "Http", "HttpServer", "Test", "Sql", "Postgres"]; for effect in &func.effects { let is_builtin = builtin_effects.contains(&effect.name.as_str()); let is_defined = self.env.lookup_effect(&effect.name).is_some(); @@ -2010,7 +2010,7 @@ impl TypeChecker { } // Built-in effects are always available - let builtin_effects = ["Console", "Fail", "State", "Reader", "Random", "Time", "File", "Process", "Http", "HttpServer", "Test", "Sql"]; + let builtin_effects = ["Console", "Fail", "State", "Reader", "Random", "Time", "File", "Process", "Http", "HttpServer", "Test", "Sql", "Postgres"]; let is_builtin = builtin_effects.contains(&effect.name.as_str()); // Track this effect for inference