Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Divergences that survive translation because the difference lives in the engines
- **Transactions.** A failing statement aborts the whole transaction in PostgreSQL and leaves it open in SQLite, so applying a translated batch correctly means rolling back yourself once anything fails inside an open transaction.
- **Compound `SELECT`.** PostgreSQL resolves one type per output column and SQLite decides per value, so `SELECT 1 UNION SELECT '1'` is one row on the server and two in the replica. Cast the branches when the type matters.
- **Generated keys.** `SERIAL` and `IDENTITY` become `INTEGER PRIMARY KEY AUTOINCREMENT`. A supplied insert value moves the counter where a sequence ignores one, a rolled-back insert consumes no key, both reported as warnings, and inserts or updates that assign a `GENERATED ALWAYS` column are refused outright.
- **`NULLS NOT DISTINCT`.** A unique key declared with it keeps its plain unique index and gains a companion unique index, named after it with `_nnd` appended, over `(key IS NULL, ifnull(key, 0))` for each key that can be NULL. A repeated NULL key is therefore an ordinary unique violation, and `ON CONFLICT DO NOTHING` skips it. `ON CONFLICT (key) DO UPDATE` aborts on a repeated NULL key where PostgreSQL updates the existing row, because SQLite matches that target to the plain index.
- **RLS emulation.** An unqualified `UPDATE` or `DELETE` and `RETURNING` through the view can diverge from the server only when a row is readable under a wider predicate than it is writable.

## License
Expand Down
1 change: 1 addition & 0 deletions src/impls/emitted_namespace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ fn generated_artifact_label(statement: &Statement) -> &'static str {
Statement::CreateView(_) => "a generated RLS view",
Statement::CreateTrigger(_) => "a generated trigger",
Statement::CreateTable(_) => "a generated backing table",
Statement::CreateIndex(_) => "a generated NULLS NOT DISTINCT index",
_ => "a generated statement",
}
}
19 changes: 0 additions & 19 deletions src/impls/shared_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,25 +167,6 @@ pub(crate) fn with_ordinality_not_supported_error() -> Error {
)
}

/// Returns the standardised error for `NULLS NOT DISTINCT`.
///
/// PostgreSQL makes two NULL rows collide under it, and SQLite's unique
/// indexes always treat NULLs as distinct, with no clause to change that.
/// Verified on both: PostgreSQL 16 answers `duplicate key value violates
/// unique constraint` for a second NULL, SQLite accepts it. So the clause
/// cannot be dropped, which would let through rows PostgreSQL refuses, and it
/// cannot be emitted either, which is `near "NULLS": syntax error`.
///
/// `NULLS DISTINCT`, PostgreSQL's default, IS what SQLite does, so that
/// spelling is dropped rather than refused.
#[must_use]
pub(crate) fn nulls_not_distinct_not_supported_error() -> Error {
Error::forward_refusal("NULLS NOT DISTINCT is not supported in SQLite, whose unique indexes always treat NULLs as \
distinct, so the constraint would accept rows PostgreSQL rejects. Add a CHECK that the \
column is NOT NULL, or enforce the rule with a trigger."
.to_string())
}

/// Returns the standardised error for `MATCH PARTIAL` on a foreign key.
///
/// PostgreSQL 17 refuses the clause itself, with `MATCH PARTIAL not yet
Expand Down
1 change: 1 addition & 0 deletions src/impls/translator_impls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ mod helpers;
pub(crate) use helpers::Forward;
mod index_column;
pub(crate) mod insert;
mod nulls_not_distinct;
mod order_by_expr;
pub mod plpgsql;
pub mod postgis;
Expand Down
87 changes: 52 additions & 35 deletions src/impls/translator_impls/create_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,18 @@ use crate::{
ast_builder,
function_helpers::{simple_function_expr, string_literal},
object_name::{
append_suffix, fts_table_name, last_ident_value_or_display,
append_suffix, fts_table_name, last_ident, last_ident_value_or_display,
normalize_schema_qualified_object_name_for_sqlite, postgres_catalog_function_name,
quoted_ident, resolve_translation_table, sqlite_unqualified_object_name,
sync_trigger_name, translation_table_has_rls,
},
query_builder::{
from_relation, make_query, make_simple_select, plain_table_factor, single_expr_query,
},
shared_helpers::{
ColumnReferences, extract_columns_from_expr, nulls_not_distinct_not_supported_error,
shared_helpers::{ColumnReferences, extract_columns_from_expr},
translator_impls::{
nulls_not_distinct::companion_index, postgis, rls::resolve_trigger_table_name,
},
translator_impls::{postgis, rls::resolve_trigger_table_name},
},
};

Expand Down Expand Up @@ -562,16 +562,14 @@ impl crate::traits::translator::TranslatorWithContext for CreateIndex {
) -> Result<Self::SQLiteEntry, crate::errors::Error> {
// An index expression and a partial index predicate name the indexed
// table's own columns, with no query around them.
let index_scope =
crate::impls::object_name::resolve_translation_table(schema, &self.table_name)?.map(
|table| {
sql_traits::structs::ColumnScope::for_table(
table,
schema,
sql_traits::structs::IdentifierCase::AsWritten,
)
},
);
let indexed_source = resolve_translation_table(schema, &self.table_name)?;
let index_scope = indexed_source.map(|table| {
sql_traits::structs::ColumnScope::for_table(
table,
schema,
sql_traits::structs::IdentifierCase::AsWritten,
)
});
let scoped = index_scope.as_ref().map(|scope| options.with_scope(scope));
let options = scoped.as_ref().unwrap_or(options);
let sqlite_table_name =
Expand Down Expand Up @@ -612,49 +610,68 @@ impl crate::traits::translator::TranslatorWithContext for CreateIndex {
};
}

if self.nulls_distinct == Some(false) {
return Err(nulls_not_distinct_not_supported_error());
}

report_dropped_index_clauses(self, emit);
let indexed_table = if translation_table_has_rls(schema, &self.table_name)? {
let backing_table = if translation_table_has_rls(schema, &self.table_name)? {
append_suffix(&sqlite_table_name, options.get_rls_table_suffix())
} else {
sqlite_table_name
};
let indexed_table = sqlite_unqualified_object_name(&backing_table);
let definition_options = options.without_subqueries();
let options = &definition_options;
let columns = self
.columns
.iter()
.map(|col| col.translate_with_warnings(schema, options, emit))
.collect::<Result<Vec<_>, _>>()?;
let predicate = self
.predicate
.as_ref()
.map(|predicate| predicate.translate_with_warnings(schema, options, emit))
.transpose()?;

let companion = if self.unique && self.nulls_distinct == Some(false) {
let Some(name) = self.name.as_ref().and_then(last_ident) else {
return Err(Error::forward_refusal(format!(
"{self} cannot be translated without a name. SQLite enforces NULLS NOT \
DISTINCT through a companion index named after this one, which DROP INDEX \
removes along with it. Name the index."
)));
};
companion_index(
&name.value,
&indexed_table,
&columns,
indexed_source,
schema,
predicate.clone(),
self.if_not_exists,
)?
} else {
None
};

// Regular index - translate normally, explicitly dropping PG-only
// fields (using, concurrently, include, nulls_distinct, with,
// index_options, alter_options) that are not valid in SQLite.
// `nulls_distinct` is only safe to drop for the DISTINCT
// spelling, refused above otherwise, because it decides which
// rows collide.
Ok(vec![Statement::CreateIndex(CreateIndex {
let mut statements = vec![Statement::CreateIndex(CreateIndex {
name: self.name.clone(),
table_name: sqlite_unqualified_object_name(&indexed_table),
table_name: indexed_table,
using: None,
columns: self
.columns
.iter()
.map(|col| col.translate_with_warnings(schema, options, emit))
.collect::<Result<_, _>>()?,
columns,
unique: self.unique,
concurrently: false,
r#async: false,
if_not_exists: self.if_not_exists,
include: vec![],
nulls_distinct: None,
with: vec![],
predicate: self
.predicate
.as_ref()
.map(|predicate| predicate.translate_with_warnings(schema, options, emit))
.transpose()?,
predicate,
index_options: vec![],
alter_options: vec![],
})])
})];
statements.extend(companion);
Ok(statements)
}
}

Expand Down
6 changes: 5 additions & 1 deletion src/impls/translator_impls/insert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,11 @@ fn declared_constraint_name(constraint: &sqlparser::ast::UniqueConstraint) -> Op
/// PostgreSQL 16 by reading `pg_constraint`: `<table>_pkey` for a primary key,
/// and `<table>_<column>_key` for a unique constraint with every column joined
/// by an underscore.
fn postgres_constraint_name(table: &str, columns: &[String], is_primary_key: bool) -> String {
pub(crate) fn postgres_constraint_name(
table: &str,
columns: &[String],
is_primary_key: bool,
) -> String {
if is_primary_key {
return format!("{table}_pkey");
}
Expand Down
Loading