Repository: typeorm/typeorm
Pull request #11269: fix: change how array columns are compared on column changed detection
Author: login: mnbaccari
Created: 2025-01-31T14:50:01Z | Merged: 2025-04-29T23:33:12Z

Closes: #5967

<!--
  😀 Wonderful!  Thank you for opening a pull request for TypeORM.

  Please fill in the information below to expedite the review
  and (hopefully) merge of your change.

  If unsure about something.. just do as best as you're able,
  or reach out through our community support channels!
  https://github.com/typeorm/typeorm/blob/master/docs/support.md
-->

### Description of change

<!--
  Please be clear and concise what the change is intended to do,
  why this change is needed, and how you've verified that it
  corrects what you intended.

  In some cases it may be helpful to include the current behavior
  and the new behavior.

  If the change is related to an open issue, you can link it here.
  If you include `Fixes #0000` (replacing `0000` with the issue number)
  when this is merged it will automatically mark the issue as fixed and
  close it.
-->

Currently, if a column is marked as `array` (postgres only), like in the below example, the column is detected as having changed on every update, because it uses `===` comparison which will always be false for arrays. This results in unneeded UPDATE queries on the database. This PR fixes that by instead using `OrmUtils.deepCompare`, similar to how it is already done for json columns.

```ts
@Column('int', {
    array: true,
})
```

The tests added were failing before my change and succeed after.

Note that I'm not the author of the related issue #5967 which also mentions json columns, but the issue is old, and the json columns seem to be handled correctly already, so I worked on array only.

### Pull-Request Checklist

<!--
  Please make sure to review and check all of the following.

  If an item is not applicable, you can add "N/A" to the end.
-->

- [x] Code is up-to-date with the `master` branch
- [x] `npm run format` to apply prettier formatting
- [x] `npm run test` passes with this change
- [x] This pull request links relevant issues as `Fixes #0000`
- [x] There are new or updated unit tests validating the change
- [x] Documentation has been updated to reflect this change -- N/A
- [x] The new commits follow conventions explained in [CONTRIBUTING.md](https://github.com/typeorm/typeorm/blob/master/CONTRIBUTING.md)

<!--
  🎉 Thank you for contributing and making TypeORM even better!
-->


Linked issue #0: 

Diff:
diff --git a/src/persistence/SubjectChangedColumnsComputer.ts b/src/persistence/SubjectChangedColumnsComputer.ts
index c7dd51a816a..5b693a1dea6 100644
--- a/src/persistence/SubjectChangedColumnsComputer.ts
+++ b/src/persistence/SubjectChangedColumnsComputer.ts
@@ -85,16 +85,32 @@ export class SubjectChangedColumnsComputer {
                 if (entityValue !== null) {
                     switch (column.type) {
                         case "date":
-                            normalizedValue =
-                                DateUtils.mixedDateToDateString(entityValue)
+                            normalizedValue = column.isArray
+                                ? entityValue.map((date: Date) =>
+                                      DateUtils.mixedDateToDateString(date),
+                                  )
+                                : DateUtils.mixedDateToDateString(entityValue)
+                            databaseValue = column.isArray
+                                ? databaseValue.map((date: Date) =>
+                                      DateUtils.mixedDateToDateString(date),
+                                  )
+                                : DateUtils.mixedDateToDateString(databaseValue)
                             break
 
                         case "time":
                         case "time with time zone":
                         case "time without time zone":
                         case "timetz":
-                            normalizedValue =
-                                DateUtils.mixedDateToTimeString(entityValue)
+                            normalizedValue = column.isArray
+                                ? entityValue.map((date: Date) =>
+                                      DateUtils.mixedDateToTimeString(date),
+                                  )
+                                : DateUtils.mixedDateToTimeString(entityValue)
+                            databaseValue = column.isArray
+                                ? databaseValue.map((date: Date) =>
+                                      DateUtils.mixedDateToTimeString(date),
+                                  )
+                                : DateUtils.mixedDateToTimeString(databaseValue)
                             break
 
                         case "datetime":
@@ -105,14 +121,26 @@ export class SubjectChangedColumnsComputer {
                         case "timestamp with time zone":
                         case "timestamp with local time zone":
                         case "timestamptz":
-                            normalizedValue =
-                                DateUtils.mixedDateToUtcDatetimeString(
-                                    entityValue,
-                                )
-                            databaseValue =
-                                DateUtils.mixedDateToUtcDatetimeString(
-                                    databaseValue,
-                                )
+                            normalizedValue = column.isArray
+                                ? entityValue.map((date: Date) =>
+                                      DateUtils.mixedDateToUtcDatetimeString(
+                                          date,
+                                      ),
+                                  )
+                                : DateUtils.mixedDateToUtcDatetimeString(
+                                      entityValue,
+                                  )
+
+                            databaseValue = column.isArray
+                                ? databaseValue.map((date: Date) =>
+                                      DateUtils.mixedDateToUtcDatetimeString(
+                                          date,
+                                      ),
+                                  )
+                                : DateUtils.mixedDateToUtcDatetimeString(
+                                      databaseValue,
+                                  )
+
                             break
 
                         case "json":
@@ -155,7 +183,10 @@ export class SubjectChangedColumnsComputer {
                 }
 
                 // if value is not changed - then do nothing
-                if (
+                if (column.isArray) {
+                    if (OrmUtils.deepCompare(normalizedValue, databaseValue))
+                        return
+                } else if (
                     Buffer.isBuffer(normalizedValue) &&
                     Buffer.isBuffer(databaseValue)
                 ) {
diff --git a/test/github-issues/5967/entity/User.ts b/test/github-issues/5967/entity/User.ts
new file mode 100644
index 00000000000..ba733a0128e
--- /dev/null
+++ b/test/github-issues/5967/entity/User.ts
@@ -0,0 +1,20 @@
+import { Column, PrimaryGeneratedColumn } from "../../../../src"
+import { Entity } from "../../../../src/decorator/entity/Entity"
+
+@Entity()
+export class User {
+    @PrimaryGeneratedColumn()
+    id: number
+
+    @Column("int", {
+        array: true,
+        default: "{}",
+    })
+    roles: number[]
+
+    @Column("date", {
+        array: true,
+        default: "{}",
+    })
+    dates: Date[]
+}
diff --git a/test/github-issues/5967/issue-5967.ts b/test/github-issues/5967/issue-5967.ts
new file mode 100644
index 00000000000..9a1f9ab5e02
--- /dev/null
+++ b/test/github-issues/5967/issue-5967.ts
@@ -0,0 +1,200 @@
+import { expect } from "chai"
+import { DataSource } from "../../../src"
+import {
+    closeTestingConnections,
+    createTestingConnections,
+    reloadTestingDatabases,
+} from "../../utils/test-utils"
+import { User } from "./entity/User"
+import { MemoryLogger } from "./memory-logger"
+
+describe("github issues > #5967 @afterUpdate always says array/json field updated", () => {
+    let dataSources: DataSource[]
+    before(
+        async () =>
+            (dataSources = await createTestingConnections({
+                entities: [__dirname + "/entity/*{.js,.ts}"],
+                schemaCreate: true,
+                dropSchema: true,
+                enabledDrivers: ["postgres"], // Array column are only supported by postgres src/decorator/options/ColumnCommonOptions.ts
+                createLogger: () => new MemoryLogger(true),
+            })),
+    )
+
+    beforeEach(() => reloadTestingDatabases(dataSources))
+    after(() => closeTestingConnections(dataSources))
+
+    it("should not update an array column if there was no change", () =>
+        Promise.all(
+            dataSources.map(async (dataSource) => {
+                const valueBefore = [1, 2, 3]
+                const valueAfter = [1, 2, 3]
+
+                const repository = dataSource.getRepository(User)
+
+                const logger = dataSource.logger as MemoryLogger
+                logger.clear()
+
+                const user = await repository.save({
+                    roles: valueBefore,
+                })
+
+                const insertQueries = logger.queries.filter((q) =>
+                    q.startsWith("INSERT"),
+                )
+                expect(insertQueries).to.have.length(1)
+                logger.clear()
+
+                await repository.save({
+                    id: user.id,
+                    roles: valueAfter,
+                })
+
+                const updateQueries = logger.queries.filter((q) =>
+                    q.startsWith("UPDATE"),
+                )
+                expect(updateQueries).to.have.length(0)
+            }),
+        ))
+
+    it("should not update a date array column if there was no change", () =>
+        Promise.all(
+            dataSources.map(async (dataSource) => {
+                const date = new Date("2023-01-01")
+                const valueBefore = [date]
+                const valueAfter = [date]
+
+                const repository = dataSource.getRepository(User)
+
+                const logger = dataSource.logger as MemoryLogger
+                logger.clear()
+
+                const user = await repository.save({
+                    dates: valueBefore,
+                })
+
+                const insertQueries = logger.queries.filter((q) =>
+                    q.startsWith("INSERT"),
+                )
+                expect(insertQueries).to.have.length(1)
+                logger.clear()
+
+                await repository.save({
+                    id: user.id,
+                    dates: valueAfter,
+                })
+
+                const updateQueries = logger.queries.filter((q) =>
+                    q.startsWith("UPDATE"),
+                )
+                expect(updateQueries).to.have.length(0)
+            }),
+        ))
+
+    it("should not update a date array column if the only change was a normalization one", () =>
+        Promise.all(
+            dataSources.map(async (dataSource) => {
+                const valueBefore = [new Date("2023-01-01:00:00:00")]
+                const valueAfter = [new Date("2023-01-01:01:00:00")]
+
+                const repository = dataSource.getRepository(User)
+
+                const logger = dataSource.logger as MemoryLogger
+                logger.clear()
+
+                const user = await repository.save({
+                    dates: valueBefore,
+                })
+
+                const insertQueries = logger.queries.filter((q) =>
+                    q.startsWith("INSERT"),
+                )
+                expect(insertQueries).to.have.length(1)
+                logger.clear()
+
+                await repository.save({
+                    id: user.id,
+                    dates: valueAfter,
+                })
+
+                const updateQueries = logger.queries.filter((q) =>
+                    q.startsWith("UPDATE"),
+                )
+                expect(updateQueries).to.have.length(0)
+            }),
+        ))
+
+    it("should update and array column if there was a change", () =>
+        Promise.all(
+            dataSources.map(async (dataSource) => {
+                const valueBefore = [1, 2, 3]
+                const valueAfter = [4, 5, 6]
+
+                const repository = dataSource.getRepository(User)
+
+                const logger = dataSource.logger as MemoryLogger
+                logger.clear()
+
+                const user = await repository.save({
+                    roles: valueBefore,
+                })
+
+                const insertQueries = logger.queries.filter((q) =>
+                    q.startsWith("INSERT"),
+                )
+                expect(insertQueries).to.have.length(1)
+                logger.clear()
+
+                await repository.save({
+                    id: user.id,
+                    roles: valueAfter,
+                })
+
+                const updateQueries = logger.queries.filter((q) =>
+                    q.startsWith("UPDATE"),
+                )
+                expect(updateQueries).to.have.length(1)
+            }),
+        ))
+
+    it("should update a date array column if there was a change", () =>
+        Promise.all(
+            dataSources.map(async (dataSource) => {
+                const valueBefore = [
+                    new Date("2023-01-01"),
+                    new Date("2023-01-02"),
+                    new Date("2023-01-03"),
+                ]
+                const valueAfter = [
+                    new Date("2023-01-04"),
+                    new Date("2023-01-05"),
+                    new Date("2023-01-06"),
+                ]
+
+                const repository = dataSource.getRepository(User)
+
+                const logger = dataSource.logger as MemoryLogger
+                logger.clear()
+
+                const user = await repository.save({
+                    dates: valueBefore,
+                })
+
+                const insertQueries = logger.queries.filter((q) =>
+                    q.startsWith("INSERT"),
+                )
+                expect(insertQueries).to.have.length(1)
+                logger.clear()
+
+                await repository.save({
+                    id: user.id,
+                    dates: valueAfter,
+                })
+
+                const updateQueries = logger.queries.filter((q) =>
+                    q.startsWith("UPDATE"),
+                )
+                expect(updateQueries).to.have.length(1)
+            }),
+        ))
+})
diff --git a/test/github-issues/5967/memory-logger.ts b/test/github-issues/5967/memory-logger.ts
new file mode 100644
index 00000000000..1bb95cf7a74
--- /dev/null
+++ b/test/github-issues/5967/memory-logger.ts
@@ -0,0 +1,30 @@
+import { Logger } from "../../../src/logger/Logger"
+
+export class MemoryLogger implements Logger {
+    constructor(public enabled = true) {}
+
+    private _queries: string[] = []
+    get queries() {
+        return this._queries
+    }
+
+    logQuery(query: string) {
+        if (this.enabled) {
+            this._queries.push(query)
+        }
+    }
+
+    logQueryError(error: string, query: string) {}
+
+    logQuerySlow(time: number, query: string) {}
+
+    logSchemaBuild(message: string) {}
+
+    logMigration(message: string) {}
+
+    log(level: "log" | "info" | "warn", message: any) {}
+
+    clear() {
+        this._queries = []
+    }
+}


Reviews:
user: login: naorpeled
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-28T21:05:44Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR fixes the issue where array columns in PostgreSQL were always detected as changed due to reference comparison by using deep comparison instead.  
- Updated the column change detection logic in SubjectChangedColumnsComputer.ts to perform a deep comparison for array columns.  
- Added tests to verify that update queries are not generated when there is no actual change in array column values.  
- Introduced a MemoryLogger to capture and validate query generation in tests.

### Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

| File                                                     | Description                                                                  |
| -------------------------------------------------------- | ---------------------------------------------------------------------------- |
| test/github-issues/5967/memory-logger.ts                 | Implements an in-memory logger for capturing query executions during tests.  |
| test/github-issues/5967/issue-5967.ts                     | Provides test cases to validate the new array comparison behavior.          |
| test/github-issues/5967/entity/User.ts                   | Defines a User entity with an array column for testing purposes.             |
| src/persistence/SubjectChangedColumnsComputer.ts         | Updates column change detection logic to use deep comparison for array types.  |




state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T21:06:05Z
user: login: sgarner
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-28T21:22:53Z
user: login: gioboa
body: Thanks for your help @mnbaccari 
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-29T11:14:27Z
user: login: mnbaccari
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T17:36:39Z
user: login: sgarner
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-29T22:02:31Z
user: login: mnbaccari
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T22:22:24Z
user: login: sgarner
body: Thanks @mnbaccari !
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-29T23:32:12Z

Review comments:
diff_hunk: @@ -83,6 +83,12 @@ export class SubjectChangedColumnsComputer {
                 let normalizedValue = entityValue
                 // normalize special values to make proper comparision
                 if (entityValue !== null) {
+                    // If the column is an array, we don't care about column.type and instead compare the arrays directly
+                    if (column.isArray) {
+                        if (OrmUtils.deepCompare(entityValue, databaseValue))
+                            return
+                    }
+
path: src/persistence/SubjectChangedColumnsComputer.ts
user: login: sgarner
body: Postgres allows array columns to be defined with any regular base type for its elements. If you had an array of dates, would we want the elements in the array to be normalized before comparison like we do for non-array columns?
created_at: 2025-04-28T21:20:55Z
author_association: COLLABORATOR
diff_hunk: @@ -83,6 +83,12 @@ export class SubjectChangedColumnsComputer {
                 let normalizedValue = entityValue
                 // normalize special values to make proper comparision
                 if (entityValue !== null) {
+                    // If the column is an array, we don't care about column.type and instead compare the arrays directly
+                    if (column.isArray) {
+                        if (OrmUtils.deepCompare(entityValue, databaseValue))
+                            return
+                    }
+
path: src/persistence/SubjectChangedColumnsComputer.ts
user: login: mnbaccari
body: Thanks for taking a look. For the issue I'm trying to fix specifically, it doesn't really matter. Because I'm trying to fix the case where a column was not changed at all, but was still always triggering updates. I added a test case with a date array column to show that is handled.

But you're right that it doesn't fix the case where a column is changed to an "equivalent" value. I drafted a PR that would fix that: https://github.com/mnbaccari/typeorm/pull/2, let me know if you think it's worth including here. It makes the code slightly messier, but more importantly, I'm not sure why normalizing the database value was only done for timestamp/datetime, and not date or time.

Let me know what you think.
created_at: 2025-04-29T17:36:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -83,6 +83,12 @@ export class SubjectChangedColumnsComputer {
                 let normalizedValue = entityValue
                 // normalize special values to make proper comparision
                 if (entityValue !== null) {
+                    // If the column is an array, we don't care about column.type and instead compare the arrays directly
+                    if (column.isArray) {
+                        if (OrmUtils.deepCompare(entityValue, databaseValue))
+                            return
+                    }
+
path: src/persistence/SubjectChangedColumnsComputer.ts
user: login: sgarner
body: I've approved that PR, let's include it here. I think that is the more "correct" solution so that we aren't leaving edge cases behind.

I suspect that date/time values on the `databaseEntity` were not normalized before because such values are usually persisted and loaded as strings, not as `Date` objects. But there is no harm in normalizing them, and this will allow for accurate change detection in the case where an entity has a `Date` value set in such a field and then is updated in the same code path.
created_at: 2025-04-29T22:02:31Z
author_association: COLLABORATOR
diff_hunk: @@ -83,6 +83,12 @@ export class SubjectChangedColumnsComputer {
                 let normalizedValue = entityValue
                 // normalize special values to make proper comparision
                 if (entityValue !== null) {
+                    // If the column is an array, we don't care about column.type and instead compare the arrays directly
+                    if (column.isArray) {
+                        if (OrmUtils.deepCompare(entityValue, databaseValue))
+                            return
+                    }
+
path: src/persistence/SubjectChangedColumnsComputer.ts
user: login: mnbaccari
body: Makes sense thanks, I've merged that PR here.
created_at: 2025-04-29T22:22:23Z
author_association: CONTRIBUTOR

PR comments:
user: login: coveralls
created_at: 2025-01-31T16:49:20Z
body: 
[![Coverage Status](https://coveralls.io/builds/73443185/badge)](https://coveralls.io/builds/73443185)

coverage: 76.307% (-0.01%) from 76.317%
when pulling **398777d9e9ea5a2965ea836d2758adb00b85ef1b on mnbaccari:fix-array-column-changed**
into **61a6f971afa6c82fb6abbc57839dac25a81db775 on typeorm:master**.

author_association: NONE
user: login: mnbaccari
created_at: 2025-01-31T18:00:57Z
body: The only failing test is a known flaky test being investigated in https://github.com/typeorm/typeorm/pull/11261
author_association: CONTRIBUTOR
user: login: mnbaccari
created_at: 2025-02-04T13:16:49Z
body: @naorpeled sorry for the @, but I saw you re-ran the github flow for the tests. Just wondering if I should ping someone directly to get a review here? I also posted on the discord but didn't get a reply there: https://discord.com/channels/1124626013360488529/1333593115495501905/1333593115495501905

Thanks for the help.
author_association: CONTRIBUTOR
user: login: naorpeled
created_at: 2025-02-04T13:54:42Z
body: > @naorpeled sorry for the @, but I saw you re-ran the github flow for the tests. Just wondering if I should ping someone directly to get a review here? I also posted on the discord but didn't get a reply there: [discord.com/channels/1124626013360488529/1333593115495501905/1333593115495501905](https://discord.com/channels/1124626013360488529/1333593115495501905/1333593115495501905)
> 
> Thanks for the help.

Hey,
first of all feel free to ping me whenever, it's absolutely okay.

I was trying to understand if the failed tests were flaky 🙏 
The rest of the team and I are working on the next version and your PR is definitely on my mind as a candidate to be released.
I'm not approving just yet but we're on it.
author_association: MEMBER
user: login: mnbaccari
created_at: 2025-04-25T13:44:18Z
body: Hey @naorpeled , any updates here? Can this be approved and merged?
author_association: CONTRIBUTOR
user: login: naorpeled
created_at: 2025-04-28T21:04:40Z
body: > Hey @naorpeled , any updates here? Can this be approved and merged?

Hey, 
we require two approvals for PRs so I've sent a message about this PR to the rest of the maintainers.

In the meantime, can you please pull from master?

author_association: MEMBER
user: login: naorpeled
created_at: 2025-04-30T05:40:17Z
body: Thanks @mnbaccari 😎 
author_association: MEMBER