Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: kysely/helpers/sqlite #588

Merged
merged 13 commits into from
Jul 22, 2023
Merged
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
"import": "./dist/esm/helpers/mysql.js",
"require": "./dist/cjs/helpers/mysql.js",
"default": "./dist/cjs/helpers/mysql.js"
},
"./helpers/sqlite": {
"import": "./dist/esm/helpers/sqlite.js",
"require": "./dist/cjs/helpers/sqlite.js",
"default": "./dist/cjs/helpers/sqlite.js"
}
},
"scripts": {
Expand Down
20 changes: 15 additions & 5 deletions site/docs/recipes/relations.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@ Kysely IS a query builder. Kysely DOES build the SQL you tell it to, nothing mor

Phew, glad we got that out the way..

All that was said above doesn't mean there's no way to nest related rows in your queries.
You just have to do it with the tools SQL and the underlying dialect (e.g. PostgreSQL or MySQL) provide.
In this recipe we show one way to do that when using the built-in PostgreSQL and MySQL dialects.
All that was said above doesn't mean there's no way to nest related rows in your queries.
You just have to do it with the tools SQL and the underlying dialect (e.g. PostgreSQL, MySQL, or SQLite) provide.
In this recipe we show one way to do that when using the built-in PostgreSQL, MySQL, and SQLite dialects.

## The `json` data type and functions

Both PostgreSQL and MySQL have rich JSON support through their `json` data types and functions. `pg` and `mysql2`, the node drivers, automatically parse returned `json` columns as json objects. With the combination of these two things, we can write some super efficient queries with nested relations.
PostgreSQL and MySQL have rich JSON support through their `json` data types and functions. `pg` and `mysql2`, the node drivers, automatically parse returned `json` columns as json objects. With the combination of these two things, we can write some super efficient queries with nested relations.

With the `ParseJSONResultsPlugin`, SQLite can also automatically parse results:

```ts
db = db.withPlugin(new ParseJSONResultsPlugin())
```

Let's start with some raw postgres SQL, and then see how we can write the query using Kysely in a nice type-safe way.

Expand Down Expand Up @@ -82,12 +88,16 @@ These helpers are included in Kysely and you can import them from the `helpers`
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'
```

MySQL versions of the helpers are slightly different but you can use them the same way. You can import them like this:
MySQL and SQLite versions of the helpers are slightly different but you can use them the same way. You can import them like this:

```ts
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/mysql'
```

```ts
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/sqlite'
```

With these helpers, our example query already becomes a little more bearable to look at:

```ts
Expand Down
6 changes: 3 additions & 3 deletions src/helpers/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { Simplify } from '../util/type-utils.js'
* A MySQL helper for aggregating a subquery into a JSON array.
*
* NOTE: This helper is only guaranteed to fully work with the built-in `MysqlDialect`.
* While the produced SQL is compatibe with all MySQL databases, some 3rd party dialects
* While the produced SQL is compatible with all MySQL databases, some 3rd party dialects
* may not parse the nested results into arrays.
*
* ### Examples
Expand Down Expand Up @@ -68,7 +68,7 @@ export function jsonArrayFrom<O>(
* The subquery must only return one row.
*
* NOTE: This helper is only guaranteed to fully work with the built-in `MysqlDialect`.
* While the produced SQL is compatibe with all MySQL databases, some 3rd party dialects
* While the produced SQL is compatible with all MySQL databases, some 3rd party dialects
* may not parse the nested results into objects.
*
* ### Examples
Expand Down Expand Up @@ -121,7 +121,7 @@ export function jsonObjectFrom<O>(
* The MySQL `json_object` function.
*
* NOTE: This helper is only guaranteed to fully work with the built-in `MysqlDialect`.
* While the produced SQL is compatibe with all MySQL databases, some 3rd party dialects
* While the produced SQL is compatible with all MySQL databases, some 3rd party dialects
* may not parse the nested results into objects.
*
* ### Examples
Expand Down
2 changes: 1 addition & 1 deletion src/helpers/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export function jsonObjectFrom<O>(
* The PostgreSQL `json_build_object` function.
*
* NOTE: This helper is only guaranteed to fully work with the built-in `PostgresDialect`.
* While the produced SQL is compatibe with all PostgreSQL databases, some 3rd party dialects
* While the produced SQL is compatible with all PostgreSQL databases, some 3rd party dialects
* may not parse the nested results into objects.
*
* ### Examples
Expand Down
212 changes: 212 additions & 0 deletions src/helpers/sqlite.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { Expression } from '../expression/expression.js'
import { AliasNode } from '../operation-node/alias-node.js'
import { ColumnNode } from '../operation-node/column-node.js'
import { IdentifierNode } from '../operation-node/identifier-node.js'
import { ReferenceNode } from '../operation-node/reference-node.js'
import { SelectQueryNode } from '../operation-node/select-query-node.js'
import { SelectQueryBuilder } from '../query-builder/select-query-builder.js'
import { RawBuilder } from '../raw-builder/raw-builder.js'
import { sql } from '../raw-builder/sql.js'
import { Simplify } from '../util/type-utils.js'

/**
* A SQLite helper for aggregating a subquery into a JSON array.
*
* NOTE: This helper is only guaranteed to fully work with the built-in `SqliteDialect` and `ParseJSONResultsPlugin`.
* While the produced SQL is compatible with many databases, SQLite needs the `ParseJSONResultsPlugin` to automatically parse the results.
*
* ### Examples
*
* Installing the plugin:
*
* ```ts
* db = db.withPlugin(new ParseJSONResultsPlugin())
* ```
*
* Writing the query:
*
* ```ts
* const result = await db
* .selectFrom('person')
* .select((eb) => [
* 'id',
* jsonArrayFrom(
* eb.selectFrom('pet')
* .select(['pet.id as pet_id', 'pet.name'])
* .where('pet.owner_id', '=', 'person.id')
* .orderBy('pet.name')
* ).as('pets')
* ])
* .execute()
*
* result[0].id
* result[0].pets[0].pet_id
* result[0].pets[0].name
* ```
*
* The generated SQL (SQLite):
*
* ```sql
* select "id", (
* select coalesce(json_group_array(json_object(
* 'pet_id', "agg"."pet_id",
* 'name', "agg"."name"
* )), '[]') from (
* select "pet"."id" as "pet_id", "pet"."name"
* from "pet"
* where "pet"."owner_id" = "person"."id"
* order by "pet"."name"
* ) as "agg"
* ) as "pets"
* from "person"
* ```
*/
export function jsonArrayFrom<O>(
expr: SelectQueryBuilder<any, any, O>
): RawBuilder<Simplify<O>[]> {
return sql`(select coalesce(json_group_array(json_object(${sql.join(
getJsonObjectArgs(expr.toOperationNode(), 'agg')
)})), '[]') from ${expr} as agg)`
}

/**
* A SQLite helper for turning a subquery into a JSON object.
*
* The subquery must only return one row.
*
* NOTE: This helper is only guaranteed to fully work with the built-in `SqliteDialect` and `ParseJSONResultsPlugin`.
* While the produced SQL is compatible with many databases, SQLite needs the `ParseJSONResultsPlugin` to automatically parse the results.
*
* ### Examples
*
* Installing the plugin:
*
* ```ts
* db = db.withPlugin(new ParseJSONResultsPlugin())
* ```
*
* Writing the query:
*
* ```ts
* const result = await db
* .selectFrom('person')
* .select((eb) => [
* 'id',
* jsonObjectFrom(
* eb.selectFrom('pet')
* .select(['pet.id as pet_id', 'pet.name'])
* .where('pet.owner_id', '=', 'person.id')
* .where('pet.is_favorite', '=', true)
* ).as('favorite_pet')
* ])
* .execute()
*
* result[0].id
* result[0].favorite_pet.pet_id
* result[0].favorite_pet.name
* ```
*
* The generated SQL (SQLite):
*
* ```sql
* select "id", (
* select json_object(
* 'pet_id', "obj"."pet_id",
* 'name', "obj"."name"
* ) from (
* select "pet"."id" as "pet_id", "pet"."name"
* from "pet"
* where "pet"."owner_id" = "person"."id"
* and "pet"."is_favorite" = ?
* ) as obj
* ) as "favorite_pet"
* from "person";
* ```
*/
export function jsonObjectFrom<O>(
expr: SelectQueryBuilder<any, any, O>
): RawBuilder<Simplify<O> | null> {
return sql`(select json_object(${sql.join(
getJsonObjectArgs(expr.toOperationNode(), 'obj')
)}) from ${expr} as obj)`
}

/**
* The SQLite `json_object` function.
*
* NOTE: This helper is only guaranteed to fully work with the built-in `SqliteDialect` and `ParseJSONResultsPlugin`.
* While the produced SQL is compatible with many databases, SQLite needs the `ParseJSONResultsPlugin` to automatically parse the results.
*
* ### Examples
*
* Installing the plugin:
*
* ```ts
* db = db.withPlugin(new ParseJSONResultsPlugin())
* ```
*
* Writing the query:
*
* ```ts
* const result = await db
* .selectFrom('person')
* .select((eb) => [
* 'id',
* jsonBuildObject({
* first: eb.ref('first_name'),
* last: eb.ref('last_name'),
* full: eb.fn('concat', ['first_name', eb.val(' '), 'last_name'])
* }).as('name')
* ])
* .execute()
*
* result[0].id
* result[0].name.first
* result[0].name.last
* result[0].name.full
* ```
*
* The generated SQL (SQLite):
*
* ```sql
* select "id", json_object(
* 'first', first_name,
* 'last', last_name,
* 'full', "first_name" || ' ' || "last_name"
* ) as "name"
* from "person"
* ```
*/
export function jsonBuildObject<O extends Record<string, Expression<unknown>>>(
obj: O
): RawBuilder<
Simplify<{
[K in keyof O]: O[K] extends Expression<infer V> ? V : never
}>
> {
return sql`json_object(${sql.join(
Object.keys(obj).flatMap((k) => [sql.lit(k), obj[k]])
)})`
}

function getJsonObjectArgs(
node: SelectQueryNode,
table: string
): RawBuilder<unknown>[] {
return node.selections!.flatMap(({ selection: s }) => {
if (ReferenceNode.is(s) && ColumnNode.is(s.column)) {
return [
sql.lit(s.column.column.name),
sql.id(table, s.column.column.name),
]
} else if (ColumnNode.is(s)) {
return [sql.lit(s.column.name), sql.id(table, s.column.name)]
} else if (AliasNode.is(s) && IdentifierNode.is(s.alias)) {
return [sql.lit(s.alias.name), sql.id(table, s.alias.name)]
} else {
throw new Error(
'SQLite jsonArrayFrom and jsonObjectFrom functions can only handle explicit selections due to limitations of the json_object function. selectAll() is not allowed in the subquery.'
)
}
})
}
59 changes: 41 additions & 18 deletions test/node/src/json.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { Generated, Kysely, RawBuilder, sql } from '../../..'
import {
Generated,
Kysely,
RawBuilder,
sql,
ParseJSONResultsPlugin,
} from '../../..'
import {
jsonArrayFrom as pg_jsonArrayFrom,
jsonObjectFrom as pg_jsonObjectFrom,
Expand All @@ -9,6 +15,11 @@ import {
jsonObjectFrom as mysql_jsonObjectFrom,
jsonBuildObject as mysql_jsonBuildObject,
} from '../../../helpers/mysql'
import {
jsonArrayFrom as sqlite_jsonArrayFrom,
jsonObjectFrom as sqlite_jsonObjectFrom,
jsonBuildObject as sqlite_jsonBuildObject,
} from '../../../helpers/sqlite'

import {
destroyTest,
Expand All @@ -31,19 +42,27 @@ interface JsonTable {
}
}

for (const dialect of DIALECTS) {
if (dialect !== 'postgres' && dialect !== 'mysql') {
continue
}

const jsonArrayFrom =
dialect === 'postgres' ? pg_jsonArrayFrom : mysql_jsonArrayFrom

const jsonObjectFrom =
dialect === 'postgres' ? pg_jsonObjectFrom : mysql_jsonObjectFrom
const jsonFunctions = {
postgres: {
jsonArrayFrom: pg_jsonArrayFrom,
jsonObjectFrom: pg_jsonObjectFrom,
jsonBuildObject: pg_jsonBuildObject,
},
mysql: {
jsonArrayFrom: mysql_jsonArrayFrom,
jsonObjectFrom: mysql_jsonObjectFrom,
jsonBuildObject: mysql_jsonBuildObject,
},
sqlite: {
jsonArrayFrom: sqlite_jsonArrayFrom,
jsonObjectFrom: sqlite_jsonObjectFrom,
jsonBuildObject: sqlite_jsonBuildObject,
},
} as const

const jsonBuildObject =
dialect === 'postgres' ? pg_jsonBuildObject : mysql_jsonBuildObject
for (const dialect of DIALECTS) {
const { jsonArrayFrom, jsonObjectFrom, jsonBuildObject } =
jsonFunctions[dialect]

describe(`${dialect} json tests`, () => {
let ctx: TestContext
Expand All @@ -69,6 +88,10 @@ for (const dialect of DIALECTS) {
}

db = ctx.db.withTables<{ json_table: JsonTable }>()

if (dialect === 'sqlite') {
db = db.withPlugin(new ParseJSONResultsPlugin())
}
})

beforeEach(async () => {
Expand Down Expand Up @@ -159,15 +182,15 @@ for (const dialect of DIALECTS) {
jsonBuildObject({
first: eb.ref('first_name'),
last: eb.ref('last_name'),
full: eb.fn('concat', ['first_name', sql.lit(' '), 'last_name']),
full:
dialect === 'sqlite'
? sql<string>`first_name || ' ' || last_name`
: eb.fn('concat', ['first_name', sql.lit(' '), 'last_name']),
}).as('name'),

// Nest an empty list
jsonArrayFrom(
eb
.selectFrom('pet')
.select('id')
.where((eb) => eb.val(false))
eb.selectFrom('pet').select('id').where(sql.lit(false))
).as('emptyList'),
])

Expand Down
Loading