Skip to content
12 changes: 12 additions & 0 deletions libs/user-facing-errors/src/schema_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,18 @@ pub struct UnexpectedNamespaceInExternalTables;
)]
pub struct ShadowDbSameAsMainDb;

#[derive(Debug, UserFacingError, Serialize)]
#[user_facing(
code = "P3026",
message = "The shadow database at `{shadow_database_location}` is not empty. Prisma Migrate resets the shadow database before it replays your migration history into it, which would destroy the data it currently holds.\n\nPoint `datasource.shadowDatabaseUrl` in your `prisma.config.ts` at an empty database, or, if you are certain that the data in this one can be destroyed, run the command again with `--reset-shadow-database`."
)]
pub struct ShadowDbNotEmpty {
/// The shadow database, with credentials removed. Reads
/// `(driver adapter shadow database)` when the shadow database is reached through a driver
/// adapter and has no connection string of its own.
pub shadow_database_location: String,
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
#[derive(Debug, SimpleUserFacingError)]
#[user_facing(code = "P4001", message = "The introspected database was empty.")]
pub struct IntrospectionResultEmpty;
Expand Down
1 change: 1 addition & 0 deletions schema-engine/cli/tests/cli_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,7 @@ async fn test_missing_datasource_url_gives_proper_error() {
let datasource_urls = DatasourceUrls {
url: None,
shadow_database_url: None,
reset_shadow_database: false,
};

let output = Command::new(schema_engine_bin_path())
Expand Down
2 changes: 2 additions & 0 deletions schema-engine/commands/src/commands/dev_diagnostic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub async fn dev_diagnostic(
namespaces: Option<Namespaces>,
connector: &mut dyn SchemaConnector,
adapter_factory: Arc<dyn ExternalConnectorFactory>,
reset_shadow_database: bool,
migration_schema_cache: &mut MigrationSchemaCache,
) -> ConnectorResult<DevDiagnosticOutput> {
migrations_directory::error_on_changed_provider(&input.migrations_list.lockfile, connector.connector_type())?;
Expand All @@ -32,6 +33,7 @@ pub async fn dev_diagnostic(
namespaces,
connector,
adapter_factory,
reset_shadow_database,
migration_schema_cache,
)
.await?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ pub async fn diagnose_migration_history(
namespaces: Option<Namespaces>,
connector: &mut dyn SchemaConnector,
adapter_factory: Arc<dyn ExternalConnectorFactory>,
reset_shadow_database: bool,
migration_schema_cache: &mut MigrationSchemaCache,
) -> CoreResult<DiagnoseMigrationHistoryOutput> {
tracing::debug!("Diagnosing migration history");
Expand Down Expand Up @@ -149,6 +150,7 @@ pub async fn diagnose_migration_history(
let target = ExternalShadowDatabase::DriverAdapter {
factory: adapter_factory,
preview_features: connector.preview_features(),
reset_allowed: reset_shadow_database,
};
let from = migration_schema_cache
.get_or_insert(&applied_migrations.migration_directories, || async {
Expand Down
23 changes: 11 additions & 12 deletions schema-engine/commands/src/commands/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub async fn diff(
params: DiffParams,
connector: &mut dyn SchemaConnector,
adapter_factory: Arc<dyn ExternalConnectorFactory>,
reset_shadow_database: bool,
extension_types: &dyn ExtensionTypes,
) -> CoreResult<DiffResult> {
// In order to properly handle MultiSchema, we need to make sure the preview feature is
Expand All @@ -30,10 +31,16 @@ pub async fn diff(
let filter: SchemaFilter = params.filters.into();
filter.validate(&*connector.schema_dialect())?;

let shadow_database = ExternalShadowDatabase::DriverAdapter {
factory: adapter_factory,
preview_features,
reset_allowed: reset_shadow_database,
};

let (conn_from, schema_from) = diff_target_to_dialect(
&params.from,
connector,
adapter_factory.clone(),
shadow_database.clone(),
namespaces.clone(),
&filter,
preview_features,
Expand All @@ -45,7 +52,7 @@ pub async fn diff(
let (conn_to, schema_to) = diff_target_to_dialect(
&params.to,
connector,
adapter_factory,
shadow_database,
namespaces,
&filter,
preview_features,
Expand Down Expand Up @@ -118,7 +125,7 @@ fn namespaces_and_preview_features_from_diff_targets(
async fn diff_target_to_dialect(
target: &DiffTarget,
connector: &mut dyn SchemaConnector,
adapter_factory: Arc<dyn ExternalConnectorFactory>,
shadow_database: ExternalShadowDatabase,
namespaces: Option<Namespaces>,
filter: &SchemaFilter,
preview_features: BitFlags<psl::PreviewFeature>,
Expand Down Expand Up @@ -160,15 +167,7 @@ async fn diff_target_to_dialect(

// TODO: enable Driver Adapter for shadow database, using the AdapterFactory.
let schema = dialect
.schema_from_migrations_with_target(
&migrations,
namespaces,
filter,
ExternalShadowDatabase::DriverAdapter {
factory: adapter_factory,
preview_features,
},
)
.schema_from_migrations_with_target(&migrations, namespaces, filter, shadow_database)
.await?;
Ok(Some((dialect, schema)))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ where
connection_string: connection_string.clone(),
preview_features,
shadow_database_connection_string: None,
reset_shadow_database: false,
};

let connector = MongoDbSchemaConnector::new(params);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ fn new_connector(preview_features: BitFlags<PreviewFeature>) -> (String, MongoDb
connection_string: url.to_string(),
preview_features,
shadow_database_connection_string: None,
reset_shadow_database: false,
};
(db_name, MongoDbSchemaConnector::new(params))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ pub struct ConnectorParams {
pub preview_features: BitFlags<PreviewFeature>,
/// The shadow database connection string.
pub shadow_database_connection_string: Option<String>,
/// Whether the user consented to the shadow database being reset even when it is not empty.
pub reset_shadow_database: bool,
}

impl ConnectorParams {
/// Creates new [`ConnectorParams`].
/// Creates new [`ConnectorParams`], without consent to reset a non-empty shadow database.
pub fn new(
connection_string: String,
preview_features: BitFlags<PreviewFeature>,
Expand All @@ -23,6 +25,7 @@ impl ConnectorParams {
connection_string,
preview_features,
shadow_database_connection_string,
reset_shadow_database: false,
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -231,12 +231,16 @@ pub enum ExternalShadowDatabase {
factory: Arc<dyn ExternalConnectorFactory>,
/// The preview features to use while building the shadow schema.
preview_features: PreviewFeatures,
/// Whether the user consented to the shadow database being reset even when it is not empty.
reset_allowed: bool,
},
/// A shadow database connection string and preview features.
ConnectionString {
/// The shadow database connection string.
connection_string: String,
/// The preview features.
preview_features: PreviewFeatures,
/// Whether the user consented to the shadow database being reset even when it is not empty.
reset_allowed: bool,
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
//! Replaying a migration history into a shadow database the user provided.
//!
//! Such a database is reset before the history is replayed into it, which destroys whatever it
//! holds, so it is only ever used when it holds nothing or when the user said that its contents
//! can go. It is also left empty afterwards, which is what makes the promise hold for the next
//! replay: a command that replays the history several times finds an empty database every time
//! after the first, and so does the command after it.

use crate::flavour::{SqlConnector, UsingExternalShadowDb};
use schema_connector::{ConnectorError, ConnectorResult, Namespaces, SchemaFilter, migrations_directory::Migrations};
use sql_schema_describer::SqlSchema;
use user_facing_errors::schema_engine::ShadowDbNotEmpty;

/// Replays `migrations` into the shadow database `shadow_db` is connected to, and leaves it empty.
///
/// `reset_allowed` carries the user's consent to destroy the contents of a shadow database that is
/// not empty; `location` names the database in the error raised when that consent is missing, and
/// must be safe to show (see [`crate::sanitize_connection_string`]).
///
/// Disposing of `shadow_db` is the caller's business, on this function's success and failure alike.
pub(crate) async fn replay_migration_history(
shadow_db: &mut (dyn SqlConnector + Send + Sync),
migrations: &Migrations,
namespaces: Option<Namespaces>,
filter: &SchemaFilter,
reset_allowed: bool,
location: &str,
) -> ConnectorResult<SqlSchema> {
if reset_allowed {
// Consent is exercised here rather than left to the flavour: SQLite and the Wasm PostgreSQL
// connector replay a migration history into an external shadow database without resetting
// it first, and the contents the user agreed to part with would collide with the replay.
reset(shadow_db, namespaces.clone(), filter).await?;
} else {
ensure_shadow_db_is_empty(shadow_db, namespaces.clone(), location).await?;
}

let schema = shadow_db
.sql_schema_from_migration_history(migrations, namespaces.clone(), filter, UsingExternalShadowDb::Yes)
.await?;

reset(shadow_db, namespaces, filter).await?;

Ok(schema)
}

/// Empties the shadow database. A user may well have granted the engine the right to create and to
/// drop tables in it without making it the owner of anything, which is not enough to drop and
/// recreate the schema itself, so the objects are dropped one by one when that fails.
async fn reset(
shadow_db: &mut (dyn SqlConnector + Send + Sync),
namespaces: Option<Namespaces>,
filter: &SchemaFilter,
) -> ConnectorResult<()> {
if shadow_db.reset(namespaces.clone()).await.is_err() {
crate::best_effort_reset(shadow_db, namespaces, filter).await?;
}

Ok(())
}

/// A failed replay leaves the shadow database as it was when it failed: the next run finding it
/// dirty, and asking about it, is the signal that something went wrong here.
async fn ensure_shadow_db_is_empty(
shadow_db: &mut (dyn SqlConnector + Send + Sync),
namespaces: Option<Namespaces>,
location: &str,
) -> ConnectorResult<()> {
let schema = shadow_db.describe_schema(namespaces).await?;

if holds_no_objects(&schema) {
return Ok(());
}

Err(ConnectorError::user_facing(ShadowDbNotEmpty {
shadow_database_location: location.to_owned(),
}))
}

/// Whether the described schema holds nothing at all — no table (`_prisma_migrations` included, a
/// database that only holds one is the remains of an earlier replay), no view, no enum, no
/// user-defined type, no stored procedure.
fn holds_no_objects(schema: &SqlSchema) -> bool {
schema.tables_count() == 0
&& schema.views_count() == 0
&& schema.enum_walkers().len() == 0
&& schema.udt_walkers().next().is_none()
&& schema.procedures_count() == 0
}

#[cfg(test)]
mod tests {
use super::*;

fn schema_with_a_namespace() -> SqlSchema {
let mut schema = SqlSchema::default();
schema.push_namespace("public".to_owned());
schema
}

#[test]
fn a_schema_without_objects_holds_no_objects() {
assert!(holds_no_objects(&SqlSchema::default()));
assert!(holds_no_objects(&schema_with_a_namespace()));
}

#[test]
fn a_table_counts() {
let mut schema = schema_with_a_namespace();
schema.push_table("Cat".to_owned(), Default::default(), None);

assert!(!holds_no_objects(&schema));
}

#[test]
fn the_migrations_table_alone_counts() {
let mut schema = schema_with_a_namespace();
schema.push_table(crate::MIGRATIONS_TABLE_NAME.to_owned(), Default::default(), None);

assert!(!holds_no_objects(&schema));
}

#[test]
fn a_view_counts() {
let mut schema = schema_with_a_namespace();
schema.push_view("Cats".to_owned(), Default::default(), None, None);

assert!(!holds_no_objects(&schema));
}

#[test]
fn an_enum_counts() {
let mut schema = schema_with_a_namespace();
schema.push_enum(Default::default(), "Mood".to_owned(), None);

assert!(!holds_no_objects(&schema));
}

#[test]
fn a_user_defined_type_counts() {
let mut schema = schema_with_a_namespace();
schema.push_udt(Default::default(), "Whiskers".to_owned(), None);

assert!(!holds_no_objects(&schema));
}
}
Comment on lines +91 to +146

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Confirm no push_procedure-style constructor already exists in SqlSchema.
rg -n 'fn push_' schema-engine/sql-schema-describer/src/lib.rs
rg -n 'push_procedure|Procedure' schema-engine/sql-schema-describer/src/lib.rs -A3 -B3

Repository: prisma/prisma-engines

Length of output: 2921


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== SqlSchema relevant definitions =="
sed -n '150,270p' schema-engine/sql-schema-describer/src/lib.rs
sed
sed -n '380,460p' schema-engine/sql-schema-describer/src/lib.rs
sed -n '610,635p' schema-engine/sql-schema-describer/src/lib.rs

echo "== Procedure construction/export references =="
rg -n 'ProcedureId|Procedure\{|get_procedure|procedures_count|holds_no_objects|push_udt|push_enum' schema-engine -g '*.rs' -A2 -B2

echo "== test module =="
sed -n '80,155p' schema-engine/connectors/sql-schema-connector/src/external_shadow_db.rs

Repository: prisma/prisma-engines

Length of output: 6366


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Push methods and procedure count definitions =="
sed -n '377,456p' schema-engine/sql-schema-describer/img/lib.rs 2>/dev/null || true
sed -n '377,456p' schema-engine/sql-schema-describer/src/lib.rs
sed -n '613,632p' schema-engine/sql-schema-describer/src/lib.rs

echo "== ProcedureId and Procedure references =="
rg -n 'type ProcedureId|struct ProcedureId|ProcedureId|pub struct Procedure|struct Procedure|get_procedure|procedures_count|holds_no_objects|push_udt|push_enum|push_table' schema-engine/connectors schema-engine/sql-schema-describer -g '*.rs' -A2 -B2

echo "== Test module =="
sed -n '88,152p' schema-engine/connectors/sql-schema-connector/src/external_shadow_db.rs

Repository: prisma/prisma-engines

Length of output: 39317


Add construction coverage for procedure-only emptiness checks.

holds_no_objects includes procedures_count(), but the test suite cannot reach a schema containing only a stored procedure because Procedure has no public constructor like push_udt. Add a public push_procedure(...) to SqlSchema and a procedure-counts test in external_shadow_db.rs.

📍 Affects 2 files
  • schema-engine/connectors/sql-schema-connector/src/external_shadow_db.rs#L91-L146 (this comment)
  • schema-engine/sql-schema-describer/src/lib.rs#L450-L453
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@schema-engine/connectors/sql-schema-connector/src/external_shadow_db.rs`
around lines 91 - 146, The procedure-only emptiness check lacks construction
coverage. In schema-engine/sql-schema-describer/src/lib.rs lines 450-453, add a
public SqlSchema::push_procedure(...) constructor matching the existing schema
object insertion helpers. In
schema-engine/connectors/sql-schema-connector/src/external_shadow_db.rs lines
91-146, add a test that creates a namespace, inserts only a procedure via
push_procedure, and asserts holds_no_objects returns false.

3 changes: 3 additions & 0 deletions schema-engine/connectors/sql-schema-connector/src/flavour.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ pub(crate) trait SqlConnector: Send + Sync + Debug {

fn shadow_db_url(&self) -> Option<&str>;

/// Whether the user consented to the shadow database being reset even when it is not empty.
fn reset_shadow_database(&self) -> bool;

fn acquire_lock(&mut self) -> BoxFuture<'_, ConnectorResult<()>>;

fn apply_migration_script<'a>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,12 @@ impl SqlConnector for MssqlConnector {
.as_deref()
}

fn reset_shadow_database(&self) -> bool {
self.state
.params()
.is_some_and(|params| params.connector_params.reset_shadow_database)
}

fn acquire_lock(&mut self) -> BoxFuture<'_, ConnectorResult<()>> {
// see
// https://docs.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-getapplock-transact-sql?view=sql-server-ver15
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@ impl SqlConnector for MysqlConnector {
.as_deref()
}

fn reset_shadow_database(&self) -> bool {
self.state
.params()
.is_some_and(|params| params.connector_params.reset_shadow_database)
}

fn acquire_lock(&mut self) -> BoxFuture<'_, ConnectorResult<()>> {
with_connection(&mut self.state, |params, _, connection| async move {
// We do not acquire advisory locks on PlanetScale instances.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,10 @@ impl SqlConnector for PostgresConnector {
imp::get_shadow_db_url(&self.state)
}

fn reset_shadow_database(&self) -> bool {
imp::get_reset_shadow_database(&self.state)
}

fn acquire_lock(&mut self) -> BoxFuture<'_, ConnectorResult<()>> {
// They do not support advisory locking:
// https://github.com/cockroachdb/cockroach/issues/13546
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,12 @@ pub fn get_shadow_db_url(state: &State) -> Option<&str> {
.as_deref()
}

pub fn get_reset_shadow_database(state: &State) -> bool {
state
.params()
.is_some_and(|params| params.connector_params.reset_shadow_database)
}

pub async fn dispose(state: &mut State) -> ConnectorResult<()> {
if let State::Connected(_, (_, conn)) = std::mem::replace(state, State::Initial) {
conn.close().await;
Expand Down
Loading
Loading