mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
fix(sqlserver): edit triggers with events and disabled state
This commit is contained in:
@@ -1022,7 +1022,7 @@ const filteredIndexRowIds = computed(() => {
|
||||
});
|
||||
const indexSearchMatchCount = computed(() => (indexSearchText.value.trim() ? filteredIndexRowIds.value.size : 0));
|
||||
const foreignKeyActionOptions = ["", "CASCADE", "SET NULL", "RESTRICT", "NO ACTION"];
|
||||
const triggerTimingOptions = ["BEFORE", "AFTER"];
|
||||
const triggerTimingOptions = computed(() => (databaseType.value === "sqlserver" ? ["AFTER", "INSTEAD OF"] : ["BEFORE", "AFTER"]));
|
||||
const triggerEventOptions = ["INSERT", "UPDATE", "DELETE"];
|
||||
const metadataSchema = computed(() => connectionObjectTreeQuerySchema(connection.value, props.database, props.schema));
|
||||
const refreshVersion = computed(() => (props.connectionId && props.tableName ? queryStore.tableStructureRefreshVersion(props.connectionId, props.database, props.schema, props.tableName) : 0));
|
||||
@@ -3269,8 +3269,9 @@ function canDropIndex(index: EditableStructureIndex): boolean {
|
||||
}
|
||||
|
||||
const canEditForeignKeys = computed(() => structureCapabilities.value.foreignKey);
|
||||
const canEditTriggers = computed(() => structureDialect.value === "mysql" || structureDialect.value === "oracle");
|
||||
const canEditTriggers = computed(() => structureDialect.value === "mysql" || structureDialect.value === "oracle" || structureDialect.value === "sqlserver");
|
||||
const isOracleTriggerEditor = computed(() => structureDialect.value === "oracle");
|
||||
const isSqlServerTriggerEditor = computed(() => structureDialect.value === "sqlserver");
|
||||
|
||||
function generatedForeignKeyName(column = ""): string {
|
||||
const table = structureIndexTableName() || "table";
|
||||
@@ -3323,9 +3324,9 @@ function addTrigger() {
|
||||
triggers.value.push({
|
||||
id: `new:${uuid()}`,
|
||||
name: "",
|
||||
timing: isOracleTriggerEditor.value ? "BEFORE EACH ROW" : "BEFORE",
|
||||
timing: isOracleTriggerEditor.value ? "BEFORE EACH ROW" : isSqlServerTriggerEditor.value ? "AFTER" : "BEFORE",
|
||||
event: "INSERT",
|
||||
statement: isOracleTriggerEditor.value ? "BEGIN\n NULL;\nEND" : "BEGIN\n \nEND",
|
||||
statement: isOracleTriggerEditor.value ? "BEGIN\n NULL;\nEND" : isSqlServerTriggerEditor.value ? "BEGIN\n SET NOCOUNT ON;\nEND" : "BEGIN\n \nEND",
|
||||
markedForDrop: false,
|
||||
});
|
||||
}
|
||||
@@ -4725,7 +4726,7 @@ watch([activeTab, loading, ddlLoading, ddlContent], ([tab, structureIsLoading, d
|
||||
<SelectItem v-for="timing in triggerTimingOptions" :key="timing" :value="timing">{{ timing }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input v-if="isOracleTriggerEditor" v-model="trigger.event" :class="structureControlClass" :disabled="!canEditTriggerDraft(trigger)" />
|
||||
<Input v-if="isOracleTriggerEditor || isSqlServerTriggerEditor" v-model="trigger.event" :class="structureControlClass" :disabled="!canEditTriggerDraft(trigger)" />
|
||||
<Select v-else v-model="trigger.event" :disabled="!canEditTriggerDraft(trigger)">
|
||||
<SelectTrigger class="h-[var(--structure-control-height)] rounded-[6px] px-[var(--structure-control-px)] text-[length:var(--structure-font-size)] focus-visible:border-ring/50 focus-visible:ring-1 focus-visible:ring-ring/25">
|
||||
<SelectValue />
|
||||
@@ -4735,6 +4736,9 @@ watch([activeTab, loading, ddlLoading, ddlContent], ([tab, structureIsLoading, d
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Badge v-if="trigger.original && trigger.original.enabled !== undefined && trigger.original.enabled !== null" variant="outline" class="shrink-0 text-[length:var(--structure-font-size)]">
|
||||
{{ trigger.original.enabled ? t("damengJobAdmin.enabled") : t("damengJobAdmin.disabled") }}
|
||||
</Badge>
|
||||
<Button v-if="trigger.original" variant="ghost" size="sm" :class="structureToolbarButtonClass" @click="toggleDropTrigger(trigger)">
|
||||
<Trash2 :class="structureIconClass" />
|
||||
{{ trigger.markedForDrop ? t("structureEditor.restore") : t("structureEditor.drop") }}
|
||||
|
||||
@@ -51,6 +51,7 @@ describe("tableStructureEditorState", () => {
|
||||
expect(canEditStructuredTriggerDraft("oracle", existing)).toBe(false);
|
||||
expect(canEditStructuredTriggerDraft(undefined, existing)).toBe(false);
|
||||
expect(canEditStructuredTriggerDraft("mysql", existing)).toBe(true);
|
||||
expect(canEditStructuredTriggerDraft("sqlserver", existing)).toBe(true);
|
||||
expect(
|
||||
canEditStructuredTriggerDraft("oracle", {
|
||||
id: "new:trigger",
|
||||
|
||||
@@ -2849,7 +2849,7 @@ pub async fn list_triggers(
|
||||
level: None,
|
||||
condition: None,
|
||||
language: None,
|
||||
enabled: None,
|
||||
enabled: row.get::<bool, _>(4),
|
||||
valid: None,
|
||||
comment: None,
|
||||
created_at: None,
|
||||
@@ -2860,10 +2860,16 @@ pub async fn list_triggers(
|
||||
|
||||
fn sqlserver_triggers_sql(schema: &str, table: &str) -> String {
|
||||
format!(
|
||||
"SELECT t.name, te.type_desc, CASE WHEN t.is_instead_of_trigger = 1 THEN 'INSTEAD OF' ELSE 'AFTER' END, \
|
||||
OBJECT_DEFINITION(t.object_id) \
|
||||
"SELECT t.name, \
|
||||
STUFF((SELECT ', ' + te2.type_desc \
|
||||
FROM sys.trigger_events te2 \
|
||||
WHERE te2.object_id = t.object_id \
|
||||
ORDER BY te2.type_desc \
|
||||
FOR XML PATH(''), TYPE).value('.', 'NVARCHAR(MAX)'), 1, 2, ''), \
|
||||
CASE WHEN t.is_instead_of_trigger = 1 THEN 'INSTEAD OF' ELSE 'AFTER' END, \
|
||||
OBJECT_DEFINITION(t.object_id), \
|
||||
CASE WHEN t.is_disabled = 1 THEN CAST(0 AS bit) ELSE CAST(1 AS bit) END \
|
||||
FROM sys.triggers t \
|
||||
JOIN sys.trigger_events te ON t.object_id = te.object_id \
|
||||
WHERE t.parent_id = OBJECT_ID('{s}.{t}') \
|
||||
ORDER BY t.name",
|
||||
s = schema.replace('\'', "''"),
|
||||
@@ -4116,7 +4122,9 @@ mod tests {
|
||||
let sql = sqlserver_triggers_sql("d'bo", "t'able");
|
||||
|
||||
assert!(sql.contains("OBJECT_DEFINITION(t.object_id)"));
|
||||
assert!(sql.contains("JOIN sys.trigger_events te ON t.object_id = te.object_id"));
|
||||
assert!(sql.contains("STUFF((SELECT ', ' + te2.type_desc"));
|
||||
assert!(sql.contains("FOR XML PATH(''), TYPE"));
|
||||
assert!(sql.contains("t.is_disabled"));
|
||||
assert!(sql.contains("OBJECT_ID('d''bo.t''able')"));
|
||||
assert!(sql.contains("ORDER BY t.name"));
|
||||
assert!(!sql.contains("STRING_AGG"));
|
||||
|
||||
@@ -4922,6 +4922,7 @@ fn builds_mysql_trigger_changes() {
|
||||
event: "UPDATE".to_string(),
|
||||
timing: "BEFORE".to_string(),
|
||||
statement: Some("SET NEW.updated_at = CURRENT_TIMESTAMP".to_string()),
|
||||
enabled: None,
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
@@ -4949,6 +4950,109 @@ fn builds_mysql_trigger_changes() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_sqlserver_trigger_with_multiple_events() {
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
schema: Some("dbo".to_string()),
|
||||
table_name: "orders".to_string(),
|
||||
columns: Vec::new(),
|
||||
indexes: Vec::new(),
|
||||
foreign_keys: Vec::new(),
|
||||
triggers: vec![trigger("orders_audit", "AFTER", "INSERT, UPDATE", "BEGIN\n SET NOCOUNT ON;\nEND")],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
mysql_engine: None,
|
||||
partitioned: false,
|
||||
is_gaussdb_m_mode: false,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec![
|
||||
"CREATE TRIGGER [dbo].[orders_audit] ON [dbo].[orders] AFTER INSERT, UPDATE AS\nBEGIN\n SET NOCOUNT ON;\nEND;"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuilds_changed_sqlserver_trigger_from_complete_metadata_source() {
|
||||
let mut existing = trigger(
|
||||
"orders_audit",
|
||||
"AFTER",
|
||||
"INSERT, UPDATE",
|
||||
"CREATE TRIGGER dbo.orders_audit ON dbo.orders AFTER INSERT, UPDATE AS BEGIN SET NOCOUNT ON; INSERT INTO audit_log VALUES (1); END",
|
||||
);
|
||||
existing.original = Some(TriggerInfo {
|
||||
name: "orders_audit".to_string(),
|
||||
event: "INSERT, UPDATE".to_string(),
|
||||
timing: "AFTER".to_string(),
|
||||
statement: Some(
|
||||
"CREATE TRIGGER dbo.orders_audit ON dbo.orders AFTER INSERT, UPDATE AS BEGIN SET NOCOUNT ON; INSERT INTO audit_log VALUES (0); END"
|
||||
.to_string(),
|
||||
),
|
||||
enabled: None,
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
schema: Some("dbo".to_string()),
|
||||
table_name: "orders".to_string(),
|
||||
columns: Vec::new(),
|
||||
indexes: Vec::new(),
|
||||
foreign_keys: Vec::new(),
|
||||
triggers: vec![existing],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
mysql_engine: None,
|
||||
partitioned: false,
|
||||
is_gaussdb_m_mode: false,
|
||||
});
|
||||
|
||||
assert_eq!(result.warnings, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
result.statements,
|
||||
vec![
|
||||
"DROP TRIGGER [dbo].[orders_audit];",
|
||||
"CREATE TRIGGER [dbo].[orders_audit] ON [dbo].[orders] AFTER INSERT, UPDATE AS\nBEGIN SET NOCOUNT ON; INSERT INTO audit_log VALUES (1); END;"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlserver_trigger_edit_restores_disabled_state() {
|
||||
let mut existing =
|
||||
trigger("orders_audit", "AFTER", "INSERT", "BEGIN SET NOCOUNT ON; INSERT INTO audit_log VALUES (1); END");
|
||||
existing.original = Some(TriggerInfo {
|
||||
name: "orders_audit".to_string(),
|
||||
event: "INSERT".to_string(),
|
||||
timing: "AFTER".to_string(),
|
||||
statement: Some("CREATE TRIGGER dbo.orders_audit ON dbo.orders AFTER INSERT AS PRINT 'old'".to_string()),
|
||||
enabled: Some(false),
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
database_type: Some(DatabaseType::SqlServer),
|
||||
schema: Some("dbo".to_string()),
|
||||
table_name: "orders".to_string(),
|
||||
columns: Vec::new(),
|
||||
indexes: Vec::new(),
|
||||
foreign_keys: Vec::new(),
|
||||
triggers: vec![existing],
|
||||
table_comment: None,
|
||||
original_table_comment: None,
|
||||
partitioned: false,
|
||||
mysql_engine: None,
|
||||
is_gaussdb_m_mode: false,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
result.statements.last().map(String::as_str),
|
||||
Some("DISABLE TRIGGER [dbo].[orders_audit] ON [dbo].[orders];")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_postgres_trigger_does_not_block_column_rename() {
|
||||
let mut renamed = column("display_name");
|
||||
@@ -4968,6 +5072,7 @@ fn unchanged_postgres_trigger_does_not_block_column_rename() {
|
||||
event: "UPDATE".to_string(),
|
||||
timing: "AFTER".to_string(),
|
||||
statement: Some("EXECUTE FUNCTION audit_users()".to_string()),
|
||||
enabled: None,
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
@@ -4997,6 +5102,7 @@ fn changed_postgres_trigger_remains_unsupported() {
|
||||
event: "UPDATE".to_string(),
|
||||
timing: "AFTER".to_string(),
|
||||
statement: Some("EXECUTE FUNCTION audit_users()".to_string()),
|
||||
enabled: None,
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
@@ -5031,6 +5137,7 @@ fn rejects_editing_existing_oracle_trigger_without_complete_source() {
|
||||
event: "INSERT OR UPDATE OR DELETE".to_string(),
|
||||
timing: "AFTER EACH ROW".to_string(),
|
||||
statement: Some("BEGIN\n NULL;\nEND;".to_string()),
|
||||
enabled: None,
|
||||
});
|
||||
|
||||
let result = build_table_structure_change_sql(TableStructureSqlOptions {
|
||||
@@ -5089,6 +5196,7 @@ fn drops_existing_oracle_trigger_without_reconstructing_it() {
|
||||
event: "INSERT".to_string(),
|
||||
timing: "AFTER EACH ROW".to_string(),
|
||||
statement: Some("BEGIN\n NULL;\nEND;".to_string()),
|
||||
enabled: None,
|
||||
});
|
||||
existing.marked_for_drop = true;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ pub(super) fn build_trigger_sql(options: &TableStructureSqlOptions, warnings: &m
|
||||
|
||||
let dialect = super::dialect::capabilities_for(options.database_type).dialect;
|
||||
let database_label = database_label(options.database_type);
|
||||
if !matches!(dialect, StructureDialect::Mysql | StructureDialect::Oracle) {
|
||||
if !matches!(dialect, StructureDialect::Mysql | StructureDialect::Oracle | StructureDialect::SqlServer) {
|
||||
if options.triggers.iter().any(has_trigger_edit) {
|
||||
warnings.push(format!("Editing triggers is not supported for {database_label} from this editor."));
|
||||
}
|
||||
@@ -48,12 +48,30 @@ pub(super) fn build_trigger_sql(options: &TableStructureSqlOptions, warnings: &m
|
||||
|
||||
if let Some(sql) = create_trigger_sql(dialect, options.schema.as_deref(), &table, trigger, warnings) {
|
||||
statements.push(sql);
|
||||
// SQL Server rebuilds via DROP + CREATE, which resets is_disabled to
|
||||
// enabled; restore the catalog-reported disabled state explicitly.
|
||||
// `table` is already qualified the same way the CREATE's ON clause is.
|
||||
if dialect == StructureDialect::SqlServer
|
||||
&& trigger.original.as_ref().is_some_and(|original| original.enabled == Some(false))
|
||||
{
|
||||
let schema = options.schema.as_deref();
|
||||
let trigger_name = qualified_trigger_object_name(dialect, schema, &trigger.name);
|
||||
statements.push(format!("DISABLE TRIGGER {trigger_name} ON {table};"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
fn qualified_trigger_object_name(dialect: StructureDialect, schema: Option<&str>, name: &str) -> String {
|
||||
if schema.is_some_and(|schema| !schema.trim().is_empty()) {
|
||||
format!("{}.{}", quote_ident(dialect, schema.unwrap()), quote_ident(dialect, name))
|
||||
} else {
|
||||
quote_ident(dialect, name)
|
||||
}
|
||||
}
|
||||
|
||||
fn has_trigger_edit(trigger: &EditableStructureTrigger) -> bool {
|
||||
trigger.marked_for_drop || trigger.original.as_ref().is_none_or(|original| has_trigger_change(trigger, original))
|
||||
}
|
||||
@@ -92,6 +110,9 @@ fn create_trigger_sql(
|
||||
}
|
||||
match dialect {
|
||||
StructureDialect::Mysql => create_mysql_trigger_sql(table, &name, &timing, &event, &statement, warnings),
|
||||
StructureDialect::SqlServer => {
|
||||
create_sqlserver_trigger_sql(schema, table, &name, &timing, &event, &statement, warnings)
|
||||
}
|
||||
StructureDialect::Oracle => {
|
||||
create_oracle_trigger_sql(schema, table, &name, &timing, &event, &statement, warnings)
|
||||
}
|
||||
@@ -99,6 +120,99 @@ fn create_trigger_sql(
|
||||
}
|
||||
}
|
||||
|
||||
fn create_sqlserver_trigger_sql(
|
||||
schema: Option<&str>,
|
||||
table: &str,
|
||||
name: &str,
|
||||
timing: &str,
|
||||
event: &str,
|
||||
statement: &str,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Option<String> {
|
||||
if !matches!(timing, "AFTER" | "INSTEAD OF") {
|
||||
warnings.push(format!("Unsupported SQL Server trigger timing \"{timing}\"."));
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut events = Vec::new();
|
||||
for item in event.split([',', '\n']) {
|
||||
let item = item.trim();
|
||||
if item.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let normalized_item = item.to_ascii_uppercase();
|
||||
for event in normalized_item.split(" OR ") {
|
||||
let event = event.trim().to_string();
|
||||
if !matches!(event.as_str(), "INSERT" | "UPDATE" | "DELETE") {
|
||||
warnings.push(format!("Unsupported SQL Server trigger event \"{}\".", item));
|
||||
return None;
|
||||
}
|
||||
if !events.contains(&event) {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
if events.is_empty() {
|
||||
warnings.push("SQL Server trigger event is required.".to_string());
|
||||
return None;
|
||||
}
|
||||
|
||||
let trigger_name = if schema.is_some_and(|schema| !schema.trim().is_empty()) {
|
||||
format!(
|
||||
"{}.{}",
|
||||
quote_ident(StructureDialect::SqlServer, schema.unwrap()),
|
||||
quote_ident(StructureDialect::SqlServer, name)
|
||||
)
|
||||
} else {
|
||||
quote_ident(StructureDialect::SqlServer, name)
|
||||
};
|
||||
let body = sqlserver_trigger_body(statement);
|
||||
if body.is_empty() {
|
||||
warnings.push("SQL Server trigger statement is required.".to_string());
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"CREATE TRIGGER {trigger_name} ON {table} {timing} {} AS\n{};",
|
||||
events.join(", "),
|
||||
body.trim_end_matches(';').trim_end()
|
||||
))
|
||||
}
|
||||
|
||||
fn sqlserver_trigger_body(statement: &str) -> &str {
|
||||
let statement = statement.trim();
|
||||
if statement.get(..2).is_some_and(|prefix| prefix.eq_ignore_ascii_case("AS")) {
|
||||
return statement[2..].trim_start();
|
||||
}
|
||||
if !statement.to_ascii_uppercase().starts_with("CREATE TRIGGER") {
|
||||
return statement;
|
||||
}
|
||||
|
||||
// The body separator is the standalone AS that follows the event list
|
||||
// (`... AFTER INSERT, UPDATE AS <body>`); earlier AS tokens can appear in
|
||||
// trigger options such as `WITH EXECUTE AS OWNER`, so the first standalone
|
||||
// AS alone is not a reliable split point.
|
||||
let mut seen_event = false;
|
||||
let mut token = String::new();
|
||||
let mut char_indices = statement.char_indices().peekable();
|
||||
while let Some((index, ch)) = char_indices.next() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' {
|
||||
token.push(ch);
|
||||
if !char_indices.peek().is_some_and(|(_, next)| next.is_ascii_alphanumeric() || *next == '_') {
|
||||
if token.eq_ignore_ascii_case("AS") && seen_event {
|
||||
return statement[index + 1..].trim_start();
|
||||
}
|
||||
if matches!(token.to_ascii_uppercase().as_str(), "INSERT" | "UPDATE" | "DELETE" | "LOGON") {
|
||||
seen_event = true;
|
||||
}
|
||||
token.clear();
|
||||
}
|
||||
} else {
|
||||
token.clear();
|
||||
}
|
||||
}
|
||||
statement
|
||||
}
|
||||
|
||||
fn create_mysql_trigger_sql(
|
||||
table: &str,
|
||||
name: &str,
|
||||
@@ -191,3 +305,19 @@ fn normalize_keyword(value: &str) -> String {
|
||||
fn normalize_statement(value: &str) -> String {
|
||||
clean(value).trim_end_matches(';').trim().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sqlserver_trigger_body_splits_after_event_list_not_execute_as() {
|
||||
// The AS inside `WITH EXECUTE AS OWNER` must not be treated as the body separator.
|
||||
let source = "CREATE TRIGGER dbo.t ON dbo.tbl WITH EXECUTE AS OWNER AFTER INSERT AS PRINT 'x'";
|
||||
assert_eq!(sqlserver_trigger_body(source), "PRINT 'x'");
|
||||
let no_options = "CREATE TRIGGER dbo.t ON dbo.tbl AFTER UPDATE AS SELECT 1 AS a";
|
||||
assert_eq!(sqlserver_trigger_body(no_options), "SELECT 1 AS a");
|
||||
let leading_as = "AS SELECT 2";
|
||||
assert_eq!(sqlserver_trigger_body(leading_as), "SELECT 2");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,10 @@ pub struct TriggerInfo {
|
||||
pub timing: String,
|
||||
#[serde(default)]
|
||||
pub statement: Option<String>,
|
||||
/// Carries the catalog-reported enabled state so SQL Server edits can
|
||||
/// restore it after the DROP + CREATE rebuild (`DISABLE TRIGGER`).
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
Reference in New Issue
Block a user