Skip to content

Commit

Permalink
Add DeduplicateJoinsPlugin. closes #47
Browse files Browse the repository at this point in the history
  • Loading branch information
koskimas committed Jan 20, 2022
1 parent 191feaa commit 4963fa2
Show file tree
Hide file tree
Showing 16 changed files with 370 additions and 7 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kysely",
"version": "0.16.6",
"version": "0.16.7",
"description": "Type safe SQL query builder",
"repository": {
"type": "git",
Expand Down
3 changes: 2 additions & 1 deletion recipes/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# Recipes

* [Conditional selects](https://github.com/koskimas/kysely/tree/master/recipes/conditional-selects.md)
* [Conditional selects](https://github.com/koskimas/kysely/tree/master/recipes/conditional-selects.md)
* [Deduplicate joins](https://github.com/koskimas/kysely/tree/master/recipes/deduplicate-joins.md)
73 changes: 73 additions & 0 deletions recipes/deduplicate-joins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Deduplicate joins

When building dynamic queries, you sometimes end up in situations where the same join
could be added twice. Consider this query:

```ts
async function getPerson(id: number, withPetName: boolean, withPetSpecies: boolean) {
return await db
.selectFrom('person')
.selectAll('person')
.if(withPetName, (qb) => qb
.innerJoin('pet', 'pet.owner_id', 'person.id')
.select('pet.name as pet_name')
)
.if(withPetSpecies, (qb) => qb
.innerJoin('pet', 'pet.owner_id', 'person.id')
.select('pet.species as pet_species')
)
.where('person.id', '=', id)
.executeTakeFirst()
}
```

We have two optional selections `pet_name` and `pet_species`. Both of them require
the `pet` table to be joined, but we don't want to add an unnecessary join if both
`withPetName` and `withPetSpecies` are `false`.

But if both `withPetName` and `withPetSpecies` are `true`, we end up with two identical
joins which will cause an error in the database.

To prevent the error from happening, you can install the
[DeduplicateJoinsPlugin](https://koskimas.github.io/kysely/classes/DeduplicateJoinsPlugin.html).
You can either install it globally by providing it in the configuration:

```ts
const db = new Kysely<Database>({
dialect,
plugins: [
new DeduplicateJoinsPlugin(),
]
})
```

or you can use it when needed:

```ts
async function getPerson(id: number, withPetName: boolean, withPetSpecies: boolean) {
return await db
.withPlugin(new DeduplicateJoinsPlugin())
.selectFrom('person')
.selectAll('person')
.if(withPetName, (qb) => qb
.innerJoin('pet', 'pet.owner_id', 'person.id')
.select('pet.name as pet_name')
)
.if(withPetSpecies, (qb) => qb
.innerJoin('pet', 'pet.owner_id', 'person.id')
.select('pet.species as pet_species')
)
.where('person.id', '=', id)
.executeTakeFirst()
}
```

You may wonder why this is a plugin and not the default behavior? The reason is that it's surprisingly
difficult to detect if two joins are identical. It's trivial for simple joins like the ones in the
example, but becomes quite complex with arbitrary joins with nested subqueries etc. There may be
corner cases where the `DeduplicateJoinsPlugin` fails and we don't want it to affect people that
don't need this deduplication (most people).

See [this recipe](https://github.com/koskimas/kysely/tree/master/recipes/conditional-selects.md)
if you are wondering why we are using the `if` method.

1 change: 1 addition & 0 deletions src/index-nodeless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export * from './migration/migrator.js'

export * from './plugin/kysely-plugin.js'
export * from './plugin/camel-case/camel-case-plugin.js'
export * from './plugin/deduplicate-joins/deduplicate-joins-plugin.js'

export * from './operation-node/add-column-node.js'
export * from './operation-node/add-constraint-node.js'
Expand Down
28 changes: 28 additions & 0 deletions src/plugin/deduplicate-joins/deduplicate-joins-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { QueryResult } from '../../driver/database-connection.js'
import { RootOperationNode } from '../../query-compiler/query-compiler.js'
import { UnknownRow } from '../../util/type-utils.js'
import {
KyselyPlugin,
PluginTransformQueryArgs,
PluginTransformResultArgs,
} from '../kysely-plugin.js'
import { DeduplicateJoinsTransformer } from './deduplicate-joins-transformer.js'

/**
* Plugin that removes duplicate joins from queries.
*
* See [this recipe](https://github.com/koskimas/kysely/tree/master/recipes/deduplicate-joins.md)
*/
export class DeduplicateJoinsPlugin implements KyselyPlugin {
readonly #transformer = new DeduplicateJoinsTransformer()

transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
return this.#transformer.transformNode(args.node)
}

transformResult(
args: PluginTransformResultArgs
): Promise<QueryResult<UnknownRow>> {
return Promise.resolve(args.result)
}
}
56 changes: 56 additions & 0 deletions src/plugin/deduplicate-joins/deduplicate-joins-transformer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {
DeleteQueryNode,
JoinNode,
UpdateQueryNode,
} from '../../index-nodeless.js'
import { OperationNodeTransformer } from '../../operation-node/operation-node-transformer.js'
import { SelectQueryNode } from '../../operation-node/select-query-node.js'
import { compare, freeze } from '../../util/object-utils.js'

export class DeduplicateJoinsTransformer extends OperationNodeTransformer {
protected transformSelectQuery(node: SelectQueryNode): SelectQueryNode {
return this.#transformQuery(super.transformSelectQuery(node))
}

protected transformUpdateQuery(node: UpdateQueryNode): UpdateQueryNode {
return this.#transformQuery(super.transformUpdateQuery(node))
}

protected transformDeleteQuery(node: DeleteQueryNode): DeleteQueryNode {
return this.#transformQuery(super.transformDeleteQuery(node))
}

#transformQuery<
T extends SelectQueryNode | UpdateQueryNode | DeleteQueryNode
>(node: T): T {
if (!node.joins || node.joins.length === 0) {
return node
}

return freeze({
...node,
joins: this.#deduplicateJoins(node.joins),
})
}

#deduplicateJoins(joins: ReadonlyArray<JoinNode>): ReadonlyArray<JoinNode> {
const out: JoinNode[] = []

for (let i = 0; i < joins.length; ++i) {
let foundDuplicate = false

for (let j = i + 1; j < joins.length; ++j) {

This comment has been minimized.

Copy link
@Dorcy64

Dorcy64 Sep 11, 2024

We ran into a few issues because we kept the last join instead of the first. Is there a reason you wrote it this way?
We are trying to understand this before we write our function to keep the first.
This is mainly because the SQL language we use requires joins to be ordered a certain way. If you have a join that depends on another join, you want that join to be at the top.
Any explanation would be much appreciated!

if (compare(joins[i], joins[j])) {
foundDuplicate = true
break
}
}

if (!foundDuplicate) {
out.push(joins[i])
}
}

return freeze(out)
}
}
6 changes: 5 additions & 1 deletion src/query-builder/delete-query-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,9 +384,10 @@ export class DeleteQueryBuilder<DB, TB extends keyof DB, O>
* ### Examples
*
* ```ts
* async function deletePerson(returnLastName: boolean) {
* async function deletePerson(id: number, returnLastName: boolean) {
* return await db
* .deleteFrom('person')
* .where('id', '=', id)
* .returning(['id', 'first_name'])
* .if(returnLastName, (qb) => qb.returning('last_name'))
* .executeTakeFirstOrThrow()
Expand Down Expand Up @@ -436,6 +437,9 @@ export class DeleteQueryBuilder<DB, TB extends keyof DB, O>
return new DeleteQueryBuilder(this.#props)
}

/**
* Returns a copy of this DeleteQueryBuilder instance with the given plugin installed.
*/
withPlugin(plugin: KyselyPlugin): DeleteQueryBuilder<DB, TB, O> {
return new DeleteQueryBuilder({
...this.#props,
Expand Down
3 changes: 3 additions & 0 deletions src/query-builder/insert-query-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,9 @@ export class InsertQueryBuilder<DB, TB extends keyof DB, O>
return new InsertQueryBuilder(this.#props)
}

/**
* Returns a copy of this InsertQueryBuilder instance with the given plugin installed.
*/
withPlugin(plugin: KyselyPlugin): InsertQueryBuilder<DB, TB, O> {
return new InsertQueryBuilder({
...this.#props,
Expand Down
3 changes: 3 additions & 0 deletions src/query-builder/select-query-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1279,6 +1279,9 @@ export class SelectQueryBuilder<DB, TB extends keyof DB, O>
return new SelectQueryBuilder(this.#props)
}

/**
* Returns a copy of this SelectQueryBuilder instance with the given plugin installed.
*/
withPlugin(plugin: KyselyPlugin): SelectQueryBuilder<DB, TB, O> {
return new SelectQueryBuilder({
...this.#props,
Expand Down
6 changes: 5 additions & 1 deletion src/query-builder/update-query-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,10 +484,11 @@ export class UpdateQueryBuilder<DB, TB extends keyof DB, O>
* ### Examples
*
* ```ts
* async function updatePerson(updates: UpdateablePerson, returnLastName: boolean) {
* async function updatePerson(id: number, updates: UpdateablePerson, returnLastName: boolean) {
* return await db
* .updateTable('person')
* .set(updates)
* .where('id', '=', id)
* .returning(['id', 'first_name'])
* .if(returnLastName, (qb) => qb.returning('last_name'))
* .executeTakeFirstOrThrow()
Expand Down Expand Up @@ -537,6 +538,9 @@ export class UpdateQueryBuilder<DB, TB extends keyof DB, O>
return new UpdateQueryBuilder(this.#props)
}

/**
* Returns a copy of this UpdateQueryBuilder instance with the given plugin installed.
*/
withPlugin(plugin: KyselyPlugin): UpdateQueryBuilder<DB, TB, O> {
return new UpdateQueryBuilder({
...this.#props,
Expand Down
4 changes: 2 additions & 2 deletions src/query-creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ export class QueryCreator<DB> {
/**
* Creates a delete query.
*
* See the {@link WhereInterface.where} method for examples on how to specify
* See the {@link DeleteQueryBuilder.where} method for examples on how to specify
* a where clause for the delete operation.
*
* The return value of the query is an instance of {@link DeleteResult}.
Expand Down Expand Up @@ -264,7 +264,7 @@ export class QueryCreator<DB> {
/**
* Creates an update query.
*
* See the {@link WhereInterface.where} method for examples on how to specify
* See the {@link UpdateQueryBuilder.where} method for examples on how to specify
* a where clause for the update operation.
*
* See the {@link UpdateQueryBuilder.set} method for examples on how to
Expand Down
2 changes: 1 addition & 1 deletion src/schema/create-view-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { freeze } from '../util/object-utils.js'
import { CreateViewNode } from '../operation-node/create-view-node.js'
import { parseColumnName } from '../parser/reference-parser.js'
import { AnyRawBuilder, AnySelectQueryBuilder } from '../util/type-utils.js'
import { ImmediateValuePlugin } from '../plugin/immediate-value.ts/immediate-value-plugin.js'
import { ImmediateValuePlugin } from '../plugin/immediate-value/immediate-value-plugin.js'

export class CreateViewBuilder implements OperationNodeSource, Compilable {
readonly #props: CreateViewBuilderProps
Expand Down
68 changes: 68 additions & 0 deletions src/util/object-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,71 @@ export function isReadonlyArray(arg: unknown): arg is ReadonlyArray<unknown> {
export function noop<T>(obj: T): T {
return obj
}

export function compare(obj1: unknown, obj2: unknown): boolean {
if (isReadonlyArray(obj1) && isReadonlyArray(obj2)) {
return compareArrays(obj1, obj2)
} else if (isObject(obj1) && isObject(obj2)) {
return compareObjects(obj1, obj2)
}

return obj1 === obj2
}

function compareArrays(
arr1: ReadonlyArray<unknown>,
arr2: ReadonlyArray<unknown>
): boolean {
if (arr1.length !== arr2.length) {
return false
}

for (let i = 0; i < arr1.length; ++i) {
if (!compare(arr1[i], arr2[i])) {
return false
}
}

return true
}

function compareObjects(
obj1: Record<string, unknown>,
obj2: Record<string, unknown>
): boolean {
if (isBuffer(obj1) && isBuffer(obj2)) {
return compareBuffers(obj1, obj2)
} else if (isDate(obj1) && isDate(obj2)) {
return compareDates(obj1, obj2)
}

return compareGenericObjects(obj1, obj2)
}

function compareBuffers(buf1: unknown, buf2: unknown): boolean {
return Buffer.compare(buf1 as any, buf2 as any) === 0
}

function compareDates(date1: Date, date2: Date) {
return date1.getTime() === date2.getTime()
}

function compareGenericObjects(
obj1: Record<string, unknown>,
obj2: Record<string, unknown>
): boolean {
const keys1 = Object.keys(obj1)
const keys2 = Object.keys(obj2)

if (keys1.length !== keys2.length) {
return false
}

for (const key of keys1) {
if (!compare(obj1[key], obj2[key])) {
return false
}
}

return true
}
Loading

0 comments on commit 4963fa2

Please sign in to comment.