budibase/packages/server/src/sdk/app/views/index.ts

432 lines
12 KiB
TypeScript

import {
CalculationType,
canGroupBy,
FieldType,
isNumeric,
PermissionLevel,
RelationSchemaField,
RenameColumn,
Table,
TableSchema,
View,
ViewV2,
ViewV2ColumnEnriched,
ViewV2Enriched,
} from "@budibase/types"
import { context, docIds, HTTPError } from "@budibase/backend-core"
import {
helpers,
PROTECTED_EXTERNAL_COLUMNS,
PROTECTED_INTERNAL_COLUMNS,
} from "@budibase/shared-core"
import * as utils from "../../../db/utils"
import { isExternalTableID } from "../../../integrations/utils"
import * as internal from "./internal"
import * as external from "./external"
import sdk from "../../../sdk"
function pickApi(tableId: any) {
if (isExternalTableID(tableId)) {
return external
}
return internal
}
export async function get(viewId: string): Promise<ViewV2> {
const { tableId } = utils.extractViewInfoFromID(viewId)
return pickApi(tableId).get(viewId)
}
export async function getEnriched(viewId: string): Promise<ViewV2Enriched> {
const { tableId } = utils.extractViewInfoFromID(viewId)
return pickApi(tableId).getEnriched(viewId)
}
export async function getTable(view: string | ViewV2): Promise<Table> {
const viewId = typeof view === "string" ? view : view.id
const cached = context.getTableForView(viewId)
if (cached) {
return cached
}
const { tableId } = utils.extractViewInfoFromID(viewId)
const table = await sdk.tables.getTable(tableId)
context.setTableForView(viewId, table)
return table
}
export function isView(view: any): view is ViewV2 {
return view.id && docIds.isViewId(view.id) && view.version === 2
}
async function guardCalculationViewSchema(
table: Table,
view: Omit<ViewV2, "id" | "version">
) {
const calculationFields = helpers.views.calculationFields(view)
if (Object.keys(calculationFields).length > 5) {
throw new HTTPError(
"Calculation views can only have a maximum of 5 fields",
400
)
}
const seen: Record<string, Record<CalculationType, boolean>> = {}
for (const name of Object.keys(calculationFields)) {
const schema = calculationFields[name]
const isCount = schema.calculationType === CalculationType.COUNT
const isDistinct = isCount && "distinct" in schema && schema.distinct
const field = isCount && !isDistinct ? "*" : schema.field
if (seen[field]?.[schema.calculationType]) {
throw new HTTPError(
`Duplicate calculation on field "${field}", calculation type "${schema.calculationType}"`,
400
)
}
seen[field] ??= {} as Record<CalculationType, boolean>
seen[field][schema.calculationType] = true
// Count fields that aren't distinct don't need to reference another field,
// so we don't validate it.
if (isCount && !isDistinct) {
continue
}
const targetSchema = table.schema[schema.field]
if (!targetSchema) {
throw new HTTPError(
`Calculation field "${name}" references field "${schema.field}" which does not exist in the table schema`,
400
)
}
if (!isCount && !isNumeric(targetSchema.type)) {
throw new HTTPError(
`Calculation field "${name}" references field "${schema.field}" which is not a numeric field`,
400
)
}
}
const groupByFields = helpers.views.basicFields(view)
for (const groupByFieldName of Object.keys(groupByFields)) {
const targetSchema = table.schema[groupByFieldName]
if (!targetSchema) {
throw new HTTPError(
`Group by field "${groupByFieldName}" does not exist in the table schema`,
400
)
}
if (!canGroupBy(targetSchema.type)) {
throw new HTTPError(
`Grouping by fields of type "${targetSchema.type}" is not supported`,
400
)
}
}
}
async function guardViewSchema(
tableId: string,
view: Omit<ViewV2, "id" | "version">
) {
const table = await sdk.tables.getTable(tableId)
if (helpers.views.isCalculationView(view)) {
await guardCalculationViewSchema(table, view)
} else {
if (helpers.views.hasCalculationFields(view)) {
throw new HTTPError(
"Calculation fields are not allowed in non-calculation views",
400
)
}
}
await checkReadonlyFields(table, view)
if (!helpers.views.isCalculationView(view)) {
checkRequiredFields(table, view)
}
checkDisplayField(view)
}
async function checkReadonlyFields(
table: Table,
view: Omit<ViewV2, "id" | "version">
) {
const viewSchema = view.schema || {}
for (const field of Object.keys(viewSchema)) {
const viewFieldSchema = viewSchema[field]
if (helpers.views.isCalculationField(viewFieldSchema)) {
continue
}
const tableFieldSchema = table.schema[field]
if (!tableFieldSchema) {
throw new HTTPError(
`Field "${field}" is not valid for the requested table`,
400
)
}
if (viewSchema[field].readonly) {
if (!viewSchema[field].visible) {
throw new HTTPError(
`Field "${field}" must be visible if you want to make it readonly`,
400
)
}
}
}
}
function checkDisplayField(view: Omit<ViewV2, "id" | "version">) {
if (view.primaryDisplay) {
const viewSchemaField = view.schema?.[view.primaryDisplay]
if (!viewSchemaField?.visible) {
throw new HTTPError(
`You can't hide "${view.primaryDisplay}" because it is the display column.`,
400
)
}
}
}
function checkRequiredFields(
table: Table,
view: Omit<ViewV2, "id" | "version">
) {
const existingView = table.views?.[view.name] as ViewV2 | undefined
for (const field of Object.values(table.schema)) {
if (!helpers.schema.isRequired(field.constraints)) {
continue
}
const viewSchemaField = view.schema?.[field.name]
const existingViewSchema = existingView?.schema?.[field.name]
if (!viewSchemaField && !existingViewSchema?.visible) {
// Supporting existing configs with required columns but hidden in views
continue
}
if (!viewSchemaField?.visible) {
throw new HTTPError(
`You can't hide "${field.name}" because it is a required field.`,
400
)
}
if (
helpers.views.isBasicViewField(viewSchemaField) &&
viewSchemaField.readonly
) {
throw new HTTPError(
`You can't make "${field.name}" readonly because it is a required field.`,
400
)
}
}
}
export async function create(
tableId: string,
viewRequest: Omit<ViewV2, "id" | "version">
): Promise<ViewV2> {
await guardViewSchema(tableId, viewRequest)
const view = await pickApi(tableId).create(tableId, viewRequest)
// Set permissions to be the same as the table
const tablePerms = await sdk.permissions.getResourcePerms(tableId)
await sdk.permissions.setPermissions(view.id, {
writeRole: tablePerms[PermissionLevel.WRITE].role,
readRole: tablePerms[PermissionLevel.READ].role,
})
return view
}
export async function update(tableId: string, view: ViewV2): Promise<ViewV2> {
await guardViewSchema(tableId, view)
return pickApi(tableId).update(tableId, view)
}
export function isV2(view: View | ViewV2): view is ViewV2 {
return (view as ViewV2).version === 2
}
export async function remove(viewId: string): Promise<ViewV2> {
const { tableId } = utils.extractViewInfoFromID(viewId)
return pickApi(tableId).remove(viewId)
}
export function allowedFields(
view: View | ViewV2,
permission: "WRITE" | "READ"
) {
return [
...Object.keys(view?.schema || {}).filter(key => {
if (!isV2(view)) {
return true
}
const fieldSchema = view.schema![key]
if (permission === "WRITE") {
return fieldSchema.visible && !fieldSchema.readonly
}
return fieldSchema.visible
}),
...PROTECTED_EXTERNAL_COLUMNS,
...PROTECTED_INTERNAL_COLUMNS,
]
}
export async function enrichSchema(
view: ViewV2,
tableSchema: TableSchema
): Promise<ViewV2Enriched> {
async function populateRelTableSchema(
tableId: string,
viewFields: Record<string, RelationSchemaField>
) {
const relTable = await sdk.tables.getTable(tableId)
const result: Record<string, ViewV2ColumnEnriched> = {}
for (const relTableFieldName of Object.keys(relTable.schema)) {
const relTableField = relTable.schema[relTableFieldName]
if ([FieldType.LINK, FieldType.FORMULA].includes(relTableField.type)) {
continue
}
if (relTableField.visible === false) {
continue
}
const viewFieldSchema = viewFields[relTableFieldName]
const isVisible = !!viewFieldSchema?.visible
const isReadonly = !!viewFieldSchema?.readonly
result[relTableFieldName] = {
...relTableField,
...viewFieldSchema,
name: relTableField.name,
visible: isVisible,
readonly: isReadonly,
}
}
return result
}
let schema: ViewV2Enriched["schema"] = {}
const viewSchema = view.schema || {}
const anyViewOrder = Object.values(viewSchema).some(ui => ui.order != null)
const visibleSchemaFields = Object.keys(viewSchema).filter(key => {
if (helpers.views.isCalculationField(viewSchema[key])) {
return viewSchema[key].visible !== false
}
return key in tableSchema && tableSchema[key].visible !== false
})
const visibleTableFields = Object.keys(tableSchema).filter(
key => tableSchema[key].visible !== false
)
const visibleFields = new Set([...visibleSchemaFields, ...visibleTableFields])
for (const key of visibleFields) {
// if nothing specified in view, then it is not visible
const ui = viewSchema[key] || { visible: false }
schema[key] = {
...tableSchema[key],
...ui,
order: anyViewOrder ? ui?.order ?? undefined : tableSchema[key]?.order,
columns: undefined,
}
if (schema[key].type === FieldType.LINK) {
schema[key].columns = await populateRelTableSchema(
schema[key].tableId,
viewSchema[key]?.columns || {}
)
}
}
return { ...view, schema }
}
export function syncSchema(
view: ViewV2,
schema: TableSchema,
renameColumn: RenameColumn | undefined
): ViewV2 {
if (renameColumn && view.schema) {
view.schema[renameColumn.updated] = view.schema[renameColumn.old]
delete view.schema[renameColumn.old]
}
if (view.schema) {
for (const fieldName of Object.keys(view.schema)) {
if (!schema[fieldName]) {
delete view.schema[fieldName]
}
}
for (const fieldName of Object.keys(schema)) {
if (!view.schema[fieldName]) {
view.schema[fieldName] = { visible: false }
}
}
}
return view
}
export async function renameLinkedViews(table: Table, renaming: RenameColumn) {
const relatedTables: Record<string, Table> = {}
for (const field of Object.values(table.schema)) {
if (field.type !== FieldType.LINK) {
continue
}
relatedTables[field.tableId] ??= await sdk.tables.getTable(field.tableId)
}
for (const relatedTable of Object.values(relatedTables)) {
let toSave = false
const viewsV2 = Object.values(relatedTable.views || {}).filter(
sdk.views.isV2
)
if (!viewsV2) {
continue
}
for (const view of viewsV2) {
for (const relField of Object.keys(view.schema || {}).filter(f => {
const tableField = relatedTable.schema[f]
if (!tableField || tableField.type !== FieldType.LINK) {
return false
}
return tableField.tableId === table._id
})) {
const columns = view.schema?.[relField]?.columns
if (columns && columns[renaming.old]) {
columns[renaming.updated] = columns[renaming.old]
delete columns[renaming.old]
toSave = true
}
}
}
if (toSave) {
await sdk.tables.saveTable(relatedTable)
}
}
}