2024-01-24 17:58:13 +01:00
|
|
|
import { FieldType, FieldSubtype } from "@budibase/types"
|
2023-10-09 18:02:21 +02:00
|
|
|
import { ValidColumnNameRegex, utils } from "@budibase/shared-core"
|
|
|
|
import { db } from "@budibase/backend-core"
|
2023-10-10 10:47:43 +02:00
|
|
|
import { parseCsvExport } from "../api/controllers/view/exporters"
|
2023-01-17 16:07:52 +01:00
|
|
|
|
|
|
|
interface SchemaColumn {
|
|
|
|
readonly name: string
|
2024-01-24 17:58:13 +01:00
|
|
|
readonly type: FieldType
|
2023-10-09 18:02:21 +02:00
|
|
|
readonly subtype: FieldSubtype
|
2023-01-17 16:07:52 +01:00
|
|
|
readonly autocolumn?: boolean
|
2023-05-02 13:48:05 +02:00
|
|
|
readonly constraints?: {
|
|
|
|
presence: boolean
|
|
|
|
}
|
2023-01-17 16:07:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
interface Schema {
|
|
|
|
readonly [index: string]: SchemaColumn
|
|
|
|
}
|
|
|
|
|
|
|
|
interface Row {
|
|
|
|
[index: string]: any
|
|
|
|
}
|
|
|
|
|
|
|
|
type Rows = Array<Row>
|
|
|
|
|
|
|
|
interface SchemaValidation {
|
|
|
|
[index: string]: boolean
|
|
|
|
}
|
|
|
|
|
|
|
|
interface ValidationResults {
|
|
|
|
schemaValidation: SchemaValidation
|
|
|
|
allValid: boolean
|
|
|
|
invalidColumns: Array<string>
|
2023-07-20 13:21:09 +02:00
|
|
|
errors: Record<string, string>
|
2023-01-17 16:07:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export function isSchema(schema: any): schema is Schema {
|
|
|
|
return (
|
|
|
|
typeof schema === "object" &&
|
|
|
|
Object.values(schema).every(rawColumn => {
|
|
|
|
const column = rawColumn as SchemaColumn
|
|
|
|
|
|
|
|
return (
|
|
|
|
column !== null &&
|
|
|
|
typeof column === "object" &&
|
|
|
|
typeof column.type === "string" &&
|
2024-01-24 17:58:13 +01:00
|
|
|
Object.values(FieldType).includes(column.type as FieldType)
|
2023-01-17 16:07:52 +01:00
|
|
|
)
|
|
|
|
})
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
export function isRows(rows: any): rows is Rows {
|
|
|
|
return Array.isArray(rows) && rows.every(row => typeof row === "object")
|
|
|
|
}
|
|
|
|
|
|
|
|
export function validate(rows: Rows, schema: Schema): ValidationResults {
|
|
|
|
const results: ValidationResults = {
|
|
|
|
schemaValidation: {},
|
|
|
|
allValid: false,
|
|
|
|
invalidColumns: [],
|
2023-07-20 13:21:09 +02:00
|
|
|
errors: {},
|
2023-01-17 16:07:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
rows.forEach(row => {
|
|
|
|
Object.entries(row).forEach(([columnName, columnData]) => {
|
|
|
|
const columnType = schema[columnName]?.type
|
2023-10-09 18:02:21 +02:00
|
|
|
const columnSubtype = schema[columnName]?.subtype
|
2023-01-17 16:07:52 +01:00
|
|
|
const isAutoColumn = schema[columnName]?.autocolumn
|
|
|
|
|
2023-10-09 18:02:21 +02:00
|
|
|
// If the column had an invalid value we don't want to override it
|
|
|
|
if (results.schemaValidation[columnName] === false) {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-01-17 16:07:52 +01:00
|
|
|
// If the columnType is not a string, then it's not present in the schema, and should be added to the invalid columns array
|
|
|
|
if (typeof columnType !== "string") {
|
|
|
|
results.invalidColumns.push(columnName)
|
2023-07-20 13:21:09 +02:00
|
|
|
} else if (!columnName.match(ValidColumnNameRegex)) {
|
|
|
|
// Check for special characters in column names
|
2023-07-20 15:06:31 +02:00
|
|
|
results.schemaValidation[columnName] = false
|
2023-07-20 13:21:09 +02:00
|
|
|
results.errors[columnName] =
|
|
|
|
"Column names can't contain special characters"
|
2023-05-02 13:48:05 +02:00
|
|
|
} else if (
|
|
|
|
columnData == null &&
|
|
|
|
!schema[columnName].constraints?.presence
|
|
|
|
) {
|
|
|
|
results.schemaValidation[columnName] = true
|
2023-01-17 16:07:52 +01:00
|
|
|
} else if (
|
|
|
|
// If there's no data for this field don't bother with further checks
|
|
|
|
// If the field is already marked as invalid there's no need for further checks
|
|
|
|
results.schemaValidation[columnName] === false ||
|
|
|
|
columnData == null ||
|
|
|
|
isAutoColumn
|
|
|
|
) {
|
|
|
|
return
|
2024-01-24 17:58:13 +01:00
|
|
|
} else if (columnType === FieldType.NUMBER && isNaN(Number(columnData))) {
|
2023-01-17 16:07:52 +01:00
|
|
|
// If provided must be a valid number
|
|
|
|
results.schemaValidation[columnName] = false
|
|
|
|
} else if (
|
|
|
|
// If provided must be a valid date
|
2024-01-24 17:58:13 +01:00
|
|
|
columnType === FieldType.DATETIME &&
|
2023-01-17 16:07:52 +01:00
|
|
|
isNaN(new Date(columnData).getTime())
|
|
|
|
) {
|
|
|
|
results.schemaValidation[columnName] = false
|
2023-10-09 18:02:21 +02:00
|
|
|
} else if (
|
2024-01-24 17:58:13 +01:00
|
|
|
columnType === FieldType.BB_REFERENCE &&
|
2023-10-09 18:02:21 +02:00
|
|
|
!isValidBBReference(columnData, columnSubtype)
|
|
|
|
) {
|
|
|
|
results.schemaValidation[columnName] = false
|
2023-01-17 16:07:52 +01:00
|
|
|
} else {
|
|
|
|
results.schemaValidation[columnName] = true
|
|
|
|
}
|
|
|
|
})
|
|
|
|
})
|
|
|
|
|
|
|
|
results.allValid =
|
|
|
|
Object.values(results.schemaValidation).length > 0 &&
|
|
|
|
Object.values(results.schemaValidation).every(column => column)
|
|
|
|
|
|
|
|
// Select unique values
|
|
|
|
results.invalidColumns = [...new Set(results.invalidColumns)]
|
|
|
|
return results
|
|
|
|
}
|
|
|
|
|
|
|
|
export function parse(rows: Rows, schema: Schema): Rows {
|
|
|
|
return rows.map(row => {
|
|
|
|
const parsedRow: Row = {}
|
|
|
|
|
|
|
|
Object.entries(row).forEach(([columnName, columnData]) => {
|
|
|
|
if (!(columnName in schema) || schema[columnName]?.autocolumn) {
|
|
|
|
// Objects can be present in the row data but not in the schema, so make sure we don't proceed in such a case
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
const columnType = schema[columnName].type
|
2023-10-10 13:37:59 +02:00
|
|
|
const columnSubtype = schema[columnName].subtype
|
2023-01-17 16:07:52 +01:00
|
|
|
|
2024-01-24 17:58:13 +01:00
|
|
|
if (columnType === FieldType.NUMBER) {
|
2023-01-17 16:07:52 +01:00
|
|
|
// If provided must be a valid number
|
|
|
|
parsedRow[columnName] = columnData ? Number(columnData) : columnData
|
2024-01-24 17:58:13 +01:00
|
|
|
} else if (columnType === FieldType.DATETIME) {
|
2023-01-17 16:07:52 +01:00
|
|
|
// If provided must be a valid date
|
|
|
|
parsedRow[columnName] = columnData
|
|
|
|
? new Date(columnData).toISOString()
|
|
|
|
: columnData
|
2024-01-24 17:58:13 +01:00
|
|
|
} else if (columnType === FieldType.BB_REFERENCE) {
|
2023-10-10 13:37:59 +02:00
|
|
|
const parsedValues =
|
|
|
|
!!columnData && parseCsvExport<{ _id: string }[]>(columnData)
|
|
|
|
if (!parsedValues) {
|
|
|
|
parsedRow[columnName] = undefined
|
|
|
|
} else {
|
|
|
|
switch (columnSubtype) {
|
|
|
|
case FieldSubtype.USER:
|
|
|
|
parsedRow[columnName] = parsedValues[0]?._id
|
|
|
|
break
|
|
|
|
case FieldSubtype.USERS:
|
|
|
|
parsedRow[columnName] = parsedValues.map(u => u._id)
|
|
|
|
break
|
|
|
|
default:
|
|
|
|
utils.unreachable(columnSubtype)
|
|
|
|
}
|
|
|
|
}
|
2023-01-17 16:07:52 +01:00
|
|
|
} else {
|
|
|
|
parsedRow[columnName] = columnData
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
|
|
return parsedRow
|
|
|
|
})
|
|
|
|
}
|
2023-10-09 18:02:21 +02:00
|
|
|
|
|
|
|
function isValidBBReference(
|
|
|
|
columnData: any,
|
|
|
|
columnSubtype: FieldSubtype
|
|
|
|
): boolean {
|
|
|
|
switch (columnSubtype) {
|
|
|
|
case FieldSubtype.USER:
|
2024-03-19 16:58:25 +01:00
|
|
|
case FieldSubtype.USERS: {
|
2023-10-10 10:47:43 +02:00
|
|
|
if (typeof columnData !== "string") {
|
|
|
|
return false
|
|
|
|
}
|
2023-10-10 14:20:26 +02:00
|
|
|
const userArray = parseCsvExport<{ _id: string }[]>(columnData)
|
2023-10-10 15:39:05 +02:00
|
|
|
if (!Array.isArray(userArray)) {
|
2023-10-09 18:02:21 +02:00
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
2023-10-10 14:39:55 +02:00
|
|
|
if (columnSubtype === FieldSubtype.USER && userArray.length > 1) {
|
2023-10-09 18:02:21 +02:00
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
2023-10-10 14:20:26 +02:00
|
|
|
const constainsWrongId = userArray.find(
|
|
|
|
user => !db.isGlobalUserID(user._id)
|
|
|
|
)
|
|
|
|
return !constainsWrongId
|
2024-03-19 16:58:25 +01:00
|
|
|
}
|
2023-10-09 18:02:21 +02:00
|
|
|
default:
|
|
|
|
throw utils.unreachable(columnSubtype)
|
|
|
|
}
|
|
|
|
}
|