@@ -209,7 +210,7 @@
-
+
Publish
{#if parameters.confirm}
- Confirm text
+ Title
+
+
+ Text
{#if parameters.confirm}
- Confirm text
+ Title
+
+
+ Text
+
+
diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/ExportData.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/ExportData.svelte
index 5955cc762d..096341783d 100644
--- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/ExportData.svelte
+++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/ExportData.svelte
@@ -106,11 +106,12 @@
/>
Export columns
{
const columns = e.detail
+ parameters.columns = columns
parameters.customHeaders = columns.reduce((headerMap, column) => {
return {
[column.name]: column.displayName,
diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/SaveRow.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/SaveRow.svelte
index a1fe773455..d834e9aac9 100644
--- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/SaveRow.svelte
+++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/SaveRow.svelte
@@ -72,7 +72,10 @@
{#if parameters.confirm}
- Confirm text
+ Title
+
+
+ Text
- import EditComponentPopover from "../EditComponentPopover.svelte"
+ import EditComponentPopover from "../EditComponentPopover/EditComponentPopover.svelte"
import { Icon } from "@budibase/bbui"
import { runtimeToReadableBinding } from "dataBinding"
import { isJSBinding } from "@budibase/string-templates"
diff --git a/packages/builder/src/components/design/settings/controls/EditComponentPopover.svelte b/packages/builder/src/components/design/settings/controls/EditComponentPopover/EditComponentPopover.svelte
similarity index 83%
rename from packages/builder/src/components/design/settings/controls/EditComponentPopover.svelte
rename to packages/builder/src/components/design/settings/controls/EditComponentPopover/EditComponentPopover.svelte
index 4e645fe343..39e4bc2ada 100644
--- a/packages/builder/src/components/design/settings/controls/EditComponentPopover.svelte
+++ b/packages/builder/src/components/design/settings/controls/EditComponentPopover/EditComponentPopover.svelte
@@ -3,6 +3,7 @@
import { componentStore } from "stores/builder"
import { cloneDeep } from "lodash/fp"
import { createEventDispatcher, getContext } from "svelte"
+ import { customPositionHandler } from "."
import ComponentSettingsSection from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
export let anchor
@@ -54,25 +55,6 @@
dispatch("change", nestedComponentInstance)
}
-
- const customPositionHandler = (anchorBounds, eleBounds, cfg) => {
- let { left, top } = cfg
- let percentageOffset = 30
- // left-outside
- left = anchorBounds.left - eleBounds.width - 18
-
- // shift up from the anchor, if space allows
- let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset
- let defaultTop = anchorBounds.top - offsetPos
-
- if (window.innerHeight - defaultTop < eleBounds.height) {
- top = window.innerHeight - eleBounds.height - 5
- } else {
- top = anchorBounds.top - offsetPos
- }
-
- return { ...cfg, left, top }
- }
0}
maxHeight={600}
+ offset={18}
handlePostionUpdate={customPositionHandler}
>
diff --git a/packages/builder/src/components/design/settings/controls/EditComponentPopover/index.js b/packages/builder/src/components/design/settings/controls/EditComponentPopover/index.js
new file mode 100644
index 0000000000..2dc3f60185
--- /dev/null
+++ b/packages/builder/src/components/design/settings/controls/EditComponentPopover/index.js
@@ -0,0 +1,18 @@
+export const customPositionHandler = (anchorBounds, eleBounds, cfg) => {
+ let { left, top, offset } = cfg
+ let percentageOffset = 30
+ // left-outside
+ left = anchorBounds.left - eleBounds.width - (offset || 5)
+
+ // shift up from the anchor, if space allows
+ let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset
+ let defaultTop = anchorBounds.top - offsetPos
+
+ if (window.innerHeight - defaultTop < eleBounds.height) {
+ top = window.innerHeight - eleBounds.height - 5
+ } else {
+ top = anchorBounds.top - offsetPos
+ }
+
+ return { ...cfg, left, top }
+}
diff --git a/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldSetting.svelte b/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldSetting.svelte
index 27590a9858..771bcf20e0 100644
--- a/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldSetting.svelte
+++ b/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldSetting.svelte
@@ -1,5 +1,5 @@
-a11y-click-events-have-key-events
{displayValue}
@@ -140,10 +139,22 @@ a11y-click-events-have-key-events
{/each}
+
+ createScreenModal.show("form")}>
+
+
+
+
+ Form
+ Capture data from your users
+
+
diff --git a/packages/builder/src/stores/builder/app.js b/packages/builder/src/stores/builder/app.js
index cb58f69fa8..275d143f80 100644
--- a/packages/builder/src/stores/builder/app.js
+++ b/packages/builder/src/stores/builder/app.js
@@ -29,6 +29,7 @@ export const INITIAL_APP_META_STATE = {
initialised: false,
hasAppPackage: false,
usedPlugins: null,
+ automations: {},
routes: {},
}
@@ -63,6 +64,7 @@ export class AppMetaStore extends BudiStore {
...app.features,
},
initialised: true,
+ automations: app.automations || {},
hasAppPackage: true,
}))
}
diff --git a/packages/builder/src/stores/builder/builder.js b/packages/builder/src/stores/builder/builder.js
index 94fbfafb19..398f3c1606 100644
--- a/packages/builder/src/stores/builder/builder.js
+++ b/packages/builder/src/stores/builder/builder.js
@@ -2,12 +2,11 @@ import { get } from "svelte/store"
import { createBuilderWebsocket } from "./websocket.js"
import { BuilderSocketEvent } from "@budibase/shared-core"
import BudiStore from "./BudiStore"
-import { previewStore } from "./preview.js"
import { TOUR_KEYS } from "components/portal/onboarding/tours.js"
export const INITIAL_BUILDER_STATE = {
previousTopNavPath: {},
- highlightedSettingKey: null,
+ highlightedSetting: null,
propertyFocus: null,
builderSidePanel: false,
onboarding: false,
@@ -26,7 +25,6 @@ export class BuilderStore extends BudiStore {
this.reset = this.reset.bind(this)
this.highlightSetting = this.highlightSetting.bind(this)
this.propertyFocus = this.propertyFocus.bind(this)
- this.hover = this.hover.bind(this)
this.hideBuilderSidePanel = this.hideBuilderSidePanel.bind(this)
this.showBuilderSidePanel = this.showBuilderSidePanel.bind(this)
this.setPreviousTopNavPath = this.setPreviousTopNavPath.bind(this)
@@ -58,10 +56,10 @@ export class BuilderStore extends BudiStore {
this.websocket = null
}
- highlightSetting(key) {
+ highlightSetting(key, type) {
this.update(state => ({
...state,
- highlightedSettingKey: key,
+ highlightedSetting: key ? { key, type: type || "info" } : null,
}))
}
@@ -135,25 +133,20 @@ export class BuilderStore extends BudiStore {
}))
}
- startTour(tourKey) {
+ endBuilderOnboarding() {
this.update(state => ({
...state,
- tourKey: tourKey,
+ onboarding: false,
}))
}
- hover(componentId, notifyClient = true) {
- const store = get(this.store)
- if (componentId === store.hoveredComponentId) {
- return
- }
- this.update(state => {
- state.hoveredComponentId = componentId
- return state
- })
- if (notifyClient) {
- previewStore.sendEvent("hover-component", componentId)
- }
+ setTour(tourKey) {
+ this.update(state => ({
+ ...state,
+ tourStepKey: null,
+ tourNodes: null,
+ tourKey: tourKey,
+ }))
}
}
diff --git a/packages/builder/src/stores/builder/tables.js b/packages/builder/src/stores/builder/tables.js
index 51b8416eda..f86b37ab85 100644
--- a/packages/builder/src/stores/builder/tables.js
+++ b/packages/builder/src/stores/builder/tables.js
@@ -147,6 +147,12 @@ export function createTablesStore() {
if (indexes) {
draft.indexes = indexes
}
+ // Add object to indicate if column is being added
+ if (draft.schema[field.name] === undefined) {
+ draft._add = {
+ name: field.name,
+ }
+ }
draft.schema = {
...draft.schema,
[field.name]: cloneDeep(field),
diff --git a/packages/builder/src/stores/builder/tests/builder.test.js b/packages/builder/src/stores/builder/tests/builder.test.js
index 7aac2489db..e6f52689aa 100644
--- a/packages/builder/src/stores/builder/tests/builder.test.js
+++ b/packages/builder/src/stores/builder/tests/builder.test.js
@@ -88,14 +88,42 @@ describe("Builder store", () => {
)
})
- it("Sync a highlighted setting key to state", ctx => {
- expect(ctx.test.store.highlightedSettingKey).toBeNull()
+ it("Sync a highlighted setting key to state. Default to info type", ctx => {
+ expect(ctx.test.store.highlightedSetting).toBeNull()
ctx.test.builderStore.highlightSetting("testing")
expect(ctx.test.store).toStrictEqual({
...INITIAL_BUILDER_STATE,
- highlightedSettingKey: "testing",
+ highlightedSetting: {
+ key: "testing",
+ type: "info",
+ },
+ })
+ })
+
+ it("Sync a highlighted setting key to state. Use provided type", ctx => {
+ expect(ctx.test.store.highlightedSetting).toBeNull()
+
+ ctx.test.builderStore.highlightSetting("testing", "error")
+
+ expect(ctx.test.store).toStrictEqual({
+ ...INITIAL_BUILDER_STATE,
+ highlightedSetting: {
+ key: "testing",
+ type: "error",
+ },
+ })
+ })
+
+ it("Sync a highlighted setting key to state. Unset when no value is passed", ctx => {
+ expect(ctx.test.store.highlightedSetting).toBeNull()
+
+ ctx.test.builderStore.highlightSetting("testing", "error")
+ ctx.test.builderStore.highlightSetting()
+
+ expect(ctx.test.store).toStrictEqual({
+ ...INITIAL_BUILDER_STATE,
})
})
diff --git a/packages/builder/src/templates/formScreen.js b/packages/builder/src/templates/formScreen.js
new file mode 100644
index 0000000000..b99744e8e8
--- /dev/null
+++ b/packages/builder/src/templates/formScreen.js
@@ -0,0 +1,43 @@
+import { Screen } from "./Screen"
+import { Component } from "./Component"
+import sanitizeUrl from "helpers/sanitizeUrl"
+
+export const FORM_TEMPLATE = "FORM_TEMPLATE"
+export const formUrl = datasource => sanitizeUrl(`/${datasource.label}-form`)
+
+// Mode not really necessary
+export default function (datasources, config) {
+ if (!Array.isArray(datasources)) {
+ return []
+ }
+ return datasources.map(datasource => {
+ return {
+ name: `${datasource.label} - Form`,
+ create: () => createScreen(datasource, config),
+ id: FORM_TEMPLATE,
+ resourceId: datasource.resourceId,
+ }
+ })
+}
+
+const generateMultistepFormBlock = (dataSource, { actionType } = {}) => {
+ const multistepFormBlock = new Component(
+ "@budibase/standard-components/multistepformblock"
+ )
+ multistepFormBlock
+ .customProps({
+ actionType,
+ dataSource,
+ steps: [{}],
+ })
+ .instanceName(`${dataSource.label} - Multistep Form block`)
+ return multistepFormBlock
+}
+
+const createScreen = (datasource, config) => {
+ return new Screen()
+ .route(formUrl(datasource))
+ .instanceName(`${datasource.label} - Form`)
+ .addChild(generateMultistepFormBlock(datasource, config))
+ .json()
+}
diff --git a/packages/builder/src/templates/index.js b/packages/builder/src/templates/index.js
index 3ff42fdec6..fff31cc070 100644
--- a/packages/builder/src/templates/index.js
+++ b/packages/builder/src/templates/index.js
@@ -1,7 +1,11 @@
import rowListScreen from "./rowListScreen"
import createFromScratchScreen from "./createFromScratchScreen"
+import formScreen from "./formScreen"
-const allTemplates = datasources => [...rowListScreen(datasources)]
+const allTemplates = datasources => [
+ ...rowListScreen(datasources),
+ ...formScreen(datasources),
+]
// Allows us to apply common behaviour to all create() functions
const createTemplateOverride = template => () => {
@@ -19,6 +23,7 @@ export default datasources => {
})
const fromScratch = enrichTemplate(createFromScratchScreen)
const tableTemplates = allTemplates(datasources).map(enrichTemplate)
+
return [
fromScratch,
...tableTemplates.sort((templateA, templateB) => {
diff --git a/packages/client/manifest.json b/packages/client/manifest.json
index 43b75ebe26..10f9c5f412 100644
--- a/packages/client/manifest.json
+++ b/packages/client/manifest.json
@@ -525,6 +525,38 @@
"barTitle": "Disable button",
"key": "disabled"
},
+ {
+ "type": "icon",
+ "label": "Icon",
+ "key": "icon"
+ },
+ {
+ "type": "select",
+ "label": "Gap",
+ "key": "gap",
+ "showInBar": true,
+ "barStyle": "picker",
+ "dependsOn": "icon",
+ "options": [
+ {
+ "label": "None",
+ "value": "N"
+ },
+ {
+ "label": "Small",
+ "value": "S"
+ },
+ {
+ "label": "Medium",
+ "value": "M"
+ },
+ {
+ "label": "Large",
+ "value": "L"
+ }
+ ],
+ "defaultValue": "M"
+ },
{
"type": "event",
"label": "On click",
diff --git a/packages/client/src/components/app/Button.svelte b/packages/client/src/components/app/Button.svelte
index 361e64a983..c43face1bb 100644
--- a/packages/client/src/components/app/Button.svelte
+++ b/packages/client/src/components/app/Button.svelte
@@ -13,9 +13,10 @@
export let size = "M"
export let type = "cta"
export let quiet = false
+ export let icon = null
+ export let gap = "M"
// For internal use only for now - not defined in the manifest
- export let icon = null
export let active = false
const handleOnClick = async () => {
@@ -47,7 +48,7 @@
{#key $component.editing}
{#if icon}
- 0}
- class="spectrum-Icon spectrum-Icon--size{size.toUpperCase()}"
- focusable="false"
- aria-hidden="true"
- aria-label={icon}
- >
-
-
+
{/if}
{componentText}
@@ -92,4 +85,13 @@
.active {
color: var(--spectrum-global-color-blue-600);
}
+ .gap-S {
+ gap: 8px;
+ }
+ .gap-M {
+ gap: 16px;
+ }
+ .gap-L {
+ gap: 32px;
+ }
diff --git a/packages/client/src/components/app/ButtonGroup.svelte b/packages/client/src/components/app/ButtonGroup.svelte
index 3ee703e253..2cf6b3db7d 100644
--- a/packages/client/src/components/app/ButtonGroup.svelte
+++ b/packages/client/src/components/app/ButtonGroup.svelte
@@ -20,7 +20,7 @@
wrap: true,
}}
>
- {#each buttons as { text, type, quiet, disabled, onClick, size }}
+ {#each buttons as { text, type, quiet, disabled, onClick, size, icon, gap }}
diff --git a/packages/client/src/components/app/dynamic-filter/DynamicFilter.svelte b/packages/client/src/components/app/dynamic-filter/DynamicFilter.svelte
index 199a6122ab..549574e89b 100644
--- a/packages/client/src/components/app/dynamic-filter/DynamicFilter.svelte
+++ b/packages/client/src/components/app/dynamic-filter/DynamicFilter.svelte
@@ -92,9 +92,9 @@
{#if schemaLoaded}
0}
diff --git a/packages/client/src/components/app/forms/RelationshipField.svelte b/packages/client/src/components/app/forms/RelationshipField.svelte
index 7cd571f6d9..1fbd0df522 100644
--- a/packages/client/src/components/app/forms/RelationshipField.svelte
+++ b/packages/client/src/components/app/forms/RelationshipField.svelte
@@ -1,7 +1,7 @@
readonly,
getType: () => column.schema.type,
getValue: () => row[column.name],
- setValue: (value, options = { save: true }) => {
+ setValue: (value, options = { apply: true }) => {
validation.actions.setError(cellId, null)
updateValue({
rowId: row._id,
column: column.name,
value,
- save: options?.save,
+ apply: options?.apply,
})
},
}
diff --git a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte
index 8b0a0f0942..5e3a035d89 100644
--- a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte
+++ b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte
@@ -217,14 +217,14 @@
const type = $focusedCellAPI.getType()
if (type === "number" && keyCodeIsNumber(keyCode)) {
// Update the value locally but don't save it yet
- $focusedCellAPI.setValue(parseInt(key), { save: false })
+ $focusedCellAPI.setValue(parseInt(key), { apply: false })
$focusedCellAPI.focus()
} else if (
["string", "barcodeqr", "longform"].includes(type) &&
(keyCodeIsLetter(keyCode) || keyCodeIsNumber(keyCode))
) {
// Update the value locally but don't save it yet
- $focusedCellAPI.setValue(key, { save: false })
+ $focusedCellAPI.setValue(key, { apply: false })
$focusedCellAPI.focus()
}
}
diff --git a/packages/frontend-core/src/components/grid/stores/rows.js b/packages/frontend-core/src/components/grid/stores/rows.js
index 34ba77afa2..5dc9413ccd 100644
--- a/packages/frontend-core/src/components/grid/stores/rows.js
+++ b/packages/frontend-core/src/components/grid/stores/rows.js
@@ -327,29 +327,31 @@ export const createActions = context => {
get(fetch)?.getInitialData()
}
- // Patches a row with some changes
- const updateRow = async (rowId, changes, options = { save: true }) => {
+ // Checks if a changeset for a row actually mutates the row or not
+ const changesAreValid = (row, changes) => {
+ const columns = Object.keys(changes || {})
+ if (!row || !columns.length) {
+ return false
+ }
+
+ // Ensure there is at least 1 column that creates a difference
+ return columns.some(column => row[column] !== changes[column])
+ }
+
+ // Patches a row with some changes in local state, and returns whether a
+ // valid pending change was made or not
+ const stashRowChanges = (rowId, changes) => {
const $rows = get(rows)
const $rowLookupMap = get(rowLookupMap)
const index = $rowLookupMap[rowId]
const row = $rows[index]
- if (index == null || !Object.keys(changes || {}).length) {
- return
+
+ // Check this is a valid change
+ if (!row || !changesAreValid(row, changes)) {
+ return false
}
- // Abandon if no changes
- let same = true
- for (let column of Object.keys(changes)) {
- if (row[column] !== changes[column]) {
- same = false
- break
- }
- }
- if (same) {
- return
- }
-
- // Immediately update state so that the change is reflected
+ // Add change to cache
rowChangeCache.update(state => ({
...state,
[rowId]: {
@@ -357,26 +359,30 @@ export const createActions = context => {
...changes,
},
}))
+ return true
+ }
- // Stop here if we don't want to persist the change
- if (!options?.save) {
+ // Saves any pending changes to a row
+ const applyRowChanges = async rowId => {
+ const $rows = get(rows)
+ const $rowLookupMap = get(rowLookupMap)
+ const index = $rowLookupMap[rowId]
+ const row = $rows[index]
+ if (row == null) {
return
}
// Save change
try {
- inProgressChanges.update(state => ({
- ...state,
- [rowId]: true,
- }))
+ // Mark as in progress
+ inProgressChanges.update(state => ({ ...state, [rowId]: true }))
// Update row
- const saved = await datasource.actions.updateRow({
- ...cleanRow(row),
- ...get(rowChangeCache)[rowId],
- })
+ const changes = get(rowChangeCache)[rowId]
+ const newRow = { ...cleanRow(row), ...changes }
+ const saved = await datasource.actions.updateRow(newRow)
- // Update state after a successful change
+ // Update row state after a successful change
if (saved?._id) {
rows.update(state => {
state[index] = saved
@@ -386,6 +392,8 @@ export const createActions = context => {
// Handle users table edge case
await refreshRow(saved.id)
}
+
+ // Wipe row change cache now that we've saved the row
rowChangeCache.update(state => {
delete state[rowId]
return state
@@ -393,15 +401,17 @@ export const createActions = context => {
} catch (error) {
handleValidationError(rowId, error)
}
- inProgressChanges.update(state => ({
- ...state,
- [rowId]: false,
- }))
+
+ // Mark as completed
+ inProgressChanges.update(state => ({ ...state, [rowId]: false }))
}
// Updates a value of a row
- const updateValue = async ({ rowId, column, value, save = true }) => {
- return await updateRow(rowId, { [column]: value }, { save })
+ const updateValue = async ({ rowId, column, value, apply = true }) => {
+ const success = stashRowChanges(rowId, { [column]: value })
+ if (success && apply) {
+ await applyRowChanges(rowId)
+ }
}
// Deletes an array of rows
@@ -411,9 +421,7 @@ export const createActions = context => {
}
// Actually delete rows
- rowsToDelete.forEach(row => {
- delete row.__idx
- })
+ rowsToDelete.forEach(row => delete row.__idx)
await datasource.actions.deleteRows(rowsToDelete)
// Update state
@@ -433,7 +441,7 @@ export const createActions = context => {
newRow = newRows[i]
// Ensure we have a unique _id.
- // This means generating one for non DS+, overriting any that may already
+ // This means generating one for non DS+, overwriting any that may already
// exist as we cannot allow duplicates.
if (!$isDatasourcePlus) {
newRow._id = Helpers.uuid()
@@ -494,7 +502,7 @@ export const createActions = context => {
duplicateRow,
getRow,
updateValue,
- updateRow,
+ applyRowChanges,
deleteRows,
hasRow,
loadNextPage,
@@ -508,7 +516,14 @@ export const createActions = context => {
}
export const initialise = context => {
- const { rowChangeCache, inProgressChanges, previousFocusedRowId } = context
+ const {
+ rowChangeCache,
+ inProgressChanges,
+ previousFocusedRowId,
+ previousFocusedCellId,
+ rows,
+ validation,
+ } = context
// Wipe the row change cache when changing row
previousFocusedRowId.subscribe(id => {
@@ -519,4 +534,15 @@ export const initialise = context => {
})
}
})
+
+ // Ensure any unsaved changes are saved when changing cell
+ previousFocusedCellId.subscribe(async id => {
+ const rowId = id?.split("-")[0]
+ const hasErrors = validation.actions.rowHasErrors(rowId)
+ const hasChanges = Object.keys(get(rowChangeCache)[rowId] || {}).length > 0
+ const isSavingChanges = get(inProgressChanges)[rowId]
+ if (rowId && !hasErrors && hasChanges && !isSavingChanges) {
+ await rows.actions.applyRowChanges(rowId)
+ }
+ })
}
diff --git a/packages/frontend-core/src/components/grid/stores/ui.js b/packages/frontend-core/src/components/grid/stores/ui.js
index 129d6614e5..da0558bb5b 100644
--- a/packages/frontend-core/src/components/grid/stores/ui.js
+++ b/packages/frontend-core/src/components/grid/stores/ui.js
@@ -16,6 +16,7 @@ export const createStores = context => {
const hoveredRowId = writable(null)
const rowHeight = writable(get(props).fixedRowHeight || DefaultRowHeight)
const previousFocusedRowId = writable(null)
+ const previousFocusedCellId = writable(null)
const gridFocused = writable(false)
const isDragging = writable(false)
const buttonColumnWidth = writable(0)
@@ -48,6 +49,7 @@ export const createStores = context => {
focusedCellAPI,
focusedRowId,
previousFocusedRowId,
+ previousFocusedCellId,
hoveredRowId,
rowHeight,
gridFocused,
@@ -129,6 +131,7 @@ export const initialise = context => {
const {
focusedRowId,
previousFocusedRowId,
+ previousFocusedCellId,
rows,
focusedCellId,
selectedRows,
@@ -181,6 +184,13 @@ export const initialise = context => {
lastFocusedRowId = id
})
+ // Remember the last focused cell ID so that we can store the previous one
+ let lastFocusedCellId = null
+ focusedCellId.subscribe(id => {
+ previousFocusedCellId.set(lastFocusedCellId)
+ lastFocusedCellId = id
+ })
+
// Remove hovered row when a cell is selected
focusedCellId.subscribe(cell => {
if (cell && get(hoveredRowId)) {
diff --git a/packages/frontend-core/src/components/grid/stores/validation.js b/packages/frontend-core/src/components/grid/stores/validation.js
index 9c3927f9c9..70db076593 100644
--- a/packages/frontend-core/src/components/grid/stores/validation.js
+++ b/packages/frontend-core/src/components/grid/stores/validation.js
@@ -1,8 +1,23 @@
-import { writable, get } from "svelte/store"
+import { writable, get, derived } from "svelte/store"
+// Normally we would break out actions into the explicit "createActions"
+// function, but for validation all these actions are pure so can go into
+// "createStores" instead to make dependency ordering simpler
export const createStores = () => {
const validation = writable({})
+ // Derive which rows have errors so that we can use that info later
+ const rowErrorMap = derived(validation, $validation => {
+ let map = {}
+ Object.entries($validation).forEach(([key, error]) => {
+ // Extract row ID from all errored cell IDs
+ if (error) {
+ map[key.split("-")[0]] = true
+ }
+ })
+ return map
+ })
+
const setError = (cellId, error) => {
if (!cellId) {
return
@@ -13,11 +28,16 @@ export const createStores = () => {
}))
}
+ const rowHasErrors = rowId => {
+ return get(rowErrorMap)[rowId]
+ }
+
return {
validation: {
...validation,
actions: {
setError,
+ rowHasErrors,
},
},
}
diff --git a/packages/pro b/packages/pro
index 1b6964d047..6504ee7451 160000
--- a/packages/pro
+++ b/packages/pro
@@ -1 +1 @@
-Subproject commit 1b6964d047ffa1dd628758e4215c0242a06a5780
+Subproject commit 6504ee7451b0a4b21e7e061a0f49add143bb483e
diff --git a/packages/server/__mocks__/node-fetch.ts b/packages/server/__mocks__/node-fetch.ts
index 265d6b1c36..98c75bb84f 100644
--- a/packages/server/__mocks__/node-fetch.ts
+++ b/packages/server/__mocks__/node-fetch.ts
@@ -8,6 +8,7 @@ module FetchMock {
let mockSearch = false
const func = async (url: any, opts: any) => {
+ const { host, pathname } = new URL(url)
function json(body: any, status = 200) {
return {
status,
@@ -34,7 +35,7 @@ module FetchMock {
}
}
- if (url.includes("/api/global")) {
+ if (pathname.includes("/api/global")) {
const user = {
email: "test@example.com",
_id: "us_test@example.com",
@@ -47,31 +48,31 @@ module FetchMock {
global: false,
},
}
- return url.endsWith("/users") && opts.method === "GET"
+ return pathname.endsWith("/users") && opts.method === "GET"
? json([user])
: json(user)
}
// mocked data based on url
- else if (url.includes("api/apps")) {
+ else if (pathname.includes("api/apps")) {
return json({
app1: {
url: "/app1",
},
})
- } else if (url.includes("example.com")) {
+ } else if (host.includes("example.com")) {
return json({
body: opts.body,
url,
method: opts.method,
})
- } else if (url.includes("invalid.com")) {
+ } else if (host.includes("invalid.com")) {
return json(
{
invalid: true,
},
404
)
- } else if (mockSearch && url.includes("_search")) {
+ } else if (mockSearch && pathname.includes("_search")) {
const body = opts.body
const parts = body.split("tableId:")
let tableId
@@ -90,7 +91,7 @@ module FetchMock {
],
bookmark: "test",
})
- } else if (url.includes("google.com")) {
+ } else if (host.includes("google.com")) {
return json({
url,
opts,
@@ -177,7 +178,7 @@ module FetchMock {
} else if (url === "https://www.googleapis.com/oauth2/v4/token") {
// any valid response
return json({})
- } else if (url.includes("failonce.com")) {
+ } else if (host.includes("failonce.com")) {
failCount++
if (failCount === 1) {
return json({ message: "error" }, 500)
diff --git a/packages/server/package.json b/packages/server/package.json
index 45980a4be6..97de17eb58 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -149,7 +149,7 @@
"rimraf": "3.0.2",
"supertest": "6.3.3",
"swagger-jsdoc": "6.1.0",
- "testcontainers": "10.6.0",
+ "testcontainers": "10.7.2",
"timekeeper": "2.2.0",
"ts-node": "10.8.1",
"tsconfig-paths": "4.0.0",
diff --git a/packages/server/scripts/integrations/mysql/init.sql b/packages/server/scripts/integrations/mysql/init.sql
index ae5cd07788..e687c7c3b1 100644
--- a/packages/server/scripts/integrations/mysql/init.sql
+++ b/packages/server/scripts/integrations/mysql/init.sql
@@ -10,6 +10,11 @@ CREATE TABLE Persons (
City varchar(255),
PRIMARY KEY (PersonID)
);
+CREATE TABLE Person (
+ PersonID int NOT NULL AUTO_INCREMENT,
+ Name varchar(255),
+ PRIMARY KEY (PersonID)
+);
CREATE TABLE Tasks (
TaskID int NOT NULL AUTO_INCREMENT,
PersonID INT,
@@ -27,6 +32,7 @@ CREATE TABLE Products (
);
INSERT INTO Persons (FirstName, LastName, Age, Address, City, CreatedAt) VALUES ('Mike', 'Hughes', 28.2, '123 Fake Street', 'Belfast', '2021-01-19 03:14:07');
INSERT INTO Persons (FirstName, LastName, Age, Address, City, CreatedAt) VALUES ('Dave', 'Johnson', 29, '124 Fake Street', 'Belfast', '2022-04-01 00:11:11');
+INSERT INTO Person (Name) VALUES ('Elf');
INSERT INTO Tasks (PersonID, TaskName, CreatedAt) VALUES (1, 'assembling', '2020-01-01');
INSERT INTO Tasks (PersonID, TaskName, CreatedAt) VALUES (2, 'processing', '2019-12-31');
INSERT INTO Products (name, updated) VALUES ('Meat', '11:00:22'), ('Fruit', '10:00:00');
diff --git a/packages/server/scripts/integrations/postgres/reset.sh b/packages/server/scripts/integrations/postgres/reset.sh
index 32778bd11f..8deb01cdf8 100755
--- a/packages/server/scripts/integrations/postgres/reset.sh
+++ b/packages/server/scripts/integrations/postgres/reset.sh
@@ -1,3 +1,3 @@
#!/bin/bash
-docker-compose down
+docker-compose down -v
docker volume prune -f
diff --git a/packages/server/src/api/controllers/automation.ts b/packages/server/src/api/controllers/automation.ts
index 186b68f3b7..b986b5232b 100644
--- a/packages/server/src/api/controllers/automation.ts
+++ b/packages/server/src/api/controllers/automation.ts
@@ -20,6 +20,7 @@ import {
AutomationActionStepId,
AutomationResults,
UserCtx,
+ DeleteAutomationResponse,
} from "@budibase/types"
import { getActionDefinitions as actionDefs } from "../../automations/actions"
import sdk from "../../sdk"
@@ -72,7 +73,9 @@ function cleanAutomationInputs(automation: Automation) {
return automation
}
-export async function create(ctx: UserCtx) {
+export async function create(
+ ctx: UserCtx
+) {
const db = context.getAppDB()
let automation = ctx.request.body
automation.appId = ctx.appId
@@ -207,7 +210,7 @@ export async function find(ctx: UserCtx) {
ctx.body = await db.get(ctx.params.id)
}
-export async function destroy(ctx: UserCtx) {
+export async function destroy(ctx: UserCtx) {
const db = context.getAppDB()
const automationId = ctx.params.id
const oldAutomation = await db.get(automationId)
diff --git a/packages/server/src/api/controllers/datasource.ts b/packages/server/src/api/controllers/datasource.ts
index d70c13f800..0f17c5a2f5 100644
--- a/packages/server/src/api/controllers/datasource.ts
+++ b/packages/server/src/api/controllers/datasource.ts
@@ -15,10 +15,14 @@ import {
FieldType,
RelationshipFieldMetadata,
SourceName,
+ UpdateDatasourceRequest,
UpdateDatasourceResponse,
UserCtx,
VerifyDatasourceRequest,
VerifyDatasourceResponse,
+ Table,
+ RowValue,
+ DynamicVariable,
} from "@budibase/types"
import sdk from "../../sdk"
import { builderSocket } from "../../websockets"
@@ -90,8 +94,10 @@ async function invalidateVariables(
existingDatasource: Datasource,
updatedDatasource: Datasource
) {
- const existingVariables: any = existingDatasource.config?.dynamicVariables
- const updatedVariables: any = updatedDatasource.config?.dynamicVariables
+ const existingVariables: DynamicVariable[] =
+ existingDatasource.config?.dynamicVariables || []
+ const updatedVariables: DynamicVariable[] =
+ updatedDatasource.config?.dynamicVariables || []
const toInvalidate = []
if (!existingVariables) {
@@ -103,9 +109,9 @@ async function invalidateVariables(
toInvalidate.push(...existingVariables)
} else {
// invaldate changed / removed
- existingVariables.forEach((existing: any) => {
+ existingVariables.forEach(existing => {
const unchanged = updatedVariables.find(
- (updated: any) =>
+ updated =>
existing.name === updated.name &&
existing.queryId === updated.queryId &&
existing.value === updated.value
@@ -118,24 +124,32 @@ async function invalidateVariables(
await invalidateDynamicVariables(toInvalidate)
}
-export async function update(ctx: UserCtx) {
+export async function update(
+ ctx: UserCtx
+) {
const db = context.getAppDB()
const datasourceId = ctx.params.datasourceId
const baseDatasource = await sdk.datasources.get(datasourceId)
- const auth = baseDatasource.config?.auth
await invalidateVariables(baseDatasource, ctx.request.body)
const isBudibaseSource =
baseDatasource.type === dbCore.BUDIBASE_DATASOURCE_TYPE
- const dataSourceBody = isBudibaseSource
- ? { name: ctx.request.body?.name }
+ const dataSourceBody: Datasource = isBudibaseSource
+ ? {
+ name: ctx.request.body?.name,
+ type: dbCore.BUDIBASE_DATASOURCE_TYPE,
+ source: SourceName.BUDIBASE,
+ }
: ctx.request.body
let datasource: Datasource = {
...baseDatasource,
...sdk.datasources.mergeConfigs(dataSourceBody, baseDatasource),
}
+
+ // this block is specific to GSheets, if no auth set, set it back
+ const auth = baseDatasource.config?.auth
if (auth && !ctx.request.body.auth) {
// don't strip auth config from DB
datasource.config!.auth = auth
@@ -204,7 +218,7 @@ async function destroyInternalTablesBySourceId(datasourceId: string) {
const db = context.getAppDB()
// Get all internal tables
- const internalTables = await db.allDocs(
+ const internalTables = await db.allDocs(
getTableParams(null, {
include_docs: true,
})
@@ -212,8 +226,8 @@ async function destroyInternalTablesBySourceId(datasourceId: string) {
// Filter by datasource and return the docs.
const datasourceTableDocs = internalTables.rows.reduce(
- (acc: any, table: any) => {
- if (table.doc.sourceId == datasourceId) {
+ (acc: Table[], table) => {
+ if (table.doc?.sourceId == datasourceId) {
acc.push(table.doc)
}
return acc
@@ -254,9 +268,9 @@ export async function destroy(ctx: UserCtx) {
if (datasource.type === dbCore.BUDIBASE_DATASOURCE_TYPE) {
await destroyInternalTablesBySourceId(datasourceId)
} else {
- const queries = await db.allDocs(getQueryParams(datasourceId))
+ const queries = await db.allDocs(getQueryParams(datasourceId))
await db.bulkDocs(
- queries.rows.map((row: any) => ({
+ queries.rows.map(row => ({
_id: row.id,
_rev: row.value.rev,
_deleted: true,
diff --git a/packages/server/src/api/controllers/integration.ts b/packages/server/src/api/controllers/integration.ts
index 9cfde31e4c..57038f8401 100644
--- a/packages/server/src/api/controllers/integration.ts
+++ b/packages/server/src/api/controllers/integration.ts
@@ -1,7 +1,10 @@
import { getDefinition, getDefinitions } from "../../integrations"
import { SourceName, UserCtx } from "@budibase/types"
-const DISABLED_EXTERNAL_INTEGRATIONS = [SourceName.AIRTABLE]
+const DISABLED_EXTERNAL_INTEGRATIONS = [
+ SourceName.AIRTABLE,
+ SourceName.BUDIBASE,
+]
export async function fetch(ctx: UserCtx) {
const definitions = await getDefinitions()
diff --git a/packages/server/src/api/controllers/layout.ts b/packages/server/src/api/controllers/layout.ts
index 69e4ad91ed..c0406f50ac 100644
--- a/packages/server/src/api/controllers/layout.ts
+++ b/packages/server/src/api/controllers/layout.ts
@@ -1,9 +1,17 @@
import { EMPTY_LAYOUT } from "../../constants/layouts"
import { generateLayoutID, getScreenParams } from "../../db/utils"
import { events, context } from "@budibase/backend-core"
-import { BBContext, Layout } from "@budibase/types"
+import {
+ BBContext,
+ Layout,
+ SaveLayoutRequest,
+ SaveLayoutResponse,
+ UserCtx,
+} from "@budibase/types"
-export async function save(ctx: BBContext) {
+export async function save(
+ ctx: UserCtx
+) {
const db = context.getAppDB()
let layout = ctx.request.body
diff --git a/packages/server/src/api/controllers/permission.ts b/packages/server/src/api/controllers/permission.ts
index e2bd6c40e5..cdfa6d8b1c 100644
--- a/packages/server/src/api/controllers/permission.ts
+++ b/packages/server/src/api/controllers/permission.ts
@@ -7,6 +7,10 @@ import {
GetResourcePermsResponse,
ResourcePermissionInfo,
GetDependantResourcesResponse,
+ AddPermissionResponse,
+ AddPermissionRequest,
+ RemovePermissionRequest,
+ RemovePermissionResponse,
} from "@budibase/types"
import { getRoleParams } from "../../db/utils"
import {
@@ -16,9 +20,9 @@ import {
import { removeFromArray } from "../../utilities"
import sdk from "../../sdk"
-const PermissionUpdateType = {
- REMOVE: "remove",
- ADD: "add",
+const enum PermissionUpdateType {
+ REMOVE = "remove",
+ ADD = "add",
}
const SUPPORTED_LEVELS = CURRENTLY_SUPPORTED_LEVELS
@@ -39,7 +43,7 @@ async function updatePermissionOnRole(
resourceId,
level,
}: { roleId: string; resourceId: string; level: PermissionLevel },
- updateType: string
+ updateType: PermissionUpdateType
) {
const allowedAction = await sdk.permissions.resourceActionAllowed({
resourceId,
@@ -107,11 +111,15 @@ async function updatePermissionOnRole(
}
const response = await db.bulkDocs(docUpdates)
- return response.map((resp: any) => {
+ return response.map(resp => {
const version = docUpdates.find(role => role._id === resp.id)?.version
- resp._id = roles.getExternalRoleID(resp.id, version)
- delete resp.id
- return resp
+ const _id = roles.getExternalRoleID(resp.id, version)
+ return {
+ _id,
+ rev: resp.rev,
+ error: resp.error,
+ reason: resp.reason,
+ }
})
}
@@ -189,13 +197,14 @@ export async function getDependantResources(
}
}
-export async function addPermission(ctx: UserCtx) {
- ctx.body = await updatePermissionOnRole(ctx.params, PermissionUpdateType.ADD)
+export async function addPermission(ctx: UserCtx) {
+ const params: AddPermissionRequest = ctx.params
+ ctx.body = await updatePermissionOnRole(params, PermissionUpdateType.ADD)
}
-export async function removePermission(ctx: UserCtx) {
- ctx.body = await updatePermissionOnRole(
- ctx.params,
- PermissionUpdateType.REMOVE
- )
+export async function removePermission(
+ ctx: UserCtx
+) {
+ const params: RemovePermissionRequest = ctx.params
+ ctx.body = await updatePermissionOnRole(params, PermissionUpdateType.REMOVE)
}
diff --git a/packages/server/src/api/controllers/query/index.ts b/packages/server/src/api/controllers/query/index.ts
index 768c921150..3c21537484 100644
--- a/packages/server/src/api/controllers/query/index.ts
+++ b/packages/server/src/api/controllers/query/index.ts
@@ -17,10 +17,12 @@ import {
QueryPreview,
QuerySchema,
FieldType,
- type ExecuteQueryRequest,
- type ExecuteQueryResponse,
- type Row,
+ ExecuteQueryRequest,
+ ExecuteQueryResponse,
+ Row,
QueryParameter,
+ PreviewQueryRequest,
+ PreviewQueryResponse,
} from "@budibase/types"
import { ValidQueryNameRegex, utils as JsonUtils } from "@budibase/shared-core"
@@ -73,7 +75,7 @@ const _import = async (ctx: UserCtx) => {
}
export { _import as import }
-export async function save(ctx: UserCtx) {
+export async function save(ctx: UserCtx) {
const db = context.getAppDB()
const query: Query = ctx.request.body
@@ -134,14 +136,16 @@ function enrichParameters(
return requestParameters
}
-export async function preview(ctx: UserCtx) {
+export async function preview(
+ ctx: UserCtx
+) {
const { datasource, envVars } = await sdk.datasources.getWithEnvVars(
ctx.request.body.datasourceId
)
- const query: QueryPreview = ctx.request.body
// preview may not have a queryId as it hasn't been saved, but if it does
// this stops dynamic variables from calling the same query
- const { fields, parameters, queryVerb, transformer, queryId, schema } = query
+ const { fields, parameters, queryVerb, transformer, queryId, schema } =
+ ctx.request.body
let existingSchema = schema
if (queryId && !existingSchema) {
@@ -266,9 +270,7 @@ export async function preview(ctx: UserCtx) {
},
}
- const { rows, keys, info, extra } = (await Runner.run(
- inputs
- )) as QueryResponse
+ const { rows, keys, info, extra } = await Runner.run(inputs)
const { previewSchema, nestedSchemaFields } = getSchemaFields(rows, keys)
// if existing schema, update to include any previous schema keys
@@ -281,7 +283,7 @@ export async function preview(ctx: UserCtx) {
}
// remove configuration before sending event
delete datasource.config
- await events.query.previewed(datasource, query)
+ await events.query.previewed(datasource, ctx.request.body)
ctx.body = {
rows,
nestedSchemaFields,
@@ -295,7 +297,10 @@ export async function preview(ctx: UserCtx) {
}
async function execute(
- ctx: UserCtx,
+ ctx: UserCtx<
+ ExecuteQueryRequest,
+ ExecuteQueryResponse | Record[]
+ >,
opts: any = { rowsOnly: false, isAutomation: false }
) {
const db = context.getAppDB()
@@ -350,18 +355,23 @@ async function execute(
}
}
-export async function executeV1(ctx: UserCtx) {
+export async function executeV1(
+ ctx: UserCtx[]>
+) {
return execute(ctx, { rowsOnly: true, isAutomation: false })
}
export async function executeV2(
- ctx: UserCtx,
+ ctx: UserCtx<
+ ExecuteQueryRequest,
+ ExecuteQueryResponse | Record[]
+ >,
{ isAutomation }: { isAutomation?: boolean } = {}
) {
return execute(ctx, { rowsOnly: false, isAutomation })
}
-const removeDynamicVariables = async (queryId: any) => {
+const removeDynamicVariables = async (queryId: string) => {
const db = context.getAppDB()
const query = await db.get(queryId)
const datasource = await sdk.datasources.get(query.datasourceId)
@@ -384,7 +394,7 @@ const removeDynamicVariables = async (queryId: any) => {
export async function destroy(ctx: UserCtx) {
const db = context.getAppDB()
- const queryId = ctx.params.queryId
+ const queryId = ctx.params.queryId as string
await removeDynamicVariables(queryId)
const query = await db.get(queryId)
const datasource = await sdk.datasources.get(query.datasourceId)
diff --git a/packages/server/src/api/controllers/role.ts b/packages/server/src/api/controllers/role.ts
index ae6b89e6d4..2f5340d2e6 100644
--- a/packages/server/src/api/controllers/role.ts
+++ b/packages/server/src/api/controllers/role.ts
@@ -7,8 +7,14 @@ import {
} from "@budibase/backend-core"
import { getUserMetadataParams, InternalTables } from "../../db/utils"
import {
+ AccessibleRolesResponse,
Database,
+ DestroyRoleResponse,
+ FetchRolesResponse,
+ FindRoleResponse,
Role,
+ SaveRoleRequest,
+ SaveRoleResponse,
UserCtx,
UserMetadata,
UserRoles,
@@ -25,43 +31,36 @@ async function updateRolesOnUserTable(
db: Database,
roleId: string,
updateOption: string,
- roleVersion: string | undefined
+ roleVersion?: string
) {
const table = await sdk.tables.getTable(InternalTables.USER_METADATA)
- const schema = table.schema
+ const constraints = table.schema.roleId?.constraints
+ if (!constraints) {
+ return
+ }
+ const updatedRoleId =
+ roleVersion === roles.RoleIDVersion.NAME
+ ? roles.getExternalRoleID(roleId, roleVersion)
+ : roleId
+ const indexOfRoleId = constraints.inclusion!.indexOf(updatedRoleId)
const remove = updateOption === UpdateRolesOptions.REMOVED
- let updated = false
- for (let prop of Object.keys(schema)) {
- if (prop === "roleId") {
- updated = true
- const constraints = schema[prop].constraints!
- const updatedRoleId =
- roleVersion === roles.RoleIDVersion.NAME
- ? roles.getExternalRoleID(roleId, roleVersion)
- : roleId
- const indexOfRoleId = constraints.inclusion!.indexOf(updatedRoleId)
- if (remove && indexOfRoleId !== -1) {
- constraints.inclusion!.splice(indexOfRoleId, 1)
- } else if (!remove && indexOfRoleId === -1) {
- constraints.inclusion!.push(updatedRoleId)
- }
- break
- }
- }
- if (updated) {
- await db.put(table)
+ if (remove && indexOfRoleId !== -1) {
+ constraints.inclusion!.splice(indexOfRoleId, 1)
+ } else if (!remove && indexOfRoleId === -1) {
+ constraints.inclusion!.push(updatedRoleId)
}
+ await db.put(table)
}
-export async function fetch(ctx: UserCtx) {
+export async function fetch(ctx: UserCtx) {
ctx.body = await roles.getAllRoles()
}
-export async function find(ctx: UserCtx) {
+export async function find(ctx: UserCtx) {
ctx.body = await roles.getRole(ctx.params.roleId)
}
-export async function save(ctx: UserCtx) {
+export async function save(ctx: UserCtx) {
const db = context.getAppDB()
let { _id, name, inherits, permissionId, version } = ctx.request.body
let isCreate = false
@@ -107,11 +106,26 @@ export async function save(ctx: UserCtx) {
)
role._rev = result.rev
ctx.body = role
+
+ const devDb = context.getDevAppDB()
+ const prodDb = context.getProdAppDB()
+
+ if (await prodDb.exists()) {
+ const replication = new dbCore.Replication({
+ source: devDb.name,
+ target: prodDb.name,
+ })
+ await replication.replicate({
+ filter: (doc: any, params: any) => {
+ return doc._id && doc._id.startsWith("role_")
+ },
+ })
+ }
}
-export async function destroy(ctx: UserCtx) {
+export async function destroy(ctx: UserCtx) {
const db = context.getAppDB()
- let roleId = ctx.params.roleId
+ let roleId = ctx.params.roleId as string
if (roles.isBuiltin(roleId)) {
ctx.throw(400, "Cannot delete builtin role.")
} else {
@@ -144,14 +158,18 @@ export async function destroy(ctx: UserCtx) {
ctx.status = 200
}
-export async function accessible(ctx: UserCtx) {
+export async function accessible(ctx: UserCtx) {
let roleId = ctx.user?.roleId
if (!roleId) {
roleId = roles.BUILTIN_ROLE_IDS.PUBLIC
}
if (ctx.user && sharedSdk.users.isAdminOrBuilder(ctx.user)) {
const appId = context.getAppId()
- ctx.body = await roles.getAllRoleIds(appId)
+ if (!appId) {
+ ctx.body = []
+ } else {
+ ctx.body = await roles.getAllRoleIds(appId)
+ }
} else {
ctx.body = await roles.getUserRoleIdHierarchy(roleId!)
}
diff --git a/packages/server/src/api/controllers/routing.ts b/packages/server/src/api/controllers/routing.ts
index 4154c6b597..040cda4dd0 100644
--- a/packages/server/src/api/controllers/routing.ts
+++ b/packages/server/src/api/controllers/routing.ts
@@ -63,7 +63,7 @@ export async function fetch(ctx: UserCtx) {
export async function clientFetch(ctx: UserCtx) {
const routing = await getRoutingStructure()
let roleId = ctx.user?.role?._id
- const roleIds = await roles.getUserRoleIdHierarchy(roleId)
+ const roleIds = roleId ? await roles.getUserRoleIdHierarchy(roleId) : []
for (let topLevel of Object.values(routing.routes) as any) {
for (let subpathKey of Object.keys(topLevel.subpaths)) {
let found = false
diff --git a/packages/server/src/api/controllers/row/ExternalRequest.ts b/packages/server/src/api/controllers/row/ExternalRequest.ts
index b7dc02c0db..685af4e98e 100644
--- a/packages/server/src/api/controllers/row/ExternalRequest.ts
+++ b/packages/server/src/api/controllers/row/ExternalRequest.ts
@@ -7,6 +7,7 @@ import {
FilterType,
IncludeRelationship,
ManyToManyRelationshipFieldMetadata,
+ ManyToOneRelationshipFieldMetadata,
OneToManyRelationshipFieldMetadata,
Operation,
PaginationJson,
@@ -18,6 +19,7 @@ import {
SortJson,
SortType,
Table,
+ isManyToOne,
} from "@budibase/types"
import {
breakExternalTableId,
@@ -32,7 +34,9 @@ import { processObjectSync } from "@budibase/string-templates"
import { cloneDeep } from "lodash/fp"
import { processDates, processFormulas } from "../../../utilities/rowProcessor"
import { db as dbCore } from "@budibase/backend-core"
+import AliasTables from "./alias"
import sdk from "../../../sdk"
+import env from "../../../environment"
export interface ManyRelationship {
tableId?: string
@@ -101,6 +105,39 @@ function buildFilters(
}
}
+async function removeManyToManyRelationships(
+ rowId: string,
+ table: Table,
+ colName: string
+) {
+ const tableId = table._id!
+ const filters = buildFilters(rowId, {}, table)
+ // safety check, if there are no filters on deletion bad things happen
+ if (Object.keys(filters).length !== 0) {
+ return getDatasourceAndQuery({
+ endpoint: getEndpoint(tableId, Operation.DELETE),
+ body: { [colName]: null },
+ filters,
+ })
+ } else {
+ return []
+ }
+}
+
+async function removeOneToManyRelationships(rowId: string, table: Table) {
+ const tableId = table._id!
+ const filters = buildFilters(rowId, {}, table)
+ // safety check, if there are no filters on deletion bad things happen
+ if (Object.keys(filters).length !== 0) {
+ return getDatasourceAndQuery({
+ endpoint: getEndpoint(tableId, Operation.UPDATE),
+ filters,
+ })
+ } else {
+ return []
+ }
+}
+
/**
* This function checks the incoming parameters to make sure all the inputs are
* valid based on on the table schema. The main thing this is looking for is when a
@@ -178,13 +215,13 @@ function generateIdForRow(
function getEndpoint(tableId: string | undefined, operation: string) {
if (!tableId) {
- return {}
+ throw new Error("Cannot get endpoint information - no table ID specified")
}
const { datasourceId, tableName } = breakExternalTableId(tableId)
return {
- datasourceId,
- entityId: tableName,
- operation,
+ datasourceId: datasourceId!,
+ entityId: tableName!,
+ operation: operation as Operation,
}
}
@@ -304,6 +341,18 @@ export class ExternalRequest {
}
}
+ async getRow(table: Table, rowId: string): Promise {
+ const response = await getDatasourceAndQuery({
+ endpoint: getEndpoint(table._id!, Operation.READ),
+ filters: buildFilters(rowId, {}, table),
+ })
+ if (Array.isArray(response) && response.length > 0) {
+ return response[0]
+ } else {
+ throw new Error(`Cannot fetch row by ID "${rowId}"`)
+ }
+ }
+
inputProcessing(row: Row | undefined, table: Table) {
if (!row) {
return { row, manyRelationships: [] }
@@ -571,7 +620,9 @@ export class ExternalRequest {
* information.
*/
async lookupRelations(tableId: string, row: Row) {
- const related: { [key: string]: any } = {}
+ const related: {
+ [key: string]: { rows: Row[]; isMany: boolean; tableId: string }
+ } = {}
const { tableName } = breakExternalTableId(tableId)
if (!tableName) {
return related
@@ -589,14 +640,26 @@ export class ExternalRequest {
) {
continue
}
- const isMany = field.relationshipType === RelationshipType.MANY_TO_MANY
- const tableId = isMany ? field.through : field.tableId
+ let tableId: string | undefined,
+ lookupField: string | undefined,
+ fieldName: string | undefined
+ if (isManyToMany(field)) {
+ tableId = field.through
+ lookupField = primaryKey
+ fieldName = field.throughTo || primaryKey
+ } else if (isManyToOne(field)) {
+ tableId = field.tableId
+ lookupField = field.foreignKey
+ fieldName = field.fieldName
+ }
+ if (!tableId || !lookupField || !fieldName) {
+ throw new Error(
+ "Unable to lookup relationships - undefined column properties."
+ )
+ }
const { tableName: relatedTableName } = breakExternalTableId(tableId)
// @ts-ignore
const linkPrimaryKey = this.tables[relatedTableName].primary[0]
-
- const lookupField = isMany ? primaryKey : field.foreignKey
- const fieldName = isMany ? field.throughTo || primaryKey : field.fieldName
if (!lookupField || !row[lookupField]) {
continue
}
@@ -609,9 +672,12 @@ export class ExternalRequest {
},
})
// this is the response from knex if no rows found
- const rows = !response[0].read ? response : []
- const storeTo = isMany ? field.throughFrom || linkPrimaryKey : fieldName
- related[storeTo] = { rows, isMany, tableId }
+ const rows: Row[] =
+ !Array.isArray(response) || response?.[0].read ? [] : response
+ const storeTo = isManyToMany(field)
+ ? field.throughFrom || linkPrimaryKey
+ : fieldName
+ related[storeTo] = { rows, isMany: isManyToMany(field), tableId }
}
return related
}
@@ -697,24 +763,43 @@ export class ExternalRequest {
continue
}
for (let row of rows) {
- const filters = buildFilters(generateIdForRow(row, table), {}, table)
- // safety check, if there are no filters on deletion bad things happen
- if (Object.keys(filters).length !== 0) {
- const op = isMany ? Operation.DELETE : Operation.UPDATE
- const body = isMany ? null : { [colName]: null }
- promises.push(
- getDatasourceAndQuery({
- endpoint: getEndpoint(tableId, op),
- body,
- filters,
- })
- )
+ const rowId = generateIdForRow(row, table)
+ const promise: Promise = isMany
+ ? removeManyToManyRelationships(rowId, table, colName)
+ : removeOneToManyRelationships(rowId, table)
+ if (promise) {
+ promises.push(promise)
}
}
}
await Promise.all(promises)
}
+ async removeRelationshipsToRow(table: Table, rowId: string) {
+ const row = await this.getRow(table, rowId)
+ const related = await this.lookupRelations(table._id!, row)
+ for (let column of Object.values(table.schema)) {
+ const relationshipColumn = column as RelationshipFieldMetadata
+ if (!isManyToOne(relationshipColumn)) {
+ continue
+ }
+ const { rows, isMany, tableId } = related[relationshipColumn.fieldName]
+ const table = this.getTable(tableId)!
+ await Promise.all(
+ rows.map(row => {
+ const rowId = generateIdForRow(row, table)
+ return isMany
+ ? removeManyToManyRelationships(
+ rowId,
+ table,
+ relationshipColumn.fieldName
+ )
+ : removeOneToManyRelationships(rowId, table)
+ })
+ )
+ }
+ }
+
/**
* This function is a bit crazy, but the exact purpose of it is to protect against the scenario in which
* you have column overlap in relationships, e.g. we join a few different tables and they all have the
@@ -804,7 +889,7 @@ export class ExternalRequest {
}
let json = {
endpoint: {
- datasourceId,
+ datasourceId: datasourceId!,
entityId: tableName,
operation,
},
@@ -826,17 +911,30 @@ export class ExternalRequest {
},
}
- // can't really use response right now
- const response = await getDatasourceAndQuery(json)
- // handle many to many relationships now if we know the ID (could be auto increment)
+ // remove any relationships that could block deletion
+ if (operation === Operation.DELETE && id) {
+ await this.removeRelationshipsToRow(table, generateRowIdField(id))
+ }
+
+ // aliasing can be disabled fully if desired
+ let response
+ if (env.SQL_ALIASING_DISABLE) {
+ response = await getDatasourceAndQuery(json)
+ } else {
+ const aliasing = new AliasTables(Object.keys(this.tables))
+ response = await aliasing.queryWithAliasing(json)
+ }
+
+ const responseRows = Array.isArray(response) ? response : []
+ // handle many-to-many relationships now if we know the ID (could be auto increment)
if (operation !== Operation.READ) {
await this.handleManyRelationships(
table._id || "",
- response[0],
+ responseRows[0],
processed.manyRelationships
)
}
- const output = this.outputProcessing(response, table, relationships)
+ const output = this.outputProcessing(responseRows, table, relationships)
// if reading it'll just be an array of rows, return whole thing
if (operation === Operation.READ) {
return (
diff --git a/packages/server/src/api/controllers/row/alias.ts b/packages/server/src/api/controllers/row/alias.ts
new file mode 100644
index 0000000000..1d586c54fd
--- /dev/null
+++ b/packages/server/src/api/controllers/row/alias.ts
@@ -0,0 +1,204 @@
+import {
+ Datasource,
+ DatasourcePlusQueryResponse,
+ Operation,
+ QueryJson,
+ Row,
+ SearchFilters,
+} from "@budibase/types"
+import { getSQLClient } from "../../../sdk/app/rows/utils"
+import { cloneDeep } from "lodash"
+import sdk from "../../../sdk"
+import { makeExternalQuery } from "../../../integrations/base/query"
+import { SqlClient } from "../../../integrations/utils"
+
+const WRITE_OPERATIONS: Operation[] = [
+ Operation.CREATE,
+ Operation.UPDATE,
+ Operation.DELETE,
+]
+const DISABLED_WRITE_CLIENTS: SqlClient[] = [
+ SqlClient.MY_SQL,
+ SqlClient.MS_SQL,
+ SqlClient.ORACLE,
+]
+
+class CharSequence {
+ static alphabet = "abcdefghijklmnopqrstuvwxyz"
+ counters: number[]
+
+ constructor() {
+ this.counters = [0]
+ }
+
+ getCharacter(): string {
+ const char = this.counters.map(i => CharSequence.alphabet[i]).join("")
+ for (let i = this.counters.length - 1; i >= 0; i--) {
+ if (this.counters[i] < CharSequence.alphabet.length - 1) {
+ this.counters[i]++
+ return char
+ }
+ this.counters[i] = 0
+ }
+ this.counters.unshift(0)
+ return char
+ }
+}
+
+export default class AliasTables {
+ aliases: Record
+ tableAliases: Record
+ tableNames: string[]
+ charSeq: CharSequence
+
+ constructor(tableNames: string[]) {
+ this.tableNames = tableNames
+ this.aliases = {}
+ this.tableAliases = {}
+ this.charSeq = new CharSequence()
+ }
+
+ isAliasingEnabled(json: QueryJson, datasource: Datasource) {
+ const fieldLength = json.resource?.fields?.length
+ if (!fieldLength || fieldLength <= 0) {
+ return false
+ }
+ try {
+ const sqlClient = getSQLClient(datasource)
+ const isWrite = WRITE_OPERATIONS.includes(json.endpoint.operation)
+ const isDisabledClient = DISABLED_WRITE_CLIENTS.includes(sqlClient)
+ if (isWrite && isDisabledClient) {
+ return false
+ }
+ } catch (err) {
+ // if we can't get an SQL client, we can't alias
+ return false
+ }
+ return true
+ }
+
+ getAlias(tableName: string) {
+ if (this.aliases[tableName]) {
+ return this.aliases[tableName]
+ }
+ const char = this.charSeq.getCharacter()
+ this.aliases[tableName] = char
+ this.tableAliases[char] = tableName
+ return char
+ }
+
+ aliasField(field: string) {
+ const tableNames = this.tableNames
+ if (field.includes(".")) {
+ const [tableName, column] = field.split(".")
+ const foundTableName = tableNames.find(name => {
+ const idx = tableName.indexOf(name)
+ if (idx === -1 || idx > 1) {
+ return
+ }
+ // this might look a bit mad, but the idea is if the field is wrapped, say in "", `` or []
+ // then the idx of the table name will be 1, and we should allow for it ending in a closing
+ // character - otherwise it should be the full length if the index is zero
+ const allowedCharacterDiff = idx * 2
+ return Math.abs(tableName.length - name.length) <= allowedCharacterDiff
+ })
+ if (foundTableName) {
+ const aliasedTableName = tableName.replace(
+ foundTableName,
+ this.getAlias(foundTableName)
+ )
+ field = `${aliasedTableName}.${column}`
+ }
+ }
+ return field
+ }
+
+ reverse(rows: T): T {
+ const process = (row: Row) => {
+ const final: Row = {}
+ for (let [key, value] of Object.entries(row)) {
+ if (!key.includes(".")) {
+ final[key] = value
+ } else {
+ const [alias, column] = key.split(".")
+ const tableName = this.tableAliases[alias] || alias
+ final[`${tableName}.${column}`] = value
+ }
+ }
+ return final
+ }
+ if (Array.isArray(rows)) {
+ return rows.map(row => process(row)) as T
+ } else {
+ return process(rows) as T
+ }
+ }
+
+ aliasMap(tableNames: (string | undefined)[]) {
+ const map: Record = {}
+ for (let tableName of tableNames) {
+ if (tableName) {
+ map[tableName] = this.getAlias(tableName)
+ }
+ }
+ return map
+ }
+
+ async queryWithAliasing(json: QueryJson): DatasourcePlusQueryResponse {
+ const datasourceId = json.endpoint.datasourceId
+ const datasource = await sdk.datasources.get(datasourceId)
+
+ const aliasingEnabled = this.isAliasingEnabled(json, datasource)
+ if (aliasingEnabled) {
+ json = cloneDeep(json)
+ // run through the query json to update anywhere a table may be used
+ if (json.resource?.fields) {
+ json.resource.fields = json.resource.fields.map(field =>
+ this.aliasField(field)
+ )
+ }
+ if (json.filters) {
+ for (let [filterKey, filter] of Object.entries(json.filters)) {
+ if (typeof filter !== "object") {
+ continue
+ }
+ const aliasedFilters: typeof filter = {}
+ for (let key of Object.keys(filter)) {
+ aliasedFilters[this.aliasField(key)] = filter[key]
+ }
+ json.filters[filterKey as keyof SearchFilters] = aliasedFilters
+ }
+ }
+ if (json.meta?.table) {
+ this.getAlias(json.meta.table.name)
+ }
+ if (json.meta?.tables) {
+ Object.keys(json.meta.tables).forEach(tableName =>
+ this.getAlias(tableName)
+ )
+ }
+ if (json.relationships) {
+ json.relationships = json.relationships.map(relationship => ({
+ ...relationship,
+ aliases: this.aliasMap([
+ relationship.through,
+ relationship.tableName,
+ json.endpoint.entityId,
+ ]),
+ }))
+ }
+ // invert and return
+ const invertedTableAliases: Record = {}
+ for (let [key, value] of Object.entries(this.tableAliases)) {
+ invertedTableAliases[value] = key
+ }
+ json.tableAliases = invertedTableAliases
+ }
+ const response = await makeExternalQuery(datasource, json)
+ if (Array.isArray(response) && aliasingEnabled) {
+ return this.reverse(response)
+ } else {
+ return response
+ }
+ }
+}
diff --git a/packages/server/src/api/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts
index ec56919d12..54c294c42b 100644
--- a/packages/server/src/api/controllers/row/index.ts
+++ b/packages/server/src/api/controllers/row/index.ts
@@ -211,7 +211,7 @@ export async function validate(ctx: Ctx) {
}
}
-export async function fetchEnrichedRow(ctx: any) {
+export async function fetchEnrichedRow(ctx: UserCtx) {
const tableId = utils.getTableId(ctx)
ctx.body = await pickApi(tableId).fetchEnrichedRow(ctx)
}
diff --git a/packages/server/src/api/controllers/row/internal.ts b/packages/server/src/api/controllers/row/internal.ts
index 3ee08fff2e..cc903bd74a 100644
--- a/packages/server/src/api/controllers/row/internal.ts
+++ b/packages/server/src/api/controllers/row/internal.ts
@@ -189,11 +189,12 @@ export async function fetchEnrichedRow(ctx: UserCtx) {
const tableId = utils.getTableId(ctx)
const rowId = ctx.params.rowId as string
// need table to work out where links go in row, as well as the link docs
- const [table, row, links] = await Promise.all([
+ const [table, links] = await Promise.all([
sdk.tables.getTable(tableId),
- utils.findRow(ctx, tableId, rowId),
linkRows.getLinkDocuments({ tableId, rowId, fieldName }),
])
+ let row = await utils.findRow(ctx, tableId, rowId)
+ row = await outputProcessing(table, row)
const linkVals = links as LinkDocumentValue[]
// look up the actual rows based on the ids
diff --git a/packages/server/src/api/controllers/screen.ts b/packages/server/src/api/controllers/screen.ts
index 446fe2e5fa..ee8e0ff892 100644
--- a/packages/server/src/api/controllers/screen.ts
+++ b/packages/server/src/api/controllers/screen.ts
@@ -7,7 +7,13 @@ import {
roles,
} from "@budibase/backend-core"
import { updateAppPackage } from "./application"
-import { Plugin, ScreenProps, BBContext, Screen } from "@budibase/types"
+import {
+ Plugin,
+ ScreenProps,
+ BBContext,
+ Screen,
+ UserCtx,
+} from "@budibase/types"
import { builderSocket } from "../../websockets"
export async function fetch(ctx: BBContext) {
@@ -31,7 +37,7 @@ export async function fetch(ctx: BBContext) {
)
}
-export async function save(ctx: BBContext) {
+export async function save(ctx: UserCtx) {
const db = context.getAppDB()
let screen = ctx.request.body
diff --git a/packages/server/src/api/controllers/static/index.ts b/packages/server/src/api/controllers/static/index.ts
index 5a3803e6d5..c718d5f704 100644
--- a/packages/server/src/api/controllers/static/index.ts
+++ b/packages/server/src/api/controllers/static/index.ts
@@ -170,6 +170,7 @@ export const serveApp = async function (ctx: Ctx) {
if (!env.isJest()) {
const plugins = objectStore.enrichPluginURLs(appInfo.usedPlugins)
const { head, html, css } = AppComponent.render({
+ title: branding?.platformTitle || `${appInfo.name}`,
metaImage:
branding?.metaImageUrl ||
"https://res.cloudinary.com/daog6scxm/image/upload/v1698759482/meta-images/plain-branded-meta-image-coral_ocxmgu.png",
diff --git a/packages/server/src/api/controllers/table/external.ts b/packages/server/src/api/controllers/table/external.ts
index f035822068..c85b46a95c 100644
--- a/packages/server/src/api/controllers/table/external.ts
+++ b/packages/server/src/api/controllers/table/external.ts
@@ -6,6 +6,7 @@ import {
BulkImportRequest,
BulkImportResponse,
Operation,
+ RenameColumn,
SaveTableRequest,
SaveTableResponse,
Table,
@@ -25,9 +26,12 @@ function getDatasourceId(table: Table) {
return breakExternalTableId(table._id).datasourceId
}
-export async function save(ctx: UserCtx) {
+export async function save(
+ ctx: UserCtx,
+ renaming?: RenameColumn
+) {
const inputs = ctx.request.body
- const renaming = inputs?._rename
+ const adding = inputs?._add
// can't do this right now
delete inputs.rows
const tableId = ctx.request.body._id
@@ -40,7 +44,7 @@ export async function save(ctx: UserCtx) {
const { datasource, table } = await sdk.tables.external.save(
datasourceId!,
inputs,
- { tableId, renaming }
+ { tableId, renaming, adding }
)
builderSocket?.emitDatasourceUpdate(ctx, datasource)
return table
diff --git a/packages/server/src/api/controllers/table/index.ts b/packages/server/src/api/controllers/table/index.ts
index 55a896373f..69305c461e 100644
--- a/packages/server/src/api/controllers/table/index.ts
+++ b/packages/server/src/api/controllers/table/index.ts
@@ -74,8 +74,15 @@ export async function save(ctx: UserCtx) {
const appId = ctx.appId
const table = ctx.request.body
const isImport = table.rows
+ const renaming = ctx.request.body._rename
- let savedTable = await pickApi({ table }).save(ctx)
+ const api = pickApi({ table })
+ // do not pass _rename or _add if saving to CouchDB
+ if (api === internal) {
+ delete ctx.request.body._add
+ delete ctx.request.body._rename
+ }
+ let savedTable = await api.save(ctx, renaming)
if (!table._id) {
await events.table.created(savedTable)
savedTable = sdk.tables.enrichViewSchemas(savedTable)
diff --git a/packages/server/src/api/controllers/table/internal.ts b/packages/server/src/api/controllers/table/internal.ts
index 8e90007d88..eb5e4b6c41 100644
--- a/packages/server/src/api/controllers/table/internal.ts
+++ b/packages/server/src/api/controllers/table/internal.ts
@@ -12,11 +12,12 @@ import {
} from "@budibase/types"
import sdk from "../../../sdk"
-export async function save(ctx: UserCtx) {
+export async function save(
+ ctx: UserCtx,
+ renaming?: RenameColumn
+) {
const { rows, ...rest } = ctx.request.body
- let tableToSave: Table & {
- _rename?: RenameColumn
- } = {
+ let tableToSave: Table = {
_id: generateTableID(),
...rest,
// Ensure these fields are populated, even if not sent in the request
@@ -28,15 +29,12 @@ export async function save(ctx: UserCtx) {
tableToSave.views = {}
}
- const renaming = tableToSave._rename
- delete tableToSave._rename
-
try {
const { table } = await sdk.tables.internal.save(tableToSave, {
user: ctx.user,
rowsToImport: rows,
tableId: ctx.request.body._id,
- renaming: renaming,
+ renaming,
})
return table
diff --git a/packages/server/src/api/controllers/user.ts b/packages/server/src/api/controllers/user.ts
index 108e29fd3d..7fc5c6e1bc 100644
--- a/packages/server/src/api/controllers/user.ts
+++ b/packages/server/src/api/controllers/user.ts
@@ -1,6 +1,6 @@
import { generateUserFlagID, InternalTables } from "../../db/utils"
import { getFullUser } from "../../utilities/users"
-import { context } from "@budibase/backend-core"
+import { cache, context } from "@budibase/backend-core"
import {
ContextUserMetadata,
Ctx,
diff --git a/packages/server/src/api/routes/tests/apikeys.spec.js b/packages/server/src/api/routes/tests/apikeys.spec.js
index eb6933af7d..678da38f28 100644
--- a/packages/server/src/api/routes/tests/apikeys.spec.js
+++ b/packages/server/src/api/routes/tests/apikeys.spec.js
@@ -13,7 +13,7 @@ describe("/api/keys", () => {
describe("fetch", () => {
it("should allow fetching", async () => {
- await setup.switchToSelfHosted(async () => {
+ await config.withEnv({ SELF_HOSTED: "true" }, async () => {
const res = await request
.get(`/api/keys`)
.set(config.defaultHeaders())
@@ -34,7 +34,7 @@ describe("/api/keys", () => {
describe("update", () => {
it("should allow updating a value", async () => {
- await setup.switchToSelfHosted(async () => {
+ await config.withEnv({ SELF_HOSTED: "true" }, async () => {
const res = await request
.put(`/api/keys/TEST`)
.send({
diff --git a/packages/server/src/api/routes/tests/application.spec.ts b/packages/server/src/api/routes/tests/application.spec.ts
index dbe4eb51ae..63c9fe44b8 100644
--- a/packages/server/src/api/routes/tests/application.spec.ts
+++ b/packages/server/src/api/routes/tests/application.spec.ts
@@ -16,8 +16,9 @@ import * as setup from "./utilities"
import { AppStatus } from "../../../db/utils"
import { events, utils, context } from "@budibase/backend-core"
import env from "../../../environment"
-import type { App } from "@budibase/types"
+import { type App } from "@budibase/types"
import tk from "timekeeper"
+import * as uuid from "uuid"
describe("/applications", () => {
let config = setup.getConfig()
@@ -184,7 +185,7 @@ describe("/applications", () => {
it("app should not sync if production", async () => {
const { message } = await config.api.application.sync(
app.appId.replace("_dev", ""),
- { statusCode: 400 }
+ { status: 400 }
)
expect(message).toEqual(
@@ -248,4 +249,93 @@ describe("/applications", () => {
expect(devLogs.data.length).toBe(0)
})
})
+
+ describe("permissions", () => {
+ it("should only return apps a user has access to", async () => {
+ let user = await config.createUser({
+ builder: { global: false },
+ admin: { global: false },
+ })
+
+ await config.withUser(user, async () => {
+ const apps = await config.api.application.fetch()
+ expect(apps).toHaveLength(0)
+ })
+
+ user = await config.globalUser({
+ ...user,
+ builder: {
+ apps: [config.getProdAppId()],
+ },
+ })
+
+ await config.withUser(user, async () => {
+ const apps = await config.api.application.fetch()
+ expect(apps).toHaveLength(1)
+ })
+ })
+
+ it("should only return apps a user has access to through a custom role", async () => {
+ let user = await config.createUser({
+ builder: { global: false },
+ admin: { global: false },
+ })
+
+ await config.withUser(user, async () => {
+ const apps = await config.api.application.fetch()
+ expect(apps).toHaveLength(0)
+ })
+
+ const role = await config.api.roles.save({
+ name: "Test",
+ inherits: "PUBLIC",
+ permissionId: "read_only",
+ version: "name",
+ })
+
+ user = await config.globalUser({
+ ...user,
+ roles: {
+ [config.getProdAppId()]: role.name,
+ },
+ })
+
+ await config.withUser(user, async () => {
+ const apps = await config.api.application.fetch()
+ expect(apps).toHaveLength(1)
+ })
+ })
+
+ it.only("should only return apps a user has access to through a custom role on a group", async () => {
+ let user = await config.createUser({
+ builder: { global: false },
+ admin: { global: false },
+ })
+
+ await config.withUser(user, async () => {
+ const apps = await config.api.application.fetch()
+ expect(apps).toHaveLength(0)
+ })
+
+ const roleName = uuid.v4().replace(/-/g, "")
+ const role = await config.api.roles.save({
+ name: roleName,
+ inherits: "PUBLIC",
+ permissionId: "read_only",
+ version: "name",
+ })
+
+ const group = await config.createGroup(role._id!)
+
+ user = await config.globalUser({
+ ...user,
+ userGroups: [group._id!],
+ })
+
+ await config.withUser(user, async () => {
+ const apps = await config.api.application.fetch()
+ expect(apps).toHaveLength(1)
+ })
+ })
+ })
})
diff --git a/packages/server/src/api/routes/tests/attachment.spec.ts b/packages/server/src/api/routes/tests/attachment.spec.ts
index e230b0688a..aa02ea898e 100644
--- a/packages/server/src/api/routes/tests/attachment.spec.ts
+++ b/packages/server/src/api/routes/tests/attachment.spec.ts
@@ -29,7 +29,7 @@ describe("/api/applications/:appId/sync", () => {
let resp = (await config.api.attachment.process(
"ohno.exe",
Buffer.from([0]),
- { expectStatus: 400 }
+ { status: 400 }
)) as unknown as APIError
expect(resp.message).toContain("invalid extension")
})
@@ -40,7 +40,7 @@ describe("/api/applications/:appId/sync", () => {
let resp = (await config.api.attachment.process(
"OHNO.EXE",
Buffer.from([0]),
- { expectStatus: 400 }
+ { status: 400 }
)) as unknown as APIError
expect(resp.message).toContain("invalid extension")
})
@@ -51,7 +51,7 @@ describe("/api/applications/:appId/sync", () => {
undefined as any,
undefined as any,
{
- expectStatus: 400,
+ status: 400,
}
)) as unknown as APIError
expect(resp.message).toContain("No file provided")
diff --git a/packages/server/src/api/routes/tests/automation.spec.ts b/packages/server/src/api/routes/tests/automation.spec.ts
index 178189555d..ee8fc7d544 100644
--- a/packages/server/src/api/routes/tests/automation.spec.ts
+++ b/packages/server/src/api/routes/tests/automation.spec.ts
@@ -394,7 +394,7 @@ describe("/automations", () => {
it("deletes a automation by its ID", async () => {
const automation = await config.createAutomation()
const res = await request
- .delete(`/api/automations/${automation.id}/${automation.rev}`)
+ .delete(`/api/automations/${automation._id}/${automation._rev}`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
@@ -408,7 +408,7 @@ describe("/automations", () => {
await checkBuilderEndpoint({
config,
method: "DELETE",
- url: `/api/automations/${automation.id}/${automation._rev}`,
+ url: `/api/automations/${automation._id}/${automation._rev}`,
})
})
})
diff --git a/packages/server/src/api/routes/tests/backup.spec.ts b/packages/server/src/api/routes/tests/backup.spec.ts
index acfac783db..c862106d58 100644
--- a/packages/server/src/api/routes/tests/backup.spec.ts
+++ b/packages/server/src/api/routes/tests/backup.spec.ts
@@ -19,11 +19,8 @@ describe("/backups", () => {
describe("/api/backups/export", () => {
it("should be able to export app", async () => {
- const { body, headers } = await config.api.backup.exportBasicBackup(
- config.getAppId()!
- )
+ const body = await config.api.backup.exportBasicBackup(config.getAppId()!)
expect(body instanceof Buffer).toBe(true)
- expect(headers["content-type"]).toEqual("application/gzip")
expect(events.app.exported).toBeCalledTimes(1)
})
@@ -38,15 +35,13 @@ describe("/backups", () => {
it("should infer the app name from the app", async () => {
tk.freeze(mocks.date.MOCK_DATE)
- const { headers } = await config.api.backup.exportBasicBackup(
- config.getAppId()!
- )
-
- expect(headers["content-disposition"]).toEqual(
- `attachment; filename="${
- config.getApp()!.name
- }-export-${mocks.date.MOCK_DATE.getTime()}.tar.gz"`
- )
+ await config.api.backup.exportBasicBackup(config.getAppId()!, {
+ headers: {
+ "content-disposition": `attachment; filename="${
+ config.getApp().name
+ }-export-${mocks.date.MOCK_DATE.getTime()}.tar.gz"`,
+ },
+ })
})
})
diff --git a/packages/server/src/api/routes/tests/datasource.spec.ts b/packages/server/src/api/routes/tests/datasource.spec.ts
index 41229b0a2a..032da71b80 100644
--- a/packages/server/src/api/routes/tests/datasource.spec.ts
+++ b/packages/server/src/api/routes/tests/datasource.spec.ts
@@ -86,7 +86,7 @@ describe("/datasources", () => {
})
// check variables in cache
let contents = await checkCacheForDynamicVariable(
- query._id,
+ query._id!,
"variable3"
)
expect(contents.rows.length).toEqual(1)
@@ -102,7 +102,7 @@ describe("/datasources", () => {
expect(res.body.errors).toBeUndefined()
// check variables no longer in cache
- contents = await checkCacheForDynamicVariable(query._id, "variable3")
+ contents = await checkCacheForDynamicVariable(query._id!, "variable3")
expect(contents).toBe(null)
})
})
diff --git a/packages/server/src/api/routes/tests/permissions.spec.ts b/packages/server/src/api/routes/tests/permissions.spec.ts
index 129bc00b44..1eabf6edbb 100644
--- a/packages/server/src/api/routes/tests/permissions.spec.ts
+++ b/packages/server/src/api/routes/tests/permissions.spec.ts
@@ -45,7 +45,7 @@ describe("/permission", () => {
table = (await config.createTable()) as typeof table
row = await config.createRow()
view = await config.api.viewV2.create({ tableId: table._id })
- perms = await config.api.permission.set({
+ perms = await config.api.permission.add({
roleId: STD_ROLE_ID,
resourceId: table._id,
level: PermissionLevel.READ,
@@ -88,13 +88,13 @@ describe("/permission", () => {
})
it("should get resource permissions with multiple roles", async () => {
- perms = await config.api.permission.set({
+ perms = await config.api.permission.add({
roleId: HIGHER_ROLE_ID,
resourceId: table._id,
level: PermissionLevel.WRITE,
})
const res = await config.api.permission.get(table._id)
- expect(res.body).toEqual({
+ expect(res).toEqual({
permissions: {
read: { permissionType: "EXPLICIT", role: STD_ROLE_ID },
write: { permissionType: "EXPLICIT", role: HIGHER_ROLE_ID },
@@ -117,16 +117,19 @@ describe("/permission", () => {
level: PermissionLevel.READ,
})
- const response = await config.api.permission.set(
+ await config.api.permission.add(
{
roleId: STD_ROLE_ID,
resourceId: table._id,
level: PermissionLevel.EXECUTE,
},
- { expectStatus: 403 }
- )
- expect(response.message).toEqual(
- "You are not allowed to 'read' the resource type 'datasource'"
+ {
+ status: 403,
+ body: {
+ message:
+ "You are not allowed to 'read' the resource type 'datasource'",
+ },
+ }
)
})
})
@@ -138,9 +141,9 @@ describe("/permission", () => {
resourceId: table._id,
level: PermissionLevel.READ,
})
- expect(res.body[0]._id).toEqual(STD_ROLE_ID)
+ expect(res[0]._id).toEqual(STD_ROLE_ID)
const permsRes = await config.api.permission.get(table._id)
- expect(permsRes.body[STD_ROLE_ID]).toBeUndefined()
+ expect(permsRes.permissions[STD_ROLE_ID]).toBeUndefined()
})
it("throw forbidden if the action is not allowed for the resource", async () => {
@@ -156,10 +159,13 @@ describe("/permission", () => {
resourceId: table._id,
level: PermissionLevel.EXECUTE,
},
- { expectStatus: 403 }
- )
- expect(response.body.message).toEqual(
- "You are not allowed to 'read' the resource type 'datasource'"
+ {
+ status: 403,
+ body: {
+ message:
+ "You are not allowed to 'read' the resource type 'datasource'",
+ },
+ }
)
})
})
@@ -181,10 +187,8 @@ describe("/permission", () => {
// replicate changes before checking permissions
await config.publish()
- const res = await config.api.viewV2.search(view.id, undefined, {
- usePublicUser: true,
- })
- expect(res.body.rows[0]._id).toEqual(row._id)
+ const res = await config.api.viewV2.publicSearch(view.id)
+ expect(res.rows[0]._id).toEqual(row._id)
})
it("should not be able to access the view data when the table is not public and there are no view permissions overrides", async () => {
@@ -196,14 +200,11 @@ describe("/permission", () => {
// replicate changes before checking permissions
await config.publish()
- await config.api.viewV2.search(view.id, undefined, {
- expectStatus: 403,
- usePublicUser: true,
- })
+ await config.api.viewV2.publicSearch(view.id, undefined, { status: 403 })
})
it("should ignore the view permissions if the flag is not on", async () => {
- await config.api.permission.set({
+ await config.api.permission.add({
roleId: STD_ROLE_ID,
resourceId: view.id,
level: PermissionLevel.READ,
@@ -216,15 +217,14 @@ describe("/permission", () => {
// replicate changes before checking permissions
await config.publish()
- await config.api.viewV2.search(view.id, undefined, {
- expectStatus: 403,
- usePublicUser: true,
+ await config.api.viewV2.publicSearch(view.id, undefined, {
+ status: 403,
})
})
it("should use the view permissions if the flag is on", async () => {
mocks.licenses.useViewPermissions()
- await config.api.permission.set({
+ await config.api.permission.add({
roleId: STD_ROLE_ID,
resourceId: view.id,
level: PermissionLevel.READ,
@@ -237,10 +237,8 @@ describe("/permission", () => {
// replicate changes before checking permissions
await config.publish()
- const res = await config.api.viewV2.search(view.id, undefined, {
- usePublicUser: true,
- })
- expect(res.body.rows[0]._id).toEqual(row._id)
+ const res = await config.api.viewV2.publicSearch(view.id)
+ expect(res.rows[0]._id).toEqual(row._id)
})
it("shouldn't allow writing from a public user", async () => {
@@ -277,7 +275,7 @@ describe("/permission", () => {
const res = await config.api.permission.get(legacyView.name)
- expect(res.body).toEqual({
+ expect(res).toEqual({
permissions: {
read: {
permissionType: "BASE",
diff --git a/packages/server/src/api/routes/tests/queries/query.seq.spec.ts b/packages/server/src/api/routes/tests/queries/query.seq.spec.ts
index 52d35fa782..4347ed9044 100644
--- a/packages/server/src/api/routes/tests/queries/query.seq.spec.ts
+++ b/packages/server/src/api/routes/tests/queries/query.seq.spec.ts
@@ -157,7 +157,7 @@ describe("/queries", () => {
})
it("should find a query in cloud", async () => {
- await setup.switchToSelfHosted(async () => {
+ await config.withEnv({ SELF_HOSTED: "true" }, async () => {
const query = await config.createQuery()
const res = await request
.get(`/api/queries/${query._id}`)
@@ -397,15 +397,16 @@ describe("/queries", () => {
})
it("should fail with invalid integration type", async () => {
- const response = await config.api.datasource.create(
- {
- ...basicDatasource().datasource,
- source: "INVALID_INTEGRATION" as SourceName,
+ const datasource: Datasource = {
+ ...basicDatasource().datasource,
+ source: "INVALID_INTEGRATION" as SourceName,
+ }
+ await config.api.datasource.create(datasource, {
+ status: 500,
+ body: {
+ message: "No datasource implementation found.",
},
- { expectStatus: 500, rawResponse: true }
- )
-
- expect(response.body.message).toBe("No datasource implementation found.")
+ })
})
})
@@ -467,7 +468,10 @@ describe("/queries", () => {
queryString: "test={{ variable3 }}",
})
// check its in cache
- const contents = await checkCacheForDynamicVariable(base._id, "variable3")
+ const contents = await checkCacheForDynamicVariable(
+ base._id!,
+ "variable3"
+ )
expect(contents.rows.length).toEqual(1)
const responseBody = await preview(datasource, {
path: "www.failonce.com",
@@ -490,7 +494,7 @@ describe("/queries", () => {
queryString: "test={{ variable3 }}",
})
// check its in cache
- let contents = await checkCacheForDynamicVariable(base._id, "variable3")
+ let contents = await checkCacheForDynamicVariable(base._id!, "variable3")
expect(contents.rows.length).toEqual(1)
// delete the query
@@ -500,7 +504,7 @@ describe("/queries", () => {
.expect(200)
// check variables no longer in cache
- contents = await checkCacheForDynamicVariable(base._id, "variable3")
+ contents = await checkCacheForDynamicVariable(base._id!, "variable3")
expect(contents).toBe(null)
})
})
diff --git a/packages/server/src/api/routes/tests/role.spec.js b/packages/server/src/api/routes/tests/role.spec.js
index a653b573b2..4575f9b213 100644
--- a/packages/server/src/api/routes/tests/role.spec.js
+++ b/packages/server/src/api/routes/tests/role.spec.js
@@ -93,7 +93,7 @@ describe("/roles", () => {
it("should be able to get the role with a permission added", async () => {
const table = await config.createTable()
- await config.api.permission.set({
+ await config.api.permission.add({
roleId: BUILTIN_ROLE_IDS.POWER,
resourceId: table._id,
level: PermissionLevel.READ,
diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts
index 239da36351..de411f5397 100644
--- a/packages/server/src/api/routes/tests/row.spec.ts
+++ b/packages/server/src/api/routes/tests/row.spec.ts
@@ -7,6 +7,7 @@ import { context, InternalTable, roles, tenancy } from "@budibase/backend-core"
import { quotas } from "@budibase/pro"
import {
AutoFieldSubType,
+ DeleteRow,
FieldSchema,
FieldType,
FieldTypeSubtypes,
@@ -106,11 +107,8 @@ describe.each([
mocks.licenses.useCloudFree()
})
- const loadRow = (id: string, tbl_Id: string, status = 200) =>
- config.api.row.get(tbl_Id, id, { expectStatus: status })
-
const getRowUsage = async () => {
- const { total } = await config.doInContext(null, () =>
+ const { total } = await config.doInContext(undefined, () =>
quotas.getCurrentUsageValues(QuotaUsageType.STATIC, StaticQuotaName.ROWS)
)
return total
@@ -235,7 +233,7 @@ describe.each([
const res = await config.api.row.get(tableId, existing._id!)
- expect(res.body).toEqual({
+ expect(res).toEqual({
...existing,
...defaultRowFields,
})
@@ -265,7 +263,7 @@ describe.each([
await config.createRow()
await config.api.row.get(tableId, "1234567", {
- expectStatus: 404,
+ status: 404,
})
})
@@ -395,7 +393,7 @@ describe.each([
const createdRow = await config.createRow(row)
const id = createdRow._id!
- const saved = (await loadRow(id, table._id!)).body
+ const saved = await config.api.row.get(table._id!, id)
expect(saved.stringUndefined).toBe(undefined)
expect(saved.stringNull).toBe(null)
@@ -476,8 +474,8 @@ describe.each([
)
const row = await config.api.row.get(table._id!, createRowResponse._id!)
- expect(row.body.Story).toBeUndefined()
- expect(row.body).toEqual({
+ expect(row.Story).toBeUndefined()
+ expect(row).toEqual({
...defaultRowFields,
OrderID: 1111,
Country: "Aussy",
@@ -524,10 +522,10 @@ describe.each([
expect(row.name).toEqual("Updated Name")
expect(row.description).toEqual(existing.description)
- const savedRow = await loadRow(row._id!, table._id!)
+ const savedRow = await config.api.row.get(table._id!, row._id!)
- expect(savedRow.body.description).toEqual(existing.description)
- expect(savedRow.body.name).toEqual("Updated Name")
+ expect(savedRow.description).toEqual(existing.description)
+ expect(savedRow.name).toEqual("Updated Name")
await assertRowUsage(rowUsage)
})
@@ -543,7 +541,7 @@ describe.each([
tableId: table._id!,
name: 1,
},
- { expectStatus: 400 }
+ { status: 400 }
)
await assertRowUsage(rowUsage)
@@ -582,8 +580,8 @@ describe.each([
})
let getResp = await config.api.row.get(table._id!, row._id!)
- expect(getResp.body.user1[0]._id).toEqual(user1._id)
- expect(getResp.body.user2[0]._id).toEqual(user2._id)
+ expect(getResp.user1[0]._id).toEqual(user1._id)
+ expect(getResp.user2[0]._id).toEqual(user2._id)
let patchResp = await config.api.row.patch(table._id!, {
_id: row._id!,
@@ -595,8 +593,8 @@ describe.each([
expect(patchResp.user2[0]._id).toEqual(user2._id)
getResp = await config.api.row.get(table._id!, row._id!)
- expect(getResp.body.user1[0]._id).toEqual(user2._id)
- expect(getResp.body.user2[0]._id).toEqual(user2._id)
+ expect(getResp.user1[0]._id).toEqual(user2._id)
+ expect(getResp.user2[0]._id).toEqual(user2._id)
})
it("should be able to update relationships when both columns are same name", async () => {
@@ -609,7 +607,7 @@ describe.each([
description: "test",
relationship: [row._id],
})
- row = (await config.api.row.get(table._id!, row._id!)).body
+ row = await config.api.row.get(table._id!, row._id!)
expect(row.relationship.length).toBe(1)
const resp = await config.api.row.patch(table._id!, {
_id: row._id!,
@@ -632,8 +630,10 @@ describe.each([
const createdRow = await config.createRow()
const rowUsage = await getRowUsage()
- const res = await config.api.row.delete(table._id!, [createdRow])
- expect(res.body[0]._id).toEqual(createdRow._id)
+ const res = await config.api.row.bulkDelete(table._id!, {
+ rows: [createdRow],
+ })
+ expect(res[0]._id).toEqual(createdRow._id)
await assertRowUsage(rowUsage - 1)
})
})
@@ -682,10 +682,12 @@ describe.each([
const row2 = await config.createRow()
const rowUsage = await getRowUsage()
- const res = await config.api.row.delete(table._id!, [row1, row2])
+ const res = await config.api.row.bulkDelete(table._id!, {
+ rows: [row1, row2],
+ })
- expect(res.body.length).toEqual(2)
- await loadRow(row1._id!, table._id!, 404)
+ expect(res.length).toEqual(2)
+ await config.api.row.get(table._id!, row1._id!, { status: 404 })
await assertRowUsage(rowUsage - 2)
})
@@ -697,14 +699,12 @@ describe.each([
])
const rowUsage = await getRowUsage()
- const res = await config.api.row.delete(table._id!, [
- row1,
- row2._id,
- { _id: row3._id },
- ])
+ const res = await config.api.row.bulkDelete(table._id!, {
+ rows: [row1, row2._id!, { _id: row3._id }],
+ })
- expect(res.body.length).toEqual(3)
- await loadRow(row1._id!, table._id!, 404)
+ expect(res.length).toEqual(3)
+ await config.api.row.get(table._id!, row1._id!, { status: 404 })
await assertRowUsage(rowUsage - 3)
})
@@ -712,34 +712,36 @@ describe.each([
const row1 = await config.createRow()
const rowUsage = await getRowUsage()
- const res = await config.api.row.delete(table._id!, row1)
+ const res = await config.api.row.delete(table._id!, row1 as DeleteRow)
- expect(res.body.id).toEqual(row1._id)
- await loadRow(row1._id!, table._id!, 404)
+ expect(res.id).toEqual(row1._id)
+ await config.api.row.get(table._id!, row1._id!, { status: 404 })
await assertRowUsage(rowUsage - 1)
})
it("Should ignore malformed/invalid delete requests", async () => {
const rowUsage = await getRowUsage()
- const res = await config.api.row.delete(
- table._id!,
- { not: "valid" },
- { expectStatus: 400 }
- )
- expect(res.body.message).toEqual("Invalid delete rows request")
-
- const res2 = await config.api.row.delete(
- table._id!,
- { rows: 123 },
- { expectStatus: 400 }
- )
- expect(res2.body.message).toEqual("Invalid delete rows request")
-
- const res3 = await config.api.row.delete(table._id!, "invalid", {
- expectStatus: 400,
+ await config.api.row.delete(table._id!, { not: "valid" } as any, {
+ status: 400,
+ body: {
+ message: "Invalid delete rows request",
+ },
+ })
+
+ await config.api.row.delete(table._id!, { rows: 123 } as any, {
+ status: 400,
+ body: {
+ message: "Invalid delete rows request",
+ },
+ })
+
+ await config.api.row.delete(table._id!, "invalid" as any, {
+ status: 400,
+ body: {
+ message: "Invalid delete rows request",
+ },
})
- expect(res3.body.message).toEqual("Invalid delete rows request")
await assertRowUsage(rowUsage)
})
@@ -757,16 +759,16 @@ describe.each([
const row = await config.createRow()
const rowUsage = await getRowUsage()
- const res = await config.api.legacyView.get(table._id!)
- expect(res.body.length).toEqual(1)
- expect(res.body[0]._id).toEqual(row._id)
+ const rows = await config.api.legacyView.get(table._id!)
+ expect(rows.length).toEqual(1)
+ expect(rows[0]._id).toEqual(row._id)
await assertRowUsage(rowUsage)
})
it("should throw an error if view doesn't exist", async () => {
const rowUsage = await getRowUsage()
- await config.api.legacyView.get("derp", { expectStatus: 404 })
+ await config.api.legacyView.get("derp", { status: 404 })
await assertRowUsage(rowUsage)
})
@@ -781,9 +783,9 @@ describe.each([
const row = await config.createRow()
const rowUsage = await getRowUsage()
- const res = await config.api.legacyView.get(view.name)
- expect(res.body.length).toEqual(1)
- expect(res.body[0]._id).toEqual(row._id)
+ const rows = await config.api.legacyView.get(view.name)
+ expect(rows.length).toEqual(1)
+ expect(rows[0]._id).toEqual(row._id)
await assertRowUsage(rowUsage)
})
@@ -841,8 +843,8 @@ describe.each([
linkedTable._id!,
secondRow._id!
)
- expect(resBasic.body.link.length).toBe(1)
- expect(resBasic.body.link[0]).toEqual({
+ expect(resBasic.link.length).toBe(1)
+ expect(resBasic.link[0]).toEqual({
_id: firstRow._id,
primaryDisplay: firstRow.name,
})
@@ -852,10 +854,10 @@ describe.each([
linkedTable._id!,
secondRow._id!
)
- expect(resEnriched.body.link.length).toBe(1)
- expect(resEnriched.body.link[0]._id).toBe(firstRow._id)
- expect(resEnriched.body.link[0].name).toBe("Test Contact")
- expect(resEnriched.body.link[0].description).toBe("original description")
+ expect(resEnriched.link.length).toBe(1)
+ expect(resEnriched.link[0]._id).toBe(firstRow._id)
+ expect(resEnriched.link[0].name).toBe("Test Contact")
+ expect(resEnriched.link[0].description).toBe("original description")
await assertRowUsage(rowUsage)
})
})
@@ -880,8 +882,7 @@ describe.each([
],
tableId: table._id,
})
- // the environment needs configured for this
- await setup.switchToSelfHosted(async () => {
+ await config.withEnv({ SELF_HOSTED: "true" }, async () => {
return context.doInAppContext(config.getAppId(), async () => {
const enriched = await outputProcessing(table, [row])
expect((enriched as Row[])[0].attachment[0].url).toBe(
@@ -903,7 +904,7 @@ describe.each([
const res = await config.api.row.exportRows(table._id!, {
rows: [existing._id!],
})
- const results = JSON.parse(res.text)
+ const results = JSON.parse(res)
expect(results.length).toEqual(1)
const row = results[0]
@@ -922,7 +923,7 @@ describe.each([
rows: [existing._id!],
columns: ["_id"],
})
- const results = JSON.parse(res.text)
+ const results = JSON.parse(res)
expect(results.length).toEqual(1)
const row = results[0]
@@ -1000,7 +1001,7 @@ describe.each([
})
const row = await config.api.row.get(table._id!, newRow._id!)
- expect(row.body).toEqual({
+ expect(row).toEqual({
name: data.name,
surname: data.surname,
address: data.address,
@@ -1010,9 +1011,9 @@ describe.each([
id: newRow.id,
...defaultRowFields,
})
- expect(row.body._viewId).toBeUndefined()
- expect(row.body.age).toBeUndefined()
- expect(row.body.jobTitle).toBeUndefined()
+ expect(row._viewId).toBeUndefined()
+ expect(row.age).toBeUndefined()
+ expect(row.jobTitle).toBeUndefined()
})
})
@@ -1042,7 +1043,7 @@ describe.each([
})
const row = await config.api.row.get(tableId, newRow._id!)
- expect(row.body).toEqual({
+ expect(row).toEqual({
...newRow,
name: newData.name,
address: newData.address,
@@ -1051,9 +1052,9 @@ describe.each([
id: newRow.id,
...defaultRowFields,
})
- expect(row.body._viewId).toBeUndefined()
- expect(row.body.age).toBeUndefined()
- expect(row.body.jobTitle).toBeUndefined()
+ expect(row._viewId).toBeUndefined()
+ expect(row.age).toBeUndefined()
+ expect(row.jobTitle).toBeUndefined()
})
})
@@ -1071,12 +1072,12 @@ describe.each([
const createdRow = await config.createRow()
const rowUsage = await getRowUsage()
- await config.api.row.delete(view.id, [createdRow])
+ await config.api.row.bulkDelete(view.id, { rows: [createdRow] })
await assertRowUsage(rowUsage - 1)
await config.api.row.get(tableId, createdRow._id!, {
- expectStatus: 404,
+ status: 404,
})
})
@@ -1097,17 +1098,17 @@ describe.each([
])
const rowUsage = await getRowUsage()
- await config.api.row.delete(view.id, [rows[0], rows[2]])
+ await config.api.row.bulkDelete(view.id, { rows: [rows[0], rows[2]] })
await assertRowUsage(rowUsage - 2)
await config.api.row.get(tableId, rows[0]._id!, {
- expectStatus: 404,
+ status: 404,
})
await config.api.row.get(tableId, rows[2]._id!, {
- expectStatus: 404,
+ status: 404,
})
- await config.api.row.get(tableId, rows[1]._id!, { expectStatus: 200 })
+ await config.api.row.get(tableId, rows[1]._id!, { status: 200 })
})
})
@@ -1154,8 +1155,8 @@ describe.each([
const createViewResponse = await config.createView()
const response = await config.api.viewV2.search(createViewResponse.id)
- expect(response.body.rows).toHaveLength(10)
- expect(response.body).toEqual({
+ expect(response.rows).toHaveLength(10)
+ expect(response).toEqual({
rows: expect.arrayContaining(
rows.map(r => ({
_viewId: createViewResponse.id,
@@ -1206,8 +1207,8 @@ describe.each([
const response = await config.api.viewV2.search(createViewResponse.id)
- expect(response.body.rows).toHaveLength(5)
- expect(response.body).toEqual({
+ expect(response.rows).toHaveLength(5)
+ expect(response).toEqual({
rows: expect.arrayContaining(
expectedRows.map(r => ({
_viewId: createViewResponse.id,
@@ -1328,8 +1329,8 @@ describe.each([
createViewResponse.id
)
- expect(response.body.rows).toHaveLength(4)
- expect(response.body.rows).toEqual(
+ expect(response.rows).toHaveLength(4)
+ expect(response.rows).toEqual(
expected.map(name => expect.objectContaining({ name }))
)
}
@@ -1357,8 +1358,8 @@ describe.each([
}
)
- expect(response.body.rows).toHaveLength(4)
- expect(response.body.rows).toEqual(
+ expect(response.rows).toHaveLength(4)
+ expect(response.rows).toEqual(
expected.map(name => expect.objectContaining({ name }))
)
}
@@ -1382,8 +1383,8 @@ describe.each([
})
const response = await config.api.viewV2.search(view.id)
- expect(response.body.rows).toHaveLength(10)
- expect(response.body.rows).toEqual(
+ expect(response.rows).toHaveLength(10)
+ expect(response.rows).toEqual(
expect.arrayContaining(
rows.map(r => ({
...(isInternal
@@ -1402,7 +1403,7 @@ describe.each([
const createViewResponse = await config.createView()
const response = await config.api.viewV2.search(createViewResponse.id)
- expect(response.body.rows).toHaveLength(0)
+ expect(response.rows).toHaveLength(0)
})
it("respects the limit parameter", async () => {
@@ -1417,7 +1418,7 @@ describe.each([
query: {},
})
- expect(response.body.rows).toHaveLength(limit)
+ expect(response.rows).toHaveLength(limit)
})
it("can handle pagination", async () => {
@@ -1426,7 +1427,7 @@ describe.each([
const createViewResponse = await config.createView()
const allRows = (await config.api.viewV2.search(createViewResponse.id))
- .body.rows
+ .rows
const firstPageResponse = await config.api.viewV2.search(
createViewResponse.id,
@@ -1436,7 +1437,7 @@ describe.each([
query: {},
}
)
- expect(firstPageResponse.body).toEqual({
+ expect(firstPageResponse).toEqual({
rows: expect.arrayContaining(allRows.slice(0, 4)),
totalRows: isInternal ? 10 : undefined,
hasNextPage: true,
@@ -1448,12 +1449,12 @@ describe.each([
{
paginate: true,
limit: 4,
- bookmark: firstPageResponse.body.bookmark,
+ bookmark: firstPageResponse.bookmark,
query: {},
}
)
- expect(secondPageResponse.body).toEqual({
+ expect(secondPageResponse).toEqual({
rows: expect.arrayContaining(allRows.slice(4, 8)),
totalRows: isInternal ? 10 : undefined,
hasNextPage: true,
@@ -1465,11 +1466,11 @@ describe.each([
{
paginate: true,
limit: 4,
- bookmark: secondPageResponse.body.bookmark,
+ bookmark: secondPageResponse.bookmark,
query: {},
}
)
- expect(lastPageResponse.body).toEqual({
+ expect(lastPageResponse).toEqual({
rows: expect.arrayContaining(allRows.slice(8)),
totalRows: isInternal ? 10 : undefined,
hasNextPage: false,
@@ -1489,7 +1490,7 @@ describe.each([
email: "joe@joe.com",
roles: {},
},
- { expectStatus: 400 }
+ { status: 400 }
)
expect(response.message).toBe("Cannot create new user entry.")
})
@@ -1516,58 +1517,52 @@ describe.each([
it("does not allow public users to fetch by default", async () => {
await config.publish()
- await config.api.viewV2.search(viewId, undefined, {
- expectStatus: 403,
- usePublicUser: true,
+ await config.api.viewV2.publicSearch(viewId, undefined, {
+ status: 403,
})
})
it("allow public users to fetch when permissions are explicit", async () => {
- await config.api.permission.set({
+ await config.api.permission.add({
roleId: roles.BUILTIN_ROLE_IDS.PUBLIC,
level: PermissionLevel.READ,
resourceId: viewId,
})
await config.publish()
- const response = await config.api.viewV2.search(viewId, undefined, {
- usePublicUser: true,
- })
+ const response = await config.api.viewV2.publicSearch(viewId)
- expect(response.body.rows).toHaveLength(10)
+ expect(response.rows).toHaveLength(10)
})
it("allow public users to fetch when permissions are inherited", async () => {
- await config.api.permission.set({
+ await config.api.permission.add({
roleId: roles.BUILTIN_ROLE_IDS.PUBLIC,
level: PermissionLevel.READ,
resourceId: tableId,
})
await config.publish()
- const response = await config.api.viewV2.search(viewId, undefined, {
- usePublicUser: true,
- })
+ const response = await config.api.viewV2.publicSearch(viewId)
- expect(response.body.rows).toHaveLength(10)
+ expect(response.rows).toHaveLength(10)
})
it("respects inherited permissions, not allowing not public views from public tables", async () => {
- await config.api.permission.set({
+ await config.api.permission.add({
roleId: roles.BUILTIN_ROLE_IDS.PUBLIC,
level: PermissionLevel.READ,
resourceId: tableId,
})
- await config.api.permission.set({
+ await config.api.permission.add({
roleId: roles.BUILTIN_ROLE_IDS.POWER,
level: PermissionLevel.READ,
resourceId: viewId,
})
await config.publish()
- await config.api.viewV2.search(viewId, undefined, {
- usePublicUser: true,
- expectStatus: 403,
+ await config.api.viewV2.publicSearch(viewId, undefined, {
+ status: 403,
})
})
})
@@ -1754,7 +1749,7 @@ describe.each([
}
const row = await config.api.row.save(tableId, rowData)
- const { body: retrieved } = await config.api.row.get(tableId, row._id!)
+ const retrieved = await config.api.row.get(tableId, row._id!)
expect(retrieved).toEqual({
name: rowData.name,
description: rowData.description,
@@ -1781,7 +1776,7 @@ describe.each([
}
const row = await config.api.row.save(tableId, rowData)
- const { body: retrieved } = await config.api.row.get(tableId, row._id!)
+ const retrieved = await config.api.row.get(tableId, row._id!)
expect(retrieved).toEqual({
name: rowData.name,
description: rowData.description,
diff --git a/packages/server/src/api/routes/tests/table.spec.ts b/packages/server/src/api/routes/tests/table.spec.ts
index ce119e56f0..77704a0408 100644
--- a/packages/server/src/api/routes/tests/table.spec.ts
+++ b/packages/server/src/api/routes/tests/table.spec.ts
@@ -26,6 +26,7 @@ import { TableToBuild } from "../../../tests/utilities/TestConfiguration"
tk.freeze(mocks.date.MOCK_DATE)
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()
@@ -285,6 +286,35 @@ describe("/tables", () => {
expect(res.body.schema.roleId).toBeDefined()
})
})
+
+ it("should add a new column for an internal DB table", async () => {
+ const saveTableRequest: SaveTableRequest = {
+ _add: {
+ name: "NEW_COLUMN",
+ },
+ ...basicTable(),
+ }
+
+ const response = await request
+ .post(`/api/tables`)
+ .send(saveTableRequest)
+ .set(config.defaultHeaders())
+ .expect("Content-Type", /json/)
+ .expect(200)
+
+ const expectedResponse = {
+ ...saveTableRequest,
+ _rev: expect.stringMatching(/^\d-.+/),
+ _id: expect.stringMatching(/^ta_.+/),
+ createdAt: expect.stringMatching(ISO_REGEX_PATTERN),
+ updatedAt: expect.stringMatching(ISO_REGEX_PATTERN),
+ views: {},
+ }
+ delete expectedResponse._add
+
+ expect(response.status).toBe(200)
+ expect(response.body).toEqual(expectedResponse)
+ })
})
describe("import", () => {
@@ -663,8 +693,7 @@ describe("/tables", () => {
expect(migratedTable.schema["user column"]).toBeDefined()
expect(migratedTable.schema["user relationship"]).not.toBeDefined()
- const resp = await config.api.row.get(table._id!, testRow._id!)
- const migratedRow = resp.body as Row
+ const migratedRow = await config.api.row.get(table._id!, testRow._id!)
expect(migratedRow["user column"]).toBeDefined()
expect(migratedRow["user relationship"]).not.toBeDefined()
@@ -716,15 +745,13 @@ describe("/tables", () => {
expect(migratedTable.schema["user column"]).toBeDefined()
expect(migratedTable.schema["user relationship"]).not.toBeDefined()
- const row1Migrated = (await config.api.row.get(table._id!, row1._id!))
- .body as Row
+ const row1Migrated = await config.api.row.get(table._id!, row1._id!)
expect(row1Migrated["user relationship"]).not.toBeDefined()
expect(row1Migrated["user column"].map((r: Row) => r._id)).toEqual(
expect.arrayContaining([users[0]._id, users[1]._id])
)
- const row2Migrated = (await config.api.row.get(table._id!, row2._id!))
- .body as Row
+ const row2Migrated = await config.api.row.get(table._id!, row2._id!)
expect(row2Migrated["user relationship"]).not.toBeDefined()
expect(row2Migrated["user column"].map((r: Row) => r._id)).toEqual(
expect.arrayContaining([users[1]._id, users[2]._id])
@@ -773,15 +800,13 @@ describe("/tables", () => {
expect(migratedTable.schema["user column"]).toBeDefined()
expect(migratedTable.schema["user relationship"]).not.toBeDefined()
- const row1Migrated = (await config.api.row.get(table._id!, row1._id!))
- .body as Row
+ const row1Migrated = await config.api.row.get(table._id!, row1._id!)
expect(row1Migrated["user relationship"]).not.toBeDefined()
expect(row1Migrated["user column"].map((r: Row) => r._id)).toEqual(
expect.arrayContaining([users[0]._id, users[1]._id])
)
- const row2Migrated = (await config.api.row.get(table._id!, row2._id!))
- .body as Row
+ const row2Migrated = await config.api.row.get(table._id!, row2._id!)
expect(row2Migrated["user relationship"]).not.toBeDefined()
expect(row2Migrated["user column"].map((r: Row) => r._id)).toEqual([
users[2]._id,
@@ -831,7 +856,7 @@ describe("/tables", () => {
subtype: FieldSubtype.USERS,
},
},
- { expectStatus: 400 }
+ { status: 400 }
)
})
@@ -846,7 +871,7 @@ describe("/tables", () => {
subtype: FieldSubtype.USERS,
},
},
- { expectStatus: 400 }
+ { status: 400 }
)
})
@@ -861,7 +886,7 @@ describe("/tables", () => {
subtype: FieldSubtype.USERS,
},
},
- { expectStatus: 400 }
+ { status: 400 }
)
})
@@ -880,7 +905,7 @@ describe("/tables", () => {
subtype: FieldSubtype.USERS,
},
},
- { expectStatus: 400 }
+ { status: 400 }
)
})
})
diff --git a/packages/server/src/api/routes/tests/user.spec.ts b/packages/server/src/api/routes/tests/user.spec.ts
index e6349099d7..ff8c0d54b3 100644
--- a/packages/server/src/api/routes/tests/user.spec.ts
+++ b/packages/server/src/api/routes/tests/user.spec.ts
@@ -27,15 +27,17 @@ describe("/users", () => {
describe("fetch", () => {
it("returns a list of users from an instance db", async () => {
- await config.createUser({ id: "uuidx" })
- await config.createUser({ id: "uuidy" })
+ const id1 = `us_${utils.newid()}`
+ const id2 = `us_${utils.newid()}`
+ await config.createUser({ _id: id1 })
+ await config.createUser({ _id: id2 })
const res = await config.api.user.fetch()
expect(res.length).toBe(3)
const ids = res.map(u => u._id)
- expect(ids).toContain(`ro_ta_users_us_uuidx`)
- expect(ids).toContain(`ro_ta_users_us_uuidy`)
+ expect(ids).toContain(`ro_ta_users_${id1}`)
+ expect(ids).toContain(`ro_ta_users_${id2}`)
})
it("should apply authorization to endpoint", async () => {
@@ -54,7 +56,7 @@ describe("/users", () => {
describe("update", () => {
it("should be able to update the user", async () => {
const user: UserMetadata = await config.createUser({
- id: `us_update${utils.newid()}`,
+ _id: `us_update${utils.newid()}`,
})
user.roleId = roles.BUILTIN_ROLE_IDS.BASIC
delete user._rev
@@ -88,7 +90,7 @@ describe("/users", () => {
})
await config.api.user.update(
{ ...user, roleId: roles.BUILTIN_ROLE_IDS.POWER },
- { expectStatus: 409 }
+ { status: 409 }
)
})
})
diff --git a/packages/server/src/api/routes/tests/utilities/TestFunctions.ts b/packages/server/src/api/routes/tests/utilities/TestFunctions.ts
index 53e90396aa..8a843551ac 100644
--- a/packages/server/src/api/routes/tests/utilities/TestFunctions.ts
+++ b/packages/server/src/api/routes/tests/utilities/TestFunctions.ts
@@ -4,6 +4,7 @@ import { AppStatus } from "../../../../db/utils"
import { roles, tenancy, context, db } from "@budibase/backend-core"
import env from "../../../../environment"
import Nano from "@budibase/nano"
+import TestConfiguration from "src/tests/utilities/TestConfiguration"
class Request {
appId: any
@@ -52,10 +53,10 @@ export const clearAllApps = async (
})
}
-export const clearAllAutomations = async (config: any) => {
+export const clearAllAutomations = async (config: TestConfiguration) => {
const automations = await config.getAllAutomations()
for (let auto of automations) {
- await context.doInAppContext(config.appId, async () => {
+ await context.doInAppContext(config.getAppId(), async () => {
await config.deleteAutomation(auto)
})
}
@@ -101,7 +102,12 @@ export const checkBuilderEndpoint = async ({
method,
url,
body,
-}: any) => {
+}: {
+ config: TestConfiguration
+ method: string
+ url: string
+ body?: any
+}) => {
const headers = await config.login({
userId: "us_fail",
builder: false,
diff --git a/packages/server/src/api/routes/tests/utilities/index.ts b/packages/server/src/api/routes/tests/utilities/index.ts
index 27c178fc38..dcb8ccd6c0 100644
--- a/packages/server/src/api/routes/tests/utilities/index.ts
+++ b/packages/server/src/api/routes/tests/utilities/index.ts
@@ -1,5 +1,4 @@
-import TestConfig from "../../../../tests/utilities/TestConfiguration"
-import env from "../../../../environment"
+import TestConfiguration from "../../../../tests/utilities/TestConfiguration"
import supertest from "supertest"
export * as structures from "../../../../tests/utilities/structures"
@@ -47,10 +46,10 @@ export function delay(ms: number) {
}
let request: supertest.SuperTest | undefined | null,
- config: TestConfig | null
+ config: TestConfiguration | null
export function beforeAll() {
- config = new TestConfig()
+ config = new TestConfiguration()
request = config.getRequest()
}
@@ -77,21 +76,3 @@ export function getConfig() {
}
return config!
}
-
-export async function switchToSelfHosted(func: any) {
- // self hosted stops any attempts to Dynamo
- env._set("NODE_ENV", "production")
- env._set("SELF_HOSTED", true)
- let error
- try {
- await func()
- } catch (err) {
- error = err
- }
- env._set("NODE_ENV", "jest")
- env._set("SELF_HOSTED", false)
- // don't throw error until after reset
- if (error) {
- throw error
- }
-}
diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts
index b03a73ddda..5198e63338 100644
--- a/packages/server/src/api/routes/tests/viewV2.spec.ts
+++ b/packages/server/src/api/routes/tests/viewV2.spec.ts
@@ -177,7 +177,7 @@ describe.each([
}
await config.api.viewV2.create(newView, {
- expectStatus: 201,
+ status: 201,
})
})
})
@@ -275,7 +275,7 @@ describe.each([
const tableId = table._id!
await config.api.viewV2.update(
{ ...view, id: generator.guid() },
- { expectStatus: 404 }
+ { status: 404 }
)
expect(await config.api.table.get(tableId)).toEqual(
@@ -304,7 +304,7 @@ describe.each([
},
],
},
- { expectStatus: 404 }
+ { status: 404 }
)
expect(await config.api.table.get(tableId)).toEqual(
@@ -326,12 +326,10 @@ describe.each([
...viewV1,
},
{
- expectStatus: 400,
- handleResponse: r => {
- expect(r.body).toEqual({
- message: "Only views V2 can be updated",
- status: 400,
- })
+ status: 400,
+ body: {
+ message: "Only views V2 can be updated",
+ status: 400,
},
}
)
@@ -403,7 +401,7 @@ describe.each([
} as Record,
},
{
- expectStatus: 200,
+ status: 200,
}
)
})
diff --git a/packages/server/src/api/routes/tests/webhook.spec.ts b/packages/server/src/api/routes/tests/webhook.spec.ts
index 38f84852b4..48a6da38bf 100644
--- a/packages/server/src/api/routes/tests/webhook.spec.ts
+++ b/packages/server/src/api/routes/tests/webhook.spec.ts
@@ -36,7 +36,7 @@ describe("/webhooks", () => {
const automation = await config.createAutomation()
const res = await request
.put(`/api/webhooks`)
- .send(basicWebhook(automation._id))
+ .send(basicWebhook(automation._id!))
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
@@ -145,7 +145,7 @@ describe("/webhooks", () => {
let automation = collectAutomation()
let newAutomation = await config.createAutomation(automation)
let syncWebhook = await config.createWebhook(
- basicWebhook(newAutomation._id)
+ basicWebhook(newAutomation._id!)
)
// replicate changes before checking webhook
diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts
index 4e84422dec..aa96a30b00 100644
--- a/packages/server/src/app.ts
+++ b/packages/server/src/app.ts
@@ -29,6 +29,6 @@ start().catch(err => {
throw err
})
-export function getServer() {
+export function getServer(): Server {
return server
}
diff --git a/packages/server/src/appMigrations/tests/migrations.spec.ts b/packages/server/src/appMigrations/tests/migrations.spec.ts
index 5eb8535695..7af2346934 100644
--- a/packages/server/src/appMigrations/tests/migrations.spec.ts
+++ b/packages/server/src/appMigrations/tests/migrations.spec.ts
@@ -30,9 +30,9 @@ describe("migrations", () => {
const appId = config.getAppId()
- const response = await config.api.application.getRaw(appId)
-
- expect(response.headers[Header.MIGRATING_APP]).toBeUndefined()
+ await config.api.application.get(appId, {
+ headersNotPresent: [Header.MIGRATING_APP],
+ })
})
it("accessing an app that has pending migrations will attach the migrating header", async () => {
@@ -46,8 +46,10 @@ describe("migrations", () => {
func: async () => {},
})
- const response = await config.api.application.getRaw(appId)
-
- expect(response.headers[Header.MIGRATING_APP]).toEqual(appId)
+ await config.api.application.get(appId, {
+ headers: {
+ [Header.MIGRATING_APP]: appId,
+ },
+ })
})
})
diff --git a/packages/server/src/automations/tests/createRow.spec.ts b/packages/server/src/automations/tests/createRow.spec.ts
index 0615fcdd97..0098be39a5 100644
--- a/packages/server/src/automations/tests/createRow.spec.ts
+++ b/packages/server/src/automations/tests/createRow.spec.ts
@@ -24,7 +24,7 @@ describe("test the create row action", () => {
expect(res.id).toBeDefined()
expect(res.revision).toBeDefined()
expect(res.success).toEqual(true)
- const gottenRow = await config.getRow(table._id, res.id)
+ const gottenRow = await config.api.row.get(table._id, res.id)
expect(gottenRow.name).toEqual("test")
expect(gottenRow.description).toEqual("test")
})
diff --git a/packages/server/src/automations/tests/updateRow.spec.ts b/packages/server/src/automations/tests/updateRow.spec.ts
index b64c52147d..76823e8a11 100644
--- a/packages/server/src/automations/tests/updateRow.spec.ts
+++ b/packages/server/src/automations/tests/updateRow.spec.ts
@@ -36,7 +36,7 @@ describe("test the update row action", () => {
it("should be able to run the action", async () => {
const res = await setup.runStep(setup.actions.UPDATE_ROW.stepId, inputs)
expect(res.success).toEqual(true)
- const updatedRow = await config.getRow(table._id!, res.id)
+ const updatedRow = await config.api.row.get(table._id!, res.id)
expect(updatedRow.name).toEqual("Updated name")
expect(updatedRow.description).not.toEqual("")
})
@@ -87,8 +87,8 @@ describe("test the update row action", () => {
})
let getResp = await config.api.row.get(table._id!, row._id!)
- expect(getResp.body.user1[0]._id).toEqual(user1._id)
- expect(getResp.body.user2[0]._id).toEqual(user2._id)
+ expect(getResp.user1[0]._id).toEqual(user1._id)
+ expect(getResp.user2[0]._id).toEqual(user2._id)
let stepResp = await setup.runStep(setup.actions.UPDATE_ROW.stepId, {
rowId: row._id,
@@ -103,8 +103,8 @@ describe("test the update row action", () => {
expect(stepResp.success).toEqual(true)
getResp = await config.api.row.get(table._id!, row._id!)
- expect(getResp.body.user1[0]._id).toEqual(user2._id)
- expect(getResp.body.user2[0]._id).toEqual(user2._id)
+ expect(getResp.user1[0]._id).toEqual(user2._id)
+ expect(getResp.user2[0]._id).toEqual(user2._id)
})
it("should overwrite links if those links are not set and we ask it do", async () => {
@@ -140,8 +140,8 @@ describe("test the update row action", () => {
})
let getResp = await config.api.row.get(table._id!, row._id!)
- expect(getResp.body.user1[0]._id).toEqual(user1._id)
- expect(getResp.body.user2[0]._id).toEqual(user2._id)
+ expect(getResp.user1[0]._id).toEqual(user1._id)
+ expect(getResp.user2[0]._id).toEqual(user2._id)
let stepResp = await setup.runStep(setup.actions.UPDATE_ROW.stepId, {
rowId: row._id,
@@ -163,7 +163,7 @@ describe("test the update row action", () => {
expect(stepResp.success).toEqual(true)
getResp = await config.api.row.get(table._id!, row._id!)
- expect(getResp.body.user1[0]._id).toEqual(user2._id)
- expect(getResp.body.user2).toBeUndefined()
+ expect(getResp.user1[0]._id).toEqual(user2._id)
+ expect(getResp.user2).toBeUndefined()
})
})
diff --git a/packages/server/src/constants/layouts.ts b/packages/server/src/constants/layouts.ts
index 835a5d2e15..f4eb337c2d 100644
--- a/packages/server/src/constants/layouts.ts
+++ b/packages/server/src/constants/layouts.ts
@@ -1,9 +1,11 @@
+import { Layout } from "@budibase/types"
+
export const BASE_LAYOUT_PROP_IDS = {
PRIVATE: "layout_private_master",
PUBLIC: "layout_public_master",
}
-export const EMPTY_LAYOUT = {
+export const EMPTY_LAYOUT: Layout = {
componentLibraries: ["@budibase/standard-components"],
title: "{{ name }}",
favicon: "./_shared/favicon.png",
diff --git a/packages/server/src/constants/screens.ts b/packages/server/src/constants/screens.ts
index 6c88b0f957..1107289ea0 100644
--- a/packages/server/src/constants/screens.ts
+++ b/packages/server/src/constants/screens.ts
@@ -1,5 +1,6 @@
import { roles } from "@budibase/backend-core"
import { BASE_LAYOUT_PROP_IDS } from "./layouts"
+import { Screen } from "@budibase/types"
export function createHomeScreen(
config: {
@@ -9,10 +10,8 @@ export function createHomeScreen(
roleId: roles.BUILTIN_ROLE_IDS.BASIC,
route: "/",
}
-) {
+): Screen {
return {
- description: "",
- url: "",
layoutId: BASE_LAYOUT_PROP_IDS.PRIVATE,
props: {
_id: "d834fea2-1b3e-4320-ab34-f9009f5ecc59",
diff --git a/packages/server/src/db/defaultData/datasource_bb_default.ts b/packages/server/src/db/defaultData/datasource_bb_default.ts
index ac540cd8fc..03aed3c118 100644
--- a/packages/server/src/db/defaultData/datasource_bb_default.ts
+++ b/packages/server/src/db/defaultData/datasource_bb_default.ts
@@ -1,8 +1,8 @@
import {
DEFAULT_BB_DATASOURCE_ID,
- DEFAULT_INVENTORY_TABLE_ID,
DEFAULT_EMPLOYEE_TABLE_ID,
DEFAULT_EXPENSES_TABLE_ID,
+ DEFAULT_INVENTORY_TABLE_ID,
DEFAULT_JOBS_TABLE_ID,
} from "../../constants"
import { importToRows } from "../../api/controllers/table/utils"
@@ -15,19 +15,21 @@ import { expensesImport } from "./expensesImport"
import { db as dbCore } from "@budibase/backend-core"
import {
AutoFieldSubType,
+ Datasource,
FieldType,
RelationshipType,
Row,
+ SourceName,
Table,
TableSchema,
TableSourceType,
} from "@budibase/types"
-const defaultDatasource = {
+const defaultDatasource: Datasource = {
_id: DEFAULT_BB_DATASOURCE_ID,
type: dbCore.BUDIBASE_DATASOURCE_TYPE,
name: "Sample Data",
- source: "BUDIBASE",
+ source: SourceName.BUDIBASE,
config: {},
}
diff --git a/packages/server/src/db/tests/linkController.spec.ts b/packages/server/src/db/tests/linkController.spec.ts
index ae1922db27..4f41fd3838 100644
--- a/packages/server/src/db/tests/linkController.spec.ts
+++ b/packages/server/src/db/tests/linkController.spec.ts
@@ -100,7 +100,7 @@ describe("test the link controller", () => {
const { _id } = await config.createRow(
basicLinkedRow(t1._id!, row._id!, linkField)
)
- return config.getRow(t1._id!, _id!)
+ return config.api.row.get(t1._id!, _id!)
}
it("should be able to confirm if two table schemas are equal", async () => {
diff --git a/packages/server/src/db/utils.ts b/packages/server/src/db/utils.ts
index 35d9b69e96..983cbf423c 100644
--- a/packages/server/src/db/utils.ts
+++ b/packages/server/src/db/utils.ts
@@ -1,13 +1,15 @@
import newid from "./newid"
import { db as dbCore } from "@budibase/backend-core"
import {
- FieldType,
+ DatabaseQueryOpts,
+ Datasource,
DocumentType,
FieldSchema,
- RelationshipFieldMetadata,
- VirtualDocumentType,
+ FieldType,
INTERNAL_TABLE_SOURCE_ID,
- DatabaseQueryOpts,
+ RelationshipFieldMetadata,
+ SourceName,
+ VirtualDocumentType,
} from "@budibase/types"
export { DocumentType, VirtualDocumentType } from "@budibase/types"
@@ -20,11 +22,11 @@ export const enum AppStatus {
DEPLOYED = "published",
}
-export const BudibaseInternalDB = {
+export const BudibaseInternalDB: Datasource = {
_id: INTERNAL_TABLE_SOURCE_ID,
type: dbCore.BUDIBASE_DATASOURCE_TYPE,
name: "Budibase DB",
- source: "BUDIBASE",
+ source: SourceName.BUDIBASE,
config: {},
}
diff --git a/packages/server/src/environment.ts b/packages/server/src/environment.ts
index d0b7e91401..a7c6df29ea 100644
--- a/packages/server/src/environment.ts
+++ b/packages/server/src/environment.ts
@@ -76,13 +76,16 @@ const environment = {
DEFAULTS.AUTOMATION_THREAD_TIMEOUT > QUERY_THREAD_TIMEOUT
? DEFAULTS.AUTOMATION_THREAD_TIMEOUT
: QUERY_THREAD_TIMEOUT,
- SQL_MAX_ROWS: process.env.SQL_MAX_ROWS,
BB_ADMIN_USER_EMAIL: process.env.BB_ADMIN_USER_EMAIL,
BB_ADMIN_USER_PASSWORD: process.env.BB_ADMIN_USER_PASSWORD,
PLUGINS_DIR: process.env.PLUGINS_DIR || DEFAULTS.PLUGINS_DIR,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
MAX_IMPORT_SIZE_MB: process.env.MAX_IMPORT_SIZE_MB,
SESSION_EXPIRY_SECONDS: process.env.SESSION_EXPIRY_SECONDS,
+ // SQL
+ SQL_MAX_ROWS: process.env.SQL_MAX_ROWS,
+ SQL_LOGGING_ENABLE: process.env.SQL_LOGGING_ENABLE,
+ SQL_ALIASING_DISABLE: process.env.SQL_ALIASING_DISABLE,
// flags
ALLOW_DEV_AUTOMATIONS: process.env.ALLOW_DEV_AUTOMATIONS,
DISABLE_THREADING: process.env.DISABLE_THREADING,
diff --git a/packages/server/src/integration-test/mysql.spec.ts b/packages/server/src/integration-test/mysql.spec.ts
new file mode 100644
index 0000000000..fac2bfcfeb
--- /dev/null
+++ b/packages/server/src/integration-test/mysql.spec.ts
@@ -0,0 +1,363 @@
+import fetch from "node-fetch"
+import {
+ generateMakeRequest,
+ MakeRequestResponse,
+} from "../api/routes/public/tests/utils"
+import { v4 as uuidv4 } from "uuid"
+import * as setup from "../api/routes/tests/utilities"
+import {
+ Datasource,
+ FieldType,
+ Table,
+ TableRequest,
+ TableSourceType,
+} from "@budibase/types"
+import _ from "lodash"
+import { databaseTestProviders } from "../integrations/tests/utils"
+import mysql from "mysql2/promise"
+import { builderSocket } from "../websockets"
+// @ts-ignore
+fetch.mockSearch()
+
+const config = setup.getConfig()!
+
+jest.unmock("mysql2/promise")
+jest.mock("../websockets", () => ({
+ clientAppSocket: jest.fn(),
+ gridAppSocket: jest.fn(),
+ initialise: jest.fn(),
+ builderSocket: {
+ emitTableUpdate: jest.fn(),
+ emitTableDeletion: jest.fn(),
+ emitDatasourceUpdate: jest.fn(),
+ emitDatasourceDeletion: jest.fn(),
+ emitScreenUpdate: jest.fn(),
+ emitAppMetadataUpdate: jest.fn(),
+ emitAppPublish: jest.fn(),
+ },
+}))
+
+describe("mysql integrations", () => {
+ let makeRequest: MakeRequestResponse,
+ mysqlDatasource: Datasource,
+ primaryMySqlTable: Table
+
+ beforeAll(async () => {
+ await config.init()
+ const apiKey = await config.generateApiKey()
+
+ makeRequest = generateMakeRequest(apiKey, true)
+
+ mysqlDatasource = await config.api.datasource.create(
+ await databaseTestProviders.mysql.datasource()
+ )
+ })
+
+ afterAll(async () => {
+ await databaseTestProviders.mysql.stop()
+ })
+
+ beforeEach(async () => {
+ primaryMySqlTable = await config.createTable({
+ name: uuidv4(),
+ type: "table",
+ primary: ["id"],
+ schema: {
+ id: {
+ name: "id",
+ type: FieldType.AUTO,
+ autocolumn: true,
+ },
+ name: {
+ name: "name",
+ type: FieldType.STRING,
+ },
+ description: {
+ name: "description",
+ type: FieldType.STRING,
+ },
+ value: {
+ name: "value",
+ type: FieldType.NUMBER,
+ },
+ },
+ sourceId: mysqlDatasource._id,
+ sourceType: TableSourceType.EXTERNAL,
+ })
+ })
+
+ afterAll(config.end)
+
+ it("validate table schema", async () => {
+ const res = await makeRequest(
+ "get",
+ `/api/datasources/${mysqlDatasource._id}`
+ )
+
+ expect(res.status).toBe(200)
+ expect(res.body).toEqual({
+ config: {
+ database: "mysql",
+ host: mysqlDatasource.config!.host,
+ password: "--secret-value--",
+ port: mysqlDatasource.config!.port,
+ user: "root",
+ },
+ plus: true,
+ source: "MYSQL",
+ type: "datasource_plus",
+ _id: expect.any(String),
+ _rev: expect.any(String),
+ createdAt: expect.any(String),
+ updatedAt: expect.any(String),
+ entities: expect.any(Object),
+ })
+ })
+
+ describe("POST /api/datasources/verify", () => {
+ it("should be able to verify the connection", async () => {
+ await config.api.datasource.verify(
+ {
+ datasource: await databaseTestProviders.mysql.datasource(),
+ },
+ {
+ body: {
+ connected: true,
+ },
+ }
+ )
+ })
+
+ it("should state an invalid datasource cannot connect", async () => {
+ const dbConfig = await databaseTestProviders.mysql.datasource()
+ await config.api.datasource.verify(
+ {
+ datasource: {
+ ...dbConfig,
+ config: {
+ ...dbConfig.config,
+ password: "wrongpassword",
+ },
+ },
+ },
+ {
+ body: {
+ connected: false,
+ error:
+ "Access denied for the specified user. User does not have the necessary privileges or the provided credentials are incorrect. Please verify the credentials, and ensure that the user has appropriate permissions.",
+ },
+ }
+ )
+ })
+ })
+
+ describe("POST /api/datasources/info", () => {
+ it("should fetch information about mysql datasource", async () => {
+ const primaryName = primaryMySqlTable.name
+ const response = await makeRequest("post", "/api/datasources/info", {
+ datasource: mysqlDatasource,
+ })
+ expect(response.status).toBe(200)
+ expect(response.body.tableNames).toBeDefined()
+ expect(response.body.tableNames.indexOf(primaryName)).not.toBe(-1)
+ })
+ })
+
+ describe("Integration compatibility with mysql search_path", () => {
+ let client: mysql.Connection, pathDatasource: Datasource
+ const database = "test1"
+ const database2 = "test-2"
+
+ beforeAll(async () => {
+ const dsConfig = await databaseTestProviders.mysql.datasource()
+ const dbConfig = dsConfig.config!
+
+ client = await mysql.createConnection(dbConfig)
+ await client.query(`CREATE DATABASE \`${database}\`;`)
+ await client.query(`CREATE DATABASE \`${database2}\`;`)
+
+ const pathConfig: any = {
+ ...dsConfig,
+ config: {
+ ...dbConfig,
+ database,
+ },
+ }
+ pathDatasource = await config.api.datasource.create(pathConfig)
+ })
+
+ afterAll(async () => {
+ await client.query(`DROP DATABASE \`${database}\`;`)
+ await client.query(`DROP DATABASE \`${database2}\`;`)
+ await client.end()
+ })
+
+ it("discovers tables from any schema in search path", async () => {
+ await client.query(
+ `CREATE TABLE \`${database}\`.table1 (id1 SERIAL PRIMARY KEY);`
+ )
+ const response = await makeRequest("post", "/api/datasources/info", {
+ datasource: pathDatasource,
+ })
+ expect(response.status).toBe(200)
+ expect(response.body.tableNames).toBeDefined()
+ expect(response.body.tableNames).toEqual(
+ expect.arrayContaining(["table1"])
+ )
+ })
+
+ it("does not mix columns from different tables", async () => {
+ const repeated_table_name = "table_same_name"
+ await client.query(
+ `CREATE TABLE \`${database}\`.${repeated_table_name} (id SERIAL PRIMARY KEY, val1 TEXT);`
+ )
+ await client.query(
+ `CREATE TABLE \`${database2}\`.${repeated_table_name} (id2 SERIAL PRIMARY KEY, val2 TEXT);`
+ )
+ const response = await makeRequest(
+ "post",
+ `/api/datasources/${pathDatasource._id}/schema`,
+ {
+ tablesFilter: [repeated_table_name],
+ }
+ )
+ expect(response.status).toBe(200)
+ expect(
+ response.body.datasource.entities[repeated_table_name].schema
+ ).toBeDefined()
+ const schema =
+ response.body.datasource.entities[repeated_table_name].schema
+ expect(Object.keys(schema).sort()).toEqual(["id", "val1"])
+ })
+ })
+
+ describe("POST /api/tables/", () => {
+ let client: mysql.Connection
+ const emitDatasourceUpdateMock = jest.fn()
+
+ beforeEach(async () => {
+ client = await mysql.createConnection(
+ (
+ await databaseTestProviders.mysql.datasource()
+ ).config!
+ )
+ mysqlDatasource = await config.api.datasource.create(
+ await databaseTestProviders.mysql.datasource()
+ )
+ })
+
+ afterEach(async () => {
+ await client.end()
+ })
+
+ it("will emit the datasource entity schema with externalType to the front-end when adding a new column", async () => {
+ const addColumnToTable: TableRequest = {
+ type: "table",
+ sourceType: TableSourceType.EXTERNAL,
+ name: "table",
+ sourceId: mysqlDatasource._id!,
+ primary: ["id"],
+ schema: {
+ id: {
+ type: FieldType.AUTO,
+ name: "id",
+ autocolumn: true,
+ },
+ new_column: {
+ type: FieldType.NUMBER,
+ name: "new_column",
+ },
+ },
+ _add: {
+ name: "new_column",
+ },
+ }
+
+ jest
+ .spyOn(builderSocket!, "emitDatasourceUpdate")
+ .mockImplementation(emitDatasourceUpdateMock)
+
+ await makeRequest("post", "/api/tables/", addColumnToTable)
+
+ const expectedTable: TableRequest = {
+ ...addColumnToTable,
+ schema: {
+ id: {
+ type: FieldType.NUMBER,
+ name: "id",
+ autocolumn: true,
+ constraints: {
+ presence: false,
+ },
+ externalType: "int unsigned",
+ },
+ new_column: {
+ type: FieldType.NUMBER,
+ name: "new_column",
+ autocolumn: false,
+ constraints: {
+ presence: false,
+ },
+ externalType: "float(8,2)",
+ },
+ },
+ created: true,
+ _id: `${mysqlDatasource._id}__table`,
+ }
+ delete expectedTable._add
+
+ expect(emitDatasourceUpdateMock).toBeCalledTimes(1)
+ const emittedDatasource: Datasource =
+ emitDatasourceUpdateMock.mock.calls[0][1]
+ expect(emittedDatasource.entities!["table"]).toEqual(expectedTable)
+ })
+
+ it("will rename a column", async () => {
+ await makeRequest("post", "/api/tables/", primaryMySqlTable)
+
+ let renameColumnOnTable: TableRequest = {
+ ...primaryMySqlTable,
+ schema: {
+ id: {
+ name: "id",
+ type: FieldType.AUTO,
+ autocolumn: true,
+ externalType: "unsigned integer",
+ },
+ name: {
+ name: "name",
+ type: FieldType.STRING,
+ externalType: "text",
+ },
+ description: {
+ name: "description",
+ type: FieldType.STRING,
+ externalType: "text",
+ },
+ age: {
+ name: "age",
+ type: FieldType.NUMBER,
+ externalType: "float(8,2)",
+ },
+ },
+ }
+
+ const response = await makeRequest(
+ "post",
+ "/api/tables/",
+ renameColumnOnTable
+ )
+ mysqlDatasource = (
+ await makeRequest(
+ "post",
+ `/api/datasources/${mysqlDatasource._id}/schema`
+ )
+ ).body.datasource
+
+ expect(response.status).toEqual(200)
+ expect(
+ Object.keys(mysqlDatasource.entities![primaryMySqlTable.name].schema)
+ ).toEqual(["id", "name", "description", "age"])
+ })
+ })
+})
diff --git a/packages/server/src/integration-test/postgres.spec.ts b/packages/server/src/integration-test/postgres.spec.ts
index 0031fe1136..7c14bc2b69 100644
--- a/packages/server/src/integration-test/postgres.spec.ts
+++ b/packages/server/src/integration-test/postgres.spec.ts
@@ -398,7 +398,7 @@ describe("postgres integrations", () => {
expect(res.status).toBe(200)
expect(res.body).toEqual(updatedRow)
- const persistedRow = await config.getRow(
+ const persistedRow = await config.api.row.get(
primaryPostgresTable._id!,
row.id
)
@@ -1040,28 +1040,37 @@ describe("postgres integrations", () => {
describe("POST /api/datasources/verify", () => {
it("should be able to verify the connection", async () => {
- const response = await config.api.datasource.verify({
- datasource: await databaseTestProviders.postgres.datasource(),
- })
- expect(response.status).toBe(200)
- expect(response.body.connected).toBe(true)
+ await config.api.datasource.verify(
+ {
+ datasource: await databaseTestProviders.postgres.datasource(),
+ },
+ {
+ body: {
+ connected: true,
+ },
+ }
+ )
})
it("should state an invalid datasource cannot connect", async () => {
const dbConfig = await databaseTestProviders.postgres.datasource()
- const response = await config.api.datasource.verify({
- datasource: {
- ...dbConfig,
- config: {
- ...dbConfig.config,
- password: "wrongpassword",
+ await config.api.datasource.verify(
+ {
+ datasource: {
+ ...dbConfig,
+ config: {
+ ...dbConfig.config,
+ password: "wrongpassword",
+ },
},
},
- })
-
- expect(response.status).toBe(200)
- expect(response.body.connected).toBe(false)
- expect(response.body.error).toBeDefined()
+ {
+ body: {
+ connected: false,
+ error: 'password authentication failed for user "postgres"',
+ },
+ }
+ )
})
})
diff --git a/packages/server/src/integrations/base/query.ts b/packages/server/src/integrations/base/query.ts
index 4f31e37744..b906ecbb1b 100644
--- a/packages/server/src/integrations/base/query.ts
+++ b/packages/server/src/integrations/base/query.ts
@@ -1,11 +1,15 @@
-import { QueryJson, Datasource } from "@budibase/types"
+import {
+ QueryJson,
+ Datasource,
+ DatasourcePlusQueryResponse,
+} from "@budibase/types"
import { getIntegration } from "../index"
import sdk from "../../sdk"
export async function makeExternalQuery(
datasource: Datasource,
json: QueryJson
-) {
+): DatasourcePlusQueryResponse {
datasource = await sdk.datasources.enrich(datasource)
const Integration = await getIntegration(datasource.source)
// query is the opinionated function
diff --git a/packages/server/src/integrations/base/sql.ts b/packages/server/src/integrations/base/sql.ts
index 29c8416b34..be1883c8ec 100644
--- a/packages/server/src/integrations/base/sql.ts
+++ b/packages/server/src/integrations/base/sql.ts
@@ -12,12 +12,13 @@ import {
} from "@budibase/types"
import environment from "../../environment"
+type QueryFunction = (query: Knex.SqlNative, operation: Operation) => any
+
const envLimit = environment.SQL_MAX_ROWS
? parseInt(environment.SQL_MAX_ROWS)
: null
const BASE_LIMIT = envLimit || 5000
-type KnexQuery = Knex.QueryBuilder | Knex
// these are invalid dates sent by the client, need to convert them to a real max date
const MIN_ISO_DATE = "0000-00-00T00:00:00.000Z"
const MAX_ISO_DATE = "9999-00-00T00:00:00.000Z"
@@ -127,10 +128,15 @@ class InternalBuilder {
// right now we only do filters on the specific table being queried
addFilters(
- query: KnexQuery,
+ query: Knex.QueryBuilder,
filters: SearchFilters | undefined,
- opts: { relationship?: boolean; tableName?: string }
- ): KnexQuery {
+ tableName: string,
+ opts: { aliases?: Record; relationship?: boolean }
+ ): Knex.QueryBuilder {
+ function getTableName(name: string) {
+ const alias = opts.aliases?.[name]
+ return alias || name
+ }
function iterate(
structure: { [key: string]: any },
fn: (key: string, value: any) => void
@@ -139,10 +145,11 @@ class InternalBuilder {
const updatedKey = dbCore.removeKeyNumbering(key)
const isRelationshipField = updatedKey.includes(".")
if (!opts.relationship && !isRelationshipField) {
- fn(`${opts.tableName}.${updatedKey}`, value)
+ fn(`${getTableName(tableName)}.${updatedKey}`, value)
}
if (opts.relationship && isRelationshipField) {
- fn(updatedKey, value)
+ const [filterTableName, property] = updatedKey.split(".")
+ fn(`${getTableName(filterTableName)}.${property}`, value)
}
}
}
@@ -314,32 +321,47 @@ class InternalBuilder {
return query
}
- addSorting(query: KnexQuery, json: QueryJson): KnexQuery {
+ addSorting(query: Knex.QueryBuilder, json: QueryJson): Knex.QueryBuilder {
let { sort, paginate } = json
const table = json.meta?.table
+ const aliases = json.tableAliases
+ const aliased =
+ table?.name && aliases?.[table.name] ? aliases[table.name] : table?.name
if (sort && Object.keys(sort || {}).length > 0) {
for (let [key, value] of Object.entries(sort)) {
const direction =
value.direction === SortDirection.ASCENDING ? "asc" : "desc"
- query = query.orderBy(`${table?.name}.${key}`, direction)
+ query = query.orderBy(`${aliased}.${key}`, direction)
}
} else if (this.client === SqlClient.MS_SQL && paginate?.limit) {
// @ts-ignore
- query = query.orderBy(`${table?.name}.${table?.primary[0]}`)
+ query = query.orderBy(`${aliased}.${table?.primary[0]}`)
}
return query
}
+ tableNameWithSchema(
+ tableName: string,
+ opts?: { alias?: string; schema?: string }
+ ) {
+ let withSchema = opts?.schema ? `${opts.schema}.${tableName}` : tableName
+ if (opts?.alias) {
+ withSchema += ` as ${opts.alias}`
+ }
+ return withSchema
+ }
+
addRelationships(
- query: KnexQuery,
+ query: Knex.QueryBuilder,
fromTable: string,
relationships: RelationshipsJson[] | undefined,
- schema: string | undefined
- ): KnexQuery {
+ schema: string | undefined,
+ aliases?: Record
+ ): Knex.QueryBuilder {
if (!relationships) {
return query
}
- const tableSets: Record = {}
+ const tableSets: Record = {}
// aggregate into table sets (all the same to tables)
for (let relationship of relationships) {
const keyObj: { toTable: string; throughTable: string | undefined } = {
@@ -358,10 +380,17 @@ class InternalBuilder {
}
for (let [key, relationships] of Object.entries(tableSets)) {
const { toTable, throughTable } = JSON.parse(key)
- const toTableWithSchema = schema ? `${schema}.${toTable}` : toTable
- const throughTableWithSchema = schema
- ? `${schema}.${throughTable}`
- : throughTable
+ const toAlias = aliases?.[toTable] || toTable,
+ throughAlias = aliases?.[throughTable] || throughTable,
+ fromAlias = aliases?.[fromTable] || fromTable
+ let toTableWithSchema = this.tableNameWithSchema(toTable, {
+ alias: toAlias,
+ schema,
+ })
+ let throughTableWithSchema = this.tableNameWithSchema(throughTable, {
+ alias: throughAlias,
+ schema,
+ })
if (!throughTable) {
// @ts-ignore
query = query.leftJoin(toTableWithSchema, function () {
@@ -369,7 +398,7 @@ class InternalBuilder {
const from = relationship.from,
to = relationship.to
// @ts-ignore
- this.orOn(`${fromTable}.${from}`, "=", `${toTable}.${to}`)
+ this.orOn(`${fromAlias}.${from}`, "=", `${toAlias}.${to}`)
}
})
} else {
@@ -381,9 +410,9 @@ class InternalBuilder {
const from = relationship.from
// @ts-ignore
this.orOn(
- `${fromTable}.${fromPrimary}`,
+ `${fromAlias}.${fromPrimary}`,
"=",
- `${throughTable}.${from}`
+ `${throughAlias}.${from}`
)
}
})
@@ -392,7 +421,7 @@ class InternalBuilder {
const toPrimary = relationship.toPrimary
const to = relationship.to
// @ts-ignore
- this.orOn(`${toTable}.${toPrimary}`, `${throughTable}.${to}`)
+ this.orOn(`${toAlias}.${toPrimary}`, `${throughAlias}.${to}`)
}
})
}
@@ -400,12 +429,27 @@ class InternalBuilder {
return query.limit(BASE_LIMIT)
}
- create(knex: Knex, json: QueryJson, opts: QueryOptions): KnexQuery {
- const { endpoint, body } = json
- let query: KnexQuery = knex(endpoint.entityId)
+ knexWithAlias(
+ knex: Knex,
+ endpoint: QueryJson["endpoint"],
+ aliases?: QueryJson["tableAliases"]
+ ): Knex.QueryBuilder {
+ const tableName = endpoint.entityId
+ const tableAlias = aliases?.[tableName]
+ let table: string | Record = tableName
+ if (tableAlias) {
+ table = { [tableAlias]: tableName }
+ }
+ let query = knex(table)
if (endpoint.schema) {
query = query.withSchema(endpoint.schema)
}
+ return query
+ }
+
+ create(knex: Knex, json: QueryJson, opts: QueryOptions): Knex.QueryBuilder {
+ const { endpoint, body } = json
+ let query = this.knexWithAlias(knex, endpoint)
const parsedBody = parseBody(body)
// make sure no null values in body for creation
for (let [key, value] of Object.entries(parsedBody)) {
@@ -422,12 +466,9 @@ class InternalBuilder {
}
}
- bulkCreate(knex: Knex, json: QueryJson): KnexQuery {
+ bulkCreate(knex: Knex, json: QueryJson): Knex.QueryBuilder {
const { endpoint, body } = json
- let query: KnexQuery = knex(endpoint.entityId)
- if (endpoint.schema) {
- query = query.withSchema(endpoint.schema)
- }
+ let query = this.knexWithAlias(knex, endpoint)
if (!Array.isArray(body)) {
return query
}
@@ -435,8 +476,10 @@ class InternalBuilder {
return query.insert(parsedBody)
}
- read(knex: Knex, json: QueryJson, limit: number): KnexQuery {
- let { endpoint, resource, filters, paginate, relationships } = json
+ read(knex: Knex, json: QueryJson, limit: number): Knex.QueryBuilder {
+ let { endpoint, resource, filters, paginate, relationships, tableAliases } =
+ json
+
const tableName = endpoint.entityId
// select all if not specified
if (!resource) {
@@ -462,21 +505,20 @@ class InternalBuilder {
foundLimit = paginate.limit
}
// start building the query
- let query: KnexQuery = knex(tableName).limit(foundLimit)
- if (endpoint.schema) {
- query = query.withSchema(endpoint.schema)
- }
+ let query = this.knexWithAlias(knex, endpoint, tableAliases)
+ query = query.limit(foundLimit)
if (foundOffset) {
query = query.offset(foundOffset)
}
- query = this.addFilters(query, filters, { tableName })
+ query = this.addFilters(query, filters, tableName, {
+ aliases: tableAliases,
+ })
// add sorting to pre-query
query = this.addSorting(query, json)
- // @ts-ignore
- let preQuery: KnexQuery = knex({
- // @ts-ignore
- [tableName]: query,
- }).select(selectStatement)
+ const alias = tableAliases?.[tableName] || tableName
+ let preQuery = knex({
+ [alias]: query,
+ } as any).select(selectStatement) as any
// have to add after as well (this breaks MS-SQL)
if (this.client !== SqlClient.MS_SQL) {
preQuery = this.addSorting(preQuery, json)
@@ -486,19 +528,22 @@ class InternalBuilder {
preQuery,
tableName,
relationships,
- endpoint.schema
+ endpoint.schema,
+ tableAliases
)
- return this.addFilters(query, filters, { relationship: true })
+ return this.addFilters(query, filters, tableName, {
+ relationship: true,
+ aliases: tableAliases,
+ })
}
- update(knex: Knex, json: QueryJson, opts: QueryOptions): KnexQuery {
- const { endpoint, body, filters } = json
- let query: KnexQuery = knex(endpoint.entityId)
- if (endpoint.schema) {
- query = query.withSchema(endpoint.schema)
- }
+ update(knex: Knex, json: QueryJson, opts: QueryOptions): Knex.QueryBuilder {
+ const { endpoint, body, filters, tableAliases } = json
+ let query = this.knexWithAlias(knex, endpoint, tableAliases)
const parsedBody = parseBody(body)
- query = this.addFilters(query, filters, { tableName: endpoint.entityId })
+ query = this.addFilters(query, filters, endpoint.entityId, {
+ aliases: tableAliases,
+ })
// mysql can't use returning
if (opts.disableReturning) {
return query.update(parsedBody)
@@ -507,13 +552,12 @@ class InternalBuilder {
}
}
- delete(knex: Knex, json: QueryJson, opts: QueryOptions): KnexQuery {
- const { endpoint, filters } = json
- let query: KnexQuery = knex(endpoint.entityId)
- if (endpoint.schema) {
- query = query.withSchema(endpoint.schema)
- }
- query = this.addFilters(query, filters, { tableName: endpoint.entityId })
+ delete(knex: Knex, json: QueryJson, opts: QueryOptions): Knex.QueryBuilder {
+ const { endpoint, filters, tableAliases } = json
+ let query = this.knexWithAlias(knex, endpoint, tableAliases)
+ query = this.addFilters(query, filters, endpoint.entityId, {
+ aliases: tableAliases,
+ })
// mysql can't use returning
if (opts.disableReturning) {
return query.delete()
@@ -537,10 +581,10 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
* which for the sake of mySQL stops adding the returning statement to inserts, updates and deletes.
* @return the query ready to be passed to the driver.
*/
- _query(json: QueryJson, opts: QueryOptions = {}) {
+ _query(json: QueryJson, opts: QueryOptions = {}): Knex.SqlNative | Knex.Sql {
const sqlClient = this.getSqlClient()
const client = knex({ client: sqlClient })
- let query
+ let query: Knex.QueryBuilder
const builder = new InternalBuilder(sqlClient)
switch (this._operation(json)) {
case Operation.CREATE:
@@ -565,12 +609,10 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
default:
throw `Operation type is not supported by SQL query builder`
}
-
- // @ts-ignore
return query.toSQL().toNative()
}
- async getReturningRow(queryFn: Function, json: QueryJson) {
+ async getReturningRow(queryFn: QueryFunction, json: QueryJson) {
if (!json.extra || !json.extra.idFilter) {
return {}
}
@@ -582,7 +624,7 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
resource: {
fields: [],
},
- filters: json.extra.idFilter,
+ filters: json.extra?.idFilter,
paginate: {
limit: 1,
},
@@ -611,7 +653,7 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
// this function recreates the returning functionality of postgres
async queryWithReturning(
json: QueryJson,
- queryFn: Function,
+ queryFn: QueryFunction,
processFn: Function = (result: any) => result
) {
const sqlClient = this.getSqlClient()
@@ -648,6 +690,18 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
}
return results.length ? results : [{ [operation.toLowerCase()]: true }]
}
+
+ log(query: string, values?: any[]) {
+ if (!environment.SQL_LOGGING_ENABLE) {
+ return
+ }
+ const sqlClient = this.getSqlClient()
+ let string = `[SQL] [${sqlClient.toUpperCase()}] query="${query}"`
+ if (values) {
+ string += ` values="${values.join(", ")}"`
+ }
+ console.log(string)
+ }
}
export default SqlQueryBuilder
diff --git a/packages/server/src/integrations/base/sqlTable.ts b/packages/server/src/integrations/base/sqlTable.ts
index f553690b23..f3560791e6 100644
--- a/packages/server/src/integrations/base/sqlTable.ts
+++ b/packages/server/src/integrations/base/sqlTable.ts
@@ -9,7 +9,7 @@ import {
Table,
FieldType,
} from "@budibase/types"
-import { breakExternalTableId } from "../utils"
+import { breakExternalTableId, SqlClient } from "../utils"
import SchemaBuilder = Knex.SchemaBuilder
import CreateTableBuilder = Knex.CreateTableBuilder
import { utils } from "@budibase/shared-core"
@@ -135,7 +135,8 @@ function generateSchema(
// need to check if any columns have been deleted
if (oldTable) {
const deletedColumns = Object.entries(oldTable.schema).filter(
- ([key, column]) => isIgnoredType(column.type) && table.schema[key] == null
+ ([key, column]) =>
+ !isIgnoredType(column.type) && table.schema[key] == null
)
deletedColumns.forEach(([key, column]) => {
if (renamed?.old === key || isIgnoredType(column.type)) {
@@ -197,13 +198,14 @@ class SqlTableQueryBuilder {
return json.endpoint.operation
}
- _tableQuery(json: QueryJson): any {
+ _tableQuery(json: QueryJson): Knex.Sql | Knex.SqlNative {
let client = knex({ client: this.sqlClient }).schema
- if (json?.endpoint?.schema) {
- client = client.withSchema(json.endpoint.schema)
+ let schemaName = json?.endpoint?.schema
+ if (schemaName) {
+ client = client.withSchema(schemaName)
}
- let query
+ let query: Knex.SchemaBuilder
if (!json.table || !json.meta || !json.meta.tables) {
throw "Cannot execute without table being specified"
}
@@ -215,6 +217,18 @@ class SqlTableQueryBuilder {
if (!json.meta || !json.meta.table) {
throw "Must specify old table for update"
}
+ // renameColumn does not work for MySQL, so return a raw query
+ if (this.sqlClient === SqlClient.MY_SQL && json.meta.renamed) {
+ const updatedColumn = json.meta.renamed.updated
+ 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};`,
+ bindings: [],
+ }
+ }
query = buildUpdateTable(
client,
json.table,
diff --git a/packages/server/src/integrations/googlesheets.ts b/packages/server/src/integrations/googlesheets.ts
index 58c867ea0b..32398bde41 100644
--- a/packages/server/src/integrations/googlesheets.ts
+++ b/packages/server/src/integrations/googlesheets.ts
@@ -16,6 +16,7 @@ import {
Table,
TableRequest,
TableSourceType,
+ DatasourcePlusQueryResponse,
} from "@budibase/types"
import { OAuth2Client } from "google-auth-library"
import {
@@ -334,7 +335,7 @@ class GoogleSheetsIntegration implements DatasourcePlus {
return { tables: externalTables, errors }
}
- async query(json: QueryJson) {
+ async query(json: QueryJson): DatasourcePlusQueryResponse {
const sheet = json.endpoint.entityId
switch (json.endpoint.operation) {
case Operation.CREATE:
@@ -384,7 +385,7 @@ class GoogleSheetsIntegration implements DatasourcePlus {
}
try {
await this.connect()
- return await this.client.addSheet({ title: name, headerValues: [name] })
+ await this.client.addSheet({ title: name, headerValues: [name] })
} catch (err) {
console.error("Error creating new table in google sheets", err)
throw err
@@ -450,7 +451,7 @@ class GoogleSheetsIntegration implements DatasourcePlus {
try {
await this.connect()
const sheetToDelete = this.client.sheetsByTitle[sheet]
- return await sheetToDelete.delete()
+ await sheetToDelete.delete()
} catch (err) {
console.error("Error deleting table in google sheets", err)
throw err
diff --git a/packages/server/src/integrations/index.ts b/packages/server/src/integrations/index.ts
index 49761bac85..8cbc29251b 100644
--- a/packages/server/src/integrations/index.ts
+++ b/packages/server/src/integrations/index.ts
@@ -14,7 +14,12 @@ import firebase from "./firebase"
import redis from "./redis"
import snowflake from "./snowflake"
import oracle from "./oracle"
-import { SourceName, Integration, PluginType } from "@budibase/types"
+import {
+ SourceName,
+ Integration,
+ PluginType,
+ IntegrationBase,
+} from "@budibase/types"
import { getDatasourcePlugin } from "../utilities/fileSystem"
import env from "../environment"
import cloneDeep from "lodash/cloneDeep"
@@ -37,26 +42,31 @@ const DEFINITIONS: Record = {
[SourceName.REDIS]: redis.schema,
[SourceName.SNOWFLAKE]: snowflake.schema,
[SourceName.ORACLE]: undefined,
+ [SourceName.BUDIBASE]: undefined,
}
-const INTEGRATIONS: Record = {
- [SourceName.POSTGRES]: postgres.integration,
- [SourceName.DYNAMODB]: dynamodb.integration,
- [SourceName.MONGODB]: mongodb.integration,
- [SourceName.ELASTICSEARCH]: elasticsearch.integration,
- [SourceName.COUCHDB]: couchdb.integration,
- [SourceName.SQL_SERVER]: sqlServer.integration,
- [SourceName.S3]: s3.integration,
- [SourceName.AIRTABLE]: airtable.integration,
- [SourceName.MYSQL]: mysql.integration,
- [SourceName.ARANGODB]: arangodb.integration,
- [SourceName.REST]: rest.integration,
- [SourceName.FIRESTORE]: firebase.integration,
- [SourceName.GOOGLE_SHEETS]: googlesheets.integration,
- [SourceName.REDIS]: redis.integration,
- [SourceName.SNOWFLAKE]: snowflake.integration,
- [SourceName.ORACLE]: undefined,
-}
+type IntegrationBaseConstructor = new (...args: any[]) => IntegrationBase
+
+const INTEGRATIONS: Record =
+ {
+ [SourceName.POSTGRES]: postgres.integration,
+ [SourceName.DYNAMODB]: dynamodb.integration,
+ [SourceName.MONGODB]: mongodb.integration,
+ [SourceName.ELASTICSEARCH]: elasticsearch.integration,
+ [SourceName.COUCHDB]: couchdb.integration,
+ [SourceName.SQL_SERVER]: sqlServer.integration,
+ [SourceName.S3]: s3.integration,
+ [SourceName.AIRTABLE]: airtable.integration,
+ [SourceName.MYSQL]: mysql.integration,
+ [SourceName.ARANGODB]: arangodb.integration,
+ [SourceName.REST]: rest.integration,
+ [SourceName.FIRESTORE]: firebase.integration,
+ [SourceName.GOOGLE_SHEETS]: googlesheets.integration,
+ [SourceName.REDIS]: redis.integration,
+ [SourceName.SNOWFLAKE]: snowflake.integration,
+ [SourceName.ORACLE]: undefined,
+ [SourceName.BUDIBASE]: undefined,
+ }
// optionally add oracle integration if the oracle binary can be installed
if (
diff --git a/packages/server/src/integrations/microsoftSqlServer.ts b/packages/server/src/integrations/microsoftSqlServer.ts
index d0a06d4476..f87e248ac0 100644
--- a/packages/server/src/integrations/microsoftSqlServer.ts
+++ b/packages/server/src/integrations/microsoftSqlServer.ts
@@ -13,6 +13,7 @@ import {
SourceName,
Schema,
TableSourceType,
+ DatasourcePlusQueryResponse,
} from "@budibase/types"
import {
getSqlQuery,
@@ -329,6 +330,7 @@ class SqlServerIntegration extends Sql implements DatasourcePlus {
operation === Operation.CREATE
? `${query.sql}; SELECT SCOPE_IDENTITY() AS id;`
: query.sql
+ this.log(sql, query.bindings)
return await request.query(sql)
} catch (err: any) {
let readableMessage = getReadableErrorMessage(
@@ -492,7 +494,7 @@ class SqlServerIntegration extends Sql implements DatasourcePlus {
return response.recordset || [{ deleted: true }]
}
- async query(json: QueryJson) {
+ async query(json: QueryJson): DatasourcePlusQueryResponse {
const schema = this.config.schema
await this.connect()
if (schema && schema !== DEFAULT_SCHEMA && json?.endpoint) {
diff --git a/packages/server/src/integrations/mysql.ts b/packages/server/src/integrations/mysql.ts
index 8ec73307f4..f629381807 100644
--- a/packages/server/src/integrations/mysql.ts
+++ b/packages/server/src/integrations/mysql.ts
@@ -12,7 +12,7 @@ import {
SourceName,
Schema,
TableSourceType,
- FieldType,
+ DatasourcePlusQueryResponse,
} from "@budibase/types"
import {
getSqlQuery,
@@ -261,6 +261,7 @@ class MySQLIntegration extends Sql implements DatasourcePlus {
const bindings = opts?.disableCoercion
? baseBindings
: bindingTypeCoerce(baseBindings)
+ this.log(query.sql, bindings)
// Node MySQL is callback based, so we must wrap our call in a promise
const response = await this.client!.query(query.sql, bindings)
return response[0]
@@ -380,7 +381,7 @@ class MySQLIntegration extends Sql implements DatasourcePlus {
return results.length ? results : [{ deleted: true }]
}
- async query(json: QueryJson) {
+ async query(json: QueryJson): DatasourcePlusQueryResponse {
await this.connect()
try {
const queryFn = (query: any) =>
diff --git a/packages/server/src/integrations/oracle.ts b/packages/server/src/integrations/oracle.ts
index cf76622581..08f3058d63 100644
--- a/packages/server/src/integrations/oracle.ts
+++ b/packages/server/src/integrations/oracle.ts
@@ -12,6 +12,8 @@ import {
ConnectionInfo,
Schema,
TableSourceType,
+ Row,
+ DatasourcePlusQueryResponse,
} from "@budibase/types"
import {
buildExternalTableId,
@@ -368,6 +370,7 @@ class OracleIntegration extends Sql implements DatasourcePlus {
const options: ExecuteOptions = { autoCommit: true }
const bindings: BindParameters = query.bindings || []
+ this.log(query.sql, bindings)
return await connection.execute(query.sql, bindings, options)
} finally {
if (connection) {
@@ -419,9 +422,9 @@ class OracleIntegration extends Sql implements DatasourcePlus {
: [{ deleted: true }]
}
- async query(json: QueryJson) {
+ async query(json: QueryJson): DatasourcePlusQueryResponse {
const operation = this._operation(json)
- const input = this._query(json, { disableReturning: true })
+ const input = this._query(json, { disableReturning: true }) as SqlQuery
if (Array.isArray(input)) {
const responses = []
for (let query of input) {
@@ -443,7 +446,7 @@ class OracleIntegration extends Sql implements DatasourcePlus {
if (deletedRows?.rows?.length) {
return deletedRows.rows
} else if (response.rows?.length) {
- return response.rows
+ return response.rows as Row[]
} else {
// get the last row that was updated
if (
@@ -454,7 +457,7 @@ class OracleIntegration extends Sql implements DatasourcePlus {
const lastRow = await this.internalQuery({
sql: `SELECT * FROM \"${json.endpoint.entityId}\" WHERE ROWID = '${response.lastRowid}'`,
})
- return lastRow.rows
+ return lastRow.rows as Row[]
} else {
return [{ [operation.toLowerCase()]: true }]
}
diff --git a/packages/server/src/integrations/postgres.ts b/packages/server/src/integrations/postgres.ts
index 9949dee6bb..635d834761 100644
--- a/packages/server/src/integrations/postgres.ts
+++ b/packages/server/src/integrations/postgres.ts
@@ -12,6 +12,7 @@ import {
SourceName,
Schema,
TableSourceType,
+ DatasourcePlusQueryResponse,
} from "@budibase/types"
import {
getSqlQuery,
@@ -268,7 +269,9 @@ class PostgresIntegration extends Sql implements DatasourcePlus {
}
}
try {
- return await client.query(query.sql, query.bindings || [])
+ const bindings = query.bindings || []
+ this.log(query.sql, bindings)
+ return await client.query(query.sql, bindings)
} catch (err: any) {
await this.closeConnection()
let readableMessage = getReadableErrorMessage(
@@ -417,9 +420,9 @@ class PostgresIntegration extends Sql implements DatasourcePlus {
return response.rows.length ? response.rows : [{ deleted: true }]
}
- async query(json: QueryJson) {
+ async query(json: QueryJson): DatasourcePlusQueryResponse {
const operation = this._operation(json).toLowerCase()
- const input = this._query(json)
+ const input = this._query(json) as SqlQuery
if (Array.isArray(input)) {
const responses = []
for (let query of input) {
diff --git a/packages/server/src/integrations/tests/sql.spec.ts b/packages/server/src/integrations/tests/sql.spec.ts
index 5cc4849d03..f4eaf2859c 100644
--- a/packages/server/src/integrations/tests/sql.spec.ts
+++ b/packages/server/src/integrations/tests/sql.spec.ts
@@ -1,5 +1,12 @@
-const Sql = require("../base/sql").default
-const { SqlClient } = require("../utils")
+import { SqlClient } from "../utils"
+import Sql from "../base/sql"
+import {
+ Operation,
+ QueryJson,
+ TableSourceType,
+ Table,
+ FieldType,
+} from "@budibase/types"
const TABLE_NAME = "test"
@@ -17,7 +24,7 @@ function generateReadJson({
filters,
sort,
paginate,
-}: any = {}) {
+}: any = {}): QueryJson {
return {
endpoint: endpoint(table || TABLE_NAME, "READ"),
resource: {
@@ -28,41 +35,51 @@ function generateReadJson({
paginate: paginate || {},
meta: {
table: {
+ type: "table",
+ sourceType: TableSourceType.EXTERNAL,
+ sourceId: "SOURCE_ID",
+ schema: {},
name: table || TABLE_NAME,
primary: ["id"],
- },
+ } as any,
},
}
}
-function generateCreateJson(table = TABLE_NAME, body = {}) {
+function generateCreateJson(table = TABLE_NAME, body = {}): QueryJson {
return {
endpoint: endpoint(table, "CREATE"),
body,
}
}
-function generateUpdateJson(table = TABLE_NAME, body = {}, filters = {}) {
+function generateUpdateJson({
+ table = TABLE_NAME,
+ body = {},
+ filters = {},
+ meta = {},
+}): QueryJson {
return {
endpoint: endpoint(table, "UPDATE"),
filters,
body,
+ meta,
}
}
-function generateDeleteJson(table = TABLE_NAME, filters = {}) {
+function generateDeleteJson(table = TABLE_NAME, filters = {}): QueryJson {
return {
endpoint: endpoint(table, "DELETE"),
filters,
}
}
-function generateRelationshipJson(config: { schema?: string } = {}) {
+function generateRelationshipJson(config: { schema?: string } = {}): QueryJson {
return {
endpoint: {
datasourceId: "Postgres",
entityId: "brands",
- operation: "READ",
+ operation: Operation.READ,
schema: config.schema,
},
resource: {
@@ -76,7 +93,6 @@ function generateRelationshipJson(config: { schema?: string } = {}) {
},
filters: {},
sort: {},
- paginate: {},
relationships: [
{
from: "brand_id",
@@ -240,17 +256,17 @@ describe("SQL query builder", () => {
it("should test an update statement", () => {
const query = sql._query(
- generateUpdateJson(
- TABLE_NAME,
- {
+ generateUpdateJson({
+ table: TABLE_NAME,
+ body: {
name: "John",
},
- {
+ filters: {
equal: {
id: 1001,
},
- }
- )
+ },
+ })
)
expect(query).toEqual({
bindings: ["John", 1001],
@@ -502,7 +518,7 @@ describe("SQL query builder", () => {
const query = sql._query(generateRelationshipJson({ schema: "production" }))
expect(query).toEqual({
bindings: [500, 5000],
- sql: `select "brands"."brand_id" as "brands.brand_id", "brands"."brand_name" as "brands.brand_name", "products"."product_id" as "products.product_id", "products"."product_name" as "products.product_name", "products"."brand_id" as "products.brand_id" from (select * from "production"."brands" limit $1) as "brands" left join "production"."products" on "brands"."brand_id" = "products"."brand_id" limit $2`,
+ sql: `select "brands"."brand_id" as "brands.brand_id", "brands"."brand_name" as "brands.brand_name", "products"."product_id" as "products.product_id", "products"."product_name" as "products.product_name", "products"."brand_id" as "products.brand_id" from (select * from "production"."brands" limit $1) as "brands" left join "production"."products" as "products" on "brands"."brand_id" = "products"."brand_id" limit $2`,
})
})
@@ -510,7 +526,7 @@ describe("SQL query builder", () => {
const query = sql._query(generateRelationshipJson())
expect(query).toEqual({
bindings: [500, 5000],
- sql: `select "brands"."brand_id" as "brands.brand_id", "brands"."brand_name" as "brands.brand_name", "products"."product_id" as "products.product_id", "products"."product_name" as "products.product_name", "products"."brand_id" as "products.brand_id" from (select * from "brands" limit $1) as "brands" left join "products" on "brands"."brand_id" = "products"."brand_id" limit $2`,
+ sql: `select "brands"."brand_id" as "brands.brand_id", "brands"."brand_name" as "brands.brand_name", "products"."product_id" as "products.product_id", "products"."product_name" as "products.product_name", "products"."brand_id" as "products.brand_id" from (select * from "brands" limit $1) as "brands" left join "products" as "products" on "brands"."brand_id" = "products"."brand_id" limit $2`,
})
})
@@ -520,7 +536,7 @@ describe("SQL query builder", () => {
)
expect(query).toEqual({
bindings: [500, 5000],
- sql: `select "stores"."store_id" as "stores.store_id", "stores"."store_name" as "stores.store_name", "products"."product_id" as "products.product_id", "products"."product_name" as "products.product_name" from (select * from "production"."stores" limit $1) as "stores" left join "production"."stocks" on "stores"."store_id" = "stocks"."store_id" left join "production"."products" on "products"."product_id" = "stocks"."product_id" limit $2`,
+ sql: `select "stores"."store_id" as "stores.store_id", "stores"."store_name" as "stores.store_name", "products"."product_id" as "products.product_id", "products"."product_name" as "products.product_name" from (select * from "production"."stores" limit $1) as "stores" left join "production"."stocks" as "stocks" on "stores"."store_id" = "stocks"."store_id" left join "production"."products" as "products" on "products"."product_id" = "stocks"."product_id" limit $2`,
})
})
@@ -682,4 +698,99 @@ describe("SQL query builder", () => {
sql: `insert into \"test\" (\"name\") values ($1) returning *`,
})
})
+
+ it("should be able to rename column for MySQL", () => {
+ const table: Table = {
+ type: "table",
+ sourceType: TableSourceType.EXTERNAL,
+ name: TABLE_NAME,
+ schema: {
+ first_name: {
+ type: FieldType.STRING,
+ name: "first_name",
+ externalType: "varchar(45)",
+ },
+ },
+ sourceId: "SOURCE_ID",
+ }
+ const oldTable: Table = {
+ ...table,
+ schema: {
+ name: {
+ type: FieldType.STRING,
+ name: "name",
+ externalType: "varchar(45)",
+ },
+ },
+ }
+ const query = new Sql(SqlClient.MY_SQL, limit)._query({
+ table,
+ endpoint: {
+ datasourceId: "MySQL",
+ operation: Operation.UPDATE_TABLE,
+ entityId: TABLE_NAME,
+ },
+ meta: {
+ table: oldTable,
+ tables: { [oldTable.name]: oldTable },
+ renamed: {
+ old: "name",
+ updated: "first_name",
+ },
+ },
+ })
+ expect(query).toEqual({
+ bindings: [],
+ sql: `alter table \`${TABLE_NAME}\` change column \`name\` \`first_name\` varchar(45);`,
+ })
+ })
+
+ it("should be able to delete a column", () => {
+ const table: Table = {
+ type: "table",
+ sourceType: TableSourceType.EXTERNAL,
+ name: TABLE_NAME,
+ schema: {
+ first_name: {
+ type: FieldType.STRING,
+ name: "first_name",
+ externalType: "varchar(45)",
+ },
+ },
+ sourceId: "SOURCE_ID",
+ }
+ const oldTable: Table = {
+ ...table,
+ schema: {
+ first_name: {
+ type: FieldType.STRING,
+ name: "first_name",
+ externalType: "varchar(45)",
+ },
+ last_name: {
+ type: FieldType.STRING,
+ name: "last_name",
+ externalType: "varchar(45)",
+ },
+ },
+ }
+ const query = sql._query({
+ table,
+ endpoint: {
+ datasourceId: "Postgres",
+ operation: Operation.UPDATE_TABLE,
+ entityId: TABLE_NAME,
+ },
+ meta: {
+ table: oldTable,
+ tables: [oldTable],
+ },
+ })
+ expect(query).toEqual([
+ {
+ bindings: [],
+ sql: `alter table "${TABLE_NAME}" drop column "last_name"`,
+ },
+ ])
+ })
})
diff --git a/packages/server/src/integrations/tests/sqlAlias.spec.ts b/packages/server/src/integrations/tests/sqlAlias.spec.ts
new file mode 100644
index 0000000000..dd82dadac0
--- /dev/null
+++ b/packages/server/src/integrations/tests/sqlAlias.spec.ts
@@ -0,0 +1,335 @@
+import { Datasource, Operation, QueryJson, SourceName } from "@budibase/types"
+import { join } from "path"
+import Sql from "../base/sql"
+import { SqlClient } from "../utils"
+import AliasTables from "../../api/controllers/row/alias"
+import { generator } from "@budibase/backend-core/tests"
+import { Knex } from "knex"
+
+function multiline(sql: string) {
+ return sql.replace(/\n/g, "").replace(/ +/g, " ")
+}
+
+describe("Captures of real examples", () => {
+ const limit = 5000
+ const relationshipLimit = 100
+
+ function getJson(name: string): QueryJson {
+ return require(join(__dirname, "sqlQueryJson", name)) as QueryJson
+ }
+
+ describe("create", () => {
+ it("should create a row with relationships", () => {
+ const queryJson = getJson("createWithRelationships.json")
+ let query = new Sql(SqlClient.POSTGRES, limit)._query(queryJson)
+ expect(query).toEqual({
+ bindings: ["A Street", 34, "London", "A", "B", "designer", 1990],
+ sql: multiline(`insert into "persons" ("address", "age", "city", "firstname", "lastname", "type", "year")
+ values ($1, $2, $3, $4, $5, $6, $7) returning *`),
+ })
+ })
+ })
+
+ describe("read", () => {
+ it("should handle basic retrieval with relationships", () => {
+ const queryJson = getJson("basicFetchWithRelationships.json")
+ let query = new Sql(SqlClient.POSTGRES, limit)._query(queryJson)
+ expect(query).toEqual({
+ bindings: [relationshipLimit, limit],
+ sql: multiline(`select "a"."year" as "a.year", "a"."firstname" as "a.firstname", "a"."personid" as "a.personid",
+ "a"."address" as "a.address", "a"."age" as "a.age", "a"."type" as "a.type", "a"."city" as "a.city",
+ "a"."lastname" as "a.lastname", "b"."executorid" as "b.executorid", "b"."taskname" as "b.taskname",
+ "b"."taskid" as "b.taskid", "b"."completed" as "b.completed", "b"."qaid" as "b.qaid",
+ "b"."executorid" as "b.executorid", "b"."taskname" as "b.taskname", "b"."taskid" as "b.taskid",
+ "b"."completed" as "b.completed", "b"."qaid" as "b.qaid"
+ from (select * from "persons" as "a" order by "a"."firstname" asc limit $1) as "a"
+ left join "tasks" as "b" on "a"."personid" = "b"."qaid" or "a"."personid" = "b"."executorid"
+ order by "a"."firstname" asc limit $2`),
+ })
+ })
+
+ it("should handle filtering by relationship", () => {
+ const queryJson = getJson("filterByRelationship.json")
+ let query = new Sql(SqlClient.POSTGRES, limit)._query(queryJson)
+ expect(query).toEqual({
+ bindings: [relationshipLimit, "assembling", limit],
+ sql: multiline(`select "a"."productname" as "a.productname", "a"."productid" as "a.productid",
+ "b"."executorid" as "b.executorid", "b"."taskname" as "b.taskname", "b"."taskid" as "b.taskid",
+ "b"."completed" as "b.completed", "b"."qaid" as "b.qaid"
+ from (select * from "products" as "a" order by "a"."productname" asc limit $1) as "a"
+ left join "products_tasks" as "c" on "a"."productid" = "c"."productid"
+ left join "tasks" as "b" on "b"."taskid" = "c"."taskid" where "b"."taskname" = $2
+ order by "a"."productname" asc limit $3`),
+ })
+ })
+
+ it("should handle fetching many to many relationships", () => {
+ const queryJson = getJson("fetchManyToMany.json")
+ let query = new Sql(SqlClient.POSTGRES, limit)._query(queryJson)
+ expect(query).toEqual({
+ bindings: [relationshipLimit, limit],
+ sql: multiline(`select "a"."productname" as "a.productname", "a"."productid" as "a.productid",
+ "b"."executorid" as "b.executorid", "b"."taskname" as "b.taskname", "b"."taskid" as "b.taskid",
+ "b"."completed" as "b.completed", "b"."qaid" as "b.qaid"
+ from (select * from "products" as "a" order by "a"."productname" asc limit $1) as "a"
+ left join "products_tasks" as "c" on "a"."productid" = "c"."productid"
+ left join "tasks" as "b" on "b"."taskid" = "c"."taskid"
+ order by "a"."productname" asc limit $2`),
+ })
+ })
+
+ it("should handle enrichment of rows", () => {
+ const queryJson = getJson("enrichRelationship.json")
+ const filters = queryJson.filters?.oneOf?.taskid as number[]
+ let query = new Sql(SqlClient.POSTGRES, limit)._query(queryJson)
+ expect(query).toEqual({
+ bindings: [...filters, limit, limit],
+ sql: multiline(`select "a"."executorid" as "a.executorid", "a"."taskname" as "a.taskname",
+ "a"."taskid" as "a.taskid", "a"."completed" as "a.completed", "a"."qaid" as "a.qaid",
+ "b"."productname" as "b.productname", "b"."productid" as "b.productid"
+ from (select * from "tasks" as "a" where "a"."taskid" in ($1, $2) limit $3) as "a"
+ left join "products_tasks" as "c" on "a"."taskid" = "c"."taskid"
+ left join "products" as "b" on "b"."productid" = "c"."productid" limit $4`),
+ })
+ })
+
+ it("should manage query with many relationship filters", () => {
+ const queryJson = getJson("manyRelationshipFilters.json")
+ let query = new Sql(SqlClient.POSTGRES, limit)._query(queryJson)
+ const filters = queryJson.filters
+ const notEqualsValue = Object.values(filters?.notEqual!)[0]
+ const rangeValue = Object.values(filters?.range!)[0]
+ const equalValue = Object.values(filters?.equal!)[0]
+
+ expect(query).toEqual({
+ bindings: [
+ notEqualsValue,
+ relationshipLimit,
+ rangeValue.low,
+ rangeValue.high,
+ equalValue,
+ limit,
+ ],
+ sql: multiline(`select "a"."executorid" as "a.executorid", "a"."taskname" as "a.taskname", "a"."taskid" as "a.taskid",
+ "a"."completed" as "a.completed", "a"."qaid" as "a.qaid", "b"."productname" as "b.productname",
+ "b"."productid" as "b.productid", "c"."year" as "c.year", "c"."firstname" as "c.firstname",
+ "c"."personid" as "c.personid", "c"."address" as "c.address", "c"."age" as "c.age", "c"."type" as "c.type",
+ "c"."city" as "c.city", "c"."lastname" as "c.lastname", "c"."year" as "c.year", "c"."firstname" as "c.firstname",
+ "c"."personid" as "c.personid", "c"."address" as "c.address", "c"."age" as "c.age", "c"."type" as "c.type",
+ "c"."city" as "c.city", "c"."lastname" as "c.lastname"
+ from (select * from "tasks" as "a" where not "a"."completed" = $1
+ order by "a"."taskname" asc limit $2) as "a"
+ left join "products_tasks" as "d" on "a"."taskid" = "d"."taskid"
+ left join "products" as "b" on "b"."productid" = "d"."productid"
+ left join "persons" as "c" on "a"."executorid" = "c"."personid" or "a"."qaid" = "c"."personid"
+ where "c"."year" between $3 and $4 and "b"."productname" = $5 order by "a"."taskname" asc limit $6`),
+ })
+ })
+ })
+
+ describe("update", () => {
+ it("should handle performing a simple update", () => {
+ const queryJson = getJson("updateSimple.json")
+ let query = new Sql(SqlClient.POSTGRES, limit)._query(queryJson)
+ expect(query).toEqual({
+ bindings: [1990, "C", "A Street", 34, "designer", "London", "B", 5],
+ sql: multiline(`update "persons" as "a" set "year" = $1, "firstname" = $2, "address" = $3, "age" = $4,
+ "type" = $5, "city" = $6, "lastname" = $7 where "a"."personid" = $8 returning *`),
+ })
+ })
+
+ it("should handle performing an update of relationships", () => {
+ const queryJson = getJson("updateRelationship.json")
+ let query = new Sql(SqlClient.POSTGRES, limit)._query(queryJson)
+ expect(query).toEqual({
+ bindings: [1990, "C", "A Street", 34, "designer", "London", "B", 5],
+ sql: multiline(`update "persons" as "a" set "year" = $1, "firstname" = $2, "address" = $3, "age" = $4,
+ "type" = $5, "city" = $6, "lastname" = $7 where "a"."personid" = $8 returning *`),
+ })
+ })
+ })
+
+ describe("delete", () => {
+ it("should handle deleting with relationships", () => {
+ const queryJson = getJson("deleteSimple.json")
+ let query = new Sql(SqlClient.POSTGRES, limit)._query(queryJson)
+ expect(query).toEqual({
+ bindings: ["ddd", ""],
+ sql: multiline(`delete from "compositetable" as "a" where "a"."keypartone" = $1 and "a"."keyparttwo" = $2
+ returning "a"."keyparttwo" as "a.keyparttwo", "a"."keypartone" as "a.keypartone", "a"."name" as "a.name"`),
+ })
+ })
+ })
+
+ describe("returning (everything bar Postgres)", () => {
+ it("should be able to handle row returning", () => {
+ const queryJson = getJson("createSimple.json")
+ const SQL = new Sql(SqlClient.MS_SQL, limit)
+ let query = SQL._query(queryJson, { disableReturning: true })
+ expect(query).toEqual({
+ sql: "insert into [people] ([age], [name]) values (@p0, @p1)",
+ bindings: [22, "Test"],
+ })
+
+ // now check returning
+ let returningQuery: Knex.SqlNative = { sql: "", bindings: [] }
+ SQL.getReturningRow((input: Knex.SqlNative) => {
+ returningQuery = input
+ }, queryJson)
+ expect(returningQuery).toEqual({
+ sql: "select * from (select top (@p0) * from [people] where [people].[name] = @p1 and [people].[age] = @p2 order by [people].[name] asc) as [people]",
+ bindings: [1, "Test", 22],
+ })
+ })
+ })
+
+ describe("check max character aliasing", () => {
+ it("should handle over 'z' max character alias", () => {
+ const tableNames = []
+ for (let i = 0; i < 100; i++) {
+ tableNames.push(generator.guid())
+ }
+ const aliasing = new AliasTables(tableNames)
+ let alias: string = ""
+ for (let table of tableNames) {
+ alias = aliasing.getAlias(table)
+ }
+ expect(alias).toEqual("cv")
+ })
+ })
+
+ describe("check aliasing is disabled/enabled", () => {
+ const tables = ["tableA", "tableB"]
+
+ function getDatasource(source: SourceName): Datasource {
+ return {
+ source,
+ type: "datasource",
+ isSQL: true,
+ }
+ }
+
+ function getQuery(op: Operation, fields: string[] = ["a"]): QueryJson {
+ return {
+ endpoint: { datasourceId: "", entityId: "", operation: op },
+ resource: {
+ fields,
+ },
+ }
+ }
+
+ it("should check for Postgres aliased status", () => {
+ const aliasing = new AliasTables(tables)
+ const datasource = getDatasource(SourceName.POSTGRES)
+ expect(
+ aliasing.isAliasingEnabled(getQuery(Operation.CREATE), datasource)
+ ).toEqual(true)
+ expect(
+ aliasing.isAliasingEnabled(getQuery(Operation.READ), datasource)
+ ).toEqual(true)
+ expect(
+ aliasing.isAliasingEnabled(getQuery(Operation.UPDATE), datasource)
+ ).toEqual(true)
+ expect(
+ aliasing.isAliasingEnabled(getQuery(Operation.DELETE), datasource)
+ ).toEqual(true)
+ })
+
+ it("should check for MS-SQL aliased status", () => {
+ const aliasing = new AliasTables(tables)
+ const datasource = getDatasource(SourceName.SQL_SERVER)
+ expect(
+ aliasing.isAliasingEnabled(getQuery(Operation.CREATE), datasource)
+ ).toEqual(false)
+ expect(
+ aliasing.isAliasingEnabled(getQuery(Operation.READ), datasource)
+ ).toEqual(true)
+ expect(
+ aliasing.isAliasingEnabled(getQuery(Operation.UPDATE), datasource)
+ ).toEqual(false)
+ expect(
+ aliasing.isAliasingEnabled(getQuery(Operation.DELETE), datasource)
+ ).toEqual(false)
+ })
+
+ it("should check for MySQL aliased status", () => {
+ const aliasing = new AliasTables(tables)
+ const datasource = getDatasource(SourceName.MYSQL)
+ expect(
+ aliasing.isAliasingEnabled(getQuery(Operation.CREATE), datasource)
+ ).toEqual(false)
+ expect(
+ aliasing.isAliasingEnabled(getQuery(Operation.READ), datasource)
+ ).toEqual(true)
+ expect(
+ aliasing.isAliasingEnabled(getQuery(Operation.UPDATE), datasource)
+ ).toEqual(false)
+ expect(
+ aliasing.isAliasingEnabled(getQuery(Operation.DELETE), datasource)
+ ).toEqual(false)
+ })
+
+ it("should check for Oracle aliased status", () => {
+ const aliasing = new AliasTables(tables)
+ const datasource = getDatasource(SourceName.ORACLE)
+ expect(
+ aliasing.isAliasingEnabled(getQuery(Operation.CREATE), datasource)
+ ).toEqual(false)
+ expect(
+ aliasing.isAliasingEnabled(getQuery(Operation.READ), datasource)
+ ).toEqual(true)
+ expect(
+ aliasing.isAliasingEnabled(getQuery(Operation.UPDATE), datasource)
+ ).toEqual(false)
+ expect(
+ aliasing.isAliasingEnabled(getQuery(Operation.DELETE), datasource)
+ ).toEqual(false)
+ })
+
+ it("should disable aliasing for non-SQL datasources", () => {
+ const aliasing = new AliasTables(tables)
+ expect(
+ aliasing.isAliasingEnabled(getQuery(Operation.READ), {
+ source: SourceName.GOOGLE_SHEETS,
+ type: "datasource",
+ isSQL: false,
+ })
+ )
+ })
+
+ it("should disable when no fields", () => {
+ const aliasing = new AliasTables(tables)
+ const datasource = getDatasource(SourceName.POSTGRES)
+ expect(
+ aliasing.isAliasingEnabled(getQuery(Operation.READ, []), datasource)
+ ).toEqual(false)
+ })
+ })
+
+ describe("check some edge cases", () => {
+ const tableNames = ["hello", "world"]
+
+ it("should handle quoted table names", () => {
+ const aliasing = new AliasTables(tableNames)
+ const aliased = aliasing.aliasField(`"hello"."field"`)
+ expect(aliased).toEqual(`"a"."field"`)
+ })
+
+ it("should handle quoted table names with graves", () => {
+ const aliasing = new AliasTables(tableNames)
+ const aliased = aliasing.aliasField("`hello`.`world`")
+ expect(aliased).toEqual("`a`.`world`")
+ })
+
+ it("should handle table names in table names correctly", () => {
+ const tableNames = ["he", "hell", "hello"]
+ const aliasing = new AliasTables(tableNames)
+ const aliased1 = aliasing.aliasField("`he`.`world`")
+ const aliased2 = aliasing.aliasField("`hell`.`world`")
+ const aliased3 = aliasing.aliasField("`hello`.`world`")
+ expect(aliased1).toEqual("`a`.`world`")
+ expect(aliased2).toEqual("`b`.`world`")
+ expect(aliased3).toEqual("`c`.`world`")
+ })
+ })
+})
diff --git a/packages/server/src/integrations/tests/sqlQueryJson/basicFetchWithRelationships.json b/packages/server/src/integrations/tests/sqlQueryJson/basicFetchWithRelationships.json
new file mode 100644
index 0000000000..ba7fa4ef9b
--- /dev/null
+++ b/packages/server/src/integrations/tests/sqlQueryJson/basicFetchWithRelationships.json
@@ -0,0 +1,183 @@
+{
+ "endpoint": {
+ "datasourceId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7",
+ "entityId": "persons",
+ "operation": "READ"
+ },
+ "resource": {
+ "fields": [
+ "a.year",
+ "a.firstname",
+ "a.personid",
+ "a.address",
+ "a.age",
+ "a.type",
+ "a.city",
+ "a.lastname",
+ "b.executorid",
+ "b.taskname",
+ "b.taskid",
+ "b.completed",
+ "b.qaid",
+ "b.executorid",
+ "b.taskname",
+ "b.taskid",
+ "b.completed",
+ "b.qaid"
+ ]
+ },
+ "filters": {},
+ "sort": {
+ "firstname": {
+ "direction": "ASCENDING"
+ }
+ },
+ "paginate": {
+ "limit": 100,
+ "page": 1
+ },
+ "relationships": [
+ {
+ "tableName": "tasks",
+ "column": "QA",
+ "from": "personid",
+ "to": "qaid",
+ "aliases": {
+ "tasks": "b",
+ "persons": "a"
+ }
+ },
+ {
+ "tableName": "tasks",
+ "column": "executor",
+ "from": "personid",
+ "to": "executorid",
+ "aliases": {
+ "tasks": "b",
+ "persons": "a"
+ }
+ }
+ ],
+ "extra": {
+ "idFilter": {}
+ },
+ "meta": {
+ "table": {
+ "type": "table",
+ "_id": "datasource_plus_8066e56456784eb2a00129d31be5c3e7__persons",
+ "primary": [
+ "personid"
+ ],
+ "name": "persons",
+ "schema": {
+ "year": {
+ "type": "number",
+ "externalType": "integer",
+ "autocolumn": false,
+ "name": "year",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "firstname": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "firstname",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "personid": {
+ "type": "number",
+ "externalType": "integer",
+ "autocolumn": true,
+ "name": "personid",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "address": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "address",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "age": {
+ "type": "number",
+ "externalType": "integer",
+ "autocolumn": false,
+ "name": "age",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "type": {
+ "type": "options",
+ "externalType": "USER-DEFINED",
+ "autocolumn": false,
+ "name": "type",
+ "constraints": {
+ "presence": false,
+ "inclusion": [
+ "support",
+ "designer",
+ "programmer",
+ "qa"
+ ]
+ }
+ },
+ "city": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "city",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "lastname": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "lastname",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "QA": {
+ "tableId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7__tasks",
+ "name": "QA",
+ "relationshipType": "many-to-one",
+ "fieldName": "qaid",
+ "type": "link",
+ "main": true,
+ "_id": "ccb68481c80c34217a4540a2c6c27fe46",
+ "foreignKey": "personid"
+ },
+ "executor": {
+ "tableId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7__tasks",
+ "name": "executor",
+ "relationshipType": "many-to-one",
+ "fieldName": "executorid",
+ "type": "link",
+ "main": true,
+ "_id": "c89530b9770d94bec851e062b5cff3001",
+ "foreignKey": "personid",
+ "tableName": "persons"
+ }
+ },
+ "sourceId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7",
+ "sourceType": "external",
+ "primaryDisplay": "firstname",
+ "views": {}
+ }
+ },
+ "tableAliases": {
+ "persons": "a",
+ "tasks": "b"
+ }
+}
\ No newline at end of file
diff --git a/packages/server/src/integrations/tests/sqlQueryJson/createSimple.json b/packages/server/src/integrations/tests/sqlQueryJson/createSimple.json
new file mode 100644
index 0000000000..33a88d30e1
--- /dev/null
+++ b/packages/server/src/integrations/tests/sqlQueryJson/createSimple.json
@@ -0,0 +1,64 @@
+{
+ "endpoint": {
+ "datasourceId": "datasource_plus_0ed5835e5552496285df546030f7c4ae",
+ "entityId": "people",
+ "operation": "CREATE"
+ },
+ "resource": {
+ "fields": [
+ "a.name",
+ "a.age"
+ ]
+ },
+ "filters": {},
+ "relationships": [],
+ "body": {
+ "name": "Test",
+ "age": 22
+ },
+ "extra": {
+ "idFilter": {
+ "equal": {
+ "name": "Test",
+ "age": 22
+ }
+ }
+ },
+ "meta": {
+ "table": {
+ "_id": "datasource_plus_0ed5835e5552496285df546030f7c4ae__people",
+ "type": "table",
+ "sourceId": "datasource_plus_0ed5835e5552496285df546030f7c4ae",
+ "sourceType": "external",
+ "primary": [
+ "name",
+ "age"
+ ],
+ "name": "people",
+ "schema": {
+ "name": {
+ "type": "string",
+ "externalType": "varchar",
+ "autocolumn": false,
+ "name": "name",
+ "constraints": {
+ "presence": true
+ }
+ },
+ "age": {
+ "type": "number",
+ "externalType": "int",
+ "autocolumn": false,
+ "name": "age",
+ "constraints": {
+ "presence": false
+ }
+ }
+ },
+ "primaryDisplay": "name"
+ }
+ },
+ "tableAliases": {
+ "people": "a"
+ }
+}
\ No newline at end of file
diff --git a/packages/server/src/integrations/tests/sqlQueryJson/createWithRelationships.json b/packages/server/src/integrations/tests/sqlQueryJson/createWithRelationships.json
new file mode 100644
index 0000000000..82d85c417b
--- /dev/null
+++ b/packages/server/src/integrations/tests/sqlQueryJson/createWithRelationships.json
@@ -0,0 +1,173 @@
+{
+ "endpoint": {
+ "datasourceId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7",
+ "entityId": "persons",
+ "operation": "CREATE"
+ },
+ "resource": {
+ "fields": [
+ "a.year",
+ "a.firstname",
+ "a.personid",
+ "a.address",
+ "a.age",
+ "a.type",
+ "a.city",
+ "a.lastname"
+ ]
+ },
+ "filters": {},
+ "relationships": [
+ {
+ "tableName": "tasks",
+ "column": "QA",
+ "from": "personid",
+ "to": "qaid",
+ "aliases": {
+ "tasks": "b",
+ "persons": "a"
+ }
+ },
+ {
+ "tableName": "tasks",
+ "column": "executor",
+ "from": "personid",
+ "to": "executorid",
+ "aliases": {
+ "tasks": "b",
+ "persons": "a"
+ }
+ }
+ ],
+ "body": {
+ "year": 1990,
+ "firstname": "A",
+ "address": "A Street",
+ "age": 34,
+ "type": "designer",
+ "city": "London",
+ "lastname": "B"
+ },
+ "extra": {
+ "idFilter": {}
+ },
+ "meta": {
+ "table": {
+ "type": "table",
+ "_id": "datasource_plus_8066e56456784eb2a00129d31be5c3e7__persons",
+ "primary": [
+ "personid"
+ ],
+ "name": "persons",
+ "schema": {
+ "year": {
+ "type": "number",
+ "externalType": "integer",
+ "autocolumn": false,
+ "name": "year",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "firstname": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "firstname",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "personid": {
+ "type": "number",
+ "externalType": "integer",
+ "autocolumn": true,
+ "name": "personid",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "address": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "address",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "age": {
+ "type": "number",
+ "externalType": "integer",
+ "autocolumn": false,
+ "name": "age",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "type": {
+ "type": "options",
+ "externalType": "USER-DEFINED",
+ "autocolumn": false,
+ "name": "type",
+ "constraints": {
+ "presence": false,
+ "inclusion": [
+ "support",
+ "designer",
+ "programmer",
+ "qa"
+ ]
+ }
+ },
+ "city": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "city",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "lastname": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "lastname",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "QA": {
+ "tableId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7__tasks",
+ "name": "QA",
+ "relationshipType": "many-to-one",
+ "fieldName": "qaid",
+ "type": "link",
+ "main": true,
+ "_id": "ccb68481c80c34217a4540a2c6c27fe46",
+ "foreignKey": "personid"
+ },
+ "executor": {
+ "tableId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7__tasks",
+ "name": "executor",
+ "relationshipType": "many-to-one",
+ "fieldName": "executorid",
+ "type": "link",
+ "main": true,
+ "_id": "c89530b9770d94bec851e062b5cff3001",
+ "foreignKey": "personid",
+ "tableName": "persons"
+ }
+ },
+ "sourceId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7",
+ "sourceType": "external",
+ "primaryDisplay": "firstname",
+ "views": {}
+ }
+ },
+ "tableAliases": {
+ "persons": "a",
+ "tasks": "b"
+ }
+}
\ No newline at end of file
diff --git a/packages/server/src/integrations/tests/sqlQueryJson/deleteSimple.json b/packages/server/src/integrations/tests/sqlQueryJson/deleteSimple.json
new file mode 100644
index 0000000000..d6e099c4b6
--- /dev/null
+++ b/packages/server/src/integrations/tests/sqlQueryJson/deleteSimple.json
@@ -0,0 +1,75 @@
+{
+ "endpoint": {
+ "datasourceId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7",
+ "entityId": "compositetable",
+ "operation": "DELETE"
+ },
+ "resource": {
+ "fields": [
+ "a.keyparttwo",
+ "a.keypartone",
+ "a.name"
+ ]
+ },
+ "filters": {
+ "equal": {
+ "keypartone": "ddd",
+ "keyparttwo": ""
+ }
+ },
+ "relationships": [],
+ "extra": {
+ "idFilter": {
+ "equal": {
+ "keypartone": "ddd",
+ "keyparttwo": ""
+ }
+ }
+ },
+ "meta": {
+ "table": {
+ "type": "table",
+ "_id": "datasource_plus_8066e56456784eb2a00129d31be5c3e7__compositetable",
+ "primary": [
+ "keypartone",
+ "keyparttwo"
+ ],
+ "name": "compositetable",
+ "schema": {
+ "keyparttwo": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "keyparttwo",
+ "constraints": {
+ "presence": true
+ }
+ },
+ "keypartone": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "keypartone",
+ "constraints": {
+ "presence": true
+ }
+ },
+ "name": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "name",
+ "constraints": {
+ "presence": false
+ }
+ }
+ },
+ "sourceId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7",
+ "sourceType": "external",
+ "primaryDisplay": "keypartone"
+ }
+ },
+ "tableAliases": {
+ "compositetable": "a"
+ }
+}
\ No newline at end of file
diff --git a/packages/server/src/integrations/tests/sqlQueryJson/enrichRelationship.json b/packages/server/src/integrations/tests/sqlQueryJson/enrichRelationship.json
new file mode 100644
index 0000000000..d71f0552c6
--- /dev/null
+++ b/packages/server/src/integrations/tests/sqlQueryJson/enrichRelationship.json
@@ -0,0 +1,123 @@
+{
+ "endpoint": {
+ "datasourceId": "datasource_plus_44a967caf37a435f84fe01cd6dfe8f81",
+ "entityId": "tasks",
+ "operation": "READ"
+ },
+ "resource": {
+ "fields": [
+ "a.executorid",
+ "a.taskname",
+ "a.taskid",
+ "a.completed",
+ "a.qaid",
+ "b.productname",
+ "b.productid"
+ ]
+ },
+ "filters": {
+ "oneOf": {
+ "taskid": [
+ 1,
+ 2
+ ]
+ }
+ },
+ "relationships": [
+ {
+ "tableName": "products",
+ "column": "products",
+ "through": "products_tasks",
+ "from": "taskid",
+ "to": "productid",
+ "fromPrimary": "taskid",
+ "toPrimary": "productid",
+ "aliases": {
+ "products_tasks": "c",
+ "products": "b",
+ "tasks": "a"
+ }
+ }
+ ],
+ "extra": {
+ "idFilter": {}
+ },
+ "meta": {
+ "table": {
+ "type": "table",
+ "_id": "datasource_plus_44a967caf37a435f84fe01cd6dfe8f81__tasks",
+ "primary": [
+ "taskid"
+ ],
+ "name": "tasks",
+ "schema": {
+ "executorid": {
+ "type": "number",
+ "externalType": "integer",
+ "autocolumn": false,
+ "name": "executorid",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "taskname": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "taskname",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "taskid": {
+ "type": "number",
+ "externalType": "integer",
+ "autocolumn": true,
+ "name": "taskid",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "completed": {
+ "type": "boolean",
+ "externalType": "boolean",
+ "autocolumn": false,
+ "name": "completed",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "qaid": {
+ "type": "number",
+ "externalType": "integer",
+ "autocolumn": false,
+ "name": "qaid",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "products": {
+ "tableId": "datasource_plus_44a967caf37a435f84fe01cd6dfe8f81__products",
+ "name": "products",
+ "relationshipType": "many-to-many",
+ "through": "datasource_plus_44a967caf37a435f84fe01cd6dfe8f81__products_tasks",
+ "type": "link",
+ "_id": "c3b91d00cd36c4cc1a347794725b9adbd",
+ "fieldName": "productid",
+ "throughFrom": "productid",
+ "throughTo": "taskid"
+ }
+ },
+ "sourceId": "datasource_plus_44a967caf37a435f84fe01cd6dfe8f81",
+ "sourceType": "external",
+ "primaryDisplay": "taskname",
+ "sql": true,
+ "views": {}
+ }
+ },
+ "tableAliases": {
+ "tasks": "a",
+ "products": "b",
+ "products_tasks": "c"
+ }
+}
\ No newline at end of file
diff --git a/packages/server/src/integrations/tests/sqlQueryJson/fetchManyToMany.json b/packages/server/src/integrations/tests/sqlQueryJson/fetchManyToMany.json
new file mode 100644
index 0000000000..cec2fdb025
--- /dev/null
+++ b/packages/server/src/integrations/tests/sqlQueryJson/fetchManyToMany.json
@@ -0,0 +1,109 @@
+{
+ "endpoint": {
+ "datasourceId": "datasource_plus_44a967caf37a435f84fe01cd6dfe8f81",
+ "entityId": "products",
+ "operation": "READ"
+ },
+ "resource": {
+ "fields": [
+ "a.productname",
+ "a.productid",
+ "b.executorid",
+ "b.taskname",
+ "b.taskid",
+ "b.completed",
+ "b.qaid"
+ ]
+ },
+ "filters": {
+ "string": {},
+ "fuzzy": {},
+ "range": {},
+ "equal": {},
+ "notEqual": {},
+ "empty": {},
+ "notEmpty": {},
+ "contains": {},
+ "notContains": {},
+ "oneOf": {},
+ "containsAny": {}
+ },
+ "sort": {
+ "productname": {
+ "direction": "ASCENDING"
+ }
+ },
+ "paginate": {
+ "limit": 100,
+ "page": 1
+ },
+ "relationships": [
+ {
+ "tableName": "tasks",
+ "column": "tasks",
+ "through": "products_tasks",
+ "from": "productid",
+ "to": "taskid",
+ "fromPrimary": "productid",
+ "toPrimary": "taskid",
+ "aliases": {
+ "products_tasks": "c",
+ "tasks": "b",
+ "products": "a"
+ }
+ }
+ ],
+ "extra": {
+ "idFilter": {}
+ },
+ "meta": {
+ "table": {
+ "type": "table",
+ "_id": "datasource_plus_44a967caf37a435f84fe01cd6dfe8f81__products",
+ "primary": [
+ "productid"
+ ],
+ "name": "products",
+ "schema": {
+ "productname": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "productname",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "productid": {
+ "type": "number",
+ "externalType": "integer",
+ "autocolumn": true,
+ "name": "productid",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "tasks": {
+ "tableId": "datasource_plus_44a967caf37a435f84fe01cd6dfe8f81__tasks",
+ "name": "tasks",
+ "relationshipType": "many-to-many",
+ "fieldName": "taskid",
+ "through": "datasource_plus_44a967caf37a435f84fe01cd6dfe8f81__products_tasks",
+ "throughFrom": "taskid",
+ "throughTo": "productid",
+ "type": "link",
+ "main": true,
+ "_id": "c3b91d00cd36c4cc1a347794725b9adbd"
+ }
+ },
+ "sourceId": "datasource_plus_44a967caf37a435f84fe01cd6dfe8f81",
+ "sourceType": "external",
+ "primaryDisplay": "productname"
+ }
+ },
+ "tableAliases": {
+ "products": "a",
+ "tasks": "b",
+ "products_tasks": "c"
+ }
+}
\ No newline at end of file
diff --git a/packages/server/src/integrations/tests/sqlQueryJson/filterByRelationship.json b/packages/server/src/integrations/tests/sqlQueryJson/filterByRelationship.json
new file mode 100644
index 0000000000..399cb0f4d2
--- /dev/null
+++ b/packages/server/src/integrations/tests/sqlQueryJson/filterByRelationship.json
@@ -0,0 +1,94 @@
+{
+ "endpoint": {
+ "datasourceId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7",
+ "entityId": "products",
+ "operation": "READ"
+ },
+ "resource": {
+ "fields": [
+ "a.productname",
+ "a.productid",
+ "b.executorid",
+ "b.taskname",
+ "b.taskid",
+ "b.completed",
+ "b.qaid"
+ ]
+ },
+ "filters": {
+ "equal": {
+ "1:tasks.taskname": "assembling"
+ },
+ "onEmptyFilter": "all"
+ },
+ "sort": {
+ "productname": {
+ "direction": "ASCENDING"
+ }
+ },
+ "paginate": {
+ "limit": 100,
+ "page": 1
+ },
+ "relationships": [
+ {
+ "tableName": "tasks",
+ "column": "tasks",
+ "through": "products_tasks",
+ "from": "productid",
+ "to": "taskid",
+ "fromPrimary": "productid",
+ "toPrimary": "taskid"
+ }
+ ],
+ "tableAliases": {
+ "products_tasks": "c",
+ "tasks": "b",
+ "products": "a"
+ },
+ "meta": {
+ "table": {
+ "type": "table",
+ "_id": "datasource_plus_8066e56456784eb2a00129d31be5c3e7__products",
+ "primary": [
+ "productid"
+ ],
+ "name": "products",
+ "schema": {
+ "productname": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "productname",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "productid": {
+ "type": "number",
+ "externalType": "integer",
+ "autocolumn": true,
+ "name": "productid",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "tasks": {
+ "tableId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7__tasks",
+ "name": "tasks",
+ "relationshipType": "many-to-many",
+ "fieldName": "taskid",
+ "through": "datasource_plus_8066e56456784eb2a00129d31be5c3e7__products_tasks",
+ "throughFrom": "taskid",
+ "throughTo": "productid",
+ "type": "link",
+ "main": true,
+ "_id": "ca6862d9ba09146dd8a68e3b5b7055a09"
+ }
+ },
+ "sourceId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7",
+ "sourceType": "external",
+ "primaryDisplay": "productname"
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/server/src/integrations/tests/sqlQueryJson/manyRelationshipFilters.json b/packages/server/src/integrations/tests/sqlQueryJson/manyRelationshipFilters.json
new file mode 100644
index 0000000000..2b5d156546
--- /dev/null
+++ b/packages/server/src/integrations/tests/sqlQueryJson/manyRelationshipFilters.json
@@ -0,0 +1,202 @@
+{
+ "endpoint": {
+ "datasourceId": "datasource_plus_44a967caf37a435f84fe01cd6dfe8f81",
+ "entityId": "tasks",
+ "operation": "READ"
+ },
+ "resource": {
+ "fields": [
+ "a.executorid",
+ "a.taskname",
+ "a.taskid",
+ "a.completed",
+ "a.qaid",
+ "b.productname",
+ "b.productid",
+ "c.year",
+ "c.firstname",
+ "c.personid",
+ "c.address",
+ "c.age",
+ "c.type",
+ "c.city",
+ "c.lastname",
+ "c.year",
+ "c.firstname",
+ "c.personid",
+ "c.address",
+ "c.age",
+ "c.type",
+ "c.city",
+ "c.lastname"
+ ]
+ },
+ "filters": {
+ "string": {},
+ "fuzzy": {},
+ "range": {
+ "1:persons.year": {
+ "low": 1990,
+ "high": 2147483647
+ }
+ },
+ "equal": {
+ "2:products.productname": "Computers"
+ },
+ "notEqual": {
+ "3:completed": true
+ },
+ "empty": {},
+ "notEmpty": {},
+ "contains": {},
+ "notContains": {},
+ "oneOf": {},
+ "containsAny": {},
+ "onEmptyFilter": "all"
+ },
+ "sort": {
+ "taskname": {
+ "direction": "ASCENDING"
+ }
+ },
+ "paginate": {
+ "limit": 100,
+ "page": 1
+ },
+ "relationships": [
+ {
+ "tableName": "products",
+ "column": "products",
+ "through": "products_tasks",
+ "from": "taskid",
+ "to": "productid",
+ "fromPrimary": "taskid",
+ "toPrimary": "productid",
+ "aliases": {
+ "products_tasks": "d",
+ "products": "b",
+ "tasks": "a"
+ }
+ },
+ {
+ "tableName": "persons",
+ "column": "tasksToExecute",
+ "from": "executorid",
+ "to": "personid",
+ "aliases": {
+ "persons": "c",
+ "tasks": "a"
+ }
+ },
+ {
+ "tableName": "persons",
+ "column": "tasksToQA",
+ "from": "qaid",
+ "to": "personid",
+ "aliases": {
+ "persons": "c",
+ "tasks": "a"
+ }
+ }
+ ],
+ "extra": {
+ "idFilter": {}
+ },
+ "meta": {
+ "table": {
+ "type": "table",
+ "_id": "datasource_plus_44a967caf37a435f84fe01cd6dfe8f81__tasks",
+ "primary": [
+ "taskid"
+ ],
+ "name": "tasks",
+ "schema": {
+ "executorid": {
+ "type": "number",
+ "externalType": "integer",
+ "name": "executorid",
+ "constraints": {
+ "presence": false
+ },
+ "autocolumn": true,
+ "autoReason": "foreign_key"
+ },
+ "taskname": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "taskname",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "taskid": {
+ "type": "number",
+ "externalType": "integer",
+ "autocolumn": true,
+ "name": "taskid",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "completed": {
+ "type": "boolean",
+ "externalType": "boolean",
+ "autocolumn": false,
+ "name": "completed",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "qaid": {
+ "type": "number",
+ "externalType": "integer",
+ "name": "qaid",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "products": {
+ "tableId": "datasource_plus_44a967caf37a435f84fe01cd6dfe8f81__products",
+ "name": "products",
+ "relationshipType": "many-to-many",
+ "through": "datasource_plus_44a967caf37a435f84fe01cd6dfe8f81__products_tasks",
+ "type": "link",
+ "_id": "c3b91d00cd36c4cc1a347794725b9adbd",
+ "fieldName": "productid",
+ "throughFrom": "productid",
+ "throughTo": "taskid"
+ },
+ "tasksToExecute": {
+ "tableId": "datasource_plus_44a967caf37a435f84fe01cd6dfe8f81__persons",
+ "name": "tasksToExecute",
+ "relationshipType": "one-to-many",
+ "type": "link",
+ "_id": "c0f440590bda04f28846242156c1dd60b",
+ "foreignKey": "executorid",
+ "fieldName": "personid"
+ },
+ "tasksToQA": {
+ "tableId": "datasource_plus_44a967caf37a435f84fe01cd6dfe8f81__persons",
+ "name": "tasksToQA",
+ "relationshipType": "one-to-many",
+ "type": "link",
+ "_id": "c5fdf453a0ba743d58e29491d174c974b",
+ "foreignKey": "qaid",
+ "fieldName": "personid"
+ }
+ },
+ "sourceId": "datasource_plus_44a967caf37a435f84fe01cd6dfe8f81",
+ "sourceType": "external",
+ "primaryDisplay": "taskname",
+ "sql": true,
+ "views": {}
+ }
+ },
+ "tableAliases": {
+ "tasks": "a",
+ "products": "b",
+ "persons": "c",
+ "products_tasks": "d"
+ }
+}
\ No newline at end of file
diff --git a/packages/server/src/integrations/tests/sqlQueryJson/updateRelationship.json b/packages/server/src/integrations/tests/sqlQueryJson/updateRelationship.json
new file mode 100644
index 0000000000..42c2a44335
--- /dev/null
+++ b/packages/server/src/integrations/tests/sqlQueryJson/updateRelationship.json
@@ -0,0 +1,181 @@
+{
+ "endpoint": {
+ "datasourceId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7",
+ "entityId": "persons",
+ "operation": "UPDATE"
+ },
+ "resource": {
+ "fields": [
+ "a.year",
+ "a.firstname",
+ "a.personid",
+ "a.address",
+ "a.age",
+ "a.type",
+ "a.city",
+ "a.lastname"
+ ]
+ },
+ "filters": {
+ "equal": {
+ "personid": 5
+ }
+ },
+ "relationships": [
+ {
+ "tableName": "tasks",
+ "column": "QA",
+ "from": "personid",
+ "to": "qaid",
+ "aliases": {
+ "tasks": "b",
+ "persons": "a"
+ }
+ },
+ {
+ "tableName": "tasks",
+ "column": "executor",
+ "from": "personid",
+ "to": "executorid",
+ "aliases": {
+ "tasks": "b",
+ "persons": "a"
+ }
+ }
+ ],
+ "body": {
+ "year": 1990,
+ "firstname": "C",
+ "address": "A Street",
+ "age": 34,
+ "type": "designer",
+ "city": "London",
+ "lastname": "B"
+ },
+ "extra": {
+ "idFilter": {
+ "equal": {
+ "personid": 5
+ }
+ }
+ },
+ "meta": {
+ "table": {
+ "type": "table",
+ "_id": "datasource_plus_8066e56456784eb2a00129d31be5c3e7__persons",
+ "primary": [
+ "personid"
+ ],
+ "name": "persons",
+ "schema": {
+ "year": {
+ "type": "number",
+ "externalType": "integer",
+ "autocolumn": false,
+ "name": "year",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "firstname": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "firstname",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "personid": {
+ "type": "number",
+ "externalType": "integer",
+ "autocolumn": true,
+ "name": "personid",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "address": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "address",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "age": {
+ "type": "number",
+ "externalType": "integer",
+ "autocolumn": false,
+ "name": "age",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "type": {
+ "type": "options",
+ "externalType": "USER-DEFINED",
+ "autocolumn": false,
+ "name": "type",
+ "constraints": {
+ "presence": false,
+ "inclusion": [
+ "support",
+ "designer",
+ "programmer",
+ "qa"
+ ]
+ }
+ },
+ "city": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "city",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "lastname": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "lastname",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "QA": {
+ "tableId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7__tasks",
+ "name": "QA",
+ "relationshipType": "many-to-one",
+ "fieldName": "qaid",
+ "type": "link",
+ "main": true,
+ "_id": "ccb68481c80c34217a4540a2c6c27fe46",
+ "foreignKey": "personid"
+ },
+ "executor": {
+ "tableId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7__tasks",
+ "name": "executor",
+ "relationshipType": "many-to-one",
+ "fieldName": "executorid",
+ "type": "link",
+ "main": true,
+ "_id": "c89530b9770d94bec851e062b5cff3001",
+ "foreignKey": "personid",
+ "tableName": "persons"
+ }
+ },
+ "sourceId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7",
+ "sourceType": "external",
+ "primaryDisplay": "firstname",
+ "views": {}
+ }
+ },
+ "tableAliases": {
+ "persons": "a",
+ "tasks": "b"
+ }
+}
\ No newline at end of file
diff --git a/packages/server/src/integrations/tests/sqlQueryJson/updateSimple.json b/packages/server/src/integrations/tests/sqlQueryJson/updateSimple.json
new file mode 100644
index 0000000000..42c2a44335
--- /dev/null
+++ b/packages/server/src/integrations/tests/sqlQueryJson/updateSimple.json
@@ -0,0 +1,181 @@
+{
+ "endpoint": {
+ "datasourceId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7",
+ "entityId": "persons",
+ "operation": "UPDATE"
+ },
+ "resource": {
+ "fields": [
+ "a.year",
+ "a.firstname",
+ "a.personid",
+ "a.address",
+ "a.age",
+ "a.type",
+ "a.city",
+ "a.lastname"
+ ]
+ },
+ "filters": {
+ "equal": {
+ "personid": 5
+ }
+ },
+ "relationships": [
+ {
+ "tableName": "tasks",
+ "column": "QA",
+ "from": "personid",
+ "to": "qaid",
+ "aliases": {
+ "tasks": "b",
+ "persons": "a"
+ }
+ },
+ {
+ "tableName": "tasks",
+ "column": "executor",
+ "from": "personid",
+ "to": "executorid",
+ "aliases": {
+ "tasks": "b",
+ "persons": "a"
+ }
+ }
+ ],
+ "body": {
+ "year": 1990,
+ "firstname": "C",
+ "address": "A Street",
+ "age": 34,
+ "type": "designer",
+ "city": "London",
+ "lastname": "B"
+ },
+ "extra": {
+ "idFilter": {
+ "equal": {
+ "personid": 5
+ }
+ }
+ },
+ "meta": {
+ "table": {
+ "type": "table",
+ "_id": "datasource_plus_8066e56456784eb2a00129d31be5c3e7__persons",
+ "primary": [
+ "personid"
+ ],
+ "name": "persons",
+ "schema": {
+ "year": {
+ "type": "number",
+ "externalType": "integer",
+ "autocolumn": false,
+ "name": "year",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "firstname": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "firstname",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "personid": {
+ "type": "number",
+ "externalType": "integer",
+ "autocolumn": true,
+ "name": "personid",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "address": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "address",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "age": {
+ "type": "number",
+ "externalType": "integer",
+ "autocolumn": false,
+ "name": "age",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "type": {
+ "type": "options",
+ "externalType": "USER-DEFINED",
+ "autocolumn": false,
+ "name": "type",
+ "constraints": {
+ "presence": false,
+ "inclusion": [
+ "support",
+ "designer",
+ "programmer",
+ "qa"
+ ]
+ }
+ },
+ "city": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "city",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "lastname": {
+ "type": "string",
+ "externalType": "character varying",
+ "autocolumn": false,
+ "name": "lastname",
+ "constraints": {
+ "presence": false
+ }
+ },
+ "QA": {
+ "tableId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7__tasks",
+ "name": "QA",
+ "relationshipType": "many-to-one",
+ "fieldName": "qaid",
+ "type": "link",
+ "main": true,
+ "_id": "ccb68481c80c34217a4540a2c6c27fe46",
+ "foreignKey": "personid"
+ },
+ "executor": {
+ "tableId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7__tasks",
+ "name": "executor",
+ "relationshipType": "many-to-one",
+ "fieldName": "executorid",
+ "type": "link",
+ "main": true,
+ "_id": "c89530b9770d94bec851e062b5cff3001",
+ "foreignKey": "personid",
+ "tableName": "persons"
+ }
+ },
+ "sourceId": "datasource_plus_8066e56456784eb2a00129d31be5c3e7",
+ "sourceType": "external",
+ "primaryDisplay": "firstname",
+ "views": {}
+ }
+ },
+ "tableAliases": {
+ "persons": "a",
+ "tasks": "b"
+ }
+}
\ No newline at end of file
diff --git a/packages/server/src/migrations/functions/usageQuotas/tests/syncApps.spec.ts b/packages/server/src/migrations/functions/usageQuotas/tests/syncApps.spec.ts
index d0d50395b2..1d4d4d0f71 100644
--- a/packages/server/src/migrations/functions/usageQuotas/tests/syncApps.spec.ts
+++ b/packages/server/src/migrations/functions/usageQuotas/tests/syncApps.spec.ts
@@ -13,7 +13,7 @@ describe("syncApps", () => {
afterAll(config.end)
it("runs successfully", async () => {
- return config.doInContext(null, async () => {
+ return config.doInContext(undefined, async () => {
// create the usage quota doc and mock usages
await quotas.getQuotaUsage()
await quotas.setUsage(3, StaticQuotaName.APPS, QuotaUsageType.STATIC)
diff --git a/packages/server/src/migrations/functions/usageQuotas/tests/syncCreators.spec.ts b/packages/server/src/migrations/functions/usageQuotas/tests/syncCreators.spec.ts
index 75fa9f217e..93b7d4949b 100644
--- a/packages/server/src/migrations/functions/usageQuotas/tests/syncCreators.spec.ts
+++ b/packages/server/src/migrations/functions/usageQuotas/tests/syncCreators.spec.ts
@@ -12,8 +12,8 @@ describe("syncCreators", () => {
afterAll(config.end)
it("syncs creators", async () => {
- return config.doInContext(null, async () => {
- await config.createUser({ admin: true })
+ return config.doInContext(undefined, async () => {
+ await config.createUser({ admin: { global: true } })
await syncCreators.run()
diff --git a/packages/server/src/migrations/functions/usageQuotas/tests/syncRows.spec.ts b/packages/server/src/migrations/functions/usageQuotas/tests/syncRows.spec.ts
index e644d605b6..730278683c 100644
--- a/packages/server/src/migrations/functions/usageQuotas/tests/syncRows.spec.ts
+++ b/packages/server/src/migrations/functions/usageQuotas/tests/syncRows.spec.ts
@@ -14,7 +14,7 @@ describe("syncRows", () => {
afterAll(config.end)
it("runs successfully", async () => {
- return config.doInContext(null, async () => {
+ return config.doInContext(undefined, async () => {
// create the usage quota doc and mock usages
await quotas.getQuotaUsage()
await quotas.setUsage(300, StaticQuotaName.ROWS, QuotaUsageType.STATIC)
diff --git a/packages/server/src/migrations/functions/usageQuotas/tests/syncUsers.spec.ts b/packages/server/src/migrations/functions/usageQuotas/tests/syncUsers.spec.ts
index f7500c8b4d..2731cc041d 100644
--- a/packages/server/src/migrations/functions/usageQuotas/tests/syncUsers.spec.ts
+++ b/packages/server/src/migrations/functions/usageQuotas/tests/syncUsers.spec.ts
@@ -12,7 +12,7 @@ describe("syncUsers", () => {
afterAll(config.end)
it("syncs users", async () => {
- return config.doInContext(null, async () => {
+ return config.doInContext(undefined, async () => {
await config.createUser()
await syncUsers.run()
diff --git a/packages/server/src/migrations/tests/index.spec.ts b/packages/server/src/migrations/tests/index.spec.ts
index c01040593a..236776cd3f 100644
--- a/packages/server/src/migrations/tests/index.spec.ts
+++ b/packages/server/src/migrations/tests/index.spec.ts
@@ -40,7 +40,7 @@ describe("migrations", () => {
describe("backfill", () => {
it("runs app db migration", async () => {
- await config.doInContext(null, async () => {
+ await config.doInContext(undefined, async () => {
await clearMigrations()
await config.createAutomation()
await config.createAutomation(structures.newAutomation())
@@ -93,18 +93,18 @@ describe("migrations", () => {
})
it("runs global db migration", async () => {
- await config.doInContext(null, async () => {
+ await config.doInContext(undefined, async () => {
await clearMigrations()
- const appId = config.prodAppId
+ const appId = config.getProdAppId()
const roles = { [appId]: "role_12345" }
await config.createUser({
- builder: false,
- admin: true,
+ builder: { global: false },
+ admin: { global: true },
roles,
}) // admin only
await config.createUser({
- builder: false,
- admin: false,
+ builder: { global: false },
+ admin: { global: false },
roles,
}) // non admin non builder
await config.createTable()
diff --git a/packages/server/src/sdk/app/applications/import.ts b/packages/server/src/sdk/app/applications/import.ts
index c3415bdb36..f712548fcb 100644
--- a/packages/server/src/sdk/app/applications/import.ts
+++ b/packages/server/src/sdk/app/applications/import.ts
@@ -85,7 +85,9 @@ async function getImportableDocuments(db: Database) {
const docPromises = []
for (let docType of DocumentTypesToImport) {
docPromises.push(
- db.allDocs(dbCore.getDocParams(docType, null, { include_docs: true }))
+ db.allDocs(
+ dbCore.getDocParams(docType, null, { include_docs: true })
+ )
)
}
// map the responses to the document itself
diff --git a/packages/server/src/sdk/app/applications/tests/sync.spec.ts b/packages/server/src/sdk/app/applications/tests/sync.spec.ts
index 1d28ed977c..a53bdb0bd7 100644
--- a/packages/server/src/sdk/app/applications/tests/sync.spec.ts
+++ b/packages/server/src/sdk/app/applications/tests/sync.spec.ts
@@ -43,8 +43,8 @@ async function createUser(email: string, roles: UserRoles, builder?: boolean) {
const user = await config.createUser({
email,
roles,
- builder: builder || false,
- admin: false,
+ builder: { global: builder || false },
+ admin: { global: false },
})
await context.doInContext(config.appId!, async () => {
await events.user.created(user)
@@ -55,10 +55,10 @@ async function createUser(email: string, roles: UserRoles, builder?: boolean) {
async function removeUserRole(user: User) {
const final = await config.globalUser({
...user,
- id: user._id,
+ _id: user._id,
roles: {},
- builder: false,
- admin: false,
+ builder: { global: false },
+ admin: { global: false },
})
await context.doInContext(config.appId!, async () => {
await events.user.updated(final)
@@ -69,8 +69,8 @@ async function createGroupAndUser(email: string) {
groupUser = await config.createUser({
email,
roles: {},
- builder: false,
- admin: false,
+ builder: { global: false },
+ admin: { global: false },
})
group = await config.createGroup()
await config.addUserToGroup(group._id!, groupUser._id!)
diff --git a/packages/server/src/sdk/app/datasources/datasources.ts b/packages/server/src/sdk/app/datasources/datasources.ts
index c71c3f1b31..fd0d291d91 100644
--- a/packages/server/src/sdk/app/datasources/datasources.ts
+++ b/packages/server/src/sdk/app/datasources/datasources.ts
@@ -229,7 +229,7 @@ export async function removeSecretSingle(datasource: Datasource) {
}
export function mergeConfigs(update: Datasource, old: Datasource) {
- if (!update.config) {
+ if (!update.config || !old.config) {
return update
}
// specific to REST datasources, fix the auth configs again if required
diff --git a/packages/server/src/sdk/app/datasources/plus.ts b/packages/server/src/sdk/app/datasources/plus.ts
index 04cd508863..31ec51c728 100644
--- a/packages/server/src/sdk/app/datasources/plus.ts
+++ b/packages/server/src/sdk/app/datasources/plus.ts
@@ -3,12 +3,33 @@ import {
DatasourcePlus,
IntegrationBase,
Schema,
+ Table,
} from "@budibase/types"
import * as datasources from "./datasources"
import tableSdk from "../tables"
import { getIntegration } from "../../../integrations"
import { context } from "@budibase/backend-core"
+function checkForSchemaErrors(schema: Record) {
+ const errors: Record = {}
+ for (let [tableName, table] of Object.entries(schema)) {
+ if (tableName.includes(".")) {
+ errors[tableName] = "Table names containing dots are not supported."
+ } else {
+ const columnNames = Object.keys(table.schema)
+ const invalidColumnName = columnNames.find(columnName =>
+ columnName.includes(".")
+ )
+ if (invalidColumnName) {
+ errors[
+ tableName
+ ] = `Column '${invalidColumnName}' is not supported as it contains a dot.`
+ }
+ }
+ }
+ return errors
+}
+
export async function buildFilteredSchema(
datasource: Datasource,
filter?: string[]
@@ -30,16 +51,19 @@ export async function buildFilteredSchema(
filteredSchema.errors[key] = schema.errors[key]
}
}
- return filteredSchema
+
+ return {
+ ...filteredSchema,
+ errors: {
+ ...filteredSchema.errors,
+ ...checkForSchemaErrors(filteredSchema.tables),
+ },
+ }
}
async function buildSchemaHelper(datasource: Datasource): Promise {
const connector = (await getConnector(datasource)) as DatasourcePlus
- const externalSchema = await connector.buildSchema(
- datasource._id!,
- datasource.entities!
- )
- return externalSchema
+ return await connector.buildSchema(datasource._id!, datasource.entities!)
}
export async function getConnector(
diff --git a/packages/server/src/sdk/app/rows/search/external.ts b/packages/server/src/sdk/app/rows/search/external.ts
index e2d1a1b32c..e95b904767 100644
--- a/packages/server/src/sdk/app/rows/search/external.ts
+++ b/packages/server/src/sdk/app/rows/search/external.ts
@@ -11,7 +11,10 @@ import {
import * as exporters from "../../../../api/controllers/view/exporters"
import sdk from "../../../../sdk"
import { handleRequest } from "../../../../api/controllers/row/external"
-import { breakExternalTableId } from "../../../../integrations/utils"
+import {
+ breakExternalTableId,
+ breakRowIdField,
+} from "../../../../integrations/utils"
import { cleanExportRows } from "../utils"
import { utils } from "@budibase/shared-core"
import { ExportRowsParams, ExportRowsResult } from "../search"
@@ -52,6 +55,15 @@ export async function search(options: SearchParams) {
}
}
+ // Make sure oneOf _id queries decode the Row IDs
+ if (query?.oneOf?._id) {
+ const rowIds = query.oneOf._id
+ query.oneOf._id = rowIds.map((row: string) => {
+ const ids = breakRowIdField(row)
+ return ids[0]
+ })
+ }
+
try {
const table = await sdk.tables.getTable(tableId)
options = searchInputMapping(table, options)
@@ -119,9 +131,7 @@ export async function exportRows(
requestQuery = {
oneOf: {
_id: rowIds.map((row: string) => {
- const ids = JSON.parse(
- decodeURI(row).replace(/'/g, `"`).replace(/%2C/g, ",")
- )
+ const ids = breakRowIdField(row)
if (ids.length > 1) {
throw new HTTPError(
"Export data does not support composite keys.",
diff --git a/packages/server/src/sdk/app/rows/search/tests/external.spec.ts b/packages/server/src/sdk/app/rows/search/tests/external.spec.ts
index 1aaea8e258..bae84592ca 100644
--- a/packages/server/src/sdk/app/rows/search/tests/external.spec.ts
+++ b/packages/server/src/sdk/app/rows/search/tests/external.spec.ts
@@ -21,10 +21,11 @@ jest.unmock("mysql2/promise")
jest.setTimeout(30000)
-describe.skip("external", () => {
+describe("external search", () => {
const config = new TestConfiguration()
let externalDatasource: Datasource, tableData: Table
+ const rows: Row[] = []
beforeAll(async () => {
const container = await new GenericContainer("mysql")
@@ -89,67 +90,81 @@ describe.skip("external", () => {
},
},
}
+
+ const table = await config.createExternalTable({
+ ...tableData,
+ sourceId: externalDatasource._id,
+ })
+ for (let i = 0; i < 10; i++) {
+ rows.push(
+ await config.createRow({
+ tableId: table._id,
+ name: generator.first(),
+ surname: generator.last(),
+ age: generator.age(),
+ address: generator.address(),
+ })
+ )
+ }
})
- describe("search", () => {
- const rows: Row[] = []
- beforeAll(async () => {
- const table = await config.createExternalTable({
- ...tableData,
- sourceId: externalDatasource._id,
- })
- for (let i = 0; i < 10; i++) {
- rows.push(
- await config.createRow({
- tableId: table._id,
- name: generator.first(),
- surname: generator.last(),
- age: generator.age(),
- address: generator.address(),
- })
- )
+ it("default search returns all the data", async () => {
+ await config.doInContext(config.appId, async () => {
+ const tableId = config.table!._id!
+
+ const searchParams: SearchParams = {
+ tableId,
+ query: {},
}
+ const result = await search(searchParams)
+
+ expect(result.rows).toHaveLength(10)
+ expect(result.rows).toEqual(
+ expect.arrayContaining(rows.map(r => expect.objectContaining(r)))
+ )
})
+ })
- it("default search returns all the data", async () => {
- await config.doInContext(config.appId, async () => {
- const tableId = config.table!._id!
+ it("querying by fields will always return data attribute columns", async () => {
+ await config.doInContext(config.appId, async () => {
+ const tableId = config.table!._id!
- const searchParams: SearchParams = {
- tableId,
- query: {},
- }
- const result = await search(searchParams)
+ const searchParams: SearchParams = {
+ tableId,
+ query: {},
+ fields: ["name", "age"],
+ }
+ const result = await search(searchParams)
- expect(result.rows).toHaveLength(10)
- expect(result.rows).toEqual(
- expect.arrayContaining(rows.map(r => expect.objectContaining(r)))
+ expect(result.rows).toHaveLength(10)
+ expect(result.rows).toEqual(
+ expect.arrayContaining(
+ rows.map(r => ({
+ ...expectAnyExternalColsAttributes,
+ name: r.name,
+ age: r.age,
+ }))
)
- })
+ )
})
+ })
- it("querying by fields will always return data attribute columns", async () => {
- await config.doInContext(config.appId, async () => {
- const tableId = config.table!._id!
+ it("will decode _id in oneOf query", async () => {
+ await config.doInContext(config.appId, async () => {
+ const tableId = config.table!._id!
- const searchParams: SearchParams = {
- tableId,
- query: {},
- fields: ["name", "age"],
- }
- const result = await search(searchParams)
+ const searchParams: SearchParams = {
+ tableId,
+ query: {
+ oneOf: {
+ _id: ["%5B1%5D", "%5B4%5D", "%5B8%5D"],
+ },
+ },
+ }
+ const result = await search(searchParams)
- expect(result.rows).toHaveLength(10)
- expect(result.rows).toEqual(
- expect.arrayContaining(
- rows.map(r => ({
- ...expectAnyExternalColsAttributes,
- name: r.name,
- age: r.age,
- }))
- )
- )
- })
+ expect(result.rows).toHaveLength(3)
+ expect(result.rows.map(row => row.id)).toEqual([1, 4, 8])
})
})
})
diff --git a/packages/server/src/sdk/app/rows/search/utils.ts b/packages/server/src/sdk/app/rows/search/utils.ts
index 4eee3cea41..5d93dcaca2 100644
--- a/packages/server/src/sdk/app/rows/search/utils.ts
+++ b/packages/server/src/sdk/app/rows/search/utils.ts
@@ -1,6 +1,5 @@
import {
FieldType,
- FieldTypeSubtypes,
SearchParams,
Table,
DocumentType,
diff --git a/packages/server/src/sdk/app/rows/tests/internal.spec.ts b/packages/server/src/sdk/app/rows/tests/internal.spec.ts
index dda41d5720..877bd1e6dc 100644
--- a/packages/server/src/sdk/app/rows/tests/internal.spec.ts
+++ b/packages/server/src/sdk/app/rows/tests/internal.spec.ts
@@ -81,7 +81,7 @@ describe("sdk >> rows >> internal", () => {
const response = await internalSdk.save(
table._id!,
row,
- config.user._id
+ config.getUser()._id
)
expect(response).toEqual({
@@ -98,7 +98,10 @@ describe("sdk >> rows >> internal", () => {
},
})
- const persistedRow = await config.getRow(table._id!, response.row._id!)
+ const persistedRow = await config.api.row.get(
+ table._id!,
+ response.row._id!
+ )
expect(persistedRow).toEqual({
...row,
type: "row",
@@ -129,7 +132,7 @@ describe("sdk >> rows >> internal", () => {
const response = await internalSdk.save(
table._id!,
row,
- config.user._id
+ config.getUser()._id
)
expect(response).toEqual({
@@ -157,7 +160,10 @@ describe("sdk >> rows >> internal", () => {
},
})
- const persistedRow = await config.getRow(table._id!, response.row._id!)
+ const persistedRow = await config.api.row.get(
+ table._id!,
+ response.row._id!
+ )
expect(persistedRow).toEqual({
...row,
type: "row",
@@ -190,15 +196,15 @@ describe("sdk >> rows >> internal", () => {
await config.doInContext(config.appId, async () => {
for (const row of makeRows(5)) {
- await internalSdk.save(table._id!, row, config.user._id)
+ await internalSdk.save(table._id!, row, config.getUser()._id)
}
await Promise.all(
makeRows(10).map(row =>
- internalSdk.save(table._id!, row, config.user._id)
+ internalSdk.save(table._id!, row, config.getUser()._id)
)
)
for (const row of makeRows(5)) {
- await internalSdk.save(table._id!, row, config.user._id)
+ await internalSdk.save(table._id!, row, config.getUser()._id)
}
})
diff --git a/packages/server/src/sdk/app/rows/utils.ts b/packages/server/src/sdk/app/rows/utils.ts
index 0ff85f40ac..6e3e25364e 100644
--- a/packages/server/src/sdk/app/rows/utils.ts
+++ b/packages/server/src/sdk/app/rows/utils.ts
@@ -1,12 +1,55 @@
import cloneDeep from "lodash/cloneDeep"
import validateJs from "validate.js"
-import { FieldType, Row, Table, TableSchema } from "@budibase/types"
+import {
+ Datasource,
+ DatasourcePlusQueryResponse,
+ FieldType,
+ QueryJson,
+ Row,
+ SourceName,
+ Table,
+ TableSchema,
+} from "@budibase/types"
import { makeExternalQuery } from "../../../integrations/base/query"
import { Format } from "../../../api/controllers/view/exporters"
import sdk from "../.."
import { isRelationshipColumn } from "../../../db/utils"
+import { SqlClient } from "../../../integrations/utils"
-export async function getDatasourceAndQuery(json: any) {
+const SQL_CLIENT_SOURCE_MAP: Record = {
+ [SourceName.POSTGRES]: SqlClient.POSTGRES,
+ [SourceName.MYSQL]: SqlClient.MY_SQL,
+ [SourceName.SQL_SERVER]: SqlClient.MS_SQL,
+ [SourceName.ORACLE]: SqlClient.ORACLE,
+ [SourceName.DYNAMODB]: undefined,
+ [SourceName.MONGODB]: undefined,
+ [SourceName.ELASTICSEARCH]: undefined,
+ [SourceName.COUCHDB]: undefined,
+ [SourceName.S3]: undefined,
+ [SourceName.AIRTABLE]: undefined,
+ [SourceName.ARANGODB]: undefined,
+ [SourceName.REST]: undefined,
+ [SourceName.FIRESTORE]: undefined,
+ [SourceName.GOOGLE_SHEETS]: undefined,
+ [SourceName.REDIS]: undefined,
+ [SourceName.SNOWFLAKE]: undefined,
+ [SourceName.BUDIBASE]: undefined,
+}
+
+export function getSQLClient(datasource: Datasource): SqlClient {
+ if (!datasource.isSQL) {
+ throw new Error("Cannot get SQL Client for non-SQL datasource")
+ }
+ const lookup = SQL_CLIENT_SOURCE_MAP[datasource.source]
+ if (lookup) {
+ return lookup
+ }
+ throw new Error("Unable to determine client for SQL datasource")
+}
+
+export async function getDatasourceAndQuery(
+ json: QueryJson
+): DatasourcePlusQueryResponse {
const datasourceId = json.endpoint.datasourceId
const datasource = await sdk.datasources.get(datasourceId)
return makeExternalQuery(datasource, json)
diff --git a/packages/server/src/sdk/app/tables/external/index.ts b/packages/server/src/sdk/app/tables/external/index.ts
index 9a2bed0da2..0ace19d00e 100644
--- a/packages/server/src/sdk/app/tables/external/index.ts
+++ b/packages/server/src/sdk/app/tables/external/index.ts
@@ -3,6 +3,7 @@ import {
Operation,
RelationshipType,
RenameColumn,
+ AddColumn,
Table,
TableRequest,
ViewV2,
@@ -32,7 +33,7 @@ import * as viewSdk from "../../views"
export async function save(
datasourceId: string,
update: Table,
- opts?: { tableId?: string; renaming?: RenameColumn }
+ opts?: { tableId?: string; renaming?: RenameColumn; adding?: AddColumn }
) {
let tableToSave: TableRequest = {
...update,
@@ -165,8 +166,17 @@ export async function save(
// remove the rename prop
delete tableToSave._rename
+
+ // if adding a new column, we need to rebuild the schema for that table to get the 'externalType' of the column
+ if (opts?.adding) {
+ datasource.entities[tableToSave.name] = (
+ await datasourceSdk.buildFilteredSchema(datasource, [tableToSave.name])
+ ).tables[tableToSave.name]
+ } else {
+ datasource.entities[tableToSave.name] = tableToSave
+ }
+
// store it into couch now for budibase reference
- datasource.entities[tableToSave.name] = tableToSave
await db.put(populateExternalTableSchemas(datasource))
// Since tables are stored inside datasources, we need to notify clients
diff --git a/packages/server/src/sdk/users/tests/utils.spec.ts b/packages/server/src/sdk/users/tests/utils.spec.ts
index efe790d49b..6f1c5afd3d 100644
--- a/packages/server/src/sdk/users/tests/utils.spec.ts
+++ b/packages/server/src/sdk/users/tests/utils.spec.ts
@@ -22,15 +22,18 @@ describe("syncGlobalUsers", () => {
expect(metadata).toHaveLength(1)
expect(metadata).toEqual([
expect.objectContaining({
- _id: db.generateUserMetadataID(config.user._id),
+ _id: db.generateUserMetadataID(config.getUser()._id!),
}),
])
})
})
it("admin and builders users are synced", async () => {
- const user1 = await config.createUser({ admin: true })
- const user2 = await config.createUser({ admin: false, builder: true })
+ const user1 = await config.createUser({ admin: { global: true } })
+ const user2 = await config.createUser({
+ admin: { global: false },
+ builder: { global: true },
+ })
await config.doInContext(config.appId, async () => {
expect(await rawUserMetadata()).toHaveLength(1)
await syncGlobalUsers()
@@ -51,7 +54,10 @@ describe("syncGlobalUsers", () => {
})
it("app users are not synced if not specified", async () => {
- const user = await config.createUser({ admin: false, builder: false })
+ const user = await config.createUser({
+ admin: { global: false },
+ builder: { global: false },
+ })
await config.doInContext(config.appId, async () => {
await syncGlobalUsers()
@@ -68,8 +74,14 @@ describe("syncGlobalUsers", () => {
it("app users are added when group is assigned to app", async () => {
await config.doInTenant(async () => {
const group = await proSdk.groups.save(structures.userGroups.userGroup())
- const user1 = await config.createUser({ admin: false, builder: false })
- const user2 = await config.createUser({ admin: false, builder: false })
+ const user1 = await config.createUser({
+ admin: { global: false },
+ builder: { global: false },
+ })
+ const user2 = await config.createUser({
+ admin: { global: false },
+ builder: { global: false },
+ })
await proSdk.groups.addUsers(group.id, [user1._id!, user2._id!])
await config.doInContext(config.appId, async () => {
@@ -103,8 +115,14 @@ describe("syncGlobalUsers", () => {
it("app users are removed when app is removed from user group", async () => {
await config.doInTenant(async () => {
const group = await proSdk.groups.save(structures.userGroups.userGroup())
- const user1 = await config.createUser({ admin: false, builder: false })
- const user2 = await config.createUser({ admin: false, builder: false })
+ const user1 = await config.createUser({
+ admin: { global: false },
+ builder: { global: false },
+ })
+ const user2 = await config.createUser({
+ admin: { global: false },
+ builder: { global: false },
+ })
await proSdk.groups.updateGroupApps(group.id, {
appsToAdd: [
{ appId: config.prodAppId!, roleId: roles.BUILTIN_ROLE_IDS.BASIC },
diff --git a/packages/server/src/startup.ts b/packages/server/src/startup.ts
index f9b5974eb2..abe931f503 100644
--- a/packages/server/src/startup.ts
+++ b/packages/server/src/startup.ts
@@ -38,6 +38,7 @@ async function initRoutes(app: Koa) {
// api routes
app.use(api.router.routes())
+ app.use(api.router.allowedMethods())
}
async function initPro() {
diff --git a/packages/server/src/tests/jestEnv.ts b/packages/server/src/tests/jestEnv.ts
index 4763208c54..7c58470e9b 100644
--- a/packages/server/src/tests/jestEnv.ts
+++ b/packages/server/src/tests/jestEnv.ts
@@ -10,3 +10,4 @@ process.env.MOCK_REDIS = "1"
process.env.PLATFORM_URL = "http://localhost:10000"
process.env.REDIS_PASSWORD = "budibase"
process.env.BUDIBASE_VERSION = "0.0.0+jest"
+process.env.WORKER_URL = "http://localhost:10000"
diff --git a/packages/server/src/tests/utilities/TestConfiguration.ts b/packages/server/src/tests/utilities/TestConfiguration.ts
index 22bb66b130..316f266653 100644
--- a/packages/server/src/tests/utilities/TestConfiguration.ts
+++ b/packages/server/src/tests/utilities/TestConfiguration.ts
@@ -49,25 +49,31 @@ import {
AuthToken,
Automation,
CreateViewRequest,
+ Ctx,
Datasource,
FieldType,
INTERNAL_TABLE_SOURCE_ID,
+ Layout,
+ Query,
RelationshipFieldMetadata,
RelationshipType,
Row,
+ Screen,
SearchParams,
SourceName,
Table,
TableSourceType,
User,
- UserRoles,
+ UserCtx,
View,
+ Webhook,
WithRequired,
} from "@budibase/types"
import API from "./api"
import { cloneDeep } from "lodash"
import jwt, { Secret } from "jsonwebtoken"
+import { Server } from "http"
mocks.licenses.init(pro)
@@ -82,27 +88,23 @@ export interface TableToBuild extends Omit {
}
export default class TestConfiguration {
- server: any
- request: supertest.SuperTest | undefined
+ server?: Server
+ request?: supertest.SuperTest
started: boolean
- appId: string | null
- allApps: any[]
+ appId?: string
+ allApps: App[]
app?: App
- prodApp: any
- prodAppId: any
- user: any
- userMetadataId: any
+ prodApp?: App
+ prodAppId?: string
+ user?: User
+ userMetadataId?: string
table?: Table
- automation: any
+ automation?: Automation
datasource?: Datasource
tenantId?: string
api: API
csrfToken?: string
- private get globalUserId() {
- return this.user._id
- }
-
constructor(openServer = true) {
if (openServer) {
// use a random port because it doesn't matter
@@ -114,7 +116,7 @@ export default class TestConfiguration {
} else {
this.started = false
}
- this.appId = null
+ this.appId = undefined
this.allApps = []
this.api = new API(this)
@@ -125,46 +127,86 @@ export default class TestConfiguration {
}
getApp() {
+ if (!this.app) {
+ throw new Error("app has not been initialised, call config.init() first")
+ }
return this.app
}
getProdApp() {
+ if (!this.prodApp) {
+ throw new Error(
+ "prodApp has not been initialised, call config.init() first"
+ )
+ }
return this.prodApp
}
getAppId() {
if (!this.appId) {
- throw "appId has not been initialised properly"
+ throw new Error(
+ "appId has not been initialised, call config.init() first"
+ )
}
-
return this.appId
}
getProdAppId() {
+ if (!this.prodAppId) {
+ throw new Error(
+ "prodAppId has not been initialised, call config.init() first"
+ )
+ }
return this.prodAppId
}
+ getUser(): User {
+ if (!this.user) {
+ throw new Error("User has not been initialised, call config.init() first")
+ }
+ return this.user
+ }
+
getUserDetails() {
+ const user = this.getUser()
return {
- globalId: this.globalUserId,
- email: this.user.email,
- firstName: this.user.firstName,
- lastName: this.user.lastName,
+ globalId: user._id!,
+ email: user.email,
+ firstName: user.firstName,
+ lastName: user.lastName,
}
}
+ getAutomation() {
+ if (!this.automation) {
+ throw new Error(
+ "automation has not been initialised, call config.init() first"
+ )
+ }
+ return this.automation
+ }
+
+ getDatasource() {
+ if (!this.datasource) {
+ throw new Error(
+ "datasource has not been initialised, call config.init() first"
+ )
+ }
+ return this.datasource
+ }
+
async doInContext(
- appId: string | null,
+ appId: string | undefined,
task: () => Promise
): Promise {
- if (!appId) {
- appId = this.appId
- }
-
const tenant = this.getTenantId()
return tenancy.doInTenant(tenant, () => {
+ if (!appId) {
+ appId = this.appId
+ }
+
// check if already in a context
- if (context.getAppId() == null && appId !== null) {
+ if (context.getAppId() == null && appId) {
return context.doInAppContext(appId, async () => {
return task()
})
@@ -257,9 +299,23 @@ export default class TestConfiguration {
}
}
+ async withUser(user: User, f: () => Promise) {
+ const oldUser = this.user
+ this.user = user
+ try {
+ return await f()
+ } finally {
+ this.user = oldUser
+ }
+ }
+
// UTILS
- _req(body: any, params: any, controlFunc: any) {
+ _req | void, Res>(
+ handler: (ctx: UserCtx) => Promise,
+ body?: Req,
+ params?: Record
+ ): Promise {
// create a fake request ctx
const request: any = {}
const appId = this.appId
@@ -278,110 +334,66 @@ export default class TestConfiguration {
throw new Error(`Error ${status} - ${message}`)
}
return this.doInContext(appId, async () => {
- await controlFunc(request)
+ await handler(request)
return request.body
})
}
// USER / AUTH
- async globalUser(
- config: {
- id?: string
- firstName?: string
- lastName?: string
- builder?: boolean
- admin?: boolean
- email?: string
- roles?: any
- } = {}
- ): Promise {
+ async globalUser(config: Partial = {}): Promise {
const {
- id = `us_${newid()}`,
+ _id = `us_${newid()}`,
firstName = generator.first(),
lastName = generator.last(),
- builder = true,
- admin = false,
- email = generator.email(),
- roles,
+ builder = { global: true },
+ admin = { global: false },
+ email = generator.email({ domain: "example.com" }),
+ tenantId = this.getTenantId(),
+ roles = {},
} = config
const db = tenancy.getTenantDB(this.getTenantId())
- let existing
+ let existing: Partial = {}
try {
- existing = await db.get(id)
+ existing = await db.get(_id)
} catch (err) {
- existing = { email }
+ // ignore
}
const user: User = {
- _id: id,
+ _id,
...existing,
- roles: roles || {},
- tenantId: this.getTenantId(),
+ ...config,
+ _rev: existing._rev,
+ email,
+ roles,
+ tenantId,
firstName,
lastName,
+ builder,
+ admin,
}
- await sessions.createASession(id, {
- sessionId: "sessionid",
+ await sessions.createASession(_id, {
+ sessionId: this.sessionIdForUser(_id),
tenantId: this.getTenantId(),
csrfToken: this.csrfToken,
})
- if (builder) {
- user.builder = { global: true }
- } else {
- user.builder = { global: false }
- }
- if (admin) {
- user.admin = { global: true }
- } else {
- user.admin = { global: false }
- }
const resp = await db.put(user)
+ await cache.user.invalidateUser(_id)
return {
_rev: resp.rev,
...user,
}
}
- async createUser(
- user: {
- id?: string
- firstName?: string
- lastName?: string
- email?: string
- builder?: boolean
- admin?: boolean
- roles?: UserRoles
- } = {}
- ): Promise {
- const {
- id,
- firstName = generator.first(),
- lastName = generator.last(),
- email = generator.email(),
- builder = true,
- admin,
- roles,
- } = user
-
- const globalId = !id ? `us_${Math.random()}` : `us_${id}`
- const resp = await this.globalUser({
- id: globalId,
- firstName,
- lastName,
- email,
- builder,
- admin,
- roles: roles || {},
- })
- await cache.user.invalidateUser(globalId)
- return resp
+ async createUser(user: Partial = {}): Promise {
+ return await this.globalUser(user)
}
async createGroup(roleId: string = roles.BUILTIN_ROLE_IDS.BASIC) {
return context.doInTenant(this.tenantId!, async () => {
const baseGroup = structures.userGroups.userGroup()
baseGroup.roles = {
- [this.prodAppId]: roleId,
+ [this.getProdAppId()]: roleId,
}
const { id, rev } = await pro.sdk.groups.save(baseGroup)
return {
@@ -404,8 +416,22 @@ export default class TestConfiguration {
})
}
- async login({ roleId, userId, builder, prodApp = false }: any = {}) {
- const appId = prodApp ? this.prodAppId : this.appId
+ sessionIdForUser(userId: string): string {
+ return `sessionid-${userId}`
+ }
+
+ async login({
+ roleId,
+ userId,
+ builder,
+ prodApp,
+ }: {
+ roleId?: string
+ userId: string
+ builder: boolean
+ prodApp: boolean
+ }) {
+ const appId = prodApp ? this.getProdAppId() : this.getAppId()
return context.doInAppContext(appId, async () => {
userId = !userId ? `us_uuid1` : userId
if (!this.request) {
@@ -414,19 +440,19 @@ export default class TestConfiguration {
// make sure the user exists in the global DB
if (roleId !== roles.BUILTIN_ROLE_IDS.PUBLIC) {
await this.globalUser({
- id: userId,
- builder,
- roles: { [this.prodAppId]: roleId },
+ _id: userId,
+ builder: { global: builder },
+ roles: { [appId]: roleId || roles.BUILTIN_ROLE_IDS.BASIC },
})
}
await sessions.createASession(userId, {
- sessionId: "sessionid",
+ sessionId: this.sessionIdForUser(userId),
tenantId: this.getTenantId(),
})
// have to fake this
const authObj = {
userId,
- sessionId: "sessionid",
+ sessionId: this.sessionIdForUser(userId),
tenantId: this.getTenantId(),
}
const authToken = jwt.sign(authObj, coreEnv.JWT_SECRET as Secret)
@@ -445,9 +471,10 @@ export default class TestConfiguration {
defaultHeaders(extras = {}, prodApp = false) {
const tenantId = this.getTenantId()
+ const user = this.getUser()
const authObj: AuthToken = {
- userId: this.globalUserId,
- sessionId: "sessionid",
+ userId: user._id!,
+ sessionId: this.sessionIdForUser(user._id!),
tenantId,
}
const authToken = jwt.sign(authObj, coreEnv.JWT_SECRET as Secret)
@@ -485,7 +512,7 @@ export default class TestConfiguration {
async basicRoleHeaders() {
return await this.roleHeaders({
- email: generator.email(),
+ email: generator.email({ domain: "example.com" }),
builder: false,
prodApp: true,
roleId: roles.BUILTIN_ROLE_IDS.BASIC,
@@ -493,12 +520,12 @@ export default class TestConfiguration {
}
async roleHeaders({
- email = generator.email(),
+ email = generator.email({ domain: "example.com" }),
roleId = roles.BUILTIN_ROLE_IDS.ADMIN,
builder = false,
prodApp = true,
} = {}) {
- return this.login({ email, roleId, builder, prodApp })
+ return this.login({ userId: email, roleId, builder, prodApp })
}
// TENANCY
@@ -521,18 +548,22 @@ export default class TestConfiguration {
this.tenantId = structures.tenant.id()
this.user = await this.globalUser()
- this.userMetadataId = generateUserMetadataID(this.user._id)
+ this.userMetadataId = generateUserMetadataID(this.user._id!)
return this.createApp(appName)
}
- doInTenant(task: any) {
+ doInTenant(task: () => T) {
return context.doInTenant(this.getTenantId(), task)
}
// API
- async generateApiKey(userId = this.user._id) {
+ async generateApiKey(userId?: string) {
+ const user = this.getUser()
+ if (!userId) {
+ userId = user._id!
+ }
const db = tenancy.getTenantDB(this.getTenantId())
const id = dbCore.generateDevInfoID(userId)
let devInfo: any
@@ -552,25 +583,28 @@ export default class TestConfiguration {
async createApp(appName: string): Promise {
// create dev app
// clear any old app
- this.appId = null
- this.app = await context.doInTenant(this.tenantId!, async () => {
- const app = await this._req({ name: appName }, null, appController.create)
- this.appId = app.appId!
- return app
- })
- return await context.doInAppContext(this.getAppId(), async () => {
+ this.appId = undefined
+ this.app = await context.doInTenant(
+ this.tenantId!,
+ async () =>
+ (await this._req(appController.create, {
+ name: appName,
+ })) as App
+ )
+ this.appId = this.app.appId
+ return await context.doInAppContext(this.app.appId!, async () => {
// create production app
this.prodApp = await this.publish()
this.allApps.push(this.prodApp)
- this.allApps.push(this.app)
+ this.allApps.push(this.app!)
return this.app!
})
}
async publish() {
- await this._req(null, null, deployController.publishApp)
+ await this._req(deployController.publishApp)
// @ts-ignore
const prodAppId = this.getAppId().replace("_dev", "")
this.prodAppId = prodAppId
@@ -582,13 +616,11 @@ export default class TestConfiguration {
}
async unpublish() {
- const response = await this._req(
- null,
- { appId: this.appId },
- appController.unpublish
- )
- this.prodAppId = null
- this.prodApp = null
+ const response = await this._req(appController.unpublish, {
+ appId: this.appId,
+ })
+ this.prodAppId = undefined
+ this.prodApp = undefined
return response
}
@@ -694,11 +726,6 @@ export default class TestConfiguration {
return this.api.row.save(tableId, config)
}
- async getRow(tableId: string, rowId: string): Promise {
- const res = await this.api.row.get(tableId, rowId)
- return res.body
- }
-
async getRows(tableId: string) {
if (!tableId && this.table) {
tableId = this.table._id!
@@ -716,8 +743,7 @@ export default class TestConfiguration {
// ROLE
async createRole(config?: any) {
- config = config || basicRole()
- return this._req(config, null, roleController.save)
+ return this._req(roleController.save, config || basicRole())
}
// VIEW
@@ -730,7 +756,7 @@ export default class TestConfiguration {
tableId: this.table!._id,
name: generator.guid(),
}
- return this._req(view, null, viewController.v1.save)
+ return this._req(viewController.v1.save, view)
}
async createView(
@@ -754,40 +780,38 @@ export default class TestConfiguration {
// AUTOMATION
- async createAutomation(config?: any) {
+ async createAutomation(config?: Automation) {
config = config || basicAutomation()
if (config._rev) {
delete config._rev
}
- this.automation = (
- await this._req(config, null, automationController.create)
- ).automation
+ const res = await this._req(automationController.create, config)
+ this.automation = res.automation
return this.automation
}
async getAllAutomations() {
- return this._req(null, null, automationController.fetch)
+ return this._req(automationController.fetch)
}
- async deleteAutomation(automation?: any) {
+ async deleteAutomation(automation?: Automation) {
automation = automation || this.automation
if (!automation) {
return
}
- return this._req(
- null,
- { id: automation._id, rev: automation._rev },
- automationController.destroy
- )
+ return this._req(automationController.destroy, undefined, {
+ id: automation._id,
+ rev: automation._rev,
+ })
}
- async createWebhook(config?: any) {
+ async createWebhook(config?: Webhook) {
if (!this.automation) {
throw "Must create an automation before creating webhook."
}
- config = config || basicWebhook(this.automation._id)
+ config = config || basicWebhook(this.automation._id!)
- return (await this._req(config, null, webhookController.save)).webhook
+ return (await this._req(webhookController.save, config)).webhook
}
// DATASOURCE
@@ -809,7 +833,7 @@ export default class TestConfiguration {
return { ...this.datasource, _id: this.datasource!._id! }
}
- async restDatasource(cfg?: any) {
+ async restDatasource(cfg?: Record) {
return this.createDatasource({
datasource: {
...basicDatasource().datasource,
@@ -866,26 +890,25 @@ export default class TestConfiguration {
// QUERY
- async createQuery(config?: any) {
- if (!this.datasource && !config) {
- throw "No datasource created for query."
- }
- config = config || basicQuery(this.datasource!._id!)
- return this._req(config, null, queryController.save)
+ async createQuery(config?: Query) {
+ return this._req(
+ queryController.save,
+ config || basicQuery(this.getDatasource()._id!)
+ )
}
// SCREEN
- async createScreen(config?: any) {
+ async createScreen(config?: Screen) {
config = config || basicScreen()
- return this._req(config, null, screenController.save)
+ return this._req(screenController.save, config)
}
// LAYOUT
- async createLayout(config?: any) {
+ async createLayout(config?: Layout) {
config = config || basicLayout()
- return await this._req(config, null, layoutController.save)
+ return await this._req(layoutController.save, config)
}
}
diff --git a/packages/server/src/tests/utilities/api/application.ts b/packages/server/src/tests/utilities/api/application.ts
index 3951bba667..da3d7cefd8 100644
--- a/packages/server/src/tests/utilities/api/application.ts
+++ b/packages/server/src/tests/utilities/api/application.ts
@@ -1,193 +1,133 @@
-import { Response } from "supertest"
import {
App,
+ PublishResponse,
type CreateAppRequest,
type FetchAppDefinitionResponse,
type FetchAppPackageResponse,
} from "@budibase/types"
-import TestConfiguration from "../TestConfiguration"
-import { TestAPI } from "./base"
+import { Expectations, TestAPI } from "./base"
import { AppStatus } from "../../../db/utils"
import { constants } from "@budibase/backend-core"
export class ApplicationAPI extends TestAPI {
- constructor(config: TestConfiguration) {
- super(config)
+ create = async (
+ app: CreateAppRequest,
+ expectations?: Expectations
+ ): Promise => {
+ const files = app.templateFile ? { templateFile: app.templateFile } : {}
+ delete app.templateFile
+ return await this._post("/api/applications", {
+ fields: app,
+ files,
+ expectations,
+ })
}
- create = async (app: CreateAppRequest): Promise => {
- const request = this.request
- .post("/api/applications")
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
-
- for (const key of Object.keys(app)) {
- request.field(key, (app as any)[key])
- }
-
- if (app.templateFile) {
- request.attach("templateFile", app.templateFile)
- }
-
- const result = await request
-
- if (result.statusCode !== 200) {
- throw new Error(JSON.stringify(result.body))
- }
-
- return result.body as App
+ delete = async (
+ appId: string,
+ expectations?: Expectations
+ ): Promise => {
+ await this._delete(`/api/applications/${appId}`, { expectations })
}
- delete = async (appId: string): Promise => {
- await this.request
- .delete(`/api/applications/${appId}`)
- .set(this.config.defaultHeaders())
- .expect(200)
- }
-
- publish = async (
- appId: string
- ): Promise<{ _id: string; status: string; appUrl: string }> => {
- // While the publish endpoint does take an :appId parameter, it doesn't
- // use it. It uses the appId from the context.
- let headers = {
- ...this.config.defaultHeaders(),
- [constants.Header.APP_ID]: appId,
- }
- const result = await this.request
- .post(`/api/applications/${appId}/publish`)
- .set(headers)
- .expect("Content-Type", /json/)
- .expect(200)
- return result.body as { _id: string; status: string; appUrl: string }
+ publish = async (appId: string): Promise => {
+ return await this._post(
+ `/api/applications/${appId}/publish`,
+ {
+ // While the publish endpoint does take an :appId parameter, it doesn't
+ // use it. It uses the appId from the context.
+ headers: {
+ [constants.Header.APP_ID]: appId,
+ },
+ }
+ )
}
unpublish = async (appId: string): Promise => {
- await this.request
- .post(`/api/applications/${appId}/unpublish`)
- .set(this.config.defaultHeaders())
- .expect(204)
+ await this._post(`/api/applications/${appId}/unpublish`, {
+ expectations: { status: 204 },
+ })
}
sync = async (
appId: string,
- { statusCode }: { statusCode: number } = { statusCode: 200 }
+ expectations?: Expectations
): Promise<{ message: string }> => {
- const result = await this.request
- .post(`/api/applications/${appId}/sync`)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(statusCode)
- return result.body
+ return await this._post<{ message: string }>(
+ `/api/applications/${appId}/sync`,
+ { expectations }
+ )
}
- getRaw = async (appId: string): Promise => {
- // While the appPackage endpoint does take an :appId parameter, it doesn't
- // use it. It uses the appId from the context.
- let headers = {
- ...this.config.defaultHeaders(),
- [constants.Header.APP_ID]: appId,
- }
- const result = await this.request
- .get(`/api/applications/${appId}/appPackage`)
- .set(headers)
- .expect("Content-Type", /json/)
- .expect(200)
- return result
- }
-
- get = async (appId: string): Promise => {
- const result = await this.getRaw(appId)
- return result.body.application as App
+ get = async (appId: string, expectations?: Expectations): Promise => {
+ return await this._get(`/api/applications/${appId}`, {
+ // While the get endpoint does take an :appId parameter, it doesn't use
+ // it. It uses the appId from the context.
+ headers: {
+ [constants.Header.APP_ID]: appId,
+ },
+ expectations,
+ })
}
getDefinition = async (
- appId: string
+ appId: string,
+ expectations?: Expectations
): Promise => {
- const result = await this.request
- .get(`/api/applications/${appId}/definition`)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
- return result.body as FetchAppDefinitionResponse
+ return await this._get(
+ `/api/applications/${appId}/definition`,
+ { expectations }
+ )
}
- getAppPackage = async (appId: string): Promise => {
- const result = await this.request
- .get(`/api/applications/${appId}/appPackage`)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
- return result.body
+ getAppPackage = async (
+ appId: string,
+ expectations?: Expectations
+ ): Promise => {
+ return await this._get(
+ `/api/applications/${appId}/appPackage`,
+ { expectations }
+ )
}
update = async (
appId: string,
- app: { name?: string; url?: string }
+ app: { name?: string; url?: string },
+ expectations?: Expectations
): Promise => {
- const request = this.request
- .put(`/api/applications/${appId}`)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
-
- for (const key of Object.keys(app)) {
- request.field(key, (app as any)[key])
- }
-
- const result = await request
-
- if (result.statusCode !== 200) {
- throw new Error(JSON.stringify(result.body))
- }
-
- return result.body as App
+ return await this._put(`/api/applications/${appId}`, {
+ fields: app,
+ expectations,
+ })
}
- updateClient = async (appId: string): Promise => {
- // While the updateClient endpoint does take an :appId parameter, it doesn't
- // use it. It uses the appId from the context.
- let headers = {
- ...this.config.defaultHeaders(),
- [constants.Header.APP_ID]: appId,
- }
- const response = await this.request
- .post(`/api/applications/${appId}/client/update`)
- .set(headers)
- .expect("Content-Type", /json/)
-
- if (response.statusCode !== 200) {
- throw new Error(JSON.stringify(response.body))
- }
+ updateClient = async (
+ appId: string,
+ expectations?: Expectations
+ ): Promise => {
+ await this._post(`/api/applications/${appId}/client/update`, {
+ // While the updateClient endpoint does take an :appId parameter, it doesn't
+ // use it. It uses the appId from the context.
+ headers: {
+ [constants.Header.APP_ID]: appId,
+ },
+ expectations,
+ })
}
revertClient = async (appId: string): Promise => {
- // While the revertClient endpoint does take an :appId parameter, it doesn't
- // use it. It uses the appId from the context.
- let headers = {
- ...this.config.defaultHeaders(),
- [constants.Header.APP_ID]: appId,
- }
- const response = await this.request
- .post(`/api/applications/${appId}/client/revert`)
- .set(headers)
- .expect("Content-Type", /json/)
-
- if (response.statusCode !== 200) {
- throw new Error(JSON.stringify(response.body))
- }
+ await this._post(`/api/applications/${appId}/client/revert`, {
+ // While the revertClient endpoint does take an :appId parameter, it doesn't
+ // use it. It uses the appId from the context.
+ headers: {
+ [constants.Header.APP_ID]: appId,
+ },
+ })
}
fetch = async ({ status }: { status?: AppStatus } = {}): Promise => {
- let query = []
- if (status) {
- query.push(`status=${status}`)
- }
-
- const result = await this.request
- .get(`/api/applications${query.length ? `?${query.join("&")}` : ""}`)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
- return result.body as App[]
+ return await this._get("/api/applications", {
+ query: { status },
+ })
}
}
diff --git a/packages/server/src/tests/utilities/api/attachment.ts b/packages/server/src/tests/utilities/api/attachment.ts
index a466f1a67e..bb33ef04bb 100644
--- a/packages/server/src/tests/utilities/api/attachment.ts
+++ b/packages/server/src/tests/utilities/api/attachment.ts
@@ -1,35 +1,16 @@
-import {
- APIError,
- Datasource,
- ProcessAttachmentResponse,
-} from "@budibase/types"
-import TestConfiguration from "../TestConfiguration"
-import { TestAPI } from "./base"
+import { ProcessAttachmentResponse } from "@budibase/types"
+import { Expectations, TestAPI } from "./base"
import fs from "fs"
export class AttachmentAPI extends TestAPI {
- constructor(config: TestConfiguration) {
- super(config)
- }
-
process = async (
name: string,
file: Buffer | fs.ReadStream | string,
- { expectStatus } = { expectStatus: 200 }
+ expectations?: Expectations
): Promise => {
- const result = await this.request
- .post(`/api/attachments/process`)
- .attach("file", file, name)
- .set(this.config.defaultHeaders())
-
- if (result.statusCode !== expectStatus) {
- throw new Error(
- `Expected status ${expectStatus} but got ${
- result.statusCode
- }, body: ${JSON.stringify(result.body)}`
- )
- }
-
- return result.body
+ return await this._post(`/api/attachments/process`, {
+ files: { file: { name, file } },
+ expectations,
+ })
}
}
diff --git a/packages/server/src/tests/utilities/api/backup.ts b/packages/server/src/tests/utilities/api/backup.ts
index 8cd1e58a29..7c01b57108 100644
--- a/packages/server/src/tests/utilities/api/backup.ts
+++ b/packages/server/src/tests/utilities/api/backup.ts
@@ -2,42 +2,38 @@ import {
CreateAppBackupResponse,
ImportAppBackupResponse,
} from "@budibase/types"
-import TestConfiguration from "../TestConfiguration"
-import { TestAPI } from "./base"
+import { Expectations, TestAPI } from "./base"
export class BackupAPI extends TestAPI {
- constructor(config: TestConfiguration) {
- super(config)
- }
-
- exportBasicBackup = async (appId: string) => {
- const result = await this.request
- .post(`/api/backups/export?appId=${appId}`)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /application\/gzip/)
- .expect(200)
- return {
- body: result.body as Buffer,
- headers: result.headers,
+ exportBasicBackup = async (appId: string, expectations?: Expectations) => {
+ const exp = {
+ ...expectations,
+ headers: {
+ ...expectations?.headers,
+ "Content-Type": "application/gzip",
+ },
}
+ return await this._post(`/api/backups/export`, {
+ query: { appId },
+ expectations: exp,
+ })
}
- createBackup = async (appId: string) => {
- const result = await this.request
- .post(`/api/apps/${appId}/backups`)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
- return result.body as CreateAppBackupResponse
+ createBackup = async (appId: string, expectations?: Expectations) => {
+ return await this._post(
+ `/api/apps/${appId}/backups`,
+ { expectations }
+ )
}
waitForBackupToComplete = async (appId: string, backupId: string) => {
for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, 1000))
- const result = await this.request
- .get(`/api/apps/${appId}/backups/${backupId}/file`)
- .set(this.config.defaultHeaders())
- if (result.status === 200) {
+ const response = await this._requestRaw(
+ "get",
+ `/api/apps/${appId}/backups/${backupId}/file`
+ )
+ if (response.status === 200) {
return
}
}
@@ -46,13 +42,12 @@ export class BackupAPI extends TestAPI {
importBackup = async (
appId: string,
- backupId: string
+ backupId: string,
+ expectations?: Expectations
): Promise => {
- const result = await this.request
- .post(`/api/apps/${appId}/backups/${backupId}/import`)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
- return result.body as ImportAppBackupResponse
+ return await this._post(
+ `/api/apps/${appId}/backups/${backupId}/import`,
+ { expectations }
+ )
}
}
diff --git a/packages/server/src/tests/utilities/api/base.ts b/packages/server/src/tests/utilities/api/base.ts
index 34120277c0..3f534fba86 100644
--- a/packages/server/src/tests/utilities/api/base.ts
+++ b/packages/server/src/tests/utilities/api/base.ts
@@ -1,17 +1,196 @@
import TestConfiguration from "../TestConfiguration"
-import { SuperTest, Test } from "supertest"
+import { SuperTest, Test, Response } from "supertest"
+import { ReadStream } from "fs"
-export interface TestAPIOpts {
- headers?: any
+type Headers = Record
+type Method = "get" | "post" | "put" | "patch" | "delete"
+
+export interface AttachedFile {
+ name: string
+ file: Buffer | ReadStream | string
+}
+
+function isAttachedFile(file: any): file is AttachedFile {
+ if (file === undefined) {
+ return false
+ }
+ const attachedFile = file as AttachedFile
+ return (
+ Object.hasOwnProperty.call(attachedFile, "file") &&
+ Object.hasOwnProperty.call(attachedFile, "name")
+ )
+}
+
+export interface Expectations {
status?: number
+ headers?: Record
+ headersNotPresent?: string[]
+ body?: Record
+}
+
+export interface RequestOpts {
+ headers?: Headers
+ query?: Record
+ body?: Record
+ fields?: Record
+ files?: Record<
+ string,
+ Buffer | ReadStream | string | AttachedFile | undefined
+ >
+ expectations?: Expectations
+ publicUser?: boolean
}
export abstract class TestAPI {
config: TestConfiguration
request: SuperTest
- protected constructor(config: TestConfiguration) {
+ constructor(config: TestConfiguration) {
this.config = config
this.request = config.request!
}
+
+ protected _get = async (url: string, opts?: RequestOpts): Promise => {
+ return await this._request("get", url, opts)
+ }
+
+ protected _post = async (url: string, opts?: RequestOpts): Promise => {
+ return await this._request("post", url, opts)
+ }
+
+ protected _put = async (url: string, opts?: RequestOpts): Promise => {
+ return await this._request("put", url, opts)
+ }
+
+ protected _patch = async (url: string, opts?: RequestOpts): Promise => {
+ return await this._request("patch", url, opts)
+ }
+
+ protected _delete = async (
+ url: string,
+ opts?: RequestOpts
+ ): Promise => {
+ return await this._request("delete", url, opts)
+ }
+
+ protected _requestRaw = async (
+ method: "get" | "post" | "put" | "patch" | "delete",
+ url: string,
+ opts?: RequestOpts
+ ): Promise => {
+ const {
+ headers = {},
+ query = {},
+ body,
+ fields = {},
+ files = {},
+ expectations,
+ publicUser = false,
+ } = opts || {}
+ const { status = 200 } = expectations || {}
+ const expectHeaders = expectations?.headers || {}
+
+ if (status !== 204 && !expectHeaders["Content-Type"]) {
+ expectHeaders["Content-Type"] = /^application\/json/
+ }
+
+ let queryParams = []
+ for (const [key, value] of Object.entries(query)) {
+ if (value) {
+ queryParams.push(`${key}=${value}`)
+ }
+ }
+ if (queryParams.length) {
+ url += `?${queryParams.join("&")}`
+ }
+
+ const headersFn = publicUser
+ ? this.config.publicHeaders.bind(this.config)
+ : this.config.defaultHeaders.bind(this.config)
+ let request = this.request[method](url).set(
+ headersFn({
+ "x-budibase-include-stacktrace": "true",
+ })
+ )
+ if (headers) {
+ request = request.set(headers)
+ }
+ if (body) {
+ request = request.send(body)
+ }
+ for (const [key, value] of Object.entries(fields)) {
+ request = request.field(key, value)
+ }
+
+ for (const [key, value] of Object.entries(files)) {
+ if (isAttachedFile(value)) {
+ request = request.attach(key, value.file, value.name)
+ } else {
+ request = request.attach(key, value as any)
+ }
+ }
+ if (expectations?.headers) {
+ for (const [key, value] of Object.entries(expectations.headers)) {
+ if (value === undefined) {
+ throw new Error(
+ `Got an undefined expected value for header "${key}", if you want to check for the absence of a header, use headersNotPresent`
+ )
+ }
+ request = request.expect(key, value as any)
+ }
+ }
+
+ return await request
+ }
+
+ protected _request = async (
+ method: Method,
+ url: string,
+ opts?: RequestOpts
+ ): Promise => {
+ const { expectations } = opts || {}
+ const { status = 200 } = expectations || {}
+
+ const response = await this._requestRaw(method, url, opts)
+
+ if (response.status !== status) {
+ let message = `Expected status ${status} but got ${response.status}`
+
+ const stack = response.body.stack
+ delete response.body.stack
+
+ if (response.body) {
+ message += `\n\nBody:`
+ const body = JSON.stringify(response.body, null, 2)
+ for (const line of body.split("\n")) {
+ message += `\n⏐ ${line}`
+ }
+ }
+
+ if (stack) {
+ message += `\n\nStack from request handler:`
+ for (const line of stack.split("\n")) {
+ message += `\n⏐ ${line}`
+ }
+ }
+
+ throw new Error(message)
+ }
+
+ if (expectations?.headersNotPresent) {
+ for (const header of expectations.headersNotPresent) {
+ if (response.headers[header]) {
+ throw new Error(
+ `Expected header ${header} not to be present, found value "${response.headers[header]}"`
+ )
+ }
+ }
+ }
+
+ if (expectations?.body) {
+ expect(response.body).toMatchObject(expectations.body)
+ }
+
+ return response.body
+ }
}
diff --git a/packages/server/src/tests/utilities/api/datasource.ts b/packages/server/src/tests/utilities/api/datasource.ts
index bcd7a71089..06aa9b4e1e 100644
--- a/packages/server/src/tests/utilities/api/datasource.ts
+++ b/packages/server/src/tests/utilities/api/datasource.ts
@@ -1,63 +1,48 @@
import {
- CreateDatasourceRequest,
Datasource,
VerifyDatasourceRequest,
+ CreateDatasourceResponse,
+ UpdateDatasourceResponse,
+ UpdateDatasourceRequest,
} from "@budibase/types"
-import TestConfiguration from "../TestConfiguration"
-import { TestAPI } from "./base"
-import supertest from "supertest"
+import { Expectations, TestAPI } from "./base"
export class DatasourceAPI extends TestAPI {
- constructor(config: TestConfiguration) {
- super(config)
- }
-
- create = async (
+ create = async (
config: Datasource,
- {
- expectStatus,
- rawResponse,
- }: { expectStatus?: number; rawResponse?: B } = {}
- ): Promise => {
- const body: CreateDatasourceRequest = {
- datasource: config,
- tablesFilter: [],
- }
- const result = await this.request
- .post(`/api/datasources`)
- .send(body)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(expectStatus || 200)
- if (rawResponse) {
- return result as any
- }
- return result.body.datasource
+ expectations?: Expectations
+ ): Promise => {
+ const response = await this._post(
+ `/api/datasources`,
+ {
+ body: {
+ datasource: config,
+ tablesFilter: [],
+ },
+ expectations,
+ }
+ )
+ return response.datasource
}
update = async (
- datasource: Datasource,
- { expectStatus } = { expectStatus: 200 }
+ datasource: UpdateDatasourceRequest,
+ expectations?: Expectations
): Promise => {
- const result = await this.request
- .put(`/api/datasources/${datasource._id}`)
- .send(datasource)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(expectStatus)
- return result.body.datasource as Datasource
+ const response = await this._put(
+ `/api/datasources/${datasource._id}`,
+ { body: datasource, expectations }
+ )
+ return response.datasource
}
verify = async (
data: VerifyDatasourceRequest,
- { expectStatus } = { expectStatus: 200 }
+ expectations?: Expectations
) => {
- const result = await this.request
- .post(`/api/datasources/verify`)
- .send(data)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(expectStatus)
- return result
+ return await this._post(`/api/datasources/verify`, {
+ body: data,
+ expectations,
+ })
}
}
diff --git a/packages/server/src/tests/utilities/api/index.ts b/packages/server/src/tests/utilities/api/index.ts
index fdcec3098d..d66acd86fd 100644
--- a/packages/server/src/tests/utilities/api/index.ts
+++ b/packages/server/src/tests/utilities/api/index.ts
@@ -11,6 +11,7 @@ import { BackupAPI } from "./backup"
import { AttachmentAPI } from "./attachment"
import { UserAPI } from "./user"
import { QueryAPI } from "./query"
+import { RoleAPI } from "./role"
export default class API {
table: TableAPI
@@ -25,6 +26,7 @@ export default class API {
attachment: AttachmentAPI
user: UserAPI
query: QueryAPI
+ roles: RoleAPI
constructor(config: TestConfiguration) {
this.table = new TableAPI(config)
@@ -39,5 +41,6 @@ export default class API {
this.attachment = new AttachmentAPI(config)
this.user = new UserAPI(config)
this.query = new QueryAPI(config)
+ this.roles = new RoleAPI(config)
}
}
diff --git a/packages/server/src/tests/utilities/api/legacyView.ts b/packages/server/src/tests/utilities/api/legacyView.ts
index 63981cec5e..38ef70d62a 100644
--- a/packages/server/src/tests/utilities/api/legacyView.ts
+++ b/packages/server/src/tests/utilities/api/legacyView.ts
@@ -1,16 +1,8 @@
-import TestConfiguration from "../TestConfiguration"
-import { TestAPI } from "./base"
+import { Expectations, TestAPI } from "./base"
+import { Row } from "@budibase/types"
export class LegacyViewAPI extends TestAPI {
- constructor(config: TestConfiguration) {
- super(config)
- }
-
- get = async (id: string, { expectStatus } = { expectStatus: 200 }) => {
- return await this.request
- .get(`/api/views/${id}`)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(expectStatus)
+ get = async (id: string, expectations?: Expectations) => {
+ return await this._get(`/api/views/${id}`, { expectations })
}
}
diff --git a/packages/server/src/tests/utilities/api/permission.ts b/packages/server/src/tests/utilities/api/permission.ts
index ffa89e88f9..986796d9a1 100644
--- a/packages/server/src/tests/utilities/api/permission.ts
+++ b/packages/server/src/tests/utilities/api/permission.ts
@@ -1,52 +1,39 @@
-import { AnyDocument, PermissionLevel } from "@budibase/types"
-import TestConfiguration from "../TestConfiguration"
-import { TestAPI } from "./base"
+import {
+ AddPermissionRequest,
+ AddPermissionResponse,
+ GetResourcePermsResponse,
+ RemovePermissionRequest,
+ RemovePermissionResponse,
+} from "@budibase/types"
+import { Expectations, TestAPI } from "./base"
export class PermissionAPI extends TestAPI {
- constructor(config: TestConfiguration) {
- super(config)
+ get = async (resourceId: string, expectations?: Expectations) => {
+ return await this._get(
+ `/api/permission/${resourceId}`,
+ { expectations }
+ )
}
- get = async (
- resourceId: string,
- { expectStatus } = { expectStatus: 200 }
- ) => {
- return this.request
- .get(`/api/permission/${resourceId}`)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(expectStatus)
- }
-
- set = async (
- {
- roleId,
- resourceId,
- level,
- }: { roleId: string; resourceId: string; level: PermissionLevel },
- { expectStatus } = { expectStatus: 200 }
- ): Promise => {
- const res = await this.request
- .post(`/api/permission/${roleId}/${resourceId}/${level}`)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(expectStatus)
- return res.body
+ add = async (
+ request: AddPermissionRequest,
+ expectations?: Expectations
+ ): Promise => {
+ const { roleId, resourceId, level } = request
+ return await this._post(
+ `/api/permission/${roleId}/${resourceId}/${level}`,
+ { expectations }
+ )
}
revoke = async (
- {
- roleId,
- resourceId,
- level,
- }: { roleId: string; resourceId: string; level: PermissionLevel },
- { expectStatus } = { expectStatus: 200 }
+ request: RemovePermissionRequest,
+ expectations?: Expectations
) => {
- const res = await this.request
- .delete(`/api/permission/${roleId}/${resourceId}/${level}`)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(expectStatus)
- return res
+ const { roleId, resourceId, level } = request
+ return await this._delete(
+ `/api/permission/${roleId}/${resourceId}/${level}`,
+ { expectations }
+ )
}
}
diff --git a/packages/server/src/tests/utilities/api/query.ts b/packages/server/src/tests/utilities/api/query.ts
index b0eac5c8b7..32866314ff 100644
--- a/packages/server/src/tests/utilities/api/query.ts
+++ b/packages/server/src/tests/utilities/api/query.ts
@@ -1,60 +1,32 @@
-import TestConfiguration from "../TestConfiguration"
import {
Query,
- QueryPreview,
- type ExecuteQueryRequest,
- type ExecuteQueryResponse,
+ ExecuteQueryRequest,
+ ExecuteQueryResponse,
+ PreviewQueryRequest,
+ PreviewQueryResponse,
} from "@budibase/types"
import { TestAPI } from "./base"
export class QueryAPI extends TestAPI {
- constructor(config: TestConfiguration) {
- super(config)
- }
-
create = async (body: Query): Promise => {
- const res = await this.request
- .post(`/api/queries`)
- .set(this.config.defaultHeaders())
- .send(body)
- .expect("Content-Type", /json/)
-
- if (res.status !== 200) {
- throw new Error(JSON.stringify(res.body))
- }
-
- return res.body as Query
+ return await this._post(`/api/queries`, { body })
}
execute = async (
queryId: string,
body?: ExecuteQueryRequest
): Promise => {
- const res = await this.request
- .post(`/api/v2/queries/${queryId}`)
- .set(this.config.defaultHeaders())
- .send(body)
- .expect("Content-Type", /json/)
-
- if (res.status !== 200) {
- throw new Error(JSON.stringify(res.body))
- }
-
- return res.body
+ return await this._post(
+ `/api/v2/queries/${queryId}`,
+ {
+ body,
+ }
+ )
}
- previewQuery = async (queryPreview: QueryPreview) => {
- const res = await this.request
- .post(`/api/queries/preview`)
- .send(queryPreview)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
-
- if (res.status !== 200) {
- throw new Error(JSON.stringify(res.body))
- }
-
- return res.body
+ previewQuery = async (queryPreview: PreviewQueryRequest) => {
+ return await this._post(`/api/queries/preview`, {
+ body: queryPreview,
+ })
}
}
diff --git a/packages/server/src/tests/utilities/api/role.ts b/packages/server/src/tests/utilities/api/role.ts
new file mode 100644
index 0000000000..4defbc1220
--- /dev/null
+++ b/packages/server/src/tests/utilities/api/role.ts
@@ -0,0 +1,41 @@
+import {
+ AccessibleRolesResponse,
+ FetchRolesResponse,
+ FindRoleResponse,
+ SaveRoleRequest,
+ SaveRoleResponse,
+} from "@budibase/types"
+import { Expectations, TestAPI } from "./base"
+
+export class RoleAPI extends TestAPI {
+ fetch = async (expectations?: Expectations) => {
+ return await this._get(`/api/roles`, {
+ expectations,
+ })
+ }
+
+ find = async (roleId: string, expectations?: Expectations) => {
+ return await this._get(`/api/roles/${roleId}`, {
+ expectations,
+ })
+ }
+
+ save = async (body: SaveRoleRequest, expectations?: Expectations) => {
+ return await this._post(`/api/roles`, {
+ body,
+ expectations,
+ })
+ }
+
+ destroy = async (roleId: string, expectations?: Expectations) => {
+ return await this._delete(`/api/roles/${roleId}`, {
+ expectations,
+ })
+ }
+
+ accesssible = async (expectations?: Expectations) => {
+ return await this._get(`/api/roles/accessible`, {
+ expectations,
+ })
+ }
+}
diff --git a/packages/server/src/tests/utilities/api/row.ts b/packages/server/src/tests/utilities/api/row.ts
index 936c906f9f..86664574cb 100644
--- a/packages/server/src/tests/utilities/api/row.ts
+++ b/packages/server/src/tests/utilities/api/row.ts
@@ -8,162 +8,140 @@ import {
BulkImportResponse,
SearchRowResponse,
SearchParams,
+ DeleteRowRequest,
+ DeleteRows,
+ DeleteRow,
+ ExportRowsResponse,
} from "@budibase/types"
-import TestConfiguration from "../TestConfiguration"
-import { TestAPI } from "./base"
+import { Expectations, TestAPI } from "./base"
export class RowAPI extends TestAPI {
- constructor(config: TestConfiguration) {
- super(config)
- }
-
get = async (
sourceId: string,
rowId: string,
- { expectStatus } = { expectStatus: 200 }
+ expectations?: Expectations
) => {
- const request = this.request
- .get(`/api/${sourceId}/rows/${rowId}`)
- .set(this.config.defaultHeaders())
- .expect(expectStatus)
- if (expectStatus !== 404) {
- request.expect("Content-Type", /json/)
- }
- return request
+ return await this._get(`/api/${sourceId}/rows/${rowId}`, {
+ expectations,
+ })
}
getEnriched = async (
sourceId: string,
rowId: string,
- { expectStatus } = { expectStatus: 200 }
+ expectations?: Expectations
) => {
- const request = this.request
- .get(`/api/${sourceId}/${rowId}/enrich`)
- .set(this.config.defaultHeaders())
- .expect(expectStatus)
- if (expectStatus !== 404) {
- request.expect("Content-Type", /json/)
- }
- return request
+ return await this._get(`/api/${sourceId}/${rowId}/enrich`, {
+ expectations,
+ })
}
save = async (
tableId: string,
row: SaveRowRequest,
- { expectStatus } = { expectStatus: 200 }
+ expectations?: Expectations
): Promise => {
- const resp = await this.request
- .post(`/api/${tableId}/rows`)
- .send(row)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- if (resp.status !== expectStatus) {
- throw new Error(
- `Expected status ${expectStatus} but got ${
- resp.status
- }, body: ${JSON.stringify(resp.body)}`
- )
- }
- return resp.body as Row
+ return await this._post(`/api/${tableId}/rows`, {
+ body: row,
+ expectations,
+ })
}
validate = async (
sourceId: string,
row: SaveRowRequest,
- { expectStatus } = { expectStatus: 200 }
+ expectations?: Expectations
): Promise => {
- const resp = await this.request
- .post(`/api/${sourceId}/rows/validate`)
- .send(row)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(expectStatus)
- return resp.body as ValidateResponse
+ return await this._post(
+ `/api/${sourceId}/rows/validate`,
+ {
+ body: row,
+ expectations,
+ }
+ )
}
patch = async (
sourceId: string,
row: PatchRowRequest,
- { expectStatus } = { expectStatus: 200 }
+ expectations?: Expectations
): Promise => {
- let resp = await this.request
- .patch(`/api/${sourceId}/rows`)
- .send(row)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- if (resp.status !== expectStatus) {
- throw new Error(
- `Expected status ${expectStatus} but got ${
- resp.status
- }, body: ${JSON.stringify(resp.body)}`
- )
- }
- return resp.body as Row
+ return await this._patch(`/api/${sourceId}/rows`, {
+ body: row,
+ expectations,
+ })
}
delete = async (
sourceId: string,
- rows: Row | string | (Row | string)[],
- { expectStatus } = { expectStatus: 200 }
+ row: DeleteRow,
+ expectations?: Expectations
) => {
- return this.request
- .delete(`/api/${sourceId}/rows`)
- .send(Array.isArray(rows) ? { rows } : rows)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(expectStatus)
+ return await this._delete(`/api/${sourceId}/rows`, {
+ body: row,
+ expectations,
+ })
+ }
+
+ bulkDelete = async (
+ sourceId: string,
+ body: DeleteRows,
+ expectations?: Expectations
+ ) => {
+ return await this._delete(`/api/${sourceId}/rows`, {
+ body,
+ expectations,
+ })
}
fetch = async (
sourceId: string,
- { expectStatus } = { expectStatus: 200 }
+ expectations?: Expectations
): Promise => {
- const request = this.request
- .get(`/api/${sourceId}/rows`)
- .set(this.config.defaultHeaders())
- .expect(expectStatus)
-
- return (await request).body
+ return await this._get(`/api/${sourceId}/rows`, {
+ expectations,
+ })
}
exportRows = async (
tableId: string,
body: ExportRowsRequest,
- { expectStatus } = { expectStatus: 200 }
+ expectations?: Expectations
) => {
- const request = this.request
- .post(`/api/${tableId}/rows/exportRows?format=json`)
- .set(this.config.defaultHeaders())
- .send(body)
- .expect("Content-Type", /json/)
- .expect(expectStatus)
- return request
+ const response = await this._requestRaw(
+ "post",
+ `/api/${tableId}/rows/exportRows`,
+ {
+ body,
+ query: { format: "json" },
+ expectations,
+ }
+ )
+ return response.text
}
bulkImport = async (
tableId: string,
body: BulkImportRequest,
- { expectStatus } = { expectStatus: 200 }
+ expectations?: Expectations
): Promise => {
- let request = this.request
- .post(`/api/tables/${tableId}/import`)
- .send(body)
- .set(this.config.defaultHeaders())
- .expect(expectStatus)
- return (await request).body
+ return await this._post(
+ `/api/tables/${tableId}/import`,
+ {
+ body,
+ expectations,
+ }
+ )
}
search = async (
sourceId: string,
params?: SearchParams,
- { expectStatus } = { expectStatus: 200 }
+ expectations?: Expectations
): Promise => {
- const request = this.request
- .post(`/api/${sourceId}/search`)
- .send(params)
- .set(this.config.defaultHeaders())
- .expect(expectStatus)
-
- return (await request).body
+ return await this._post(`/api/${sourceId}/search`, {
+ body: params,
+ expectations,
+ })
}
}
diff --git a/packages/server/src/tests/utilities/api/screen.ts b/packages/server/src/tests/utilities/api/screen.ts
index 9245ffe4ba..c8d3e647d8 100644
--- a/packages/server/src/tests/utilities/api/screen.ts
+++ b/packages/server/src/tests/utilities/api/screen.ts
@@ -1,18 +1,8 @@
-import TestConfiguration from "../TestConfiguration"
import { Screen } from "@budibase/types"
-import { TestAPI } from "./base"
+import { Expectations, TestAPI } from "./base"
export class ScreenAPI extends TestAPI {
- constructor(config: TestConfiguration) {
- super(config)
- }
-
- list = async (): Promise => {
- const res = await this.request
- .get(`/api/screens`)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(200)
- return res.body as Screen[]
+ list = async (expectations?: Expectations): Promise => {
+ return await this._get(`/api/screens`, { expectations })
}
}
diff --git a/packages/server/src/tests/utilities/api/table.ts b/packages/server/src/tests/utilities/api/table.ts
index 5a9654e3bc..49105a3883 100644
--- a/packages/server/src/tests/utilities/api/table.ts
+++ b/packages/server/src/tests/utilities/api/table.ts
@@ -5,74 +5,38 @@ import {
SaveTableResponse,
Table,
} from "@budibase/types"
-import TestConfiguration from "../TestConfiguration"
-import { TestAPI } from "./base"
+import { Expectations, TestAPI } from "./base"
export class TableAPI extends TestAPI {
- constructor(config: TestConfiguration) {
- super(config)
- }
-
save = async (
data: SaveTableRequest,
- { expectStatus } = { expectStatus: 200 }
+ expectations?: Expectations
): Promise => {
- const res = await this.request
- .post(`/api/tables`)
- .send(data)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
-
- if (res.status !== expectStatus) {
- throw new Error(
- `Expected status ${expectStatus} but got ${
- res.status
- } with body ${JSON.stringify(res.body)}`
- )
- }
-
- return res.body
+ return await this._post("/api/tables", {
+ body: data,
+ expectations,
+ })
}
- fetch = async (
- { expectStatus } = { expectStatus: 200 }
- ): Promise => {
- const res = await this.request
- .get(`/api/tables`)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(expectStatus)
- return res.body
+ fetch = async (expectations?: Expectations): Promise => {
+ return await this._get("/api/tables", { expectations })
}
get = async (
tableId: string,
- { expectStatus } = { expectStatus: 200 }
+ expectations?: Expectations
): Promise => {
- const res = await this.request
- .get(`/api/tables/${tableId}`)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(expectStatus)
- return res.body
+ return await this._get(`/api/tables/${tableId}`, { expectations })
}
migrate = async (
tableId: string,
data: MigrateRequest,
- { expectStatus } = { expectStatus: 200 }
+ expectations?: Expectations
): Promise => {
- const res = await this.request
- .post(`/api/tables/${tableId}/migrate`)
- .send(data)
- .set(this.config.defaultHeaders())
- if (res.status !== expectStatus) {
- throw new Error(
- `Expected status ${expectStatus} but got ${
- res.status
- } with body ${JSON.stringify(res.body)}`
- )
- }
- return res.body
+ return await this._post(`/api/tables/${tableId}/migrate`, {
+ body: data,
+ expectations,
+ })
}
}
diff --git a/packages/server/src/tests/utilities/api/user.ts b/packages/server/src/tests/utilities/api/user.ts
index 2ed23c0461..bb3eae0542 100644
--- a/packages/server/src/tests/utilities/api/user.ts
+++ b/packages/server/src/tests/utilities/api/user.ts
@@ -4,154 +4,79 @@ import {
Flags,
UserMetadata,
} from "@budibase/types"
-import TestConfiguration from "../TestConfiguration"
-import { TestAPI } from "./base"
+import { Expectations, TestAPI } from "./base"
import { DocumentInsertResponse } from "@budibase/nano"
export class UserAPI extends TestAPI {
- constructor(config: TestConfiguration) {
- super(config)
- }
-
fetch = async (
- { expectStatus } = { expectStatus: 200 }
+ expectations?: Expectations
): Promise => {
- const res = await this.request
- .get(`/api/users/metadata`)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
-
- if (res.status !== expectStatus) {
- throw new Error(
- `Expected status ${expectStatus} but got ${
- res.status
- } with body ${JSON.stringify(res.body)}`
- )
- }
-
- return res.body
+ return await this._get("/api/users/metadata", {
+ expectations,
+ })
}
find = async (
id: string,
- { expectStatus } = { expectStatus: 200 }
+ expectations?: Expectations
): Promise => {
- const res = await this.request
- .get(`/api/users/metadata/${id}`)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
-
- if (res.status !== expectStatus) {
- throw new Error(
- `Expected status ${expectStatus} but got ${
- res.status
- } with body ${JSON.stringify(res.body)}`
- )
- }
-
- return res.body
+ return await this._get(
+ `/api/users/metadata/${id}`,
+ {
+ expectations,
+ }
+ )
}
update = async (
user: UserMetadata,
- { expectStatus } = { expectStatus: 200 }
+ expectations?: Expectations
): Promise => {
- const res = await this.request
- .put(`/api/users/metadata`)
- .set(this.config.defaultHeaders())
- .send(user)
- .expect("Content-Type", /json/)
-
- if (res.status !== expectStatus) {
- throw new Error(
- `Expected status ${expectStatus} but got ${
- res.status
- } with body ${JSON.stringify(res.body)}`
- )
- }
-
- return res.body as DocumentInsertResponse
+ return await this._put("/api/users/metadata", {
+ body: user,
+ expectations,
+ })
}
updateSelf = async (
user: UserMetadata,
- { expectStatus } = { expectStatus: 200 }
+ expectations?: Expectations
): Promise => {
- const res = await this.request
- .post(`/api/users/metadata/self`)
- .set(this.config.defaultHeaders())
- .send(user)
- .expect("Content-Type", /json/)
-
- if (res.status !== expectStatus) {
- throw new Error(
- `Expected status ${expectStatus} but got ${
- res.status
- } with body ${JSON.stringify(res.body)}`
- )
- }
-
- return res.body as DocumentInsertResponse
+ return await this._post(
+ "/api/users/metadata/self",
+ {
+ body: user,
+ expectations,
+ }
+ )
}
destroy = async (
id: string,
- { expectStatus } = { expectStatus: 200 }
+ expectations?: Expectations
): Promise<{ message: string }> => {
- const res = await this.request
- .delete(`/api/users/metadata/${id}`)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
-
- if (res.status !== expectStatus) {
- throw new Error(
- `Expected status ${expectStatus} but got ${
- res.status
- } with body ${JSON.stringify(res.body)}`
- )
- }
-
- return res.body as { message: string }
+ return await this._delete<{ message: string }>(
+ `/api/users/metadata/${id}`,
+ {
+ expectations,
+ }
+ )
}
setFlag = async (
flag: string,
value: any,
- { expectStatus } = { expectStatus: 200 }
+ expectations?: Expectations
): Promise<{ message: string }> => {
- const res = await this.request
- .post(`/api/users/flags`)
- .set(this.config.defaultHeaders())
- .send({ flag, value })
- .expect("Content-Type", /json/)
-
- if (res.status !== expectStatus) {
- throw new Error(
- `Expected status ${expectStatus} but got ${
- res.status
- } with body ${JSON.stringify(res.body)}`
- )
- }
-
- return res.body as { message: string }
+ return await this._post<{ message: string }>(`/api/users/flags`, {
+ body: { flag, value },
+ expectations,
+ })
}
- getFlags = async (
- { expectStatus } = { expectStatus: 200 }
- ): Promise => {
- const res = await this.request
- .get(`/api/users/flags`)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
-
- if (res.status !== expectStatus) {
- throw new Error(
- `Expected status ${expectStatus} but got ${
- res.status
- } with body ${JSON.stringify(res.body)}`
- )
- }
-
- return res.body as Flags
+ getFlags = async (expectations?: Expectations): Promise => {
+ return await this._get(`/api/users/flags`, {
+ expectations,
+ })
}
}
diff --git a/packages/server/src/tests/utilities/api/viewV2.ts b/packages/server/src/tests/utilities/api/viewV2.ts
index 92a6d394bf..d4539e00b1 100644
--- a/packages/server/src/tests/utilities/api/viewV2.ts
+++ b/packages/server/src/tests/utilities/api/viewV2.ts
@@ -3,21 +3,16 @@ import {
UpdateViewRequest,
ViewV2,
SearchViewRowRequest,
+ PaginatedSearchRowResponse,
} from "@budibase/types"
-import TestConfiguration from "../TestConfiguration"
-import { TestAPI } from "./base"
+import { Expectations, TestAPI } from "./base"
import { generator } from "@budibase/backend-core/tests"
-import { Response } from "superagent"
import sdk from "../../../sdk"
export class ViewV2API extends TestAPI {
- constructor(config: TestConfiguration) {
- super(config)
- }
-
create = async (
viewData?: Partial,
- { expectStatus } = { expectStatus: 201 }
+ expectations?: Expectations
): Promise => {
let tableId = viewData?.tableId
if (!tableId && !this.config.table) {
@@ -30,43 +25,36 @@ export class ViewV2API extends TestAPI {
name: generator.guid(),
...viewData,
}
- const result = await this.request
- .post(`/api/v2/views`)
- .send(view)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(expectStatus)
- return result.body.data as ViewV2
+
+ const exp: Expectations = {
+ status: 201,
+ ...expectations,
+ }
+
+ const resp = await this._post<{ data: ViewV2 }>("/api/v2/views", {
+ body: view,
+ expectations: exp,
+ })
+ return resp.data
}
update = async (
view: UpdateViewRequest,
- {
- expectStatus,
- handleResponse,
- }: {
- expectStatus: number
- handleResponse?: (response: Response) => void
- } = { expectStatus: 200 }
+ expectations?: Expectations
): Promise => {
- const result = await this.request
- .put(`/api/v2/views/${view.id}`)
- .send(view)
- .set(this.config.defaultHeaders())
- .expect("Content-Type", /json/)
- .expect(expectStatus)
-
- if (handleResponse) {
- handleResponse(result)
- }
- return result.body.data as ViewV2
+ const resp = await this._put<{ data: ViewV2 }>(`/api/v2/views/${view.id}`, {
+ body: view,
+ expectations,
+ })
+ return resp.data
}
- delete = async (viewId: string, { expectStatus } = { expectStatus: 204 }) => {
- return this.request
- .delete(`/api/v2/views/${viewId}`)
- .set(this.config.defaultHeaders())
- .expect(expectStatus)
+ delete = async (viewId: string, expectations?: Expectations) => {
+ const exp = {
+ status: 204,
+ ...expectations,
+ }
+ return await this._delete(`/api/v2/views/${viewId}`, { expectations: exp })
}
get = async (viewId: string) => {
@@ -78,17 +66,29 @@ export class ViewV2API extends TestAPI {
search = async (
viewId: string,
params?: SearchViewRowRequest,
- { expectStatus = 200, usePublicUser = false } = {}
+ expectations?: Expectations
) => {
- return this.request
- .post(`/api/v2/views/${viewId}/search`)
- .send(params)
- .set(
- usePublicUser
- ? this.config.publicHeaders()
- : this.config.defaultHeaders()
- )
- .expect("Content-Type", /json/)
- .expect(expectStatus)
+ return await this._post(
+ `/api/v2/views/${viewId}/search`,
+ {
+ body: params,
+ expectations,
+ }
+ )
+ }
+
+ publicSearch = async (
+ viewId: string,
+ params?: SearchViewRowRequest,
+ expectations?: Expectations
+ ) => {
+ return await this._post(
+ `/api/v2/views/${viewId}/search`,
+ {
+ body: params,
+ expectations,
+ publicUser: true,
+ }
+ )
}
}
diff --git a/packages/server/src/tests/utilities/structures.ts b/packages/server/src/tests/utilities/structures.ts
index 2fecf15fd6..5b50bd1175 100644
--- a/packages/server/src/tests/utilities/structures.ts
+++ b/packages/server/src/tests/utilities/structures.ts
@@ -22,6 +22,8 @@ import {
INTERNAL_TABLE_SOURCE_ID,
TableSourceType,
Query,
+ Webhook,
+ WebhookActionType,
} from "@budibase/types"
import { LoopInput, LoopStepType } from "../../definitions/automations"
@@ -407,12 +409,12 @@ export function basicLayout() {
return cloneDeep(EMPTY_LAYOUT)
}
-export function basicWebhook(automationId: string) {
+export function basicWebhook(automationId: string): Webhook {
return {
live: true,
name: "webhook",
action: {
- type: "automation",
+ type: WebhookActionType.AUTOMATION,
target: automationId,
},
}
diff --git a/packages/shared-core/src/filters.ts b/packages/shared-core/src/filters.ts
index 46d765a7b5..d9fe533c88 100644
--- a/packages/shared-core/src/filters.ts
+++ b/packages/shared-core/src/filters.ts
@@ -390,24 +390,41 @@ export const runLuceneQuery = (docs: any[], query?: SearchQuery) => {
}
)
- // Match a document against all criteria
const docMatch = (doc: any) => {
- return (
- stringMatch(doc) &&
- fuzzyMatch(doc) &&
- rangeMatch(doc) &&
- equalMatch(doc) &&
- notEqualMatch(doc) &&
- emptyMatch(doc) &&
- notEmptyMatch(doc) &&
- oneOf(doc) &&
- contains(doc) &&
- containsAny(doc) &&
- notContains(doc)
- )
- }
+ const filterFunctions: Record boolean> =
+ {
+ string: stringMatch,
+ fuzzy: fuzzyMatch,
+ range: rangeMatch,
+ equal: equalMatch,
+ notEqual: notEqualMatch,
+ empty: emptyMatch,
+ notEmpty: notEmptyMatch,
+ oneOf: oneOf,
+ contains: contains,
+ containsAny: containsAny,
+ notContains: notContains,
+ }
- // Process all docs
+ const activeFilterKeys: SearchQueryOperators[] = Object.entries(query || {})
+ .filter(
+ ([key, value]: [string, any]) =>
+ !["allOr", "onEmptyFilter"].includes(key) &&
+ value &&
+ Object.keys(value as Record).length > 0
+ )
+ .map(([key]) => key as any)
+
+ const results: boolean[] = activeFilterKeys.map(filterKey => {
+ return filterFunctions[filterKey]?.(doc) ?? false
+ })
+
+ if (query!.allOr) {
+ return results.some(result => result === true)
+ } else {
+ return results.every(result => result === true)
+ }
+ }
return docs.filter(docMatch)
}
diff --git a/packages/shared-core/src/tests/filters.test.ts b/packages/shared-core/src/tests/filters.test.ts
index 8586d58777..de969562af 100644
--- a/packages/shared-core/src/tests/filters.test.ts
+++ b/packages/shared-core/src/tests/filters.test.ts
@@ -47,10 +47,7 @@ describe("runLuceneQuery", () => {
},
]
- function buildQuery(
- filterKey: string,
- value: { [key: string]: any }
- ): SearchQuery {
+ function buildQuery(filters: { [filterKey: string]: any }): SearchQuery {
const query: SearchQuery = {
string: {},
fuzzy: {},
@@ -63,8 +60,13 @@ describe("runLuceneQuery", () => {
notContains: {},
oneOf: {},
containsAny: {},
+ allOr: false,
}
- query[filterKey as SearchQueryOperators] = value
+
+ for (const filterKey in filters) {
+ query[filterKey as SearchQueryOperators] = filters[filterKey]
+ }
+
return query
}
@@ -73,16 +75,17 @@ describe("runLuceneQuery", () => {
})
it("should return matching rows for equal filter", () => {
- const query = buildQuery("equal", {
- order_status: 4,
+ const query = buildQuery({
+ equal: { order_status: 4 },
})
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([1, 2])
})
it("should return matching row for notEqual filter", () => {
- const query = buildQuery("notEqual", {
- order_status: 4,
+ const query = buildQuery({
+ notEqual: { order_status: 4 },
})
+
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([3])
})
@@ -90,48 +93,56 @@ describe("runLuceneQuery", () => {
expect(
runLuceneQuery(
docs,
- buildQuery("fuzzy", {
- description: "sm",
+ buildQuery({
+ fuzzy: { description: "sm" },
})
).map(row => row.description)
).toEqual(["Small box"])
expect(
runLuceneQuery(
docs,
- buildQuery("string", {
- description: "SM",
+ buildQuery({
+ string: { description: "SM" },
})
).map(row => row.description)
).toEqual(["Small box"])
})
it("should return rows within a range filter", () => {
- const query = buildQuery("range", {
- customer_id: {
- low: 500,
- high: 1000,
+ const query = buildQuery({
+ range: {
+ customer_id: {
+ low: 500,
+ high: 1000,
+ },
},
})
+
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([3])
})
it("should return rows with numeric strings within a range filter", () => {
- const query = buildQuery("range", {
- customer_id: {
- low: "500",
- high: "1000",
+ const query = buildQuery({
+ range: {
+ customer_id: {
+ low: "500",
+ high: "1000",
+ },
},
})
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([3])
})
it("should return rows with ISO date strings within a range filter", () => {
- const query = buildQuery("range", {
- order_date: {
- low: "2016-01-04T00:00:00.000Z",
- high: "2016-01-11T00:00:00.000Z",
+ const query = buildQuery({
+ range: {
+ order_date: {
+ low: "2016-01-04T00:00:00.000Z",
+ high: "2016-01-11T00:00:00.000Z",
+ },
},
})
+
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([2])
})
@@ -150,40 +161,88 @@ describe("runLuceneQuery", () => {
label: "",
},
]
- const query = buildQuery("range", {
- order_date: {
- low: "2016-01-04T00:00:00.000Z",
- high: "2016-01-11T00:00:00.000Z",
+
+ const query = buildQuery({
+ range: {
+ order_date: {
+ low: "2016-01-04T00:00:00.000Z",
+ high: "2016-01-11T00:00:00.000Z",
+ },
},
})
+
expect(runLuceneQuery(docs, query)).toEqual(docs)
})
it("should return rows with matches on empty filter", () => {
- const query = buildQuery("empty", {
- label: null,
+ const query = buildQuery({
+ empty: {
+ label: null,
+ },
})
+
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([1])
})
it("should return rows with matches on notEmpty filter", () => {
- const query = buildQuery("notEmpty", {
- label: null,
+ const query = buildQuery({
+ notEmpty: {
+ label: null,
+ },
})
+
expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([2, 3])
})
test.each([[523, 259], "523,259"])(
"should return rows with matches on numeric oneOf filter",
input => {
- let query = buildQuery("oneOf", {
- customer_id: input,
+ const query = buildQuery({
+ oneOf: {
+ customer_id: input,
+ },
})
+
expect(runLuceneQuery(docs, query).map(row => row.customer_id)).toEqual([
259, 523,
])
}
)
+
+ test.each([
+ [false, []],
+ [true, [1, 2, 3]],
+ ])("should return %s if allOr is %s ", (allOr, expectedResult) => {
+ const query = buildQuery({
+ allOr,
+ oneOf: { staff_id: [10] },
+ contains: { description: ["box"] },
+ })
+
+ expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual(
+ expectedResult
+ )
+ })
+
+ it("should return matching results if allOr is true and only one filter matches with different operands", () => {
+ const query = buildQuery({
+ allOr: true,
+ equal: { order_status: 4 },
+ oneOf: { label: ["FRAGILE"] },
+ })
+
+ expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([1, 2])
+ })
+
+ it("should handle when a value is null or undefined", () => {
+ const query = buildQuery({
+ allOr: true,
+ equal: { order_status: null },
+ oneOf: { label: ["FRAGILE"] },
+ })
+
+ expect(runLuceneQuery(docs, query).map(row => row.order_id)).toEqual([2])
+ })
})
describe("buildLuceneQuery", () => {
diff --git a/packages/types/package.json b/packages/types/package.json
index ce4fce95fb..558e55a632 100644
--- a/packages/types/package.json
+++ b/packages/types/package.json
@@ -18,7 +18,7 @@
"@budibase/nano": "10.1.5",
"@types/koa": "2.13.4",
"@types/pouchdb": "6.4.0",
- "@types/redlock": "4.0.3",
+ "@types/redlock": "4.0.7",
"rimraf": "3.0.2",
"typescript": "5.2.2"
},
diff --git a/packages/types/src/api/web/app/datasource.ts b/packages/types/src/api/web/app/datasource.ts
index 4a3d07a952..f931665917 100644
--- a/packages/types/src/api/web/app/datasource.ts
+++ b/packages/types/src/api/web/app/datasource.ts
@@ -32,9 +32,7 @@ export interface FetchDatasourceInfoResponse {
tableNames: string[]
}
-export interface UpdateDatasourceRequest extends Datasource {
- datasource: Datasource
-}
+export interface UpdateDatasourceRequest extends Datasource {}
export interface BuildSchemaFromSourceRequest {
tablesFilter?: string[]
diff --git a/packages/types/src/api/web/app/permission.ts b/packages/types/src/api/web/app/permission.ts
index a8ab0e8084..88ff4e9d2f 100644
--- a/packages/types/src/api/web/app/permission.ts
+++ b/packages/types/src/api/web/app/permission.ts
@@ -1,4 +1,4 @@
-import { PlanType } from "../../../sdk"
+import { PermissionLevel, PlanType } from "../../../sdk"
export interface ResourcePermissionInfo {
role: string
@@ -14,3 +14,21 @@ export interface GetResourcePermsResponse {
export interface GetDependantResourcesResponse {
resourceByType?: Record
}
+
+export interface AddedPermission {
+ _id?: string
+ rev?: string
+ error?: string
+ reason?: string
+}
+
+export type AddPermissionResponse = AddedPermission[]
+
+export interface AddPermissionRequest {
+ roleId: string
+ resourceId: string
+ level: PermissionLevel
+}
+
+export interface RemovePermissionRequest extends AddPermissionRequest {}
+export interface RemovePermissionResponse extends AddPermissionResponse {}
diff --git a/packages/types/src/api/web/app/rows.ts b/packages/types/src/api/web/app/rows.ts
index 14e28e4a01..0a43182dfd 100644
--- a/packages/types/src/api/web/app/rows.ts
+++ b/packages/types/src/api/web/app/rows.ts
@@ -1,6 +1,6 @@
import { SearchFilters, SearchParams } from "../../../sdk"
import { Row } from "../../../documents"
-import { SortOrder } from "../../../api"
+import { PaginationResponse, SortOrder } from "../../../api"
import { ReadStream } from "fs"
export interface SaveRowRequest extends Row {}
@@ -31,6 +31,10 @@ export interface SearchRowResponse {
rows: any[]
}
+export interface PaginatedSearchRowResponse
+ extends SearchRowResponse,
+ PaginationResponse {}
+
export interface ExportRowsRequest {
rows: string[]
columns?: string[]
diff --git a/packages/types/src/api/web/application.ts b/packages/types/src/api/web/application.ts
index 87a0bd6ef9..3d33fce5b1 100644
--- a/packages/types/src/api/web/application.ts
+++ b/packages/types/src/api/web/application.ts
@@ -27,3 +27,9 @@ export interface FetchAppPackageResponse {
clientLibPath: string
hasLock: boolean
}
+
+export interface PublishResponse {
+ _id: string
+ status: string
+ appUrl: string
+}
diff --git a/packages/types/src/api/web/auth.ts b/packages/types/src/api/web/auth.ts
index 46b1e8cec9..5ff0c3c1f5 100644
--- a/packages/types/src/api/web/auth.ts
+++ b/packages/types/src/api/web/auth.ts
@@ -18,6 +18,7 @@ export interface UpdateSelfRequest {
password?: string
forceResetPassword?: boolean
onboardedAt?: string
+ tours?: Record
}
export interface UpdateSelfResponse {
diff --git a/packages/types/src/api/web/automation.ts b/packages/types/src/api/web/automation.ts
new file mode 100644
index 0000000000..c1f3d01b2f
--- /dev/null
+++ b/packages/types/src/api/web/automation.ts
@@ -0,0 +1,3 @@
+import { DocumentDestroyResponse } from "@budibase/nano"
+
+export interface DeleteAutomationResponse extends DocumentDestroyResponse {}
diff --git a/packages/types/src/api/web/index.ts b/packages/types/src/api/web/index.ts
index ab18add208..8a091afdba 100644
--- a/packages/types/src/api/web/index.ts
+++ b/packages/types/src/api/web/index.ts
@@ -11,3 +11,7 @@ export * from "./global"
export * from "./pagination"
export * from "./searchFilter"
export * from "./cookies"
+export * from "./automation"
+export * from "./layout"
+export * from "./query"
+export * from "./role"
diff --git a/packages/types/src/api/web/layout.ts b/packages/types/src/api/web/layout.ts
new file mode 100644
index 0000000000..50512777ef
--- /dev/null
+++ b/packages/types/src/api/web/layout.ts
@@ -0,0 +1,5 @@
+import { Layout } from "../../documents"
+
+export interface SaveLayoutRequest extends Layout {}
+
+export interface SaveLayoutResponse extends Layout {}
diff --git a/packages/types/src/api/web/query.ts b/packages/types/src/api/web/query.ts
new file mode 100644
index 0000000000..3959cdea19
--- /dev/null
+++ b/packages/types/src/api/web/query.ts
@@ -0,0 +1,20 @@
+import { QueryPreview, QuerySchema } from "../../documents"
+
+export interface PreviewQueryRequest extends QueryPreview {}
+
+export interface PreviewQueryResponse {
+ rows: any[]
+ nestedSchemaFields: { [key: string]: { [key: string]: string | QuerySchema } }
+ schema: { [key: string]: string | QuerySchema }
+ info: any
+ extra: any
+}
+
+export interface ExecuteQueryRequest {
+ parameters?: { [key: string]: string }
+ pagination?: any
+}
+
+export interface ExecuteQueryResponse {
+ data: Record[]
+}
diff --git a/packages/types/src/api/web/role.ts b/packages/types/src/api/web/role.ts
new file mode 100644
index 0000000000..c37dee60e0
--- /dev/null
+++ b/packages/types/src/api/web/role.ts
@@ -0,0 +1,22 @@
+import { Role } from "../../documents"
+
+export interface SaveRoleRequest {
+ _id?: string
+ _rev?: string
+ name: string
+ inherits: string
+ permissionId: string
+ version: string
+}
+
+export interface SaveRoleResponse extends Role {}
+
+export interface FindRoleResponse extends Role {}
+
+export type FetchRolesResponse = Role[]
+
+export interface DestroyRoleResponse {
+ message: string
+}
+
+export type AccessibleRolesResponse = string[]
diff --git a/packages/types/src/documents/app/datasource.ts b/packages/types/src/documents/app/datasource.ts
index 67035a2e72..8976e1cae3 100644
--- a/packages/types/src/documents/app/datasource.ts
+++ b/packages/types/src/documents/app/datasource.ts
@@ -6,6 +6,9 @@ export interface Datasource extends Document {
type: string
name?: string
source: SourceName
+ // this is a googlesheets specific property which
+ // can be found in the GSheets schema - pertains to SSO creds
+ auth?: { type: string }
// the config is defined by the schema
config?: Record
plus?: boolean
@@ -36,6 +39,12 @@ export interface RestAuthConfig {
config: RestBasicAuthConfig | RestBearerAuthConfig
}
+export interface DynamicVariable {
+ name: string
+ queryId: string
+ value: string
+}
+
export interface RestConfig {
url: string
rejectUnauthorized: boolean
@@ -47,11 +56,5 @@ export interface RestConfig {
staticVariables: {
[key: string]: string
}
- dynamicVariables: [
- {
- name: string
- queryId: string
- value: string
- }
- ]
+ dynamicVariables: DynamicVariable[]
}
diff --git a/packages/types/src/documents/app/layout.ts b/packages/types/src/documents/app/layout.ts
index 06542f680d..51ce511712 100644
--- a/packages/types/src/documents/app/layout.ts
+++ b/packages/types/src/documents/app/layout.ts
@@ -1,6 +1,11 @@
import { Document } from "../document"
export interface Layout extends Document {
+ componentLibraries: string[]
+ title: string
+ favicon: string
+ stylesheets: string[]
props: any
layoutId?: string
+ name?: string
}
diff --git a/packages/types/src/documents/app/query.ts b/packages/types/src/documents/app/query.ts
index b1b0a1d780..3227666bf3 100644
--- a/packages/types/src/documents/app/query.ts
+++ b/packages/types/src/documents/app/query.ts
@@ -62,22 +62,6 @@ export interface PaginationValues {
limit: number | null
}
-export interface PreviewQueryRequest extends Omit {
- parameters: {}
- flags?: {
- urlName?: boolean
- }
-}
-
-export interface ExecuteQueryRequest {
- parameters?: { [key: string]: string }
- pagination?: any
-}
-
-export interface ExecuteQueryResponse {
- data: Row[]
-}
-
export enum HttpMethod {
GET = "GET",
POST = "POST",
diff --git a/packages/types/src/documents/app/role.ts b/packages/types/src/documents/app/role.ts
index d126a67b16..f32ba810b0 100644
--- a/packages/types/src/documents/app/role.ts
+++ b/packages/types/src/documents/app/role.ts
@@ -5,4 +5,5 @@ export interface Role extends Document {
inherits?: string
permissions: { [key: string]: string[] }
version?: string
+ name: string
}
diff --git a/packages/types/src/documents/app/screen.ts b/packages/types/src/documents/app/screen.ts
index 58c00ef3d6..4977c79b0b 100644
--- a/packages/types/src/documents/app/screen.ts
+++ b/packages/types/src/documents/app/screen.ts
@@ -22,4 +22,5 @@ export interface Screen extends Document {
routing: ScreenRouting
props: ScreenProps
name?: string
+ pluginAdded?: boolean
}
diff --git a/packages/types/src/documents/app/table/table.ts b/packages/types/src/documents/app/table/table.ts
index f3b8e6df8d..3b419dd811 100644
--- a/packages/types/src/documents/app/table/table.ts
+++ b/packages/types/src/documents/app/table/table.ts
@@ -1,6 +1,6 @@
import { Document } from "../../document"
import { View, ViewV2 } from "../view"
-import { RenameColumn } from "../../../sdk"
+import { AddColumn, RenameColumn } from "../../../sdk"
import { TableSchema } from "./schema"
export const INTERNAL_TABLE_SOURCE_ID = "bb_internal"
@@ -29,5 +29,6 @@ export interface Table extends Document {
export interface TableRequest extends Table {
_rename?: RenameColumn
+ _add?: AddColumn
created?: boolean
}
diff --git a/packages/types/src/documents/global/user.ts b/packages/types/src/documents/global/user.ts
index 337855787f..ddb1e39c64 100644
--- a/packages/types/src/documents/global/user.ts
+++ b/packages/types/src/documents/global/user.ts
@@ -55,6 +55,7 @@ export interface User extends Document {
dayPassRecordedAt?: string
userGroups?: string[]
onboardedAt?: string
+ tours?: Record
scimInfo?: { isSync: true } & Record
ssoId?: string
}
diff --git a/packages/types/src/documents/global/userGroup.ts b/packages/types/src/documents/global/userGroup.ts
index e6b70a160c..f914b2876d 100644
--- a/packages/types/src/documents/global/userGroup.ts
+++ b/packages/types/src/documents/global/userGroup.ts
@@ -24,7 +24,7 @@ export interface GroupUser {
}
export interface UserGroupRoles {
- [key: string]: string
+ [key: string]: string | undefined
}
export interface SearchGroupRequest {}
diff --git a/packages/types/src/documents/pouch.ts b/packages/types/src/documents/pouch.ts
index 11efc502be..6ff851a515 100644
--- a/packages/types/src/documents/pouch.ts
+++ b/packages/types/src/documents/pouch.ts
@@ -5,15 +5,15 @@ export interface RowValue {
deleted: boolean
}
-export interface RowResponse {
+export interface RowResponse {
id: string
key: string
error: string
- value: T | RowValue
+ value: T
doc?: T
}
-export interface AllDocsResponse {
+export interface AllDocsResponse {
offset: number
total_rows: number
rows: RowResponse[]
diff --git a/packages/types/src/sdk/datasources.ts b/packages/types/src/sdk/datasources.ts
index 7a335eb3b9..6b09959b6c 100644
--- a/packages/types/src/sdk/datasources.ts
+++ b/packages/types/src/sdk/datasources.ts
@@ -1,4 +1,5 @@
-import { Table } from "../documents"
+import { Table, Row } from "../documents"
+import { QueryJson } from "./search"
export const PASSWORD_REPLACEMENT = "--secret-value--"
@@ -56,6 +57,7 @@ export enum SourceName {
FIRESTORE = "FIRESTORE",
REDIS = "REDIS",
SNOWFLAKE = "SNOWFLAKE",
+ BUDIBASE = "BUDIBASE",
}
export enum IncludeRelationship {
@@ -180,11 +182,24 @@ export interface Schema {
errors: Record
}
+// return these when an operation occurred but we got no response
+enum DSPlusOperation {
+ CREATE = "create",
+ READ = "read",
+ UPDATE = "update",
+ DELETE = "delete",
+}
+
+export type DatasourcePlusQueryResponse = Promise<
+ Row[] | Record[] | void
+>
+
export interface DatasourcePlus extends IntegrationBase {
// if the datasource supports the use of bindings directly (to protect against SQL injection)
// this returns the format of the identifier
getBindingIdentifier(): string
getStringConcat(parts: string[]): string
+ query(json: QueryJson): DatasourcePlusQueryResponse
buildSchema(
datasourceId: string,
entities: Record
diff --git a/packages/types/src/sdk/db.ts b/packages/types/src/sdk/db.ts
index 9e44a4827f..c4e4a4f02f 100644
--- a/packages/types/src/sdk/db.ts
+++ b/packages/types/src/sdk/db.ts
@@ -1,5 +1,11 @@
import type Nano from "@budibase/nano"
-import { AllDocsResponse, AnyDocument, Document, ViewTemplateOpts } from "../"
+import {
+ AllDocsResponse,
+ AnyDocument,
+ Document,
+ RowValue,
+ ViewTemplateOpts,
+} from "../"
import { Writable } from "stream"
export enum SearchIndex {
@@ -135,7 +141,7 @@ export interface Database {
opts?: DatabasePutOpts
): Promise
bulkDocs(documents: AnyDocument[]): Promise
- allDocs(
+ allDocs