Skip to content

Commit 0044280

Browse files
committed
introduce Serialized<O>
introduce ValueNode.serialized. introduce eb.valSerialized. introduce sql.valSerialized. fix json-traversal test suite. fix null handling @ compiler. rename to `valJson`. add instructions in errors. typings test inserts. call the new type `Json` instead, to not introduce a breaking change. add missing json column @ Getting Started. add `appendSerializedValue`.
1 parent cdd1cd9 commit 0044280

10 files changed

Lines changed: 270 additions & 40 deletions

File tree

site/docs/getting-started/Summary.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,7 @@ import Admonition from '@theme/Admonition'
22
import CodeBlock from '@theme/CodeBlock'
33
import Link from '@docusaurus/Link'
44
import { IUseADifferentDatabase } from './IUseADifferentDatabase'
5-
import {
6-
PRETTY_DIALECT_NAMES,
7-
type Dialect,
8-
type PropsWithDialect,
9-
} from './shared'
5+
import { type Dialect, type PropsWithDialect } from './shared'
106

117
const dialectSpecificCodeSnippets: Record<Dialect, string> = {
128
postgresql: ` await db.schema.createTable('person')
@@ -17,6 +13,7 @@ const dialectSpecificCodeSnippets: Record<Dialect, string> = {
1713
.addColumn('created_at', 'timestamp', (cb) =>
1814
cb.notNull().defaultTo(sql\`now()\`)
1915
)
16+
.addColumn('metadata', 'jsonb', (cb) => cb.notNull())
2017
.execute()`,
2118
mysql: ` await db.schema.createTable('person')
2219
.addColumn('id', 'integer', (cb) => cb.primaryKey().autoIncrement())
@@ -26,6 +23,7 @@ const dialectSpecificCodeSnippets: Record<Dialect, string> = {
2623
.addColumn('created_at', 'timestamp', (cb) =>
2724
cb.notNull().defaultTo(sql\`now()\`)
2825
)
26+
.addColumn('metadata', 'json', (cb) => cb.notNull())
2927
.execute()`,
3028
// TODO: Update line 42's IDENTITY once identity(1,1) is added to core.
3129
mssql: ` await db.schema.createTable('person')
@@ -36,6 +34,7 @@ const dialectSpecificCodeSnippets: Record<Dialect, string> = {
3634
.addColumn('created_at', 'datetime', (cb) =>
3735
cb.notNull().defaultTo(sql\`GETDATE()\`)
3836
)
37+
.addColumn('metadata', sql\`nvarchar(max)\`, (cb) => cb.notNull())
3938
.execute()`,
4039
sqlite: ` await db.schema.createTable('person')
4140
.addColumn('id', 'integer', (cb) => cb.primaryKey().autoIncrement().notNull())
@@ -45,6 +44,7 @@ const dialectSpecificCodeSnippets: Record<Dialect, string> = {
4544
.addColumn('created_at', 'timestamp', (cb) =>
4645
cb.notNull().defaultTo(sql\`current_timestamp\`)
4746
)
47+
.addColumn('metadata', 'text', (cb) => cb.notNull())
4848
.execute()`,
4949
}
5050

@@ -107,6 +107,12 @@ ${dialectSpecificCodeSnippet}
107107
first_name: 'Jennifer',
108108
last_name: 'Aniston',
109109
gender: 'woman',
110+
metadata: sql.valJson({
111+
login_at: new Date().toISOString(),
112+
ip: null,
113+
agent: null,
114+
plan: 'free',
115+
}),
110116
})
111117
})
112118

site/docs/getting-started/_types.mdx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
ColumnType,
1111
Generated,
1212
Insertable,
13-
JSONColumnType,
13+
Json,
1414
Selectable,
1515
Updateable,
1616
} from 'kysely'
@@ -45,12 +45,10 @@ export interface PersonTable {
4545
// can never be updated:
4646
created_at: ColumnType<Date, string | undefined, never>
4747

48-
// You can specify JSON columns using the `JSONColumnType` wrapper.
49-
// It is a shorthand for `ColumnType<T, string, string>`, where T
50-
// is the type of the JSON object/array retrieved from the database,
51-
// and the insert and update types are always `string` since you're
52-
// always stringifying insert/update values.
53-
metadata: JSONColumnType<{
48+
// You can specify JSON columns using the `Json` wrapper.
49+
// When inserting/updating values of such columns, you're required to wrap the
50+
// values with `eb.valJson` or `sql.valJson`.
51+
metadata: Json<{
5452
login_at: string
5553
ip: string | null
5654
agent: string | null

src/expression/expression-builder.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ import {
6969
ValTuple5,
7070
} from '../parser/tuple-parser.js'
7171
import { TupleNode } from '../operation-node/tuple-node.js'
72-
import { Selectable } from '../util/column-type.js'
72+
import { Selectable, Serialized } from '../util/column-type.js'
7373
import { JSONPathNode } from '../operation-node/json-path-node.js'
7474
import { KyselyTypeError } from '../util/type-error.js'
7575
import {
@@ -78,6 +78,7 @@ import {
7878
} from '../parser/data-type-parser.js'
7979
import { CastNode } from '../operation-node/cast-node.js'
8080
import { SelectFrom } from '../parser/select-from-parser.js'
81+
import { ValueNode } from '../operation-node/value-node.js'
8182

8283
export interface ExpressionBuilder<DB, TB extends keyof DB> {
8384
/**
@@ -590,6 +591,44 @@ export interface ExpressionBuilder<DB, TB extends keyof DB> {
590591
value: VE,
591592
): ExpressionWrapper<DB, TB, ExtractTypeFromValueExpression<VE>>
592593

594+
/**
595+
* Returns a value expression that will be serialized before being passed to the database.
596+
*
597+
* This can be used to pass in an object/array value when inserting/updating a
598+
* value to a column defined with `Json`.
599+
*
600+
* Default serializer function is `JSON.stringify`.
601+
*
602+
* ### Example
603+
*
604+
* ```ts
605+
* import { GeneratedAlways, Json } from 'kysely'
606+
*
607+
* interface Database {
608+
* person: {
609+
* id: GeneratedAlways<number>
610+
* name: string
611+
* experience: Json<{ title: string; company: string }[]>
612+
* preferences: Json<{ locale: string; timezone: string }>
613+
* profile: Json<{ email_verified: boolean }>
614+
* }
615+
* }
616+
*
617+
* const result = await db
618+
* .insertInto('person')
619+
* .values(({ valJson }) => ({
620+
* name: 'Jennifer Aniston',
621+
* experience: valJson([{ title: 'Software Engineer', company: 'Google' }]), // ✔️
622+
* preferences: valJson({ locale: 'en' }), // ❌ missing `timezone`
623+
* profile: JSON.stringify({ email_verified: true }), // ❌ doesn't match `Serialized<{ email_verified }>`
624+
* }))
625+
* .execute()
626+
* ```
627+
*/
628+
valJson<O extends object | null>(
629+
obj: O,
630+
): ExpressionWrapper<DB, TB, Serialized<O>>
631+
593632
/**
594633
* Creates a tuple expression.
595634
*
@@ -1233,6 +1272,14 @@ export function createExpressionBuilder<DB, TB extends keyof DB>(
12331272
return new ExpressionWrapper(parseValueExpression(value))
12341273
},
12351274

1275+
valJson<O extends object | null>(
1276+
value: O,
1277+
): ExpressionWrapper<DB, TB, Serialized<O>> {
1278+
return new ExpressionWrapper(
1279+
ValueNode.create(value, { serialized: true }),
1280+
)
1281+
},
1282+
12361283
refTuple(
12371284
...values: ReadonlyArray<ReferenceExpression<any, any>>
12381285
): ExpressionWrapper<DB, TB, any> {

src/operation-node/value-node.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export interface ValueNode extends OperationNode {
55
readonly kind: 'ValueNode'
66
readonly value: unknown
77
readonly immediate?: boolean
8+
readonly serialized?: boolean
89
}
910

1011
/**
@@ -15,9 +16,10 @@ export const ValueNode = freeze({
1516
return node.kind === 'ValueNode'
1617
},
1718

18-
create(value: unknown): ValueNode {
19+
create(value: unknown, props?: { serialized?: boolean }): ValueNode {
1920
return freeze({
2021
kind: 'ValueNode',
22+
...props,
2123
value,
2224
})
2325
},

src/query-compiler/default-query-compiler.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,8 @@ export class DefaultQueryCompiler
519519
protected override visitValue(node: ValueNode): void {
520520
if (node.immediate) {
521521
this.appendImmediateValue(node.value)
522+
} else if (node.serialized) {
523+
this.appendSerializedValue(node.value)
522524
} else {
523525
this.appendValue(node.value)
524526
}
@@ -1757,6 +1759,14 @@ export class DefaultQueryCompiler
17571759
this.append(this.getCurrentParameterPlaceholder())
17581760
}
17591761

1762+
protected appendSerializedValue(parameter: unknown): void {
1763+
if (parameter === null) {
1764+
this.appendValue(null)
1765+
} else {
1766+
this.appendValue(JSON.stringify(parameter))
1767+
}
1768+
}
1769+
17601770
protected getLeftIdentifierWrapper(): string {
17611771
return '"'
17621772
}

src/raw-builder/sql.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ValueNode } from '../operation-node/value-node.js'
66
import { parseStringReference } from '../parser/reference-parser.js'
77
import { parseTable } from '../parser/table-parser.js'
88
import { parseValueExpression } from '../parser/value-parser.js'
9+
import { Serialized } from '../util/column-type.js'
910
import { createQueryId } from '../util/query-id.js'
1011
import { RawBuilder, createRawBuilder } from './raw-builder.js'
1112

@@ -137,6 +138,17 @@ export interface Sql {
137138
*/
138139
val<V>(value: V): RawBuilder<V>
139140

141+
/**
142+
* `sql.valJson(value)` is a shortcut for:
143+
*
144+
* ```ts
145+
* sql<Serialized<ValueType>>`${serializerFn(obj)}`
146+
* ```
147+
*
148+
* Default serializer function is `JSON.stringify`.
149+
*/
150+
valJson<O extends object | null>(value: O): RawBuilder<Serialized<O>>
151+
140152
/**
141153
* @deprecated Use {@link Sql.val} instead.
142154
*/
@@ -417,6 +429,15 @@ export const sql: Sql = Object.assign(
417429
})
418430
},
419431

432+
valJson<O extends object | null>(value: O): RawBuilder<Serialized<O>> {
433+
return createRawBuilder({
434+
queryId: createQueryId(),
435+
rawNode: RawNode.createWithChild(
436+
ValueNode.create(value, { serialized: true }),
437+
),
438+
})
439+
},
440+
420441
value<V>(value: V): RawBuilder<V> {
421442
return this.val(value)
422443
},

src/util/column-type.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,37 @@ export type Generated<S> = ColumnType<S, S | undefined, S>
6363
*/
6464
export type GeneratedAlways<S> = ColumnType<S, never, never>
6565

66+
/**
67+
* A shortcut for defining type-safe JSON columns. Inserts/updates require passing
68+
* values that are wrapped with `eb.valJson` or `sql.valJson` instead of `JSON.stringify`.
69+
*/
70+
export type Json<
71+
SelectType extends object | null,
72+
InsertType extends Serialized<SelectType> | Extract<null, SelectType> =
73+
| Serialized<SelectType>
74+
| Extract<null, SelectType>,
75+
UpdateType extends Serialized<SelectType> | Extract<null, SelectType> =
76+
| Serialized<SelectType>
77+
| Extract<null, SelectType>,
78+
> = ColumnType<SelectType, InsertType, UpdateType>
79+
80+
/**
81+
* A symbol that is used to brand serialized objects/arrays.
82+
* @internal
83+
*/
84+
declare const SerializedBrand: unique symbol
85+
86+
/**
87+
* A type that is used to brand serialized objects/arrays.
88+
*/
89+
export type Serialized<O extends object | null> = O & {
90+
readonly [SerializedBrand]: '⚠️ When you insert into or update columns of type `Json` (or similar), you should wrap your JSON value with `eb.valJson` or `sql.valJson`, instead of `JSON.stringify`. ⚠️'
91+
}
92+
6693
/**
6794
* A shortcut for defining JSON columns, which are by default inserted/updated
6895
* as stringified JSON strings.
96+
* @deprecated Use {@link Json} instead.
6997
*/
7098
export type JSONColumnType<
7199
SelectType extends object | null,

test/node/src/json-traversal.test.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {
22
ColumnDefinitionBuilder,
3-
JSONColumnType,
3+
Json,
44
ParseJSONResultsPlugin,
55
SqlBool,
66
sql,
@@ -732,9 +732,9 @@ async function initJSONTest<D extends BuiltInDialect>(
732732
let db = testContext.db.withTables<{
733733
person_metadata: {
734734
person_id: number
735-
website: JSONColumnType<{ url: string }>
736-
nicknames: JSONColumnType<string[]>
737-
profile: JSONColumnType<{
735+
website: Json<{ url: string }>
736+
nicknames: Json<string[]>
737+
profile: Json<{
738738
auth: {
739739
roles: string[]
740740
last_login?: { device: string }
@@ -744,12 +744,12 @@ async function initJSONTest<D extends BuiltInDialect>(
744744
avatar: string | null
745745
tags: string[]
746746
}>
747-
experience: JSONColumnType<
747+
experience: Json<
748748
{
749749
establishment: string
750750
}[]
751751
>
752-
schedule: JSONColumnType<{ name: string; time: string }[][][]>
752+
schedule: Json<{ name: string; time: string }[][][]>
753753
}
754754
}>()
755755

@@ -798,20 +798,20 @@ async function insertDefaultJSONDataSet(ctx: TestContext) {
798798

799799
await ctx.db
800800
.insertInto('person_metadata')
801-
.values(
801+
.values((eb) =>
802802
people
803803
.filter((person) => person.first_name && person.last_name)
804804
.map((person, index) => ({
805805
person_id: person.id,
806-
website: JSON.stringify({
806+
website: eb.valJson({
807807
url: `https://www.${person.first_name!.toLowerCase()}${person.last_name!.toLowerCase()}.com`,
808808
}),
809-
nicknames: JSON.stringify([
809+
nicknames: eb.valJson([
810810
`${person.first_name![0]}.${person.last_name![0]}.`,
811811
`${person.first_name} the Great`,
812812
`${person.last_name} the Magnificent`,
813813
]),
814-
profile: JSON.stringify({
814+
profile: eb.valJson({
815815
tags: ['awesome'],
816816
auth: {
817817
roles: ['contributor', 'moderator'],
@@ -823,12 +823,12 @@ async function insertDefaultJSONDataSet(ctx: TestContext) {
823823
},
824824
avatar: null,
825825
}),
826-
experience: JSON.stringify([
826+
experience: eb.valJson([
827827
{
828828
establishment: 'The University of Life',
829829
},
830830
]),
831-
schedule: JSON.stringify([[[{ name: 'Gym', time: '12:15' }]]]),
831+
schedule: sql.valJson([[[{ name: 'Gym', time: '12:15' }]]]),
832832
})),
833833
)
834834
.execute()

0 commit comments

Comments
 (0)