Skip to content

Commit 0d78de5

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`. Renames `valJson` to `jval` for JSON value wrapping Renames the `valJson` method to `jval` for wrapping JSON values when inserting or updating columns. This change promotes brevity and consistency throughout the codebase. The name change affects the expression builder, SQL raw builder, and documentation.
1 parent d87ac8e commit 0d78de5

File tree

10 files changed

+269
-35
lines changed

10 files changed

+269
-35
lines changed

site/docs/getting-started/Summary.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const postgresqlCodeSnippet = ` await db.schema.createTable('person')
1212
.addColumn('created_at', 'timestamp', (cb) =>
1313
cb.notNull().defaultTo(sql\`now()\`)
1414
)
15+
.addColumn('metadata', 'jsonb', (cb) => cb.notNull())
1516
.execute()`
1617

1718
const dialectSpecificCodeSnippets: Record<Dialect, string> = {
@@ -24,6 +25,7 @@ const dialectSpecificCodeSnippets: Record<Dialect, string> = {
2425
.addColumn('created_at', 'timestamp', (cb) =>
2526
cb.notNull().defaultTo(sql\`now()\`)
2627
)
28+
.addColumn('metadata', 'json', (cb) => cb.notNull())
2729
.execute()`,
2830
// TODO: Update line 42's IDENTITY once identity(1,1) is added to core.
2931
mssql: ` await db.schema.createTable('person')
@@ -34,6 +36,7 @@ const dialectSpecificCodeSnippets: Record<Dialect, string> = {
3436
.addColumn('created_at', 'datetime', (cb) =>
3537
cb.notNull().defaultTo(sql\`GETDATE()\`)
3638
)
39+
.addColumn('metadata', sql\`nvarchar(max)\`, (cb) => cb.notNull())
3740
.execute()`,
3841
sqlite: ` await db.schema.createTable('person')
3942
.addColumn('id', 'integer', (cb) => cb.primaryKey().autoIncrement().notNull())
@@ -43,6 +46,7 @@ const dialectSpecificCodeSnippets: Record<Dialect, string> = {
4346
.addColumn('created_at', 'timestamp', (cb) =>
4447
cb.notNull().defaultTo(sql\`current_timestamp\`)
4548
)
49+
.addColumn('metadata', 'text', (cb) => cb.notNull())
4650
.execute()`,
4751
pglite: postgresqlCodeSnippet,
4852
}
@@ -109,6 +113,12 @@ ${dialectSpecificCodeSnippet}
109113
first_name: 'Jennifer',
110114
last_name: 'Aniston',
111115
gender: 'woman',
116+
metadata: sql.jval({
117+
login_at: new Date().toISOString(),
118+
ip: null,
119+
agent: null,
120+
plan: 'free',
121+
}),
112122
})
113123
})
114124

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.jval` or `sql.jval`.
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(({ jval }) => ({
620+
* name: 'Jennifer Aniston',
621+
* experience: jval([{ title: 'Software Engineer', company: 'Google' }]), // ✔️
622+
* preferences: jval({ locale: 'en' }), // ❌ missing `timezone`
623+
* profile: JSON.stringify({ email_verified: true }), // ❌ doesn't match `Serialized<{ email_verified }>`
624+
* }))
625+
* .execute()
626+
* ```
627+
*/
628+
jval<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+
jval<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.jval(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+
jval<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+
jval<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.jval` or `sql.jval` 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.jval` or `sql.jval`, 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,
@@ -756,9 +756,9 @@ async function initJSONTest<D extends DialectDescriptor>(
756756
let db = testContext.db.withTables<{
757757
person_metadata: {
758758
person_id: number
759-
website: JSONColumnType<{ url: string }>
760-
nicknames: JSONColumnType<string[]>
761-
profile: JSONColumnType<{
759+
website: Json<{ url: string }>
760+
nicknames: Json<string[]>
761+
profile: Json<{
762762
auth: {
763763
roles: string[]
764764
last_login?: { device: string }
@@ -768,12 +768,12 @@ async function initJSONTest<D extends DialectDescriptor>(
768768
avatar: string | null
769769
tags: string[]
770770
}>
771-
experience: JSONColumnType<
771+
experience: Json<
772772
{
773773
establishment: string
774774
}[]
775775
>
776-
schedule: JSONColumnType<{ name: string; time: string }[][][]>
776+
schedule: Json<{ name: string; time: string }[][][]>
777777
}
778778
}>()
779779

@@ -822,20 +822,20 @@ async function insertDefaultJSONDataSet(ctx: TestContext) {
822822

823823
await ctx.db
824824
.insertInto('person_metadata')
825-
.values(
825+
.values((eb) =>
826826
people
827827
.filter((person) => person.first_name && person.last_name)
828828
.map((person, index) => ({
829829
person_id: person.id,
830-
website: JSON.stringify({
830+
website: eb.jval({
831831
url: `https://www.${person.first_name!.toLowerCase()}${person.last_name!.toLowerCase()}.com`,
832832
}),
833-
nicknames: JSON.stringify([
833+
nicknames: eb.jval([
834834
`${person.first_name![0]}.${person.last_name![0]}.`,
835835
`${person.first_name} the Great`,
836836
`${person.last_name} the Magnificent`,
837837
]),
838-
profile: JSON.stringify({
838+
profile: eb.jval({
839839
tags: ['awesome'],
840840
auth: {
841841
roles: ['contributor', 'moderator'],
@@ -847,12 +847,12 @@ async function insertDefaultJSONDataSet(ctx: TestContext) {
847847
},
848848
avatar: null,
849849
}),
850-
experience: JSON.stringify([
850+
experience: eb.jval([
851851
{
852852
establishment: 'The University of Life',
853853
},
854854
]),
855-
schedule: JSON.stringify([[[{ name: 'Gym', time: '12:15' }]]]),
855+
schedule: sql.jval([[[{ name: 'Gym', time: '12:15' }]]]),
856856
})),
857857
)
858858
.execute()

test/typings/shared.d.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,4 @@
1-
import {
2-
ColumnType,
3-
Generated,
4-
GeneratedAlways,
5-
JSONColumnType,
6-
} from '../../dist/cjs'
1+
import { ColumnType, Generated, GeneratedAlways, Json } from '../../dist/cjs'
72

83
export interface Pet {
94
id: Generated<string>
@@ -71,21 +66,21 @@ export interface Person {
7166
export interface PersonMetadata {
7267
id: Generated<number>
7368
person_id: number
74-
website: JSONColumnType<{ url: string }>
75-
nicknames: JSONColumnType<string[]>
76-
profile: JSONColumnType<{
69+
website: Json<{ url: string }>
70+
nicknames: Json<string[]>
71+
profile: Json<{
7772
auth: {
7873
roles: string[]
7974
last_login?: { device: string }
8075
}
8176
tags: string[]
8277
}>
83-
experience: JSONColumnType<
78+
experience: Json<
8479
{
8580
establishment: string
8681
}[]
8782
>
88-
schedule: JSONColumnType<{ name: string; time: string }[][][]>
89-
record: JSONColumnType<Record<string, string>>
90-
array: JSONColumnType<Array<string>>
83+
schedule: Json<{ name: string; time: string }[][][]>
84+
record: Json<Record<string, string>>
85+
array: Json<Array<string> | null>
9186
}

0 commit comments

Comments
 (0)