Merge branch 'master' of github.com:Budibase/budibase into feature/sqs-table-cleanup

This commit is contained in:
mike12345567 2024-05-09 17:38:24 +01:00
commit fd7f6455bd
36 changed files with 1141 additions and 600 deletions

View File

@ -281,7 +281,7 @@ export function doInScimContext(task: any) {
return newContext(updates, task) return newContext(updates, task)
} }
export async function ensureSnippetContext() { export async function ensureSnippetContext(enabled = !env.isTest()) {
const ctx = getCurrentContext() const ctx = getCurrentContext()
// If we've already added snippets to context, continue // If we've already added snippets to context, continue
@ -292,7 +292,7 @@ export async function ensureSnippetContext() {
// Otherwise get snippets for this app and update context // Otherwise get snippets for this app and update context
let snippets: Snippet[] | undefined let snippets: Snippet[] | undefined
const db = getAppDB() const db = getAppDB()
if (db && !env.isTest()) { if (db && enabled) {
const app = await db.get<App>(DocumentType.APP_METADATA) const app = await db.get<App>(DocumentType.APP_METADATA)
snippets = app.snippets snippets = app.snippets
} }

View File

@ -13,13 +13,14 @@ import { bucketTTLConfig, budibaseTempDir } from "./utils"
import { v4 } from "uuid" import { v4 } from "uuid"
import { APP_PREFIX, APP_DEV_PREFIX } from "../db" import { APP_PREFIX, APP_DEV_PREFIX } from "../db"
import fsp from "fs/promises" import fsp from "fs/promises"
import { HeadObjectOutput } from "aws-sdk/clients/s3"
const streamPipeline = promisify(stream.pipeline) const streamPipeline = promisify(stream.pipeline)
// use this as a temporary store of buckets that are being created // use this as a temporary store of buckets that are being created
const STATE = { const STATE = {
bucketCreationPromises: {}, bucketCreationPromises: {},
} }
const signedFilePrefix = "/files/signed" export const SIGNED_FILE_PREFIX = "/files/signed"
type ListParams = { type ListParams = {
ContinuationToken?: string ContinuationToken?: string
@ -40,8 +41,13 @@ type UploadParams = BaseUploadParams & {
path?: string | PathLike path?: string | PathLike
} }
type StreamUploadParams = BaseUploadParams & { export type StreamTypes =
stream: ReadStream | ReadStream
| NodeJS.ReadableStream
| ReadableStream<Uint8Array>
export type StreamUploadParams = BaseUploadParams & {
stream?: StreamTypes
} }
const CONTENT_TYPE_MAP: any = { const CONTENT_TYPE_MAP: any = {
@ -174,11 +180,9 @@ export async function upload({
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
const bucketCreated = await createBucketIfNotExists(objectStore, bucketName) const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)
if (ttl && (bucketCreated.created || bucketCreated.exists)) { if (ttl && bucketCreated.created) {
let ttlConfig = bucketTTLConfig(bucketName, ttl) let ttlConfig = bucketTTLConfig(bucketName, ttl)
if (objectStore.putBucketLifecycleConfiguration) { await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
}
} }
let contentType = type let contentType = type
@ -222,11 +226,9 @@ export async function streamUpload({
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
const bucketCreated = await createBucketIfNotExists(objectStore, bucketName) const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)
if (ttl && (bucketCreated.created || bucketCreated.exists)) { if (ttl && bucketCreated.created) {
let ttlConfig = bucketTTLConfig(bucketName, ttl) let ttlConfig = bucketTTLConfig(bucketName, ttl)
if (objectStore.putBucketLifecycleConfiguration) { await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
}
} }
// Set content type for certain known extensions // Set content type for certain known extensions
@ -333,7 +335,7 @@ export function getPresignedUrl(
const signedUrl = new URL(url) const signedUrl = new URL(url)
const path = signedUrl.pathname const path = signedUrl.pathname
const query = signedUrl.search const query = signedUrl.search
return `${signedFilePrefix}${path}${query}` return `${SIGNED_FILE_PREFIX}${path}${query}`
} }
} }
@ -521,6 +523,26 @@ export async function getReadStream(
return client.getObject(params).createReadStream() return client.getObject(params).createReadStream()
} }
export async function getObjectMetadata(
bucket: string,
path: string
): Promise<HeadObjectOutput> {
bucket = sanitizeBucket(bucket)
path = sanitizeKey(path)
const client = ObjectStore(bucket)
const params = {
Bucket: bucket,
Key: path,
}
try {
return await client.headObject(params).promise()
} catch (err: any) {
throw new Error("Unable to retrieve metadata from object")
}
}
/* /*
Given a signed url like '/files/signed/tmp-files-attachments/app_123456/myfile.txt' extract Given a signed url like '/files/signed/tmp-files-attachments/app_123456/myfile.txt' extract
the bucket and the path from it the bucket and the path from it
@ -530,7 +552,9 @@ export function extractBucketAndPath(
): { bucket: string; path: string } | null { ): { bucket: string; path: string } | null {
const baseUrl = url.split("?")[0] const baseUrl = url.split("?")[0]
const regex = new RegExp(`^${signedFilePrefix}/(?<bucket>[^/]+)/(?<path>.+)$`) const regex = new RegExp(
`^${SIGNED_FILE_PREFIX}/(?<bucket>[^/]+)/(?<path>.+)$`
)
const match = baseUrl.match(regex) const match = baseUrl.match(regex)
if (match && match.groups) { if (match && match.groups) {

View File

@ -1,9 +1,14 @@
import { join } from "path" import path, { join } from "path"
import { tmpdir } from "os" import { tmpdir } from "os"
import fs from "fs" import fs from "fs"
import env from "../environment" import env from "../environment"
import { PutBucketLifecycleConfigurationRequest } from "aws-sdk/clients/s3" import { PutBucketLifecycleConfigurationRequest } from "aws-sdk/clients/s3"
import * as objectStore from "./objectStore"
import {
AutomationAttachment,
AutomationAttachmentContent,
BucketedContent,
} from "@budibase/types"
/**************************************************** /****************************************************
* NOTE: When adding a new bucket - name * * NOTE: When adding a new bucket - name *
* sure that S3 usages (like budibase-infra) * * sure that S3 usages (like budibase-infra) *
@ -55,3 +60,50 @@ export const bucketTTLConfig = (
return params return params
} }
async function processUrlAttachment(
attachment: AutomationAttachment
): Promise<AutomationAttachmentContent> {
const response = await fetch(attachment.url)
if (!response.ok || !response.body) {
throw new Error(`Unexpected response ${response.statusText}`)
}
const fallbackFilename = path.basename(new URL(attachment.url).pathname)
return {
filename: attachment.filename || fallbackFilename,
content: response.body,
}
}
export async function processObjectStoreAttachment(
attachment: AutomationAttachment
): Promise<BucketedContent> {
const result = objectStore.extractBucketAndPath(attachment.url)
if (result === null) {
throw new Error("Invalid signed URL")
}
const { bucket, path: objectPath } = result
const readStream = await objectStore.getReadStream(bucket, objectPath)
const fallbackFilename = path.basename(objectPath)
return {
bucket,
path: objectPath,
filename: attachment.filename || fallbackFilename,
content: readStream,
}
}
export async function processAutomationAttachment(
attachment: AutomationAttachment
): Promise<AutomationAttachmentContent | BucketedContent> {
const isFullyFormedUrl =
attachment.url?.startsWith("http://") ||
attachment.url?.startsWith("https://")
if (isFullyFormedUrl) {
return await processUrlAttachment(attachment)
} else {
return await processObjectStoreAttachment(attachment)
}
}

View File

@ -358,7 +358,8 @@
value.customType !== "cron" && value.customType !== "cron" &&
value.customType !== "triggerSchema" && value.customType !== "triggerSchema" &&
value.customType !== "automationFields" && value.customType !== "automationFields" &&
value.type !== "attachment" value.type !== "attachment" &&
value.type !== "attachment_single"
) )
} }

View File

@ -2,6 +2,8 @@
import { tables } from "stores/builder" import { tables } from "stores/builder"
import { Select, Checkbox, Label } from "@budibase/bbui" import { Select, Checkbox, Label } from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { FieldType } from "@budibase/types"
import RowSelectorTypes from "./RowSelectorTypes.svelte" import RowSelectorTypes from "./RowSelectorTypes.svelte"
import DrawerBindableSlot from "../../common/bindings/DrawerBindableSlot.svelte" import DrawerBindableSlot from "../../common/bindings/DrawerBindableSlot.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte" import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
@ -14,7 +16,6 @@
export let bindings export let bindings
export let isTestModal export let isTestModal
export let isUpdateRow export let isUpdateRow
$: parsedBindings = bindings.map(binding => { $: parsedBindings = bindings.map(binding => {
let clone = Object.assign({}, binding) let clone = Object.assign({}, binding)
clone.icon = "ShareAndroid" clone.icon = "ShareAndroid"
@ -26,15 +27,19 @@
$: { $: {
table = $tables.list.find(table => table._id === value?.tableId) table = $tables.list.find(table => table._id === value?.tableId)
schemaFields = Object.entries(table?.schema ?? {})
// surface the schema so the user can see it in the json // Just sorting attachment types to the bottom here for a cleaner UX
schemaFields.map(([, schema]) => { schemaFields = Object.entries(table?.schema ?? {}).sort(
([, schemaA], [, schemaB]) =>
(schemaA.type === "attachment") - (schemaB.type === "attachment")
)
schemaFields.forEach(([, schema]) => {
if (!schema.autocolumn && !value[schema.name]) { if (!schema.autocolumn && !value[schema.name]) {
value[schema.name] = "" value[schema.name] = ""
} }
}) })
} }
const onChangeTable = e => { const onChangeTable = e => {
value["tableId"] = e.detail value["tableId"] = e.detail
dispatch("change", value) dispatch("change", value)
@ -114,10 +119,16 @@
</div> </div>
{#if schemaFields.length} {#if schemaFields.length}
{#each schemaFields as [field, schema]} {#each schemaFields as [field, schema]}
{#if !schema.autocolumn && schema.type !== "attachment"} {#if !schema.autocolumn}
<div class="schema-fields"> <div
class:schema-fields={schema.type !== FieldType.ATTACHMENTS &&
schema.type !== FieldType.ATTACHMENT_SINGLE}
>
<Label>{field}</Label> <Label>{field}</Label>
<div class="field-width"> <div
class:field-width={schema.type !== FieldType.ATTACHMENTS &&
schema.type !== FieldType.ATTACHMENT_SINGLE}
>
{#if isTestModal} {#if isTestModal}
<RowSelectorTypes <RowSelectorTypes
{isTestModal} {isTestModal}

View File

@ -1,10 +1,12 @@
<script> <script>
import { Select, DatePicker, Multiselect, TextArea } from "@budibase/bbui" import { Select, DatePicker, Multiselect, TextArea } from "@budibase/bbui"
import { FieldType } from "@budibase/types"
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte" import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
import ModalBindableInput from "../../common/bindings/ModalBindableInput.svelte" import ModalBindableInput from "../../common/bindings/ModalBindableInput.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte" import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
import Editor from "components/integration/QueryEditor.svelte" import Editor from "components/integration/QueryEditor.svelte"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
export let onChange export let onChange
export let field export let field
@ -22,6 +24,27 @@
function schemaHasOptions(schema) { function schemaHasOptions(schema) {
return !!schema.constraints?.inclusion?.length return !!schema.constraints?.inclusion?.length
} }
const handleAttachmentParams = keyValuObj => {
let params = {}
if (
schema.type === FieldType.ATTACHMENT_SINGLE &&
Object.keys(keyValuObj).length === 0
) {
return []
}
if (!Array.isArray(keyValuObj)) {
keyValuObj = [keyValuObj]
}
if (keyValuObj.length) {
for (let param of keyValuObj) {
params[param.url] = param.filename
}
}
return params
}
</script> </script>
{#if schemaHasOptions(schema) && schema.type !== "array"} {#if schemaHasOptions(schema) && schema.type !== "array"}
@ -77,6 +100,35 @@
on:change={e => onChange(e, field)} on:change={e => onChange(e, field)}
useLabel={false} useLabel={false}
/> />
{:else if schema.type === FieldType.ATTACHMENTS || schema.type === FieldType.ATTACHMENT_SINGLE}
<div class="attachment-field-spacinng">
<KeyValueBuilder
on:change={e =>
onChange(
{
detail:
schema.type === FieldType.ATTACHMENT_SINGLE
? e.detail.length > 0
? { url: e.detail[0].name, filename: e.detail[0].value }
: {}
: e.detail.map(({ name, value }) => ({
url: name,
filename: value,
})),
},
field
)}
object={handleAttachmentParams(value[field])}
allowJS
{bindings}
keyBindings
customButtonText={"Add attachment"}
keyPlaceholder={"URL"}
valuePlaceholder={"Filename"}
actionButtonDisabled={schema.type === FieldType.ATTACHMENT_SINGLE &&
Object.keys(value[field]).length >= 1}
/>
</div>
{:else if ["string", "number", "bigint", "barcodeqr", "array"].includes(schema.type)} {:else if ["string", "number", "bigint", "barcodeqr", "array"].includes(schema.type)}
<svelte:component <svelte:component
this={isTestModal ? ModalBindableInput : DrawerBindableInput} this={isTestModal ? ModalBindableInput : DrawerBindableInput}
@ -90,3 +142,10 @@
title={schema.name} title={schema.name}
/> />
{/if} {/if}
<style>
.attachment-field-spacinng {
margin-top: var(--spacing-s);
margin-bottom: var(--spacing-l);
}
</style>

View File

@ -1,7 +1,9 @@
<script> <script>
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { ActionButton, Modal, ModalContent } from "@budibase/bbui" import { ActionButton, Drawer, DrawerContent, Button } from "@budibase/bbui"
import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte" import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
import { getUserBindings } from "dataBinding"
import { makePropSafe } from "@budibase/string-templates"
export let schema export let schema
export let filters export let filters
@ -10,7 +12,7 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let modal let drawer
$: tempValue = filters || [] $: tempValue = filters || []
$: schemaFields = Object.entries(schema || {}).map( $: schemaFields = Object.entries(schema || {}).map(
@ -22,37 +24,53 @@
$: text = getText(filters) $: text = getText(filters)
$: selected = tempValue.filter(x => !x.onEmptyFilter)?.length > 0 $: selected = tempValue.filter(x => !x.onEmptyFilter)?.length > 0
$: bindings = [
{
type: "context",
runtimeBinding: `${makePropSafe("now")}`,
readableBinding: `Date`,
category: "Date",
icon: "Date",
display: {
name: "Server date",
},
},
...getUserBindings(),
]
const getText = filters => { const getText = filters => {
const count = filters?.filter(filter => filter.field)?.length const count = filters?.filter(filter => filter.field)?.length
return count ? `Filter (${count})` : "Filter" return count ? `Filter (${count})` : "Filter"
} }
</script> </script>
<ActionButton icon="Filter" quiet {disabled} on:click={modal.show} {selected}> <ActionButton icon="Filter" quiet {disabled} on:click={drawer.show} {selected}>
{text} {text}
</ActionButton> </ActionButton>
<Modal bind:this={modal}>
<ModalContent
title="Filter"
confirmText="Save"
size="XL"
onConfirm={() => dispatch("change", tempValue)}
>
<div class="wrapper">
<FilterBuilder
allowBindings={false}
{filters}
{schemaFields}
datasource={{ type: "table", tableId }}
on:change={e => (tempValue = e.detail)}
/>
</div>
</ModalContent>
</Modal>
<style> <Drawer
.wrapper :global(.main) { bind:this={drawer}
padding: 0; title="Filtering"
} on:drawerHide
</style> on:drawerShow
forceModal
>
<Button
cta
slot="buttons"
on:click={() => {
dispatch("change", tempValue)
drawer.hide()
}}
>
Save
</Button>
<DrawerContent slot="body">
<FilterBuilder
{filters}
{schemaFields}
datasource={{ type: "table", tableId }}
on:change={e => (tempValue = e.detail)}
{bindings}
/>
</DrawerContent>
</Drawer>

View File

@ -4,6 +4,7 @@
readableToRuntimeBinding, readableToRuntimeBinding,
runtimeToReadableBinding, runtimeToReadableBinding,
} from "dataBinding" } from "dataBinding"
import { FieldType } from "@budibase/types"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte" import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { createEventDispatcher, setContext } from "svelte" import { createEventDispatcher, setContext } from "svelte"
@ -102,6 +103,8 @@
longform: value => !isJSBinding(value), longform: value => !isJSBinding(value),
json: value => !isJSBinding(value), json: value => !isJSBinding(value),
boolean: isValidBoolean, boolean: isValidBoolean,
attachment: false,
attachment_single: false,
} }
const isValid = value => { const isValid = value => {
@ -116,7 +119,16 @@
if (type === "json" && !isJSBinding(value)) { if (type === "json" && !isJSBinding(value)) {
return "json-slot-icon" return "json-slot-icon"
} }
if (!["string", "number", "bigint", "barcodeqr"].includes(type)) { if (
![
"string",
"number",
"bigint",
"barcodeqr",
"attachment",
"attachment_single",
].includes(type)
) {
return "slot-icon" return "slot-icon"
} }
return "" return ""
@ -157,7 +169,7 @@
{updateOnChange} {updateOnChange}
/> />
{/if} {/if}
{#if !disabled && type !== "formula"} {#if !disabled && type !== "formula" && !disabled && type !== FieldType.ATTACHMENTS && !disabled && type !== FieldType.ATTACHMENT_SINGLE}
<div <div
class={`icon ${getIconClass(value, type)}`} class={`icon ${getIconClass(value, type)}`}
on:click={() => { on:click={() => {

View File

@ -37,6 +37,7 @@
export let customButtonText = null export let customButtonText = null
export let keyBindings = false export let keyBindings = false
export let allowJS = false export let allowJS = false
export let actionButtonDisabled = false
export let compare = (option, value) => option === value export let compare = (option, value) => option === value
let fields = Object.entries(object || {}).map(([name, value]) => ({ let fields = Object.entries(object || {}).map(([name, value]) => ({
@ -189,7 +190,14 @@
{/if} {/if}
{#if !readOnly && !noAddButton} {#if !readOnly && !noAddButton}
<div> <div>
<ActionButton icon="Add" secondary thin outline on:click={addEntry}> <ActionButton
disabled={actionButtonDisabled}
icon="Add"
secondary
thin
outline
on:click={addEntry}
>
{#if customButtonText} {#if customButtonText}
{customButtonText} {customButtonText}
{:else} {:else}

View File

@ -289,6 +289,7 @@
OperatorOptions.ContainsAny.value, OperatorOptions.ContainsAny.value,
].includes(filter.operator)} ].includes(filter.operator)}
disabled={filter.noValue} disabled={filter.noValue}
type={filter.valueType}
/> />
{:else} {:else}
<Input disabled /> <Input disabled />
@ -325,8 +326,6 @@
<style> <style>
.container { .container {
width: 100%; width: 100%;
max-width: 1000px;
margin: 0 auto;
} }
.fields { .fields {
display: grid; display: grid;

View File

@ -4,6 +4,7 @@
import { createAPIClient } from "../api" import { createAPIClient } from "../api"
export let API = createAPIClient() export let API = createAPIClient()
export let value = null export let value = null
export let disabled export let disabled
export let multiselect = false export let multiselect = false
@ -23,12 +24,14 @@
$: component = multiselect ? Multiselect : Select $: component = multiselect ? Multiselect : Select
</script> </script>
<svelte:component <div class="user-control">
this={component} <svelte:component
bind:value this={component}
autocomplete bind:value
{options} autocomplete
getOptionLabel={option => option.email} {options}
getOptionValue={option => option._id} getOptionLabel={option => option.email}
{disabled} getOptionValue={option => option._id}
/> {disabled}
/>
</div>

View File

@ -2,7 +2,7 @@ import stream from "stream"
import archiver from "archiver" import archiver from "archiver"
import { quotas } from "@budibase/pro" import { quotas } from "@budibase/pro"
import { objectStore } from "@budibase/backend-core" import { objectStore, context } from "@budibase/backend-core"
import * as internal from "./internal" import * as internal from "./internal"
import * as external from "./external" import * as external from "./external"
import { isExternalTableID } from "../../../integrations/utils" import { isExternalTableID } from "../../../integrations/utils"
@ -198,8 +198,18 @@ export async function destroy(ctx: UserCtx<DeleteRowRequest>) {
export async function search(ctx: Ctx<SearchRowRequest, SearchRowResponse>) { export async function search(ctx: Ctx<SearchRowRequest, SearchRowResponse>) {
const tableId = utils.getTableId(ctx) const tableId = utils.getTableId(ctx)
await context.ensureSnippetContext(true)
const enrichedQuery = await utils.enrichSearchContext(
{ ...ctx.request.body.query },
{
user: sdk.users.getUserContextBindings(ctx.user),
}
)
const searchParams: RowSearchParams = { const searchParams: RowSearchParams = {
...ctx.request.body, ...ctx.request.body,
query: enrichedQuery,
tableId, tableId,
} }

View File

@ -73,12 +73,15 @@ export function basicProcessing({
// filter the row down to what is actually the row (not joined) // filter the row down to what is actually the row (not joined)
for (let field of Object.values(table.schema)) { for (let field of Object.values(table.schema)) {
const fieldName = field.name const fieldName = field.name
const value = extractFieldValue({ let value = extractFieldValue({
row, row,
tableName: table.name, tableName: table.name,
fieldName, fieldName,
isLinked, isLinked,
}) })
if (value instanceof Buffer) {
value = value.toString()
}
// all responses include "select col as table.col" so that overlaps are handled // all responses include "select col as table.col" so that overlaps are handled
if (value != null) { if (value != null) {
thisRow[fieldName] = value thisRow[fieldName] = value

View File

@ -22,7 +22,7 @@ import {
getInternalRowId, getInternalRowId,
} from "./basic" } from "./basic"
import sdk from "../../../../sdk" import sdk from "../../../../sdk"
import { processStringSync } from "@budibase/string-templates"
import validateJs from "validate.js" import validateJs from "validate.js"
validateJs.extend(validateJs.validators.datetime, { validateJs.extend(validateJs.validators.datetime, {
@ -204,3 +204,63 @@ export async function sqlOutputProcessing(
export function isUserMetadataTable(tableId: string) { export function isUserMetadataTable(tableId: string) {
return tableId === InternalTables.USER_METADATA return tableId === InternalTables.USER_METADATA
} }
export async function enrichArrayContext(
fields: any[],
inputs = {},
helpers = true
): Promise<any[]> {
const map: Record<string, any> = {}
for (let index in fields) {
map[index] = fields[index]
}
const output = await enrichSearchContext(map, inputs, helpers)
const outputArray: any[] = []
for (let [key, value] of Object.entries(output)) {
outputArray[parseInt(key)] = value
}
return outputArray
}
export async function enrichSearchContext(
fields: Record<string, any>,
inputs = {},
helpers = true
): Promise<Record<string, any>> {
const enrichedQuery: Record<string, any> = {}
if (!fields || !inputs) {
return enrichedQuery
}
const parameters = { ...inputs }
if (Array.isArray(fields)) {
return enrichArrayContext(fields, inputs, helpers)
}
// enrich the fields with dynamic parameters
for (let key of Object.keys(fields)) {
if (fields[key] == null) {
enrichedQuery[key] = null
continue
}
if (typeof fields[key] === "object") {
// enrich nested fields object
enrichedQuery[key] = await enrichSearchContext(
fields[key],
parameters,
helpers
)
} else if (typeof fields[key] === "string") {
// enrich string value as normal
enrichedQuery[key] = processStringSync(fields[key], parameters, {
noEscaping: true,
noHelpers: !helpers,
escapeNewlines: true,
})
} else {
enrichedQuery[key] = fields[key]
}
}
return enrichedQuery
}

View File

@ -9,7 +9,8 @@ import {
} from "@budibase/types" } from "@budibase/types"
import { dataFilters } from "@budibase/shared-core" import { dataFilters } from "@budibase/shared-core"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { db } from "@budibase/backend-core" import { db, context } from "@budibase/backend-core"
import { enrichSearchContext } from "./utils"
export async function searchView( export async function searchView(
ctx: UserCtx<SearchViewRowRequest, SearchRowResponse> ctx: UserCtx<SearchViewRowRequest, SearchRowResponse>
@ -56,10 +57,16 @@ export async function searchView(
}) })
} }
await context.ensureSnippetContext(true)
const enrichedQuery = await enrichSearchContext(query, {
user: sdk.users.getUserContextBindings(ctx.user),
})
const searchOptions: RequiredKeys<SearchViewRowRequest> & const searchOptions: RequiredKeys<SearchViewRowRequest> &
RequiredKeys<Pick<RowSearchParams, "tableId" | "query" | "fields">> = { RequiredKeys<Pick<RowSearchParams, "tableId" | "query" | "fields">> = {
tableId: view.tableId, tableId: view.tableId,
query, query: enrichedQuery,
fields: viewFields, fields: viewFields,
...getSortOptions(body, view), ...getSortOptions(body, view),
limit: body.limit, limit: body.limit,

View File

@ -32,8 +32,6 @@ import * as uuid from "uuid"
const timestamp = new Date("2023-01-26T11:48:57.597Z").toISOString() const timestamp = new Date("2023-01-26T11:48:57.597Z").toISOString()
tk.freeze(timestamp) tk.freeze(timestamp)
jest.unmock("mssql")
describe.each([ describe.each([
["internal", undefined], ["internal", undefined],
[DatabaseName.POSTGRES, getDatasource(DatabaseName.POSTGRES)], [DatabaseName.POSTGRES, getDatasource(DatabaseName.POSTGRES)],
@ -131,7 +129,13 @@ describe.each([
const assertRowUsage = async (expected: number) => { const assertRowUsage = async (expected: number) => {
const usage = await getRowUsage() const usage = await getRowUsage()
expect(usage).toBe(expected)
// Because our quota tracking is not perfect, we allow a 10% margin of
// error. This is to account for the fact that parallel writes can result
// in some quota updates getting lost. We don't have any need to solve this
// right now, so we just allow for some error.
expect(usage).toBeGreaterThan(expected * 0.9)
expect(usage).toBeLessThan(expected * 1.1)
} }
const defaultRowFields = isInternal const defaultRowFields = isInternal
@ -194,39 +198,99 @@ describe.each([
await assertRowUsage(rowUsage) await assertRowUsage(rowUsage)
}) })
it("increment row autoId per create row request", async () => { isInternal &&
const rowUsage = await getRowUsage() it("increment row autoId per create row request", async () => {
const rowUsage = await getRowUsage()
const newTable = await config.api.table.save( const newTable = await config.api.table.save(
saveTableRequest({ saveTableRequest({
schema: { schema: {
"Row ID": { "Row ID": {
name: "Row ID", name: "Row ID",
type: FieldType.NUMBER, type: FieldType.NUMBER,
subtype: AutoFieldSubType.AUTO_ID, subtype: AutoFieldSubType.AUTO_ID,
icon: "ri-magic-line", icon: "ri-magic-line",
autocolumn: true, autocolumn: true,
constraints: { constraints: {
type: "number", type: "number",
presence: true, presence: true,
numericality: { numericality: {
greaterThanOrEqualTo: "", greaterThanOrEqualTo: "",
lessThanOrEqualTo: "", lessThanOrEqualTo: "",
},
}, },
}, },
}, },
}, })
}) )
)
let previousId = 0 let previousId = 0
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
const row = await config.api.row.save(newTable._id!, {}) const row = await config.api.row.save(newTable._id!, {})
expect(row["Row ID"]).toBeGreaterThan(previousId) expect(row["Row ID"]).toBeGreaterThan(previousId)
previousId = row["Row ID"] previousId = row["Row ID"]
} }
await assertRowUsage(rowUsage + 10) await assertRowUsage(rowUsage + 10)
}) })
isInternal &&
it("should increment auto ID correctly when creating rows in parallel", async () => {
const table = await config.api.table.save(
saveTableRequest({
schema: {
"Row ID": {
name: "Row ID",
type: FieldType.NUMBER,
subtype: AutoFieldSubType.AUTO_ID,
icon: "ri-magic-line",
autocolumn: true,
constraints: {
type: "number",
presence: true,
numericality: {
greaterThanOrEqualTo: "",
lessThanOrEqualTo: "",
},
},
},
},
})
)
const sequence = Array(50)
.fill(0)
.map((_, i) => i + 1)
// This block of code is simulating users creating auto ID rows at the
// same time. It's expected that this operation will sometimes return
// a document conflict error (409), but the idea is to retry in those
// situations. The code below does this a large number of times with
// small, random delays between them to try and get through the list
// as quickly as possible.
await Promise.all(
sequence.map(async () => {
const attempts = 20
for (let attempt = 0; attempt < attempts; attempt++) {
try {
await config.api.row.save(table._id!, {})
return
} catch (e) {
await new Promise(r => setTimeout(r, Math.random() * 15))
}
}
throw new Error(`Failed to create row after ${attempts} attempts`)
})
)
const rows = await config.api.row.fetch(table._id!)
expect(rows).toHaveLength(50)
// The main purpose of this test is to ensure that even under pressure,
// we maintain data integrity. An auto ID column should hand out
// monotonically increasing unique integers no matter what.
const ids = rows.map(r => r["Row ID"])
expect(ids).toEqual(expect.arrayContaining(sequence))
})
isInternal && isInternal &&
it("row values are coerced", async () => { it("row values are coerced", async () => {

View File

@ -1,11 +1,13 @@
import { tableForDatasource } from "../../../tests/utilities/structures" import { tableForDatasource } from "../../../tests/utilities/structures"
import { DatabaseName, getDatasource } from "../../../integrations/tests/utils" import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
import { db as dbCore } from "@budibase/backend-core"
import * as setup from "./utilities" import * as setup from "./utilities"
import { import {
AutoFieldSubType, AutoFieldSubType,
Datasource, Datasource,
EmptyFilterOption, EmptyFilterOption,
BBReferenceFieldSubType,
FieldType, FieldType,
RowSearchParams, RowSearchParams,
SearchFilters, SearchFilters,
@ -13,10 +15,14 @@ import {
SortType, SortType,
Table, Table,
TableSchema, TableSchema,
User,
} from "@budibase/types" } from "@budibase/types"
import _ from "lodash" import _ from "lodash"
import tk from "timekeeper"
import { encodeJSBinding } from "@budibase/string-templates"
jest.unmock("mssql") const serverTime = new Date("2024-05-06T00:00:00.000Z")
tk.freeze(serverTime)
describe.each([ describe.each([
["lucene", undefined], ["lucene", undefined],
@ -35,11 +41,25 @@ describe.each([
let datasource: Datasource | undefined let datasource: Datasource | undefined
let table: Table let table: Table
const snippets = [
{
name: "WeeksAgo",
code: "return function (weeks) {\n const currentTime = new Date();\n currentTime.setDate(currentTime.getDate()-(7 * (weeks || 1)));\n return currentTime.toISOString();\n}",
},
]
beforeAll(async () => { beforeAll(async () => {
if (isSqs) { if (isSqs) {
envCleanup = config.setEnv({ SQS_SEARCH_ENABLE: "true" }) envCleanup = config.setEnv({ SQS_SEARCH_ENABLE: "true" })
} }
await config.init() await config.init()
if (config.app?.appId) {
config.app = await config.api.application.update(config.app?.appId, {
snippets,
})
}
if (dsProvider) { if (dsProvider) {
datasource = await config.createDatasource({ datasource = await config.createDatasource({
datasource: await dsProvider, datasource: await dsProvider,
@ -227,6 +247,232 @@ describe.each([
}) })
}) })
// Ensure all bindings resolve and perform as expected
describe("bindings", () => {
let globalUsers: any = []
const future = new Date(serverTime.getTime())
future.setDate(future.getDate() + 30)
const rows = (currentUser: User) => {
return [
{ name: "foo", appointment: "1982-01-05T00:00:00.000Z" },
{ name: "bar", appointment: "1995-05-06T00:00:00.000Z" },
{ name: currentUser.firstName, appointment: future.toISOString() },
{ name: "serverDate", appointment: serverTime.toISOString() },
{
name: "single user, session user",
single_user: JSON.stringify([currentUser]),
},
{
name: "single user",
single_user: JSON.stringify([globalUsers[0]]),
},
{
name: "multi user",
multi_user: JSON.stringify(globalUsers),
},
{
name: "multi user with session user",
multi_user: JSON.stringify([...globalUsers, currentUser]),
},
]
}
beforeAll(async () => {
// Set up some global users
globalUsers = await Promise.all(
Array(2)
.fill(0)
.map(async () => {
const globalUser = await config.globalUser()
const userMedataId = globalUser._id
? dbCore.generateUserMetadataID(globalUser._id)
: null
return {
_id: globalUser._id,
_meta: userMedataId,
}
})
)
await createTable({
name: { name: "name", type: FieldType.STRING },
appointment: { name: "appointment", type: FieldType.DATETIME },
single_user: {
name: "single_user",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USER,
},
multi_user: {
name: "multi_user",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
},
})
await createRows(rows(config.getUser()))
})
// !! Current User is auto generated per run
it("should return all rows matching the session user firstname", async () => {
await expectQuery({
equal: { name: "{{ [user].firstName }}" },
}).toContainExactly([
{
name: config.getUser().firstName,
appointment: future.toISOString(),
},
])
})
it("should parse the date binding and return all rows after the resolved value", async () => {
await expectQuery({
range: {
appointment: {
low: "{{ [now] }}",
high: "9999-00-00T00:00:00.000Z",
},
},
}).toContainExactly([
{
name: config.getUser().firstName,
appointment: future.toISOString(),
},
{ name: "serverDate", appointment: serverTime.toISOString() },
])
})
it("should parse the date binding and return all rows before the resolved value", async () => {
await expectQuery({
range: {
appointment: {
low: "0000-00-00T00:00:00.000Z",
high: "{{ [now] }}",
},
},
}).toContainExactly([
{ name: "foo", appointment: "1982-01-05T00:00:00.000Z" },
{ name: "bar", appointment: "1995-05-06T00:00:00.000Z" },
{ name: "serverDate", appointment: serverTime.toISOString() },
])
})
it("should parse the encoded js snippet. Return rows with appointments up to 1 week in the past", async () => {
const jsBinding = "return snippets.WeeksAgo();"
const encodedBinding = encodeJSBinding(jsBinding)
await expectQuery({
range: {
appointment: {
low: "0000-00-00T00:00:00.000Z",
high: encodedBinding,
},
},
}).toContainExactly([
{ name: "foo", appointment: "1982-01-05T00:00:00.000Z" },
{ name: "bar", appointment: "1995-05-06T00:00:00.000Z" },
])
})
it("should parse the encoded js binding. Return rows with appointments 2 weeks in the past", async () => {
const jsBinding =
"const currentTime = new Date()\ncurrentTime.setDate(currentTime.getDate()-14);\nreturn currentTime.toISOString();"
const encodedBinding = encodeJSBinding(jsBinding)
await expectQuery({
range: {
appointment: {
low: "0000-00-00T00:00:00.000Z",
high: encodedBinding,
},
},
}).toContainExactly([
{ name: "foo", appointment: "1982-01-05T00:00:00.000Z" },
{ name: "bar", appointment: "1995-05-06T00:00:00.000Z" },
])
})
it("should match a single user row by the session user id", async () => {
await expectQuery({
equal: { single_user: "{{ [user]._id }}" },
}).toContainExactly([
{
name: "single user, session user",
single_user: [{ _id: config.getUser()._id }],
},
])
})
// TODO(samwho): fix for SQS
!isSqs &&
it("should match the session user id in a multi user field", async () => {
const allUsers = [...globalUsers, config.getUser()].map((user: any) => {
return { _id: user._id }
})
await expectQuery({
contains: { multi_user: ["{{ [user]._id }}"] },
}).toContainExactly([
{
name: "multi user with session user",
multi_user: allUsers,
},
])
})
// TODO(samwho): fix for SQS
!isSqs &&
it("should not match the session user id in a multi user field", async () => {
await expectQuery({
notContains: { multi_user: ["{{ [user]._id }}"] },
notEmpty: { multi_user: true },
}).toContainExactly([
{
name: "multi user",
multi_user: globalUsers.map((user: any) => {
return { _id: user._id }
}),
},
])
})
it("should match the session user id and a user table row id using helpers, user binding and a static user id.", async () => {
await expectQuery({
oneOf: {
single_user: [
"{{ default [user]._id '_empty_' }}",
globalUsers[0]._id,
],
},
}).toContainExactly([
{
name: "single user, session user",
single_user: [{ _id: config.getUser()._id }],
},
{
name: "single user",
single_user: [{ _id: globalUsers[0]._id }],
},
])
})
it("should resolve 'default' helper to '_empty_' when binding resolves to nothing", async () => {
await expectQuery({
oneOf: {
single_user: [
"{{ default [user]._idx '_empty_' }}",
globalUsers[0]._id,
],
},
}).toContainExactly([
{
name: "single user",
single_user: [{ _id: globalUsers[0]._id }],
},
])
})
})
describe.each([FieldType.STRING, FieldType.LONGFORM])("%s", () => { describe.each([FieldType.STRING, FieldType.LONGFORM])("%s", () => {
beforeAll(async () => { beforeAll(async () => {
await createTable({ await createTable({

View File

@ -1,8 +1,8 @@
import { context, events } from "@budibase/backend-core" import { context, events } from "@budibase/backend-core"
import { import {
AutoFieldSubType, AutoFieldSubType,
Datasource,
BBReferenceFieldSubType, BBReferenceFieldSubType,
Datasource,
FieldType, FieldType,
INTERNAL_TABLE_SOURCE_ID, INTERNAL_TABLE_SOURCE_ID,
InternalTable, InternalTable,
@ -149,58 +149,59 @@ describe.each([
expect(res.name).toBeUndefined() expect(res.name).toBeUndefined()
}) })
it("updates only the passed fields", async () => { isInternal &&
await timekeeper.withFreeze(new Date(2021, 1, 1), async () => { it("updates only the passed fields", async () => {
const table = await config.api.table.save( await timekeeper.withFreeze(new Date(2021, 1, 1), async () => {
tableForDatasource(datasource, { const table = await config.api.table.save(
schema: { tableForDatasource(datasource, {
autoId: { schema: {
name: "id", autoId: {
type: FieldType.NUMBER, name: "id",
subtype: AutoFieldSubType.AUTO_ID, type: FieldType.NUMBER,
autocolumn: true, subtype: AutoFieldSubType.AUTO_ID,
constraints: { autocolumn: true,
type: "number", constraints: {
presence: false, type: "number",
presence: false,
},
}, },
}, },
}, })
)
const newName = generator.guid()
const updatedTable = await config.api.table.save({
...table,
name: newName,
}) })
)
const newName = generator.guid() let expected: Table = {
...table,
name: newName,
_id: expect.any(String),
}
if (isInternal) {
expected._rev = expect.stringMatching(/^2-.+/)
}
const updatedTable = await config.api.table.save({ expect(updatedTable).toEqual(expected)
...table,
name: newName, const persistedTable = await config.api.table.get(updatedTable._id!)
expected = {
...table,
name: newName,
_id: updatedTable._id,
}
if (datasource?.isSQL) {
expected.sql = true
}
if (isInternal) {
expected._rev = expect.stringMatching(/^2-.+/)
}
expect(persistedTable).toEqual(expected)
}) })
let expected: Table = {
...table,
name: newName,
_id: expect.any(String),
}
if (isInternal) {
expected._rev = expect.stringMatching(/^2-.+/)
}
expect(updatedTable).toEqual(expected)
const persistedTable = await config.api.table.get(updatedTable._id!)
expected = {
...table,
name: newName,
_id: updatedTable._id,
}
if (datasource?.isSQL) {
expected.sql = true
}
if (isInternal) {
expected._rev = expect.stringMatching(/^2-.+/)
}
expect(persistedTable).toEqual(expected)
}) })
})
describe("user table", () => { describe("user table", () => {
isInternal && isInternal &&
@ -214,6 +215,57 @@ describe.each([
}) })
}) })
describe("external table validation", () => {
!isInternal &&
it("should error if column is of type auto", async () => {
const table = basicTable(datasource)
await config.api.table.save(
{
...table,
schema: {
...table.schema,
auto: {
name: "auto",
autocolumn: true,
type: FieldType.AUTO,
},
},
},
{
status: 400,
body: {
message: `Column "auto" has type "${FieldType.AUTO}" - this is not supported.`,
},
}
)
})
!isInternal &&
it("should error if column has auto subtype", async () => {
const table = basicTable(datasource)
await config.api.table.save(
{
...table,
schema: {
...table.schema,
auto: {
name: "auto",
autocolumn: true,
type: FieldType.NUMBER,
subtype: AutoFieldSubType.AUTO_ID,
},
},
},
{
status: 400,
body: {
message: `Column "auto" has subtype "${AutoFieldSubType.AUTO_ID}" - this is not supported.`,
},
}
)
})
})
it("should add a new column for an internal DB table", async () => { it("should add a new column for an internal DB table", async () => {
const saveTableRequest: SaveTableRequest = { const saveTableRequest: SaveTableRequest = {
...basicTable(), ...basicTable(),

View File

@ -24,8 +24,6 @@ import merge from "lodash/merge"
import { quotas } from "@budibase/pro" import { quotas } from "@budibase/pro"
import { roles } from "@budibase/backend-core" import { roles } from "@budibase/backend-core"
jest.unmock("mssql")
describe.each([ describe.each([
["internal", undefined], ["internal", undefined],
[DatabaseName.POSTGRES, getDatasource(DatabaseName.POSTGRES)], [DatabaseName.POSTGRES, getDatasource(DatabaseName.POSTGRES)],

View File

@ -4,8 +4,11 @@ import {
encodeJSBinding, encodeJSBinding,
} from "@budibase/string-templates" } from "@budibase/string-templates"
import sdk from "../sdk" import sdk from "../sdk"
import { Row } from "@budibase/types" import { AutomationAttachment, FieldType, Row } from "@budibase/types"
import { LoopInput, LoopStepType } from "../definitions/automations" import { LoopInput, LoopStepType } from "../definitions/automations"
import { objectStore, context } from "@budibase/backend-core"
import * as uuid from "uuid"
import path from "path"
/** /**
* When values are input to the system generally they will be of type string as this is required for template strings. * When values are input to the system generally they will be of type string as this is required for template strings.
@ -96,6 +99,98 @@ export function getError(err: any) {
return typeof err !== "string" ? err.toString() : err return typeof err !== "string" ? err.toString() : err
} }
export async function sendAutomationAttachmentsToStorage(
tableId: string,
row: Row
): Promise<Row> {
const table = await sdk.tables.getTable(tableId)
const attachmentRows: Record<
string,
AutomationAttachment[] | AutomationAttachment
> = {}
for (const [prop, value] of Object.entries(row)) {
const schema = table.schema[prop]
if (
schema?.type === FieldType.ATTACHMENTS ||
schema?.type === FieldType.ATTACHMENT_SINGLE
) {
attachmentRows[prop] = value
}
}
for (const [prop, attachments] of Object.entries(attachmentRows)) {
if (Array.isArray(attachments)) {
if (attachments.length) {
row[prop] = await Promise.all(
attachments.map(attachment => generateAttachmentRow(attachment))
)
}
} else if (Object.keys(row[prop]).length > 0) {
row[prop] = await generateAttachmentRow(attachments)
}
}
return row
}
async function generateAttachmentRow(attachment: AutomationAttachment) {
const prodAppId = context.getProdAppId()
async function uploadToS3(
extension: string,
content: objectStore.StreamTypes
) {
const fileName = `${uuid.v4()}${extension}`
const s3Key = `${prodAppId}/attachments/${fileName}`
await objectStore.streamUpload({
bucket: objectStore.ObjectStoreBuckets.APPS,
stream: content,
filename: s3Key,
})
return s3Key
}
async function getSize(s3Key: string) {
return (
await objectStore.getObjectMetadata(
objectStore.ObjectStoreBuckets.APPS,
s3Key
)
).ContentLength
}
try {
const { filename } = attachment
const extension = path.extname(filename)
const attachmentResult = await objectStore.processAutomationAttachment(
attachment
)
let s3Key = ""
if (
"path" in attachmentResult &&
attachmentResult.path.startsWith(`${prodAppId}/attachments/`)
) {
s3Key = attachmentResult.path
} else {
s3Key = await uploadToS3(extension, attachmentResult.content)
}
const size = await getSize(s3Key)
return {
size,
name: filename,
extension,
key: s3Key,
}
} catch (error) {
console.error("Failed to process attachment:", error)
throw error
}
}
export function substituteLoopStep(hbsString: string, substitute: string) { export function substituteLoopStep(hbsString: string, substitute: string) {
let checkForJS = isJSBinding(hbsString) let checkForJS = isJSBinding(hbsString)
let substitutedHbsString = "" let substitutedHbsString = ""

View File

@ -1,5 +1,9 @@
import { save } from "../../api/controllers/row" import { save } from "../../api/controllers/row"
import { cleanUpRow, getError } from "../automationUtils" import {
cleanUpRow,
getError,
sendAutomationAttachmentsToStorage,
} from "../automationUtils"
import { buildCtx } from "./utils" import { buildCtx } from "./utils"
import { import {
AutomationActionStepId, AutomationActionStepId,
@ -89,6 +93,10 @@ export async function run({ inputs, appId, emitter }: AutomationStepInput) {
try { try {
inputs.row = await cleanUpRow(inputs.row.tableId, inputs.row) inputs.row = await cleanUpRow(inputs.row.tableId, inputs.row)
inputs.row = await sendAutomationAttachmentsToStorage(
inputs.row.tableId,
inputs.row
)
await save(ctx) await save(ctx)
return { return {
row: inputs.row, row: inputs.row,

View File

@ -108,7 +108,15 @@ export async function run({ inputs, appId, emitter }: AutomationStepInput) {
try { try {
if (tableId) { if (tableId) {
inputs.row = await automationUtils.cleanUpRow(tableId, inputs.row) inputs.row = await automationUtils.cleanUpRow(
inputs.row.tableId,
inputs.row
)
inputs.row = await automationUtils.sendAutomationAttachmentsToStorage(
inputs.row.tableId,
inputs.row
)
} }
await rowController.patch(ctx) await rowController.patch(ctx)
return { return {

View File

@ -1,5 +1,18 @@
import * as setup from "./utilities" import * as setup from "./utilities"
import { basicTableWithAttachmentField } from "../../tests/utilities/structures"
import { objectStore } from "@budibase/backend-core"
async function uploadTestFile(filename: string) {
let bucket = "testbucket"
await objectStore.upload({
bucket,
filename,
body: Buffer.from("test data"),
})
let presignedUrl = await objectStore.getPresignedUrl(bucket, filename, 60000)
return presignedUrl
}
describe("test the create row action", () => { describe("test the create row action", () => {
let table: any let table: any
let row: any let row: any
@ -43,4 +56,76 @@ describe("test the create row action", () => {
const res = await setup.runStep(setup.actions.CREATE_ROW.stepId, {}) const res = await setup.runStep(setup.actions.CREATE_ROW.stepId, {})
expect(res.success).toEqual(false) expect(res.success).toEqual(false)
}) })
it("should check that an attachment field is sent to storage and parsed", async () => {
let attachmentTable = await config.createTable(
basicTableWithAttachmentField()
)
let attachmentRow: any = {
tableId: attachmentTable._id,
}
let filename = "test1.txt"
let presignedUrl = await uploadTestFile(filename)
let attachmentObject = [
{
url: presignedUrl,
filename,
},
]
attachmentRow.file_attachment = attachmentObject
const res = await setup.runStep(setup.actions.CREATE_ROW.stepId, {
row: attachmentRow,
})
expect(res.success).toEqual(true)
expect(res.row.file_attachment[0]).toHaveProperty("key")
let s3Key = res.row.file_attachment[0].key
const client = objectStore.ObjectStore(objectStore.ObjectStoreBuckets.APPS)
const objectData = await client
.headObject({ Bucket: objectStore.ObjectStoreBuckets.APPS, Key: s3Key })
.promise()
expect(objectData).toBeDefined()
expect(objectData.ContentLength).toBeGreaterThan(0)
})
it("should check that an single attachment field is sent to storage and parsed", async () => {
let attachmentTable = await config.createTable(
basicTableWithAttachmentField()
)
let attachmentRow: any = {
tableId: attachmentTable._id,
}
let filename = "test2.txt"
let presignedUrl = await uploadTestFile(filename)
let attachmentObject = {
url: presignedUrl,
filename,
}
attachmentRow.single_file_attachment = attachmentObject
const res = await setup.runStep(setup.actions.CREATE_ROW.stepId, {
row: attachmentRow,
})
expect(res.success).toEqual(true)
expect(res.row.single_file_attachment).toHaveProperty("key")
let s3Key = res.row.single_file_attachment.key
const client = objectStore.ObjectStore(objectStore.ObjectStoreBuckets.APPS)
const objectData = await client
.headObject({ Bucket: objectStore.ObjectStoreBuckets.APPS, Key: s3Key })
.promise()
expect(objectData).toBeDefined()
expect(objectData.ContentLength).toBeGreaterThan(0)
})
}) })

View File

@ -1200,4 +1200,38 @@ describe("postgres integrations", () => {
expect(Object.keys(schema).sort()).toEqual(["id", "val1"]) expect(Object.keys(schema).sort()).toEqual(["id", "val1"])
}) })
}) })
describe("check custom column types", () => {
beforeAll(async () => {
await rawQuery(
rawDatasource,
`CREATE TABLE binaryTable (
id BYTEA PRIMARY KEY,
column1 TEXT,
column2 INT
);
`
)
})
it("should handle binary columns", async () => {
const response = await makeRequest(
"post",
`/api/datasources/${datasource._id}/schema`
)
expect(response.body).toBeDefined()
expect(response.body.datasource.entities).toBeDefined()
const table = response.body.datasource.entities["binarytable"]
expect(table).toBeDefined()
expect(table.schema.id.externalType).toBe("bytea")
const row = await config.api.row.save(table._id, {
id: "1111",
column1: "hello",
column2: 222,
})
expect(row._id).toBeDefined()
const decoded = decodeURIComponent(row._id!).replace(/'/g, '"')
expect(JSON.parse(decoded)[0]).toBe("1111")
})
})
}) })

View File

@ -139,13 +139,13 @@ class RestIntegration implements IntegrationBase {
const contentType = response.headers.get("content-type") || "" const contentType = response.headers.get("content-type") || ""
const contentDisposition = response.headers.get("content-disposition") || "" const contentDisposition = response.headers.get("content-disposition") || ""
if ( if (
contentDisposition.includes("filename") ||
contentDisposition.includes("attachment") || contentDisposition.includes("attachment") ||
contentDisposition.includes("form-data") contentDisposition.includes("form-data")
) { ) {
filename = filename =
path.basename(parse(contentDisposition).parameters?.filename) || "" path.basename(parse(contentDisposition).parameters?.filename) || ""
} }
try { try {
if (filename) { if (filename) {
return handleFileResponse(response, filename, this.startTimeMs) return handleFileResponse(response, filename, this.startTimeMs)

View File

@ -192,6 +192,11 @@ export function generateRowIdField(keyProps: any[] = []) {
if (!Array.isArray(keyProps)) { if (!Array.isArray(keyProps)) {
keyProps = [keyProps] keyProps = [keyProps]
} }
for (let index in keyProps) {
if (keyProps[index] instanceof Buffer) {
keyProps[index] = keyProps[index].toString()
}
}
// this conserves order and types // this conserves order and types
// we have to swap the double quotes to single quotes for use in HBS statements // we have to swap the double quotes to single quotes for use in HBS statements
// when using the literal helper the double quotes can break things // when using the literal helper the double quotes can break things

View File

@ -1,117 +0,0 @@
import {
FieldType,
Row,
Table,
RowSearchParams,
INTERNAL_TABLE_SOURCE_ID,
TableSourceType,
} from "@budibase/types"
import TestConfiguration from "../../../../../tests/utilities/TestConfiguration"
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",
sourceId: INTERNAL_TABLE_SOURCE_ID,
sourceType: TableSourceType.INTERNAL,
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: RowSearchParams = {
tableId,
query: {},
}
const result = await search(searchParams, config.table!)
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: RowSearchParams = {
tableId,
query: {},
fields: ["name", "age"],
}
const result = await search(searchParams, config.table!)
expect(result.rows).toHaveLength(10)
expect(result.rows).toEqual(
expect.arrayContaining(
rows.map(r => ({
...expectAnyInternalColsAttributes,
name: r.name,
age: r.age,
}))
)
)
})
})
})
})

View File

@ -1,230 +0,0 @@
import tk from "timekeeper"
import * as internalSdk from "../internal"
import { generator } from "@budibase/backend-core/tests"
import {
INTERNAL_TABLE_SOURCE_ID,
TableSourceType,
FieldType,
Table,
AutoFieldSubType,
AutoColumnFieldMetadata,
} from "@budibase/types"
import TestConfiguration from "../../../../tests/utilities/TestConfiguration"
tk.freeze(Date.now())
describe("sdk >> rows >> internal", () => {
const config = new TestConfiguration()
beforeAll(async () => {
await config.init()
})
function makeRow() {
return {
name: generator.first(),
surname: generator.last(),
age: generator.age(),
address: generator.address(),
}
}
describe("save", () => {
const tableData: Table = {
name: generator.word(),
type: "table",
sourceId: INTERNAL_TABLE_SOURCE_ID,
sourceType: TableSourceType.INTERNAL,
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,
},
},
},
}
beforeEach(() => {
jest.clearAllMocks()
})
it("save will persist the row properly", async () => {
const table = await config.createTable(tableData)
const row = makeRow()
await config.doInContext(config.appId, async () => {
const response = await internalSdk.save(
table._id!,
row,
config.getUser()._id
)
expect(response).toEqual({
table,
row: {
...row,
type: "row",
_rev: expect.stringMatching("1-.*"),
},
squashed: {
...row,
type: "row",
_rev: expect.stringMatching("1-.*"),
},
})
const persistedRow = await config.api.row.get(
table._id!,
response.row._id!
)
expect(persistedRow).toEqual({
...row,
type: "row",
_rev: expect.stringMatching("1-.*"),
createdAt: expect.any(String),
updatedAt: expect.any(String),
})
})
})
it("auto ids will update when creating new rows", async () => {
const table = await config.createTable({
...tableData,
schema: {
...tableData.schema,
id: {
name: "id",
type: FieldType.AUTO,
subtype: AutoFieldSubType.AUTO_ID,
autocolumn: true,
lastID: 0,
},
},
})
const row = makeRow()
await config.doInContext(config.appId, async () => {
const response = await internalSdk.save(
table._id!,
row,
config.getUser()._id
)
expect(response).toEqual({
table: {
...table,
schema: {
...table.schema,
id: {
...table.schema.id,
lastID: 1,
},
},
_rev: expect.stringMatching("2-.*"),
},
row: {
...row,
id: 1,
type: "row",
_rev: expect.stringMatching("1-.*"),
},
squashed: {
...row,
id: 1,
type: "row",
_rev: expect.stringMatching("1-.*"),
},
})
const persistedRow = await config.api.row.get(
table._id!,
response.row._id!
)
expect(persistedRow).toEqual({
...row,
type: "row",
id: 1,
_rev: expect.stringMatching("1-.*"),
createdAt: expect.any(String),
updatedAt: expect.any(String),
})
})
})
it("auto ids will update when creating new rows in parallel", async () => {
function makeRows(count: number) {
return Array.from({ length: count }, () => makeRow())
}
const table = await config.createTable({
...tableData,
schema: {
...tableData.schema,
id: {
name: "id",
type: FieldType.AUTO,
subtype: AutoFieldSubType.AUTO_ID,
autocolumn: true,
},
},
})
await config.doInContext(config.appId, async () => {
for (const row of makeRows(5)) {
await internalSdk.save(table._id!, row, config.getUser()._id)
}
await Promise.all(
makeRows(20).map(row =>
internalSdk.save(table._id!, row, config.getUser()._id)
)
)
for (const row of makeRows(5)) {
await internalSdk.save(table._id!, row, config.getUser()._id)
}
})
const persistedRows = await config.getRows(table._id!)
expect(persistedRows).toHaveLength(30)
expect(persistedRows).toEqual(
expect.arrayContaining(
Array.from({ length: 30 }).map((_, i) =>
expect.objectContaining({ id: i + 1 })
)
)
)
const persistedTable = await config.getTable(table._id)
expect(
(table.schema.id as AutoColumnFieldMetadata).lastID
).toBeUndefined()
expect((persistedTable.schema.id as AutoColumnFieldMetadata).lastID).toBe(
30
)
})
})
})

View File

@ -6,6 +6,7 @@ import {
Table, Table,
TableRequest, TableRequest,
ViewV2, ViewV2,
AutoFieldSubType,
} from "@budibase/types" } from "@budibase/types"
import { context } from "@budibase/backend-core" import { context } from "@budibase/backend-core"
import { buildExternalTableId } from "../../../../integrations/utils" import { buildExternalTableId } from "../../../../integrations/utils"
@ -29,6 +30,52 @@ import { populateExternalTableSchemas } from "../validation"
import datasourceSdk from "../../datasources" import datasourceSdk from "../../datasources"
import * as viewSdk from "../../views" import * as viewSdk from "../../views"
const DEFAULT_PRIMARY_COLUMN = "id"
function noPrimaryKey(table: Table) {
return table.primary == null || table.primary.length === 0
}
function validate(table: Table, oldTable?: Table) {
if (
!oldTable &&
table.schema[DEFAULT_PRIMARY_COLUMN] &&
noPrimaryKey(table)
) {
throw new Error(
"External tables with no `primary` column set will define an `id` column, but we found an `id` column in the supplied schema. Either set a `primary` column or remove the `id` column."
)
}
if (hasTypeChanged(table, oldTable)) {
throw new Error("A column type has changed.")
}
const autoSubTypes = Object.values(AutoFieldSubType)
// check for auto columns, they are not allowed
for (let [key, column] of Object.entries(table.schema)) {
// this column is a special case, do not validate it
if (key === DEFAULT_PRIMARY_COLUMN) {
continue
}
// the auto-column type should never be used
if (column.type === FieldType.AUTO) {
throw new Error(
`Column "${key}" has type "${FieldType.AUTO}" - this is not supported.`
)
}
if (
column.subtype &&
autoSubTypes.includes(column.subtype as AutoFieldSubType)
) {
throw new Error(
`Column "${key}" has subtype "${column.subtype}" - this is not supported.`
)
}
}
}
export async function save( export async function save(
datasourceId: string, datasourceId: string,
update: Table, update: Table,
@ -47,28 +94,18 @@ export async function save(
oldTable = await getTable(tableId) oldTable = await getTable(tableId)
} }
if ( // this will throw an error if something is wrong
!oldTable && validate(tableToSave, oldTable)
(tableToSave.primary == null || tableToSave.primary.length === 0)
) {
if (tableToSave.schema.id) {
throw new Error(
"External tables with no `primary` column set will define an `id` column, but we found an `id` column in the supplied schema. Either set a `primary` column or remove the `id` column."
)
}
tableToSave.primary = ["id"] if (!oldTable && noPrimaryKey(tableToSave)) {
tableToSave.schema.id = { tableToSave.primary = [DEFAULT_PRIMARY_COLUMN]
tableToSave.schema[DEFAULT_PRIMARY_COLUMN] = {
type: FieldType.NUMBER, type: FieldType.NUMBER,
autocolumn: true, autocolumn: true,
name: "id", name: DEFAULT_PRIMARY_COLUMN,
} }
} }
if (hasTypeChanged(tableToSave, oldTable)) {
throw new Error("A column type has changed.")
}
for (let view in tableToSave.views) { for (let view in tableToSave.views) {
const tableView = tableToSave.views[view] const tableView = tableToSave.views[view]
if (!tableView || !viewSdk.isV2(tableView)) continue if (!tableView || !viewSdk.isV2(tableView)) continue

View File

@ -124,3 +124,12 @@ export async function syncGlobalUsers() {
await db.bulkDocs(toWrite) await db.bulkDocs(toWrite)
} }
} }
export function getUserContextBindings(user: ContextUser) {
if (!user) {
return {}
}
// Current user context for bindable search
const { _id, _rev, firstName, lastName, email, status, roleId } = user
return { _id, _rev, firstName, lastName, email, status, roleId }
}

View File

@ -81,6 +81,12 @@ mocks.licenses.useUnlimited()
dbInit() dbInit()
export interface CreateAppRequest {
appName: string
url?: string
snippets?: any[]
}
export interface TableToBuild extends Omit<Table, "sourceId" | "sourceType"> { export interface TableToBuild extends Omit<Table, "sourceId" | "sourceType"> {
sourceId?: string sourceId?: string
sourceType?: TableSourceType sourceType?: TableSourceType
@ -580,8 +586,6 @@ export default class TestConfiguration {
// APP // APP
async createApp(appName: string, url?: string): Promise<App> { async createApp(appName: string, url?: string): Promise<App> {
// create dev app
// clear any old app
this.appId = undefined this.appId = undefined
this.app = await context.doInTenant( this.app = await context.doInTenant(
this.tenantId!, this.tenantId!,
@ -592,6 +596,7 @@ export default class TestConfiguration {
})) as App })) as App
) )
this.appId = this.app.appId this.appId = this.app.appId
return await context.doInAppContext(this.app.appId!, async () => { return await context.doInAppContext(this.app.appId!, async () => {
// create production app // create production app
this.prodApp = await this.publish() this.prodApp = await this.publish()

View File

@ -78,6 +78,32 @@ export function basicTable(
) )
} }
export function basicTableWithAttachmentField(
datasource?: Datasource,
...extra: Partial<Table>[]
): Table {
return tableForDatasource(
datasource,
{
name: "TestTable",
schema: {
file_attachment: {
type: FieldType.ATTACHMENTS,
name: "description",
constraints: {
type: "array",
},
},
single_file_attachment: {
type: FieldType.ATTACHMENT_SINGLE,
name: "description",
},
},
},
...extra
)
}
export function basicView(tableId: string) { export function basicView(tableId: string) {
return { return {
tableId, tableId,

View File

@ -1,6 +1,6 @@
import * as linkRows from "../../db/linkedRows" import * as linkRows from "../../db/linkedRows"
import { processFormulas, fixAutoColumnSubType } from "./utils" import { processFormulas, fixAutoColumnSubType } from "./utils"
import { context, objectStore, utils } from "@budibase/backend-core" import { objectStore, utils } from "@budibase/backend-core"
import { InternalTables } from "../../db/utils" import { InternalTables } from "../../db/utils"
import { TYPE_TRANSFORM_MAP } from "./map" import { TYPE_TRANSFORM_MAP } from "./map"
import { import {
@ -25,45 +25,6 @@ type AutoColumnProcessingOpts = {
noAutoRelationships?: boolean noAutoRelationships?: boolean
} }
// Returns the next auto ID for a column in a table. On success, the table will
// be updated which is why it gets returned. The nextID returned is guaranteed
// to be given only to you, and if you don't use it it's gone forever (a gap
// will be left in the auto ID sequence).
//
// This function can throw if it fails to generate an auto ID after so many
// attempts.
async function getNextAutoId(
table: Table,
column: string
): Promise<{ table: Table; nextID: number }> {
const db = context.getAppDB()
for (let attempt = 0; attempt < 5; attempt++) {
const schema = table.schema[column]
if (schema.type !== FieldType.NUMBER && schema.type !== FieldType.AUTO) {
throw new Error(`Column ${column} is not an auto column`)
}
schema.lastID = (schema.lastID || 0) + 1
try {
const resp = await db.put(table)
table._rev = resp.rev
return { table, nextID: schema.lastID }
} catch (e: any) {
if (e.status !== 409) {
throw e
}
// We wait for a random amount of time before retrying. The randomness
// makes it less likely for multiple requests modifying this table to
// collide.
await new Promise(resolve =>
setTimeout(resolve, Math.random() * 1.2 ** attempt * 1000)
)
table = await db.get(table._id)
}
}
throw new Error("Failed to generate an auto ID")
}
/** /**
* This will update any auto columns that are found on the row/table with the correct information based on * This will update any auto columns that are found on the row/table with the correct information based on
* time now and the current logged in user making the request. * time now and the current logged in user making the request.
@ -116,9 +77,10 @@ export async function processAutoColumn(
break break
case AutoFieldSubType.AUTO_ID: case AutoFieldSubType.AUTO_ID:
if (creating) { if (creating) {
const { table: newTable, nextID } = await getNextAutoId(table, key) schema.lastID = schema.lastID || 0
table = newTable row[key] = schema.lastID + 1
row[key] = nextID schema.lastID++
table.schema[key] = schema
} }
break break
} }
@ -272,7 +234,7 @@ export async function outputProcessing<T extends Row[] | Row>(
} }
} else if (column.type === FieldType.ATTACHMENT_SINGLE) { } else if (column.type === FieldType.ATTACHMENT_SINGLE) {
for (let row of enriched) { for (let row of enriched) {
if (!row[property]) { if (!row[property] || Object.keys(row[property]).length === 0) {
continue continue
} }

View File

@ -1,6 +1,7 @@
import { Document } from "../document" import { Document } from "../document"
import { EventEmitter } from "events" import { EventEmitter } from "events"
import { User } from "../global" import { User } from "../global"
import { ReadStream } from "fs"
export enum AutomationIOType { export enum AutomationIOType {
OBJECT = "object", OBJECT = "object",
@ -235,3 +236,18 @@ export interface AutomationMetadata extends Document {
errorCount?: number errorCount?: number
automationChainCount?: number automationChainCount?: number
} }
export type AutomationAttachment = {
url: string
filename: string
}
export type AutomationAttachmentContent = {
filename: string
content: ReadStream | NodeJS.ReadableStream | ReadableStream<Uint8Array>
}
export type BucketedContent = AutomationAttachmentContent & {
bucket: string
path: string
}

View File

@ -1,5 +1,4 @@
jest.unmock("node-fetch") jest.unmock("node-fetch")
jest.unmock("aws-sdk")
import { TestConfiguration } from "../../../../tests" import { TestConfiguration } from "../../../../tests"
import { EmailTemplatePurpose } from "../../../../constants" import { EmailTemplatePurpose } from "../../../../constants"
import { objectStore } from "@budibase/backend-core" import { objectStore } from "@budibase/backend-core"

View File

@ -6,8 +6,7 @@ import { processString } from "@budibase/string-templates"
import { User, SendEmailOpts, SMTPInnerConfig } from "@budibase/types" import { User, SendEmailOpts, SMTPInnerConfig } from "@budibase/types"
import { configs, cache, objectStore } from "@budibase/backend-core" import { configs, cache, objectStore } from "@budibase/backend-core"
import ical from "ical-generator" import ical from "ical-generator"
import fetch from "node-fetch" import _ from "lodash"
import path from "path"
const nodemailer = require("nodemailer") const nodemailer = require("nodemailer")
@ -165,39 +164,12 @@ export async function sendEmail(
}), }),
} }
if (opts?.attachments) { if (opts?.attachments) {
const attachments = await Promise.all( let attachments = await Promise.all(
opts.attachments?.map(async attachment => { opts.attachments?.map(objectStore.processAutomationAttachment)
const isFullyFormedUrl =
attachment.url.startsWith("http://") ||
attachment.url.startsWith("https://")
if (isFullyFormedUrl) {
const response = await fetch(attachment.url)
if (!response.ok) {
throw new Error(`unexpected response ${response.statusText}`)
}
const fallbackFilename = path.basename(
new URL(attachment.url).pathname
)
return {
filename: attachment.filename || fallbackFilename,
content: response?.body,
}
} else {
const url = attachment.url
const result = objectStore.extractBucketAndPath(url)
if (result === null) {
throw new Error("Invalid signed URL")
}
const { bucket, path } = result
const readStream = await objectStore.getReadStream(bucket, path)
const fallbackFilename = path.split("/").pop() || ""
return {
filename: attachment.filename || fallbackFilename,
content: readStream,
}
}
})
) )
attachments = attachments.map(attachment => {
return _.omit(attachment, "path")
})
message = { ...message, attachments } message = { ...message, attachments }
} }