diff --git a/lerna.json b/lerna.json index 94ca18985e..74b10c07ea 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.22.17", + "version": "2.22.18", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/packages/account-portal b/packages/account-portal index 532c4db35c..a0ee9cad8c 160000 --- a/packages/account-portal +++ b/packages/account-portal @@ -1 +1 @@ -Subproject commit 532c4db35cecd346b5c24f0b89ab7b397a122a36 +Subproject commit a0ee9cad8cefb8f9f40228705711be174f018fa9 diff --git a/packages/builder/package.json b/packages/builder/package.json index 253f5a0c14..f29ae3f7f2 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -72,7 +72,7 @@ "fast-json-patch": "^3.1.1", "json-format-highlight": "^1.0.4", "lodash": "4.17.21", - "posthog-js": "^1.116.6", + "posthog-js": "^1.118.0", "remixicon": "2.5.0", "sanitize-html": "^2.7.0", "shortid": "2.2.15", diff --git a/packages/builder/src/analytics/index.js b/packages/builder/src/analytics/index.js index 3a80a05d7f..a0ddfe1d42 100644 --- a/packages/builder/src/analytics/index.js +++ b/packages/builder/src/analytics/index.js @@ -38,6 +38,10 @@ class AnalyticsHub { intercom.show(user) } + initPosthog() { + posthog.init() + } + async logout() { posthog.logout() intercom.logout() diff --git a/packages/builder/src/components/deploy/AppActions.svelte b/packages/builder/src/components/deploy/AppActions.svelte index 1d7bb4f65e..30b95d639e 100644 --- a/packages/builder/src/components/deploy/AppActions.svelte +++ b/packages/builder/src/components/deploy/AppActions.svelte @@ -33,13 +33,10 @@ import { TOUR_STEP_KEYS } from "components/portal/onboarding/tours.js" import { goto } from "@roxi/routify" import { onMount } from "svelte" - import PosthogClient from "../../analytics/PosthogClient" export let application export let loaded - const posthog = new PosthogClient(process.env.POSTHOG_TOKEN) - let unpublishModal let updateAppModal let revertModal @@ -156,7 +153,7 @@ } onMount(() => { - posthog.init() + analytics.initPosthog() }) diff --git a/packages/client/src/components/Screen.svelte b/packages/client/src/components/Screen.svelte index 4b3acb2019..ac0af9f3b2 100644 --- a/packages/client/src/components/Screen.svelte +++ b/packages/client/src/components/Screen.svelte @@ -5,29 +5,29 @@ import Provider from "./context/Provider.svelte" import { onMount, getContext } from "svelte" import { enrichButtonActions } from "../utils/buttonActions.js" + import { memo } from "@budibase/frontend-core" export let params = {} const context = getContext("context") + const onLoadActions = memo() // Get the screen definition for the current route $: screenDefinition = $screenStore.activeScreen?.props - - $: runOnLoadActions(params) + $: onLoadActions.set($screenStore.activeScreen?.onLoad) + $: runOnLoadActions($onLoadActions, params) // Enrich and execute any on load actions. // We manually construct the full context here as this component is the // one that provides the url context, so it is not available in $context yet - const runOnLoadActions = params => { - const screenState = get(screenStore) - - if (screenState.activeScreen?.onLoad && !get(builderStore).inBuilder) { - const actions = enrichButtonActions(screenState.activeScreen.onLoad, { + const runOnLoadActions = (actions, params) => { + if (actions?.length && !get(builderStore).inBuilder) { + const enrichedActions = enrichButtonActions(actions, { ...get(context), url: params, }) - if (actions != null) { - actions() + if (enrichedActions != null) { + enrichedActions() } } } diff --git a/packages/server/src/api/controllers/table/index.ts b/packages/server/src/api/controllers/table/index.ts index 69305c461e..f799113333 100644 --- a/packages/server/src/api/controllers/table/index.ts +++ b/packages/server/src/api/controllers/table/index.ts @@ -84,8 +84,8 @@ export async function save(ctx: UserCtx) { } let savedTable = await api.save(ctx, renaming) if (!table._id) { - await events.table.created(savedTable) savedTable = sdk.tables.enrichViewSchemas(savedTable) + await events.table.created(savedTable) } else { await events.table.updated(savedTable) } diff --git a/packages/server/src/api/controllers/view/viewsV2.ts b/packages/server/src/api/controllers/view/viewsV2.ts index a386ac303f..eb28883e15 100644 --- a/packages/server/src/api/controllers/view/viewsV2.ts +++ b/packages/server/src/api/controllers/view/viewsV2.ts @@ -6,6 +6,7 @@ import { UIFieldMetadata, UpdateViewRequest, ViewResponse, + ViewResponseEnriched, ViewV2, } from "@budibase/types" import { builderSocket, gridSocket } from "../../../websockets" @@ -39,9 +40,9 @@ async function parseSchema(view: CreateViewRequest) { return finalViewSchema } -export async function get(ctx: Ctx) { +export async function get(ctx: Ctx) { ctx.body = { - data: await sdk.views.get(ctx.params.viewId, { enriched: true }), + data: await sdk.views.getEnriched(ctx.params.viewId), } } diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index f9e05c5bd8..d9895466a5 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -722,6 +722,39 @@ describe.each([ }) }) + describe("bulkImport", () => { + isInternal && + it("should update Auto ID field after bulk import", async () => { + const table = await config.api.table.save( + saveTableRequest({ + primary: ["autoId"], + schema: { + autoId: { + name: "autoId", + type: FieldType.NUMBER, + subtype: AutoFieldSubType.AUTO_ID, + autocolumn: true, + constraints: { + type: "number", + presence: false, + }, + }, + }, + }) + ) + + let row = await config.api.row.save(table._id!, {}) + expect(row.autoId).toEqual(1) + + await config.api.row.bulkImport(table._id!, { + rows: [{ autoId: 2 }], + }) + + row = await config.api.row.save(table._id!, {}) + expect(row.autoId).toEqual(3) + }) + }) + describe("enrich", () => { beforeAll(async () => { table = await config.api.table.save(defaultTable()) diff --git a/packages/server/src/api/routes/tests/table.spec.ts b/packages/server/src/api/routes/tests/table.spec.ts index 1038808fe1..7639b840dc 100644 --- a/packages/server/src/api/routes/tests/table.spec.ts +++ b/packages/server/src/api/routes/tests/table.spec.ts @@ -1,11 +1,11 @@ import { context, events } from "@budibase/backend-core" import { AutoFieldSubType, + Datasource, FieldSubtype, FieldType, INTERNAL_TABLE_SOURCE_ID, InternalTable, - NumberFieldMetadata, RelationshipType, Row, SaveTableRequest, @@ -13,31 +13,41 @@ import { TableSourceType, User, ViewCalculation, + ViewV2Enriched, } from "@budibase/types" import { checkBuilderEndpoint } from "./utilities/TestFunctions" import * as setup from "./utilities" -import sdk from "../../../sdk" import * as uuid from "uuid" -import tk from "timekeeper" -import { generator, mocks } from "@budibase/backend-core/tests" -import { TableToBuild } from "../../../tests/utilities/TestConfiguration" - -tk.freeze(mocks.date.MOCK_DATE) +import { generator } from "@budibase/backend-core/tests" +import { DatabaseName, getDatasource } 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$/ -describe("/tables", () => { - let request = setup.getRequest() +describe.each([ + ["internal", undefined], + [DatabaseName.POSTGRES, getDatasource(DatabaseName.POSTGRES)], + [DatabaseName.MYSQL, getDatasource(DatabaseName.MYSQL)], + [DatabaseName.SQL_SERVER, getDatasource(DatabaseName.SQL_SERVER)], + [DatabaseName.MARIADB, getDatasource(DatabaseName.MARIADB)], +])("/tables (%s)", (_, dsProvider) => { + let isInternal: boolean + let datasource: Datasource | undefined let config = setup.getConfig() - let appId: string afterAll(setup.afterAll) beforeAll(async () => { - const app = await config.init() - appId = app.appId + await config.init() + if (dsProvider) { + datasource = await config.api.datasource.create(await dsProvider) + isInternal = false + } else { + isInternal = true + } }) describe("create", () => { @@ -45,102 +55,28 @@ describe("/tables", () => { jest.clearAllMocks() }) - const createTable = (table?: Table) => { - if (!table) { - table = basicTable() - } - return request - .post(`/api/tables`) - .send(table) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) - } - - it("returns a success message when the table is successfully created", async () => { - const res = await createTable() - - expect((res as any).res.statusMessage).toEqual( - "Table TestTable saved successfully." + it("creates a table successfully", async () => { + const name = generator.guid() + const table = await config.api.table.save( + tableForDatasource(datasource, { name }) ) - expect(res.body.name).toEqual("TestTable") + expect(table.name).toEqual(name) expect(events.table.created).toHaveBeenCalledTimes(1) - expect(events.table.created).toHaveBeenCalledWith(res.body) - }) - - it("creates all the passed fields", async () => { - const tableData: TableToBuild = { - name: "TestTable", - type: "table", - schema: { - autoId: { - name: "id", - type: FieldType.NUMBER, - subtype: AutoFieldSubType.AUTO_ID, - autocolumn: true, - constraints: { - type: "number", - presence: false, - }, - }, - }, - views: { - "table view": { - id: "viewId", - version: 2, - name: "table view", - tableId: "tableId", - }, - }, - } - const testTable = await config.createTable(tableData) - - const expected: Table = { - ...tableData, - type: "table", - views: { - "table view": { - ...tableData.views!["table view"], - schema: { - autoId: { - autocolumn: true, - constraints: { - presence: false, - type: "number", - }, - name: "id", - type: FieldType.NUMBER, - subtype: AutoFieldSubType.AUTO_ID, - visible: false, - } as NumberFieldMetadata, - }, - }, - }, - sourceType: TableSourceType.INTERNAL, - sourceId: expect.any(String), - _rev: expect.stringMatching(/^1-.+/), - _id: expect.any(String), - createdAt: mocks.date.MOCK_DATE.toISOString(), - updatedAt: mocks.date.MOCK_DATE.toISOString(), - } - expect(testTable).toEqual(expected) - - const persistedTable = await config.api.table.get(testTable._id!) - expect(persistedTable).toEqual(expected) + expect(events.table.created).toHaveBeenCalledWith(table) }) it("creates a table via data import", async () => { const table: SaveTableRequest = basicTable() table.rows = [{ name: "test-name", description: "test-desc" }] - const res = await createTable(table) + const res = await config.api.table.save(table) expect(events.table.created).toHaveBeenCalledTimes(1) - expect(events.table.created).toHaveBeenCalledWith(res.body) + expect(events.table.created).toHaveBeenCalledWith(res) expect(events.table.imported).toHaveBeenCalledTimes(1) - expect(events.table.imported).toHaveBeenCalledWith(res.body) + expect(events.table.imported).toHaveBeenCalledWith(res) expect(events.rows.imported).toHaveBeenCalledTimes(1) - expect(events.rows.imported).toHaveBeenCalledWith(res.body, 1) + expect(events.rows.imported).toHaveBeenCalledWith(res, 1) }) it("should apply authorization to endpoint", async () => { @@ -155,21 +91,31 @@ describe("/tables", () => { describe("update", () => { it("updates a table", async () => { - const testTable = await config.createTable() + const table = await config.api.table.save( + tableForDatasource(datasource, { + schema: { + name: { + type: FieldType.STRING, + name: "name", + constraints: { + type: "string", + }, + }, + }, + }) + ) - const res = await request - .post(`/api/tables`) - .send(testTable) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) + const updatedTable = await config.api.table.save({ + ...table, + name: generator.guid(), + }) expect(events.table.updated).toHaveBeenCalledTimes(1) - expect(events.table.updated).toHaveBeenCalledWith(res.body) + expect(events.table.updated).toHaveBeenCalledWith(updatedTable) }) it("updates all the row fields for a table when a schema key is renamed", async () => { - const testTable = await config.createTable() + const testTable = await config.api.table.save(basicTable(datasource)) await config.createLegacyView({ name: "TestView", field: "Price", @@ -179,112 +125,96 @@ describe("/tables", () => { filters: [], }) - const testRow = await request - .post(`/api/${testTable._id}/rows`) - .send({ - name: "test", - }) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) + const testRow = await config.api.row.save(testTable._id!, { + name: "test", + }) - const updatedTable = await request - .post(`/api/tables`) - .send({ - _id: testTable._id, - _rev: testTable._rev, - name: "TestTable", - key: "name", - _rename: { - old: "name", - updated: "updatedName", + const { name, ...otherColumns } = testTable.schema + const updatedTable = await config.api.table.save({ + ...testTable, + _rename: { + old: "name", + updated: "updatedName", + }, + schema: { + ...otherColumns, + updatedName: { + ...name, + name: "updatedName", }, - schema: { - updatedName: { type: "string" }, - }, - }) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) - expect((updatedTable as any).res.statusMessage).toEqual( - "Table TestTable saved successfully." - ) - expect(updatedTable.body.name).toEqual("TestTable") + }, + }) - const res = await request - .get(`/api/${testTable._id}/rows/${testRow.body._id}`) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) + expect(updatedTable.name).toEqual(testTable.name) - expect(res.body.updatedName).toEqual("test") - expect(res.body.name).toBeUndefined() + const res = await config.api.row.get(testTable._id!, testRow._id!) + expect(res.updatedName).toEqual("test") + expect(res.name).toBeUndefined() }) it("updates only the passed fields", async () => { - const testTable = await config.createTable({ - name: "TestTable", - type: "table", - schema: { - autoId: { - name: "id", - type: FieldType.NUMBER, - subtype: AutoFieldSubType.AUTO_ID, - autocolumn: true, - constraints: { - type: "number", - presence: false, + await timekeeper.withFreeze(new Date(2021, 1, 1), async () => { + const table = await config.api.table.save( + tableForDatasource(datasource, { + schema: { + autoId: { + name: "id", + type: FieldType.NUMBER, + subtype: AutoFieldSubType.AUTO_ID, + autocolumn: true, + constraints: { + type: "number", + presence: false, + }, + }, }, - }, - }, - views: { - view1: { - id: "viewId", - version: 2, - name: "table view", - tableId: "tableId", - }, - }, - }) + }) + ) - const response = await request - .post(`/api/tables`) - .send({ - ...testTable, - name: "UpdatedName", + const newName = generator.guid() + + const updatedTable = await config.api.table.save({ + ...table, + name: newName, }) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) - expect(response.body).toEqual({ - ...testTable, - name: "UpdatedName", - _rev: expect.stringMatching(/^2-.+/), - }) + let expected: Table = { + ...table, + name: newName, + _id: expect.any(String), + } + if (isInternal) { + expected._rev = expect.stringMatching(/^2-.+/) + } - const persistedTable = await config.api.table.get(testTable._id!) - expect(persistedTable).toEqual({ - ...testTable, - name: "UpdatedName", - _rev: expect.stringMatching(/^2-.+/), + expect(updatedTable).toEqual(expected) + + const persistedTable = await config.api.table.get(updatedTable._id!) + expected = { + ...table, + name: newName, + _id: updatedTable._id, + } + if (datasource?.isSQL) { + expected.sql = true + } + if (isInternal) { + expected._rev = expect.stringMatching(/^2-.+/) + } + expect(persistedTable).toEqual(expected) }) }) describe("user table", () => { - it("should add roleId and email field when adjusting user table schema", async () => { - const res = await request - .post(`/api/tables`) - .send({ - ...basicTable(), + isInternal && + it("should add roleId and email field when adjusting user table schema", async () => { + const table = await config.api.table.save({ + ...basicTable(datasource), _id: "ta_users", }) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) - expect(res.body.schema.email).toBeDefined() - expect(res.body.schema.roleId).toBeDefined() - }) + expect(table.schema.email).toBeDefined() + expect(table.schema.roleId).toBeDefined() + }) }) it("should add a new column for an internal DB table", async () => { @@ -295,12 +225,7 @@ describe("/tables", () => { ...basicTable(), } - const response = await request - .post(`/api/tables`) - .send(saveTableRequest) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) + const response = await config.api.table.save(saveTableRequest) const expectedResponse = { ...saveTableRequest, @@ -311,15 +236,16 @@ describe("/tables", () => { views: {}, } delete expectedResponse._add - - expect(response.status).toBe(200) - expect(response.body).toEqual(expectedResponse) + expect(response).toEqual(expectedResponse) }) }) describe("import", () => { it("imports rows successfully", async () => { - const table = await config.createTable() + const name = generator.guid() + const table = await config.api.table.save( + basicTable(datasource, { name }) + ) const importRequest = { schema: table.schema, rows: [{ name: "test-name", description: "test-desc" }], @@ -327,83 +253,36 @@ describe("/tables", () => { jest.clearAllMocks() - await request - .post(`/api/tables/${table._id}/import`) - .send(importRequest) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) + await config.api.table.import(table._id!, importRequest) expect(events.table.created).not.toHaveBeenCalled() expect(events.rows.imported).toHaveBeenCalledTimes(1) expect(events.rows.imported).toHaveBeenCalledWith( expect.objectContaining({ - name: "TestTable", + name, _id: table._id, }), 1 ) }) - - it("should update Auto ID field after bulk import", async () => { - const table = await config.createTable({ - name: "TestTable", - type: "table", - schema: { - autoId: { - name: "id", - type: FieldType.NUMBER, - subtype: AutoFieldSubType.AUTO_ID, - autocolumn: true, - constraints: { - type: "number", - presence: false, - }, - }, - }, - }) - - let row = await config.api.row.save(table._id!, {}) - expect(row.autoId).toEqual(1) - - await config.api.row.bulkImport(table._id!, { - rows: [{ autoId: 2 }], - identifierFields: [], - }) - - row = await config.api.row.save(table._id!, {}) - expect(row.autoId).toEqual(3) - }) }) describe("fetch", () => { let testTable: Table - const enrichViewSchemasMock = jest.spyOn(sdk.tables, "enrichViewSchemas") beforeEach(async () => { - testTable = await config.createTable(testTable) + testTable = await config.api.table.save( + basicTable(datasource, { name: generator.guid() }) + ) }) - afterEach(() => { - delete testTable._rev - }) - - afterAll(() => { - enrichViewSchemasMock.mockRestore() - }) - - it("returns all the tables for that instance in the response body", async () => { - const res = await request - .get(`/api/tables`) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) - - const table = res.body.find((t: Table) => t._id === testTable._id) + it("returns all tables", async () => { + const res = await config.api.table.fetch() + const table = res.find(t => t._id === testTable._id) expect(table).toBeDefined() - expect(table.name).toEqual(testTable.name) - expect(table.type).toEqual("table") - expect(table.sourceType).toEqual("internal") + expect(table!.name).toEqual(testTable.name) + expect(table!.type).toEqual("table") + expect(table!.sourceType).toEqual(testTable.sourceType) }) it("should apply authorization to endpoint", async () => { @@ -414,99 +293,110 @@ describe("/tables", () => { }) }) - it("should fetch views", async () => { - const tableId = config.table!._id! - const views = [ - await config.api.viewV2.create({ tableId, name: generator.guid() }), - await config.api.viewV2.create({ tableId, name: generator.guid() }), - ] - - const res = await request - .get(`/api/tables`) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) - - expect(res.body).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - _id: tableId, - views: views.reduce((p, c) => { - p[c.name] = { ...c, schema: expect.anything() } - return p - }, {} as any), - }), - ]) - ) - }) - - it("should enrich the view schemas for viewsV2", async () => { - const tableId = config.table!._id! - enrichViewSchemasMock.mockImplementation(t => ({ - ...t, - views: { - view1: { - version: 2, - name: "view1", - schema: {}, - id: "new_view_id", - tableId: t._id!, - }, - }, - })) - - await config.api.viewV2.create({ tableId, name: generator.guid() }) - await config.createLegacyView() + it("should enrich the view schemas", async () => { + const viewV2 = await config.api.viewV2.create({ + tableId: testTable._id!, + name: generator.guid(), + }) + const legacyView = await config.api.legacyView.save({ + tableId: testTable._id!, + name: generator.guid(), + filters: [], + schema: {}, + }) const res = await config.api.table.fetch() - expect(res).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - _id: tableId, - views: { - view1: { - version: 2, - name: "view1", - schema: {}, - id: "new_view_id", - tableId, - }, + const table = res.find(t => t._id === testTable._id) + expect(table).toBeDefined() + expect(table!.views![viewV2.name]).toBeDefined() + + const expectedViewV2: ViewV2Enriched = { + ...viewV2, + schema: { + description: { + constraints: { + type: "string", }, - }), - ]) + name: "description", + type: FieldType.STRING, + visible: false, + }, + name: { + constraints: { + type: "string", + }, + name: "name", + type: FieldType.STRING, + visible: false, + }, + }, + } + + if (!isInternal) { + expectedViewV2.schema!.id = { + name: "id", + type: FieldType.NUMBER, + visible: false, + autocolumn: true, + } + } + + expect(table!.views![viewV2.name!]).toEqual(expectedViewV2) + + if (isInternal) { + expect(table!.views![legacyView.name!]).toBeDefined() + expect(table!.views![legacyView.name!]).toEqual({ + ...legacyView, + schema: { + description: { + constraints: { + type: "string", + }, + name: "description", + type: "string", + }, + name: { + constraints: { + type: "string", + }, + name: "name", + type: "string", + }, + }, + }) + } + }) + }) + + describe("get", () => { + it("returns a table", async () => { + const table = await config.api.table.save( + basicTable(datasource, { name: generator.guid() }) ) + const res = await config.api.table.get(table._id!) + expect(res).toEqual(table) }) }) describe("indexing", () => { it("should be able to create a table with indexes", async () => { - await context.doInAppContext(appId, async () => { + await context.doInAppContext(config.getAppId(), async () => { const db = context.getAppDB() const indexCount = (await db.getIndexes()).total_rows const table = basicTable() table.indexes = ["name"] - const res = await request - .post(`/api/tables`) - .send(table) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) - expect(res.body._id).toBeDefined() - expect(res.body._rev).toBeDefined() + const res = await config.api.table.save(table) + expect(res._id).toBeDefined() + expect(res._rev).toBeDefined() expect((await db.getIndexes()).total_rows).toEqual(indexCount + 1) // update index to see what happens table.indexes = ["name", "description"] - await request - .post(`/api/tables`) - .send({ - ...table, - _id: res.body._id, - _rev: res.body._rev, - }) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) + await config.api.table.save({ + ...table, + _id: res._id, + _rev: res._rev, + }) // shouldn't have created a new index expect((await db.getIndexes()).total_rows).toEqual(indexCount + 1) }) @@ -521,12 +411,9 @@ describe("/tables", () => { }) it("returns a success response when a table is deleted.", async () => { - const res = await request - .delete(`/api/tables/${testTable._id}/${testTable._rev}`) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) - expect(res.body.message).toEqual(`Table ${testTable._id} deleted.`) + await config.api.table.destroy(testTable._id!, testTable._rev!, { + body: { message: `Table ${testTable._id} deleted.` }, + }) expect(events.table.deleted).toHaveBeenCalledTimes(1) expect(events.table.deleted).toHaveBeenCalledWith({ ...testTable, @@ -559,12 +446,9 @@ describe("/tables", () => { }, }) - const res = await request - .delete(`/api/tables/${testTable._id}/${testTable._rev}`) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) - expect(res.body.message).toEqual(`Table ${testTable._id} deleted.`) + await config.api.table.destroy(testTable._id!, testTable._rev!, { + body: { message: `Table ${testTable._id} deleted.` }, + }) const dependentTable = await config.api.table.get(linkedTable._id!) expect(dependentTable.schema.TestTable).not.toBeDefined() }) @@ -816,33 +700,31 @@ describe("/tables", () => { describe("unhappy paths", () => { let table: Table beforeAll(async () => { - table = await config.api.table.save({ - name: "table", - type: "table", - sourceId: INTERNAL_TABLE_SOURCE_ID, - sourceType: TableSourceType.INTERNAL, - schema: { - "user relationship": { - type: FieldType.LINK, - fieldName: "test", - name: "user relationship", - constraints: { - type: "array", - presence: false, + table = await config.api.table.save( + tableForDatasource(datasource, { + schema: { + "user relationship": { + type: FieldType.LINK, + fieldName: "test", + name: "user relationship", + constraints: { + type: "array", + presence: false, + }, + relationshipType: RelationshipType.MANY_TO_ONE, + tableId: InternalTable.USER_METADATA, }, - relationshipType: RelationshipType.MANY_TO_ONE, - tableId: InternalTable.USER_METADATA, - }, - num: { - type: FieldType.NUMBER, - name: "num", - constraints: { - type: "number", - presence: false, + num: { + type: FieldType.NUMBER, + name: "num", + constraints: { + type: "number", + presence: false, + }, }, }, - }, - }) + }) + ) }) it("should fail if the new column name is blank", async () => { diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index 1ed6b45a08..a4ecd7c818 100644 --- a/packages/server/src/api/routes/tests/viewV2.spec.ts +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -181,7 +181,7 @@ describe.each([ const createdView = await config.api.viewV2.create(newView) - expect(await config.api.viewV2.get(createdView.id)).toEqual({ + expect(createdView).toEqual({ ...newView, schema: { Price: { @@ -398,7 +398,7 @@ describe.each([ }) it("updates only UI schema overrides", async () => { - await config.api.viewV2.update({ + const updatedView = await config.api.viewV2.update({ ...view, schema: { Price: { @@ -417,7 +417,7 @@ describe.each([ } as Record, }) - expect(await config.api.viewV2.get(view.id)).toEqual({ + expect(updatedView).toEqual({ ...view, schema: { Price: { @@ -479,17 +479,17 @@ describe.each([ describe("fetch view (through table)", () => { it("should be able to fetch a view V2", async () => { - const newView: CreateViewRequest = { + const res = await config.api.viewV2.create({ name: generator.name(), tableId: table._id!, schema: { Price: { visible: false }, Category: { visible: true }, }, - } - const res = await config.api.viewV2.create(newView) + }) + expect(res.schema?.Price).toBeUndefined() + const view = await config.api.viewV2.get(res.id) - expect(view!.schema?.Price).toBeUndefined() const updatedTable = await config.api.table.get(table._id!) const viewSchema = updatedTable.views![view!.name!].schema as Record< string, diff --git a/packages/server/src/integrations/base/sqlTable.ts b/packages/server/src/integrations/base/sqlTable.ts index 0feecefb89..80f3864438 100644 --- a/packages/server/src/integrations/base/sqlTable.ts +++ b/packages/server/src/integrations/base/sqlTable.ts @@ -224,12 +224,12 @@ class SqlTableQueryBuilder { const tableName = schemaName ? `\`${schemaName}\`.\`${json.table.name}\`` : `\`${json.table.name}\`` - const externalType = json.table.schema[updatedColumn].externalType! return { - sql: `alter table ${tableName} change column \`${json.meta.renamed.old}\` \`${updatedColumn}\` ${externalType};`, + sql: `alter table ${tableName} rename column \`${json.meta.renamed.old}\` to \`${updatedColumn}\`;`, bindings: [], } } + query = buildUpdateTable( client, json.table, @@ -237,6 +237,27 @@ class SqlTableQueryBuilder { json.meta.table, json.meta.renamed! ) + + // renameColumn for SQL Server returns a parameterised `sp_rename` query, + // which is not supported by SQL Server and gives a syntax error. + if (this.sqlClient === SqlClient.MS_SQL && json.meta.renamed) { + const oldColumn = json.meta.renamed.old + const updatedColumn = json.meta.renamed.updated + const tableName = schemaName + ? `${schemaName}.${json.table.name}` + : `${json.table.name}` + const sql = query.toSQL() + if (Array.isArray(sql)) { + for (const query of sql) { + if (query.sql.startsWith("exec sp_rename")) { + query.sql = `exec sp_rename '${tableName}.${oldColumn}', '${updatedColumn}', 'COLUMN'` + query.bindings = [] + } + } + } + + return sql + } break case Operation.DELETE_TABLE: query = buildDeleteTable(client, json.table) diff --git a/packages/server/src/integrations/tests/sql.spec.ts b/packages/server/src/integrations/tests/sql.spec.ts index c0b92b3849..dc2a06446b 100644 --- a/packages/server/src/integrations/tests/sql.spec.ts +++ b/packages/server/src/integrations/tests/sql.spec.ts @@ -722,7 +722,7 @@ describe("SQL query builder", () => { }) expect(query).toEqual({ bindings: [], - sql: `alter table \`${TABLE_NAME}\` change column \`name\` \`first_name\` varchar(45);`, + sql: `alter table \`${TABLE_NAME}\` rename column \`name\` to \`first_name\`;`, }) }) diff --git a/packages/server/src/sdk/app/tables/external/index.ts b/packages/server/src/sdk/app/tables/external/index.ts index 0ace19d00e..65cd4a07c1 100644 --- a/packages/server/src/sdk/app/tables/external/index.ts +++ b/packages/server/src/sdk/app/tables/external/index.ts @@ -48,6 +48,18 @@ export async function save( oldTable = await getTable(tableId) } + if ( + !oldTable && + (tableToSave.primary == null || tableToSave.primary.length === 0) + ) { + tableToSave.primary = ["id"] + tableToSave.schema.id = { + type: FieldType.NUMBER, + autocolumn: true, + name: "id", + } + } + if (hasTypeChanged(tableToSave, oldTable)) { throw new Error("A column type has changed.") } @@ -183,6 +195,10 @@ export async function save( // that the datasource definition changed const updatedDatasource = await datasourceSdk.get(datasource._id!) + if (updatedDatasource.isSQL) { + tableToSave.sql = true + } + return { datasource: updatedDatasource, table: tableToSave } } diff --git a/packages/server/src/sdk/app/tables/getters.ts b/packages/server/src/sdk/app/tables/getters.ts index 72a6ab61f1..414af2c837 100644 --- a/packages/server/src/sdk/app/tables/getters.ts +++ b/packages/server/src/sdk/app/tables/getters.ts @@ -142,7 +142,9 @@ export function enrichViewSchemas(table: Table): TableResponse { return { ...table, views: Object.values(table.views ?? []) - .map(v => sdk.views.enrichSchema(v, table.schema)) + .map(v => + sdk.views.isV2(v) ? sdk.views.enrichSchema(v, table.schema) : v + ) .reduce((p, v) => { p[v.name!] = v return p diff --git a/packages/server/src/sdk/app/views/external.ts b/packages/server/src/sdk/app/views/external.ts index 47301873f5..0f96bcc061 100644 --- a/packages/server/src/sdk/app/views/external.ts +++ b/packages/server/src/sdk/app/views/external.ts @@ -1,4 +1,4 @@ -import { ViewV2 } from "@budibase/types" +import { ViewV2, ViewV2Enriched } from "@budibase/types" import { context, HTTPError } from "@budibase/backend-core" import sdk from "../../../sdk" @@ -6,26 +6,34 @@ import * as utils from "../../../db/utils" import { enrichSchema, isV2 } from "." import { breakExternalTableId } from "../../../integrations/utils" -export async function get( - viewId: string, - opts?: { enriched: boolean } -): Promise { +export async function get(viewId: string): Promise { const { tableId } = utils.extractViewInfoFromID(viewId) const { datasourceId, tableName } = breakExternalTableId(tableId) const ds = await sdk.datasources.get(datasourceId!) const table = ds.entities![tableName!] - const views = Object.values(table.views!) - const found = views.find(v => isV2(v) && v.id === viewId) + const views = Object.values(table.views!).filter(isV2) + const found = views.find(v => v.id === viewId) if (!found) { throw new Error("No view found") } - if (opts?.enriched) { - return enrichSchema(found, table.schema) as ViewV2 - } else { - return found as ViewV2 + return found +} + +export async function getEnriched(viewId: string): Promise { + const { tableId } = utils.extractViewInfoFromID(viewId) + + const { datasourceId, tableName } = breakExternalTableId(tableId) + const ds = await sdk.datasources.get(datasourceId!) + + const table = ds.entities![tableName!] + const views = Object.values(table.views!).filter(isV2) + const found = views.find(v => v.id === viewId) + if (!found) { + throw new Error("No view found") } + return 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 67e7158f21..2edfd900c4 100644 --- a/packages/server/src/sdk/app/views/index.ts +++ b/packages/server/src/sdk/app/views/index.ts @@ -1,8 +1,13 @@ -import { RenameColumn, TableSchema, View, ViewV2 } from "@budibase/types" +import { + RenameColumn, + TableSchema, + View, + ViewV2, + ViewV2Enriched, +} from "@budibase/types" import { db as dbCore } from "@budibase/backend-core" import { cloneDeep } from "lodash" -import sdk from "../../../sdk" import * as utils from "../../../db/utils" import { isExternalTableID } from "../../../integrations/utils" @@ -16,12 +21,14 @@ function pickApi(tableId: any) { return internal } -export async function get( - viewId: string, - opts?: { enriched: boolean } -): Promise { +export async function get(viewId: string): Promise { const { tableId } = utils.extractViewInfoFromID(viewId) - return pickApi(tableId).get(viewId, opts) + return pickApi(tableId).get(viewId) +} + +export async function getEnriched(viewId: string): Promise { + const { tableId } = utils.extractViewInfoFromID(viewId) + return pickApi(tableId).getEnriched(viewId) } export async function create( @@ -52,11 +59,10 @@ export function allowedFields(view: View | ViewV2) { ] } -export function enrichSchema(view: View | ViewV2, tableSchema: TableSchema) { - if (!sdk.views.isV2(view)) { - return view - } - +export function enrichSchema( + view: ViewV2, + tableSchema: TableSchema +): ViewV2Enriched { let schema = cloneDeep(tableSchema) const anyViewOrder = Object.values(view.schema || {}).some( ui => ui.order != null diff --git a/packages/server/src/sdk/app/views/internal.ts b/packages/server/src/sdk/app/views/internal.ts index d1dedd8566..7b2f9f6c80 100644 --- a/packages/server/src/sdk/app/views/internal.ts +++ b/packages/server/src/sdk/app/views/internal.ts @@ -1,26 +1,30 @@ -import { ViewV2 } from "@budibase/types" +import { ViewV2, ViewV2Enriched } from "@budibase/types" import { context, HTTPError } from "@budibase/backend-core" import sdk from "../../../sdk" import * as utils from "../../../db/utils" import { enrichSchema, isV2 } from "." -export async function get( - viewId: string, - opts?: { enriched: boolean } -): Promise { +export async function get(viewId: string): Promise { const { tableId } = utils.extractViewInfoFromID(viewId) const table = await sdk.tables.getTable(tableId) - const views = Object.values(table.views!) - const found = views.find(v => isV2(v) && v.id === viewId) + const views = Object.values(table.views!).filter(isV2) + const found = views.find(v => v.id === viewId) if (!found) { throw new Error("No view found") } - if (opts?.enriched) { - return enrichSchema(found, table.schema) as ViewV2 - } else { - return found as ViewV2 + return found +} + +export async function getEnriched(viewId: string): Promise { + const { tableId } = utils.extractViewInfoFromID(viewId) + const table = await sdk.tables.getTable(tableId) + const views = Object.values(table.views!).filter(isV2) + const found = views.find(v => v.id === viewId) + if (!found) { + throw new Error("No view found") } + return enrichSchema(found, table.schema) } export async function create( diff --git a/packages/server/src/tests/utilities/api/table.ts b/packages/server/src/tests/utilities/api/table.ts index 49105a3883..d918ba8b9a 100644 --- a/packages/server/src/tests/utilities/api/table.ts +++ b/packages/server/src/tests/utilities/api/table.ts @@ -1,4 +1,6 @@ import { + BulkImportRequest, + BulkImportResponse, MigrateRequest, MigrateResponse, SaveTableRequest, @@ -39,4 +41,28 @@ export class TableAPI extends TestAPI { expectations, }) } + + import = async ( + tableId: string, + data: BulkImportRequest, + expectations?: Expectations + ): Promise => { + return await this._post( + `/api/tables/${tableId}/import`, + { + body: data, + expectations, + } + ) + } + + destroy = async ( + tableId: string, + revId: string, + expectations?: Expectations + ): Promise => { + return await this._delete(`/api/tables/${tableId}/${revId}`, { + expectations, + }) + } } diff --git a/packages/server/src/tests/utilities/api/viewV2.ts b/packages/server/src/tests/utilities/api/viewV2.ts index 2bc2357551..9741240f27 100644 --- a/packages/server/src/tests/utilities/api/viewV2.ts +++ b/packages/server/src/tests/utilities/api/viewV2.ts @@ -4,9 +4,9 @@ import { ViewV2, SearchViewRowRequest, PaginatedSearchRowResponse, + ViewResponseEnriched, } from "@budibase/types" import { Expectations, TestAPI } from "./base" -import sdk from "../../../sdk" export class ViewV2API extends TestAPI { create = async ( @@ -45,9 +45,8 @@ export class ViewV2API extends TestAPI { } get = async (viewId: string) => { - return await this.config.doInContext(this.config.appId, () => - sdk.views.get(viewId) - ) + return (await this._get(`/api/v2/views/${viewId}`)) + .data } search = async ( diff --git a/packages/server/src/tests/utilities/structures.ts b/packages/server/src/tests/utilities/structures.ts index 5b50bd1175..2a32489c30 100644 --- a/packages/server/src/tests/utilities/structures.ts +++ b/packages/server/src/tests/utilities/structures.ts @@ -26,32 +26,56 @@ import { WebhookActionType, } from "@budibase/types" import { LoopInput, LoopStepType } from "../../definitions/automations" +import { merge } from "lodash" +import { generator } from "@budibase/backend-core/tests" const { BUILTIN_ROLE_IDS } = roles -export function basicTable(): Table { - return { - name: "TestTable", - type: "table", - sourceId: INTERNAL_TABLE_SOURCE_ID, - sourceType: TableSourceType.INTERNAL, - schema: { - name: { - type: FieldType.STRING, - name: "name", - constraints: { - type: "string", +export function tableForDatasource( + datasource?: Datasource, + ...extra: Partial[] +): Table { + return merge( + { + name: generator.guid(), + type: "table", + sourceType: datasource + ? TableSourceType.EXTERNAL + : TableSourceType.INTERNAL, + sourceId: datasource ? datasource._id! : INTERNAL_TABLE_SOURCE_ID, + schema: {}, + }, + ...extra + ) +} + +export function basicTable( + datasource?: Datasource, + ...extra: Partial
[] +): Table { + return tableForDatasource( + datasource, + { + name: "TestTable", + schema: { + name: { + type: FieldType.STRING, + name: "name", + constraints: { + type: "string", + }, }, - }, - description: { - type: FieldType.STRING, - name: "description", - constraints: { - type: "string", + description: { + type: FieldType.STRING, + name: "description", + constraints: { + type: "string", + }, }, }, }, - } + ...extra + ) } export function basicView(tableId: string) { diff --git a/packages/types/src/api/web/app/table.ts b/packages/types/src/api/web/app/table.ts index f4d6720516..ffe59ae395 100644 --- a/packages/types/src/api/web/app/table.ts +++ b/packages/types/src/api/web/app/table.ts @@ -3,16 +3,11 @@ import { Row, Table, TableRequest, - TableSchema, View, - ViewV2, + ViewV2Enriched, } from "../../../documents" -interface ViewV2Response extends ViewV2 { - schema: TableSchema -} - -export type TableViewsResponse = { [key: string]: View | ViewV2Response } +export type TableViewsResponse = { [key: string]: View | ViewV2Enriched } export interface TableResponse extends Table { views?: TableViewsResponse diff --git a/packages/types/src/api/web/app/view.ts b/packages/types/src/api/web/app/view.ts index 30e7bf77d7..c00bc0e468 100644 --- a/packages/types/src/api/web/app/view.ts +++ b/packages/types/src/api/web/app/view.ts @@ -1,14 +1,13 @@ -import { ViewV2, UIFieldMetadata } from "../../../documents" +import { ViewV2, ViewV2Enriched } from "../../../documents" export interface ViewResponse { data: ViewV2 } -export interface CreateViewRequest - extends Omit { - schema?: Record +export interface ViewResponseEnriched { + data: ViewV2Enriched } -export interface UpdateViewRequest extends Omit { - schema?: Record -} +export interface CreateViewRequest extends Omit {} + +export interface UpdateViewRequest extends ViewV2 {} diff --git a/packages/types/src/documents/app/view.ts b/packages/types/src/documents/app/view.ts index 7b93d24f3d..8a36b96b4e 100644 --- a/packages/types/src/documents/app/view.ts +++ b/packages/types/src/documents/app/view.ts @@ -1,5 +1,5 @@ import { SearchFilter, SortOrder, SortType } from "../../api" -import { UIFieldMetadata } from "./table" +import { TableSchema, UIFieldMetadata } from "./table" import { Document } from "../document" import { DBView } from "../../sdk" @@ -48,6 +48,10 @@ export interface ViewV2 { schema?: Record } +export interface ViewV2Enriched extends ViewV2 { + schema?: TableSchema +} + export type ViewSchema = ViewCountOrSumSchema | ViewStatisticsSchema export interface ViewCountOrSumSchema {