From 092a5365cf01ab4b212fa0dde2ae71207eb4cce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20K=C3=A4ldstr=C3=B6m?= Date: Sun, 13 Sep 2026 21:59:12 +0300 Subject: [PATCH 1/2] =?UTF-8?q?Plan=201:=20init=20=E2=80=94=20the=20crate?= =?UTF-8?q?=20skeleton,=20Postgres=20for=20tests,=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JSRPugoPu8zLyykBCW6QJh --- docs/plans/1-init.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 docs/plans/1-init.md diff --git a/docs/plans/1-init.md b/docs/plans/1-init.md new file mode 100644 index 0000000..2fa7c63 --- /dev/null +++ b/docs/plans/1-init.md @@ -0,0 +1,40 @@ +# Plan 1 — init: the crate skeleton, Postgres for tests, CI + +## Goal + +`cedar-sql` compiles Cedar evaluation to SQL (`README.md`). This branch turns the empty repository into a +buildable crate with the module layout the later phases fill in, provisions a Postgres for the test suites +and the differential tests, and sets up CI, so that every later branch lands as code plus tests. + +## Design + +- **Crate** (`Cargo.toml`, `src/lib.rs`): depends on `cedar-policy` and `cedar-policy-core` with `tpe`, + patched to the `../cedar` checkout as `cedar-spec` does; modules `ident`, `config`, `annotations`, `ddl`, + `dialect`, `load`, `plan`, `compile`, `authorizer`, `backend`, `testing`, `error` — the last four have code, + the rest document what their phase adds. +- **Error** (`src/error.rs`): `Error::Unsupported(&'static str)` names a construct the compiler does not + handle yet (the differential tests skip these), `Database`, `Decode`, `Provision`. +- **Backend** (`src/backend/`): `trait Backend { execute_batch, query, begin, rollback }` over a + `SqlValue {Null, Bool, Long, Text, Json}` row model; `PgBackend` on the synchronous `postgres` client, + decoding `bool`, `int8`, `int4`, `text`, `jsonb`. +- **Testing** (`src/testing.rs`, feature `testing`): `SharedPostgres::get()` provisions one server per process + behind a `OnceLock`: the one at `CEDAR_SQL_PG_URL` when set, otherwise `postgresql_embedded` (Postgres 18, + temporary data directory, install directory overridable with `CEDAR_SQL_PG_INSTALL_DIR`, stopped through an + `atexit` hook since a `static` is never dropped). Tests run `BEGIN … ROLLBACK`, so the database stays empty. +- **CI** (`.github/workflows/ci.yml`): a `postgres:18` service, `cargo fmt/build/clippy/test` with + `-D warnings`, the `cedar-woodpecker` fork checked out next to the crate. +- **README**: a "Status and phases" section listing the phases and the two decided deviations. + +## Files + +`Cargo.toml`, `src/lib.rs`, `src/error.rs`, `src/backend/{mod,postgres}.rs`, `src/testing.rs`, the module +stubs, `tests/smoke_pg.rs`, `.github/workflows/ci.yml`, `README.md`, `docs/plans/1-init.md`. + +## Verification + +`cargo build --all-features`, `cargo clippy --all-targets --all-features -- -D warnings`, +`cargo fmt --all --check`, `cargo test --all-features` (with `CEDAR_SQL_PG_URL` set, or the embedded server). + +## History + +New; the first of the `cedar-sql` branches planned on 2026-09-13. From 83ef6b4cd41ef406471f85141db3a1acf62bf7d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lucas=20K=C3=A4ldstr=C3=B6m?= Date: Sun, 13 Sep 2026 21:59:12 +0300 Subject: [PATCH 2/2] Add the crate skeleton, the Postgres test provisioning and CI The module layout the later phases fill in, the `Backend` trait with the Postgres implementation, `SharedPostgres` (`CEDAR_SQL_PG_URL` or an embedded Postgres 18 started once per process), a smoke test, the CI workflow, and the README's "Status and phases" section. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01JSRPugoPu8zLyykBCW6QJh --- .github/workflows/ci.yml | 52 +++++++++++++++++ .gitignore | 3 + Cargo.toml | 37 ++++++++++++ README.md | 28 ++++++++++ src/annotations.rs | 7 +++ src/authorizer.rs | 4 ++ src/backend/mod.rs | 39 +++++++++++++ src/backend/postgres.rs | 80 ++++++++++++++++++++++++++ src/compile.rs | 5 ++ src/config.rs | 5 ++ src/ddl.rs | 5 ++ src/dialect.rs | 4 ++ src/error.rs | 28 ++++++++++ src/ident.rs | 5 ++ src/lib.rs | 55 ++++++++++++++++++ src/load.rs | 5 ++ src/plan.rs | 5 ++ src/testing.rs | 118 +++++++++++++++++++++++++++++++++++++++ tests/smoke_pg.rs | 29 ++++++++++ 19 files changed, 514 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 Cargo.toml create mode 100644 src/annotations.rs create mode 100644 src/authorizer.rs create mode 100644 src/backend/mod.rs create mode 100644 src/backend/postgres.rs create mode 100644 src/compile.rs create mode 100644 src/config.rs create mode 100644 src/ddl.rs create mode 100644 src/dialect.rs create mode 100644 src/error.rs create mode 100644 src/ident.rs create mode 100644 src/lib.rs create mode 100644 src/load.rs create mode 100644 src/plan.rs create mode 100644 src/testing.rs create mode 100644 tests/smoke_pg.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0ce9238 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: Build and test cedar-sql + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + build_and_test: + name: Build and test + runs-on: ubuntu-latest + services: + postgres: + image: postgres:18 + env: + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + CEDAR_SQL_PG_URL: postgres://postgres@localhost:5432/postgres + steps: + - name: Checkout cedar-sql + uses: actions/checkout@v7 + with: + path: ./cedar-sql + - name: Checkout cedar (luxas/cedar-woodpecker) + uses: actions/checkout@v7 + with: + repository: luxas/cedar-woodpecker + path: ./cedar + - name: Prepare Rust build + run: rustup update stable && rustup default stable && rustup component add rustfmt clippy + - name: cargo fmt + working-directory: ./cedar-sql + run: cargo fmt --all --check + - name: cargo build + working-directory: ./cedar-sql + run: RUSTFLAGS="-D warnings" cargo build --all-features + - name: cargo clippy + working-directory: ./cedar-sql + run: cargo clippy --all-targets --all-features -- -D warnings + - name: cargo test + working-directory: ./cedar-sql + run: cargo test --all-features -- --nocapture diff --git a/.gitignore b/.gitignore index ad67955..9402efc 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ target # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# The crate is patched to a sibling checkout of cedar; no lockfile is tracked, as in cedar-spec. +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8a3c2d1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "cedar-sql" +edition = "2024" +version = "0.1.0" +license = "Apache-2.0" +publish = false +description = "An experimental Cedar evaluator that compiles policy evaluation and entity fetching into SQL" +repository = "https://github.com/luxas/cedar-sql" + +[dependencies] +cedar-policy = { version = "4.12.0", features = ["tpe"] } +cedar-policy-core = { version = "4.12.0", features = ["tpe"] } +indexmap = "2" +postgres = { version = "0.19", features = ["with-serde_json-1"] } +serde_json = "1" +smol_str = "0.3" +thiserror = "2" + +# The `testing` feature provisions a Postgres for the test suites and the +# differential tests: it connects to `CEDAR_SQL_PG_URL` when set and otherwise +# starts an embedded server once per process. +postgresql_embedded = { version = "0.21", features = ["blocking"], optional = true } +libc = { version = "0.2", optional = true } + +[features] +default = [] +testing = ["dep:postgresql_embedded", "dep:libc"] + +[dev-dependencies] +cedar-sql = { path = ".", features = ["testing"] } + +# A checkout of `luxas/cedar-woodpecker` (a fork of `cedar-policy/cedar`) is +# expected next to this repository, as in `cedar-spec`. +[patch.crates-io] +cedar-policy = { path = "../cedar/cedar-policy" } +cedar-policy-core = { path = "../cedar/cedar-policy-core" } +cedar-policy-formatter = { path = "../cedar/cedar-policy-formatter" } diff --git a/README.md b/README.md index 10bad0d..9688b60 100644 --- a/README.md +++ b/README.md @@ -724,6 +724,34 @@ Other operators are handled accordingly. The evaluator should eventually pass all Cedar evaluator test using the DRT framework in the `cedar-spec` repo, as well as the `cedar-integration-tests` bundle. +## Status and phases + +The implementation is built in phases, one branch and plan document each (`docs/plans/N-slug.md` here, with the +differential tests and their `docs/plans/cedar-sql-N-slug.md` in the [`cedar-sql-spec`] fork of `cedar-spec`): + +1. **init** — the crate skeleton, the Postgres provisioning for tests, CI. +1. **schema** — Idea 1: the schema annotations, the database configuration, DDL, and loading Cedar entities as rows. +1. **is-authorized** — Idea 2 for a concrete request over the core operators, and the first differential test. +1. **values** — sets, records, `like`, tags, and the remaining operators. +1. **query** — unknown `principal` and/or `resource`, returning one row per candidate. +1. **corpus** — the `cedar-integration-tests` corpus and hardening. +1. **extensions** — the Cedar extension types. +1. **sqlite** — SQLite and Turso. +1. **benchmarks**. + +Deviations from the text above that were decided during implementation: + +- Sets and records are stored as canonical JSONB (sets deduplicated and sorted, records and entity references + wrapped) rather than `[]` arrays, so that JSONB equality is Cedar equality for every nested shape. +- The hierarchy table may hold the transitive closure instead of the direct edges (the recursive CTE tolerates both). + +The crate expects a checkout of [`cedar-woodpecker`] (a fork of `cedar`) next to this repository, as `cedar-spec` +does. Tests need a Postgres: set `CEDAR_SQL_PG_URL`, or leave it unset to have an embedded Postgres downloaded and +started on first use. + +[`cedar-sql-spec`]: https://github.com/luxas/cedar-sql-spec +[`cedar-woodpecker`]: https://github.com/luxas/cedar-woodpecker + ## Benchmarks - Compare how fast it is for single checks vs "cross-product of principal x resource" diff --git a/src/annotations.rs b/src/annotations.rs new file mode 100644 index 0000000..ceee341 --- /dev/null +++ b/src/annotations.rs @@ -0,0 +1,7 @@ +//! Reading the `@sql_*` schema annotations. +//! +//! Plan 2 adds the entity-type annotations (`@sql_table`, `@sql_tags_table`, +//! `@sql_primary_key`, `@sql_custom_config`, `@sql_entity_id_column`) and the +//! attribute annotations (`@sql_column`, `@sql_unique`, `@sql_custom_attrs`), +//! read from the schema fragment since annotations do not survive into the +//! validator schema. diff --git a/src/authorizer.rs b/src/authorizer.rs new file mode 100644 index 0000000..33dc3c3 --- /dev/null +++ b/src/authorizer.rs @@ -0,0 +1,4 @@ +//! The public entry points. +//! +//! Plan 3 adds `SqlAuthorizer` with `compile_request` and `is_authorized`; +//! Plan 5 adds `query` for partial requests. diff --git a/src/backend/mod.rs b/src/backend/mod.rs new file mode 100644 index 0000000..01f803e --- /dev/null +++ b/src/backend/mod.rs @@ -0,0 +1,39 @@ +//! Executing SQL on a database. + +pub mod postgres; + +use crate::Result; + +/// A value read from a result row. +#[derive(Debug, Clone, PartialEq)] +pub enum SqlValue { + /// SQL `NULL`. + Null, + /// A boolean. + Bool(bool), + /// A 64-bit integer. + Long(i64), + /// A string. + Text(String), + /// A JSON document. + Json(serde_json::Value), +} + +/// One result row. +pub type Row = Vec; + +/// A database connection this crate can run its statements on. +pub trait Backend { + /// Runs one or more statements, separated by `;`, without parameters. + fn execute_batch(&mut self, sql: &str) -> Result<()>; + /// Runs one query and returns its rows. + fn query(&mut self, sql: &str) -> Result>; + /// Starts a transaction. + fn begin(&mut self) -> Result<()> { + self.execute_batch("BEGIN") + } + /// Discards the current transaction. + fn rollback(&mut self) -> Result<()> { + self.execute_batch("ROLLBACK") + } +} diff --git a/src/backend/postgres.rs b/src/backend/postgres.rs new file mode 100644 index 0000000..3840800 --- /dev/null +++ b/src/backend/postgres.rs @@ -0,0 +1,80 @@ +//! The Postgres backend. + +use postgres::types::Type; +use postgres::{Client, NoTls}; + +use super::{Backend, Row, SqlValue}; +use crate::{Error, Result}; + +/// A synchronous Postgres connection. +pub struct PgBackend { + client: Client, +} + +impl PgBackend { + /// Connects to `url` (a `postgres://` URL or a key-value connection string). + pub fn connect(url: &str) -> Result { + Ok(Self { + client: Client::connect(url, NoTls)?, + }) + } + + /// The underlying client. + pub fn client_mut(&mut self) -> &mut Client { + &mut self.client + } +} + +impl Backend for PgBackend { + fn execute_batch(&mut self, sql: &str) -> Result<()> { + Ok(self.client.batch_execute(sql)?) + } + + fn query(&mut self, sql: &str) -> Result> { + let rows = self.client.query(sql, &[])?; + rows.iter() + .map(|row| { + row.columns() + .iter() + .enumerate() + .map(|(i, column)| decode(row, i, column.type_())) + .collect() + }) + .collect() + } +} + +fn decode(row: &postgres::Row, i: usize, ty: &Type) -> Result { + let decode_err = |e: postgres::Error| Error::Decode { + column: i, + message: e.to_string(), + }; + Ok(match *ty { + Type::BOOL => row + .try_get::<_, Option>(i) + .map_err(decode_err)? + .map_or(SqlValue::Null, SqlValue::Bool), + Type::INT8 => row + .try_get::<_, Option>(i) + .map_err(decode_err)? + .map_or(SqlValue::Null, SqlValue::Long), + Type::INT4 => row + .try_get::<_, Option>(i) + .map_err(decode_err)? + .map_or(SqlValue::Null, |v| SqlValue::Long(v.into())), + Type::TEXT | Type::VARCHAR => row + .try_get::<_, Option>(i) + .map_err(decode_err)? + .map_or(SqlValue::Null, SqlValue::Text), + Type::JSONB | Type::JSON => row + .try_get::<_, Option>(i) + .map_err(decode_err)? + .map_or(SqlValue::Null, SqlValue::Json), + ref other => { + return Err(Error::Decode { + column: i, + message: format!("unsupported column type {other}"), + }); + } + }) +} diff --git a/src/compile.rs b/src/compile.rs new file mode 100644 index 0000000..edef36a --- /dev/null +++ b/src/compile.rs @@ -0,0 +1,5 @@ +//! Compiling a TPE residual into a SQL expression. +//! +//! Plan 3 adds the three-valued encoding (`NULL` is an error) for the core +//! operators; Plan 4 completes it with sets, records, `like`, tags and +//! memoization. diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..618a5c0 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,5 @@ +//! The database, table and column model a Cedar schema maps to. +//! +//! Plan 2 adds `DatabaseConfiguration`, `TableConfiguration`, +//! `ColumnConfiguration` and `SQLType` as described in the README, together +//! with the globally unique interface-column and hierarchy-table naming rule. diff --git a/src/ddl.rs b/src/ddl.rs new file mode 100644 index 0000000..7b1b4a8 --- /dev/null +++ b/src/ddl.rs @@ -0,0 +1,5 @@ +//! Rendering a [`crate::config::DatabaseConfiguration`] as DDL. +//! +//! Plan 2 adds `create_tables` and `drop_tables`: the entity tables, the tags +//! tables, the hierarchy table, and the foreign keys as trailing `ALTER TABLE` +//! statements so table order does not matter. diff --git a/src/dialect.rs b/src/dialect.rs new file mode 100644 index 0000000..d63ae91 --- /dev/null +++ b/src/dialect.rs @@ -0,0 +1,4 @@ +//! The SQL dialect differences. +//! +//! Plan 2 adds the `Dialect` trait with the Postgres implementation; Plan 8 +//! adds SQLite (Turso). diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..e5a8652 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,28 @@ +//! The crate's error type. + +/// A `Result` with this crate's [`Error`]. +pub type Result = std::result::Result; + +/// Why an operation of this crate failed. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// A construct this crate does not compile (yet). The differential tests + /// treat this as a benign skip, so the string names the construct. + #[error("unsupported: {0}")] + Unsupported(&'static str), + /// The database returned an error. + #[error("database error: {0}")] + Database(#[from] postgres::Error), + /// A row's column had a type or value this crate cannot decode. + #[error("cannot decode column {column}: {message}")] + Decode { + /// The column's index in the row. + column: usize, + /// What went wrong. + message: String, + }, + /// The test-only database provisioning failed (feature `testing`). + #[error("cannot provision a Postgres for testing: {0}")] + Provision(String), +} diff --git a/src/ident.rs b/src/ident.rs new file mode 100644 index 0000000..7810629 --- /dev/null +++ b/src/ident.rs @@ -0,0 +1,5 @@ +//! SQL identifiers. +//! +//! Plan 2 adds `SQLIdentifier`: a validated identifier of at most 63 bytes whose +//! `Display` emits the double-quoted form with `"` doubled, plus +//! `quoted_literal` for single-quoted string literals. diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..4f47ea0 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,55 @@ +/* + * Copyright Lucas Käldström + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! `cedar-sql` compiles Cedar policy evaluation into SQL. +//! +//! The Cedar schema becomes SQL DDL (one table per entity type, a tags table per +//! entity type with tags, and one entity-hierarchy table), and after typed +//! partial evaluation (TPE) the residual policies become a single query whose +//! CTEs fetch the referenced entity data, so the database computes every +//! policy's three-valued outcome (`true`, `false`, `NULL` for an error) and the +//! authorization decision. When the request's `principal` and/or `resource` are +//! unknown, the same query returns one row per candidate entity. +//! +//! See `README.md` for the design and `docs/plans/` for the implementation +//! phases. The pipeline is: +//! +//! 1. [`config`] / [`annotations`] / [`ddl`]: Cedar schema → [`config::DatabaseConfiguration`] → DDL. +//! 2. [`load`]: Cedar entities → rows (used by the tests and the differential tests). +//! 3. [`plan`] / [`compile`]: TPE residuals → query plan → SQL text. +//! 4. [`authorizer`]: the public entry points, computing the decision from the rows. +//! 5. [`backend`]: executing SQL on a database. +//! +//! **This crate is experimental and must not be used in production.** + +#![warn(missing_docs)] +#![deny(unsafe_code)] + +pub mod annotations; +pub mod authorizer; +pub mod backend; +pub mod compile; +pub mod config; +pub mod ddl; +pub mod dialect; +mod error; +pub mod ident; +pub mod load; +pub mod plan; +#[cfg(feature = "testing")] +pub mod testing; + +pub use error::{Error, Result}; diff --git a/src/load.rs b/src/load.rs new file mode 100644 index 0000000..0b73312 --- /dev/null +++ b/src/load.rs @@ -0,0 +1,5 @@ +//! Turning Cedar entities into rows. +//! +//! Plan 2 adds the canonical JSON encoding of compound values (sets +//! deduplicated and sorted, records and entity references wrapped), the tag +//! rows and the ancestor rows, and separates the action entities out for TPE. diff --git a/src/plan.rs b/src/plan.rs new file mode 100644 index 0000000..d34a9f9 --- /dev/null +++ b/src/plan.rs @@ -0,0 +1,5 @@ +//! The query plan: which roots, joins and CTEs a set of residuals needs. +//! +//! Plan 3 adds `QueryPlan`, the roots (unknown request variables and +//! dereferenced entity literals), the attribute-path joins and the ancestor +//! CTEs, collected by a walk over the residuals. diff --git a/src/testing.rs b/src/testing.rs new file mode 100644 index 0000000..0d4fe59 --- /dev/null +++ b/src/testing.rs @@ -0,0 +1,118 @@ +//! Provisioning a Postgres for the test suites and the differential tests +//! (feature `testing`). +//! +//! [`SharedPostgres::get`] returns one server per process: the one at +//! `CEDAR_SQL_PG_URL` when that variable is set (CI runs a `postgres` service), +//! otherwise an embedded server started on first use and stopped when the +//! process exits. Every test or fuzz input should run inside +//! `BEGIN … ROLLBACK` (see [`crate::backend::Backend::begin`]), so the database +//! stays empty between inputs. + +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use postgresql_embedded::blocking::PostgreSQL; +use postgresql_embedded::{Settings, VersionReq}; + +use crate::backend::postgres::PgBackend; +use crate::{Error, Result}; + +/// The environment variable naming an existing Postgres to use. +pub const PG_URL_ENV: &str = "CEDAR_SQL_PG_URL"; + +/// The environment variable overriding where the embedded server is installed +/// (default: `~/.theseus/postgresql`); its data directory then lives under the +/// same directory instead of the system temporary directory. +pub const PG_INSTALL_DIR_ENV: &str = "CEDAR_SQL_PG_INSTALL_DIR"; + +/// The Postgres major version the embedded server installs. +const EMBEDDED_VERSION: &str = "=18.*"; + +/// The name of the database the embedded server creates. +const DATABASE: &str = "cedar_sql"; + +/// The Postgres shared by every test in this process. +pub struct SharedPostgres { + url: String, +} + +static SHARED: OnceLock> = OnceLock::new(); +static EMBEDDED: Mutex> = Mutex::new(None); + +impl SharedPostgres { + /// The process-wide Postgres, provisioned on first use. + /// + /// # Errors + /// + /// When neither `CEDAR_SQL_PG_URL` is set nor an embedded server can be + /// installed and started; the error is the same for every later call. + pub fn get() -> Result<&'static SharedPostgres> { + SHARED + .get_or_init(|| Self::provision().map_err(|e| e.to_string())) + .as_ref() + .map_err(|e| Error::Provision(e.clone())) + } + + /// The connection URL. + pub fn url(&self) -> &str { + &self.url + } + + /// Opens a new connection. + pub fn connect(&self) -> Result { + PgBackend::connect(&self.url) + } + + fn provision() -> Result { + if let Ok(url) = std::env::var(PG_URL_ENV) { + return Ok(Self { url }); + } + let mut settings = Settings { + version: VersionReq::parse(EMBEDDED_VERSION) + .map_err(|e| Error::Provision(e.to_string()))?, + temporary: true, + ..Settings::default() + }; + if let Some(dir) = std::env::var_os(PG_INSTALL_DIR_ENV) { + let dir = PathBuf::from(dir); + settings.data_dir = dir.join("data").join(format!( + "{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()) + )); + settings.installation_dir = dir; + } + let mut server = PostgreSQL::new(settings); + server + .setup() + .map_err(|e| Error::Provision(format!("setup: {e}")))?; + server + .start() + .map_err(|e| Error::Provision(format!("start: {e}")))?; + server + .create_database(DATABASE) + .map_err(|e| Error::Provision(format!("create database: {e}")))?; + let url = server.settings().url(DATABASE); + *EMBEDDED.lock().unwrap_or_else(|p| p.into_inner()) = Some(server); + // The server is a separate process: stop it when this one exits, since + // a `static` is never dropped. + // SAFETY: `atexit` with an `extern "C"` function of the right signature. + #[allow(unsafe_code)] + unsafe { + libc::atexit(stop_embedded); + } + Ok(Self { url }) + } +} + +extern "C" fn stop_embedded() { + if let Ok(mut guard) = EMBEDDED.try_lock() + && let Some(server) = guard.take() + { + // Errors cannot be reported meaningfully at exit. + let _ = server.stop(); + } +} diff --git a/tests/smoke_pg.rs b/tests/smoke_pg.rs new file mode 100644 index 0000000..0a88591 --- /dev/null +++ b/tests/smoke_pg.rs @@ -0,0 +1,29 @@ +//! The provisioning smoke test: a query round trip through the shared Postgres. + +use cedar_sql::backend::{Backend, SqlValue}; +use cedar_sql::testing::SharedPostgres; + +#[test] +fn select_one() { + let pg = SharedPostgres::get().expect("a Postgres is available"); + let mut db = pg.connect().expect("connects"); + db.begin().unwrap(); + db.execute_batch("CREATE TABLE t (id TEXT PRIMARY KEY, n BIGINT, b BOOLEAN, j JSONB)") + .unwrap(); + db.execute_batch("INSERT INTO t VALUES ('a', 1, true, '[1, {\"r\": {}}]')") + .unwrap(); + let rows = db.query("SELECT id, n, b, j, NULL::bigint FROM t").unwrap(); + assert_eq!( + rows, + vec![vec![ + SqlValue::Text("a".into()), + SqlValue::Long(1), + SqlValue::Bool(true), + SqlValue::Json(serde_json::json!([1, {"r": {}}])), + SqlValue::Null, + ]] + ); + db.rollback().unwrap(); + // The table did not survive the rollback. + assert!(db.query("SELECT 1 FROM t").is_err()); +}