-
Notifications
You must be signed in to change notification settings - Fork 286
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add DeduplicateJoinsPlugin. closes #47
- Loading branch information
Showing
16 changed files
with
370 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
56
src/plugin/deduplicate-joins/deduplicate-joins-transformer.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
Sorry, something went wrong. |
||
if (compare(joins[i], joins[j])) { | ||
foundDuplicate = true | ||
break | ||
} | ||
} | ||
|
||
if (!foundDuplicate) { | ||
out.push(joins[i]) | ||
} | ||
} | ||
|
||
return freeze(out) | ||
} | ||
} |
File renamed without changes.
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
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!