diff --git a/Cargo.lock b/Cargo.lock index d63d715467c..f2981f0dd4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4308,6 +4308,7 @@ dependencies = [ "proc-macro2", "quote", "spacetimedb-primitives", + "spacetimedb-sql-parser", "syn 2.0.77", ] diff --git a/crates/bindings-macro/Cargo.toml b/crates/bindings-macro/Cargo.toml index 2860071576e..2c4ecdab953 100644 --- a/crates/bindings-macro/Cargo.toml +++ b/crates/bindings-macro/Cargo.toml @@ -13,6 +13,7 @@ bench = false [dependencies] spacetimedb-primitives.workspace = true +spacetimedb-sql-parser.workspace = true bitflags.workspace = true humantime.workspace = true diff --git a/crates/bindings-macro/src/lib.rs b/crates/bindings-macro/src/lib.rs index dcab7835cac..f6b1ee7fd43 100644 --- a/crates/bindings-macro/src/lib.rs +++ b/crates/bindings-macro/src/lib.rs @@ -18,6 +18,7 @@ use proc_macro2::{Span, TokenStream}; use quote::{format_ident, quote, quote_spanned, ToTokens}; use std::borrow::Cow; use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; use std::time::Duration; use syn::ext::IdentExt; use syn::meta::ParseNestedMeta; @@ -1241,3 +1242,74 @@ pub fn schema_type(input: proc_macro::TokenStream) -> proc_macro::TokenStream { .unwrap_or_else(syn::Error::into_compile_error) .into() } + +fn parse_sql(input: ParseStream) -> syn::Result { + use spacetimedb_sql_parser::parser::sub; + + let lookahead = input.lookahead1(); + let sql = if lookahead.peek(syn::LitStr) { + let s = input.parse::()?; + // Checks the query is syntactically valid + let _ = sub::parse_subscription(&s.value()).map_err(|e| syn::Error::new(s.span(), format_args!("{e}")))?; + + s.value() + } else { + return Err(lookahead.error()); + }; + + Ok(sql) +} + +/// Generates code for registering a row-level security `SQL` function. +/// +/// A row-level security function takes a `SQL` query expression that is used to filter rows. +/// +/// The query follows the same syntax as a subscription query. +/// +/// **Example:** +/// +/// ```rust,ignore +/// /// Players can only see what's in their chunk +/// spacetimedb::filter!(" +/// SELECT * FROM LocationState WHERE chunk_index IN ( +/// SELECT chunk_index FROM LocationState WHERE entity_id IN ( +/// SELECT entity_id FROM UserState WHERE identity = @sender +/// ) +/// ) +/// "); +/// ``` +/// +/// **NOTE:** The `SQL` query expression is pre-parsed at compile time, but only check is a valid +/// subscription query *syntactically*, not that the query is valid when executed. +/// +/// For example, it could refer to a non-existent table. +#[proc_macro] +pub fn filter(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let rls_sql = syn::parse_macro_input!(input with parse_sql); + + let mut hasher = DefaultHasher::new(); + rls_sql.hash(&mut hasher); + let rls_name = format_ident!("rls_{}", hasher.finish()); + + let register_rls_symbol = format!("__preinit__20_register_{rls_name}"); + + let generated_describe_function = quote! { + #[export_name = #register_rls_symbol] + extern "C" fn __register_rls() { + spacetimedb::rt::register_row_level_security::<#rls_name>() + } + }; + + let emission = quote! { + const _: () = { + #generated_describe_function + }; + #[allow(non_camel_case_types)] + struct #rls_name { _never: ::core::convert::Infallible } + impl spacetimedb::rt::RowLevelSecurityInfo for #rls_name { + const SQL: &'static str = #rls_sql; + } + }; + + emission.into() +} diff --git a/crates/bindings/src/lib.rs b/crates/bindings/src/lib.rs index f3b62eba6a6..8a14cd760db 100644 --- a/crates/bindings/src/lib.rs +++ b/crates/bindings/src/lib.rs @@ -26,7 +26,7 @@ pub use rng::StdbRng; pub use sats::SpacetimeType; #[doc(hidden)] pub use spacetimedb_bindings_macro::__TableHelper; -pub use spacetimedb_bindings_macro::{duration, reducer, table}; +pub use spacetimedb_bindings_macro::{duration, filter, reducer, table}; pub use spacetimedb_bindings_sys as sys; pub use spacetimedb_lib; pub use spacetimedb_lib::de::{Deserialize, DeserializeOwned}; diff --git a/crates/bindings/src/rt.rs b/crates/bindings/src/rt.rs index d55fe130b3c..8b9becc37c1 100644 --- a/crates/bindings/src/rt.rs +++ b/crates/bindings/src/rt.rs @@ -221,6 +221,12 @@ impl RepeaterArgs for (Timestamp,) { } } +/// A trait for types that can *describe* a row-level security policy. +pub trait RowLevelSecurityInfo { + /// The SQL expression for the row-level security policy. + const SQL: &'static str; +} + /// Registers into `DESCRIBERS` a function `f` to modify the module builder. fn register_describer(f: fn(&mut ModuleBuilder)) { DESCRIBERS.lock().unwrap().push(f) @@ -283,6 +289,13 @@ pub fn register_reducer<'a, A: Args<'a>, I: ReducerInfo>(_: impl Reducer<'a, A>) }) } +/// Registers a row-level security policy. +pub fn register_row_level_security() { + register_describer(|module| { + module.inner.add_row_level_security(R::SQL); + }) +} + /// A builder for a module. #[derive(Default)] struct ModuleBuilder { diff --git a/crates/bindings/tests/snapshots/deps__spacetimedb_bindings_dependencies.snap b/crates/bindings/tests/snapshots/deps__spacetimedb_bindings_dependencies.snap index b855db69318..308e0cd47e7 100644 --- a/crates/bindings/tests/snapshots/deps__spacetimedb_bindings_dependencies.snap +++ b/crates/bindings/tests/snapshots/deps__spacetimedb_bindings_dependencies.snap @@ -2,7 +2,7 @@ source: crates/bindings/tests/deps.rs expression: "cargo tree -p spacetimedb -f {lib} -e no-dev" --- -total crates: 62 +total crates: 64 spacetimedb ├── bytemuck ├── derive_more @@ -48,6 +48,15 @@ spacetimedb │ │ ├── itertools │ │ │ └── either │ │ └── nohash_hasher +│ ├── spacetimedb_sql_parser +│ │ ├── derive_more (*) +│ │ ├── sqlparser +│ │ │ └── log +│ │ └── thiserror +│ │ └── thiserror_impl +│ │ ├── proc_macro2 (*) +│ │ ├── quote (*) +│ │ └── syn (*) │ └── syn (*) ├── spacetimedb_bindings_sys │ └── spacetimedb_primitives (*) @@ -74,11 +83,7 @@ spacetimedb │ │ │ └── allocator_api2 │ │ ├── nohash_hasher │ │ ├── smallvec -│ │ └── thiserror -│ │ └── thiserror_impl -│ │ ├── proc_macro2 (*) -│ │ ├── quote (*) -│ │ └── syn (*) +│ │ └── thiserror (*) │ ├── spacetimedb_primitives (*) │ ├── spacetimedb_sats │ │ ├── arrayvec diff --git a/crates/core/src/db/update.rs b/crates/core/src/db/update.rs index 9d88b99045a..c6f1ac62aa7 100644 --- a/crates/core/src/db/update.rs +++ b/crates/core/src/db/update.rs @@ -2,12 +2,14 @@ use super::datastore::locking_tx_datastore::MutTxId; use super::relational_db::RelationalDB; use crate::database_logger::SystemLogger; use crate::execution_context::ExecutionContext; +use crate::sql::parser::RowLevelExpr; use spacetimedb_data_structures::map::HashMap; use spacetimedb_lib::db::auth::StTableType; +use spacetimedb_lib::db::raw_def::v9::RawRowLevelSecurityDefV9; use spacetimedb_lib::AlgebraicValue; use spacetimedb_primitives::ColSet; use spacetimedb_schema::auto_migrate::{AutoMigratePlan, ManualMigratePlan, MigratePlan}; -use spacetimedb_schema::def::TableDef; +use spacetimedb_schema::def::{ModuleDefLookup, TableDef}; use spacetimedb_schema::schema::{IndexSchema, Schema, SequenceSchema, TableSchema}; use std::sync::Arc; @@ -105,6 +107,9 @@ fn auto_migrate_database( } } } + // Is necessary to collect the full list of old and new tables to pass to the `RowLevelExpr::try_from` method, + // because we removed all the old row-level security definitions, then added the new ones. + let mut all_tables = table_schemas_by_name.clone(); log::info!("Running database update steps: {}", stdb.address()); @@ -115,11 +120,14 @@ fn auto_migrate_database( // Recursively sets IDs to 0. // They will be initialized by the database when the table is created. - let table_schema = TableSchema::from_module_def(plan.new, table_def, (), 0.into()); + let mut table_schema = TableSchema::from_module_def(plan.new, table_def, (), 0.into()); system_logger.info(&format!("Creating table `{}`", table_name)); log::info!("Creating table `{}`", table_name); - stdb.create_table(tx, table_schema)?; + + let table_id = stdb.create_table(tx, table_schema.clone())?; + table_schema.table_id = table_id; + all_tables.insert(table_schema.table_name.clone(), Arc::new(table_schema)); } spacetimedb_schema::auto_migrate::AutoMigrateStep::AddIndex(index_name) => { let table_def = plan.new.stored_in_table_def(index_name).unwrap(); @@ -221,6 +229,20 @@ fn auto_migrate_database( spacetimedb_schema::auto_migrate::AutoMigrateStep::RemoveSchedule(_) => { anyhow::bail!("Removing schedules is not yet implemented"); } + spacetimedb_schema::auto_migrate::AutoMigrateStep::AddRowLevelSecurity(sql_rls) => { + system_logger.info(&format!("Adding row-level security `{sql_rls}`")); + log::info!("Adding row-level security `{sql_rls}`"); + let tables = all_tables.values().cloned().collect::>(); + let rls = RawRowLevelSecurityDefV9::lookup(plan.new, sql_rls).unwrap(); + let rls = RowLevelExpr::try_from((rls, tables.as_slice()))?; + + stdb.create_row_level_security(tx, rls.def)?; + } + spacetimedb_schema::auto_migrate::AutoMigrateStep::RemoveRowLevelSecurity(sql_rls) => { + system_logger.info(&format!("Removing-row level security `{sql_rls}`")); + log::info!("Removing row-level security `{sql_rls}`"); + stdb.drop_row_level_security(tx, sql_rls.clone())?; + } } } diff --git a/crates/core/src/host/wasm_common/module_host_actor.rs b/crates/core/src/host/wasm_common/module_host_actor.rs index 9f22d7a42e7..9b9f22b5999 100644 --- a/crates/core/src/host/wasm_common/module_host_actor.rs +++ b/crates/core/src/host/wasm_common/module_host_actor.rs @@ -28,6 +28,7 @@ use crate::identity::Identity; use crate::messages::control_db::HostType; use crate::module_host_context::ModuleCreationContext; use crate::replica_context::ReplicaContext; +use crate::sql::parser::RowLevelExpr; use crate::subscription::module_subscription_actor::WriteConflict; use crate::util::const_unwrap; use crate::util::prometheus_handle::HistogramExt; @@ -268,13 +269,30 @@ impl ModuleInstance for WasmModuleInstance { .with_auto_rollback(&ctx, tx, |tx| { let mut table_defs: Vec<_> = self.info.module_def.tables().collect(); table_defs.sort_by(|a, b| a.name.cmp(&b.name)); + let mut table_schemas = Vec::with_capacity(table_defs.len()); for def in table_defs { let table_name = &def.name; self.system_logger().info(&format!("Creating table `{table_name}`")); - let schema = TableSchema::from_module_def(&self.info.module_def, def, (), TableId::SENTINEL); - stdb.create_table(tx, schema) + let mut schema = TableSchema::from_module_def(&self.info.module_def, def, (), TableId::SENTINEL); + let table_id = stdb + .create_table(tx, schema.clone()) .with_context(|| format!("failed to create table {table_name}"))?; + schema.table_id = table_id; + table_schemas.push(schema.into()); + } + // Insert the late-bound row-level security expressions. + for rls in self.info.module_def.row_level_security() { + self.system_logger() + .info(&format!("Creating row level security `{}`", rls.sql)); + + let rls = RowLevelExpr::try_from((rls, table_schemas.as_slice())) + .with_context(|| format!("failed to create row-level security: `{}`", rls.sql))?; + let table_id = rls.def.table_id; + let sql = rls.def.sql.clone(); + stdb.create_row_level_security(tx, rls.def).with_context(|| { + format!("failed to create row-level security for table `{table_id}`: `{sql}`",) + })?; } stdb.set_initialized(tx, HostType::Wasm, program)?; diff --git a/crates/core/src/sql/mod.rs b/crates/core/src/sql/mod.rs index 59aada0d554..77bedc08261 100644 --- a/crates/core/src/sql/mod.rs +++ b/crates/core/src/sql/mod.rs @@ -1,4 +1,5 @@ pub mod ast; pub mod compiler; pub mod execute; +pub mod parser; mod type_check; diff --git a/crates/core/src/sql/parser.rs b/crates/core/src/sql/parser.rs new file mode 100644 index 00000000000..3584fe7b48a --- /dev/null +++ b/crates/core/src/sql/parser.rs @@ -0,0 +1,29 @@ +use spacetimedb_expr::check::parse_and_type_sub; +use spacetimedb_expr::errors::TypingError; +use spacetimedb_expr::expr::RelExpr; +use spacetimedb_expr::ty::TyCtx; +use spacetimedb_lib::db::raw_def::v9::RawRowLevelSecurityDefV9; +use spacetimedb_schema::schema::{RowLevelSecuritySchema, TableSchema}; +use std::sync::Arc; + +pub struct RowLevelExpr { + pub sql: RelExpr, + pub def: RowLevelSecuritySchema, +} + +impl TryFrom<(&RawRowLevelSecurityDefV9, &[Arc])> for RowLevelExpr { + type Error = TypingError; + + fn try_from((rls, tx): (&RawRowLevelSecurityDefV9, &[Arc])) -> Result { + let mut ctx = TyCtx::default(); + let sql = parse_and_type_sub(&mut ctx, &rls.sql, &tx)?; + + Ok(Self { + def: RowLevelSecuritySchema { + table_id: sql.table_id(&mut ctx)?, + sql: rls.sql.clone(), + }, + sql, + }) + } +} diff --git a/crates/expr/src/check.rs b/crates/expr/src/check.rs index 72d2e382a94..a69e2c606a0 100644 --- a/crates/expr/src/check.rs +++ b/crates/expr/src/check.rs @@ -27,6 +27,11 @@ pub trait SchemaView { fn schema(&self, name: &str) -> Option>; } +impl SchemaView for &[Arc] { + fn schema(&self, name: &str) -> Option> { + self.iter().find(|schema| schema.table_name == Box::from(name)).cloned() + } +} pub trait TypeChecker { type Ast; type Set; diff --git a/crates/expr/src/errors.rs b/crates/expr/src/errors.rs index 44ceb4eb8cb..aa14fb420aa 100644 --- a/crates/expr/src/errors.rs +++ b/crates/expr/src/errors.rs @@ -1,10 +1,10 @@ -use spacetimedb_sql_parser::{ast::BinOp, parser::errors::SqlParseError}; -use thiserror::Error; - use super::{ statement::InvalidVar, ty::{InvalidTypeId, TypeWithCtx}, }; +use spacetimedb_sql_parser::ast::BinOp; +use spacetimedb_sql_parser::parser::errors::SqlParseError; +use thiserror::Error; #[derive(Error, Debug)] pub enum Unresolved { @@ -134,6 +134,10 @@ impl UnexpectedType { #[error("Duplicate name `{0}`")] pub struct DuplicateName(pub String); +#[derive(Debug, Error)] +#[error("No `TableId` found in `sql` expression")] +pub struct NoTableId; + #[derive(Error, Debug)] pub enum TypingError { #[error(transparent)] @@ -163,4 +167,6 @@ pub enum TypingError { Wildcard(#[from] InvalidWildcard), #[error(transparent)] DuplicateName(#[from] DuplicateName), + #[error(transparent)] + NoTableId(#[from] NoTableId), } diff --git a/crates/expr/src/expr.rs b/crates/expr/src/expr.rs index 42b7feb68a2..c445fff0a37 100644 --- a/crates/expr/src/expr.rs +++ b/crates/expr/src/expr.rs @@ -1,12 +1,13 @@ use std::sync::Arc; +use crate::errors::{NoTableId, TypingError}; +use crate::static_assert_size; use spacetimedb_lib::AlgebraicValue; +use spacetimedb_primitives::TableId; use spacetimedb_schema::schema::TableSchema; use spacetimedb_sql_parser::ast::BinOp; -use crate::static_assert_size; - -use super::ty::{InvalidTypeId, Symbol, TyCtx, TyId, TypeWithCtx}; +use super::ty::{InvalidTypeId, Symbol, TyCtx, TyId, Type, TypeWithCtx}; /// A logical relational expression #[derive(Debug)] @@ -54,6 +55,13 @@ impl RelExpr { pub fn ty<'a>(&self, ctx: &'a TyCtx) -> Result, InvalidTypeId> { ctx.try_resolve(self.ty_id()) } + + pub fn table_id(&self, ctx: &mut TyCtx) -> Result { + match &*self.ty(ctx)? { + Type::Var(id, _) => Ok(*id), + _ => Err(NoTableId.into()), + } + } } /// A relational select operation or filter diff --git a/crates/lib/src/db/raw_def/v9.rs b/crates/lib/src/db/raw_def/v9.rs index 88251ae56c0..1be6f034d5f 100644 --- a/crates/lib/src/db/raw_def/v9.rs +++ b/crates/lib/src/db/raw_def/v9.rs @@ -339,9 +339,9 @@ pub struct RawUniqueConstraintDataV9 { } /// Data for the `RLS` policy on a table. -#[derive(Debug, Clone, SpacetimeType)] +#[derive(Debug, Clone, PartialEq, Eq, SpacetimeType)] #[sats(crate = crate)] -#[cfg_attr(feature = "test", derive(PartialEq, Eq, PartialOrd, Ord))] +#[cfg_attr(feature = "test", derive(PartialOrd, Ord))] pub struct RawRowLevelSecurityDefV9 { /// The `sql` expression to use for row-level security. pub sql: RawSql, diff --git a/crates/schema/src/auto_migrate.rs b/crates/schema/src/auto_migrate.rs index 67da3f94067..a2511b2a03f 100644 --- a/crates/schema/src/auto_migrate.rs +++ b/crates/schema/src/auto_migrate.rs @@ -3,7 +3,7 @@ use spacetimedb_data_structures::{ error_stream::{CollectAllErrors, CombineErrors, ErrorStream}, map::HashSet, }; -use spacetimedb_lib::db::raw_def::v9::TableType; +use spacetimedb_lib::db::raw_def::v9::{RawRowLevelSecurityDefV9, TableType}; use spacetimedb_sats::WithTypespace; pub type Result = std::result::Result>; @@ -87,6 +87,10 @@ pub enum AutoMigrateStep<'def> { AddSchedule(::Key<'def>), /// Remove a schedule annotation from a table. RemoveSchedule(::Key<'def>), + /// Add a row-level security query. + AddRowLevelSecurity(::Key<'def>), + /// Remove a row-level security query. + RemoveRowLevelSecurity(::Key<'def>), } /// Something that might prevent an automatic migration. @@ -170,8 +174,10 @@ pub fn ponder_auto_migrate<'def>(old: &'def ModuleDef, new: &'def ModuleDef) -> let indexes_ok = auto_migrate_indexes(&mut plan, &new_tables); let sequences_ok = auto_migrate_sequences(&mut plan, &new_tables); let constraints_ok = auto_migrate_constraints(&mut plan, &new_tables); + // Is important that this is the last step, because it needs the list of tables + let rls_ok = auto_migrate_row_level_security(&mut plan); - let ((), (), (), ()) = (tables_ok, indexes_ok, sequences_ok, constraints_ok).combine_errors()?; + let ((), (), (), (), ()) = (tables_ok, indexes_ok, sequences_ok, constraints_ok, rls_ok).combine_errors()?; Ok(plan) } @@ -218,6 +224,7 @@ fn auto_migrate_tables(plan: &mut AutoMigratePlan<'_>) -> Result<()> { plan.steps.push(AutoMigrateStep::AddTable(new.key())); Ok(()) } + // TODO: When we remove tables, we should also remove their dependencies, including row-level security. Diff::Remove { old } => Err(AutoMigrateError::RemoveTable { table: old.name.clone(), } @@ -408,6 +415,19 @@ fn auto_migrate_constraints(plan: &mut AutoMigratePlan, new_tables: &HashSet<&Id .collect_all_errors() } +// Because we can refer to many tables and fields on the row level-security query, we need to remove all of them, +// then add the new ones, instead of trying to track the graph of dependencies. +fn auto_migrate_row_level_security(plan: &mut AutoMigratePlan) -> Result<()> { + for rls in plan.old.row_level_security() { + plan.steps.push(AutoMigrateStep::RemoveRowLevelSecurity(rls.key())); + } + for rls in plan.new.row_level_security() { + plan.steps.push(AutoMigrateStep::AddRowLevelSecurity(rls.key())); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -490,6 +510,8 @@ mod tests { ) .finish(); + old_builder.add_row_level_security("SELECT * FROM Apples"); + let old_def: ModuleDef = old_builder .finish() .try_into() @@ -597,6 +619,8 @@ mod tests { .with_primary_key(0) .finish(); + new_builder.add_row_level_security("SELECT * FROM Bananas"); + let new_def: ModuleDef = new_builder .finish() .try_into() @@ -620,6 +644,13 @@ mod tests { plan.prechecks[0], AutoMigratePrecheck::CheckAddSequenceRangeValid(&bananas_sequence) ); + let sql_old = RawRowLevelSecurityDefV9 { + sql: "SELECT * FROM Apples".into(), + }; + + let sql_new = RawRowLevelSecurityDefV9 { + sql: "SELECT * FROM Bananas".into(), + }; assert!(plan.steps.contains(&AutoMigrateStep::RemoveSequence(&apples_sequence))); assert!(plan @@ -641,6 +672,11 @@ mod tests { assert!(plan .steps .contains(&AutoMigrateStep::AddSchedule(&inspections_schedule))); + + assert!(plan + .steps + .contains(&AutoMigrateStep::RemoveRowLevelSecurity(&sql_old.sql))); + assert!(plan.steps.contains(&AutoMigrateStep::AddRowLevelSecurity(&sql_new.sql))); } #[test] @@ -710,6 +746,10 @@ mod tests { .with_type(TableType::System) // change type .finish(); + // Invalid row-level security queries can't be detected in the ponder_auto_migrate function, they + // are detected when executing the plan because they depend on the database state. + // new_builder.add_row_level_security("SELECT wrong"); + // remove Bananas let new_def: ModuleDef = new_builder .finish() diff --git a/modules/sdk-test/src/lib.rs b/modules/sdk-test/src/lib.rs index 252ae7592d2..c370b65dc3c 100644 --- a/modules/sdk-test/src/lib.rs +++ b/modules/sdk-test/src/lib.rs @@ -639,3 +639,5 @@ define_tables! { #[spacetimedb::reducer] fn no_op_succeeds(_ctx: &ReducerContext) {} + +spacetimedb::filter!("SELECT * FROM one_u8"); diff --git a/smoketests/tests/auto_migration.py b/smoketests/tests/auto_migration.py index 90c09e5b00c..57900167797 100644 --- a/smoketests/tests/auto_migration.py +++ b/smoketests/tests/auto_migration.py @@ -36,6 +36,8 @@ class AddTableAutoMigration(Smoketest): x: f64, y: f64, } + +spacetimedb::filter!("SELECT * FROM person"); """ MODULE_CODE_UPDATED = ( @@ -57,12 +59,28 @@ class AddTableAutoMigration(Smoketest): println!("{}: {}", prefix, book.isbn); } } + +spacetimedb::filter!("SELECT * FROM book"); """ ) + def assertSql(self, sql, expected): + self.maxDiff = None + sql_out = self.spacetime("sql", self.address, sql) + sql_out = "\n".join([line.rstrip() for line in sql_out.splitlines()]) + expected = "\n".join([line.rstrip() for line in expected.splitlines()]) + self.assertMultiLineEqual(sql_out, expected) + def test_add_table_auto_migration(self): """This tests uploading a module with a schema change that should not require clearing the database.""" + # Check the row-level SQL filter is created correctly + self.assertSql("SELECT sql FROM st_row_level_security", """\ + sql +------------------------ + "SELECT * FROM person" +""") + logging.info("Initial publish complete") # initial module code is already published by test framework @@ -83,6 +101,15 @@ def test_add_table_auto_migration(self): self.publish_module(self.address, clear=False) logging.info("Updated") + + # Check the row-level SQL filter is added correctly + self.assertSql("SELECT sql FROM st_row_level_security", """\ + sql +------------------------ + "SELECT * FROM person" + "SELECT * FROM book" +""") + self.logs(100) self.call("add_person", "Husserl")