Skip to content

Commit

Permalink
introduce Serialized<O>
Browse files Browse the repository at this point in the history
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`.
  • Loading branch information
igalklebanov committed Nov 3, 2024
1 parent 93b6ee2 commit 1cda284
Show file tree
Hide file tree
Showing 10 changed files with 270 additions and 40 deletions.
16 changes: 11 additions & 5 deletions site/docs/getting-started/Summary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@ import Admonition from '@theme/Admonition'
import CodeBlock from '@theme/CodeBlock'
import Link from '@docusaurus/Link'
import { IUseADifferentDatabase } from './IUseADifferentDatabase'
import {
PRETTY_DIALECT_NAMES,
type Dialect,
type PropsWithDialect,
} from './shared'
import { type Dialect, type PropsWithDialect } from './shared'

const dialectSpecificCodeSnippets: Record<Dialect, string> = {
postgresql: ` await db.schema.createTable('person')
Expand All @@ -17,6 +13,7 @@ const dialectSpecificCodeSnippets: Record<Dialect, string> = {
.addColumn('created_at', 'timestamp', (cb) =>
cb.notNull().defaultTo(sql\`now()\`)
)
.addColumn('metadata', 'jsonb', (cb) => cb.notNull())
.execute()`,
mysql: ` await db.schema.createTable('person')
.addColumn('id', 'integer', (cb) => cb.primaryKey().autoIncrement())
Expand All @@ -26,6 +23,7 @@ const dialectSpecificCodeSnippets: Record<Dialect, string> = {
.addColumn('created_at', 'timestamp', (cb) =>
cb.notNull().defaultTo(sql\`now()\`)
)
.addColumn('metadata', 'json', (cb) => cb.notNull())
.execute()`,
// TODO: Update line 42's IDENTITY once identity(1,1) is added to core.
mssql: ` await db.schema.createTable('person')
Expand All @@ -36,6 +34,7 @@ const dialectSpecificCodeSnippets: Record<Dialect, string> = {
.addColumn('created_at', 'datetime', (cb) =>
cb.notNull().defaultTo(sql\`GETDATE()\`)
)
.addColumn('metadata', sql\`nvarchar(max)\`, (cb) => cb.notNull())
.execute()`,
sqlite: ` await db.schema.createTable('person')
.addColumn('id', 'integer', (cb) => cb.primaryKey().autoIncrement().notNull())
Expand All @@ -45,6 +44,7 @@ const dialectSpecificCodeSnippets: Record<Dialect, string> = {
.addColumn('created_at', 'timestamp', (cb) =>
cb.notNull().defaultTo(sql\`current_timestamp\`)
)
.addColumn('metadata', 'text', (cb) => cb.notNull())
.execute()`,
}

Expand Down Expand Up @@ -107,6 +107,12 @@ ${dialectSpecificCodeSnippet}
first_name: 'Jennifer',
last_name: 'Aniston',
gender: 'woman',
metadata: sql.valJson({
login_at: new Date().toISOString(),
ip: null,
agent: null,
plan: 'free',
}),
})
})
Expand Down
12 changes: 5 additions & 7 deletions site/docs/getting-started/_types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
ColumnType,
Generated,
Insertable,
JSONColumnType,
Json,
Selectable,
Updateable,
} from 'kysely'
Expand Down Expand Up @@ -45,12 +45,10 @@ export interface PersonTable {
// can never be updated:
created_at: ColumnType<Date, string | undefined, never>

// You can specify JSON columns using the `JSONColumnType` wrapper.
// It is a shorthand for `ColumnType<T, string, string>`, where T
// is the type of the JSON object/array retrieved from the database,
// and the insert and update types are always `string` since you're
// always stringifying insert/update values.
metadata: JSONColumnType<{
// You can specify JSON columns using the `Json` wrapper.
// When inserting/updating values of such columns, you're required to wrap the
// values with `eb.valJson` or `sql.valJson`.
metadata: Json<{
login_at: string
ip: string | null
agent: string | null
Expand Down
49 changes: 48 additions & 1 deletion src/expression/expression-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ import {
ValTuple5,
} from '../parser/tuple-parser.js'
import { TupleNode } from '../operation-node/tuple-node.js'
import { Selectable } from '../util/column-type.js'
import { Selectable, Serialized } from '../util/column-type.js'
import { JSONPathNode } from '../operation-node/json-path-node.js'
import { KyselyTypeError } from '../util/type-error.js'
import {
Expand All @@ -78,6 +78,7 @@ import {
} from '../parser/data-type-parser.js'
import { CastNode } from '../operation-node/cast-node.js'
import { SelectFrom } from '../parser/select-from-parser.js'
import { ValueNode } from '../operation-node/value-node.js'

export interface ExpressionBuilder<DB, TB extends keyof DB> {
/**
Expand Down Expand Up @@ -511,6 +512,44 @@ export interface ExpressionBuilder<DB, TB extends keyof DB> {
value: VE,
): ExpressionWrapper<DB, TB, ExtractTypeFromValueExpression<VE>>

/**
* Returns a value expression that will be serialized before being passed to the database.
*
* This can be used to pass in an object/array value when inserting/updating a
* value to a column defined with `Json`.
*
* Default serializer function is `JSON.stringify`.
*
* ### Example
*
* ```ts
* import { GeneratedAlways, Json } from 'kysely'
*
* interface Database {
* person: {
* id: GeneratedAlways<number>
* name: string
* experience: Json<{ title: string; company: string }[]>
* preferences: Json<{ locale: string; timezone: string }>
* profile: Json<{ email_verified: boolean }>
* }
* }
*
* const result = await db
* .insertInto('person')
* .values(({ valJson }) => ({
* name: 'Jennifer Aniston',
* experience: valJson([{ title: 'Software Engineer', company: 'Google' }]), // ✔️
* preferences: valJson({ locale: 'en' }), // ❌ missing `timezone`
* profile: JSON.stringify({ email_verified: true }), // ❌ doesn't match `Serialized<{ email_verified }>`
* }))
* .execute()
* ```
*/
valJson<O extends object | null>(
obj: O,
): ExpressionWrapper<DB, TB, Serialized<O>>

/**
* Creates a tuple expression.
*
Expand Down Expand Up @@ -1140,6 +1179,14 @@ export function createExpressionBuilder<DB, TB extends keyof DB>(
return new ExpressionWrapper(parseValueExpression(value))
},

valJson<O extends object | null>(
value: O,
): ExpressionWrapper<DB, TB, Serialized<O>> {
return new ExpressionWrapper(
ValueNode.create(value, { serialized: true }),
)
},

refTuple(
...values: ReadonlyArray<ReferenceExpression<any, any>>
): ExpressionWrapper<DB, TB, any> {
Expand Down
4 changes: 3 additions & 1 deletion src/operation-node/value-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface ValueNode extends OperationNode {
readonly kind: 'ValueNode'
readonly value: unknown
readonly immediate?: boolean
readonly serialized?: boolean
}

/**
Expand All @@ -15,9 +16,10 @@ export const ValueNode = freeze({
return node.kind === 'ValueNode'
},

create(value: unknown): ValueNode {
create(value: unknown, props?: { serialized?: boolean }): ValueNode {
return freeze({
kind: 'ValueNode',
...props,
value,
})
},
Expand Down
10 changes: 10 additions & 0 deletions src/query-compiler/default-query-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,8 @@ export class DefaultQueryCompiler
protected override visitValue(node: ValueNode): void {
if (node.immediate) {
this.appendImmediateValue(node.value)
} else if (node.serialized) {
this.appendSerializedValue(node.value)
} else {
this.appendValue(node.value)
}
Expand Down Expand Up @@ -1667,6 +1669,14 @@ export class DefaultQueryCompiler
this.append(this.getCurrentParameterPlaceholder())
}

protected appendSerializedValue(parameter: unknown): void {
if (parameter === null) {
this.appendValue(null)
} else {
this.appendValue(JSON.stringify(parameter))
}
}

protected getLeftIdentifierWrapper(): string {
return '"'
}
Expand Down
21 changes: 21 additions & 0 deletions src/raw-builder/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ValueNode } from '../operation-node/value-node.js'
import { parseStringReference } from '../parser/reference-parser.js'
import { parseTable } from '../parser/table-parser.js'
import { parseValueExpression } from '../parser/value-parser.js'
import { Serialized } from '../util/column-type.js'
import { createQueryId } from '../util/query-id.js'
import { RawBuilder, createRawBuilder } from './raw-builder.js'

Expand Down Expand Up @@ -120,6 +121,17 @@ export interface Sql {
*/
val<V>(value: V): RawBuilder<V>

/**
* `sql.valJson(value)` is a shortcut for:
*
* ```ts
* sql<Serialized<ValueType>>`${serializerFn(obj)}`
* ```
*
* Default serializer function is `JSON.stringify`.
*/
valJson<O extends object | null>(value: O): RawBuilder<Serialized<O>>

/**
* @deprecated Use {@link Sql.val} instead.
*/
Expand Down Expand Up @@ -398,6 +410,15 @@ export const sql: Sql = Object.assign(
})
},

valJson<O extends object | null>(value: O): RawBuilder<Serialized<O>> {
return createRawBuilder({
queryId: createQueryId(),
rawNode: RawNode.createWithChild(
ValueNode.create(value, { serialized: true }),
),
})
},

value<V>(value: V): RawBuilder<V> {
return this.val(value)
},
Expand Down
28 changes: 28 additions & 0 deletions src/util/column-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,37 @@ export type Generated<S> = ColumnType<S, S | undefined, S>
*/
export type GeneratedAlways<S> = ColumnType<S, never, never>

/**
* A shortcut for defining type-safe JSON columns. Inserts/updates require passing
* values that are wrapped with `eb.valJson` or `sql.valJson` instead of `JSON.stringify`.
*/
export type Json<
SelectType extends object | null,
InsertType extends Serialized<SelectType> | Extract<null, SelectType> =
| Serialized<SelectType>
| Extract<null, SelectType>,
UpdateType extends Serialized<SelectType> | Extract<null, SelectType> =
| Serialized<SelectType>
| Extract<null, SelectType>,
> = ColumnType<SelectType, InsertType, UpdateType>

/**
* A symbol that is used to brand serialized objects/arrays.
* @internal
*/
declare const SerializedBrand: unique symbol

/**
* A type that is used to brand serialized objects/arrays.
*/
export type Serialized<O extends object | null> = O & {
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`. ⚠️'
}

/**
* A shortcut for defining JSON columns, which are by default inserted/updated
* as stringified JSON strings.
* @deprecated Use {@link Json} instead.
*/
export type JSONColumnType<
SelectType extends object | null,
Expand Down
24 changes: 12 additions & 12 deletions test/node/src/json-traversal.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
ColumnDefinitionBuilder,
JSONColumnType,
Json,
ParseJSONResultsPlugin,
SqlBool,
sql,
Expand Down Expand Up @@ -732,9 +732,9 @@ async function initJSONTest<D extends BuiltInDialect>(
let db = testContext.db.withTables<{
person_metadata: {
person_id: number
website: JSONColumnType<{ url: string }>
nicknames: JSONColumnType<string[]>
profile: JSONColumnType<{
website: Json<{ url: string }>
nicknames: Json<string[]>
profile: Json<{
auth: {
roles: string[]
last_login?: { device: string }
Expand All @@ -744,12 +744,12 @@ async function initJSONTest<D extends BuiltInDialect>(
avatar: string | null
tags: string[]
}>
experience: JSONColumnType<
experience: Json<
{
establishment: string
}[]
>
schedule: JSONColumnType<{ name: string; time: string }[][][]>
schedule: Json<{ name: string; time: string }[][][]>
}
}>()

Expand Down Expand Up @@ -798,20 +798,20 @@ async function insertDefaultJSONDataSet(ctx: TestContext) {

await ctx.db
.insertInto('person_metadata')
.values(
.values((eb) =>
people
.filter((person) => person.first_name && person.last_name)
.map((person, index) => ({
person_id: person.id,
website: JSON.stringify({
website: eb.valJson({
url: `https://www.${person.first_name!.toLowerCase()}${person.last_name!.toLowerCase()}.com`,
}),
nicknames: JSON.stringify([
nicknames: eb.valJson([
`${person.first_name![0]}.${person.last_name![0]}.`,
`${person.first_name} the Great`,
`${person.last_name} the Magnificent`,
]),
profile: JSON.stringify({
profile: eb.valJson({
tags: ['awesome'],
auth: {
roles: ['contributor', 'moderator'],
Expand All @@ -823,12 +823,12 @@ async function insertDefaultJSONDataSet(ctx: TestContext) {
},
avatar: null,
}),
experience: JSON.stringify([
experience: eb.valJson([
{
establishment: 'The University of Life',
},
]),
schedule: JSON.stringify([[[{ name: 'Gym', time: '12:15' }]]]),
schedule: sql.valJson([[[{ name: 'Gym', time: '12:15' }]]]),
})),
)
.execute()
Expand Down
Loading

0 comments on commit 1cda284

Please sign in to comment.