diff --git a/.github/workflows/budibase_ci.yml b/.github/workflows/budibase_ci.yml index e294e50e8d..6a9aa3e8d0 100644 --- a/.github/workflows/budibase_ci.yml +++ b/.github/workflows/budibase_ci.yml @@ -155,7 +155,18 @@ jobs: strategy: matrix: datasource: - [mssql, mysql, postgres, postgres_legacy, mongodb, mariadb, oracle, sqs, none] + [ + mssql, + mysql, + postgres, + postgres_legacy, + mongodb, + mariadb, + oracle, + sqs, + elasticsearch, + none, + ] steps: - name: Checkout repo uses: actions/checkout@v4 @@ -192,6 +203,8 @@ jobs: docker pull budibase/oracle-database:23.2-slim-faststart elif [ "${{ matrix.datasource }}" == "postgres_legacy" ]; then docker pull postgres:9.5.25 + elif [ "${{ matrix.datasource }}" == "elasticsearch" ]; then + docker pull elasticsearch@${{ steps.dotenv.outputs.ELASTICSEARCH_SHA }} fi docker pull minio/minio & docker pull redis & diff --git a/hosting/nginx.dev.conf b/hosting/nginx.dev.conf index 747235e8ef..a8cefe9ccc 100644 --- a/hosting/nginx.dev.conf +++ b/hosting/nginx.dev.conf @@ -62,6 +62,12 @@ http { proxy_connect_timeout 120s; proxy_send_timeout 120s; proxy_http_version 1.1; + + # Enable buffering for potentially large OIDC configs + proxy_buffering on; + proxy_buffer_size 16k; + proxy_buffers 4 32k; + proxy_set_header Host $host; proxy_set_header Connection ""; diff --git a/lerna.json b/lerna.json index bb71d10f41..8ea860e3c4 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "3.4.16", + "version": "3.4.17", "npmClient": "yarn", "concurrency": 20, "command": { diff --git a/packages/builder/src/stores/builder/automations.ts b/packages/builder/src/stores/builder/automations.ts index 039a057a1b..eeee973290 100644 --- a/packages/builder/src/stores/builder/automations.ts +++ b/packages/builder/src/stores/builder/automations.ts @@ -484,7 +484,7 @@ const automationActions = (store: AutomationStore) => ({ branches.forEach((branch, bIdx) => { children[branch.id].forEach( (bBlock: AutomationStep, sIdx: number, array: AutomationStep[]) => { - const ended = array.length - 1 === sIdx && !branches.length + const ended = array.length - 1 === sIdx treeTraverse(bBlock, pathToCurrentNode, sIdx, bIdx, ended) } ) @@ -505,7 +505,6 @@ const automationActions = (store: AutomationStore) => ({ blocks.forEach((block, idx, array) => { treeTraverse(block, null, idx, null, array.length - 1 === idx) }) - return blockRefs }, diff --git a/packages/server/__mocks__/@elastic/elasticsearch.ts b/packages/server/__mocks__/@elastic/elasticsearch.ts deleted file mode 100644 index 5e13437f29..0000000000 --- a/packages/server/__mocks__/@elastic/elasticsearch.ts +++ /dev/null @@ -1,24 +0,0 @@ -const elastic: any = {} - -elastic.Client = function () { - this.index = jest.fn().mockResolvedValue({ body: [] }) - this.search = jest.fn().mockResolvedValue({ - body: { - hits: { - hits: [ - { - _source: { - name: "test", - }, - }, - ], - }, - }, - }) - this.update = jest.fn().mockResolvedValue({ body: [] }) - this.delete = jest.fn().mockResolvedValue({ body: [] }) - - this.close = jest.fn() -} - -module.exports = elastic diff --git a/packages/server/datasource-sha.env b/packages/server/datasource-sha.env index 61249d530c..383fb191f4 100644 --- a/packages/server/datasource-sha.env +++ b/packages/server/datasource-sha.env @@ -3,3 +3,4 @@ MYSQL_SHA=sha256:9de9d54fecee6253130e65154b930978b1fcc336bcc86dfd06e89b72a2588eb POSTGRES_SHA=sha256:bd0d8e485d1aca439d39e5ea99b931160bd28d862e74c786f7508e9d0053090e MONGODB_SHA=sha256:afa36bca12295b5f9dae68a493c706113922bdab520e901bd5d6c9d7247a1d8d MARIADB_SHA=sha256:e59ba8783bf7bc02a4779f103bb0d8751ac0e10f9471089709608377eded7aa8 +ELASTICSEARCH_SHA=sha256:9a6443f55243f6acbfeb4a112d15eb3b9aac74bf25e0e39fa19b3ddd3a6879d0 \ No newline at end of file diff --git a/packages/server/src/api/controllers/row/utils/tests/sqlUtils.spec.ts b/packages/server/src/api/controllers/row/utils/tests/sqlUtils.spec.ts index 365f571fcf..cf14ba9b42 100644 --- a/packages/server/src/api/controllers/row/utils/tests/sqlUtils.spec.ts +++ b/packages/server/src/api/controllers/row/utils/tests/sqlUtils.spec.ts @@ -1,511 +1,532 @@ import { AIOperationEnum, CalculationType, + Datasource, FieldType, RelationshipType, - SourceName, Table, ViewV2, ViewV2Type, } from "@budibase/types" import { buildSqlFieldList } from "../sqlUtils" import { structures } from "../../../../routes/tests/utilities" -import { sql } from "@budibase/backend-core" import { generator } from "@budibase/backend-core/tests" import { generateViewID } from "../../../../../db/utils" -import sdk from "../../../../../sdk" -import { cloneDeep } from "lodash" import { utils } from "@budibase/shared-core" +import { + DatabaseName, + datasourceDescribe, +} from "../../../../../integrations/tests/utils" +import { context } from "@budibase/backend-core" -jest.mock("../../../../../sdk/app/views", () => ({ - ...jest.requireActual("../../../../../sdk/app/views"), - getTable: jest.fn(), -})) -const getTableMock = sdk.views.getTable as jest.MockedFunction< - typeof sdk.views.getTable -> - -describe("buildSqlFieldList", () => { - let allTables: Record - - class TableConfig { - private _table: Table & { _id: string } - - constructor(name: string) { - this._table = { - ...structures.tableForDatasource({ - type: "datasource", - source: SourceName.POSTGRES, - }), - name, - _id: sql.utils.buildExternalTableId("ds_id", name), - schema: { - name: { - name: "name", - type: FieldType.STRING, - }, - description: { - name: "description", - type: FieldType.STRING, - }, - amount: { - name: "amount", - type: FieldType.NUMBER, - }, - }, - } - - allTables[name] = this._table - } - - withHiddenField(field: string) { - this._table.schema[field].visible = false - return this - } - - withField( - name: string, - type: - | FieldType.STRING - | FieldType.NUMBER - | FieldType.FORMULA - | FieldType.AI, - options?: { visible: boolean } - ) { - switch (type) { - case FieldType.NUMBER: - case FieldType.STRING: - this._table.schema[name] = { - name, - type, - ...options, - } - break - case FieldType.FORMULA: - this._table.schema[name] = { - name, - type, - formula: "any", - ...options, - } - break - case FieldType.AI: - this._table.schema[name] = { - name, - type, - operation: AIOperationEnum.PROMPT, - ...options, - } - break - default: - utils.unreachable(type) - } - return this - } - - withRelation(name: string, toTableId: string) { - this._table.schema[name] = { - name, - type: FieldType.LINK, - relationshipType: RelationshipType.ONE_TO_MANY, - fieldName: "link", - tableId: toTableId, - } - return this - } - - withPrimary(field: string) { - this._table.primary = [field] - return this - } - - withDisplay(field: string) { - this._table.primaryDisplay = field - return this - } - - create() { - return cloneDeep(this._table) - } - } - - class ViewConfig { - private _table: Table - private _view: ViewV2 - - constructor(table: Table) { - this._table = table - this._view = { - version: 2, - id: generateViewID(table._id!), - name: generator.word(), - tableId: table._id!, - } - } - - withVisible(field: string) { - this._view.schema ??= {} - this._view.schema[field] ??= {} - this._view.schema[field].visible = true - return this - } - - withHidden(field: string) { - this._view.schema ??= {} - this._view.schema[field] ??= {} - this._view.schema[field].visible = false - return this - } - - withRelationshipColumns( - field: string, - columns: Record - ) { - this._view.schema ??= {} - this._view.schema[field] ??= {} - this._view.schema[field].columns = columns - return this - } - - withCalculation( - name: string, - field: string, - calculationType: CalculationType - ) { - this._view.type = ViewV2Type.CALCULATION - this._view.schema ??= {} - this._view.schema[name] = { - field, - calculationType, - visible: true, - } - return this - } - - create() { - getTableMock.mockResolvedValueOnce(this._table) - return cloneDeep(this._view) - } - } - - beforeEach(() => { - jest.clearAllMocks() - allTables = {} - }) - - describe("table", () => { - it("extracts fields from table schema", async () => { - const table = new TableConfig("table").create() - const result = await buildSqlFieldList(table, {}) - expect(result).toEqual([ - "table.name", - "table.description", - "table.amount", - ]) - }) - - it("excludes hidden fields", async () => { - const table = new TableConfig("table") - .withHiddenField("description") - .create() - const result = await buildSqlFieldList(table, {}) - expect(result).toEqual(["table.name", "table.amount"]) - }) - - it("excludes non-sql fields fields", async () => { - const table = new TableConfig("table") - .withField("formula", FieldType.FORMULA) - .withField("ai", FieldType.AI) - .withRelation("link", "otherTableId") - .create() - - const result = await buildSqlFieldList(table, {}) - expect(result).toEqual([ - "table.name", - "table.description", - "table.amount", - ]) - }) - - it("includes hidden fields if there is a formula column", async () => { - const table = new TableConfig("table") - .withHiddenField("description") - .withField("formula", FieldType.FORMULA) - .create() - - const result = await buildSqlFieldList(table, {}) - expect(result).toEqual([ - "table.name", - "table.description", - "table.amount", - ]) - }) - - it("includes relationships fields when flagged", async () => { - const otherTable = new TableConfig("linkedTable") - .withField("id", FieldType.NUMBER) - .withPrimary("id") - .withDisplay("name") - .create() - - const table = new TableConfig("table") - .withRelation("link", otherTable._id) - .create() - - const result = await buildSqlFieldList(table, allTables, { - relationships: true, - }) - expect(result).toEqual([ - "table.name", - "table.description", - "table.amount", - "linkedTable.id", - "linkedTable.name", - ]) - }) - - it("includes all relationship fields if there is a formula column", async () => { - const otherTable = new TableConfig("linkedTable") - .withField("hidden", FieldType.STRING, { visible: false }) - .create() - - const table = new TableConfig("table") - .withRelation("link", otherTable._id) - .withField("formula", FieldType.FORMULA) - .create() - - const result = await buildSqlFieldList(table, allTables, { - relationships: true, - }) - expect(result).toEqual([ - "table.name", - "table.description", - "table.amount", - "linkedTable.name", - "linkedTable.description", - "linkedTable.amount", - "linkedTable.hidden", - ]) - }) - - it("never includes non-sql columns from relationships", async () => { - const otherTable = new TableConfig("linkedTable") - .withField("id", FieldType.NUMBER) - .withField("hidden", FieldType.STRING, { visible: false }) - .withField("formula", FieldType.FORMULA) - .withField("ai", FieldType.AI) - .withRelation("link", "otherTableId") - .create() - - const table = new TableConfig("table") - .withRelation("link", otherTable._id) - .withField("formula", FieldType.FORMULA) - .create() - - const result = await buildSqlFieldList(table, allTables, { - relationships: true, - }) - expect(result).toEqual([ - "table.name", - "table.description", - "table.amount", - "linkedTable.name", - "linkedTable.description", - "linkedTable.amount", - "linkedTable.id", - "linkedTable.hidden", - ]) - }) - }) - - describe("view", () => { - it("extracts fields from table schema", async () => { - const view = new ViewConfig(new TableConfig("table").create()) - .withVisible("amount") - .withHidden("name") - .create() - - const result = await buildSqlFieldList(view, {}) - expect(result).toEqual(["table.amount"]) - }) - - it("includes all fields if there is a formula column", async () => { - const table = new TableConfig("table") - .withField("formula", FieldType.FORMULA) - .create() - const view = new ViewConfig(table) - .withHidden("name") - .withVisible("amount") - .withVisible("formula") - .create() - - const result = await buildSqlFieldList(view, {}) - expect(result).toEqual([ - "table.name", - "table.description", - "table.amount", - ]) - }) - - it("does not includes all fields if the formula column is not included", async () => { - const table = new TableConfig("table") - .withField("formula", FieldType.FORMULA) - .create() - const view = new ViewConfig(table) - .withHidden("name") - .withVisible("amount") - .withHidden("formula") - .create() - - const result = await buildSqlFieldList(view, {}) - expect(result).toEqual(["table.amount"]) - }) - - it("includes relationships columns", async () => { - const otherTable = new TableConfig("linkedTable") - .withField("id", FieldType.NUMBER) - .withField("formula", FieldType.FORMULA) - .withPrimary("id") - .create() - - const table = new TableConfig("table") - .withRelation("link", otherTable._id) - .create() - - const view = new ViewConfig(table) - .withVisible("name") - .withVisible("link") - .withRelationshipColumns("link", { - name: { visible: false }, - amount: { visible: true }, - formula: { visible: false }, - }) - .create() - - const result = await buildSqlFieldList(view, allTables, { - relationships: true, - }) - expect(result).toEqual([ - "table.name", - "linkedTable.id", - "linkedTable.amount", - ]) - }) - - it("excludes relationships fields when view is not included in the view", async () => { - const otherTable = new TableConfig("linkedTable") - .withField("id", FieldType.NUMBER) - .withPrimary("id") - .withDisplay("name") - .create() - - const table = new TableConfig("table") - .withRelation("link", otherTable._id) - .withField("formula", FieldType.FORMULA) - .create() - - const view = new ViewConfig(table) - .withVisible("name") - .withHidden("amount") - .create() - - const result = await buildSqlFieldList(view, allTables, { - relationships: true, - }) - expect(result).toEqual(["table.name"]) - }) - - it("does not include relationships columns for hidden links", async () => { - const otherTable = new TableConfig("linkedTable") - .withField("id", FieldType.NUMBER) - .withField("formula", FieldType.FORMULA) - .withPrimary("id") - .create() - - const table = new TableConfig("table") - .withRelation("link", otherTable._id) - .create() - - const view = new ViewConfig(table) - .withVisible("name") - .withHidden("link") - .withRelationshipColumns("link", { - name: { visible: false }, - amount: { visible: true }, - formula: { visible: false }, - }) - .create() - - const result = await buildSqlFieldList(view, allTables, { - relationships: true, - }) - expect(result).toEqual(["table.name"]) - }) - - it("includes all relationship fields if there is a formula column", async () => { - const otherTable = new TableConfig("linkedTable") - .withField("id", FieldType.NUMBER) - .withField("hidden", FieldType.STRING, { visible: false }) - .withField("formula", FieldType.FORMULA) - .withField("ai", FieldType.AI) - .withRelation("link", "otherTableId") - .withPrimary("id") - .create() - - const table = new TableConfig("table") - .withRelation("link", otherTable._id) - .withField("formula", FieldType.FORMULA) - .create() - - const view = new ViewConfig(table) - .withVisible("name") - .withVisible("formula") - .withHidden("link") - .withRelationshipColumns("link", { - name: { visible: false }, - amount: { visible: true }, - formula: { visible: false }, - }) - .create() - - const result = await buildSqlFieldList(view, allTables, { - relationships: true, - }) - expect(result).toEqual([ - "table.name", - "table.description", - "table.amount", - "linkedTable.name", - "linkedTable.description", - "linkedTable.amount", - "linkedTable.id", - "linkedTable.hidden", - ]) - }) - }) - - describe("calculation view", () => { - it("does not include calculation fields", async () => { - const view = new ViewConfig(new TableConfig("table").create()) - .withCalculation("average", "amount", CalculationType.AVG) - - .create() - - const result = await buildSqlFieldList(view, {}) - expect(result).toEqual([]) - }) - - it("includes visible fields calculation fields", async () => { - const view = new ViewConfig(new TableConfig("table").create()) - .withCalculation("average", "amount", CalculationType.AVG) - .withHidden("name") - .withVisible("amount") - - .create() - - const result = await buildSqlFieldList(view, {}) - expect(result).toEqual(["table.amount"]) - }) - }) +const descriptions = datasourceDescribe({ + only: [DatabaseName.POSTGRES], }) + +if (descriptions.length) { + describe.each(descriptions)( + "buildSqlFieldList ($dbName)", + ({ config, dsProvider }) => { + let allTables: Record + let datasource: Datasource + + beforeEach(async () => { + const ds = await dsProvider() + datasource = ds.datasource! + allTables = {} + }) + + class TableConfig { + private _table: Table + + constructor(name: string) { + this._table = { + ...structures.tableForDatasource(datasource), + name, + schema: { + name: { + name: "name", + type: FieldType.STRING, + }, + description: { + name: "description", + type: FieldType.STRING, + }, + amount: { + name: "amount", + type: FieldType.NUMBER, + }, + }, + } + } + + withHiddenField(field: string) { + this._table.schema[field].visible = false + return this + } + + withField( + name: string, + type: + | FieldType.STRING + | FieldType.NUMBER + | FieldType.FORMULA + | FieldType.AI, + options?: { visible: boolean } + ) { + switch (type) { + case FieldType.NUMBER: + case FieldType.STRING: + this._table.schema[name] = { + name, + type, + ...options, + } + break + case FieldType.FORMULA: + this._table.schema[name] = { + name, + type, + formula: "any", + ...options, + } + break + case FieldType.AI: + this._table.schema[name] = { + name, + type, + operation: AIOperationEnum.PROMPT, + ...options, + } + break + default: + utils.unreachable(type) + } + return this + } + + withRelation(name: string, toTableId: string) { + this._table.schema[name] = { + name, + type: FieldType.LINK, + relationshipType: RelationshipType.ONE_TO_MANY, + fieldName: "link", + foreignKey: "link", + tableId: toTableId, + } + return this + } + + withPrimary(field: string) { + this._table.primary = [field] + return this + } + + withDisplay(field: string) { + this._table.primaryDisplay = field + return this + } + + async create() { + const table = await config.api.table.save(this._table) + allTables[table.name] = table + return table + } + } + + class ViewConfig { + private _view: ViewV2 + + constructor(table: Table) { + this._view = { + version: 2, + id: generateViewID(table._id!), + name: generator.word(), + tableId: table._id!, + } + } + + withVisible(field: string) { + this._view.schema ??= {} + this._view.schema[field] ??= {} + this._view.schema[field].visible = true + return this + } + + withHidden(field: string) { + this._view.schema ??= {} + this._view.schema[field] ??= {} + this._view.schema[field].visible = false + return this + } + + withRelationshipColumns( + field: string, + columns: Record + ) { + this._view.schema ??= {} + this._view.schema[field] ??= {} + this._view.schema[field].columns = columns + return this + } + + withCalculation( + name: string, + field: string, + calculationType: CalculationType + ) { + this._view.type = ViewV2Type.CALCULATION + this._view.schema ??= {} + this._view.schema[name] = { + field, + calculationType, + visible: true, + } + return this + } + + async create() { + return await config.api.viewV2.create(this._view) + } + } + + const buildSqlFieldListInApp: typeof buildSqlFieldList = async ( + table, + allTables, + opts + ) => { + return context.doInAppContext(config.getAppId(), () => + buildSqlFieldList(table, allTables, opts) + ) + } + + describe("table", () => { + it("extracts fields from table schema", async () => { + const table = await new TableConfig("table").create() + const result = await buildSqlFieldListInApp(table, {}) + expect(result).toEqual([ + "table.name", + "table.description", + "table.amount", + "table.id", + ]) + }) + + it("excludes hidden fields", async () => { + const table = await new TableConfig("table") + .withHiddenField("description") + .create() + const result = await buildSqlFieldListInApp(table, {}) + expect(result).toEqual(["table.name", "table.amount", "table.id"]) + }) + + it("excludes non-sql fields fields", async () => { + const table = await new TableConfig("table") + .withField("formula", FieldType.FORMULA) + .withField("ai", FieldType.AI) + .create() + + const result = await buildSqlFieldListInApp(table, {}) + expect(result).toEqual([ + "table.name", + "table.description", + "table.amount", + "table.id", + ]) + }) + + it("includes hidden fields if there is a formula column", async () => { + const table = await new TableConfig("table") + .withHiddenField("description") + .withField("formula", FieldType.FORMULA) + .create() + + const result = await buildSqlFieldListInApp(table, {}) + expect(result).toEqual([ + "table.name", + "table.description", + "table.amount", + "table.id", + ]) + }) + + it("includes relationships fields when flagged", async () => { + const otherTable = await new TableConfig("linkedTable") + .withField("id", FieldType.NUMBER) + .withPrimary("id") + .withDisplay("name") + .create() + + const table = await new TableConfig("table") + .withRelation("link", otherTable._id!) + .create() + + const result = await buildSqlFieldListInApp(table, allTables, { + relationships: true, + }) + expect(result).toEqual([ + "table.name", + "table.description", + "table.amount", + "table.id", + "linkedTable.id", + "linkedTable.name", + ]) + }) + + it("includes all relationship fields if there is a formula column", async () => { + const otherTable = await new TableConfig("linkedTable") + .withField("hidden", FieldType.STRING, { visible: false }) + .create() + + const table = await new TableConfig("table") + .withRelation("link", otherTable._id!) + .withField("formula", FieldType.FORMULA) + .create() + + const result = await buildSqlFieldListInApp(table, allTables, { + relationships: true, + }) + expect(result).toEqual([ + "table.name", + "table.description", + "table.amount", + "table.id", + "linkedTable.name", + "linkedTable.description", + "linkedTable.amount", + "linkedTable.hidden", + "linkedTable.id", + ]) + }) + + it("never includes non-sql columns from relationships", async () => { + const otherTable = await new TableConfig("linkedTable") + .withField("hidden", FieldType.STRING, { visible: false }) + .withField("formula", FieldType.FORMULA) + .withField("ai", FieldType.AI) + .create() + + const table = await new TableConfig("table") + .withRelation("link", otherTable._id!) + .withField("formula", FieldType.FORMULA) + .create() + + const result = await buildSqlFieldListInApp(table, allTables, { + relationships: true, + }) + expect(result).toEqual([ + "table.name", + "table.description", + "table.amount", + "table.id", + "linkedTable.name", + "linkedTable.description", + "linkedTable.amount", + "linkedTable.hidden", + "linkedTable.id", + ]) + }) + }) + + describe("view", () => { + it("extracts fields from table schema", async () => { + const view = await new ViewConfig( + await new TableConfig("table").create() + ) + .withVisible("amount") + .withHidden("name") + .create() + + const result = await buildSqlFieldListInApp(view, {}) + expect(result).toEqual(["table.amount", "table.id"]) + }) + + it("includes all fields if there is a formula column", async () => { + const table = await new TableConfig("table") + .withField("formula", FieldType.FORMULA) + .create() + const view = await new ViewConfig(table) + .withHidden("name") + .withVisible("amount") + .withVisible("formula") + .create() + + const result = await buildSqlFieldListInApp(view, {}) + expect(result).toEqual([ + "table.name", + "table.description", + "table.amount", + "table.id", + ]) + }) + + it("does not includes all fields if the formula column is not included", async () => { + const table = await new TableConfig("table") + .withField("formula", FieldType.FORMULA) + .create() + const view = await new ViewConfig(table) + .withHidden("name") + .withVisible("amount") + .withHidden("formula") + .create() + + const result = await buildSqlFieldListInApp(view, {}) + expect(result).toEqual(["table.amount", "table.id"]) + }) + + it("includes relationships columns", async () => { + const otherTable = await new TableConfig("linkedTable") + .withField("id", FieldType.NUMBER) + .withField("formula", FieldType.FORMULA) + .withPrimary("id") + .create() + + const table = await new TableConfig("table") + .withRelation("link", otherTable._id!) + .create() + + const view = await new ViewConfig(table) + .withVisible("name") + .withVisible("link") + .withRelationshipColumns("link", { + name: { visible: false }, + amount: { visible: true }, + formula: { visible: false }, + }) + .create() + + const result = await buildSqlFieldListInApp(view, allTables, { + relationships: true, + }) + expect(result).toEqual([ + "table.name", + "table.id", + "linkedTable.id", + "linkedTable.amount", + ]) + }) + + it("excludes relationships fields when view is not included in the view", async () => { + const otherTable = await new TableConfig("linkedTable") + .withField("id", FieldType.NUMBER) + .withPrimary("id") + .withDisplay("name") + .create() + + const table = await new TableConfig("table") + .withRelation("link", otherTable._id!) + .withField("formula", FieldType.FORMULA) + .create() + + const view = await new ViewConfig(table) + .withVisible("name") + .withHidden("amount") + .create() + + const result = await buildSqlFieldListInApp(view, allTables, { + relationships: true, + }) + expect(result).toEqual(["table.name", "table.id"]) + }) + + it("does not include relationships columns for hidden links", async () => { + const otherTable = await new TableConfig("linkedTable") + .withField("id", FieldType.NUMBER) + .withField("formula", FieldType.FORMULA) + .withPrimary("id") + .create() + + const table = await new TableConfig("table") + .withRelation("link", otherTable._id!) + .create() + + const view = await new ViewConfig(table) + .withVisible("name") + .withHidden("link") + .withRelationshipColumns("link", { + name: { visible: false }, + amount: { visible: true }, + formula: { visible: false }, + }) + .create() + + const result = await buildSqlFieldListInApp(view, allTables, { + relationships: true, + }) + expect(result).toEqual(["table.name", "table.id"]) + }) + + it("includes all relationship fields if there is a formula column", async () => { + const otherTable = await new TableConfig("linkedTable") + .withField("id", FieldType.NUMBER) + .withField("hidden", FieldType.STRING, { visible: false }) + .withField("formula", FieldType.FORMULA) + .withField("ai", FieldType.AI) + .withPrimary("id") + .create() + + const table = await new TableConfig("table") + .withRelation("link", otherTable._id!) + .withField("formula", FieldType.FORMULA) + .create() + + const view = await new ViewConfig(table) + .withVisible("name") + .withVisible("formula") + .withHidden("link") + .withRelationshipColumns("link", { + name: { visible: false }, + amount: { visible: true }, + formula: { visible: false }, + }) + .create() + + const result = await buildSqlFieldListInApp(view, allTables, { + relationships: true, + }) + expect(result).toEqual([ + "table.name", + "table.description", + "table.amount", + "table.id", + "linkedTable.name", + "linkedTable.description", + "linkedTable.amount", + "linkedTable.id", + "linkedTable.hidden", + ]) + }) + }) + + describe("calculation view", () => { + it("does not include calculation fields", async () => { + const view = await new ViewConfig( + await new TableConfig("table").create() + ) + .withCalculation("average", "amount", CalculationType.AVG) + + .create() + + const result = await buildSqlFieldListInApp(view, {}) + expect(result).toEqual([]) + }) + + it("includes visible fields calculation fields", async () => { + const view = await new ViewConfig( + await new TableConfig("table").create() + ) + .withCalculation("average", "amount", CalculationType.AVG) + .withHidden("name") + .withVisible("amount") + + .create() + + const result = await buildSqlFieldListInApp(view, {}) + expect(result).toEqual(["table.amount"]) + }) + }) + } + ) +} diff --git a/packages/server/src/api/routes/tests/datasource.spec.ts b/packages/server/src/api/routes/tests/datasource.spec.ts index 21e9effa77..12029c39c4 100644 --- a/packages/server/src/api/routes/tests/datasource.spec.ts +++ b/packages/server/src/api/routes/tests/datasource.spec.ts @@ -165,7 +165,8 @@ describe("/datasources", () => { }) const descriptions = datasourceDescribe({ - exclude: [DatabaseName.MONGODB, DatabaseName.SQS], + plus: true, + exclude: [DatabaseName.SQS], }) if (descriptions.length) { @@ -590,7 +591,8 @@ if (descriptions.length) { } const datasources = datasourceDescribe({ - exclude: [DatabaseName.MONGODB, DatabaseName.SQS, DatabaseName.ORACLE], + plus: true, + exclude: [DatabaseName.SQS, DatabaseName.ORACLE], }) if (datasources.length) { diff --git a/packages/server/src/api/routes/tests/queries/generic-sql.spec.ts b/packages/server/src/api/routes/tests/queries/generic-sql.spec.ts index 863f5b65e0..4a545b253e 100644 --- a/packages/server/src/api/routes/tests/queries/generic-sql.spec.ts +++ b/packages/server/src/api/routes/tests/queries/generic-sql.spec.ts @@ -9,7 +9,8 @@ import { Knex } from "knex" import { generator } from "@budibase/backend-core/tests" const descriptions = datasourceDescribe({ - exclude: [DatabaseName.MONGODB, DatabaseName.SQS], + plus: true, + exclude: [DatabaseName.SQS], }) if (descriptions.length) { diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 87002670b7..b349a1df8a 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -1,9 +1,6 @@ import * as setup from "./utilities" -import { - DatabaseName, - datasourceDescribe, -} from "../../../integrations/tests/utils" +import { datasourceDescribe } from "../../../integrations/tests/utils" import tk from "timekeeper" import emitter from "../../../../src/events" @@ -80,7 +77,7 @@ function encodeJS(binding: string) { return `{{ js "${Buffer.from(binding).toString("base64")}"}}` } -const descriptions = datasourceDescribe({ exclude: [DatabaseName.MONGODB] }) +const descriptions = datasourceDescribe({ plus: true }) if (descriptions.length) { describe.each(descriptions)( diff --git a/packages/server/src/api/routes/tests/search.spec.ts b/packages/server/src/api/routes/tests/search.spec.ts index ee372914d7..caa651f3bb 100644 --- a/packages/server/src/api/routes/tests/search.spec.ts +++ b/packages/server/src/api/routes/tests/search.spec.ts @@ -1,8 +1,5 @@ import { tableForDatasource } from "../../../tests/utilities/structures" -import { - DatabaseName, - datasourceDescribe, -} from "../../../integrations/tests/utils" +import { datasourceDescribe } from "../../../integrations/tests/utils" import { context, db as dbCore, @@ -60,7 +57,7 @@ jest.mock("@budibase/pro", () => ({ }, })) -const descriptions = datasourceDescribe({ exclude: [DatabaseName.MONGODB] }) +const descriptions = datasourceDescribe({ plus: true }) if (descriptions.length) { describe.each(descriptions)( @@ -3553,6 +3550,31 @@ if (descriptions.length) { limit: 1, }).toContainExactly([row]) }) + + isInternal && + describe("search by _id for relations", () => { + it("can filter by the related _id", async () => { + await expectSearch({ + query: { + equal: { "rel._id": row.rel[0]._id }, + }, + }).toContainExactly([row]) + + await expectSearch({ + query: { + equal: { "rel._id": row.rel[1]._id }, + }, + }).toContainExactly([row]) + }) + + it("can filter by the related _id and find nothing", async () => { + await expectSearch({ + query: { + equal: { "rel._id": "rel_none" }, + }, + }).toFindNothing() + }) + }) }) !isInternal && diff --git a/packages/server/src/api/routes/tests/table.spec.ts b/packages/server/src/api/routes/tests/table.spec.ts index 2a7f039ff5..29b576d16a 100644 --- a/packages/server/src/api/routes/tests/table.spec.ts +++ b/packages/server/src/api/routes/tests/table.spec.ts @@ -28,17 +28,14 @@ import * as setup from "./utilities" import * as uuid from "uuid" import { generator } from "@budibase/backend-core/tests" -import { - DatabaseName, - datasourceDescribe, -} from "../../../integrations/tests/utils" +import { datasourceDescribe } from "../../../integrations/tests/utils" import { tableForDatasource } from "../../../tests/utilities/structures" import timekeeper from "timekeeper" const { basicTable } = setup.structures const ISO_REGEX_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ -const descriptions = datasourceDescribe({ exclude: [DatabaseName.MONGODB] }) +const descriptions = datasourceDescribe({ plus: true }) if (descriptions.length) { describe.each(descriptions)( diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index 9531737d30..7eed1811d9 100644 --- a/packages/server/src/api/routes/tests/viewV2.spec.ts +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -37,17 +37,14 @@ import { ViewV2Type, } from "@budibase/types" import { generator, mocks } from "@budibase/backend-core/tests" -import { - DatabaseName, - datasourceDescribe, -} from "../../../integrations/tests/utils" +import { datasourceDescribe } from "../../../integrations/tests/utils" import merge from "lodash/merge" import { quotas } from "@budibase/pro" import { context, db, events, roles, setEnv } from "@budibase/backend-core" import { mockChatGPTResponse } from "../../../tests/utilities/mocks/openai" import nock from "nock" -const descriptions = datasourceDescribe({ exclude: [DatabaseName.MONGODB] }) +const descriptions = datasourceDescribe({ plus: true }) if (descriptions.length) { describe.each(descriptions)( diff --git a/packages/server/src/automations/tests/branching.spec.ts b/packages/server/src/automations/tests/branching.spec.ts index bf9b9ce3f8..4572871c44 100644 --- a/packages/server/src/automations/tests/branching.spec.ts +++ b/packages/server/src/automations/tests/branching.spec.ts @@ -1,5 +1,5 @@ import * as automation from "../index" -import { Table, AutomationStatus } from "@budibase/types" +import { Table, AutomationStatus, EmptyFilterOption } from "@budibase/types" import { createAutomationBuilder } from "./utilities/AutomationTestBuilder" import TestConfiguration from "../../tests/utilities/TestConfiguration" @@ -280,4 +280,23 @@ describe("Branching automations", () => { expect(results.steps[2].outputs.message).toContain("Special user") }) + + it("should not fail with empty conditions", async () => { + const results = await createAutomationBuilder(config) + .onAppAction() + .branch({ + specialBranch: { + steps: stepBuilder => stepBuilder.serverLog({ text: "Hello!" }), + condition: { + onEmptyFilter: EmptyFilterOption.RETURN_NONE, + }, + }, + }) + .test({ fields: { test_trigger: true } }) + + expect(results.steps[0].outputs.success).toEqual(false) + expect(results.steps[0].outputs.status).toEqual( + AutomationStatus.NO_CONDITION_MET + ) + }) }) diff --git a/packages/server/src/automations/tests/steps/executeQuery.spec.ts b/packages/server/src/automations/tests/steps/executeQuery.spec.ts index dff3580b7e..a51d335902 100644 --- a/packages/server/src/automations/tests/steps/executeQuery.spec.ts +++ b/packages/server/src/automations/tests/steps/executeQuery.spec.ts @@ -9,7 +9,8 @@ import { generator } from "@budibase/backend-core/tests" import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" const descriptions = datasourceDescribe({ - exclude: [DatabaseName.MONGODB, DatabaseName.SQS], + plus: true, + exclude: [DatabaseName.SQS], }) if (descriptions.length) { diff --git a/packages/server/src/automations/tests/steps/sendSmtpEmail.spec.ts b/packages/server/src/automations/tests/steps/sendSmtpEmail.spec.ts index 7452239dfa..7aff612a97 100644 --- a/packages/server/src/automations/tests/steps/sendSmtpEmail.spec.ts +++ b/packages/server/src/automations/tests/steps/sendSmtpEmail.spec.ts @@ -1,3 +1,4 @@ +import { SendEmailResponse } from "@budibase/types" import TestConfiguration from "../../../tests/utilities/TestConfiguration" import * as workerRequests from "../../../utilities/workerRequests" @@ -5,17 +6,18 @@ jest.mock("../../../utilities/workerRequests", () => ({ sendSmtpEmail: jest.fn(), })) -function generateResponse(to: string, from: string) { +function generateResponse(to: string, from: string): SendEmailResponse { return { - success: true, - response: { - accepted: [to], - envelope: { - from: from, - to: [to], - }, - message: `Email sent to ${to}.`, + message: `Email sent to ${to}.`, + accepted: [to], + envelope: { + from: from, + to: [to], }, + messageId: "messageId", + pending: [], + rejected: [], + response: "response", } } diff --git a/packages/server/src/integrations/elasticsearch.ts b/packages/server/src/integrations/elasticsearch.ts index af03baaef1..10f9d1e697 100644 --- a/packages/server/src/integrations/elasticsearch.ts +++ b/packages/server/src/integrations/elasticsearch.ts @@ -10,7 +10,7 @@ import { import { Client, ClientOptions } from "@elastic/elasticsearch" import { HOST_ADDRESS } from "./utils" -interface ElasticsearchConfig { +export interface ElasticsearchConfig { url: string ssl?: boolean ca?: string @@ -99,9 +99,9 @@ const SCHEMA: Integration = { }, } -class ElasticSearchIntegration implements IntegrationBase { +export class ElasticSearchIntegration implements IntegrationBase { private config: ElasticsearchConfig - private client + private client: Client constructor(config: ElasticsearchConfig) { this.config = config @@ -132,20 +132,23 @@ class ElasticSearchIntegration implements IntegrationBase { } } - async create(query: { index: string; json: object }) { - const { index, json } = query + async create(query: { + index: string + json: object + extra?: Record + }) { + const { index, json, extra } = query try { const result = await this.client.index({ index, body: json, + ...extra, }) return result.body } catch (err) { console.error("Error writing to elasticsearch", err) throw err - } finally { - await this.client.close() } } @@ -160,41 +163,46 @@ class ElasticSearchIntegration implements IntegrationBase { } catch (err) { console.error("Error querying elasticsearch", err) throw err - } finally { - await this.client.close() } } - async update(query: { id: string; index: string; json: object }) { - const { id, index, json } = query + async update(query: { + id: string + index: string + json: object + extra?: Record + }) { + const { id, index, json, extra } = query try { const result = await this.client.update({ id, index, body: json, + ...extra, }) return result.body } catch (err) { console.error("Error querying elasticsearch", err) throw err - } finally { - await this.client.close() } } - async delete(query: { id: string; index: string }) { - const { id, index } = query + async delete(query: { + id: string + index: string + extra?: Record + }) { + const { id, index, extra } = query try { const result = await this.client.delete({ id, index, + ...extra, }) return result.body } catch (err) { console.error("Error deleting from elasticsearch", err) throw err - } finally { - await this.client.close() } } } diff --git a/packages/server/src/integrations/tests/elasticsearch.spec.ts b/packages/server/src/integrations/tests/elasticsearch.spec.ts index f8a1dd8013..bcf8def1e9 100644 --- a/packages/server/src/integrations/tests/elasticsearch.spec.ts +++ b/packages/server/src/integrations/tests/elasticsearch.spec.ts @@ -1,83 +1,81 @@ -import { default as ElasticSearchIntegration } from "../elasticsearch" +import { Datasource } from "@budibase/types" +import { ElasticsearchConfig, ElasticSearchIntegration } from "../elasticsearch" +import { generator } from "@budibase/backend-core/tests" +import { DatabaseName, datasourceDescribe } from "./utils" -jest.mock("@elastic/elasticsearch") +const describes = datasourceDescribe({ only: [DatabaseName.ELASTICSEARCH] }) -class TestConfiguration { - integration: any +if (describes.length) { + describe.each(describes)("Elasticsearch Integration", ({ dsProvider }) => { + let datasource: Datasource + let integration: ElasticSearchIntegration - constructor(config: any = {}) { - this.integration = new ElasticSearchIntegration.integration(config) - } + let index: string + + beforeAll(async () => { + const ds = await dsProvider() + datasource = ds.datasource! + }) + + beforeEach(() => { + index = generator.guid() + integration = new ElasticSearchIntegration( + datasource.config! as ElasticsearchConfig + ) + }) + + it("can create a record", async () => { + await integration.create({ + index, + json: { name: "Hello" }, + extra: { refresh: "true" }, + }) + const records = await integration.read({ + index, + json: { query: { match_all: {} } }, + }) + expect(records).toEqual([{ name: "Hello" }]) + }) + + it("can update a record", async () => { + const create = await integration.create({ + index, + json: { name: "Hello" }, + extra: { refresh: "true" }, + }) + + await integration.update({ + id: create._id, + index, + json: { doc: { name: "World" } }, + extra: { refresh: "true" }, + }) + + const records = await integration.read({ + index, + json: { query: { match_all: {} } }, + }) + expect(records).toEqual([{ name: "World" }]) + }) + + it("can delete a record", async () => { + const create = await integration.create({ + index, + json: { name: "Hello" }, + extra: { refresh: "true" }, + }) + + await integration.delete({ + id: create._id, + index, + extra: { refresh: "true" }, + }) + + const records = await integration.read({ + index, + json: { query: { match_all: {} } }, + }) + expect(records).toEqual([]) + }) + }) } - -describe("Elasticsearch Integration", () => { - let config: any - let indexName = "Users" - - beforeEach(() => { - config = new TestConfiguration() - }) - - it("calls the create method with the correct params", async () => { - const body = { - name: "Hello", - } - await config.integration.create({ - index: indexName, - json: body, - }) - expect(config.integration.client.index).toHaveBeenCalledWith({ - index: indexName, - body, - }) - }) - - it("calls the read method with the correct params", async () => { - const body = { - query: { - term: { - name: "kimchy", - }, - }, - } - const response = await config.integration.read({ - index: indexName, - json: body, - }) - expect(config.integration.client.search).toHaveBeenCalledWith({ - index: indexName, - body, - }) - expect(response).toEqual(expect.any(Array)) - }) - - it("calls the update method with the correct params", async () => { - const body = { - name: "updated", - } - - const response = await config.integration.update({ - id: "1234", - index: indexName, - json: body, - }) - - expect(config.integration.client.update).toHaveBeenCalledWith({ - id: "1234", - index: indexName, - body, - }) - expect(response).toEqual(expect.any(Array)) - }) - - it("calls the delete method with the correct params", async () => { - const body = { - id: "1234", - } - - const response = await config.integration.delete(body) - - expect(config.integration.client.delete).toHaveBeenCalledWith(body) - expect(response).toEqual(expect.any(Array)) - }) -}) diff --git a/packages/server/src/integrations/tests/utils/elasticsearch.ts b/packages/server/src/integrations/tests/utils/elasticsearch.ts new file mode 100644 index 0000000000..a2ea22f73b --- /dev/null +++ b/packages/server/src/integrations/tests/utils/elasticsearch.ts @@ -0,0 +1,54 @@ +import { Datasource, SourceName } from "@budibase/types" +import { GenericContainer, Wait } from "testcontainers" +import { testContainerUtils } from "@budibase/backend-core/tests" +import { startContainer } from "." +import { ELASTICSEARCH_IMAGE } from "./images" +import { ElasticsearchConfig } from "../../elasticsearch" + +let ports: Promise + +export async function getDatasource(): Promise { + if (!ports) { + ports = startContainer( + new GenericContainer(ELASTICSEARCH_IMAGE) + .withExposedPorts(9200) + .withEnvironment({ + // We need to set the discovery type to single-node to avoid the + // cluster waiting for other nodes to join before starting up. + "discovery.type": "single-node", + // We disable security to avoid having to do any auth against the + // container, and to disable SSL. With SSL enabled it uses a self + // signed certificate that we'd have to ignore anyway. + "xpack.security.enabled": "false", + }) + .withWaitStrategy( + Wait.forHttp( + // Single node clusters never reach status green, so we wait for + // yellow instead. + "/_cluster/health?wait_for_status=yellow&timeout=10s", + 9200 + ).withStartupTimeout(60000) + ) + // We gave the container a tmpfs data directory. Without this, I found + // that the default data directory was very small and the container + // easily filled it up. This caused the cluster to go into a red status + // and stop responding to requests. + .withTmpFs({ "/usr/share/elasticsearch/data": "rw" }) + ) + } + + const port = (await ports).find(x => x.container === 9200)?.host + if (!port) { + throw new Error("Elasticsearch port not found") + } + + const config: ElasticsearchConfig = { + url: `http://127.0.0.1:${port}`, + } + + return { + type: "datasource", + source: SourceName.ELASTICSEARCH, + config, + } +} diff --git a/packages/server/src/integrations/tests/utils/images.ts b/packages/server/src/integrations/tests/utils/images.ts index 00686412c6..c09b130ea5 100644 --- a/packages/server/src/integrations/tests/utils/images.ts +++ b/packages/server/src/integrations/tests/utils/images.ts @@ -12,3 +12,4 @@ export const POSTGRES_IMAGE = `postgres@${process.env.POSTGRES_SHA}` export const POSTGRES_LEGACY_IMAGE = `postgres:9.5.25` export const MONGODB_IMAGE = `mongo@${process.env.MONGODB_SHA}` export const MARIADB_IMAGE = `mariadb@${process.env.MARIADB_SHA}` +export const ELASTICSEARCH_IMAGE = `elasticsearch@${process.env.ELASTICSEARCH_SHA}` diff --git a/packages/server/src/integrations/tests/utils/index.ts b/packages/server/src/integrations/tests/utils/index.ts index 9e2c4f7e70..08777cab89 100644 --- a/packages/server/src/integrations/tests/utils/index.ts +++ b/packages/server/src/integrations/tests/utils/index.ts @@ -6,6 +6,7 @@ import * as mysql from "./mysql" import * as mssql from "./mssql" import * as mariadb from "./mariadb" import * as oracle from "./oracle" +import * as elasticsearch from "./elasticsearch" import { testContainerUtils } from "@budibase/backend-core/tests" import { Knex } from "knex" import TestConfiguration from "../../../tests/utilities/TestConfiguration" @@ -23,22 +24,32 @@ export enum DatabaseName { MARIADB = "mariadb", ORACLE = "oracle", SQS = "sqs", + ELASTICSEARCH = "elasticsearch", } +const DATASOURCE_PLUS = [ + DatabaseName.POSTGRES, + DatabaseName.POSTGRES_LEGACY, + DatabaseName.MYSQL, + DatabaseName.SQL_SERVER, + DatabaseName.MARIADB, + DatabaseName.ORACLE, + DatabaseName.SQS, +] + const providers: Record = { + // datasource_plus entries [DatabaseName.POSTGRES]: postgres.getDatasource, [DatabaseName.POSTGRES_LEGACY]: postgres.getLegacyDatasource, - [DatabaseName.MONGODB]: mongodb.getDatasource, [DatabaseName.MYSQL]: mysql.getDatasource, [DatabaseName.SQL_SERVER]: mssql.getDatasource, [DatabaseName.MARIADB]: mariadb.getDatasource, [DatabaseName.ORACLE]: oracle.getDatasource, [DatabaseName.SQS]: async () => undefined, -} -export interface DatasourceDescribeOpts { - only?: DatabaseName[] - exclude?: DatabaseName[] + // rest + [DatabaseName.ELASTICSEARCH]: elasticsearch.getDatasource, + [DatabaseName.MONGODB]: mongodb.getDatasource, } export interface DatasourceDescribeReturnPromise { @@ -103,6 +114,20 @@ function createDummyTest() { }) } +interface OnlyOpts { + only: DatabaseName[] +} + +interface PlusOpts { + plus: true + exclude?: DatabaseName[] +} + +export type DatasourceDescribeOpts = OnlyOpts | PlusOpts + +// If you ever want to rename this function, be mindful that you will also need +// to modify src/tests/filters/index.js to make sure that we're correctly +// filtering datasource/non-datasource tests in CI. export function datasourceDescribe(opts: DatasourceDescribeOpts) { // tests that call this need a lot longer timeouts jest.setTimeout(120000) @@ -111,17 +136,15 @@ export function datasourceDescribe(opts: DatasourceDescribeOpts) { createDummyTest() } - const { only, exclude } = opts - - if (only && exclude) { - throw new Error("you can only supply one of 'only' or 'exclude'") - } - - let databases = Object.values(DatabaseName) - if (only) { - databases = only - } else if (exclude) { - databases = databases.filter(db => !exclude.includes(db)) + let databases: DatabaseName[] = [] + if ("only" in opts) { + databases = opts.only + } else if ("plus" in opts) { + databases = Object.values(DatabaseName) + .filter(db => DATASOURCE_PLUS.includes(db)) + .filter(db => !opts.exclude?.includes(db)) + } else { + throw new Error("invalid options") } if (process.env.DATASOURCE) { @@ -156,6 +179,7 @@ export function datasourceDescribe(opts: DatasourceDescribeOpts) { isMSSQL: dbName === DatabaseName.SQL_SERVER, isOracle: dbName === DatabaseName.ORACLE, isMariaDB: dbName === DatabaseName.MARIADB, + isElasticsearch: dbName === DatabaseName.ELASTICSEARCH, })) } diff --git a/packages/server/src/sdk/app/rows/queryUtils.ts b/packages/server/src/sdk/app/rows/queryUtils.ts index ddd32870be..8ab50ea1b7 100644 --- a/packages/server/src/sdk/app/rows/queryUtils.ts +++ b/packages/server/src/sdk/app/rows/queryUtils.ts @@ -7,6 +7,7 @@ import { } from "@budibase/types" import { cloneDeep } from "lodash/fp" import sdk from "../../../sdk" +import { isInternal } from "../tables/utils" export const removeInvalidFilters = ( filters: SearchFilters, @@ -70,6 +71,10 @@ export const getQueryableFields = async ( opts?: { noRelationships?: boolean } ): Promise => { const result = [] + if (isInternal({ table })) { + result.push("_id") + } + for (const field of Object.keys(table.schema).filter( f => allowedFields.includes(f) && table.schema[f].visible !== false )) { @@ -113,14 +118,13 @@ export const getQueryableFields = async ( return result } - const result = [ - "_id", // Querying by _id is always allowed, even if it's never part of the schema - ] + // Querying by _id is always allowed, even if it's never part of the schema + const result = ["_id"] if (fields == null) { fields = Object.keys(table.schema) } result.push(...(await extractTableFields(table, fields, [table._id!]))) - return result + return Array.from(new Set(result)) } diff --git a/packages/server/src/sdk/app/rows/search/tests/search.spec.ts b/packages/server/src/sdk/app/rows/search/tests/search.spec.ts index b424c3707d..eaf495e25f 100644 --- a/packages/server/src/sdk/app/rows/search/tests/search.spec.ts +++ b/packages/server/src/sdk/app/rows/search/tests/search.spec.ts @@ -10,16 +10,13 @@ import { import { search } from "../../../../../sdk/app/rows/search" import { generator } from "@budibase/backend-core/tests" -import { - DatabaseName, - datasourceDescribe, -} from "../../../../../integrations/tests/utils" +import { datasourceDescribe } from "../../../../../integrations/tests/utils" import { tableForDatasource } from "../../../../../tests/utilities/structures" // These test cases are only for things that cannot be tested through the API // (e.g. limiting searches to returning specific fields). If it's possible to // test through the API, it should be done there instead. -const descriptions = datasourceDescribe({ exclude: [DatabaseName.MONGODB] }) +const descriptions = datasourceDescribe({ plus: true }) if (descriptions.length) { describe.each(descriptions)( diff --git a/packages/server/src/sdk/app/rows/tests/queryUtils.spec.ts b/packages/server/src/sdk/app/rows/tests/queryUtils.spec.ts index f399801f1e..26a8431446 100644 --- a/packages/server/src/sdk/app/rows/tests/queryUtils.spec.ts +++ b/packages/server/src/sdk/app/rows/tests/queryUtils.spec.ts @@ -250,6 +250,8 @@ describe("query utils", () => { expect(result).toEqual([ "_id", "name", + "aux._id", + "auxTable._id", "aux.title", "auxTable.title", "aux.name", @@ -284,7 +286,14 @@ describe("query utils", () => { const result = await config.doInContext(config.appId, () => { return getQueryableFields(table) }) - expect(result).toEqual(["_id", "name", "aux.name", "auxTable.name"]) + expect(result).toEqual([ + "_id", + "name", + "aux._id", + "auxTable._id", + "aux.name", + "auxTable.name", + ]) }) it("excludes all relationship fields if hidden", async () => { @@ -387,10 +396,14 @@ describe("query utils", () => { "_id", "name", // aux1 primitive props + "aux1._id", + "aux1Table._id", "aux1.name", "aux1Table.name", // aux2 primitive props + "aux2._id", + "aux2Table._id", "aux2.title", "aux2Table.title", ]) @@ -405,14 +418,18 @@ describe("query utils", () => { "name", // aux2_1 primitive props + "aux2_1._id", + "aux2Table._id", "aux2_1.title", "aux2Table.title", // aux2_2 primitive props + "aux2_2._id", "aux2_2.title", - "aux2Table.title", // table primitive props + "table._id", + "TestTable._id", "table.name", "TestTable.name", ]) @@ -427,14 +444,18 @@ describe("query utils", () => { "title", // aux1_1 primitive props + "aux1_1._id", + "aux1Table._id", "aux1_1.name", "aux1Table.name", // aux1_2 primitive props + "aux1_2._id", "aux1_2.name", - "aux1Table.name", // table primitive props + "table._id", + "TestTable._id", "table.name", "TestTable.name", ]) @@ -481,6 +502,8 @@ describe("query utils", () => { "name", // deep 1 aux primitive props + "aux._id", + "auxTable._id", "aux.title", "auxTable.title", ]) @@ -495,6 +518,8 @@ describe("query utils", () => { "title", // deep 1 dependency primitive props + "table._id", + "TestTable._id", "table.name", "TestTable.name", ]) diff --git a/packages/server/src/sdk/app/tables/tests/tables.spec.ts b/packages/server/src/sdk/app/tables/tests/tables.spec.ts deleted file mode 100644 index 41ac808f5c..0000000000 --- a/packages/server/src/sdk/app/tables/tests/tables.spec.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { - FieldType, - INTERNAL_TABLE_SOURCE_ID, - Table, - TableSourceType, - ViewV2, -} from "@budibase/types" -import { generator } from "@budibase/backend-core/tests" -import sdk from "../../.." - -jest.mock("../../views", () => ({ - ...jest.requireActual("../../views"), - enrichSchema: jest.fn().mockImplementation(v => ({ ...v, mocked: true })), -})) - -describe("table sdk", () => { - describe("enrichViewSchemas", () => { - const basicTable: Table = { - _id: generator.guid(), - name: "TestTable", - type: "table", - sourceId: INTERNAL_TABLE_SOURCE_ID, - sourceType: TableSourceType.INTERNAL, - schema: { - name: { - type: FieldType.STRING, - name: "name", - visible: true, - width: 80, - order: 2, - constraints: { - type: "string", - }, - }, - description: { - type: FieldType.STRING, - name: "description", - visible: true, - width: 200, - constraints: { - type: "string", - }, - }, - id: { - type: FieldType.NUMBER, - name: "id", - visible: true, - order: 1, - constraints: { - type: "number", - }, - }, - hiddenField: { - type: FieldType.STRING, - name: "hiddenField", - visible: false, - constraints: { - type: "string", - }, - }, - }, - } - - it("should fetch the default schema if not overriden", async () => { - const tableId = basicTable._id! - function getTable() { - const view: ViewV2 = { - version: 2, - id: generator.guid(), - name: generator.guid(), - tableId, - } - return view - } - const view1 = getTable() - const view2 = getTable() - const view3 = getTable() - const res = await sdk.tables.enrichViewSchemas({ - ...basicTable, - views: { - [view1.name]: view1, - [view2.name]: view2, - [view3.name]: view3, - }, - }) - - expect(sdk.views.enrichSchema).toHaveBeenCalledTimes(3) - - expect(res).toEqual({ - ...basicTable, - views: { - [view1.name]: { - ...view1, - mocked: true, - }, - [view2.name]: { - ...view2, - mocked: true, - }, - [view3.name]: { - ...view3, - mocked: true, - }, - }, - }) - }) - }) -}) diff --git a/packages/server/src/threads/automation.ts b/packages/server/src/threads/automation.ts index 762da1cbc1..def2ab4201 100644 --- a/packages/server/src/threads/automation.ts +++ b/packages/server/src/threads/automation.ts @@ -367,6 +367,8 @@ class Orchestrator { if (e.errno === "ETIME") { span?.addTags({ timedOut: true }) console.warn(`Automation execution timed out after ${timeout}ms`) + } else { + throw e } } diff --git a/packages/server/src/utilities/workerRequests.ts b/packages/server/src/utilities/workerRequests.ts index 0f487d9f31..dd1493b82f 100644 --- a/packages/server/src/utilities/workerRequests.ts +++ b/packages/server/src/utilities/workerRequests.ts @@ -8,7 +8,15 @@ import { logging, env as coreEnv, } from "@budibase/backend-core" -import { Ctx, User, EmailInvite, EmailAttachment } from "@budibase/types" +import { + Ctx, + User, + EmailInvite, + EmailAttachment, + SendEmailResponse, + SendEmailRequest, + EmailTemplatePurpose, +} from "@budibase/types" interface Request { ctx?: Ctx @@ -110,25 +118,23 @@ export async function sendSmtpEmail({ invite?: EmailInvite }) { // tenant ID will be set in header + const request: SendEmailRequest = { + email: to, + from, + contents, + subject, + cc, + bcc, + purpose: EmailTemplatePurpose.CUSTOM, + automation, + invite, + attachments, + } const response = await fetch( checkSlashesInUrl(env.WORKER_URL + `/api/global/email/send`), - createRequest({ - method: "POST", - body: { - email: to, - from, - contents, - subject, - cc, - bcc, - purpose: "custom", - automation, - invite, - attachments, - }, - }) + createRequest({ method: "POST", body: request }) ) - return checkResponse(response, "send email") + return (await checkResponse(response, "send email")) as SendEmailResponse } export async function removeAppFromUserRoles(ctx: Ctx, appId: string) { diff --git a/packages/types/package.json b/packages/types/package.json index ee3c059bc9..a6e08ab84c 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -17,6 +17,7 @@ "@budibase/nano": "10.1.5", "@types/json-schema": "^7.0.15", "@types/koa": "2.13.4", + "@types/nodemailer": "^6.4.17", "@types/redlock": "4.0.7", "koa-useragent": "^4.1.0", "rimraf": "3.0.2", diff --git a/packages/types/src/api/web/global/email.ts b/packages/types/src/api/web/global/email.ts index a0ca0e8485..3d2e007231 100644 --- a/packages/types/src/api/web/global/email.ts +++ b/packages/types/src/api/web/global/email.ts @@ -1,4 +1,5 @@ import { EmailAttachment, EmailInvite } from "../../../documents" +import SMTPTransport from "nodemailer/lib/smtp-transport" export enum EmailTemplatePurpose { CORE = "core", @@ -12,17 +13,17 @@ export enum EmailTemplatePurpose { export interface SendEmailRequest { workspaceId?: string email: string - userId: string + userId?: string purpose: EmailTemplatePurpose contents?: string from?: string subject: string - cc?: boolean - bcc?: boolean + cc?: string + bcc?: string automation?: boolean invite?: EmailInvite attachments?: EmailAttachment[] } -export interface SendEmailResponse extends Record { +export interface SendEmailResponse extends SMTPTransport.SentMessageInfo { message: string } diff --git a/packages/types/src/documents/app/automation/automation.ts b/packages/types/src/documents/app/automation/automation.ts index d5ef35d059..cfe2ba5147 100644 --- a/packages/types/src/documents/app/automation/automation.ts +++ b/packages/types/src/documents/app/automation/automation.ts @@ -1,10 +1,10 @@ import { Document } from "../../document" import { User } from "../../global" -import { ReadStream } from "fs" import { Row } from "../row" import { Table } from "../table" import { AutomationStep, AutomationTrigger } from "./schema" import { ContextEmitter } from "../../../sdk" +import { Readable } from "stream" export enum AutomationIOType { OBJECT = "object", @@ -108,8 +108,8 @@ export interface SendEmailOpts { subject: string // info Pass in a structure of information to be stored alongside the invitation. info?: any - cc?: boolean - bcc?: boolean + cc?: string + bcc?: string automation?: boolean invite?: EmailInvite attachments?: EmailAttachment[] @@ -269,7 +269,7 @@ export type AutomationAttachment = { export type AutomationAttachmentContent = { filename: string - content: ReadStream | NodeJS.ReadableStream + content: Readable } export type BucketedContent = AutomationAttachmentContent & { diff --git a/packages/types/src/sdk/search.ts b/packages/types/src/sdk/search.ts index 992e9961d4..99c3658fa8 100644 --- a/packages/types/src/sdk/search.ts +++ b/packages/types/src/sdk/search.ts @@ -3,6 +3,7 @@ import { Row, DocumentType, Table, Datasource } from "../documents" import { SortOrder, SortType } from "../api" import { Knex } from "knex" import { Aggregation } from "./row" +import _ from "lodash" export enum BasicOperator { EQUAL = "equal", @@ -83,7 +84,7 @@ type RangeFilter = Record< type LogicalFilter = { conditions: SearchFilters[] } export function isLogicalFilter(filter: any): filter is LogicalFilter { - return "conditions" in filter + return _.isPlainObject(filter) && "conditions" in filter } export type AnySearchFilter = BasicFilter | ArrayFilter | RangeFilter diff --git a/packages/worker/package.json b/packages/worker/package.json index 53d14dacee..edaab50d78 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -62,6 +62,7 @@ "koa-body": "4.2.0", "koa-compress": "4.0.1", "koa-passport": "4.1.4", + "koa-redis": "^4.0.1", "koa-send": "5.0.1", "koa-session": "5.13.1", "koa-static": "5.0.0", @@ -85,6 +86,7 @@ "@types/koa__router": "12.0.4", "@types/lodash": "4.14.200", "@types/node-fetch": "2.6.4", + "@types/nodemailer": "^6.4.17", "@types/server-destroy": "1.0.1", "@types/supertest": "2.0.14", "@types/uuid": "8.3.4", diff --git a/packages/worker/src/api/controllers/global/email.ts b/packages/worker/src/api/controllers/global/email.ts index ad0fc3fa32..ed2d9b5125 100644 --- a/packages/worker/src/api/controllers/global/email.ts +++ b/packages/worker/src/api/controllers/global/email.ts @@ -24,10 +24,13 @@ export async function sendEmail( invite, attachments, } = ctx.request.body - let user: any + let user: User | undefined = undefined if (userId) { const db = tenancy.getGlobalDB() - user = await db.get(userId) + user = await db.tryGet(userId) + } + if (!user) { + ctx.throw(404, "User not found.") } const response = await sendEmailFn(email, purpose, { workspaceId, diff --git a/packages/worker/src/api/routes/global/tests/auth.spec.ts b/packages/worker/src/api/routes/global/tests/auth.spec.ts index bff959469e..f89cb4a027 100644 --- a/packages/worker/src/api/routes/global/tests/auth.spec.ts +++ b/packages/worker/src/api/routes/global/tests/auth.spec.ts @@ -311,7 +311,7 @@ describe("/api/global/auth", () => { }) }) - describe("GET /api/global/auth/:tenantId/oidc/callback", () => { + describe.skip("GET /api/global/auth/:tenantId/oidc/callback", () => { it("logs in", async () => { const email = `${generator.guid()}@example.com` diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 0547afab38..bfb022f213 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -4,7 +4,7 @@ if (process.env.DD_APM_ENABLED) { // need to load environment first import env from "./environment" -import Application from "koa" +import Application, { Middleware } from "koa" import { bootstrap } from "global-agent" import * as db from "./db" import { sdk as proSdk } from "@budibase/pro" @@ -20,6 +20,7 @@ import { cache, features, } from "@budibase/backend-core" +import RedisStore from "koa-redis" db.init() import koaBody from "koa-body" @@ -52,7 +53,23 @@ app.proxy = true app.use(handleScimBody) app.use(koaBody({ multipart: true })) -app.use(koaSession(app)) +const sessionMiddleware: Middleware = async (ctx: any, next: any) => { + const redisClient = await new redis.Client( + redis.utils.Databases.SESSIONS + ).init() + return koaSession( + { + // @ts-ignore + store: new RedisStore({ client: redisClient.getClient() }), + key: "koa:sess", + maxAge: 86400000, // one day + }, + app + )(ctx, next) +} + +app.use(sessionMiddleware) + app.use(middleware.correlation) app.use(middleware.pino) app.use(middleware.ip) diff --git a/packages/worker/src/koa-redis.d.ts b/packages/worker/src/koa-redis.d.ts new file mode 100644 index 0000000000..ad1b7a46f1 --- /dev/null +++ b/packages/worker/src/koa-redis.d.ts @@ -0,0 +1 @@ +declare module "koa-redis" {} diff --git a/packages/worker/src/utilities/email.ts b/packages/worker/src/utilities/email.ts index fa9dd7a6fa..a2b9c3bfc2 100644 --- a/packages/worker/src/utilities/email.ts +++ b/packages/worker/src/utilities/email.ts @@ -13,7 +13,8 @@ import { configs, cache, objectStore } from "@budibase/backend-core" import ical from "ical-generator" import _ from "lodash" -const nodemailer = require("nodemailer") +import nodemailer from "nodemailer" +import SMTPTransport from "nodemailer/lib/smtp-transport" const TEST_MODE = env.ENABLE_EMAIL_TEST_MODE && env.isDev() const TYPE = TemplateType.EMAIL @@ -26,7 +27,7 @@ const FULL_EMAIL_PURPOSES = [ ] function createSMTPTransport(config?: SMTPInnerConfig) { - let options: any + let options: SMTPTransport.Options let secure = config?.secure // default it if not specified if (secure == null) { @@ -161,7 +162,7 @@ export async function sendEmail( const code = await getLinkCode(purpose, email, opts.user, opts?.info) let context = await getSettingsTemplateContext(purpose, code) - let message: any = { + let message: Parameters[0] = { from: opts?.from || config?.from, html: await buildEmail(purpose, email, context, { user: opts?.user, diff --git a/yarn.lock b/yarn.lock index 64810291c4..445e0abc18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2695,6 +2695,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.8.3": + version "7.26.9" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.9.tgz#aa4c6facc65b9cb3f87d75125ffd47781b475433" + integrity sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.22.15", "@babel/template@^7.22.5", "@babel/template@^7.25.9", "@babel/template@^7.3.3": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.25.9.tgz#ecb62d81a8a6f5dc5fe8abfc3901fc52ddf15016" @@ -2778,9 +2785,9 @@ through2 "^2.0.0" "@budibase/pro@npm:@budibase/pro@latest": - version "3.4.12" - resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-3.4.12.tgz#60e630944de4e2de970a04179d8f0f57d48ce75e" - integrity sha512-msUBmcWxRDg+ugjZvd27XudERQqtQRdiARsO8MaDVTcp5ejIXgshEIVVshHOCj3hcbRblw9pXvBIMI53iTMUsA== + version "3.4.16" + resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-3.4.16.tgz#c482a400e27b7e89ca73092c4c81bdeac1d24581" + integrity sha512-8ECnqOh9jQ10KlQEwmKPFcoVGE+2gGgSybj+vbshwDp1zAW76doyMR2DMNjEatNpWVnpoMnTkDWtE9aqQ5v0vQ== dependencies: "@anthropic-ai/sdk" "^0.27.3" "@budibase/backend-core" "*" @@ -6768,6 +6775,13 @@ dependencies: undici-types "~6.19.2" +"@types/nodemailer@^6.4.17": + version "6.4.17" + resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.17.tgz#5c82a42aee16a3dd6ea31446a1bd6a447f1ac1a4" + integrity sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww== + dependencies: + "@types/node" "*" + "@types/normalize-package-data@^2.4.0": version "2.4.1" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" @@ -9041,7 +9055,14 @@ co-body@^5.1.1: raw-body "^2.2.0" type-is "^1.6.14" -co@^4.6.0: +co-wrap-all@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/co-wrap-all/-/co-wrap-all-1.0.0.tgz#370ae3e8333510a53f6b2f7fdfbe4568a11b7ecf" + integrity sha512-aru6gLi2vTUazr+MxVm3Rv6ST7/EKtFj9BrfkcOrbCO2Qv6LqJdE71m88HhHiBEviKw/ucVrwoGLrq2xHpOsJA== + dependencies: + co "^4.0.0" + +co@^4.0.0, co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== @@ -13177,7 +13198,7 @@ ioredis@5.3.2: redis-parser "^3.0.0" standard-as-callback "^2.1.0" -ioredis@^4.28.5: +ioredis@^4.14.1, ioredis@^4.28.5: version "4.28.5" resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-4.28.5.tgz#5c149e6a8d76a7f8fa8a504ffc85b7d5b6797f9f" integrity sha512-3GYo0GJtLqgNXj4YhrisLaNNvWSNwSS2wS4OELGfGxH8I69+XfNdnmV1AyN+ZqMh0i7eX+SWjrwFKDBDgfBC1A== @@ -14677,6 +14698,16 @@ koa-pino-logger@4.0.0: dependencies: pino-http "^6.5.0" +koa-redis@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/koa-redis/-/koa-redis-4.0.1.tgz#57ac1b46d9ab851221a9f4952c1e8d4bf289db40" + integrity sha512-o2eTVNo1NBnloeUGhHed5Q2ZvJSLpUEj/+E1/7oH5EmH8WuQ+QLdl/VawkshxdFQ47W1p6V09lM3hCTu7D0YnQ== + dependencies: + "@babel/runtime" "^7.8.3" + co-wrap-all "^1.0.0" + debug "^4.1.1" + ioredis "^4.14.1" + koa-router@^10.0.0: version "10.1.1" resolved "https://registry.yarnpkg.com/koa-router/-/koa-router-10.1.1.tgz#20809f82648518b84726cd445037813cd99f17ff"