Files
2026-06-08 18:14:59 +08:00

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,
)