diff --git a/store/postgres/src/lib.rs b/store/postgres/src/lib.rs index 32b6c495b2e..10cdf2ff627 100644 --- a/store/postgres/src/lib.rs +++ b/store/postgres/src/lib.rs @@ -50,6 +50,7 @@ pub mod layout_for_tests { Connection, EVENT_TAP, EVENT_TAP_ENABLED, Mirror, Namespace, make_dummy_site, }; pub use crate::relational::*; + pub use crate::relational_queries::CopyEntityBatchQuery; pub mod writable { pub use crate::writable::test_support::allow_steps; } diff --git a/store/postgres/src/relational.rs b/store/postgres/src/relational.rs index dcec0578a67..4059e736635 100644 --- a/store/postgres/src/relational.rs +++ b/store/postgres/src/relational.rs @@ -1752,7 +1752,12 @@ impl Table { } /// Find the column `name` in this table. The name must be in snake case, - /// i.e., use SQL conventions + /// i.e., use SQL conventions. + /// + /// Fulltext search columns (`ColumnType::TSVector`) are deliberately + /// skipped since they are not queryable entity attributes. Use + /// [`Table::column_including_fulltext`] when the fulltext columns are + /// needed, e.g. when copying a table. pub fn column(&self, name: &SqlName) -> Option<&Column> { self.columns .iter() @@ -1760,6 +1765,13 @@ impl Table { .find(|column| &column.name == name) } + /// Find the column `name` in this table, including fulltext search + /// columns which [`Table::column`] hides. The name must be in snake + /// case, i.e., use SQL conventions. + pub fn column_including_fulltext(&self, name: &SqlName) -> Option<&Column> { + self.columns.iter().find(|column| &column.name == name) + } + /// Find the column for `field` in this table. The name must be the /// GraphQL name of an entity field pub fn column_for_field(&self, field: &str) -> Result<&Column, StoreError> { diff --git a/store/postgres/src/relational_queries.rs b/store/postgres/src/relational_queries.rs index a538bdaddf8..d87b950adc1 100644 --- a/store/postgres/src/relational_queries.rs +++ b/store/postgres/src/relational_queries.rs @@ -5104,7 +5104,13 @@ impl<'a> CopyEntityBatchQuery<'a> { ) -> Result { let mut columns = Vec::new(); for dcol in &dst.columns { - if let Some(scol) = src.column(&dcol.name) { + // Match against all source columns, INCLUDING fulltext (tsvector) + // columns, which `Table::column` deliberately hides. The stored + // tsvector is copied verbatim; without this, fulltext columns + // would be silently dropped from the copy (they are nullable, so + // the error branch below is not taken) and the destination's + // search index would be left empty. + if let Some(scol) = src.column_including_fulltext(&dcol.name) { if let Some(msg) = dcol.is_assignable_from(scol, &src.object) { return Err(anyhow!("{}", msg).into()); } else { diff --git a/store/test-store/tests/postgres/relational.rs b/store/test-store/tests/postgres/relational.rs index c7399568e28..c4946b408c5 100644 --- a/store/test-store/tests/postgres/relational.rs +++ b/store/test-store/tests/postgres/relational.rs @@ -632,6 +632,83 @@ async fn insert_null_fulltext_fields() { .await; } +/// Copying a table must also populate its fulltext (tsvector) search +/// columns. These columns are not returned by `Table::column`, so a naive +/// copy would silently drop them and leave the destination's search index +/// empty. This copies the `User` table into a fresh schema and checks that a +/// fulltext query against the copy still returns the expected entity. +#[graph::test] +async fn copy_populates_fulltext() { + use diesel_async::RunQueryDsl; + use graph_store_postgres::layout_for_tests::CopyEntityBatchQuery; + + run_test(async |conn, layout| { + // Populate the source `User` table with searchable text + insert_users(conn, layout).await; + + // Create a destination schema with the same layout in a separate + // namespace to copy into. Drop it first in case a previous run left + // it behind, since `remove_schema` only cleans up the source + // namespace. + let dst_namespace = Namespace::new("sgd0816".to_string()).unwrap(); + conn.batch_execute(&format!( + "drop schema if exists {ns} cascade; create schema {ns}", + ns = dst_namespace.as_str() + )) + .await + .unwrap(); + let dst_site = make_dummy_site( + THINGS_SUBGRAPH_ID.clone(), + dst_namespace, + NETWORK_NAME.to_string(), + ); + let dst_layout = Layout::create_relational_schema( + conn, + Arc::new(dst_site), + &THINGS_SCHEMA, + BTreeSet::new(), + ) + .await + .expect("Failed to create destination relational schema"); + + // Copy the `User` table from the source into the destination + let src_table = layout.table_for_entity(&USER_TYPE).unwrap(); + let dst_table = dst_layout.table_for_entity(&USER_TYPE).unwrap(); + CopyEntityBatchQuery::new( + dst_table.as_ref(), + src_table.as_ref(), + 0, + i64::MAX, + BLOCK_NUMBER_MAX, + ) + .unwrap() + .count_current() + .get_result::(conn) + .await + .expect("Failed to copy User table"); + + // A fulltext query against the copy must find the matching user, + // which is only possible if the tsvector column was copied. + let mut query = + user_query().filter(EntityFilter::Fulltext("userSearch".into(), "Shaq:*".into())); + query.block = BLOCK_NUMBER_MAX; + let entities = dst_layout + .query::(&LOGGER, conn, query) + .await + .expect("fulltext query on copy failed") + .0; + let ids: Vec<_> = entities + .iter() + .map(|entity| match entity.get("id") { + Some(Value::String(id)) => id.clone(), + _ => panic!("entity without string id"), + }) + .collect(); + assert_eq!(ids, vec!["3".to_string()]); + }) + .await; +} + #[graph::test] async fn update() { run_test(async |conn, layout| {