Merge branch 'develop' of github.com:Budibase/budibase into feature/BUDI-7052

This commit is contained in:
mike12345567 2023-07-26 14:24:29 +01:00
commit 8253ad0a2e
64 changed files with 2555 additions and 387 deletions

View File

@ -1,5 +1,5 @@
{
"version": "2.8.22-alpha.3",
"version": "2.8.23-alpha.3",
"npmClient": "yarn",
"packages": [
"packages/*"

View File

@ -0,0 +1,10 @@
export const CONSTANT_INTERNAL_ROW_COLS = [
"_id",
"_rev",
"type",
"createdAt",
"updatedAt",
"tableId",
] as const
export const CONSTANT_EXTERNAL_ROW_COLS = ["_id", "_rev", "tableId"] as const

View File

@ -2,3 +2,4 @@ export * from "./connections"
export * from "./DatabaseImpl"
export * from "./utils"
export { init, getPouch, getPouchDB, closePouchDB } from "./pouchDB"
export * from "../constants"

View File

@ -1,3 +1,5 @@
import { db } from "../../../src"
export function expectFunctionWasCalledTimesWith(
jestFunction: any,
times: number,
@ -7,3 +9,22 @@ export function expectFunctionWasCalledTimesWith(
jestFunction.mock.calls.filter((call: any) => call[0] === argument).length
).toBe(times)
}
export const expectAnyInternalColsAttributes: {
[K in (typeof db.CONSTANT_INTERNAL_ROW_COLS)[number]]: any
} = {
tableId: expect.anything(),
type: expect.anything(),
_id: expect.anything(),
_rev: expect.anything(),
createdAt: expect.anything(),
updatedAt: expect.anything(),
}
export const expectAnyExternalColsAttributes: {
[K in (typeof db.CONSTANT_EXTERNAL_ROW_COLS)[number]]: any
} = {
tableId: expect.anything(),
_id: expect.anything(),
_rev: expect.anything(),
}

View File

@ -96,7 +96,8 @@
"dependsOn": [
{
"projects": [
"@budibase/string-templates"
"@budibase/string-templates",
"@budibase/shared-core"
],
"target": "build"
}

View File

@ -491,6 +491,7 @@ const getSelectedRowsBindings = asset => {
readableBinding: `${table._instanceName}.Selected rows`,
category: "Selected rows",
icon: "ViewRow",
display: { name: table._instanceName },
}))
)
@ -506,6 +507,7 @@ const getSelectedRowsBindings = asset => {
)}.${makePropSafe("selectedRows")}`,
readableBinding: `${block._instanceName}.Selected rows`,
category: "Selected rows",
display: { name: block._instanceName },
}))
)
}

View File

@ -206,6 +206,11 @@
return allBindings
}
const toDisplay = eventKey => {
const type = actionTypes.find(action => action.name == eventKey)
return type?.displayName || type?.name
}
</script>
<DrawerContent>
@ -231,7 +236,9 @@
<ul>
{#each category as actionType}
<li on:click={onAddAction(actionType)}>
<span class="action-name">{actionType.name}</span>
<span class="action-name">
{actionType.displayName || actionType.name}
</span>
</li>
{/each}
</ul>
@ -262,7 +269,7 @@
>
<Icon name="DragHandle" size="XL" />
<div class="action-header">
{index + 1}.&nbsp;{action[EVENT_TYPE_KEY]}
{index + 1}.&nbsp;{toDisplay(action[EVENT_TYPE_KEY])}
</div>
<Icon
name="Close"

View File

@ -1,5 +1,5 @@
<script>
import { Select, Label, Checkbox, Input } from "@budibase/bbui"
import { Select, Label, Checkbox, Input, Body } from "@budibase/bbui"
import { tables } from "stores/backend"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
@ -10,47 +10,59 @@
</script>
<div class="root">
<Label>Table</Label>
<Select
bind:value={parameters.tableId}
options={tableOptions}
getOptionLabel={table => table.name}
getOptionValue={table => table._id}
/>
<Label small>Row ID</Label>
<DrawerBindableInput
{bindings}
title="Row ID to delete"
value={parameters.rowId}
on:change={value => (parameters.rowId = value.detail)}
/>
<Label small />
<Checkbox
text="Do not display default notification"
bind:value={parameters.notificationOverride}
/>
<br />
<Checkbox text="Require confirmation" bind:value={parameters.confirm} />
{#if parameters.confirm}
<Label small>Confirm text</Label>
<Input
placeholder="Are you sure you want to delete this row?"
bind:value={parameters.confirmText}
<Body size="small">Please specify one or more rows to delete.</Body>
<div class="params">
<Label>Table</Label>
<Select
bind:value={parameters.tableId}
options={tableOptions}
getOptionLabel={table => table.name}
getOptionValue={table => table._id}
/>
{/if}
<Label small>Row IDs</Label>
<DrawerBindableInput
{bindings}
title="Rows to delete"
value={parameters.rowId}
on:change={value => (parameters.rowId = value.detail)}
/>
<Label small />
<Checkbox
text="Do not display default notification"
bind:value={parameters.notificationOverride}
/>
<br />
<Checkbox text="Require confirmation" bind:value={parameters.confirm} />
{#if parameters.confirm}
<Label small>Confirm text</Label>
<Input
placeholder="Are you sure you want to delete?"
bind:value={parameters.confirmText}
/>
{/if}
</div>
</div>
<style>
.root {
width: 100%;
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-xl);
}
.params {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
grid-template-columns: 60px 1fr;
align-items: center;
max-width: 800px;
margin: 0 auto;
}
</style>

View File

@ -24,6 +24,7 @@
},
{
"name": "Delete Row",
"displayName": "Delete Rows",
"type": "data",
"component": "DeleteRow"
},

View File

@ -14,8 +14,9 @@
Tab,
Modal,
ModalContent,
notifications,
Divider,
} from "@budibase/bbui"
import { notifications, Divider } from "@budibase/bbui"
import ExtraQueryConfig from "./ExtraQueryConfig.svelte"
import IntegrationQueryEditor from "components/integration/index.svelte"
import ExternalDataSourceTable from "components/backend/DataTable/ExternalDataSourceTable.svelte"
@ -28,6 +29,7 @@
import KeyValueBuilder from "./KeyValueBuilder.svelte"
import { fieldsToSchema, schemaToFields } from "helpers/data/utils"
import AccessLevelSelect from "./AccessLevelSelect.svelte"
import { ValidQueryNameRegex } from "@budibase/shared-core"
export let query
@ -47,6 +49,7 @@
let saveModal
let override = false
let navigateTo = null
let nameError = null
// seed the transformer
if (query && !query.transformer) {
@ -77,7 +80,7 @@
$: queryConfig = integrationInfo?.query
$: shouldShowQueryConfig = queryConfig && query.queryVerb
$: readQuery = query.queryVerb === "read" || query.readable
$: queryInvalid = !query.name || (readQuery && data.length === 0)
$: queryInvalid = !query.name || nameError || (readQuery && data.length === 0)
//Cast field in query preview response to number if specified by schema
$: {
@ -139,9 +142,10 @@
queryStr = JSON.stringify(query)
}
notifications.success("Query saved successfully")
return response
} catch (error) {
notifications.error("Error saving query")
notifications.error(error.message || "Error saving query")
}
}
</script>
@ -183,8 +187,14 @@
value={query.name}
on:input={e => {
let newValue = e.target.value || ""
query.name = newValue.trim()
if (newValue.match(ValidQueryNameRegex)) {
query.name = newValue.trim()
nameError = null
} else {
nameError = "Invalid query name"
}
}}
error={nameError}
/>
</div>
{#if queryConfig}
@ -250,9 +260,9 @@
size="L"
/>
</div>
<Body size="S"
>Add a JavaScript function to transform the query result.</Body
>
<Body size="S">
Add a JavaScript function to transform the query result.
</Body>
<CodeMirrorEditor
height={200}
label="Transformer"
@ -264,13 +274,12 @@
</div>
<div class="viewer-controls">
<Heading size="S">Results</Heading>
<ButtonGroup gap="XS">
<ButtonGroup gap="S">
<Button
cta
disabled={queryInvalid}
on:click={async () => {
await saveQuery()
notifications.success(`Query saved successfully`)
// Go to the correct URL if we just created a new query
if (!query._rev) {
$goto(`../../${query._id}`)

View File

@ -47,6 +47,14 @@
)
}
// If the data changes, double check that the selected elements are still present.
$: if (data) {
let rowIds = data.map(row => row._id)
if (rowIds.length) {
selectedRows = selectedRows.filter(row => rowIds.includes(row._id))
}
}
const getFields = (schema, customColumns, showAutoColumns) => {
// Check for an invalid column selection
let invalid = false

View File

@ -102,12 +102,46 @@ const fetchRowHandler = async action => {
}
const deleteRowHandler = async action => {
const { tableId, revId, rowId, notificationOverride } = action.parameters
if (tableId && rowId) {
const { tableId, rowId: rowConfig, notificationOverride } = action.parameters
if (tableId && rowConfig) {
try {
await API.deleteRow({ tableId, rowId, revId })
let requestConfig
let parsedRowConfig = []
if (typeof rowConfig === "string") {
try {
parsedRowConfig = JSON.parse(rowConfig)
} catch (e) {
parsedRowConfig = rowConfig
.split(",")
.map(id => id.trim())
.filter(id => id)
}
} else {
parsedRowConfig = rowConfig
}
if (
typeof parsedRowConfig === "object" &&
parsedRowConfig.constructor === Object
) {
requestConfig = [parsedRowConfig]
} else if (Array.isArray(parsedRowConfig)) {
requestConfig = parsedRowConfig
}
if (!requestConfig.length) {
notificationStore.actions.warning("No valid rows were supplied")
return false
}
const resp = await API.deleteRows({ tableId, rows: requestConfig })
if (!notificationOverride) {
notificationStore.actions.success("Row deleted")
notificationStore.actions.success(
resp?.length == 1 ? "Row deleted" : `${resp.length} Rows deleted`
)
}
// Refresh related datasources
@ -115,8 +149,10 @@ const deleteRowHandler = async action => {
invalidateRelationships: true,
})
} catch (error) {
// Abort next actions
return false
console.error(error)
notificationStore.actions.error(
"An error occurred while executing the query"
)
}
}
}

@ -1 +1 @@
Subproject commit 347ee5326812c01ef07f0e744f691ab4823e185a
Subproject commit a60183319f410d05aaa1c2f2718b772978b54d64

View File

@ -5,7 +5,7 @@ import { convertBookmark } from "../../../utilities"
// makes sure that the user doesn't need to pass in the type, tableId or _id params for
// the call to be correct
function fixRow(row: Row, params: any) {
export function fixRow(row: Row, params: any) {
if (!params || !row) {
return row
}

View File

@ -10,6 +10,7 @@ import { events, context, utils, constants } from "@budibase/backend-core"
import sdk from "../../../sdk"
import { QueryEvent } from "../../../threads/definitions"
import { Query } from "@budibase/types"
import { ValidQueryNameRegex } from "@budibase/shared-core"
const Runner = new Thread(ThreadType.QUERY, {
timeoutMs: env.QUERY_THREAD_TIMEOUT || 10000,
@ -76,6 +77,11 @@ export async function save(ctx: any) {
const db = context.getAppDB()
const query = ctx.request.body
// Validate query name
if (!query?.name.match(ValidQueryNameRegex)) {
ctx.throw(400, "Invalid query name")
}
const datasource = await sdk.datasources.get(query.datasourceId)
let eventFn

View File

@ -73,9 +73,11 @@ export async function patch(ctx: UserCtx) {
row: inputs,
})
const row = await getRow(tableId, id, { relationships: true })
const table = await sdk.tables.getTable(tableId)
return {
...response,
row,
table,
}
}

View File

@ -2,9 +2,22 @@ import { quotas } from "@budibase/pro"
import * as internal from "./internal"
import * as external from "./external"
import { isExternalTable } from "../../../integrations/utils"
import { Ctx } from "@budibase/types"
import {
Ctx,
UserCtx,
DeleteRowRequest,
DeleteRow,
DeleteRows,
Row,
SearchResponse,
SortOrder,
SortType,
ViewV2,
} from "@budibase/types"
import * as utils from "./utils"
import { gridSocket } from "../../../websockets"
import { addRev } from "../public/utils"
import { fixRow } from "../public/rows"
import sdk from "../../../sdk"
import * as exporters from "../view/exporters"
import { apiFileReturn } from "../../../utilities/fileSystem"
@ -98,35 +111,83 @@ export async function find(ctx: any) {
})
}
export async function destroy(ctx: any) {
const appId = ctx.appId
const inputs = ctx.request.body
function isDeleteRows(input: any): input is DeleteRows {
return input.rows !== undefined && Array.isArray(input.rows)
}
function isDeleteRow(input: any): input is DeleteRow {
return input._id !== undefined
}
async function processDeleteRowsRequest(ctx: UserCtx<DeleteRowRequest>) {
let request = ctx.request.body as DeleteRows
const tableId = utils.getTableId(ctx)
let response, row
if (inputs.rows) {
let { rows } = await quotas.addQuery<any>(
() => pickApi(tableId).bulkDestroy(ctx),
{
datasourceId: tableId,
}
)
await quotas.removeRows(rows.length)
response = rows
for (let row of rows) {
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row)
gridSocket?.emitRowDeletion(ctx, row._id)
}
} else {
let resp = await quotas.addQuery<any>(() => pickApi(tableId).destroy(ctx), {
const processedRows = request.rows.map(row => {
let processedRow: Row = typeof row == "string" ? { _id: row } : row
return !processedRow._rev
? addRev(fixRow(processedRow, ctx.params), tableId)
: fixRow(processedRow, ctx.params)
})
return await Promise.all(processedRows)
}
async function deleteRows(ctx: UserCtx<DeleteRowRequest>) {
const tableId = utils.getTableId(ctx)
const appId = ctx.appId
let deleteRequest = ctx.request.body as DeleteRows
const rowDeletes: Row[] = await processDeleteRowsRequest(ctx)
deleteRequest.rows = rowDeletes
let { rows } = await quotas.addQuery<any>(
() => pickApi(tableId).bulkDestroy(ctx),
{
datasourceId: tableId,
})
await quotas.removeRow()
response = resp.response
row = resp.row
}
)
await quotas.removeRows(rows.length)
for (let row of rows) {
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row)
gridSocket?.emitRowDeletion(ctx, row._id)
gridSocket?.emitRowDeletion(ctx, row._id!)
}
return rows
}
async function deleteRow(ctx: UserCtx<DeleteRowRequest>) {
const appId = ctx.appId
const tableId = utils.getTableId(ctx)
let resp = await quotas.addQuery<any>(() => pickApi(tableId).destroy(ctx), {
datasourceId: tableId,
})
await quotas.removeRow()
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, resp.row)
gridSocket?.emitRowDeletion(ctx, resp.row._id)
return resp
}
export async function destroy(ctx: UserCtx<DeleteRowRequest>) {
let response, row
ctx.status = 200
if (isDeleteRows(ctx.request.body)) {
response = await deleteRows(ctx)
} else if (isDeleteRow(ctx.request.body)) {
const deleteResp = await deleteRow(ctx)
response = deleteResp.response
row = deleteResp.row
} else {
ctx.status = 400
response = { message: "Invalid delete rows request" }
}
// for automations include the row that was deleted
ctx.row = row || {}
ctx.body = response
@ -146,6 +207,80 @@ export async function search(ctx: any) {
})
}
function getSortOptions(
ctx: Ctx,
view: ViewV2
):
| {
sort: string
sortOrder?: SortOrder
sortType?: SortType
}
| undefined {
const { sort_column, sort_order, sort_type } = ctx.query
if (Array.isArray(sort_column)) {
ctx.throw(400, "sort_column cannot be an array")
}
if (Array.isArray(sort_order)) {
ctx.throw(400, "sort_order cannot be an array")
}
if (Array.isArray(sort_type)) {
ctx.throw(400, "sort_type cannot be an array")
}
if (sort_column) {
return {
sort: sort_column,
sortOrder: sort_order as SortOrder,
sortType: sort_type as SortType,
}
}
if (view.sort) {
return {
sort: view.sort.field,
sortOrder: view.sort.order,
sortType: view.sort.type,
}
}
return
}
export async function searchView(ctx: Ctx<void, SearchResponse>) {
const { viewId } = ctx.params
const view = await sdk.views.get(viewId)
if (!view) {
ctx.throw(404, `View ${viewId} not found`)
}
if (view.version !== 2) {
ctx.throw(400, `This method only supports viewsV2`)
}
const table = await sdk.tables.getTable(view?.tableId)
const viewFields =
(view.columns &&
Object.entries(view.columns).length &&
Object.keys(sdk.views.enrichSchema(view, table.schema).schema)) ||
undefined
ctx.status = 200
ctx.body = await quotas.addQuery(
() =>
sdk.rows.search({
tableId: view.tableId,
query: view.query || {},
fields: viewFields,
...getSortOptions(ctx, view),
}),
{
datasourceId: view.tableId,
}
)
}
export async function validate(ctx: Ctx) {
const tableId = utils.getTableId(ctx)
// external tables are hard to validate currently

View File

@ -19,7 +19,6 @@ import { UserCtx, LinkDocumentValue, Row, Table } from "@budibase/types"
import sdk from "../../../sdk"
export async function patch(ctx: UserCtx) {
const db = context.getAppDB()
const inputs = ctx.request.body
const tableId = inputs.tableId
const isUserTable = tableId === InternalTables.USER_METADATA
@ -77,7 +76,7 @@ export async function patch(ctx: UserCtx) {
// the row has been updated, need to put it into the ctx
ctx.request.body = row
await userController.updateMetadata(ctx)
return { row: ctx.body, table }
return { row: ctx.body as Row, table }
}
return finaliseRow(table, row, {

View File

@ -6,9 +6,13 @@ import {
isRows,
} from "../../../utilities/schema"
import { isExternalTable, isSQL } from "../../../integrations/utils"
import { getDatasourceParams } from "../../../db/utils"
import { context, events } from "@budibase/backend-core"
import { Table, UserCtx } from "@budibase/types"
import { events } from "@budibase/backend-core"
import {
FetchTablesResponse,
Table,
TableResponse,
UserCtx,
} from "@budibase/types"
import sdk from "../../../sdk"
import { jsonFromCsvString } from "../../../utilities/csv"
import { builderSocket } from "../../../websockets"
@ -26,37 +30,34 @@ function pickApi({ tableId, table }: { tableId?: string; table?: Table }) {
}
// covers both internal and external
export async function fetch(ctx: UserCtx) {
const db = context.getAppDB()
export async function fetch(ctx: UserCtx<void, FetchTablesResponse>) {
const internal = await sdk.tables.getAllInternalTables()
const externalTables = await db.allDocs(
getDatasourceParams("plus", {
include_docs: true,
})
)
const externalTables = await sdk.datasources.getExternalDatasources()
const external = externalTables.rows.flatMap(tableDoc => {
let entities = tableDoc.doc.entities
const external = externalTables.flatMap(table => {
let entities = table.entities
if (entities) {
return Object.values(entities).map((entity: any) => ({
return Object.values(entities).map<Table>((entity: Table) => ({
...entity,
type: "external",
sourceId: tableDoc.doc._id,
sql: isSQL(tableDoc.doc),
sourceId: table._id,
sql: isSQL(table),
}))
} else {
return []
}
})
ctx.body = [...internal, ...external]
const response = [...internal, ...external].map(sdk.tables.enrichViewSchemas)
ctx.body = response
}
export async function find(ctx: UserCtx) {
export async function find(ctx: UserCtx<void, TableResponse>) {
const tableId = ctx.params.tableId
ctx.body = await sdk.tables.getTable(tableId)
const table = await sdk.tables.getTable(tableId)
ctx.body = sdk.tables.enrichViewSchemas(table)
}
export async function save(ctx: UserCtx) {

View File

@ -1,195 +1,2 @@
import viewTemplate from "./viewBuilder"
import { apiFileReturn } from "../../../utilities/fileSystem"
import { csv, json, jsonWithSchema, Format, isFormat } from "./exporters"
import { deleteView, getView, getViews, saveView } from "./utils"
import { fetchView } from "../row"
import { context, events } from "@budibase/backend-core"
import { DocumentType } from "../../../db/utils"
import sdk from "../../../sdk"
import { FieldTypes } from "../../../constants"
import {
Ctx,
Row,
Table,
TableExportFormat,
TableSchema,
View,
} from "@budibase/types"
import { builderSocket } from "../../../websockets"
const { cloneDeep, isEqual } = require("lodash")
export async function fetch(ctx: Ctx) {
ctx.body = await getViews()
}
export async function save(ctx: Ctx) {
const db = context.getAppDB()
const { originalName, ...viewToSave } = ctx.request.body
const existingTable = await sdk.tables.getTable(ctx.request.body.tableId)
existingTable.views ??= {}
const table = cloneDeep(existingTable)
const groupByField: any = Object.values(table.schema).find(
(field: any) => field.name == viewToSave.groupBy
)
const view = viewTemplate(viewToSave, groupByField?.type === FieldTypes.ARRAY)
const viewName = viewToSave.name
if (!viewName) {
ctx.throw(400, "Cannot create view without a name")
}
await saveView(originalName, viewName, view)
// add views to table document
if (!table.views) table.views = {}
if (!view.meta.schema) {
view.meta.schema = table.schema
}
table.views[viewName] = { ...view.meta, name: viewName }
if (originalName) {
delete table.views[originalName]
existingTable.views[viewName] = existingTable.views[originalName]
}
await db.put(table)
await handleViewEvents(existingTable.views[viewName], table.views[viewName])
ctx.body = table.views[viewName]
builderSocket?.emitTableUpdate(ctx, table)
}
export async function calculationEvents(existingView: View, newView: View) {
const existingCalculation = existingView && existingView.calculation
const newCalculation = newView && newView.calculation
if (existingCalculation && !newCalculation) {
await events.view.calculationDeleted(existingView)
}
if (!existingCalculation && newCalculation) {
await events.view.calculationCreated(newView)
}
if (
existingCalculation &&
newCalculation &&
existingCalculation !== newCalculation
) {
await events.view.calculationUpdated(newView)
}
}
export async function filterEvents(existingView: View, newView: View) {
const hasExistingFilters = !!(
existingView &&
existingView.filters &&
existingView.filters.length
)
const hasNewFilters = !!(newView && newView.filters && newView.filters.length)
if (hasExistingFilters && !hasNewFilters) {
await events.view.filterDeleted(newView)
}
if (!hasExistingFilters && hasNewFilters) {
await events.view.filterCreated(newView)
}
if (
hasExistingFilters &&
hasNewFilters &&
!isEqual(existingView.filters, newView.filters)
) {
await events.view.filterUpdated(newView)
}
}
async function handleViewEvents(existingView: View, newView: View) {
if (!existingView) {
await events.view.created(newView)
} else {
await events.view.updated(newView)
}
await calculationEvents(existingView, newView)
await filterEvents(existingView, newView)
}
export async function destroy(ctx: Ctx) {
const db = context.getAppDB()
const viewName = decodeURIComponent(ctx.params.viewName)
const view = await deleteView(viewName)
const table = await sdk.tables.getTable(view.meta.tableId)
delete table.views![viewName]
await db.put(table)
await events.view.deleted(view)
ctx.body = view
builderSocket?.emitTableUpdate(ctx, table)
}
export async function exportView(ctx: Ctx) {
const viewName = decodeURIComponent(ctx.query.view as string)
const view = await getView(viewName)
const format = ctx.query.format as unknown
if (!isFormat(format)) {
ctx.throw(
400,
"Format must be specified, either csv, json or jsonWithSchema"
)
}
if (view) {
ctx.params.viewName = viewName
// Fetch view rows
ctx.query = {
group: view.meta.groupBy,
calculation: view.meta.calculation,
// @ts-ignore
stats: !!view.meta.field,
field: view.meta.field,
}
} else {
// table all_ view
/* istanbul ignore next */
ctx.params.viewName = viewName
}
await fetchView(ctx)
let rows = ctx.body as Row[]
let schema: TableSchema = view && view.meta && view.meta.schema
const tableId =
ctx.params.tableId ||
view?.meta?.tableId ||
(viewName.startsWith(DocumentType.TABLE) && viewName)
const table: Table = await sdk.tables.getTable(tableId)
if (!schema) {
schema = table.schema
}
let exportRows = sdk.rows.utils.cleanExportRows(rows, schema, format, [])
if (format === Format.CSV) {
ctx.attachment(`${viewName}.csv`)
ctx.body = apiFileReturn(csv(Object.keys(schema), exportRows))
} else if (format === Format.JSON) {
ctx.attachment(`${viewName}.json`)
ctx.body = apiFileReturn(json(exportRows))
} else if (format === Format.JSON_WITH_SCHEMA) {
ctx.attachment(`${viewName}.json`)
ctx.body = apiFileReturn(jsonWithSchema(schema, exportRows))
} else {
throw "Format not recognised"
}
if (viewName.startsWith(DocumentType.TABLE)) {
await events.table.exported(table, format as TableExportFormat)
} else {
await events.view.exported(table, format as TableExportFormat)
}
}
export * as v1 from "./views"
export * as v2 from "./viewsV2"

View File

@ -0,0 +1,198 @@
import viewTemplate from "./viewBuilder"
import { apiFileReturn } from "../../../utilities/fileSystem"
import { csv, json, jsonWithSchema, Format, isFormat } from "./exporters"
import { deleteView, getView, getViews, saveView } from "./utils"
import { fetchView } from "../row"
import { context, events } from "@budibase/backend-core"
import { DocumentType } from "../../../db/utils"
import sdk from "../../../sdk"
import { FieldTypes } from "../../../constants"
import {
Ctx,
Row,
Table,
TableExportFormat,
TableSchema,
View,
} from "@budibase/types"
import { builderSocket } from "../../../websockets"
const { cloneDeep, isEqual } = require("lodash")
export async function fetch(ctx: Ctx) {
ctx.body = await getViews()
}
export async function save(ctx: Ctx) {
const db = context.getAppDB()
const { originalName, ...viewToSave } = ctx.request.body
const existingTable = await sdk.tables.getTable(ctx.request.body.tableId)
existingTable.views ??= {}
const table = cloneDeep(existingTable)
const groupByField: any = Object.values(table.schema).find(
(field: any) => field.name == viewToSave.groupBy
)
const view = viewTemplate(viewToSave, groupByField?.type === FieldTypes.ARRAY)
const viewName = viewToSave.name
if (!viewName) {
ctx.throw(400, "Cannot create view without a name")
}
await saveView(originalName, viewName, view)
// add views to table document
if (!table.views) table.views = {}
if (!view.meta.schema) {
view.meta.schema = table.schema
}
table.views[viewName] = { ...view.meta, name: viewName }
if (originalName) {
delete table.views[originalName]
existingTable.views[viewName] = existingTable.views[originalName]
}
await db.put(table)
await handleViewEvents(
existingTable.views[viewName] as View,
table.views[viewName]
)
ctx.body = table.views[viewName]
builderSocket?.emitTableUpdate(ctx, table)
}
export async function calculationEvents(existingView: View, newView: View) {
const existingCalculation = existingView && existingView.calculation
const newCalculation = newView && newView.calculation
if (existingCalculation && !newCalculation) {
await events.view.calculationDeleted(existingView)
}
if (!existingCalculation && newCalculation) {
await events.view.calculationCreated(newView)
}
if (
existingCalculation &&
newCalculation &&
existingCalculation !== newCalculation
) {
await events.view.calculationUpdated(newView)
}
}
export async function filterEvents(existingView: View, newView: View) {
const hasExistingFilters = !!(
existingView &&
existingView.filters &&
existingView.filters.length
)
const hasNewFilters = !!(newView && newView.filters && newView.filters.length)
if (hasExistingFilters && !hasNewFilters) {
await events.view.filterDeleted(newView)
}
if (!hasExistingFilters && hasNewFilters) {
await events.view.filterCreated(newView)
}
if (
hasExistingFilters &&
hasNewFilters &&
!isEqual(existingView.filters, newView.filters)
) {
await events.view.filterUpdated(newView)
}
}
async function handleViewEvents(existingView: View, newView: View) {
if (!existingView) {
await events.view.created(newView)
} else {
await events.view.updated(newView)
}
await calculationEvents(existingView, newView)
await filterEvents(existingView, newView)
}
export async function destroy(ctx: Ctx) {
const db = context.getAppDB()
const viewName = decodeURIComponent(ctx.params.viewName)
const view = await deleteView(viewName)
const table = await sdk.tables.getTable(view.meta.tableId)
delete table.views![viewName]
await db.put(table)
await events.view.deleted(view)
ctx.body = view
builderSocket?.emitTableUpdate(ctx, table)
}
export async function exportView(ctx: Ctx) {
const viewName = decodeURIComponent(ctx.query.view as string)
const view = await getView(viewName)
const format = ctx.query.format as unknown
if (!isFormat(format)) {
ctx.throw(
400,
"Format must be specified, either csv, json or jsonWithSchema"
)
}
if (view) {
ctx.params.viewName = viewName
// Fetch view rows
ctx.query = {
group: view.meta.groupBy,
calculation: view.meta.calculation,
// @ts-ignore
stats: !!view.meta.field,
field: view.meta.field,
}
} else {
// table all_ view
/* istanbul ignore next */
ctx.params.viewName = viewName
}
await fetchView(ctx)
let rows = ctx.body as Row[]
let schema: TableSchema = view && view.meta && view.meta.schema
const tableId =
ctx.params.tableId ||
view?.meta?.tableId ||
(viewName.startsWith(DocumentType.TABLE) && viewName)
const table: Table = await sdk.tables.getTable(tableId)
if (!schema) {
schema = table.schema
}
let exportRows = sdk.rows.utils.cleanExportRows(rows, schema, format, [])
if (format === Format.CSV) {
ctx.attachment(`${viewName}.csv`)
ctx.body = apiFileReturn(csv(Object.keys(schema), exportRows))
} else if (format === Format.JSON) {
ctx.attachment(`${viewName}.json`)
ctx.body = apiFileReturn(json(exportRows))
} else if (format === Format.JSON_WITH_SCHEMA) {
ctx.attachment(`${viewName}.json`)
ctx.body = apiFileReturn(jsonWithSchema(schema, exportRows))
} else {
throw "Format not recognised"
}
if (viewName.startsWith(DocumentType.TABLE)) {
await events.table.exported(table, format as TableExportFormat)
} else {
await events.view.exported(table, format as TableExportFormat)
}
}

View File

@ -0,0 +1,44 @@
import sdk from "../../../sdk"
import {
CreateViewRequest,
Ctx,
UpdateViewRequest,
ViewResponse,
} from "@budibase/types"
export async function create(ctx: Ctx<CreateViewRequest, ViewResponse>) {
const view = ctx.request.body
const { tableId } = view
const result = await sdk.views.create(tableId, view)
ctx.status = 201
ctx.body = {
data: result,
}
}
export async function update(ctx: Ctx<UpdateViewRequest, ViewResponse>) {
const view = ctx.request.body
if (view.version !== 2) {
ctx.throw(400, "Only views V2 can be updated")
}
if (ctx.params.viewId !== view.id) {
ctx.throw(400, "View id does not match between the body and the uri path")
}
const { tableId } = view
const result = await sdk.views.update(tableId, view)
ctx.body = {
data: result,
}
}
export async function remove(ctx: Ctx) {
const { viewId } = ctx.params
await sdk.views.remove(viewId)
ctx.status = 204
}

View File

@ -146,6 +146,11 @@ router
authorized(PermissionType.TABLE, PermissionLevel.READ),
rowController.search
)
.get(
"/api/v2/views/:viewId/search",
authorized(PermissionType.VIEW, PermissionLevel.READ),
rowController.searchView
)
/**
* @api {post} /api/:tableId/rows Creates a new row
* @apiName Creates a new row

View File

@ -5,7 +5,7 @@ tk.freeze(timestamp)
import { outputProcessing } from "../../../utilities/rowProcessor"
import * as setup from "./utilities"
const { basicRow } = setup.structures
import { context, tenancy } from "@budibase/backend-core"
import { context, db, tenancy } from "@budibase/backend-core"
import { quotas } from "@budibase/pro"
import {
QuotaUsageType,
@ -14,8 +14,14 @@ import {
Row,
Table,
FieldType,
SortType,
SortOrder,
} from "@budibase/types"
import { structures } from "@budibase/backend-core/tests"
import {
expectAnyInternalColsAttributes,
generator,
structures,
} from "@budibase/backend-core/tests"
describe("/rows", () => {
let request = setup.getRequest()
@ -517,6 +523,81 @@ describe("/rows", () => {
await assertRowUsage(rowUsage - 2)
await assertQueryUsage(queryUsage + 1)
})
it("should be able to delete a variety of row set types", async () => {
const row1 = await config.createRow()
const row2 = await config.createRow()
const row3 = await config.createRow()
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await request
.delete(`/api/${table._id}/rows`)
.send({
rows: [row1, row2._id, { _id: row3._id }],
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.length).toEqual(3)
await loadRow(row1._id!, table._id!, 404)
await assertRowUsage(rowUsage - 3)
await assertQueryUsage(queryUsage + 1)
})
it("should accept a valid row object and delete the row", async () => {
const row1 = await config.createRow()
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await request
.delete(`/api/${table._id}/rows`)
.send(row1)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.id).toEqual(row1._id)
await loadRow(row1._id!, table._id!, 404)
await assertRowUsage(rowUsage - 1)
await assertQueryUsage(queryUsage + 1)
})
it("Should ignore malformed/invalid delete requests", async () => {
const rowUsage = await getRowUsage()
const queryUsage = await getQueryUsage()
const res = await request
.delete(`/api/${table._id}/rows`)
.send({ not: "valid" })
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(400)
expect(res.body.message).toEqual("Invalid delete rows request")
const res2 = await request
.delete(`/api/${table._id}/rows`)
.send({ rows: 123 })
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(400)
expect(res2.body.message).toEqual("Invalid delete rows request")
const res3 = await request
.delete(`/api/${table._id}/rows`)
.send("invalid")
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(400)
expect(res3.body.message).toEqual("Invalid delete rows request")
await assertRowUsage(rowUsage)
await assertQueryUsage(queryUsage)
})
})
describe("fetchView", () => {
@ -685,4 +766,249 @@ describe("/rows", () => {
expect(row._id).toEqual(existing._id)
})
})
describe("view search", () => {
function userTable(): Table {
return {
name: "user",
type: "user",
schema: {
name: {
type: FieldType.STRING,
name: "name",
constraints: { type: "string" },
},
age: {
type: FieldType.NUMBER,
name: "age",
constraints: {},
},
},
}
}
it("returns table rows from view", async () => {
const table = await config.createTable(userTable())
const rows = []
for (let i = 0; i < 10; i++) {
rows.push(await config.createRow({ tableId: table._id }))
}
const createViewResponse = await config.api.viewV2.create()
const response = await config.api.viewV2.search(createViewResponse.id)
expect(response.body.rows).toHaveLength(10)
expect(response.body).toEqual({
rows: expect.arrayContaining(rows.map(expect.objectContaining)),
})
})
it("searching respects the view filters", async () => {
const table = await config.createTable(userTable())
const expectedRows = []
for (let i = 0; i < 10; i++)
await config.createRow({
tableId: table._id,
name: generator.name(),
age: generator.integer({ min: 10, max: 30 }),
})
for (let i = 0; i < 5; i++)
expectedRows.push(
await config.createRow({
tableId: table._id,
name: generator.name(),
age: 40,
})
)
const createViewResponse = await config.api.viewV2.create({
query: { equal: { age: 40 } },
})
const response = await config.api.viewV2.search(createViewResponse.id)
expect(response.body.rows).toHaveLength(5)
expect(response.body).toEqual({
rows: expect.arrayContaining(expectedRows.map(expect.objectContaining)),
})
})
const sortTestOptions: [
{
field: string
order?: SortOrder
type?: SortType
},
string[]
][] = [
[
{
field: "name",
order: SortOrder.ASCENDING,
type: SortType.STRING,
},
["Alice", "Bob", "Charly", "Danny"],
],
[
{
field: "name",
},
["Alice", "Bob", "Charly", "Danny"],
],
[
{
field: "name",
order: SortOrder.DESCENDING,
},
["Danny", "Charly", "Bob", "Alice"],
],
[
{
field: "name",
order: SortOrder.DESCENDING,
type: SortType.STRING,
},
["Danny", "Charly", "Bob", "Alice"],
],
[
{
field: "age",
order: SortOrder.ASCENDING,
type: SortType.number,
},
["Danny", "Alice", "Charly", "Bob"],
],
[
{
field: "age",
order: SortOrder.ASCENDING,
},
["Danny", "Alice", "Charly", "Bob"],
],
[
{
field: "age",
order: SortOrder.DESCENDING,
},
["Bob", "Charly", "Alice", "Danny"],
],
[
{
field: "age",
order: SortOrder.DESCENDING,
type: SortType.number,
},
["Bob", "Charly", "Alice", "Danny"],
],
]
it.each(sortTestOptions)(
"allow sorting (%s)",
async (sortParams, expected) => {
await config.createTable(userTable())
const users = [
{ name: "Alice", age: 25 },
{ name: "Bob", age: 30 },
{ name: "Charly", age: 27 },
{ name: "Danny", age: 15 },
]
for (const user of users) {
await config.createRow({
tableId: config.table!._id,
...user,
})
}
const createViewResponse = await config.api.viewV2.create({
sort: sortParams,
})
const response = await config.api.viewV2.search(createViewResponse.id)
expect(response.body.rows).toHaveLength(4)
expect(response.body).toEqual({
rows: expected.map(name => expect.objectContaining({ name })),
})
}
)
it.each(sortTestOptions)(
"allow override the default view sorting (%s)",
async (sortParams, expected) => {
await config.createTable(userTable())
const users = [
{ name: "Alice", age: 25 },
{ name: "Bob", age: 30 },
{ name: "Charly", age: 27 },
{ name: "Danny", age: 15 },
]
for (const user of users) {
await config.createRow({
tableId: config.table!._id,
...user,
})
}
const createViewResponse = await config.api.viewV2.create({
sort: {
field: "name",
order: SortOrder.ASCENDING,
type: SortType.STRING,
},
})
const response = await config.api.viewV2.search(createViewResponse.id, {
sort: {
column: sortParams.field,
order: sortParams.order,
type: sortParams.type,
},
})
expect(response.body.rows).toHaveLength(4)
expect(response.body).toEqual({
rows: expected.map(name => expect.objectContaining({ name })),
})
}
)
it("when schema is defined, defined columns and row attributes are returned", async () => {
const table = await config.createTable(userTable())
const rows = []
for (let i = 0; i < 10; i++) {
rows.push(
await config.createRow({
tableId: table._id,
name: generator.name(),
age: generator.age(),
})
)
}
const createViewResponse = await config.api.viewV2.create({
columns: { name: { visible: true } },
})
const response = await config.api.viewV2.search(createViewResponse.id)
expect(response.body.rows).toHaveLength(10)
expect(response.body.rows).toEqual(
expect.arrayContaining(
rows.map(r => ({
...expectAnyInternalColsAttributes,
name: r.name,
}))
)
)
})
it("views without data can be returned", async () => {
const table = await config.createTable(userTable())
const createViewResponse = await config.api.viewV2.create()
const response = await config.api.viewV2.search(createViewResponse.id)
expect(response.body.rows).toHaveLength(0)
})
})
})

View File

@ -1,12 +1,15 @@
const { checkBuilderEndpoint } = require("./utilities/TestFunctions")
const setup = require("./utilities")
import { generator } from "@budibase/backend-core/tests"
import { events, context } from "@budibase/backend-core"
import { FieldType, Table } from "@budibase/types"
import { checkBuilderEndpoint } from "./utilities/TestFunctions"
import * as setup from "./utilities"
const { basicTable } = setup.structures
const { events, context } = require("@budibase/backend-core")
import sdk from "../../../sdk"
describe("/tables", () => {
let request = setup.getRequest()
let config = setup.getConfig()
let appId
let appId: string
afterAll(setup.afterAll)
@ -16,12 +19,11 @@ describe("/tables", () => {
})
describe("create", () => {
beforeEach(() => {
jest.clearAllMocks()
})
const createTable = (table) => {
const createTable = (table?: Table) => {
if (!table) {
table = basicTable()
}
@ -29,15 +31,16 @@ describe("/tables", () => {
.post(`/api/tables`)
.send(table)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
}
it("returns a success message when the table is successfully created", async () => {
const res = await createTable()
expect(res.res.statusMessage).toEqual("Table TestTable saved successfully.")
expect((res as any).res.statusMessage).toEqual(
"Table TestTable saved successfully."
)
expect(res.body.name).toEqual("TestTable")
expect(events.table.created).toBeCalledTimes(1)
expect(events.table.created).toBeCalledWith(res.body)
@ -45,7 +48,7 @@ describe("/tables", () => {
it("creates a table via data import", async () => {
const table = basicTable()
table.rows = [{ name: 'test-name', description: 'test-desc' }]
table.rows = [{ name: "test-name", description: "test-desc" }]
const res = await createTable(table)
@ -62,7 +65,7 @@ describe("/tables", () => {
config,
method: "POST",
url: `/api/tables`,
body: basicTable()
body: basicTable(),
})
})
})
@ -75,7 +78,7 @@ describe("/tables", () => {
.post(`/api/tables`)
.send(testTable)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
expect(events.table.updated).toBeCalledTimes(1)
@ -94,10 +97,10 @@ describe("/tables", () => {
const testRow = await request
.post(`/api/${testTable._id}/rows`)
.send({
name: "test"
name: "test",
})
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
const updatedTable = await request
@ -109,22 +112,24 @@ describe("/tables", () => {
key: "name",
_rename: {
old: "name",
updated: "updatedName"
updated: "updatedName",
},
schema: {
updatedName: { type: "string" }
}
updatedName: { type: "string" },
},
})
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
expect(updatedTable.res.statusMessage).toEqual("Table TestTable saved successfully.")
expect((updatedTable as any).res.statusMessage).toEqual(
"Table TestTable saved successfully."
)
expect(updatedTable.body.name).toEqual("TestTable")
const res = await request
.get(`/api/${testTable._id}/rows/${testRow.body._id}`)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.updatedName).toEqual("test")
@ -140,7 +145,7 @@ describe("/tables", () => {
_id: "ta_users",
})
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.schema.email).toBeDefined()
expect(res.body.schema.roleId).toBeDefined()
@ -153,7 +158,7 @@ describe("/tables", () => {
const table = await config.createTable()
const importRequest = {
schema: table.schema,
rows: [{ name: 'test-name', description: 'test-desc' }]
rows: [{ name: "test-name", description: "test-desc" }],
}
jest.clearAllMocks()
@ -162,20 +167,23 @@ describe("/tables", () => {
.post(`/api/tables/${table._id}/import`)
.send(importRequest)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
expect(events.table.created).not.toHaveBeenCalled()
expect(events.rows.imported).toBeCalledTimes(1)
expect(events.rows.imported).toBeCalledWith(expect.objectContaining({
name: "TestTable",
_id: table._id
}), 1)
expect(events.rows.imported).toBeCalledWith(
expect.objectContaining({
name: "TestTable",
_id: table._id,
}),
1
)
})
})
describe("fetch", () => {
let testTable
let testTable: Table
beforeEach(async () => {
testTable = await config.createTable(testTable)
@ -189,7 +197,7 @@ describe("/tables", () => {
const res = await request
.get(`/api/tables`)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
const fetchedTable = res.body[0]
expect(fetchedTable.name).toEqual(testTable.name)
@ -203,6 +211,70 @@ describe("/tables", () => {
url: `/api/tables`,
})
})
it("should fetch views", async () => {
const tableId = config.table!._id!
const views = [
await config.api.viewV2.create({ tableId }),
await config.api.viewV2.create({ tableId }),
]
const res = await request
.get(`/api/tables`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body).toEqual(
expect.arrayContaining([
expect.objectContaining({
_id: tableId,
views: views.reduce((p, c) => {
p[c.name] = { ...c, schema: expect.anything() }
return p
}, {} as any),
}),
])
)
})
it("should enrich the view schemas for viewsV2", async () => {
const tableId = config.table!._id!
jest.spyOn(sdk.tables, "enrichViewSchemas").mockImplementation(t => ({
...t,
views: {
view1: {
version: 2,
name: "view1",
schema: {},
id: "new_view_id",
tableId,
},
},
}))
await config.api.viewV2.create({ tableId })
await config.createView({ tableId, name: generator.guid() })
const res = await config.api.table.fetch()
expect(res).toEqual(
expect.arrayContaining([
expect.objectContaining({
_id: tableId,
views: {
view1: {
version: 2,
name: "view1",
schema: {},
id: "new_view_id",
tableId,
},
},
}),
])
)
})
})
describe("indexing", () => {
@ -216,7 +288,7 @@ describe("/tables", () => {
.post(`/api/tables`)
.send(table)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body._id).toBeDefined()
expect(res.body._rev).toBeDefined()
@ -231,7 +303,7 @@ describe("/tables", () => {
_rev: res.body._rev,
})
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
// shouldn't have created a new index
expect((await db.getIndexes()).total_rows).toEqual(indexCount + 1)
@ -240,7 +312,7 @@ describe("/tables", () => {
})
describe("destroy", () => {
let testTable
let testTable: Table
beforeEach(async () => {
testTable = await config.createTable(testTable)
@ -254,40 +326,44 @@ describe("/tables", () => {
const res = await request
.delete(`/api/tables/${testTable._id}/${testTable._rev}`)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toEqual(`Table ${testTable._id} deleted.`)
expect(events.table.deleted).toBeCalledTimes(1)
expect(events.table.deleted).toBeCalledWith({ ...testTable, tableId: testTable._id })
expect(events.table.deleted).toBeCalledWith({
...testTable,
tableId: testTable._id,
})
})
it("deletes linked references to the table after deletion", async () => {
const linkedTable = await config.createTable({
name: "LinkedTable",
type: "table",
key: "name",
schema: {
name: {
type: "string",
type: FieldType.STRING,
name: "name",
constraints: {
type: "string",
},
},
TestTable: {
type: "link",
type: FieldType.LINK,
name: "TestTable",
fieldName: "TestTable",
tableId: testTable._id,
constraints: {
type: "array"
}
}
type: "array",
},
},
},
})
const res = await request
.delete(`/api/tables/${testTable._id}/${testTable._rev}`)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toEqual(`Table ${testTable._id} deleted.`)
const dependentTable = await config.getTable(linkedTable._id)

View File

@ -0,0 +1,227 @@
import * as setup from "./utilities"
import {
CreateViewRequest,
FieldType,
SortOrder,
SortType,
Table,
ViewV2,
} from "@budibase/types"
import { generator } from "@budibase/backend-core/tests"
function priceTable(): Table {
return {
name: "table",
type: "table",
schema: {
Price: {
type: FieldType.NUMBER,
name: "Price",
constraints: {},
},
Category: {
type: FieldType.STRING,
name: "Category",
constraints: {
type: "string",
},
},
},
}
}
describe("/v2/views", () => {
const config = setup.getConfig()
const viewFilters: Omit<CreateViewRequest, "name" | "tableId"> = {
query: { allOr: false, equal: { field: "value" } },
sort: {
field: "fieldToSort",
order: SortOrder.DESCENDING,
type: SortType.STRING,
},
columns: {
name: {
visible: true,
},
},
}
afterAll(setup.afterAll)
beforeAll(async () => {
await config.init()
await config.createTable(priceTable())
})
describe("create", () => {
it("persist the view when the view is successfully created", async () => {
const newView: CreateViewRequest = {
name: generator.name(),
tableId: config.table!._id!,
}
const res = await config.api.viewV2.create(newView)
expect(res).toEqual({
...newView,
id: expect.stringMatching(new RegExp(`${config.table?._id!}_`)),
version: 2,
})
})
it("can persist views with queries", async () => {
const newView: CreateViewRequest = {
name: generator.name(),
tableId: config.table!._id!,
...viewFilters,
}
const res = await config.api.viewV2.create(newView)
expect(res).toEqual({
...newView,
...viewFilters,
id: expect.any(String),
version: 2,
})
})
})
describe("update", () => {
let view: ViewV2
beforeEach(async () => {
await config.createTable(priceTable())
view = await config.api.viewV2.create({ name: "View A" })
})
it("can update an existing view data", async () => {
const tableId = config.table!._id!
await config.api.viewV2.update({
...view,
query: { equal: { newField: "thatValue" } },
})
expect(await config.api.table.get(tableId)).toEqual({
...config.table,
views: {
[view.name]: {
...view,
query: { equal: { newField: "thatValue" } },
schema: expect.anything(),
},
},
_rev: expect.any(String),
updatedAt: expect.any(String),
})
})
it("can update an existing view name", async () => {
const tableId = config.table!._id!
await config.api.viewV2.update({ ...view, name: "View B" })
expect(await config.api.table.get(tableId)).toEqual(
expect.objectContaining({
views: {
"View B": { ...view, name: "View B", schema: expect.anything() },
},
})
)
})
it("cannot update an unexisting views nor edit ids", async () => {
const tableId = config.table!._id!
await config.api.viewV2.update(
{ ...view, id: generator.guid() },
{ expectStatus: 404 }
)
expect(await config.api.table.get(tableId)).toEqual(
expect.objectContaining({
views: {
[view.name]: {
...view,
schema: expect.anything(),
},
},
})
)
})
it("cannot update views with the wrong tableId", async () => {
const tableId = config.table!._id!
await config.api.viewV2.update(
{
...view,
tableId: generator.guid(),
query: { equal: { newField: "thatValue" } },
},
{ expectStatus: 404 }
)
expect(await config.api.table.get(tableId)).toEqual(
expect.objectContaining({
views: {
[view.name]: {
...view,
schema: expect.anything(),
},
},
})
)
})
it("cannot update views v1", async () => {
const viewV1 = await config.createView()
await config.api.viewV2.update(
{
...viewV1,
},
{
expectStatus: 400,
handleResponse: r => {
expect(r.body).toEqual({
message: "Only views V2 can be updated",
status: 400,
})
},
}
)
})
it("cannot update the a view with unmatching ids between url and body", async () => {
const anotherView = await config.api.viewV2.create()
const result = await config
.request!.put(`/api/v2/views/${anotherView.id}`)
.send(view)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(400)
expect(result.body).toEqual({
message: "View id does not match between the body and the uri path",
status: 400,
})
})
})
describe("delete", () => {
let view: ViewV2
beforeAll(async () => {
await config.createTable(priceTable())
view = await config.api.viewV2.create()
})
it("can delete an existing view", async () => {
const tableId = config.table!._id!
const getPersistedView = async () =>
(await config.api.table.get(tableId)).views![view.name]
expect(await getPersistedView()).toBeDefined()
await config.api.viewV2.delete(view.id)
expect(await getPersistedView()).toBeUndefined()
})
})
})

View File

@ -7,11 +7,28 @@ import { permissions } from "@budibase/backend-core"
const router: Router = new Router()
router
.post(
"/api/v2/views",
authorized(permissions.BUILDER),
viewController.v2.create
)
.put(
`/api/v2/views/:viewId`,
authorized(permissions.BUILDER),
viewController.v2.update
)
.delete(
`/api/v2/views/:viewId`,
authorized(permissions.BUILDER),
viewController.v2.remove
)
router
.get(
"/api/views/export",
authorized(permissions.BUILDER),
viewController.exportView
viewController.v1.exportView
)
.get(
"/api/views/:viewName",
@ -22,13 +39,13 @@ router
),
rowController.fetchView
)
.get("/api/views", authorized(permissions.BUILDER), viewController.fetch)
.get("/api/views", authorized(permissions.BUILDER), viewController.v1.fetch)
.delete(
"/api/views/:viewName",
paramResource("viewName"),
authorized(permissions.BUILDER),
viewController.destroy
viewController.v1.destroy
)
.post("/api/views", authorized(permissions.BUILDER), viewController.save)
.post("/api/views", authorized(permissions.BUILDER), viewController.v1.save)
export default router

View File

@ -186,6 +186,13 @@ export function getDatasourceParams(
return getDocParams(DocumentType.DATASOURCE, datasourceId, otherProps)
}
export function getDatasourcePlusParams(
datasourceId?: Optional,
otherProps?: { include_docs: boolean }
) {
return getDocParams(DocumentType.DATASOURCE_PLUS, datasourceId, otherProps)
}
/**
* Generates a new query ID.
* @returns {string} The new query ID which the query doc can be stored under.
@ -271,3 +278,19 @@ export function getMultiIDParams(ids: string[]) {
include_docs: true,
}
}
/**
* Generates a new view ID.
* @returns {string} The new view ID which the view doc can be stored under.
*/
export function generateViewID(tableId: string) {
return `${tableId}${SEPARATOR}${newid()}`
}
export function extractViewInfoFromID(viewId: string) {
const regex = new RegExp(`^(?<tableId>.+)${SEPARATOR}([^${SEPARATOR}]+)$`)
const res = regex.exec(viewId)
return {
tableId: res!.groups!["tableId"],
}
}

View File

@ -10,6 +10,10 @@ export const backfill = async (appDb: Database, timestamp: string | number) => {
if (table.views) {
for (const view of Object.values(table.views)) {
if (sdk.views.isV2(view)) {
continue
}
await events.view.created(view, timestamp)
if (view.calculation) {

View File

@ -19,6 +19,7 @@ import _ from "lodash"
import {
BudibaseInternalDB,
getDatasourceParams,
getDatasourcePlusParams,
getTableParams,
} from "../../../db/utils"
import sdk from "../../index"
@ -243,3 +244,15 @@ export function mergeConfigs(update: Datasource, old: Datasource) {
return update
}
export async function getExternalDatasources(): Promise<Datasource[]> {
const db = context.getAppDB()
const externalDatasources = await db.allDocs<Datasource>(
getDatasourcePlusParams(undefined, {
include_docs: true,
})
)
return externalDatasources.rows.map(r => r.doc)
}

View File

@ -1,4 +1,4 @@
import { SearchFilters } from "@budibase/types"
import { SearchFilters, SortOrder, SortType } from "@budibase/types"
import { isExternalTable } from "../../../integrations/utils"
import * as internal from "./search/internal"
import * as external from "./search/external"
@ -11,10 +11,11 @@ export interface SearchParams {
bookmark?: string
limit?: number
sort?: string
sortOrder?: string
sortType?: string
sortOrder?: SortOrder
sortType?: SortType
version?: string
disableEscaping?: boolean
fields?: string[]
}
export interface ViewParams {
@ -30,7 +31,11 @@ function pickApi(tableId: any) {
return internal
}
export async function search(options: SearchParams) {
export async function search(options: SearchParams): Promise<{
rows: any[]
hasNextPage?: boolean
bookmark?: number | null
}> {
return pickApi(options.tableId).search(options)
}

View File

@ -14,7 +14,8 @@ import { breakExternalTableId } from "../../../../integrations/utils"
import { cleanExportRows } from "../utils"
import { utils } from "@budibase/shared-core"
import { ExportRowsParams, ExportRowsResult, SearchParams } from "../search"
import { HTTPError } from "@budibase/backend-core"
import { HTTPError, db } from "@budibase/backend-core"
import pick from "lodash/pick"
export async function search(options: SearchParams) {
const { tableId } = options
@ -48,7 +49,7 @@ export async function search(options: SearchParams) {
}
}
try {
const rows = (await handleRequest(Operation.READ, tableId, {
let rows = (await handleRequest(Operation.READ, tableId, {
filters: query,
sort,
paginate: paginateObj as PaginationJson,
@ -67,6 +68,12 @@ export async function search(options: SearchParams) {
})) as Row[]
hasNextPage = nextRows.length > 0
}
if (options.fields) {
const fields = [...options.fields, ...db.CONSTANT_EXTERNAL_ROW_COLS]
rows = rows.map((r: any) => pick(r, fields))
}
// need wrapper object for bookmarks etc when paginating
return { rows, hasNextPage, bookmark: bookmark && bookmark + 1 }
} catch (err: any) {

View File

@ -1,5 +1,6 @@
import {
context,
db,
SearchParams as InternalSearchParams,
} from "@budibase/backend-core"
import env from "../../../../environment"
@ -28,6 +29,7 @@ import {
} from "../../../../api/controllers/view/utils"
import sdk from "../../../../sdk"
import { ExportRowsParams, ExportRowsResult, SearchParams } from "../search"
import pick from "lodash/pick"
export async function search(options: SearchParams) {
const { tableId } = options
@ -72,6 +74,12 @@ export async function search(options: SearchParams) {
response.rows = await getGlobalUsersFromMetadata(response.rows)
}
table = table || (await sdk.tables.getTable(tableId))
if (options.fields) {
const fields = [...options.fields, ...db.CONSTANT_INTERNAL_ROW_COLS]
response.rows = response.rows.map((r: any) => pick(r, fields))
}
response.rows = await outputProcessing(table, response.rows)
}

View File

@ -0,0 +1,143 @@
import { GenericContainer } from "testcontainers"
import { Datasource, FieldType, Row, SourceName, Table } from "@budibase/types"
import TestConfiguration from "../../../../../tests/utilities/TestConfiguration"
import { SearchParams } from "../../search"
import { search } from "../external"
import {
expectAnyExternalColsAttributes,
generator,
} from "@budibase/backend-core/tests"
jest.unmock("mysql2/promise")
jest.setTimeout(30000)
describe("external", () => {
const config = new TestConfiguration()
let externalDatasource: Datasource
const tableData: Table = {
name: generator.word(),
type: "external",
primary: ["id"],
schema: {
id: {
name: "id",
type: FieldType.AUTO,
autocolumn: true,
},
name: {
name: "name",
type: FieldType.STRING,
},
surname: {
name: "surname",
type: FieldType.STRING,
},
age: {
name: "age",
type: FieldType.NUMBER,
},
address: {
name: "address",
type: FieldType.STRING,
},
},
}
beforeAll(async () => {
const container = await new GenericContainer("mysql")
.withExposedPorts(3306)
.withEnv("MYSQL_ROOT_PASSWORD", "admin")
.withEnv("MYSQL_DATABASE", "db")
.withEnv("MYSQL_USER", "user")
.withEnv("MYSQL_PASSWORD", "password")
.start()
const host = container.getContainerIpAddress()
const port = container.getMappedPort(3306)
await config.init()
externalDatasource = await config.createDatasource({
datasource: {
type: "datasource",
name: "Test",
source: SourceName.MYSQL,
plus: true,
config: {
host,
port,
user: "user",
database: "db",
password: "password",
rejectUnauthorized: true,
},
},
})
})
describe("search", () => {
const rows: Row[] = []
beforeAll(async () => {
const table = await config.createTable({
...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("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: {},
fields: ["name", "age"],
}
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,
}))
)
)
})
})
})
})

View File

@ -0,0 +1,109 @@
import { FieldType, Row, Table } from "@budibase/types"
import TestConfiguration from "../../../../../tests/utilities/TestConfiguration"
import { SearchParams } from "../../search"
import { search } from "../internal"
import {
expectAnyInternalColsAttributes,
generator,
} from "@budibase/backend-core/tests"
describe("internal", () => {
const config = new TestConfiguration()
const tableData: Table = {
name: generator.word(),
type: "table",
schema: {
name: {
name: "name",
type: FieldType.STRING,
constraints: {
type: FieldType.STRING,
},
},
surname: {
name: "surname",
type: FieldType.STRING,
constraints: {
type: FieldType.STRING,
},
},
age: {
name: "age",
type: FieldType.NUMBER,
constraints: {
type: FieldType.NUMBER,
},
},
address: {
name: "address",
type: FieldType.STRING,
constraints: {
type: FieldType.STRING,
},
},
},
}
beforeAll(async () => {
await config.init()
})
describe("search", () => {
const rows: Row[] = []
beforeAll(async () => {
await config.createTable(tableData)
for (let i = 0; i < 10; i++) {
rows.push(
await config.createRow({
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("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: {},
fields: ["name", "age"],
}
const result = await search(searchParams)
expect(result.rows).toHaveLength(10)
expect(result.rows).toEqual(
expect.arrayContaining(
rows.map(r => ({
...expectAnyInternalColsAttributes,
name: r.name,
age: r.age,
}))
)
)
})
})
})
})

View File

@ -5,9 +5,16 @@ import {
isExternalTable,
isSQL,
} from "../../../integrations/utils"
import { Table, Database } from "@budibase/types"
import {
Table,
Database,
TableResponse,
TableViewsResponse,
} from "@budibase/types"
import datasources from "../datasources"
import { populateExternalTableSchemas, isEditableColumn } from "./validation"
import sdk from "../../../sdk"
import _ from "lodash"
async function getAllInternalTables(db?: Database): Promise<Table[]> {
if (!db) {
@ -55,6 +62,20 @@ async function getTable(tableId: any): Promise<Table> {
}
}
function enrichViewSchemas(table: Table): TableResponse {
const result: TableResponse = {
...table,
views: Object.values(table.views ?? [])
.map(v => sdk.views.enrichSchema(v, table.schema))
.reduce((p, v) => {
p[v.name] = v
return p
}, {} as TableViewsResponse),
}
return result
}
export default {
getAllInternalTables,
getAllExternalTables,
@ -62,4 +83,5 @@ export default {
getTable,
populateExternalTableSchemas,
isEditableColumn,
enrichViewSchemas,
}

View File

@ -0,0 +1,100 @@
import { FieldType, Table, ViewV2 } from "@budibase/types"
import { generator } from "@budibase/backend-core/tests"
import sdk from "../../.."
jest.mock("../../views", () => ({
...jest.requireActual("../../views"),
enrichSchema: jest.fn().mockImplementation(v => ({ ...v, mocked: true })),
}))
describe("table sdk", () => {
describe("enrichViewSchemas", () => {
const basicTable: Table = {
_id: generator.guid(),
name: "TestTable",
type: "table",
schema: {
name: {
type: FieldType.STRING,
name: "name",
visible: true,
width: 80,
order: 2,
constraints: {
type: "string",
},
},
description: {
type: FieldType.STRING,
name: "description",
visible: true,
width: 200,
constraints: {
type: "string",
},
},
id: {
type: FieldType.NUMBER,
name: "id",
visible: true,
order: 1,
constraints: {
type: "number",
},
},
hiddenField: {
type: FieldType.STRING,
name: "hiddenField",
visible: false,
constraints: {
type: "string",
},
},
},
}
it("should fetch the default schema if not overriden", async () => {
const tableId = basicTable._id!
function getTable() {
const view: ViewV2 = {
version: 2,
id: generator.guid(),
name: generator.guid(),
tableId,
}
return view
}
const view1 = getTable()
const view2 = getTable()
const view3 = getTable()
const res = sdk.tables.enrichViewSchemas({
...basicTable,
views: {
[view1.name]: view1,
[view2.name]: view2,
[view3.name]: view3,
},
})
expect(sdk.views.enrichSchema).toBeCalledTimes(3)
expect(res).toEqual({
...basicTable,
views: {
[view1.name]: {
...view1,
mocked: true,
},
[view2.name]: {
...view2,
mocked: true,
},
[view3.name]: {
...view3,
mocked: true,
},
},
})
})
})
})

View File

@ -0,0 +1,109 @@
import { HTTPError, context } from "@budibase/backend-core"
import { TableSchema, UIFieldMetadata, View, ViewV2 } from "@budibase/types"
import sdk from "../../../sdk"
import * as utils from "../../../db/utils"
import _ from "lodash"
export async function get(viewId: string): Promise<ViewV2 | undefined> {
const { tableId } = utils.extractViewInfoFromID(viewId)
const table = await sdk.tables.getTable(tableId)
const views = Object.values(table.views!)
const view = views.find(v => isV2(v) && v.id === viewId) as ViewV2 | undefined
return view
}
export async function create(
tableId: string,
viewRequest: Omit<ViewV2, "id" | "version">
): Promise<ViewV2> {
const view: ViewV2 = {
...viewRequest,
id: utils.generateViewID(tableId),
version: 2,
}
const db = context.getAppDB()
const table = await sdk.tables.getTable(tableId)
table.views ??= {}
table.views[view.name] = view
await db.put(table)
return view
}
export async function update(tableId: string, view: ViewV2): Promise<ViewV2> {
const db = context.getAppDB()
const table = await sdk.tables.getTable(tableId)
table.views ??= {}
const existingView = Object.values(table.views).find(
v => isV2(v) && v.id === view.id
)
if (!existingView) {
throw new HTTPError(`View ${view.id} not found in table ${tableId}`, 404)
}
delete table.views[existingView.name]
table.views[view.name] = view
await db.put(table)
return view
}
export function isV2(view: View | ViewV2): view is ViewV2 {
return (view as ViewV2).version === 2
}
export async function remove(viewId: string): Promise<void> {
const db = context.getAppDB()
const view = await get(viewId)
const table = await sdk.tables.getTable(view?.tableId)
if (!view) {
throw new HTTPError(`View ${viewId} not found`, 404)
}
delete table.views![view?.name]
await db.put(table)
}
export function enrichSchema(view: View | ViewV2, tableSchema: TableSchema) {
if (!sdk.views.isV2(view)) {
return view
}
return {
...view,
schema:
!view?.columns || !Object.entries(view?.columns).length
? tableSchema
: enrichViewV2Schema(tableSchema, view.columns),
}
}
function enrichViewV2Schema(
tableSchema: TableSchema,
viewOverrides: Record<string, UIFieldMetadata>
) {
const result: TableSchema = {}
const viewOverridesEntries = Object.entries(viewOverrides)
const viewSetsOrder = viewOverridesEntries.some(([_, v]) => v.order)
for (const [columnName, columnUIMetadata] of viewOverridesEntries) {
if (!columnUIMetadata.visible) {
continue
}
if (!tableSchema[columnName]) {
continue
}
const tableFieldSchema = tableSchema[columnName]
if (viewSetsOrder) {
delete tableFieldSchema.order
}
result[columnName] = _.merge(tableFieldSchema, columnUIMetadata)
}
return result
}

View File

@ -0,0 +1,265 @@
import { FieldType, Table, ViewV2 } from "@budibase/types"
import { generator } from "@budibase/backend-core/tests"
import { enrichSchema } from ".."
describe("table sdk", () => {
describe("enrichViewSchemas", () => {
const basicTable: Table = {
_id: generator.guid(),
name: "TestTable",
type: "table",
schema: {
name: {
type: FieldType.STRING,
name: "name",
visible: true,
width: 80,
order: 2,
constraints: {
type: "string",
},
},
description: {
type: FieldType.STRING,
name: "description",
visible: true,
width: 200,
constraints: {
type: "string",
},
},
id: {
type: FieldType.NUMBER,
name: "id",
visible: true,
order: 1,
constraints: {
type: "number",
},
},
hiddenField: {
type: FieldType.STRING,
name: "hiddenField",
visible: false,
constraints: {
type: "string",
},
},
},
}
it("should fetch the default schema if not overriden", async () => {
const tableId = basicTable._id!
const view: ViewV2 = {
version: 2,
id: generator.guid(),
name: generator.guid(),
tableId,
}
const res = enrichSchema(view, basicTable.schema)
expect(res).toEqual({
...view,
schema: {
name: {
type: "string",
name: "name",
visible: true,
order: 2,
width: 80,
constraints: {
type: "string",
},
},
description: {
type: "string",
name: "description",
visible: true,
width: 200,
constraints: {
type: "string",
},
},
id: {
type: "number",
name: "id",
visible: true,
order: 1,
constraints: {
type: "number",
},
},
hiddenField: {
type: "string",
name: "hiddenField",
visible: false,
constraints: {
type: "string",
},
},
},
})
})
it("if view schema only defines visiblility, should only fetch the selected fields", async () => {
const tableId = basicTable._id!
const view: ViewV2 = {
version: 2,
id: generator.guid(),
name: generator.guid(),
tableId,
columns: {
name: { visible: true },
id: { visible: true },
description: { visible: false },
},
}
const res = enrichSchema(view, basicTable.schema)
expect(res).toEqual({
...view,
schema: {
name: {
type: "string",
name: "name",
visible: true,
order: 2,
width: 80,
constraints: {
type: "string",
},
},
id: {
type: "number",
name: "id",
visible: true,
order: 1,
constraints: {
type: "number",
},
},
},
})
})
it("schema does not break if the view has corrupted columns", async () => {
const tableId = basicTable._id!
const view: ViewV2 = {
version: 2,
id: generator.guid(),
name: generator.guid(),
tableId,
columns: { unnexisting: { visible: true }, name: { visible: true } },
}
const res = enrichSchema(view, basicTable.schema)
expect(res).toEqual(
expect.objectContaining({
...view,
schema: {
name: {
type: "string",
name: "name",
order: 2,
visible: true,
width: 80,
constraints: {
type: "string",
},
},
},
})
)
})
it("if view schema only defines visiblility, should only fetch the selected fields", async () => {
const tableId = basicTable._id!
const view: ViewV2 = {
version: 2,
id: generator.guid(),
name: generator.guid(),
tableId,
columns: {
name: { visible: true },
id: { visible: true },
description: { visible: false },
},
}
const res = enrichSchema(view, basicTable.schema)
expect(res).toEqual(
expect.objectContaining({
...view,
schema: {
name: {
type: "string",
name: "name",
order: 2,
visible: true,
width: 80,
constraints: {
type: "string",
},
},
id: {
type: "number",
name: "id",
order: 1,
visible: true,
constraints: {
type: "number",
},
},
},
})
)
})
it("if view defines order, the table schema order should be ignored", async () => {
const tableId = basicTable._id!
const view: ViewV2 = {
version: 2,
id: generator.guid(),
name: generator.guid(),
tableId,
columns: {
name: { visible: true, order: 1 },
id: { visible: true },
description: { visible: false, order: 2 },
},
}
const res = enrichSchema(view, basicTable.schema)
expect(res).toEqual(
expect.objectContaining({
...view,
schema: {
name: {
type: "string",
name: "name",
order: 1,
visible: true,
width: 80,
constraints: {
type: "string",
},
},
id: {
type: "number",
name: "id",
visible: true,
constraints: {
type: "number",
},
},
},
})
)
})
})
})

View File

@ -7,6 +7,7 @@ import { default as queries } from "./app/queries"
import { default as rows } from "./app/rows"
import { default as users } from "./users"
import { default as plugins } from "./plugins"
import * as views from "./app/views"
const sdk = {
backups,
@ -18,6 +19,7 @@ const sdk = {
datasources,
queries,
plugins,
views,
}
// default export for TS

View File

@ -53,6 +53,8 @@ import {
} from "@budibase/types"
import { BUILTIN_ROLE_IDS } from "@budibase/backend-core/src/security/roles"
import API from "./api"
type DefaultUserValues = {
globalUserId: string
email: string
@ -73,12 +75,13 @@ class TestConfiguration {
user: any
globalUserId: any
userMetadataId: any
table: any
table?: Table
linkedTable: any
automation: any
datasource: any
tenantId?: string
defaultUserValues: DefaultUserValues
api: API
constructor(openServer = true) {
if (openServer) {
@ -94,6 +97,8 @@ class TestConfiguration {
this.appId = null
this.allApps = []
this.defaultUserValues = this.populateDefaultUserValues()
this.api = new API(this)
}
populateDefaultUserValues(): DefaultUserValues {
@ -242,7 +247,7 @@ class TestConfiguration {
const db = tenancy.getTenantDB(this.getTenantId())
let existing
try {
existing = await db.get(id)
existing = await db.get<any>(id)
} catch (err) {
existing = { email }
}
@ -460,7 +465,7 @@ class TestConfiguration {
async generateApiKey(userId = this.defaultUserValues.globalUserId) {
const db = tenancy.getTenantDB(this.getTenantId())
const id = dbCore.generateDevInfoID(userId)
let devInfo
let devInfo: any
try {
devInfo = await db.get(id)
} catch (err) {
@ -522,21 +527,27 @@ class TestConfiguration {
// TABLE
async updateTable(config?: any): Promise<Table> {
async updateTable(
config?: any,
{ skipReassigning } = { skipReassigning: false }
): Promise<Table> {
config = config || basicTable()
this.table = await this._req(config, null, controllers.table.save)
return this.table
const response = await this._req(config, null, controllers.table.save)
if (!skipReassigning) {
this.table = response
}
return response
}
async createTable(config?: Table) {
async createTable(config?: Table, options = { skipReassigning: false }) {
if (config != null && config._id) {
delete config._id
}
return this.updateTable(config)
return this.updateTable(config, options)
}
async getTable(tableId?: string) {
tableId = tableId || this.table._id
tableId = tableId || this.table?._id
return this._req(null, { tableId }, controllers.table.find)
}
@ -577,7 +588,7 @@ class TestConfiguration {
throw "Test requires table to be configured."
}
const tableId = (config && config.tableId) || this.table._id
config = config || basicRow(tableId)
config = config || basicRow(tableId!)
return this._req(config, { tableId }, controllers.row.save)
}
@ -587,14 +598,14 @@ class TestConfiguration {
async getRows(tableId: string) {
if (!tableId && this.table) {
tableId = this.table._id
tableId = this.table._id!
}
return this._req(null, { tableId }, controllers.row.fetch)
}
async searchRows(tableId: string, searchParams: SearchFilters = {}) {
if (!tableId && this.table) {
tableId = this.table._id
tableId = this.table._id!
}
const body = {
query: searchParams,
@ -631,7 +642,7 @@ class TestConfiguration {
tableId: this.table._id,
name: "ViewTest",
}
return this._req(view, null, controllers.view.save)
return this._req(view, null, controllers.view.v1.save)
}
// AUTOMATION

View File

@ -0,0 +1,17 @@
import TestConfiguration from "../TestConfiguration"
import { SuperTest, Test } from "supertest"
export interface TestAPIOpts {
headers?: any
status?: number
}
export abstract class TestAPI {
config: TestConfiguration
request: SuperTest<Test>
protected constructor(config: TestConfiguration) {
this.config = config
this.request = config.request!
}
}

View File

@ -0,0 +1,13 @@
import TestConfiguration from "../TestConfiguration"
import { TableAPI } from "./table"
import { ViewV2API } from "./viewV2"
export default class API {
table: TableAPI
viewV2: ViewV2API
constructor(config: TestConfiguration) {
this.table = new TableAPI(config)
this.viewV2 = new ViewV2API(config)
}
}

View File

@ -0,0 +1,32 @@
import { Table } from "@budibase/types"
import TestConfiguration from "../TestConfiguration"
import { TestAPI } from "./base"
export class TableAPI extends TestAPI {
constructor(config: TestConfiguration) {
super(config)
}
fetch = async (
{ expectStatus } = { expectStatus: 200 }
): Promise<Table[]> => {
const res = await this.request
.get(`/api/tables`)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(expectStatus)
return res.body
}
get = async (
tableId: string,
{ expectStatus } = { expectStatus: 200 }
): Promise<Table> => {
const res = await this.request
.get(`/api/tables/${tableId}`)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(expectStatus)
return res.body
}
}

View File

@ -0,0 +1,96 @@
import { CreateViewRequest, SortOrder, SortType, ViewV2 } from "@budibase/types"
import TestConfiguration from "../TestConfiguration"
import { TestAPI } from "./base"
import { generator } from "@budibase/backend-core/tests"
import { Response } from "superagent"
export class ViewV2API extends TestAPI {
constructor(config: TestConfiguration) {
super(config)
}
create = async (
viewData?: Partial<CreateViewRequest>,
{ expectStatus } = { expectStatus: 201 }
): Promise<ViewV2> => {
let tableId = viewData?.tableId
if (!tableId && !this.config.table) {
throw "Test requires table to be configured."
}
tableId = this.config.table!._id!
const view = {
tableId,
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
}
update = async (
view: ViewV2,
{
expectStatus,
handleResponse,
}: {
expectStatus: number
handleResponse?: (response: Response) => void
} = { expectStatus: 200 }
): Promise<ViewV2> => {
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
}
delete = async (viewId: string, { expectStatus } = { expectStatus: 204 }) => {
return this.request
.delete(`/api/v2/views/${viewId}`)
.set(this.config.defaultHeaders())
.expect(expectStatus)
}
search = async (
viewId: string,
options?: {
sort: {
column: string
order?: SortOrder
type?: SortType
}
},
{ expectStatus } = { expectStatus: 200 }
) => {
const qs: [string, any][] = []
if (options?.sort.column) {
qs.push(["sort_column", options.sort.column])
}
if (options?.sort.order) {
qs.push(["sort_order", options.sort.order])
}
if (options?.sort.type) {
qs.push(["sort_type", options.sort.type])
}
let url = `/api/v2/views/${viewId}/search`
if (qs.length) {
url += "?" + qs.map(q => q.join("=")).join("&")
}
return this.request
.get(url)
.set(this.config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(expectStatus)
}
}

View File

@ -16,23 +16,26 @@ import {
AutomationTrigger,
AutomationTriggerStepId,
Datasource,
FieldType,
SourceName,
Table,
} from "@budibase/types"
export function basicTable() {
export function basicTable(): Table {
return {
name: "TestTable",
type: "table",
key: "name",
schema: {
name: {
type: "string",
type: FieldType.STRING,
name: "name",
constraints: {
type: "string",
},
},
description: {
type: "string",
type: FieldType.STRING,
name: "description",
constraints: {
type: "string",
},

View File

@ -94,4 +94,5 @@ export enum BuilderSocketEvent {
}
export const SocketSessionTTL = 60
export const ValidQueryNameRegex = /^[^()]*$/
export const ValidColumnNameRegex = /^[_a-zA-Z0-9\s]*$/g

View File

@ -1,2 +1,6 @@
export * from "./backup"
export * from "./datasource"
export * from "./row"
export * from "./view"
export * from "./rows"
export * from "./table"

View File

@ -0,0 +1,11 @@
import { Row } from "../../../documents/app/row"
export interface DeleteRows {
rows: (Row | string)[]
}
export interface DeleteRow {
_id: string
}
export type DeleteRowRequest = DeleteRows | DeleteRow

View File

@ -0,0 +1,3 @@
export interface SearchResponse {
rows: any[]
}

View File

@ -0,0 +1,13 @@
import { Table, TableSchema, View, ViewV2 } from "../../../documents"
interface ViewV2Response extends ViewV2 {
schema: TableSchema
}
export type TableViewsResponse = { [key: string]: View | ViewV2Response }
export interface TableResponse extends Table {
views?: TableViewsResponse
}
export type FetchTablesResponse = TableResponse[]

View File

@ -0,0 +1,9 @@
import { TableSchema, ViewV2 } from "../../../documents"
export interface ViewResponse {
data: ViewV2
}
export type CreateViewRequest = Omit<ViewV2, "version" | "id">
export type UpdateViewRequest = ViewV2

View File

@ -1,11 +1,11 @@
import { Document } from "../../document"
import { View } from "../view"
import { View, ViewV2 } from "../view"
import { RenameColumn } from "../../../sdk"
import { TableSchema } from "./schema"
export interface Table extends Document {
type?: string
views?: { [key: string]: View }
views?: { [key: string]: View | ViewV2 }
name: string
primary?: string[]
schema: TableSchema

View File

@ -1,3 +1,7 @@
import { SortOrder, SortType } from "../../api"
import { SearchFilters } from "../../sdk"
import { TableSchema, UIFieldMetadata } from "./table"
export interface View {
name: string
tableId: string
@ -10,6 +14,20 @@ export interface View {
meta?: Record<string, any>
}
export interface ViewV2 {
version: 2
id: string
name: string
tableId: string
query?: SearchFilters
sort?: {
field: string
order?: SortOrder
type?: SortType
}
columns?: Record<string, UIFieldMetadata>
}
export type ViewSchema = ViewCountOrSumSchema | ViewStatisticsSchema
export interface ViewCountOrSumSchema {

View File

@ -1,6 +1,10 @@
const actual = jest.requireActual("@budibase/pro")
const pro = {
...actual,
features: {
...actual.features,
isSSOEnforced: jest.fn(),
},
licensing: {
keys: {
activateLicenseKey: jest.fn(),

View File

@ -277,6 +277,7 @@ describe("configs", () => {
describe("GET /api/global/configs/public", () => {
it("should return the expected public settings", async () => {
await saveSettingsConfig()
mocks.pro.features.isSSOEnforced.mockResolvedValue(false)
const res = await config.api.configs.getPublicSettings()
const body = res.body as GetPublicSettingsResponse

View File

@ -1,13 +1,8 @@
import { structures } from "../../../tests"
import { mocks } from "@budibase/backend-core/tests"
import { env, context, users as usersCore } from "@budibase/backend-core"
import { structures, mocks } from "../../../tests"
import { env, context } from "@budibase/backend-core"
import * as users from "../users"
import { CloudAccount } from "@budibase/types"
jest.mock("@budibase/pro")
import * as _pro from "@budibase/pro"
const pro = jest.mocked(_pro, true)
describe("users", () => {
beforeEach(() => {
jest.clearAllMocks()
@ -17,7 +12,7 @@ describe("users", () => {
it("returns false for non sso user", async () => {
await context.doInTenant(structures.tenant.id(), async () => {
const user = structures.users.user()
const result = await usersCore.isPreventPasswordActions(user)
const result = await users.isPreventPasswordActions(user)
expect(result).toBe(false)
})
})
@ -28,7 +23,7 @@ describe("users", () => {
const account = structures.accounts.ssoAccount() as CloudAccount
account.email = user.email
mocks.accounts.getAccountByTenantId.mockResolvedValueOnce(account)
const result = await usersCore.isPreventPasswordActions(user)
const result = await users.isPreventPasswordActions(user)
expect(result).toBe(true)
})
})
@ -38,7 +33,7 @@ describe("users", () => {
const user = structures.users.user()
const account = structures.accounts.ssoAccount() as CloudAccount
mocks.accounts.getAccountByTenantId.mockResolvedValueOnce(account)
const result = await usersCore.isPreventPasswordActions(user)
const result = await users.isPreventPasswordActions(user)
expect(result).toBe(false)
})
})
@ -46,7 +41,7 @@ describe("users", () => {
it("returns true for sso user", async () => {
await context.doInTenant(structures.tenant.id(), async () => {
const user = structures.users.ssoUser()
const result = await usersCore.isPreventPasswordActions(user)
const result = await users.isPreventPasswordActions(user)
expect(result).toBe(true)
})
})
@ -55,8 +50,8 @@ describe("users", () => {
it("returns true for all users when sso is enforced", async () => {
await context.doInTenant(structures.tenant.id(), async () => {
const user = structures.users.user()
pro.features.isSSOEnforced.mockResolvedValueOnce(true)
const result = await usersCore.isPreventPasswordActions(user)
mocks.pro.features.isSSOEnforced.mockResolvedValueOnce(true)
const result = await users.isPreventPasswordActions(user)
expect(result).toBe(true)
})
})
@ -74,7 +69,7 @@ describe("users", () => {
describe("non-admin user", () => {
it("returns true", async () => {
const user = structures.users.ssoUser()
const result = await usersCore.isPreventPasswordActions(user)
const result = await users.isPreventPasswordActions(user)
expect(result).toBe(true)
})
})
@ -84,7 +79,7 @@ describe("users", () => {
const user = structures.users.ssoUser({
user: structures.users.adminUser(),
})
const result = await usersCore.isPreventPasswordActions(user)
const result = await users.isPreventPasswordActions(user)
expect(result).toBe(false)
})
})

View File

@ -49,6 +49,7 @@ export default class AccountInternalAPIClient {
requestOptions.headers = {
...requestOptions.headers,
...{ [Header.API_KEY]: env.ACCOUNT_PORTAL_API_KEY },
cookie: "",
}
}

View File

@ -1,5 +1,10 @@
import AccountInternalAPIClient from "../AccountInternalAPIClient"
import { Account, UpdateLicenseRequest } from "@budibase/types"
import {
Account,
CreateOfflineLicenseRequest,
GetOfflineLicenseResponse,
UpdateLicenseRequest,
} from "@budibase/types"
import { Response } from "node-fetch"
import BaseAPI from "./BaseAPI"
import { APIRequestOpts } from "../../../types"
@ -24,4 +29,44 @@ export default class LicenseAPI extends BaseAPI {
})
}, opts)
}
// TODO: Better approach for setting tenant id header
async createOfflineLicense(
accountId: string,
tenantId: string,
body: CreateOfflineLicenseRequest,
opts: { status?: number } = {}
): Promise<Response> {
const [response, json] = await this.client.post(
`/api/internal/accounts/${accountId}/license/offline`,
{
body,
internal: true,
headers: {
"x-budibase-tenant-id": tenantId,
},
}
)
expect(response.status).toBe(opts.status ? opts.status : 201)
return response
}
async getOfflineLicense(
accountId: string,
tenantId: string,
opts: { status?: number } = {}
): Promise<[Response, GetOfflineLicenseResponse]> {
const [response, json] = await this.client.get(
`/api/internal/accounts/${accountId}/license/offline`,
{
internal: true,
headers: {
"x-budibase-tenant-id": tenantId,
},
}
)
expect(response.status).toBe(opts.status ? opts.status : 200)
return [response, json]
}
}

View File

@ -2,7 +2,9 @@ import { generator } from "../../shared"
import { Hosting, CreateAccountRequest } from "@budibase/types"
// TODO: Refactor me to central location
export const generateAccount = (): CreateAccountRequest => {
export const generateAccount = (
partial: Partial<CreateAccountRequest>
): CreateAccountRequest => {
const uuid = generator.guid()
const email = `${uuid}@budibase.com`
@ -17,5 +19,6 @@ export const generateAccount = (): CreateAccountRequest => {
size: "10+",
tenantId: tenant,
tenantName: tenant,
...partial,
}
}

View File

@ -0,0 +1,79 @@
import TestConfiguration from "../../config/TestConfiguration"
import * as fixures from "../../fixtures"
import { Hosting, Feature } from "@budibase/types"
describe("offline", () => {
const config = new TestConfiguration()
beforeAll(async () => {
await config.beforeAll()
})
afterAll(async () => {
await config.afterAll()
})
// TODO: Currently requires a self host install + account portal
// Ignored until we set this up
xit("creates, activates and deletes offline license", async () => {
// installation: Delete any token
await config.internalApi.license.deleteOfflineLicenseToken()
// installation: Assert token not found
let [getTokenRes] = await config.internalApi.license.getOfflineLicenseToken(
{ status: 404 }
)
// installation: Retrieve Identifier
const [getIdentifierRes, identifier] =
await config.internalApi.license.getOfflineIdentifier()
// account-portal: Create self-host account
const createAccountRequest = fixures.accounts.generateAccount({
hosting: Hosting.SELF,
})
const [createAccountRes, account] =
await config.accountsApi.accounts.create(createAccountRequest)
const accountId = account.accountId!
const tenantId = account.tenantId!
// account-portal: Enable feature on license
await config.accountsApi.licenses.updateLicense(accountId, {
overrides: {
features: [Feature.OFFLINE],
},
})
// account-portal: Create offline token
const expireAt = new Date()
expireAt.setDate(new Date().getDate() + 1)
await config.accountsApi.licenses.createOfflineLicense(
accountId,
tenantId,
{
expireAt: expireAt.toISOString(),
installationIdentifierBase64: identifier.identifierBase64,
}
)
// account-portal: Retrieve offline token
const [getLicenseRes, offlineLicense] =
await config.accountsApi.licenses.getOfflineLicense(accountId, tenantId)
// installation: Activate offline token
await config.internalApi.license.activateOfflineLicenseToken({
offlineLicenseToken: offlineLicense.offlineLicenseToken,
})
// installation: Assert token found
await config.internalApi.license.getOfflineLicenseToken()
// TODO: Assert on license for current user
// installation: Remove the token
await config.internalApi.license.deleteOfflineLicenseToken()
// installation: Assert token not found
await config.internalApi.license.getOfflineLicenseToken({ status: 404 })
})
})

View File

@ -11,6 +11,7 @@ import DatasourcesAPI from "./apis/DatasourcesAPI"
import IntegrationsAPI from "./apis/IntegrationsAPI"
import QueriesAPI from "./apis/QueriesAPI"
import PermissionsAPI from "./apis/PermissionsAPI"
import LicenseAPI from "./apis/LicenseAPI"
import BudibaseInternalAPIClient from "./BudibaseInternalAPIClient"
import { State } from "../../types"
@ -30,6 +31,7 @@ export default class BudibaseInternalAPI {
integrations: IntegrationsAPI
queries: QueriesAPI
permissions: PermissionsAPI
license: LicenseAPI
constructor(state: State) {
this.client = new BudibaseInternalAPIClient(state)
@ -47,5 +49,6 @@ export default class BudibaseInternalAPI {
this.integrations = new IntegrationsAPI(this.client)
this.queries = new QueriesAPI(this.client)
this.permissions = new PermissionsAPI(this.client)
this.license = new LicenseAPI(this.client)
}
}

View File

@ -8,9 +8,9 @@ export default class BaseAPI {
this.client = client
}
async get(url: string): Promise<[Response, any]> {
async get(url: string, status?: number): Promise<[Response, any]> {
const [response, json] = await this.client.get(url)
expect(response).toHaveStatusCode(200)
expect(response).toHaveStatusCode(status ? status : 200)
return [response, json]
}

View File

@ -0,0 +1,45 @@
import { Response } from "node-fetch"
import {
ActivateOfflineLicenseTokenRequest,
GetOfflineIdentifierResponse,
GetOfflineLicenseTokenResponse,
} from "@budibase/types"
import BudibaseInternalAPIClient from "../BudibaseInternalAPIClient"
import BaseAPI from "./BaseAPI"
export default class LicenseAPI extends BaseAPI {
constructor(client: BudibaseInternalAPIClient) {
super(client)
}
async getOfflineLicenseToken(
opts: { status?: number } = {}
): Promise<[Response, GetOfflineLicenseTokenResponse]> {
const [response, body] = await this.get(
`/global/license/offline`,
opts.status
)
return [response, body]
}
async deleteOfflineLicenseToken(): Promise<[Response]> {
const [response] = await this.del(`/global/license/offline`, 204)
return [response]
}
async activateOfflineLicenseToken(
body: ActivateOfflineLicenseTokenRequest
): Promise<[Response]> {
const [response] = await this.post(`/global/license/offline`, body)
return [response]
}
async getOfflineIdentifier(): Promise<
[Response, GetOfflineIdentifierResponse]
> {
const [response, body] = await this.get(
`/global/license/offline/identifier`
)
return [response, body]
}
}