#!/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, )