mirror of https://github.com/nocodb/nocodb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
450 lines
14 KiB
450 lines
14 KiB
import { RelationTypes, UITypes, buildFilterTree, isDateMonthFormat, isSystemColumn, isVirtualCol } from 'nocodb-sdk' |
|
import type { ColumnType, FilterType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk' |
|
import dayjs from 'dayjs' |
|
import { isColumnRequiredAndNull } from './columnUtils' |
|
|
|
export const isValidValue = (val: unknown) => { |
|
if (ncIsNull(val) || ncIsUndefined(val)) { |
|
return false |
|
} |
|
|
|
if (ncIsString(val) && val === '') { |
|
return false |
|
} |
|
|
|
if (ncIsEmptyArray(val)) { |
|
return false |
|
} |
|
|
|
if (ncIsEmptyObject(val)) { |
|
return false |
|
} |
|
|
|
return true |
|
} |
|
|
|
export const extractPkFromRow = (row: Record<string, any>, columns: ColumnType[]) => { |
|
if (!row || !columns) return null |
|
|
|
const pkCols = columns.filter((c: Required<ColumnType>) => c.pk) |
|
// if multiple pk columns, join them with ___ and escape _ in id values with \_ to avoid conflicts |
|
if (pkCols.length > 1) { |
|
return pkCols.map((c: Required<ColumnType>) => row?.[c.title]?.toString?.().replaceAll('_', '\\_') ?? null).join('___') |
|
} else if (pkCols.length) { |
|
const id = row?.[pkCols[0].title] ?? null |
|
return id === null ? null : `${id}` |
|
} |
|
} |
|
|
|
export const rowPkData = (row: Record<string, any>, columns: ColumnType[]) => { |
|
const pkData: Record<string, string> = {} |
|
const pks = columns?.filter((c) => c.pk) |
|
if (row && pks && pks.length) { |
|
for (const pk of pks) { |
|
if (pk.title) pkData[pk.title] = row[pk.title] |
|
} |
|
} |
|
return pkData |
|
} |
|
|
|
export const extractPk = (columns: ColumnType[]) => { |
|
if (!columns && !Array.isArray(columns)) return null |
|
return columns |
|
.filter((c) => c.pk) |
|
.map((c) => c.title) |
|
.join('___') |
|
} |
|
|
|
export const findIndexByPk = (pk: Record<string, string>, data: Row[]) => { |
|
for (const [i, row] of Object.entries(data)) { |
|
if (Object.keys(pk).every((k) => pk[k] === row.row[k])) { |
|
return parseInt(i) |
|
} |
|
} |
|
return -1 |
|
} |
|
|
|
// a function to populate insert object and verify if all required fields are present |
|
export async function populateInsertObject({ |
|
getMeta, |
|
row, |
|
meta, |
|
ltarState, |
|
throwError, |
|
undo = false, |
|
}: { |
|
meta: TableType |
|
ltarState: Record<string, any> |
|
getMeta: (tableIdOrTitle: string, force?: boolean) => Promise<TableType | null> |
|
row: Record<string, any> |
|
throwError?: boolean |
|
undo?: boolean |
|
}) { |
|
const missingRequiredColumns = new Set() |
|
const insertObj = await meta.columns?.reduce(async (_o: Promise<any>, col) => { |
|
const o = await _o |
|
|
|
// if column is BT relation then check if foreign key is not_null(required) |
|
if ( |
|
ltarState && |
|
col.uidt === UITypes.LinkToAnotherRecord && |
|
(<LinkToAnotherRecordType>col.colOptions).type === RelationTypes.BELONGS_TO |
|
) { |
|
if (ltarState[col.title!] || row[col.title!]) { |
|
const ltarVal = ltarState[col.title!] || row[col.title!] |
|
const colOpt = <LinkToAnotherRecordType>col.colOptions |
|
const childCol = meta.columns!.find((c) => colOpt.fk_child_column_id === c.id) |
|
const relatedTableMeta = (await getMeta(colOpt.fk_related_model_id!)) as TableType |
|
if (relatedTableMeta && childCol) { |
|
o[childCol.title!] = ltarVal[relatedTableMeta!.columns!.find((c) => c.id === colOpt.fk_parent_column_id)!.title!] |
|
if (o[childCol.title!] !== null && o[childCol.title!] !== undefined) missingRequiredColumns.delete(childCol.title) |
|
} |
|
} |
|
} |
|
// check all the required columns are not null |
|
if (isColumnRequiredAndNull(col, row)) { |
|
missingRequiredColumns.add(col.title) |
|
} |
|
|
|
if ((!col.ai || undo) && row?.[col.title as string] !== null) { |
|
o[col.title as string] = row?.[col.title as string] |
|
} |
|
|
|
return o |
|
}, Promise.resolve({})) |
|
|
|
if (throwError && missingRequiredColumns.size) { |
|
throw new Error(`Missing required columns: ${[...missingRequiredColumns].join(', ')}`) |
|
} |
|
|
|
return { missingRequiredColumns, insertObj } |
|
} |
|
|
|
// a function to get default values of row |
|
export const rowDefaultData = (columns: ColumnType[] = []) => { |
|
const defaultData: Record<string, string> = columns.reduce<Record<string, any>>((acc: Record<string, any>, col: ColumnType) => { |
|
// avoid setting default value for system col, virtual col, rollup, formula, barcode, qrcode, links, ltar |
|
if ( |
|
!isSystemColumn(col) && |
|
!isVirtualCol(col) && |
|
![UITypes.Rollup, UITypes.Lookup, UITypes.Formula, UITypes.Barcode, UITypes.QrCode].includes(col.uidt) && |
|
isValidValue(col?.cdf) && |
|
!/^\w+\(\)|CURRENT_TIMESTAMP$/.test(col.cdf) |
|
) { |
|
const defaultValue = col.cdf |
|
acc[col.title!] = typeof defaultValue === 'string' ? defaultValue.replace(/^'|'$/g, '') : defaultValue |
|
} |
|
return acc |
|
}, {} as Record<string, any>) |
|
|
|
return defaultData |
|
} |
|
|
|
export const isRowEmpty = (record: Row, col: ColumnType): boolean => { |
|
if (!record || !col || !col.title) return true; |
|
|
|
return !isValidValue(record.row[col.title]); |
|
}; |
|
|
|
export const isRowRecordEmpty = (record: Record<string, any>, col: ColumnType): boolean => { |
|
if (!record || !col || !col.title) return true; |
|
|
|
return !isValidValue(record[col.title]); |
|
}; |
|
|
|
export function validateRowFilters(_filters: FilterType[], data: any, columns: ColumnType[], client: any) { |
|
if (!_filters.length) { |
|
return true |
|
} |
|
|
|
const filters = buildFilterTree(_filters) |
|
|
|
let isValid = null |
|
for (const filter of filters) { |
|
let res |
|
if (filter.is_group && filter.children?.length) { |
|
res = validateRowFilters(filter.children, data, columns, client) |
|
} else { |
|
const column = columns.find((c) => c.id === filter.fk_column_id) |
|
if (!column) { |
|
continue |
|
} |
|
const field = column.title! |
|
let val = data[field] |
|
if ( |
|
[UITypes.Date, UITypes.DateTime, UITypes.CreatedTime, UITypes.LastModifiedTime].includes(column.uidt!) && |
|
!['empty', 'blank', 'notempty', 'notblank'].includes(filter.comparison_op!) |
|
) { |
|
const dateFormat = client === 'mysql2' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ' |
|
|
|
let now = dayjs(new Date()) |
|
const dateFormatFromMeta = column?.meta?.date_format |
|
const dataVal: any = val |
|
let filterVal: any = filter.value |
|
if (dateFormatFromMeta && isDateMonthFormat(dateFormatFromMeta)) { |
|
// reset to 1st |
|
now = dayjs(now).date(1) |
|
if (val) val = dayjs(val).date(1) |
|
} |
|
if (filterVal) res = dayjs(filterVal).isSame(dataVal, 'day') |
|
|
|
// handle sub operation |
|
switch (filter.comparison_sub_op) { |
|
case 'today': |
|
filterVal = now |
|
break |
|
case 'tomorrow': |
|
filterVal = now.add(1, 'day') |
|
break |
|
case 'yesterday': |
|
filterVal = now.add(-1, 'day') |
|
break |
|
case 'oneWeekAgo': |
|
filterVal = now.add(-1, 'week') |
|
break |
|
case 'oneWeekFromNow': |
|
filterVal = now.add(1, 'week') |
|
break |
|
case 'oneMonthAgo': |
|
filterVal = now.add(-1, 'month') |
|
break |
|
case 'oneMonthFromNow': |
|
filterVal = now.add(1, 'month') |
|
break |
|
case 'daysAgo': |
|
if (!filterVal) return |
|
filterVal = now.add(-filterVal, 'day') |
|
break |
|
case 'daysFromNow': |
|
if (!filterVal) return |
|
filterVal = now.add(filterVal, 'day') |
|
break |
|
case 'exactDate': |
|
if (!filterVal) return |
|
break |
|
// sub-ops for `isWithin` comparison |
|
case 'pastWeek': |
|
filterVal = now.add(-1, 'week') |
|
break |
|
case 'pastMonth': |
|
filterVal = now.add(-1, 'month') |
|
break |
|
case 'pastYear': |
|
filterVal = now.add(-1, 'year') |
|
break |
|
case 'nextWeek': |
|
filterVal = now.add(1, 'week') |
|
break |
|
case 'nextMonth': |
|
filterVal = now.add(1, 'month') |
|
break |
|
case 'nextYear': |
|
filterVal = now.add(1, 'year') |
|
break |
|
case 'pastNumberOfDays': |
|
if (!filterVal) return |
|
filterVal = now.add(-filterVal, 'day') |
|
break |
|
case 'nextNumberOfDays': |
|
if (!filterVal) return |
|
filterVal = now.add(filterVal, 'day') |
|
break |
|
} |
|
|
|
if (dataVal) { |
|
switch (filter.comparison_op) { |
|
case 'eq': |
|
res = dayjs(dataVal).isSame(filterVal, 'day') |
|
break |
|
case 'neq': |
|
res = !dayjs(dataVal).isSame(filterVal, 'day') |
|
break |
|
case 'gt': |
|
res = dayjs(dataVal).isAfter(filterVal, 'day') |
|
break |
|
case 'lt': |
|
res = dayjs(dataVal).isBefore(filterVal, 'day') |
|
break |
|
case 'lte': |
|
case 'le': |
|
res = dayjs(dataVal).isSameOrBefore(filterVal, 'day') |
|
break |
|
case 'gte': |
|
case 'ge': |
|
res = dayjs(dataVal).isSameOrAfter(filterVal, 'day') |
|
break |
|
case 'empty': |
|
case 'blank': |
|
res = dataVal === '' || dataVal === null || dataVal === undefined |
|
break |
|
case 'notempty': |
|
case 'notblank': |
|
res = !(dataVal === '' || dataVal === null || dataVal === undefined) |
|
break |
|
case 'isWithin': { |
|
let now = dayjs(new Date()).format(dateFormat).toString() |
|
now = column.uidt === UITypes.Date ? now.substring(0, 10) : now |
|
switch (filter.comparison_sub_op) { |
|
case 'pastWeek': |
|
case 'pastMonth': |
|
case 'pastYear': |
|
case 'pastNumberOfDays': |
|
res = dayjs(dataVal).isBetween(filterVal, now, 'day') |
|
break |
|
case 'nextWeek': |
|
case 'nextMonth': |
|
case 'nextYear': |
|
case 'nextNumberOfDays': |
|
res = dayjs(dataVal).isBetween(now, filterVal, 'day') |
|
break |
|
} |
|
} |
|
} |
|
} |
|
} else { |
|
switch (typeof filter.value) { |
|
case 'boolean': |
|
val = !!data[field] |
|
break |
|
case 'number': |
|
val = +data[field] |
|
break |
|
} |
|
|
|
if ([UITypes.User, UITypes.CreatedBy, UITypes.LastModifiedBy].includes(column.uidt!)) { |
|
const userIds: string[] = Array.isArray(data[field]) |
|
? data[field].map((user) => user.id) |
|
: data[field]?.id |
|
? [data[field].id] |
|
: [] |
|
|
|
const filterValues = filter.value.split(',').map((v) => v.trim()) |
|
|
|
switch (filter.comparison_op) { |
|
case 'anyof': |
|
res = userIds.some((id) => filterValues.includes(id)) |
|
break |
|
case 'nanyof': |
|
res = !userIds.some((id) => filterValues.includes(id)) |
|
break |
|
case 'allof': |
|
res = filterValues.every((id) => userIds.includes(id)) |
|
break |
|
case 'nallof': |
|
res = !filterValues.every((id) => userIds.includes(id)) |
|
break |
|
case 'empty': |
|
case 'blank': |
|
res = userIds.length === 0 |
|
break |
|
case 'notempty': |
|
case 'notblank': |
|
res = userIds.length > 0 |
|
break |
|
default: |
|
res = false // Unsupported operation for User fields |
|
} |
|
} else { |
|
switch (filter.comparison_op) { |
|
case 'eq': |
|
// eslint-disable-next-line eqeqeq |
|
res = val == filter.value |
|
break |
|
case 'neq': |
|
// eslint-disable-next-line eqeqeq |
|
res = val != filter.value |
|
break |
|
case 'like': |
|
res = data[field]?.toString?.()?.toLowerCase()?.indexOf(filter.value?.toLowerCase()) > -1 |
|
break |
|
case 'nlike': |
|
res = data[field]?.toString?.()?.toLowerCase()?.indexOf(filter.value?.toLowerCase()) === -1 |
|
break |
|
case 'empty': |
|
case 'blank': |
|
res = data[field] === '' || data[field] === null || data[field] === undefined |
|
break |
|
case 'notempty': |
|
case 'notblank': |
|
res = !(data[field] === '' || data[field] === null || data[field] === undefined) |
|
break |
|
case 'checked': |
|
res = !!data[field] |
|
break |
|
case 'notchecked': |
|
res = !data[field] |
|
break |
|
case 'null': |
|
res = res = data[field] === null |
|
break |
|
case 'notnull': |
|
res = data[field] !== null |
|
break |
|
case 'allof': |
|
res = (filter.value?.split(',').map((item) => item.trim()) ?? []).every((item) => |
|
(data[field]?.split(',') ?? []).includes(item), |
|
) |
|
break |
|
case 'anyof': |
|
res = (filter.value?.split(',').map((item) => item.trim()) ?? []).some((item) => |
|
(data[field]?.split(',') ?? []).includes(item), |
|
) |
|
break |
|
case 'nallof': |
|
res = !(filter.value?.split(',').map((item) => item.trim()) ?? []).every((item) => |
|
(data[field]?.split(',') ?? []).includes(item), |
|
) |
|
break |
|
case 'nanyof': |
|
res = !(filter.value?.split(',').map((item) => item.trim()) ?? []).some((item) => |
|
(data[field]?.split(',') ?? []).includes(item), |
|
) |
|
break |
|
case 'lt': |
|
res = +data[field] < +filter.value |
|
break |
|
case 'lte': |
|
case 'le': |
|
res = +data[field] <= +filter.value |
|
break |
|
case 'gt': |
|
res = +data[field] > +filter.value |
|
break |
|
case 'gte': |
|
case 'ge': |
|
res = +data[field] >= +filter.value |
|
break |
|
} |
|
} |
|
} |
|
} |
|
|
|
switch (filter.logical_op) { |
|
case 'or': |
|
isValid = isValid || !!res |
|
break |
|
case 'not': |
|
isValid = isValid && !res |
|
break |
|
case 'and': |
|
default: |
|
isValid = (isValid ?? true) && res |
|
break |
|
} |
|
} |
|
return isValid |
|
} |
|
|
|
export const isAllowToRenderRowEmptyField = (col: ColumnType) => { |
|
if (!col) return false |
|
|
|
if (isAI(col)) { |
|
return true |
|
} |
|
|
|
if (isAiButton(col)) { |
|
return true |
|
} |
|
|
|
return false |
|
}
|
|
|