Skip to content

Commit 8372531

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 ccc2f0a commit 8372531

File tree

10 files changed

+270
-40
lines changed

10 files changed

+270
-40
lines changed

site/docs/getting-started/Summary.tsx

+11-5
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

+5-7
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

+48-1
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
/**
@@ -511,6 +512,44 @@ export interface ExpressionBuilder<DB, TB extends keyof DB> {
511512
value: VE,
512513
): ExpressionWrapper<DB, TB, ExtractTypeFromValueExpression<VE>>
513514

515+
/**
516+
* Returns a value expression that will be serialized before being passed to the database.
517+
*
518+
* This can be used to pass in an object/array value when inserting/updating a
519+
* value to a column defined with `Json`.
520+
*
521+
* Default serializer function is `JSON.stringify`.
522+
*
523+
* ### Example
524+
*
525+
* ```ts
526+
* import { GeneratedAlways, Json } from 'kysely'
527+
*
528+
* interface Database {
529+
* person: {
530+
* id: GeneratedAlways<number>
531+
* name: string
532+
* experience: Json<{ title: string; company: string }[]>
533+
* preferences: Json<{ locale: string; timezone: string }>
534+
* profile: Json<{ email_verified: boolean }>
535+
* }
536+
* }
537+
*
538+
* const result = await db
539+
* .insertInto('person')
540+
* .values(({ valJson }) => ({
541+
* name: 'Jennifer Aniston',
542+
* experience: valJson([{ title: 'Software Engineer', company: 'Google' }]), // ✔️
543+
* preferences: valJson({ locale: 'en' }), // ❌ missing `timezone`
544+
* profile: JSON.stringify({ email_verified: true }), // ❌ doesn't match `Serialized<{ email_verified }>`
545+
* }))
546+
* .execute()
547+
* ```
548+
*/
549+
valJson<O extends object | null>(
550+
obj: O,
551+
): ExpressionWrapper<DB, TB, Serialized<O>>
552+
514553
/**
515554
* Creates a tuple expression.
516555
*
@@ -1140,6 +1179,14 @@ export function createExpressionBuilder<DB, TB extends keyof DB>(
11401179
return new ExpressionWrapper(parseValueExpression(value))
11411180
},
11421181

1182+
valJson<O extends object | null>(
1183+
value: O,
1184+
): ExpressionWrapper<DB, TB, Serialized<O>> {
1185+
return new ExpressionWrapper(
1186+
ValueNode.create(value, { serialized: true }),
1187+
)
1188+
},
1189+
11431190
refTuple(
11441191
...values: ReadonlyArray<ReferenceExpression<any, any>>
11451192
): ExpressionWrapper<DB, TB, any> {

src/operation-node/value-node.ts

+3-1
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

+10
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,8 @@ export class DefaultQueryCompiler
502502
protected override visitValue(node: ValueNode): void {
503503
if (node.immediate) {
504504
this.appendImmediateValue(node.value)
505+
} else if (node.serialized) {
506+
this.appendSerializedValue(node.value)
505507
} else {
506508
this.appendValue(node.value)
507509
}
@@ -1667,6 +1669,14 @@ export class DefaultQueryCompiler
16671669
this.append(this.getCurrentParameterPlaceholder())
16681670
}
16691671

1672+
protected appendSerializedValue(parameter: unknown): void {
1673+
if (parameter === null) {
1674+
this.appendValue(null)
1675+
} else {
1676+
this.appendValue(JSON.stringify(parameter))
1677+
}
1678+
}
1679+
16701680
protected getLeftIdentifierWrapper(): string {
16711681
return '"'
16721682
}

src/raw-builder/sql.ts

+21
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

@@ -120,6 +121,17 @@ export interface Sql {
120121
*/
121122
val<V>(value: V): RawBuilder<V>
122123

124+
/**
125+
* `sql.valJson(value)` is a shortcut for:
126+
*
127+
* ```ts
128+
* sql<Serialized<ValueType>>`${serializerFn(obj)}`
129+
* ```
130+
*
131+
* Default serializer function is `JSON.stringify`.
132+
*/
133+
valJson<O extends object | null>(value: O): RawBuilder<Serialized<O>>
134+
123135
/**
124136
* @deprecated Use {@link Sql.val} instead.
125137
*/
@@ -398,6 +410,15 @@ export const sql: Sql = Object.assign(
398410
})
399411
},
400412

413+
valJson<O extends object | null>(value: O): RawBuilder<Serialized<O>> {
414+
return createRawBuilder({
415+
queryId: createQueryId(),
416+
rawNode: RawNode.createWithChild(
417+
ValueNode.create(value, { serialized: true }),
418+
),
419+
})
420+
},
421+
401422
value<V>(value: V): RawBuilder<V> {
402423
return this.val(value)
403424
},

src/util/column-type.ts

+28
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,37 @@ export type Generated<S> = ColumnType<S, S | undefined, S>
6767
*/
6868
export type GeneratedAlways<S> = ColumnType<S, never, never>
6969

70+
/**
71+
* A shortcut for defining type-safe JSON columns. Inserts/updates require passing
72+
* values that are wrapped with `eb.valJson` or `sql.valJson` instead of `JSON.stringify`.
73+
*/
74+
export type Json<
75+
SelectType extends object | null,
76+
InsertType extends Serialized<SelectType> | Extract<null, SelectType> =
77+
| Serialized<SelectType>
78+
| Extract<null, SelectType>,
79+
UpdateType extends Serialized<SelectType> | Extract<null, SelectType> =
80+
| Serialized<SelectType>
81+
| Extract<null, SelectType>,
82+
> = ColumnType<SelectType, InsertType, UpdateType>
83+
84+
/**
85+
* A symbol that is used to brand serialized objects/arrays.
86+
* @internal
87+
*/
88+
declare const SerializedBrand: unique symbol
89+
90+
/**
91+
* A type that is used to brand serialized objects/arrays.
92+
*/
93+
export type Serialized<O extends object | null> = O & {
94+
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`. ⚠️'
95+
}
96+
7097
/**
7198
* A shortcut for defining JSON columns, which are by default inserted/updated
7299
* as stringified JSON strings.
100+
* @deprecated Use {@link Json} instead.
73101
*/
74102
export type JSONColumnType<
75103
SelectType extends object | null,

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

+12-12
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)