Skip to content

Commit

Permalink
prefer safeParse/safeParseAsync
Browse files Browse the repository at this point in the history
  • Loading branch information
mmkal committed Mar 14, 2024
1 parent 93783ba commit 36d3854
Show file tree
Hide file tree
Showing 9 changed files with 166 additions and 53 deletions.
71 changes: 55 additions & 16 deletions packages/client/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,25 +427,37 @@ expect(newRecords).toEqual([{id: 10, name: 'ten'}])
`sql.type` lets you use a zod schema (or another type validator) to validate the result of a query. See the [Zod](#zod) section for more details.

```typescript
const Fooish = z.object({foo: z.number()})
await expect(client.one(sql.type(Fooish)`select 1 as foo`)).resolves.toMatchInlineSnapshot(`
{
"foo": 1,
}
`)
const StringId = z.object({id: z.string()})
await expect(client.any(sql.type(StringId)`select text(id) id from usage_test`)).resolves.toMatchObject([
{id: '1'},
{id: '2'},
{id: '3'},
])

await expect(client.one(sql.type(Fooish)`select 'hello' as foo`)).rejects.toMatchInlineSnapshot(`
[Error: [Query select_c2b3cb1]: [
const error = await client.any(sql.type(StringId)`select id from usage_test`).catch(e => e)

expect(error.cause).toMatchInlineSnapshot(`
{
"error": [ZodError: [
{
"code": "invalid_type",
"expected": "number",
"received": "string",
"expected": "string",
"received": "number",
"path": [
"foo"
"id"
],
"message": "Expected number, received string"
"message": "Expected string, received number"
}
]]
]],
"query": {
"name": "select-usage_test_8729cac",
"parse": [Function],
"sql": "select id from usage_test",
"templateArgs": [Function],
"token": "sql",
"values": [],
},
}
`)
```

Expand All @@ -466,8 +478,10 @@ const result = await client.one(sql.typeAlias('Profile')`select 'Bob' as name`)
expectTypeOf(result).toEqualTypeOf<{name: string}>()
expect(result).toEqual({name: 'Bob'})

await expect(client.one(sql.typeAlias('Profile')`select 123 as name`)).rejects.toMatchInlineSnapshot(`
[Error: [Query select_245d49b]: [
const err = await client.any(sql.typeAlias('Profile')`select 123 as name`).catch(e => e)
expect(err.cause).toMatchInlineSnapshot(`
{
"error": [ZodError: [
{
"code": "invalid_type",
"expected": "string",
Expand All @@ -477,7 +491,16 @@ await expect(client.one(sql.typeAlias('Profile')`select 123 as name`)).rejects.t
],
"message": "Expected string, received number"
}
]]
]],
"query": {
"name": "select_245d49b",
"parse": [Function],
"sql": "select 123 as name",
"templateArgs": [Function],
"token": "sql",
"values": [],
},
}
`)
```
<!-- codegen:end -->
Expand Down Expand Up @@ -560,6 +583,22 @@ const Profile = {
const profiles = await client.any(sql.type(Profile)`select * from profile`)
```

You can also define `safeParse`, `parseAsync` or `safeParseAsync` as long as they match their zod equivalents:

```ts
import * as v from 'valibot'

const ProfileSchema = v.object({
id: v.string(),
name: v.string(),
})
const Profile = {
parseAsync: async (input: unknown) => v.parse(ProfileSchema, input),
}

const profiles = await client.any(sql.type(Profile)`select * from profile`)
```

You can use any zod features here. For example:

<!-- codegen:start {preset: markdownFromTests, source: test/zod.test.ts} -->
Expand Down
19 changes: 12 additions & 7 deletions packages/client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,20 +71,25 @@ const createQueryable = (query: Queryable['query']): Queryable => {

export const createQueryFn = (pgpQueryable: pgPromise.ITask<any> | pgPromise.IDatabase<any>): Queryable['query'] => {
return async query => {
type Result = SQLQueryResult<typeof query>
let results: Result[]
try {
type Result = SQLQueryResult<typeof query>
const result = await pgpQueryable.query<Result[]>(query.sql, query.values.length > 0 ? query.values : undefined)
if (query.parse === identityParser) {
return {rows: result}
}

return {rows: result.map(query.parse)}
results = await pgpQueryable.query<any>(query.sql, query.values.length > 0 ? query.values : undefined)
} catch (err: unknown) {
const error = errorFromUnknown(err)
throw new QueryError(error.message, {
cause: {query, error},
})
}

try {
return {rows: await Promise.all(results.map(query.parse))}
} catch (err: unknown) {
const error = errorFromUnknown(err)
throw new QueryError(`Parsing rows failed`, {
cause: {query, error},
})
}
}
}

Expand Down
49 changes: 33 additions & 16 deletions packages/client/src/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,46 @@ const sqlMethodHelpers: SQLMethodHelpers = {
values: [],
templateArgs: () => [[query]],
}),
type:
type =>
(strings, ...parameters) => {
type: type => {
type Result = typeof type extends ZodesqueType<infer R> ? R : never
let parseAsync: (input: unknown) => Promise<Result>
if ('parseAsync' in type) {
parseAsync = type.parseAsync
} else if ('safeParseAsync' in type) {
parseAsync = async input => {
const parsed = await type.safeParseAsync(input)
if (!parsed.success) {
throw parsed.error
}
return parsed.data
}
} else if ('parse' in type) {
parseAsync = async input => type.parse(input)
} else if ('safeParse' in type) {
parseAsync = async input => {
const parsed = type.safeParse(input)
if (!parsed.success) {
throw parsed.error
}
return parsed.data
}
} else {
const _: never = type
throw new Error('Invalid type parser. Must have parse, safeParse, parseAsync or safeParseAsync method', {
cause: type,
})
}
return (strings, ...parameters) => {
return {
parse(input) {
if ('parse' in type) {
return type.parse(input)
}

const parsed = type.safeParse(input)
if (!parsed.success) {
throw parsed.error
}

return parsed.data
},
parse: parseAsync,
name: nameQuery(strings),
sql: strings.join(''),
token: 'sql',
values: parameters,
templateArgs: () => [strings, ...parameters],
}
},
}
},
}

const sqlFn: SQLTagFunction = (strings, ...inputParameters) => {
Expand Down
8 changes: 6 additions & 2 deletions packages/client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export interface SQLQuery<Result = Record<string, unknown>, Values extends unkno
name: string
sql: string
values: Values
parse: (input: unknown) => Result
parse: (input: unknown) => Result | Promise<Result>
/** @internal */
templateArgs: () => [strings: readonly string[], ...inputParameters: readonly any[]]
}
Expand Down Expand Up @@ -86,7 +86,11 @@ export type TypeNameIdentifier =
| 'timestamptz'
| 'uuid'

export type ZodesqueType<T> = ZodesqueTypeUnsafe<T> | ZodesqueTypeSafe<T>
export type ZodesqueType<T> =
| ZodesqueTypeUnsafe<T>
| ZodesqueTypeSafe<T>
| ZodesqueTypeAsyncUnsafe<T>
| ZodesqueTypeAsyncSafe<T>
export type ZodesqueTypeUnsafe<T> = {parse: (input: unknown) => T}
export type ZodesqueTypeSafe<T> = {safeParse: (input: unknown) => ZodesqueResult<T>}
export type ZodesqueTypeAsyncUnsafe<T> = {parseAsync: (input: unknown) => Promise<T>}
Expand Down
4 changes: 2 additions & 2 deletions packages/client/test/__snapshots__/slonik37.test.ts.snap
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`createSqlTag + sql.typeAlias 1`] = `[SchemaValidationError: Query returned rows that do not conform with the schema.]`;
exports[`createSqlTag + sql.typeAlias 1`] = `undefined`;

exports[`sql.binary 1`] = `"hello"`;

exports[`sql.interval 1`] = `946818000000`;

exports[`sql.interval 2`] = `86400`;

exports[`sql.type 1`] = `[SchemaValidationError: Query returned rows that do not conform with the schema.]`;
exports[`sql.type 1`] = `undefined`;
35 changes: 29 additions & 6 deletions packages/client/test/api-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,11 @@ test('sql.type', async () => {
{id: '3'},
])

await expect(client.one(sql.type(StringId)`select id from usage_test`)).rejects.toMatchInlineSnapshot(`
[Error: [Query select-usage_test_8729cac]: [
const error = await client.any(sql.type(StringId)`select id from usage_test`).catch(e => e)

expect(error.cause).toMatchInlineSnapshot(`
{
"error": [ZodError: [
{
"code": "invalid_type",
"expected": "string",
Expand All @@ -250,7 +253,16 @@ test('sql.type', async () => {
],
"message": "Expected string, received number"
}
]]
]],
"query": {
"name": "select-usage_test_8729cac",
"parse": [Function],
"sql": "select id from usage_test",
"templateArgs": [Function],
"token": "sql",
"values": [],
},
}
`)
})

Expand All @@ -271,8 +283,10 @@ test('createSqlTag + sql.typeAlias', async () => {
expectTypeOf(result).toEqualTypeOf<{name: string}>()
expect(result).toEqual({name: 'Bob'})

await expect(client.one(sql.typeAlias('Profile')`select 123 as name`)).rejects.toMatchInlineSnapshot(`
[Error: [Query select_245d49b]: [
const err = await client.any(sql.typeAlias('Profile')`select 123 as name`).catch(e => e)
expect(err.cause).toMatchInlineSnapshot(`
{
"error": [ZodError: [
{
"code": "invalid_type",
"expected": "string",
Expand All @@ -282,6 +296,15 @@ test('createSqlTag + sql.typeAlias', async () => {
],
"message": "Expected string, received number"
}
]]
]],
"query": {
"name": "select_245d49b",
"parse": [Function],
"sql": "select 123 as name",
"templateArgs": [Function],
"token": "sql",
"values": [],
},
}
`)
})
8 changes: 7 additions & 1 deletion packages/client/test/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,14 @@ export const generate: import('eslint-plugin-codegen').Preset<GenerateOptions> =
if (i > -1) updated = updated.slice(0, i) + 'toMatchSnapshot()' + updated.slice(endOfSnapshot + 2)
}
return updated
const newContent = updated
.split('\n')
.flatMap((line, i, arr) => (line || arr[i - 1] ? [line] : []))
.join('\n')
if (newContent.includes('toMatchInlineSnapshot')) {
throw new Error(`toMatchInlineSnapshot still exists in the generated content`)
}

return newContent
}
18 changes: 17 additions & 1 deletion packages/client/test/slonik23.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,23 @@ test('sql.join', async () => {
})

/**
* Lets you create reusable SQL fragments, for example a where clause. Note that right now, fragments do not allow parameters.
* Use `sql.fragment` to build reusable pieces which can be plugged into full queries.
*/

test('nested `sql` tag', async () => {
const idGreaterThan = (id: number) => sql`id > ${id}`
const result = await client.any(sql`
select * from test_slonik23 where ${idGreaterThan(1)}
`)

expect(result).toEqual([
{id: 2, name: 'two'},
{id: 3, name: 'three'},
])
})

/**
* A strongly typed helper for creating a PostgreSQL interval. Note that you could also do something like `'1 day'::interval`, but this way avoids a cast and offers typescript types.
*/

test('sql.binary', async () => {
Expand Down
7 changes: 5 additions & 2 deletions packages/client/test/slonik37.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,9 @@ test('sql.type', async () => {
{id: '3'},
])

await expect(client.one(sql.type(StringId)`select id from test_slonik37`)).rejects.toMatchSnapshot()
const error = await client.any(sql.type(StringId)`select id from test_slonik37`).catch(e => e)

expect(error.cause).toMatchSnapshot()
})

/**
Expand All @@ -303,7 +305,8 @@ test('createSqlTag + sql.typeAlias', async () => {
expectTypeOf(result).toEqualTypeOf<{name: string}>()
expect(result).toEqual({name: 'Bob'})

await expect(client.one(sql.typeAlias('Profile')`select 123 as name`)).rejects.toMatchSnapshot()
const err = await client.any(sql.typeAlias('Profile')`select 123 as name`).catch(e => e)
expect(err.cause).toMatchSnapshot()
})
// codegen:end

Expand Down

0 comments on commit 36d3854

Please sign in to comment.