diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9879bae..6a5507d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,14 @@ name: CI on: push: + branches: [main] + tags: ["v*"] pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true permissions: contents: read @@ -11,6 +18,7 @@ jobs: unit: name: Unit tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} + timeout-minutes: 10 strategy: fail-fast: false matrix: @@ -26,8 +34,15 @@ jobs: - run: rvpm doc database-e2e: - name: PostgreSQL, MySQL, and SQLite end-to-end + name: PostgreSQL ${{ matrix.postgres }}, MySQL, and SQLite end-to-end runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + postgres: ["16.9-alpine", "17.5-alpine"] + env: + NEST_POSTGRES_IMAGE: postgres:${{ matrix.postgres }} steps: - uses: actions/checkout@v4 - uses: martian56/setup-raven@v1 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d809e93 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## 0.2.0 - 2026-07-15 + +- Surface SQLite binding, execution, reading, finalization, and close failures. +- Discover SQLite result column labels for wildcard and raw queries. +- Preserve the logical behavior of empty `IN` and `NOT IN` predicates. +- Compile equality comparisons with SQL NULL as `IS NULL` and `IS NOT NULL`. +- Encode booleans portably for PostgreSQL, MySQL, and SQLite. +- Generate valid offset-only queries for every bundled dialect. +- Detect renamed or missing migrations that were already applied. +- Add a MySQL adapter mode for `NO_BACKSLASH_ESCAPES` sessions. +- Update all bundled database drivers to version 0.2.0. +- Expand Docker coverage for transactions, PostgreSQL `RETURNING`, adapter recovery, + migration history, and MySQL SQL modes. +- Test PostgreSQL 16 and 17 combinations in CI. + +## 0.1.0 - 2026-07-14 + +- Initial release. diff --git a/README.md b/README.md index a0da6cf..8ca0f45 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ database libraries. Nest is under active initial development. It requires Raven 2.26.1 or later (the transaction helper passes a closure whose parameter is typed with the adapter, which earlier compilers could not resolve). The current foundation is tested end -to end with Raven 2.26.1, PostgreSQL 17, MySQL 8.0.43, and bundled SQLite. +to end with Raven 2.26.1, PostgreSQL 16 and 17, MySQL 8.0.43, and bundled SQLite. ## What works @@ -17,7 +17,8 @@ to end with Raven 2.26.1, PostgreSQL 17, MySQL 8.0.43, and bundled SQLite. - PostgreSQL, MySQL, and SQLite dialect-specific placeholders and identifier quoting. - INNER and LEFT joins, aliases, predicates, ordering, limits, and offsets. -- IN and NOT IN predicates with parameterized lists (an empty list matches nothing). +- IN and NOT IN predicates with parameterized lists. Empty `IN` matches nothing; + empty `NOT IN` keeps every row. - INSERT ... RETURNING on PostgreSQL and SQLite to read generated columns. - Transactions: begin, commit, rollback, and a transaction() helper that commits on success and rolls back on error. @@ -31,14 +32,14 @@ to end with Raven 2.26.1, PostgreSQL 17, MySQL 8.0.43, and bundled SQLite. ## Install -When the first Nest release is published, add it to `rv.toml`: +Add Nest to `rv.toml`: ```toml [dependencies] -"github.com/martian56/nest-orm" = "v0.1.0" +"github.com/martian56/nest-orm" = "v0.2.0" ``` -Nest currently depends on version `v0.1.0` of all three Raven database drivers. +Nest currently depends on version `v0.2.0` of all three Raven database drivers. ## Quick start with SQLite @@ -88,6 +89,8 @@ let my = mysql(mysql_connection) ``` The adapters own their connection wrapper. Call `db.close()` when finished. +The SQLite adapter also exposes `db.try_close()` when shutdown failures need to +be handled explicitly. ## Query builders @@ -95,6 +98,9 @@ Values are always sent through driver parameters. Nest validates and quotes identifiers separately, so neither values nor identifiers can silently become arbitrary SQL syntax. +Passing `null()` to `where_eq` or `where_not_eq` produces `IS NULL` or +`IS NOT NULL`; ordered comparisons with NULL are rejected as mistakes. + ```raven let query = select("users") .column("users.id") @@ -128,9 +134,42 @@ let deleted = delete_all(db, delete_from("users").where_eq("id", integer(7)))? ``` `inserted`, `changed`, and `deleted` are `QueryResult` values. Their `affected` -field reports changed rows. `last_insert_id` is populated by MySQL and SQLite; -PostgreSQL applications should select generated values with an explicit query -until Nest adds RETURNING support. +field reports changed rows. `last_insert_id` is populated by MySQL and SQLite. +PostgreSQL and SQLite can return generated values in the same round trip: + +```raven +let inserted = insert_one( + db, + insert_into("users").value("name", text("Ada")).returning("id"), +)? +let id = inserted.rows[0].int_named("id")? +``` + +MySQL does not support `INSERT ... RETURNING`; Nest reports that combination at +query compilation, and MySQL-generated ids remain available as +`last_insert_id`. + +## Transactions + +Use `transaction` to commit a successful closure or roll back its error: + +```raven +let result = transaction(db, fun(tx: Sqlite) -> Result = + insert_one(tx, insert_into("users").value("name", text("Grace"))) +)? +``` + +`begin`, `commit`, and `rollback` are also public for explicit control. The +helper preserves the closure's original error if its best-effort rollback also +fails. + +## MySQL SQL modes + +The default `mysql(connection)` adapter matches MySQL's standard string-literal +rules. If the connection session enables `NO_BACKSLASH_ESCAPES`, construct it +with `mysql_no_backslash_escapes(connection)` so placeholders inside raw SQL are +scanned with the same rules. Builder-generated queries remain parameterized in +either mode. ## Model mapping @@ -182,6 +221,8 @@ let report = migrate(db, migrations)? Nest creates `nest_migrations`, applies only missing versions, and requires the input list to contain unique positive versions in increasing order. `rollback_last(db, migrations)` reverts the latest applied migration. +Nest rejects an applied migration whose version is missing from the compiled +history or whose name changed, preventing silent history drift. PostgreSQL and SQLite migrations run in a transaction. MySQL automatically commits many DDL statements, so Nest records a MySQL migration only after all @@ -235,11 +276,8 @@ shell script to keep the database containers running after tests. - Values use the text parameter formats exposed by the three current drivers. - Results are collected in memory; streaming cursors are not implemented. -- SQLite wildcard/raw-query columns use generated names when the underlying - driver cannot expose column labels. Explicit `.column(...)` selections have - stable names. -- PostgreSQL RETURNING, upserts, aggregates, subqueries, connection pools, - relations, eager loading, and derive-generated model mappings are planned. +- Upserts, aggregates, subqueries, connection pools, relations, eager loading, + and derive-generated model mappings are not implemented yet. - Migration SQL is intentionally explicit because column types and many DDL operations differ across databases. diff --git a/adapters_test.rv b/adapters_test.rv index 87a8eb9..0746cf1 100644 --- a/adapters_test.rv +++ b/adapters_test.rv @@ -5,10 +5,10 @@ import "github.com/martian56/raven-mysql" { connect as connect_mysql } import "github.com/martian56/raven-postgres" { connect as connect_postgres } import std/error { error_kind } import "github.com/martian56/raven-sqlite" { Db as SqliteDb } -import "./lib" { Executor, QueryResult, delete_all, delete_from, fetch_all, insert_into, insert_one, integer, null, select, text, transaction, update, update_all } +import "./lib" { Executor, QueryResult, Value, boolean, delete_all, delete_from, fetch_all, insert_into, insert_one, integer, null, raw_query, select, text, transaction, update, update_all } import "./migrations" { Migration, migrate, migration, rollback_last } -import "./mysql" { mysql } -import "./postgres" { postgres } +import "./mysql" { MySql, mysql, mysql_no_backslash_escapes } +import "./postgres" { Postgres, postgres } import "./sqlite" { Sqlite, sqlite } fun must_result(result: Result) -> T { @@ -104,7 +104,7 @@ fun test_transaction_commits_and_rolls_back() { fun test_sqlite_adapter_crud_end_to_end() { let connection = must_open_sqlite(":memory:") match connection.exec( - "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, nickname TEXT NULL)", + "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, nickname TEXT NULL, active BOOLEAN NOT NULL)", ) { Ok(changed) -> {}, Err(message) -> __panic(message), @@ -112,7 +112,13 @@ fun test_sqlite_adapter_crud_end_to_end() { let db = sqlite(connection) let first = must_result( - insert_one(db, insert_into("users").value("name", text("Ada")).value("nickname", null())), + insert_one( + db, + insert_into("users").value("name", text("Ada")).value("nickname", null()).value( + "active", + boolean(true), + ), + ), ) assert_eq_int(first.affected, 1) assert_eq_int(first.last_insert_id, 1) @@ -122,7 +128,7 @@ fun test_sqlite_adapter_crud_end_to_end() { insert_into("users").value("name", text("Grace")).value( "nickname", text("Amazing Grace"), - ), + ).value("active", boolean(false)), ), ) assert_eq_int(second.last_insert_id, 2) @@ -130,12 +136,16 @@ fun test_sqlite_adapter_crud_end_to_end() { let users = must_result( fetch_all( db, - select("users").column("id").column("name").column("nickname").order_asc("id"), + select("users").column("id").column("name").column("nickname").column("active").order_asc( + "id", + ), ), ) assert_eq_int(users.row_count(), 2) assert_eq_str(string_or(users.rows[0].get_named("name"), "missing"), "Ada") assert_true(users.rows[0].is_null_named("nickname")) + assert_true(must_result(users.rows[0].bool_named("active"))) + assert_true(!must_result(users.rows[1].bool_named("active"))) assert_eq_str(users.rows[1].get(2), "Amazing Grace") let updated = must_result( @@ -167,6 +177,62 @@ fun test_sqlite_adapter_keeps_hostile_values_out_of_sql() { db.close() } +fun test_sqlite_adapter_surfaces_failures_and_remains_reusable() { + let connection = must_open_sqlite(":memory:") + let _ = connection.exec( + "CREATE TABLE unique_users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE)", + ) + let db = sqlite(connection) + let _ = must_result(insert_one(db, insert_into("unique_users").value("name", text("Ada")))) + + match insert_one(db, insert_into("unique_users").value("name", text("Ada"))) { + Ok(_) -> assert_msg(false, "duplicate value unexpectedly succeeded"), + Err(e) -> assert_msg(e.message().contains("UNIQUE"), e.message()), + } + + let no_params: List> = [] + let one_column: List = ["value"] + match db.run(raw_query("SELECT ?", no_params, one_column, true)) { + Ok(_) -> assert_msg(false, "wrong parameter count unexpectedly succeeded"), + Err(e) -> assert_msg(e.message().contains("expected 1 parameters"), e.message()), + } + + // Both failures finalize their statements, so the same connection can be + // used immediately and closed cleanly. + assert_eq_int(must_result(fetch_all(db, select("unique_users"))).row_count(), 1) + let discover_columns: List = [] + let discovered = must_result( + db.run(raw_query("SELECT 7 AS answer", no_params, discover_columns, true)), + ) + assert_eq_str(string_or(discovered.rows[0].get_named("answer"), "missing"), "7") + let _ = must_result(db.try_close()) +} + +fun test_sqlite_returning_and_empty_membership_end_to_end() { + let connection = must_open_sqlite(":memory:") + let _ = connection.exec( + "CREATE TABLE people (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)", + ) + let db = sqlite(connection) + let inserted = must_result( + insert_one( + db, + insert_into("people").value("name", text("Ada")).returning("id").returning("name"), + ), + ) + assert_eq_str(string_or(inserted.rows[0].get_named("id"), "missing"), "1") + assert_eq_str(string_or(inserted.rows[0].get_named("name"), "missing"), "Ada") + let _ = must_result(insert_one(db, insert_into("people").value("name", text("Grace")))) + + let none: List = [] + assert_eq_int(must_result(fetch_all(db, select("people").where_in("id", none))).row_count(), 0) + assert_eq_int( + must_result(fetch_all(db, select("people").where_not_in("id", none))).row_count(), + 2, + ) + let _ = must_result(db.try_close()) +} + fun postgres_port() -> Int { return match get_env_or("NEST_POSTGRES_PORT", "55433").parse_int() { Some(port) -> port, @@ -197,23 +263,26 @@ fun test_postgres_adapter_end_to_end() { let _ = must_result(connection.execute("DROP TABLE IF EXISTS nest_users")) let _ = must_result( connection.execute( - "CREATE TABLE nest_users (id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, nickname TEXT NULL)", + "CREATE TABLE nest_users (id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, nickname TEXT NULL, active BOOLEAN NOT NULL DEFAULT FALSE)", ), ) let db = postgres(connection) - assert_eq_int( - must_result( - insert_one( - db, - insert_into("nest_users").value("name", text("Ada")).value("nickname", null()), - ), - ).affected, - 1, + let inserted = must_result( + insert_one( + db, + insert_into("nest_users").value("name", text("Ada")).value("nickname", null()).value( + "active", + boolean(true), + ).returning("id").returning("name"), + ), ) + assert_eq_int(inserted.affected, 1) + assert_eq_str(string_or(inserted.rows[0].get_named("id"), "missing"), "1") + assert_eq_str(string_or(inserted.rows[0].get_named("name"), "missing"), "Ada") let rows = must_result( fetch_all( db, - select("nest_users").column("id").column("name").column("nickname").where_eq( + select("nest_users").column("id").column("name").column("nickname").column("active").where_eq( "name", text("Ada"), ), @@ -222,6 +291,30 @@ fun test_postgres_adapter_end_to_end() { assert_eq_int(rows.row_count(), 1) assert_eq_str(string_or(rows.rows[0].get_named("name"), "missing"), "Ada") assert_true(rows.rows[0].is_null_named("nickname")) + assert_true(must_result(rows.rows[0].bool_named("active"))) + let _ = must_result( + transaction( + db, + fun(tx: Postgres) -> Result = insert_one( + tx, + insert_into("nest_users").value("name", text("Grace")), + ), + ), + ) + match transaction( + db, + fun(tx: Postgres) -> Result = match insert_one( + tx, + insert_into("nest_users").value("name", text("Hopper")), + ) { + Ok(_) -> Err(error_kind("test", "rollback")), + Err(inner) -> Err(inner), + }, + ) { + Ok(_) -> assert_msg(false, "PostgreSQL rollback transaction unexpectedly succeeded"), + Err(_) -> assert_true(true), + } + assert_eq_int(must_result(fetch_all(db, select("nest_users"))).row_count(), 2) let _ = must_result(delete_all(db, delete_from("nest_users").where_eq("name", text("Ada")))) verify_live_migrations(db) let _ = must_result(db.connection.execute("DROP TABLE nest_users")) @@ -244,14 +337,17 @@ fun test_mysql_adapter_end_to_end() { let _ = must_result(connection.execute("DROP TABLE IF EXISTS nest_users")) let _ = must_result( connection.execute( - "CREATE TABLE nest_users (id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, nickname VARCHAR(100) NULL)", + "CREATE TABLE nest_users (id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, nickname VARCHAR(100) NULL, active BOOLEAN NOT NULL DEFAULT FALSE)", ), ) let db = mysql(connection) let inserted = must_result( insert_one( db, - insert_into("nest_users").value("name", text("Ada")).value("nickname", null()), + insert_into("nest_users").value("name", text("Ada")).value("nickname", null()).value( + "active", + boolean(true), + ), ), ) assert_eq_int(inserted.affected, 1) @@ -259,7 +355,7 @@ fun test_mysql_adapter_end_to_end() { let rows = must_result( fetch_all( db, - select("nest_users").column("id").column("name").column("nickname").where_eq( + select("nest_users").column("id").column("name").column("nickname").column("active").where_eq( "name", text("Ada"), ), @@ -268,6 +364,7 @@ fun test_mysql_adapter_end_to_end() { assert_eq_int(rows.row_count(), 1) assert_eq_str(string_or(rows.rows[0].get_named("name"), "missing"), "Ada") assert_true(rows.rows[0].is_null_named("nickname")) + assert_true(must_result(rows.rows[0].bool_named("active"))) let _ = must_result( update_all(db, update("nest_users").set("name", text("Augusta")).where_eq("id", integer(1))), ) @@ -277,6 +374,46 @@ fun test_mysql_adapter_end_to_end() { ), "Augusta", ) + let _ = must_result( + transaction( + db, + fun(tx: MySql) -> Result = insert_one( + tx, + insert_into("nest_users").value("name", text("Grace")), + ), + ), + ) + match transaction( + db, + fun(tx: MySql) -> Result = match insert_one( + tx, + insert_into("nest_users").value("name", text("Hopper")), + ) { + Ok(_) -> Err(error_kind("test", "rollback")), + Err(inner) -> Err(inner), + }, + ) { + Ok(_) -> assert_msg(false, "MySQL rollback transaction unexpectedly succeeded"), + Err(_) -> assert_true(true), + } + assert_eq_int(must_result(fetch_all(db, select("nest_users"))).row_count(), 2) + + let _ = must_result(db.connection.execute("SET SESSION sql_mode = 'NO_BACKSLASH_ESCAPES'")) + let mode_db = mysql_no_backslash_escapes(db.connection) + let raw_params: List> = [Some("safe")] + let raw_columns: List = ["literal_value", "bound_value"] + let raw = must_result( + mode_db.run( + raw_query( + "SELECT 'backslash\\' AS literal_value, ? AS bound_value", + raw_params, + raw_columns, + true, + ), + ), + ) + assert_eq_str(raw.rows[0].get(0), "backslash\\") + assert_eq_str(raw.rows[0].get(1), "safe") verify_live_migrations(db) let _ = must_result(db.connection.execute("DROP TABLE nest_users")) db.close() diff --git a/compose.yaml b/compose.yaml index 206d9df..30d1b33 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,6 @@ services: postgres: - image: postgres:17.5-alpine + image: ${NEST_POSTGRES_IMAGE:-postgres:17.5-alpine} environment: POSTGRES_USER: raven POSTGRES_PASSWORD: ravenpw diff --git a/lib.rv b/lib.rv index 6686dc2..67465bc 100644 --- a/lib.rv +++ b/lib.rv @@ -57,9 +57,9 @@ impl Value { Decimal(value) -> Some("${value}"), Boolean(value) -> { if value { - Some("true") + Some("1") } else { - Some("false") + Some("0") } }, } @@ -431,7 +431,18 @@ impl SelectQuery { if count < 0 { return Err(error_kind("nest", "offset cannot be negative")) } - sql = sql.concat(" OFFSET ${count}") + match self.limit_value { + Some(_) -> { + sql = sql.concat(" OFFSET ${count}") + }, + None -> { + sql = match dialect { + Postgres -> sql.concat(" OFFSET ${count}"), + MySql -> sql.concat(" LIMIT 18446744073709551615 OFFSET ${count}"), + Sqlite -> sql.concat(" LIMIT -1 OFFSET ${count}"), + } + }, + } }, None -> {}, } @@ -634,12 +645,30 @@ fun no_values() -> List { return values } +fun is_null_value(value: Value) -> Bool { + return match value { + Null -> true, + Text(_) -> false, + Integer(_) -> false, + Decimal(_) -> false, + Boolean(_) -> false, + } +} + fun value_condition( connector: String, column: String, operator: String, value: Value, ) -> Condition { + if is_null_value(value) { + if operator == "=" { + return null_condition(connector, column, "IS NULL") + } + if operator == "<>" { + return null_condition(connector, column, "IS NOT NULL") + } + } return Condition { connector, column, operator, value: Some(value), values: no_values() } } @@ -671,14 +700,19 @@ fun compile_conditions( } else { sql = sql.concat(" ${condition.connector} ") } - sql = sql.concat(quote_identifier(condition.column, dialect)?) - sql = sql.concat(" ").concat(condition.operator) - if condition.operator == "IN" || condition.operator == "NOT IN" { - // An empty list can never match, so IN (NULL) yields no rows rather - // than the invalid IN (). - if condition.values.len() == 0 { - sql = sql.concat(" (NULL)") + let list_condition = condition.operator == "IN" || condition.operator == "NOT IN" + if list_condition && condition.values.len() == 0 { + // Empty membership predicates are constants. This also preserves + // the useful identity that excluding an empty list keeps all rows. + if condition.operator == "IN" { + sql = sql.concat("1 = 0") } else { + sql = sql.concat("1 = 1") + } + } else { + sql = sql.concat(quote_identifier(condition.column, dialect)?) + sql = sql.concat(" ").concat(condition.operator) + if list_condition { sql = sql.concat(" (") let j = 0 while j < condition.values.len() { @@ -690,14 +724,21 @@ fun compile_conditions( j = j + 1 } sql = sql.concat(")") - } - } else { - match condition.value { - Some(value) -> { - sql = sql.concat(" ").concat(dialect.placeholder(start_index + params.len())) - params.push(value.parameter()) - }, - None -> {}, + } else { + match condition.value { + Some(value) -> { + match value.parameter() { + Some(parameter) -> { + sql = sql.concat(" ").concat(dialect.placeholder(start_index + params.len())) + params.push(Some(parameter)) + }, + None -> { + return Err(error_kind("nest", "NULL can only be compared with equality predicates")) + }, + } + }, + None -> {}, + } } } i = i + 1 diff --git a/lib_test.rv b/lib_test.rv index 6daa22b..a84bf2a 100644 --- a/lib_test.rv +++ b/lib_test.rv @@ -42,7 +42,7 @@ fun test_select_compiles_postgres_query() { "SELECT \"users\".\"id\", \"users\".\"name\" AS \"display_name\" FROM \"users\" LEFT JOIN \"profiles\" ON \"users\".\"id\" = \"profiles\".\"user_id\" WHERE \"users\".\"active\" = $1 AND \"users\".\"age\" > $2 ORDER BY \"users\".\"id\" DESC LIMIT 10 OFFSET 5", ) assert_eq_int(compiled.params.len(), 2) - assert_eq_str(must_some(compiled.params[0]), "true") + assert_eq_str(must_some(compiled.params[0]), "1") assert_eq_str(must_some(compiled.params[1]), "17") assert_eq_str(compiled.columns[0], "id") assert_eq_str(compiled.columns[1], "display_name") @@ -77,6 +77,24 @@ fun test_select_star_and_null_predicates() { assert_eq_int(compiled.params.len(), 0) } +fun test_null_values_compile_to_null_predicates() { + let compiled = must( + select("events").where_eq("deleted_at", null()).where_not_eq("published_at", null()).compile( + Dialect.Postgres, + ), + ) + assert_eq_str( + compiled.sql, + "SELECT * FROM \"events\" WHERE \"deleted_at\" IS NULL AND \"published_at\" IS NOT NULL", + ) + assert_eq_int(compiled.params.len(), 0) + + match select("events").where_gt("created_at", null()).compile(Dialect.Postgres) { + Ok(_) -> assert_msg(false, "ordered NULL comparison unexpectedly compiled"), + Err(e) -> assert_msg(e.message().contains("NULL"), e.message()), + } +} + fun test_where_in_compiles_with_placeholders() { let status: List = [text("active"), text("pending")] let roles: List = [text("admin")] @@ -92,11 +110,15 @@ fun test_where_in_compiles_with_placeholders() { assert_eq_int(compiled.params.len(), 3) assert_eq_str(must_some(compiled.params[0]), "active") - // An empty IN list can never match and emits no parameters. + // Empty membership lists compile to their logical constants and emit no + // parameters: membership matches nothing, exclusion keeps every row. let none: List = [] let empty = must(select("t").where_in("x", none).compile(Dialect.Sqlite)) - assert_eq_str(empty.sql, "SELECT * FROM \"t\" WHERE \"x\" IN (NULL)") + assert_eq_str(empty.sql, "SELECT * FROM \"t\" WHERE 1 = 0") assert_eq_int(empty.params.len(), 0) + let empty_exclusion = must(select("t").where_not_in("x", none).compile(Dialect.Sqlite)) + assert_eq_str(empty_exclusion.sql, "SELECT * FROM \"t\" WHERE 1 = 1") + assert_eq_int(empty_exclusion.params.len(), 0) } fun test_insert_returning_is_postgres_and_sqlite_only() { @@ -149,7 +171,7 @@ fun test_update_numbers_conditions_after_assignments() { "UPDATE \"users\" SET \"name\" = $1, \"active\" = $2 WHERE \"id\" = $3", ) assert_eq_str(must_some(compiled.params[0]), "Grace") - assert_eq_str(must_some(compiled.params[1]), "false") + assert_eq_str(must_some(compiled.params[1]), "0") assert_eq_str(must_some(compiled.params[2]), "7") } @@ -191,6 +213,21 @@ fun test_invalid_builder_states_return_errors() { } } +fun test_offset_without_limit_compiles_for_each_dialect() { + assert_eq_str( + must(select("events").offset(5).compile(Dialect.Postgres)).sql, + "SELECT * FROM \"events\" OFFSET 5", + ) + assert_eq_str( + must(select("events").offset(5).compile(Dialect.MySql)).sql, + "SELECT * FROM `events` LIMIT 18446744073709551615 OFFSET 5", + ) + assert_eq_str( + must(select("events").offset(5).compile(Dialect.Sqlite)).sql, + "SELECT * FROM \"events\" LIMIT -1 OFFSET 5", + ) +} + fun test_row_supports_indexed_and_named_access() { let row = Row { columns: ["id", "nickname"], diff --git a/migrations.rv b/migrations.rv index 5eca1d8..1469a64 100644 --- a/migrations.rv +++ b/migrations.rv @@ -118,6 +118,35 @@ fun contains_version(applied: List, version: Int) -> Bool { return false } +fun validate_applied_history( + applied: List, + migrations: List, +) -> Result { + for record in applied { + match find_migration(migrations, record.version) { + Some(item) -> { + if item.name != record.name { + return Err( + error_kind( + "nest", + "migration ${record.version} is named '${record.name}' in the database but '${item.name}' in code", + ), + ) + } + }, + None -> { + return Err( + error_kind( + "nest", + "applied migration ${record.version} is missing from the migration list", + ), + ) + }, + } + } + return Ok(true) +} + fun transactional_ddl(dialect: Dialect) -> Bool { return match dialect { Postgres -> true, @@ -174,6 +203,7 @@ fun migrate( let _ = validate_migrations(migrations)? let _ = ensure_migration_table(executor)? let applied_before = applied_migrations(executor)? + let _ = validate_applied_history(applied_before, migrations)? let applied: List = [] let skipped = 0 for item in migrations { diff --git a/migrations_test.rv b/migrations_test.rv index cce7756..8e7a943 100644 --- a/migrations_test.rv +++ b/migrations_test.rv @@ -121,3 +121,26 @@ fun test_migration_validation_rejects_unsafe_sequences() { Err(e) -> assert_true(e.message().contains("no up statements")), } } + +fun test_migrate_rejects_changed_or_missing_applied_history() { + let db = open_memory_db() + let original = [ + migration(1, "create audit table").up( + "CREATE TABLE migration_audit (id INTEGER PRIMARY KEY)", + ).down("DROP TABLE migration_audit"), + ] + let _ = must_migration(migrate(db, original)) + + let renamed = [migration(1, "renamed after release").up("SELECT 1").down("SELECT 1")] + match migrate(db, renamed) { + Ok(_) -> assert_msg(false, "renamed applied migration unexpectedly passed"), + Err(e) -> assert_msg(e.message().contains("named"), e.message()), + } + + let missing: List = [] + match migrate(db, missing) { + Ok(_) -> assert_msg(false, "missing applied migration unexpectedly passed"), + Err(e) -> assert_msg(e.message().contains("missing"), e.message()), + } + db.close() +} diff --git a/mysql.rv b/mysql.rv index facfd48..c5285f7 100644 --- a/mysql.rv +++ b/mysql.rv @@ -5,10 +5,17 @@ import "./lib" { CompiledQuery, Dialect, Executor, QueryResult as NestQueryResul struct MySql { connection: MySqlConnection, + no_backslash_escapes: Bool, } fun mysql(connection: MySqlConnection) -> MySql { - return MySql { connection } + return MySql { connection, no_backslash_escapes: false } +} + +// Use this adapter when the connection's session SQL mode contains +// NO_BACKSLASH_ESCAPES so raw SQL strings are scanned with matching rules. +fun mysql_no_backslash_escapes(connection: MySqlConnection) -> MySql { + return MySql { connection, no_backslash_escapes: true } } impl MySql { @@ -21,7 +28,11 @@ impl Executor for MySql { fun dialect(self) -> Dialect = Dialect.MySql fun run(self, query: CompiledQuery) -> Result { - let result = self.connection.query_optional_params(query.sql, query.params)? + let result = if self.no_backslash_escapes { + self.connection.query_optional_params_no_backslash_escapes(query.sql, query.params)? + } else { + self.connection.query_optional_params(query.sql, query.params)? + } return Ok(mysql_result(result)) } } diff --git a/rv.lock b/rv.lock index 7613937..90525c8 100644 --- a/rv.lock +++ b/rv.lock @@ -2,15 +2,15 @@ version = 1 [[package]] source = "github.com/martian56/raven-mysql" -version = "v0.1.0" -hash = "sha256:b36008fcb6c3b25646ef97b086e04291e91769b8ac4c76197c078bc3af8aad2b" +version = "v0.2.0" +hash = "sha256:7a4db2da6df96b7e3f07b0374d74ec344182599cc4e2690d0fa2979aafe194fe" [[package]] source = "github.com/martian56/raven-postgres" -version = "v0.1.0" -hash = "sha256:e36b28e03b5311a07d72adfd2fe2987e4df66dbfc76b55f8cbef29591e4432c0" +version = "v0.2.0" +hash = "sha256:3bda3c2f7c83de5847b96d2e4bc3c281d6103ba58c18d8d2ae280e0093537182" [[package]] source = "github.com/martian56/raven-sqlite" -version = "v0.1.0" -hash = "sha256:49737a54e18fdc8a0fb6a9f196a4540644176d5d96b6257e392f162b8fa6ccdf" +version = "v0.2.0" +hash = "sha256:ae4ecab484265faf4274614eaab72af9476930c04486111e006d8724766de558" diff --git a/rv.toml b/rv.toml index cd6da59..15ab82f 100644 --- a/rv.toml +++ b/rv.toml @@ -1,9 +1,9 @@ [package] name = "nest-orm" -version = "0.1.0" +version = "0.2.0" edition = "v2" [dependencies] -"github.com/martian56/raven-mysql" = "v0.1.0" -"github.com/martian56/raven-postgres" = "v0.1.0" -"github.com/martian56/raven-sqlite" = "v0.1.0" +"github.com/martian56/raven-mysql" = "v0.2.0" +"github.com/martian56/raven-postgres" = "v0.2.0" +"github.com/martian56/raven-sqlite" = "v0.2.0" diff --git a/sqlite.rv b/sqlite.rv index 03632bc..cf576ac 100644 --- a/sqlite.rv +++ b/sqlite.rv @@ -14,8 +14,15 @@ fun sqlite(connection: SqliteDb) -> Sqlite { } impl Sqlite { + fun try_close(self) -> Result { + return match self.connection.try_close() { + Ok(closed) -> Ok(closed), + Err(message) -> Err(error_kind("sqlite", message)), + } + } + fun close(self) { - self.connection.close() + let _ = self.try_close() } } @@ -24,12 +31,34 @@ impl Executor for Sqlite { fun run(self, query: CompiledQuery) -> Result { let statement = prepare_sqlite(self.connection, query.sql)? - bind_sqlite(statement, query.params) + match bind_sqlite(statement, query.params) { + Ok(_) -> {}, + Err(e) -> { + finalize_sqlite_quietly(statement) + return Err(e) + }, + } if query.returns_rows { - return Ok(read_sqlite_rows(self.connection, statement, query.columns)) + return read_sqlite_rows(self.connection, statement, query.columns) + } + match statement.next() { + Ok(has_row) -> { + if has_row { + finalize_sqlite_quietly(statement) + return Err(error_kind("sqlite", "query returned rows but was not marked as row-returning")) + } + }, + Err(message) -> { + finalize_sqlite_quietly(statement) + return Err(error_kind("sqlite", message)) + }, + } + match statement.try_finalize() { + Ok(_) -> {}, + Err(message) -> { + return Err(error_kind("sqlite", message)) + }, } - let _ = statement.step() - statement.finalize() let columns: List = [] let rows: List = [] return Ok( @@ -50,29 +79,52 @@ fun prepare_sqlite(connection: SqliteDb, sql: String) -> Result>) { +fun bind_sqlite(statement: SqliteStmt, params: List>) -> Result { + if params.len() != statement.parameter_count() { + return Err( + error_kind( + "sqlite", + "expected ${statement.parameter_count()} parameters, got ${params.len()}", + ), + ) + } let i = 0 while i < params.len() { match params[i] { Some(value) -> { - let _ = statement.bind_text(i + 1, value) + match statement.try_bind_text(i + 1, value) { + Ok(_) -> {}, + Err(message) -> { + return Err(error_kind("sqlite", message)) + }, + } }, None -> { - let _ = statement.bind_null(i + 1) + match statement.try_bind_null(i + 1) { + Ok(_) -> {}, + Err(message) -> { + return Err(error_kind("sqlite", message)) + }, + } }, } i = i + 1 } + return Ok(true) +} + +fun finalize_sqlite_quietly(statement: SqliteStmt) { + let _ = statement.try_finalize() } fun sqlite_columns(statement: SqliteStmt, expected: List) -> List { - if expected.len() == statement.column_count() { + if expected.len() > 0 && expected.len() == statement.column_count() { return expected } let columns: List = [] let i = 0 while i < statement.column_count() { - columns.push("column_${i}") + columns.push(statement.column_name(i)) i = i + 1 } return columns @@ -82,30 +134,66 @@ fun read_sqlite_rows( connection: SqliteDb, statement: SqliteStmt, expected_columns: List, -) -> NestQueryResult { +) -> Result { let columns = sqlite_columns(statement, expected_columns) let rows: List = [] - while statement.step() { + let reading = true + while reading { + match statement.next() { + Ok(has_row) -> { + reading = has_row + }, + Err(message) -> { + finalize_sqlite_quietly(statement) + return Err(error_kind("sqlite", message)) + }, + } + if !reading { + break + } let values: List = [] let nulls: List = [] let i = 0 while i < statement.column_count() { - if statement.is_null(i) { + let is_null = match statement.try_is_null(i) { + Ok(value) -> value, + Err(message) -> { + finalize_sqlite_quietly(statement) + return Err(error_kind("sqlite", message)) + false + }, + } + if is_null { values.push("") nulls.push(true) } else { - values.push(statement.text(i)) + let value = match statement.try_text(i) { + Ok(text) -> text, + Err(message) -> { + finalize_sqlite_quietly(statement) + return Err(error_kind("sqlite", message)) + "" + }, + } + values.push(value) nulls.push(false) } i = i + 1 } rows.push(NestRow { columns, values, nulls }) } - statement.finalize() - return NestQueryResult { - columns, - rows, - affected: connection.changes(), - last_insert_id: connection.last_insert_id(), + match statement.try_finalize() { + Ok(_) -> {}, + Err(message) -> { + return Err(error_kind("sqlite", message)) + }, } + return Ok( + NestQueryResult { + columns, + rows, + affected: connection.changes(), + last_insert_id: connection.last_insert_id(), + }, + ) }