Merge branch 'cheeks-lab-day-binding-eval' of github.com:Budibase/budibase into cheeks-snippets-poc

This commit is contained in:
Andrew Kingston 2024-03-14 16:24:16 +00:00
commit b6eab42c18
24 changed files with 1162 additions and 893 deletions

View File

@ -32,7 +32,7 @@ describe("docWritethrough", () => {
describe("patch", () => { describe("patch", () => {
function generatePatchObject(fieldCount: number) { function generatePatchObject(fieldCount: number) {
const keys = generator.unique(() => generator.word(), fieldCount) const keys = generator.unique(() => generator.guid(), fieldCount)
return keys.reduce((acc, c) => { return keys.reduce((acc, c) => {
acc[c] = generator.word() acc[c] = generator.word()
return acc return acc

View File

@ -71,6 +71,10 @@
await auth.getSelf() await auth.getSelf()
await admin.init() await admin.init()
if ($admin.maintenance.length > 0) {
$redirect("./maintenance")
}
if ($auth.user) { if ($auth.user) {
await licensing.init() await licensing.init()
} }

View File

@ -0,0 +1,83 @@
<script>
import { MaintenanceType } from "@budibase/types"
import { Heading, Body, Button, Layout } from "@budibase/bbui"
import { admin } from "stores/portal"
import BudibaseLogo from "../portal/_components/BudibaseLogo.svelte"
$: {
if ($admin.maintenance.length === 0) {
window.location = "/builder"
}
}
</script>
<div class="main">
<div class="content">
<div class="hero">
<BudibaseLogo />
</div>
<div class="inner-content">
{#each $admin.maintenance as maintenance}
{#if maintenance.type === MaintenanceType.SQS_MISSING}
<Layout>
<Heading>Please upgrade your Budibase installation</Heading>
<Body>
We've detected that the version of Budibase you're using depends
on a more recent version of the CouchDB database than what you
have installed.
</Body>
<Body>
To resolve this, you can either rollback to a previous version of
Budibase, or follow the migration guide to update to a later
version of CouchDB.
</Body>
</Layout>
<Button
on:click={() => (window.location = "https://docs.budibase.com")}
>Migration guide</Button
>
{/if}
{/each}
</div>
</div>
</div>
<style>
.main {
max-width: 700px;
margin: auto;
height: 100vh;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: var(--spacing-l);
}
.hero {
margin: var(--spacing-l);
}
.content {
display: flex;
flex-direction: row;
align-items: baseline;
justify-content: center;
gap: var(--spacing-m);
}
.inner-content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-m);
}
@media only screen and (max-width: 600px) {
.content {
flex-direction: column;
align-items: flex-start;
}
.main {
height: auto;
}
}
</style>

View File

@ -0,0 +1,15 @@
<script>
import Logo from "assets/bb-emblem.svg"
import { goto } from "@roxi/routify"
</script>
<!-- svelte-ignore a11y-no-noninteractive-element-interactions-->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<img src={Logo} alt="Budibase Logo" on:click={() => $goto("./apps")} />
<style>
img {
width: 30px;
height: 30px;
}
</style>

View File

@ -17,6 +17,7 @@ export const DEFAULT_CONFIG = {
adminUser: { checked: false }, adminUser: { checked: false },
sso: { checked: false }, sso: { checked: false },
}, },
maintenance: [],
offlineMode: false, offlineMode: false,
} }
@ -48,6 +49,7 @@ export function createAdminStore() {
store.isDev = environment.isDev store.isDev = environment.isDev
store.baseUrl = environment.baseUrl store.baseUrl = environment.baseUrl
store.offlineMode = environment.offlineMode store.offlineMode = environment.offlineMode
store.maintenance = environment.maintenance
return store return store
}) })
} }

View File

@ -17,6 +17,7 @@
appStore, appStore,
devToolsStore, devToolsStore,
devToolsEnabled, devToolsEnabled,
environmentStore,
} from "stores" } from "stores"
import NotificationDisplay from "components/overlay/NotificationDisplay.svelte" import NotificationDisplay from "components/overlay/NotificationDisplay.svelte"
import ConfirmationDisplay from "components/overlay/ConfirmationDisplay.svelte" import ConfirmationDisplay from "components/overlay/ConfirmationDisplay.svelte"
@ -36,6 +37,7 @@
import DevToolsHeader from "components/devtools/DevToolsHeader.svelte" import DevToolsHeader from "components/devtools/DevToolsHeader.svelte"
import DevTools from "components/devtools/DevTools.svelte" import DevTools from "components/devtools/DevTools.svelte"
import FreeFooter from "components/FreeFooter.svelte" import FreeFooter from "components/FreeFooter.svelte"
import MaintenanceScreen from "components/MaintenanceScreen.svelte"
import licensing from "../licensing" import licensing from "../licensing"
import SnippetsProvider from "./context/SnippetsProvider.svelte" import SnippetsProvider from "./context/SnippetsProvider.svelte"
@ -112,6 +114,9 @@
class="spectrum spectrum--medium {$themeStore.baseTheme} {$themeStore.theme}" class="spectrum spectrum--medium {$themeStore.baseTheme} {$themeStore.theme}"
class:builder={$builderStore.inBuilder} class:builder={$builderStore.inBuilder}
> >
{#if $environmentStore.maintenance.length > 0}
<MaintenanceScreen maintenanceList={$environmentStore.maintenance} />
{:else}
<DeviceBindingsProvider> <DeviceBindingsProvider>
<UserBindingsProvider> <UserBindingsProvider>
<StateBindingsProvider> <StateBindingsProvider>
@ -232,6 +237,7 @@
</StateBindingsProvider> </StateBindingsProvider>
</UserBindingsProvider> </UserBindingsProvider>
</DeviceBindingsProvider> </DeviceBindingsProvider>
{/if}
</div> </div>
<KeyboardManager /> <KeyboardManager />
{/if} {/if}

View File

@ -0,0 +1,53 @@
<!--
This is the public facing maintenance screen. It is displayed when there is
required maintenance to be done on the Budibase installation. We only use this
if we detect that the Budibase installation is in a state where the vast
majority of apps would not function correctly.
The builder-facing maintenance screen is in
packages/builder/src/pages/builder/maintenance/index.svelte, and tends to
contain more detailed information and actions for the installation owner to
take.
-->
<script>
import { MaintenanceType } from "@budibase/types"
import { Heading, Body, Layout } from "@budibase/bbui"
export let maintenanceList
</script>
<div class="content">
{#each maintenanceList as maintenance}
{#if maintenance.type === MaintenanceType.SQS_MISSING}
<Layout>
<Heading>Budibase installation requires maintenance</Heading>
<Body>
The administrator of this Budibase installation needs to take actions
to update components that are out of date. Please contact them and
show them this warning. More information will be available when they
log into their account.
</Body>
</Layout>
{/if}
{/each}
</div>
<style>
.content {
max-width: 700px;
margin: auto;
height: 100vh;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: var(--spacing-l);
}
@media (max-width: 640px) {
.content {
justify-content: flex-start;
align-items: flex-start;
}
}
</style>

View File

@ -1,38 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`/views query returns data for the created view 1`] = `
[
{
"avg": 2333.3333333333335,
"count": 3,
"group": null,
"max": 4000,
"min": 1000,
"sum": 7000,
"sumsqr": 21000000,
},
]
`;
exports[`/views query returns data for the created view using a group by 1`] = `
[
{
"avg": 1500,
"count": 2,
"group": "One",
"max": 2000,
"min": 1000,
"sum": 3000,
"sumsqr": 5000000,
},
{
"avg": 4000,
"count": 1,
"group": "Two",
"max": 4000,
"min": 4000,
"sum": 4000,
"sumsqr": 16000000,
},
]
`;

File diff suppressed because it is too large Load Diff

View File

@ -1,30 +1,38 @@
const setup = require("./utilities") import { events } from "@budibase/backend-core"
const { events } = require("@budibase/backend-core") import * as setup from "./utilities"
import {
FieldType,
INTERNAL_TABLE_SOURCE_ID,
SaveTableRequest,
Table,
TableSourceType,
View,
ViewCalculation,
} from "@budibase/types"
function priceTable() { const priceTable: SaveTableRequest = {
return {
name: "table", name: "table",
type: "table", type: "table",
key: "name", sourceId: INTERNAL_TABLE_SOURCE_ID,
sourceType: TableSourceType.INTERNAL,
schema: { schema: {
Price: { Price: {
type: "number", name: "Price",
constraints: {}, type: FieldType.NUMBER,
}, },
Category: { Category: {
type: "string", name: "Category",
type: FieldType.STRING,
constraints: { constraints: {
type: "string", type: "string",
}, },
}, },
}, },
} }
}
describe("/views", () => { describe("/views", () => {
let request = setup.getRequest()
let config = setup.getConfig() let config = setup.getConfig()
let table let table: Table
afterAll(setup.afterAll) afterAll(setup.afterAll)
@ -33,38 +41,34 @@ describe("/views", () => {
}) })
beforeEach(async () => { beforeEach(async () => {
table = await config.createTable(priceTable()) table = await config.api.table.save(priceTable)
}) })
const saveView = async view => { const saveView = async (view?: Partial<View>) => {
const viewToSave = { const viewToSave: View = {
name: "TestView", name: "TestView",
field: "Price", field: "Price",
calculation: "stats", calculation: ViewCalculation.STATISTICS,
tableId: table._id, tableId: table._id!,
filters: [],
schema: {},
...view, ...view,
} }
return request return config.api.legacyView.save(viewToSave)
.post(`/api/views`)
.send(viewToSave)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
} }
describe("create", () => { describe("create", () => {
it("returns a success message when the view is successfully created", async () => { it("returns a success message when the view is successfully created", async () => {
const res = await saveView() const res = await saveView()
expect(res.body.tableId).toBe(table._id)
expect(events.view.created).toBeCalledTimes(1) expect(events.view.created).toBeCalledTimes(1)
}) })
it("creates a view with a calculation", async () => { it("creates a view with a calculation", async () => {
jest.clearAllMocks() jest.clearAllMocks()
const res = await saveView({ calculation: "count" }) const view = await saveView({ calculation: ViewCalculation.COUNT })
expect(res.body.tableId).toBe(table._id) expect(view.tableId).toBe(table._id)
expect(events.view.created).toBeCalledTimes(1) expect(events.view.created).toBeCalledTimes(1)
expect(events.view.updated).not.toBeCalled() expect(events.view.updated).not.toBeCalled()
expect(events.view.calculationCreated).toBeCalledTimes(1) expect(events.view.calculationCreated).toBeCalledTimes(1)
@ -78,8 +82,8 @@ describe("/views", () => {
it("creates a view with a filter", async () => { it("creates a view with a filter", async () => {
jest.clearAllMocks() jest.clearAllMocks()
const res = await saveView({ const view = await saveView({
calculation: null, calculation: undefined,
filters: [ filters: [
{ {
value: "1", value: "1",
@ -89,7 +93,7 @@ describe("/views", () => {
], ],
}) })
expect(res.body.tableId).toBe(table._id) expect(view.tableId).toBe(table._id)
expect(events.view.created).toBeCalledTimes(1) expect(events.view.created).toBeCalledTimes(1)
expect(events.view.updated).not.toBeCalled() expect(events.view.updated).not.toBeCalled()
expect(events.view.calculationCreated).not.toBeCalled() expect(events.view.calculationCreated).not.toBeCalled()
@ -101,21 +105,10 @@ describe("/views", () => {
}) })
it("updates the table row with the new view metadata", async () => { it("updates the table row with the new view metadata", async () => {
const res = await request await saveView()
.post(`/api/views`) const updatedTable = await config.api.table.get(table._id!)
.send({ expect(updatedTable.views).toEqual(
name: "TestView", expect.objectContaining({
field: "Price",
calculation: "stats",
tableId: table._id,
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.tableId).toBe(table._id)
const updatedTable = await config.getTable(table._id)
const expectedObj = expect.objectContaining({
TestView: expect.objectContaining({ TestView: expect.objectContaining({
field: "Price", field: "Price",
calculation: "stats", calculation: "stats",
@ -146,7 +139,7 @@ describe("/views", () => {
}, },
}), }),
}) })
expect(updatedTable.views).toEqual(expectedObj) )
}) })
}) })
@ -168,10 +161,10 @@ describe("/views", () => {
}) })
it("updates a view calculation", async () => { it("updates a view calculation", async () => {
await saveView({ calculation: "sum" }) await saveView({ calculation: ViewCalculation.SUM })
jest.clearAllMocks() jest.clearAllMocks()
await saveView({ calculation: "count" }) await saveView({ calculation: ViewCalculation.COUNT })
expect(events.view.created).not.toBeCalled() expect(events.view.created).not.toBeCalled()
expect(events.view.updated).toBeCalledTimes(1) expect(events.view.updated).toBeCalledTimes(1)
@ -184,10 +177,10 @@ describe("/views", () => {
}) })
it("deletes a view calculation", async () => { it("deletes a view calculation", async () => {
await saveView({ calculation: "sum" }) await saveView({ calculation: ViewCalculation.SUM })
jest.clearAllMocks() jest.clearAllMocks()
await saveView({ calculation: null }) await saveView({ calculation: undefined })
expect(events.view.created).not.toBeCalled() expect(events.view.created).not.toBeCalled()
expect(events.view.updated).toBeCalledTimes(1) expect(events.view.updated).toBeCalledTimes(1)
@ -258,100 +251,98 @@ describe("/views", () => {
describe("fetch", () => { describe("fetch", () => {
beforeEach(async () => { beforeEach(async () => {
table = await config.createTable(priceTable()) table = await config.api.table.save(priceTable)
}) })
it("returns only custom views", async () => { it("returns only custom views", async () => {
await config.createLegacyView({ await saveView({
name: "TestView", name: "TestView",
field: "Price", field: "Price",
calculation: "stats", calculation: ViewCalculation.STATISTICS,
tableId: table._id, tableId: table._id,
}) })
const res = await request const views = await config.api.legacyView.fetch()
.get(`/api/views`) expect(views.length).toBe(1)
.set(config.defaultHeaders()) expect(views.find(({ name }) => name === "TestView")).toBeDefined()
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.length).toBe(1)
expect(res.body.find(({ name }) => name === "TestView")).toBeDefined()
}) })
}) })
describe("query", () => { describe("query", () => {
it("returns data for the created view", async () => { it("returns data for the created view", async () => {
await config.createLegacyView({ await saveView({
name: "TestView", name: "TestView",
field: "Price", field: "Price",
calculation: "stats", calculation: ViewCalculation.STATISTICS,
tableId: table._id, tableId: table._id!,
}) })
await config.createRow({ await config.api.row.save(table._id!, {
tableId: table._id,
Price: 1000, Price: 1000,
}) })
await config.createRow({ await config.api.row.save(table._id!, {
tableId: table._id,
Price: 2000, Price: 2000,
}) })
await config.createRow({ await config.api.row.save(table._id!, {
tableId: table._id,
Price: 4000, Price: 4000,
}) })
const res = await request const rows = await config.api.legacyView.get("TestView", {
.get(`/api/views/TestView?calculation=stats`) calculation: ViewCalculation.STATISTICS,
.set(config.defaultHeaders()) })
.expect("Content-Type", /json/) expect(rows.length).toBe(1)
.expect(200) expect(rows[0]).toEqual({
expect(res.body.length).toBe(1) avg: 2333.3333333333335,
expect(res.body).toMatchSnapshot() count: 3,
group: null,
max: 4000,
min: 1000,
sum: 7000,
sumsqr: 21000000,
})
}) })
it("returns data for the created view using a group by", async () => { it("returns data for the created view using a group by", async () => {
await config.createLegacyView({ await saveView({
calculation: "stats", calculation: ViewCalculation.STATISTICS,
name: "TestView", name: "TestView",
field: "Price", field: "Price",
groupBy: "Category", groupBy: "Category",
tableId: table._id, tableId: table._id,
}) })
await config.createRow({ await config.api.row.save(table._id!, {
tableId: table._id,
Price: 1000, Price: 1000,
Category: "One", Category: "One",
}) })
await config.createRow({ await config.api.row.save(table._id!, {
tableId: table._id,
Price: 2000, Price: 2000,
Category: "One", Category: "One",
}) })
await config.createRow({ await config.api.row.save(table._id!, {
tableId: table._id,
Price: 4000, Price: 4000,
Category: "Two", Category: "Two",
}) })
const res = await request const rows = await config.api.legacyView.get("TestView", {
.get(`/api/views/TestView?calculation=stats&group=Category`) calculation: ViewCalculation.STATISTICS,
.set(config.defaultHeaders()) group: "Category",
.expect("Content-Type", /json/) })
.expect(200) expect(rows.length).toBe(2)
expect(rows[0]).toEqual({
expect(res.body.length).toBe(2) avg: 1500,
expect(res.body).toMatchSnapshot() count: 2,
group: "One",
max: 2000,
min: 1000,
sum: 3000,
sumsqr: 5000000,
})
}) })
}) })
describe("destroy", () => { describe("destroy", () => {
it("should be able to delete a view", async () => { it("should be able to delete a view", async () => {
const table = await config.createTable(priceTable()) const table = await config.api.table.save(priceTable)
const view = await config.createLegacyView() const view = await saveView({ tableId: table._id })
const res = await request const deletedView = await config.api.legacyView.destroy(view.name!)
.delete(`/api/views/${view.name}`) expect(deletedView.map).toBeDefined()
.set(config.defaultHeaders()) expect(deletedView.meta?.tableId).toEqual(table._id)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.map).toBeDefined()
expect(res.body.meta.tableId).toEqual(table._id)
expect(events.view.deleted).toBeCalledTimes(1) expect(events.view.deleted).toBeCalledTimes(1)
}) })
}) })
@ -362,33 +353,44 @@ describe("/views", () => {
}) })
const setupExport = async () => { const setupExport = async () => {
const table = await config.createTable() const table = await config.api.table.save({
await config.createRow({ name: "test-name", description: "ùúûü" }) name: "test-table",
type: "table",
sourceId: INTERNAL_TABLE_SOURCE_ID,
sourceType: TableSourceType.INTERNAL,
schema: {
name: {
name: "name",
type: FieldType.STRING,
},
description: {
name: "description",
type: FieldType.STRING,
},
},
})
await config.api.row.save(table._id!, {
name: "test-name",
description: "ùúûü",
})
return table return table
} }
const exportView = async (viewName, format) => { const assertJsonExport = (res: string) => {
return request const rows = JSON.parse(res)
.get(`/api/views/export?view=${viewName}&format=${format}`)
.set(config.defaultHeaders())
.expect(200)
}
const assertJsonExport = res => {
const rows = JSON.parse(res.text)
expect(rows.length).toBe(1) expect(rows.length).toBe(1)
expect(rows[0].name).toBe("test-name") expect(rows[0].name).toBe("test-name")
expect(rows[0].description).toBe("ùúûü") expect(rows[0].description).toBe("ùúûü")
} }
const assertCSVExport = res => { const assertCSVExport = (res: string) => {
expect(res.text).toBe(`"name","description"\n"test-name","ùúûü"`) expect(res).toBe(`"name","description"\n"test-name","ùúûü"`)
} }
it("should be able to export a table as JSON", async () => { it("should be able to export a table as JSON", async () => {
const table = await setupExport() const table = await setupExport()
const res = await exportView(table._id, "json") const res = await config.api.legacyView.export(table._id!, "json")
assertJsonExport(res) assertJsonExport(res)
expect(events.table.exported).toBeCalledTimes(1) expect(events.table.exported).toBeCalledTimes(1)
@ -398,7 +400,7 @@ describe("/views", () => {
it("should be able to export a table as CSV", async () => { it("should be able to export a table as CSV", async () => {
const table = await setupExport() const table = await setupExport()
const res = await exportView(table._id, "csv") const res = await config.api.legacyView.export(table._id!, "csv")
assertCSVExport(res) assertCSVExport(res)
expect(events.table.exported).toBeCalledTimes(1) expect(events.table.exported).toBeCalledTimes(1)
@ -407,10 +409,15 @@ describe("/views", () => {
it("should be able to export a view as JSON", async () => { it("should be able to export a view as JSON", async () => {
let table = await setupExport() let table = await setupExport()
const view = await config.createLegacyView() const view = await config.api.legacyView.save({
table = await config.getTable(table._id) name: "test-view",
tableId: table._id!,
filters: [],
schema: {},
})
table = await config.api.table.get(table._id!)
let res = await exportView(view.name, "json") let res = await config.api.legacyView.export(view.name!, "json")
assertJsonExport(res) assertJsonExport(res)
expect(events.view.exported).toBeCalledTimes(1) expect(events.view.exported).toBeCalledTimes(1)
@ -419,10 +426,15 @@ describe("/views", () => {
it("should be able to export a view as CSV", async () => { it("should be able to export a view as CSV", async () => {
let table = await setupExport() let table = await setupExport()
const view = await config.createLegacyView() const view = await config.api.legacyView.save({
table = await config.getTable(table._id) name: "test-view",
tableId: table._id!,
filters: [],
schema: {},
})
table = await config.api.table.get(table._id!)
let res = await exportView(view.name, "csv") let res = await config.api.legacyView.export(view.name!, "csv")
assertCSVExport(res) assertCSVExport(res)
expect(events.view.exported).toBeCalledTimes(1) expect(events.view.exported).toBeCalledTimes(1)

View File

@ -4,11 +4,17 @@ import { QueryOptions } from "../../definitions/datasource"
import { isIsoDateString, SqlClient, isValidFilter } from "../utils" import { isIsoDateString, SqlClient, isValidFilter } from "../utils"
import SqlTableQueryBuilder from "./sqlTable" import SqlTableQueryBuilder from "./sqlTable"
import { import {
BBReferenceFieldMetadata,
FieldSchema,
FieldSubtype,
FieldType,
JsonFieldMetadata,
Operation, Operation,
QueryJson, QueryJson,
RelationshipsJson, RelationshipsJson,
SearchFilters, SearchFilters,
SortDirection, SortDirection,
Table,
} from "@budibase/types" } from "@budibase/types"
import environment from "../../environment" import environment from "../../environment"
@ -691,6 +697,37 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
return results.length ? results : [{ [operation.toLowerCase()]: true }] return results.length ? results : [{ [operation.toLowerCase()]: true }]
} }
convertJsonStringColumns(
table: Table,
results: Record<string, any>[]
): Record<string, any>[] {
for (const [name, field] of Object.entries(table.schema)) {
if (!this._isJsonColumn(field)) {
continue
}
const fullName = `${table.name}.${name}`
for (let row of results) {
if (typeof row[fullName] === "string") {
row[fullName] = JSON.parse(row[fullName])
}
if (typeof row[name] === "string") {
row[name] = JSON.parse(row[name])
}
}
}
return results
}
_isJsonColumn(
field: FieldSchema
): field is JsonFieldMetadata | BBReferenceFieldMetadata {
return (
field.type === FieldType.JSON ||
(field.type === FieldType.BB_REFERENCE &&
field.subtype === FieldSubtype.USERS)
)
}
log(query: string, values?: any[]) { log(query: string, values?: any[]) {
if (!environment.SQL_LOGGING_ENABLE) { if (!environment.SQL_LOGGING_ENABLE) {
return return

View File

@ -14,6 +14,8 @@ import {
Schema, Schema,
TableSourceType, TableSourceType,
DatasourcePlusQueryResponse, DatasourcePlusQueryResponse,
FieldType,
FieldSubtype,
} from "@budibase/types" } from "@budibase/types"
import { import {
getSqlQuery, getSqlQuery,
@ -502,8 +504,14 @@ class SqlServerIntegration extends Sql implements DatasourcePlus {
} }
const operation = this._operation(json) const operation = this._operation(json)
const queryFn = (query: any, op: string) => this.internalQuery(query, op) const queryFn = (query: any, op: string) => this.internalQuery(query, op)
const processFn = (result: any) => const processFn = (result: any) => {
result.recordset ? result.recordset : [{ [operation]: true }] if (json?.meta?.table && result.recordset) {
return this.convertJsonStringColumns(json.meta.table, result.recordset)
} else if (result.recordset) {
return result.recordset
}
return [{ [operation]: true }]
}
return this.queryWithReturning(json, queryFn, processFn) return this.queryWithReturning(json, queryFn, processFn)
} }

View File

@ -13,6 +13,8 @@ import {
Schema, Schema,
TableSourceType, TableSourceType,
DatasourcePlusQueryResponse, DatasourcePlusQueryResponse,
FieldType,
FieldSubtype,
} from "@budibase/types" } from "@budibase/types"
import { import {
getSqlQuery, getSqlQuery,
@ -386,7 +388,13 @@ class MySQLIntegration extends Sql implements DatasourcePlus {
try { try {
const queryFn = (query: any) => const queryFn = (query: any) =>
this.internalQuery(query, { connect: false, disableCoercion: true }) this.internalQuery(query, { connect: false, disableCoercion: true })
return await this.queryWithReturning(json, queryFn) const processFn = (result: any) => {
if (json?.meta?.table && Array.isArray(result)) {
return this.convertJsonStringColumns(json.meta.table, result)
}
return result
}
return await this.queryWithReturning(json, queryFn, processFn)
} finally { } finally {
await this.disconnect() await this.disconnect()
} }

View File

@ -4,6 +4,8 @@ import { Datasource } from "@budibase/types"
import * as postgres from "./postgres" import * as postgres from "./postgres"
import * as mongodb from "./mongodb" import * as mongodb from "./mongodb"
import * as mysql from "./mysql" import * as mysql from "./mysql"
import * as mssql from "./mssql"
import * as mariadb from "./mariadb"
import { StartedTestContainer } from "testcontainers" import { StartedTestContainer } from "testcontainers"
jest.setTimeout(30000) jest.setTimeout(30000)
@ -14,4 +16,10 @@ export interface DatabaseProvider {
datasource(): Promise<Datasource> datasource(): Promise<Datasource>
} }
export const databaseTestProviders = { postgres, mongodb, mysql } export const databaseTestProviders = {
postgres,
mongodb,
mysql,
mssql,
mariadb,
}

View File

@ -0,0 +1,58 @@
import { Datasource, SourceName } from "@budibase/types"
import { GenericContainer, Wait, StartedTestContainer } from "testcontainers"
import { AbstractWaitStrategy } from "testcontainers/build/wait-strategies/wait-strategy"
let container: StartedTestContainer | undefined
class MariaDBWaitStrategy extends AbstractWaitStrategy {
async waitUntilReady(container: any, boundPorts: any, startTime?: Date) {
// Because MariaDB first starts itself up, runs an init script, then restarts,
// it's possible for the mysqladmin ping to succeed early and then tests to
// run against a MariaDB that's mid-restart and fail. To get around this, we
// wait for logs and then do a ping check.
const logs = Wait.forLogMessage("mariadbd: ready for connections", 2)
await logs.waitUntilReady(container, boundPorts, startTime)
const command = Wait.forSuccessfulCommand(
`mysqladmin ping -h localhost -P 3306 -u root -ppassword`
)
await command.waitUntilReady(container)
}
}
export async function start(): Promise<StartedTestContainer> {
return await new GenericContainer("mariadb:lts")
.withExposedPorts(3306)
.withEnvironment({ MARIADB_ROOT_PASSWORD: "password" })
.withWaitStrategy(new MariaDBWaitStrategy())
.start()
}
export async function datasource(): Promise<Datasource> {
if (!container) {
container = await start()
}
const host = container.getHost()
const port = container.getMappedPort(3306)
return {
type: "datasource_plus",
source: SourceName.MYSQL,
plus: true,
config: {
host,
port,
user: "root",
password: "password",
database: "mysql",
},
}
}
export async function stop() {
if (container) {
await container.stop()
container = undefined
}
}

View File

@ -0,0 +1,53 @@
import { Datasource, SourceName } from "@budibase/types"
import { GenericContainer, Wait, StartedTestContainer } from "testcontainers"
let container: StartedTestContainer | undefined
export async function start(): Promise<StartedTestContainer> {
return await new GenericContainer(
"mcr.microsoft.com/mssql/server:2022-latest"
)
.withExposedPorts(1433)
.withEnvironment({
ACCEPT_EULA: "Y",
MSSQL_SA_PASSWORD: "Password_123",
// This is important, as Microsoft allow us to use the "Developer" edition
// of SQL Server for development and testing purposes. We can't use other
// versions without a valid license, and we cannot use the Developer
// version in production.
MSSQL_PID: "Developer",
})
.withWaitStrategy(
Wait.forSuccessfulCommand(
"/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P Password_123 -q 'SELECT 1'"
)
)
.start()
}
export async function datasource(): Promise<Datasource> {
if (!container) {
container = await start()
}
const host = container.getHost()
const port = container.getMappedPort(1433)
return {
type: "datasource_plus",
source: SourceName.SQL_SERVER,
plus: true,
config: {
server: host,
port,
user: "sa",
password: "Password_123",
},
}
}
export async function stop() {
if (container) {
await container.stop()
container = undefined
}
}

View File

@ -1,10 +1,4 @@
import { import { Row, SearchFilters, SearchParams, SortOrder } from "@budibase/types"
Row,
SearchFilters,
SearchParams,
SortOrder,
SortType,
} from "@budibase/types"
import { isExternalTableID } from "../../../integrations/utils" import { isExternalTableID } from "../../../integrations/utils"
import * as internal from "./search/internal" import * as internal from "./search/internal"
import * as external from "./search/external" import * as external from "./search/external"

View File

@ -1,8 +1,36 @@
import { Expectations, TestAPI } from "./base" import { Expectations, TestAPI } from "./base"
import { Row } from "@budibase/types" import { Row, View, ViewCalculation } from "@budibase/types"
export class LegacyViewAPI extends TestAPI { export class LegacyViewAPI extends TestAPI {
get = async (id: string, expectations?: Expectations) => { get = async (
return await this._get<Row[]>(`/api/views/${id}`, { expectations }) id: string,
query?: { calculation: ViewCalculation; group?: string },
expectations?: Expectations
) => {
return await this._get<Row[]>(`/api/views/${id}`, { query, expectations })
}
save = async (body: View, expectations?: Expectations) => {
return await this._post<View>(`/api/views/`, { body, expectations })
}
fetch = async (expectations?: Expectations) => {
return await this._get<View[]>(`/api/views`, { expectations })
}
destroy = async (id: string, expectations?: Expectations) => {
return await this._delete<View>(`/api/views/${id}`, { expectations })
}
export = async (
viewName: string,
format: "json" | "csv" | "jsonWithSchema",
expectations?: Expectations
) => {
const response = await this._requestRaw("get", `/api/views/export`, {
query: { view: viewName, format },
expectations,
})
return response.text
} }
} }

View File

@ -43,7 +43,7 @@ export class AttachmentCleanup {
if ((columnRemoved && !renaming) || opts.deleting) { if ((columnRemoved && !renaming) || opts.deleting) {
rows.forEach(row => { rows.forEach(row => {
files = files.concat( files = files.concat(
row[key].map((attachment: any) => attachment.key) (row[key] || []).map((attachment: any) => attachment.key)
) )
}) })
} }

View File

@ -115,4 +115,31 @@ describe("attachment cleanup", () => {
await AttachmentCleanup.rowUpdate(table(), { row: row(), oldRow: row() }) await AttachmentCleanup.rowUpdate(table(), { row: row(), oldRow: row() })
expect(mockedDeleteFiles).not.toBeCalled() expect(mockedDeleteFiles).not.toBeCalled()
}) })
it("should be able to cleanup a column and not throw when attachments are undefined", async () => {
const originalTable = table()
delete originalTable.schema["attach"]
await AttachmentCleanup.tableUpdate(
originalTable,
[row("file 1"), { attach: undefined }, row("file 2")],
{
oldTable: table(),
}
)
expect(mockedDeleteFiles).toBeCalledTimes(1)
expect(mockedDeleteFiles).toBeCalledWith(BUCKET, ["file 1", "file 2"])
})
it("should be able to cleanup a column and not throw when ALL attachments are undefined", async () => {
const originalTable = table()
delete originalTable.schema["attach"]
await AttachmentCleanup.tableUpdate(
originalTable,
[{}, { attach: undefined }],
{
oldTable: table(),
}
)
expect(mockedDeleteFiles).not.toBeCalled()
})
}) })

View File

@ -2,3 +2,7 @@ export enum ServiceType {
WORKER = "worker", WORKER = "worker",
APPS = "apps", APPS = "apps",
} }
export enum MaintenanceType {
SQS_MISSING = "sqs_missing",
}

View File

@ -30,6 +30,7 @@ export interface View {
map?: string map?: string
reduce?: any reduce?: any
meta?: ViewTemplateOpts meta?: ViewTemplateOpts
groupBy?: string
} }
export interface ViewV2 { export interface ViewV2 {

View File

@ -1,10 +1,18 @@
import { Ctx } from "@budibase/types" import { Ctx, MaintenanceType } from "@budibase/types"
import env from "../../../environment" import env from "../../../environment"
import { env as coreEnv } from "@budibase/backend-core" import { env as coreEnv } from "@budibase/backend-core"
import nodeFetch from "node-fetch" import nodeFetch from "node-fetch"
// When we come to move to SQS fully and move away from Clouseau, we will need
// to flip this to true (or remove it entirely). This will then be used to
// determine if we should show the maintenance page that links to the SQS
// migration docs.
const sqsRequired = false
let sqsAvailable: boolean let sqsAvailable: boolean
async function isSqsAvailable() { async function isSqsAvailable() {
// We cache this value for the duration of the Node process because we don't
// want every page load to be making this relatively expensive check.
if (sqsAvailable !== undefined) { if (sqsAvailable !== undefined) {
return sqsAvailable return sqsAvailable
} }
@ -21,6 +29,10 @@ async function isSqsAvailable() {
} }
} }
async function isSqsMissing() {
return sqsRequired && !(await isSqsAvailable())
}
export const fetch = async (ctx: Ctx) => { export const fetch = async (ctx: Ctx) => {
ctx.body = { ctx.body = {
multiTenancy: !!env.MULTI_TENANCY, multiTenancy: !!env.MULTI_TENANCY,
@ -30,11 +42,12 @@ export const fetch = async (ctx: Ctx) => {
disableAccountPortal: env.DISABLE_ACCOUNT_PORTAL, disableAccountPortal: env.DISABLE_ACCOUNT_PORTAL,
baseUrl: env.PLATFORM_URL, baseUrl: env.PLATFORM_URL,
isDev: env.isDev() && !env.isTest(), isDev: env.isDev() && !env.isTest(),
maintenance: [],
} }
if (env.SELF_HOSTED) { if (env.SELF_HOSTED) {
ctx.body.infrastructure = { if (await isSqsMissing()) {
sqs: await isSqsAvailable(), ctx.body.maintenance.push({ type: MaintenanceType.SQS_MISSING })
} }
} }
} }

View File

@ -27,6 +27,7 @@ describe("/api/system/environment", () => {
multiTenancy: true, multiTenancy: true,
baseUrl: "http://localhost:10000", baseUrl: "http://localhost:10000",
offlineMode: false, offlineMode: false,
maintenance: [],
}) })
}) })
@ -40,9 +41,7 @@ describe("/api/system/environment", () => {
multiTenancy: true, multiTenancy: true,
baseUrl: "http://localhost:10000", baseUrl: "http://localhost:10000",
offlineMode: false, offlineMode: false,
infrastructure: { maintenance: [],
sqs: false,
},
}) })
}) })
}) })