fix(postgres): improve handling of shared ENUMs during schema sync#12000
fix(postgres): improve handling of shared ENUMs during schema sync#12000baszczewski wants to merge 1 commit intotypeorm:masterfrom
Conversation
User descriptionProblemSynchronizing PostgreSQL ENUM types shared across multiple columns or tables could:
Root CauseSchema builder did not properly handle:
Solution
TestsAdded functional tests covering:
Closes #9233 Description of changePull-Request Checklist
PR TypeBug fix, Tests Description
Diagram Walkthroughflowchart LR
A["ENUM Sync Logic"] --> B["Check if ENUM Used"]
B --> C["isEnumUsed Method"]
C --> D["Query information_schema"]
D --> E["Count Other Usages"]
E --> F{Used by Others?}
F -->|Yes| G["Skip Drop/Create"]
F -->|No| H["Drop/Create ENUM"]
A --> I["ALTER TYPE ADD VALUE"]
I --> J["Add IF NOT EXISTS Clause"]
J --> K["Idempotent Modifications"]
|
| Relevant files | |||||
|---|---|---|---|---|---|
| Bug fix |
| ||||
| Tests |
|
PR Code Suggestions ✨Latest suggestions up to 011554d
Previous suggestionsSuggestions up to commit 4b411ea
|
|||||||||||||||||||||||||||||||
Code Review by Qodo
1. Shared enum rename corrupts other columns
|
| @@ -0,0 +1,79 @@ | |||
| import { DataSource } from "../../../src/data-source/DataSource" | |||
There was a problem hiding this comment.
1. issue-9233.ts missing .test.ts extension 📘 Rule violation ✓ Correctness
The new github-issues test file is named issue-9233.ts instead of issue-9233.test.ts. Every other file in test/github-issues/ consistently uses the .test.ts suffix, so this file deviates from the established naming convention.
Agent Prompt
## Issue description
The new github-issues test file is missing the `.test.ts` extension that is universally used across the project's `test/github-issues/` directory.
## Issue Context
All existing files in `test/github-issues/` follow the pattern `issue-<number>.test.ts`. The new file `issue-9233.ts` omits the `.test` part, which may also prevent the test runner from discovering and executing it automatically.
## Fix Focus Areas
- test/github-issues/9233/issue-9233.ts[1-79] — rename file to `issue-9233.test.ts`
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| if (isEnumUsed) { | ||
| // if enum is used by other columns we should not drop it | ||
| } else { | ||
| upQueries.push( | ||
| this.dropEnumTypeSql(table, column, escapedEnumName), | ||
| ) | ||
| downQueries.push( | ||
| this.createEnumTypeSql(table, column, escapedEnumName), | ||
| ) | ||
| } |
There was a problem hiding this comment.
2. Empty if (isenumused) body with comment 📘 Rule violation ✓ Correctness
The new code uses an inverted if (isEnumUsed) { // comment } else { ... } pattern, leaving the
if-body empty and putting all logic in else. This is an AI-generated code smell; idiomatic code
would use if (!isEnumUsed) { ... } directly, consistent with the rest of the file.
Agent Prompt
## Issue description
The `if (isEnumUsed) { // comment } else { ... }` block has an empty `if`-body and places all meaningful logic inside `else`. This is inconsistent with the rest of the file and the rest of this PR.
## Issue Context
The intent is to skip dropping the enum when it is still in use. This should be expressed as a guard (`if (!isEnumUsed) { ... }`) without an empty positive branch, exactly as done at lines 1732 and 1827 of the same file in this PR.
## Fix Focus Areas
- src/driver/postgres/PostgresQueryRunner.ts[2646-2655]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| protected async checkEnumUsage( | ||
| schema: string, | ||
| name: string, | ||
| ): Promise<number> { | ||
| const sql = `SELECT COUNT("column_name") AS "count" FROM "information_schema"."columns" WHERE "udt_name" IN ($1, $2) AND "table_schema" = $3` | ||
| const result = await this.query(sql, [name, `_${name}`, schema]) | ||
| return parseInt(result[0].count) | ||
| } |
There was a problem hiding this comment.
3. checkenumusage is dead code 📘 Rule violation ✓ Correctness
The checkEnumUsage method is added to PostgresQueryRunner but is never called anywhere in the codebase. Introducing an unused method is AI-generated noise that adds unnecessary complexity alongside the already-used isEnumUsed method.
Agent Prompt
## Issue description
The `checkEnumUsage` method is defined but never invoked anywhere in the codebase. It duplicates the intent already covered by `isEnumUsed` and constitutes dead code.
## Issue Context
All enum-in-use checks introduced by this PR are performed via `isEnumUsed`. `checkEnumUsage` was likely added by an AI assistant as a leftover artefact and should be deleted.
## Fix Focus Areas
- src/driver/postgres/PostgresQueryRunner.ts[4610-4621]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| const result2 = await queryRunner.query( | ||
| `SELECT count(*) as "count" FROM "pg_type" WHERE "typname" = 'test_enum'`, | ||
| ) | ||
| if (parseInt(result2[0].count) !== 0) { | ||
| // Wait, if 0 usages remaining, we SHOULD drop it ideally | ||
| // My fix enables dropping if usage == 1 (before drop). | ||
| // So here usage was 1. Drop check passes. Drops. | ||
| // So result should be 0. | ||
| } else { | ||
| // Success | ||
| } |
There was a problem hiding this comment.
4. Non-asserting if/else with ai narration 📘 Rule violation ✓ Correctness
Lines 63–73 of the github-issues test contain an if/else block whose both branches are empty
except for AI-style first-person narration comments ("My fix enables…", "// Success"), providing
zero test coverage for the scenario it describes.
Agent Prompt
## Issue description
The if/else block at lines 63-73 has no assertions in either branch and contains AI-generated first-person commentary rather than real test logic, making the second half of the test scenario completely unverified.
## Issue Context
The test is verifying that after dropping the last column using a shared enum, the enum itself is also dropped. This should be expressed as a direct assertion on `result2[0].count`, consistent with how the first assertion is written at lines 52-57.
## Fix Focus Areas
- test/github-issues/9233/issue-9233.ts[63-73]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| const sql = | ||
| `SELECT "table_name", "column_name" FROM "information_schema"."columns" ` + | ||
| `WHERE "table_schema" = '${schema}' AND "udt_name" = '${enumName}'` | ||
| const results = await this.query(sql) |
There was a problem hiding this comment.
5. Sql injection in isenumused() 🐞 Bug ⛨ Security
The isEnumUsed method directly interpolates schema and enumName into a raw SQL string without parameterization. A user-controlled schema name or enum name containing single-quotes or SQL metacharacters can break out of the string literal and execute arbitrary SQL against the database.
Agent Prompt
## Issue description
The `isEnumUsed` method in `PostgresQueryRunner` interpolates `schema` and `enumName` directly into a SQL string, creating a SQL injection vulnerability. Both values originate from user-controlled entity metadata (schema name, enum name).
## Issue Context
The sibling method `checkEnumUsage` (added in the same PR) already uses the correct parameterized query pattern with `$1, $2, $3` placeholders. The fix is to apply the same pattern to `isEnumUsed`.
## Fix Focus Areas
- src/driver/postgres/PostgresQueryRunner.ts[4574-4577]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| const otherUsages = results.filter( | ||
| (row: any) => | ||
| !( | ||
| row.table_name === table.name && | ||
| row.column_name === column.name | ||
| ), | ||
| ) | ||
|
|
||
| return otherUsages.length > 0 |
There was a problem hiding this comment.
6. Isenumused filter breaks non-default schemas 🐞 Bug ✓ Correctness
The filter in isEnumUsed that excludes the current table/column compares `row.table_name === table.name, but information_schema.columns.table_name` is always the bare table name while table.name in TypeORM includes a schema prefix (e.g. "myschema.my_table") for non-default schemas. The strings never match, so the current column is never excluded, causing isEnumUsed to always return true. This means the old ENUM type is never dropped after dropColumn, and the renamed *_old type is permanently left as an orphan after every changeColumn rename on non-default schemas.
Agent Prompt
## Issue description
In `isEnumUsed`, the filter that excludes the current table/column from 'other usages' compares `row.table_name === table.name`. For non-default schemas, `table.name` contains a schema prefix (e.g. `"myschema.my_table"`), but `information_schema.columns.table_name` is always the bare table name (`"my_table"`). The strings never match, so the current column is never excluded, causing `isEnumUsed` to always return `true` for non-default schemas.
## Issue Context
This causes two downstream bugs:
1. In `dropColumn` (lines 2640–2655): the ENUM type is never dropped even when it should be.
2. In `changeColumn` rename path (lines 1820–1842): the renamed `*_old` type is never dropped, permanently polluting the database schema.
The fix is to use `this.driver.parseTableName(table).tableName` to get the bare table name for the comparison.
## Fix Focus Areas
- src/driver/postgres/PostgresQueryRunner.ts[4580-4586]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
Problem: Synchronizing PostgreSQL ENUM types shared across multiple columns or tables could: - Attempt to re-create or drop ENUM types still in use - Fail when adding values that already exist - Mis-handle shared ENUM renaming These issues caused errors during migrations or schema sync. Root Cause: Schema builder did not properly handle: - Reuse of ENUM types across multiple metadata entries - Existing ENUM values in the database - Dependency tracking of tables referencing a shared ENUM Solution: - Prevent dropping ENUM types still referenced by other columns - Use ALTER TYPE ... ADD VALUE IF NOT EXISTS (Postgres 12+) - Improve detection logic for shared ENUM reuse and renaming - Ensure idempotent ENUM modifications during schema synchronization Tests: Added functional tests covering: - Shared ENUM reuse - Adding existing enum values - ENUM renaming scenarios Closes typeorm#9233
4b411ea to
011554d
Compare
|
Persistent review updated to latest commit 011554d |
| // rename old ENUM | ||
| upQueries.push( | ||
| new Query( |
There was a problem hiding this comment.
1. Shared enum rename corrupts other columns 🐞 Bug ✓ Correctness
In the else branch of changeColumn, the old enum is unconditionally renamed to _old via `ALTER TYPE ... RENAME TO`, which is a global PostgreSQL operation. When the enum is shared across multiple tables/columns, this rename changes the type name as seen by ALL referencing columns. On the next synchronize(), TypeORM reads those unrelated columns as having type old_enum_old while entity definitions declare old_enum, triggering an infinite loop of schema changes.
Agent Prompt
## Issue description
In `changeColumn`, the `else` branch (triggered when enum values are removed or the enum name changes) unconditionally renames the old enum type to `_old`. Because `ALTER TYPE ... RENAME TO` is a global PostgreSQL operation, this rename affects ALL columns in ALL tables that reference the same enum type. When the enum is shared, subsequent calls to `synchronize()` detect the renamed type as a schema drift and attempt another `changeColumn`, creating an infinite loop.
## Issue Context
The `isEnumUsed` check was added to guard the DROP of the renamed type, but the rename itself must also be guarded. When the old enum is shared, the rename must be skipped to preserve the type name for other referencing columns.
## Fix Focus Areas
- src/driver/postgres/PostgresQueryRunner.ts[1706-1716] — wrap the rename `upQueries.push` and its corresponding `downQueries.push` inside an `if (!isOldEnumShared)` block, where `isOldEnumShared` is computed via `isEnumUsed(table, oldColumn, oldEnumNameWithoutSchemaRaw, schema!)` before the rename is attempted
- src/driver/postgres/PostgresQueryRunner.ts[1820-1842] — the existing `isEnumUsed` guard for the drop can remain, but must be consistent with the rename guard above
- test/functional/database-schema/column-types/postgres-enum-shared/postgres-enum-shared.test.ts[251-260] — add a second `synchronize()` call after the first and assert that no further queries are generated (idempotency check)
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| const newEnumIsUsed = await this.isEnumUsed( | ||
| table, | ||
| newColumn, | ||
| newEnumNameRaw, | ||
| schema!, | ||
| ) | ||
|
|
||
| if (!newEnumIsUsed) { | ||
| upQueries.push( | ||
| this.createEnumTypeSql( | ||
| table, | ||
| newColumn, | ||
| newEnumName, | ||
| ), | ||
| ) | ||
| downQueries.push( | ||
| this.dropEnumTypeSql(table, newColumn, newEnumName), | ||
| ) | ||
| } |
There was a problem hiding this comment.
2. isenumused wrong guard for new enum creation 🐞 Bug ⛯ Reliability
isEnumUsed checks information_schema.columns for column references to the type, but does NOT check whether the type itself exists in pg_type. If the new enum type already exists in pg_type with no column references (an orphaned type from a previously interrupted migration), isEnumUsed returns false, the code pushes CREATE TYPE, and the query fails at runtime with `ERROR: type already exists. The correct guard is hasEnumType`, which checks type existence in pg_type/pg_namespace.
Agent Prompt
## Issue description
The guard before `createEnumTypeSql` uses `isEnumUsed`, which queries `information_schema.columns` for column references. This does not detect an orphaned type (one that exists in `pg_type` but has no column assignments). The correct check is `hasEnumType`, which queries `pg_type JOIN pg_namespace` for type existence.
## Issue Context
An orphaned type can result from a previously interrupted migration where `CREATE TYPE` succeeded but the subsequent `ALTER COLUMN` failed and was rolled back without rolling back the type creation. On the next sync attempt, `isEnumUsed` returns `false` (no column references), the code pushes `CREATE TYPE`, and PostgreSQL raises `ERROR: type already exists`.
## Fix Focus Areas
- src/driver/postgres/PostgresQueryRunner.ts[1725-1743] — replace `isEnumUsed(table, newColumn, newEnumNameRaw, schema!)` with `hasEnumType(table, newColumn)` and invert the condition: `if (!newEnumAlreadyExists)`
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
Problem
Synchronizing PostgreSQL ENUM types shared across multiple columns or tables could:
Root Cause
Schema builder did not properly handle:
Solution
Tests
Added functional tests covering:
Closes #9233
Description of change
Pull-Request Checklist
masterbranchFixes #9233tests/**.test.ts)docs/docs/**.md)