1use crate::auth::{Permission, RoleEntry, UserEntry};
72use crate::catalog::{Catalog, CatalogEntry, MaterializedViewEntry, TableState};
73use crate::error::{MongrelError, Result};
74use crate::procedure::{ProcedureEntry, StoredProcedure};
75use crate::schema::{ColumnDef, IndexDef, Schema, TypeId};
76use crate::security::{ColumnMask, MaskStrategy, RowPolicy, SecurityCatalog, SecurityExpr};
77use crate::trigger::{StoredTrigger, TriggerEntry, TriggerTarget};
78use serde::{Deserialize, Serialize};
79
80pub const CATALOG_COMMAND_FORMAT_VERSION: u16 = 1;
83
84pub const COMMAND_HISTORY_LIMIT: usize = 256;
88
89pub type ResourceGroupDef = crate::resource::ResourceGroup;
95
96pub use crate::jobs::{JobKind, JobState};
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
103#[serde(deny_unknown_fields)]
104pub struct JobDefinition {
105 pub job_id: u64,
107 pub kind: JobKind,
108 pub state: JobState,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub target: Option<String>,
112 pub created_epoch: u64,
113 pub updated_epoch: u64,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
124pub enum CatalogCommand {
125 CreateTable {
129 name: String,
130 schema: Schema,
131 created_epoch: u64,
132 },
133 DropTable {
138 name: String,
139 at_epoch: u64,
140 },
141 RenameTable {
145 name: String,
146 new_name: String,
147 at_epoch: u64,
148 },
149 AlterColumn {
152 table: String,
153 column: ColumnDef,
154 },
155 AddColumn {
157 table: String,
158 column: ColumnDef,
159 },
160 DropColumn {
163 table: String,
164 column: String,
165 },
166 AddIndex {
169 table: String,
170 index: IndexDef,
171 },
172 RemoveIndex {
175 table: String,
176 name: String,
177 },
178 CreateUser {
182 username: String,
183 password_hash: String,
184 is_admin: bool,
185 created_epoch: u64,
186 },
187 DropUser {
188 username: String,
189 },
190 AlterUserPassword {
191 username: String,
192 password_hash: String,
193 },
194 SetUserAdmin {
195 username: String,
196 is_admin: bool,
197 },
198 CreateRole {
199 name: String,
200 created_epoch: u64,
201 },
202 DropRole {
205 name: String,
206 },
207 GrantRole {
210 username: String,
211 role: String,
212 },
213 RevokeRole {
215 username: String,
216 role: String,
217 },
218 GrantPermission {
221 role: String,
222 permission: Permission,
223 },
224 RevokePermission {
226 role: String,
227 permission: Permission,
228 },
229 EnableRls {
231 table: String,
232 },
233 DisableRls {
234 table: String,
235 },
236 SetRowPolicy {
238 policy: RowPolicy,
239 },
240 DropRowPolicy {
241 table: String,
242 name: String,
243 },
244 SetColumnMask {
246 mask: ColumnMask,
247 },
248 DropColumnMask {
249 table: String,
250 name: String,
251 },
252 SetSecurityCatalog {
256 security: SecurityCatalog,
257 },
258 CreateTrigger {
260 trigger: StoredTrigger,
261 },
262 DropTrigger {
263 name: String,
264 },
265 CreateProcedure {
266 procedure: StoredProcedure,
267 },
268 DropProcedure {
269 name: String,
270 },
271 CreateMaterializedView {
275 definition: MaterializedViewEntry,
276 },
277 DropMaterializedView {
280 name: String,
281 },
282 RefreshMaterializedView {
285 name: String,
286 at_epoch: u64,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
288 checkpoint_event_id: Option<String>,
289 },
290 SetResourceGroup {
293 group: ResourceGroupDef,
294 },
295 RemoveResourceGroup {
296 name: String,
297 },
298 SubmitJob {
301 job: JobDefinition,
302 },
303 SetJobState {
306 job_id: u64,
307 state: JobState,
308 at_epoch: u64,
309 },
310 ReplaceTrigger {
317 trigger: StoredTrigger,
318 },
319 ReplaceProcedure {
323 procedure: StoredProcedure,
324 },
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
329#[serde(deny_unknown_fields)]
330pub struct CatalogCommandRecord {
331 pub version: u16,
333 pub catalog_version: u64,
335 pub command: CatalogCommand,
337}
338
339impl CatalogCommandRecord {
340 pub fn next(catalog: &Catalog, command: CatalogCommand) -> Self {
342 Self {
343 version: CATALOG_COMMAND_FORMAT_VERSION,
344 catalog_version: catalog.catalog_version.saturating_add(1),
345 command,
346 }
347 }
348}
349
350#[derive(Debug, Clone)]
356pub enum CatalogDelta {
357 NoOp,
360 TableCreated {
361 entry: CatalogEntry,
362 },
363 TableDropped {
364 table_id: u64,
365 name: String,
366 at_epoch: u64,
367 },
368 TableRenamed {
369 table_id: u64,
370 old_name: String,
371 new_name: String,
372 at_epoch: u64,
373 },
374 SchemaReplaced {
376 table_id: u64,
377 schema: Schema,
378 },
379 UserUpserted(UserEntry),
382 UserRemoved {
383 username: String,
384 },
385 RoleUpserted(RoleEntry),
387 RoleRemoved {
388 name: String,
389 },
390 SecurityReplaced {
393 security: SecurityCatalog,
394 },
395 TriggerUpserted(TriggerEntry),
396 TriggerRemoved {
397 name: String,
398 },
399 ProcedureUpserted(ProcedureEntry),
400 ProcedureRemoved {
401 name: String,
402 },
403 MaterializedViewUpserted(MaterializedViewEntry),
404 MaterializedViewRemoved {
405 name: String,
406 },
407 ResourceGroupUpserted(ResourceGroupDef),
408 ResourceGroupRemoved {
409 name: String,
410 },
411 JobUpserted(JobDefinition),
412}
413
414impl CatalogDelta {
415 pub(crate) fn apply_to(&self, catalog: &mut Catalog) -> Result<()> {
418 match self {
419 CatalogDelta::NoOp => {}
420 CatalogDelta::TableCreated { entry } => {
421 catalog.next_table_id = entry.table_id.checked_add(1).ok_or_else(|| {
422 MongrelError::InvalidArgument("table id space exhausted".into())
423 })?;
424 catalog.tables.push(entry.clone());
425 }
426 CatalogDelta::TableDropped {
427 table_id,
428 name,
429 at_epoch,
430 } => {
431 let entry = catalog
432 .tables
433 .iter_mut()
434 .find(|table| table.table_id == *table_id)
435 .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
436 entry.state = TableState::Dropped {
437 at_epoch: *at_epoch,
438 };
439 catalog.triggers.retain(|trigger| {
440 !matches!(
441 &trigger.trigger.target,
442 TriggerTarget::Table(target) if target == name
443 )
444 });
445 catalog
446 .materialized_views
447 .retain(|definition| definition.name != *name);
448 catalog.security.rls_tables.retain(|table| table != name);
449 catalog
450 .security
451 .policies
452 .retain(|policy| policy.table != *name);
453 catalog.security.masks.retain(|mask| mask.table != *name);
454 for role in &mut catalog.roles {
455 role.permissions
456 .retain(|permission| permission_table(permission) != Some(name.as_str()));
457 }
458 advance_security_version(catalog)?;
459 }
460 CatalogDelta::TableRenamed {
461 table_id,
462 old_name,
463 new_name,
464 at_epoch,
465 } => {
466 let entry = catalog
467 .tables
468 .iter_mut()
469 .find(|table| table.table_id == *table_id)
470 .ok_or_else(|| {
471 MongrelError::NotFound(format!("table {old_name:?} not found"))
472 })?;
473 entry.name = new_name.clone();
474 for trigger in &mut catalog.triggers {
475 if matches!(
476 &trigger.trigger.target,
477 TriggerTarget::Table(target) if target == old_name
478 ) {
479 trigger.trigger = trigger.trigger.retarget_table(new_name, *at_epoch)?;
480 }
481 }
482 if let Some(definition) = catalog
483 .materialized_views
484 .iter_mut()
485 .find(|definition| definition.name == *old_name)
486 {
487 definition.name = new_name.clone();
488 }
489 for table in &mut catalog.security.rls_tables {
493 if table == old_name {
494 *table = new_name.clone();
495 }
496 }
497 for policy in &mut catalog.security.policies {
498 if policy.table == *old_name {
499 policy.table = new_name.clone();
500 }
501 }
502 for mask in &mut catalog.security.masks {
503 if mask.table == *old_name {
504 mask.table = new_name.clone();
505 }
506 }
507 for role in &mut catalog.roles {
508 for permission in &mut role.permissions {
509 rename_permission_table(permission, old_name, new_name);
510 }
511 }
512 advance_security_version(catalog)?;
513 }
514 CatalogDelta::SchemaReplaced { table_id, schema } => {
515 let entry = catalog
516 .tables
517 .iter_mut()
518 .find(|table| table.table_id == *table_id)
519 .ok_or_else(|| {
520 MongrelError::NotFound(format!("table id {table_id} not found"))
521 })?;
522 entry.schema = schema.clone();
523 }
524 CatalogDelta::UserUpserted(entry) => {
525 match catalog
526 .users
527 .iter_mut()
528 .find(|user| user.username == entry.username)
529 {
530 Some(existing) => *existing = entry.clone(),
531 None => {
532 let next = entry.id.checked_add(1).ok_or_else(|| {
533 MongrelError::Full("user-id namespace exhausted".into())
534 })?;
535 catalog.users.push(entry.clone());
536 catalog.next_user_id = catalog.next_user_id.max(next);
537 }
538 }
539 advance_security_version(catalog)?;
540 }
541 CatalogDelta::UserRemoved { username } => {
542 catalog.users.retain(|user| user.username != *username);
543 advance_security_version(catalog)?;
544 }
545 CatalogDelta::RoleUpserted(entry) => {
546 match catalog
547 .roles
548 .iter_mut()
549 .find(|role| role.name == entry.name)
550 {
551 Some(existing) => *existing = entry.clone(),
552 None => catalog.roles.push(entry.clone()),
553 }
554 advance_security_version(catalog)?;
555 }
556 CatalogDelta::RoleRemoved { name } => {
557 catalog.roles.retain(|role| role.name != *name);
558 for user in &mut catalog.users {
559 user.roles.retain(|role| role != name);
560 }
561 advance_security_version(catalog)?;
562 }
563 CatalogDelta::SecurityReplaced { security } => {
564 catalog.security = security.clone();
565 advance_security_version(catalog)?;
566 }
567 CatalogDelta::TriggerUpserted(entry) => {
568 match catalog
569 .triggers
570 .iter_mut()
571 .find(|trigger| trigger.trigger.name == entry.trigger.name)
572 {
573 Some(existing) => *existing = entry.clone(),
574 None => catalog.triggers.push(entry.clone()),
575 }
576 }
577 CatalogDelta::TriggerRemoved { name } => {
578 catalog
579 .triggers
580 .retain(|trigger| trigger.trigger.name != *name);
581 }
582 CatalogDelta::ProcedureUpserted(entry) => {
583 match catalog
584 .procedures
585 .iter_mut()
586 .find(|procedure| procedure.procedure.name == entry.procedure.name)
587 {
588 Some(existing) => *existing = entry.clone(),
589 None => catalog.procedures.push(entry.clone()),
590 }
591 }
592 CatalogDelta::ProcedureRemoved { name } => {
593 catalog
594 .procedures
595 .retain(|procedure| procedure.procedure.name != *name);
596 }
597 CatalogDelta::MaterializedViewUpserted(definition) => {
598 match catalog
599 .materialized_views
600 .iter_mut()
601 .find(|existing| existing.name == definition.name)
602 {
603 Some(existing) => *existing = definition.clone(),
604 None => catalog.materialized_views.push(definition.clone()),
605 }
606 }
607 CatalogDelta::MaterializedViewRemoved { name } => {
608 catalog
609 .materialized_views
610 .retain(|definition| definition.name != *name);
611 }
612 CatalogDelta::ResourceGroupUpserted(group) => {
613 match catalog
614 .resource_groups
615 .iter_mut()
616 .find(|existing| existing.name == group.name)
617 {
618 Some(existing) => *existing = group.clone(),
619 None => catalog.resource_groups.push(group.clone()),
620 }
621 }
622 CatalogDelta::ResourceGroupRemoved { name } => {
623 catalog.resource_groups.retain(|group| group.name != *name);
624 }
625 CatalogDelta::JobUpserted(job) => {
626 match catalog
627 .job_definitions
628 .iter_mut()
629 .find(|existing| existing.job_id == job.job_id)
630 {
631 Some(existing) => *existing = job.clone(),
632 None => catalog.job_definitions.push(job.clone()),
633 }
634 }
635 }
636 Ok(())
637 }
638}
639
640pub fn required_permission(command: &CatalogCommand) -> Permission {
644 match command {
645 CatalogCommand::CreateTable { .. }
646 | CatalogCommand::DropTable { .. }
647 | CatalogCommand::RenameTable { .. }
648 | CatalogCommand::AlterColumn { .. }
649 | CatalogCommand::AddColumn { .. }
650 | CatalogCommand::DropColumn { .. }
651 | CatalogCommand::AddIndex { .. }
652 | CatalogCommand::RemoveIndex { .. }
653 | CatalogCommand::CreateTrigger { .. }
654 | CatalogCommand::DropTrigger { .. }
655 | CatalogCommand::CreateProcedure { .. }
656 | CatalogCommand::DropProcedure { .. }
657 | CatalogCommand::ReplaceTrigger { .. }
658 | CatalogCommand::ReplaceProcedure { .. }
659 | CatalogCommand::CreateMaterializedView { .. }
660 | CatalogCommand::DropMaterializedView { .. }
661 | CatalogCommand::RefreshMaterializedView { .. } => Permission::Ddl,
662 CatalogCommand::CreateUser { .. }
663 | CatalogCommand::DropUser { .. }
664 | CatalogCommand::AlterUserPassword { .. }
665 | CatalogCommand::SetUserAdmin { .. }
666 | CatalogCommand::CreateRole { .. }
667 | CatalogCommand::DropRole { .. }
668 | CatalogCommand::GrantRole { .. }
669 | CatalogCommand::RevokeRole { .. }
670 | CatalogCommand::GrantPermission { .. }
671 | CatalogCommand::RevokePermission { .. }
672 | CatalogCommand::EnableRls { .. }
673 | CatalogCommand::DisableRls { .. }
674 | CatalogCommand::SetRowPolicy { .. }
675 | CatalogCommand::DropRowPolicy { .. }
676 | CatalogCommand::SetColumnMask { .. }
677 | CatalogCommand::DropColumnMask { .. }
678 | CatalogCommand::SetSecurityCatalog { .. }
679 | CatalogCommand::SetResourceGroup { .. }
680 | CatalogCommand::RemoveResourceGroup { .. }
681 | CatalogCommand::SubmitJob { .. }
682 | CatalogCommand::SetJobState { .. } => Permission::Admin,
683 }
684}
685
686pub fn apply(catalog: &Catalog, command: &CatalogCommand) -> Result<CatalogDelta> {
690 match command {
691 CatalogCommand::CreateTable {
692 name,
693 schema,
694 created_epoch,
695 } => {
696 if name.is_empty() || name.starts_with(crate::database::CTAS_BUILD_TABLE_PREFIX) {
697 return Err(MongrelError::InvalidArgument(format!(
698 "invalid table name {name:?}"
699 )));
700 }
701 if catalog.live(name).is_some() || catalog.building_for(name).is_some() {
702 return Err(MongrelError::InvalidArgument(format!(
703 "table {name:?} already exists or is being built"
704 )));
705 }
706 let mut schema = schema.clone();
707 schema.validate_auto_increment()?;
711 schema.validate_defaults()?;
712 schema.validate_ai()?;
713 for index in &schema.indexes {
714 index.validate_options()?;
715 }
716 for constraint in &schema.constraints.checks {
717 constraint.expr.validate()?;
718 }
719 let table_id = catalog.next_table_id;
720 schema.schema_id = table_id;
721 Ok(CatalogDelta::TableCreated {
722 entry: CatalogEntry {
723 table_id,
724 name: name.clone(),
725 schema,
726 state: TableState::Live,
727 created_epoch: *created_epoch,
728 },
729 })
730 }
731 CatalogCommand::DropTable { name, at_epoch } => {
732 let entry = catalog
733 .live(name)
734 .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
735 Ok(CatalogDelta::TableDropped {
736 table_id: entry.table_id,
737 name: name.clone(),
738 at_epoch: *at_epoch,
739 })
740 }
741 CatalogCommand::RenameTable {
742 name,
743 new_name,
744 at_epoch,
745 } => {
746 if name == new_name {
747 return Ok(CatalogDelta::NoOp);
748 }
749 if new_name.is_empty()
750 || name.starts_with(crate::database::CTAS_BUILD_TABLE_PREFIX)
751 || new_name.starts_with(crate::database::CTAS_BUILD_TABLE_PREFIX)
752 {
753 return Err(MongrelError::InvalidArgument(
754 "invalid table rename identity".into(),
755 ));
756 }
757 let entry = catalog
758 .live(name)
759 .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
760 if catalog.live(new_name).is_some() || catalog.building_for(new_name).is_some() {
761 return Err(MongrelError::InvalidArgument(format!(
762 "a table named {new_name:?} already exists"
763 )));
764 }
765 Ok(CatalogDelta::TableRenamed {
766 table_id: entry.table_id,
767 old_name: name.clone(),
768 new_name: new_name.clone(),
769 at_epoch: *at_epoch,
770 })
771 }
772 CatalogCommand::AlterColumn { table, column } => {
773 let entry = live_entry(catalog, table)?;
774 let mut schema = entry.schema.clone();
775 let position = schema
776 .columns
777 .iter()
778 .position(|existing| existing.id == column.id)
779 .ok_or_else(|| {
780 MongrelError::ColumnNotFound(format!("column id {} on {table}", column.id))
781 })?;
782 schema.columns[position] = column.clone();
783 schema.schema_id = schema
787 .schema_id
788 .checked_add(1)
789 .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
790 Ok(CatalogDelta::SchemaReplaced {
791 table_id: entry.table_id,
792 schema,
793 })
794 }
795 CatalogCommand::AddColumn { table, column } => {
796 let entry = live_entry(catalog, table)?;
797 if entry.schema.column(&column.name).is_some() {
798 return Err(MongrelError::InvalidArgument(format!(
799 "column {} already exists on {table}",
800 column.name
801 )));
802 }
803 if entry
804 .schema
805 .columns
806 .iter()
807 .any(|existing| existing.id == column.id)
808 {
809 return Err(MongrelError::InvalidArgument(format!(
810 "column id {} already used on {table}",
811 column.id
812 )));
813 }
814 let mut schema = entry.schema.clone();
815 schema.columns.push(column.clone());
816 schema.validate_auto_increment()?;
817 schema.validate_defaults()?;
818 schema.validate_ai()?;
819 Ok(CatalogDelta::SchemaReplaced {
820 table_id: entry.table_id,
821 schema,
822 })
823 }
824 CatalogCommand::DropColumn { table, column } => {
825 let entry = live_entry(catalog, table)?;
826 let schema = &entry.schema;
827 let target = schema.column(column).ok_or_else(|| {
828 MongrelError::ColumnNotFound(format!("column {column} on {table}"))
829 })?;
830 let dropped_id = target.id;
831 let mut schema = schema.clone();
832 schema.columns.retain(|existing| existing.id != dropped_id);
833 schema.indexes.retain(|index| index.column_id != dropped_id);
834 Ok(CatalogDelta::SchemaReplaced {
835 table_id: entry.table_id,
836 schema,
837 })
838 }
839 CatalogCommand::AddIndex { table, index } => {
840 let entry = live_entry(catalog, table)?;
841 let schema = &entry.schema;
842 if schema
843 .indexes
844 .iter()
845 .any(|existing| existing.name == index.name)
846 {
847 return Err(MongrelError::InvalidArgument(format!(
848 "index {} already exists on {table}",
849 index.name
850 )));
851 }
852 index.validate_options()?;
853 let mut schema = schema.clone();
854 schema.indexes.push(index.clone());
855 schema.validate_ai()?;
857 Ok(CatalogDelta::SchemaReplaced {
858 table_id: entry.table_id,
859 schema,
860 })
861 }
862 CatalogCommand::RemoveIndex { table, name } => {
863 let entry = live_entry(catalog, table)?;
864 let schema = &entry.schema;
865 if !schema.indexes.iter().any(|index| index.name == *name) {
866 return Err(MongrelError::NotFound(format!(
867 "index {name} does not exist on {table}"
868 )));
869 }
870 let mut schema = schema.clone();
871 schema.indexes.retain(|index| index.name != *name);
872 Ok(CatalogDelta::SchemaReplaced {
873 table_id: entry.table_id,
874 schema,
875 })
876 }
877 CatalogCommand::CreateUser {
878 username,
879 password_hash,
880 is_admin,
881 created_epoch,
882 } => {
883 if username.is_empty() {
884 return Err(MongrelError::InvalidArgument(
885 "username must not be empty".into(),
886 ));
887 }
888 if catalog.users.iter().any(|user| user.username == *username) {
889 return Err(MongrelError::InvalidArgument(format!(
890 "user {username:?} already exists"
891 )));
892 }
893 let id = catalog.next_user_id.max(1);
894 Ok(CatalogDelta::UserUpserted(UserEntry {
895 id,
896 username: username.clone(),
897 password_hash: password_hash.clone(),
898 roles: Vec::new(),
899 is_admin: *is_admin,
900 created_epoch: *created_epoch,
901 }))
902 }
903 CatalogCommand::DropUser { username } => {
904 if !catalog.users.iter().any(|user| user.username == *username) {
905 return Err(MongrelError::NotFound(format!(
906 "user {username:?} not found"
907 )));
908 }
909 Ok(CatalogDelta::UserRemoved {
910 username: username.clone(),
911 })
912 }
913 CatalogCommand::AlterUserPassword {
914 username,
915 password_hash,
916 } => {
917 let user = find_user(catalog, username)?;
918 let mut user = user.clone();
919 user.password_hash = password_hash.clone();
920 Ok(CatalogDelta::UserUpserted(user))
921 }
922 CatalogCommand::SetUserAdmin { username, is_admin } => {
923 let user = find_user(catalog, username)?;
924 if user.is_admin == *is_admin {
925 return Ok(CatalogDelta::NoOp);
926 }
927 let mut user = user.clone();
928 user.is_admin = *is_admin;
929 Ok(CatalogDelta::UserUpserted(user))
930 }
931 CatalogCommand::CreateRole {
932 name,
933 created_epoch,
934 } => {
935 if name.is_empty() {
936 return Err(MongrelError::InvalidArgument(
937 "role name must not be empty".into(),
938 ));
939 }
940 if catalog.roles.iter().any(|role| role.name == *name) {
941 return Err(MongrelError::InvalidArgument(format!(
942 "role {name:?} already exists"
943 )));
944 }
945 Ok(CatalogDelta::RoleUpserted(RoleEntry {
946 name: name.clone(),
947 permissions: Vec::new(),
948 created_epoch: *created_epoch,
949 }))
950 }
951 CatalogCommand::DropRole { name } => {
952 if !catalog.roles.iter().any(|role| role.name == *name) {
953 return Err(MongrelError::NotFound(format!("role {name:?} not found")));
954 }
955 Ok(CatalogDelta::RoleRemoved { name: name.clone() })
956 }
957 CatalogCommand::GrantRole { username, role } => {
958 if !catalog.roles.iter().any(|entry| entry.name == *role) {
959 return Err(MongrelError::NotFound(format!("role {role:?} not found")));
960 }
961 let user = find_user(catalog, username)?;
962 if user.roles.iter().any(|held| held == role) {
963 return Ok(CatalogDelta::NoOp);
964 }
965 let mut user = user.clone();
966 user.roles.push(role.clone());
967 Ok(CatalogDelta::UserUpserted(user))
968 }
969 CatalogCommand::RevokeRole { username, role } => {
970 let user = find_user(catalog, username)?;
971 if !user.roles.iter().any(|held| held == role) {
972 return Ok(CatalogDelta::NoOp);
973 }
974 let mut user = user.clone();
975 user.roles.retain(|held| held != role);
976 Ok(CatalogDelta::UserUpserted(user))
977 }
978 CatalogCommand::GrantPermission { role, permission } => {
979 let role = find_role(catalog, role)?;
980 let mut resolved = role.clone();
981 merge_permission(&mut resolved.permissions, permission.clone());
982 if resolved.permissions == role.permissions {
983 return Ok(CatalogDelta::NoOp);
984 }
985 Ok(CatalogDelta::RoleUpserted(resolved))
986 }
987 CatalogCommand::RevokePermission { role, permission } => {
988 let role = find_role(catalog, role)?;
989 let mut resolved = role.clone();
990 revoke_permission_from(&mut resolved.permissions, permission);
991 if resolved.permissions == role.permissions {
992 return Ok(CatalogDelta::NoOp);
993 }
994 Ok(CatalogDelta::RoleUpserted(resolved))
995 }
996 CatalogCommand::EnableRls { table } => {
997 live_entry(catalog, table)?;
998 if catalog.security.rls_enabled(table) {
999 return Ok(CatalogDelta::NoOp);
1000 }
1001 let mut security = catalog.security.clone();
1002 security.rls_tables.push(table.clone());
1003 Ok(CatalogDelta::SecurityReplaced { security })
1004 }
1005 CatalogCommand::DisableRls { table } => {
1006 if !catalog.security.rls_enabled(table) {
1007 return Ok(CatalogDelta::NoOp);
1008 }
1009 let mut security = catalog.security.clone();
1010 security.rls_tables.retain(|name| name != table);
1011 Ok(CatalogDelta::SecurityReplaced { security })
1012 }
1013 CatalogCommand::SetRowPolicy { policy } => {
1014 let entry = live_entry(catalog, &policy.table)?;
1015 if let Some(expression) = &policy.using {
1016 validate_policy_columns(expression, &entry.schema)?;
1017 }
1018 if let Some(expression) = &policy.with_check {
1019 validate_policy_columns(expression, &entry.schema)?;
1020 }
1021 let mut security = catalog.security.clone();
1022 match security
1023 .policies
1024 .iter_mut()
1025 .find(|existing| existing.table == policy.table && existing.name == policy.name)
1026 {
1027 Some(existing) => *existing = policy.clone(),
1028 None => security.policies.push(policy.clone()),
1029 }
1030 Ok(CatalogDelta::SecurityReplaced { security })
1031 }
1032 CatalogCommand::DropRowPolicy { table, name } => {
1033 if !catalog
1034 .security
1035 .policies
1036 .iter()
1037 .any(|policy| policy.table == *table && policy.name == *name)
1038 {
1039 return Err(MongrelError::NotFound(format!(
1040 "policy {name:?} on {table:?} not found"
1041 )));
1042 }
1043 let mut security = catalog.security.clone();
1044 security
1045 .policies
1046 .retain(|policy| !(policy.table == *table && policy.name == *name));
1047 Ok(CatalogDelta::SecurityReplaced { security })
1048 }
1049 CatalogCommand::SetColumnMask { mask } => {
1050 let entry = live_entry(catalog, &mask.table)?;
1051 let column = entry
1052 .schema
1053 .columns
1054 .iter()
1055 .find(|column| column.id == mask.column)
1056 .ok_or_else(|| {
1057 MongrelError::NotFound(format!(
1058 "mask column {} on {:?} not found",
1059 mask.column, mask.table
1060 ))
1061 })?;
1062 if matches!(
1063 mask.strategy,
1064 MaskStrategy::Redact { .. } | MaskStrategy::Sha256
1065 ) && !matches!(column.ty, TypeId::Bytes | TypeId::Enum { .. })
1066 {
1067 return Err(MongrelError::InvalidArgument(format!(
1068 "mask {:?} requires a string/bytes column",
1069 mask.name
1070 )));
1071 }
1072 let mut security = catalog.security.clone();
1073 match security
1074 .masks
1075 .iter_mut()
1076 .find(|existing| existing.table == mask.table && existing.name == mask.name)
1077 {
1078 Some(existing) => *existing = mask.clone(),
1079 None => security.masks.push(mask.clone()),
1080 }
1081 Ok(CatalogDelta::SecurityReplaced { security })
1082 }
1083 CatalogCommand::DropColumnMask { table, name } => {
1084 if !catalog
1085 .security
1086 .masks
1087 .iter()
1088 .any(|mask| mask.table == *table && mask.name == *name)
1089 {
1090 return Err(MongrelError::NotFound(format!(
1091 "mask {name:?} on {table:?} not found"
1092 )));
1093 }
1094 let mut security = catalog.security.clone();
1095 security
1096 .masks
1097 .retain(|mask| !(mask.table == *table && mask.name == *name));
1098 Ok(CatalogDelta::SecurityReplaced { security })
1099 }
1100 CatalogCommand::SetSecurityCatalog { security } => {
1101 crate::database::validate_security_catalog(catalog, security)?;
1106 Ok(CatalogDelta::SecurityReplaced {
1107 security: security.clone(),
1108 })
1109 }
1110 CatalogCommand::CreateTrigger { trigger } => {
1111 trigger.validate()?;
1112 if let TriggerTarget::Table(target) = &trigger.target {
1113 if catalog.live(target).is_none() {
1114 return Err(MongrelError::InvalidArgument(format!(
1115 "trigger {:?} references unknown target table {target:?}",
1116 trigger.name
1117 )));
1118 }
1119 }
1120 if catalog
1121 .triggers
1122 .iter()
1123 .any(|entry| entry.trigger.name == trigger.name)
1124 {
1125 return Err(MongrelError::TriggerValidation(format!(
1126 "trigger {:?} already exists",
1127 trigger.name
1128 )));
1129 }
1130 Ok(CatalogDelta::TriggerUpserted(TriggerEntry::from(
1131 trigger.clone(),
1132 )))
1133 }
1134 CatalogCommand::DropTrigger { name } => {
1135 if !catalog
1136 .triggers
1137 .iter()
1138 .any(|entry| entry.trigger.name == *name)
1139 {
1140 return Err(MongrelError::NotFound(format!(
1141 "trigger {name:?} not found"
1142 )));
1143 }
1144 Ok(CatalogDelta::TriggerRemoved { name: name.clone() })
1145 }
1146 CatalogCommand::CreateProcedure { procedure } => {
1147 procedure.validate()?;
1148 for step in &procedure.body.steps {
1149 if let Some(table) = step.table() {
1150 if catalog.live(table).is_none() {
1151 return Err(MongrelError::InvalidArgument(format!(
1152 "procedure {:?} references unknown table {table:?}",
1153 procedure.name
1154 )));
1155 }
1156 }
1157 }
1158 if catalog
1159 .procedures
1160 .iter()
1161 .any(|entry| entry.procedure.name == procedure.name)
1162 {
1163 return Err(MongrelError::InvalidArgument(format!(
1164 "procedure {:?} already exists",
1165 procedure.name
1166 )));
1167 }
1168 Ok(CatalogDelta::ProcedureUpserted(ProcedureEntry::from(
1169 procedure.clone(),
1170 )))
1171 }
1172 CatalogCommand::DropProcedure { name } => {
1173 if !catalog
1174 .procedures
1175 .iter()
1176 .any(|entry| entry.procedure.name == *name)
1177 {
1178 return Err(MongrelError::NotFound(format!(
1179 "procedure {name:?} not found"
1180 )));
1181 }
1182 Ok(CatalogDelta::ProcedureRemoved { name: name.clone() })
1183 }
1184 CatalogCommand::ReplaceTrigger { trigger } => {
1185 trigger.validate()?;
1190 if let TriggerTarget::Table(target) = &trigger.target {
1191 if catalog.live(target).is_none() {
1192 return Err(MongrelError::InvalidArgument(format!(
1193 "trigger {:?} references unknown target table {target:?}",
1194 trigger.name
1195 )));
1196 }
1197 }
1198 Ok(CatalogDelta::TriggerUpserted(TriggerEntry::from(
1199 trigger.clone(),
1200 )))
1201 }
1202 CatalogCommand::ReplaceProcedure { procedure } => {
1203 procedure.validate()?;
1204 for step in &procedure.body.steps {
1205 if let Some(table) = step.table() {
1206 if catalog.live(table).is_none() {
1207 return Err(MongrelError::InvalidArgument(format!(
1208 "procedure {:?} references unknown table {table:?}",
1209 procedure.name
1210 )));
1211 }
1212 }
1213 }
1214 Ok(CatalogDelta::ProcedureUpserted(ProcedureEntry::from(
1215 procedure.clone(),
1216 )))
1217 }
1218 CatalogCommand::CreateMaterializedView { definition } => {
1219 if definition.name.is_empty() || definition.query.trim().is_empty() {
1220 return Err(MongrelError::InvalidArgument(
1221 "materialized view name and query must not be empty".into(),
1222 ));
1223 }
1224 if catalog.live(&definition.name).is_none() {
1225 return Err(MongrelError::NotFound(format!(
1226 "materialized view table {:?} not found",
1227 definition.name
1228 )));
1229 }
1230 Ok(CatalogDelta::MaterializedViewUpserted(definition.clone()))
1231 }
1232 CatalogCommand::DropMaterializedView { name } => {
1233 if !catalog
1234 .materialized_views
1235 .iter()
1236 .any(|definition| definition.name == *name)
1237 {
1238 return Err(MongrelError::NotFound(format!(
1239 "materialized view {name:?} not found"
1240 )));
1241 }
1242 Ok(CatalogDelta::MaterializedViewRemoved { name: name.clone() })
1243 }
1244 CatalogCommand::RefreshMaterializedView {
1245 name,
1246 at_epoch,
1247 checkpoint_event_id,
1248 } => {
1249 let definition = catalog
1250 .materialized_views
1251 .iter()
1252 .find(|definition| definition.name == *name)
1253 .ok_or_else(|| {
1254 MongrelError::NotFound(format!("materialized view {name:?} not found"))
1255 })?;
1256 let mut definition = definition.clone();
1257 definition.last_refresh_epoch = *at_epoch;
1258 if let Some(checkpoint) = checkpoint_event_id {
1259 let plan = definition.incremental.as_mut().ok_or_else(|| {
1260 MongrelError::InvalidArgument(format!(
1261 "materialized view {name:?} has no incremental plan"
1262 ))
1263 })?;
1264 plan.checkpoint_event_id = checkpoint.clone();
1265 }
1266 Ok(CatalogDelta::MaterializedViewUpserted(definition))
1267 }
1268 CatalogCommand::SetResourceGroup { group } => {
1269 if group.name.is_empty() {
1270 return Err(MongrelError::InvalidArgument(
1271 "resource group name must not be empty".into(),
1272 ));
1273 }
1274 Ok(CatalogDelta::ResourceGroupUpserted(group.clone()))
1275 }
1276 CatalogCommand::RemoveResourceGroup { name } => {
1277 if !catalog
1278 .resource_groups
1279 .iter()
1280 .any(|group| group.name == *name)
1281 {
1282 return Err(MongrelError::NotFound(format!(
1283 "resource group {name:?} not found"
1284 )));
1285 }
1286 Ok(CatalogDelta::ResourceGroupRemoved { name: name.clone() })
1287 }
1288 CatalogCommand::SubmitJob { job } => {
1289 if catalog
1290 .job_definitions
1291 .iter()
1292 .any(|existing| existing.job_id == job.job_id)
1293 {
1294 return Err(MongrelError::InvalidArgument(format!(
1295 "job id {} already exists",
1296 job.job_id
1297 )));
1298 }
1299 Ok(CatalogDelta::JobUpserted(job.clone()))
1300 }
1301 CatalogCommand::SetJobState {
1302 job_id,
1303 state,
1304 at_epoch,
1305 } => {
1306 let job = catalog
1307 .job_definitions
1308 .iter()
1309 .find(|job| job.job_id == *job_id)
1310 .ok_or_else(|| MongrelError::NotFound(format!("job id {job_id} not found")))?;
1311 if job.state.is_terminal() {
1312 return Err(MongrelError::InvalidArgument(format!(
1313 "job id {job_id} is in terminal state {:?}",
1314 job.state
1315 )));
1316 }
1317 let mut job = job.clone();
1318 job.state = *state;
1319 job.updated_epoch = *at_epoch;
1320 Ok(CatalogDelta::JobUpserted(job))
1321 }
1322 }
1323}
1324
1325pub fn encode_command(record: &CatalogCommandRecord) -> Result<Vec<u8>> {
1327 serde_json::to_vec(record)
1328 .map_err(|error| MongrelError::Other(format!("catalog command serialize: {error}")))
1329}
1330
1331pub fn decode_command(bytes: &[u8]) -> Result<CatalogCommandRecord> {
1334 let record: CatalogCommandRecord = serde_json::from_slice(bytes)
1335 .map_err(|error| MongrelError::Other(format!("catalog command deserialize: {error}")))?;
1336 if record.version != CATALOG_COMMAND_FORMAT_VERSION {
1337 return Err(MongrelError::UnsupportedStorageVersion {
1338 component: "catalog command",
1339 found: record.version,
1340 supported: CATALOG_COMMAND_FORMAT_VERSION,
1341 });
1342 }
1343 Ok(record)
1344}
1345
1346fn live_entry<'a>(catalog: &'a Catalog, table: &str) -> Result<&'a CatalogEntry> {
1347 catalog
1348 .live(table)
1349 .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))
1350}
1351
1352fn find_user<'a>(catalog: &'a Catalog, username: &str) -> Result<&'a UserEntry> {
1353 catalog
1354 .users
1355 .iter()
1356 .find(|user| user.username == username)
1357 .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))
1358}
1359
1360fn find_role<'a>(catalog: &'a Catalog, name: &str) -> Result<&'a RoleEntry> {
1361 catalog
1362 .roles
1363 .iter()
1364 .find(|role| role.name == name)
1365 .ok_or_else(|| MongrelError::NotFound(format!("role {name:?} not found")))
1366}
1367
1368fn advance_security_version(catalog: &mut Catalog) -> Result<()> {
1372 catalog.security_version = catalog.security_version.checked_add(1).ok_or_else(|| {
1373 MongrelError::Conflict("security catalog version space is exhausted".into())
1374 })?;
1375 Ok(())
1376}
1377
1378fn permission_table(permission: &Permission) -> Option<&str> {
1381 match permission {
1382 Permission::Select { table }
1383 | Permission::Insert { table }
1384 | Permission::Update { table }
1385 | Permission::Delete { table }
1386 | Permission::SelectColumns { table, .. }
1387 | Permission::InsertColumns { table, .. }
1388 | Permission::UpdateColumns { table, .. } => Some(table),
1389 Permission::All | Permission::Ddl | Permission::Admin => None,
1390 }
1391}
1392
1393fn rename_permission_table(permission: &mut Permission, old: &str, new: &str) {
1396 let table = match permission {
1397 Permission::Select { table }
1398 | Permission::Insert { table }
1399 | Permission::Update { table }
1400 | Permission::Delete { table }
1401 | Permission::SelectColumns { table, .. }
1402 | Permission::InsertColumns { table, .. }
1403 | Permission::UpdateColumns { table, .. } => Some(table),
1404 Permission::All | Permission::Ddl | Permission::Admin => None,
1405 };
1406 if let Some(table) = table.filter(|table| table.as_str() == old) {
1407 *table = new.to_string();
1408 }
1409}
1410
1411fn merge_permission(permissions: &mut Vec<Permission>, permission: Permission) {
1414 let (kind, table, mut columns) = match permission {
1415 Permission::SelectColumns { table, columns } => (0, table, columns),
1416 Permission::InsertColumns { table, columns } => (1, table, columns),
1417 Permission::UpdateColumns { table, columns } => (2, table, columns),
1418 permission if !permissions.contains(&permission) => {
1419 permissions.push(permission);
1420 return;
1421 }
1422 _ => return,
1423 };
1424 for permission in permissions.iter_mut() {
1425 let existing = match permission {
1426 Permission::SelectColumns {
1427 table: existing_table,
1428 columns,
1429 } if kind == 0 && existing_table == &table => Some(columns),
1430 Permission::InsertColumns {
1431 table: existing_table,
1432 columns,
1433 } if kind == 1 && existing_table == &table => Some(columns),
1434 Permission::UpdateColumns {
1435 table: existing_table,
1436 columns,
1437 } if kind == 2 && existing_table == &table => Some(columns),
1438 _ => None,
1439 };
1440 if let Some(existing) = existing {
1441 existing.append(&mut columns);
1442 existing.sort();
1443 existing.dedup();
1444 return;
1445 }
1446 }
1447 columns.sort();
1448 columns.dedup();
1449 let permission = if kind == 0 {
1450 Permission::SelectColumns { table, columns }
1451 } else if kind == 1 {
1452 Permission::InsertColumns { table, columns }
1453 } else {
1454 Permission::UpdateColumns { table, columns }
1455 };
1456 permissions.push(permission);
1457}
1458
1459fn revoke_permission_from(permissions: &mut Vec<Permission>, revoked: &Permission) {
1462 let revoked_columns = match revoked {
1463 Permission::SelectColumns { table, columns } => Some((0, table, columns)),
1464 Permission::InsertColumns { table, columns } => Some((1, table, columns)),
1465 Permission::UpdateColumns { table, columns } => Some((2, table, columns)),
1466 _ => None,
1467 };
1468 let Some((kind, table, columns)) = revoked_columns else {
1469 permissions.retain(|permission| permission != revoked);
1470 return;
1471 };
1472 for permission in permissions.iter_mut() {
1473 let current = match permission {
1474 Permission::SelectColumns {
1475 table: current_table,
1476 columns,
1477 } if kind == 0 && current_table == table => Some(columns),
1478 Permission::InsertColumns {
1479 table: current_table,
1480 columns,
1481 } if kind == 1 && current_table == table => Some(columns),
1482 Permission::UpdateColumns {
1483 table: current_table,
1484 columns,
1485 } if kind == 2 && current_table == table => Some(columns),
1486 _ => None,
1487 };
1488 if let Some(current) = current {
1489 current.retain(|column| !columns.contains(column));
1490 }
1491 }
1492 permissions.retain(|permission| match permission {
1493 Permission::SelectColumns { columns, .. }
1494 | Permission::InsertColumns { columns, .. }
1495 | Permission::UpdateColumns { columns, .. } => !columns.is_empty(),
1496 _ => true,
1497 });
1498}
1499
1500fn validate_policy_columns(expression: &SecurityExpr, schema: &Schema) -> Result<()> {
1504 match expression {
1505 SecurityExpr::True => Ok(()),
1506 SecurityExpr::ColumnEqCurrentUser { column }
1507 | SecurityExpr::ColumnEqValue { column, .. } => {
1508 if schema
1509 .columns
1510 .iter()
1511 .any(|candidate| candidate.id == *column)
1512 {
1513 Ok(())
1514 } else {
1515 Err(MongrelError::InvalidArgument(format!(
1516 "security expression references unknown column id {column}"
1517 )))
1518 }
1519 }
1520 SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
1521 validate_policy_columns(left, schema)?;
1522 validate_policy_columns(right, schema)
1523 }
1524 SecurityExpr::Not { expression } => validate_policy_columns(expression, schema),
1525 }
1526}
1527
1528#[cfg(test)]
1529mod tests {
1530 use super::*;
1531 use crate::schema::ColumnFlags;
1532
1533 fn test_schema() -> Schema {
1534 Schema {
1535 schema_id: 0,
1536 columns: vec![
1537 ColumnDef {
1538 id: 1,
1539 name: "id".into(),
1540 ty: TypeId::Int64,
1541 flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
1542 default_value: None,
1543 embedding_source: None,
1544 },
1545 ColumnDef {
1546 id: 2,
1547 name: "secret".into(),
1548 ty: TypeId::Bytes,
1549 flags: ColumnFlags::empty(),
1550 default_value: None,
1551 embedding_source: None,
1552 },
1553 ],
1554 indexes: vec![],
1555 colocation: vec![],
1556 constraints: Default::default(),
1557 clustered: false,
1558 }
1559 }
1560
1561 fn catalog_with_table() -> Catalog {
1562 let mut catalog = Catalog::empty();
1563 let record = CatalogCommandRecord::next(
1564 &catalog,
1565 CatalogCommand::CreateTable {
1566 name: "t".into(),
1567 schema: test_schema(),
1568 created_epoch: 1,
1569 },
1570 );
1571 catalog.apply_command(&record).unwrap();
1572 catalog
1573 }
1574
1575 #[test]
1576 fn create_table_allocates_id_and_stamps_schema() {
1577 let catalog = catalog_with_table();
1578 assert_eq!(catalog.catalog_version(), 1);
1579 let entry = catalog.live("t").unwrap();
1580 assert_eq!(entry.table_id, 0);
1581 assert_eq!(entry.schema.schema_id, 0);
1582 assert_eq!(catalog.next_table_id, 1);
1583 let mut catalog = catalog;
1585 let record = CatalogCommandRecord::next(
1586 &catalog,
1587 CatalogCommand::CreateTable {
1588 name: "u".into(),
1589 schema: test_schema(),
1590 created_epoch: 2,
1591 },
1592 );
1593 catalog.apply_command(&record).unwrap();
1594 assert_eq!(catalog.live("u").unwrap().table_id, 1);
1595 assert_eq!(catalog.live("u").unwrap().schema.schema_id, 1);
1596 }
1597
1598 #[test]
1599 fn duplicate_create_table_fails_closed() {
1600 let catalog = catalog_with_table();
1601 let delta = apply(
1602 &catalog,
1603 &CatalogCommand::CreateTable {
1604 name: "t".into(),
1605 schema: test_schema(),
1606 created_epoch: 9,
1607 },
1608 );
1609 assert!(matches!(delta, Err(MongrelError::InvalidArgument(_))));
1610 }
1611
1612 #[test]
1613 fn version_gap_and_replay_guard() {
1614 let mut catalog = catalog_with_table();
1615 let gap = CatalogCommandRecord {
1617 version: CATALOG_COMMAND_FORMAT_VERSION,
1618 catalog_version: 3,
1619 command: CatalogCommand::DisableRls { table: "t".into() },
1620 };
1621 assert!(matches!(
1622 catalog.apply_command(&gap),
1623 Err(MongrelError::Conflict(_))
1624 ));
1625 let recorded = catalog.commands_since(0)[0].clone();
1627 let delta = catalog.apply_command(&recorded).unwrap();
1628 assert!(matches!(delta, CatalogDelta::NoOp));
1629 assert_eq!(catalog.catalog_version(), 1);
1630 let conflicting = CatalogCommandRecord {
1632 version: CATALOG_COMMAND_FORMAT_VERSION,
1633 catalog_version: 1,
1634 command: CatalogCommand::CreateTable {
1635 name: "other".into(),
1636 schema: test_schema(),
1637 created_epoch: 5,
1638 },
1639 };
1640 assert!(matches!(
1641 catalog.apply_command(&conflicting),
1642 Err(MongrelError::Conflict(_))
1643 ));
1644 }
1645
1646 #[test]
1647 fn unsupported_encoding_version_fails_closed() {
1648 let mut catalog = catalog_with_table();
1649 let record = CatalogCommandRecord {
1650 version: CATALOG_COMMAND_FORMAT_VERSION + 1,
1651 catalog_version: 2,
1652 command: CatalogCommand::DisableRls { table: "t".into() },
1653 };
1654 assert!(matches!(
1655 catalog.apply_command(&record),
1656 Err(MongrelError::UnsupportedStorageVersion { .. })
1657 ));
1658 }
1659
1660 #[test]
1661 fn encode_decode_round_trip() {
1662 let catalog = catalog_with_table();
1663 let record = &catalog.commands_since(0)[0];
1664 let bytes = encode_command(record).unwrap();
1665 let decoded = decode_command(&bytes).unwrap();
1666 assert_eq!(decoded.catalog_version, record.catalog_version);
1667 assert_eq!(decoded.version, CATALOG_COMMAND_FORMAT_VERSION);
1668 assert_eq!(encode_command(&decoded).unwrap(), bytes);
1669 }
1670
1671 #[test]
1672 fn decode_rejects_unknown_version_and_variant() {
1673 let catalog = catalog_with_table();
1674 let bytes = encode_command(&catalog.commands_since(0)[0]).unwrap();
1675 let mut json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1676 json["version"] = serde_json::json!(99);
1677 assert!(matches!(
1678 decode_command(serde_json::to_string(&json).unwrap().as_bytes()),
1679 Err(MongrelError::UnsupportedStorageVersion { .. })
1680 ));
1681 let unknown = br#"{"version":1,"catalog_version":1,"command":{"NotACommand":{}}}"#;
1682 assert!(decode_command(unknown).is_err());
1683 let unknown_field =
1684 br#"{"version":1,"catalog_version":1,"command":{"DisableRls":{"table":"t"}},"x":1}"#;
1685 assert!(decode_command(unknown_field).is_err());
1686 }
1687
1688 #[test]
1689 fn drop_table_cascades_security_and_roles() {
1690 let mut catalog = catalog_with_table();
1691 for command in [
1692 CatalogCommand::EnableRls { table: "t".into() },
1693 CatalogCommand::SetRowPolicy {
1694 policy: RowPolicy {
1695 name: "p".into(),
1696 table: "t".into(),
1697 command: crate::security::PolicyCommand::Select,
1698 subjects: vec![],
1699 permissive: true,
1700 using: Some(SecurityExpr::True),
1701 with_check: None,
1702 },
1703 },
1704 CatalogCommand::SetColumnMask {
1705 mask: ColumnMask {
1706 name: "m".into(),
1707 table: "t".into(),
1708 column: 2,
1709 strategy: MaskStrategy::Sha256,
1710 exempt_subjects: vec![],
1711 },
1712 },
1713 CatalogCommand::CreateRole {
1714 name: "r".into(),
1715 created_epoch: 1,
1716 },
1717 CatalogCommand::GrantPermission {
1718 role: "r".into(),
1719 permission: Permission::Select { table: "t".into() },
1720 },
1721 ] {
1722 let record = CatalogCommandRecord::next(&catalog, command);
1723 catalog.apply_command(&record).unwrap();
1724 }
1725 let security_before = catalog.security.clone();
1726 assert!(security_before.rls_enabled("t"));
1727 let record = CatalogCommandRecord::next(
1728 &catalog,
1729 CatalogCommand::DropTable {
1730 name: "t".into(),
1731 at_epoch: 7,
1732 },
1733 );
1734 catalog.apply_command(&record).unwrap();
1735 assert!(catalog.live("t").is_none());
1736 assert!(catalog.security.policies.is_empty());
1737 assert!(catalog.security.masks.is_empty());
1738 assert!(!catalog.security.rls_enabled("t"));
1739 assert!(catalog.roles[0].permissions.is_empty());
1740 assert!(matches!(
1741 catalog.tables[0].state,
1742 TableState::Dropped { at_epoch: 7 }
1743 ));
1744 }
1745
1746 #[test]
1747 fn grant_and_revoke_permission_merge_columns() {
1748 let mut catalog = catalog_with_table();
1749 let commands = [
1750 CatalogCommand::CreateRole {
1751 name: "r".into(),
1752 created_epoch: 1,
1753 },
1754 CatalogCommand::GrantPermission {
1755 role: "r".into(),
1756 permission: Permission::SelectColumns {
1757 table: "t".into(),
1758 columns: vec!["id".into()],
1759 },
1760 },
1761 CatalogCommand::GrantPermission {
1762 role: "r".into(),
1763 permission: Permission::SelectColumns {
1764 table: "t".into(),
1765 columns: vec!["secret".into(), "id".into()],
1766 },
1767 },
1768 ];
1769 for command in commands {
1770 let record = CatalogCommandRecord::next(&catalog, command);
1771 catalog.apply_command(&record).unwrap();
1772 }
1773 assert_eq!(
1774 catalog.roles[0].permissions,
1775 vec![Permission::SelectColumns {
1776 table: "t".into(),
1777 columns: vec!["id".into(), "secret".into()],
1778 }]
1779 );
1780 let record = CatalogCommandRecord::next(
1781 &catalog,
1782 CatalogCommand::RevokePermission {
1783 role: "r".into(),
1784 permission: Permission::SelectColumns {
1785 table: "t".into(),
1786 columns: vec!["id".into()],
1787 },
1788 },
1789 );
1790 catalog.apply_command(&record).unwrap();
1791 assert_eq!(
1792 catalog.roles[0].permissions,
1793 vec![Permission::SelectColumns {
1794 table: "t".into(),
1795 columns: vec!["secret".into()],
1796 }]
1797 );
1798 }
1799
1800 #[test]
1801 fn job_state_terminal_guard() {
1802 let mut catalog = Catalog::empty();
1803 let submit = CatalogCommandRecord::next(
1804 &catalog,
1805 CatalogCommand::SubmitJob {
1806 job: JobDefinition {
1807 job_id: 1,
1808 kind: JobKind::IndexBuild,
1809 state: JobState::Pending,
1810 target: Some("t".into()),
1811 created_epoch: 1,
1812 updated_epoch: 1,
1813 },
1814 },
1815 );
1816 catalog.apply_command(&submit).unwrap();
1817 let run = CatalogCommandRecord::next(
1818 &catalog,
1819 CatalogCommand::SetJobState {
1820 job_id: 1,
1821 state: JobState::Succeeded,
1822 at_epoch: 2,
1823 },
1824 );
1825 catalog.apply_command(&run).unwrap();
1826 let late = CatalogCommandRecord::next(
1827 &catalog,
1828 CatalogCommand::SetJobState {
1829 job_id: 1,
1830 state: JobState::Running,
1831 at_epoch: 3,
1832 },
1833 );
1834 assert!(matches!(
1835 catalog.apply_command(&late),
1836 Err(MongrelError::InvalidArgument(_))
1837 ));
1838 }
1839
1840 #[test]
1841 fn history_is_bounded() {
1842 let mut catalog = Catalog::empty();
1843 for index in 0..(COMMAND_HISTORY_LIMIT + 10) {
1844 let record = CatalogCommandRecord::next(
1845 &catalog,
1846 CatalogCommand::SubmitJob {
1847 job: JobDefinition {
1848 job_id: index as u64 + 1,
1849 kind: JobKind::SchemaValidation,
1850 state: JobState::Pending,
1851 target: None,
1852 created_epoch: 0,
1853 updated_epoch: 0,
1854 },
1855 },
1856 );
1857 catalog.apply_command(&record).unwrap();
1858 }
1859 assert_eq!(catalog.command_log.len(), COMMAND_HISTORY_LIMIT);
1860 let retained = catalog.commands_since(0);
1861 assert_eq!(retained.len(), COMMAND_HISTORY_LIMIT);
1862 let oldest = retained[0].catalog_version;
1864 assert_eq!(
1865 oldest,
1866 catalog.catalog_version() - COMMAND_HISTORY_LIMIT as u64 + 1
1867 );
1868 let compacted = CatalogCommandRecord {
1870 version: CATALOG_COMMAND_FORMAT_VERSION,
1871 catalog_version: 1,
1872 command: CatalogCommand::SubmitJob {
1873 job: JobDefinition {
1874 job_id: 999_999,
1875 kind: JobKind::KeyRotation,
1876 state: JobState::Pending,
1877 target: None,
1878 created_epoch: 0,
1879 updated_epoch: 0,
1880 },
1881 },
1882 };
1883 let delta = catalog.apply_command(&compacted).unwrap();
1884 assert!(matches!(delta, CatalogDelta::NoOp));
1885 assert!(catalog
1886 .job_definitions
1887 .iter()
1888 .all(|job| job.job_id != 999_999));
1889 }
1890
1891 #[test]
1892 fn required_permission_matches_legacy_gates() {
1893 assert_eq!(
1894 required_permission(&CatalogCommand::CreateTable {
1895 name: "t".into(),
1896 schema: test_schema(),
1897 created_epoch: 0,
1898 }),
1899 Permission::Ddl
1900 );
1901 assert_eq!(
1902 required_permission(&CatalogCommand::EnableRls { table: "t".into() }),
1903 Permission::Admin
1904 );
1905 assert_eq!(
1906 required_permission(&CatalogCommand::DropUser {
1907 username: "u".into()
1908 }),
1909 Permission::Admin
1910 );
1911 assert_eq!(
1912 required_permission(&CatalogCommand::DropTrigger { name: "t".into() }),
1913 Permission::Ddl
1914 );
1915 }
1916
1917 fn replacement_trigger(name: &str) -> StoredTrigger {
1918 StoredTrigger::new(
1919 name,
1920 crate::trigger::TriggerDefinition {
1921 target: TriggerTarget::Table("t".into()),
1922 timing: crate::trigger::TriggerTiming::After,
1923 event: crate::trigger::TriggerEvent::Insert,
1924 update_of: Vec::new(),
1925 target_columns: Vec::new(),
1926 when: None,
1927 program: crate::trigger::TriggerProgram { steps: Vec::new() },
1928 },
1929 0,
1930 )
1931 .unwrap()
1932 }
1933
1934 fn replacement_procedure(name: &str) -> StoredProcedure {
1935 StoredProcedure::new(
1936 name,
1937 crate::procedure::ProcedureMode::ReadOnly,
1938 Vec::new(),
1939 crate::procedure::ProcedureBody {
1940 steps: Vec::new(),
1941 return_value: crate::procedure::ProcedureValue::Literal(
1942 crate::memtable::Value::Null,
1943 ),
1944 },
1945 0,
1946 )
1947 .unwrap()
1948 }
1949
1950 #[test]
1951 fn alter_column_delta_bumps_schema_id_like_the_engine() {
1952 let mut catalog = catalog_with_table();
1953 let mut column = catalog.live("t").unwrap().schema.columns[1].clone();
1954 column.flags = column.flags.with(crate::schema::ColumnFlags::NULLABLE);
1955 let record = CatalogCommandRecord::next(
1956 &catalog,
1957 CatalogCommand::AlterColumn {
1958 table: "t".into(),
1959 column: column.clone(),
1960 },
1961 );
1962 let delta = catalog.apply_command(&record).unwrap();
1963 let CatalogDelta::SchemaReplaced { table_id, schema } = delta else {
1966 panic!("expected SchemaReplaced");
1967 };
1968 assert_eq!(table_id, 0);
1969 assert_eq!(schema.schema_id, 1);
1970 assert_eq!(schema.columns[1], column);
1971 assert_eq!(catalog.live("t").unwrap().schema.schema_id, 1);
1972 let mut unknown = column;
1974 unknown.id = 99;
1975 assert!(matches!(
1976 apply(
1977 &catalog,
1978 &CatalogCommand::AlterColumn {
1979 table: "t".into(),
1980 column: unknown,
1981 },
1982 ),
1983 Err(MongrelError::ColumnNotFound(_))
1984 ));
1985 }
1986
1987 #[test]
1988 fn replace_trigger_upserts_and_validates_references() {
1989 let mut catalog = catalog_with_table();
1990 let record = CatalogCommandRecord::next(
1992 &catalog,
1993 CatalogCommand::ReplaceTrigger {
1994 trigger: replacement_trigger("trg"),
1995 },
1996 );
1997 let delta = catalog.apply_command(&record).unwrap();
1998 assert!(matches!(delta, CatalogDelta::TriggerUpserted(_)));
1999 assert_eq!(catalog.triggers.len(), 1);
2000 let mut resolved = replacement_trigger("trg");
2003 resolved.version = 2;
2004 resolved.updated_epoch = 7;
2005 let record = CatalogCommandRecord::next(
2006 &catalog,
2007 CatalogCommand::ReplaceTrigger {
2008 trigger: resolved.clone(),
2009 },
2010 );
2011 catalog.apply_command(&record).unwrap();
2012 assert_eq!(catalog.triggers.len(), 1);
2013 assert_eq!(catalog.triggers[0].trigger.version, 2);
2014 assert_eq!(catalog.triggers[0].trigger.updated_epoch, 7);
2015 let mut dangling = replacement_trigger("trg2");
2017 dangling.target = TriggerTarget::Table("missing".into());
2018 assert!(matches!(
2019 apply(
2020 &catalog,
2021 &CatalogCommand::ReplaceTrigger { trigger: dangling }
2022 ),
2023 Err(MongrelError::InvalidArgument(_))
2024 ));
2025 assert_eq!(
2026 required_permission(&CatalogCommand::ReplaceTrigger {
2027 trigger: replacement_trigger("trg"),
2028 }),
2029 Permission::Ddl
2030 );
2031 }
2032
2033 #[test]
2034 fn replace_procedure_upserts_and_validates_references() {
2035 let mut catalog = catalog_with_table();
2036 let record = CatalogCommandRecord::next(
2037 &catalog,
2038 CatalogCommand::ReplaceProcedure {
2039 procedure: replacement_procedure("proc"),
2040 },
2041 );
2042 let delta = catalog.apply_command(&record).unwrap();
2043 assert!(matches!(delta, CatalogDelta::ProcedureUpserted(_)));
2044 assert_eq!(catalog.procedures.len(), 1);
2045 let mut resolved = replacement_procedure("proc");
2048 resolved.version = 3;
2049 resolved.updated_epoch = 9;
2050 let record = CatalogCommandRecord::next(
2051 &catalog,
2052 CatalogCommand::ReplaceProcedure {
2053 procedure: resolved.clone(),
2054 },
2055 );
2056 catalog.apply_command(&record).unwrap();
2057 assert_eq!(catalog.procedures.len(), 1);
2058 assert_eq!(catalog.procedures[0].procedure.version, 3);
2059 assert_eq!(catalog.procedures[0].procedure.updated_epoch, 9);
2060 assert_eq!(
2061 required_permission(&CatalogCommand::ReplaceProcedure {
2062 procedure: replacement_procedure("proc"),
2063 }),
2064 Permission::Ddl
2065 );
2066 }
2067
2068 #[test]
2069 fn serde_appended_variants_decode_older_records() {
2070 let legacy = br#"{"version":1,"catalog_version":1,"command":{"DropTrigger":{"name":"t"}}}"#;
2073 let record = decode_command(legacy).unwrap();
2074 assert!(matches!(
2075 record.command,
2076 CatalogCommand::DropTrigger { ref name } if name == "t"
2077 ));
2078 let record = CatalogCommandRecord {
2080 version: CATALOG_COMMAND_FORMAT_VERSION,
2081 catalog_version: 2,
2082 command: CatalogCommand::ReplaceTrigger {
2083 trigger: replacement_trigger("trg"),
2084 },
2085 };
2086 let bytes = encode_command(&record).unwrap();
2087 let decoded = decode_command(&bytes).unwrap();
2088 assert_eq!(encode_command(&decoded).unwrap(), bytes);
2089 }
2090}