mirror of
https://github.com/chartdb/chartdb.git
synced 2025-12-21 11:30:42 -06:00
fix: preserve multi-word types in DBML export/import (#956)
* fix: preserve multi-word types in DBML export/import * fix
This commit is contained in:
@@ -338,7 +338,13 @@ export const exportBaseSQL = ({
|
|||||||
|
|
||||||
const quotedFieldName = getQuotedFieldName(field.name, isDBMLFlow);
|
const quotedFieldName = getQuotedFieldName(field.name, isDBMLFlow);
|
||||||
|
|
||||||
sqlScript += ` ${quotedFieldName} ${typeName}`;
|
// Quote multi-word type names for DBML flow to prevent @dbml/core parser issues
|
||||||
|
const quotedTypeName =
|
||||||
|
isDBMLFlow && typeName.includes(' ')
|
||||||
|
? `"${typeName}"`
|
||||||
|
: typeName;
|
||||||
|
|
||||||
|
sqlScript += ` ${quotedFieldName} ${quotedTypeName}`;
|
||||||
|
|
||||||
// Add size for character types
|
// Add size for character types
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
Table "public"."guy_table" {
|
Table "public"."guy_table" {
|
||||||
"id" integer [pk, not null]
|
"id" integer [pk, not null]
|
||||||
"created_at" timestamp [not null]
|
"created_at" "timestamp without time zone" [not null]
|
||||||
"column3" text
|
"column3" text
|
||||||
"arrayfield" text[]
|
"arrayfield" text[]
|
||||||
"field_5" "character varying"
|
"field_5" "character varying"
|
||||||
|
|||||||
205
src/lib/dbml/dbml-export/__tests__/empty-tables.test.ts
Normal file
205
src/lib/dbml/dbml-export/__tests__/empty-tables.test.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { generateDBMLFromDiagram } from '../dbml-export';
|
||||||
|
import { DatabaseType } from '@/lib/domain/database-type';
|
||||||
|
import type { Diagram } from '@/lib/domain/diagram';
|
||||||
|
import { generateId, generateDiagramId } from '@/lib/utils';
|
||||||
|
|
||||||
|
describe('DBML Export - Empty Tables', () => {
|
||||||
|
it('should filter out tables with no fields', () => {
|
||||||
|
const diagram: Diagram = {
|
||||||
|
id: generateDiagramId(),
|
||||||
|
name: 'Test Diagram',
|
||||||
|
databaseType: DatabaseType.POSTGRESQL,
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'valid_table',
|
||||||
|
schema: 'public',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'id',
|
||||||
|
type: { id: 'integer', name: 'integer' },
|
||||||
|
primaryKey: true,
|
||||||
|
unique: true,
|
||||||
|
nullable: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [],
|
||||||
|
color: '#8eb7ff',
|
||||||
|
isView: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'empty_table',
|
||||||
|
schema: 'public',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
fields: [], // Empty fields array
|
||||||
|
indexes: [],
|
||||||
|
color: '#8eb7ff',
|
||||||
|
isView: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'another_valid_table',
|
||||||
|
schema: 'public',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'name',
|
||||||
|
type: { id: 'varchar', name: 'varchar' },
|
||||||
|
primaryKey: false,
|
||||||
|
unique: false,
|
||||||
|
nullable: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [],
|
||||||
|
color: '#8eb7ff',
|
||||||
|
isView: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
relationships: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = generateDBMLFromDiagram(diagram);
|
||||||
|
|
||||||
|
// Verify the DBML doesn't contain the empty table
|
||||||
|
expect(result.inlineDbml).not.toContain('empty_table');
|
||||||
|
expect(result.standardDbml).not.toContain('empty_table');
|
||||||
|
|
||||||
|
// Verify the valid tables are still present
|
||||||
|
expect(result.inlineDbml).toContain('valid_table');
|
||||||
|
expect(result.inlineDbml).toContain('another_valid_table');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle diagram with only empty tables', () => {
|
||||||
|
const diagram: Diagram = {
|
||||||
|
id: generateDiagramId(),
|
||||||
|
name: 'Test Diagram',
|
||||||
|
databaseType: DatabaseType.POSTGRESQL,
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'empty_table_1',
|
||||||
|
schema: 'public',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
fields: [],
|
||||||
|
indexes: [],
|
||||||
|
color: '#8eb7ff',
|
||||||
|
isView: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'empty_table_2',
|
||||||
|
schema: 'public',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
fields: [],
|
||||||
|
indexes: [],
|
||||||
|
color: '#8eb7ff',
|
||||||
|
isView: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
relationships: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = generateDBMLFromDiagram(diagram);
|
||||||
|
|
||||||
|
// Should not error and should return empty DBML (or just enums if any)
|
||||||
|
expect(result.inlineDbml).toBeTruthy();
|
||||||
|
expect(result.standardDbml).toBeTruthy();
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out table that becomes empty after removing invalid fields', () => {
|
||||||
|
const diagram: Diagram = {
|
||||||
|
id: generateDiagramId(),
|
||||||
|
name: 'Test Diagram',
|
||||||
|
databaseType: DatabaseType.POSTGRESQL,
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'table_with_only_empty_field_names',
|
||||||
|
schema: 'public',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: '', // Empty field name - will be filtered
|
||||||
|
type: { id: 'integer', name: 'integer' },
|
||||||
|
primaryKey: false,
|
||||||
|
unique: false,
|
||||||
|
nullable: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: '', // Empty field name - will be filtered
|
||||||
|
type: { id: 'varchar', name: 'varchar' },
|
||||||
|
primaryKey: false,
|
||||||
|
unique: false,
|
||||||
|
nullable: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [],
|
||||||
|
color: '#8eb7ff',
|
||||||
|
isView: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'valid_table',
|
||||||
|
schema: 'public',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'id',
|
||||||
|
type: { id: 'integer', name: 'integer' },
|
||||||
|
primaryKey: true,
|
||||||
|
unique: true,
|
||||||
|
nullable: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [],
|
||||||
|
color: '#8eb7ff',
|
||||||
|
isView: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
relationships: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = generateDBMLFromDiagram(diagram);
|
||||||
|
|
||||||
|
// Table with only empty field names should be filtered out
|
||||||
|
expect(result.inlineDbml).not.toContain(
|
||||||
|
'table_with_only_empty_field_names'
|
||||||
|
);
|
||||||
|
// Valid table should remain
|
||||||
|
expect(result.inlineDbml).toContain('valid_table');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { generateDBMLFromDiagram } from '../dbml-export';
|
||||||
|
import { importDBMLToDiagram } from '../../dbml-import/dbml-import';
|
||||||
|
import { DatabaseType } from '@/lib/domain/database-type';
|
||||||
|
import type { Diagram } from '@/lib/domain/diagram';
|
||||||
|
import { generateId, generateDiagramId } from '@/lib/utils';
|
||||||
|
|
||||||
|
describe('DBML Export - Timestamp with Time Zone', () => {
|
||||||
|
it('should preserve "timestamp with time zone" type through export and reimport', async () => {
|
||||||
|
// Create a diagram with timestamp with time zone field
|
||||||
|
const diagram: Diagram = {
|
||||||
|
id: generateDiagramId(),
|
||||||
|
name: 'Test Diagram',
|
||||||
|
databaseType: DatabaseType.POSTGRESQL,
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'events',
|
||||||
|
schema: 'public',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'id',
|
||||||
|
type: { id: 'integer', name: 'integer' },
|
||||||
|
primaryKey: true,
|
||||||
|
unique: true,
|
||||||
|
nullable: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'created_at',
|
||||||
|
type: {
|
||||||
|
id: 'timestamp_with_time_zone',
|
||||||
|
name: 'timestamp with time zone',
|
||||||
|
},
|
||||||
|
primaryKey: false,
|
||||||
|
unique: false,
|
||||||
|
nullable: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'updated_at',
|
||||||
|
type: {
|
||||||
|
id: 'timestamp_without_time_zone',
|
||||||
|
name: 'timestamp without time zone',
|
||||||
|
},
|
||||||
|
primaryKey: false,
|
||||||
|
unique: false,
|
||||||
|
nullable: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [],
|
||||||
|
color: '#8eb7ff',
|
||||||
|
isView: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
relationships: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export to DBML
|
||||||
|
const exportResult = generateDBMLFromDiagram(diagram);
|
||||||
|
|
||||||
|
// Verify the DBML contains quoted multi-word types
|
||||||
|
expect(exportResult.inlineDbml).toContain('"timestamp with time zone"');
|
||||||
|
expect(exportResult.inlineDbml).toContain(
|
||||||
|
'"timestamp without time zone"'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reimport the DBML
|
||||||
|
const reimportedDiagram = await importDBMLToDiagram(
|
||||||
|
exportResult.inlineDbml,
|
||||||
|
{
|
||||||
|
databaseType: DatabaseType.POSTGRESQL,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the types are preserved
|
||||||
|
const table = reimportedDiagram.tables?.find(
|
||||||
|
(t) => t.name === 'events'
|
||||||
|
);
|
||||||
|
expect(table).toBeDefined();
|
||||||
|
|
||||||
|
const createdAtField = table?.fields.find(
|
||||||
|
(f) => f.name === 'created_at'
|
||||||
|
);
|
||||||
|
const updatedAtField = table?.fields.find(
|
||||||
|
(f) => f.name === 'updated_at'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(createdAtField?.type.name).toBe('timestamp with time zone');
|
||||||
|
expect(updatedAtField?.type.name).toBe('timestamp without time zone');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle time with time zone types', async () => {
|
||||||
|
const diagram: Diagram = {
|
||||||
|
id: generateDiagramId(),
|
||||||
|
name: 'Test Diagram',
|
||||||
|
databaseType: DatabaseType.POSTGRESQL,
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'schedules',
|
||||||
|
schema: 'public',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'id',
|
||||||
|
type: { id: 'integer', name: 'integer' },
|
||||||
|
primaryKey: true,
|
||||||
|
unique: true,
|
||||||
|
nullable: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'start_time',
|
||||||
|
type: {
|
||||||
|
id: 'time_with_time_zone',
|
||||||
|
name: 'time with time zone',
|
||||||
|
},
|
||||||
|
primaryKey: false,
|
||||||
|
unique: false,
|
||||||
|
nullable: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'end_time',
|
||||||
|
type: {
|
||||||
|
id: 'time_without_time_zone',
|
||||||
|
name: 'time without time zone',
|
||||||
|
},
|
||||||
|
primaryKey: false,
|
||||||
|
unique: false,
|
||||||
|
nullable: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [],
|
||||||
|
color: '#8eb7ff',
|
||||||
|
isView: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
relationships: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportResult = generateDBMLFromDiagram(diagram);
|
||||||
|
|
||||||
|
expect(exportResult.inlineDbml).toContain('"time with time zone"');
|
||||||
|
expect(exportResult.inlineDbml).toContain('"time without time zone"');
|
||||||
|
|
||||||
|
const reimportedDiagram = await importDBMLToDiagram(
|
||||||
|
exportResult.inlineDbml,
|
||||||
|
{
|
||||||
|
databaseType: DatabaseType.POSTGRESQL,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = reimportedDiagram.tables?.find(
|
||||||
|
(t) => t.name === 'schedules'
|
||||||
|
);
|
||||||
|
const startTimeField = table?.fields.find(
|
||||||
|
(f) => f.name === 'start_time'
|
||||||
|
);
|
||||||
|
const endTimeField = table?.fields.find((f) => f.name === 'end_time');
|
||||||
|
|
||||||
|
expect(startTimeField?.type.name).toBe('time with time zone');
|
||||||
|
expect(endTimeField?.type.name).toBe('time without time zone');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle double precision type', async () => {
|
||||||
|
const diagram: Diagram = {
|
||||||
|
id: generateDiagramId(),
|
||||||
|
name: 'Test Diagram',
|
||||||
|
databaseType: DatabaseType.POSTGRESQL,
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'measurements',
|
||||||
|
schema: 'public',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'id',
|
||||||
|
type: { id: 'integer', name: 'integer' },
|
||||||
|
primaryKey: true,
|
||||||
|
unique: true,
|
||||||
|
nullable: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'value',
|
||||||
|
type: {
|
||||||
|
id: 'double_precision',
|
||||||
|
name: 'double precision',
|
||||||
|
},
|
||||||
|
primaryKey: false,
|
||||||
|
unique: false,
|
||||||
|
nullable: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indexes: [],
|
||||||
|
color: '#8eb7ff',
|
||||||
|
isView: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
relationships: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportResult = generateDBMLFromDiagram(diagram);
|
||||||
|
|
||||||
|
expect(exportResult.inlineDbml).toContain('"double precision"');
|
||||||
|
|
||||||
|
const reimportedDiagram = await importDBMLToDiagram(
|
||||||
|
exportResult.inlineDbml,
|
||||||
|
{
|
||||||
|
databaseType: DatabaseType.POSTGRESQL,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = reimportedDiagram.tables?.find(
|
||||||
|
(t) => t.name === 'measurements'
|
||||||
|
);
|
||||||
|
const valueField = table?.fields.find((f) => f.name === 'value');
|
||||||
|
|
||||||
|
expect(valueField?.type.name).toBe('double precision');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -807,31 +807,37 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
|
|||||||
};
|
};
|
||||||
}) ?? [];
|
}) ?? [];
|
||||||
|
|
||||||
// Remove duplicate tables (consider both schema and table name)
|
// Filter out empty tables and duplicates in a single pass for performance
|
||||||
const seenTableIdentifiers = new Set<string>();
|
const seenTableIdentifiers = new Set<string>();
|
||||||
const uniqueTables = sanitizedTables.filter((table) => {
|
const tablesWithFields = sanitizedTables.filter((table) => {
|
||||||
|
// Skip tables with no fields (empty tables cause DBML export to fail)
|
||||||
|
if (table.fields.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Create a unique identifier combining schema and table name
|
// Create a unique identifier combining schema and table name
|
||||||
const tableIdentifier = table.schema
|
const tableIdentifier = table.schema
|
||||||
? `${table.schema}.${table.name}`
|
? `${table.schema}.${table.name}`
|
||||||
: table.name;
|
: table.name;
|
||||||
|
|
||||||
|
// Skip duplicate tables
|
||||||
if (seenTableIdentifiers.has(tableIdentifier)) {
|
if (seenTableIdentifiers.has(tableIdentifier)) {
|
||||||
return false; // Skip duplicate
|
return false;
|
||||||
}
|
}
|
||||||
seenTableIdentifiers.add(tableIdentifier);
|
seenTableIdentifiers.add(tableIdentifier);
|
||||||
return true; // Keep unique table
|
return true; // Keep unique, non-empty table
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create the base filtered diagram structure
|
// Create the base filtered diagram structure
|
||||||
const filteredDiagram: Diagram = {
|
const filteredDiagram: Diagram = {
|
||||||
...diagram,
|
...diagram,
|
||||||
tables: uniqueTables,
|
tables: tablesWithFields,
|
||||||
relationships:
|
relationships:
|
||||||
diagram.relationships?.filter((rel) => {
|
diagram.relationships?.filter((rel) => {
|
||||||
const sourceTable = uniqueTables.find(
|
const sourceTable = tablesWithFields.find(
|
||||||
(t) => t.id === rel.sourceTableId
|
(t) => t.id === rel.sourceTableId
|
||||||
);
|
);
|
||||||
const targetTable = uniqueTables.find(
|
const targetTable = tablesWithFields.find(
|
||||||
(t) => t.id === rel.targetTableId
|
(t) => t.id === rel.targetTableId
|
||||||
);
|
);
|
||||||
const sourceFieldExists = sourceTable?.fields.some(
|
const sourceFieldExists = sourceTable?.fields.some(
|
||||||
@@ -931,13 +937,13 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Restore schema information that may have been stripped by DBML importer
|
// Restore schema information that may have been stripped by DBML importer
|
||||||
standard = restoreTableSchemas(standard, uniqueTables);
|
standard = restoreTableSchemas(standard, tablesWithFields);
|
||||||
|
|
||||||
// Restore composite primary key names
|
// Restore composite primary key names
|
||||||
standard = restoreCompositePKNames(standard, uniqueTables);
|
standard = restoreCompositePKNames(standard, tablesWithFields);
|
||||||
|
|
||||||
// Restore increment attribute for auto-incrementing fields
|
// Restore increment attribute for auto-incrementing fields
|
||||||
standard = restoreIncrementAttribute(standard, uniqueTables);
|
standard = restoreIncrementAttribute(standard, tablesWithFields);
|
||||||
|
|
||||||
// Prepend Enum DBML to the standard output
|
// Prepend Enum DBML to the standard output
|
||||||
if (enumsDBML) {
|
if (enumsDBML) {
|
||||||
|
|||||||
Reference in New Issue
Block a user