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

535 lines
15 KiB
TypeScript

import {
BBReferenceFieldSubType,
CalculationType,
canGroupBy,
FieldType,
isNumeric,
PermissionLevel,
RelationSchemaField,
RenameColumn,
RequiredKeys,
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"
import { ensureQueryUISet } from "./utils"
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 getAllEnriched(): Promise<ViewV2Enriched[]> {
const tables = await sdk.tables.getAllTables()
let views: ViewV2Enriched[] = []
for (let table of tables) {
if (!table.views || Object.keys(table.views).length === 0) {
continue
}
const v2Views = Object.values(table.views).filter(isV2)
const enrichedViews = await Promise.all(
v2Views.map(view =>
enrichSchema(ensureQueryUISet(view), table.schema, tables)
)
)
views = views.concat(enrichedViews)
}
return views
}
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
}
function guardDuplicateCalculationFields(view: Omit<ViewV2, "id" | "version">) {
const seen: Record<string, Record<CalculationType, boolean>> = {}
const calculationFields = helpers.views.calculationFields(view)
for (const name of Object.keys(calculationFields)) {
const schema = calculationFields[name]
const isCount = schema.calculationType === CalculationType.COUNT
const isDistinct = "distinct" in schema
if (isCount && isDistinct) {
// We do these separately.
continue
}
if (seen[schema.field]?.[schema.calculationType]) {
throw new HTTPError(
`Duplicate calculation on field "${schema.field}", calculation type "${schema.calculationType}"`,
400
)
}
seen[schema.field] ??= {} as Record<CalculationType, boolean>
seen[schema.field][schema.calculationType] = true
}
}
function guardDuplicateCountDistinctFields(
view: Omit<ViewV2, "id" | "version">
) {
const seen: Record<string, Record<CalculationType, boolean>> = {}
const calculationFields = helpers.views.calculationFields(view)
for (const name of Object.keys(calculationFields)) {
const schema = calculationFields[name]
const isCount = schema.calculationType === CalculationType.COUNT
if (!isCount) {
continue
}
const isDistinct = "distinct" in schema
if (!isDistinct) {
// We do these separately.
continue
}
if (seen[schema.field]?.[schema.calculationType]) {
throw new HTTPError(
`Duplicate calculation on field "${schema.field}", calculation type "${schema.calculationType} distinct"`,
400
)
}
seen[schema.field] ??= {} as Record<CalculationType, boolean>
seen[schema.field][schema.calculationType] = true
}
}
async function guardCalculationViewSchema(
table: Table,
view: Omit<ViewV2, "id" | "version">
) {
const calculationFields = helpers.views.calculationFields(view)
guardDuplicateCalculationFields(view)
guardDuplicateCountDistinctFields(view)
if (Object.keys(calculationFields).length > 5) {
throw new HTTPError(
"Calculation views can only have a maximum of 5 fields",
400
)
}
for (const name of Object.keys(calculationFields)) {
const schema = calculationFields[name]
if (!schema.field) {
throw new HTTPError(
`Calculation field "${name}" is missing a "field" property`,
400
)
}
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
)
}
const isCount = schema.calculationType === CalculationType.COUNT
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<{ view: ViewV2; existingView: ViewV2 }> {
await guardViewSchema(tableId, view)
return pickApi(tableId).update(tableId, view)
}
export function isV2(view: View | ViewV2) {
return helpers.views.isV2(view)
}
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,
tables?: Table[]
): Promise<ViewV2Enriched> {
async function populateRelTableSchema(
tableId: string,
viewFields: Record<string, RelationSchemaField>
) {
let relTable = tables
? tables?.find(t => t._id === tableId)
: await sdk.tables.getTable(tableId)
if (!relTable) {
throw new Error("Cannot enrich relationship, table not found")
}
const result: Record<string, ViewV2ColumnEnriched> = {}
for (const relTableFieldName of Object.keys(relTable.schema)) {
const relTableField = relTable.schema[relTableFieldName]
if (
[FieldType.LINK, FieldType.FORMULA, FieldType.AI].includes(
relTableField.type
)
) {
continue
}
if (relTableField.visible === false) {
continue
}
const viewFieldSchema = viewFields[relTableFieldName]
const isVisible = !!viewFieldSchema?.visible
const isReadonly = !!viewFieldSchema?.readonly
const enrichedFieldSchema: RequiredKeys<ViewV2ColumnEnriched> = {
visible: isVisible,
readonly: isReadonly,
order: viewFieldSchema?.order,
width: viewFieldSchema?.width,
icon: relTableField.icon,
type: relTableField.type,
subtype: relTableField.subtype,
}
if (
!enrichedFieldSchema.icon &&
relTableField.type === FieldType.BB_REFERENCE &&
relTableField.subtype === BBReferenceFieldSubType.USER &&
!helpers.schema.isDeprecatedSingleUserColumn(relTableField)
) {
// Forcing the icon, otherwise we would need to pass the constraints to show the proper icon
enrichedFieldSchema.icon = "UserGroup"
}
result[relTableFieldName] = enrichedFieldSchema
}
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,
// When this was written, the only column types in FieldSchema to have columns
// field were the relationship columns. We blank this out here to make sure it's
// not set on non-relationship columns, then below we populate it by calling
// populateRelSchema.
//
// For Budibase 3.0 we introduced the FieldType.AI fields. Some of these fields
// have `columns: string[]` and it flew under the radar here because the
// AIFieldMetadata type isn't a union on its subtypes, it has a collection of
// optional fields. So columns is `columns?: string[]` which allows undefined,
// and doesn't fail this type check.
//
// What this means in practice is when FieldType.AI fields get enriched, we
// delete their `columns`. At the time of writing, I don't believe anything in
// the frontend depends on this, but it is odd and will probably bite us at
// some point.
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)) {
const viewSchema = view.schema[fieldName]
if (!helpers.views.isCalculationField(viewSchema) && !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)
}
}
}