diff --git a/lerna.json b/lerna.json index 3396c082bf..bb0302353e 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "2.31.4", + "version": "2.31.5", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/packages/backend-core/src/features/index.ts b/packages/backend-core/src/features/index.ts index b84237b1f0..49f8044e8f 100644 --- a/packages/backend-core/src/features/index.ts +++ b/packages/backend-core/src/features/index.ts @@ -1,7 +1,7 @@ import env from "../environment" import * as context from "../context" import { PostHog, PostHogOptions } from "posthog-node" -import { IdentityType, UserCtx } from "@budibase/types" +import { FeatureFlag, IdentityType, UserCtx } from "@budibase/types" import tracer from "dd-trace" let posthog: PostHog | undefined @@ -268,4 +268,5 @@ export class FlagSet, T extends { [key: string]: V }> { export const flags = new FlagSet({ DEFAULT_VALUES: Flag.boolean(env.isDev()), SQS: Flag.boolean(env.isDev()), + [FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(false), }) diff --git a/packages/builder/src/components/backend/DataTable/ViewV2DataTable.svelte b/packages/builder/src/components/backend/DataTable/ViewV2DataTable.svelte index 646b764a2c..b56c5f6568 100644 --- a/packages/builder/src/components/backend/DataTable/ViewV2DataTable.svelte +++ b/packages/builder/src/components/backend/DataTable/ViewV2DataTable.svelte @@ -6,6 +6,8 @@ import GridCreateEditRowModal from "components/backend/DataTable/modals/grid/GridCreateEditRowModal.svelte" import GridFilterButton from "components/backend/DataTable/buttons/grid/GridFilterButton.svelte" import GridManageAccessButton from "components/backend/DataTable/buttons/grid/GridManageAccessButton.svelte" + import { isEnabled } from "helpers/featureFlags" + import { FeatureFlag } from "@budibase/types" $: id = $viewsV2.selected?.id $: datasource = { @@ -29,6 +31,7 @@ on:updatedatasource={handleGridViewUpdate} isCloud={$admin.cloud} allowViewReadonlyColumns={$licensing.isViewReadonlyColumnsEnabled} + canSetRelationshipSchemas={isEnabled(FeatureFlag.ENRICHED_RELATIONSHIPS)} > diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte index 1d66a0ba00..a956d09ee6 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte @@ -84,7 +84,7 @@ } let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions) let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions) - let relationshipMap = { + const relationshipMap = { [RelationshipType.ONE_TO_MANY]: { part1: PrettyRelationshipDefinitions.MANY, part2: PrettyRelationshipDefinitions.ONE, @@ -98,7 +98,7 @@ part2: PrettyRelationshipDefinitions.MANY, }, } - let autoColumnInfo = getAutoColumnInformation() + const autoColumnInfo = getAutoColumnInformation() let optionsValid = true $: rowGoldenSample = RowUtils.generateGoldenSample($rows) diff --git a/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte b/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte index 1b3b24ff2e..73c8a99cc2 100644 --- a/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte @@ -29,6 +29,7 @@ let searching = false let container let anchor + let relationshipFields $: fieldValue = parseValue(value) $: oneRowOnly = schema?.relationshipType === "one-to-many" @@ -41,6 +42,26 @@ } } + $: relationFields = fieldValue?.reduce((acc, f) => { + const fields = {} + for (const [column] of Object.entries(schema?.columns || {}).filter( + ([key, column]) => + column.visible !== false && f[key] !== null && f[key] !== undefined + )) { + fields[column] = f[column] + } + if (Object.keys(fields).length) { + acc[f._id] = fields + } + return acc + }, {}) + + $: showRelationshipFields = + relationshipFields && + Object.keys(relationshipFields).length && + focused && + !isOpen + const parseValue = value => { if (Array.isArray(value) && value.every(x => x?._id)) { return value @@ -221,6 +242,14 @@ return value } + const displayRelationshipFields = relationship => { + relationshipFields = relationFields[relationship._id] + } + + const hideRelationshipFields = () => { + relationshipFields = undefined + } + onMount(() => { api = { focus: open, @@ -244,11 +273,18 @@
1} + class:disabled={!focused} on:wheel={e => (focused ? e.stopPropagation() : null)} > {#each fieldValue || [] as relationship} {#if relationship[primaryDisplay] || relationship.primaryDisplay} -
+
displayRelationshipFields(relationship)} + on:focus={() => {}} + on:mouseleave={() => hideRelationshipFields()} + > {readable( relationship[primaryDisplay] || relationship.primaryDisplay @@ -322,6 +358,21 @@ {/if} +{#if showRelationshipFields} + +
+ {#each Object.entries(relationshipFields) as [fieldName, fieldValue]} +
+ {fieldName} +
+
+ {fieldValue} +
+ {/each} +
+
+{/if} + diff --git a/packages/frontend-core/src/components/grid/controls/ColumnsSettingButton.svelte b/packages/frontend-core/src/components/grid/controls/ColumnsSettingButton.svelte index 8c66d9ecfc..b4940c8903 100644 --- a/packages/frontend-core/src/components/grid/controls/ColumnsSettingButton.svelte +++ b/packages/frontend-core/src/components/grid/controls/ColumnsSettingButton.svelte @@ -2,16 +2,29 @@ import { getContext } from "svelte" import { ActionButton, Popover } from "@budibase/bbui" import ColumnsSettingContent from "./ColumnsSettingContent.svelte" + import { FieldPermissions } from "../../../constants" export let allowViewReadonlyColumns = false - const { columns } = getContext("grid") + const { columns, datasource } = getContext("grid") let open = false let anchor $: anyRestricted = $columns.filter(col => !col.visible || col.readonly).length $: text = anyRestricted ? `Columns (${anyRestricted} restricted)` : "Columns" + + $: permissions = + $datasource.type === "viewV2" + ? [ + FieldPermissions.WRITABLE, + FieldPermissions.READONLY, + FieldPermissions.HIDDEN, + ] + : [FieldPermissions.WRITABLE, FieldPermissions.HIDDEN] + $: disabledPermissions = allowViewReadonlyColumns + ? [] + : [FieldPermissions.READONLY]
@@ -28,5 +41,9 @@
- + diff --git a/packages/frontend-core/src/components/grid/controls/ColumnsSettingContent.svelte b/packages/frontend-core/src/components/grid/controls/ColumnsSettingContent.svelte index 4f0e4424d4..02ed0a504c 100644 --- a/packages/frontend-core/src/components/grid/controls/ColumnsSettingContent.svelte +++ b/packages/frontend-core/src/components/grid/controls/ColumnsSettingContent.svelte @@ -1,22 +1,162 @@ @@ -94,16 +190,56 @@ {column.label}
- toggleColumn(column, e.detail)} - value={columnToPermissionOptions(column)} - options={column.options} - /> +
+ toggleColumn(column, e.detail)} + value={columnToPermissionOptions(column)} + options={column.options} + /> + {#if canSetRelationshipSchemas && column.schema.type === FieldType.LINK && columnToPermissionOptions(column) !== FieldPermissions.HIDDEN} +
+ { + relationshipFieldName = column.name + relationshipPanelAnchor = e.currentTarget + }} + size="S" + icon="ChevronRight" + quiet + /> +
+ {/if} +
{/each}
+{#if canSetRelationshipSchemas} + (relationshipFieldName = null)} + open={relationshipFieldName} + anchor={relationshipPanelAnchor} + align="right-outside" + > + {#if relationshipPanelColumns.length} +
+ {relationshipFieldName} columns +
+ {/if} + +
+{/if} + diff --git a/packages/frontend-core/src/components/grid/layout/Grid.svelte b/packages/frontend-core/src/components/grid/layout/Grid.svelte index 04a11da812..f24ff0ae10 100644 --- a/packages/frontend-core/src/components/grid/layout/Grid.svelte +++ b/packages/frontend-core/src/components/grid/layout/Grid.svelte @@ -43,6 +43,7 @@ export let canDeleteRows = true export let canEditColumns = true export let canSaveSchema = true + export let canSetRelationshipSchemas = false export let stripeRows = false export let quiet = false export let collaboration = true @@ -99,6 +100,7 @@ canDeleteRows, canEditColumns, canSaveSchema, + canSetRelationshipSchemas, stripeRows, quiet, collaboration, diff --git a/packages/frontend-core/src/components/grid/stores/cache.js b/packages/frontend-core/src/components/grid/stores/cache.js index 7eab6795e4..cf4690f15b 100644 --- a/packages/frontend-core/src/components/grid/stores/cache.js +++ b/packages/frontend-core/src/components/grid/stores/cache.js @@ -4,35 +4,40 @@ export const createActions = context => { // Cache for the primary display columns of different tables. // If we ever need to cache table definitions for other purposes then we can // expand this to be a more generic cache. - let primaryDisplayCache = {} + let tableCache = {} - const resetPrimaryDisplayCache = () => { - primaryDisplayCache = {} + const resetCache = () => { + tableCache = {} } - const getPrimaryDisplayForTableId = async tableId => { + const fetchTable = async tableId => { // If we've never encountered this tableId before then store a promise that // resolves to the primary display so that subsequent invocations before the // promise completes can reuse this promise - if (!primaryDisplayCache[tableId]) { - primaryDisplayCache[tableId] = new Promise(resolve => { - API.fetchTableDefinition(tableId).then(def => { - const display = def?.primaryDisplay || def?.schema?.[0]?.name - primaryDisplayCache[tableId] = display - resolve(display) - }) - }) + if (!tableCache[tableId]) { + tableCache[tableId] = API.fetchTableDefinition(tableId) } - // We await the result so that we account for both promises and primitives - return await primaryDisplayCache[tableId] + return await tableCache[tableId] + } + + const getPrimaryDisplayForTableId = async tableId => { + const table = await fetchTable(tableId) + const display = table?.primaryDisplay || table?.schema?.[0]?.name + return display + } + + const getTable = async tableId => { + const table = await fetchTable(tableId) + return table } return { cache: { actions: { getPrimaryDisplayForTableId, - resetPrimaryDisplayCache, + getTable, + resetCache, }, }, } @@ -43,5 +48,5 @@ export const initialise = context => { // Wipe the caches whenever the datasource changes to ensure we aren't // storing any stale information - datasource.subscribe(cache.actions.resetPrimaryDisplayCache) + datasource.subscribe(cache.actions.resetCache) } diff --git a/packages/frontend-core/src/components/grid/stores/datasource.js b/packages/frontend-core/src/components/grid/stores/datasource.js index 12a3bd5afe..68053f38ae 100644 --- a/packages/frontend-core/src/components/grid/stores/datasource.js +++ b/packages/frontend-core/src/components/grid/stores/datasource.js @@ -5,16 +5,24 @@ import { memo } from "../../../utils" export const createStores = () => { const definition = memo(null) const schemaMutations = memo({}) + const subSchemaMutations = memo({}) return { definition, schemaMutations, + subSchemaMutations, } } export const deriveStores = context => { - const { API, definition, schemaOverrides, datasource, schemaMutations } = - context + const { + API, + definition, + schemaOverrides, + datasource, + schemaMutations, + subSchemaMutations, + } = context const schema = derived(definition, $definition => { let schema = getDatasourceSchema({ @@ -40,8 +48,8 @@ export const deriveStores = context => { // Derives the total enriched schema, made up of the saved schema and any // prop and user overrides const enrichedSchema = derived( - [schema, schemaOverrides, schemaMutations], - ([$schema, $schemaOverrides, $schemaMutations]) => { + [schema, schemaOverrides, schemaMutations, subSchemaMutations], + ([$schema, $schemaOverrides, $schemaMutations, $subSchemaMutations]) => { if (!$schema) { return null } @@ -52,6 +60,18 @@ export const deriveStores = context => { ...$schemaOverrides?.[field], ...$schemaMutations[field], } + + if ($subSchemaMutations[field]) { + enrichedSchema[field].columns ??= {} + for (const [fieldName, mutation] of Object.entries( + $subSchemaMutations[field] + )) { + enrichedSchema[field].columns[fieldName] = { + ...enrichedSchema[field].columns[fieldName], + ...mutation, + } + } + } }) return enrichedSchema } @@ -83,6 +103,7 @@ export const createActions = context => { viewV2, nonPlus, schemaMutations, + subSchemaMutations, schema, notifications, } = context @@ -162,6 +183,25 @@ export const createActions = context => { }) } + // Adds a nested schema mutation for a single field + const addSubSchemaMutation = (field, fromField, mutation) => { + if (!field || !fromField || !mutation) { + return + } + subSchemaMutations.update($subSchemaMutations => { + return { + ...$subSchemaMutations, + [fromField]: { + ...$subSchemaMutations[fromField], + [field]: { + ...($subSchemaMutations[fromField] || {})[field], + ...mutation, + }, + }, + } + }) + } + // Adds schema mutations for multiple fields at once const addSchemaMutations = mutations => { const fields = Object.keys(mutations || {}) @@ -188,6 +228,7 @@ export const createActions = context => { } const $definition = get(definition) const $schemaMutations = get(schemaMutations) + const $subSchemaMutations = get(subSchemaMutations) const $schema = get(schema) let newSchema = {} @@ -197,6 +238,17 @@ export const createActions = context => { ...$schema[column], ...$schemaMutations[column], } + if ($subSchemaMutations[column]) { + newSchema[column].columns ??= {} + for (const [fieldName, mutation] of Object.entries( + $subSchemaMutations[column] + )) { + newSchema[column].columns[fieldName] = { + ...newSchema[column].columns[fieldName], + ...mutation, + } + } + } }) // Save the changes, then reset our local mutations @@ -209,6 +261,7 @@ export const createActions = context => { const resetSchemaMutations = () => { schemaMutations.set({}) + subSchemaMutations.set({}) } // Adds a row to the datasource @@ -255,6 +308,7 @@ export const createActions = context => { canUseColumn, changePrimaryDisplay, addSchemaMutation, + addSubSchemaMutation, addSchemaMutations, saveSchemaMutations, resetSchemaMutations, diff --git a/packages/frontend-core/src/constants.js b/packages/frontend-core/src/constants.js index 32d2d27e49..1f82610408 100644 --- a/packages/frontend-core/src/constants.js +++ b/packages/frontend-core/src/constants.js @@ -172,3 +172,9 @@ export const TypeIconMap = { export const OptionColours = [...new Array(12).keys()].map(idx => { return `hsla(${((idx + 1) * 222) % 360}, 90%, 75%, 0.3)` }) + +export const FieldPermissions = { + WRITABLE: "writable", + READONLY: "readonly", + HIDDEN: "hidden", +} diff --git a/packages/server/src/api/controllers/row/external.ts b/packages/server/src/api/controllers/row/external.ts index e14e2d454a..bd5201c05c 100644 --- a/packages/server/src/api/controllers/row/external.ts +++ b/packages/server/src/api/controllers/row/external.ts @@ -38,7 +38,7 @@ export async function handleRequest( } export async function patch(ctx: UserCtx) { - const { tableId } = utils.getSourceId(ctx) + const { tableId, viewId } = utils.getSourceId(ctx) const { _id, ...rowData } = ctx.request.body const table = await sdk.tables.getTable(tableId) @@ -77,6 +77,7 @@ export async function patch(ctx: UserCtx) { outputProcessing(table, row, { squash: true, preserveLinks: true, + fromViewId: viewId, }), outputProcessing(table, beforeRow, { squash: true, @@ -163,14 +164,10 @@ export async function fetchEnrichedRow(ctx: UserCtx) { }, includeSqlRelationships: IncludeRelationship.INCLUDE, }) - row[fieldName] = await outputProcessing( - linkedTable, - relatedRows.rows, - { - squash: true, - preserveLinks: true, - } - ) + row[fieldName] = await outputProcessing(linkedTable, relatedRows.rows, { + squash: true, + preserveLinks: true, + }) } return row } diff --git a/packages/server/src/api/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts index 46aec4e11c..cd85f57982 100644 --- a/packages/server/src/api/controllers/row/index.ts +++ b/packages/server/src/api/controllers/row/index.ts @@ -71,8 +71,10 @@ export async function patch( } export const save = async (ctx: UserCtx) => { + const { tableId, viewId } = utils.getSourceId(ctx) + const sourceId = viewId || tableId + const appId = ctx.appId - const { tableId } = utils.getSourceId(ctx) const body = ctx.request.body // user metadata doesn't exist yet - don't allow creation @@ -85,9 +87,9 @@ export const save = async (ctx: UserCtx) => { return patch(ctx as UserCtx) } const { row, table, squashed } = tableId.includes("datasource_plus") - ? await sdk.rows.save(tableId, ctx.request.body, ctx.user?._id) + ? await sdk.rows.save(sourceId, ctx.request.body, ctx.user?._id) : await quotas.addRow(() => - sdk.rows.save(tableId, ctx.request.body, ctx.user?._id) + sdk.rows.save(sourceId, ctx.request.body, ctx.user?._id) ) ctx.status = 200 ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:save`, appId, row, table) @@ -115,10 +117,12 @@ export async function fetch(ctx: any) { } export async function find(ctx: UserCtx) { - const { tableId } = utils.getSourceId(ctx) + const { tableId, viewId } = utils.getSourceId(ctx) + const sourceId = viewId || tableId const rowId = ctx.params.rowId - ctx.body = await sdk.rows.find(tableId, rowId) + const response = await sdk.rows.find(sourceId, rowId) + ctx.body = response } function isDeleteRows(input: any): input is DeleteRows { diff --git a/packages/server/src/api/controllers/row/internal.ts b/packages/server/src/api/controllers/row/internal.ts index e698efe981..33e3c7707b 100644 --- a/packages/server/src/api/controllers/row/internal.ts +++ b/packages/server/src/api/controllers/row/internal.ts @@ -23,7 +23,7 @@ import { getLinkedTableIDs } from "../../../db/linkedRows/linkUtils" import { flatten } from "lodash" export async function patch(ctx: UserCtx) { - const { tableId } = utils.getSourceId(ctx) + const { tableId, viewId } = utils.getSourceId(ctx) const inputs = ctx.request.body const isUserTable = tableId === InternalTables.USER_METADATA let oldRow @@ -90,6 +90,7 @@ export async function patch(ctx: UserCtx) { const result = await finaliseRow(table, row, { oldTable: dbTable, updateFormula: true, + fromViewId: viewId, }) return { ...result, oldRow } diff --git a/packages/server/src/api/controllers/row/staticFormula.ts b/packages/server/src/api/controllers/row/staticFormula.ts index a6a02952d3..777379db14 100644 --- a/packages/server/src/api/controllers/row/staticFormula.ts +++ b/packages/server/src/api/controllers/row/staticFormula.ts @@ -123,7 +123,11 @@ export async function updateAllFormulasInTable(table: Table) { export async function finaliseRow( table: Table, row: Row, - { oldTable, updateFormula }: { oldTable?: Table; updateFormula: boolean } = { + { + oldTable, + updateFormula, + fromViewId, + }: { oldTable?: Table; updateFormula: boolean; fromViewId?: string } = { updateFormula: true, } ) { @@ -154,9 +158,8 @@ export async function finaliseRow( if (updateFormula) { await updateRelatedFormula(table, enrichedRow) } - const squashed = await linkRows.squashLinksToPrimaryDisplay( - table, - enrichedRow - ) + const squashed = await linkRows.squashLinks(table, enrichedRow, { + fromViewId, + }) return { row: enrichedRow, squashed, table } } diff --git a/packages/server/src/api/controllers/row/views.ts b/packages/server/src/api/controllers/row/views.ts index d2541dfa25..06a01646a7 100644 --- a/packages/server/src/api/controllers/row/views.ts +++ b/packages/server/src/api/controllers/row/views.ts @@ -75,8 +75,11 @@ export async function searchView( }) const searchOptions: RequiredKeys & - RequiredKeys> = { + RequiredKeys< + Pick + > = { tableId: view.tableId, + viewId: view.id, query: enrichedQuery, fields: viewFields, ...getSortOptions(body, view), diff --git a/packages/server/src/api/controllers/table/index.ts b/packages/server/src/api/controllers/table/index.ts index 6e1b69b7ac..0e16077092 100644 --- a/packages/server/src/api/controllers/table/index.ts +++ b/packages/server/src/api/controllers/table/index.ts @@ -85,14 +85,19 @@ export async function fetch(ctx: UserCtx) { } }) - ctx.body = [...internal, ...external].map(sdk.tables.enrichViewSchemas) + const result: FetchTablesResponse = [] + for (const table of [...internal, ...external]) { + result.push(await sdk.tables.enrichViewSchemas(table)) + } + ctx.body = result } export async function find(ctx: UserCtx) { const tableId = ctx.params.tableId const table = await sdk.tables.getTable(tableId) - ctx.body = sdk.tables.enrichViewSchemas(table) + const result = await sdk.tables.enrichViewSchemas(table) + ctx.body = result } export async function save(ctx: UserCtx) { @@ -106,11 +111,14 @@ export async function save(ctx: UserCtx) { const api = pickApi({ table }) let savedTable = await api.save(ctx, renaming) if (!table._id) { - savedTable = sdk.tables.enrichViewSchemas(savedTable) + savedTable = await sdk.tables.enrichViewSchemas(savedTable) await events.table.created(savedTable) } else { await events.table.updated(savedTable) } + if (renaming) { + await sdk.views.renameLinkedViews(savedTable, renaming) + } if (isImport) { await events.table.imported(savedTable) } diff --git a/packages/server/src/api/controllers/view/viewsV2.ts b/packages/server/src/api/controllers/view/viewsV2.ts index 4208772fa6..90e80fe81d 100644 --- a/packages/server/src/api/controllers/view/viewsV2.ts +++ b/packages/server/src/api/controllers/view/viewsV2.ts @@ -3,11 +3,12 @@ import { CreateViewRequest, Ctx, RequiredKeys, - ViewUIFieldMetadata, UpdateViewRequest, ViewResponse, ViewResponseEnriched, ViewV2, + ViewFieldMetadata, + RelationSchemaField, } from "@budibase/types" import { builderSocket, gridSocket } from "../../../websockets" @@ -18,21 +19,41 @@ async function parseSchema(view: CreateViewRequest) { const finalViewSchema = view.schema && Object.entries(view.schema).reduce((p, [fieldName, schemaValue]) => { - const fieldSchema: RequiredKeys = { + let fieldRelatedSchema: + | Record> + | undefined + if (schemaValue.columns) { + fieldRelatedSchema = Object.entries(schemaValue.columns).reduce< + NonNullable + >((acc, [key, fieldSchema]) => { + acc[key] = { + visible: fieldSchema.visible, + readonly: fieldSchema.readonly, + } + return acc + }, {}) + } + + const fieldSchema: RequiredKeys< + ViewFieldMetadata & { + columns: typeof fieldRelatedSchema + } + > = { order: schemaValue.order, width: schemaValue.width, visible: schemaValue.visible, readonly: schemaValue.readonly, icon: schemaValue.icon, + columns: fieldRelatedSchema, } Object.entries(fieldSchema) .filter(([, val]) => val === undefined) .forEach(([key]) => { - delete fieldSchema[key as keyof ViewUIFieldMetadata] + delete fieldSchema[key as keyof ViewFieldMetadata] }) p[fieldName] = fieldSchema return p - }, {} as Record>) + }, {} as Record>) return finalViewSchema } diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 765d757680..9790703806 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -40,6 +40,8 @@ import { TableSchema, JsonFieldSubType, RowExportFormat, + FeatureFlag, + RelationSchemaField, } from "@budibase/types" import { generator, mocks } from "@budibase/backend-core/tests" import _, { merge } from "lodash" @@ -1231,7 +1233,7 @@ describe.each([ }) it.each([{ not: "valid" }, { rows: 123 }, "invalid"])( - "Should ignore malformed/invalid delete request: %s", + "should ignore malformed/invalid delete request: %s", async (request: any) => { const rowUsage = await getRowUsage() @@ -2426,6 +2428,376 @@ describe.each([ }) }) + // Upserting isn't yet supported in MSSQL or Oracle, see: + // https://github.com/knex/knex/pull/6050 + !isMSSQL && + !isOracle && + describe("relationships", () => { + let tableId: string + let viewId: string + + let auxData: Row[] = [] + + let flagCleanup: (() => void) | undefined + + beforeAll(async () => { + flagCleanup = setCoreEnv({ + TENANT_FEATURE_FLAGS: `*:${FeatureFlag.ENRICHED_RELATIONSHIPS}`, + }) + + const aux2Table = await config.api.table.save(saveTableRequest()) + const aux2Data = await config.api.row.save(aux2Table._id!, {}) + + const auxTable = await config.api.table.save( + saveTableRequest({ + primaryDisplay: "name", + schema: { + name: { + name: "name", + type: FieldType.STRING, + constraints: { presence: true }, + }, + age: { + name: "age", + type: FieldType.NUMBER, + constraints: { presence: true }, + }, + address: { + name: "address", + type: FieldType.STRING, + constraints: { presence: true }, + visible: false, + }, + link: { + name: "link", + type: FieldType.LINK, + tableId: aux2Table._id!, + relationshipType: RelationshipType.MANY_TO_MANY, + fieldName: "fk_aux", + constraints: { presence: true }, + }, + formula: { + name: "formula", + type: FieldType.FORMULA, + formula: "{{ any }}", + constraints: { presence: true }, + }, + }, + }) + ) + const auxTableId = auxTable._id! + + for (const name of generator.unique(() => generator.name(), 10)) { + auxData.push( + await config.api.row.save(auxTableId, { + name, + age: generator.age(), + address: generator.address(), + link: [aux2Data], + }) + ) + } + + const table = await config.api.table.save( + saveTableRequest({ + schema: { + title: { + name: "title", + type: FieldType.STRING, + constraints: { presence: true }, + }, + relWithNoSchema: { + name: "relWithNoSchema", + relationshipType: RelationshipType.ONE_TO_MANY, + type: FieldType.LINK, + tableId: auxTableId, + fieldName: "fk_relWithNoSchema", + constraints: { presence: true }, + }, + relWithEmptySchema: { + name: "relWithEmptySchema", + relationshipType: RelationshipType.ONE_TO_MANY, + type: FieldType.LINK, + tableId: auxTableId, + fieldName: "fk_relWithEmptySchema", + constraints: { presence: true }, + }, + relWithFullSchema: { + name: "relWithFullSchema", + relationshipType: RelationshipType.ONE_TO_MANY, + type: FieldType.LINK, + tableId: auxTableId, + fieldName: "fk_relWithFullSchema", + constraints: { presence: true }, + }, + relWithHalfSchema: { + name: "relWithHalfSchema", + relationshipType: RelationshipType.ONE_TO_MANY, + type: FieldType.LINK, + tableId: auxTableId, + fieldName: "fk_relWithHalfSchema", + constraints: { presence: true }, + }, + relWithIllegalSchema: { + name: "relWithIllegalSchema", + relationshipType: RelationshipType.ONE_TO_MANY, + type: FieldType.LINK, + tableId: auxTableId, + fieldName: "fk_relWithIllegalSchema", + constraints: { presence: true }, + }, + }, + }) + ) + tableId = table._id! + const view = await config.api.viewV2.create({ + name: generator.guid(), + tableId, + schema: { + title: { + visible: true, + }, + relWithNoSchema: { + visible: true, + }, + relWithEmptySchema: { + visible: true, + columns: {}, + }, + relWithFullSchema: { + visible: true, + columns: Object.keys(auxTable.schema).reduce< + Record + >((acc, c) => ({ ...acc, [c]: { visible: true } }), {}), + }, + relWithHalfSchema: { + visible: true, + columns: { + name: { visible: true }, + age: { visible: false, readonly: true }, + }, + }, + relWithIllegalSchema: { + visible: true, + columns: { + name: { visible: true }, + address: { visible: true }, + unexisting: { visible: true }, + }, + }, + }, + }) + + viewId = view.id + }) + + afterAll(() => { + flagCleanup?.() + }) + + const testScenarios: [string, (row: Row) => Promise | Row][] = [ + ["get row", (row: Row) => config.api.row.get(viewId, row._id!)], + [ + "from view search", + async (row: Row) => { + const { rows } = await config.api.viewV2.search(viewId) + return rows.find(r => r._id === row._id!) + }, + ], + ["from original saved row", (row: Row) => row], + ["from updated row", (row: Row) => config.api.row.save(viewId, row)], + ] + + it.each(testScenarios)( + "can retrieve rows with populated relationships (via %s)", + async (__, retrieveDelegate) => { + const otherRows = _.sampleSize(auxData, 5) + + const row = await config.api.row.save(viewId, { + title: generator.word(), + relWithNoSchema: [otherRows[0]], + relWithEmptySchema: [otherRows[1]], + relWithFullSchema: [otherRows[2]], + relWithHalfSchema: [otherRows[3]], + relWithIllegalSchema: [otherRows[4]], + }) + + const retrieved = await retrieveDelegate(row) + + expect(retrieved).toEqual( + expect.objectContaining({ + title: row.title, + relWithNoSchema: [ + { + _id: otherRows[0]._id, + primaryDisplay: otherRows[0].name, + }, + ], + relWithEmptySchema: [ + { + _id: otherRows[1]._id, + primaryDisplay: otherRows[1].name, + }, + ], + relWithFullSchema: [ + { + _id: otherRows[2]._id, + primaryDisplay: otherRows[2].name, + name: otherRows[2].name, + age: otherRows[2].age, + id: otherRows[2].id, + }, + ], + relWithHalfSchema: [ + { + _id: otherRows[3]._id, + primaryDisplay: otherRows[3].name, + name: otherRows[3].name, + }, + ], + relWithIllegalSchema: [ + { + _id: otherRows[4]._id, + primaryDisplay: otherRows[4].name, + name: otherRows[4].name, + }, + ], + }) + ) + } + ) + + it.each(testScenarios)( + "does not enrich relationships when not enabled (via %s)", + async (__, retrieveDelegate) => { + await withCoreEnv( + { + TENANT_FEATURE_FLAGS: ``, + }, + async () => { + const otherRows = _.sampleSize(auxData, 5) + + const row = await config.api.row.save(viewId, { + title: generator.word(), + relWithNoSchema: [otherRows[0]], + relWithEmptySchema: [otherRows[1]], + relWithFullSchema: [otherRows[2]], + relWithHalfSchema: [otherRows[3]], + relWithIllegalSchema: [otherRows[4]], + }) + + const retrieved = await retrieveDelegate(row) + + expect(retrieved).toEqual( + expect.objectContaining({ + title: row.title, + relWithNoSchema: [ + { + _id: otherRows[0]._id, + primaryDisplay: otherRows[0].name, + }, + ], + relWithEmptySchema: [ + { + _id: otherRows[1]._id, + primaryDisplay: otherRows[1].name, + }, + ], + relWithFullSchema: [ + { + _id: otherRows[2]._id, + primaryDisplay: otherRows[2].name, + }, + ], + relWithHalfSchema: [ + { + _id: otherRows[3]._id, + primaryDisplay: otherRows[3].name, + }, + ], + relWithIllegalSchema: [ + { + _id: otherRows[4]._id, + primaryDisplay: otherRows[4].name, + }, + ], + }) + ) + } + ) + } + ) + + it.each([ + [ + "from table fetch", + async (row: Row) => { + const rows = await config.api.row.fetch(tableId) + return rows.find(r => r._id === row._id!) + }, + ], + [ + "from table search", + async (row: Row) => { + const { rows } = await config.api.row.search(tableId) + return rows.find(r => r._id === row._id!) + }, + ], + ])( + "does not enrich when fetching from the table (via %s)", + async (__, retrieveDelegate) => { + const otherRows = _.sampleSize(auxData, 5) + + const row = await config.api.row.save(viewId, { + title: generator.word(), + relWithNoSchema: [otherRows[0]], + relWithEmptySchema: [otherRows[1]], + relWithFullSchema: [otherRows[2]], + relWithHalfSchema: [otherRows[3]], + relWithIllegalSchema: [otherRows[4]], + }) + + const retrieved = await retrieveDelegate(row) + + expect(retrieved).toEqual( + expect.objectContaining({ + title: row.title, + relWithNoSchema: [ + { + _id: otherRows[0]._id, + primaryDisplay: otherRows[0].name, + }, + ], + relWithEmptySchema: [ + { + _id: otherRows[1]._id, + primaryDisplay: otherRows[1].name, + }, + ], + relWithFullSchema: [ + { + _id: otherRows[2]._id, + primaryDisplay: otherRows[2].name, + }, + ], + relWithHalfSchema: [ + { + _id: otherRows[3]._id, + primaryDisplay: otherRows[3].name, + }, + ], + relWithIllegalSchema: [ + { + _id: otherRows[4]._id, + primaryDisplay: otherRows[4].name, + }, + ], + }) + ) + } + ) + }) + describe("Formula fields", () => { let table: Table let otherTable: Table diff --git a/packages/server/src/api/routes/tests/rowAction.spec.ts b/packages/server/src/api/routes/tests/rowAction.spec.ts index 40ba2c1811..d43f5075d0 100644 --- a/packages/server/src/api/routes/tests/rowAction.spec.ts +++ b/packages/server/src/api/routes/tests/rowAction.spec.ts @@ -672,6 +672,7 @@ describe("/rowsActions", () => { } it("can trigger an automation given valid data", async () => { + expect(await getAutomationLogs()).toBeEmpty() await config.api.rowAction.trigger(tableId, rowAction.id, { rowId: row._id!, }) @@ -680,13 +681,19 @@ describe("/rowsActions", () => { expect(automationLogs).toEqual([ expect.objectContaining({ automationId: rowAction.automationId, - trigger: expect.objectContaining({ + trigger: { + id: "trigger", + stepId: "ROW_ACTION", + inputs: null, outputs: { fields: {}, row: await config.api.row.get(tableId, row._id!), table: await config.api.table.get(tableId), + automation: expect.objectContaining({ + _id: rowAction.automationId, + }), }, - }), + }, }), ]) }) @@ -731,6 +738,7 @@ describe("/rowsActions", () => { ) await config.publish() + expect(await getAutomationLogs()).toBeEmpty() await config.api.rowAction.trigger(viewId, rowAction.id, { rowId: row._id!, }) diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index 356f01dee0..6d2d13e580 100644 --- a/packages/server/src/api/routes/tests/viewV2.spec.ts +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -18,6 +18,10 @@ import { ViewV2, SearchResponse, BasicOperator, + RelationshipType, + TableSchema, + ViewFieldMetadata, + RenameColumn, } from "@budibase/types" import { generator, mocks } from "@budibase/backend-core/tests" import { DatabaseName, getDatasource } from "../../../integrations/tests/utils" @@ -1177,6 +1181,263 @@ describe.each([ ) }) }) + + describe("foreign relationship columns", () => { + const createAuxTable = () => + config.api.table.save( + saveTableRequest({ + primaryDisplay: "name", + schema: { + name: { name: "name", type: FieldType.STRING }, + age: { name: "age", type: FieldType.NUMBER }, + }, + }) + ) + + const createMainTable = async ( + links: { + name: string + tableId: string + fk: string + }[] + ) => { + const table = await config.api.table.save( + saveTableRequest({ + schema: {}, + }) + ) + await config.api.table.save({ + ...table, + schema: { + ...table.schema, + ...links.reduce((acc, c) => { + acc[c.name] = { + name: c.name, + relationshipType: RelationshipType.ONE_TO_MANY, + type: FieldType.LINK, + tableId: c.tableId, + fieldName: c.fk, + constraints: { type: "array" }, + } + return acc + }, {}), + }, + }) + return table + } + + const createView = async ( + tableId: string, + schema: Record + ) => + await config.api.viewV2.create({ + name: generator.guid(), + tableId, + schema, + }) + + const renameColumn = async (table: Table, renaming: RenameColumn) => { + const newSchema = { ...table.schema } + newSchema[renaming.updated] = { + ...table.schema[renaming.old], + name: renaming.updated, + } + delete newSchema[renaming.old] + + await config.api.table.save({ + ...table, + schema: newSchema, + _rename: renaming, + }) + } + + it("updating a column will update link columns configuration", async () => { + let auxTable = await createAuxTable() + + const table = await createMainTable([ + { name: "aux", tableId: auxTable._id!, fk: "fk_aux" }, + ]) + // Refetch auxTable + auxTable = await config.api.table.get(auxTable._id!) + + const view = await createView(table._id!, { + aux: { + visible: true, + columns: { + name: { visible: true, readonly: true }, + age: { visible: true, readonly: true }, + }, + }, + }) + + await renameColumn(auxTable, { old: "age", updated: "dob" }) + + const updatedView = await config.api.viewV2.get(view.id) + expect(updatedView).toEqual( + expect.objectContaining({ + schema: expect.objectContaining({ + aux: expect.objectContaining({ + columns: { + id: { visible: false, readonly: false }, + name: { visible: true, readonly: true }, + dob: { visible: true, readonly: true }, + }, + }), + }), + }) + ) + }) + + it("handles multiple fields using the same table", async () => { + let auxTable = await createAuxTable() + + const table = await createMainTable([ + { name: "aux", tableId: auxTable._id!, fk: "fk_aux" }, + { name: "aux2", tableId: auxTable._id!, fk: "fk_aux2" }, + ]) + // Refetch auxTable + auxTable = await config.api.table.get(auxTable._id!) + + const view = await createView(table._id!, { + aux: { + visible: true, + columns: { + name: { visible: true, readonly: true }, + age: { visible: true, readonly: true }, + }, + }, + aux2: { + visible: true, + columns: { + name: { visible: true, readonly: true }, + age: { visible: true, readonly: true }, + }, + }, + }) + + await renameColumn(auxTable, { old: "age", updated: "dob" }) + + const updatedView = await config.api.viewV2.get(view.id) + expect(updatedView).toEqual( + expect.objectContaining({ + schema: expect.objectContaining({ + aux: expect.objectContaining({ + columns: { + id: { visible: false, readonly: false }, + name: { visible: true, readonly: true }, + dob: { visible: true, readonly: true }, + }, + }), + aux2: expect.objectContaining({ + columns: { + id: { visible: false, readonly: false }, + name: { visible: true, readonly: true }, + dob: { visible: true, readonly: true }, + }, + }), + }), + }) + ) + }) + + it("does not rename columns with the same name but from other tables", async () => { + let auxTable = await createAuxTable() + let aux2Table = await createAuxTable() + + const table = await createMainTable([ + { name: "aux", tableId: auxTable._id!, fk: "fk_aux" }, + { name: "aux2", tableId: aux2Table._id!, fk: "fk_aux2" }, + ]) + + // Refetch auxTable + auxTable = await config.api.table.get(auxTable._id!) + + const view = await createView(table._id!, { + aux: { + visible: true, + columns: { + name: { visible: true, readonly: true }, + }, + }, + aux2: { + visible: true, + columns: { + name: { visible: true, readonly: true }, + }, + }, + }) + + await renameColumn(auxTable, { old: "name", updated: "fullName" }) + + const updatedView = await config.api.viewV2.get(view.id) + expect(updatedView).toEqual( + expect.objectContaining({ + schema: expect.objectContaining({ + aux: expect.objectContaining({ + columns: { + id: { visible: false, readonly: false }, + fullName: { visible: true, readonly: true }, + age: { visible: false, readonly: false }, + }, + }), + aux2: expect.objectContaining({ + columns: { + id: { visible: false, readonly: false }, + name: { visible: true, readonly: true }, + age: { visible: false, readonly: false }, + }, + }), + }), + }) + ) + }) + + it("updates all views references", async () => { + let auxTable = await createAuxTable() + + const table1 = await createMainTable([ + { name: "aux", tableId: auxTable._id!, fk: "fk_aux_table1" }, + ]) + const table2 = await createMainTable([ + { name: "aux", tableId: auxTable._id!, fk: "fk_aux_table2" }, + ]) + + // Refetch auxTable + auxTable = await config.api.table.get(auxTable._id!) + + const viewSchema = { + aux: { + visible: true, + columns: { + name: { visible: true, readonly: true }, + age: { visible: true, readonly: true }, + }, + }, + } + const view1 = await createView(table1._id!, viewSchema) + const view2 = await createView(table1._id!, viewSchema) + const view3 = await createView(table2._id!, viewSchema) + + await renameColumn(auxTable, { old: "age", updated: "dob" }) + + for (const view of [view1, view2, view3]) { + const updatedView = await config.api.viewV2.get(view.id) + expect(updatedView).toEqual( + expect.objectContaining({ + schema: expect.objectContaining({ + aux: expect.objectContaining({ + columns: { + id: { visible: false, readonly: false }, + name: { visible: true, readonly: true }, + dob: { visible: true, readonly: true }, + }, + }), + }), + }) + ) + } + }) + }) }) }) diff --git a/packages/server/src/db/linkedRows/index.ts b/packages/server/src/db/linkedRows/index.ts index 9a86df7041..2c8d1f77ac 100644 --- a/packages/server/src/db/linkedRows/index.ts +++ b/packages/server/src/db/linkedRows/index.ts @@ -11,14 +11,17 @@ import { USER_METDATA_PREFIX } from "../utils" import partition from "lodash/partition" import { getGlobalUsersFromMetadata } from "../../utilities/global" import { processFormulas } from "../../utilities/rowProcessor" -import { context } from "@budibase/backend-core" +import { context, features } from "@budibase/backend-core" import { ContextUser, + FeatureFlag, FieldType, LinkDocumentValue, Row, Table, TableSchema, + ViewFieldMetadata, + ViewV2, } from "@budibase/types" import sdk from "../../sdk" @@ -241,34 +244,78 @@ function getPrimaryDisplayValue(row: Row, table?: Table) { } } +export type SquashTableFields = Record + /** * This function will take the given enriched rows and squash the links to only contain the primary display field. * @param table The table from which the rows originated. * @param enriched The pre-enriched rows (full docs) which are to be squashed. + * @param squashFields Per link column (key) define which columns are allowed while squashing. * @returns The rows after having their links squashed to only contain the ID and primary display. */ -export async function squashLinksToPrimaryDisplay( +export async function squashLinks( table: Table, - enriched: Row[] | Row -) { + enriched: T, + options?: { + fromViewId?: string + } +): Promise { + const allowRelationshipSchemas = await features.flags.isEnabled( + FeatureFlag.ENRICHED_RELATIONSHIPS + ) + + let viewSchema: Record = {} + if (options?.fromViewId && allowRelationshipSchemas) { + const view = Object.values(table.views || {}).find( + (v): v is ViewV2 => sdk.views.isV2(v) && v.id === options?.fromViewId + ) + viewSchema = view?.schema || {} + } + // will populate this as we find them const linkedTables = [table] const isArray = Array.isArray(enriched) - let enrichedArray = !isArray ? [enriched] : enriched - for (let row of enrichedArray) { + const enrichedArray = !isArray ? [enriched] : enriched + for (const row of enrichedArray) { // this only fetches the table if its not already in array const rowTable = await getLinkedTable(row.tableId!, linkedTables) - for (let [column, schema] of Object.entries(rowTable?.schema || {})) { + for (let [column, schema] of Object.entries(rowTable.schema)) { if (schema.type !== FieldType.LINK || !Array.isArray(row[column])) { continue } const newLinks = [] - for (let link of row[column]) { + for (const link of row[column]) { const linkTblId = link.tableId || getRelatedTableForField(table.schema, column) const linkedTable = await getLinkedTable(linkTblId!, linkedTables) const obj: any = { _id: link._id } obj.primaryDisplay = getPrimaryDisplayValue(link, linkedTable) + + if (viewSchema[column]?.columns) { + const squashFields = Object.entries(viewSchema[column].columns) + .filter(([columnName, viewColumnConfig]) => { + const tableColumn = linkedTable.schema[columnName] + if (!tableColumn) { + return false + } + if ( + [FieldType.LINK, FieldType.FORMULA].includes(tableColumn.type) + ) { + return false + } + return ( + tableColumn.visible !== false && + viewColumnConfig.visible !== false + ) + }) + + .map(([columnName]) => columnName) + + for (const relField of squashFields) { + obj[relField] = link[relField] + } + } + newLinks.push(obj) } row[column] = newLinks diff --git a/packages/server/src/middleware/trimViewRowInfo.ts b/packages/server/src/middleware/trimViewRowInfo.ts index 2d29a88a6f..0a5f066c9e 100644 --- a/packages/server/src/middleware/trimViewRowInfo.ts +++ b/packages/server/src/middleware/trimViewRowInfo.ts @@ -28,7 +28,7 @@ export default async (ctx: Ctx, next: Next) => { } // have to mutate the koa context, can't return -export async function trimNonViewFields( +async function trimNonViewFields( row: Row, view: ViewV2, permission: "WRITE" | "READ" diff --git a/packages/server/src/sdk/app/rowActions.ts b/packages/server/src/sdk/app/rowActions.ts index aed2dff1a4..e963a4317b 100644 --- a/packages/server/src/sdk/app/rowActions.ts +++ b/packages/server/src/sdk/app/rowActions.ts @@ -216,11 +216,15 @@ export async function run(tableId: any, rowActionId: any, rowId: string) { const automation = await sdk.automations.get(rowAction.automationId) const row = await sdk.rows.find(tableId, rowId) - await triggers.externalTrigger(automation, { - fields: { - row, - table, + await triggers.externalTrigger( + automation, + { + fields: { + row, + table, + }, + appId: context.getAppId(), }, - appId: context.getAppId(), - }) + { getResponses: true } + ) } diff --git a/packages/server/src/sdk/app/rows/external.ts b/packages/server/src/sdk/app/rows/external.ts index f81d67f621..7630e5638b 100644 --- a/packages/server/src/sdk/app/rows/external.ts +++ b/packages/server/src/sdk/app/rows/external.ts @@ -9,6 +9,7 @@ import { } from "../../../utilities/rowProcessor" import cloneDeep from "lodash/fp/cloneDeep" import isEqual from "lodash/fp/isEqual" +import { tryExtractingTableAndViewId } from "./utils" export async function getRow( tableId: string, @@ -26,10 +27,11 @@ export async function getRow( } export async function save( - tableId: string, + tableOrViewId: string, inputs: Row, userId: string | undefined ) { + const { tableId, viewId } = tryExtractingTableAndViewId(tableOrViewId) const table = await sdk.tables.getTable(tableId) const { table: updatedTable, row } = await inputProcessing( userId, @@ -63,6 +65,7 @@ export async function save( row: await outputProcessing(table, row, { preserveLinks: true, squash: true, + fromViewId: viewId, }), } } else { @@ -70,7 +73,9 @@ export async function save( } } -export async function find(tableId: string, rowId: string): Promise { +export async function find(tableOrViewId: string, rowId: string): Promise { + const { tableId, viewId } = tryExtractingTableAndViewId(tableOrViewId) + const row = await getRow(tableId, rowId, { relationships: true, }) @@ -84,5 +89,6 @@ export async function find(tableId: string, rowId: string): Promise { return await outputProcessing(table, row, { squash: true, preserveLinks: true, + fromViewId: viewId, }) } diff --git a/packages/server/src/sdk/app/rows/internal.ts b/packages/server/src/sdk/app/rows/internal.ts index c21d3465a7..b5b3437e5e 100644 --- a/packages/server/src/sdk/app/rows/internal.ts +++ b/packages/server/src/sdk/app/rows/internal.ts @@ -10,12 +10,14 @@ import { import * as linkRows from "../../../db/linkedRows" import { InternalTables } from "../../../db/utils" import { getFullUser } from "../../../utilities/users" +import { tryExtractingTableAndViewId } from "./utils" export async function save( - tableId: string, + tableOrViewId: string, inputs: Row, userId: string | undefined ) { + const { tableId, viewId } = tryExtractingTableAndViewId(tableOrViewId) inputs.tableId = tableId if (!inputs._rev && !inputs._id) { @@ -50,14 +52,17 @@ export async function save( return finaliseRow(table, row, { oldTable: dbTable, updateFormula: true, + fromViewId: viewId, }) } -export async function find(tableId: string, rowId: string): Promise { +export async function find(tableOrViewId: string, rowId: string): Promise { + const { tableId, viewId } = tryExtractingTableAndViewId(tableOrViewId) + const table = await sdk.tables.getTable(tableId) let row = await findRow(tableId, rowId) - row = await outputProcessing(table, row) + row = await outputProcessing(table, row, { squash: true, fromViewId: viewId }) return row } diff --git a/packages/server/src/sdk/app/rows/rows.ts b/packages/server/src/sdk/app/rows/rows.ts index ef03210800..c61b8692ed 100644 --- a/packages/server/src/sdk/app/rows/rows.ts +++ b/packages/server/src/sdk/app/rows/rows.ts @@ -1,6 +1,10 @@ import { db as dbCore, context } from "@budibase/backend-core" import { Database, Row } from "@budibase/types" -import { getRowParams } from "../../../db/utils" +import { + extractViewInfoFromID, + getRowParams, + isViewID, +} from "../../../db/utils" import { isExternalTableID } from "../../../integrations/utils" import * as internal from "./internal" import * as external from "./external" @@ -20,7 +24,12 @@ export async function getAllInternalRows(appId?: string) { return response.rows.map(row => row.doc) as Row[] } -function pickApi(tableId: any) { +function pickApi(tableOrViewId: string) { + let tableId = tableOrViewId + if (isViewID(tableOrViewId)) { + tableId = extractViewInfoFromID(tableOrViewId).tableId + } + if (isExternalTableID(tableId)) { return external } @@ -28,13 +37,13 @@ function pickApi(tableId: any) { } export async function save( - tableId: string, + tableOrViewId: string, row: Row, userId: string | undefined ) { - return pickApi(tableId).save(tableId, row, userId) + return pickApi(tableOrViewId).save(tableOrViewId, row, userId) } -export async function find(tableId: string, rowId: string) { - return pickApi(tableId).find(tableId, rowId) +export async function find(tableOrViewId: string, rowId: string) { + return pickApi(tableOrViewId).find(tableOrViewId, rowId) } diff --git a/packages/server/src/sdk/app/rows/search/external.ts b/packages/server/src/sdk/app/rows/search/external.ts index 992596ab34..0ff25a00e4 100644 --- a/packages/server/src/sdk/app/rows/search/external.ts +++ b/packages/server/src/sdk/app/rows/search/external.ts @@ -112,9 +112,10 @@ export async function search( : Promise.resolve(undefined), ]) - let processed = await outputProcessing(table, rows, { + let processed = await outputProcessing(table, rows, { preserveLinks: true, squash: true, + fromViewId: options.viewId, }) let hasNextPage = false @@ -260,7 +261,7 @@ export async function fetch(tableId: string): Promise { includeSqlRelationships: IncludeRelationship.INCLUDE, }) const table = await sdk.tables.getTable(tableId) - return await outputProcessing(table, response.rows, { + return await outputProcessing(table, response.rows, { preserveLinks: true, squash: true, }) diff --git a/packages/server/src/sdk/app/rows/search/internal/internal.ts b/packages/server/src/sdk/app/rows/search/internal/internal.ts index ae7bca3b0c..6617fc376c 100644 --- a/packages/server/src/sdk/app/rows/search/internal/internal.ts +++ b/packages/server/src/sdk/app/rows/search/internal/internal.ts @@ -61,7 +61,7 @@ export async function exportRows( }) ).rows.map(row => row.doc!) - result = await outputProcessing(table, response) + result = await outputProcessing(table, response) } else if (query) { let searchResponse = await sdk.rows.search({ tableId, diff --git a/packages/server/src/sdk/app/rows/search/internal/lucene.ts b/packages/server/src/sdk/app/rows/search/internal/lucene.ts index a25803804b..2c149e5b21 100644 --- a/packages/server/src/sdk/app/rows/search/internal/lucene.ts +++ b/packages/server/src/sdk/app/rows/search/internal/lucene.ts @@ -59,7 +59,10 @@ export async function search( response.rows = response.rows.map((r: any) => pick(r, fields)) } - response.rows = await outputProcessing(table, response.rows) + response.rows = await outputProcessing(table, response.rows, { + squash: true, + fromViewId: options.viewId, + }) } return response diff --git a/packages/server/src/sdk/app/rows/search/internal/sqs.ts b/packages/server/src/sdk/app/rows/search/internal/sqs.ts index 6736ff6abf..64489042e5 100644 --- a/packages/server/src/sdk/app/rows/search/internal/sqs.ts +++ b/packages/server/src/sdk/app/rows/search/internal/sqs.ts @@ -379,9 +379,10 @@ export async function search( } // get the rows - let finalRows = await outputProcessing(table, processed, { + let finalRows = await outputProcessing(table, processed, { preserveLinks: true, squash: true, + fromViewId: options.viewId, }) // check if we need to pick specific rows out diff --git a/packages/server/src/sdk/app/rows/utils.ts b/packages/server/src/sdk/app/rows/utils.ts index 0cae39f5a9..bc09116b3b 100644 --- a/packages/server/src/sdk/app/rows/utils.ts +++ b/packages/server/src/sdk/app/rows/utils.ts @@ -17,7 +17,11 @@ import { import { makeExternalQuery } from "../../../integrations/base/query" import { Format } from "../../../api/controllers/view/exporters" import sdk from "../.." -import { isRelationshipColumn } from "../../../db/utils" +import { + extractViewInfoFromID, + isRelationshipColumn, + isViewID, +} from "../../../db/utils" import { isSQL } from "../../../integrations/utils" const SQL_CLIENT_SOURCE_MAP: Record = { @@ -317,3 +321,14 @@ function validateTimeOnlyField( export function isArrayFilter(operator: any): operator is ArrayOperator { return Object.values(ArrayOperator).includes(operator) } + +export function tryExtractingTableAndViewId(tableOrViewId: string) { + if (isViewID(tableOrViewId)) { + return { + tableId: extractViewInfoFromID(tableOrViewId).tableId, + viewId: tableOrViewId, + } + } + + return { tableId: tableOrViewId } +} diff --git a/packages/server/src/sdk/app/tables/getters.ts b/packages/server/src/sdk/app/tables/getters.ts index ddb1bbbf6c..27e9962a1a 100644 --- a/packages/server/src/sdk/app/tables/getters.ts +++ b/packages/server/src/sdk/app/tables/getters.ts @@ -143,16 +143,19 @@ export async function getTables(tableIds: string[]): Promise { return await processTables(tables) } -export function enrichViewSchemas(table: Table): TableResponse { +export async function enrichViewSchemas(table: Table): Promise { + const views = [] + for (const view of Object.values(table.views ?? [])) { + if (sdk.views.isV2(view)) { + views.push(await sdk.views.enrichSchema(view, table.schema)) + } else views.push(view) + } + return { ...table, - views: Object.values(table.views ?? []) - .map(v => - sdk.views.isV2(v) ? sdk.views.enrichSchema(v, table.schema) : v - ) - .reduce((p, v) => { - p[v.name!] = v - return p - }, {} as TableViewsResponse), + views: views.reduce((p, v) => { + p[v.name!] = v + return p + }, {} as TableViewsResponse), } } diff --git a/packages/server/src/sdk/app/tables/tests/tables.spec.ts b/packages/server/src/sdk/app/tables/tests/tables.spec.ts index 6e2cf9efa8..41ac808f5c 100644 --- a/packages/server/src/sdk/app/tables/tests/tables.spec.ts +++ b/packages/server/src/sdk/app/tables/tests/tables.spec.ts @@ -75,7 +75,7 @@ describe("table sdk", () => { const view1 = getTable() const view2 = getTable() const view3 = getTable() - const res = sdk.tables.enrichViewSchemas({ + const res = await sdk.tables.enrichViewSchemas({ ...basicTable, views: { [view1.name]: view1, diff --git a/packages/server/src/sdk/app/views/external.ts b/packages/server/src/sdk/app/views/external.ts index 2b3e271597..3afd7e9bf9 100644 --- a/packages/server/src/sdk/app/views/external.ts +++ b/packages/server/src/sdk/app/views/external.ts @@ -33,7 +33,7 @@ export async function getEnriched(viewId: string): Promise { if (!found) { throw new Error("No view found") } - return enrichSchema(found, table.schema) + return await enrichSchema(found, table.schema) } export async function create( diff --git a/packages/server/src/sdk/app/views/index.ts b/packages/server/src/sdk/app/views/index.ts index ed624c2b5c..a0cffb2634 100644 --- a/packages/server/src/sdk/app/views/index.ts +++ b/packages/server/src/sdk/app/views/index.ts @@ -1,8 +1,11 @@ import { + FieldType, + RelationSchemaField, RenameColumn, + Table, TableSchema, View, - ViewUIFieldMetadata, + ViewFieldMetadata, ViewV2, ViewV2Enriched, } from "@budibase/types" @@ -57,7 +60,7 @@ async function guardViewSchema( if (viewSchema[field].readonly) { if ( !(await features.isViewReadonlyColumnsEnabled()) && - !(tableSchemaField as ViewUIFieldMetadata).readonly + !(tableSchemaField as ViewFieldMetadata).readonly ) { throw new HTTPError(`Readonly fields are not enabled`, 400) } @@ -158,24 +161,64 @@ export function allowedFields( ] } -export function enrichSchema( +export async function enrichSchema( view: ViewV2, tableSchema: TableSchema -): ViewV2Enriched { - let schema: TableSchema = {} - const anyViewOrder = Object.values(view.schema || {}).some( - ui => ui.order != null - ) +): Promise { + const tableCache: Record = {} + + async function populateRelTableSchema( + tableId: string, + viewFields: Record + ) { + if (!tableCache[tableId]) { + tableCache[tableId] = await sdk.tables.getTable(tableId) + } + const relTable = tableCache[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 isVisible = !!viewFields[relTableFieldName]?.visible + const isReadonly = !!viewFields[relTableFieldName]?.readonly + result[relTableFieldName] = { + visible: isVisible, + readonly: isReadonly, + } + } + return result + } + + let schema: ViewV2Enriched["schema"] = {} + + const viewSchema = view.schema || {} + const anyViewOrder = Object.values(viewSchema).some(ui => ui.order != null) for (const key of Object.keys(tableSchema).filter( - key => tableSchema[key].visible !== false + k => tableSchema[k].visible !== false )) { // if nothing specified in view, then it is not visible - const ui = view.schema?.[key] || { visible: false } + const ui = viewSchema[key] || { visible: false } schema[key] = { ...tableSchema[key], ...ui, order: anyViewOrder ? ui?.order ?? undefined : tableSchema[key].order, } + + if (schema[key].type === FieldType.LINK) { + schema[key].columns = await populateRelTableSchema( + schema[key].tableId, + viewSchema[key]?.columns || {} + ) + } } return { @@ -209,3 +252,48 @@ export function syncSchema( 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) + } + } +} diff --git a/packages/server/src/sdk/app/views/internal.ts b/packages/server/src/sdk/app/views/internal.ts index 7b2f9f6c80..19a9f6ab14 100644 --- a/packages/server/src/sdk/app/views/internal.ts +++ b/packages/server/src/sdk/app/views/internal.ts @@ -24,7 +24,7 @@ export async function getEnriched(viewId: string): Promise { if (!found) { throw new Error("No view found") } - return enrichSchema(found, table.schema) + return await enrichSchema(found, table.schema) } export async function create( diff --git a/packages/server/src/sdk/app/views/tests/views.spec.ts b/packages/server/src/sdk/app/views/tests/views.spec.ts index 9dd4a7fb69..5a86702ab6 100644 --- a/packages/server/src/sdk/app/views/tests/views.spec.ts +++ b/packages/server/src/sdk/app/views/tests/views.spec.ts @@ -3,6 +3,7 @@ import { FieldSchema, FieldType, INTERNAL_TABLE_SOURCE_ID, + RelationshipType, Table, TableSchema, TableSourceType, @@ -10,6 +11,7 @@ import { } from "@budibase/types" import { generator } from "@budibase/backend-core/tests" import { enrichSchema, syncSchema } from ".." +import sdk from "../../../../sdk" describe("table sdk", () => { const basicTable: Table = { @@ -58,7 +60,7 @@ describe("table sdk", () => { }, } - describe("enrichViewSchemas", () => { + describe("enrichSchema", () => { it("should fetch the default schema if not overridden", async () => { const tableId = basicTable._id! const view: ViewV2 = { @@ -68,7 +70,7 @@ describe("table sdk", () => { tableId, } - const res = enrichSchema(view, basicTable.schema) + const res = await enrichSchema(view, basicTable.schema) expect(res).toEqual({ ...view, @@ -118,7 +120,7 @@ describe("table sdk", () => { }, } - const res = enrichSchema(view, basicTable.schema) + const res = await enrichSchema(view, basicTable.schema) expect(res).toEqual({ ...view, @@ -152,7 +154,7 @@ describe("table sdk", () => { }, } - const res = enrichSchema(view, basicTable.schema) + const res = await enrichSchema(view, basicTable.schema) expect(res).toEqual({ ...view, @@ -187,7 +189,7 @@ describe("table sdk", () => { }, } - const res = enrichSchema(view, basicTable.schema) + const res = await enrichSchema(view, basicTable.schema) expect(res).toEqual( expect.objectContaining({ @@ -241,7 +243,7 @@ describe("table sdk", () => { }, } - const res = enrichSchema(view, basicTable.schema) + const res = await enrichSchema(view, basicTable.schema) expect(res).toEqual( expect.objectContaining({ @@ -280,6 +282,92 @@ describe("table sdk", () => { }) ) }) + + it("should include related fields", async () => { + const table: Table = { + ...basicTable, + schema: { + name: { + name: "name", + type: FieldType.STRING, + }, + other: { + name: "other", + type: FieldType.LINK, + relationshipType: RelationshipType.ONE_TO_MANY, + fieldName: "table", + tableId: "otherTableId", + }, + }, + } + + const otherTable: Table = { + ...basicTable, + primaryDisplay: "title", + schema: { + title: { + name: "title", + type: FieldType.STRING, + }, + age: { + name: "age", + type: FieldType.NUMBER, + }, + }, + } + + const tableId = table._id! + + const getTableSpy = jest.spyOn(sdk.tables, "getTable") + getTableSpy.mockResolvedValueOnce(otherTable) + + const view: ViewV2 = { + version: 2, + id: generator.guid(), + name: generator.guid(), + tableId, + schema: { + name: { visible: true }, + other: { + visible: true, + columns: { + title: { + visible: true, + readonly: true, + }, + }, + }, + }, + } + + const res = await enrichSchema(view, table.schema) + + expect(res).toEqual( + expect.objectContaining({ + ...view, + schema: { + name: { + ...table.schema.name, + visible: true, + }, + other: { + ...table.schema.other, + visible: true, + columns: { + title: { + visible: true, + readonly: true, + }, + age: { + visible: false, + readonly: false, + }, + }, + }, + }, + }) + ) + }) }) describe("syncSchema", () => { diff --git a/packages/server/src/utilities/rowProcessor/index.ts b/packages/server/src/utilities/rowProcessor/index.ts index 2940f2118e..030f696ac0 100644 --- a/packages/server/src/utilities/rowProcessor/index.ts +++ b/packages/server/src/utilities/rowProcessor/index.ts @@ -247,6 +247,7 @@ export async function outputProcessing( preserveLinks?: boolean fromRow?: Row skipBBReferences?: boolean + fromViewId?: string } = { squash: true, preserveLinks: false, @@ -343,10 +344,9 @@ export async function outputProcessing( enriched = await processFormulas(table, enriched, { dynamic: true }) if (opts.squash) { - enriched = (await linkRows.squashLinksToPrimaryDisplay( - table, - enriched - )) as Row[] + enriched = await linkRows.squashLinks(table, enriched, { + fromViewId: opts?.fromViewId, + }) } // remove null properties to match internal API const isExternal = isExternalTableID(table._id!) diff --git a/packages/types/src/documents/app/view.ts b/packages/types/src/documents/app/view.ts index 24dad0bcca..b5fdcacefe 100644 --- a/packages/types/src/documents/app/view.ts +++ b/packages/types/src/documents/app/view.ts @@ -33,7 +33,13 @@ export interface View { groupBy?: string } -export type ViewUIFieldMetadata = UIFieldMetadata & { +export type ViewFieldMetadata = UIFieldMetadata & { + readonly?: boolean + columns?: Record +} + +export type RelationSchemaField = { + visible?: boolean readonly?: boolean } @@ -45,7 +51,7 @@ export enum CalculationType { MAX = "max", } -export type ViewCalculationFieldMetadata = ViewUIFieldMetadata & { +export type ViewCalculationFieldMetadata = ViewFieldMetadata & { calculationType: CalculationType field: string } @@ -62,7 +68,7 @@ export interface ViewV2 { order?: SortOrder type?: SortType } - schema?: Record + schema?: Record } export type ViewSchema = ViewCountOrSumSchema | ViewStatisticsSchema diff --git a/packages/types/src/sdk/featureFlag.ts b/packages/types/src/sdk/featureFlag.ts index 257b4ee576..3d96b63c64 100644 --- a/packages/types/src/sdk/featureFlag.ts +++ b/packages/types/src/sdk/featureFlag.ts @@ -1,6 +1,7 @@ export enum FeatureFlag { PER_CREATOR_PER_USER_PRICE = "PER_CREATOR_PER_USER_PRICE", PER_CREATOR_PER_USER_PRICE_ALERT = "PER_CREATOR_PER_USER_PRICE_ALERT", + ENRICHED_RELATIONSHIPS = "ENRICHED_RELATIONSHIPS", } export interface TenantFeatureFlags { diff --git a/packages/types/src/sdk/row.ts b/packages/types/src/sdk/row.ts index 6850359cc3..af9f1481d1 100644 --- a/packages/types/src/sdk/row.ts +++ b/packages/types/src/sdk/row.ts @@ -5,6 +5,7 @@ import { WithRequired } from "../shared" export interface SearchParams { tableId?: string + viewId?: string query?: SearchFilters paginate?: boolean bookmark?: string | number diff --git a/packages/types/src/sdk/view.ts b/packages/types/src/sdk/view.ts index cb551dada9..b330db3950 100644 --- a/packages/types/src/sdk/view.ts +++ b/packages/types/src/sdk/view.ts @@ -1,5 +1,9 @@ -import { TableSchema, ViewV2 } from "../documents" +import { FieldSchema, RelationSchemaField, ViewV2 } from "../documents" export interface ViewV2Enriched extends ViewV2 { - schema?: TableSchema + schema?: { + [key: string]: FieldSchema & { + columns?: Record + } + } }