import { BBReferenceFieldSubType, CalculationType, canGroupBy, FeatureFlag, FieldType, isNumeric, PermissionLevel, RelationSchemaField, RenameColumn, RequiredKeys, Table, TableSchema, View, ViewV2, ViewV2ColumnEnriched, ViewV2Enriched, } from "@budibase/types" import { context, docIds, features, 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 { const { tableId } = utils.extractViewInfoFromID(viewId) return pickApi(tableId).get(viewId) } export async function getEnriched(viewId: string): Promise { const { tableId } = utils.extractViewInfoFromID(viewId) return pickApi(tableId).getEnriched(viewId) } export async function getTable(view: string | ViewV2): Promise { 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 ) { 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> = {} 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 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 } 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 ) } 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 ) { 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 ) { 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) { 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 ) { 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 ): Promise { await guardViewSchema(tableId, viewRequest) const view = await pickApi(tableId).create(tableId, viewRequest) const setExplicitPermission = await features.flags.isEnabled( FeatureFlag.TABLES_DEFAULT_ADMIN ) if (setExplicitPermission) { // 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 { 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 { 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 { async function populateRelTableSchema( tableId: string, viewFields: Record ) { const relTable = await sdk.tables.getTable(tableId) const result: Record = {} 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 const enrichedFieldSchema: RequiredKeys = { 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, 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 = {} 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) } } }