From d909bf2b604b7ae2f6c6aafa90e6fc746293177a Mon Sep 17 00:00:00 2001 From: Devin Date: Thu, 8 Oct 2026 08:00:35 -0400 Subject: [PATCH] fix(python): emit Alembic op.execute SQL as an escaped string literal PythonGenerator.generateMigration wrapped each statement in """...""". SQL from TypeORM usually ends in a quoted identifier, so the line ended in four quotes (DROP TABLE "user"""") and most generated migrations failed with "SyntaxError: unterminated string literal". Emit JSON.stringify(sql) instead. Each JSON escape (\" \\ \n \t \uXXXX) means the same in a Python string literal, so the SQL reaches op.execute unchanged. The new test runs the generated migration in python3 under a stub alembic.op and checks it records exactly the input SQL (quoted identifier, backslash, newlines, and the downgrade path). It is skipped when python3 is not installed. fixes #148 --- src/lib/generators/python.ts | 4 +-- test/lib/generators/python.test.ts | 47 ++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/lib/generators/python.ts b/src/lib/generators/python.ts index 2f92a9e..564193f 100644 --- a/src/lib/generators/python.ts +++ b/src/lib/generators/python.ts @@ -319,10 +319,10 @@ export class PythonGenerator extends BaseGenerator { const filename = `${timestamp}_${name}.py`; const upStatements = upSql - .map((sql) => ` op.execute("""${sql}""")`) + .map((sql) => ` op.execute(${JSON.stringify(sql)})`) .join("\n"); const downStatements = (downSql || []) - .map((sql) => ` op.execute("""${sql}""")`) + .map((sql) => ` op.execute(${JSON.stringify(sql)})`) .join("\n"); const content = `"""${name}""" diff --git a/test/lib/generators/python.test.ts b/test/lib/generators/python.test.ts index 4f37da3..dc5fbb4 100644 --- a/test/lib/generators/python.test.ts +++ b/test/lib/generators/python.test.ts @@ -1,4 +1,5 @@ import { expect, describe, test, beforeAll } from "@jest/globals"; +import { spawnSync } from "child_process"; import { PythonGenerator } from "../../../src/lib/generators/python"; import { GeneratorConfig, Entity, Relationship } from "../../../src/lib/types"; @@ -567,4 +568,50 @@ describe("PythonGenerator", () => { expect(files).toEqual([]); }); }); + + describe("migration generation (cli#148)", () => { + const hasPython = spawnSync("python3", ["--version"]).status === 0; + + // Run the migration under a stub alembic.op that records each statement. + const runMigration = ` +import json, sys, types +calls = [] +alembic = types.ModuleType("alembic") +alembic.op = types.SimpleNamespace(execute=calls.append) +sys.modules["alembic"] = alembic +exec(sys.stdin.read()) +upgrade() +downgrade() +print(json.dumps(calls)) +`; + + (hasPython ? test : test.skip)( + "writes a migration Python runs with the SQL unchanged", + async () => { + const upSql = [ + // ends in a quoted identifier + 'ALTER TABLE "task" DROP COLUMN "title"', + // backslash + String.raw`ALTER TABLE "task" ALTER COLUMN "path" SET DEFAULT 'C:\tmp'`, + // newlines + 'CREATE TABLE "note" (\n "id" SERIAL NOT NULL\n)', + ]; + const downSql = ['DROP TABLE "user"']; + const files = await generator.generateMigration({ + entities: [], + relationshipMap: {}, + upSql, + downSql, + }); + + expect(files).toHaveLength(1); + const result = spawnSync("python3", ["-c", runMigration], { + input: files[0].content, + encoding: "utf8", + }); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual([...upSql, ...downSql]); + } + ); + }); });