feat(utils,types): DML Index can generate where SQL from a query builder (#7849)

what:

- introduces a simple query builder
- uses the query builder to tranform an object in where to SQL when applying indexes

```
Examples:
  { where: { column: null } } -> column IS NULL
  { where: { column: { $ne: null } } } -> column IS NOT NULL
  { where: { boolean_column: true } } -> boolean_column IS TRUE
  { where: { column: "value", another_column: { $ne: 30 } } } -> column = "value" AND another_column != 30
```

```
const user = model
  .define("user", {
    email: model.text(),
    account: model.text(),
    organization: model.text(),
  })
  .indexes([
    {
      on: ["organization", "account"],
      where: { email: { $ne: null } },
    },
    {
      name: "IDX-email-account-special",
      on: ["organization", "account"],
      where: {
        email: { $ne: null },
        account: null,
      },
    },
```

RESOLVES CORE-2392
This commit is contained in:
Riqwan Thamir
2024-06-27 12:24:34 +02:00
committed by GitHub
parent b1df20b0dc
commit 3f16b011fa
8 changed files with 215 additions and 45 deletions
+13 -2
View File
@@ -180,9 +180,20 @@ export type InferIndexableProperties<T> = keyof (T extends IDmlEntity<
} & InferForeignKeys<T>
: never)
export type EntityIndex<TSchema extends DMLSchema = DMLSchema> = {
export type EntityIndex<
TSchema extends DMLSchema = DMLSchema,
TWhere = string
> = {
name?: string
unique?: boolean
on: InferIndexableProperties<IDmlEntity<TSchema>>[]
where?: string
where?: TWhere
}
export type SimpleQueryValue = string | number | boolean | null
export type NeQueryValue = { $ne: SimpleQueryValue }
export type QueryValue = SimpleQueryValue | NeQueryValue
export interface QueryCondition {
[key: string]: QueryValue | QueryCondition | QueryCondition[]
}
+10 -9
View File
@@ -2,6 +2,7 @@ export * from "./alter-columns-helper"
export * from "./array-difference"
export * from "./array-intersection"
export * from "./build-query"
export * from "./build-regexp-if-valid"
export * from "./camel-to-snake-case"
export * from "./container"
export * from "./convert-item-response-to-update-request"
@@ -11,29 +12,36 @@ export * from "./deduplicate"
export * from "./deep-copy"
export * from "./deep-equal-obj"
export * from "./deep-flat-map"
export * from "./define-config"
export * from "./errors"
export * from "./file-system"
export * from "./generate-entity-id"
export * from "./generate-linkable-keys-map"
export * from "./get-caller-file-path"
export * from "./get-config-file"
export * from "./get-duplicates"
export * from "./get-iso-string-from-date"
export * from "./get-selects-and-relations-from-object-array"
export * from "./get-set-difference"
export * from "./graceful-shutdown-server"
export * from "./group-by"
export * from "./handle-postgres-database-error"
export * from "./is-big-number"
export * from "./is-boolean"
export * from "./is-date"
export * from "./is-defined"
export * from "./is-email"
export * from "./is-object"
export * from "./is-present"
export * from "./is-string"
export * from "./load-env"
export * from "./lower-case-first"
export * from "./map-object-to"
export * from "./medusa-container"
export * from "./object-from-string-path"
export * from "./object-to-string-path"
export * from "./optional-numeric-serializer"
export * from "./parse-cors-origins"
export * from "./partition-array"
export * from "./pick-deep"
export * from "./pick-value-from-object"
@@ -51,18 +59,11 @@ export * from "./simple-hash"
export * from "./string-to-select-relation-object"
export * from "./stringify-circular"
export * from "./to-camel-case"
export * from "./to-handle"
export * from "./to-kebab-case"
export * from "./to-pascal-case"
export * from "./transaction"
export * from "./trim-zeros"
export * from "./upper-case-first"
export * from "./wrap-handler"
export * from "./to-handle"
export * from "./validate-handle"
export * from "./parse-cors-origins"
export * from "./build-regexp-if-valid"
export * from "./load-env"
export * from "./define-config"
export * from "./file-system"
export * from "./graceful-shutdown-server"
export * from "./get-caller-file-path"
export * from "./wrap-handler"
@@ -0,0 +1,3 @@
export function isBoolean(val: any): val is boolean {
return val != null && typeof val === "boolean"
}
@@ -2626,35 +2626,113 @@ describe("Entity builder", () => {
])
})
test("should throw an error if field is unknown for an index", () => {
try {
const group = model.define("group", {
id: model.number(),
name: model.text(),
users: model.hasMany(() => user),
test("should define indexes with a query builder", () => {
const group = model.define("group", {
id: model.number(),
name: model.text(),
users: model.hasMany(() => user),
})
const user = model
.define("user", {
email: model.text(),
account: model.text(),
organization: model.text(),
is_owner: model.boolean(),
group: model.belongsTo(() => group, { mappedBy: "users" }),
})
const user = model
.define("user", {
email: model.text(),
account: model.text(),
organization: model.text(),
group: model.belongsTo(() => group, { mappedBy: "users" }),
})
.indexes([
{
on: ["email", "account", "doesnotexist", "anotherdoesnotexist"],
.indexes([
{
on: ["organization", "account"],
where: { email: { $ne: null } },
},
{
name: "IDX-email-account-special",
on: ["organization", "account"],
where: {
email: { $ne: null },
account: null,
},
])
},
{
name: "IDX_unique-name",
unique: true,
on: ["organization", "account", "group_id"],
},
{
on: ["organization", "group_id"],
where: { is_owner: false },
},
{
on: ["account", "group_id"],
where: { is_owner: true },
},
])
const metaData = MetadataStorage.getMetadataFromDecorator(
toMikroORMEntity(user)
)
throw "should not reach"
expect(metaData.indexes).toEqual([
{
expression:
'CREATE INDEX IF NOT EXISTS "IDX_user_organization_account" ON "user" (organization, account) WHERE email IS NOT NULL AND deleted_at IS NULL',
name: "IDX_user_organization_account",
},
{
expression:
'CREATE INDEX IF NOT EXISTS "IDX-email-account-special" ON "user" (organization, account) WHERE email IS NOT NULL AND account IS NULL AND deleted_at IS NULL',
name: "IDX-email-account-special",
},
{
expression:
'CREATE UNIQUE INDEX IF NOT EXISTS "IDX_unique-name" ON "user" (organization, account, group_id) WHERE deleted_at IS NULL',
name: "IDX_unique-name",
},
{
expression:
'CREATE INDEX IF NOT EXISTS "IDX_user_organization_group_id" ON "user" (organization, group_id) WHERE is_owner IS FALSE AND deleted_at IS NULL',
name: "IDX_user_organization_group_id",
},
{
expression:
'CREATE INDEX IF NOT EXISTS "IDX_user_account_group_id" ON "user" (account, group_id) WHERE is_owner IS TRUE AND deleted_at IS NULL',
name: "IDX_user_account_group_id",
},
])
})
test("should throw an error if field is unknown for an index", () => {
const group = model.define("group", {
id: model.number(),
name: model.text(),
users: model.hasMany(() => user),
})
const user = model
.define("user", {
email: model.text(),
account: model.text(),
organization: model.text(),
group: model.belongsTo(() => group, { mappedBy: "users" }),
})
.indexes([
{
on: ["email", "account", "doesnotexist", "anotherdoesnotexist"],
},
])
let err: any
try {
toMikroORMEntity(user)
} catch (e) {
expect(e.message).toEqual(
"Fields (doesnotexist, anotherdoesnotexist) are not found when applying indexes from DML entity"
)
err = e
}
expect(err.message).toEqual(
`Cannot apply indexes on fields (doesnotexist, anotherdoesnotexist) for model User`
)
})
})
+4 -3
View File
@@ -5,6 +5,7 @@ import {
ExtractEntityRelations,
IDmlEntity,
IsDmlEntity,
QueryCondition,
} from "@medusajs/types"
import { isObject, isString, toCamelCase } from "../common"
import { transformIndexWhere } from "./helpers/entity-builder/build-indexes"
@@ -121,13 +122,13 @@ export class DmlEntity<Schema extends DMLSchema> implements IDmlEntity<Schema> {
return this
}
indexes(indexes: EntityIndex<Schema>[]) {
indexes(indexes: EntityIndex<Schema, string | QueryCondition>[]) {
for (const index of indexes) {
index.where = transformIndexWhere(index)
index.unique = index.unique ?? false
index.unique ??= false
}
this.#indexes = indexes
this.#indexes = indexes as EntityIndex<Schema>[]
return this
}
}
@@ -1,5 +1,6 @@
import { DMLSchema, EntityIndex } from "@medusajs/types"
import { isPresent } from "../../../common"
import { DMLSchema, EntityIndex, QueryCondition } from "@medusajs/types"
import { isObject, isPresent } from "../../../common"
import { buildWhereQuery } from "./query-builder"
/*
The DML provides an opinionated soft deletable entity as a part of every model
@@ -8,9 +9,22 @@ import { isPresent } from "../../../common"
this will need to be updated to include that case.
*/
export function transformIndexWhere<TSchema extends DMLSchema>(
index: EntityIndex<TSchema>
) {
let where = index.where
index: EntityIndex<TSchema, string | QueryCondition>
): string {
return isObject(index.where)
? transformWhereQb(index.where)
: transformWhere(index.where)
}
function transformWhereQb(where: QueryCondition): string {
if (!isPresent(where.deleted_at)) {
where.deleted_at = null
}
return buildWhereQuery(where)
}
function transformWhere(where?: string): string {
const lowerCaseWhere = where?.toLowerCase()
const whereIncludesDeleteable =
lowerCaseWhere?.includes("deleted_at is null") ||
@@ -22,7 +36,7 @@ export function transformIndexWhere<TSchema extends DMLSchema>(
}
// If where scope isn't present, we will set an opinionated where scope to the index
if (!isPresent(where)) {
if (!where?.length) {
where = "deleted_at IS NULL"
}
@@ -0,0 +1,62 @@
import { QueryCondition, QueryValue, SimpleQueryValue } from "@medusajs/types"
import { isBoolean, isDefined, isObject, isString } from "../../../common"
/*
When creating indexes on the entity, we provide a basic query builder to generate
the SQL for where query upon the index. Since this is not a full query builder,
the onus is upon the user to ensure that the SQL is accurately generated.
Examples:
{ where: { column: null } }
{ where: { column: { $ne: null } } }
{ where: { boolean_column: true } }
{ where: { column: "value", another_column: { $ne: 30 }, boolean_column: true } }
*/
export function buildWhereQuery(query: QueryCondition): string {
const conditions: string[] = []
for (const [key, value] of Object.entries(query)) {
if (isQueryCondition(value)) {
conditions.push(buildWhereQuery(value))
} else {
conditions.push(buildCondition(key, value as QueryValue))
}
}
return conditions.filter((condition) => condition?.length).join(" AND ")
}
function isQueryCondition(value: any): value is QueryCondition {
return isObject(value) && value !== null && !("$ne" in value)
}
function buildCondition(key: string, value: QueryValue): string {
if (value === null) {
return `${key} IS NULL`
} else if (isObject(value) && "$ne" in value) {
if (value.$ne === null) {
return `${key} IS NOT NULL`
} else {
return `${key} != ${formatValue(value.$ne)}`
}
} else if (isBoolean(value)) {
return `${key} IS ${formatValue(value)}`
} else if (!isDefined(value)) {
return ""
} else {
return `${key} = ${formatValue(value)}`
}
}
function formatValue(value: SimpleQueryValue): string {
if (isString(value)) {
return `'${value.replace(/'/g, "''")}'` // Escape single quotes
}
if (isBoolean(value)) {
return value ? "TRUE" : "FALSE"
}
return String(value)
}
@@ -24,9 +24,9 @@ export function validateIndexFields<TSchema extends DMLSchema>(
if (invalidFields.length) {
throw new Error(
`Fields (${invalidFields.join(
", "
)}) are not found when applying indexes from DML entity`
`Cannot apply indexes on fields (${invalidFields.join(", ")}) for model ${
MikroORMEntity.name
}`
)
}
}