Skip to content

Commit

Permalink
feat: allow unique and exists validations to perform case insensitive…
Browse files Browse the repository at this point in the history
… search
  • Loading branch information
thetutlage committed Dec 4, 2024
1 parent 62a1ba5 commit 761f823
Show file tree
Hide file tree
Showing 5 changed files with 622 additions and 69 deletions.
36 changes: 16 additions & 20 deletions providers/database_provider.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
/*
* @adonisjs/lucid
*
* (c) Harminder Virk <[email protected]>
* (c) AdonisJS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import type { FieldContext } from '@vinejs/vine/types'
import type { ApplicationService } from '@adonisjs/core/types'

import { Database } from '../src/database/main.js'
Expand All @@ -16,7 +15,7 @@ import { QueryClient } from '../src/query_client/index.js'
import { BaseModel } from '../src/orm/base_model/index.js'
import { DatabaseTestUtils } from '../src/test_utils/database.js'
import type { DatabaseConfig, DbQueryEventNode } from '../src/types/database.js'
import { DatabaseQueryBuilderContract } from '../src/types/querybuilder.js'
import { VineDbSearchCallback, VineDbSearchOptions } from '../src/types/vine.js'

/**
* Extending AdonisJS types
Expand All @@ -40,43 +39,40 @@ declare module '@adonisjs/core/test_utils' {
* Extending VineJS schema types
*/
declare module '@vinejs/vine' {
interface VineLucidBindings {
interface VineLucidBindings<ValueType> {
/**
* Ensure the value is unique inside the database by table and column name.
* Optionally, you can define a filter to narrow down the query.
*/
unique(options: VineDbSearchOptions): this

/**
* Ensure the value is unique inside the database by self
* executing a query.
*
* - The callback must return "true", if the value is unique (does not exist).
* - The callback must return "false", if the value is not unique (already exists).
*/
unique(callback: (db: Database, value: string, field: FieldContext) => Promise<boolean>): this
unique(callback: VineDbSearchCallback<ValueType>): this

/**
* Ensure the value is unique inside the database by table and column name.
* Ensure the value exists inside the database by table and column name.
* Optionally, you can define a filter to narrow down the query.
*/
unique(options: {
table: string
column?: string
filter?: (
db: DatabaseQueryBuilderContract,
value: unknown,
field: FieldContext
) => Promise<void>
}): this
exists(options: VineDbSearchOptions): this

/**
* Ensure the value is exists inside the database by self
* Ensure the value exists inside the database by self
* executing a query.
*
* - The callback must return "false", if the value exists.
* - The callback must return "true", if the value does not exist.
*/
exists(callback: (db: Database, value: string, field: FieldContext) => Promise<boolean>): this
exists(callback: VineDbSearchCallback<ValueType>): this
}

interface VineNumber extends VineLucidBindings {}

interface VineString extends VineLucidBindings {}
interface VineNumber extends VineLucidBindings<number> {}
interface VineString extends VineLucidBindings<string> {}
}

/**
Expand Down
149 changes: 100 additions & 49 deletions src/bindings/vinejs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,80 +7,131 @@
* file that was distributed with this source code.
*/

import vine, { VineNumber, VineString } from '@vinejs/vine'
import type { Database } from '../database/main.js'
import type { FieldContext } from '@vinejs/vine/types'
import { DatabaseQueryBuilderContract } from '../types/querybuilder.js'
import vine, { VineNumber, VineString } from '@vinejs/vine'
import { VineDbSearchCallback, VineDbSearchOptions } from '../types/vine.js'

/**
* Default validation messages used by the unique and the
* exists rules
*/
export const messages = {
'database.unique': 'The {{ field }} has already been taken',
'database.exists': 'The selected {{ field }} is invalid',
}

/**
* Defines the "unique" and "exists" validation rules with
* VineJS.
*/
export function defineValidationRules(db: Database) {
const uniqueRule = vine.createRule<
| ((db: Database, value: string, field: FieldContext) => Promise<boolean>)
| {
table: string
column?: string
filter?: (
db: DatabaseQueryBuilderContract,
value: unknown,
field: FieldContext
) => Promise<void>
const uniqueRule = vine.createRule<VineDbSearchCallback | VineDbSearchOptions>(

Check failure on line 28 in src/bindings/vinejs.ts

View workflow job for this annotation

GitHub Actions / typecheck

Generic type 'VineDbSearchCallback' requires 1 type argument(s).
async (value, callbackOrOptions, field) => {
if (!field.isValid) {
return
}
>(async (value, checkerOrOptions, field) => {
if (!field.isValid) {
return
}

if (typeof checkerOrOptions === 'function') {
const isUnique = await checkerOrOptions(db, value as string, field)
if (!isUnique) {
field.report('The {{ field }} has already been taken', 'database.unique', field)
/**
* Rely on the callback to execute the query and return value
* a boolean.
*
* True means value is unique
* False means value is not unique
*/
if (typeof callbackOrOptions === 'function') {
const isUnique = await callbackOrOptions(db, value as string, field)
if (!isUnique) {
field.report(messages['database.unique'], 'database.unique', field)
}
return
}
return
}

if (typeof value !== 'string') {
return
}
const { table, column, filter, connection, caseInsensitive } = callbackOrOptions
const query = db.connection(connection).from(table).select(column)

if (typeof field.name !== 'string') {
return
}
/**
* Apply where clause respecting the caseInsensitive flag.
*/
if (caseInsensitive) {
query.whereRaw(`lower(${column}) = ?`, [db.raw(`lower(?)`, [value])])
} else {
query.where(column, value as string)
}

/**
* Apply user filter
*/
await filter?.(query, value as string, field)

const { table, column = field.name, filter } = checkerOrOptions
const baseQuery = db.from(table).select(column).where(column, value)
await filter?.(baseQuery, value, field)
const row = await baseQuery.first()
if (row) {
field.report('The {{ field }} has already been taken', 'database.unique', field)
/**
* Fetch the first row from the database
*/
const row = await query.first()
if (row) {
field.report(messages['database.unique'], 'database.unique', field)
}
}
})
)

const existsRule = vine.createRule<Parameters<VineString['exists'] | VineNumber['exists']>[0]>(
async (value, checker, field) => {
const existsRule = vine.createRule<VineDbSearchCallback | VineDbSearchOptions>(

Check failure on line 76 in src/bindings/vinejs.ts

View workflow job for this annotation

GitHub Actions / typecheck

Generic type 'VineDbSearchCallback' requires 1 type argument(s).
async (value, callbackOrOptions, field) => {
if (!field.isValid) {
return
}

const exists = await checker(db, value as string, field)
if (!exists) {
field.report('The selected {{ field }} is invalid', 'database.exists', field)
/**
* Rely on the callback to execute the query and return value
* a boolean.
*
* True means value exists
* False means value does not exist
*/
if (typeof callbackOrOptions === 'function') {
const exists = await callbackOrOptions(db, value as string, field)
if (!exists) {
field.report(messages['database.exists'], 'database.exists', field)
}
return
}

const { table, column, filter, connection, caseInsensitive } = callbackOrOptions
const query = db.connection(connection).from(table).select(column)

/**
* Apply where clause respecting the caseInsensitive flag.
*/
if (caseInsensitive) {
query.whereRaw(`lower(${column}) = ?`, [db.raw(`lower(?)`, [value])])
} else {
query.where(column, value as string)
}

/**
* Apply user filter
*/
await filter?.(query, value as string, field)

/**
* Fetch the first row from the database
*/
const row = await query.first()
if (!row) {
field.report(messages['database.exists'], 'database.exists', field)
}
}
)

VineString.macro('unique', function (this: VineString, checkerOrOptions) {
return this.use(uniqueRule(checkerOrOptions))
VineString.macro('unique', function (this: VineString, callbackOrOptions) {
return this.use(uniqueRule(callbackOrOptions))
})
VineString.macro('exists', function (this: VineString, checker) {
return this.use(existsRule(checker))
VineString.macro('exists', function (this: VineString, callbackOrOptions) {
return this.use(existsRule(callbackOrOptions))
})
VineNumber.macro('unique', function (this: VineNumber, checkerOrOptions) {
return this.use(uniqueRule(checkerOrOptions))

VineNumber.macro('unique', function (this: VineNumber, callbackOrOptions) {
return this.use(uniqueRule(callbackOrOptions))
})
VineNumber.macro('exists', function (this: VineNumber, checker) {
return this.use(existsRule(checker))
VineNumber.macro('exists', function (this: VineNumber, callbackOrOptions) {
return this.use(existsRule(callbackOrOptions))
})
}
65 changes: 65 additions & 0 deletions src/types/vine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* @adonisjs/lucid
*
* (c) AdonisJS
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

import type { FieldContext } from '@vinejs/vine/types'
import type { Database } from '../database/main.js'
import type { DatabaseQueryBuilderContract } from './querybuilder.js'

/**
* Options for the unique and the exists validations
*/
export type VineDbSearchOptions = {
/**
* Database table for the query
*/
table: string

/**
* The column against which to search the value
*/
column: string

/**
* Specify a custom connection for the query
*/
connection?: string

/**
* Enable to perform a case insensitive search on the column. The
* current value and the existing value in the database will be
* lowercased using the "lower" function
*
* https://www.sqlite.org/lang_corefunc.html#lower
* https://docs.aws.amazon.com/redshift/latest/dg/r_LOWER.html
* https://dev.mysql.com/doc/refman/8.0/en/string-functions.html#function_lower
* https://www.postgresql.org/docs/9.1/functions-string.html
* https://docs.microsoft.com/en-us/sql/t-sql/functions/lower-transact-sql?view=sql-server-ver15
* https://coderwall.com/p/6yhsuq/improve-case-insensitive-queries-in-postgres-using-smarter-indexes
*/
caseInsensitive?: boolean

/**
* Apply a custom filter to the query builder
*/
filter?: (
db: DatabaseQueryBuilderContract,
value: string,
field: FieldContext
) => void | Promise<void>
}

/**
* Callback to self execute the query for the unique and the
* exists validations
*/
export type VineDbSearchCallback<ValueType> = (
db: Database,
value: ValueType,
field: FieldContext
) => Promise<boolean>
Loading

0 comments on commit 761f823

Please sign in to comment.