492 lines
14 KiB
Python
492 lines
14 KiB
Python
#!/usr/bin/env python
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
DDL 生成器:canonical 字段类型 → 多方言 CREATE TABLE / SCHEMA / INDEX / COMMENT
|
|
|
|
与前端 web/apps/web-ele/src/utils/database-types.ts 的 mapToDbType 规则对齐。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from core.database_manager.sql_utils import quote_identifier, quote_table
|
|
|
|
# 表单工作流系统字段(设计节点与 DDL 生成共用)
|
|
SYSTEM_FIELDS: List[Dict[str, Any]] = [
|
|
{
|
|
'name': 'id',
|
|
'type': 'varchar',
|
|
'maxLength': 36,
|
|
'comment': '主键ID',
|
|
'nullable': False,
|
|
'isPrimaryKey': True,
|
|
},
|
|
{
|
|
'name': 'sys_create_datetime',
|
|
'type': 'datetime',
|
|
'comment': '创建时间',
|
|
'nullable': True,
|
|
'isPrimaryKey': False,
|
|
},
|
|
{
|
|
'name': 'sys_update_datetime',
|
|
'type': 'datetime',
|
|
'comment': '更新时间',
|
|
'nullable': True,
|
|
'isPrimaryKey': False,
|
|
},
|
|
{
|
|
'name': 'sys_creator_id',
|
|
'type': 'varchar',
|
|
'maxLength': 36,
|
|
'comment': '创建人ID',
|
|
'nullable': True,
|
|
'isPrimaryKey': False,
|
|
},
|
|
{
|
|
'name': 'sys_modifier_id',
|
|
'type': 'varchar',
|
|
'maxLength': 36,
|
|
'comment': '修改人ID',
|
|
'nullable': True,
|
|
'isPrimaryKey': False,
|
|
},
|
|
{
|
|
'name': 'sys_dept_id',
|
|
'type': 'varchar',
|
|
'maxLength': 36,
|
|
'comment': '部门ID',
|
|
'nullable': True,
|
|
'isPrimaryKey': False,
|
|
},
|
|
{
|
|
'name': 'is_deleted',
|
|
'type': 'boolean',
|
|
'comment': '是否删除',
|
|
'nullable': False,
|
|
'isPrimaryKey': False,
|
|
},
|
|
{
|
|
'name': 'sort',
|
|
'type': 'int',
|
|
'comment': '排序',
|
|
'nullable': False,
|
|
'isPrimaryKey': False,
|
|
},
|
|
]
|
|
|
|
SYSTEM_INDEX_FIELDS = frozenset({
|
|
'sys_update_datetime',
|
|
'sys_create_datetime',
|
|
'sys_creator_id',
|
|
'sys_dept_id',
|
|
'is_deleted',
|
|
})
|
|
|
|
CANONICAL_TYPE_MAPPING: Dict[str, str] = {
|
|
'string': 'varchar',
|
|
'str': 'varchar',
|
|
'varchar': 'varchar',
|
|
'char': 'char',
|
|
'text': 'text',
|
|
'int': 'int',
|
|
'integer': 'int',
|
|
'bigint': 'bigint',
|
|
'smallint': 'smallint',
|
|
'decimal': 'decimal',
|
|
'numeric': 'decimal',
|
|
'float': 'float',
|
|
'double': 'double',
|
|
'datetime': 'datetime',
|
|
'timestamp': 'datetime',
|
|
'date': 'date',
|
|
'time': 'time',
|
|
'boolean': 'boolean',
|
|
'bool': 'boolean',
|
|
'json': 'json',
|
|
'jsonb': 'jsonb',
|
|
}
|
|
|
|
COMMON_TYPE_TO_DB_TYPE: Dict[str, Dict[str, str]] = {
|
|
'postgresql': {
|
|
'int': 'INTEGER',
|
|
'bigint': 'BIGINT',
|
|
'smallint': 'SMALLINT',
|
|
'float': 'REAL',
|
|
'double': 'DOUBLE PRECISION',
|
|
'datetime': 'TIMESTAMP',
|
|
'boolean': 'BOOLEAN',
|
|
'json': 'JSON',
|
|
'jsonb': 'JSONB',
|
|
'varchar': 'VARCHAR',
|
|
'char': 'CHAR',
|
|
'text': 'TEXT',
|
|
'decimal': 'DECIMAL',
|
|
'numeric': 'NUMERIC',
|
|
'date': 'DATE',
|
|
'time': 'TIME',
|
|
},
|
|
'mysql': {
|
|
'int': 'INT',
|
|
'bigint': 'BIGINT',
|
|
'smallint': 'SMALLINT',
|
|
'float': 'FLOAT',
|
|
'double': 'DOUBLE',
|
|
'datetime': 'DATETIME',
|
|
'boolean': 'TINYINT(1)',
|
|
'json': 'JSON',
|
|
'jsonb': 'JSON',
|
|
'varchar': 'VARCHAR',
|
|
'char': 'CHAR',
|
|
'text': 'TEXT',
|
|
'decimal': 'DECIMAL',
|
|
'numeric': 'DECIMAL',
|
|
'date': 'DATE',
|
|
'time': 'TIME',
|
|
},
|
|
'sqlserver': {
|
|
'int': 'INT',
|
|
'bigint': 'BIGINT',
|
|
'smallint': 'SMALLINT',
|
|
'float': 'FLOAT',
|
|
'double': 'FLOAT',
|
|
'datetime': 'DATETIME2',
|
|
'boolean': 'BIT',
|
|
'json': 'NVARCHAR(MAX)',
|
|
'jsonb': 'NVARCHAR(MAX)',
|
|
'varchar': 'NVARCHAR',
|
|
'char': 'NCHAR',
|
|
'text': 'NVARCHAR(MAX)',
|
|
'decimal': 'DECIMAL',
|
|
'numeric': 'NUMERIC',
|
|
'date': 'DATE',
|
|
'time': 'TIME',
|
|
},
|
|
'oracle': {
|
|
'int': 'NUMBER',
|
|
'bigint': 'NUMBER',
|
|
'smallint': 'NUMBER',
|
|
'float': 'BINARY_FLOAT',
|
|
'double': 'BINARY_DOUBLE',
|
|
'datetime': 'TIMESTAMP',
|
|
'boolean': 'NUMBER(1)',
|
|
'json': 'CLOB',
|
|
'jsonb': 'CLOB',
|
|
'varchar': 'VARCHAR2',
|
|
'char': 'CHAR',
|
|
'text': 'CLOB',
|
|
'decimal': 'NUMBER',
|
|
'numeric': 'NUMBER',
|
|
'date': 'DATE',
|
|
'time': 'TIMESTAMP',
|
|
},
|
|
}
|
|
|
|
|
|
def normalize_db_type(db_type: str) -> str:
|
|
db = (db_type or 'postgresql').lower().strip()
|
|
if db in ('mssql', 'sql server'):
|
|
return 'sqlserver'
|
|
if db in ('postgres', 'psql'):
|
|
return 'postgresql'
|
|
return db
|
|
|
|
|
|
def normalize_canonical_type(field_type: str) -> str:
|
|
if not field_type:
|
|
return 'varchar'
|
|
return CANONICAL_TYPE_MAPPING.get(field_type.lower().strip(), field_type.lower().strip())
|
|
|
|
|
|
def process_canonical_field(field: dict) -> dict:
|
|
"""标准化单个 canonical 字段配置(设计节点使用)。"""
|
|
field_name = field.get('name', '')
|
|
standard_type = normalize_canonical_type(field.get('type', 'varchar'))
|
|
|
|
processed: Dict[str, Any] = {
|
|
'name': field_name,
|
|
'type': standard_type,
|
|
'comment': field.get('comment', field_name),
|
|
'nullable': field.get('nullable', True),
|
|
'isPrimaryKey': field.get('isPrimaryKey', False),
|
|
}
|
|
|
|
if standard_type in ('varchar', 'char'):
|
|
processed['maxLength'] = field.get('maxLength', 255)
|
|
elif standard_type in ('decimal', 'numeric'):
|
|
processed['precision'] = field.get('precision', 10)
|
|
processed['scale'] = field.get('scale', 2)
|
|
|
|
return processed
|
|
|
|
|
|
def map_to_dialect_type(field: dict, db_type: str) -> str:
|
|
"""将 canonical 字段映射为带长度/精度的方言类型 SQL 片段。"""
|
|
db = normalize_db_type(db_type)
|
|
field_type = normalize_canonical_type(field.get('type', 'varchar'))
|
|
type_map = COMMON_TYPE_TO_DB_TYPE.get(db, COMMON_TYPE_TO_DB_TYPE['postgresql'])
|
|
base_type = type_map.get(field_type, field_type.upper())
|
|
|
|
if field_type == 'varchar' and base_type in ('VARCHAR', 'NVARCHAR', 'VARCHAR2'):
|
|
length = field.get('maxLength', 255)
|
|
return f'{base_type}({length})'
|
|
if field_type == 'char' and base_type in ('CHAR', 'NCHAR'):
|
|
length = field.get('maxLength', 10)
|
|
return f'{base_type}({length})'
|
|
if field_type in ('decimal', 'numeric') and base_type in ('DECIMAL', 'NUMERIC', 'NUMBER'):
|
|
precision = field.get('precision', 10)
|
|
scale = field.get('scale', 2)
|
|
if db == 'oracle':
|
|
return f'NUMBER({precision}, {scale})'
|
|
return f'{base_type}({precision}, {scale})'
|
|
|
|
return base_type
|
|
|
|
|
|
def quote_table_name(table_name: str, schema: str, db_type: str) -> str:
|
|
db = normalize_db_type(db_type)
|
|
if schema and db in ('postgresql', 'sqlserver', 'oracle'):
|
|
return quote_table(schema, table_name, db)
|
|
return quote_identifier(table_name, db)
|
|
|
|
|
|
def _escape_sql_literal(value: str) -> str:
|
|
return (value or '').replace("'", "''")
|
|
|
|
|
|
@dataclass
|
|
class CreateTableDdlResult:
|
|
create_sql: str = ''
|
|
comment_sqls: List[str] = field(default_factory=list)
|
|
skipped: bool = False
|
|
|
|
|
|
def build_create_schema_sql(schema: str, db_type: str) -> str:
|
|
db = normalize_db_type(db_type)
|
|
if not schema:
|
|
return ''
|
|
|
|
if db == 'postgresql':
|
|
quoted = quote_identifier(schema, db)
|
|
return f'CREATE SCHEMA IF NOT EXISTS {quoted};'
|
|
|
|
if db == 'sqlserver':
|
|
safe_schema = schema.replace("'", "''")
|
|
return f"""
|
|
IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = '{safe_schema}')
|
|
BEGIN
|
|
EXEC('CREATE SCHEMA [{schema}]')
|
|
END;
|
|
""".strip()
|
|
|
|
return ''
|
|
|
|
|
|
def _generate_field_definition(field: dict, db_type: str) -> str:
|
|
field_name = field.get('name', '')
|
|
if not field_name:
|
|
return ''
|
|
|
|
db = normalize_db_type(db_type)
|
|
field_type = normalize_canonical_type(field.get('type', 'varchar'))
|
|
nullable = field.get('nullable', True)
|
|
is_primary = field.get('isPrimaryKey', False)
|
|
|
|
quoted_name = quote_identifier(field_name, db)
|
|
sql_type = map_to_dialect_type(field, db)
|
|
parts = [quoted_name, sql_type]
|
|
|
|
if is_primary:
|
|
parts.append('PRIMARY KEY')
|
|
|
|
if not nullable and not is_primary:
|
|
parts.append('NOT NULL')
|
|
|
|
if field_type == 'boolean' and not nullable:
|
|
if db == 'postgresql':
|
|
parts.append('DEFAULT FALSE')
|
|
else:
|
|
parts.append('DEFAULT 0')
|
|
|
|
if field_name == 'sort' and field_type in ('int', 'integer', 'bigint', 'smallint'):
|
|
parts.append('DEFAULT 0')
|
|
|
|
return ' '.join(parts)
|
|
|
|
|
|
def _generate_create_index_sql(
|
|
idx_name: str,
|
|
full_table_name: str,
|
|
field_name: str,
|
|
table_name: str,
|
|
db_type: str,
|
|
) -> str:
|
|
db = normalize_db_type(db_type)
|
|
quoted_idx = quote_identifier(idx_name, db)
|
|
quoted_field = quote_identifier(field_name, db)
|
|
|
|
if db == 'postgresql':
|
|
return f'CREATE INDEX IF NOT EXISTS {quoted_idx} ON {full_table_name} ({quoted_field});'
|
|
|
|
if db == 'mysql':
|
|
safe_table = table_name.replace("'", "''")
|
|
safe_idx = idx_name.replace("'", "''")
|
|
return (
|
|
f"SET @exist := (SELECT COUNT(*) FROM information_schema.statistics "
|
|
f"WHERE table_schema=DATABASE() AND table_name='{safe_table}' "
|
|
f"AND index_name='{safe_idx}');\n"
|
|
f"SET @sqlstmt := IF(@exist > 0, 'SELECT ''index exists''', "
|
|
f"'CREATE INDEX `{idx_name}` ON {full_table_name} (`{field_name}`)');\n"
|
|
f'PREPARE stmt FROM @sqlstmt;\nEXECUTE stmt;\nDEALLOCATE PREPARE stmt;'
|
|
)
|
|
|
|
if db == 'oracle':
|
|
return f'CREATE INDEX {quoted_idx} ON {full_table_name} ({quoted_field});'
|
|
|
|
if db == 'sqlserver':
|
|
safe_idx = idx_name.replace("'", "''")
|
|
return (
|
|
f"IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = '{safe_idx}')\n"
|
|
f'CREATE INDEX {quoted_idx} ON {full_table_name} ({quoted_field});'
|
|
)
|
|
|
|
return ''
|
|
|
|
|
|
def _build_column_comments_sql(
|
|
fields: List[dict],
|
|
full_table_name: str,
|
|
db_type: str,
|
|
) -> List[str]:
|
|
db = normalize_db_type(db_type)
|
|
if db not in ('postgresql', 'oracle'):
|
|
return []
|
|
|
|
comment_sqls: List[str] = []
|
|
for fld in fields:
|
|
comment = fld.get('comment', '')
|
|
field_name = fld.get('name', '')
|
|
if not comment or not field_name:
|
|
continue
|
|
safe_comment = _escape_sql_literal(comment)
|
|
quoted_col = quote_identifier(field_name, db)
|
|
comment_sqls.append(
|
|
f"COMMENT ON COLUMN {full_table_name}.{quoted_col} IS '{safe_comment}';"
|
|
)
|
|
return comment_sqls
|
|
|
|
|
|
def _wrap_sqlserver_create_if_not_exists(
|
|
schema: str,
|
|
table_name: str,
|
|
inner_sql: str,
|
|
) -> str:
|
|
schema_part = schema.replace("'", "''")
|
|
table_part = table_name.replace("'", "''")
|
|
return f"""
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM sys.tables t
|
|
INNER JOIN sys.schemas s ON t.schema_id = s.schema_id
|
|
WHERE s.name = '{schema_part}' AND t.name = '{table_part}'
|
|
)
|
|
BEGIN
|
|
{inner_sql}
|
|
END;
|
|
""".strip()
|
|
|
|
|
|
def _wrap_oracle_create_if_not_exists(table_name: str, inner_sql: str) -> str:
|
|
safe_table = table_name.upper().replace("'", "''")
|
|
single_line = ' '.join(inner_sql.split())
|
|
escaped_sql = single_line.replace("'", "''")
|
|
return f"""
|
|
DECLARE
|
|
v_count NUMBER;
|
|
BEGIN
|
|
SELECT COUNT(*) INTO v_count FROM user_tables WHERE table_name = '{safe_table}';
|
|
IF v_count = 0 THEN
|
|
EXECUTE IMMEDIATE '{escaped_sql}';
|
|
END IF;
|
|
END;
|
|
""".strip()
|
|
|
|
|
|
def build_create_table_ddl(
|
|
table: dict,
|
|
*,
|
|
db_type: str,
|
|
if_exists: str = 'skip',
|
|
effective_schema: str = '',
|
|
) -> CreateTableDdlResult:
|
|
"""
|
|
根据 canonical 表定义生成 CREATE TABLE DDL。
|
|
|
|
Args:
|
|
table: 含 tableName、fields、meta
|
|
db_type: postgresql / mysql / sqlserver / oracle
|
|
if_exists: skip | error | replace
|
|
effective_schema: 优先使用的 schema
|
|
"""
|
|
db = normalize_db_type(db_type)
|
|
table_name = table.get('tableName', '')
|
|
fields = table.get('fields', [])
|
|
meta = table.get('meta', {})
|
|
schema = effective_schema or meta.get('schema', '')
|
|
|
|
if not table_name or not fields:
|
|
raise ValueError(f'表 {table_name or "(unknown)"} 缺少必要配置')
|
|
|
|
full_table_name = quote_table_name(table_name, schema, db)
|
|
sql_parts: List[str] = []
|
|
|
|
if if_exists == 'replace':
|
|
if db == 'postgresql':
|
|
sql_parts.append(f'DROP TABLE IF EXISTS {full_table_name} CASCADE;')
|
|
elif db == 'mysql':
|
|
sql_parts.append(f'DROP TABLE IF EXISTS {full_table_name};')
|
|
elif db == 'sqlserver':
|
|
sql_parts.append(
|
|
f'IF OBJECT_ID(N\'{full_table_name.replace("[", "").replace("]", "")}\', N\'U\') IS NOT NULL '
|
|
f'DROP TABLE {full_table_name};'
|
|
)
|
|
elif db == 'oracle':
|
|
sql_parts.append(f'BEGIN EXECUTE IMMEDIATE \'DROP TABLE {full_table_name}\'; EXCEPTION WHEN OTHERS THEN NULL; END;')
|
|
|
|
if db in ('postgresql', 'mysql'):
|
|
create_clause = 'CREATE TABLE IF NOT EXISTS' if if_exists == 'skip' else 'CREATE TABLE'
|
|
else:
|
|
create_clause = 'CREATE TABLE'
|
|
|
|
field_defs = []
|
|
for fld in fields:
|
|
field_def = _generate_field_definition(fld, db)
|
|
if field_def:
|
|
field_defs.append(f' {field_def}')
|
|
|
|
create_body = f'{create_clause} {full_table_name} (\n' + ',\n'.join(field_defs) + '\n);'
|
|
|
|
if db == 'sqlserver' and if_exists == 'skip' and schema:
|
|
create_body = _wrap_sqlserver_create_if_not_exists(schema, table_name, create_body)
|
|
elif db == 'oracle' and if_exists == 'skip':
|
|
create_body = _wrap_oracle_create_if_not_exists(table_name, create_body)
|
|
|
|
sql_parts.append(create_body)
|
|
|
|
field_names = {f.get('name', '') for f in fields}
|
|
for field_name in sorted(field_names & SYSTEM_INDEX_FIELDS):
|
|
idx_name = f'idx_{table_name}_{field_name}'
|
|
index_sql = _generate_create_index_sql(
|
|
idx_name, full_table_name, field_name, table_name, db,
|
|
)
|
|
if index_sql:
|
|
sql_parts.append(index_sql)
|
|
|
|
comment_sqls = _build_column_comments_sql(fields, full_table_name, db)
|
|
|
|
return CreateTableDdlResult(
|
|
create_sql='\n'.join(sql_parts),
|
|
comment_sqls=comment_sqls,
|
|
skipped=False,
|
|
)
|