feat:add utc flag to date column (#11740)

This commit is contained in:
CHOIJEWON 2025-11-30 22:18:50 +09:00 committed by GitHub
parent 67f793feaa
commit 55cd8e2b08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 159 additions and 23 deletions

View File

@ -453,6 +453,7 @@ List of available options in `ColumnOptions`:
- `hstoreType: "object"|"string"` - Return type of `HSTORE` column. Returns value as string or as object. Used only in [Postgres](https://www.postgresql.org/docs/9.6/static/hstore.html).
- `array: boolean` - Used for postgres and cockroachdb column types which can be array (for example int[])
- `transformer: { from(value: DatabaseType): EntityType, to(value: EntityType): DatabaseType }` - Used to marshal properties of arbitrary type `EntityType` into a type `DatabaseType` supported by the database. Array of transformers are also supported and will be applied in natural order when writing, and in reverse order when reading. e.g. `[lowercase, encrypt]` will first lowercase the string then encrypt it when writing, and will decrypt then do nothing when reading.
- `utc: boolean` - Indicates if date values should be stored and retrieved in UTC timezone instead of local timezone. Only applies to `date` column type. Default value is `false` (uses local timezone for backward compatibility).
Note: most of those column options are RDBMS-specific and aren't available in `MongoDB`.

View File

@ -200,4 +200,15 @@ export interface ColumnOptions extends ColumnCommonOptions {
* @See https://typeorm.io/decorator-reference#virtualcolumn for more details.
*/
query?: (alias: string) => string
/**
* Indicates if date values should be stored and retrieved in UTC timezone
* instead of local timezone. Only applies to "date" column type.
* Default value is "false" (uses local timezone for backward compatibility).
*
* @example
* @Column({ type: "date", utc: true })
* birthDate: Date
*/
utc?: boolean
}

View File

@ -533,7 +533,9 @@ export class AuroraMysqlDriver implements Driver {
if (columnMetadata.type === Boolean) {
return value === true ? 1 : 0
} else if (columnMetadata.type === "date") {
return DateUtils.mixedDateToDateString(value)
return DateUtils.mixedDateToDateString(value, {
utc: columnMetadata.utc,
})
} else if (columnMetadata.type === "time") {
return DateUtils.mixedDateToTimeString(value)
} else if (columnMetadata.type === "json") {
@ -592,7 +594,9 @@ export class AuroraMysqlDriver implements Driver {
) {
value = DateUtils.normalizeHydratedDate(value)
} else if (columnMetadata.type === "date") {
value = DateUtils.mixedDateToDateString(value)
value = DateUtils.mixedDateToDateString(value, {
utc: columnMetadata.utc,
})
} else if (columnMetadata.type === "json") {
value = typeof value === "string" ? JSON.parse(value) : value
} else if (columnMetadata.type === "time") {

View File

@ -385,7 +385,9 @@ export class CockroachDriver implements Driver {
if (columnMetadata.type === Boolean) {
return value === true ? 1 : 0
} else if (columnMetadata.type === "date") {
return DateUtils.mixedDateToDateString(value)
return DateUtils.mixedDateToDateString(value, {
utc: columnMetadata.utc,
})
} else if (columnMetadata.type === "time") {
return DateUtils.mixedDateToTimeString(value)
} else if (
@ -445,7 +447,9 @@ export class CockroachDriver implements Driver {
) {
value = DateUtils.normalizeHydratedDate(value)
} else if (columnMetadata.type === "date") {
value = DateUtils.mixedDateToDateString(value)
value = DateUtils.mixedDateToDateString(value, {
utc: columnMetadata.utc,
})
} else if (columnMetadata.type === "time") {
value = DateUtils.mixedTimeToString(value)
} else if (columnMetadata.type === "simple-array") {

View File

@ -616,7 +616,9 @@ export class MysqlDriver implements Driver {
if (columnMetadata.type === Boolean) {
return value === true ? 1 : 0
} else if (columnMetadata.type === "date") {
return DateUtils.mixedDateToDateString(value)
return DateUtils.mixedDateToDateString(value, {
utc: columnMetadata.utc,
})
} else if (columnMetadata.type === "time") {
return DateUtils.mixedDateToTimeString(value)
} else if (columnMetadata.type === "json") {
@ -670,7 +672,9 @@ export class MysqlDriver implements Driver {
) {
value = DateUtils.normalizeHydratedDate(value)
} else if (columnMetadata.type === "date") {
value = DateUtils.mixedDateToDateString(value)
value = DateUtils.mixedDateToDateString(value, {
utc: columnMetadata.utc,
})
} else if (columnMetadata.type === "json") {
// mysql2 returns JSON values already parsed, but may still be a string
// if the JSON value itself is a string (e.g., "\"hello\"")

View File

@ -531,9 +531,9 @@ export class OracleDriver implements Driver {
} else if (columnMetadata.type === "date") {
if (typeof value === "string") value = value.replace(/[^0-9-]/g, "")
return () =>
`TO_DATE('${DateUtils.mixedDateToDateString(
value,
)}', 'YYYY-MM-DD')`
`TO_DATE('${DateUtils.mixedDateToDateString(value, {
utc: columnMetadata.utc,
})}', 'YYYY-MM-DD')`
} else if (
columnMetadata.type === Date ||
columnMetadata.type === "timestamp" ||
@ -567,7 +567,9 @@ export class OracleDriver implements Driver {
if (columnMetadata.type === Boolean) {
value = !!value
} else if (columnMetadata.type === "date") {
value = DateUtils.mixedDateToDateString(value)
value = DateUtils.mixedDateToDateString(value, {
utc: columnMetadata.utc,
})
} else if (columnMetadata.type === "time") {
value = DateUtils.mixedTimeToString(value)
} else if (

View File

@ -656,7 +656,9 @@ export class PostgresDriver implements Driver {
if (columnMetadata.type === Boolean) {
return value === true ? 1 : 0
} else if (columnMetadata.type === "date") {
return DateUtils.mixedDateToDateString(value)
return DateUtils.mixedDateToDateString(value, {
utc: columnMetadata.utc,
})
} else if (columnMetadata.type === "time") {
return DateUtils.mixedDateToTimeString(value)
} else if (
@ -755,7 +757,9 @@ export class PostgresDriver implements Driver {
) {
value = DateUtils.normalizeHydratedDate(value)
} else if (columnMetadata.type === "date") {
value = DateUtils.mixedDateToDateString(value)
value = DateUtils.mixedDateToDateString(value, {
utc: columnMetadata.utc,
})
} else if (columnMetadata.type === "time") {
value = DateUtils.mixedTimeToString(value)
} else if (

View File

@ -335,7 +335,9 @@ export class ReactNativeDriver implements Driver {
) {
return value === true ? 1 : 0
} else if (columnMetadata.type === "date") {
return DateUtils.mixedDateToDateString(value)
return DateUtils.mixedDateToDateString(value, {
utc: columnMetadata.utc,
})
} else if (columnMetadata.type === "time") {
return DateUtils.mixedDateToTimeString(value)
} else if (
@ -407,7 +409,9 @@ export class ReactNativeDriver implements Driver {
value = DateUtils.normalizeHydratedDate(value)
} else if (columnMetadata.type === "date") {
value = DateUtils.mixedDateToDateString(value)
value = DateUtils.mixedDateToDateString(value, {
utc: columnMetadata.utc,
})
} else if (columnMetadata.type === "time") {
value = DateUtils.mixedTimeToString(value)
} else if (columnMetadata.type === "simple-array") {

View File

@ -542,7 +542,9 @@ export class SapDriver implements Driver {
if (value === null || value === undefined) return value
if (columnMetadata.type === "date") {
return DateUtils.mixedDateToDateString(value)
return DateUtils.mixedDateToDateString(value, {
utc: columnMetadata.utc,
})
} else if (columnMetadata.type === "time") {
return DateUtils.mixedDateToTimeString(value)
} else if (
@ -584,7 +586,9 @@ export class SapDriver implements Driver {
) {
value = DateUtils.normalizeHydratedDate(value)
} else if (columnMetadata.type === "date") {
value = DateUtils.mixedDateToDateString(value)
value = DateUtils.mixedDateToDateString(value, {
utc: columnMetadata.utc,
})
} else if (columnMetadata.type === "time") {
value = DateUtils.mixedTimeToString(value)
} else if (columnMetadata.type === "simple-array") {

View File

@ -399,7 +399,9 @@ export class SpannerDriver implements Driver {
const lib = this.options.driver || PlatformTools.load("spanner")
return lib.Spanner.numeric(value.toString())
} else if (columnMetadata.type === "date") {
return DateUtils.mixedDateToDateString(value)
return DateUtils.mixedDateToDateString(value, {
utc: columnMetadata.utc,
})
} else if (columnMetadata.type === "json") {
return value
} else if (
@ -434,7 +436,9 @@ export class SpannerDriver implements Driver {
} else if (columnMetadata.type === "numeric") {
value = value.value
} else if (columnMetadata.type === "date") {
value = DateUtils.mixedDateToDateString(value)
value = DateUtils.mixedDateToDateString(value, {
utc: columnMetadata.utc,
})
} else if (columnMetadata.type === "json") {
value = typeof value === "string" ? JSON.parse(value) : value
} else if (columnMetadata.type === Number) {

View File

@ -331,7 +331,9 @@ export abstract class AbstractSqliteDriver implements Driver {
) {
return value === true ? 1 : 0
} else if (columnMetadata.type === "date") {
return DateUtils.mixedDateToDateString(value)
return DateUtils.mixedDateToDateString(value, {
utc: columnMetadata.utc,
})
} else if (columnMetadata.type === "time") {
return DateUtils.mixedDateToTimeString(value)
} else if (
@ -406,7 +408,9 @@ export abstract class AbstractSqliteDriver implements Driver {
value = DateUtils.normalizeHydratedDate(value)
} else if (columnMetadata.type === "date") {
value = DateUtils.mixedDateToDateString(value)
value = DateUtils.mixedDateToDateString(value, {
utc: columnMetadata.utc,
})
} else if (columnMetadata.type === "time") {
value = DateUtils.mixedTimeToString(value)
} else if (

View File

@ -532,7 +532,7 @@ export class SqlServerDriver implements Driver {
if (columnMetadata.type === Boolean) {
return value === true ? 1 : 0
} else if (columnMetadata.type === "date") {
return DateUtils.mixedDateToDate(value)
return DateUtils.mixedDateToDate(value, columnMetadata.utc)
} else if (columnMetadata.type === "time") {
return DateUtils.mixedTimeToDate(value)
} else if (
@ -586,7 +586,9 @@ export class SqlServerDriver implements Driver {
) {
value = DateUtils.normalizeHydratedDate(value)
} else if (columnMetadata.type === "date") {
value = DateUtils.mixedDateToDateString(value)
value = DateUtils.mixedDateToDateString(value, {
utc: columnMetadata.utc,
})
} else if (columnMetadata.type === "time") {
value = DateUtils.mixedTimeToString(value)
} else if (columnMetadata.type === "simple-array") {

View File

@ -123,6 +123,12 @@ export class ColumnMetadata {
*/
comment?: string
/**
* Indicates if date values use UTC timezone.
* Only applies to "date" column type.
*/
utc: boolean = false
/**
* Default database value.
*/
@ -388,6 +394,8 @@ export class ColumnMetadata {
this.isSelect = options.args.options.select
if (options.args.options.insert !== undefined)
this.isInsert = options.args.options.insert
if (options.args.options.utc !== undefined)
this.utc = options.args.options.utc
if (options.args.options.update !== undefined)
this.isUpdate = options.args.options.update
if (options.args.options.readonly !== undefined)

View File

@ -25,8 +25,21 @@ export class DateUtils {
/**
* Converts given value into date string in a "YYYY-MM-DD" format.
*/
static mixedDateToDateString(value: string | Date): string {
static mixedDateToDateString(
value: string | Date,
options?: { utc?: boolean },
): string {
const utc = options?.utc ?? false
if (value instanceof Date) {
if (utc) {
return (
this.formatZerolessValue(value.getUTCFullYear(), 4) +
"-" +
this.formatZerolessValue(value.getUTCMonth() + 1) +
"-" +
this.formatZerolessValue(value.getUTCDate())
)
}
return (
this.formatZerolessValue(value.getFullYear(), 4) +
"-" +

View File

@ -0,0 +1,50 @@
import "reflect-metadata"
import { expect } from "chai"
import {
closeTestingConnections,
createTestingConnections,
reloadTestingDatabases,
} from "../../../utils/test-utils"
import { Event } from "./entity/Event"
import { DataSource } from "../../../../src"
describe("columns > date utc flag", () => {
let originalTZ: string | undefined
let connections: DataSource[]
before(async () => {
originalTZ = process.env.TZ
process.env.TZ = "America/New_York"
connections = await createTestingConnections({
entities: [Event],
})
})
after(async () => {
process.env.TZ = originalTZ
await closeTestingConnections(connections)
})
beforeEach(() => reloadTestingDatabases(connections))
it("should save date columns in UTC when utc flag is true and in local timezone when false", () =>
Promise.all(
connections.map(async (connection) => {
const event = new Event()
const testDate = new Date(Date.UTC(2025, 5, 1)) // 2025-06-01 in UTC
event.localDate = testDate
event.utcDate = testDate
const savedEvent = await connection.manager.save(event)
const result = await connection.manager.findOneBy(Event, {
id: savedEvent.id,
})
// UTC flag true: should save as 2025-06-01 (UTC date)
expect(result!.utcDate).to.equal("2025-06-01")
// UTC flag false (default): should save as 2025-05-31 (local timezone)
expect(result!.localDate).to.equal("2025-05-31")
}),
))
})

View File

@ -0,0 +1,17 @@
import { Entity } from "../../../../../src/decorator/entity/Entity"
import { PrimaryGeneratedColumn } from "../../../../../src/decorator/columns/PrimaryGeneratedColumn"
import { Column } from "../../../../../src/decorator/columns/Column"
@Entity({
name: "event",
})
export class Event {
@PrimaryGeneratedColumn()
id: number
@Column({ type: "date" })
localDate: Date
@Column({ type: "date", utc: true })
utcDate: Date
}