#!/usr/bin/env python3 """建表脚本:从各模块 models/*.json 经 json2ddl 生成 DDL,幂等落库。 铁律(2026-09-07 用户定夺): 1. DDL 一律由 json2ddl 从 models/*.json 生成——models JSON 是表结构唯一事实源, 禁止手写 CREATE TABLE(历史手写段已全部移除,含 pipeline-llm mysql.ddl.sql 依赖)。 2. 全程幂等:CREATE TABLE IF NOT EXISTS,**绝不 DROP**——2026-09-07 14:08 事故: 原 TABLE_MODULES 走 DROP 重建,一次部署清空 pipeline 库 product/pricing/discount/sms 全部数据(13 个模型定价丢失、27 个产品映射丢失、全平台记账 failed)。 3. 存量表 schema 演进靠「通用列同步器」:解析 DDL 的期望列 vs information_schema 实际列, 缺列 ALTER TABLE ADD COLUMN 补齐。加字段只改 models JSON,部署自动补列。 4. 索引幂等:按 (表, 列集合, 唯一性) 判重后 CREATE(不按索引名——手写时代的旧索引名 与 json2ddl 生成名不同,按名判重会重复建同字段索引)。 用法: py3/bin/python scripts/create_tables.py """ import sys, os, re, json, asyncio, subprocess, glob SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) ROOT_DIR = os.path.dirname(SCRIPT_DIR) sys.path.insert(0, os.path.join(ROOT_DIR, 'py3', 'lib', 'python3.10', 'site-packages')) sys.path.insert(0, ROOT_DIR) from sqlor.dbpools import DBPools from appPublic.jsonConfig import getConfig from appPublic.folderUtils import ProgramPath from ahserver.serverenv import ServerEnv from ahserver.globalEnv import initEnv # 不建表的模块(无 models/ 或表由宿主/其他机制管理) SKIP_MODULES = set() # appbase params 默认参数(数据种子,非 schema;params 表结构走 models JSON) _PARAMS_INIT = [ ("workspace_base", "/d/pipeline/workspaces"), ("task_max_retry", "3"), ("register_open", "1"), ] # sd_iterations 存量数据修复(数据层,非 schema;seq_no 列由通用列同步器补) _DATA_FIXUPS = [ # 「当前迭代」改为显式 status='in_progress',迭代按 seq_no 顺序编号推进 ("sd_iterations seq_no 回填", "UPDATE sd_iterations t JOIN (" "SELECT id, ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY created_at ASC, id ASC) AS rn " "FROM sd_iterations WHERE seq_no = 0) x ON x.id = t.id " "SET t.seq_no = x.rn"), ("sd_iterations 脏状态修复 active→in_progress", "UPDATE sd_iterations SET status='in_progress', updated_at=NOW() WHERE status='active'"), ] def split_ddl(ddl): """按分号分割 DDL,跳过注释行和空语句。 引号感知:单引号字符串内的分号不作为分隔符(实测事故 2026-09-07: llm_usage 的 COMMENT '记账状态(三态:...;仅status=SUCCEEDED...)' 注释含分号, 裸 split(';') 把 CREATE TABLE 拦腰截断 → 语法错误 → 该模块建表整段中止)。 """ stmts = [] cur = [] in_str = False i = 0 n = len(ddl) while i < n: ch = ddl[i] if in_str: cur.append(ch) if ch == "'": if i + 1 < n and ddl[i + 1] == "'": # '' 转义,仍在串内 cur.append("'") i += 1 else: in_str = False elif ch == '\\' and i + 1 < n: cur.append(ddl[i + 1]) # 反斜杠转义(MySQL 风格) i += 1 elif ch == "'": in_str = True cur.append(ch) elif ch == ';': raw = ''.join(cur) lines = [l for l in raw.split('\n') if not l.strip().startswith('--')] s = '\n'.join(lines).strip() if s: stmts.append(s) cur = [] else: cur.append(ch) i += 1 raw = ''.join(cur) lines = [l for l in raw.split('\n') if not l.strip().startswith('--')] s = '\n'.join(lines).strip() if s: stmts.append(s) return stmts def parse_create_table(stmt): """从 CREATE TABLE 语句解析 (表名, {列名: 列定义DDL})。 列定义直接取自 json2ddl 输出——类型映射逻辑单一来源(sqlor 模板),不在此重复实现。 """ m = re.search(r'(?is)CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?(\w+)`?\s*\(', stmt) if not m: return None, {} table = m.group(1) body = stmt[m.end():] # 截到最后一个 ) 之前(表选项在括号外) depth = 1 end = -1 in_str = False for i, ch in enumerate(body): if ch == "'" and (i == 0 or body[i - 1] != '\\'): in_str = not in_str elif not in_str: if ch == '(': depth += 1 elif ch == ')': depth -= 1 if depth == 0: end = i break if end < 0: return table, {} cols = {} for line in body[:end].split('\n'): line = line.strip().rstrip(',') cm = re.match(r'`(\w+)`\s+(.+)$', line) if cm: cols[cm.group(1)] = cm.group(2).strip() return table, cols def parse_create_index(stmt): """解析 CREATE [UNIQUE] INDEX 语句 → (索引名, 表名, 唯一?, 列列表)。""" m = re.match(r'(?is)CREATE\s+(UNIQUE\s+)?INDEX\s+`?(\w+)`?\s+ON\s+`?(\w+)`?\s*\(([^)]+)\)', stmt) if not m: return None unique = bool(m.group(1)) name, table = m.group(2), m.group(3) cols = [c.strip().strip('`') for c in m.group(4).split(',')] return name, table, unique, cols async def get_existing_indexes(sor, table): """现网索引集合:{(唯一?, (列...)): 索引名}——按列集合判重,免疫索引改名。""" recs = await sor.sqlExe( "SELECT index_name, non_unique, column_name, seq_in_index " "FROM information_schema.statistics " "WHERE table_schema=DATABASE() AND table_name=${t}$ " "ORDER BY index_name, seq_in_index", {"t": table}) await sor.sqlExe("COMMIT", {}) acc = {} for r in (recs or []): name = getattr(r, 'index_name', '') acc.setdefault((name, str(getattr(r, 'non_unique', ''))), []).append( getattr(r, 'column_name', '')) return {(nu == '0', tuple(cols)): name for (name, nu), cols in acc.items()} async def get_existing_columns(sor, table): recs = await sor.sqlExe( "SELECT column_name FROM information_schema.columns " "WHERE table_schema=DATABASE() AND table_name=${t}$", {"t": table}) await sor.sqlExe("COMMIT", {}) return {getattr(r, 'column_name', '') for r in (recs or [])} async def table_exists(sor, table): recs = await sor.sqlExe( "SELECT COUNT(*) AS c FROM information_schema.tables " "WHERE table_schema=DATABASE() AND table_name=${t}$", {"t": table}) await sor.sqlExe("COMMIT", {}) return bool(recs) and int(getattr(recs[0], 'c', 0) or 0) > 0 async def apply_module_ddl(sor, mod, models_dir, stats): """单模块:json2ddl → 幂等建表 → 列同步 → 索引幂等。""" json2ddl = os.path.join(ROOT_DIR, 'py3', 'bin', 'json2ddl') r = subprocess.run([json2ddl, 'mysql', models_dir], capture_output=True, text=True) if r.returncode != 0 or not r.stdout.strip(): print(f' skip {mod}: json2ddl failed ({(r.stderr or "").strip()[:120]})') return creates, indexes = [], [] for stmt in split_ddl(r.stdout): head = stmt.lstrip('(').lstrip().split(None, 2) kw = (head[0] + ' ' + (head[1] if len(head) > 1 else '')).upper() if re.match(r'(?i)^\s*DROP\s+TABLE', stmt): continue # 铁律:绝不 DROP(2026-09-07 清库事故根治) if re.match(r'(?i)^\s*CREATE\s+TABLE', stmt): creates.append(stmt) elif re.match(r'(?i)^\s*CREATE\s+(UNIQUE\s+)?INDEX', stmt): indexes.append(stmt) elif re.match(r'(?i)^\s*ALTER\s+TABLE', stmt): pass # json2ddl 不产 ALTER;存量演进走列同步器 else: stats['other'] += 1 # 1) 建表(IF NOT EXISTS)+ 收集期望列 expected_cols = {} for stmt in creates: table, cols = parse_create_table(stmt) if not table: continue expected_cols[table] = cols s = re.sub(r'(?i)CREATE\s+TABLE\s+', 'CREATE TABLE IF NOT EXISTS ', stmt, count=1) existed = await table_exists(sor, table) try: await sor.execute(s, {}) if not existed: stats['created'] += 1 print(f' + table {table} (新建)') except Exception as e: print(f' ! table {table} 建表失败: {str(e)[:160]}') stats['errors'] += 1 # 2) 列同步(存量表补新列——models JSON 是唯一事实源) for table, cols in expected_cols.items(): if not await table_exists(sor, table): continue have = await get_existing_columns(sor, table) for col, colddl in cols.items(): if col in have: continue try: await sor.execute( f'ALTER TABLE `{table}` ADD COLUMN `{col}` {colddl}', {}) stats['cols_added'] += 1 print(f' + column {table}.{col} (存量表补列)') except Exception as e: print(f' ! column {table}.{col} 补列失败: {str(e)[:160]}') stats['errors'] += 1 # 3) 索引幂等(按 列集合+唯一性 判重,免疫手写时代的旧索引名) idx_cache = {} for stmt in indexes: parsed = parse_create_index(stmt) if not parsed: continue name, table, unique, cols = parsed if not await table_exists(sor, table): continue if table not in idx_cache: idx_cache[table] = await get_existing_indexes(sor, table) if (unique, tuple(cols)) in idx_cache[table]: stats['idx_skipped'] += 1 continue try: await sor.execute(stmt, {}) idx_cache[table][(unique, tuple(cols))] = name stats['idx_created'] += 1 print(f' + index {table}({",".join(cols)}){" UNIQUE" if unique else ""}') except Exception as e: msg = str(e)[:160] if 'Duplicate key name' in msg: stats['idx_skipped'] += 1 else: print(f' ! index {name} 失败: {msg}') stats['errors'] += 1 async def main(): config = getConfig(ROOT_DIR, NS={'workdir': ROOT_DIR, 'ProgramPath': ProgramPath()}) DBPools(config.databases) initEnv() env = ServerEnv() env.get_module_dbname = lambda m: 'pipeline' if 'pipeline' in m else 'sage' # 动态扫描 pkgs/*/models——不再维护静态清单 #(历史教训:清单遗漏 → pricing 定价管理 500、product/pricing 走 DROP 清库) mods = [] for d in sorted(glob.glob(os.path.join(ROOT_DIR, 'pkgs', '*', 'models'))): mod = os.path.basename(os.path.dirname(d)) if mod in SKIP_MODULES: continue if not glob.glob(os.path.join(d, '*.json')): continue mods.append((mod, d)) print(f' modules with models/: {len(mods)}') stats = {'created': 0, 'cols_added': 0, 'idx_created': 0, 'idx_skipped': 0, 'errors': 0, 'other': 0} async with DBPools().sqlorContext('pipeline') as sor: for mod, models_dir in mods: print(f' === {mod} ===') await apply_module_ddl(sor, mod, models_dir, stats) # appbase params 默认参数(数据种子) try: for pname, pval in _PARAMS_INIT: await sor.execute( "INSERT INTO params (id, params_name, params_value) " "VALUES (${id}$, ${n}$, ${v}$) " "ON DUPLICATE KEY UPDATE params_name=params_name", {"id": pname, "n": pname, "v": pval}) print(' seed: appbase params ensured (workspace_base/task_max_retry/register_open)') except Exception as e: print(f' WARN: appbase params seed failed: {e}') # 存量数据修复(非 schema) for label, sql in _DATA_FIXUPS: try: await sor.execute(sql, {}) print(f' fixup: {label}') except Exception as e: print(f' WARN: {label} failed: {str(e)[:120]}') print(f' DONE: 新建表={stats["created"]} 补列={stats["cols_added"]} ' f'新建索引={stats["idx_created"]} 索引已存在跳过={stats["idx_skipped"]} ' f'错误={stats["errors"]}') if stats['errors']: sys.exit(1) if __name__ == '__main__': asyncio.run(main())