diff --git a/lerna.json b/lerna.json index df50828b30..8235ee9b60 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "2.33.2", + "version": "2.33.3", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/packages/server/src/api/controllers/row/ExternalRequest.ts b/packages/server/src/api/controllers/row/ExternalRequest.ts index a8229463ea..62aa4d8d2a 100644 --- a/packages/server/src/api/controllers/row/ExternalRequest.ts +++ b/packages/server/src/api/controllers/row/ExternalRequest.ts @@ -11,11 +11,13 @@ import { IncludeRelationship, InternalSearchFilterOperator, isManyToOne, + isOneToMany, OneToManyRelationshipFieldMetadata, Operation, PaginationJson, QueryJson, RelationshipFieldMetadata, + RelationshipType, Row, SearchFilters, SortJson, @@ -50,13 +52,15 @@ import sdk from "../../../sdk" import env from "../../../environment" import { makeExternalQuery } from "../../../integrations/base/query" import { dataFilters, helpers } from "@budibase/shared-core" +import { isRelationshipColumn } from "../../../db/utils" -export interface ManyRelationship { +interface ManyRelationship { tableId?: string id?: string isUpdate?: boolean key: string [key: string]: any + relationshipType: RelationshipType } export interface RunConfig { @@ -384,6 +388,7 @@ export class ExternalRequest { [otherKey]: breakRowIdField(relationship)[0], // leave the ID for enrichment later [thisKey]: `{{ literal ${tablePrimary} }}`, + relationshipType: RelationshipType.MANY_TO_MANY, }) } } @@ -400,6 +405,7 @@ export class ExternalRequest { [thisKey]: breakRowIdField(relationship)[0], // leave the ID for enrichment later [otherKey]: `{{ literal ${tablePrimary} }}`, + relationshipType: RelationshipType.MANY_TO_ONE, }) } } @@ -420,14 +426,30 @@ export class ExternalRequest { return { row: newRow as T, manyRelationships } } + private getLookupRelationsKey(relationship: { + relationshipType: RelationshipType + fieldName: string + through?: string + }) { + if (relationship.relationshipType === RelationshipType.MANY_TO_MANY) { + return `${relationship.through}_${relationship.fieldName}` + } + return relationship.fieldName + } /** * This is a cached lookup, of relationship records, this is mainly for creating/deleting junction * information. */ - async lookupRelations(tableId: string, row: Row) { - const related: { - [key: string]: { rows: Row[]; isMany: boolean; tableId: string } - } = {} + private async lookupRelations(tableId: string, row: Row) { + const related: Record< + string, + { + rows: Row[] + isMany: boolean + tableId: string + } + > = {} + const { tableName } = breakExternalTableId(tableId) const table = this.tables[tableName] // @ts-ignore @@ -459,11 +481,8 @@ export class ExternalRequest { "Unable to lookup relationships - undefined column properties." ) } - const { tableName: relatedTableName } = - breakExternalTableId(relatedTableId) - // @ts-ignore - const linkPrimaryKey = this.tables[relatedTableName].primary[0] - if (!lookupField || !row?.[lookupField] == null) { + + if (!lookupField || !row?.[lookupField]) { continue } const endpoint = getEndpoint(relatedTableId, Operation.READ) @@ -487,10 +506,8 @@ export class ExternalRequest { !Array.isArray(response) || isKnexEmptyReadResponse(response) ? [] : response - const storeTo = isManyToMany(field) - ? field.throughFrom || linkPrimaryKey - : fieldName - related[storeTo] = { + + related[this.getLookupRelationsKey(field)] = { rows, isMany: isManyToMany(field), tableId: relatedTableId, @@ -518,7 +535,8 @@ export class ExternalRequest { const promises = [] const related = await this.lookupRelations(mainTableId, row) for (let relationship of relationships) { - const { key, tableId, isUpdate, id, ...rest } = relationship + const { key, tableId, isUpdate, id, relationshipType, ...rest } = + relationship const body: { [key: string]: any } = processObjectSync(rest, row, {}) const linkTable = this.getTable(tableId) const relationshipPrimary = linkTable?.primary || [] @@ -529,7 +547,14 @@ export class ExternalRequest { const linkSecondary = relationshipPrimary[1] - const rows = related[key]?.rows || [] + const rows = + related[ + this.getLookupRelationsKey({ + relationshipType, + fieldName: key, + through: relationship.tableId, + }) + ]?.rows || [] const relationshipMatchPredicate = ({ row, @@ -574,12 +599,12 @@ export class ExternalRequest { } } // finally cleanup anything that needs to be removed - for (let [colName, { isMany, rows, tableId }] of Object.entries(related)) { + for (const [field, { isMany, rows, tableId }] of Object.entries(related)) { const table: Table | undefined = this.getTable(tableId) // if it's not the foreign key skip it, nothing to do if ( !table || - (!isMany && table.primary && table.primary.indexOf(colName) !== -1) + (!isMany && table.primary && table.primary.indexOf(field) !== -1) ) { continue } @@ -587,7 +612,7 @@ export class ExternalRequest { const rowId = generateIdForRow(row, table) const promise: Promise = isMany ? this.removeManyToManyRelationships(rowId, table) - : this.removeOneToManyRelationships(rowId, table, colName) + : this.removeOneToManyRelationships(rowId, table, field) if (promise) { promises.push(promise) } @@ -599,23 +624,24 @@ export class ExternalRequest { async removeRelationshipsToRow(table: Table, rowId: string) { const row = await this.getRow(table, rowId) const related = await this.lookupRelations(table._id!, row) - for (let column of Object.values(table.schema)) { - const relationshipColumn = column as RelationshipFieldMetadata - if (!isManyToOne(relationshipColumn)) { + for (const column of Object.values(table.schema)) { + if (!isRelationshipColumn(column) || isOneToMany(column)) { continue } - const { rows, isMany, tableId } = related[relationshipColumn.fieldName] + + const relatedByTable = related[this.getLookupRelationsKey(column)] + if (!relatedByTable) { + continue + } + + const { rows, isMany, tableId } = relatedByTable const table = this.getTable(tableId)! await Promise.all( rows.map(row => { const rowId = generateIdForRow(row, table) return isMany ? this.removeManyToManyRelationships(rowId, table) - : this.removeOneToManyRelationships( - rowId, - table, - relationshipColumn.fieldName - ) + : this.removeOneToManyRelationships(rowId, table, column.fieldName) }) ) } diff --git a/packages/server/src/api/routes/tests/automation.spec.ts b/packages/server/src/api/routes/tests/automation.spec.ts index b6233b24ad..a517346c82 100644 --- a/packages/server/src/api/routes/tests/automation.spec.ts +++ b/packages/server/src/api/routes/tests/automation.spec.ts @@ -9,9 +9,15 @@ import { TRIGGER_DEFINITIONS, BUILTIN_ACTION_DEFINITIONS, } from "../../../automations" -import { events } from "@budibase/backend-core" +import { configs, context, events } from "@budibase/backend-core" import sdk from "../../../sdk" -import { Automation, FieldType, Table } from "@budibase/types" +import { + Automation, + ConfigType, + FieldType, + SettingsConfig, + Table, +} from "@budibase/types" import { mocks } from "@budibase/backend-core/tests" import { FilterConditions } from "../../../automations/steps/filter" import { removeDeprecated } from "../../../automations/utils" @@ -39,8 +45,7 @@ describe("/automations", () => { }) beforeEach(() => { - // @ts-ignore - events.automation.deleted.mockClear() + jest.clearAllMocks() }) describe("get definitions", () => { @@ -244,6 +249,59 @@ describe("/automations", () => { }) }) + describe("run", () => { + let oldConfig: SettingsConfig + beforeAll(async () => { + await context.doInTenant(config.getTenantId(), async () => { + oldConfig = await configs.getSettingsConfigDoc() + + const settings: SettingsConfig = { + _id: oldConfig._id, + _rev: oldConfig._rev, + type: ConfigType.SETTINGS, + config: { + platformUrl: "https://example.com", + logoUrl: "https://example.com/logo.png", + company: "Test Company", + }, + } + const saved = await configs.save(settings) + oldConfig._rev = saved.rev + }) + }) + + afterAll(async () => { + await context.doInTenant(config.getTenantId(), async () => { + await configs.save(oldConfig) + }) + }) + + it("should be able to access platformUrl, logoUrl and company in the automation", async () => { + const result = await createAutomationBuilder({ + name: "Test Automation", + appId: config.getAppId(), + config, + }) + .appAction({ fields: {} }) + .serverLog({ + text: "{{ settings.url }}", + }) + .serverLog({ + text: "{{ settings.logo }}", + }) + .serverLog({ + text: "{{ settings.company }}", + }) + .run() + + expect(result.steps[0].outputs.message).toEndWith("https://example.com") + expect(result.steps[1].outputs.message).toEndWith( + "https://example.com/logo.png" + ) + expect(result.steps[2].outputs.message).toEndWith("Test Company") + }) + }) + describe("test", () => { it("tests the automation successfully", async () => { let table = await config.createTable() diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 66e931d0ab..2980803fbc 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -952,6 +952,105 @@ describe.each([ }) }) }) + + !isLucene && + describe("relations to same table", () => { + let relatedRows: Row[] + + beforeAll(async () => { + const relatedTable = await config.api.table.save( + defaultTable({ + schema: { + name: { name: "name", type: FieldType.STRING }, + }, + }) + ) + const relatedTableId = relatedTable._id! + table = await config.api.table.save( + defaultTable({ + schema: { + name: { name: "name", type: FieldType.STRING }, + related1: { + type: FieldType.LINK, + name: "related1", + fieldName: "main1", + tableId: relatedTableId, + relationshipType: RelationshipType.MANY_TO_MANY, + }, + related2: { + type: FieldType.LINK, + name: "related2", + fieldName: "main2", + tableId: relatedTableId, + relationshipType: RelationshipType.MANY_TO_MANY, + }, + }, + }) + ) + relatedRows = await Promise.all([ + config.api.row.save(relatedTableId, { name: "foo" }), + config.api.row.save(relatedTableId, { name: "bar" }), + config.api.row.save(relatedTableId, { name: "baz" }), + config.api.row.save(relatedTableId, { name: "boo" }), + ]) + }) + + it("can create rows with both relationships", async () => { + const row = await config.api.row.save(table._id!, { + name: "test", + related1: [relatedRows[0]._id!], + related2: [relatedRows[1]._id!], + }) + + expect(row).toEqual( + expect.objectContaining({ + name: "test", + related1: [ + { + _id: relatedRows[0]._id, + primaryDisplay: relatedRows[0].name, + }, + ], + related2: [ + { + _id: relatedRows[1]._id, + primaryDisplay: relatedRows[1].name, + }, + ], + }) + ) + }) + + it("can create rows with no relationships", async () => { + const row = await config.api.row.save(table._id!, { + name: "test", + }) + + expect(row.related1).toBeUndefined() + expect(row.related2).toBeUndefined() + }) + + it("can create rows with only one relationships field", async () => { + const row = await config.api.row.save(table._id!, { + name: "test", + related1: [], + related2: [relatedRows[1]._id!], + }) + + expect(row).toEqual( + expect.objectContaining({ + name: "test", + related2: [ + { + _id: relatedRows[1]._id, + primaryDisplay: relatedRows[1].name, + }, + ], + }) + ) + expect(row.related1).toBeUndefined() + }) + }) }) describe("get", () => { @@ -1054,6 +1153,134 @@ describe.each([ const rows = await config.api.row.fetch(table._id!) expect(rows).toHaveLength(1) }) + + !isLucene && + describe("relations to same table", () => { + let relatedRows: Row[] + + beforeAll(async () => { + const relatedTable = await config.api.table.save( + defaultTable({ + schema: { + name: { name: "name", type: FieldType.STRING }, + }, + }) + ) + const relatedTableId = relatedTable._id! + table = await config.api.table.save( + defaultTable({ + schema: { + name: { name: "name", type: FieldType.STRING }, + related1: { + type: FieldType.LINK, + name: "related1", + fieldName: "main1", + tableId: relatedTableId, + relationshipType: RelationshipType.MANY_TO_MANY, + }, + related2: { + type: FieldType.LINK, + name: "related2", + fieldName: "main2", + tableId: relatedTableId, + relationshipType: RelationshipType.MANY_TO_MANY, + }, + }, + }) + ) + relatedRows = await Promise.all([ + config.api.row.save(relatedTableId, { name: "foo" }), + config.api.row.save(relatedTableId, { name: "bar" }), + config.api.row.save(relatedTableId, { name: "baz" }), + config.api.row.save(relatedTableId, { name: "boo" }), + ]) + }) + + it("can edit rows with both relationships", async () => { + let row = await config.api.row.save(table._id!, { + name: "test", + related1: [relatedRows[0]._id!], + related2: [relatedRows[1]._id!], + }) + + row = await config.api.row.save(table._id!, { + ...row, + related1: [relatedRows[0]._id!, relatedRows[1]._id!], + related2: [relatedRows[2]._id!], + }) + + expect(row).toEqual( + expect.objectContaining({ + name: "test", + related1: expect.arrayContaining([ + { + _id: relatedRows[0]._id, + primaryDisplay: relatedRows[0].name, + }, + { + _id: relatedRows[1]._id, + primaryDisplay: relatedRows[1].name, + }, + ]), + related2: [ + { + _id: relatedRows[2]._id, + primaryDisplay: relatedRows[2].name, + }, + ], + }) + ) + }) + + it("can drop existing relationship", async () => { + let row = await config.api.row.save(table._id!, { + name: "test", + related1: [relatedRows[0]._id!], + related2: [relatedRows[1]._id!], + }) + + row = await config.api.row.save(table._id!, { + ...row, + related1: [], + related2: [relatedRows[2]._id!], + }) + + expect(row).toEqual( + expect.objectContaining({ + name: "test", + related2: [ + { + _id: relatedRows[2]._id, + primaryDisplay: relatedRows[2].name, + }, + ], + }) + ) + expect(row.related1).toBeUndefined() + }) + + it("can drop both relationships", async () => { + let row = await config.api.row.save(table._id!, { + name: "test", + related1: [relatedRows[0]._id!], + related2: [relatedRows[1]._id!], + }) + + row = await config.api.row.save(table._id!, { + ...row, + related1: [], + related2: [], + }) + + expect(row).toEqual( + expect.objectContaining({ + name: "test", + }) + ) + expect(row.related1).toBeUndefined() + expect(row.related2).toBeUndefined() + }) + }) }) describe("patch", () => { @@ -1330,6 +1557,73 @@ describe.each([ ) expect(res.length).toEqual(2) }) + + !isLucene && + describe("relations to same table", () => { + let relatedRows: Row[] + + beforeAll(async () => { + const relatedTable = await config.api.table.save( + defaultTable({ + schema: { + name: { name: "name", type: FieldType.STRING }, + }, + }) + ) + const relatedTableId = relatedTable._id! + table = await config.api.table.save( + defaultTable({ + schema: { + name: { name: "name", type: FieldType.STRING }, + related1: { + type: FieldType.LINK, + name: "related1", + fieldName: "main1", + tableId: relatedTableId, + relationshipType: RelationshipType.MANY_TO_MANY, + }, + related2: { + type: FieldType.LINK, + name: "related2", + fieldName: "main2", + tableId: relatedTableId, + relationshipType: RelationshipType.MANY_TO_MANY, + }, + }, + }) + ) + relatedRows = await Promise.all([ + config.api.row.save(relatedTableId, { name: "foo" }), + config.api.row.save(relatedTableId, { name: "bar" }), + config.api.row.save(relatedTableId, { name: "baz" }), + config.api.row.save(relatedTableId, { name: "boo" }), + ]) + }) + + it("can delete rows with both relationships", async () => { + const row = await config.api.row.save(table._id!, { + name: "test", + related1: [relatedRows[0]._id!], + related2: [relatedRows[1]._id!], + }) + + await config.api.row.delete(table._id!, { _id: row._id! }) + + await config.api.row.get(table._id!, row._id!, { status: 404 }) + }) + + it("can delete rows with empty relationships", async () => { + const row = await config.api.row.save(table._id!, { + name: "test", + related1: [], + related2: [], + }) + + await config.api.row.delete(table._id!, { _id: row._id! }) + + await config.api.row.get(table._id!, row._id!, { status: 404 }) + }) + }) }) describe("validate", () => { @@ -2796,7 +3090,7 @@ describe.each([ }, ], ["from original saved row", (row: Row) => row], - ["from updated row", (row: Row) => config.api.row.save(viewId, row)], + ["from updated row", (row: Row) => config.api.row.save(viewId, row)], ] it.each(testScenarios)( diff --git a/packages/server/src/automations/tests/utilities/AutomationTestBuilder.ts b/packages/server/src/automations/tests/utilities/AutomationTestBuilder.ts index 6aaf22cd6a..fa8061edce 100644 --- a/packages/server/src/automations/tests/utilities/AutomationTestBuilder.ts +++ b/packages/server/src/automations/tests/utilities/AutomationTestBuilder.ts @@ -225,7 +225,9 @@ class AutomationBuilder extends BaseStepBuilder { private triggerOutputs: any private triggerSet: boolean = false - constructor(options: { name?: string; appId?: string } = {}) { + constructor( + options: { name?: string; appId?: string; config?: TestConfiguration } = {} + ) { super() this.automationConfig = { name: options.name || `Test Automation ${uuidv4()}`, @@ -237,7 +239,7 @@ class AutomationBuilder extends BaseStepBuilder { type: "automation", appId: options.appId ?? setup.getConfig().getAppId(), } - this.config = setup.getConfig() + this.config = options.config || setup.getConfig() } // TRIGGERS @@ -347,6 +349,7 @@ class AutomationBuilder extends BaseStepBuilder { export function createAutomationBuilder(options?: { name?: string appId?: string + config?: TestConfiguration }) { return new AutomationBuilder(options) } diff --git a/packages/server/src/definitions/automations.ts b/packages/server/src/definitions/automations.ts index d551584fd1..e45c555201 100644 --- a/packages/server/src/definitions/automations.ts +++ b/packages/server/src/definitions/automations.ts @@ -20,4 +20,9 @@ export interface AutomationContext extends AutomationResults { env?: Record user?: UserBindings trigger: any + settings?: { + url?: string + logo?: string + company?: string + } } diff --git a/packages/server/src/threads/automation.ts b/packages/server/src/threads/automation.ts index 0f17b44424..1af8646cf4 100644 --- a/packages/server/src/threads/automation.ts +++ b/packages/server/src/threads/automation.ts @@ -30,7 +30,7 @@ import { } from "@budibase/types" import { AutomationContext, TriggerOutput } from "../definitions/automations" import { WorkerCallback } from "./definitions" -import { context, logging } from "@budibase/backend-core" +import { context, logging, configs } from "@budibase/backend-core" import { processObject, processStringSync } from "@budibase/string-templates" import { cloneDeep } from "lodash/fp" import { performance } from "perf_hooks" @@ -263,6 +263,18 @@ class Orchestrator { this.context.env = await sdkUtils.getEnvironmentVariables() this.context.user = this.currentUser + try { + const { config } = await configs.getSettingsConfigDoc() + this.context.settings = { + url: config.platformUrl, + logo: config.logoUrl, + company: config.company, + } + } catch (e) { + // if settings doc doesn't exist, make the settings blank + this.context.settings = {} + } + let metadata // check if this is a recurring automation, diff --git a/yarn.lock b/yarn.lock index 5dfef16e0e..492f4b3fb2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2051,7 +2051,7 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@budibase/backend-core@2.32.11": +"@budibase/backend-core@2.33.2": version "0.0.0" dependencies: "@budibase/nano" "10.1.5" @@ -2132,15 +2132,15 @@ through2 "^2.0.0" "@budibase/pro@npm:@budibase/pro@latest": - version "2.32.11" - resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.32.11.tgz#c94d534f829ca0ef252677757e157a7e58b87b4d" - integrity sha512-mOkqJpqHKWsfTWZwWcvBCYFUIluSUHltQNinc1ZRsg9rC3OKoHSDop6gzm744++H/GzGRN8V86kLhCgtNIlkpA== + version "2.33.2" + resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.33.2.tgz#5c2012f7b2bf0fd871cda1ad37ad7a0442c84658" + integrity sha512-lBB6Wfp6OIOHRlGq82WS9KxvEXRs/P2QlwJT0Aj9PhmkQFsnXm2r8d18f0xTGvcflD+iR7XGP/k56JlCanmhQg== dependencies: "@anthropic-ai/sdk" "^0.27.3" - "@budibase/backend-core" "2.32.11" - "@budibase/shared-core" "2.32.11" - "@budibase/string-templates" "2.32.11" - "@budibase/types" "2.32.11" + "@budibase/backend-core" "2.33.2" + "@budibase/shared-core" "2.33.2" + "@budibase/string-templates" "2.33.2" + "@budibase/types" "2.33.2" "@koa/router" "8.0.8" bull "4.10.1" dd-trace "5.2.0" @@ -2153,13 +2153,13 @@ scim-patch "^0.8.1" scim2-parse-filter "^0.2.8" -"@budibase/shared-core@2.32.11": +"@budibase/shared-core@2.33.2": version "0.0.0" dependencies: "@budibase/types" "0.0.0" cron-validate "1.4.5" -"@budibase/string-templates@2.32.11": +"@budibase/string-templates@2.33.2": version "0.0.0" dependencies: "@budibase/handlebars-helpers" "^0.13.2" @@ -2167,7 +2167,7 @@ handlebars "^4.7.8" lodash.clonedeep "^4.5.0" -"@budibase/types@2.32.11": +"@budibase/types@2.33.2": version "0.0.0" dependencies: scim-patch "^0.8.1"