Merge branch 'master' into borderless-grid

This commit is contained in:
Andrew Kingston 2024-04-09 08:55:39 +01:00 committed by GitHub
commit 9a1fd59e25
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 496 additions and 475 deletions

View File

@ -1,5 +1,5 @@
{ {
"version": "2.22.17", "version": "2.22.18",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

@ -1 +1 @@
Subproject commit 532c4db35cecd346b5c24f0b89ab7b397a122a36 Subproject commit a0ee9cad8cefb8f9f40228705711be174f018fa9

View File

@ -72,7 +72,7 @@
"fast-json-patch": "^3.1.1", "fast-json-patch": "^3.1.1",
"json-format-highlight": "^1.0.4", "json-format-highlight": "^1.0.4",
"lodash": "4.17.21", "lodash": "4.17.21",
"posthog-js": "^1.116.6", "posthog-js": "^1.118.0",
"remixicon": "2.5.0", "remixicon": "2.5.0",
"sanitize-html": "^2.7.0", "sanitize-html": "^2.7.0",
"shortid": "2.2.15", "shortid": "2.2.15",

View File

@ -38,6 +38,10 @@ class AnalyticsHub {
intercom.show(user) intercom.show(user)
} }
initPosthog() {
posthog.init()
}
async logout() { async logout() {
posthog.logout() posthog.logout()
intercom.logout() intercom.logout()

View File

@ -33,13 +33,10 @@
import { TOUR_STEP_KEYS } from "components/portal/onboarding/tours.js" import { TOUR_STEP_KEYS } from "components/portal/onboarding/tours.js"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { onMount } from "svelte" import { onMount } from "svelte"
import PosthogClient from "../../analytics/PosthogClient"
export let application export let application
export let loaded export let loaded
const posthog = new PosthogClient(process.env.POSTHOG_TOKEN)
let unpublishModal let unpublishModal
let updateAppModal let updateAppModal
let revertModal let revertModal
@ -156,7 +153,7 @@
} }
onMount(() => { onMount(() => {
posthog.init() analytics.initPosthog()
}) })
</script> </script>

View File

@ -5,29 +5,29 @@
import Provider from "./context/Provider.svelte" import Provider from "./context/Provider.svelte"
import { onMount, getContext } from "svelte" import { onMount, getContext } from "svelte"
import { enrichButtonActions } from "../utils/buttonActions.js" import { enrichButtonActions } from "../utils/buttonActions.js"
import { memo } from "@budibase/frontend-core"
export let params = {} export let params = {}
const context = getContext("context") const context = getContext("context")
const onLoadActions = memo()
// Get the screen definition for the current route // Get the screen definition for the current route
$: screenDefinition = $screenStore.activeScreen?.props $: screenDefinition = $screenStore.activeScreen?.props
$: onLoadActions.set($screenStore.activeScreen?.onLoad)
$: runOnLoadActions(params) $: runOnLoadActions($onLoadActions, params)
// Enrich and execute any on load actions. // Enrich and execute any on load actions.
// We manually construct the full context here as this component is the // 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 // one that provides the url context, so it is not available in $context yet
const runOnLoadActions = params => { const runOnLoadActions = (actions, params) => {
const screenState = get(screenStore) if (actions?.length && !get(builderStore).inBuilder) {
const enrichedActions = enrichButtonActions(actions, {
if (screenState.activeScreen?.onLoad && !get(builderStore).inBuilder) {
const actions = enrichButtonActions(screenState.activeScreen.onLoad, {
...get(context), ...get(context),
url: params, url: params,
}) })
if (actions != null) { if (enrichedActions != null) {
actions() enrichedActions()
} }
} }
} }

View File

@ -84,8 +84,8 @@ export async function save(ctx: UserCtx<SaveTableRequest, SaveTableResponse>) {
} }
let savedTable = await api.save(ctx, renaming) let savedTable = await api.save(ctx, renaming)
if (!table._id) { if (!table._id) {
await events.table.created(savedTable)
savedTable = sdk.tables.enrichViewSchemas(savedTable) savedTable = sdk.tables.enrichViewSchemas(savedTable)
await events.table.created(savedTable)
} else { } else {
await events.table.updated(savedTable) await events.table.updated(savedTable)
} }

View File

@ -6,6 +6,7 @@ import {
UIFieldMetadata, UIFieldMetadata,
UpdateViewRequest, UpdateViewRequest,
ViewResponse, ViewResponse,
ViewResponseEnriched,
ViewV2, ViewV2,
} from "@budibase/types" } from "@budibase/types"
import { builderSocket, gridSocket } from "../../../websockets" import { builderSocket, gridSocket } from "../../../websockets"
@ -39,9 +40,9 @@ async function parseSchema(view: CreateViewRequest) {
return finalViewSchema return finalViewSchema
} }
export async function get(ctx: Ctx<void, ViewResponse>) { export async function get(ctx: Ctx<void, ViewResponseEnriched>) {
ctx.body = { ctx.body = {
data: await sdk.views.get(ctx.params.viewId, { enriched: true }), data: await sdk.views.getEnriched(ctx.params.viewId),
} }
} }

View File

@ -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", () => { describe("enrich", () => {
beforeAll(async () => { beforeAll(async () => {
table = await config.api.table.save(defaultTable()) table = await config.api.table.save(defaultTable())

View File

@ -1,11 +1,11 @@
import { context, events } from "@budibase/backend-core" import { context, events } from "@budibase/backend-core"
import { import {
AutoFieldSubType, AutoFieldSubType,
Datasource,
FieldSubtype, FieldSubtype,
FieldType, FieldType,
INTERNAL_TABLE_SOURCE_ID, INTERNAL_TABLE_SOURCE_ID,
InternalTable, InternalTable,
NumberFieldMetadata,
RelationshipType, RelationshipType,
Row, Row,
SaveTableRequest, SaveTableRequest,
@ -13,31 +13,41 @@ import {
TableSourceType, TableSourceType,
User, User,
ViewCalculation, ViewCalculation,
ViewV2Enriched,
} from "@budibase/types" } from "@budibase/types"
import { checkBuilderEndpoint } from "./utilities/TestFunctions" import { checkBuilderEndpoint } from "./utilities/TestFunctions"
import * as setup from "./utilities" import * as setup from "./utilities"
import sdk from "../../../sdk"
import * as uuid from "uuid" import * as uuid from "uuid"
import tk from "timekeeper" import { generator } from "@budibase/backend-core/tests"
import { generator, mocks } from "@budibase/backend-core/tests" import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
import { TableToBuild } from "../../../tests/utilities/TestConfiguration" import { tableForDatasource } from "../../../tests/utilities/structures"
import timekeeper from "timekeeper"
tk.freeze(mocks.date.MOCK_DATE)
const { basicTable } = setup.structures 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 ISO_REGEX_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
describe("/tables", () => { describe.each([
let request = setup.getRequest() ["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 config = setup.getConfig()
let appId: string
afterAll(setup.afterAll) afterAll(setup.afterAll)
beforeAll(async () => { beforeAll(async () => {
const app = await config.init() await config.init()
appId = app.appId if (dsProvider) {
datasource = await config.api.datasource.create(await dsProvider)
isInternal = false
} else {
isInternal = true
}
}) })
describe("create", () => { describe("create", () => {
@ -45,102 +55,28 @@ describe("/tables", () => {
jest.clearAllMocks() jest.clearAllMocks()
}) })
const createTable = (table?: Table) => { it("creates a table successfully", async () => {
if (!table) { const name = generator.guid()
table = basicTable() const table = await config.api.table.save(
} tableForDatasource(datasource, { name })
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."
) )
expect(res.body.name).toEqual("TestTable") expect(table.name).toEqual(name)
expect(events.table.created).toHaveBeenCalledTimes(1) expect(events.table.created).toHaveBeenCalledTimes(1)
expect(events.table.created).toHaveBeenCalledWith(res.body) expect(events.table.created).toHaveBeenCalledWith(table)
})
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)
}) })
it("creates a table via data import", async () => { it("creates a table via data import", async () => {
const table: SaveTableRequest = basicTable() const table: SaveTableRequest = basicTable()
table.rows = [{ name: "test-name", description: "test-desc" }] 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).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).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).toHaveBeenCalledTimes(1)
expect(events.rows.imported).toHaveBeenCalledWith(res.body, 1) expect(events.rows.imported).toHaveBeenCalledWith(res, 1)
}) })
it("should apply authorization to endpoint", async () => { it("should apply authorization to endpoint", async () => {
@ -155,21 +91,31 @@ describe("/tables", () => {
describe("update", () => { describe("update", () => {
it("updates a table", async () => { 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 const updatedTable = await config.api.table.save({
.post(`/api/tables`) ...table,
.send(testTable) name: generator.guid(),
.set(config.defaultHeaders()) })
.expect("Content-Type", /json/)
.expect(200)
expect(events.table.updated).toHaveBeenCalledTimes(1) 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 () => { 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({ await config.createLegacyView({
name: "TestView", name: "TestView",
field: "Price", field: "Price",
@ -179,52 +125,37 @@ describe("/tables", () => {
filters: [], filters: [],
}) })
const testRow = await request const testRow = await config.api.row.save(testTable._id!, {
.post(`/api/${testTable._id}/rows`)
.send({
name: "test", name: "test",
}) })
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
const updatedTable = await request const { name, ...otherColumns } = testTable.schema
.post(`/api/tables`) const updatedTable = await config.api.table.save({
.send({ ...testTable,
_id: testTable._id,
_rev: testTable._rev,
name: "TestTable",
key: "name",
_rename: { _rename: {
old: "name", old: "name",
updated: "updatedName", updated: "updatedName",
}, },
schema: { schema: {
updatedName: { type: "string" }, ...otherColumns,
updatedName: {
...name,
name: "updatedName",
},
}, },
}) })
.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 expect(updatedTable.name).toEqual(testTable.name)
.get(`/api/${testTable._id}/rows/${testRow.body._id}`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.updatedName).toEqual("test") const res = await config.api.row.get(testTable._id!, testRow._id!)
expect(res.body.name).toBeUndefined() expect(res.updatedName).toEqual("test")
expect(res.name).toBeUndefined()
}) })
it("updates only the passed fields", async () => { it("updates only the passed fields", async () => {
const testTable = await config.createTable({ await timekeeper.withFreeze(new Date(2021, 1, 1), async () => {
name: "TestTable", const table = await config.api.table.save(
type: "table", tableForDatasource(datasource, {
schema: { schema: {
autoId: { autoId: {
name: "id", name: "id",
@ -237,53 +168,52 @@ describe("/tables", () => {
}, },
}, },
}, },
views: { })
view1: { )
id: "viewId",
version: 2, const newName = generator.guid()
name: "table view",
tableId: "tableId", const updatedTable = await config.api.table.save({
}, ...table,
}, name: newName,
}) })
const response = await request let expected: Table = {
.post(`/api/tables`) ...table,
.send({ name: newName,
...testTable, _id: expect.any(String),
name: "UpdatedName", }
}) if (isInternal) {
.set(config.defaultHeaders()) expected._rev = expect.stringMatching(/^2-.+/)
.expect("Content-Type", /json/) }
.expect(200)
expect(response.body).toEqual({ expect(updatedTable).toEqual(expected)
...testTable,
name: "UpdatedName",
_rev: expect.stringMatching(/^2-.+/),
})
const persistedTable = await config.api.table.get(testTable._id!) const persistedTable = await config.api.table.get(updatedTable._id!)
expect(persistedTable).toEqual({ expected = {
...testTable, ...table,
name: "UpdatedName", name: newName,
_rev: expect.stringMatching(/^2-.+/), _id: updatedTable._id,
}
if (datasource?.isSQL) {
expected.sql = true
}
if (isInternal) {
expected._rev = expect.stringMatching(/^2-.+/)
}
expect(persistedTable).toEqual(expected)
}) })
}) })
describe("user table", () => { describe("user table", () => {
isInternal &&
it("should add roleId and email field when adjusting user table schema", async () => { it("should add roleId and email field when adjusting user table schema", async () => {
const res = await request const table = await config.api.table.save({
.post(`/api/tables`) ...basicTable(datasource),
.send({
...basicTable(),
_id: "ta_users", _id: "ta_users",
}) })
.set(config.defaultHeaders()) expect(table.schema.email).toBeDefined()
.expect("Content-Type", /json/) expect(table.schema.roleId).toBeDefined()
.expect(200)
expect(res.body.schema.email).toBeDefined()
expect(res.body.schema.roleId).toBeDefined()
}) })
}) })
@ -295,12 +225,7 @@ describe("/tables", () => {
...basicTable(), ...basicTable(),
} }
const response = await request const response = await config.api.table.save(saveTableRequest)
.post(`/api/tables`)
.send(saveTableRequest)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
const expectedResponse = { const expectedResponse = {
...saveTableRequest, ...saveTableRequest,
@ -311,15 +236,16 @@ describe("/tables", () => {
views: {}, views: {},
} }
delete expectedResponse._add delete expectedResponse._add
expect(response).toEqual(expectedResponse)
expect(response.status).toBe(200)
expect(response.body).toEqual(expectedResponse)
}) })
}) })
describe("import", () => { describe("import", () => {
it("imports rows successfully", async () => { 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 = { const importRequest = {
schema: table.schema, schema: table.schema,
rows: [{ name: "test-name", description: "test-desc" }], rows: [{ name: "test-name", description: "test-desc" }],
@ -327,83 +253,36 @@ describe("/tables", () => {
jest.clearAllMocks() jest.clearAllMocks()
await request await config.api.table.import(table._id!, importRequest)
.post(`/api/tables/${table._id}/import`)
.send(importRequest)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(events.table.created).not.toHaveBeenCalled() expect(events.table.created).not.toHaveBeenCalled()
expect(events.rows.imported).toHaveBeenCalledTimes(1) expect(events.rows.imported).toHaveBeenCalledTimes(1)
expect(events.rows.imported).toHaveBeenCalledWith( expect(events.rows.imported).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
name: "TestTable", name,
_id: table._id, _id: table._id,
}), }),
1 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", () => { describe("fetch", () => {
let testTable: Table let testTable: Table
const enrichViewSchemasMock = jest.spyOn(sdk.tables, "enrichViewSchemas")
beforeEach(async () => { beforeEach(async () => {
testTable = await config.createTable(testTable) testTable = await config.api.table.save(
basicTable(datasource, { name: generator.guid() })
)
}) })
afterEach(() => { it("returns all tables", async () => {
delete testTable._rev const res = await config.api.table.fetch()
}) const table = res.find(t => t._id === testTable._id)
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)
expect(table).toBeDefined() expect(table).toBeDefined()
expect(table.name).toEqual(testTable.name) expect(table!.name).toEqual(testTable.name)
expect(table.type).toEqual("table") expect(table!.type).toEqual("table")
expect(table.sourceType).toEqual("internal") expect(table!.sourceType).toEqual(testTable.sourceType)
}) })
it("should apply authorization to endpoint", async () => { it("should apply authorization to endpoint", async () => {
@ -414,99 +293,110 @@ describe("/tables", () => {
}) })
}) })
it("should fetch views", async () => { it("should enrich the view schemas", async () => {
const tableId = config.table!._id! const viewV2 = await config.api.viewV2.create({
const views = [ tableId: testTable._id!,
await config.api.viewV2.create({ tableId, name: generator.guid() }), 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),
}),
])
)
}) })
const legacyView = await config.api.legacyView.save({
it("should enrich the view schemas for viewsV2", async () => { tableId: testTable._id!,
const tableId = config.table!._id! name: generator.guid(),
enrichViewSchemasMock.mockImplementation(t => ({ filters: [],
...t,
views: {
view1: {
version: 2,
name: "view1",
schema: {}, schema: {},
id: "new_view_id", })
tableId: t._id!,
},
},
}))
await config.api.viewV2.create({ tableId, name: generator.guid() })
await config.createLegacyView()
const res = await config.api.table.fetch() const res = await config.api.table.fetch()
expect(res).toEqual( const table = res.find(t => t._id === testTable._id)
expect.arrayContaining([ expect(table).toBeDefined()
expect.objectContaining({ expect(table!.views![viewV2.name]).toBeDefined()
_id: tableId,
views: { const expectedViewV2: ViewV2Enriched = {
view1: { ...viewV2,
version: 2, schema: {
name: "view1", description: {
schema: {}, constraints: {
id: "new_view_id", type: "string",
tableId, },
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", () => { describe("indexing", () => {
it("should be able to create a table with indexes", async () => { 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 db = context.getAppDB()
const indexCount = (await db.getIndexes()).total_rows const indexCount = (await db.getIndexes()).total_rows
const table = basicTable() const table = basicTable()
table.indexes = ["name"] table.indexes = ["name"]
const res = await request const res = await config.api.table.save(table)
.post(`/api/tables`) expect(res._id).toBeDefined()
.send(table) expect(res._rev).toBeDefined()
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body._id).toBeDefined()
expect(res.body._rev).toBeDefined()
expect((await db.getIndexes()).total_rows).toEqual(indexCount + 1) expect((await db.getIndexes()).total_rows).toEqual(indexCount + 1)
// update index to see what happens // update index to see what happens
table.indexes = ["name", "description"] table.indexes = ["name", "description"]
await request await config.api.table.save({
.post(`/api/tables`)
.send({
...table, ...table,
_id: res.body._id, _id: res._id,
_rev: res.body._rev, _rev: res._rev,
}) })
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
// shouldn't have created a new index // shouldn't have created a new index
expect((await db.getIndexes()).total_rows).toEqual(indexCount + 1) 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 () => { it("returns a success response when a table is deleted.", async () => {
const res = await request await config.api.table.destroy(testTable._id!, testTable._rev!, {
.delete(`/api/tables/${testTable._id}/${testTable._rev}`) body: { message: `Table ${testTable._id} deleted.` },
.set(config.defaultHeaders()) })
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toEqual(`Table ${testTable._id} deleted.`)
expect(events.table.deleted).toHaveBeenCalledTimes(1) expect(events.table.deleted).toHaveBeenCalledTimes(1)
expect(events.table.deleted).toHaveBeenCalledWith({ expect(events.table.deleted).toHaveBeenCalledWith({
...testTable, ...testTable,
@ -559,12 +446,9 @@ describe("/tables", () => {
}, },
}) })
const res = await request await config.api.table.destroy(testTable._id!, testTable._rev!, {
.delete(`/api/tables/${testTable._id}/${testTable._rev}`) body: { message: `Table ${testTable._id} deleted.` },
.set(config.defaultHeaders()) })
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toEqual(`Table ${testTable._id} deleted.`)
const dependentTable = await config.api.table.get(linkedTable._id!) const dependentTable = await config.api.table.get(linkedTable._id!)
expect(dependentTable.schema.TestTable).not.toBeDefined() expect(dependentTable.schema.TestTable).not.toBeDefined()
}) })
@ -816,11 +700,8 @@ describe("/tables", () => {
describe("unhappy paths", () => { describe("unhappy paths", () => {
let table: Table let table: Table
beforeAll(async () => { beforeAll(async () => {
table = await config.api.table.save({ table = await config.api.table.save(
name: "table", tableForDatasource(datasource, {
type: "table",
sourceId: INTERNAL_TABLE_SOURCE_ID,
sourceType: TableSourceType.INTERNAL,
schema: { schema: {
"user relationship": { "user relationship": {
type: FieldType.LINK, type: FieldType.LINK,
@ -843,6 +724,7 @@ describe("/tables", () => {
}, },
}, },
}) })
)
}) })
it("should fail if the new column name is blank", async () => { it("should fail if the new column name is blank", async () => {

View File

@ -181,7 +181,7 @@ describe.each([
const createdView = await config.api.viewV2.create(newView) const createdView = await config.api.viewV2.create(newView)
expect(await config.api.viewV2.get(createdView.id)).toEqual({ expect(createdView).toEqual({
...newView, ...newView,
schema: { schema: {
Price: { Price: {
@ -398,7 +398,7 @@ describe.each([
}) })
it("updates only UI schema overrides", async () => { it("updates only UI schema overrides", async () => {
await config.api.viewV2.update({ const updatedView = await config.api.viewV2.update({
...view, ...view,
schema: { schema: {
Price: { Price: {
@ -417,7 +417,7 @@ describe.each([
} as Record<string, FieldSchema>, } as Record<string, FieldSchema>,
}) })
expect(await config.api.viewV2.get(view.id)).toEqual({ expect(updatedView).toEqual({
...view, ...view,
schema: { schema: {
Price: { Price: {
@ -479,17 +479,17 @@ describe.each([
describe("fetch view (through table)", () => { describe("fetch view (through table)", () => {
it("should be able to fetch a view V2", async () => { it("should be able to fetch a view V2", async () => {
const newView: CreateViewRequest = { const res = await config.api.viewV2.create({
name: generator.name(), name: generator.name(),
tableId: table._id!, tableId: table._id!,
schema: { schema: {
Price: { visible: false }, Price: { visible: false },
Category: { visible: true }, 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) const view = await config.api.viewV2.get(res.id)
expect(view!.schema?.Price).toBeUndefined()
const updatedTable = await config.api.table.get(table._id!) const updatedTable = await config.api.table.get(table._id!)
const viewSchema = updatedTable.views![view!.name!].schema as Record< const viewSchema = updatedTable.views![view!.name!].schema as Record<
string, string,

View File

@ -224,12 +224,12 @@ class SqlTableQueryBuilder {
const tableName = schemaName const tableName = schemaName
? `\`${schemaName}\`.\`${json.table.name}\`` ? `\`${schemaName}\`.\`${json.table.name}\``
: `\`${json.table.name}\`` : `\`${json.table.name}\``
const externalType = json.table.schema[updatedColumn].externalType!
return { 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: [], bindings: [],
} }
} }
query = buildUpdateTable( query = buildUpdateTable(
client, client,
json.table, json.table,
@ -237,6 +237,27 @@ class SqlTableQueryBuilder {
json.meta.table, json.meta.table,
json.meta.renamed! 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 break
case Operation.DELETE_TABLE: case Operation.DELETE_TABLE:
query = buildDeleteTable(client, json.table) query = buildDeleteTable(client, json.table)

View File

@ -722,7 +722,7 @@ describe("SQL query builder", () => {
}) })
expect(query).toEqual({ expect(query).toEqual({
bindings: [], bindings: [],
sql: `alter table \`${TABLE_NAME}\` change column \`name\` \`first_name\` varchar(45);`, sql: `alter table \`${TABLE_NAME}\` rename column \`name\` to \`first_name\`;`,
}) })
}) })

View File

@ -48,6 +48,18 @@ export async function save(
oldTable = await getTable(tableId) 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)) { if (hasTypeChanged(tableToSave, oldTable)) {
throw new Error("A column type has changed.") throw new Error("A column type has changed.")
} }
@ -183,6 +195,10 @@ export async function save(
// that the datasource definition changed // that the datasource definition changed
const updatedDatasource = await datasourceSdk.get(datasource._id!) const updatedDatasource = await datasourceSdk.get(datasource._id!)
if (updatedDatasource.isSQL) {
tableToSave.sql = true
}
return { datasource: updatedDatasource, table: tableToSave } return { datasource: updatedDatasource, table: tableToSave }
} }

View File

@ -142,7 +142,9 @@ export function enrichViewSchemas(table: Table): TableResponse {
return { return {
...table, ...table,
views: Object.values(table.views ?? []) 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) => { .reduce((p, v) => {
p[v.name!] = v p[v.name!] = v
return p return p

View File

@ -1,4 +1,4 @@
import { ViewV2 } from "@budibase/types" import { ViewV2, ViewV2Enriched } from "@budibase/types"
import { context, HTTPError } from "@budibase/backend-core" import { context, HTTPError } from "@budibase/backend-core"
import sdk from "../../../sdk" import sdk from "../../../sdk"
@ -6,26 +6,34 @@ import * as utils from "../../../db/utils"
import { enrichSchema, isV2 } from "." import { enrichSchema, isV2 } from "."
import { breakExternalTableId } from "../../../integrations/utils" import { breakExternalTableId } from "../../../integrations/utils"
export async function get( export async function get(viewId: string): Promise<ViewV2> {
viewId: string,
opts?: { enriched: boolean }
): Promise<ViewV2> {
const { tableId } = utils.extractViewInfoFromID(viewId) const { tableId } = utils.extractViewInfoFromID(viewId)
const { datasourceId, tableName } = breakExternalTableId(tableId) const { datasourceId, tableName } = breakExternalTableId(tableId)
const ds = await sdk.datasources.get(datasourceId!) const ds = await sdk.datasources.get(datasourceId!)
const table = ds.entities![tableName!] const table = ds.entities![tableName!]
const views = Object.values(table.views!) const views = Object.values(table.views!).filter(isV2)
const found = views.find(v => isV2(v) && v.id === viewId) const found = views.find(v => v.id === viewId)
if (!found) { if (!found) {
throw new Error("No view found") throw new Error("No view found")
} }
if (opts?.enriched) { return found
return enrichSchema(found, table.schema) as ViewV2 }
} else {
return found as ViewV2 export async function getEnriched(viewId: string): Promise<ViewV2Enriched> {
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( export async function create(

View File

@ -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 { db as dbCore } from "@budibase/backend-core"
import { cloneDeep } from "lodash" import { cloneDeep } from "lodash"
import sdk from "../../../sdk"
import * as utils from "../../../db/utils" import * as utils from "../../../db/utils"
import { isExternalTableID } from "../../../integrations/utils" import { isExternalTableID } from "../../../integrations/utils"
@ -16,12 +21,14 @@ function pickApi(tableId: any) {
return internal return internal
} }
export async function get( export async function get(viewId: string): Promise<ViewV2> {
viewId: string,
opts?: { enriched: boolean }
): Promise<ViewV2> {
const { tableId } = utils.extractViewInfoFromID(viewId) const { tableId } = utils.extractViewInfoFromID(viewId)
return pickApi(tableId).get(viewId, opts) return pickApi(tableId).get(viewId)
}
export async function getEnriched(viewId: string): Promise<ViewV2Enriched> {
const { tableId } = utils.extractViewInfoFromID(viewId)
return pickApi(tableId).getEnriched(viewId)
} }
export async function create( export async function create(
@ -52,11 +59,10 @@ export function allowedFields(view: View | ViewV2) {
] ]
} }
export function enrichSchema(view: View | ViewV2, tableSchema: TableSchema) { export function enrichSchema(
if (!sdk.views.isV2(view)) { view: ViewV2,
return view tableSchema: TableSchema
} ): ViewV2Enriched {
let schema = cloneDeep(tableSchema) let schema = cloneDeep(tableSchema)
const anyViewOrder = Object.values(view.schema || {}).some( const anyViewOrder = Object.values(view.schema || {}).some(
ui => ui.order != null ui => ui.order != null

View File

@ -1,26 +1,30 @@
import { ViewV2 } from "@budibase/types" import { ViewV2, ViewV2Enriched } from "@budibase/types"
import { context, HTTPError } from "@budibase/backend-core" import { context, HTTPError } from "@budibase/backend-core"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import * as utils from "../../../db/utils" import * as utils from "../../../db/utils"
import { enrichSchema, isV2 } from "." import { enrichSchema, isV2 } from "."
export async function get( export async function get(viewId: string): Promise<ViewV2> {
viewId: string,
opts?: { enriched: boolean }
): Promise<ViewV2> {
const { tableId } = utils.extractViewInfoFromID(viewId) const { tableId } = utils.extractViewInfoFromID(viewId)
const table = await sdk.tables.getTable(tableId) const table = await sdk.tables.getTable(tableId)
const views = Object.values(table.views!) const views = Object.values(table.views!).filter(isV2)
const found = views.find(v => isV2(v) && v.id === viewId) const found = views.find(v => v.id === viewId)
if (!found) { if (!found) {
throw new Error("No view found") throw new Error("No view found")
} }
if (opts?.enriched) { return found
return enrichSchema(found, table.schema) as ViewV2 }
} else {
return found as ViewV2 export async function getEnriched(viewId: string): Promise<ViewV2Enriched> {
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( export async function create(

View File

@ -1,4 +1,6 @@
import { import {
BulkImportRequest,
BulkImportResponse,
MigrateRequest, MigrateRequest,
MigrateResponse, MigrateResponse,
SaveTableRequest, SaveTableRequest,
@ -39,4 +41,28 @@ export class TableAPI extends TestAPI {
expectations, expectations,
}) })
} }
import = async (
tableId: string,
data: BulkImportRequest,
expectations?: Expectations
): Promise<BulkImportResponse> => {
return await this._post<BulkImportResponse>(
`/api/tables/${tableId}/import`,
{
body: data,
expectations,
}
)
}
destroy = async (
tableId: string,
revId: string,
expectations?: Expectations
): Promise<void> => {
return await this._delete<void>(`/api/tables/${tableId}/${revId}`, {
expectations,
})
}
} }

View File

@ -4,9 +4,9 @@ import {
ViewV2, ViewV2,
SearchViewRowRequest, SearchViewRowRequest,
PaginatedSearchRowResponse, PaginatedSearchRowResponse,
ViewResponseEnriched,
} from "@budibase/types" } from "@budibase/types"
import { Expectations, TestAPI } from "./base" import { Expectations, TestAPI } from "./base"
import sdk from "../../../sdk"
export class ViewV2API extends TestAPI { export class ViewV2API extends TestAPI {
create = async ( create = async (
@ -45,9 +45,8 @@ export class ViewV2API extends TestAPI {
} }
get = async (viewId: string) => { get = async (viewId: string) => {
return await this.config.doInContext(this.config.appId, () => return (await this._get<ViewResponseEnriched>(`/api/v2/views/${viewId}`))
sdk.views.get(viewId) .data
)
} }
search = async ( search = async (

View File

@ -26,15 +26,37 @@ import {
WebhookActionType, WebhookActionType,
} from "@budibase/types" } from "@budibase/types"
import { LoopInput, LoopStepType } from "../../definitions/automations" import { LoopInput, LoopStepType } from "../../definitions/automations"
import { merge } from "lodash"
import { generator } from "@budibase/backend-core/tests"
const { BUILTIN_ROLE_IDS } = roles const { BUILTIN_ROLE_IDS } = roles
export function basicTable(): Table { export function tableForDatasource(
return { datasource?: Datasource,
name: "TestTable", ...extra: Partial<Table>[]
): Table {
return merge(
{
name: generator.guid(),
type: "table", type: "table",
sourceId: INTERNAL_TABLE_SOURCE_ID, sourceType: datasource
sourceType: TableSourceType.INTERNAL, ? TableSourceType.EXTERNAL
: TableSourceType.INTERNAL,
sourceId: datasource ? datasource._id! : INTERNAL_TABLE_SOURCE_ID,
schema: {},
},
...extra
)
}
export function basicTable(
datasource?: Datasource,
...extra: Partial<Table>[]
): Table {
return tableForDatasource(
datasource,
{
name: "TestTable",
schema: { schema: {
name: { name: {
type: FieldType.STRING, type: FieldType.STRING,
@ -51,7 +73,9 @@ export function basicTable(): Table {
}, },
}, },
}, },
} },
...extra
)
} }
export function basicView(tableId: string) { export function basicView(tableId: string) {

View File

@ -3,16 +3,11 @@ import {
Row, Row,
Table, Table,
TableRequest, TableRequest,
TableSchema,
View, View,
ViewV2, ViewV2Enriched,
} from "../../../documents" } from "../../../documents"
interface ViewV2Response extends ViewV2 { export type TableViewsResponse = { [key: string]: View | ViewV2Enriched }
schema: TableSchema
}
export type TableViewsResponse = { [key: string]: View | ViewV2Response }
export interface TableResponse extends Table { export interface TableResponse extends Table {
views?: TableViewsResponse views?: TableViewsResponse

View File

@ -1,14 +1,13 @@
import { ViewV2, UIFieldMetadata } from "../../../documents" import { ViewV2, ViewV2Enriched } from "../../../documents"
export interface ViewResponse { export interface ViewResponse {
data: ViewV2 data: ViewV2
} }
export interface CreateViewRequest export interface ViewResponseEnriched {
extends Omit<ViewV2, "version" | "id" | "schema"> { data: ViewV2Enriched
schema?: Record<string, UIFieldMetadata>
} }
export interface UpdateViewRequest extends Omit<ViewV2, "schema"> { export interface CreateViewRequest extends Omit<ViewV2, "version" | "id"> {}
schema?: Record<string, UIFieldMetadata>
} export interface UpdateViewRequest extends ViewV2 {}

View File

@ -1,5 +1,5 @@
import { SearchFilter, SortOrder, SortType } from "../../api" import { SearchFilter, SortOrder, SortType } from "../../api"
import { UIFieldMetadata } from "./table" import { TableSchema, UIFieldMetadata } from "./table"
import { Document } from "../document" import { Document } from "../document"
import { DBView } from "../../sdk" import { DBView } from "../../sdk"
@ -48,6 +48,10 @@ export interface ViewV2 {
schema?: Record<string, UIFieldMetadata> schema?: Record<string, UIFieldMetadata>
} }
export interface ViewV2Enriched extends ViewV2 {
schema?: TableSchema
}
export type ViewSchema = ViewCountOrSumSchema | ViewStatisticsSchema export type ViewSchema = ViewCountOrSumSchema | ViewStatisticsSchema
export interface ViewCountOrSumSchema { export interface ViewCountOrSumSchema {